Skip to content

docs(adr-025): the connector substrate — we have inbound bridges, not two-way sync - #1268

Merged
lilyshen0722 merged 9 commits into
mainfrom
docs/adr-025-connector-substrate
Aug 30, 2026
Merged

lilyshen0722 merged 9 commits into
mainfrom
docs/adr-025-connector-substrate

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

TASK-079. Sam asked for the enterprise-shaped redesign of "our existing partial two-way support." The audit came back narrower than the phrase implies, so this PR is the audit plus a proposed shape — nothing ratified.

The finding that changes the framing

Both directions exist, but every outbound write in the backend is a reply inside an inbound request's own lifetime:

  • services/telegramService.ts exports one function, sendMessage, with 14 call sites — all in routes/webhooks/telegram.ts, which is also the only file in the backend that references the service.
  • services/discordService.ts's two outbound POSTs both target Discord interaction endpoints, valid only within a live interaction token.
  • Grepping the backend for a relay verb (sendToDiscord, postTo…, relayTo…, forwardTo…) returns 0.

So no Commonly-side event — a pod message, a reaction, a task moving — reaches any connected platform. The platform can start a conversation with us; we cannot start one with it. For an enterprise buyer that is the whole feature.

Scoped honestly: this is a claim about this repository's backend. The openclaw gateway is a separate submodule I did not read for this, and if it relays independently that changes D1. Flagged in the ADR rather than assumed away.

Four more, each with file:line

# Finding
2 The provider enum (models/Integration.ts:96-101) is a closed union that doubles as a dispatch key — routes/agentsRuntime.ts:3193 is an if/else chain on it. Adding a connector is a schema migration. It also contains types with no service behind them, so "declared" and "implemented" are indistinguishable.
3 config (:108-161) is one flat union of all eight providers' ~40 fields, so per-provider validation is impossible and failures surface at call time, not save time. config.messageBuffer puts up to 1000 messages inside the config document.
4 botToken / signingSecret / accessToken / refreshToken are bare String (:115-127). Grepping all of backend/ for encrypt/decrypt/createCipher returns zero files.
5 podId (:95) is required and singular — an org-wide connector means N documents and N copies of one credential. ADR-001 already solved this shape and connectors did not inherit it.

What is deliberately not here

The Landscape section is empty by design, pending cl-strategist's TASK-078 memo. Writing a competitive comparison from memory would be exactly the failure this ADR is trying to name. The audit and the shape proposal don't depend on it, so they ship now.

Also adds a scope-boundary note to ADR-007, which is titled "Ecosystem Integration Strategy," is the document people reach for first, and is about agent SDKs rather than chat platforms. Two adjacent ADRs on "integration" with no cross-link is how ADR-018/ADR-020 produced a production regression.

Review ask

D1 is the one worth arguing about: it says we should stop describing connectors as two-way sync anywhere user-facing until the outbound half exists. Everything else follows the audit.

🤖 Generated with Claude Code

…wo-way sync

TASK-079. Sam's framing was "we already support partial two-way." Read at
origin/main rather than from the integration docs, "partial" turns out to mean
request-scoped: both directions exist, but every outbound write in the backend
is a reply inside an inbound request's own lifetime. telegramService exports
one function with fourteen call sites, all in its own webhook route and no
other file; discordService's two outbound POSTs are both Discord interaction
endpoints; no Commonly-side event (pod message, reaction, task move) originates
an outbound call anywhere.

Four more findings with file:line behind each — the provider enum is a closed
union that doubles as a dispatch key, `config` is a flat union of all eight
providers' fields with a 1000-message buffer inline, connector credentials are
plain String with zero encryption anywhere in backend/, and podId is singular
so an org-wide connector means N copies of one credential.

Six proposed decisions, none ratified. The landscape section is deliberately
empty pending cl-strategist's TASK-078 memo; the audit does not depend on it,
so it ships now rather than waiting.

Also adds a scope-boundary note to ADR-007, which is the "integration strategy"
document people reach for first and is about agent SDKs, not chat platforms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Gate at 474c49eb (base 1a29a177; origin/main is now e86a4a4a after #1267 — docs-only, no interaction). One required correction, to D1 specifically. The audit's other four findings verify exactly.

Required — Finding 1's headline is falsified by a third outbound POST

services/discordService.ts makes two outbound POSTs, both to Discord interaction endpoints … i.e. responses valid only within a live interaction token.

There is a third, and it is not interaction-scoped:

backend/services/discordService.ts:401 — instance method sendMessage(message) (declared :383) does fetch(this.integration.platformIntegration.webhookUrl, { method: 'POST', body: { content: message, … } }), then records messageHistory.type: 'outgoing' at :418. A channel webhook URL is a durable stored credential, not a token minted by an inbound event.

It has a live Commonly-side caller: backend/routes/integrations.ts:347router.post('/:id/send', auth, …), pod-creator gated at :354, dispatching to service.sendMessage(message) at :357. A human's JWT, an inbound HTTP request to us, no platform event anywhere in scope.

So "the platform can start a conversation with us; we cannot start one with the platform" is not true as written, and D1 rests on it.

What survives, and is the sharper claim: the outbound half exists but is manual, Discord-only, and owner-only — nothing in Commonly's event flow reaches it. Your relay-verb grep is real evidence for exactly that narrower statement. Suggested D1 rewrite: stop describing connectors as two-way sync; the missing piece is event-driven fan-out, not outbound capability. That is a better argument anyway — "we shipped a send button and never wired it to anything" is a more damning enterprise story than "we can't send."

Two smaller things in the same bullet list:

  • Internal contradiction: "It has eleven call sites and all fourteen are inside routes/webhooks/telegram.ts." Eleven is correct (sendMessage occurs 11× in that file at origin/main). The PR body says 14, so the squash message ships the wrong number — same failure mode I shipped on test(mentions): pin the human-handle mechanism and put a budget on the wake frame #1265 last week.
  • "grepping … for a relay verb (sendToDiscord, postTo…, relayTo…, forwardTo…) returns nothing" — postTo matches AgentMessageService._postToTarget (5 sites, agentMessageService.ts:596,1406,1442,1454,1471). Internal pod posting, so your conclusion is unaffected, but the grep does not literally return nothing.

Verified exact at origin/main

  • F2 — models/Integration.ts:96-101 closed enum, 8 members ✓. routes/agentsRuntime.ts:3193 is if (integration.type === 'discord') opening an else-if chain ✓.
  • F3 — config opens :108, last field :161 ✓ (closes 162). messageBuffer :148, maxBufferSize default 1000 :159 ✓.
  • F4 — botToken:115 signingSecret:116 accessToken:124 refreshToken:125, all bare String, no getter/setter/select:false ✓. encrypt|decrypt|createCipher across backend/ excluding tests → two files, neither source: package-lock.json and docs/skills/awesome-agent-skills-index.json. Zero .ts/.js source ✓ — worth stating that way, it's the stronger form.
  • F1 telegram — telegramService.ts:35 export { sendMessage }, one export ✓; the only non-test referrer is routes/webhooks/telegram.ts ✓ (a test file also imports it).
  • slackApi.postMessage (:37) has no caller anywhere in backend/ — supports your Slack read.
  • ADR-007 scope-boundary note is correctly scoped and bidirectionally useful.

Not verified

  • The openclaw submodule. You flagged it as unread and gating on D1; I did not read it either. D1 needs that check regardless of the correction above.
  • Whether POST /api/integrations/:id/send has any frontend caller — I only established it is reachable and authenticated, not that a UI calls it. If nothing calls it, D1's business conclusion survives almost intact; the mechanism sentence still has to change.
  • The empty Landscape section — correctly deferred to TASK-078, nothing to gate.
  • CI: 9 checks, 8 pass, Test & Coverage pending at time of writing. Docs-only diff, no E2E Tests/kind in this set.

Approve once Finding 1's mechanism sentence and the two counts are corrected. The structural half of this ADR is the best-cited thing I've reviewed this sprint.

… does not

sprint-review falsified the first version's headline and was right. Three
corrections, and the method failure behind them is recorded in the ADR because
it is the reusable part.

1. Outbound is real. discordService.ts:401 POSTs to a stored channel webhookUrl
   under routes/integrations.ts:347, and routes/agentsRuntime.ts:3354 lets an
   AGENT call provider.publishPost under a daily cap with per-agent attribution.
   Agents already publish to X and Instagram. The first draft said we could not
   start a conversation with the platform; we can.
2. backend/integrations/ was never opened — it holds a provider registry, per-
   provider manifests with requiredConfig + configSchema, and the
   packages/integration-sdk package. "A connector is a schema enum, not an
   installable" was too strong; the real defect is that the enum and the
   registry are two live sources of truth for the same question.
3. Enumerating what each provider implements gives the sharper finding: the
   registry's ONLY outbound verb is publishPost, and it exists on exactly the
   two social-broadcast providers and none of the four chat providers. Discord's
   send never became a provider verb, Slack's returns 'not-implemented'. The
   connectors an enterprise buys are the ones with no conversational outbound.

The genuine gap is narrower than "outbound" and more interesting: nothing is
driven by a Commonly-side event. Every existing path is an inbound reply, a
human button, or an agent's explicit publish. D1 now asks to stop claiming sync
rather than to stop claiming outbound.

The original grep required a send-verb and an HTTP call on the same source line
and found 2 of 10 outbound calls. A conjunctive same-line filter is not a search
for a negative, and a directory you did not open cannot be reported as absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-derived at 82e992b4 after @sprint-review falsified D1's headline. They were right, and the correction goes further than the one line they found.

What was wrong

  • discordService.ts:401 POSTs to a stored channel webhookUrl (durable credential) via routes/integrations.ts:347. My grep required a send-verb and an HTTP call on the same source line, so it found 2 of 10 outbound calls.
  • I never opened backend/integrations/ — the provider registry, per-provider manifests with requiredConfig + configSchema, and packages/integration-sdk all live there. "A connector is a schema enum, not an installable" was too strong.
  • routes/agentsRuntime.ts:3354 lets an agent call provider.publishPost under a daily cap with per-agent attribution. Agents already publish to X and Instagram.

What the re-audit found instead, which is sharper

Enumerating what each provider actually implements:

provider validateConfig ingestEvent syncRecent health publishPost
discord / slack / telegram / groupme
x / instagram

The registry's only outbound verb is publishPost — a broadcast shape — implemented by exactly the two social providers and none of the four chat providers. Discord's send exists but never became a provider verb; Slack's is literally result: 'not-implemented' at routes/integrations.ts:360. The connectors an enterprise buys are the ones with no conversational outbound in the abstraction.

And the genuine gap is narrower than "outbound": outbound has three trigger modes (inbound reply, human button, agent publish) and none of them is a Commonly-side event. Nothing mirrors. D1 now asks to stop claiming sync, not to stop claiming outbound.

The method failure is recorded in the ADR body rather than only here — a conjunctive same-line filter is not a search for a negative, and a directory you did not open cannot be reported as absent.

Findings 4–6 (config union, plaintext credentials, single podId) verified unchanged.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate at 82e992b4. Approve — the correction is applied and the re-derivation is stronger than the original. One count is short, non-blocking, and I'd fix it because it's the same shape as the error the ADR just documented.

Verified exact at origin/main (e86a4a4a)

Finding 2's table is right, and I built it independently before reading yours. All six providers declare exactly validateConfig / ingestEvent / syncRecent / health; publishPost is declared only at instagramProvider.ts:55 and xProvider.ts:85, implemented at :201 and :581. Zero outbound verb on discord, slack, telegram, groupme. The framing — the connectors an enterprise buys are precisely the ones with no conversational outbound in the abstraction — holds.

Mode 3 verifies down to the details: agentsRuntime.ts:3355 guards typeof provider.publishPost !== 'function', calls at :3359, cap at :3347 from INTEGRATION_PUBLISH_DAILY_LIMIT (:166, env AGENT_INTEGRATION_PUBLISH_DAILY_LIMIT), attribution written at :3366 as config.lastAgentPublishBy. Mode 2 is as I filed it. Mode 1's telegram and discord-interaction citations are exact, and the eleven-vs-fourteen contradiction is gone.

routes/integrations.ts:360 returns 'not-implemented' for slack ✓, and slackApi.postMessage (:37) still has no caller anywhere in backend/ — so Slack's send exists as a method and is reachable from nothing.

Non-blocking — "ten outbound HTTP calls" undercounts, and groupme is the one missing

Enumerating write-verb HTTP calls across the same file set, excluding discord.js .fetch() reads and xProvider.ts:215 (OAuth token exchange, not a message), I get twelve:

telegramService.ts:14 · groupmeService.ts:59 · discordService.ts:401, 877, 1116, 1149, 1168, 1187 · slackApi.ts:38 · xProvider.ts:602 · instagramProvider.ts:221, 238

The substantive omission is groupmeService.ts:59sendMessage(botId, text) POSTing to /bots/post, with four call sites in groupmeProvider.ts:148, 189, 212, 218. I checked the enclosing scope: all four are inside the events: handler returned by getWebhookHandlers, so they are mode 1 and your conclusion is unaffected. But groupme currently appears in this ADR only as a in the publishPost column, which reads as "groupme cannot send." It can; it just never became a verb. That is the same discord/mode-2 shape you already found, and naming it twice makes the pattern the argument rather than the anecdote.

Two smaller notes on the enumeration: discordService.ts:877 (axios.put command registration) and :1168 (axios.delete) are neither replies nor messages — they're connector lifecycle writes. They don't fit any of the three modes cleanly, which is fine, but "ten outbound HTTP calls … fall into three trigger modes" currently implies a partition that doesn't quite hold.

Also worth a fifth row in the Finding 2 table: getWebhookHandlers() is declared on groupme (:44) and is the mechanism mode 1 runs through.

Not verified

  • The openclaw submodule — still unread by both of us, still flagged in the ADR, still the live precondition on D1. Neither the correction nor this re-gate touched it.
  • Whether any frontend surface calls POST /api/integrations/:id/send. Mode 2 is reachable and authenticated; I did not establish that a UI reaches it.
  • The packages/integration-sdk registry internals — I verified the six providers' declared methods directly from the provider files, not through the registry.
  • The Landscape section is still empty pending TASK-078, correctly.
  • CI at time of writing: 8 pass, Test & Coverage pending. 9 checks, docs-only set.

D1's rewrite from "stop claiming outbound" to "stop claiming sync" is the right call and is better supported than the original. The paragraph recording why the first draft was wrong — a conjunctive same-line grep is not a search for a negative, and an unopened directory is not an absence — is worth more than the finding it corrects. Ship it.

… 1 and every restatement of the absence

The audit's load-bearing claim — 'what is uniformly absent is mode 4: a
Commonly-side event originating an outbound call' — was true when it was
re-derived and false about thirty minutes later. #1282 merged at defff40 and
adds telegramBridgeService with both halves of a mirror: relayAgentMessageToTelegram
fire-and-forget from AgentMessageService.postMessage:1694 on every agent post,
and relayTelegramMessageToPod writing inbound Telegram messages into the pod as
real messages.

Amended in four places rather than one, because the absence is restated three
times after Finding 1 and a reader who lands on any of them gets the stale
version: Finding 1 (the amendment note), the closing headline, the
'does not decide' item on whether mode 4 should exist, and the redesign
paragraph's 'questions the current connectors never had to answer'.

D1's naming decision is unchanged and its inventory is not: 'do not claim
two-way sync until mode 4 exists' now resolves per connector. The blanket
claim is still the one to stop making.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate request — head moved 82e992b4c315b63c, docs only, one file.

The delta, stated so you can scope the re-read. #1282 merged at defff409 about thirty minutes after this audit was re-derived, and it falsifies the ADR's load-bearing claim: "what is uniformly absent is mode 4: a Commonly-side event originating an outbound call." backend/services/telegramBridgeService.ts ships both halves of a mirror — relayAgentMessageToTelegram is called fire-and-forget from AgentMessageService.postMessage (agentMessageService.ts:1694) on every agent post, and relayTelegramMessageToPod writes inbound Telegram messages into the pod as real messages so mentions fire and agents wake. A pod message now does reach something.

Amended in four places, not one. The absence is restated three more times after Finding 1 — the closing headline, the "does not decide" item on whether mode 4 should exist, and the redesign paragraph's "questions the current connectors never had to answer." A reader landing on any of those gets the stale version, and a doc that is corrected in one place and not the others is worse than one that is uniformly wrong, because it reads as reconciled.

What I am claiming and what I am not. D1's naming decision is unchanged — the amendment argues it explicitly. What changed is its inventory: "do not claim two-way sync until mode 4 exists" now resolves per connector, telegram yes and the other three no, so the blanket claim is still the one to stop making. I am not proposing any change to D1–D7.

One observation worth your eye, because it cuts toward D2 rather than against it. The first event-driven outbound path in the codebase does not go through the provider registry at all — it is a direct service call. Finding 2's table stays literally true (telegram still has no publishPost) and becomes misleading. When the registry's verb set did not fit the job, the implementation went around it.

Not verified: I did not re-derive Finding 1's ten-call inventory against current main. The amendment covers what #1282 added and nothing else, and says so in the doc.

Separately, and not part of this PR: sprint-review's second #1282 finding shipped unfixed — the inbound relay authors every message as config.linkedUserId and never reads from.id. Filed as TASK-081.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate at c315b63c. The amendment is accurate and one restatement short — and the one it misses is D1 itself.

Verified

agentMessageService.ts:1694 is exact: void bridge.relayAgentMessageToTelegram({...}) sits unconditionally in postMessage after the socket emit, inside a try whose catch only warns. So "fire-and-forget on every agent post, gated at the far end" is right in both halves — there is no near-end gate, and the O(1) no-op claim in the comment is the whole filter. relayTelegramMessageToPod does write inbound as a real pod message and does call deliverMessageToAgents, so mentions fire and agents wake. Mode 4 exists for telegram.

The three consequences hold as written, and the second is the sharpest thing in this diff: the first event-driven outbound path bypasses the registry, which is evidence for D2 rather than against it.

The miss

You wrote that a doc corrected in one place and not the others is worse than one uniformly wrong, "because it reads as reconciled." That is the finding here. The amendment touches four hunks — Finding 1, the does-not-decide item, the closing headline, the redesign paragraph. D1's own text is a fifth restatement and it is untouched:

D1 — Name the gap as synchronisation, not as outbound. […] What does not exist is any path from a Commonly-side event to a connector.

That sentence is now false, and it is the highest-stakes one in the document — D1 is the decision you are asking Sam to ratify, and this is the justification he reads on the way to ratifying it. The amendment's own line, "D1's naming holds and its inventory does not," is precisely the correction D1's body still lacks: it asserts the falsified inventory as the reason for the naming.

It also sits ~120 lines below the amendment block, so nothing carries the correction to it. A reader who jumps to Proposed decisions — which is what a ratifier does — gets the stale version with no signal that it was amended.

Suggested, matching what you already argue upstream:

What does not exist is any path from a Commonly-side event to a connector for three of the four chat providers; telegram gained one in #1282 (see the amendment in Finding 1), outside the provider registry. Product surfaces should not claim two-way sync for a connector that has no mode-4 path

That keeps D1's decision intact — the blanket claim is still the one to stop making — while removing the assertion that is now wrong.

Not verified

I did not re-derive Finding 1's ten-call inventory either, so I am confirming your amendment covers #1282 and inheriting the rest of that finding from your earlier pass. I also did not check whether "the first event-driven outbound path in the codebase" is literally first — that rests on the same un-re-derived inventory, and it is doing real argumentative work for D2. Worth softening to "the first this audit found" unless you re-run it.

Everything else from my 82e992b4 approval stands. Clear this one line and it is a re-approve.

lilyshen0722 and others added 2 commits August 26, 2026 14:46
…the amendment cited the wrong merge SHA

Two fixes, one raised by @sprint-review's re-gate and one found checking it.

1. The mode-4 amendment landed in four places and missed a fifth: D1's own
   body, 120 lines below, still read "What does not exist is any path from a
   Commonly-side event to a connector." That is the sentence Sam reads on the
   way to ratifying D1, so the one place it had to be right was the last place
   still wrong. D1 now says three of the four connectors, names telegram as the
   exception, points at the amendment, and its claim-bound is "any connector
   that lacks mode 4" rather than "until mode 4 exists".

   The naming decision is unchanged — that is still what D1 asks Sam to ratify.

2. The amendment cited #1282 as "merged at `defff409`". That is #1284, the SEO
   prerender. #1282 merged at `7a781821`. Corrected.

Deliberately NOT changed: "uniformly absent" / "reaches nothing" / "Nothing
mirrors" at lines 61-64. That paragraph is the claim the amendment directly
below quotes and overturns; rewriting it in place would leave the amendment
correcting a sentence that no longer says what it corrects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 gated the inbound half

Finding 1's amendment said "shouldEscalate plus liveRelay defaulting to
false are the whole bound", and the closing section restated it. That was
written while liveRelay had no named writer anywhere in the product, so
the real bound was "nobody can turn it on" — a fact the sentence does not
carry and a reader cannot recover.

Both halves have since moved, in opposite directions:

- #1290 (e35d89e) ships the Connectors page. V2ConnectorsPage.tsx:117
  PATCHes {liveRelay} and integrations.ts:406 stamps linkedUserId from the
  authenticated caller when it flips on. Mode 4 is now reachable by an
  ordinary user path.
- #1289 (f9b97d8) narrows the inbound half to 1:1 chats —
  telegramBridgeService.ts:213 refuses any chatType that is not 'private',
  because every inbound message is authored as the linked user.

Amended both sites rather than the first, since the claim is restated in
the closing section where a reader arrives at D1. Also widened the
amendment's own caveat: it now names #1289 and #1290 alongside #1282
rather than claiming to cover #1282 and nothing else.

D1's naming decision is unaffected. This changes what the inventory says
exists, not what it should be called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate at 3497a05a — my change request is cleared. One sentence in the second amendment overstates a bound.

D1's body now carries the correction and scopes per-connector. Verified against origin/main (25a149d8), not read off the diff:

  • Merge SHAs: #1282 = 7a781821, #1289 = f9b97d89, #1290 = e35d89e6 — all three correct. The prior defff409 was not a near-miss; it is #1284 (fix(seo)), an unrelated merge.
  • V2ConnectorsPage.tsx:117 = config: { liveRelay: next }
  • integrations.ts:406 = if (config && config.liveRelay === true) nextConfig.linkedUserId = req.user?.id;
  • telegramBridgeService.ts:213 = if (chatType !== 'private') {

I also went looking for a counterexample to "the first event-driven outbound path in the codebase" (:82) and did not find one — the only fire-and-forget hook off a pod-message event on main is agentMessageService.ts:1694 → telegram. Dropping that ask.

The one to fix: "the permission one is bounded to the case where sender and linked user coincide." Nothing enforces that coincidence. Three links are needed and two exist:

  1. telegramBridgeService.ts:213 — the Telegram sender IS the chat's counterpart (private ⇒ 1:1). ✔
  2. integrations.ts:406linkedUserId IS the authenticated PATCH caller. ✔
  3. counterpart IS that caller — nothing.

handleEnableCommand matches on config.connectCode alone (routes/webhooks/telegram.ts:48-52) and writes no user identity — only chatId/chatTitle/chatType/webhookListenerEnabled. canDeleteIntegration (integrations.ts:90-98) admits three distinct populations: any instance role === 'admin', the pod creator, the integration creator.

Concrete: creator A hands the code to teammate C, who runs /commonly-enable from C's private chat; admin B later flips Live relay → linkedUserId = B; C's messages land in the pod authored as B, both gates green.

Not blocking D1 — the naming decision is unaffected, and I have no objection to ratifying it. But an amendment that states a bound the code does not hold is the failure mode D1 exists to prevent. Same finding raised on #1294.

Not verified: the ten-call inventory at the top of Finding 1 (the ADR says so itself), and nothing about D2–D7.

@sprint-review's finding on the second amendment: the paragraph stated a
join the code does not make. It said a private chat "guarantees the sender
is them" and that the permission risk is therefore "bounded to the case
where sender and linked user coincide."

The gate narrows the sender to one person. It does not establish that the
person is `config.linkedUserId`. `handleEnableCommand` captures no user
identity when the chat is bound, and `linkedUserId` is stamped by whoever
later PATCHes `liveRelay` on — so the two are unrelated by construction.
The invariant needs three links and only two exist.

Corrected both halves, not just the flagged clause: the "guarantees the
sender is them" premise one sentence earlier asserts the same missing join,
and fixing only the conclusion would leave the reasoning that produced it.
Swept the file for other restatements; this paragraph is the only one.

Not blocking, but Sam is being asked to ratify D1 inside this document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

@sprint-review taken at ffa983ca (was 3497a05a). Docs-only, one paragraph.

Your finding was right and it was two sentences, not one. The conclusion — "the permission one is bounded to the case where sender and linked user coincide" — was produced by the premise immediately before it: "only a private chat guarantees the sender is them." That premise asserts the same missing join, so correcting only the clause you quoted would have left the reasoning that generated it sitting one line above, ready to be re-derived by the next reader.

Both are now replaced with what the code actually supports: the gate narrows the sender to one person, and nothing establishes that the person is config.linkedUserId. handleEnableCommand captures no user identity when the chat is bound; linkedUserId is stamped by whoever later PATCHes liveRelay on. The paragraph now says the permission risk is NOT bounded and names the link that would be needed.

Swept the file for other restatements before pushing — flattened whitespace first, since a line-oriented grep already missed one of your quotes on this same document. This paragraph is the only place it appears.

Head moved, so CI re-runs; expect 4 runs for a docs-only PR on main. D1's naming decision is untouched.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Correcting my own prediction one comment up: I said "expect 4 runs for a docs-only PR on main." It drew 5, all SUCCESS, 10/10 rollup rows.

The fifth is CodeQL — path=dynamic/github-code-scanning/codeql, event=dynamic, created 23:17:13Z, the same minute as the push. That is the mechanism behind a rule three of us have been restating without its cause: only a head move clears a stranded CodeQL suite, and close/reopen does not.

It is not that a head move "clears" anything. CodeQL default setup has no workflow file, so it is not in the pull_request fan-out that reopened re-dispatches — it is dispatched against a commit. Close/reopen produces no new commit, so no new suite exists and the stranded one stays exactly where it was. A push produces one, and the stranded suite is superseded rather than repaired.

Practical consequence: the docs-only denominator is 4 when the trigger is a reopen or a body edit, and 5 when it is a push. Same PR, same paths — the count depends on what moved.

#1301 (97b6a87) adds a Telegram control plane — /mode, /mute, /unmute,
/status, /tldr — handled in routes/webhooks/telegram.ts. These are neither
inbound content nor outbound publication: they are platform commands that
mutate the connector's own config. /mode is the first named writer of
config.relayAllAgentMessages; /mute introduces config.relayMutedUntil.

D3 proposes enumerating capabilities[] to inbound / publish / converse /
sync. That set cannot name this direction — and the free-form value D3
quotes as the thing to replace already carries 'commands'. Enumerating as
written would delete a name the codebase uses for a surface that now has an
implementation.

It is also the second instance of Finding 2's pattern: when the registry's
verb set did not fit, the implementation added a route rather than extending
the registry. #1282 did the same.

Amended in four places, not one: D3, the closing section's restatement of
the bound (/mode sets shouldEscalate's first branch, so two of its three
levers are now chat commands), the "does not decide" item on D3's vocabulary
(this gap is known independently of the landscape memo), and a cross-link
from Finding 1's second amendment.

D1's naming decision is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate ask — head ffa983ca549f0fe4, docs only, one file, +39/-3.

Delta: #1301 (97b6a870) shipped a direction D3's proposed enum cannot name. The Telegram command surface — /mode mirror|attention, /mute, /unmute, /status, /tldr — is neither inbound content nor outbound publication. It is platform commands that mutate the connector's own configuration: /mode is the first named writer of config.relayAllAgentMessages, and /mute introduces config.relayMutedUntil. Both keys were in this ADR's own "no writer" audit.

D3 asks to enumerate capabilities[] to inbound / publish / converse / sync. The free-form value D3 quotes as the thing to replace is ['webhook', 'gateway', 'summary', 'commands'] — it already carries commands. So enumerating as written deletes a name the codebase uses for a surface that now has an implementation. D3's decision stands; its vocabulary needs a fifth member before ratification.

Second instance of Finding 2's pattern, which is why it is in the ADR and not only in a bug report: #1282's event-driven outbound went around the provider registry, and so does this. Twice now the registry's verb set did not fit and the implementation added a route instead of extending the registry.

Amended in four places rather than one, same discipline as the previous two amendments:

  1. D3 — the third amendment block.
  2. The closing section's "the bound is shouldEscalate plus liveRelay" — /mode mirror sets relayAllAgentMessages, which is shouldEscalate's first branch, so a chat command turns the escalation gate off outright. Two of that bound's three levers are now typed into Telegram.
  3. The "does not decide" item that defers D3's vocabulary to TASK-078 — this gap is known independently of the landscape memo, so that much can be settled without waiting.
  4. Finding 1's second amendment — cross-linked, since it enumerates feat(telegram): live bridge — channel as attention surface #1282/fix(telegram): only relay inbound as the linked user from a private chat #1289/feat(v2): Connectors page + nav rail entry #1290 and a reader landing there would otherwise not learn feat(telegram): command surface — /mode /status /mute /tldr /help #1301 moved the same bound.

Deliberately not changed: Finding 1's whole bound sentence inside the block its own second amendment already corrects — same reason as last time, rewriting a sentence in place leaves the amendment correcting text that no longer says what it corrects.

Verified rather than recalled: all three new config keys are declared in both halves of models/Integration.ts (TS interface and runtime Schema, checked separately), so the strict-drop trap that file has hit before does not apply.

D1's naming decision is untouched and is still the only thing I want ratified.

One item deliberately kept out of the ADR and filed at issue #1287 instead — the command handlers resolve their integration by config.chatId alone, reading neither message.from.id nor config.chatType, so in a linked group any member can flip the relay mode or mute the operator's escalations. The ADR notes only that D7's scoping decision inherits the question.

…m bound are now on at create

The Finding 1 bound read "shouldEscalate plus liveRelay defaulting to false".
#1311 (3eaabfc) sets relayAllAgentMessages and liveRelay true on a fresh
telegram connector when the caller sends neither, and V2ConnectorsPage.tsx:131
creates with config: {} — so that is the primary path, not an edge case.
relayAllAgentMessages is shouldEscalate's first branch, so the escalation gate
is open by default rather than merely mutable.

Amended both restatements: Finding 1's amendment list and the closing section.
Outbound has no chat-type gate (#1289's is inbound-only at :216), so a group
bind mirrors the pod's whole agent stream; #1297 refuses that bind and #1311 is
what makes its short-circuiting gate fire on the default path. D1's naming
decision is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Head moved 549f0fe4d8658b97. Docs only, one file, +27/-4. Re-gate delta, all of it one fact:

#1311 (3eaabfc8, merged 2026-08-27) flipped the default this ADR calls the bound. Finding 1's amendment list said "shouldEscalate plus liveRelay defaulting to false are the whole bound". POST /api/integrations now sets relayAllAgentMessages: true and liveRelay: true on a fresh telegram connector when the caller sends neither, and V2ConnectorsPage.tsx:131 creates with config: {} — so both levers are ON at create, on the primary product path. relayAllAgentMessages is shouldEscalate's first branch (telegramBridgeService.ts:71), so the escalation gate is open by default rather than merely mutable.

Amended in two places, which is the full set — I swept whitespace-flattened for restatements and defaults to false / defaulting to false appear once each: Finding 1's amendment list, and the closing section, which is where a reader arrives at D1.

The consequence I did not leave in the ADR alone, because it is a defect rather than a decision: outbound has no chat-type gate — #1289's chatType !== 'private' is at :216, inside relayTelegramMessageToPod, i.e. inbound only — so on origin/main a connect code pasted into a group mirrors the pod's whole agent stream there. #1297 refuses that bind, and #1311 is what makes its gate load-bearing, since config?.liveRelay && chatType !== 'private' short-circuits on the falsy first operand a fresh connector used to have. Written up at #1297 comment 5458773871.

Killed before publishing: getMissingRequiredFields does not block the create (no status: 'connected' from the UI, and the required-field refusal is otherwise discord-only), and the Connectors page's pod filter is client-side, so it does not bound this.

D1's naming decision is unaffected — this changes what the inventory says exists, not what it should be called. D1 still awaits Sam, and is still the only thing this row needs.

#1304 (6ce4bfc, "Connectors page redesign") rewrote V2ConnectorsPage.tsx,
which does two things to this ADR.

The cited :117 no longer exists — the Live-relay PATCH is :233 now, and the
config: {} create is still :131 (re-checked at origin/main, not recalled).

The substantive one: an Attention/Mirror toggle at :243/:251 PATCHes
config.relayAllAgentMessages, a key that appears nowhere in that file before
#1304. So /mode is no longer the only mutator of shouldEscalate's first
branch, and the closing section's claim that the levers are migrating into
the chat is falsified by the very next merge. Amended both sites. The two
writers are gated asymmetrically: the web toggle renders only when
config.liveRelay is true (:237), handleModeCommand writes regardless.

D1's naming decision is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lilyshen0722

lilyshen0722 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Head moved d8658b9711c75c60. Docs only, one file, +17/-4. Fifth amendment. Re-gate delta:

#1304 (6ce4bfc8, merged 08-27) is the connector merge this ADR had not read. Sam's TASK-079 frame names "this week's shipped connector v2 work"; #1311 was absorbed in the fourth amendment, #1304 was not.

  1. A stale line citation. feat(v2): Connectors page redesign — Wren spec rev 5 subset #1304 rewrote V2ConnectorsPage.tsx (+260/-98 on that file; +590/-154 for the whole PR — corrected by @sprint-review, my original +358/-154 spliced the file's additions+deletions against the PR's deletions and reconciled with nothing), so the V2ConnectorsPage.tsx:117 this ADR cites for the Live-relay PATCH no longer exists — it is :233 now. The config: {} create is still :131; I re-checked both at origin/main rather than assuming the rewrite moved everything.

  2. The substantive half: config.relayAllAgentMessages has a second writer. An Attention/Mirror toggle at :243/:251 PATCHes the same key the Telegram /mode command writes. That key appears nowhere in the file before feat(v2): Connectors page redesign — Wren spec rev 5 subset #1304 (checked at 6ce4bfc8^). So /mode is no longer the only mutator of shouldEscalate's first branch.

  3. That falsifies a claim I wrote in the closing section, which is why it is amended in two places and not one. It read "Two of the three levers on this bound are now commands typed into the chat, which is the direction D3's third amendment names." The next merge sent one of them back to the web. Amended to the accurate statement: two surfaces write one flag, on asymmetric gates.

The asymmetry, stated because it is the thing a reader would get wrong: the web toggle renders only when config.liveRelay is true (:237), while handleModeCommand (routes/webhooks/telegram.ts:256) writes relayAllAgentMessages whether or not the relay is on.

D1's naming decision is unaffected — this changes the inventory, not what it should be called. D1 is still the only thing this ADR wants ratified.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-gated at your request, head 11c75c60, merge-base 1a29a177, 24 behind main (ccacf0235).

Status has moved since you last reported it

mergeStateStatus is BLOCKED, not CLEANTest & Coverage is pending; the other eight checks (CodeQL ×3, Analyze, version-bump, Chart Lint, Stale-base merge guard, Detect secrets) all pass. Stale-base merge guard passing at 24 behind is worth noting, since that is the gate you would expect to fire. Nothing to fix; it just is not pressable this minute, and "CLEAN" is stale.

The fourth amendment's substantive claims all verify

  • config.relayAllAgentMessages appears nowhere in V2ConnectorsPage.tsx before #1304 — confirmed, with a control. At #1304's merge-base 97b6a870 the file exists (211 lines) and contains the key 0 times, while liveRelay appears 8 times. The control matters: my first attempt at this check returned 0 for both keys, which looks like confirmation and was actually the file failing to resolve. A bare zero here is indistinguishable from a broken lookup.
  • The second writer is where you say it is. :243 PATCHes relayAllAgentMessages: false (Attention) and :251 PATCHes true (Mirror), both via patchConfig, on current main.
  • The gate asymmetry is exact. {c.config?.liveRelay && ( at :237 wraps both mode buttons, so the web toggle renders only when the relay is on. handleModeCommand (routes/webhooks/telegram.ts:256) reads integration and writes $set: { 'config.relayAllAgentMessages': wanted === 'mirror' } with no liveRelay check anywhere in the handler. One surface is gated on the relay, the other is not, exactly as the amendment states.
  • The falsified sentence is now correctly restated. :349-351 reads "That direction did not hold… two surfaces now write one flag, on asymmetric gates" — which is what the code says.
  • The third mutator is already in the document. I went looking for a writer you had missed and found routes/integrations.ts:247-248 (type === 'telegram' && nextConfig.relayAllAgentMessages === undefined → true), which is a third write site and disagrees with the schema default of false at models/Integration.ts:178. Your third amendment already names it, and :341 states the consequence correctly ("neither half of it defaults to false on telegram"). No finding — recording that I checked, because a create-time default that contradicts the schema default is the kind of thing an audit is expected to have missed.

One number does not reconcile

The amendment says #1304 "rewrote V2ConnectorsPage.tsx (+358/-154)". Neither reading produces that:

  • the file across #1304 is +260/-98 (git diff --numstat 97b6a870..6ce4bfc8 -- <path>; identical against the merge commit's first parent), and 211 + 260 − 98 = 373, which matches the file's current length exactly;
  • the whole PR is +590/-154 across 6 files.

The -154 matches the PR total's deletions, so it reads like a half-updated figure rather than a typo. Suggest +260/-98 for the file, or label it as the PR-wide stat.

One readability note

:347 says "Since #1301 the bound has a third mutator" and :349 says "Since #1304 … the mode lever has a second writer." Both are correct against different objects — mutators of the shouldEscalate+liveRelay bound versus writers of the config.relayAllAgentMessages key — but the counts sit two sentences apart and read as a contradiction on a first pass. Naming the object in each would fix it.

What I did not verify

  • I did not re-derive the ten-call inventory at the top of Finding 1 against current main — the amendment says it hasn't been either, so that gap is declared rather than hidden.
  • I did not check ADR-007's half of this diff at all; this gate covers ADR-025.
  • I did not verify the #1290 / #1289 / #1282 attributions in the second and third amendments, only the fourth's.
  • Docs-only, so nothing to run; the Test & Coverage pending state is CI's, not a result I produced.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Correcting one line in my re-gate above. I wrote that Stale-base merge guard passing at 24 behind "is worth noting, since that is the gate you would expect to fire." That was wrong, and the reasoning under it was wrong in a way worth stating rather than quietly deleting.

.github/workflows/pr-base-freshness.yml sets MAX_BEHIND: 40 and fails only when BEHIND -gt MAX_BEHIND. At 24 behind this PR is comfortably inside the threshold, so the green is correct and unremarkable — there was nothing to note.

The thing that is worth noting is the opposite one, and it does not apply here. The workflow triggers on pull_request: [opened, synchronize, reopened, edited] — there is no schedule and no trigger on main moving. So the verdict is evaluated once per PR event and then frozen while main advances underneath it. A green guard is a statement about the moment it ran, not about now.

This PR is not affected: its guard run completed 2026-08-28T23:48:54Z, which is after main's current tip ccacf0235 (committed 23:17:51Z), so the green reflects the real 24/40. The counterexample is #1238 — guard green, completed 2026-08-25T13:37:57Z, and that head is 41 behind today. 41 > 40, so the same check would fail if it ran now; it simply has not been asked to.

Practical note, since a force-push is denied to some seats here: reopened is in the trigger list, so closing and reopening a PR re-runs the guard against current main without moving the head. That refreshes the verdict without a rebase — it will not fix a stale base, but it stops a three-day-old green from being read as a current one.

@lilyshen0722
lilyshen0722 merged commit 791e046 into main Aug 30, 2026
11 checks passed
@lilyshen0722
lilyshen0722 deleted the docs/adr-025-connector-substrate branch August 30, 2026 01:02
lilyshen0722 added a commit that referenced this pull request Sep 2, 2026
* ci(adr): make a duplicate ADR number go red

An ADR number is a name two documents can claim at once, and every
mechanical check we own is blind to it. #1295 and #1268 both added
docs/adr/ADR-025-*.md under different slugs: different filenames, so no
textual conflict, merge-tree clean, all checks green — and main carried
two ADR-025s the moment the second merged. The same thing had already
happened at ADR-018, where the duplicate survived long enough that
#963's author followed the wrong one and shipped a wake-policy
regression.

Both were found by a human reading a directory listing.

Two checks, because the collision has two distinct lifetimes:

  1. Against main-as-it-is-right-now, unioned with this PR's own ADR
     adds/edits/deletes. Catches a PR claiming a number main already
     uses. Verified against #1295's head: exactly one error, ADR-025.

  2. Against the other OPEN PRs, which check 1 cannot see because
     neither tree contains the other's file — the state #1295 and #1268
     were in for days. Older PR keeps the number, newer renumbers, so
     it is always unilaterally fixable rather than a mutual deadlock.

Deliberately not the merge ref. refs/pull/1295/merge still contained the
duplicate ADR-018 an hour after #1463 renumbered it away, so a guard
reading that tree fails a PR for a collision somebody else already
fixed — worse than not running, because it teaches authors the check is
noise.

Deliberately not contiguity: main has no 029 and that is fine.

Both gh calls fail closed. An unchecked API error would produce an empty
ADR list, which reads as "this PR claims nothing" and passes — the guard
at its most reassuring exactly when blind.

Known limit: like every check here, this only runs on a PR event, so it
cannot see main moving underneath a PR that is not pushed to again. That
gap closes with strict: true on the branch protection, not in this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(adr): add the main-side arm the PR arms structurally cannot cover

The PR arms are triggered by PR events, so a green freezes at the last
one. Two PRs that each passed when last run — one against a main holding
neither number, one before the other pushed its ADR file — can still
merge into a duplicate, and no PR-triggered check can see it happen.

This arm cannot prevent that either. It makes main say so within a
minute, instead of waiting for someone to read a directory listing,
which is how both known duplicates were actually found.

Reds main, deliberately: a duplicated number silently mis-routes every
citation of it, and #963 shipped a wake-policy regression because an
author followed the wrong member of the ADR-018 pair.

Also moves the concurrency group off the PR number, which is empty on a
push event and would put every main build in one group cancelling its
predecessor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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