From 0c5e2ab11482d18c892a5d00678de443e8cda4b9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:16:16 -0700 Subject: [PATCH 1/4] =?UTF-8?q?docs(adr-025):=20D17=20=E2=80=94=20the=20co?= =?UTF-8?q?nnector=20is=20an=20Installable,=20the=20Connectors=20page=20is?= =?UTF-8?q?=20its=20install=20surface=20(ruled)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sam ruled TASK-005's decision card on 2026-09-02 (pod message 62584): A — one install verb, two doors, Connectors keeps the page. D17 records the decision, the two shapes it beat and why, the four invariants the #1509 review earned (mint last; the claim is the CAS; dispatcher-scoped selection; honest D8 phasing), and what it does not decide. The status line names D17 as the one ruled decision in the document so "Draft / Proposed" cannot be read as covering it. The implementation plan it points at is docs/plans/connector-as-installable-app.md (#1509). Co-Authored-By: Claude Fable 5.1 --- docs/adr/ADR-025-connector-substrate.md | 65 ++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-025-connector-substrate.md b/docs/adr/ADR-025-connector-substrate.md index 62b961656..02d6160b3 100644 --- a/docs/adr/ADR-025-connector-substrate.md +++ b/docs/adr/ADR-025-connector-substrate.md @@ -5,12 +5,13 @@ proposals for Sam (audit re-derived after sprint-review falsified the first vers nothing here is ratified, and D1–D7 should not be cited as settled). **D8–D16** are the channel-routing decisions folded in from #1295 (Proposed 2026-08-26; folded 2026-09-02 under Sam's ruling of 2026-08-30T01:44:52Z, pod message 60455 — one ADR-025, not two). D8 supersedes D7 for the -private-chat case (see D7's note). Acknowledged unknowns in the folded half: the inbound +private-chat case (see D7's note). **D17** (the connector as an installable app) is the one **ruled** +decision in this document — Sam, 2026-09-02, by decision card — and is cited as settled. Acknowledged unknowns in the folded half: the inbound bare-message routing default (D12) and the digest cadence (D13) are guesses until measured. The landscape section is deliberately unfilled pending cl-strategist's comparison memo (TASK-078); the current-state audit and the shape proposal do not depend on it, so they are written now rather than held. -**Date:** 2026-08-26 (substrate audit) · 2026-08-26 (channel routing, #1295) · folded 2026-09-02 +**Date:** 2026-08-26 (substrate audit) · 2026-08-26 (channel routing, #1295) · folded 2026-09-02 · D17 ruled 2026-09-02 **Author:** pod-architect (Lily Shen) — D1–D7 and the audit; cl-strategist (Lily Shen) — D8–D16 **Companions:** [`ADR-001`](ADR-001-installable-taxonomy.md) (the Installable model this should become a component of), [`ADR-004`](ADR-004-commonly-agent-protocol.md) (CAP — the driver-facing @@ -463,6 +464,66 @@ Commander is disabled or down. --- +## The connector as an installable app (D17) + +> **Provenance.** TASK-005 in the Connectors v2 pod. Sam's directive of 2026-09-02 (pod message +> 62429) asked for the shape to be ruled by decision card; the card (6a9812803f95024de5f7bf4e, +> thread 62468) offered three shapes with evidence, and Sam ruled **A** at 2026-09-02T22:34:37Z +> (pod message 62584). The evidence memo (`task-005-connector-as-app-decision.md`, attached in +> the pod) and the implementation plan +> ([`docs/plans/connector-as-installable-app.md`](../plans/connector-as-installable-app.md), #1509) +> carry the detail; this decision is the record. + +**D17 — The connector is an Installable, the Connectors page is its install surface, and +there is one install verb (ruled: Sam, 2026-09-02).** The Telegram connector becomes a +builtin [`ADR-001`](ADR-001-installable-taxonomy.md) Installable — `kind: 'app'`, `source: +'builtin'`, `scope: 'user'` per D8, with a `webhook` component on the mounted route and an +`event-handler` component on `chat.message` — and **"Add a channel" on `/v2/connectors` is the +install verb**, backed by an `InstallableInstallation` parent whose projection *is* the existing +`Integration` row (`Integration.installationId` is the back-pointer). ADR-001's invariant 6 +("one install record → N component projections"), unimplemented for every component type at +the time of ruling (`routes/registry/install.ts` reads no `components[]`), is built here for +two types against the two behaviours that already ship: the webhook route and the outbound +relay hook at `agentMessageService.ts`. A Browse card is a **second door to the same verb** +and lands only when the Apps marketplace unlocks; nothing in D17 depends on it. + +Three shapes were on the card. **B — a Browse listing only** — lost because the marketplace +is locked, off the nav rail, and its `/browse` query excludes `source: 'builtin'`, so the +listing would have been invisible and would have installed by navigation — the dead end the +marketplace's connector strip already has. **C — listing plus folding the Connectors page into +the marketplace** — lost because it moves the milestone's entry point onto that locked surface +and fights [`ADR-022`](ADR-022-persona-colleagues.md) D2 (Browse sells personas; apps are the +other aisle); it remains the right end state *if* the marketplace becomes the one front door, +which is a marketplace ruling, not a connector one, and A leaves it open — same rows, same +verb, the page moves later. + +**Four invariants the ruling carries**, each earned by review on #1509 (Vera and Kai, +2026-09-02) and pinned by an acceptance test in the plan: + +1. **Mint last.** No projector mints the connect code. The projected `Integration` row is + created `isActive: false` with no code; the install service's final write — after every + component is active — flips `isActive` and mints in one step. A partial install therefore + has nothing `handleEnableCommand`'s lookup can match, and the webhook route is not edited. +2. **The claim is the compare-and-set.** One parent row per `(installable, user)` across + `installing`, `active`, and retained `error` (unique partial index); the parent is claimed by + one atomic upsert; only the lock owner runs projectors; every loser gets the existing row — + 202 while installing, 200 when active — and never invokes a projector. +3. **Selection is the dispatcher's, scoped by the event's target.** For `chat.message` in pod + P the dispatcher selects the installations bound to P in one query before any handler runs; + it never fans out to every tenant and relies on handlers to decline. The bridge's own + `findLiveIntegration(podId)` stays as defence in depth until D8's inversion replaces it. +4. **Phasing is honest about D8.** The Installable declares `scope: 'user'` because that is + what the product is; until D8's three schema consequences ship (`Integration.scope`, optional + `podId`, the pod → members → connector lookup), the projected row keeps today's pod binding + as the first gate row. The install verb's API does not change between the phases. + +**What D17 does not decide:** slash-command components for the Telegram control plane (the +handlers stay in the webhook route until the slash-command track reactivates under ADR-011); +unifying `NativeAgentTrigger` and the `event-handler` vocabulary (D17 reuses the trigger name +and stops there); external `webhook:https://…` and `agent:` handlers (ADR-006's path); the +marketplace unlock. D6 (credentials behind a reference) still gates any *new* connector; D17 +re-platforms the existing one and its Installable row carries no secret. + ## What this ADR does not decide - **The wire format for D2's conversational verb.** Whether it reuses CAP's shapes, the webhook From 342848ef8862a1eafdfad6f686574c324563a354 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:20:01 -0700 Subject: [PATCH 2/4] =?UTF-8?q?docs(adr-025):=20D17=20invariant=202=20?= =?UTF-8?q?=E2=80=94=20the=20install=20lock=20carries=20a=20lease=20(Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/adr/ADR-025-connector-substrate.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/adr/ADR-025-connector-substrate.md b/docs/adr/ADR-025-connector-substrate.md index 02d6160b3..1a234244c 100644 --- a/docs/adr/ADR-025-connector-substrate.md +++ b/docs/adr/ADR-025-connector-substrate.md @@ -506,8 +506,10 @@ verb, the page moves later. has nothing `handleEnableCommand`'s lookup can match, and the webhook route is not edited. 2. **The claim is the compare-and-set.** One parent row per `(installable, user)` across `installing`, `active`, and retained `error` (unique partial index); the parent is claimed by - one atomic upsert; only the lock owner runs projectors; every loser gets the existing row — - 202 while installing, 200 when active — and never invokes a projector. + one atomic upsert; **the lock carries a lease** (`claimedAt` + one named TTL) so a dead + owner's `installing` row is taken over by the next attempt and swept to `error` by the + reconciler, never honoured forever; only the lock owner runs projectors; every loser gets + the existing row — 202 while installing, 200 when active — and never invokes a projector. 3. **Selection is the dispatcher's, scoped by the event's target.** For `chat.message` in pod P the dispatcher selects the installations bound to P in one query before any handler runs; it never fans out to every tenant and relies on handlers to decline. The bridge's own From 0caa39b46b79aad91c5e0b227b90468b5eefafa3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:57:33 -0700 Subject: [PATCH 3/4] =?UTF-8?q?docs(adr-025):=20D17=20invariant=202=20?= =?UTF-8?q?=E2=80=94=20the=20lock=20carries=20a=20generation;=20owner=20wr?= =?UTF-8?q?ites=20are=20fenced=20(Kai,=20Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/adr/ADR-025-connector-substrate.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/adr/ADR-025-connector-substrate.md b/docs/adr/ADR-025-connector-substrate.md index 1a234244c..8820a4b81 100644 --- a/docs/adr/ADR-025-connector-substrate.md +++ b/docs/adr/ADR-025-connector-substrate.md @@ -506,10 +506,14 @@ verb, the page moves later. has nothing `handleEnableCommand`'s lookup can match, and the webhook route is not edited. 2. **The claim is the compare-and-set.** One parent row per `(installable, user)` across `installing`, `active`, and retained `error` (unique partial index); the parent is claimed by - one atomic upsert; **the lock carries a lease** (`claimedAt` + one named TTL) so a dead - owner's `installing` row is taken over by the next attempt and swept to `error` by the - reconciler, never honoured forever; only the lock owner runs projectors; every loser gets - the existing row — 202 while installing, 200 when active — and never invokes a projector. + one atomic upsert; **the lock carries a lease and a generation** (`claimedAt` + one named TTL, + and a fresh `claimId` on every claim or takeover) so a dead owner's `installing` row is + taken over by the next attempt and swept to `error` by the reconciler, never honoured + forever — and **every write the owner makes is fenced on the generation**, the activate-and- + mint write above all, so a stale owner that revives after a takeover is refused with a + distinct error rather than minting a second code over the one the user was handed (the + ADR-026 D6 nonce, one layer up); only the lock owner runs projectors; every loser gets the + existing row — 202 while installing, 200 when active — and never invokes a projector. 3. **Selection is the dispatcher's, scoped by the event's target.** For `chat.message` in pod P the dispatcher selects the installations bound to P in one query before any handler runs; it never fans out to every tenant and relies on handlers to decline. The bridge's own From 80aa51bdd0319cae4772f4573eb51d8c53737681 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:21:52 -0700 Subject: [PATCH 4/4] =?UTF-8?q?docs(adr-025):=20D17=20invariant=202=20?= =?UTF-8?q?=E2=80=94=20the=20activation=20split=20commit=20is=20bridged=20?= =?UTF-8?q?by=20an=20activating=20state=20(Kai)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/adr/ADR-025-connector-substrate.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-025-connector-substrate.md b/docs/adr/ADR-025-connector-substrate.md index 8820a4b81..6ba12baa6 100644 --- a/docs/adr/ADR-025-connector-substrate.md +++ b/docs/adr/ADR-025-connector-substrate.md @@ -512,7 +512,9 @@ verb, the page moves later. forever — and **every write the owner makes is fenced on the generation**, the activate-and- mint write above all, so a stale owner that revives after a takeover is refused with a distinct error rather than minting a second code over the one the user was handed (the - ADR-026 D6 nonce, one layer up); only the lock owner runs projectors; every loser gets the + ADR-026 D6 nonce, one layer up); the parent-then-projection activation is a split commit + bridged by a recoverable `activating` state, so a crash between the two writes is resumed + at the mint on retry and never reported as success; only the lock owner runs projectors; every loser gets the existing row — 202 while installing, 200 when active — and never invokes a projector. 3. **Selection is the dispatcher's, scoped by the event's target.** For `chat.message` in pod P the dispatcher selects the installations bound to P in one query before any handler runs;