docs(adr-025): the connector substrate — we have inbound bridges, not two-way sync - #1268
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 |
…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>
|
Head moved #1311 ( Amended in two places, which is the full set — I swept whitespace-flattened for restatements and 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 Killed before publishing: 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>
|
Head moved #1304 (
The asymmetry, stated because it is the thing a reader would get wrong: the web toggle renders only when 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
left a comment
There was a problem hiding this comment.
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 CLEAN — Test & 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.relayAllAgentMessagesappears nowhere inV2ConnectorsPage.tsxbefore #1304 — confirmed, with a control. At #1304's merge-base97b6a870the file exists (211 lines) and contains the key 0 times, whileliveRelayappears 8 times. The control matters: my first attempt at this check returned0for 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.
:243PATCHesrelayAllAgentMessages: false(Attention) and:251PATCHestrue(Mirror), both viapatchConfig, on current main. - The gate asymmetry is exact.
{c.config?.liveRelay && (at:237wraps both mode buttons, so the web toggle renders only when the relay is on.handleModeCommand(routes/webhooks/telegram.ts:256) readsintegrationand writes$set: { 'config.relayAllAgentMessages': wanted === 'mirror' }with noliveRelaycheck 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-351reads "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 offalseatmodels/Integration.ts:178. Your third amendment already names it, and:341states the consequence correctly ("neither half of it defaults tofalseon 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/#1282attributions in the second and third amendments, only the fourth's. - Docs-only, so nothing to run; the
Test & Coveragepending state is CI's, not a result I produced.
|
Correcting one line in my re-gate above. I wrote that
The thing that is worth noting is the opposite one, and it does not apply here. The workflow triggers on This PR is not affected: its guard run completed Practical note, since a force-push is denied to some seats here: |
* 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>
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