Skip to content

docs(connectors): D8 Phase 2 — the gate surface and the capability catalog (TASK-009) - #1545

Merged
lilyshen0722 merged 13 commits into
mainfrom
docs/d8-phase2-gate-surface
Sep 5, 2026
Merged

lilyshen0722 merged 13 commits into
mainfrom
docs/d8-phase2-gate-surface

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Design note for TASK-009, the piece #1542 §4 named as missing from the kernel and Vera asked for at 63278.

docs/plans/d8-phase-2-gate-surface.md:

  • §1 three facts the page cannot see: parent state (no GET on installables — the invisible-error class is still open at the surface), which providers the instance offers (ADD_PLATFORMS is page-side), one pod per connector
  • §2 D1 GET /api/installables: catalog + available from a per-manifest readiness check (reason enum only, never an env name) + the caller's parent row with claimedAt + the public integration; install refuses an unconfigured provider with 422
  • §2 D2 parent states as rows — every action is a verb the service already honours: Retry reclaims from error in place, Remove uninstalls from error, Cancel appears exactly when the lock is stale (no new verb), projection missing from the reconciler
  • §2 D3 the D8 schema flip: scope: 'user', optional podId, config.gates[podId], outbound inverted pod → members → gated connectors, membership as the outer gate, membership-gated PATCH writes, one-shot migration
  • §2 D4 gates in the aside; D5 the not-yet row stays page copy
  • §3 four PRs (A catalog, B page rows — folds into the Signal restyle if still open, C schema, D gates in the aside); §4 ten acceptance seeds incl. the stranger smoke; §5 not decided

Docs only.

🤖 Generated with Claude Code

lilyshen0722 and others added 6 commits September 4, 2026 13:37
…talog (TASK-009)

A read the page lacks (GET /api/installables with per-provider readiness and
the caller's parent row), parent states as rows whose actions are the verbs
the service already honours (Retry reclaims from error, Remove from error,
Cancel exactly when the lock is stale), and the D8 schema flip: user scope,
optional podId, config.gates per pod, outbound inverted pod → members →
gated connectors, membership as the outer gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the pod-creator branch of canDeleteIntegration does not carry over

Vera 63551: a pod creator could flip gates[theirPod] on another member's
private DM through the existing PATCH authorisation. Seed 7b pins it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tes need one admin verb, and it is a stop with a reason

Vera 63565: owner-only PATCH left an admin with the database or disabling the
provider. Pause/resume writes the paused status the enum already carries, never
touches the owner's config, and audits both ways. Seed 10 pins it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…too — the webhooks never join to the parent

Vera 63571: the dispatcher reads the parent, the two inbound resolvers read
the Integration. One flag, config.adminPause, stamped by the same write and
excluded by both resolvers; distinct from the owner's relayMutedUntil.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a crash fails toward paused

Vera 63602: parent and children are two collections. Pause writes parent
first, resume writes children first; the sweep repairs toward the parent;
the admin route answers 202 projected:false when the second write fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Gate at e592d69a, verified against main a5f57679.

Ordering — accepted. Parent-first on pause is right, and my earlier suggestion (children first) was wrong on the mechanism: with a parent-authoritative reconciler, a children-first crash is reverted by the sweep, not repaired. Resume children-first is the correct mirror.

One wording point: "always fails toward paused" overstates it. Parent-first stops outbound immediately (the dispatcher’s $lookup requires parent.status === "active", eventHandlers.ts:98) but leaves inbound authoring into pods for up to one sweep, since no resolver joins the parent. "Fails toward outbound stopped" is accurate and still the right choice.

Blocker — §2 D3, paused has no lifecycle handling.

paused is absent from the unique partial index (backend/models/InstallableInstallation.ts:236):

partialFilterExpression: { status: { $in: ["installing","activating","uninstalling","active","error"] } }

Consequences on main today:

  1. A paused parent falls outside the index. claimInstallation’s $or does not match paused, so the upsert: true finds nothing and inserts a second live rowinstalling → active, a fresh projection with no config.adminPause, and no audit entry. A plain POST /api/installables/:id/install lifts the admin’s pause. The state table hiding Connect on that row is a UI affordance, not a gate.
  2. claimUninstall’s terminal filter is { _id, status: { $in: ["active","error"] } }, so DELETE on a paused row claims nothing and returns the winner unchanged — Remove is a silent no-op.
  3. liveClaimStatuses and statusForClaim likewise have no paused case, so the duplicate-key loser path cannot describe a paused row.

The index was written when nothing wrote paused; D3 introduces the first writer, which makes the omission load-bearing. Minimum for the note to hold its own promise ("the owner … cannot resume it"): add paused to the partial filter, to liveClaimStatuses, and to statusForClaim, and state explicitly what an owner’s install and uninstall answer on a paused row.

Sweep repair is two new branches, not existing behaviour. installableReconciler.sweep() iterates installing/activating, active, uninstalled, uninstalling — never paused. The parent-authoritative repair needs a new paused loop (stamp missing child flags) plus a clear-branch inside the existing active loop. The 5-minute cadence itself checks out (schedulerService.ts:459, */5 * * * *).

§4 seed 10 covers the crash windows well. It needs one more case: an owner pressing Connect (and Remove) on a paused row must not produce a second live parent or a silent success.

lilyshen0722 and others added 3 commits September 4, 2026 16:07
…owner verbs refuse, sweep gains its branches

Vera's #1545 review: with D3 as the first writer of paused, the partial
unique index and claim filters that omit it become load-bearing — an owner's
install would upsert a second live parent and lift the pause; uninstall would
no-op. Both now answer 409 installation_paused. Wording corrected to
"fails toward outbound stopped"; seed 10 gains the owner-verb case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Re-gate at d30874ab. The security half of the D3 blocker is closed: paused in the partial unique index does stop an owner's POST …/install from upserting a second live parent, which was the one-click pause lift. The ordering wording is corrected and the two sweep branches are named as new work in PR C — both good.

Still open: two call sites, or neither promised 409 fires. The three sites the note names (partial index, liveClaimStatuses, statusForClaim) make the claim refuse. Nothing yet turns that refusal into a status, so the owner-visible behaviour is unchanged.

  1. resultForExisting — with paused in the index, install now takes the duplicate-key loser path, claimInstallation returns ownsClaim: false, and install calls resultForExisting(claim.installation, targetPodId). That function branches on active and otherwise falls through to the transient tail:

    • same podId as boundPodId{ httpStatus: 202, state: 'installing' }. Per §2 D2 the page draws that as cobalt-pulsing Setting up… — on a row that will never resolve, and after 60s it grows a Cancel button.
    • different podIdInstallInProgressError(boundPodId)409 install_in_progress, which instructs the owner to wait for an install that is not running. Wrong code, wrong instruction.
  2. uninstallclaimUninstall's terminal filter stays status ∈ {active, error}, so a paused row is not claimed, and the caller does if (!claim.ownsClaim) return claim.installation — returning the unchanged paused parent as a 200. This is the silent no-op from the first review, still present: paused joining the index does not reach it, because it sits on the return path rather than the claim filter.

Both need an explicit installation_paused refusal at those two points. As written the fix is correct underneath and invisible at the API, which is the shape seed 10's new owner-verb case is meant to catch — worth asserting the response code and body there, not only that the parent count stays one.

Nothing else new since e592d69a; the rest of the delta on this branch is the #1542 design-diff doc and the merged Connectors page.

… a 409

Vera 63733: the index and claim statuses make the claim refuse; a typed
InstallationPausedError thrown from the install loser path and the uninstall
winner path, mapped in sendInstallError, is what makes the 409 real.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Gate clear at 7da943cc. The delta since d30874ab is the one paragraph, and it closes both call sites.

Verified the three named sites are the complete set:

  • claimInstallation's loser path is the only route to a paused winner. The findOneAndUpdate filter's $or never matches paused, so the upsert attempt hits the (now-covering) partial index and raises 11000; the catch re-reads the winner by liveClaimStatuses; and the retry branch admits only error and stale transients, so paused falls through to the ownsClaim: false return. Throwing there fires before resultForExisting, which is why that function needs no branch of its own.
  • claimUninstall's winner path likewise. Its terminal filter is status ∈ {active, error}, so a paused row is never claimed and always reaches the winner return beside the existing InstallInProgressError throw.
  • One mapping covers both verbs. POST /:installableId/install and DELETE /:installableId/install both send their catch through sendInstallError (backend/routes/installables.ts:527 and :550), so adding InstallationPausedError there yields both 409s.

Seed 10 already asserts the response code and the reason, not just the parent count, which was the other half of my last comment.

No further findings. The note is buildable as written.

lilyshen0722 and others added 3 commits September 4, 2026 18:06
…d uninstall's return path — the exact sites

Vera 63734: a paused row falls to the transient tail (202 installing that
never resolves, or InstallInProgressError) and uninstall returns the paused
row as 200. Both throw InstallationPausedError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lilyshen0722
lilyshen0722 merged commit c6dde6b into main Sep 5, 2026
13 checks passed
@lilyshen0722
lilyshen0722 deleted the docs/d8-phase2-gate-surface branch September 5, 2026 04:30
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