Skip to content

fix(connectors): a dead chat becomes a named failure, and the relay stops - #1878

Merged
lilyshen0722 merged 15 commits into
mainfrom
kai/row-d-connector-failure
Sep 25, 2026
Merged

lilyshen0722 merged 15 commits into
mainfrom
kai/row-d-connector-failure

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

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, errorMessage stayed null, 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 / postMessage return the provider's own error (error_code + description, or Slack's error string) and decide nothing. The caller classifies, and only when the send it just made went to the chat the connector is bound to:

Site What it is
telegramBridgeService.ts (3 sends) relay / escalation sends to config.chatId
decisionCardReconcileService.ts the decision card's send to config.chatId
routes/webhooks/telegram.ts bind confirmation the chat this bind just stored — a permanent failure undoes that bind
installables.ts Slack connect marker the chat this confirm just stored, same shape (wren 73837)

Permanent means the chat is gone, not that the bot had a bad day. Telegram: any 403, or a 400 whose description is chat not found. Slack: channel_not_found, not_in_channel, is_archived. Everything else is content or transient and never flips — including 401 / 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 named errorMessage, and config.chatId unset. 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 filters status, 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 under parse_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 beside chat.postMessage (SlackApi.escapeSlackMrkdwn), String(value ?? '') — ?? rather than ||, so a missing value cannot become the literal string "undefined" and 0/false still render as themselves — with slackBridgeService and decisionCardReconcileService importing 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.errorMessage has a second, older writer: externalFeedService.ts copies a provider's error_description / error / detail / title, or the raw exception message, into it for x and instagram rows, so connect ECONNREFUSED <addr>:443 reaches the same status === '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 $set that 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. externalFeedService says 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's errorMessage when there is one and keeps that sentence as the fallback.

Evidence from a real browser, 390 and 1200, pairing main against this branch:

The three rows in the frame are the two connectors carrying errorMessageUserFacing: true — what a flip writes — and, in the same pod, an x connector in the error state holding connect ECONNREFUSED 10.4.4.7:443 with 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 innerText diff is exactly the three reason lines at both widths, and nothing else moves:

-The connection dropped.
+Telegram stopped delivering: the bot was blocked or removed from this chat.
-The connection dropped.
+Slack stopped delivering: this channel no longer exists.
-The connection dropped.
+Telegram stopped delivering: the bot was blocked or removed from this chat.

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 ECONNREFUSED appearing 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: 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.

That last check is the one the runbook asks for, and the first pass of this pair failed it: cut against 0e142135 it also dropped reconnect to resume and 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 from f5ded480 and the diff is clean.

Verification

  • backend: 432 suites / 4036 tests / 0 failures at an earlier head of this branch (Node 20), re-run in full at this one; lint:ts 0 errors / 1211 warnings — the pre-existing baseline, unchanged
  • frontend: V2ConnectorsPage.test.tsx 43/43 (the guard witness is new)
  • mutation ledger, each aimed at the path its claim actually takes, each reddening exactly its own witness by name: the Telegram classification and flip (4), the escape class (4), the two closing-line sites re-aimed after the first probe turned out to be mis-aimed (2), the Slack escape and its signature (4), the retired Activity row's positive control (1), the Slack connect marker and the errorMessage: null it 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 flag
  • nine test harnesses that stubbed telegramService / slackApi whole now spread jest.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: escapeHtml now takes String(raw ?? ''), matching Slack's escaper, with a witness that an absent value renders as nothing rather than as the literal undefined (unreachable today — every call site pre-guards). And the classifier's docstring now records the one direction it fails in — errorCode/description are read off the axios throw, so a Telegram reply that arrives HTTP 200 with ok: false classifies 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) renders InstallableInstallation.errorMessage, and installableInstallationService.ts:340 writes error.message into 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 / mcp version slot is needed — nothing published moves.

…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 `&amp;` 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.
@lilyshen0722
lilyshen0722 added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit a086cb1 Sep 25, 2026
19 checks passed
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
`/**`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant