Skip to content

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

Open
lilyshen0722 wants to merge 7 commits into
mainfrom
docs/adr-025-connector-substrate
Open

docs(adr-025): the connector substrate — we have inbound bridges, not two-way sync#1268
lilyshen0722 wants to merge 7 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.

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