Skip to content

fix(connectors): tag Telegram's outbound line and route a quote-reply to the pod it quotes (TASK-156, ADR-025 D10/D11) - #1935

Merged
lilyshen0722 merged 4 commits into
mainfrom
kai/task156-multipod-routing
Sep 27, 2026
Merged

lilyshen0722 merged 4 commits into
mainfrom
kai/task156-multipod-routing

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Why

TASK-156, ADR-025 D10/D11. Per the TASK-154 ruling, one private chat binds one Commonly user, and that user's pods sit behind it as gates. Two gaps appear the moment a second gate is on.

D10 — Telegram only. Slack already does both halves:

  • its outbound line carries no pod tag where Slack's carries [pod];
  • its relayMap entry records no podId where Slack's does — so a reply has nothing to route by.

D11 — both bridges. A quote-reply landed in the active pod whichever pod the quoted line came from: routeReplyContent / routeSlackReplyContent returned the @mention and never the pod. The user's answer to pod B was authored into pod A, and the agent it named woke there without B's thread.

What changed

backend/services/telegramBridgeService.ts, backend/services/slackBridgeService.ts:

  1. Telegram's outbound line is tagged with the pod name, as Slack's is: [Launch] <b>Kai</b>: …. The lookup degrades to the pod id on failure (never to silence, never to a name that was not read) — the tag is cosmetic and must not cost the relay.
  2. Telegram's relayMap entry records podId.
  3. Both readers return the quoted entry's pod. The pure functions still do no lookups: podId there means "the quoted entry names a pod", not "routing is allowed".
  4. When that pod differs from the active one, the target is re-derived before posting — the linked user must still be a member of it and the pod must still be gated (the gate predicate is the existing isRelayableIntegration, reused rather than restated). Any failure refuses in the chat, naming the pod, and posts nothing anywhere. The refusal is sent with the same delivery-failure classification as the existing "no active pod" reply.
  5. An entry with no podId routes as it always has — pre-D11 rows carry no pod to check, and inbound still never consults the active pod's gate (isInboundRelayableIntegration, the rule at telegramBridgeService.ts:184).

Falling back to the active pod is the defect this exists for, so nothing in the new path may do it: the refusal returns before any write.

The rule has one home (wren 74618, before the gate)

Wren's spec fix, applied on the head rather than deferred: the routed-reply rule was specified for two implementations and would have been written twice.

  • connectorRelayPolicy.isGatedPodTarget(integration, podId) is now the one reading of config.gates — the user-scope-gate / pod-scope-own-pod ternary that slackBridgeService.isRelayableIntegration, telegramBridgeService.isRelayableIntegration and decisionCardReconcileService.canSendClosingLine each carried a copy of. Four consumers, one reading, no divergence possible.
  • connectorRelayPolicy.isRoutedPodTarget({integration, pod, podId, userId}) is the gate + membership conjunction; both bridges' routed check calls it instead of restating it. The protocol-health half stays in each bridge's own predicate, where it belongs — chatType/teamId/chatId legitimately differ by protocol.
  • The check-then-act window is named and accepted in the predicate's docstring, with what closing it would take (make the write carry the condition — a conditional update or transaction — instead of a preceding read), rather than left unstated.
  • Both call sites state that the ACTIVE pod is exempt from the gate by design, so "re-check the gate" cannot be read as universal. The exemption itself is unchanged and still pinned by the third witness.
  • Fixture fallout, disclosed: decisionCardRelay.bridges.test.js stubbed the whole relay-policy module with a factory returning only shouldEscalate; it now spreads jest.requireActual, or the new exports would be undefined in the bridges under test.

Witnesses — 13 new, 8 suites, 163 tests green

claim witness
the quoted pod receives the reply, the active pod does not both bridges
an unquoted message still goes to the active pod both bridges
a reply to the active pod's own line is unchanged, even with its gate off Telegram
a gate-off target is refused by name, nothing posted anywhere both bridges
a membership-gone target is refused by name both bridges
a pre-D11 entry (no podId) still routes to the active pod both bridges
the reader returns the quoted entry's pod, null when it has none both bridges
Telegram's outbound line carries the pod name and the map entry carries the pod Telegram
the gate reading: both scopes, absent/disabled/non-boolean gate, pod ids by value connectorRelayPolicy.test.js
the conjunction: each half alone is a refusal, creator-only membership admits connectorRelayPolicy.test.js

Ledger — 11 mutations over the committed head. Baseline and restore 44/44.

mutation red
Telegram drops podId from the map write 1
Telegram's reader ignores the quoted entry's pod 4 (disclosed: the reader feeds every routed arm)
Telegram's routed check drops the shared predicate 1 — the membership arm; see the overlap note
Telegram keeps the checks but posts to the active pod anyway 1 — the defect itself
Telegram never refuses (the re-check is decorative) 2
Telegram's outbound line loses the tag 1
Slack's reader ignores the quoted entry's pod 3
Slack's routed check drops the shared predicate 1 — the membership arm
the shared gate reading is always true 6, across both bridges and the predicate suite
the shared predicate drops its membership half 4, across both bridges and the predicate suite
the shared predicate restores a redundant user-id guard 0 — deliberate: see below

Two ledger findings acted on, not disclosed as noise:

  1. Dropping the shared predicate from Slack's routed check reddened nothing on the first run, because the gate half is also carried there by isRelayableIntegration and the suite had no membership-gone arm on the Slack path (Telegram had one). Added that arm; the mutation now reddens exactly it. The overlap is real and intended — the gate is checked twice on the routed path, which is why dropping the shared predicate reddens only the membership half in both bridges.
  2. Restoring the Boolean(userId) guard reddens nothing: isPodMember already fails closed on a falsy id. The guard is removed rather than kept unwitnessed; the predicate's own arm still pins the behaviour, and M11 stands as the measurement that established the redundancy.

Sequencing against #1523

Open #1523 changes the same return (replyStatus, for an entry that aged out of the 100-cap map). The two fields are additive and behaviourally independent: this PR is cut from a0568771 (+ the #1933 rebase) and does not contain replyStatus, and nothing here depends on it. Whichever lands second does a four-line rebase over the two return sites — recommended: #1523 first, since it has been idle since 09-03, and TASK-156 rides on top. #1523's aged-out case has no podId, so it stays with #1523 and routes as it does today.

Scope notes

  • No UI, no schema migration, no new endpoint. Entries written before this ship keep working.
  • decisionCardReconcileService.canSendClosingLine takes the shared gate predicate for the half it duplicated; it keeps its own membership read where it is, because the mute and chatId checks sit between the two halves there and folding them would move a check earlier for no gain.
  • Added-line lint: the only diagnostics on lines this PR adds are import/no-unresolved + import/extensions on the new require(...) calls — the same pair every pre-existing require in these two files reports; the backend .js corpus is not linted (2,279 errors across 277 files). The .ts sources are 0 errors.
  • Instrument note for the record: the first ledger run wiped the uncommitted Telegram source edits via git checkout --, which is why the source is committed before any mutation run and the ledger in this body is the run over the committed head.

Gate: Vera.

… to the pod it quotes (TASK-156, ADR-025 D10/D11)

A user-scoped connector has one active inbound destination and N gated pods.
Two gaps appear once a second gate is on.

D10, Telegram only — the two things Slack already does:
- the outbound line carries the pod name, so a reader can tell two pods apart;
- the relayMap entry records `podId`, so a reply has a pod to route by.

D11, both bridges — a quote-reply answers the line it quotes:
- `routeReplyContent` / `routeSlackReplyContent` return the quoted entry's pod;
- when it differs from the active pod, the target is re-derived before posting:
  the linked user must still be a member and the pod must still be gated. Any
  failure refuses in the chat, naming the pod, and posts nothing anywhere.
  Falling back to the active pod is the defect this rule exists for — the user's
  answer to B would be authored into A and the agent it names would wake there
  without B's thread.
- an entry with no `podId` (written before this shipped) routes as it always has.

Witnesses: the quoted pod receives the reply and the active pod does not; an
unquoted message still goes to the active pod; a reply to the active pod's own
line is unchanged even with its gate off; a gate-off or membership-gone target is
refused by name with nothing posted; a pre-D11 entry still routes to the active
pod; Telegram's outbound line carries the pod name.
…ine this PR adds

Formatting only, assertions unchanged: multiline object literals, a destructured
fixture, and a one-line `toHaveBeenCalledWith`. What remains on added lines is
`import/no-unresolved` + `import/extensions` on the new `require(...)` calls, the
same pair every pre-existing require in these two files already reports — the
backend `.js` corpus is not linted (2,279 errors, 277 of these files).
…ership (TASK-156, wren 74618)

Wren's spec fix, applied before the gate: the routed-reply rule was specified for
two implementations and would have been written twice, which is how the list and
the call drifted in TASK-146.

- `connectorRelayPolicy.isGatedPodTarget(integration, podId)` is now the one
  reading of `config.gates` — the user-scope gate / pod-scope own-pod ternary that
  both bridges' `isRelayableIntegration` and `decisionCardReconcileService` each
  carried a copy of. Four consumers, one reading.
- `connectorRelayPolicy.isRoutedPodTarget({integration, pod, podId, userId})` is
  the gate + membership conjunction, and both bridges' routed-reply check calls
  it instead of restating it. The protocol-health half stays where it belongs —
  in each bridge's own predicate, since chatType/teamId/chatId differ by protocol.
- The check-then-act window is now named and accepted in the predicate's
  docstring, with what closing it would take, rather than left unstated.
- Both call sites state that the ACTIVE pod is exempt from the gate by design, so
  "re-check the gate" cannot be read as universal.
- `decisionCardRelay.bridges.test.js` stubbed the whole relay-policy module with a
  factory returning only `shouldEscalate`; it now spreads `jest.requireActual`, or
  the new exports would be undefined in the bridges under test.

Witnesses: a new `connectorRelayPolicy.test.js` pins both predicates (both scopes,
absent/disabled/non-boolean gates, pod ids compared by value, the membership
conjunction, the missing-user-id guard). Six suites, 135 tests green.
…rd the ledger showed was redundant (TASK-156)

Two findings from the mutation run on the refactor, both acted on rather than
disclosed as noise:

- Dropping the shared predicate from Slack's routed check reddened NOTHING,
  because the gate half is also carried by `isRelayableIntegration` there and the
  suite had no membership-gone arm on the Slack path (Telegram had one). Added:
  gate on, linked user no longer a member, refusal by name, nothing posted.
- Restoring the `Boolean(userId)` guard in `isRoutedPodTarget` reddened nothing:
  `isPodMember` already fails closed on a falsy id. The guard is removed rather
  than kept unwitnessed; the predicate's own arm still pins the behaviour.
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