From 2e1aaa48c95fb672dd76abfa73525cc832320f02 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:03:20 -0700 Subject: [PATCH 1/4] =?UTF-8?q?docs(d8-phase-2):=20inbound=20never=20reads?= =?UTF-8?q?=20the=20gate=20=E2=80=94=20the=20active=20pod=20is=20its=20own?= =?UTF-8?q?=20switch,=20leaving=20it=20is=20heard=20in=20the=20chat,=20and?= =?UTF-8?q?=20the=20page=20writes=20podId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vera's #1550 review (63797): isRelayableIntegration decided inbound by the gate, so a pruned gate silently dropped every message the owner typed while the page still read "linked to A". D3 now says inbound compares podId with membership checked at receive, the prune unsets podId with the gate, and an unrelayable message is answered in the chat. /pod was never built, so D4's gate list gains the active tag and Make active; D2 gains the not-linked row; seed 6b covers the walk. Co-Authored-By: Claude Fable 5.1 --- docs/plans/d8-phase-2-gate-surface.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/plans/d8-phase-2-gate-surface.md b/docs/plans/d8-phase-2-gate-surface.md index 46b66e146b..b56a8f4b66 100644 --- a/docs/plans/d8-phase-2-gate-surface.md +++ b/docs/plans/d8-phase-2-gate-surface.md @@ -61,6 +61,7 @@ | `installing` / `activating`, `claimedAt` older than 60 s | any | cobalt pulsing | Setup is taking longer than it should. | `the server let go of it` | `started {rel}` | **Cancel** (bordered) → `DELETE /api/installables/:id/install`. The service already claims a stale transient into `uninstalling`; a live one answers 409 `install_in_progress`, which the page shows as the row going back to *Setting up…*. **No new verb**: Cancel appears exactly when the server would honour it | | `error` | any | hollow `#98a2b3` | the parent's `errorMessage` as an ink sentence, else "Setup didn't finish." | `retry, or remove it` | `since {rel}` | **Retry** (ink) → `POST …/install { podId: boundPodId }`. The service's claim filter already admits `status: 'error'`; the parent is reclaimed in place, no second row. **Remove** is the bordered secondary in the aside (§2 D4) → `DELETE`, which `claimUninstall` accepts from `error` | | `active`, Integration present | per [#1542 §2](connectors-page-signal-diff.md) | as there | as there | as there | as there | Manage / Show code / New code / Authorize in Slack / Confirm — unchanged | +| `active`, Integration present, `podId` unset (the owner left the active pod; D3 prune) | live | `#e4e7ec` solid (idle) | **{chat title}** {kind} · not linked to a pod | `your messages have nowhere to go` | `added {rel}` | **Pick a pod** (ink) → selects the row and opens the aside's gate list (D4) | | `active`, Integration missing or `isActive: false`, or any `components[].status: 'stale'` | — | hollow | The channel record is gone. | `retry rebuilds it` | `since {rel}` | **Retry** (ink) — same POST; `installAttempt` re-projects. The reconciler's stale-marking (`sweepActive`) is what makes this row honest; it should also set `status: 'error'`, `errorMessage: 'projection missing'` so the parent and the page agree (one-line change in `installableReconciler.ts`) | | `uninstalling`, within TTL | any | cobalt pulsing | Removing… | `waiting for the server` | `started {rel}` | none | | `uninstalling`, past TTL | any | cobalt pulsing | Removal is taking longer than it should. | `the server let go of it` | `started {rel}` | **Retry remove** (bordered) → `DELETE` again; `claimUninstall` re-claims a stale `uninstalling` | @@ -81,16 +82,16 @@ config.gates?: Record` keeps writing it. +- **`podId` stays as the "active pod"** (ADR-025 D12) for bare-message routing; it is no longer what outbound reads. **Inbound never reads the gate** (Vera, 63797): the gate is *pod → me*, the active pod is *me → pod*, two facts and two switches, so a gate switched off does not stop the owner typing into their active pod, and the inbound predicate compares `podId`, not `gates`. Inbound's outer guard is the same one outbound has — membership, checked at receive the way outbound checks it at send. Nothing in the webhook writes `podId` today (there is no `/pod` command; ADR-025 D12 named one that was never built), so the page writes it: D4's *Make active* → `PATCH { podId }` on the row, owner-only, member-of-pod required. **Silence is the bug, not the drop.** A bare message that cannot be relayed — `podId` unset, or the owner no longer a member of it — is answered in the chat, once per message: *This chat isn't linked to a pod any more. Pick one on the Connectors page.* (Telegram `sendMessage`, Slack `chat.postMessage`). Dropping with only a server log is REVIEW rule 19 failing silently: the page still reads *linked to A* while every message vanishes. - **Migration** (`scripts/migrate-connector-gates.ts`, one-shot, idempotent): for every installable-backed row with a `podId` and no `gates`, write `gates[podId] = { enabled: true, since: createdAt }` and `scope: 'user'`. Legacy pod-scoped rows (no `installationId`) are untouched and keep `scope: 'pod'`. -- **Membership is the outer gate.** A gate for a pod the user is no longer a member of is dead weight, not a leak: outbound starts from pod members, so a stale gate never relays. The reconciler prunes gates whose pod no longer lists the user, every 5 minutes, so the aside does not show pods the user left. +- **Membership is the outer gate.** A gate for a pod the user is no longer a member of is dead weight, not a leak: outbound starts from pod members, so a stale gate never relays. The reconciler prunes gates whose pod no longer lists the user, every 5 minutes, so the aside does not show pods the user left — and when the pruned gate's pod is also the row's `podId`, the same write `$unset`s `podId`: an active pod the owner has left is not a destination, and a null `podId` is what the page and the chat reply key on. Until the sweep runs, the receive-time membership check covers the gap. - **The admin lever is pause, not edit.** Owner-only writes leave an instance admin with no route to a user's connector: PATCH is the owner's, DELETE was already identity-derived, and the only levers left would be the database or disabling the provider for everyone (Vera, 63565). That is the wrong pair of extremes, so the admin gets exactly one verb and it is not an edit: `POST /api/admin/installables/:installableId/installations/:installationId/pause` and `…/resume`, `adminAuth`, body `{ reason }` required. Pause sets the parent's `status: 'paused'` (the enum member that had no writer) with `errorMessage: reason`, **and projects it**: the same write stamps `config.adminPause: { reason, at, adminId }` on every child Integration row, the way every parent change reaches its projections. Two readers, one flag. Outbound: the dispatcher already skips parents that are not `active`. Inbound: neither webhook path joins to the parent — Slack resolves the row by `{ type, isActive, teamId, chatId, chatType, liveRelay }`, Telegram by chat, zero references to the parent in either route or bridge (Vera, 63571) — so both resolvers and `isRelayableIntegration` add `'config.adminPause': { $exists: false }`. An inbound event during a pause is acknowledged to the provider (Slack still needs its 200 inside three seconds) and dropped: no pod post, one log line naming the integration. Parent and children are two collections, so pause and resume are each a split commit, and the order is chosen so a crash between the two writes **fails toward outbound stopped** (Vera, 63602 and the #1545 review): parent-first stops outbound at once because the dispatcher requires `parent.status === 'active'`, while inbound can run for up to one sweep, since no resolver joins the parent. Pause writes the **parent first** (the record of why), then the children: a crash in between leaves outbound already stopped and inbound running for at most one sweep. Resume writes the **children first** (clear the flag), then the parent: a crash in between leaves a paused parent with unflagged children, which the sweep re-stamps, so the admin sees the row still paused and resumes again. The reconciler repairs drift toward the parent every five minutes, and that is **two new branches, not existing behaviour**: `sweep()` today iterates `installing/activating`, `active`, `uninstalled` and `uninstalling` and never sees `paused`, so PR C adds a `paused` loop that stamps missing child flags and a clear-branch inside the `active` loop for stray ones. **`paused` joins the live set, or the pause is decorative.** Today nothing writes `paused`, so the parent's partial unique index (`InstallableInstallation.ts`, `status ∈ {installing, activating, uninstalling, active, error}`), `liveClaimStatuses` and `statusForClaim` all omit it. With D3 as the first writer that omission is load-bearing (Vera, #1545 review): an owner's `POST …/install` on a paused parent would find nothing to claim and **upsert a second live parent** — a fresh projection with no `adminPause` and no audit entry, lifting the pause with one click — and `DELETE` would match neither `active` nor `error` and return a silent no-op. So `paused` is added to the partial filter, to `liveClaimStatuses` and to `statusForClaim` — that makes the claim **refuse** — and three more call sites turn the refusal into a status, or neither 409 happens (Vera, 63733): a typed `InstallationPausedError` carrying the parent's reason; `resultForExisting` — where an `ownsClaim: false` install lands and today falls to the transient tail, so a paused row would answer `202 { state: 'installing' }` for the same pod (a *Setting up…* that never resolves) or `InstallInProgressError` for another — throws it first when the existing row is `paused`; `uninstall`'s return path (`if (!claim.ownsClaim) return claim.installation`, which today hands the unchanged paused row back as a 200 — the silent no-op) throws it likewise; and `sendInstallError` in the installables route maps it to `409 { code: 'installation_paused', reason }` beside `install_in_progress` and `already_installed`. The index makes the claim refuse; these three make the refusal visible at the API. Net: `install` on a paused row → that 409, no claim, no upsert, the parent count stays one; `uninstall` → the same 409, because a removal followed by a reinstall is the same escape. The way out of a pause is the admin's resume; after it the owner's verbs work as before. The page's paused row shows no Connect and no Remove for the same reason the server refuses them. The admin route awaits both writes and answers `202 { projected: false }` when the second failed, so the caller is never told a pause is complete when it is not. `adminPause` joins the server-owned field list the owner's PATCH refuses, and it is distinct from `relayMutedUntil` on purpose: that one is the owner's `/mute`, time-bound, and `/unmute` must never lift an admin's pause. No change to the row's config otherwise — the owner's `liveRelay`, mode and gates are untouched and come back on resume. The owner sees the paused row (§2 D2) and cannot resume it; the admin cannot read or change what the owner configured. Every pause and resume is an audit entry `{ adminId, installationId, ownerId, reason, at }` on the parent row, and the reason reaches the owner's row verbatim. An admin never authors into, deletes, or reconfigures a private DM; they can stop it and say why. - **Install verb API is unchanged.** `POST …/install { podId }` writes the first gate and the active pod. The page's connect form keeps its pod picker. - **Gate writes are owner-only.** `PATCH /api/integrations/:id { config: { gates: { [podId]: { enabled } } } }` goes through the existing PATCH, but **not** through its existing authorisation. That route gates on `canDeleteIntegration`, which admits an instance admin, the **pod creator**, and the row's creator — written for pod-scoped rows, where the pod owner legitimately administers the connector. A user-scoped row inverts the subject: it is a person's private DM, and a pod creator who can write `gates[theirPod].enabled = true` on another member's row switches their pod into that member's phone, authored as that member, without the member acting (Vera, 63551). So: a row with `scope: 'user'` is written **only by its owner** (`createdBy`, equal to `linkedUserId`) — every field of it, not just `gates`, because `liveRelay` and mode on a private DM are the same inversion. The admin and pod-creator branches apply to pod-scoped rows only. On top of that, a gate key must name a pod the owner is a member of (`isPodMember`), all keys in one body or none. The PATCH already refuses `linkedUserId`; that stays. -**D4 — The aside gains the gates.** Per #1542 §3 the aside is the selected channel. Its *What the channel sees* card gains one list under the mode controls: one row per pod the user is in (`GET /api/pods` filtered as the pod picker already is — never community/showcase), a 4px ink square mark, the pod name in body text, and a switch. Enabled pods carry mono `since {rel}`; disabled carry muted `off`. Mode/lead overrides stay collapsed behind the row's name (click → the attention/mirror segment and a lead picker for that pod) so the default view is one switch per pod. **Remove** (the bordered secondary that replaces *Disconnect*) sits at the foot of the card with the two-click confirm. +**D4 — The aside gains the gates.** Per #1542 §3 the aside is the selected channel. Its *What the channel sees* card gains one list under the mode controls: one row per pod the user is in (`GET /api/pods` filtered as the pod picker already is — never community/showcase), a 4px ink square mark, the pod name in body text, and a switch. Enabled pods carry mono `since {rel}`; disabled carry muted `off`. The active pod (the row's `podId`, where the owner's typed messages land) carries a mono `active` tag after its name; every other pod's collapsed section has a bordered **Make active** (`PATCH { podId }`, owner-only, D3). When `podId` is unset, the list opens expanded with *Pick where your messages go* above it and no `active` tag — this is the only way back once the owner has left their active pod, since the chat has no `/pod` command. Mode/lead overrides stay collapsed behind the row's name (click → the attention/mirror segment and a lead picker for that pod) so the default view is one switch per pod. **Remove** (the bordered secondary that replaces *Disconnect*) sits at the foot of the card with the two-click confirm. The page renders the gate list **only when the row carries `config.gates`**; a Phase-1 row without it shows today's single "linked to {pod}" line. Feature-detect on data, never on a version. @@ -102,8 +103,8 @@ The page renders the gate list **only when the row carries `config.gates`**; a P |---|---|---|---| | A | catalog route + `readiness()` per manifest + install refusal `provider_not_configured` + reconciler `projection missing` | S | — | | B | page: rows keyed by catalog, D2 state table, Retry / Remove / Cancel wired, poll on the catalog | M | A. **Folds into Kai's Signal restyle (TASK-007)** if that PR is still open — it re-keys the rows anyway; two PRs that both rewrite the row grid is one too many | -| C | D8 schema: `scope`, optional `podId`, `config.gates`, outbound inversion, PATCH gate writes, migration script, reconciler prune; the admin pause/resume verbs with `paused` in the partial index, `liveClaimStatuses` and `statusForClaim`, the two owner-verb refusals, and the two sweep branches | M–L | — (backend only; the page ignores `gates` until D) | -| D | page: gate list in the aside, per-pod overrides, Remove at the foot | S | B, C | +| C | D8 schema: `scope`, optional `podId`, `config.gates`, outbound inversion, inbound on `podId` + membership at receive with the chat reply, PATCH gate and `podId` writes, migration script, reconciler prune (gates and the active pod); the admin pause/resume verbs with `paused` in the partial index, `liveClaimStatuses` and `statusForClaim`, the two owner-verb refusals, and the two sweep branches | M–L | — (backend only; the page ignores `gates` until D) | +| D | page: gate list in the aside, `active` tag + Make active, the *not linked* row, per-pod overrides, Remove at the foot | S | B, C | A and C are independent and can run in parallel. B before C is fine: the page shows parents honestly before gates exist. @@ -115,6 +116,7 @@ A and C are independent and can run in parallel. B before C is fine: the page sh 4. **Remove from error.** Parent `error` → `DELETE` → `uninstalled`; the catalog entry returns to `installation: null`; the orphan-secret sweep revokes any `ConnectorSecret` the failed attempt left. 5. **Projection missing.** Delete a live Integration under an `active` parent → the 5-minute sweep marks the parent `error` `projection missing`; catalog shows the row; Retry re-projects with the same `installationId`. 6. **Gates route outbound.** User in pods A and B, `gates[A].enabled: true`, `gates[B]` absent → an escalation in B reaches the channel **zero** times, in A once. Flip B on → once. User leaves A → the prune drops `gates[A]` within one sweep and outbound from A stops immediately (membership is checked at send). +6b. **Leaving the active pod is heard, not swallowed.** User in pods A and B, `podId: A`, `gates[A]` and `gates[B]` enabled; user leaves A. A bare message typed before the sweep → no pod post (membership fails at receive) and one reply in the chat naming the Connectors page; the sweep unsets `gates[A]` **and** `podId` within five minutes; a bare message after the sweep gets the same reply, still no post; the page shows the *not linked to a pod* row with **Pick a pod**; `PATCH { podId: B }` from the owner → the next bare message posts in B; the same PATCH from the pod-B creator → 403; `PATCH { podId: C }` for a pod the owner is not in → 403. Switching `gates[B]` off while `podId: B` changes nothing inbound: the owner's next message still posts in B. 7. **Gate write is membership-gated.** `PATCH` with a `gates` key for a pod the caller is not a member of → 403, no write; other keys in the same body are also refused (all-or-nothing). 7b. **Gate write is owner-only.** The creator of pod P, who is not the owner of member M's user-scoped row, PATCHes `gates[P].enabled = true` (and separately `liveRelay: true`) on M's row → 403, no write, no relay into M's DM; an instance admin gets the same 403 on a user-scoped row; the same admin PATCHing a legacy pod-scoped row keeps today's answer. 8. **Migration idempotent.** Run twice on a mixed set (installable rows with `podId`, legacy rows without `installationId`) → first run writes `gates` + `scope: 'user'` on the installable rows only; second run writes nothing. From c67ff1357186cc22002cef57886f471aab70cbae Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:04:34 -0700 Subject: [PATCH 2/4] docs(d8-phase-2): the no-active-pod reply reuses the command string #1550 already sends (Vera) Co-Authored-By: Claude Fable 5.1 --- docs/plans/d8-phase-2-gate-surface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/d8-phase-2-gate-surface.md b/docs/plans/d8-phase-2-gate-surface.md index b56a8f4b66..5e4cb338f8 100644 --- a/docs/plans/d8-phase-2-gate-surface.md +++ b/docs/plans/d8-phase-2-gate-surface.md @@ -82,7 +82,7 @@ config.gates?: Record Date: Fri, 4 Sep 2026 22:06:09 -0700 Subject: [PATCH 3/4] =?UTF-8?q?docs(d8-phase-2):=20the=20gate=20list's=20o?= =?UTF-8?q?nly=20rule=20is=20membership,=20and=20it=20is=20the=20server's?= =?UTF-8?q?=20=E2=80=94=20the=20picker's=20community/showcase=20filter=20m?= =?UTF-8?q?atches=20no=20pod=20type=20(Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/plans/d8-phase-2-gate-surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/d8-phase-2-gate-surface.md b/docs/plans/d8-phase-2-gate-surface.md index 5e4cb338f8..36bbb66ef0 100644 --- a/docs/plans/d8-phase-2-gate-surface.md +++ b/docs/plans/d8-phase-2-gate-surface.md @@ -91,7 +91,7 @@ config.gates?: Record Date: Sat, 5 Sep 2026 01:48:13 -0700 Subject: [PATCH 4/4] =?UTF-8?q?docs(d8-phase-2):=20publicIntegration=20pro?= =?UTF-8?q?jects=20adminPause=20to=20{=20reason,=20at=20}=20=E2=80=94=20th?= =?UTF-8?q?e=20moderated=20never=20learns=20who=20(Vera)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/plans/d8-phase-2-gate-surface.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/d8-phase-2-gate-surface.md b/docs/plans/d8-phase-2-gate-surface.md index 36bbb66ef0..a7fee9e264 100644 --- a/docs/plans/d8-phase-2-gate-surface.md +++ b/docs/plans/d8-phase-2-gate-surface.md @@ -46,7 +46,7 @@ ``` - `available` is a **provider readiness check** that lives beside the manifest in `backend/integrations/manifests.ts` (`readiness(): { available: boolean; reason?: 'not_configured' }`), not in the route. Telegram: `TELEGRAM_BOT_TOKEN` present and either `TELEGRAM_SECRET_TOKEN` present or `TELEGRAM_WEBHOOK_ALLOW_UNVERIFIED=true`. Slack: `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET`, `CONNECTOR_SECRET_KEYS`, `CONNECTOR_SECRET_ACTIVE_KEY` all present. The response carries the **reason enum only** — never an env var name. Operators read the missing names from the Helm values and `docs/slack/README.md`, not from an API a stranger can call. -- `installation` is the caller's live parent row (`status ∉ {uninstalled}`) with `claimedAt` exposed, because the page's *Cancel* is decided by it (§2 D2). `integration` is the projected row through `publicIntegration` (strips `botTokenRef`, `oauthStateNonce`), or `null`. +- `installation` is the caller's live parent row (`status ∉ {uninstalled}`) with `claimedAt` exposed, because the page's *Cancel* is decided by it (§2 D2). `integration` is the projected row through `publicIntegration` (strips `botTokenRef`, `oauthStateNonce`, and **projects `config.adminPause` to `{ reason, at }`** — the moderator's `adminId` never leaves the server; the moderated sees why and when, never who; Vera, 63848), or `null`. - The install verb **refuses an unavailable provider** with `422 { code: 'provider_not_configured' }` before it claims anything. Today Telegram would install and then fail at the webhook; Slack fails at `authorize-url`. Both become one early refusal that matches what the catalog said. - The existing `GET /api/integrations/catalog` (manifest list + per-pod counts, used by the legacy chat UI) is untouched. It describes the legacy pod-scoped connectors; this route describes user-scoped installables. Merging them is a later cleanup, not this plan. - The page fetches this one route and polls it while any row is transient or pending; `/api/integrations/user/all` stays only for the legacy rows the page still lists (Discord and friends), rendered as today. @@ -65,7 +65,7 @@ | `active`, Integration missing or `isActive: false`, or any `components[].status: 'stale'` | — | hollow | The channel record is gone. | `retry rebuilds it` | `since {rel}` | **Retry** (ink) — same POST; `installAttempt` re-projects. The reconciler's stale-marking (`sweepActive`) is what makes this row honest; it should also set `status: 'error'`, `errorMessage: 'projection missing'` so the parent and the page agree (one-line change in `installableReconciler.ts`) | | `uninstalling`, within TTL | any | cobalt pulsing | Removing… | `waiting for the server` | `started {rel}` | none | | `uninstalling`, past TTL | any | cobalt pulsing | Removal is taking longer than it should. | `the server let go of it` | `started {rel}` | **Retry remove** (bordered) → `DELETE` again; `claimUninstall` re-claims a stale `uninstalling` | -| `paused` (written only by the admin lever, §2 D3) | any | hollow | Paused by an administrator. | `ask your operator` | `since {rel}` | none for the owner — Resume is the admin's, so the owner is never shown a control that will 403 | +| `paused` (written only by the admin lever, §2 D3) | any | hollow | Paused by an administrator. {reason} | `ask your operator` | `paused {rel}` from `adminPause.at` | none for the owner — Resume is the admin's, so the owner is never shown a control that will 403 | | `stale` (enum value with no writer today) | any | hollow | Paused. | `not relaying` | `since {rel}` | none until a verb writes it; listed so the enum has no invisible member | Copy is final draft in the product's voice; the sentences are the row, the enum value never appears. The first row that needs the user (Connect, Retry, Confirm, Cancel) is the default selection; the aside follows it. @@ -85,7 +85,7 @@ config.gates?: Record