docs(adr-025): the connector substrate — we have inbound bridges, not two-way sync - #1268
docs(adr-025): the connector substrate — we have inbound bridges, not two-way sync#1268lilyshen0722 wants to merge 7 commits into
Conversation
…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>
|
Gate at Required — Finding 1's headline is falsified by a third outbound POST
There is a third, and it is not interaction-scoped:
It has a live Commonly-side caller: 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:
Verified exact at
|
… 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>
|
Re-derived at What was wrong
What the re-audit found instead, which is sharper Enumerating what each provider actually implements:
The registry's only outbound verb is 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 |
|
Re-gate at Verified exact at
|
… 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>
|
Re-gate request — head moved The delta, stated so you can scope the re-read. #1282 merged at 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 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 |
|
Re-gate at Verified
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 missYou 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:
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:
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 verifiedI 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 |
…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>
|
Re-gate at D1's body now carries the correction and scopes per-connector. Verified against
I also went looking for a counterexample to "the first event-driven outbound path in the codebase" ( 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:
Concrete: creator A hands the code to teammate C, who runs 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>
|
@sprint-review taken at 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 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. |
|
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 — It is not that a head move "clears" anything. CodeQL default setup has no workflow file, so it is not in the 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>
|
Re-gate ask — head Delta: #1301 ( D3 asks to enumerate 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:
Deliberately not changed: Finding 1's Verified rather than recalled: all three new config keys are declared in both halves of 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 |
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.tsexports one function,sendMessage, with 14 call sites — all inroutes/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.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
models/Integration.ts:96-101) is a closed union that doubles as a dispatch key —routes/agentsRuntime.ts:3193is 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.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.messageBufferputs up to 1000 messages inside the config document.botToken/signingSecret/accessToken/refreshTokenare bareString(:115-127). Grepping all ofbackend/forencrypt/decrypt/createCipherreturns zero files.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