docs(plans): the connector as an installable app — implementation spec (TASK-005, ruling A) - #1509
Conversation
…c (TASK-005, ruling A) 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 <noreply@anthropic.com>
…e connect code (Vera) 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 <noreply@anthropic.com>
…era)
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 <noreply@anthropic.com>
… — two-tenant pin (Vera) 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 <noreply@anthropic.com>
…error, only the lock owner projects (Kai) 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 <noreply@anthropic.com>
…or-as-installable-app
… taken over and swept (Vera) 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 <noreply@anthropic.com>
…; grantedScopes is descriptive (Vera) 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 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
SR-GATE: APPROVED WITH ONE MUST-FIX @ efb12a3 — docs-only, all checks green. The must-fix is a line of spec, not code.
§2 "written by nothing" is right about writes and misses two readers. I grepped the whole backend: Integration.installationId has no write site, so that half holds. But it has two live readers in backend/routes/discord.ts, and one of them is destructive:
:80—Integration.findOne({ installationId }), an existence probe keyed on the Discordinteraction.id. Not reachable: a Discord snowflake is ~19 decimal digits, an ObjectId string is 24 hex, so it can never match a projected row. Recording it as a non-finding so the next sweep does not stop on it.:205-213—DELETE /api/discord/uninstall/:installationId. The id comes from the URL path.findOne({ installationId })carries notype: 'discord'filter, the gate iscanManageIntegration(admin ORintegration.createdBy === userIdOR pod creator), and the body isIntegration.findByIdAndDelete(integration._id)— a hard delete.
Once §2 sets installationId = String(installation._id) on Telegram rows, that route becomes a second door onto the projected row, and it is the exact shape Vera's uninstall fix ruled out: "the route takes no installation id and no body field, so there is no way to name someone else's row." Two consequences:
- It hard-deletes where §2's uninstall soft-transitions to
uninstalledand keepsrelayMapfor audit. The parentInstallableInstallationis leftactivewithprojectionIdspointing at a row that no longer exists — a state §3's reconciler has no case for. - Its gate is pod-creator/admin, not
targetId === req.user.id. A pod creator holding the id tears down a member's connector through a Discord-named route.
The fix is one term, and the file already contains the pattern: /register-commands/:integrationId twelve lines below does check integration.type !== 'discord' → 400. Add type: 'discord' to the findOne filter at :207 before anything populates the field, and add it to §7 as carry-over so it ships with the projector rather than after it.
Everything else I checked holds: findLiveIntegration(podId) is real (telegramBridgeService.ts:102), handleEnableCommand is routes/webhooks/telegram.ts:65 and matches the lookup §2 describes, NativeAgentTrigger is config/native-agents/types.ts:30, and Integration.installationId is indeed unique, sparse — declared twice, inline at models/Integration.ts:108 and again at :210.
…guarded cross-type hard delete
`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 <noreply@anthropic.com>
|
Delta Their finding verified at
The severity is entirely prospective and this spec is what creates it: with zero writers today both readers match nothing and the DELETE always 404s. The first write arms both at once. And the route it arms bypasses this spec's own uninstall, which is soft by design ( Repair recorded as a prerequisite, not a follow-up: nothing writes Not done here: the one-line |
lilyshen0722
left a comment
There was a problem hiding this comment.
SR-GATE: APPROVED @ c883c3e — must-fix cleared. Re-gate after the push from efb12a39.
The delta is the new §2 prerequisite block and nothing else. It records both readers accurately, and it is stronger than what I filed: it generalises the exposure past Telegram to any connector that carries an installationId (Slack included), and it keeps :80 on the page as an inert reader with the reason stated, which is the right call — the disjointness argument is the thing a future editor needs to be able to check, not omit.
One term to correct, and it is the blast radius rather than the mechanism. The block glosses canManageIntegration as "pod creator, pod admin, or createdBy". Reading it at discord.ts:67-74, the first term is not a pod admin:
if (user.role === admin) return true;
if (integration.createdBy?.toString() === userId) return true;
const pod = await Pod.findById(integration.podId) ...
return pod?.createdBy?.toString() === userId;
user.role === 'admin' is the instance-wide role on the User document — it is not scoped to the integration's pod at all. So the gate is instance admin, integration creator, or pod creator, and the first is strictly broader than "pod admin" suggests. Worth fixing because the sentence is doing severity work: an instance admin can reach every connector on the instance through that route, not just the ones in pods they administer.
The merge-order note from my #1512 gate is unchanged: #1512 links this file relatively, this PR carries no reciprocal link, so landing this one first is the order that never leaves a dangling link on main.
…, not a pod role @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 <noreply@anthropic.com>
…ced on claimId, the mint write requires it, refusal is distinguishable (Kai, Vera) 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 <noreply@anthropic.com>
|
Delta Your correction reproduces at The second error is that "pod admin" names a thing the schema cannot express. That second half is the same finding this repo already has on a different surface: Unchanged and not re-litigated: the two readers, the disjointness argument for |
…llable-app' into docs/task-005-connector-as-installable-app
…leanup (Vera) 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 <noreply@anthropic.com>
…or-as-installable-app
…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 <noreply@anthropic.com>
lilyshen0722
left a comment
There was a problem hiding this comment.
SR-GATE: APPROVED @ cdfed0e — re-gate after the fencing rework. My prior gate was at c883c3e2.
The spec changed substantially since then (lease-only mutual exclusion → lease plus a claimId generation, every owner write fenced on it, and the activation split into three CAS writes), so this is a fresh read rather than a carry-forward.
My canManageIntegration correction is applied, and the widening past what I wrote checks out. The new bullet says "pod admin" is not a thing the Pod model can express. Verified: models/Pod.ts:143 is members: [{ type: Schema.Types.ObjectId, ref: 'User' }], a bare id array with no role path beneath it, and the only role in the whole file is :119 under agentEnsemble.participants[] with enum ['starter','responder','synthesizer','observer'] — a turn-taking value, no authority. So the gate's pod-scoped half really is createdBy alone.
The ADR-026 D6 analogy is precise, not decorative. D6 reads "event fetch/ack carries a delivery nonce (supervisor generation), invalidated on requeue," and the failure it was written for — an old child acking the new child's delivery after a requeue — is structurally the same as a revived stale owner minting over the winner's code. "One layer up" is the right relation.
A gap I found at an intermediate head is already closed at this one, so I am recording it as resolved rather than filing it. At d54d1dc8 the parent went installing → active on write 1 and the Integration was activated on write 2, with no intermediate state. That left a crash between the two writes as a permanently wedged install the reconciler could not see: parent active, Integration row present but isActive: false with no code, and the sweep for active rows only checks that projectionIds resolve to a live row — which they do. This head replaces that with a three-write sequence through activating, sweeps installing | activating on lease expiry, resumes a takeover at write 2, and states the invariant directly at :165 — "until write 3 the parent is activating, and only active ever returns 200 with a code." Parent-active now implies the code exists. Cases 6b/6c cover both crash windows.
Merge state is BLOCKED on being behind 120d8d646; under strict: true it needs an update before it can go in.
…or-as-installable-app
lilyshen0722
left a comment
There was a problem hiding this comment.
SR-GATE: APPROVED @ bf59c2d1 — carry-forward. Rebase-only over cdfed0ec.
Instrument: base-relative diff at each head (merge-base vs origin/main), hashing only the +/- lines — hunk headers and context dropped, because a merged PR touching the same file moves context and makes both the byte-compare and the whole-patch hash report a false change.
- base moved
120d8d64→e56342e3 - 1 file both sides,
+/-signature identical
Nothing re-reviewed because nothing changed. My cdfed0ec gate stands. Still BLOCKED only on Service Tests (Tier 1 — real DBs) pending; every other check passes.
#1512 must land after this one or main carries a dangling relative link.
…or-as-installable-app
lilyshen0722
left a comment
There was a problem hiding this comment.
SR-GATE: APPROVED @ ad26efd5 — carry-forward. Rebase-only over bf59c2d1.
Base moved e56342e3 → 5edce2e1; 1 file both sides, +/- signature identical (base-relative diff at each head, hunk headers and context dropped). My bf59c2d1 gate stands unchanged.
Ordering unchanged: land this before #1512.
…page is its install surface (ruled) (#1512) * docs(adr-025): D17 — the connector is an Installable, the Connectors page is its install surface (ruled) 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 <noreply@anthropic.com> * docs(adr-025): D17 invariant 2 — the install lock carries a lease (Vera) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(adr-025): D17 invariant 2 — the lock carries a generation; owner writes are fenced (Kai, Vera) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(adr-025): D17 invariant 2 — the activation split commit is bridged by an activating state (Kai) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Why
Sam ruled TASK-005's decision card on 2026-09-02 (pod message 62584): A — one install verb, two doors; Connectors keeps the page. The ruling needs an implementation plan Kai can build from and Vera can verify against, and an ADR home. This is the plan; ADR-025 gains D17 pointing at it once #1295 (the D8–D16 fold) lands, so the two docs PRs do not conflict.
What
docs/plans/connector-as-installable-app.md:telegramInstallable manifest —kind: app,source: builtin,scope: user(ADR-025 D8), awebhookcomponent on the mounted route and anevent-handlercomponent reusingNativeAgentTrigger'schat.messagename (no third vocabulary), no credential in the row (D6).POST/DELETE /api/installables/:id/install— the install verb "Add a channel" calls; one active installation per user; partial-failure rows kept and visible (COMMONLY_SCOPE §5).Integration.installationId(indexed, never written) becomes the back-pointer. No new projection table.requireatagentMessageService.ts:1787— with a behaviour pin (exactly one relay per post, same five fields).'skill'enum gap, the marketplace unlock).Proof
Docs only. Facts cited against
origin/mainat 670cef3:routes/registry/install.ts:108,models/InstallableInstallation.ts,models/Installable.ts:137–197,models/Integration.ts:108,agentMessageService.ts:1783–1797,V2ConnectorsPage.tsx:131,seed-native-agents.ts:265.🤖 Generated with Claude Code