From 5c8d533f932914a41c84f540fd801600d6d269aa Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:38:44 -0700 Subject: [PATCH 01/12] =?UTF-8?q?docs(plans):=20the=20connector=20as=20an?= =?UTF-8?q?=20installable=20app=20=E2=80=94=20implementation=20spec=20(TAS?= =?UTF-8?q?K-005,=20ruling=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sam ruled option A on 2026-09-02: one install verb, two doors, the Connectors page keeps its page. This is the plan that ruling points at: the builtin Telegram Installable (kind app, scope user per ADR-025 D8, Webhook + EventHandler components), the install/uninstall verbs, an InstallableInstallation parent whose projection IS the existing Integration row (installationId becomes the back-pointer), a projector registry with the two projectors built against shipped behaviour, the event dispatcher that replaces the hardcoded relay require, the reconciler, phasing behind D8's schema, the page change, the #1297 security carry-over, Vera's acceptance list, and sizes. ADR-025 gains D17 once #1295 lands; this file is what D17 points at. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 284 +++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/plans/connector-as-installable-app.md diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md new file mode 100644 index 000000000..fe3ff5905 --- /dev/null +++ b/docs/plans/connector-as-installable-app.md @@ -0,0 +1,284 @@ +# The connector as an installable app — implementation spec (TASK-005, ruling A) + +**Status:** Ruled by Sam 2026-09-02 (pod message 62584, decision card 6a9812803f95024de5f7bf4e): +**A — one install verb, two doors; Connectors keeps the page.** This document turns the ruling +into buildable work. Owner of the build: Kai. Design review: Wren. Verification: Vera. +**ADR text:** ADR-025 gains **D17** with the decision below once #1295 (the D8–D16 fold) lands; +this file is the implementation plan D17 points at, not a second decision record. + +Evidence behind the ruling: `task-005-connector-as-app-decision.md` (attached in the +Connectors v2 pod, 2026-09-02). The load-bearing facts, re-stated so this plan is self-contained: + +1. **No installer reads `components[]`.** `routes/registry/install.ts:108` is agent-only and + writes `AgentInstallation`; `InstallableInstallation` has zero production writers. ADR-001 + invariant 6 ("one install record → N component projections") is unimplemented for every + component type. This plan builds it — for two types, against two behaviours that already ship. +2. **The connector already has both components, hardcoded.** Inbound Webhook = + `routes/webhooks/telegram.ts` (one fixed path, mounted at `server.ts:207`). Outbound + EventHandler = the `require('./telegramBridgeService')` at `agentMessageService.ts:1787`, + fired after every agent post. `COMMONLY_SCOPE.md` §4.6 (the Discord bridge) is this exact + shape, written in April. +3. **The Connectors page is the install surface** (`/v2/connectors`, nav slot, #1290/#1304). The + Apps marketplace is `MARKETPLACE_LOCKED`, off the rail, and `/api/marketplace/browse` filters + `source: 'marketplace'` — a builtin app is invisible there. The Browse card is a second door + that lands when the marketplace unlocks and calls the same verb; nothing here depends on it. +4. **Scope is `user`** (ADR-025 D8, folded and Vera-verified): the private chat binds to the + user, not to a pod. The pod-scoped connector (D7) is the dormant team-group case and is out of + scope here. + +## 1. The manifest + +One builtin Installable, seeded idempotently at boot the way `seed-native-agents.ts` seeds the +first-party apps (§4 below names the seeder). Field names are `models/Installable.ts`'s, not +the prose names in ADR-001. + +```jsonc +{ + "installableId": "telegram", + "name": "Telegram", + "description": "Link your Telegram chat to Commonly — every pod you're in gets a voice where you already talk.", + "version": "1.0.0", + "kind": "app", // Apps aisle, verb "Install" (ADR-001 §3.8) + "source": "builtin", + "scope": "user", // ADR-025 D8 — one install per user, never per pod + "status": "active", + "requires": ["chat:read", "chat:write", "integrations:manage"], + "components": [ + { + "name": "telegram-webhook", + "type": "webhook", + "webhookPath": "/api/webhooks/telegram", + "webhookEvents": ["message", "edited_message"], + "addresses": [{ "mode": "webhook", "identifier": "/api/webhooks/telegram" }], + "scopes": ["chat:write"] + }, + { + "name": "telegram-relay", + "type": "event-handler", + "eventType": "chat.message", // NativeAgentTrigger's name — no third vocabulary + "eventHandler": "internal:telegram.relay", + "addresses": [{ "mode": "event", "identifier": "chat.message" }], + "scopes": ["chat:read"] + } + ] +} +``` + +Rules the manifest carries: + +- **No credential in the row.** The bot token stays in env (today) and behind H3's reference + (tomorrow). `requires` declares grants; it never carries material. (ADR-025 D6.) +- **`eventType` reuses `NativeAgentTrigger`'s `chat.message`.** Two vocabularies exist for one + idea (the closed trigger union that is live, and the open `event-handler` string that is dead); + this plan adds the first real event-handler dispatch and must not add a third name. Unifying + the two is named as follow-up, not done here. +- **Provider truth stays in the registry.** `backend/integrations/manifests.ts` remains the + source for `configSchema` / `capabilities`. The Installable is the *package*; the provider + manifest is the *driver contract*. The seeder derives display fields from the registry entry + so the two cannot drift (ADR-025 D4's direction: registry wins). + +## 2. The install verb + +**`POST /api/installables/:installableId/install`** (auth; rate-limited with the integrations +write limiter's key function). Body: `{ podId }` in Phase 1 (see §5), nothing else that the +server owns. + +What it does, in order — this is `installableInstallService.install()`: + +1. Load the Installable; 404 if absent or not `active`. +2. Resolve the target from `scope`: `user` → `targetType: 'user'`, `targetId: req.user.id`. + **One active installation per (installableId, user)**: a second call returns the existing + row with 200, not a duplicate (unique partial index on `{installableId, targetType, targetId, + status: 'active'}`). +3. Create the **parent** `InstallableInstallation` row: `installableVersion`, `installedBy`, + `installSource: 'ui'`, `grantedScopes = installable.requires`, `status: 'installing'`. +4. **Iterate `components[]`.** For each, look up `projectors[component.type]` (§3). Missing + projector → that component's `status: 'error'` with a message naming the type; the parent + ends `status: 'error'` and the install returns 422 with the parent row — **the row is kept** + so the half-install is visible (COMMONLY_SCOPE §5 "partial failure visibility"), and a retry + is idempotent. +5. Each projector returns `projectionIds`; the service writes them onto the component entry and + sets it `active`. When all components are active the parent is `active`. +6. Response: `{ installation, integration }` — the page needs the Integration row (connect code) + immediately, exactly as it gets it from `POST /api/integrations` today. + +**The projection IS the Integration row.** No new projection table. Both components project +onto the *same* `Integration` document, because the connector's runtime state (chat binding, +relay flags, relayMap) is one record today and splitting it would invent a migration for no +behaviour. `Integration.installationId` — already declared with a unique sparse index and +written by nothing — becomes the back-pointer: `String(installation._id)`. + +**Uninstall — `DELETE /api/installables/:installableId/install`:** parent → `uninstalled`; +each projector's `unproject` runs (Integration `isActive: false`, connect code cleared, +relayMap kept for audit); nothing is deleted. Re-install mints a new Integration row — Vera's +ruling on the design spec stands (the binding row is the unit; relayMap and gates are never +reused). + +**The legacy `POST /api/integrations` stays.** It is the pod-scoped path (buffer / summary +integrations, and the dormant D7 connector). Additive, not destructive: the Connectors page +stops calling it for Telegram; nothing else changes. + +## 3. The dispatcher — two projectors, one registry + +`backend/services/installable/projectors/` — one file per component type, registered in an +index the install service reads. Interface: + +```ts +interface ComponentProjector { + type: ComponentType; + project(component: IComponent, ctx: ProjectionContext): Promise>; + unproject(component: IComponent, ctx: ProjectionContext, projectionIds: Map): Promise; +} +// ctx = { installation, installable, installedBy, config } +``` + +**`webhook` projector (`internal` webhooks only in Phase 1).** For a builtin whose +`webhookPath` is a route this server mounts, projection means: resolve the provider from the +registry by path (`telegram`), and create-or-reuse the Integration row for this installation +with the same server-owned defaults `POST /api/integrations` applies today — `mintConnectCode()`, +`relayAllAgentMessages: true`, `liveRelay: true`, `linkedUserId = installedBy` (stamped after the +default, the #1297 ordering), `createdBy = installedBy`, `installationId`. Returns +`{ integrationId }`. **No new route table**: the route is already mounted; the projector +records the binding, it does not register HTTP. A `webhookPath` the server does not mount is a +projector error (that is the external-webhook case — ADR-006's, not this plan's). + +**`event-handler` projector.** `eventHandler: 'internal:'` resolves against an +in-process handler map (`backend/services/installable/eventHandlers.ts`), initially +`{ 'telegram.relay': telegramBridgeService.relayAgentMessageToTelegram }`. Projection records +`{ integrationId }` (shared with the webhook projector — same row) and marks the handler +subscribed for this installation. **The dispatch replaces the hardcoded `require`** at +`agentMessageService.ts:1787`: the message service calls +`eventHandlers.dispatch('chat.message', payload)`; the dispatcher resolves active +`event-handler` component installations for `chat.message` and invokes each handler with the +same payload the bridge takes today (`{ podId, agentUsername, displayName, content, +podMessageId }`), fire-and-forget, one `try/catch` per handler so one bridge cannot fail the +post. **Behaviour pin:** for a pod with one live Telegram row, exactly one relay fires per post, +with the same arguments as before. That is the test that proves invariant 6 landed without +moving the product. + +Unknown `eventHandler` prefix (`agent:`, `webhook:`) → projector error in Phase 1. Those are +the slash-command / external-webhook tracks; naming them here keeps the enum honest. + +**Reconciler.** A boot-time sweep (`installableReconciler.sweep()`), idempotent: for every +`active` installation, every component's `projectionIds` must resolve to a live row; a missing +row marks the component `stale` (never re-creates silently — a stale connector must not mint a +code nobody asked for). For every `uninstalled` installation, projections must be inactive. +Log a count line, the H3 pattern: the exit condition is the number reaching zero. + +## 4. Seeding + +`backend/scripts/seed-builtin-connectors.ts`, run from the same boot hook as +`seed-native-agents.ts`. Upserts the manifest in §1 keyed on `installableId`, `$set`s display +fields from the provider registry entry, `$setOnInsert`s stats. It does **not** create +installations — a builtin app is available, not installed, until a user installs it. (This is +where the seeder differs from the first-party agents, which are also auto-installed into the +demo pod; a connector auto-installed for every user would mint connect codes nobody asked for.) + +## 5. Phasing — what changes now, what waits for D8's schema + +ADR-025 D8's three schema consequences are unbuilt (`Integration.scope` does not exist; +`podId` is `required: true`; `findLiveIntegration(podId)` resolves from the pod and cannot see a +row without one). This plan does not gate on them; it lands the install substrate first and +makes D8's flip a projector change. + +**Phase 1 — install substrate (this plan).** Installation is user-scoped (`targetType: 'user'`, +one per user). The projected Integration row keeps today's pod binding: the page's pod picker +becomes the **first gate row**, and its pod is written to `Integration.podId` — the "active pod" +of D12, honestly labelled as the single pod this connector relays until D8 fans out. Relay +behaviour is byte-for-byte today's. The `Integration.podId` write on install is gated by +`isPodMember(pod, installer)` — the #1297 write gate, reused. + +**Phase 2 — D8's schema (separate PR, after this lands).** `Integration.scope: 'user'`, +`podId` optional under a conditional validator, `config.gates[podId]`, and the outbound lookup +inverted to pod → members → each member's personal connector. The projectors do not change +shape; only what they write does. The install verb's API does not change at all. + +Phase 1 is honest about D8 without pretending to have built it: the Installable declares +`scope: 'user'` because that is what the product is, and the one place the schema still says +"pod" is the gate row the user picked. + +## 6. The page + +`V2ConnectorsPage.tsx`, "Add a channel" (`createTelegram`, line ~131): the POST moves from +`/api/integrations` to `/api/installables/telegram/install` with `{ podId }`. Everything +downstream reads the same Integration row it reads today (`/api/integrations/user/all`), so +the code step, the polling, the expired-code re-mint (#1297), the relay/mode toggles, and +disconnect are unchanged — except **disconnect** calls the uninstall verb instead of +`PATCH isActive: false`, so the parent row's lifecycle stays true. + +Card copy does not change. The design spec (rev 5, §2.1–2.4) already describes a personal +connector card with pod gates beneath it; Phase 1 renders one gate row (the picked pod), +Phase 2 renders them all. + +**Browse card (later, not this plan):** `V2MarketplacePage`'s Install button posts +`agentName` to `/api/registry/install` for every kind — it must branch on `kind === 'app'` +to the install verb above before any app is listed there. That branch is a one-line seam this +plan leaves for the marketplace-unlock PR, and the `/browse` filter must admit `source: +'builtin'` for the card to exist at all. + +## 7. Security carry-over (from #1297, none of it optional) + +- `linkedUserId` is stamped from the installer, after the relay default, never from the body. +- Connect code is minted server-side (`mintConnectCode`); the body cannot supply one. +- The chosen pod is gated by `isPodMember` (write predicate, no admin read-bypass). +- The install and uninstall verbs sit behind the integrations write limiter's shared key. +- The Installable row carries no secret; H3's credential reference is the only future home. +- Enable-time refusal of a group bind, the string-`'true'` coercion, and the attempt limiter + are untouched — they live on the webhook route, which this plan does not edit. + +## 8. Acceptance — what Vera verifies + +Unit (`backend/__tests__/unit/services/installable/`): +1. `install('telegram')` creates one parent row with two component entries, both `active`, + both pointing at the **same** `integrationId`; the Integration row has `installationId === + String(parent._id)`, `linkedUserId === installer`, a 32-hex code with expiry, `liveRelay` and + `relayAllAgentMessages` true, `podId` = the gated pod. +2. A second install by the same user returns the existing row (200), creates nothing. +3. A manifest with an unknown component type yields parent `error`, the row kept, 422. +4. `uninstall` sets parent `uninstalled`, Integration `isActive: false`, code fields unset, + relayMap preserved. +5. Non-member of the chosen pod → 403, nothing written. +6. Reconciler: a deleted Integration under an active installation marks the component `stale`, + creates nothing. + +Service (`__tests__/service/`): +7. **Behaviour pin.** An agent post into a pod with one live Telegram row triggers exactly one + `relayAgentMessageToTelegram` call via the dispatcher, with the same five fields the + hardcoded hook passed. Zero calls for a pod without one. A throwing handler does not fail + the post. +8. `telegramBridgeService.attribution` and `telegram.webhook.*` suites pass unchanged — the + route is not edited. + +Frontend (`V2ConnectorsPage.test.tsx`): +9. "Add a channel" posts to `/api/installables/telegram/install` with `{ podId }`. +10. Disconnect calls the uninstall verb. + +Lint: `npm run lint:ts` 0 errors (the CI gate). New `.js` tests inherit the known-red corpus; +do not add `global-require` inside test bodies (hoist, per #1297). + +## 9. Sequencing and sizes + +| Step | Depends on | Owner | Size | +|---|---|---|---| +| ADR-025 D17 amendment (the decision text + pointer here) | #1295 merged | Wren | S | +| Projector interface + registry + install/uninstall verbs + seeder (§2–4) | #1297 merged (reuses its helpers) | Kai | M | +| Event dispatcher replacing the hardcoded hook + behaviour pin (§3, test 7) | same PR or next | Kai | S | +| Page: install verb + uninstall verb (§6) | verbs live | Kai | S | +| Reconciler + tests 1–6 | projectors | Kai | S | +| D8 schema flip (Phase 2) | all of the above | Kai, Wren for the gate UI | M | +| Browse card + `kind` branch + builtin filter | marketplace unlock (a marketplace ruling) | — | S | + +Two PRs is the intended shape: (1) verbs + projectors + seeder + dispatcher + tests, +(2) the page. The ADR amendment is its own docs PR. + +## 10. Not decided here (named so nothing inherits them silently) + +- Slash-command components for `/mode` `/status` `/mute` `/unmute` `/tldr` `/help` — the + handlers stay hardcoded in the webhook route until the slash-command track (paused, ADR-011) + reactivates. The command-handler group gate (#1287) is a route fix, independent of this. +- Unifying `NativeAgentTrigger` and `event-handler` vocabularies — this plan reuses the + trigger name and stops there. +- External webhooks (`webhook:https://…`) and `agent:` handlers — the ADR-006 path. +- Whether `'skill'` should be added to `ComponentInstallation.componentType` (it is missing + from the enum, pre-existing) — note filed; not this PR. +- The marketplace unlock and the Browse aisle — a marketplace ruling; the seam is left ready. From bc78257394a2c94f8de94f1c034a234f55f93f74 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:41:31 -0700 Subject: [PATCH 02/12] =?UTF-8?q?docs(plans):=20mint=20last=20=E2=80=94=20?= =?UTF-8?q?a=20partial=20install=20must=20not=20leave=20a=20redeemable=20c?= =?UTF-8?q?onnect=20code=20(Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook projector created the Integration row with the code already minted, and a 422'd install kept that row; handleEnableCommand's lookup (type, isActive, config.connectCode) knows nothing about installations, so the half-install shipped a fully redeemable bearer secret. Now the projector creates the row inactive with no code, and the install service's final write — after every component is active — flips isActive and mints in one step. The enable route is not edited. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 32 +++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index fe3ff5905..3c5653455 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -98,8 +98,18 @@ What it does, in order — this is `installableInstallService.install()`: so the half-install is visible (COMMONLY_SCOPE §5 "partial failure visibility"), and a retry is idempotent. 5. Each projector returns `projectionIds`; the service writes them onto the component entry and - sets it `active`. When all components are active the parent is `active`. -6. Response: `{ installation, integration }` — the page needs the Integration row (connect code) + sets it `active`. +6. **Activate last — and mint last.** Only when every component is `active` does the service, + in one write, flip the projected Integration row to `isActive: true` and mint the connect + code (`mintConnectCode()`), then set the parent `active`. Until that write the row exists + with `isActive: false` and **no code**. This is what makes a partial install safe without + touching the enable path: `handleEnableCommand` looks up + `{ type, isActive: true, 'config.connectCode' }` on main and knows nothing about + installations — a 422'd install therefore has nothing it can find. A retry of the install + reuses the inactive row and activates it; the reconciler treats an inactive row under an + `error` parent as expected, not stale. (Vera, 2026-09-02: "mint last, or teach enable the + parent status" — mint last, so the route is not edited.) +7. Response: `{ installation, integration }` — the page needs the Integration row (connect code) immediately, exactly as it gets it from `POST /api/integrations` today. **The projection IS the Integration row.** No new projection table. Both components project @@ -135,10 +145,13 @@ interface ComponentProjector { **`webhook` projector (`internal` webhooks only in Phase 1).** For a builtin whose `webhookPath` is a route this server mounts, projection means: resolve the provider from the registry by path (`telegram`), and create-or-reuse the Integration row for this installation -with the same server-owned defaults `POST /api/integrations` applies today — `mintConnectCode()`, +with the same server-owned defaults `POST /api/integrations` applies today — `relayAllAgentMessages: true`, `liveRelay: true`, `linkedUserId = installedBy` (stamped after the -default, the #1297 ordering), `createdBy = installedBy`, `installationId`. Returns -`{ integrationId }`. **No new route table**: the route is already mounted; the projector +default, the #1297 ordering), `createdBy = installedBy`, `installationId` — **created +`isActive: false` and without a connect code.** The projector never mints: the code is the one +bearer secret in the system and it is minted by the install service's final activation write +(§2 step 6), so no component failure after this projector can leave a redeemable code behind. +Returns `{ integrationId }`. **No new route table**: the route is already mounted; the projector records the binding, it does not register HTTP. A `webhookPath` the server does not mount is a projector error (that is the external-webhook case — ADR-006's, not this plan's). @@ -219,7 +232,8 @@ plan leaves for the marketplace-unlock PR, and the `/browse` filter must admit ` ## 7. Security carry-over (from #1297, none of it optional) - `linkedUserId` is stamped from the installer, after the relay default, never from the body. -- Connect code is minted server-side (`mintConnectCode`); the body cannot supply one. +- Connect code is minted server-side (`mintConnectCode`); the body cannot supply one, and it + is minted only by the final activation write — never by a projector (§2 step 6). - The chosen pod is gated by `isPodMember` (write predicate, no admin read-bypass). - The install and uninstall verbs sit behind the integrations write limiter's shared key. - The Installable row carries no secret; H3's credential reference is the only future home. @@ -234,7 +248,11 @@ Unit (`backend/__tests__/unit/services/installable/`): String(parent._id)`, `linkedUserId === installer`, a 32-hex code with expiry, `liveRelay` and `relayAllAgentMessages` true, `podId` = the gated pod. 2. A second install by the same user returns the existing row (200), creates nothing. -3. A manifest with an unknown component type yields parent `error`, the row kept, 422. +3. A manifest with an unknown component type yields parent `error`, the row kept, 422 — + **and the projected Integration row is `isActive: false` with no `connectCode`**, so + `handleEnableCommand`'s lookup cannot match it (assert `Integration.findOne({ type: + 'telegram', isActive: true, 'config.connectCode': { $exists: true } })` is null for that + installation). A retry that succeeds activates the same row and mints once. 4. `uninstall` sets parent `uninstalled`, Integration `isActive: false`, code fields unset, relayMap preserved. 5. Non-member of the chosen pod → 403, nothing written. From ff9904e2b2287a0dda72979e0c35c96754e3a9ed Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:42:23 -0700 Subject: [PATCH 03/12] =?UTF-8?q?docs(plans):=20the=20parent=20insert=20is?= =?UTF-8?q?=20the=20CAS=20=E2=80=94=20index=20both=20live=20states=20(Vera?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A unique partial index on status:'active' alone does not stop two concurrent installs from each creating an 'installing' parent and each projecting a row. The index filters to {installing, active} and the insert itself is the compare-and-set; duplicate-key is the idempotent path. Acceptance test 2 now races two installs against real Mongo. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index 3c5653455..ed916e711 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -87,11 +87,18 @@ What it does, in order — this is `installableInstallService.install()`: 1. Load the Installable; 404 if absent or not `active`. 2. Resolve the target from `scope`: `user` → `targetType: 'user'`, `targetId: req.user.id`. - **One active installation per (installableId, user)**: a second call returns the existing - row with 200, not a duplicate (unique partial index on `{installableId, targetType, targetId, - status: 'active'}`). -3. Create the **parent** `InstallableInstallation` row: `installableVersion`, `installedBy`, - `installSource: 'ui'`, `grantedScopes = installable.requires`, `status: 'installing'`. +3. **Insert the parent as the compare-and-set** (the #1315 shape). There is no read-then-write: + the service inserts the `InstallableInstallation` row (`installableVersion`, `installedBy`, + `installSource: 'ui'`, `grantedScopes = installable.requires`, `status: 'installing'`) + against a **unique partial index on `{ installableId, targetType, targetId }` filtered to + `status: { $in: ['installing', 'active'] }`** — both live states, not just `active`, or two + concurrent installs would each pass a read, each create an `installing` parent, and each + project a row (Vera, 2026-09-02). A duplicate-key error is the idempotent path: load the + existing live row and return it with 200; if it is `installing`, return it as-is — the + first caller finishes it. `error` and `uninstalled` rows are outside the filter, so a retry + after a failed install inserts fresh and reuses the inactive Integration row (§2 step 6). + One parent therefore means one projected row (`Integration.installationId` is unique), and + `findLiveIntegration(podId)` never sees two live rows born from one user's install. 4. **Iterate `components[]`.** For each, look up `projectors[component.type]` (§3). Missing projector → that component's `status: 'error'` with a message naming the type; the parent ends `status: 'error'` and the install returns 422 with the parent row — **the row is kept** @@ -247,7 +254,10 @@ Unit (`backend/__tests__/unit/services/installable/`): both pointing at the **same** `integrationId`; the Integration row has `installationId === String(parent._id)`, `linkedUserId === installer`, a 32-hex code with expiry, `liveRelay` and `relayAllAgentMessages` true, `podId` = the gated pod. -2. A second install by the same user returns the existing row (200), creates nothing. +2. A second install by the same user returns the existing row (200), creates nothing — **and + two concurrent installs** (fire both before either resolves, real Mongo via + mongodb-memory-server so the unique index is exercised) produce exactly one parent, one + Integration row, one code; the loser gets the winner's row. 3. A manifest with an unknown component type yields parent `error`, the row kept, 422 — **and the projected Integration row is `isActive: false` with no `connectCode`**, so `handleEnableCommand`'s lookup cannot match it (assert `Integration.findOne({ type: From 63d60f2b8d7f18aae6fe768911665cb779435cee Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:43:31 -0700 Subject: [PATCH 04/12] =?UTF-8?q?docs(plans):=20selection=20is=20the=20dis?= =?UTF-8?q?patcher's,=20scoped=20by=20the=20event's=20pod=20=E2=80=94=20tw?= =?UTF-8?q?o-tenant=20pin=20(Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher must not fan out to every active handler and rely on each bridge's own lookup to decline; that is a multi-tenant leak waiting for a handler that does not. Selection is one pod-scoped query at the dispatcher (the O(1) the hardcoded hook promised, moved up a layer), the bridge lookup stays as defence in depth in Phase 1 and is deleted with D8's inversion in Phase 2. Test 7 gains the two-tenant pin measured on a spy at the handler map with the bridge lookup stubbed. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 40 ++++++++++++++++------ 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index ed916e711..aba561581 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -168,13 +168,28 @@ in-process handler map (`backend/services/installable/eventHandlers.ts`), initia `{ integrationId }` (shared with the webhook projector — same row) and marks the handler subscribed for this installation. **The dispatch replaces the hardcoded `require`** at `agentMessageService.ts:1787`: the message service calls -`eventHandlers.dispatch('chat.message', payload)`; the dispatcher resolves active -`event-handler` component installations for `chat.message` and invokes each handler with the -same payload the bridge takes today (`{ podId, agentUsername, displayName, content, -podMessageId }`), fire-and-forget, one `try/catch` per handler so one bridge cannot fail the -post. **Behaviour pin:** for a pod with one live Telegram row, exactly one relay fires per post, -with the same arguments as before. That is the test that proves invariant 6 landed without -moving the product. +`eventHandlers.dispatch('chat.message', payload)`. **Selection is the dispatcher's, and it is +scoped by the event's target — never "every active handler for this event type."** The +dispatcher's selector for `chat.message` in pod P resolves the installations whose projection +is bound to P: in Phase 1 that is one query, `Integration.find({ installationId: { $exists }, +isActive: true, 'config.liveRelay': true, podId: P })`, joined to their parents — the same O(1) +cost the hardcoded hook has today (the comment at `agentMessageService.ts:1783` promises that, +and the promise moves up a layer with the call). The dispatcher then invokes each selected +handler with the same payload the bridge takes today (`{ podId, agentUsername, displayName, +content, podMessageId }`) plus the selected `integration`, fire-and-forget, one `try/catch` per +handler so one bridge cannot fail the post. The bridge's own `findLiveIntegration(podId)` stays +in Phase 1 as defence in depth, not as the selector: an install must be filtered out **before** +its handler runs, not inside it — a dispatcher that fans out to every tenant and relies on each +handler to decline is a multi-tenant leak waiting for a handler that does not (Vera, +2026-09-02). In Phase 2 the selector becomes D8's inversion — pod → members → each member's +user-scoped install — and the bridge lookup is deleted; the handler signature does not change. + +**Behaviour pins:** (a) for a pod with one live Telegram row, exactly one relay fires per post, +with the same arguments as before; (b) **two tenants**: user A's install bound to pod P and +user B's bound to pod Q — a post in P invokes A's handler once and B's zero times, measured at +the dispatcher (a spy on the handler map), not at the bridge; (c) a pod with no install costs +one selector query and zero invocations. Those are the tests that prove invariant 6 landed +without moving the product or widening it. Unknown `eventHandler` prefix (`agent:`, `webhook:`) → projector error in Phase 1. Those are the slash-command / external-webhook tracks; naming them here keeps the enum honest. @@ -270,10 +285,13 @@ Unit (`backend/__tests__/unit/services/installable/`): creates nothing. Service (`__tests__/service/`): -7. **Behaviour pin.** An agent post into a pod with one live Telegram row triggers exactly one - `relayAgentMessageToTelegram` call via the dispatcher, with the same five fields the - hardcoded hook passed. Zero calls for a pod without one. A throwing handler does not fail - the post. +7. **Behaviour pins, at the dispatcher.** (a) An agent post into a pod with one live Telegram + row triggers exactly one `relayAgentMessageToTelegram` call via the dispatcher, with the same + five fields the hardcoded hook passed. (b) **Multi-tenant:** two active installs — user A's + bound to pod P, user B's bound to pod Q — and a post in P: A's handler is invoked once, B's + zero times, asserted on a spy at the handler map (the bridge's own lookup is stubbed out so + it cannot be what filtered B). (c) A pod with no install: one selector query, zero + invocations. (d) A throwing handler does not fail the post. 8. `telegramBridgeService.attribution` and `telegram.webhook.*` suites pass unchanged — the route is not edited. From 6eda1dd20976491e556d2a8d2e4b68e22987fbff Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:44:35 -0700 Subject: [PATCH 05/12] =?UTF-8?q?docs(plans):=20the=20claim=20is=20the=20C?= =?UTF-8?q?AS=20=E2=80=94=20one=20row=20across=20installing/active/error,?= =?UTF-8?q?=20only=20the=20lock=20owner=20projects=20(Kai)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniqueness now spans the retained error state too, so a retry claims the error row atomically (findOneAndUpdate upsert) instead of inserting a sibling. The returned installing row with our claimedAt is the lock; every other outcome is the loser's path — 202 while installing, 200 when active — and never invokes a projector. Test 2 spies the projector registry and asserts the retry reuses the same _id. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 33 +++++++++++++--------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index aba561581..b3a5fe0b7 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -87,16 +87,21 @@ What it does, in order — this is `installableInstallService.install()`: 1. Load the Installable; 404 if absent or not `active`. 2. Resolve the target from `scope`: `user` → `targetType: 'user'`, `targetId: req.user.id`. -3. **Insert the parent as the compare-and-set** (the #1315 shape). There is no read-then-write: - the service inserts the `InstallableInstallation` row (`installableVersion`, `installedBy`, - `installSource: 'ui'`, `grantedScopes = installable.requires`, `status: 'installing'`) - against a **unique partial index on `{ installableId, targetType, targetId }` filtered to - `status: { $in: ['installing', 'active'] }`** — both live states, not just `active`, or two - concurrent installs would each pass a read, each create an `installing` parent, and each - project a row (Vera, 2026-09-02). A duplicate-key error is the idempotent path: load the - existing live row and return it with 200; if it is `installing`, return it as-is — the - first caller finishes it. `error` and `uninstalled` rows are outside the filter, so a retry - after a failed install inserts fresh and reuses the inactive Integration row (§2 step 6). +3. **Claim the parent atomically — the claim is the compare-and-set** (the #1315 shape; Kai's + ask, 2026-09-02). One row per `(installableId, targetType, targetId)` across every + non-uninstalled state: a **unique partial index filtered to `status: { $in: ['installing', + 'active', 'error'] }`**. The service never reads-then-writes. It runs one + `findOneAndUpdate` with `upsert: true` whose filter is the key plus `status: { $in: + ['error'] }` or no-row, and whose update sets `status: 'installing'`, `installedBy`, + `installableVersion`, `installSource: 'ui'`, `grantedScopes = installable.requires`, + `claimedAt: now` — so a retry after a failed install **claims the retained `error` row** + instead of inserting beside it, and a first install inserts. The document returned with + `status: 'installing'` and our `claimedAt` is the lock; **only the lock owner runs + projectors.** Any other outcome is the loser's path and never invokes a projector: the + existing row is returned as-is — **202 while `installing`** (the owner will finish it), + **200 when `active`**. A duplicate-key error on the upsert (two first-installs racing the + insert) is the same loser's path. `uninstalled` rows sit outside the index, so re-install + after uninstall inserts fresh and mints a new Integration row (the binding row is the unit). One parent therefore means one projected row (`Integration.installationId` is unique), and `findLiveIntegration(podId)` never sees two live rows born from one user's install. 4. **Iterate `components[]`.** For each, look up `projectors[component.type]` (§3). Missing @@ -269,10 +274,12 @@ Unit (`backend/__tests__/unit/services/installable/`): both pointing at the **same** `integrationId`; the Integration row has `installationId === String(parent._id)`, `linkedUserId === installer`, a 32-hex code with expiry, `liveRelay` and `relayAllAgentMessages` true, `podId` = the gated pod. -2. A second install by the same user returns the existing row (200), creates nothing — **and - two concurrent installs** (fire both before either resolves, real Mongo via +2. A second install by the same user returns the existing row — 200 when active, **202 while + installing** — creates nothing and **invokes no projector** (spy on the projector registry). + **Two concurrent installs** (fire both before either resolves, real Mongo via mongodb-memory-server so the unique index is exercised) produce exactly one parent, one - Integration row, one code; the loser gets the winner's row. + Integration row, one code, one set of projector calls; the loser gets the winner's row. A + retry after a 422 **claims the retained `error` row** (same `_id`), never a second parent. 3. A manifest with an unknown component type yields parent `error`, the row kept, 422 — **and the projected Integration row is `isActive: false` with no `connectCode`**, so `handleEnableCommand`'s lookup cannot match it (assert `Integration.findOne({ type: From 5708b84f7e3b2248d6c1b241043a17750ab18504 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:19:58 -0700 Subject: [PATCH 06/12] =?UTF-8?q?docs(plans):=20the=20install=20lock=20has?= =?UTF-8?q?=20a=20lease=20=E2=80=94=20stale=20installing=20rows=20are=20ta?= =?UTF-8?q?ken=20over=20and=20swept=20(Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claim filter that matched only error-or-no-row honoured a dead owner's installing row forever: every retry took the loser path and the user could never install again. The upsert now also claims installing rows whose claimedAt is older than INSTALL_LOCK_TTL_MS (60s, one named constant), takeover is safe because projection is idempotent per installation and the only mint is the activation write, and the reconciler sweeps stale installing rows to error as the backstop. Tests 6 and 6b pin both paths. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 33 ++++++++++++++++------ 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index b3a5fe0b7..e0c34d69d 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -91,13 +91,20 @@ What it does, in order — this is `installableInstallService.install()`: ask, 2026-09-02). One row per `(installableId, targetType, targetId)` across every non-uninstalled state: a **unique partial index filtered to `status: { $in: ['installing', 'active', 'error'] }`**. The service never reads-then-writes. It runs one - `findOneAndUpdate` with `upsert: true` whose filter is the key plus `status: { $in: - ['error'] }` or no-row, and whose update sets `status: 'installing'`, `installedBy`, - `installableVersion`, `installSource: 'ui'`, `grantedScopes = installable.requires`, - `claimedAt: now` — so a retry after a failed install **claims the retained `error` row** - instead of inserting beside it, and a first install inserts. The document returned with - `status: 'installing'` and our `claimedAt` is the lock; **only the lock owner runs - projectors.** Any other outcome is the loser's path and never invokes a projector: the + `findOneAndUpdate` with `upsert: true` whose filter is the key plus **one of**: no row; + `status: 'error'`; or `status: 'installing'` with `claimedAt < now − INSTALL_LOCK_TTL_MS` + — and whose update sets `status: 'installing'`, `installedBy`, `installableVersion`, + `installSource: 'ui'`, `grantedScopes = installable.requires`, `claimedAt: now`. So a + retry after a failed install **claims the retained `error` row** instead of inserting + beside it, a first install inserts, and **a lock whose owner died mid-projection is taken + over, not honoured forever** (Vera, 2026-09-02: a lock with no expiry left that user unable + to install again — every retry hit the loser path). The document returned with `status: + 'installing'` and **our** `claimedAt` is the lock; **only the lock owner runs projectors.** + `INSTALL_LOCK_TTL_MS` is one named constant, sized to the projection (two document writes, + no network) — 60 s, generous by two orders of magnitude, and short enough that a user who + saw a 202 and retries gets a real install on the second click. Takeover is safe because + projection is idempotent per installation: the webhook projector create-or-reuses the + Integration row by `installationId`, and the only mint is the final activation write. Any other outcome is the loser's path and never invokes a projector: the existing row is returned as-is — **202 while `installing`** (the owner will finish it), **200 when `active`**. A duplicate-key error on the upsert (two first-installs racing the insert) is the same loser's path. `uninstalled` rows sit outside the index, so re-install @@ -203,6 +210,11 @@ the slash-command / external-webhook tracks; naming them here keeps the enum hon `active` installation, every component's `projectionIds` must resolve to a live row; a missing row marks the component `stale` (never re-creates silently — a stale connector must not mint a code nobody asked for). For every `uninstalled` installation, projections must be inactive. +For every **`installing`** installation whose `claimedAt` is older than `INSTALL_LOCK_TTL_MS`, +the sweep sets `status: 'error'` with `errorMessage: 'install lock expired'` — the row becomes +the ordinary retryable case, and the board-facing state stops lying about work in progress. +The sweep is the backstop; the claim filter above is the primary path, so a stuck lock is +recoverable by the next install attempt even between sweeps. Log a count line, the H3 pattern: the exit condition is the number reaching zero. ## 4. Seeding @@ -289,7 +301,12 @@ Unit (`backend/__tests__/unit/services/installable/`): relayMap preserved. 5. Non-member of the chosen pod → 403, nothing written. 6. Reconciler: a deleted Integration under an active installation marks the component `stale`, - creates nothing. + creates nothing. An `installing` row with `claimedAt` older than the TTL is swept to + `error` with `'install lock expired'`. +6b. **Lock takeover.** An `installing` row with a stale `claimedAt` (owner died): the next + install claims it (same `_id`, new `claimedAt`, new `installedBy`), runs projectors, + reuses the inactive Integration row, activates and mints exactly once. A fresh + `installing` row (within the TTL) is not taken over — the second caller gets 202. Service (`__tests__/service/`): 7. **Behaviour pins, at the dispatcher.** (a) An agent post into a pod with one live Telegram From efb12a39aefa01ecbf350d695494e80104aee540 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:21:20 -0700 Subject: [PATCH 07/12] docs(plans): uninstall resolves its target from the caller's identity; grantedScopes is descriptive (Vera) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install gated the chosen pod by isPodMember while uninstall named no gate at all; a co-member could have torn down another member's row. DELETE now resolves the target exactly as install does — from the caller's identity, never an id or body field — and test 4b pins it. grantedScopes is labelled descriptive-only in Phase 1 so the next reader does not take it for authorization. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 26 ++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index e0c34d69d..19829ece9 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -94,7 +94,10 @@ What it does, in order — this is `installableInstallService.install()`: `findOneAndUpdate` with `upsert: true` whose filter is the key plus **one of**: no row; `status: 'error'`; or `status: 'installing'` with `claimedAt < now − INSTALL_LOCK_TTL_MS` — and whose update sets `status: 'installing'`, `installedBy`, `installableVersion`, - `installSource: 'ui'`, `grantedScopes = installable.requires`, `claimedAt: now`. So a + `installSource: 'ui'`, `grantedScopes = installable.requires` (**descriptive only in Phase 1** + — it records what the manifest declared at install time, mirroring ADR-001's "declared, + permissive enforcement"; nothing reads it for authorization, and no route may start to + without a decision that says so), `claimedAt: now`. So a retry after a failed install **claims the retained `error` row** instead of inserting beside it, a first install inserts, and **a lock whose owner died mid-projection is taken over, not honoured forever** (Vera, 2026-09-02: a lock with no expiry left that user unable @@ -137,9 +140,16 @@ relay flags, relayMap) is one record today and splitting it would invent a migra behaviour. `Integration.installationId` — already declared with a unique sparse index and written by nothing — becomes the back-pointer: `String(installation._id)`. -**Uninstall — `DELETE /api/installables/:installableId/install`:** parent → `uninstalled`; -each projector's `unproject` runs (Integration `isActive: false`, connect code cleared, -relayMap kept for audit); nothing is deleted. Re-install mints a new Integration row — Vera's +**Uninstall — `DELETE /api/installables/:installableId/install`:** the target is resolved +**from the caller's identity exactly as install resolves it** — `scope: 'user'` → `targetType: +'user'`, `targetId: req.user.id` — and from nothing else: the route takes no installation id +and no body field, so there is no way to name someone else's row. (Vera, 2026-09-02: install +gated the pod by `isPodMember` while uninstall had no matching gate — a co-member could have +torn down another member's connector.) The live row for that key (`installing` or `active`) +goes → `uninstalled`; each projector's `unproject` runs (Integration `isActive: false`, connect +code cleared, relayMap kept for audit); nothing is deleted; no row → 404. Uninstalling an +`installing` row is allowed and is the human escape hatch for a stuck lock in addition to the +lease. Re-install mints a new Integration row — Vera's ruling on the design spec stands (the binding row is the unit; relayMap and gates are never reused). @@ -274,6 +284,10 @@ plan leaves for the marketplace-unlock PR, and the `/browse` filter must admit ` - Connect code is minted server-side (`mintConnectCode`); the body cannot supply one, and it is minted only by the final activation write — never by a projector (§2 step 6). - The chosen pod is gated by `isPodMember` (write predicate, no admin read-bypass). +- Install and uninstall both resolve their target from the caller's identity; neither accepts + an installation id or a target from the body, so a caller can only ever act on their own row. +- `grantedScopes` is descriptive, not enforced (Phase 1). Authorization is `auth` + + `isPodMember` + identity-derived targets, nothing else. - The install and uninstall verbs sit behind the integrations write limiter's shared key. - The Installable row carries no secret; H3's credential reference is the only future home. - Enable-time refusal of a group bind, the string-`'true'` coercion, and the attempt limiter @@ -299,6 +313,10 @@ Unit (`backend/__tests__/unit/services/installable/`): installation). A retry that succeeds activates the same row and mints once. 4. `uninstall` sets parent `uninstalled`, Integration `isActive: false`, code fields unset, relayMap preserved. +4b. **Uninstall is identity-scoped.** User B, a member of the same pod as user A's connector, + calls `DELETE /api/installables/telegram/install`: A's row is untouched (B gets 404 with no + install of their own, or uninstalls only their own). A body containing another + installation's id changes nothing. 5. Non-member of the chosen pod → 403, nothing written. 6. Reconciler: a deleted Integration under an active installation marks the component `stale`, creates nothing. An `installing` row with `claimedAt` older than the TTL is swept to From c883c3e2cc6ea099be424aa0c91bbbe7d34a315f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:39:34 -0700 Subject: [PATCH 08/12] docs(plans): the back-pointer arms two existing readers; one is an unguarded cross-type hard delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Integration.installationId` is written by nothing today, and this spec is its first writer. It has two readers on origin/main, both in routes/discord.ts: - `:80` handleInstallationEvent — inert, its input is a Discord snowflake and ours is a 24-hex ObjectId, so the value spaces are disjoint. - `:208` DELETE /api/discord/uninstall/:installationId — `findOne({ installationId })` with no `type: 'discord'` filter, then findByIdAndDelete. A hard delete whose id comes from the caller's URL, so the disjointness that protects `:80` does not reach it. Any non-Discord connector carrying an installationId becomes hard-deletable through the Discord route by anyone past canManageIntegration, bypassing this spec's soft uninstall. Recorded as a prerequisite: nothing writes installationId until `:208` carries the type term its neighbour route (`register-commands`) already carries. Found by @sprint-review gating this PR. Co-Authored-By: Claude Opus 5 --- docs/plans/connector-as-installable-app.md | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index 19829ece9..90af07071 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -140,6 +140,29 @@ relay flags, relayMap) is one record today and splitting it would invent a migra behaviour. `Integration.installationId` — already declared with a unique sparse index and written by nothing — becomes the back-pointer: `String(installation._id)`. +**Prerequisite, and it is a must-fix before the first write lands (@sprint-review, 2026-09-02).** +"Written by nothing" is true of writes only. The field has **two readers**, both in +`backend/routes/discord.ts` on `origin/main`, and this spec is the first writer either has ever +had — so shipping the back-pointer arms both of them in the same commit: + +- `:80`, inside `handleInstallationEvent` — `Integration.findOne({ installationId })`, an + already-installed short-circuit. Its input comes from Discord (`interaction.id`, a numeric + snowflake) and ours would be a 24-hex ObjectId string, so the value spaces are disjoint and + this reader stays inert. Stated because it is the explanation that has to be killed, not + because it is safe by design. +- `:208`, `DELETE /api/discord/uninstall/:installationId` — same `findOne({ installationId })` + with **no `type: 'discord'` filter**, gated by `canManageIntegration` (pod creator, pod admin, + or `createdBy`), body `DiscordIntegration.findOneAndDelete` + `Integration.findByIdAndDelete`. + A **hard delete**, and its id comes from the *caller's* URL, so the disjointness that protects + `:80` does not reach it. Once a Telegram or Slack connector carries an `installationId`, any + caller who passes that gate can hard-delete it through the Discord route — bypassing this + spec's uninstall entirely, which is soft by design ("nothing is deleted", below). + +The convention already exists one route down: `POST /api/discord/register-commands/:integrationId` +returns 400 on `integration.type !== 'discord'`. So the fix is to match it — add the `type: +'discord'` term to the `:208` filter — and it is correct on its own merits, before and +independently of this spec. **Nothing here writes `installationId` until that term is in.** + **Uninstall — `DELETE /api/installables/:installableId/install`:** the target is resolved **from the caller's identity exactly as install resolves it** — `scope: 'user'` → `targetType: 'user'`, `targetId: req.user.id` — and from nothing else: the route takes no installation id From 9c8c23fc291b355fa8ec5fee5182f2699ef09b1a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:57:14 -0700 Subject: [PATCH 09/12] docs(plans): the uninstall gate's first branch is instance-wide admin, not a pod role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review: the §2 bullet glossed `canManageIntegration` as "pod creator, pod admin, or createdBy". Wrong twice, and the sentence is carrying the severity claim. `user.role === 'admin'` (discord.ts:70) is a role on the User row, scoped to neither the pod nor the integration — broader than the gloss, and it is the branch that sets the blast radius on a cross-type hard delete. And "pod admin" names something `Pod` cannot express: `members` is a bare ObjectId[] with no role path, and the model's only `role` is `agentEnsemble.participants[].role` (starter/responder/synthesizer/observer), a turn-taking value with no authority meaning. So the pod-scoped half of the gate is `createdBy` alone. Docs-only, one bullet, in place. 395 -> 407 lines, 11 headers, tail intact. Co-Authored-By: Claude Opus 5 --- docs/plans/connector-as-installable-app.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index 90af07071..737b94e75 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -151,13 +151,25 @@ had — so shipping the back-pointer arms both of them in the same commit: this reader stays inert. Stated because it is the explanation that has to be killed, not because it is safe by design. - `:208`, `DELETE /api/discord/uninstall/:installationId` — same `findOne({ installationId })` - with **no `type: 'discord'` filter**, gated by `canManageIntegration` (pod creator, pod admin, - or `createdBy`), body `DiscordIntegration.findOneAndDelete` + `Integration.findByIdAndDelete`. + with **no `type: 'discord'` filter**, gated by `canManageIntegration`, body + `DiscordIntegration.findOneAndDelete` + `Integration.findByIdAndDelete`. A **hard delete**, and its id comes from the *caller's* URL, so the disjointness that protects `:80` does not reach it. Once a Telegram or Slack connector carries an `installationId`, any caller who passes that gate can hard-delete it through the Discord route — bypassing this spec's uninstall entirely, which is soft by design ("nothing is deleted", below). + **That gate is wider than a pod role, and the width is doing the severity work + (@sprint-review, 2026-09-02).** `canManageIntegration` is three branches in order: first + `user.role === 'admin'` — an **instance-wide** role on the User row, scoped to neither this pod + nor this integration; then `integration.createdBy === userId`; then `pod.createdBy === userId`. + An earlier draft of this bullet glossed it as "pod creator, pod admin, or `createdBy`", which + was wrong twice: it read the instance role as pod-scoped, and **"pod admin" is not a thing the + `Pod` model can express** — `members` is a bare `ObjectId[]` with no role path beneath it, and + the model's only `role` field lives on `agentEnsemble.participants[]` + (`starter | responder | synthesizer | observer`), a turn-taking value carrying no authority. + So the pod-scoped half of this gate is `createdBy` alone, and the branch that actually sets the + blast radius is instance admin. + The convention already exists one route down: `POST /api/discord/register-commands/:integrationId` returns 400 on `integration.type !== 'discord'`. So the fix is to match it — add the `type: 'discord'` term to the `:208` filter — and it is correct on its own merits, before and From 1ae7deb06283c7d7a200e113ab3b26a88c0ed426 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:57:32 -0700 Subject: [PATCH 10/12] =?UTF-8?q?docs(plans):=20the=20lock=20carries=20a?= =?UTF-8?q?=20generation=20=E2=80=94=20every=20owner=20write=20is=20fenced?= =?UTF-8?q?=20on=20claimId,=20the=20mint=20write=20requires=20it,=20refusa?= =?UTF-8?q?l=20is=20distinguishable=20(Kai,=20Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first lease cut had the takeover and not the generation. Walk: A stalls past the TTL, B takes over and mints C_B, A revives and mints C_A over it, the user types C_B and gets Invalid code with nothing logged. Now every claim and takeover writes a fresh claimId; every owner mutation of the parent is a findOneAndUpdate fenced on it; the activation is two ordered writes — a parent CAS that REQUIRES the generation (null = InstallLockLostError, 409 install_lock_lost, no mint) and an Integration write fenced on isActive:false so mint runs exactly once. The TTL is now a liveness knob, not a safety one. Tests 6c and 6d pin the stale owner and the winner's retry. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 62 ++++++++++++++++------ 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index 90af07071..9befc2b8f 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -93,7 +93,8 @@ What it does, in order — this is `installableInstallService.install()`: 'active', 'error'] }`**. The service never reads-then-writes. It runs one `findOneAndUpdate` with `upsert: true` whose filter is the key plus **one of**: no row; `status: 'error'`; or `status: 'installing'` with `claimedAt < now − INSTALL_LOCK_TTL_MS` - — and whose update sets `status: 'installing'`, `installedBy`, `installableVersion`, + — and whose update sets `status: 'installing'`, **`claimId: randomUUID()` — a fresh + generation on every claim and every takeover**, `installedBy`, `installableVersion`, `installSource: 'ui'`, `grantedScopes = installable.requires` (**descriptive only in Phase 1** — it records what the manifest declared at install time, mirroring ADR-001's "declared, permissive enforcement"; nothing reads it for authorization, and no route may start to @@ -102,29 +103,49 @@ What it does, in order — this is `installableInstallService.install()`: beside it, a first install inserts, and **a lock whose owner died mid-projection is taken over, not honoured forever** (Vera, 2026-09-02: a lock with no expiry left that user unable to install again — every retry hit the loser path). The document returned with `status: - 'installing'` and **our** `claimedAt` is the lock; **only the lock owner runs projectors.** - `INSTALL_LOCK_TTL_MS` is one named constant, sized to the projection (two document writes, - no network) — 60 s, generous by two orders of magnitude, and short enough that a user who - saw a 202 and retries gets a real install on the second click. Takeover is safe because - projection is idempotent per installation: the webhook projector create-or-reuses the - Integration row by `installationId`, and the only mint is the final activation write. Any other outcome is the loser's path and never invokes a projector: the + 'installing'` and **our** `claimId` is the lock; **only the lock owner runs projectors, and + every write the owner makes is fenced on that generation** (step 6). `INSTALL_LOCK_TTL_MS` + is one named constant — 60 s — and it is a **liveness** parameter, not a safety one: it + decides how soon a user who saw a 202 gets a real install on retry. Safety is the fence. + A takeover during a GC pause is therefore harmless: the paused owner revives holding a + generation the row no longer carries, and its writes are refused (Kai 62654, Vera 62655 and + 62701, 2026-09-02 — the first lease cut had the takeover and not the generation, and the + walk was: A stalls, B takes over and mints C_B, A revives and mints C_A over it, the user + types C_B and gets "Invalid code" with nothing logged). Any other outcome is the loser's path and never invokes a projector: the existing row is returned as-is — **202 while `installing`** (the owner will finish it), **200 when `active`**. A duplicate-key error on the upsert (two first-installs racing the insert) is the same loser's path. `uninstalled` rows sit outside the index, so re-install after uninstall inserts fresh and mints a new Integration row (the binding row is the unit). One parent therefore means one projected row (`Integration.installationId` is unique), and `findLiveIntegration(podId)` never sees two live rows born from one user's install. -4. **Iterate `components[]`.** For each, look up `projectors[component.type]` (§3). Missing +4. **Iterate `components[]`** — as the lock owner, with every parent mutation fenced: + component status and `projectionIds` are written with `findOneAndUpdate({ _id, status: + 'installing', claimId: ours }, …)`, and a `null` result is **`InstallLockLostError`**, which + aborts the install without touching the Integration row. For each component, look up + `projectors[component.type]` (§3). Missing projector → that component's `status: 'error'` with a message naming the type; the parent ends `status: 'error'` and the install returns 422 with the parent row — **the row is kept** so the half-install is visible (COMMONLY_SCOPE §5 "partial failure visibility"), and a retry is idempotent. 5. Each projector returns `projectionIds`; the service writes them onto the component entry and sets it `active`. -6. **Activate last — and mint last.** Only when every component is `active` does the service, - in one write, flip the projected Integration row to `isActive: true` and mint the connect - code (`mintConnectCode()`), then set the parent `active`. Until that write the row exists - with `isActive: false` and **no code**. This is what makes a partial install safe without +6. **Activate last — mint last — and fence both.** Only when every component is `active` + does the service activate, in two writes whose order is load-bearing: + 1. **Parent CAS on the generation:** `findOneAndUpdate({ _id, status: 'installing', + claimId: ours }, { $set: { status: 'active', activatedByClaimId: ours } })`. The + `claimId` term is **required in the filter, never match-if-present** — this write is + what hands out a bearer secret. `null` ⇒ `InstallLockLostError`: the owner logs at warn + with both generations, mints nothing, and returns **409 `{ code: 'install_lock_lost' }`** + to its own caller. The refusal is a distinct error class and a distinct status, never + the `null` a no-op would return (Vera 62655: D6's first cut returned null for both and + the caller reported success). + 2. **Integration activation, fenced on its own state:** `findOneAndUpdate({ installationId: + String(parent._id), isActive: false }, { $set: { isActive: true, 'config.connectCode': + …, 'config.connectCodeExpiresAt': … } })` — `mintConnectCode()` is called exactly once, + inside this write's construction. `null` here means the row is already active (a retry + of a winner that crashed between writes 1 and 2 — the only way to get here holding the + generation that won write 1): return the existing code, mint nothing. + Until write 2 the Integration row exists with `isActive: false` and **no code**. This is what makes a partial install safe without touching the enable path: `handleEnableCommand` looks up `{ type, isActive: true, 'config.connectCode' }` on main and knows nothing about installations — a 422'd install therefore has nothing it can find. A retry of the install @@ -244,7 +265,9 @@ the slash-command / external-webhook tracks; naming them here keeps the enum hon row marks the component `stale` (never re-creates silently — a stale connector must not mint a code nobody asked for). For every `uninstalled` installation, projections must be inactive. For every **`installing`** installation whose `claimedAt` is older than `INSTALL_LOCK_TTL_MS`, -the sweep sets `status: 'error'` with `errorMessage: 'install lock expired'` — the row becomes +the sweep sets `status: 'error'` with `errorMessage: 'install lock expired'` — fenced on the +`claimId` it read, so it cannot race a takeover that happened between its read and its write +— and the row becomes the ordinary retryable case, and the board-facing state stops lying about work in progress. The sweep is the backstop; the claim filter above is the primary path, so a stuck lock is recoverable by the next install attempt even between sweeps. @@ -345,9 +368,18 @@ Unit (`backend/__tests__/unit/services/installable/`): creates nothing. An `installing` row with `claimedAt` older than the TTL is swept to `error` with `'install lock expired'`. 6b. **Lock takeover.** An `installing` row with a stale `claimedAt` (owner died): the next - install claims it (same `_id`, new `claimedAt`, new `installedBy`), runs projectors, - reuses the inactive Integration row, activates and mints exactly once. A fresh + install claims it (same `_id`, new `claimId`, new `claimedAt`, new `installedBy`), runs + projectors, reuses the inactive Integration row, activates and mints exactly once. A fresh `installing` row (within the TTL) is not taken over — the second caller gets 202. +6c. **Stale-owner completion is a refused no-op, and it is visible.** A claims (generation + `a`) and stalls; B takes over (generation `b`), activates, mints `C_B`. A revives and runs + its activation: write 1 returns `null`, `InstallLockLostError` is thrown, **no mint call + happens** (spy on `mintConnectCode`: exactly one call in the whole test, B's), the + Integration row still carries `C_B`, and A's caller receives 409 `install_lock_lost` — + asserted on the status and the code, not on a null. +6d. **Winner retry mints once.** B crashes between writes 1 and 2 and retries holding + generation `b`: write 2 finds `isActive: false` and mints; a second retry finds + `isActive: true`, returns the same code, `mintConnectCode` called once. Service (`__tests__/service/`): 7. **Behaviour pins, at the dispatcher.** (a) An agent post into a pod with one live Telegram From 3ebc4caa773a681f1f7fcc52681ddf858ca3a278 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:17:12 -0700 Subject: [PATCH 11/12] =?UTF-8?q?docs(plans):=20a=20refused=20fenced=20wri?= =?UTF-8?q?te=20does=20nothing=20=E2=80=94=20no=20unproject,=20no=20cleanu?= =?UTF-8?q?p=20(Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On InstallLockLostError the stale owner must stop: a refusal means the row belongs to someone else, and a loser that tidies up deletes the winner's work. Test 6c now spies unproject and the Integration model and asserts A writes nothing. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index 809fafc78..90785912e 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -135,8 +135,11 @@ What it does, in order — this is `installableInstallService.install()`: claimId: ours }, { $set: { status: 'active', activatedByClaimId: ours } })`. The `claimId` term is **required in the filter, never match-if-present** — this write is what hands out a bearer secret. `null` ⇒ `InstallLockLostError`: the owner logs at warn - with both generations, mints nothing, and returns **409 `{ code: 'install_lock_lost' }`** - to its own caller. The refusal is a distinct error class and a distinct status, never + with both generations, mints nothing, **does nothing else — no unproject, no status + write, no cleanup of any kind** — and returns **409 `{ code: 'install_lock_lost' }`** + to its own caller. A refusal means "someone else owns this row now"; a loser that + tidies up deletes the winner's work (Vera 62703 — D6's `markPosted` gate made exactly + that mistake). The same rule holds for every fenced write in step 4: on `null`, stop. The refusal is a distinct error class and a distinct status, never the `null` a no-op would return (Vera 62655: D6's first cut returned null for both and the caller reported success). 2. **Integration activation, fenced on its own state:** `findOneAndUpdate({ installationId: @@ -386,9 +389,11 @@ Unit (`backend/__tests__/unit/services/installable/`): 6c. **Stale-owner completion is a refused no-op, and it is visible.** A claims (generation `a`) and stalls; B takes over (generation `b`), activates, mints `C_B`. A revives and runs its activation: write 1 returns `null`, `InstallLockLostError` is thrown, **no mint call - happens** (spy on `mintConnectCode`: exactly one call in the whole test, B's), the - Integration row still carries `C_B`, and A's caller receives 409 `install_lock_lost` — - asserted on the status and the code, not on a null. + happens** (spy on `mintConnectCode`: exactly one call in the whole test, B's), **no + `unproject` call happens and A writes nothing** (spies on the projector registry and on the + `Integration` model: B's writes only), the Integration row still carries `C_B` and stays + `isActive: true`, and A's caller receives 409 `install_lock_lost` — asserted on the status + and the code, not on a null. 6d. **Winner retry mints once.** B crashes between writes 1 and 2 and retries holding generation `b`: write 2 finds `isActive: false` and mints; a second retry finds `isActive: true`, returns the same code, `mintConnectCode` called once. From cdfed0ec0943fe94d3e972fcda89bd2bd84339e0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:22:57 -0700 Subject: [PATCH 12/12] docs(plans): the activation split commit is bridged by an activating state (Kai) Parent-active-then-mint was a split commit: a crash between the writes left a retry returning 200 with no code. Now write 1 moves the parent to activating (live in the index and the claim filter, never a success return), write 2 mints on the Integration row fenced on isActive:false, write 3 moves the parent to active; a retry or takeover that finds activating resumes at write 2 with no projector run. Test 6d covers a crash on either side of the mint and asserts one mint, no 200-without- code, and no redeemable code the user does not hold. Co-Authored-By: Claude Fable 5.1 --- docs/plans/connector-as-installable-app.md | 84 ++++++++++++++-------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/docs/plans/connector-as-installable-app.md b/docs/plans/connector-as-installable-app.md index 90785912e..e1f61bf2b 100644 --- a/docs/plans/connector-as-installable-app.md +++ b/docs/plans/connector-as-installable-app.md @@ -90,9 +90,10 @@ What it does, in order — this is `installableInstallService.install()`: 3. **Claim the parent atomically — the claim is the compare-and-set** (the #1315 shape; Kai's ask, 2026-09-02). One row per `(installableId, targetType, targetId)` across every non-uninstalled state: a **unique partial index filtered to `status: { $in: ['installing', - 'active', 'error'] }`**. The service never reads-then-writes. It runs one + 'activating', 'active', 'error'] }`**. The service never reads-then-writes. It runs one `findOneAndUpdate` with `upsert: true` whose filter is the key plus **one of**: no row; - `status: 'error'`; or `status: 'installing'` with `claimedAt < now − INSTALL_LOCK_TTL_MS` + `status: 'error'`; or `status: { $in: ['installing', 'activating'] }` with `claimedAt < + now − INSTALL_LOCK_TTL_MS` — and whose update sets `status: 'installing'`, **`claimId: randomUUID()` — a fresh generation on every claim and every takeover**, `installedBy`, `installableVersion`, `installSource: 'ui'`, `grantedScopes = installable.requires` (**descriptive only in Phase 1** @@ -102,8 +103,9 @@ What it does, in order — this is `installableInstallService.install()`: retry after a failed install **claims the retained `error` row** instead of inserting beside it, a first install inserts, and **a lock whose owner died mid-projection is taken over, not honoured forever** (Vera, 2026-09-02: a lock with no expiry left that user unable - to install again — every retry hit the loser path). The document returned with `status: - 'installing'` and **our** `claimId` is the lock; **only the lock owner runs projectors, and + to install again — every retry hit the loser path). The document returned with **our** `claimId` is + the lock (its `status` tells the owner where to resume: `installing` → run projectors; + `activating` → skip projectors, resume at step 6 write 2); **only the lock owner runs projectors, and every write the owner makes is fenced on that generation** (step 6). `INSTALL_LOCK_TTL_MS` is one named constant — 60 s — and it is a **liveness** parameter, not a safety one: it decides how soon a user who saw a 202 gets a real install on retry. Safety is the fence. @@ -112,7 +114,7 @@ What it does, in order — this is `installableInstallService.install()`: 62701, 2026-09-02 — the first lease cut had the takeover and not the generation, and the walk was: A stalls, B takes over and mints C_B, A revives and mints C_A over it, the user types C_B and gets "Invalid code" with nothing logged). Any other outcome is the loser's path and never invokes a projector: the - existing row is returned as-is — **202 while `installing`** (the owner will finish it), + existing row is returned as-is — **202 while `installing` or `activating`** (the owner will finish it), **200 when `active`**. A duplicate-key error on the upsert (two first-installs racing the insert) is the same loser's path. `uninstalled` rows sit outside the index, so re-install after uninstall inserts fresh and mints a new Integration row (the binding row is the unit). @@ -130,31 +132,43 @@ What it does, in order — this is `installableInstallService.install()`: 5. Each projector returns `projectionIds`; the service writes them onto the component entry and sets it `active`. 6. **Activate last — mint last — and fence both.** Only when every component is `active` - does the service activate, in two writes whose order is load-bearing: - 1. **Parent CAS on the generation:** `findOneAndUpdate({ _id, status: 'installing', - claimId: ours }, { $set: { status: 'active', activatedByClaimId: ours } })`. The - `claimId` term is **required in the filter, never match-if-present** — this write is - what hands out a bearer secret. `null` ⇒ `InstallLockLostError`: the owner logs at warn - with both generations, mints nothing, **does nothing else — no unproject, no status - write, no cleanup of any kind** — and returns **409 `{ code: 'install_lock_lost' }`** - to its own caller. A refusal means "someone else owns this row now"; a loser that - tidies up deletes the winner's work (Vera 62703 — D6's `markPosted` gate made exactly - that mistake). The same rule holds for every fenced write in step 4: on `null`, stop. The refusal is a distinct error class and a distinct status, never - the `null` a no-op would return (Vera 62655: D6's first cut returned null for both and - the caller reported success). + does the service activate. The parent and the projected row are two documents, so the + activation is a **split commit**, and the split is bridged by a recoverable state rather + than pretended away (Kai 62709, 2026-09-03: with `active` written before the mint, a crash + between the writes made every retry return 200 with no code). Three writes, order + load-bearing: + 1. **Parent CAS on the generation → `activating`:** `findOneAndUpdate({ _id, status: + 'installing', claimId: ours }, { $set: { status: 'activating', activatedByClaimId: + ours } })`. The `claimId` term is **required in the filter, never match-if-present** — + this is the write that commits to handing out a bearer secret. `null` ⇒ + `InstallLockLostError`: the owner logs at warn with both generations, mints nothing, + **does nothing else — no unproject, no status write, no cleanup of any kind** — and + returns **409 `{ code: 'install_lock_lost' }`** to its own caller. A refusal means + "someone else owns this row now"; a loser that tidies up deletes the winner's work + (Vera 62703 — D6's `markPosted` gate made exactly that mistake). The same rule holds + for every fenced write in step 4: on `null`, stop. The refusal is a distinct error + class and a distinct status, never the `null` a no-op would return (Vera 62655: D6's + first cut returned null for both and the caller reported success). `activating` is a + live state: it sits inside the unique index and inside the claim filter (lease takeover + applies), and **the idempotent-return path never treats it as success** — a retry or a + takeover that finds `activating` skips the projectors and resumes at write 2. 2. **Integration activation, fenced on its own state:** `findOneAndUpdate({ installationId: String(parent._id), isActive: false }, { $set: { isActive: true, 'config.connectCode': …, 'config.connectCodeExpiresAt': … } })` — `mintConnectCode()` is called exactly once, - inside this write's construction. `null` here means the row is already active (a retry - of a winner that crashed between writes 1 and 2 — the only way to get here holding the - generation that won write 1): return the existing code, mint nothing. - Until write 2 the Integration row exists with `isActive: false` and **no code**. This is what makes a partial install safe without - touching the enable path: `handleEnableCommand` looks up - `{ type, isActive: true, 'config.connectCode' }` on main and knows nothing about - installations — a 422'd install therefore has nothing it can find. A retry of the install - reuses the inactive row and activates it; the reconciler treats an inactive row under an - `error` parent as expected, not stale. (Vera, 2026-09-02: "mint last, or teach enable the - parent status" — mint last, so the route is not edited.) + inside this write's construction. `null` here means the row is already active (a + resume after a crash between writes 2 and 3): read the existing code, mint nothing. + 3. **Parent CAS → `active`:** `findOneAndUpdate({ _id, status: 'activating', claimId: + ours }, { $set: { status: 'active' } })`, fenced like every other owner write; `null` ⇒ + `InstallLockLostError`, do nothing (the row's code, if any, belongs to whoever holds + the generation now). + Until write 2 the Integration row exists with `isActive: false` and **no code**; until + write 3 the parent is `activating`, and **only `active` ever returns 200 with a code.** + This is what makes a partial install safe without touching the enable path: + `handleEnableCommand` looks up `{ type, isActive: true, 'config.connectCode' }` on main and + knows nothing about installations — a 422'd install therefore has nothing it can find. A + retry of the install reuses the inactive row and activates it; the reconciler treats an + inactive row under an `error` parent as expected, not stale. (Vera, 2026-09-02: "mint + last, or teach enable the parent status" — mint last, so the route is not edited.) 7. Response: `{ installation, integration }` — the page needs the Integration row (connect code) immediately, exactly as it gets it from `POST /api/integrations` today. @@ -279,7 +293,8 @@ the slash-command / external-webhook tracks; naming them here keeps the enum hon `active` installation, every component's `projectionIds` must resolve to a live row; a missing row marks the component `stale` (never re-creates silently — a stale connector must not mint a code nobody asked for). For every `uninstalled` installation, projections must be inactive. -For every **`installing`** installation whose `claimedAt` is older than `INSTALL_LOCK_TTL_MS`, +For every **`installing` or `activating`** installation whose `claimedAt` is older than +`INSTALL_LOCK_TTL_MS`, the sweep sets `status: 'error'` with `errorMessage: 'install lock expired'` — fenced on the `claimId` it read, so it cannot race a takeover that happened between its read and its write — and the row becomes @@ -394,9 +409,16 @@ Unit (`backend/__tests__/unit/services/installable/`): `Integration` model: B's writes only), the Integration row still carries `C_B` and stays `isActive: true`, and A's caller receives 409 `install_lock_lost` — asserted on the status and the code, not on a null. -6d. **Winner retry mints once.** B crashes between writes 1 and 2 and retries holding - generation `b`: write 2 finds `isActive: false` and mints; a second retry finds - `isActive: true`, returns the same code, `mintConnectCode` called once. +6d. **The split commit is recoverable, never reported as success.** (i) B crashes between + writes 1 and 2: the parent is `activating`, the Integration row inactive with no code. A + retry within the TTL gets **202**, not 200 — never 200-with-no-code. After the TTL the + next attempt takes over (new `claimId`), finds `activating`, runs no projector, performs + write 2 (mints once) and write 3, returns 200 with the code. (ii) B crashes between writes + 2 and 3: the code exists on an active Integration row under an `activating` parent; the + takeover's write 2 returns `null`, the existing code is read, write 3 completes, + `mintConnectCode` called exactly once across the whole test. (iii) The enable path cannot + redeem in (i) — no code exists — and in (ii) the code is the one the retry hands back, so + the user never holds a code the row does not. Service (`__tests__/service/`): 7. **Behaviour pins, at the dispatcher.** (a) An agent post into a pod with one live Telegram