Skip to content

docs(plans): the connector as an installable app — implementation spec (TASK-005, ruling A) - #1509

Merged
lilyshen0722 merged 17 commits into
mainfrom
docs/task-005-connector-as-installable-app
Sep 3, 2026
Merged

lilyshen0722 merged 17 commits into
mainfrom
docs/task-005-connector-as-installable-app

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

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:

  • The builtin telegram Installable manifest — kind: app, source: builtin, scope: user (ADR-025 D8), a webhook component on the mounted route and an event-handler component reusing NativeAgentTrigger's chat.message name (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).
  • The projection is the Integration row: Integration.installationId (indexed, never written) becomes the back-pointer. No new projection table.
  • A projector registry with two projectors built against the two shipped behaviours, and an event dispatcher that replaces the hardcoded require at agentMessageService.ts:1787 — with a behaviour pin (exactly one relay per post, same five fields).
  • Phasing: install substrate now with today's pod binding as the first gate row; D8's three schema consequences as Phase 2 with no API change.
  • The fix(telegram): close the connector create/enable holes ahead of ADR-025 (P0) #1297 security carry-over, Vera's ten acceptance items, sizes, and the not-decided list (slash commands, trigger unification, external webhooks, the 'skill' enum gap, the marketplace unlock).

Proof

Docs only. Facts cited against origin/main at 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

lilyshen0722 and others added 5 commits September 2, 2026 15:38
…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>
lilyshen0722 and others added 3 commits September 2, 2026 16:17
… 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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • :80Integration.findOne({ installationId }), an existence probe keyed on the Discord interaction.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-213DELETE /api/discord/uninstall/:installationId. The id comes from the URL path. findOne({ installationId }) carries no type: 'discord' filter, the gate is canManageIntegration (admin OR integration.createdBy === userId OR pod creator), and the body is Integration.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:

  1. It hard-deletes where §2's uninstall soft-transitions to uninstalled and keeps relayMap for audit. The parent InstallableInstallation is left active with projectionIds pointing at a row that no longer exists — a state §3's reconciler has no case for.
  2. 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>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Delta efb12a39c883c3e2 — @sprint-review's must-fix, applied. Docs-only, one inserted block in §2; nothing else moved (372 → 395 lines, 11 headers, tail intact).

Their finding verified at origin/main and widened by one reader:

  • discord.ts:80 handleInstallationEventIntegration.findOne({ installationId }). Inert, and worth saying why rather than leaving it as a second hit: its input is interaction.id, a Discord snowflake, while the back-pointer this spec writes is a 24-hex ObjectId string. Disjoint value spaces. That is the explanation I killed for :208, not a design guarantee.
  • discord.ts:208 DELETE /api/discord/uninstall/:installationId — same findOne({ installationId }), no type: 'discord' filter, gate canManageIntegration (pod creator / pod admin / createdBy), body DiscordIntegration.findOneAndDelete + Integration.findByIdAndDelete. A hard delete, and its id comes from the caller's URL — so disjointness does not protect it.

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 (isActive: false, connect code cleared, nothing deleted).

Repair recorded as a prerequisite, not a follow-up: nothing writes installationId until :208 carries the type term. The neighbour route POST /api/discord/register-commands/:integrationId already 400s on integration.type !== 'discord', so the fix is matching an in-file convention and is correct independently of this spec.

Not done here: the one-line discord.ts change is a code PR against a different path, and #1509 is docs-only.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

lilyshen0722 and others added 2 commits September 2, 2026 16:57
…, 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>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Delta c883c3e29c8c23fc — @sprint-review's term correction, applied. Docs-only, one bullet, in place; 395 → 407 lines, 11 headers, tail verified. This burns your re-gate at c883c3e2.

Your correction reproduces at origin/main, and it was wrong in two ways rather than one. canManageIntegration (discord.ts:67) is three branches in order — user.role === 'admin', then integration.createdBy === userId, then pod.createdBy === userId. The first is a role on the User row, scoped to neither this pod nor this integration, so it is broader than my gloss said and it is the branch that sets the blast radius on a cross-type hard delete. That is your point and it stands.

The second error is that "pod admin" names a thing the schema cannot express. Pod.members is a bare ObjectId[] (models/Pod.ts:143) with no role path beneath it, and the model's only role field is agentEnsemble.participants[].role, enum starter | responder | synthesizer | observer — a turn-taking value with no authority meaning. So there is no pod-admin concept to be wrongly scoped to; the pod-scoped half of this gate is createdBy alone.

That second half is the same finding this repo already has on a different surface: ActivityService.getPendingApprovals selects on members.userId + members.role: 'admin', and the branch matches nothing for every pod for exactly this reason. Two independent surfaces have now been written as if pod-scoped admin exists. Mine was prose and cost a gloss; that one is a live query whose admin branch is dead. Worth knowing before a third surface assumes it.

Unchanged and not re-litigated: the two readers, the disjointness argument for :80, the prospective-severity qualification, and the type: 'discord' prerequisite. The correction makes the prerequisite stronger, not weaker — the caller set that can reach the unguarded delete is larger than the bullet claimed.

lilyshen0722 and others added 4 commits September 2, 2026 20:16
…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>
…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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 120d8d64e56342e3
  • 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.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SR-GATE: APPROVED @ ad26efd5 — carry-forward. Rebase-only over bf59c2d1.

Base moved e56342e35edce2e1; 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.

lilyshen0722 added a commit that referenced this pull request Sep 3, 2026
…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>
@lilyshen0722
lilyshen0722 merged commit 6bffed7 into main Sep 3, 2026
14 checks passed
@lilyshen0722
lilyshen0722 deleted the docs/task-005-connector-as-installable-app branch September 3, 2026 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant