Repository navigation
fix(connectors): a dead chat becomes a named failure, and the relay stops - #1878
Merged
Merged
Conversation
…nnector
Row D. A permanent send failure changed nothing: the integration stayed
`connected` with `errorMessage: null`, no Activity row was written, and the
relay kept running — so a user who blocked the bot saw replies vanish while the
page said the connector was fine.
Classify at the call site, by the chat the send targeted (wren 73777). Only the
four sends to the connector's own bound chat may flip — telegramBridge :204,
:285, :406 and decisionCardReconcileService :101 — plus the bind confirmation in
the webhook route, whose permanent failure undoes the bind it just wrote.
- `telegramService.sendMessage` now returns Telegram's own `errorCode` and
`description`. They were read only to be logged, so every failure arrived as
axios's "Request failed with status code 400" and a 403 was indistinguishable
from a transient one. The service still decides nothing.
- `noteBoundChatDeliveryFailure` refuses any chat that is not the connector's
bound one, so an inbound update's failure cannot mislabel the owner's
connector (vera 73761) even if a receive-side site is wired to it later.
- Permanent is a 403, or a 400 whose description says the chat is gone. A 401
is the shared bot token and any other 400 is content — a pod named `A <b>`
400s every confirmation with "can't parse entities", and that must not undo
a working bind. Handled at the source: `escapeHtml` moves into
telegramService beside the `parse_mode: 'HTML'` it exists for and is
imported back by the bridge, replacing a copy rather than adding one.
- The flip is one update matched on {_id, config.chatId}: status `error`, named
`errorMessage`, chatId unset — which stops the relay and re-allows a connect
code — and an Activity row only if it matched. The bind clears `errorMessage`
so reconnecting clears the named reason.
Tests: classification truth table, each non-permanent shape, the inbound-chat
refusal, a transient failure end to end, and the re-bind race. The pass-through
test exists because without the two fields the whole feature is a silent no-op.
…nto HTML
vera 73812: the bind confirmation was not the only site. `telegramService`
sets `parse_mode: 'HTML'`, and this route interpolated three more values
straight into it:
:315 the pod summary title and body (generated from pod messages)
:372 the pod name again, in /status
:374 `config.leadAgentUsername`, a value the agent is named by
All four now go through `escapeHtml`, imported from `telegramService` (already
required here) rather than added as a fifth copy of the helper.
This is not only an injection fix. Telegram HTML accepts `<a href>`, so an
unescaped name renders a clickable link inside a message the user reads as the
bot's own words; and a stray `<` returns 400 "can't parse entities" — the
content-400 class the new classifier is required NOT to flip the connector on.
Escaping removes that class at its source instead of asking the classifier to
keep sorting it out.
Witnesses: the four sites are asserted through the real route, with values that
would render as markup if unescaped. Two harness fixes were needed and are
disclosed:
- the test's `telegramService` mock was `{ sendMessage }` only, so the newly
imported `escapeHtml` was undefined and the pre-existing `/commonly-enable`
test went red with a 500. `sendMessage` stays stubbed (it is the network);
`escapeHtml` is now the real one, because escaping is behaviour this route
owes.
- the bind's `findByIdAndUpdate` assertion pins the `{ new: true }` third
argument: the confirmation's classification reads back the chat id that
update stores, so without it a permanent failure of the confirmation could
never undo the bind it just wrote.
…e it `24 * 60 * 60_000` is a numeric separator, which espree's grammar does not accept; eslint stops at a parse error, so every line after 288 in this file — including the four escape witnesses added in the previous commit — was unmeasured by the linter. `60000` is the same value. Disclosure: unblinding exposes 46 problems in this file (44 errors, 2 warnings), all in pre-existing code this row does not touch, and NONE in the lines added here. They are left alone: the backend `.js` corpus is ungated with 2,279 known errors and clearing this file is burn-down work, not row D.
… as Telegram
wren 73779 asked for the same split on Slack's `ok:false` error string and the
same unset, so that a re-authorize is not refused by the connect-code 409 while
the dead channel is still on the row. Slack stores its bound channel in
`config.chatId` exactly as Telegram does, so this is the same update, reached
through the same entry point.
Permanent (`channel_not_found`, `not_in_channel`, `is_archived`), transient and
content errors left alone, and — the important exclusion — `invalid_auth`,
`token_revoked` and `account_inactive` are NOT permanent: they are the shared bot
app, Slack's analogue of the Telegram 401 that must never mark one connector as
needing attention.
`noteBoundChatDeliveryFailure` now sorts the two providers by result shape
(`{success, errorCode}` vs `{ok, error}`) rather than making every call site name
its provider. The bound-chat provenance guard is unchanged and therefore applies
to Slack too: an inbound channel's failure cannot flip an owner's connector.
Sites, all four sending to the bound channel:
slackBridgeService :139 (no-active-pod), :228 (relay), :336 (card confirmation)
decisionCardReconcileService :117 (closing line)
Two of my first-pass test premises were wrong and the measurements corrected
them, both kept in the comments:
- the relay does NOT propagate its failure — it wraps the send in a try/catch
and warns — so the witness asserts the classification and the absent relayMap
write instead of a rejected promise;
- closing-line delivery is deliberately detached (`void Promise.allSettled`),
so the end-to-end witness polls for the flip rather than asserting into a
race with a background write.
Also fixed: `classifyDeliveryFailure` did not accept `null`, which ts-jest
rejected and made two suites fail to run.
The first attempt at mutating the Slack closing-line classification removed a line from the Telegram branch instead: the two classification lines are textually identical and the mutation tool took the first match. The probe that caught it showed the flip still coming from `sendClosingLine:126`, which is the Slack branch — so the Slack witness was fine and the Telegram site had no witness at all. It has one now, and the mutations are anchored on each branch's own throw line.
The page prints a fixed "The connection dropped." for every errored connector, so the reason the backend now writes has no reader: `interface Connector` had no `errorMessage` field at all (the catalog half at :814 already rendered the installation's, which is why only the connector row was silent). Two lines: the field, and the row line prefers it with the existing sentence as the fallback for rows that predate it and for failures we do not classify. `/api/integrations/user/all` returns the row whole and `projectIntegrationForViewer` strips config secrets rather than top-level fields, so no server change was needed — checked rather than assumed, since a reason the API drops would make this render dead code. Witness: a connector with `status: 'error'` and a `errorMessage` renders that text and NOT the generic sentence; the pre-existing test still pins the fallback for a row without one. Dropping the `||` reddens the new test alone (41 others pass), so the two are separable.
Same class as 0fec9e6, one provider over. mrkdwn reads `<url|label>` as a link and `<!channel>` as a mention, so a pod name or message body that reaches a human's DM unescaped can be made to say what the bot never said. Two sites, both named by wren (73822) and confirmed complete by vera (73823): - `slackBridgeService` relay fall-through: one ternary, two escape regimes. The card branch runs every field through the renderer, which escapes; the fall-through interpolated the pod name, the author and the body raw. Slice before escaping, as telegramBridgeService:279 already does — the other order can cut a `&` in half. - `installables.ts` connect marker: the pod name, straight into the text a human reads as the bot's confirmation. Both locals are gone: the helper now lives in `slackApi.ts` beside `chat.postMessage`, where the escape and the call that needs it cannot drift apart, and the two copies import it back. It takes `String(value ?? '')` — the reconcile copy's nullish handling with `??` rather than `||`, so a missing value renders as nothing (the bridge's `String(raw)` would render the word "undefined") while a legitimate `0` or `false` still renders as itself (vera 73824). Both test harnesses stubbed the SlackApi constructor whole, which would have left the static undefined and turned an escaped send into a caught warn; they now keep the real escape and stub only the network. Witnesses: the relay with a hostile pod name, author and body; the connect marker with a hostile pod name; and the helper itself, including `undefined` -> '' and `0` -> '0'.
…s module Nine harnesses stub `telegramService` or `slackApi` whole. That was harmless while both escapes were file-local, and stopped being harmless this branch: the bridge now reads `escapeHtml` off the telegram service and the Slack renderer reads `escapeSlackMrkdwn` off the SlackApi class, so a bare stub leaves them undefined and the send is swallowed by the caller's try/catch — the failure looks like "sendMessage was never called". The route test was fixed when it first bit (0fec9e6). The other eight were not run by anything I ran, and only the full suite showed them: two of the files that exercise the bridges fail 39 and 37 tests respectively, from this one cause. Every one of the nine now stubs the network and keeps the behaviour, so an escape asserted anywhere is the escape we ship. Found by running the whole suite rather than the touched suites: 431 passed, 6 skipped, 4026 tests, 0 failures at the tip.
73779 attached an Activity row to the flip; 73792 withdrew it, because V2 renders none — the frontend never calls /api/activity/feed, the recap keeps agent actors, and needs-you reads only AttentionItem. So the row was written and never read: it would have cost a write per permanent failure and shown up nowhere, including on D's own re-walk, which reads the page. The two tests that pinned the row are retired with it. The absence is pinned in its place by a named test with a positive control — a row created directly is counted in the same harness — so a zero there is the service's choice rather than an unplugged instrument.
…on reconnect
Two from wren's read of the same site, plus one correction of my own.
73837: the confirmation marker goes to the chat this confirm just stored, so by
73777's own test it is a bound-chat send — the twin of the Telegram bind
confirmation. A permanent refusal there means the connector is bound to a DM the
bot cannot post in, which is exactly the state the page has to name, and the page
reloads right after Confirm. It classifies through the same guarded helper as
every other bound-chat send, so the excluded errors still cannot cost a good bind:
only channel_not_found, not_in_channel and is_archived flip.
73838: the confirm's $set now clears errorMessage, as the Telegram bind does.
Without it a reconnect after any flip keeps naming a reason that is no longer
true — and this is the only site that can clear it, because a bind is the only
thing that proves the connector works again.
Mine: the service comment claimed no relay predicate reads `status`. That is true
of Telegram's (it reads the chat id and the pause flag only) and false of
Slack's, whose findLiveIntegration filters `status: { $ne: 'error' }` as well, so
there the flip stops the relay twice over. The comment now says which is which.
The response after a flip returns the row's real state rather than 'connected':
the row and the response were about to disagree, and the client polls this page.
…after, from a real browser
Four captures under `docs/design/evidence/` from `scripts/ui-evidence-shot.mjs`,
on `/v2/connectors` at 390 and 1200, pairing main against this branch:
base f5ded48 : The connection dropped.
head : Telegram stopped delivering: the bot was blocked or removed from this chat.
Slack stopped delivering: this channel no longer exists.
Viewer is a local reproduction account that owns the pod the two connectors
belong to. The errored rows are seeded directly in the dev database — `status:
error` carrying the reason a permanent failure writes — which is the state the
flip produces and the API refuses to create. Both frontends talk to the same
backend, so the pair differs in the frontend revision only, and the innerText
diff is exactly the three reason lines at both widths: no other line moves.
That last check is the one the runbook asks for, and the first pass of this pair
failed it. Cut against 0e14213 it also dropped `reconnect to resume` and the
GitHub row's second line at 390 — this branch predated #1874, which had just
restored those detail lines to the phone layout. The artifact was in the base,
not in the change; the branch is now cut from f5ded48 and the diff is clean.
`ba2a2b3b` ("keep the real escape in every harness that stubs its module")
committed the `.test.js.prev` working copies alongside the nine files it edited.
Jest never collects them and eslint never opens them, so they would have landed
on main as ~2,000 lines of dead code whose authority no reader could settle.
Nothing is lost: each tracked `.test.js` carries the edit and each backup is the
copy taken before it, so the requireActual spread that commit exists to add is
present exactly where it should be —
slackOAuth.installables prev 1 real 2
decisionCardReconcileService prev 1 real 2
slackBridgeService prev 1 real 1 (byte-identical)
the other six prev 0 real 1 or 2
Only copies, only this branch. Tests and lint unchanged.
`V2ConnectorsPage` printed `Integration.errorMessage` for every row in the error state. Our classifier writes that field, but so does externalFeedService: for an `x` or `instagram` connector it copies the provider's `error_description` / `error` / `detail` / `title`, or the raw exception message, and those rows reach the same branch — so `connect ECONNREFUSED <addr>:443` could print in someone's connector row (vera 73848). The message is not the problem, the inference is. "The field holds a value" is not the fact the page needs; "this value was written for a person" is. That fact now travels with it: `errorMessageUserFacing`, set in exactly one place — connectorDeliveryFailureService, which owns both the classifier's flip and the reconciler's unreadable-secret reason — and required by the page before it renders anything. A writer that does not set it falls back to the generic sentence, so the failure mode is a hidden message and never a printed one, and the provider's own text stays on the row for whoever debugs the feed. The reconciler's second writer comes through that service rather than its own `$set`, both binds clear the flag with the message they clear, and externalFeedService says in place why its text is diagnostic.
The render predicate changed with the user-facing flag, so the fixtures change
with it: the two connectors now carry `errorMessageUserFacing: true`, which is
what a flip writes. A third row is added to the same pod — an `x` connector in
the error state holding `connect ECONNREFUSED 10.4.4.7:443`, the text
externalFeedService writes and no flag — because that is the row the guard is
for, and a frame that only showed the happy path would not evidence it.
Both widths, main against this branch:
before 4 × "The connection dropped." 0 × named reasons
after 1 × "The connection dropped." 3 × named reasons
The single generic line in the after frame is that X row, still rendering the
sentence it rendered before; `ECONNREFUSED` appears in neither frame, at either
width. The diff is the three named lines — the Telegram row, the Slack row and
the one the page repeats in its next-step callout — and no other line moves.
Vera's asymmetry from 73849: Slack's `escapeSlackMrkdwn` took `String(value ?? '')` and Telegram's `escapeHtml` still took `String(raw)`, so the same absent value rendered as nothing on one provider and as the literal word `undefined` on the other. Unreachable today — `Summary.content` is required and the other three call sites pre-guard — but it is the same class as the guard this PR adds: a value that was never meant to be printed becoming text. `??` and not `||`, so `0` and `false` still render as themselves.
samxu01
pushed a commit
that referenced
this pull request
Sep 25, 2026
Nine `*.test.js.prev` copies reached main's history this week (#1878, `ba2a2b3b`) and `git check-ignore` exited 1 on every one of them — nothing ignored the suffix, so a scratch rewrite script's backups were fair game for staging. `*.bak` was already ignored, twice (`:2` and `:33`); the sibling suffix goes beside it and the duplicate goes away.
lilyshen0722
added a commit
to thantiklermcirony/commonly
that referenced
this pull request
Oct 9, 2026
…eam-Commonly#1879) Nine `*.test.js.prev` copies reached main's history this week (Team-Commonly#1878, `ba2a2b3b`) and `git check-ignore` exited 1 on every one of them — nothing ignored the suffix, so a scratch rewrite script's backups were fair game for staging. `*.bak` was already ignored, twice (`:2` and `:33`); the sibling suffix goes beside it and the duplicate goes away.
lilyshen0722
added a commit
to thantiklermcirony/commonly
that referenced
this pull request
Oct 9, 2026
…iter declared it readable (TASK-131) (Team-Commonly#1881) * feat(connectors): the catalog half renders a message only when a writer declared it readable `V2ConnectorsPage`'s catalogue branch was `installation.errorMessage || <generic>`, and `InstallableInstallation.errorMessage` has two kinds of writer: the reconciler's human-authored constants and `markProjectionFailure`, which stores the projector's own exception text (`connect ECONNREFUSED <addr>:443`). So a raw exception was printed in a stranger's connector row — disclosure, not diagnosis (the same class Team-Commonly#1878 fixed on the pod-scoped half, measured and disclosed rather than folded in there). Team-Commonly#1878's shape, carried across: `errorMessageUserFacing`, written together with the message by one owner so no writer can set half. - `userFacingInstallationError(reason)` / `diagnosticInstallationError(error)` are the two ways the field gets a value; the reconciler's two `$set`s and `markProjectionFailure` now take the builder instead of assembling the pair by hand. - `InstallableInstallation.errorMessageUserFacing` (Boolean, default false). - `installableCatalogService.publicInstallation` hands the flag over with the message, so the page never infers provenance from the shape of the string. - The page gates the catalogue branch the way it already gates the pod-scoped one: `errorMessageUserFacing && errorMessage`, else the generic sentence. Witnesses: - a real projection failure stores `errorMessageUserFacing: false` (the raw exception is the writer that must say so); - the reconciler's stale-lock and missing-projection sweeps store `true`, read back from the database after `sweep()`; - the mapper carries `true` and `false` through, with the message still present in both cases — the page, not the mapper, decides what to show; - the page renders the generic sentence for an unflagged catalogue failure and the message verbatim when the flag is set. The pre-existing catalogue fixture that asserted `projection missing` verbatim now carries the flag its writer sets, which is the contract change rather than a relaxed assertion. Scope note: `components[].errorMessage` is left alone — the mapper carries it but no reader renders it (the page reads component `status` only), so a flag there would be a contract with no consumer. TASK-131. * fix(connectors): the feed writer states errorMessageUserFacing instead of omitting it (wren 73858) A `$set` that leaves a key out inherits the row's stored value, so `externalFeedService`'s error write was only safe on a row that had never carried a flag: a row that had been flipped by a permanent delivery failure kept `errorMessageUserFacing: true`, and this writer's provider text or raw exception would then render under that earlier provenance. It now says `false` explicitly, and its comment says why rather than claiming the flag "stays unset". The delivery-failure service's docstring gains the fallback line: the flag is stated, not defaulted, so a writer that omits it is making a claim it cannot make. Witness: the sync-failure test now asserts the `$set` names the flag, beside the status and message it already asserted. Reachability is contract-tightening, not a live disclosure (wren 73858): the only writer of `true` runs on the Telegram and Slack flip paths, which need a bound `config.chatId`, and no `x`/`instagram` row can have one. TASK-131. * copy(connectors): the two catalogue reasons say what happened and what to do (TASK-131) The mechanism in this PR is what makes these two strings user-facing: the page now prints `errorMessage` verbatim whenever a writer declares it readable, and the only two writers that declare it are the reconciler's constants. Leaving them as `'install lock expired'` and `'projection missing'` would put the lock lifecycle vocabulary in front of the person whose connector failed (vera 73913, wren 73914). - `installableReconciler:61` -> "Setup was interrupted before it finished. Try again." - `installableReconciler:90` -> "This connector's channel is gone. Retry to rebuild it." The second is not wren's 73914 wording ("Setup didn't finish in this pod. Try again."), and the difference is vera 73915's constraint rather than taste: the page's generic fallback is "Setup didn't finish.", so a flagged reason that reads as a variation on it makes the evidence pair - and the UI - say the same thing in two states. Her adjustment clause ("adjust the wording if a string reads wrong in context") covers this, and the event it names is genuinely different: the connector existed and its channel record vanished, which is why the row offers Retry. Both strings stay free of internals - no lock, no projection, no step name. The copy is pinned where it is produced: the stale-lock sweep's witness now asserts the exact string, and the missing-projection witness already asserted its own, so either constant moving is a red test rather than a silent UI change. Every consumer of both strings was swept, not just the ones with assertions on them (vera 73915 listed 4 assertions and 5 doc references; the string appears in 13 places): - tests: 3 fixtures in `installableCatalogService.test.js`, 1 assertion in `installableInstallationService.test.js`, 5 sites in `V2ConnectorsPage.test.tsx` (the page test now names the reason once as `REASON_CHANNEL_GONE`, so the copy lives in one line of that file); - plans: `d8-phase-2-gate-surface.md` (`:9`, `:36`, `:65` as vera listed, plus `:104` and `:117` which also quote the value) and `connector-as-installable-app.md` (`:310`, `:423`). No functional reader of either string exists - neither is a discriminator in a query or a branch - so this is a pure copy change. Verified: backend 30 tests / 2 suites, frontend 45 tests / 1 suite. TASK-131. * docs(evidence): Team-Commonly#1881's before/after pair for a failed connector row (TASK-131) Captured with `scripts/ui-evidence-shot.mjs` against the harness in `docs/runbooks/local-ui-render-harness.md`. After side: this branch's worktree (backend 5050, vite 3000) — `06391f11`'s page plus the working-tree copy commit that is now `70fcaece`. Before side: `a2913955`, the revision this PR is cut from (backend 5051, vite 3001). Same database, same token, and the pair for each width was captured back to back so the row's relative age line does not differ between the two. The subject is a catalogue installation in the error state. Two rows, so one frame carries both flag states: - Slack, `errorMessageUserFacing: true` -> "This connector's channel is gone. Retry to rebuild it." in both revisions (a flagged message is already verbatim; this is the "user-facing reason when the flag is set" frame). - Telegram, `errorMessageUserFacing: false`, `errorMessage` = the raw `connect ECONNREFUSED 10.4.4.7:443` -> before: printed in the row (the disclosure this PR fixes); after: "Setup didn't finish." `diff` of the two `document.body.innerText` dumps at each width is that one line and nothing else, which is why the `.txt` pair ships beside the PNGs — a raster is the illustration, the text is the evidence: 14c14 < connect ECONNREFUSED 10.4.4.7:443 --- > Setup didn't finish. Measured, not assumed: the phone width does render the row's line (the `Team-Commonly#1757` caveat, where `v2.css` hides a row's detail at 390, does not apply here), and both revisions reported `non-2xx: none` and `console errors: none`. Harness notes, because they decide whether the frame is reachable at all: the provider readiness gate renders "Not enabled on this instance." *instead of* the error state, so the after backend boots with the dummy `TELEGRAM_*` / `SLACK_*` / `CONNECTOR_SECRET_*` values a local instance needs; the installation rows are seeded directly (the install route refuses with `provider_not_configured` before those values exist), and the flagged row's string is the reconciler's own constant, not a string this harness produced. Source blobs are untouched by this commit.
lilyshen0722
added a commit
to thantiklermcirony/commonly
that referenced
this pull request
Oct 9, 2026
…Id, kind) (TASK-124 part 2) (Team-Commonly#1898) * feat(discord): encrypt the webhook URL at rest, keyed by (integrationId, kind) (TASK-124 part 2) `discord_integrations.webhookUrl` was the last connector credential stored in plaintext outside the ESO path. The URL embeds the webhook's own token, so holding it is posting AS that channel. Writers now put it in the connector-secret envelope (AES-256-GCM, ring from CONNECTOR_SECRET_KEYS) and the row keeps only an opaque ref at `Integration.config.webhookUrlRef`; readers go through `utils/discordWebhookUrl`. Wren's ruling: option A, keyed with `kind`. The same change fixes the three blockers the design exposed, which is what makes the encryption safe to turn on rather than a new way to lose a live credential: 1. `sweepOrphanedConnectorSecrets` kept a secret only on a Slack `botTokenRef`/`pendingBind.botTokenRef` pair, so it revoked a Discord secret ten minutes after it was written (`ORPHAN_SECRET_GRACE_MS`). Its keep-condition is now drawn from the kinds registry. 2. `sweepUnavailableSlackSecretKeys` marked ANY row with a Slack-named reason, and since Team-Commonly#1878/Team-Commonly#1881 that string is user-facing — a Discord owner read "Slack connector secret key is unavailable". Renamed and made kind-aware; Slack's sentence is byte-identical. 3. `upsertEncrypted` filtered on `{ integrationId }` alone, on both the upsert and the 11000 retry, so a second kind did not collide: it MATCHED the first kind's row and $set the new ciphertext onto it, leaving the first ref valid and decrypting to the second secret. The filter now carries `kind`, the model declares ONE index on the pair, and `services/connectorSecretKinds` is the single definition all four sites read. Also: `webhookUrl` and `webhookUrlRef` join `SERVER_OWNED_CONFIG_KEYS` (the URL is derived by the server on both writers, and a planted ref would point a row at another row's secret), and `webhookUrlRef` is listed in `INTEGRATION_SECRET_CONFIG_KEYS` so the pointer never rides out either. The migration is a separate gated step: `scripts/encrypt-discord-webhook-url` (dry-run by default, `--apply` to write, digests not URLs, idempotent, reads both plaintext stores and unsets both, refuses to write at all when the key ring is unusable). Run it after this deploys — the old image reads the plaintext field directly. Evidence: full backend tier 446 suites / 4167 tests green; tsc 0; eslint .ts 0 errors at the 1210-warning baseline. Eight-arm mutation ledger, no survivors: each arm reverts one piece and every arm reddens a named test. * test(discord): witness the migration's write-before-unset ordering, and drop the inert pre-flight vera's hold at `e6d4077c`: the ordering that prevents data loss in `encrypt-discord-webhook-url` was unwitnessed. Moving both `$unset`s above the `connectorSecrets.put` (the arrangement where a failing put destroys the only copy) left the suite 8/8, and deleting the `listWithUnavailableKey()` pre-flight reddened nothing. Both observations were right, and the second one was the more interesting: `listWithUnavailableKey` returns refs whose `keyId` is not in the ring. It cannot fail. A call that cannot fail is not a guard, so it is gone — the script no longer claims to fail fast, and its comment now states the invariant that actually protects the data: per row, the `put` completes before either plaintext is cleared, so ANY failure leaves that row whole and a re-run reports it as still waiting. Two witnesses replace the claim: - a failed `put` leaves both plaintexts in place and reports nothing migrated (asserts the platform store, the legacy `config.webhookUrl` store, the absence of a ref, and that a following dry run still sees `candidates=1, migrated=0`); - clearing happens only after the row's own ref is stored, one row further in: the second row's `put` rejects, so the first must have completed end to end (the test cannot pass vacuously) and the second must be untouched. The empty-ring test keeps its place as the ring-shaped instance, with its comment corrected: what protects it is the ordering, not the deleted pre-flight. Mutation ledger, script only: vera's mutation reddens 3 named tests (both new witnesses + the empty-ring one); removing the dry-run guard reddens 1 as a control that the ledger sees this file at all; re-adding the deleted pre-flight is a SURVIVOR by design, which is the evidence for removing it rather than witnessing it. * docs(discord): correct the migration comment's claim about the deleted pre-flight vera's correction at 74198, and she is right: `listWithUnavailableKey` CAN fail. Its first statement is `const { keys } = parseKeyRing()` (services/connectorSecrets.ts:171), and `parseKeyRing` throws `ConnectorSecretConfigurationError` on an unset, malformed or duplicated ring and `ConnectorSecretKeyMissing` when the active key is absent (:49-79). I wrote "it cannot fail" by quoting the find line and dropping the line above it. So the pre-flight was redundant, not inert: `put` computes `encrypt(material, parseKeyRing())` before `upsertEncrypted` touches anything (:138-146), so an unusable ring already fails the first row ahead of either `$unset`. Deleting it remains right; the reason recorded for the deletion was wrong, and on the one script in this lane whose damage is irreversible. The comment now says what is true, in vera's wording; the same false claim is corrected in the test comment that cited it. Comment-only, so the clearance at `d015ad25` stands: the only changed lines in this commit begin with `//`, `*` or `/**`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes TASK-130.
What this fixes
A connector could be bound to a chat that no longer accepts its messages — the bot was blocked or kicked, the chat was deleted, the channel was archived — and nothing said so. The row stayed
connected,errorMessagestayednull, and the relay kept posting into a chat nobody was reading. Silent at both ends: the human saw a healthy connector, and the agent's replies simply vanished.After this change one of those permanent refusals marks the connector as needing attention, with a named reason on the Connectors page, and relaying stops until the human reconnects.
Classification happens at the call site, by the chat the send targets
sendMessage/postMessagereturn the provider's own error (error_code+description, or Slack'serrorstring) and decide nothing. The caller classifies, and only when the send it just made went to the chat the connector is bound to:telegramBridgeService.ts(3 sends)config.chatIddecisionCardReconcileService.tsconfig.chatIdroutes/webhooks/telegram.tsbind confirmationinstallables.tsSlack connect markerPermanent means the chat is gone, not that the bot had a bad day. Telegram: any
403, or a400whose description ischat not found. Slack:channel_not_found,not_in_channel,is_archived. Everything else is content or transient and never flips — including401/invalid_auth/token_revoked, which are the shared app credential and would take every connector down with them, and 429/5xx.The flip is one update matched on
{_id, config.chatId: <the failed chat>}:status: error, a namederrorMessage, andconfig.chatIdunset. The match is the race guard — if the connector was re-bound between the send and the classification, nothing matches and no stale failure is written. Unsetting is what allows a reconnect (the connect route 409s while a chat id is present) and what stops the Telegram relay, whose predicate reads the chat id and the pause flag only; the Slack relay also filtersstatus, so there it stops twice over.The flip writes nothing else. An Activity row was in the ruling and wren withdrew it (73792): V2 renders none, so it would have been written and never read. Its absence is pinned by a named test with a positive control, so a future re-add reddens that test by name rather than passing quietly.
A bind is the only thing that proves a connector works again, so it is the only thing that clears
errorMessage— both binds now clear it, or a reconnect would keep naming a reason that is no longer true.Two escape defects in the same surface (separable commits)
c256bdbf— four values the Telegram webhook route interpolated into HTML underparse_mode: 'HTML': the pod name, the summary title and content, and the lead username. Vera found the class (73812); the tell was that the route hand-escaped a static literal its own author had typed while interpolating the values that arrive.7685ba15— the Slack escapes, as their own commit (wren 73822): one home besidechat.postMessage(SlackApi.escapeSlackMrkdwn),String(value ?? '')—??rather than||, so a missing value cannot become the literal string"undefined"and0/falsestill render as themselves — withslackBridgeServiceanddecisionCardReconcileServiceimporting it back, both escaping after the slice so an escape can never be cut in half. The connect marker's pod name goes through the same helper.Both are offered to wren as separable if she would rather they land elsewhere; they are in this PR because they are the same surface this row already touches.
A reason is rendered only when it was written for a person (vera 73848)
Vera's hold, and it was not in the code this PR writes — it was in the field this PR promoted to user-facing copy.
Integration.errorMessagehas a second, older writer:externalFeedService.tscopies a provider'serror_description/error/detail/title, or the raw exception message, into it forxandinstagramrows, soconnect ECONNREFUSED <addr>:443reaches the samestatus === 'error'branch — and those rows are on the page as legacy pod-scoped connectors.The message was never the problem; the inference was. "The field holds a value" is not the fact the page needs — "this value was written for a person" is. That fact now travels with the value:
Integration.errorMessageUserFacing, set in exactly one place (connectorDeliveryFailureService, which owns both the classifier's flip and the reconciler's unreadable-secret reason), and required by the page before it renders anything. A writer that does not set it falls back to the generic sentence, so the failure direction is a hidden message, never a printed one — and the provider's own text stays on the row for whoever debugs the feed.The reconciler's reason (
'Slack connector secret key is unavailable') is ours and is written for a person, so it now comes through that service instead of a second$setthat could forget the flag. Both binds clear the flag with the message they clear: the field's two states are a state, not a string.externalFeedServicesays in place why its text is diagnostic.The reason on the Connectors page (UI hold)
V2ConnectorsPage's error row rendered a fixed sentence; it now renders the server'serrorMessagewhen there is one and keeps that sentence as the fallback.Evidence from a real browser, 390 and 1200, pairing main against this branch:
docs/design/evidence/row-d-connector-error-reason-390-{before,after}.pngdocs/design/evidence/row-d-connector-error-reason-1200-{before,after}.pngdocs/design/evidence/slack-authorize-409/slack-authorize-409-{390,1200}.png(from fix(slack): judge a Slack bind by its secret reference, not an object that is always there #1875)The three rows in the frame are the two connectors carrying
errorMessageUserFacing: true— what a flip writes — and, in the same pod, anxconnector in the error state holdingconnect ECONNREFUSED 10.4.4.7:443with no flag. That third row is the point: it is what the guard is for, and a frame showing only the happy path would not evidence it.The
innerTextdiff is exactly the three reason lines at both widths, and nothing else moves:Counted across the same two frames: before 4 × the generic sentence and 0 named reasons; after 1 × generic and 3 × named — the single generic line being that X row, still rendering exactly what it rendered before, with
ECONNREFUSEDappearing in neither frame at either width.The viewer is a local reproduction account that owns the pod the two connectors belong to; the errored rows are seeded directly in the dev database (
status: errorcarrying the reason a permanent failure writes), which is the state the flip produces and the API refuses to create. Both frontends talk to the same backend, so the pair differs in the frontend revision only.That last check is the one the runbook asks for, and the first pass of this pair failed it: cut against
0e142135it also droppedreconnect to resumeand the GitHub row's second line at 390, because this branch predated #1874 restoring those to the phone layout. The artifact was in the base, not in the change — the branch is now cut fromf5ded480and the diff is clean.Verification
lint:ts0 errors / 1211 warnings — the pre-existing baseline, unchangedV2ConnectorsPage.test.tsx43/43 (the guard witness is new)errorMessage: nullit clears (2), and the guard's four — the flip stops marking its reason, the reconciler writer stops marking it, the page ignores the flag, and the Telegram bind clears the message but not the flagtelegramService/slackApiwhole now spreadjest.requireActual: the network is stubbed, the behaviour stays real. That class was collateral from this branch — a bare stub left the escape undefined, the caller's try/catch swallowed the send, and the failure read "sendMessage was never called"Two smaller things from vera's 73849, both taken:
escapeHtmlnow takesString(raw ?? ''), matching Slack's escaper, with a witness that an absent value renders as nothing rather than as the literalundefined(unreachable today — every call site pre-guards). And the classifier's docstring now records the one direction it fails in —errorCode/descriptionare read off the axios throw, so a Telegram reply that arrives HTTP 200 withok: falseclassifies as null: a missed flip, never a wrong one.Found while fixing, not folded — row candidate for @connector-ops
The same class sits one field over, in code this PR does not touch: the page's catalog half (
V2ConnectorsPage.tsx:814) rendersInstallableInstallation.errorMessage, andinstallableInstallationService.ts:340writeserror.messageinto it — a raw exception message — beside the reconciler's and the admin pause's constants. Nothing here changes that line, so it belongs in its own row rather than widening this PR.Gate
Vera is the single gate for this PR (73713) and is asked as such. The frontend half is under a UI hold: connector-ops's look at the frames, then Sam's, before the enqueue. No
cli/mcpversion slot is needed — nothing published moves.