From 77be8e1c018bc210ee3b66b2e165af8dc94f8f67 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 21:00:45 -0400 Subject: [PATCH 01/16] docs: design for separating attribution from authorization (SYD-281) --- .../2026-08-27-human-act-integrity-design.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-27-human-act-integrity-design.md diff --git a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md new file mode 100644 index 0000000..2cddab9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md @@ -0,0 +1,218 @@ +# Human-act integrity: separating attribution from authorization (SYD-281) + +Story B of the SYD-279 epic. Design approved 2026-08-27. + +## The problem + +`resolveSupervisedPrincipal` (`src/services/supervised-sessions.ts:53-79`) resolves a +supervised session to a `Principal` whose `actor` is the **bound human**. That is +correct for attribution: the human is accountable for what their agent does, and +events carry dual provenance through `viaAgentId`/`sessionId`. + +It is wrong for authorization. Every gate in the codebase asks `actor.type === "human"`, +and in a supervised session that question returns `true` for an agent. `Principal`'s own +docstring documents the conflation as intended — `actor` is "always the accountable root +(a human for supervised sessions)" — because attribution was the only consumer when it +was written. + +The mechanism that let this spread is a fail-open default. `Attribution` is +`{ viaAgentId?, sessionId? }` and every service that takes it declares +`attr: Attribution = {}`. "This caller has no session" and "this caller forgot to thread +the session" are the same value, and only three services take it at all. + +### Verified blast radius + +A `sup_` token resolves **only at `/mcp`** (`src/server.ts:77`). REST never calls +`resolveSupervisedPrincipal`; `src/rest/api-routes.ts:173-180` says so explicitly and +keeps a `PendingAffirmation` arm as a tripwire against the day that changes. So the +reachable surface today is the MCP tool surface alone: + +| MCP tool | site | today | +|---|---|---| +| `update_issue` → `done` | `issues.ts:317-366` | **gated** — the divert works | +| `declare_pr_link` | `pr-links.ts:291` | fails open | +| `remove_dependency` | `dependencies.ts:134` | fails open | +| `revoke_pr_link` | `pr-links.ts:585` | fails open | + +`declare_pr_link` is the severe one, and it does something worse than bypass a gate. +The write is `confirmedBy: isAgent ? null : actor.id`, with +`isAgent = actor.type === "agent"`. A supervised principal resolves to the human, so an +agent's declaration is **auto-confirmed and stamped with the human's actor id**. It does +not evade the attestation; it manufactures one. `provesLanded` (`pr-links.ts:88-90`) then +accepts it as proof and the `pr_link_confirmed` event records `humanConfirmed: true`. +Nothing on the record distinguishes it from a confirm a person actually performed. That +is the whole of SYD-280's guarantee, voided silently. + +`remove_dependency` violates a stated invariant in CLAUDE.md ("Dependency removal is +human-only") and needs no code to fix: `dependency.remove` already has an executor +(`hard-gate.ts:170-182`) and is already in `EXECUTABLE_GATE_ACTIONS` +(`settings.ts:99`). It is simply absent from the **live** `supervised.hard_gate_actions`, +which reads `["done"]` on the NAS — the registry default. + +The remaining ~20 `requireHuman` sites are unreachable, but only because of the REST +containment above. That containment is incidental, not designed, and SYD-282's authorize +stamp plausibly wants supervised auth at REST. + +## Approach + +Two mechanisms, with the boundary drawn explicitly: + +- **Divert** — the supervised agent legitimately proposes the action, so it parks a + `pendingActions` row and a human releases it out-of-band by cookie click + (`rest/pending-actions.ts:118-129`) or signature (`/affirm-signed`). Applies to `done` + and `dependency.remove`, the two actions with executors. Already built, already proven. +- **Refusal** — the action is not something an agent proposes. The gate throws. + +The design does not invent a model. Model (a) from the issue — a fresh out-of-band human +act — is the divert, and it already ships. What was missing is a way to ask "is a human +acting right now?" that a gate cannot get wrong by accident. + +### The accessor + +```ts +// src/services/principal.ts +declare const humanBrand: unique symbol; +export type HumanActor = Actor & { readonly [humanBrand]: true }; + +/** + * The human who is ACTING, as distinct from the human who is ACCOUNTABLE + * (`Principal.actor`). Null inside a supervised session: presence is not + * consent for everything that follows. + */ +export function asHuman(p: Principal): HumanActor | null { + if (p.sessionId != null) return null; + return p.actor.type === "human" ? (p.actor as HumanActor) : null; +} +``` + +`Principal.actor` keeps its current meaning and its docstring. Attribution, events, and +provenance are untouched. + +### Two shapes of call site + +The brand only works where a function is human-*only*. Both populations derive from the +same predicate; they differ in how the call site consumes it. + +**Human-only functions (~20) take `HumanActor` instead of `Actor`.** Passing a plain +`Actor` becomes a compile error, so `npm run typecheck` enumerates the work and a future +gate cannot be written wrong: + +``` +src/services/settings.ts ×2 setSetting, resetSetting +src/services/actors.ts ×3 setActorAttended, rotateActorToken, revokeActorToken +src/services/projects.ts ×2 createProject, updateProject +src/services/webhooks.ts ×3 create, update, delete +src/services/github-repos.ts ×2 addGithubRepo, removeGithubRepo +src/services/triage-actions.ts ×5 snooze, duplicate, retryDelivery, + resolveProcessDeviation, resolveDeliveryFailure +src/services/affirmation-keys.ts ×2 enrollAffirmationKey, revokeAffirmationKey +src/services/dependencies.ts ×1 removeDependency's human branch +src/rest/api-routes.ts ×2 requireHumanCaller: create actor, mint login link +``` + +`enrollAffirmationKey` carries **two** `type !== "human"` checks and only one of them +converts. Its `human` parameter is the authorizing caller and becomes `HumanActor`. Its +`target` parameter is the actor the key will belong to, and `target.type !== "human"` +(`affirmation-keys.ts:54`) is validating that keys belong to people — a data rule, not an +authorization check. It stays as it is. `listAffirmationKeys` has no human check at all. + +**Mixed functions (3) accept any actor and branch on type**, so the brand cannot be their +parameter. They compute one predicate from the same source of truth: + +```ts +const vouching = asHuman({ actor, sessionId: attr.sessionId }) !== null; +``` + +At `pr-links.ts:291` this replaces `isAgent`, which is the fix: + +```ts +- confirmedBy: isAgent ? null : actor.id, +- confirmedAt: isAgent ? null : now, ++ confirmedBy: vouching ? actor.id : null, ++ confirmedAt: vouching ? now : null, +``` + +This also closes **SYD-298** (a `service` token can confirm a PR link and the proof +readers accept it). `isAgent`'s `!== "agent"` caught neither a service actor nor a +supervised agent; `vouching` catches both. SYD-298 should be closed as absorbed rather +than worked separately. + +### Where `asHuman` is called + +Adapters mint, services demand — the repo's existing "thin adapters over services" rule. + +- **MCP** (`src/mcp/server.ts`) — holds the real `Principal`. The only place `sessionId` + is ever non-null. +- **REST** (`src/rest/api-routes.ts`) — `asHuman({ actor: c.var.actor })`. No supervised + principal exists here; a test locks that in so the containment is enforced rather than + incidental. +- **CLI** (`src/cli.ts`) — the synthetic local human is minted once at the top. + +## Behaviour changes + +| Action | Today | After | +|---|---|---| +| `declare_pr_link` in a supervised session | auto-confirmed as the human | declared, `confirmedBy` null, `provesLanded` false | +| `revoke_pr_link` in a supervised session | full human powers | own link + unconfirmed + lease required | +| `remove_dependency` in a supervised session | executes | diverts to affirmation | +| `update_issue` → `done` | diverts | unchanged | +| the other ~20 gates | unreachable | unreachable **and** refused | + +A declaration from a supervised session still lands, so the board still sees the work, +the link still blocks a second claim, and dispatch is unaffected. It is a claim awaiting +a vouch — "agents propose, humans confirm", as SYD-280 specified. The human confirms in +the SYD-290 UI at review time. + +`remove_dependency` requires one settings write on the NAS alongside the code: + +``` +supervised.hard_gate_actions: ["done"] → ["done", "dependency.remove"] +``` + +Per CLAUDE.md's migration rule this is an operator step, not a startup migration — it +changes instance policy rather than deriving a value from existing data. **It must be +called out in the PR description**, or the code ships with the hole still open. + +## Out of scope + +`comments.ts:31,44`, `worker-preference.ts:30,51`, `issues.ts:664`, and +`pr-links.ts:140,470` read `actor.type === "human"` for **routing and display**, not +authorization — is this comment a question, does this actor prefer interactive work, does +this status change clear `needsInput`, was the confirmer a person. They are correct as +they stand and must not be converted. Converting them would change routing behaviour for +supervised sessions, which is a separate decision nobody has made. + +No new executors. No changes to the divert. No changes to `Principal`'s attribution +semantics. + +## Testing + +TDD, per the repo's normal workflow. The load-bearing cases: + +1. `asHuman` returns null iff `sessionId != null` or `actor.type !== "human"`. +2. A supervised principal is refused by every human-only gate. +3. REST resolves no `sup_` token — the containment made explicit. +4. `declare_pr_link` driven **through the MCP tool with a real `sup_` token** leaves + `provesLanded` false and the link unconfirmed. +5. A `service` actor declaring a link leaves it unconfirmed (SYD-298). +6. `remove_dependency` in a supervised session parks a pending action instead of removing. + +Case 4 is deliberately driven through the real entry point rather than by calling +`declarePrLink` directly. SYD-280 shipped inert because its tests called `upsertPrState` +directly while production never did; constructing the state under test masks the producer. + +Acceptance is the **SYD-213 pentest matrix** re-run against the new gates. Every +human-only gate changes behaviour, so the matrix is the check that matters, not the unit +tests. + +## Risks + +- **Wide mechanical diff.** ~20 signature changes plus their call sites and tests. The + compiler finds every one, so the risk is review fatigue rather than missed sites — the + three reachable fixes should be legible in the diff and not buried. +- **The CLI's synthetic human.** `cli.ts:22` constructs `{ id: 0, name: "cli", type: + "human", attended: true }`. It must mint a `HumanActor` at the top rather than casting + at each of its four call sites, or the cast becomes the new fail-open default. +- **`affirmation-keys.ts` is the gate's own root of trust.** Enrolling a key is what + makes a signed affirmation possible, so it must be converted, and its tests should + assert a supervised session cannot enroll. From 89f131103a09a70d98a66c41daad8e7c35ef12a5 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 21:16:05 -0400 Subject: [PATCH 02/16] docs: rev 2 of SYD-281 design after seven-seat review panel --- docs/reviews/2026-08-27-syd281-panel.md | 66 +++ .../2026-08-27-human-act-integrity-design.md | 479 ++++++++++++------ 2 files changed, 392 insertions(+), 153 deletions(-) create mode 100644 docs/reviews/2026-08-27-syd281-panel.md diff --git a/docs/reviews/2026-08-27-syd281-panel.md b/docs/reviews/2026-08-27-syd281-panel.md new file mode 100644 index 0000000..2fd59da --- /dev/null +++ b/docs/reviews/2026-08-27-syd281-panel.md @@ -0,0 +1,66 @@ +# SYD-281 design review panel — 2026-08-27 + +Seven seats, four model lineages, on revision 1 of +`docs/superpowers/specs/2026-08-27-human-act-integrity-design.md`. +**7 of 7 REVISE.** No seat disputed the core mechanism; every seat attacked the site +census it rested on. + +| seat | model | verdict | +|---|---|---| +| executor | GPT-5.6 Luna | REVISE | +| auditor | GPT-5.6 Terra | REVISE | +| simplifier | GLM-5.2 | REVISE | +| architect | Gemini 3.1 Pro | REVISE | +| skeptic | Fable | REVISE | +| skeptic | Opus | REVISE | +| pentester | Opus | REVISE | + +Citation grounding was clean — three seats independently confirmed every `file:line` in +revision 1 resolves, with three off-by-a-few range errors and no fabricated identifiers. +The failures were all omissions. + +## Findings and disposition + +| # | Finding | Seats | Disposition in rev 2 | +|---|---|---|---| +| 1 | `confirmPrLink` (`pr-links.ts:417`) gates on `=== "agent"`, so a `service` actor confirms; live at `api-routes.ts:449` via the poller tokens. **SYD-298's actual site.** | auditor, executor, pentester, fable, opus | Added to population A. SYD-298 explicitly **not** closed | +| 2 | Blast radius surveyed only `!== "human"` gates; a second population of `=== "agent"` inverse gates in `issues.ts`/`needs-input.ts` is reachable today, incl. two named CLAUDE.md invariants and the `auto`-label self-escalation | opus | Re-derived over both shapes; population B expanded. Scope roughly doubles | +| 3 | The fail-open `attr: Attribution = {}` default survives at exactly the exploited functions — "the most expensive decision to reverse" | architect, executor, pentester | `ctx: Attribution` is **required**, no default, on every function calling an accessor | +| 4 | `affirmPendingAction` (`hard-gate.ts:91`) — the divert's executor — absent from the census; its owner tie *matches* under a supervised principal rather than saving you | pentester, opus | Added, flagged load-bearing not mechanical | +| 5 | `isAgent` drives four behaviours in `declarePrLink`; rev 1 changed one. `:305` `confirmed: !isAgent` missing from the diff | executor, fable, opus, pentester | Per-line predicate table added | +| 6 | `removeDependency` filed as human-only; it is mixed — typing it `HumanActor` deletes the divert | pentester, opus, fable | Moved to population B | +| 7 | Blanket `!vouching` for `isAgent` newly lease-gates the poller `service` actors and breaks ingestion | fable, opus | Three distinct predicates named; `actingAgent` added | +| 8 | The brand is erased at runtime; rev 1 silent on whether the runtime checks stay | executor, pentester, opus | "Predicate moves, brand is additive" + ESLint rule banning `as HumanActor` | +| 9 | REST containment is not the boundary — the agent runs on the human's workstation and `scripts/syd.ts` defaults to `SWITCHYARD_HUMAN_TOKEN` | pentester | Restated as a residual risk, not a guarantee | +| 10 | "MCP holds the real `Principal`" is false — `server.ts:94-101` destructures before the call | opus | Corrected; `buildMcpServer` takes a `Principal` (~20 threading sites, now budgeted) | +| 11 | Settings write **fails closed** — rev 1's "ships with the hole still open" is backwards | pentester, fable | Corrected; framed as UX not security | +| 12 | `linear-import.ts:352` is a second synthetic human passed to `createProject` | auditor | Added to the CLI mint path | +| 13 | `backfillPrLinksFromPrState`, `openSupervisedSession` missing; `auth.ts:15` needs an explicit do-not-convert | opus, fable, pentester | Both added to A; `auth.ts:15` added to C | +| 14 | Table used prose labels that are not real identifiers (`retryDelivery`, `resolveProcessDeviation`) | opus | Real names throughout | +| 15 | "only three services take Attribution" — actually seven files | fable | Corrected | +| 16 | Rev 1 prose attributed the auto-confirm to `pr_link_confirmed`/`humanConfirmed`; declare emits `pr_link_declared`/`confirmed` | opus, fable | Corrected | +| 17 | `done_without_merged_pr` becomes a new steady-state flag for supervised work after the fix | fable | Stated with the ordering convention | +| 18 | `asHuman` keying on `sessionId` alone is a proxy; `viaAgent` is the field that means it | pentester, opus | Tests both | + +## Found by the orchestrator during verification, not by the panel + +- **`delivery-events.ts:54` and `delivery-attempts.ts:209`** gate on `type === "agent"` + with a comment saying an agent posting these "could unblock its own issue or hide a + failed delivery". A supervised principal is typed human and passes. +- **`agent-sessions.ts:48` fails closed.** `requireAgent` demands `type === "agent"`, so + `progress_note` is refused in supervised sessions today — a live functional bug. This + is what forced the design to a *pair* of accessors rather than `asHuman` alone. + +## Contradiction, resolved without a debate round + +The simplifier proposed `asHuman(actor, sessionId?)` to remove a throwaway object literal; +the architect and pentester required the session threaded so it cannot be omitted. An +optional parameter rebuilds finding 3. Making the parameter **required** satisfies both — +the ergonomics and the enforcement. The answer was forced, so no debate round was spent. + +## Non-findings worth recording + +The simplifier weighed converting the ~20 currently-unreachable gates as YAGNI's strongest +challenge and affirmed it: the fail-open default is a class bug, not a four-site bug, so +the conversion is the work rather than gold-plating. It also affirmed the population-C +exclusion list as exactly the discipline a wide mechanical diff needs. diff --git a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md index 2cddab9..b09cc52 100644 --- a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md +++ b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md @@ -1,73 +1,89 @@ # Human-act integrity: separating attribution from authorization (SYD-281) -Story B of the SYD-279 epic. Design approved 2026-08-27. +Story B of the SYD-279 epic. Revision 2 — rewritten after a seven-seat review panel +(2026-08-27) found the revision-1 site census materially incomplete. Findings and their +disposition are in `docs/reviews/2026-08-27-syd281-panel.md`. ## The problem -`resolveSupervisedPrincipal` (`src/services/supervised-sessions.ts:53-79`) resolves a -supervised session to a `Principal` whose `actor` is the **bound human**. That is -correct for attribution: the human is accountable for what their agent does, and -events carry dual provenance through `viaAgentId`/`sessionId`. +`resolveSupervisedPrincipal` (`src/services/supervised-sessions.ts:53-76`) resolves a +supervised session to a `Principal` whose `actor` is the **bound human**. That is correct +for attribution: the human is accountable for what their agent does, and events carry dual +provenance through `viaAgentId`/`sessionId`. -It is wrong for authorization. Every gate in the codebase asks `actor.type === "human"`, -and in a supervised session that question returns `true` for an agent. `Principal`'s own -docstring documents the conflation as intended — `actor` is "always the accountable root -(a human for supervised sessions)" — because attribution was the only consumer when it -was written. +It is wrong for authorization. `Principal`'s own docstring documents the conflation as +intended — `actor` is "always the accountable root (a human for supervised sessions)" — +because attribution was the only consumer when it was written. -The mechanism that let this spread is a fail-open default. `Attribution` is -`{ viaAgentId?, sessionId? }` and every service that takes it declares -`attr: Attribution = {}`. "This caller has no session" and "this caller forgot to thread -the session" are the same value, and only three services take it at all. +Two mechanisms let this spread: -### Verified blast radius +1. **A fail-open default.** `Attribution` is `{ viaAgentId?, sessionId? }` and services + declare `attr: Attribution = {}`. "No session" and "caller forgot to thread the + session" are the same value. Seven service files carry that default: + `comments.ts:16`, `dependencies.ts:30,76`, `issues.ts:172,311,730`, + `needs-input.ts:21`, `pr-links.ts:231,414,571`, `agent-sessions.ts:207`, + `attachments.ts:102`. +2. **Two gate shapes, only one of them obvious.** A gate can ask `type !== "human"` + (refuse non-humans) or `type === "agent"` (refuse agents). A supervised principal + passes *both* — it is typed human and is not typed agent. Revision 1 surveyed only the + first shape and therefore missed the more dangerous half. -A `sup_` token resolves **only at `/mcp`** (`src/server.ts:77`). REST never calls -`resolveSupervisedPrincipal`; `src/rest/api-routes.ts:173-180` says so explicitly and -keeps a `PendingAffirmation` arm as a tripwire against the day that changes. So the -reachable surface today is the MCP tool surface alone: +### Blast radius, re-derived over both shapes -| MCP tool | site | today | +Derived from `grep -rn 'type === "human"\|type !== "human"\|type === "agent"\|type !== "agent"\|type === "service"' src/`. + +**Reachable today from `/mcp` with a `sup_` token — these are live holes:** + +| Site | Gate | What a supervised agent gets | |---|---|---| -| `update_issue` → `done` | `issues.ts:317-366` | **gated** — the divert works | -| `declare_pr_link` | `pr-links.ts:291` | fails open | -| `remove_dependency` | `dependencies.ts:134` | fails open | -| `revoke_pr_link` | `pr-links.ts:585` | fails open | - -`declare_pr_link` is the severe one, and it does something worse than bypass a gate. -The write is `confirmedBy: isAgent ? null : actor.id`, with -`isAgent = actor.type === "agent"`. A supervised principal resolves to the human, so an -agent's declaration is **auto-confirmed and stamped with the human's actor id**. It does -not evade the attestation; it manufactures one. `provesLanded` (`pr-links.ts:88-90`) then -accepts it as proof and the `pr_link_confirmed` event records `humanConfirmed: true`. -Nothing on the record distinguishes it from a confirm a person actually performed. That -is the whole of SYD-280's guarantee, voided silently. - -`remove_dependency` violates a stated invariant in CLAUDE.md ("Dependency removal is -human-only") and needs no code to fix: `dependency.remove` already has an executor -(`hard-gate.ts:170-182`) and is already in `EXECUTABLE_GATE_ACTIONS` -(`settings.ts:99`). It is simply absent from the **live** `supervised.hard_gate_actions`, -which reads `["done"]` on the NAS — the registry default. - -The remaining ~20 `requireHuman` sites are unreachable, but only because of the REST -containment above. That containment is incidental, not designed, and SYD-282's authorize -stamp plausibly wants supervised auth at REST. +| `issues.ts:184,190,211` | `type === "agent"` | **Violates "agent-created issues land in `triage` with required provenance"** (CLAUDE.md). `file_issue` lands in `backlog`, no provenance, no description | +| `issues.ts:402` | `type === "agent"` | **Violates "issues in `triage` can only be moved out by human actors"** (CLAUDE.md) | +| `issues.ts:579` | `type === "agent"` | Applies the `auto` label — **opts its own issue into unattended dispatch.** Privilege escalation, not bookkeeping | +| `issues.ts:389` | `type === "agent"` | `isHolderMutation` false ⇒ SYD-210 lease validation skipped entirely on a held issue | +| `issues.ts:412-427` | `type === "agent"` | `AGENT_STATUS_TRANSITIONS` allow-list bypassed; can reopen a `done` issue | +| `issues.ts:530` | `type === "agent"` | The `in_progress` claim gates (SYD-99/111/124) | +| `issues.ts:610` | `type === "agent"` | Agents may only self-assign; supervised can reassign or unassign anyone | +| `needs-input.ts:43` | `type === "agent"` | Holder lease check skipped on `request_human_input` | +| `pr-links.ts:241,242,254,291,305` | `type === "agent"` | See "the declare path" below | +| `pr-links.ts:585,598` | `!== "human"` / `=== "agent"` | See "the revoke path" below | +| `dependencies.ts:134` | `!== "human"` | **Violates "dependency removal is human-only"** (CLAUDE.md) | +| `delivery-events.ts:54` | `type === "agent"` | The comment at `:48-53` says an agent posting these "could unblock its own issue or hide a failed delivery". A supervised principal is typed human and passes | +| `delivery-attempts.ts:209` | `type === "agent"` | Same shape | + +`update_issue` → `done` is the one gate that holds, and only because the divert +(`issues.ts:314-316`) fires on `attr.sessionId != null && isHardGated(...)` *before* +reaching `issues.ts:407`. That coupling is load-bearing and undocumented: remove `done` +from `supervised.hard_gate_actions` and `:407` falls open like the rest. + +**Reachable at REST — see "the REST residual" below, this is not containment:** +`confirmPrLink` (`pr-links.ts:417`, exposed at `api-routes.ts:449`) gates on +`type === "agent"`, so a **`service`** actor passes and writes `confirmedBy: actor.id` +(`:458`). `SWITCHYARD_GITHUB_POLLER_TOKEN` and `SWITCHYARD_DELIVER_POLLER_TOKEN` are +`service` actors that already speak REST, so this is live today. **This is SYD-298's +actual site.** Revision 1 claimed SYD-298 was absorbed by the `declare` fix; that was +wrong, and acting on it would have retired the issue with its primary vector open. + +**The conflation also fails closed.** `agent-sessions.ts:48` `requireAgent` demands +`type === "agent"`. A supervised principal is typed human, so `progress_note` is +**refused** in supervised sessions today. This is a live functional bug, and it is why +one accessor is not enough. ## Approach -Two mechanisms, with the boundary drawn explicitly: +Three mechanisms, and a gate must state which one it wants. -- **Divert** — the supervised agent legitimately proposes the action, so it parks a - `pendingActions` row and a human releases it out-of-band by cookie click - (`rest/pending-actions.ts:118-129`) or signature (`/affirm-signed`). Applies to `done` - and `dependency.remove`, the two actions with executors. Already built, already proven. +- **Divert** — the agent legitimately proposes; park a `pendingActions` row and let a + human release it out-of-band (`rest/pending-actions.ts:118-129`, or `/affirm-signed`). + Applies to `done` and `dependency.remove`, the two actions with executors. - **Refusal** — the action is not something an agent proposes. The gate throws. +- **Agent-path** — the gate is not about humanity at all; it asks "is this an agent + credential, with the claim and lease that implies?" A supervised session must answer + **yes** here, because the agent is the one acting. -The design does not invent a model. Model (a) from the issue — a fresh out-of-band human -act — is the divert, and it already ships. What was missing is a way to ask "is a human -acting right now?" that a gate cannot get wrong by accident. +### Two accessors, not one -### The accessor +Revision 1 had `asHuman` only, which cannot express the agent-path question and forced +three services to reconstruct a `Principal` from a defaulted `Attribution`. ```ts // src/services/principal.ts @@ -78,141 +94,298 @@ export type HumanActor = Actor & { readonly [humanBrand]: true }; * The human who is ACTING, as distinct from the human who is ACCOUNTABLE * (`Principal.actor`). Null inside a supervised session: presence is not * consent for everything that follows. + * + * Tests `viaAgent` as well as `sessionId`. They coincide today only because + * `src/server.ts:91-93` withholds attribution from plain sessions for an + * FK/logout reason (`deleteSession` hard-deletes rows `events.sessionId` + * references), not an authorization one. Keyed on both, a future principal + * carrying one without the other cannot fail open. */ -export function asHuman(p: Principal): HumanActor | null { - if (p.sessionId != null) return null; - return p.actor.type === "human" ? (p.actor as HumanActor) : null; +export function asHuman(actor: Actor, ctx: Attribution): HumanActor | null { + if (ctx.sessionId != null || ctx.viaAgentId != null) return null; + return actor.type === "human" ? (actor as HumanActor) : null; +} + +/** + * The identity whose claim and lease govern this write. In a supervised + * session that is the agent, never the accountable human — the human holds + * no lease. Null when no agent is acting. + */ +export function actingAgent(db: DbOrTx, actor: Actor, ctx: Attribution): Actor | null { + if (ctx.viaAgentId != null) return getActorById(db, ctx.viaAgentId); + return actor.type === "agent" ? actor : null; } ``` -`Principal.actor` keeps its current meaning and its docstring. Attribution, events, and -provenance are untouched. +`ctx` is **required**, with no default, on every function that calls either accessor. +That is the fix for cause (1): the compiler enforces the threading the authorization +depends on, instead of the fix inheriting the defect it was written to remove. An +optional parameter here would rebuild the hole. -### Two shapes of call site +`actingAgent` is what makes the agent-path expressible. It answers the objection that a +service holds `Attribution` (which carries `viaAgentId`, a number) and cannot resolve the +acting `Actor` — it resolves it from the db, once, at the point of use. -The brand only works where a function is human-*only*. Both populations derive from the -same predicate; they differ in how the call site consumes it. +### The three predicates, stated per line -**Human-only functions (~20) take `HumanActor` instead of `Actor`.** Passing a plain -`Actor` becomes a compile error, so `npm run typecheck` enumerates the work and a future -gate cannot be written wrong: +`declarePrLink`'s `isAgent` (`pr-links.ts:241`) currently drives four behaviours. They do +not all want the same predicate, and blanket-substituting one for `isAgent` breaks +ingestion by newly lease-gating the poller `service` actors. -``` -src/services/settings.ts ×2 setSetting, resetSetting -src/services/actors.ts ×3 setActorAttended, rotateActorToken, revokeActorToken -src/services/projects.ts ×2 createProject, updateProject -src/services/webhooks.ts ×3 create, update, delete -src/services/github-repos.ts ×2 addGithubRepo, removeGithubRepo -src/services/triage-actions.ts ×5 snooze, duplicate, retryDelivery, - resolveProcessDeviation, resolveDeliveryFailure -src/services/affirmation-keys.ts ×2 enrollAffirmationKey, revokeAffirmationKey -src/services/dependencies.ts ×1 removeDependency's human branch -src/rest/api-routes.ts ×2 requireHumanCaller: create actor, mint login link -``` +| line | today | becomes | why | +|---|---|---|---| +| `:242-249` assignee check | `isAgent` | `actingAgent(...) !== null` | claim-scoped; a supervised agent must hold the claim | +| `:251` `validateLease` | `isAgent`, keyed `actor.id` | `actingAgent(...)`, keyed **that agent's id** | the human holds no lease | +| `:254` role forcing | `isAgent` | `actingAgent(...) !== null` | agents must not mint `references` (`:251-253`) | +| `:291-292` `confirmedBy` | `isAgent` | `asHuman(...) !== null` | anything not a vouching human: agent, **service**, supervised | +| `:305` `confirmed:` payload | `isAgent` | `asHuman(...) !== null` | must track the row, or the audit event asserts a confirmation the row does not carry | -`enrollAffirmationKey` carries **two** `type !== "human"` checks and only one of them -converts. Its `human` parameter is the authorizing caller and becomes `HumanActor`. Its -`target` parameter is the actor the key will belong to, and `target.type !== "human"` -(`affirmation-keys.ts:54`) is validating that keys belong to people — a data rule, not an -authorization check. It stays as it is. `listAffirmationKeys` has no human check at all. +`:305` was missing from revision 1's diff. Left unchanged it writes `confirmedBy: null` +to the row while emitting `pr_link_declared` with `confirmed: true` — manufacturing the +attestation in the audit stream while fixing the mutable row. -**Mixed functions (3) accept any actor and branch on type**, so the brand cannot be their -parameter. They compute one predicate from the same source of truth: +`revokePrLink` has the same split: the outer branch gate (`:585`) takes the `asHuman` +form, and the inner `validateLease` (`:598`) takes `actingAgent`. Converting only the +outer leaves "lease required" false and compares `declaredBy` against the *human's* id — +letting a supervised agent withdraw declarations its supervisor made in person, from +other sessions. Converting both to one predicate instead makes a `service` actor hit +`validateLease` with an id that can hold no lease (`issues.ts:376-380` denies services +wholesale), turning a working path into an unconditional throw. -```ts -const vouching = asHuman({ actor, sessionId: attr.sessionId }) !== null; -``` +### Correction to revision 1's prose -At `pr-links.ts:291` this replaces `isAgent`, which is the fix: +The declare path emits `pr_link_declared` with `confirmed: !isAgent` +(`pr-links.ts:297-312`). It does **not** emit `pr_link_confirmed` /`humanConfirmed: +true` — that is `confirmPrLink` (`pr-links.ts:463-473`), which declare never calls. The +hole is real; the mechanism revision 1 described was not. -```ts -- confirmedBy: isAgent ? null : actor.id, -- confirmedAt: isAgent ? null : now, -+ confirmedBy: vouching ? actor.id : null, -+ confirmedAt: vouching ? now : null, +## The conversion, by population + +### Population A — human-only functions take `HumanActor` + +The brand rides on the return type, so a plain `Actor` is a compile error and +`npm run typecheck` enumerates the work. + +``` +src/services/settings.ts setSetting:206, resetSetting:227 helper :113 +src/services/actors.ts setActorAttended:107, rotateActorToken:120, + revokeActorToken:132 helper :12 +src/services/projects.ts createProject:17, updateProject:34 helper :11 +src/services/webhooks.ts addWebhook:18, removeWebhook:39, + setWebhookActive:48 helper :10 +src/services/github-repos.ts addGithubRepo:31, removeGithubRepo:57 helper :23 +src/services/triage-actions.ts snoozeIssue:54, markDuplicate:88, + redeliverIssue:137, resolveDeviation:250, + resolveDeliveryFailure:282 helper :45 +src/services/affirmation-keys.ts enrollAffirmationKey:44 (human check :51 only), + revokeAffirmationKey:124 +src/services/hard-gate.ts affirmPendingAction:91 ← root of trust +src/rest/pending-actions.ts :69, :126, :138 ← root of trust +src/services/pr-links.ts confirmPrLink:409, backfillPrLinksFromPrState:505 +src/services/supervised-sessions.ts openSupervisedSession:24 ← root of trust +src/rest/api-routes.ts requireHumanCaller:141 ``` -This also closes **SYD-298** (a `service` token can confirm a PR link and the proof -readers accept it). `isAgent`'s `!== "agent"` caught neither a service actor nor a -supervised agent; `vouching` catches both. SYD-298 should be closed as absorbed rather -than worked separately. +Revision 1 used prose labels (`retryDelivery`, `resolveProcessDeviation`, `create/update/ +delete`) that are not real identifiers. The names above are grep-able. -### Where `asHuman` is called +**Three additions are load-bearing rather than mechanical, and must not be reviewed as +part of the sweep:** -Adapters mint, services demand — the repo's existing "thin adapters over services" rule. +- **`affirmPendingAction` (`hard-gate.ts:91`)** turns a parked proposal into a real `done` + or a real dependency removal. It is the root of trust for the divert this whole design + rests on. Its owner tie (`hard-gate.ts:131-135`) compares `session.actorId === human.id` + — under a supervised principal that is the bound human's id, so the owner tie + **matches** rather than saving you. The day supervised auth reaches REST (which SYD-282 + plausibly wants), `/affirm-signed` authorizes on `c.var.actor` and a supervised agent + affirms the action it itself parked. That is not a weakened gate; it is the gate running + in reverse. +- **`confirmPrLink` (`pr-links.ts:409`)** is SYD-298's actual site, live today via a + `service` token at `api-routes.ts:449`. +- **`openSupervisedSession` (`supervised-sessions.ts:24`)** decides who can mint a + supervised session at all — reaching it yields a fresh 12h `sup_` token bound to the + same human under an agent name of the caller's choosing. -- **MCP** (`src/mcp/server.ts`) — holds the real `Principal`. The only place `sessionId` - is ever non-null. -- **REST** (`src/rest/api-routes.ts`) — `asHuman({ actor: c.var.actor })`. No supervised - principal exists here; a test locks that in so the containment is enforced rather than - incidental. -- **CLI** (`src/cli.ts`) — the synthetic local human is minted once at the top. +### Population B — mixed functions take the predicates -## Behaviour changes +These must keep accepting any actor (to reach a divert, or to serve agents legitimately), +so the brand cannot be their parameter type. Each takes a **required** `ctx: Attribution` +and applies the predicate table per line. -| Action | Today | After | -|---|---|---| -| `declare_pr_link` in a supervised session | auto-confirmed as the human | declared, `confirmedBy` null, `provesLanded` false | -| `revoke_pr_link` in a supervised session | full human powers | own link + unconfirmed + lease required | -| `remove_dependency` in a supervised session | executes | diverts to affirmation | -| `update_issue` → `done` | diverts | unchanged | -| the other ~20 gates | unreachable | unreachable **and** refused | +``` +src/services/pr-links.ts declarePrLink:225, revokePrLink:566 +src/services/dependencies.ts removeDependency:71 +src/services/issues.ts createIssue:172, updateIssue:305 +src/services/needs-input.ts :43 +src/services/delivery-events.ts :54 +src/services/delivery-attempts.ts :209 +src/services/agent-sessions.ts requireAgent:47 ← the fail-closed one +``` -A declaration from a supervised session still lands, so the board still sees the work, -the link still blocks a second claim, and dispatch is unaffected. It is a claim awaiting -a vouch — "agents propose, humans confirm", as SYD-280 specified. The human confirms in -the SYD-290 UI at review time. +`removeDependency` was filed in population A in revision 1. That was wrong: `hard-gate.ts:181` +and `mcp/server.ts:596` call it with mixed actors, and typing its parameter `HumanActor` +means the MCP adapter cannot call it at all — which deletes the divert this design is +trying to enable. -`remove_dependency` requires one settings write on the NAS alongside the code: +`requireAgent` (`agent-sessions.ts:47`) becomes `actingAgent(...) !== null`, which fixes +the live fail-closed bug: `progress_note` starts working in supervised sessions. + +### Population C — do not convert + +These read a type for **routing, display, or as a data rule about a target**, not to +authorize the caller. Converting them changes behaviour nobody has decided to change. ``` -supervised.hard_gate_actions: ["done"] → ["done", "dependency.remove"] +src/services/comments.ts:31,44 is this comment a question / clear needsInput +src/services/worker-preference.ts:30,51 interactive routing +src/services/issues.ts:664 clear needsInput on status change +src/services/pr-links.ts:140,470 display: was the confirmer a person +src/services/affirmation-keys.ts:54 target must be human — a data rule +src/services/auth.ts:15 login links belong to humans — a data rule +src/services/actors.ts:38 default `attended` at creation ``` -Per CLAUDE.md's migration rule this is an operator step, not a startup migration — it -changes instance policy rather than deriving a value from existing data. **It must be -called out in the PR description**, or the code ships with the hole still open. +`auth.ts:15` and `affirmation-keys.ts:54` are the same shape and are named explicitly +because a mechanical implementer will otherwise convert them. + +## The brand is not the enforcement + +`HumanActor` is erased at runtime. The six private `requireHuman` helpers listed in +population A currently ask `actor.type !== "human"` — the question this design proves +returns the wrong answer for a supervised principal. + +**The predicate moves; the brand is additive.** Each helper takes enough to call +`asHuman` (actor + ctx) and throws when it is null, returning the branded value on +success. Deleting the runtime throw as "newly redundant" would make authorization purely +compile-time, and one `as HumanActor` cast — the CLI needs one — reduces the gate to a +no-op with no runtime trace. + +Add an ESLint rule banning `as HumanActor` outside `principal.ts` and the single CLI mint. +Revision 1 predicted the cast would become the new fail-open default under Risks but did +nothing to prevent it. + +## Where the accessors are called + +- **MCP.** Revision 1 said `mcp/server.ts` "holds the real `Principal`". It does not — + `src/server.ts:94-101` destructures it before the call, and `buildMcpServer` takes + `actor`, `attribution`, `viaAgent` separately. **Change `buildMcpServer` to take a + `Principal`** and mint once per connection. The alternative — every tool call + reassembling a principal by hand — is the pattern this design exists to remove. This is + ~20 threading sites that revision 1 neither listed nor budgeted. +- **REST.** `requireHumanCaller` returns `void` today (`api-routes.ts:141-145`), so it + cannot supply the branded value downstream. It becomes a function returning + `HumanActor`, and each human-only route passes its result rather than `c.var.actor`. +- **CLI.** One helper performs the runtime check and returns `HumanActor`; `cliActor` + (`cli.ts:22`) and `requireHumanActor` (`cli.ts:27`) both route through it. + **`linear-import.ts:352` constructs a second synthetic human** and passes it to + `createProject` at `:355` — it goes through the same helper, not a cast. + +## The REST residual — stated, not claimed as containment + +Revision 1 proposed a test locking in "REST resolves no `sup_` token" and recorded the +REST gates as "unreachable **and** refused". The first half is true; the second is not the +boundary it sounds like. + +A supervised session runs on the accountable human's workstation. That workstation has +`.env` with `SWITCHYARD_HUMAN_TOKEN`, and `scripts/syd.ts` defaults to it. The supervised +agent's path to every REST gate is not a `sup_` token — it is +`npx tsx scripts/syd.ts api POST /settings/…` with the human's bearer, at which point +`asHuman` returns a `HumanActor` and every converted gate opens. + +**Nothing at the REST layer can distinguish a human's bearer presented by a person from +the same bearer presented by a process.** The codebase already knows this: the cookie-only +affirm route (`rest/pending-actions.ts:118-129`) exists for exactly this reason and says +so in its comment. + +So: keep the test, but state the residual in the PR and in this spec. REST gates are +enforced against `sup_` principals; a supervised agent with filesystem access to the +human's bearer bypasses them, and the mitigation is credential hygiene plus the +cookie/signature routes — not `asHuman`. + +## Operator step -## Out of scope +``` +supervised.hard_gate_actions: ["done"] → ["done", "dependency.remove"] +``` -`comments.ts:31,44`, `worker-preference.ts:30,51`, `issues.ts:664`, and -`pr-links.ts:140,470` read `actor.type === "human"` for **routing and display**, not -authorization — is this comment a question, does this actor prefer interactive work, does -this status change clear `needsInput`, was the confirmer a person. They are correct as -they stand and must not be converted. Converting them would change routing behaviour for -supervised sessions, which is a separate decision nobody has made. +**This fails closed, not open.** Revision 1 said the code "ships with the hole still open" +without it; that is backwards. Once `dependencies.ts:134` asks `asHuman(...) === null`, a +supervised `remove_dependency` on an un-updated NAS skips the divert +(`dependencies.ts:78,89` require `isHardGated`) and lands in the converted branch — +**refused**. The setting upgrades refusal to a proposable divert. It is UX, not security, +and framing it correctly lowers the risk of a partially-applied rollout. -No new executors. No changes to the divert. No changes to `Principal`'s attribution -semantics. +`setSetting` validates against `EXECUTABLE_GATE_ACTIONS` (`settings.ts:157-163`), and +settings are REST-only with no MCP surface, so the write itself is safe. -## Testing +## Behaviour changes -TDD, per the repo's normal workflow. The load-bearing cases: +| Action | Today | After | +|---|---|---| +| `file_issue` (supervised) | lands `backlog`, no provenance | lands `triage`, provenance required | +| `update_issue` out of `triage` | permitted | refused | +| `update_issue` adding `auto` | permitted — self-escalation | refused | +| `update_issue` reassign/claim/transition | agent gates skipped | agent gates apply, lease keyed to `viaAgent` | +| `declare_pr_link` | auto-confirmed as the human | claim + lease required, role forced `delivers`, unconfirmed | +| `revoke_pr_link` | full human powers | own link + unconfirmed + agent's lease | +| `confirm_pr_link` (service, REST) | confirms; proof readers accept | refused | +| `remove_dependency` | executes | refused, or diverts once the setting lands | +| `progress_note` (supervised) | **refused (bug)** | works | +| delivery events (supervised) | can unblock its own issue | refused | +| `update_issue` → `done` | diverts | unchanged | -1. `asHuman` returns null iff `sessionId != null` or `actor.type !== "human"`. -2. A supervised principal is refused by every human-only gate. -3. REST resolves no `sup_` token — the containment made explicit. -4. `declare_pr_link` driven **through the MCP tool with a real `sup_` token** leaves - `provesLanded` false and the link unconfirmed. -5. A `service` actor declaring a link leaves it unconfirmed (SYD-298). -6. `remove_dependency` in a supervised session parks a pending action instead of removing. +**Second-order effect to state in the PR:** today's auto-confirm means supervised +deliveries never trip `done_without_merged_pr`. After the fix, an issue affirmed to `done` +whose link the human never separately confirmed fails the attention NOT-EXISTS +(`attention.ts:113-121` requires `confirmed_by IS NOT NULL`) and flags. That is the system +working as designed — the flag *is* the vouch reminder — but it is new steady-state +behaviour for the primary supervised workflow. The convention is: confirm the link in the +SYD-290 UI before affirming `done`. -Case 4 is deliberately driven through the real entry point rather than by calling -`declarePrLink` directly. SYD-280 shipped inert because its tests called `upsertPrState` -directly while production never did; constructing the state under test masks the producer. +## Testing -Acceptance is the **SYD-213 pentest matrix** re-run against the new gates. Every -human-only gate changes behaviour, so the matrix is the check that matters, not the unit -tests. +TDD. Driven through real entry points, not by constructing state — SYD-280 shipped inert +because its tests called `upsertPrState` directly while production never did. + +1. `asHuman` returns null iff `sessionId != null` **or** `viaAgentId != null` or the actor + is not human. `actingAgent` resolves `viaAgentId` in a supervised session and the actor + itself for a plain agent. +2. Every population-A gate refuses a supervised principal. +3. Every reachable inverse gate refuses a supervised principal, driven through the MCP + tool: triage exit (`issues.ts:402`), the `auto` label (`:579`), reassign (`:610`), the + transition allow-list (`:412`), and supervised `file_issue` landing in `triage` with + provenance (`:184,190,211`). +4. `declare_pr_link` over a real `sup_` token: refused without a claim, refused without + the agent's lease, `role` forced to `delivers`, `provesLanded` false, **and the + `pr_link_declared` payload's `confirmed` field false.** Asserting only the row would + pass with the event still lying. +5. A `service` actor **confirming** via `POST /issues/:ref/pr-links/confirm` is refused — + this is SYD-298's actual case. A service *declaring* (revision 1's case 5) is not. +6. `remove_dependency` supervised: refused at the registry default, diverts once + `dependency.remove` is in `hard_gate_actions`. **Assert both**, in the same file, so the + operator dependency is visible in the diff rather than hidden in test setup. +7. `progress_note` works in a supervised session (the fail-closed regression). +8. Boundary: `dependencies.ts:89` also requires `edgeExists`, so removing a non-existent + edge never diverts and falls to `:134` — today a silent no-op, after the change a + refusal. + +Acceptance is the **SYD-213 pentest matrix** re-run against the new gates. ## Risks -- **Wide mechanical diff.** ~20 signature changes plus their call sites and tests. The - compiler finds every one, so the risk is review fatigue rather than missed sites — the - three reachable fixes should be legible in the diff and not buried. -- **The CLI's synthetic human.** `cli.ts:22` constructs `{ id: 0, name: "cli", type: - "human", attended: true }`. It must mint a `HumanActor` at the top rather than casting - at each of its four call sites, or the cast becomes the new fail-open default. -- **`affirmation-keys.ts` is the gate's own root of trust.** Enrolling a key is what - makes a signed affirmation possible, so it must be converted, and its tests should - assert a supervised session cannot enroll. +- **Scope.** This is roughly double revision 1. The inverse-gate population in `issues.ts` + is the reason, and it is not optional: two of those sites are invariants CLAUDE.md + states as server-enforced, and one is a self-escalation into unattended dispatch. +- **`buildMcpServer`'s signature change** touches ~20 threading sites and was unbudgeted + in revision 1. +- **Review legibility.** The PR must let a reviewer tell the live fixes from the + prevention sweep without reconstructing the blast-radius table. Carry the table in the + PR description and order the commits: accessors first, then live holes, then the sweep. +- **The cast is the new fail-open default** unless the ESLint rule lands with the change, + not after it. + +## Not closed by this work + +**SYD-298 stays open** until `confirmPrLink` is converted and its service-token refusal +test passes. Revision 1's instruction to close it as absorbed would have retired the issue +with its primary vector live. From 43d1635bbebd6a51665badbe3638331dc3194c93 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 21:32:08 -0400 Subject: [PATCH 03/16] =?UTF-8?q?docs:=20rev=203=20of=20SYD-281=20design?= =?UTF-8?q?=20=E2=80=94=20identity=20rule=20after=20round=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reviews/2026-08-27-syd281-panel.md | 42 ++ .../2026-08-27-human-act-integrity-design.md | 572 +++++++++--------- 2 files changed, 322 insertions(+), 292 deletions(-) diff --git a/docs/reviews/2026-08-27-syd281-panel.md b/docs/reviews/2026-08-27-syd281-panel.md index 2fd59da..60e97f4 100644 --- a/docs/reviews/2026-08-27-syd281-panel.md +++ b/docs/reviews/2026-08-27-syd281-panel.md @@ -64,3 +64,45 @@ The simplifier weighed converting the ~20 currently-unreachable gates as YAGNI's challenge and affirmed it: the fail-open default is a class bug, not a four-site bug, so the conversion is the work rather than gold-plating. It also affirmed the population-C exclusion list as exactly the discipline a wide mechanical diff needs. + +--- + +# Round 2 — on revision 2 + +**2 APPROVED** (architect, simplifier), **5 REVISE.** Every round-1 finding was verified +fixed by the seat that raised it. All five REVISE verdicts were on surface revision 2 +introduced. + +## Correction to the round-1 record + +The "found by the orchestrator" claim above that **`agent-sessions.ts:48` fails closed and +`progress_note` is broken in supervised sessions is WRONG.** `mcp/server.ts:417` passes +`viaAgent ?? actor`, with a comment explaining exactly this case. Caught by the opus +skeptic in round 2. Revision 3 reframes that line as the *precedent* `effectiveActor` +generalizes — which is a better argument for the design than the bug that was claimed. + +The `delivery-events.ts:54` / `delivery-attempts.ts:209` finding stands. + +## Round 2 findings + +| # | Finding | Seats | Disposition in rev 3 | +|---|---|---|---| +| 19 | **Claim identity is undecided.** `claimIssue:783` assigns and `:769` leases the human. Keying checks to the agent ships inert (`issues.ts:389` compares agent-id to human assignee); keying to the human leaves the hole. `claimIssue`, `assertClaimable:260`, the lease-mint sites are in no population | opus, pentester, fable | **Escalated to the user.** Decision: the agent holds the claim. New "identity rule" section; `claimIssue`/`assertClaimable` added to population B; migration section added | +| 20 | `declaredBy` (`pr-links.ts:289`) and revoke's compare (`:593`) not in the table, so converting `:585` doesn't fix the stated harm | executor, auditor, pentester, fable | Both in the predicate table; `effectiveActor` accessor added | +| 21 | `actingAgent` doesn't re-check `type`, takes `DbOrTx` but calls `getActorById(db: Db)` (`actors.ts:73`), and throws where it promises null (`:75`) | executor, auditor, opus, pentester, fable | Reimplemented: inline query, type re-check, returns null | +| 22 | `rest/pending-actions.ts:126` **cannot** take `asHuman` — its actor comes from `getSessionActor` (no session id), and threading one would break the cookie affirm route | pentester | Removed from population A with the reason; regression test added | +| 23 | `comments.ts:44` mis-filed in population C — it clears `needsInput` and releases the claim, so it authorizes | fable | Moved to population B; `issues.ts:664`'s exemption now explained | +| 24 | `issues.ts` (10+ sites) got no per-line table while pr-links did — the rev-1 pathology in the bigger file | opus, pentester | Full per-line table added | +| 25 | `requireAgent` has three call sites (`:126`, `:142`, `:209`), not one; `startAgentSession`/`endAgentSession` have no `Attribution` param and their owner tie keys on `actor.id` | pentester | Named and budgeted | +| 26 | `{}` is the next fail-open at the *value* level; `hard-gate.ts:181,196-197` ship two exemplars | pentester | Lint rule extended to `{}` literals with an allowlist | +| 27 | `createIssue`'s `creatorId` and `recordProgressNote`'s event `actorId` left implicit | executor | `effectiveActor` applied; `creatorId` in the table | +| 28 | `actors.ts:110` missing from do-not-convert | executor | Added | +| 29 | Census 12 of 14 (`queue.ts:76`, `mcp/server.ts:75`) | pentester, simplifier | Corrected to 14 | +| 30 | Ingestion rationale factually wrong — pollers call `recordIngestedPrLink`, not `declarePrLink` | opus | Corrected; caution kept | +| 31 | No comment/message sweep — error text describes the old model | opus | Added as a requirement | +| 32 | Citation drift: `validateLease` is `pr-links.ts:248`, not `:251` | pentester | Corrected | + +## Escalated rather than reviewed again + +Finding 19 is a product decision, not something a third review round resolves. Round 3 was +held until the user answered it. diff --git a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md index b09cc52..0bbb381 100644 --- a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md +++ b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md @@ -1,306 +1,297 @@ # Human-act integrity: separating attribution from authorization (SYD-281) -Story B of the SYD-279 epic. Revision 2 — rewritten after a seven-seat review panel -(2026-08-27) found the revision-1 site census materially incomplete. Findings and their -disposition are in `docs/reviews/2026-08-27-syd281-panel.md`. +Story B of the SYD-279 epic. **Revision 3** — after two rounds of a seven-seat review +panel (2026-08-27). Findings and disposition: `docs/reviews/2026-08-27-syd281-panel.md`. ## The problem `resolveSupervisedPrincipal` (`src/services/supervised-sessions.ts:53-76`) resolves a -supervised session to a `Principal` whose `actor` is the **bound human**. That is correct -for attribution: the human is accountable for what their agent does, and events carry dual -provenance through `viaAgentId`/`sessionId`. +supervised session to a `Principal` whose `actor` is the **bound human**. Correct for +attribution — the human is accountable, and events carry dual provenance via +`viaAgentId`/`sessionId`. Wrong for authorization, and wrong for identity. -It is wrong for authorization. `Principal`'s own docstring documents the conflation as -intended — `actor` is "always the accountable root (a human for supervised sessions)" — -because attribution was the only consumer when it was written. +Three distinct questions have been answered by one field: -Two mechanisms let this spread: +1. **Who authorizes this?** — must be a human *acting*, not a human *accountable*. +2. **Whose credential is this?** — the agent's, in a supervised session. +3. **Whose name goes on the row?** — the assignee, the lease holder, the declarer. -1. **A fail-open default.** `Attribution` is `{ viaAgentId?, sessionId? }` and services - declare `attr: Attribution = {}`. "No session" and "caller forgot to thread the - session" are the same value. Seven service files carry that default: - `comments.ts:16`, `dependencies.ts:30,76`, `issues.ts:172,311,730`, - `needs-input.ts:21`, `pr-links.ts:231,414,571`, `agent-sessions.ts:207`, - `attachments.ts:102`. -2. **Two gate shapes, only one of them obvious.** A gate can ask `type !== "human"` - (refuse non-humans) or `type === "agent"` (refuse agents). A supervised principal - passes *both* — it is typed human and is not typed agent. Revision 1 surveyed only the - first shape and therefore missed the more dangerous half. +`actor.type === "human"` answers (1) wrongly. `actor.type === "agent"` answers (2) +wrongly. `actor.id` answers (3) wrongly. A supervised principal is typed human and is not +typed agent, so it passes gates of *both* shapes. -### Blast radius, re-derived over both shapes +Two mechanisms let it spread: -Derived from `grep -rn 'type === "human"\|type !== "human"\|type === "agent"\|type !== "agent"\|type === "service"' src/`. +- **A fail-open default.** Services declare `attr: Attribution = {}`, so "no session" and + "caller forgot to thread the session" are the same value. **Fourteen** carriers: + `comments.ts:16`, `dependencies.ts:30,76`, `issues.ts:172,311,730` and `claimIssue:730`, + `needs-input.ts:21`, `pr-links.ts:231,414,571`, `agent-sessions.ts:207`, + `attachments.ts:102`, `queue.ts:76`, `mcp/server.ts:75`. +- **Two gate shapes.** Revision 1 surveyed only `!== "human"` and therefore missed the + more dangerous half. -**Reachable today from `/mcp` with a `sup_` token — these are live holes:** +### The precedent this design generalizes + +`mcp/server.ts:417` already solves the identity problem, by hand, at exactly one site: + +```ts +// The one agent-scoped write: recordProgressNote's requireAgent rejects a +// human, and in a supervised session `actor` IS the human root — so act as +// the bound agent instead of relaxing the guard. +recordProgressNote(db, viaAgent ?? actor, ref, note, attribution); +``` + +That is `effectiveActor`, hand-rolled. Revision 2 wrongly called `progress_note` a live +fail-closed bug; it works, because of this line. The finding is not that it is broken — +it is that one site got it right and nothing generalized it. + +### Blast radius, both gate shapes + +From `grep -rn 'type === "human"\|type !== "human"\|type === "agent"\|type !== "agent"\|type === "service"' src/`. + +**Reachable today from `/mcp` with a `sup_` token:** | Site | Gate | What a supervised agent gets | |---|---|---| -| `issues.ts:184,190,211` | `type === "agent"` | **Violates "agent-created issues land in `triage` with required provenance"** (CLAUDE.md). `file_issue` lands in `backlog`, no provenance, no description | -| `issues.ts:402` | `type === "agent"` | **Violates "issues in `triage` can only be moved out by human actors"** (CLAUDE.md) | -| `issues.ts:579` | `type === "agent"` | Applies the `auto` label — **opts its own issue into unattended dispatch.** Privilege escalation, not bookkeeping | -| `issues.ts:389` | `type === "agent"` | `isHolderMutation` false ⇒ SYD-210 lease validation skipped entirely on a held issue | -| `issues.ts:412-427` | `type === "agent"` | `AGENT_STATUS_TRANSITIONS` allow-list bypassed; can reopen a `done` issue | -| `issues.ts:530` | `type === "agent"` | The `in_progress` claim gates (SYD-99/111/124) | -| `issues.ts:610` | `type === "agent"` | Agents may only self-assign; supervised can reassign or unassign anyone | -| `needs-input.ts:43` | `type === "agent"` | Holder lease check skipped on `request_human_input` | -| `pr-links.ts:241,242,254,291,305` | `type === "agent"` | See "the declare path" below | -| `pr-links.ts:585,598` | `!== "human"` / `=== "agent"` | See "the revoke path" below | +| `issues.ts:184,190,211` | `=== "agent"` | **Violates "agent-created issues land in `triage` with required provenance"** (CLAUDE.md) | +| `issues.ts:402` | `=== "agent"` | **Violates "issues in `triage` can only be moved out by human actors"** (CLAUDE.md) | +| `issues.ts:579` | `=== "agent"` | Applies `auto` — **opts its own issue into unattended dispatch.** Escalation | +| `issues.ts:389` | `=== "agent"` | `isHolderMutation` false ⇒ SYD-210 lease validation skipped on a held issue | +| `issues.ts:412-427` | `=== "agent"` | `AGENT_STATUS_TRANSITIONS` bypassed; can reopen a `done` issue | +| `issues.ts:530` | `=== "agent"` | The `in_progress` claim gates (SYD-99/111/124) | +| `issues.ts:610` | `=== "agent"` | Agents self-assign only; supervised can reassign anyone | +| `comments.ts:44` | `=== "human"` | **Clears `needsInput` and releases the claim** — an agent answers its own escalation. Contradicts human-clear-only (SYD-220/225) | +| `needs-input.ts:43` | `=== "agent"` | Holder lease check skipped on `request_human_input` | +| `pr-links.ts:241,242,248,254,289,291,305` | `=== "agent"` | See the predicate table | +| `pr-links.ts:585,593,598` | mixed | See the predicate table | | `dependencies.ts:134` | `!== "human"` | **Violates "dependency removal is human-only"** (CLAUDE.md) | -| `delivery-events.ts:54` | `type === "agent"` | The comment at `:48-53` says an agent posting these "could unblock its own issue or hide a failed delivery". A supervised principal is typed human and passes | -| `delivery-attempts.ts:209` | `type === "agent"` | Same shape | +| `delivery-events.ts:54` | `=== "agent"` | Its own comment: an agent posting these "could unblock its own issue or hide a failed delivery" | +| `delivery-attempts.ts:209` | `=== "agent"` | Same shape | -`update_issue` → `done` is the one gate that holds, and only because the divert -(`issues.ts:314-316`) fires on `attr.sessionId != null && isHardGated(...)` *before* -reaching `issues.ts:407`. That coupling is load-bearing and undocumented: remove `done` -from `supervised.hard_gate_actions` and `:407` falls open like the rest. +`update_issue` → `done` holds, but only because the divert (`issues.ts:314-316`) fires +before `:407`. **That coupling is load-bearing and undocumented** — drop `done` from +`supervised.hard_gate_actions` and `:407` falls open with the rest. -**Reachable at REST — see "the REST residual" below, this is not containment:** -`confirmPrLink` (`pr-links.ts:417`, exposed at `api-routes.ts:449`) gates on -`type === "agent"`, so a **`service`** actor passes and writes `confirmedBy: actor.id` +**Reachable at REST:** `confirmPrLink` (`pr-links.ts:417`, route `api-routes.ts:449`) +gates on `=== "agent"`, so a **`service`** actor passes and writes `confirmedBy: actor.id` (`:458`). `SWITCHYARD_GITHUB_POLLER_TOKEN` and `SWITCHYARD_DELIVER_POLLER_TOKEN` are -`service` actors that already speak REST, so this is live today. **This is SYD-298's -actual site.** Revision 1 claimed SYD-298 was absorbed by the `declare` fix; that was -wrong, and acting on it would have retired the issue with its primary vector open. +service actors that already speak REST. **This is SYD-298's actual site**, and it is not +absorbed by any declare-path fix. -**The conflation also fails closed.** `agent-sessions.ts:48` `requireAgent` demands -`type === "agent"`. A supervised principal is typed human, so `progress_note` is -**refused** in supervised sessions today. This is a live functional bug, and it is why -one accessor is not enough. +## The identity rule (new in revision 3) -## Approach +**In a supervised session the acting agent holds the work.** It is the assignee, the lease +holder, and the declarer. The human remains the accountable root on every event. -Three mechanisms, and a gate must state which one it wants. - -- **Divert** — the agent legitimately proposes; park a `pendingActions` row and let a - human release it out-of-band (`rest/pending-actions.ts:118-129`, or `/affirm-signed`). - Applies to `done` and `dependency.remove`, the two actions with executors. -- **Refusal** — the action is not something an agent proposes. The gate throws. -- **Agent-path** — the gate is not about humanity at all; it asks "is this an agent - credential, with the claim and lease that implies?" A supervised session must answer - **yes** here, because the agent is the one acting. - -### Two accessors, not one - -Revision 1 had `asHuman` only, which cannot express the agent-path question and forced -three services to reconstruct a `Principal` from a defaulted `Attribution`. +This is the decision revision 2 left open, and everything else depends on it. Without it +`actingAgent` cannot be applied to any claim-scoped check: keyed to the agent it compares +against a human assignee and ships inert; keyed to the human it leaves the hole open. ```ts // src/services/principal.ts declare const humanBrand: unique symbol; export type HumanActor = Actor & { readonly [humanBrand]: true }; -/** - * The human who is ACTING, as distinct from the human who is ACCOUNTABLE - * (`Principal.actor`). Null inside a supervised session: presence is not - * consent for everything that follows. - * - * Tests `viaAgent` as well as `sessionId`. They coincide today only because - * `src/server.ts:91-93` withholds attribution from plain sessions for an - * FK/logout reason (`deleteSession` hard-deletes rows `events.sessionId` - * references), not an authorization one. Keyed on both, a future principal - * carrying one without the other cannot fail open. - */ +/** (1) Who authorizes. Null in a supervised session: presence is not consent. */ export function asHuman(actor: Actor, ctx: Attribution): HumanActor | null { if (ctx.sessionId != null || ctx.viaAgentId != null) return null; return actor.type === "human" ? (actor as HumanActor) : null; } /** - * The identity whose claim and lease govern this write. In a supervised - * session that is the agent, never the accountable human — the human holds - * no lease. Null when no agent is acting. + * (2) Whose credential. Null when no agent is acting. + * Takes DbOrTx: every call site is inside a transaction. Returns null rather + * than throwing on a missing or retyped row — `getActorById` throws + * (actors.ts:75), which would turn "no agent acting" into a 500. + * Re-checks `type` at use, mirroring resolveSupervisedPrincipal:69, so a role + * change after the session was minted cannot launder an agent path. */ export function actingAgent(db: DbOrTx, actor: Actor, ctx: Attribution): Actor | null { - if (ctx.viaAgentId != null) return getActorById(db, ctx.viaAgentId); + if (ctx.viaAgentId != null) { + const row = db.select().from(actors).where(eq(actors.id, ctx.viaAgentId)).get(); + return row && row.type === "agent" ? toActor(row) : null; + } return actor.type === "agent" ? actor : null; } -``` - -`ctx` is **required**, with no default, on every function that calls either accessor. -That is the fix for cause (1): the compiler enforces the threading the authorization -depends on, instead of the fix inheriting the defect it was written to remove. An -optional parameter here would rebuild the hole. -`actingAgent` is what makes the agent-path expressible. It answers the objection that a -service holds `Attribution` (which carries `viaAgentId`, a number) and cannot resolve the -acting `Actor` — it resolves it from the db, once, at the point of use. - -### The three predicates, stated per line - -`declarePrLink`'s `isAgent` (`pr-links.ts:241`) currently drives four behaviours. They do -not all want the same predicate, and blanket-substituting one for `isAgent` breaks -ingestion by newly lease-gating the poller `service` actors. - -| line | today | becomes | why | -|---|---|---|---| -| `:242-249` assignee check | `isAgent` | `actingAgent(...) !== null` | claim-scoped; a supervised agent must hold the claim | -| `:251` `validateLease` | `isAgent`, keyed `actor.id` | `actingAgent(...)`, keyed **that agent's id** | the human holds no lease | -| `:254` role forcing | `isAgent` | `actingAgent(...) !== null` | agents must not mint `references` (`:251-253`) | -| `:291-292` `confirmedBy` | `isAgent` | `asHuman(...) !== null` | anything not a vouching human: agent, **service**, supervised | -| `:305` `confirmed:` payload | `isAgent` | `asHuman(...) !== null` | must track the row, or the audit event asserts a confirmation the row does not carry | +/** (3) Whose name goes on the row. Generalizes mcp/server.ts:417. */ +export function effectiveActor(db: DbOrTx, actor: Actor, ctx: Attribution): Actor { + return actingAgent(db, actor, ctx) ?? actor; +} +``` -`:305` was missing from revision 1's diff. Left unchanged it writes `confirmedBy: null` -to the row while emitting `pr_link_declared` with `confirmed: true` — manufacturing the -attestation in the audit stream while fixing the mutable row. +`ctx` is **required**, no default, on every function calling an accessor. -`revokePrLink` has the same split: the outer branch gate (`:585`) takes the `asHuman` -form, and the inner `validateLease` (`:598`) takes `actingAgent`. Converting only the -outer leaves "lease required" false and compares `declaredBy` against the *human's* id — -letting a supervised agent withdraw declarations its supervisor made in person, from -other sessions. Converting both to one predicate instead makes a `service` actor hit -`validateLease` with an id that can hold no lease (`issues.ts:376-380` denies services -wholesale), turning a working path into an unconditional throw. +### `{}` is the next fail-open, at the value level -### Correction to revision 1's prose +Requiring `ctx` stops omission, not a literal. `hard-gate.ts:181` and `:196-197` pass `{}` +today, and they are correct there — the executor runs as the human with deliberately empty +attribution. But they are two copy-paste exemplars sitting in the file. -The declare path emits `pr_link_declared` with `confirmed: !isAgent` -(`pr-links.ts:297-312`). It does **not** emit `pr_link_confirmed` /`humanConfirmed: -true` — that is `confirmPrLink` (`pr-links.ts:463-473`), which declare never calls. The -hole is real; the mechanism revision 1 described was not. +The ESLint rule must ban **both** `as HumanActor` outside `principal.ts`, and an +object-literal `{}` passed as `ctx` outside an allowlist naming those `hard-gate.ts` lines +with their reason. A ban on the cast alone leaves the value-level hole open. -## The conversion, by population +## Predicate tables -### Population A — human-only functions take `HumanActor` +### `declarePrLink` / `revokePrLink` -The brand rides on the return type, so a plain `Actor` is a compile error and -`npm run typecheck` enumerates the work. +| line | today | becomes | +|---|---|---| +| `:242-249` assignee check | `isAgent` | `actingAgent(...) !== null`, compared against `effectiveActor().id` | +| `:248` `validateLease` | keyed `actor.id` | keyed `effectiveActor().id` | +| `:254` role forcing | `isAgent` | `actingAgent(...) !== null` | +| `:289` `declaredBy` | `actor.id` | `effectiveActor(...).id` | +| `:291-292` `confirmedBy` | `isAgent` | `asHuman(...) !== null` | +| `:305` `confirmed:` payload | `isAgent` | `asHuman(...) !== null` — must track the row | +| `:585` outer branch | `!== "human"` | `asHuman(...) === null` | +| `:593` `declaredBy` compare | `actor.id` | `effectiveActor(...).id` | +| `:598` `validateLease` | `=== "agent"` | `actingAgent(...) !== null`, keyed `effectiveActor().id` | + +`:289` and `:593` must move together. Converting `:585` alone was revision 2's error: the +comparison stays human-keyed, so a supervised agent can withdraw declarations its +supervisor made in person. + +Do **not** blanket-substitute one predicate: `service` actors must keep passing the +lease gate untouched (`issues.ts:376-380` denies them claims wholesale). Note the poller +service actors reach links through `recordIngestedPrLink`, not `declarePrLink` — revision +2's "would break ingestion" rationale named the wrong function, though the caution stands. + +### `issues.ts` — the table revision 2 owed this file + +| line | today | becomes | +|---|---|---| +| `:184,190` provenance + description | `=== "agent"` | `actingAgent(...) !== null` | +| `:211` initial status | `=== "agent" ? "triage" : "backlog"` | `actingAgent(...) !== null ? "triage" : "backlog"` | +| `:214` `creatorId` | `actor.id` | `effectiveActor(...).id` | +| `:389` `isHolderMutation` | `=== "agent" && assigneeId === actor.id` | `actingAgent(...) !== null && assigneeId === effectiveActor().id` | +| `:402` triage exit | `=== "agent"` | `asHuman(...) === null` | +| `:407` done | `=== "agent"` | `asHuman(...) === null` (defence in depth behind the divert) | +| `:412-427` transition allow-list | `=== "agent"` | `actingAgent(...) !== null` | +| `:530` claim gates | `=== "agent"` | `actingAgent(...) !== null` | +| `:579` `auto` label | `=== "agent"` | `asHuman(...) === null` | +| `:610` self-assign only | `=== "agent"` | `actingAgent(...) !== null`, compared to `effectiveActor().id` | +| `:783` `claimIssue` assignee | `actor.name` | `effectiveActor(...).name` | +| `:769` `mintLease` | `actor.id` | `effectiveActor(...).id` | +| `assertClaimable:260` | `actor` | `effectiveActor(...)` | + +## Populations + +### A — human-only, take `HumanActor` ``` -src/services/settings.ts setSetting:206, resetSetting:227 helper :113 -src/services/actors.ts setActorAttended:107, rotateActorToken:120, - revokeActorToken:132 helper :12 -src/services/projects.ts createProject:17, updateProject:34 helper :11 -src/services/webhooks.ts addWebhook:18, removeWebhook:39, - setWebhookActive:48 helper :10 -src/services/github-repos.ts addGithubRepo:31, removeGithubRepo:57 helper :23 -src/services/triage-actions.ts snoozeIssue:54, markDuplicate:88, - redeliverIssue:137, resolveDeviation:250, - resolveDeliveryFailure:282 helper :45 -src/services/affirmation-keys.ts enrollAffirmationKey:44 (human check :51 only), - revokeAffirmationKey:124 -src/services/hard-gate.ts affirmPendingAction:91 ← root of trust -src/rest/pending-actions.ts :69, :126, :138 ← root of trust -src/services/pr-links.ts confirmPrLink:409, backfillPrLinksFromPrState:505 -src/services/supervised-sessions.ts openSupervisedSession:24 ← root of trust -src/rest/api-routes.ts requireHumanCaller:141 +settings.ts setSetting:206, resetSetting:227 helper :113 +actors.ts setActorAttended:107, rotateActorToken:120, + revokeActorToken:132 helper :12 +projects.ts createProject:17, updateProject:34 helper :11 +webhooks.ts addWebhook:18, removeWebhook:39, setWebhookActive:48 helper :10 +github-repos.ts addGithubRepo:31, removeGithubRepo:57 helper :23 +triage-actions.ts snoozeIssue:54, markDuplicate:88, redeliverIssue:137, + resolveDeviation:250, resolveDeliveryFailure:282 helper :45 +affirmation-keys.ts enrollAffirmationKey:44 (human check :51 only), + revokeAffirmationKey:124 +hard-gate.ts affirmPendingAction:91 ← root of trust +pr-links.ts confirmPrLink:409, backfillPrLinksFromPrState:505 +supervised-sessions.ts openSupervisedSession:24 ← root of trust +rest/api-routes.ts requireHumanCaller:141 (returns HumanActor, not void) ``` -Revision 1 used prose labels (`retryDelivery`, `resolveProcessDeviation`, `create/update/ -delete`) that are not real identifiers. The names above are grep-able. - -**Three additions are load-bearing rather than mechanical, and must not be reviewed as -part of the sweep:** +Load-bearing rather than mechanical: **`affirmPendingAction`** (its owner tie at +`hard-gate.ts:131-135` compares `session.actorId === human.id` — under a supervised +principal that *matches* rather than saving you); **`confirmPrLink`** (SYD-298's live +site); **`openSupervisedSession`** (mints fresh `sup_` tokens). -- **`affirmPendingAction` (`hard-gate.ts:91`)** turns a parked proposal into a real `done` - or a real dependency removal. It is the root of trust for the divert this whole design - rests on. Its owner tie (`hard-gate.ts:131-135`) compares `session.actorId === human.id` - — under a supervised principal that is the bound human's id, so the owner tie - **matches** rather than saving you. The day supervised auth reaches REST (which SYD-282 - plausibly wants), `/affirm-signed` authorizes on `c.var.actor` and a supervised agent - affirms the action it itself parked. That is not a weakened gate; it is the gate running - in reverse. -- **`confirmPrLink` (`pr-links.ts:409`)** is SYD-298's actual site, live today via a - `service` token at `api-routes.ts:449`. -- **`openSupervisedSession` (`supervised-sessions.ts:24`)** decides who can mint a - supervised session at all — reaching it yields a fresh 12h `sup_` token bound to the - same human under an agent name of the caller's choosing. +**`rest/pending-actions.ts:126` is deliberately NOT in this list.** Its actor comes from +`getSessionActor` (`auth.ts:60-69`), which returns no session id, so `asHuman` cannot be +applied as designed — and threading the real cookie session id would make it return +`null` and break the cookie affirm route, the strongest human-presence signal in the +system. That route is already correct for this exact reason (`:119-124`). Leave it. +`:69` and `:138` still convert. -### Population B — mixed functions take the predicates - -These must keep accepting any actor (to reach a divert, or to serve agents legitimately), -so the brand cannot be their parameter type. Each takes a **required** `ctx: Attribution` -and applies the predicate table per line. +### B — mixed, take required `ctx` and the predicate tables ``` -src/services/pr-links.ts declarePrLink:225, revokePrLink:566 -src/services/dependencies.ts removeDependency:71 -src/services/issues.ts createIssue:172, updateIssue:305 -src/services/needs-input.ts :43 -src/services/delivery-events.ts :54 -src/services/delivery-attempts.ts :209 -src/services/agent-sessions.ts requireAgent:47 ← the fail-closed one +pr-links.ts declarePrLink:225, revokePrLink:566 +dependencies.ts removeDependency:71 +issues.ts createIssue:172, updateIssue:305, claimIssue:725, assertClaimable:260 +comments.ts :44 ← moved from C; live hole +needs-input.ts :43 +delivery-events.ts :54 +delivery-attempts.ts :209 +agent-sessions.ts requireAgent:47 — three call sites: :126, :142, :209 ``` -`removeDependency` was filed in population A in revision 1. That was wrong: `hard-gate.ts:181` -and `mcp/server.ts:596` call it with mixed actors, and typing its parameter `HumanActor` -means the MCP adapter cannot call it at all — which deletes the divert this design is -trying to enable. - -`requireAgent` (`agent-sessions.ts:47`) becomes `actingAgent(...) !== null`, which fixes -the live fail-closed bug: `progress_note` starts working in supervised sessions. +`removeDependency` cannot be population A: `hard-gate.ts:181` and `mcp/server.ts:596` call +it with mixed actors, and typing it `HumanActor` deletes the divert. -### Population C — do not convert +**`requireAgent` has three callers, not one.** `:209` is `recordProgressNote` (already +correct via `mcp/server.ts:417`); `:126` and `:142` are `startAgentSession` / +`endAgentSession`, which have **no `Attribution` parameter at all** and whose owner tie +(`:145`) keys on `actor.id`. Converting `requireAgent` without threading `ctx` into those +two and re-keying the owner tie to `effectiveActor` reproduces the +owner-tie-matches-rather-than-saves-you pattern. Budget it. -These read a type for **routing, display, or as a data rule about a target**, not to -authorize the caller. Converting them changes behaviour nobody has decided to change. +### C — do not convert ``` -src/services/comments.ts:31,44 is this comment a question / clear needsInput -src/services/worker-preference.ts:30,51 interactive routing -src/services/issues.ts:664 clear needsInput on status change -src/services/pr-links.ts:140,470 display: was the confirmer a person -src/services/affirmation-keys.ts:54 target must be human — a data rule -src/services/auth.ts:15 login links belong to humans — a data rule -src/services/actors.ts:38 default `attended` at creation +worker-preference.ts:30,51 interactive routing +pr-links.ts:140,470 display: was the confirmer a person +affirmation-keys.ts:54 target must be human — data rule +auth.ts:15 login links belong to humans — data rule + (the caller gate is api-routes.ts:235) +actors.ts:38 default `attended` at creation +actors.ts:110 target must not be human — data rule ``` -`auth.ts:15` and `affirmation-keys.ts:54` are the same shape and are named explicitly -because a mechanical implementer will otherwise convert them. +`issues.ts:664` and `comments.ts:44` were in this list in revision 2. `:44` moves to B +(it authorizes). `:664` stays out **only** because the `:44` conversion subsumes it — +state that, rather than leaving it unexplained. ## The brand is not the enforcement -`HumanActor` is erased at runtime. The six private `requireHuman` helpers listed in -population A currently ask `actor.type !== "human"` — the question this design proves -returns the wrong answer for a supervised principal. - -**The predicate moves; the brand is additive.** Each helper takes enough to call -`asHuman` (actor + ctx) and throws when it is null, returning the branded value on -success. Deleting the runtime throw as "newly redundant" would make authorization purely -compile-time, and one `as HumanActor` cast — the CLI needs one — reduces the gate to a -no-op with no runtime trace. - -Add an ESLint rule banning `as HumanActor` outside `principal.ts` and the single CLI mint. -Revision 1 predicted the cast would become the new fail-open default under Risks but did -nothing to prevent it. - -## Where the accessors are called - -- **MCP.** Revision 1 said `mcp/server.ts` "holds the real `Principal`". It does not — - `src/server.ts:94-101` destructures it before the call, and `buildMcpServer` takes - `actor`, `attribution`, `viaAgent` separately. **Change `buildMcpServer` to take a - `Principal`** and mint once per connection. The alternative — every tool call - reassembling a principal by hand — is the pattern this design exists to remove. This is - ~20 threading sites that revision 1 neither listed nor budgeted. -- **REST.** `requireHumanCaller` returns `void` today (`api-routes.ts:141-145`), so it - cannot supply the branded value downstream. It becomes a function returning - `HumanActor`, and each human-only route passes its result rather than `c.var.actor`. -- **CLI.** One helper performs the runtime check and returns `HumanActor`; `cliActor` - (`cli.ts:22`) and `requireHumanActor` (`cli.ts:27`) both route through it. - **`linear-import.ts:352` constructs a second synthetic human** and passes it to - `createProject` at `:355` — it goes through the same helper, not a cast. +`HumanActor` is erased at runtime. The private `requireHuman` helpers currently ask +`actor.type !== "human"` — the question this design proves returns the wrong answer. + +**The predicate moves; the brand is additive.** Each helper takes actor + ctx, calls +`asHuman`, throws when null, returns the branded value. Deleting the runtime throw as +"newly redundant" makes authorization purely compile-time, and one cast reduces the gate +to a no-op with no runtime trace. + +## Adapters + +- **MCP.** Revision 2 said `mcp/server.ts` holds a `Principal`. It does not — + `server.ts:94-101` destructures before the call. Change `buildMcpServer` to take a + `Principal` and mint once per connection (~20 threading sites). `mcp/server.ts:417`'s + hand-rolled `viaAgent ?? actor` collapses into `effectiveActor`. +- **REST.** `requireHumanCaller` returns `void` (`api-routes.ts:141-145`) so it cannot + supply the branded value. It returns `HumanActor`; routes pass its result. + `api-routes.ts:672` is an unclassified `=== "agent"` gate — classify it. +- **CLI.** One helper does the runtime check and returns `HumanActor`. `cliActor` + (`cli.ts:22`), `requireHumanActor` (`cli.ts:27`), **and `linear-import.ts:352`'s second + synthetic human** all route through it. No casts. ## The REST residual — stated, not claimed as containment -Revision 1 proposed a test locking in "REST resolves no `sup_` token" and recorded the -REST gates as "unreachable **and** refused". The first half is true; the second is not the -boundary it sounds like. +A supervised session runs on the human's workstation, which holds `.env` with +`SWITCHYARD_HUMAN_TOKEN`; `scripts/syd.ts` defaults to it. The agent's path to every REST +gate is not a `sup_` token — it is the human's bearer, at which point `asHuman` returns a +`HumanActor`. **Nothing at REST distinguishes a bearer presented by a person from the same +bearer presented by a process.** The cookie-only affirm route exists for exactly this +reason. + +Keep the containment test; state the residual. Mitigation is credential hygiene plus the +cookie/signature routes, not `asHuman`. -A supervised session runs on the accountable human's workstation. That workstation has -`.env` with `SWITCHYARD_HUMAN_TOKEN`, and `scripts/syd.ts` defaults to it. The supervised -agent's path to every REST gate is not a `sup_` token — it is -`npx tsx scripts/syd.ts api POST /settings/…` with the human's bearer, at which point -`asHuman` returns a `HumanActor` and every converted gate opens. +## Migration -**Nothing at the REST layer can distinguish a human's bearer presented by a person from -the same bearer presented by a process.** The codebase already knows this: the cookie-only -affirm route (`rest/pending-actions.ts:118-129`) exists for exactly this reason and says -so in its comment. +Existing supervised claims were written with the **human** as assignee and lease holder. +After the identity rule they would be unrecognisable to `isHolderMutation`. -So: keep the test, but state the residual in the PR and in this spec. REST gates are -enforced against `sup_` principals; a supervised agent with filesystem access to the -human's bearer bypasses them, and the mitigation is credential hygiene plus the -cookie/signature routes — not `asHuman`. +Per CLAUDE.md this is mechanical — the mapping is a pure function of `events.viaAgentId` +on the claim event — so it belongs at startup like `ensureRolloutBackfill`, not as an +operator step. Scope it in the PR: how many live claims, and whether re-keying them or +simply expiring them is cheaper. **Expiring is likely correct** — a lease is short-lived +by design and a re-claim costs one call. ## Operator step @@ -308,84 +299,81 @@ cookie/signature routes — not `asHuman`. supervised.hard_gate_actions: ["done"] → ["done", "dependency.remove"] ``` -**This fails closed, not open.** Revision 1 said the code "ships with the hole still open" -without it; that is backwards. Once `dependencies.ts:134` asks `asHuman(...) === null`, a -supervised `remove_dependency` on an un-updated NAS skips the divert -(`dependencies.ts:78,89` require `isHardGated`) and lands in the converted branch — -**refused**. The setting upgrades refusal to a proposable divert. It is UX, not security, -and framing it correctly lowers the risk of a partially-applied rollout. - -`setSetting` validates against `EXECUTABLE_GATE_ACTIONS` (`settings.ts:157-163`), and -settings are REST-only with no MCP surface, so the write itself is safe. +**Fails closed.** Once `dependencies.ts:134` asks `asHuman(...) === null`, a supervised +removal on an un-updated NAS skips the divert (`:78,:89` need `isHardGated`) and is +**refused**. The setting upgrades refusal to a proposable divert — UX, not security. ## Behaviour changes | Action | Today | After | |---|---|---| -| `file_issue` (supervised) | lands `backlog`, no provenance | lands `triage`, provenance required | -| `update_issue` out of `triage` | permitted | refused | -| `update_issue` adding `auto` | permitted — self-escalation | refused | -| `update_issue` reassign/claim/transition | agent gates skipped | agent gates apply, lease keyed to `viaAgent` | -| `declare_pr_link` | auto-confirmed as the human | claim + lease required, role forced `delivers`, unconfirmed | -| `revoke_pr_link` | full human powers | own link + unconfirmed + agent's lease | -| `confirm_pr_link` (service, REST) | confirms; proof readers accept | refused | +| `claim_issue` (supervised) | assigns + leases the **human** | assigns + leases the **agent** | +| `file_issue` | `backlog`, no provenance | `triage`, provenance required | +| `update_issue` out of `triage` / adding `auto` | permitted | refused | +| `update_issue` reassign / transition / claim gates | skipped | agent rules apply | +| `comment` clearing `needsInput` | permitted | refused | +| `declare_pr_link` | auto-confirmed as the human | claim + lease required, `delivers` forced, `declaredBy` = agent, unconfirmed | +| `revoke_pr_link` | full human powers | own link (agent-keyed) + unconfirmed + lease | +| `confirm_pr_link` (service, REST) | confirms; readers accept | refused | | `remove_dependency` | executes | refused, or diverts once the setting lands | -| `progress_note` (supervised) | **refused (bug)** | works | -| delivery events (supervised) | can unblock its own issue | refused | +| delivery events | can unblock its own issue | refused | +| `progress_note` | works (`mcp/server.ts:417`) | works, via `effectiveActor` | | `update_issue` → `done` | diverts | unchanged | -**Second-order effect to state in the PR:** today's auto-confirm means supervised -deliveries never trip `done_without_merged_pr`. After the fix, an issue affirmed to `done` -whose link the human never separately confirmed fails the attention NOT-EXISTS -(`attention.ts:113-121` requires `confirmed_by IS NOT NULL`) and flags. That is the system -working as designed — the flag *is* the vouch reminder — but it is new steady-state -behaviour for the primary supervised workflow. The convention is: confirm the link in the -SYD-290 UI before affirming `done`. +**Second-order:** today's auto-confirm means supervised deliveries never trip +`done_without_merged_pr`. After the fix an issue affirmed `done` whose link was never +separately confirmed fails `attention.ts:113-121` and flags. That is the flag working as +designed, but it is new steady state. Convention: confirm the link in the SYD-290 UI +before affirming `done`. ## Testing -TDD. Driven through real entry points, not by constructing state — SYD-280 shipped inert -because its tests called `upsertPrState` directly while production never did. +TDD, driven through real entry points — SYD-280 shipped inert because its tests called +`upsertPrState` directly while production never did. -1. `asHuman` returns null iff `sessionId != null` **or** `viaAgentId != null` or the actor - is not human. `actingAgent` resolves `viaAgentId` in a supervised session and the actor - itself for a plain agent. +1. `asHuman` null iff `sessionId != null` or `viaAgentId != null` or not human. + `actingAgent` null on a missing row and on a row whose type is no longer `agent`. + `effectiveActor` returns the agent supervised, the actor otherwise. 2. Every population-A gate refuses a supervised principal. -3. Every reachable inverse gate refuses a supervised principal, driven through the MCP - tool: triage exit (`issues.ts:402`), the `auto` label (`:579`), reassign (`:610`), the - transition allow-list (`:412`), and supervised `file_issue` landing in `triage` with - provenance (`:184,190,211`). +3. Every reachable inverse gate refuses, driven through the MCP tool: triage exit, + `auto` label, reassign, transition allow-list, `file_issue` landing in `triage` with + provenance, and `comment` failing to clear `needsInput`. 4. `declare_pr_link` over a real `sup_` token: refused without a claim, refused without - the agent's lease, `role` forced to `delivers`, `provesLanded` false, **and the - `pr_link_declared` payload's `confirmed` field false.** Asserting only the row would - pass with the event still lying. -5. A `service` actor **confirming** via `POST /issues/:ref/pr-links/confirm` is refused — - this is SYD-298's actual case. A service *declaring* (revision 1's case 5) is not. -6. `remove_dependency` supervised: refused at the registry default, diverts once - `dependency.remove` is in `hard_gate_actions`. **Assert both**, in the same file, so the - operator dependency is visible in the diff rather than hidden in test setup. -7. `progress_note` works in a supervised session (the fail-closed regression). -8. Boundary: `dependencies.ts:89` also requires `edgeExists`, so removing a non-existent - edge never diverts and falls to `:134` — today a silent no-op, after the change a - refusal. - -Acceptance is the **SYD-213 pentest matrix** re-run against the new gates. + the **agent's** lease, `role` forced, `declaredBy` = the agent, `provesLanded` false, + **and the `pr_link_declared` payload's `confirmed` false.** +5. `revoke_pr_link`: the supervised agent can revoke its own declaration and **cannot** + revoke one the human declared in person. +6. A `service` actor **confirming** at `POST /issues/:ref/pr-links/confirm` is refused + (SYD-298's actual case). +7. `claim_issue` supervised assigns the agent; a subsequent claim-scoped write validates + against the agent's lease. +8. `remove_dependency`: refused at the registry default, diverts once the setting lands. + **Assert both in the same file** so the operator dependency is visible in the diff. +9. The cookie affirm route still works — a regression guard on the one gate deliberately + left alone. +10. Boundary: `dependencies.ts:89` needs `edgeExists`, so a non-existent edge never + diverts and falls to `:134` — a silent no-op today, a refusal after. + +Acceptance is the **SYD-213 pentest matrix** re-run. + +## Also required + +A message and comment sweep. Errors like "Only humans move issues out of triage" and +"only agent actors record progress notes" describe the old model. A supervised agent that +hits the new refusals gets text that does not explain why. ## Risks -- **Scope.** This is roughly double revision 1. The inverse-gate population in `issues.ts` - is the reason, and it is not optional: two of those sites are invariants CLAUDE.md - states as server-enforced, and one is a self-escalation into unattended dispatch. -- **`buildMcpServer`'s signature change** touches ~20 threading sites and was unbudgeted - in revision 1. -- **Review legibility.** The PR must let a reviewer tell the live fixes from the - prevention sweep without reconstructing the blast-radius table. Carry the table in the - PR description and order the commits: accessors first, then live holes, then the sweep. -- **The cast is the new fail-open default** unless the ESLint rule lands with the change, - not after it. +- **Scope.** Roughly triple revision 1. The identity rule pulls in `claimIssue`, the lease + mint, `assertClaimable`, and a data migration none of which were in the original story. +- **`buildMcpServer`'s signature** (~20 threading sites) and `startAgentSession` / + `endAgentSession` gaining `Attribution` are both unbudgeted expansions. +- **Review legibility.** Order the commits: accessors, then live holes, then the sweep. + Carry the blast-radius table in the PR so a reviewer can tell the fixes from the + prevention. +- **The lint rule must land with the change**, covering the cast *and* the `{}` literal. ## Not closed by this work **SYD-298 stays open** until `confirmPrLink` is converted and its service-token refusal -test passes. Revision 1's instruction to close it as absorbed would have retired the issue -with its primary vector live. +test passes. From dc13178c24da072e24a4ade3b7ffa911a0fc8a3a Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 21:43:23 -0400 Subject: [PATCH 04/16] =?UTF-8?q?docs:=20rev=204=20of=20SYD-281=20design?= =?UTF-8?q?=20=E2=80=94=20complete=20claim=20path=20after=20round=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reviews/2026-08-27-syd281-panel.md | 44 ++ .../2026-08-27-human-act-integrity-design.md | 559 +++++++++++------- 2 files changed, 386 insertions(+), 217 deletions(-) diff --git a/docs/reviews/2026-08-27-syd281-panel.md b/docs/reviews/2026-08-27-syd281-panel.md index 60e97f4..d8602c9 100644 --- a/docs/reviews/2026-08-27-syd281-panel.md +++ b/docs/reviews/2026-08-27-syd281-panel.md @@ -106,3 +106,47 @@ The `delivery-events.ts:54` / `delivery-attempts.ts:209` finding stands. Finding 19 is a product decision, not something a third review round resolves. Round 3 was held until the user answered it. + +--- + +# Round 3 — on revision 3 + +**7 REVISE**, but narrow. Fable: "architecture and identity rule hold up under full consumer +tracing." Opus: "four bounded, mechanical corrections to a plan that is otherwise ready — +none require rethinking the design." Populations A and C were verified identifier-by-identifier +against source and are complete. + +The identity rule introduced a large new surface, and revision 3's `issues.ts` table converted +the leaves while omitting the lines they depend on. + +| # | Finding | Seats | Disposition in rev 4 | +|---|---|---|---| +| 33 | **The identity rule breaks `claim_issue` outright.** `:675`'s mint condition (`changes.assigneeId === actor.id`) stays human-keyed, so the lease never mints and `claimIssue:787` throws "retry the claim" — unrecoverably, every retry takes the same branch | simplifier, executor, auditor, pentester, opus, fable | `:675`, `:682` in the table | +| 34 | **`heartbeatClaim:721` missing entirely** — the lease is agent-minted, `mcp/server.ts:382` passes the human, so `validateLease` throws on every heartbeat and every supervised claim expires with no renewal | simplifier, executor, auditor | Added to the table and population B | +| 35 | `:391`, `:506-507`, `:746`, `assertAssignee:281`, `needs-input.ts:44` — predicates converted, keys not | pentester, opus, executor, auditor, fable | All in the table | +| 36 | **`effectiveActor` fails open** — supervised ctx + unresolvable agent silently returns the human, and `asHuman` is null too, so the write is neither authorized as human nor represented as agent | architect, executor, auditor | Now **throws** | +| 37 | **The migration reopens SYD-210 and strands the issue.** Expiring the lease leaves `assigneeId`=human ⇒ `isHolderMutation` false ⇒ `validateLease` skipped; and `assertClaimable` throws "already claimed by \" forever | pentester, opus, executor, auditor | Uses `lease-cutover.ts`'s release-the-assignment precedent; `claim_leases` named as migration surface; ordering vs `ensureClaimLeaseCutover` stated | +| 38 | **The delivery predicate would break production.** `asHuman === null` refuses `service` actors, which must pass — `tests/services/service-actor.test.ts:46,57,61` assert it | opus | Predicate is `actingAgent(...) !== null`; those tests are an acceptance condition | +| 39 | Delivery sites were listed as MCP-reachable; they are REST-only (the MCP surface is 22 tools, none delivery) | opus | Blast radius split into MCP-reachable and REST-only | +| 40 | **`issues.ts:664` subsumption stated backwards.** `comments.ts:44` *delegates* to `updateIssue`; `:664` is reachable directly and fires on `patch.status !== undefined`, not on a change — so a **no-op status patch** clears `needsInput`. Rev 3's test would have passed while the hole stayed | opus, pentester | `:664` is the primary conversion; test drives the no-op patch | +| 41 | **Population A specified two incompatible ways** — heading says take `HumanActor`, the brand section says take `actor + ctx` | opus | Decided: services take `HumanActor`, adapters mint, private `requireHuman` helpers deleted | +| 42 | `requireHumanCaller`'s two call sites (`createActor`, `createLoginLink`) take **no actor parameter**, so there is no branded value to pass — the adapter throw is the entire gate on the two credential-minting routes | opus | Stated as an explicit exception | +| 43 | The `{}` lint rule is unimplementable — required ctx forces ~20 legitimate empty-attribution sites | pentester, opus | `NO_SESSION` sentinel, with an honest note that it is legibility, not security | +| 44 | **Binding a supervised session to `claude/dev` collapses `assertClaimable`** — free-form argv agent name + `getOrCreateActor` returning the existing actor; after the rule a container can `takeover` a live interactive claim | pentester | `openSupervisedSession` namespaces to `supervised//` | +| 45 | `nextTask` must stay human-keyed or `isAttendedCaller` breaks the interactive queue supervised sessions exist to serve | pentester | Population C **with the reason** | +| 46 | `recordProgressNote`'s provenance: `:417` passes the agent *as actor*, so `events.actorId` is the agent and `lastNoteFor` depends on it — the "human is accountable root on every event" invariant is false as stated | opus, executor | Invariant stated with the `progress_note` exception; collapse must preserve current behaviour | +| 47 | `requireDeliveryInfra` has four callers; `recordDeliveryEvent` has no `Attribution` at all | opus | Named; decision is to leave both, since neither is MCP-reachable | +| 48 | Carrier list: `pr-links.ts:572` not `:571`; `:730` double-counted (15 listed as 14); `queue.ts:76` and `attachments.ts:102` never classified | opus, simplifier | Corrected; both classified as carriers-not-gates | +| 49 | `pr-links.ts:470` mislabelled "display" — it drives the §5a recency exception | opus | Reason corrected in C | +| 50 | `api-routes.ts:672` `/github-events` is the same shape as the delivery gates (service must pass) | pentester, opus | Classified alongside them | + +## Verified correct under attack + +- **The divert still works with an agent assignee.** Traced rather than assumed: the divert + (`issues.ts:311-313`) fires before any assignee is read, and the executor re-drives as the + human with empty attribution (`hard-gate.ts:188-198`), so `actingAgent` returns null there. +- **The `done_without_merged_pr` second-order claim is exact** (`attention.ts:113-119`). +- **`ctx.viaAgentId` is not client-supplied** — closure-baked at `server.ts:99-100`, never a + tool argument. `actingAgent` introduces no identity-forgery primitive. Worth preserving as + an explicit invariant, since the rule turns that field into an identity selector. +- **No injection surface anywhere in the change.** diff --git a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md index 0bbb381..d65a7a7 100644 --- a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md +++ b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md @@ -1,110 +1,109 @@ # Human-act integrity: separating attribution from authorization (SYD-281) -Story B of the SYD-279 epic. **Revision 3** — after two rounds of a seven-seat review +Story B of the SYD-279 epic. **Revision 4** — after three rounds of a seven-seat review panel (2026-08-27). Findings and disposition: `docs/reviews/2026-08-27-syd281-panel.md`. ## The problem -`resolveSupervisedPrincipal` (`src/services/supervised-sessions.ts:53-76`) resolves a -supervised session to a `Principal` whose `actor` is the **bound human**. Correct for -attribution — the human is accountable, and events carry dual provenance via -`viaAgentId`/`sessionId`. Wrong for authorization, and wrong for identity. +`resolveSupervisedPrincipal` (`supervised-sessions.ts:53-76`) resolves a supervised session +to a `Principal` whose `actor` is the **bound human**. Correct for attribution. Wrong for +authorization, and wrong for identity. -Three distinct questions have been answered by one field: +Three questions have been answered by one field: -1. **Who authorizes this?** — must be a human *acting*, not a human *accountable*. +1. **Who authorizes this?** — a human *acting*, not a human *accountable*. 2. **Whose credential is this?** — the agent's, in a supervised session. -3. **Whose name goes on the row?** — the assignee, the lease holder, the declarer. +3. **Whose name goes on the row?** — the assignee, lease holder, declarer. -`actor.type === "human"` answers (1) wrongly. `actor.type === "agent"` answers (2) -wrongly. `actor.id` answers (3) wrongly. A supervised principal is typed human and is not -typed agent, so it passes gates of *both* shapes. +`type === "human"` answers (1) wrongly, `type === "agent"` answers (2) wrongly, `actor.id` +answers (3) wrongly. A supervised principal is typed human and is not typed agent, so it +passes gates of both shapes. -Two mechanisms let it spread: +**The fail-open default.** Fourteen carriers declare `attr: Attribution = {}`, so "no +session" and "caller forgot the session" are the same value: -- **A fail-open default.** Services declare `attr: Attribution = {}`, so "no session" and - "caller forgot to thread the session" are the same value. **Fourteen** carriers: - `comments.ts:16`, `dependencies.ts:30,76`, `issues.ts:172,311,730` and `claimIssue:730`, - `needs-input.ts:21`, `pr-links.ts:231,414,571`, `agent-sessions.ts:207`, - `attachments.ts:102`, `queue.ts:76`, `mcp/server.ts:75`. -- **Two gate shapes.** Revision 1 surveyed only `!== "human"` and therefore missed the - more dangerous half. +``` +mcp/server.ts:75 services/issues.ts:172, :311, :730 +services/comments.ts:16 services/queue.ts:76 +services/dependencies.ts:30, :76 services/pr-links.ts:231, :414, :572 +services/needs-input.ts:21 services/agent-sessions.ts:207 +services/attachments.ts:102 +``` -### The precedent this design generalizes +`queue.ts:76` and `attachments.ts:102` are **carriers, not authorization sites** — neither +is human-gated (`api-routes.ts:457-459` says so for the queue) and neither calls an +accessor. They keep their defaults. Named here so the sweep is closed. -`mcp/server.ts:417` already solves the identity problem, by hand, at exactly one site: +### The precedent this generalizes + +`mcp/server.ts:417` already solves the identity problem by hand at one site: ```ts -// The one agent-scoped write: recordProgressNote's requireAgent rejects a -// human, and in a supervised session `actor` IS the human root — so act as -// the bound agent instead of relaxing the guard. recordProgressNote(db, viaAgent ?? actor, ref, note, attribution); ``` -That is `effectiveActor`, hand-rolled. Revision 2 wrongly called `progress_note` a live -fail-closed bug; it works, because of this line. The finding is not that it is broken — -it is that one site got it right and nothing generalized it. - -### Blast radius, both gate shapes +Revision 2 wrongly called `progress_note` a fail-closed bug. It works, because of this +line. The finding is that one site got it right and nothing generalized it. -From `grep -rn 'type === "human"\|type !== "human"\|type === "agent"\|type !== "agent"\|type === "service"' src/`. +### Blast radius -**Reachable today from `/mcp` with a `sup_` token:** +**Reachable from `/mcp` with a `sup_` token** (the MCP surface is 22 tools; delivery, +`confirm_pr_link`, and agent-session start/end are *not* among them): | Site | Gate | What a supervised agent gets | |---|---|---| -| `issues.ts:184,190,211` | `=== "agent"` | **Violates "agent-created issues land in `triage` with required provenance"** (CLAUDE.md) | -| `issues.ts:402` | `=== "agent"` | **Violates "issues in `triage` can only be moved out by human actors"** (CLAUDE.md) | -| `issues.ts:579` | `=== "agent"` | Applies `auto` — **opts its own issue into unattended dispatch.** Escalation | -| `issues.ts:389` | `=== "agent"` | `isHolderMutation` false ⇒ SYD-210 lease validation skipped on a held issue | -| `issues.ts:412-427` | `=== "agent"` | `AGENT_STATUS_TRANSITIONS` bypassed; can reopen a `done` issue | +| `issues.ts:184,190,211` | `=== "agent"` | **Violates "agent-created issues land in `triage` with provenance"** (CLAUDE.md) | +| `issues.ts:402` | `=== "agent"` | **Violates "only humans move issues out of triage"** (CLAUDE.md) | +| `issues.ts:579` | `=== "agent"` | Applies `auto` — **opts its own issue into unattended dispatch** | +| `issues.ts:389` | `=== "agent"` | `isHolderMutation` false ⇒ SYD-210 lease validation skipped | +| `issues.ts:412-427` | `=== "agent"` | Transition allow-list bypassed; can reopen `done` | | `issues.ts:530` | `=== "agent"` | The `in_progress` claim gates (SYD-99/111/124) | -| `issues.ts:610` | `=== "agent"` | Agents self-assign only; supervised can reassign anyone | -| `comments.ts:44` | `=== "human"` | **Clears `needsInput` and releases the claim** — an agent answers its own escalation. Contradicts human-clear-only (SYD-220/225) | -| `needs-input.ts:43` | `=== "agent"` | Holder lease check skipped on `request_human_input` | -| `pr-links.ts:241,242,248,254,289,291,305` | `=== "agent"` | See the predicate table | +| `issues.ts:610` | `=== "agent"` | Agents self-assign only; supervised reassigns anyone | +| `issues.ts:664` | `=== "human"` | **Clears `needsInput` on a no-op status patch** — violates human-clear-only (SYD-220/225) | +| `comments.ts:44` | `=== "human"` | Same, via `addComment` | +| `needs-input.ts:43` | `=== "agent"` | Holder lease check skipped | +| `pr-links.ts:241,243,248,254,289,291,305` | `=== "agent"` | See the predicate table | | `pr-links.ts:585,593,598` | mixed | See the predicate table | | `dependencies.ts:134` | `!== "human"` | **Violates "dependency removal is human-only"** (CLAUDE.md) | -| `delivery-events.ts:54` | `=== "agent"` | Its own comment: an agent posting these "could unblock its own issue or hide a failed delivery" | -| `delivery-attempts.ts:209` | `=== "agent"` | Same shape | -`update_issue` → `done` holds, but only because the divert (`issues.ts:314-316`) fires -before `:407`. **That coupling is load-bearing and undocumented** — drop `done` from -`supervised.hard_gate_actions` and `:407` falls open with the rest. +**REST-only, where `ctx` is always empty** (`sup_` resolves only at `/mcp`, `server.ts:77`; +REST bearer auth is `authenticate()`, `api-routes.ts:153`): -**Reachable at REST:** `confirmPrLink` (`pr-links.ts:417`, route `api-routes.ts:449`) -gates on `=== "agent"`, so a **`service`** actor passes and writes `confirmedBy: actor.id` -(`:458`). `SWITCHYARD_GITHUB_POLLER_TOKEN` and `SWITCHYARD_DELIVER_POLLER_TOKEN` are -service actors that already speak REST. **This is SYD-298's actual site**, and it is not -absorbed by any declare-path fix. +| Site | Gate | Note | +|---|---|---| +| `pr-links.ts:417` → `api-routes.ts:449` | `=== "agent"` | A **`service`** actor confirms and writes `confirmedBy`. **SYD-298's actual site** | +| `delivery-events.ts:54`, `delivery-attempts.ts:209` | `=== "agent"` | Service actors **must pass** — see population B | +| `api-routes.ts:672` `/github-events` | `=== "agent"` | Same shape, same answer: service must pass | + +Revision 3 listed the delivery sites as MCP-reachable. They are not. -## The identity rule (new in revision 3) +`update_issue` → `done` holds only because the divert (`issues.ts:314-316`) fires before +`:407`. **That coupling is load-bearing and undocumented.** Verified: the divert reads no +assignee, and the executor re-drives as the human with empty attribution +(`hard-gate.ts:188-198`), so `actingAgent` returns null there and the divert is unchanged +by this work. -**In a supervised session the acting agent holds the work.** It is the assignee, the lease -holder, and the declarer. The human remains the accountable root on every event. +## The identity rule -This is the decision revision 2 left open, and everything else depends on it. Without it -`actingAgent` cannot be applied to any claim-scoped check: keyed to the agent it compares -against a human assignee and ships inert; keyed to the human it leaves the hole open. +**In a supervised session the acting agent holds the work** — assignee, lease holder, +declarer. The human remains the accountable root on events, **except `progress_note`**, +which is agent-scoped by design (see below). ```ts // src/services/principal.ts declare const humanBrand: unique symbol; export type HumanActor = Actor & { readonly [humanBrand]: true }; -/** (1) Who authorizes. Null in a supervised session: presence is not consent. */ +/** (1) Who authorizes. Null in a supervised session. */ export function asHuman(actor: Actor, ctx: Attribution): HumanActor | null { if (ctx.sessionId != null || ctx.viaAgentId != null) return null; return actor.type === "human" ? (actor as HumanActor) : null; } /** - * (2) Whose credential. Null when no agent is acting. - * Takes DbOrTx: every call site is inside a transaction. Returns null rather - * than throwing on a missing or retyped row — `getActorById` throws - * (actors.ts:75), which would turn "no agent acting" into a 500. - * Re-checks `type` at use, mirroring resolveSupervisedPrincipal:69, so a role - * change after the session was minted cannot launder an agent path. + * (2) Whose credential. Returns null rather than throwing on a missing or + * retyped row — getActorById throws (actors.ts:75), which would turn "no agent + * acting" into a 500. Re-checks type, mirroring resolveSupervisedPrincipal:69. */ export function actingAgent(db: DbOrTx, actor: Actor, ctx: Attribution): Actor | null { if (ctx.viaAgentId != null) { @@ -114,184 +113,298 @@ export function actingAgent(db: DbOrTx, actor: Actor, ctx: Attribution): Actor | return actor.type === "agent" ? actor : null; } -/** (3) Whose name goes on the row. Generalizes mcp/server.ts:417. */ +/** + * (3) Whose name goes on the row. FAILS CLOSED: when ctx says supervised but no + * agent resolves, falling back to `actor` would silently restore the human-as- + * holder state this work exists to remove — and since asHuman also returns null + * there, the write would be neither authorized as human nor represented as agent. + */ export function effectiveActor(db: DbOrTx, actor: Actor, ctx: Attribution): Actor { - return actingAgent(db, actor, ctx) ?? actor; + const agent = actingAgent(db, actor, ctx); + if (agent) return agent; + if (ctx.sessionId != null || ctx.viaAgentId != null) { + throw new SwitchyardError( + "This supervised session's agent actor no longer resolves — re-open the session.", + ); + } + return actor; } ``` -`ctx` is **required**, no default, on every function calling an accessor. +`ctx.viaAgentId` is **server-minted and closure-baked** (`server.ts:99-100`; `mcp/server.ts:68-78` +states why it is never a tool argument). `actingAgent` turns it into an identity selector, +which raises the cost of ever making it caller-supplied — record that as an invariant in +the PR. + +### `ctx` threading and the `NO_SESSION` sentinel -### `{}` is the next fail-open, at the value level +`ctx` is required, no default. That forces ~20 REST/CLI/worker sites to pass an empty +attribution, so a lint rule banning a bare `{}` with a two-line allowlist would be +abandoned on contact. -Requiring `ctx` stops omission, not a literal. `hard-gate.ts:181` and `:196-197` pass `{}` -today, and they are correct there — the executor runs as the human with deliberately empty -attribution. But they are two copy-paste exemplars sitting in the file. +```ts +// src/services/attribution.ts +export const NO_SESSION: Attribution = {}; +``` -The ESLint rule must ban **both** `as HumanActor` outside `principal.ts`, and an -object-literal `{}` passed as `ctx` outside an allowlist naming those `hard-gate.ts` lines -with their reason. A ban on the cast alone leaves the value-level hole open. +The rule bans the bare literal and permits the sentinel. `hard-gate.ts:197` uses it with +its existing comment (note `:196` is the **lease channel** `{}`, not `ctx`). Be honest in +the PR: **the sentinel is a legibility control, not a security one.** A caller that wrongly +reaches for `NO_SESSION` in a supervised path fails open exactly as `{}` does. What it buys +is that the wrong choice is a named, greppable token instead of two invisible braces. ## Predicate tables -### `declarePrLink` / `revokePrLink` +### `pr-links.ts` | line | today | becomes | |---|---|---| -| `:242-249` assignee check | `isAgent` | `actingAgent(...) !== null`, compared against `effectiveActor().id` | +| `:243` assignee check | `isAgent` | `actingAgent(...) !== null`, compared to `effectiveActor().id` | | `:248` `validateLease` | keyed `actor.id` | keyed `effectiveActor().id` | | `:254` role forcing | `isAgent` | `actingAgent(...) !== null` | | `:289` `declaredBy` | `actor.id` | `effectiveActor(...).id` | -| `:291-292` `confirmedBy` | `isAgent` | `asHuman(...) !== null` | -| `:305` `confirmed:` payload | `isAgent` | `asHuman(...) !== null` — must track the row | +| `:291-292` `confirmedBy/At` | `isAgent` | `asHuman(...) !== null` | +| `:305` `confirmed:` payload | `isAgent` | `asHuman(...) !== null` | | `:585` outer branch | `!== "human"` | `asHuman(...) === null` | | `:593` `declaredBy` compare | `actor.id` | `effectiveActor(...).id` | -| `:598` `validateLease` | `=== "agent"` | `actingAgent(...) !== null`, keyed `effectiveActor().id` | +| `:598` inner lease | `=== "agent"` | `actingAgent(...) !== null`, keyed `effectiveActor().id` | -`:289` and `:593` must move together. Converting `:585` alone was revision 2's error: the -comparison stays human-keyed, so a supervised agent can withdraw declarations its +`:289` and `:593` must move together, or a supervised agent can withdraw declarations its supervisor made in person. -Do **not** blanket-substitute one predicate: `service` actors must keep passing the -lease gate untouched (`issues.ts:376-380` denies them claims wholesale). Note the poller -service actors reach links through `recordIngestedPrLink`, not `declarePrLink` — revision -2's "would break ingestion" rationale named the wrong function, though the caution stands. +### `issues.ts` — complete, including the claim path -### `issues.ts` — the table revision 2 owed this file +Revision 3's table converted the leaves and omitted the lines they depend on. **As written +it made supervised `claim_issue` throw on its first call.** | line | today | becomes | |---|---|---| | `:184,190` provenance + description | `=== "agent"` | `actingAgent(...) !== null` | -| `:211` initial status | `=== "agent" ? "triage" : "backlog"` | `actingAgent(...) !== null ? "triage" : "backlog"` | +| `:211` initial status | `=== "agent" ? "triage" : "backlog"` | `actingAgent(...) !== null ? …` | | `:214` `creatorId` | `actor.id` | `effectiveActor(...).id` | +| `:281` `assertAssignee` holder | `assigneeId === actor.id` | `=== effectiveActor(...).id` | | `:389` `isHolderMutation` | `=== "agent" && assigneeId === actor.id` | `actingAgent(...) !== null && assigneeId === effectiveActor().id` | +| **`:391` `validateLease`** | keyed `actor.id` | keyed `effectiveActor(...).id` | | `:402` triage exit | `=== "agent"` | `asHuman(...) === null` | -| `:407` done | `=== "agent"` | `asHuman(...) === null` (defence in depth behind the divert) | -| `:412-427` transition allow-list | `=== "agent"` | `actingAgent(...) !== null` | +| `:407` done | `=== "agent"` | `asHuman(...) === null` (defence behind the divert) | +| `:412-427` transitions | `=== "agent"` | `actingAgent(...) !== null` | +| **`:506` auto-claim assign** | `actor.id` | `effectiveActor(...).id` | +| **`:507` `assigned` payload** | `actor.name` | `effectiveActor(...).name` | | `:530` claim gates | `=== "agent"` | `actingAgent(...) !== null` | | `:579` `auto` label | `=== "agent"` | `asHuman(...) === null` | -| `:610` self-assign only | `=== "agent"` | `actingAgent(...) !== null`, compared to `effectiveActor().id` | +| `:610` self-assign only | `=== "agent"` | `actingAgent(...) !== null`, vs `effectiveActor().id` | +| `:664` `needsInput` clear | `=== "human"` | `asHuman(...) !== null` | +| **`:675` mint condition** | `changes.assigneeId === actor.id` | `=== effectiveActor(...).id` | +| **`:682` `mintLease`** | `actor.id` | `effectiveActor(...).id` | +| **`:721` `heartbeatClaim`** | `heartbeatLease(…, actor.id, …)` | `effectiveActor(...).id` | +| **`:746` re-claim branch** | `assigneeId === actor.id` | `=== effectiveActor(...).id` | +| `:769` takeover `mintLease` | `actor.id` | `effectiveActor(...).id` | | `:783` `claimIssue` assignee | `actor.name` | `effectiveActor(...).name` | -| `:769` `mintLease` | `actor.id` | `effectiveActor(...).id` | | `assertClaimable:260` | `actor` | `effectiveActor(...)` | +| `needs-input.ts:43` predicate | `=== "agent"` | `actingAgent(...) !== null` | +| **`needs-input.ts:44` lease key** | `actor.id` | `effectiveActor(...).id` | + +**Event `actorId` stays the human.** `:693` (`assigned`) and `:762` (`lease_taken_over`) +keep `actor.id`, paired with `viaAgentId` — the row becomes the agent, the audit line keeps +dual attribution. `effectiveActor` is for **row-identity columns only** +(`assigneeId`, `creatorId`, `declaredBy`, lease `actorId`); it must never replace +`events.actorId` or `affirmedById`. Applying it to `recordEvent` would collapse dual +attribution and silently rewrite the event log. + +**`heartbeatClaim` was missing from revision 3 entirely**, and the rule breaks it: the lease +is agent-minted while `mcp/server.ts:382` passes the human, so `validateLease` +(`leases.ts:79`) throws on every heartbeat and every supervised claim expires at TTL with no +renewal path. + +**`issues.ts:664` is the primary `needsInput` conversion, not `comments.ts:44`.** Revision 3 +had the dependency backwards: `comments.ts:44` *delegates* to `updateIssue` (`comments.ts:57`) +and the clear happens at `:664`. `:664` is reachable directly from `update_issue`, and fires +on `patch.status !== undefined` — not on a *change* — so a supervised agent clears its own +escalation with a **no-op status patch**, never touching `comment`. Revision 3's test would +have passed while the invariant stayed broken. ## Populations ### A — human-only, take `HumanActor` ``` -settings.ts setSetting:206, resetSetting:227 helper :113 -actors.ts setActorAttended:107, rotateActorToken:120, - revokeActorToken:132 helper :12 -projects.ts createProject:17, updateProject:34 helper :11 -webhooks.ts addWebhook:18, removeWebhook:39, setWebhookActive:48 helper :10 -github-repos.ts addGithubRepo:31, removeGithubRepo:57 helper :23 -triage-actions.ts snoozeIssue:54, markDuplicate:88, redeliverIssue:137, - resolveDeviation:250, resolveDeliveryFailure:282 helper :45 -affirmation-keys.ts enrollAffirmationKey:44 (human check :51 only), - revokeAffirmationKey:124 -hard-gate.ts affirmPendingAction:91 ← root of trust -pr-links.ts confirmPrLink:409, backfillPrLinksFromPrState:505 +settings.ts setSetting:206, resetSetting:227 +actors.ts setActorAttended:107, rotateActorToken:120, revokeActorToken:132 +projects.ts createProject:17, updateProject:34 +webhooks.ts addWebhook:18, removeWebhook:39, setWebhookActive:48 +github-repos.ts addGithubRepo:31, removeGithubRepo:57 +triage-actions.ts snoozeIssue:54, markDuplicate:88, redeliverIssue:137, + resolveDeviation:250, resolveDeliveryFailure:282 +affirmation-keys.ts enrollAffirmationKey:44 (human check :51 only), revokeAffirmationKey:124 +hard-gate.ts affirmPendingAction:91 ← root of trust +pr-links.ts confirmPrLink:409, backfillPrLinksFromPrState:505 supervised-sessions.ts openSupervisedSession:24 ← root of trust -rest/api-routes.ts requireHumanCaller:141 (returns HumanActor, not void) +rest/pending-actions.ts :69, :138 ``` -Load-bearing rather than mechanical: **`affirmPendingAction`** (its owner tie at -`hard-gate.ts:131-135` compares `session.actorId === human.id` — under a supervised -principal that *matches* rather than saving you); **`confirmPrLink`** (SYD-298's live -site); **`openSupervisedSession`** (mints fresh `sup_` tokens). - -**`rest/pending-actions.ts:126` is deliberately NOT in this list.** Its actor comes from -`getSessionActor` (`auth.ts:60-69`), which returns no session id, so `asHuman` cannot be -applied as designed — and threading the real cookie session id would make it return -`null` and break the cookie affirm route, the strongest human-presence signal in the -system. That route is already correct for this exact reason (`:119-124`). Leave it. -`:69` and `:138` still convert. +**Signature decision (revision 3 specified this two incompatible ways).** These functions +take `HumanActor` and **nothing else changes about their signatures** — no `ctx` parameter. +The adapter holds the `Principal`, so the adapter calls `asHuman(actor, ctx)`, throws when +null, and passes the branded value. The private `requireHuman` helpers +(`settings.ts:113`, `actors.ts:12`, `projects.ts:11`, `webhooks.ts:10`, +`github-repos.ts:23`, `triage-actions.ts:45`) become genuinely redundant and are **deleted**; +the ESLint ban on `as HumanActor` is the backstop that keeps the type honest. + +This is "adapters mint, services demand" taken literally. It keeps ~20 service signatures +and their call sites unchanged apart from the parameter type, which is what makes the sweep +mechanical. + +**Two exceptions where the adapter throw is the entire gate.** `requireHumanCaller`'s only +call sites are `api-routes.ts:204` (`createActor`) and `:235` (`createLoginLink`), and +neither service takes an actor parameter at all. There is no branded value to pass. These +are the two credential-minting routes — the highest-value REST gates in the system — and +their protection is a runtime throw at the adapter, not a type. Say so; do not imply a +type-level guarantee that cannot exist there. + +**`rest/pending-actions.ts:126` is deliberately excluded.** Its actor comes from +`getSessionActor` (`auth.ts:60-69`, `kind='plain'` filter at `:65`), which returns no session +id. Threading one would make `asHuman` return null and break the cookie affirm route — the +strongest human-presence signal in the system, already correct for this exact reason +(`:119-126`). `:172` reuses `:138`'s value. ### B — mixed, take required `ctx` and the predicate tables ``` -pr-links.ts declarePrLink:225, revokePrLink:566 -dependencies.ts removeDependency:71 -issues.ts createIssue:172, updateIssue:305, claimIssue:725, assertClaimable:260 -comments.ts :44 ← moved from C; live hole -needs-input.ts :43 -delivery-events.ts :54 -delivery-attempts.ts :209 -agent-sessions.ts requireAgent:47 — three call sites: :126, :142, :209 +pr-links.ts declarePrLink:225, revokePrLink:566 +dependencies.ts removeDependency:71 +issues.ts createIssue:168, updateIssue:305, claimIssue:725, + assertClaimable:260, assertAssignee:280, heartbeatClaim:715 +comments.ts :44 (defence in depth; :664 is the primary conversion) +needs-input.ts :43, :44 +agent-sessions.ts requireAgent:47 — three callers: :126, :142, :209 +delivery-events.ts :54 → `actingAgent(...) !== null` +delivery-attempts.ts requireDeliveryInfra:208 — four callers: :228, :290, :334, :371 +rest/api-routes.ts :672 /github-events → `actingAgent(...) !== null` ``` -`removeDependency` cannot be population A: `hard-gate.ts:181` and `mcp/server.ts:596` call -it with mixed actors, and typing it `HumanActor` deletes the divert. - -**`requireAgent` has three callers, not one.** `:209` is `recordProgressNote` (already -correct via `mcp/server.ts:417`); `:126` and `:142` are `startAgentSession` / -`endAgentSession`, which have **no `Attribution` parameter at all** and whose owner tie -(`:145`) keys on `actor.id`. Converting `requireAgent` without threading `ctx` into those -two and re-keying the owner tie to `effectiveActor` reproduces the -owner-tie-matches-rather-than-saves-you pattern. Budget it. +**The delivery predicate is `actingAgent(...) !== null`, NOT `asHuman(...) === null`.** +Service actors **must** pass these gates — `SWITCHYARD_DELIVER_POLLER_TOKEN` and +`SWITCHYARD_GITHUB_POLLER_TOKEN` are service actors, and +`tests/services/service-actor.test.ts:46,57,61` already assert it. Reading revision 3's +"refused" as `asHuman` would have **stopped production delivery**. `actingAgent(...) !== null` +is behaviourally identical to today and additionally refuses a supervised agent. Keeping +those three tests green is an acceptance condition. + +`recordDeliveryEvent` (`delivery-events.ts:41-46`) has **no `Attribution` parameter** and +neither do its callers; `requireDeliveryInfra` has four. Both are the same unbudgeted +expansion flagged for `startAgentSession`/`endAgentSession`. Since neither is MCP-reachable, +**the cheapest correct answer is to leave both as they are with a note** — they are +REST-only and `ctx` is always empty there. Decide in the PR; do not leave it to the +implementer. + +**`requireAgent`'s other two callers.** `:126`/`:142` (`startAgentSession`/`endAgentSession`) +have no `Attribution` parameter, and the owner tie at `:150` (`existing.actorId !== actor.id`) +keys on `actor.id` — the same "owner tie matches rather than saves you" shape flagged as +load-bearing for `affirmPendingAction`. Budget it, and re-key the tie to `effectiveActor`. + +**`recordProgressNote` keeps its current provenance.** Today `mcp/server.ts:417` passes the +agent *as the actor*, so `events.actorId` is the agent and `lastNoteFor` finds notes by +`events.actorId === agentSessions.actorId`. The collapse to `effectiveActor` must preserve +that exactly — passing the human with `viaAgentId` would change the event and break +`lastNoteFor`. This is the one event where the agent is the `actorId` by design; the +"accountable root" invariant is stated with this exception. ### C — do not convert ``` worker-preference.ts:30,51 interactive routing -pr-links.ts:140,470 display: was the confirmer a person +pr-links.ts:140 display: was the confirmer a person +pr-links.ts:470 humanConfirmed on pr_link_confirmed — drives the §5a recency + exception (:467-469). Safe once confirmPrLink is population A, + because only a real human reaches it. NOT "display" affirmation-keys.ts:54 target must be human — data rule -auth.ts:15 login links belong to humans — data rule - (the caller gate is api-routes.ts:235) +auth.ts:15 login links belong to humans — data rule (caller gate is + api-routes.ts:235) actors.ts:38 default `attended` at creation actors.ts:110 target must not be human — data rule +comments.ts:31 AGENT_QUESTION_RE signalling — not authorization +nextTask (dependencies.ts:257) MUST stay human-keyed — see below ``` -`issues.ts:664` and `comments.ts:44` were in this list in revision 2. `:44` moves to B -(it authorizes). `:664` stays out **only** because the `:44` conversion subsumes it — -state that, rather than leaving it unexplained. +**`nextTask` is the one place where *not* applying the rule is the security-relevant +choice.** `dependencies.ts:257` filters `eq(issues.assigneeId, actor.id)`, so after the rule +a supervised `next_task` stops surfacing the agent's own claimed work — a real cost. But the +actor passed to `nextTask` must stay the **human**, because `isAttendedCaller` +(`worker-preference.ts:50-52`) reads it to decide whether to apply the hard +`worker_preference <> 'interactive'` filter (`dependencies.ts:281-284`). Key it to the agent +and supervised sessions lose the interactive queue they exist to serve. Accept the narrower +result and write the reason down. + +## New attack surface the identity rule creates + +`openSupervisedSession` takes a **free-form agent name from argv** (`cli.ts:91`, `:104`) and +`getOrCreateActor` returns the *existing* actor for a known name (`actors.ts:61-68`). So +`mint-supervised-session sean claude/dev` binds a session to a live dispatch worker's actor +(`worker-preference.ts:22-24`). + +Harmless today — a supervised claim assigns the human, so a container's `assertClaimable` +refuses with "already claimed by Sean". **After the identity rule the assignee is that same +agent actor**, so `assertClaimable:260` early-returns and `claimIssue:746` downgrades the +refusal to "pass `takeover: true`". A dispatched container could seize a live interactive +session's claim on a path that previously refused outright. Lease tokens still separate the +two sessions' writes, so this is defence-in-depth loss rather than an authorization bypass — +but `assertClaimable` is the guard SYD-93 exists because of. + +**Fix: namespace supervised agents.** `openSupervisedSession` derives +`supervised//` rather than accepting a bare name. Cheaper than a registry, and +it keeps `callerClassification`'s prefix split (`worker-preference.ts:31`) meaningful. -## The brand is not the enforcement +## Migration -`HumanActor` is erased at runtime. The private `requireHuman` helpers currently ask -`actor.type !== "human"` — the question this design proves returns the wrong answer. +Existing supervised claims hold the **human** as assignee and lease holder. -**The predicate moves; the brand is additive.** Each helper takes actor + ctx, calls -`asHuman`, throws when null, returns the branded value. Deleting the runtime throw as -"newly redundant" makes authorization purely compile-time, and one cast reduces the gate -to a no-op with no runtime trace. +**Expiring the lease is wrong** — revision 3 recommended it and it produces a stuck board. +Expiry leaves `assigneeId` = human, so the new `isHolderMutation` is permanently false and +`validateLease` at `:391` is **skipped entirely** — SYD-210's shared-token hole silently +reopened — while `assertClaimable:261-269` throws "already claimed by \" forever, +since clearing an assignee is human-only (`:610`). -## Adapters +**Use the `ensureClaimLeaseCutover` precedent** (`lease-cutover.ts:17-38`, already wired at +`server.ts:162-163` beside `ensureRolloutBackfill`): marker-guarded, once-only, sets +status→`todo`, `assigneeId`→`null`, records `claim_released`. Releasing the assignment resets +the control instead of removing it, and needs no event archaeology. -- **MCP.** Revision 2 said `mcp/server.ts` holds a `Principal`. It does not — - `server.ts:94-101` destructures before the call. Change `buildMcpServer` to take a - `Principal` and mint once per connection (~20 threading sites). `mcp/server.ts:417`'s - hand-rolled `viaAgent ?? actor` collapses into `effectiveActor`. -- **REST.** `requireHumanCaller` returns `void` (`api-routes.ts:141-145`) so it cannot - supply the branded value. It returns `HumanActor`; routes pass its result. - `api-routes.ts:672` is an unclassified `=== "agent"` gate — classify it. -- **CLI.** One helper does the runtime check and returns `HumanActor`. `cliActor` - (`cli.ts:22`), `requireHumanActor` (`cli.ts:27`), **and `linear-import.ts:352`'s second - synthetic human** all route through it. No casts. +Ordering matters: the existing cutover already released all `in_progress` claims once, so a +new migration must be independently marker-guarded and idempotent, and must state whether it +finds anything at all on an instance that has already run the cutover. **`claim_leases` is +part of the migration surface** — revision 3 never mentioned the table. -## The REST residual — stated, not claimed as containment +History is not rewritten: existing `creatorId`, `declaredBy`, and `confirmedBy` rows written +under the old rule stay as they are. That asymmetry with claims is deliberate — claims are +live state, the others are history — and should be said rather than left for a reviewer to +ask about. -A supervised session runs on the human's workstation, which holds `.env` with -`SWITCHYARD_HUMAN_TOKEN`; `scripts/syd.ts` defaults to it. The agent's path to every REST -gate is not a `sup_` token — it is the human's bearer, at which point `asHuman` returns a -`HumanActor`. **Nothing at REST distinguishes a bearer presented by a person from the same -bearer presented by a process.** The cookie-only affirm route exists for exactly this -reason. +## The brand is not the enforcement -Keep the containment test; state the residual. Mitigation is credential hygiene plus the -cookie/signature routes, not `asHuman`. +`HumanActor` is erased at runtime. The predicate moves to the adapters, which call `asHuman` +and throw; the brand is compile-time defence in depth. The ESLint rule bans `as HumanActor` +outside `principal.ts` and the CLI mint, and bans a bare `{}` as `ctx` in favour of +`NO_SESSION`. -## Migration +## Adapters + +- **MCP.** `server.ts:94-101` destructures the principal into positional args. Change + `buildMcpServer` to take a `Principal` and mint once per connection (~20 threading sites). + `mcp/server.ts:417`'s hand-rolled `viaAgent ?? actor` collapses into `effectiveActor`, + preserving its current event provenance. +- **REST.** `requireHumanCaller` throws; see the two-exception note above. `ctx` is always + empty here — pass `NO_SESSION`. +- **CLI.** One helper does the runtime check and returns `HumanActor`; `cliActor` + (`cli.ts:22`), `requireHumanActor` (`cli.ts:27`), and `linear-import.ts:352`'s second + synthetic human all route through it. No casts. -Existing supervised claims were written with the **human** as assignee and lease holder. -After the identity rule they would be unrecognisable to `isHolderMutation`. +## The REST residual -Per CLAUDE.md this is mechanical — the mapping is a pure function of `events.viaAgentId` -on the claim event — so it belongs at startup like `ensureRolloutBackfill`, not as an -operator step. Scope it in the PR: how many live claims, and whether re-keying them or -simply expiring them is cheaper. **Expiring is likely correct** — a lease is short-lived -by design and a re-claim costs one call. +A supervised session runs on the human's workstation, which holds `.env` with +`SWITCHYARD_HUMAN_TOKEN`; `scripts/syd.ts` defaults to it. The agent's path to every REST +gate is the human's bearer, at which point `asHuman` returns a `HumanActor`. **Nothing at +REST distinguishes a bearer presented by a person from the same bearer presented by a +process.** Mitigation is credential hygiene plus the cookie/signature routes, not `asHuman`. ## Operator step @@ -300,8 +413,8 @@ supervised.hard_gate_actions: ["done"] → ["done", "dependency.remove"] ``` **Fails closed.** Once `dependencies.ts:134` asks `asHuman(...) === null`, a supervised -removal on an un-updated NAS skips the divert (`:78,:89` need `isHardGated`) and is -**refused**. The setting upgrades refusal to a proposable divert — UX, not security. +removal on an un-updated NAS skips the divert (`:78`, `:89` need `isHardGated`) and is +refused. The setting upgrades refusal to a proposable divert — UX, not security. ## Behaviour changes @@ -310,70 +423,82 @@ removal on an un-updated NAS skips the divert (`:78,:89` need `isHardGated`) and | `claim_issue` (supervised) | assigns + leases the **human** | assigns + leases the **agent** | | `file_issue` | `backlog`, no provenance | `triage`, provenance required | | `update_issue` out of `triage` / adding `auto` | permitted | refused | -| `update_issue` reassign / transition / claim gates | skipped | agent rules apply | +| `update_issue` no-op status clearing `needsInput` | permitted | refused | | `comment` clearing `needsInput` | permitted | refused | +| `update_issue` reassign / transition / claim gates | skipped | agent rules apply, keyed to the agent | | `declare_pr_link` | auto-confirmed as the human | claim + lease required, `delivers` forced, `declaredBy` = agent, unconfirmed | | `revoke_pr_link` | full human powers | own link (agent-keyed) + unconfirmed + lease | | `confirm_pr_link` (service, REST) | confirms; readers accept | refused | | `remove_dependency` | executes | refused, or diverts once the setting lands | -| delivery events | can unblock its own issue | refused | -| `progress_note` | works (`mcp/server.ts:417`) | works, via `effectiveActor` | +| delivery events (supervised) | can unblock its own issue | refused; **service unchanged** | +| `next_task` (supervised) | surfaces the human's claims | surfaces unassigned only — accepted, see C | +| `progress_note` | works, agent-scoped | unchanged | | `update_issue` → `done` | diverts | unchanged | +| `whoami` | the human | the human **and** the acting agent | + +**Second-order, verified:** killing the supervised auto-confirm writes `confirmedBy: null`, +which fails `attention.ts:113-119`'s `IS NOT NULL` before the disjunction is reached, so +`done_without_merged_pr` fires. That is the flag working as designed, but it is new steady +state. Convention: confirm the link in the SYD-290 UI before affirming `done`. -**Second-order:** today's auto-confirm means supervised deliveries never trip -`done_without_merged_pr`. After the fix an issue affirmed `done` whose link was never -separately confirmed fails `attention.ts:113-121` and flags. That is the flag working as -designed, but it is new steady state. Convention: confirm the link in the SYD-290 UI -before affirming `done`. +**Also:** four sites synthesize an event `actorId` from `issue.assigneeId ?? issue.creatorId` +— `deviation.ts:259`, `stale-claims.ts:59`, `leases.ts:174`, `lease-cutover.ts:23`. After the +rule those carry an agent id where they carried a human's, with no paired `viaAgentId`. +Arguably more accurate; state it in the PR rather than letting a reviewer find it in an audit +log. ## Testing -TDD, driven through real entry points — SYD-280 shipped inert because its tests called -`upsertPrState` directly while production never did. +TDD, driven through real entry points. 1. `asHuman` null iff `sessionId != null` or `viaAgentId != null` or not human. - `actingAgent` null on a missing row and on a row whose type is no longer `agent`. - `effectiveActor` returns the agent supervised, the actor otherwise. + `actingAgent` null on a missing row and on a row no longer typed `agent`. + **`effectiveActor` throws** when ctx says supervised and no agent resolves. 2. Every population-A gate refuses a supervised principal. -3. Every reachable inverse gate refuses, driven through the MCP tool: triage exit, - `auto` label, reassign, transition allow-list, `file_issue` landing in `triage` with - provenance, and `comment` failing to clear `needsInput`. -4. `declare_pr_link` over a real `sup_` token: refused without a claim, refused without - the **agent's** lease, `role` forced, `declaredBy` = the agent, `provesLanded` false, - **and the `pr_link_declared` payload's `confirmed` false.** -5. `revoke_pr_link`: the supervised agent can revoke its own declaration and **cannot** - revoke one the human declared in person. -6. A `service` actor **confirming** at `POST /issues/:ref/pr-links/confirm` is refused - (SYD-298's actual case). -7. `claim_issue` supervised assigns the agent; a subsequent claim-scoped write validates - against the agent's lease. -8. `remove_dependency`: refused at the registry default, diverts once the setting lands. - **Assert both in the same file** so the operator dependency is visible in the diff. -9. The cookie affirm route still works — a regression guard on the one gate deliberately - left alone. -10. Boundary: `dependencies.ts:89` needs `edgeExists`, so a non-existent edge never - diverts and falls to `:134` — a silent no-op today, a refusal after. +3. Every reachable inverse gate refuses, through the MCP tool: triage exit, `auto` label, + reassign, transition allow-list, `file_issue` landing in `triage` with provenance. +4. **`update_issue` with a status equal to the current status does not clear `needsInput`** — + the no-op path, not `comment`. Revision 3's test would have passed while the hole stayed. +5. `declare_pr_link` over a real `sup_` token: refused without a claim, refused without the + **agent's** lease, `role` forced, `declaredBy` = agent, `provesLanded` false, and the + `pr_link_declared` payload's `confirmed` false. +6. `revoke_pr_link`: the agent can revoke its own declaration, and **cannot** revoke one the + human declared in person. +7. A `service` actor **confirming** at `POST /issues/:ref/pr-links/confirm` is refused. +8. **`tests/services/service-actor.test.ts:46,57,61` stay green** — the delivery regression + guard. +9. `claim_issue` supervised: assigns the agent, **mints a lease**, and a subsequent + `heartbeat` renews it. Then the **SYD-111 bare-PATCH path** — `update_issue + {status:"in_progress"}` on an unassigned issue — assigns the same agent. +10. `assertAssignee` under supervision: an `assigneeOnly` transition (`:425`) on an + agent-assigned issue succeeds. +11. The migration: a pre-existing human-assigned supervised claim, through startup, then + re-claimed by the agent. Idempotent across restarts. +12. The cookie affirm route still works — a regression guard on the gate deliberately left + alone. +13. Boundary: `dependencies.ts:89` needs `edgeExists`, so a non-existent edge never diverts + and falls to `:134` — a silent no-op today, a refusal after. Acceptance is the **SYD-213 pentest matrix** re-run. ## Also required -A message and comment sweep. Errors like "Only humans move issues out of triage" and -"only agent actors record progress notes" describe the old model. A supervised agent that -hits the new refusals gets text that does not explain why. +A message and comment sweep. "Only humans move issues out of triage", "only agent actors +record progress notes", `needs-input.ts`'s assignee message, and `comments.ts`'s refusal text +all describe the old model and will now fire for supervised agents in cases that previously +passed. `whoami` should report both the accountable human and the acting agent. ## Risks -- **Scope.** Roughly triple revision 1. The identity rule pulls in `claimIssue`, the lease - mint, `assertClaimable`, and a data migration none of which were in the original story. -- **`buildMcpServer`'s signature** (~20 threading sites) and `startAgentSession` / - `endAgentSession` gaining `Attribution` are both unbudgeted expansions. -- **Review legibility.** Order the commits: accessors, then live holes, then the sweep. - Carry the blast-radius table in the PR so a reviewer can tell the fixes from the - prevention. -- **The lint rule must land with the change**, covering the cast *and* the `{}` literal. +- **Scope.** Roughly triple revision 1. The identity rule pulls in the whole claim path, + `heartbeatClaim`, a migration, and the supervised-agent namespacing. +- **`buildMcpServer`'s signature** (~20 threading sites) and the `startAgentSession` / + `endAgentSession` owner-tie work are both expansions beyond the original story. +- **Review legibility.** Commit order: accessors, then the claim path, then the live holes, + then the sweep. Carry the blast-radius table in the PR. +- **The lint rule and `NO_SESSION` must land with the change**, not after. ## Not closed by this work -**SYD-298 stays open** until `confirmPrLink` is converted and its service-token refusal -test passes. +**SYD-298 stays open** until `confirmPrLink` is converted and its service-token refusal test +passes. From 4f3ea7c75a3a2ca1e3e909d125afc2b98d379ec8 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 21:56:18 -0400 Subject: [PATCH 05/16] =?UTF-8?q?docs:=20rev=205=20of=20SYD-281=20design?= =?UTF-8?q?=20=E2=80=94=20migration=20correctness=20after=20round=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-27-human-act-integrity-design.md | 217 ++++++++++++++---- 1 file changed, 168 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md index d65a7a7..917dc0b 100644 --- a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md +++ b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md @@ -147,8 +147,10 @@ abandoned on contact. export const NO_SESSION: Attribution = {}; ``` -The rule bans the bare literal and permits the sentinel. `hard-gate.ts:197` uses it with -its existing comment (note `:196` is the **lease channel** `{}`, not `ctx`). Be honest in +The rule bans the bare literal and permits the sentinel. `hard-gate.ts:197` uses it with its existing comment (note `:196` is the **lease channel** +`{}`, not `ctx`), and so does the `dependency.remove` executor's +`removeDependency(tx, human, blockerRef, blockedRef, {})` about ten lines above — a second +`ctx` literal revision 4 never named, which the lint rule will flag. Be honest in the PR: **the sentinel is a legibility control, not a security one.** A caller that wrongly reaches for `NO_SESSION` in a supervised path fails open exactly as `{}` does. What it buys is that the wrong choice is a named, greppable token instead of two invisible braces. @@ -282,23 +284,36 @@ delivery-attempts.ts requireDeliveryInfra:208 — four callers: :228, :290, :334 rest/api-routes.ts :672 /github-events → `actingAgent(...) !== null` ``` -**The delivery predicate is `actingAgent(...) !== null`, NOT `asHuman(...) === null`.** -Service actors **must** pass these gates — `SWITCHYARD_DELIVER_POLLER_TOKEN` and -`SWITCHYARD_GITHUB_POLLER_TOKEN` are service actors, and -`tests/services/service-actor.test.ts:46,57,61` already assert it. Reading revision 3's -"refused" as `asHuman` would have **stopped production delivery**. `actingAgent(...) !== null` -is behaviourally identical to today and additionally refuses a supervised agent. Keeping -those three tests green is an acceptance condition. - -`recordDeliveryEvent` (`delivery-events.ts:41-46`) has **no `Attribution` parameter** and -neither do its callers; `requireDeliveryInfra` has four. Both are the same unbudgeted -expansion flagged for `startAgentSession`/`endAgentSession`. Since neither is MCP-reachable, -**the cheapest correct answer is to leave both as they are with a note** — they are -REST-only and `ctx` is always empty there. Decide in the PR; do not leave it to the -implementer. +**Decision on the two delivery sites: leave them exactly as they are.** Revision 4 said this +in prose and then listed them in the table anyway. They are REST-only — `sup_` resolves at +`/mcp` only, no delivery tool exists among the MCP tools — so `ctx` is always empty there and +a supervised principal cannot reach them. `recordDeliveryEvent` (`delivery-events.ts:41-46`) +has **no `Attribution` parameter** and neither do its callers; `requireDeliveryInfra` has four +(`:228, :290, :334, :371`). Converting either is the same unbudgeted expansion flagged for +`startAgentSession`/`endAgentSession`, for no reachable gain. **Delete their two rows from this +population.** `api-routes.ts:672` `/github-events` is the same shape and gets the same answer. + +Had they been converted with `asHuman(...) === null`, production delivery would have stopped: +`SWITCHYARD_DELIVER_POLLER_TOKEN` and `SWITCHYARD_GITHUB_POLLER_TOKEN` are service actors, and +`tests/services/service-actor.test.ts:46,57,61` assert they pass. Keeping those three green is +an acceptance condition regardless. + +**`assertClaimable` and `assertAssignee` take `ctx` and compare `effectiveActor` internally** — +not a call-site actor substitution, which revision 4 left ambiguous by writing the two adjacent +rows two different ways. Their call sites are `issues.ts:425` (`assertAssignee`, from the +transition allow-list) and `:547`, `:632`, `:778` (`assertClaimable`); each passes the +enclosing `attr`. Miss `:547` or `:632` and a supervised claim is checked against the human in +one branch and the agent in another. + +**Resolve `effectiveActor` once at function entry**, not at each of the eight branch points the +`issues.ts` table names. Otherwise fail-closed is branch-dependent: a patch touching only +`priority` (`:550`) or `title` (`:562`) reaches none of them, so the same broken session throws +on one patch and silently writes as the human on another. Hoisting also removes ~8 redundant +`SELECT`s per transaction and puts the row-identity-vs-event-identity split — the single most +misreadable thing in this change — in one place instead of eight. **`requireAgent`'s other two callers.** `:126`/`:142` (`startAgentSession`/`endAgentSession`) -have no `Attribution` parameter, and the owner tie at `:150` (`existing.actorId !== actor.id`) +have no `Attribution` parameter, and the owner tie at `:145` (`existing.actorId !== actor.id`) keys on `actor.id` — the same "owner tie matches rather than saves you" shape flagged as load-bearing for `affirmPendingAction`. Budget it, and re-key the tie to `effectiveActor`. @@ -323,17 +338,42 @@ auth.ts:15 login links belong to humans — data rule (caller actors.ts:38 default `attended` at creation actors.ts:110 target must not be human — data rule comments.ts:31 AGENT_QUESTION_RE signalling — not authorization -nextTask (dependencies.ts:257) MUST stay human-keyed — see below +issues.ts:179, :378 service refusals — a supervised actor is never a service +needs-input.ts:33 same +supervised-sessions.ts:28, :69 internal data rules; :69 already refuses a non-agent viaAgent +cli.ts:66 argument parsing ``` -**`nextTask` is the one place where *not* applying the rule is the security-relevant -choice.** `dependencies.ts:257` filters `eq(issues.assigneeId, actor.id)`, so after the rule -a supervised `next_task` stops surfacing the agent's own claimed work — a real cost. But the -actor passed to `nextTask` must stay the **human**, because `isAttendedCaller` -(`worker-preference.ts:50-52`) reads it to decide whether to apply the hard -`worker_preference <> 'interactive'` filter (`dependencies.ts:281-284`). Key it to the agent -and supervised sessions lose the interactive queue they exist to serve. Accept the narrower -result and write the reason down. +Those five service/internal sites are safe no-ops under the rule, but revision 4 claimed a +closed sweep without naming them. Named now so the sweep is actually closed. + +**`attachments.actorId` is a decision, not an absence.** Revision 4 classified +`attachments.ts:102` as a carrier that "calls no accessor" — true of the `attr` parameter, but +`:146` writes `actorId: actor.id` into the `attachments` table (`schema.ts:240-242`), a +persistent row-identity FK, and `attach_file` **is** an MCP tool. Under the rule a supervised +agent's screenshot stays recorded as uploaded by the human, with the agent only on the paired +event's `viaAgentId`. That is the right answer — it matches `events.actorId` — but state it as +an explicit exclusion from the row-identity list rather than letting it read as an oversight. +Every other actor FK in `schema.ts` (lines 64, 67, 188, 198, 210, 218, 229, 344, 349, 422, 437, +466, 512, 550) is accounted for. + +**`nextTask` uses `actor` for two independent things, and they get different answers.** +Revision 4 treated them as one decision and stated the consequence wrongly. + +- **The assignee leg** (`dependencies.ts:259`, `or(isNull(assigneeId), eq(assigneeId, actor.id))`) + keys to `effectiveActor`. Leaving it on the human does **not** merely narrow the result — it + **wedges**: `next_task` keeps surfacing issues assigned to the human, which the supervised + agent then cannot claim (`assertClaimable:260-269` compares against the agent and throws + "already claimed by Sean"), and with `.limit(1)` (`:303`) one such issue sorting first pins + the recommendation on something unclaimable indefinitely. +- **`isAttendedCaller` / `callerClassification`** (`worker-preference.ts:50-52`, `:29-32`) stay + on the **human**, so the hard `worker_preference <> 'interactive'` filter (`:279-282`) is not + applied and supervised sessions keep the interactive queue they exist to serve. + +Also set `attended: true` when minting the supervised agent actor. `getOrCreateActor` +(`actors.ts:61-75`) inserts with no `attended` and the column defaults false +(`schema.ts:38`), but a supervised session is attended by definition — that is what the flag +means, and `setActorAttended` is human-only anyway (`actors.ts:107`). ## New attack surface the identity rule creates @@ -350,9 +390,16 @@ session's claim on a path that previously refused outright. Lease tokens still s two sessions' writes, so this is defence-in-depth loss rather than an authorization bypass — but `assertClaimable` is the guard SYD-93 exists because of. -**Fix: namespace supervised agents.** `openSupervisedSession` derives -`supervised//` rather than accepting a bare name. Cheaper than a registry, and -it keeps `callerClassification`'s prefix split (`worker-preference.ts:31`) meaningful. +**Fix: namespace supervised agents** as `/supervised/` — engine first. +`callerClassification` is `actor.name.split("/")[0]` (`worker-preference.ts:31`), so +`supervised//` would classify as `"supervised"`, matching no `workerPreference` +value (`ui/src/types.ts:26`). Engine-first is distinct from `claude/dev` *and* keeps the prefix +split working. `cli.ts:91-93` must reject `/` in the engine argument and update its usage +string, or the existing habit `mint-supervised-session sean claude/dev` yields +`claude/dev/supervised/sean`. + +This closes the collapse for **new** sessions only — see the migration's fourth item for the +12-hour window on sessions already open at deploy. ## Migration @@ -364,20 +411,49 @@ Expiry leaves `assigneeId` = human, so the new `isHolderMutation` is permanently reopened — while `assertClaimable:261-269` throws "already claimed by \" forever, since clearing an assignee is human-only (`:610`). -**Use the `ensureClaimLeaseCutover` precedent** (`lease-cutover.ts:17-38`, already wired at -`server.ts:162-163` beside `ensureRolloutBackfill`): marker-guarded, once-only, sets -status→`todo`, `assigneeId`→`null`, records `claim_released`. Releasing the assignment resets -the control instead of removing it, and needs no event archaeology. - -Ordering matters: the existing cutover already released all `in_progress` claims once, so a -new migration must be independently marker-guarded and idempotent, and must state whether it -finds anything at all on an instance that has already run the cutover. **`claim_leases` is -part of the migration surface** — revision 3 never mentioned the table. - -History is not rewritten: existing `creatorId`, `declaredBy`, and `confirmedBy` rows written -under the old rule stay as they are. That asymmetry with claims is deliberate — claims are -live state, the others are history — and should be said rather than left for a reviewer to -ask about. +Release the **assignment**, following `ensureClaimLeaseCutover` (`lease-cutover.ts:17-38`). +Four things that precedent does not give you, each of which is required: + +**1. The selection predicate — and it does need event archaeology.** `claim_leases` +(`schema.ts:457-476`) has no session column, and an `issues.assigneeId` pointing at a human is +byte-identical whether the claim came from a supervised session or from a person claiming at +their desk. The only link is `events.sessionId` (`schema.ts:199`), written by `recordEvent` at +`issues.ts:691-697`. So the migration selects `in_progress` issues whose latest `assigned` +event has `sessionId IS NOT NULL`. Revision 4 claimed this "needs no event archaeology" — that +was only true of the broad alternative (release every human-assigned claim), which would also +release claims a person made in person. **Target precisely; do not use the broad hammer.** + +**2. Invalidate the lease in the same transaction.** `lease-cutover.ts:22-34` does a raw +`tx.update(issues)` and **never touches `claim_leases`** — verified. Copy it as-is and the +human-keyed lease row stays active, so when the agent re-claims, the mint block +(`issues.ts:673`) inserts a *second* active row. `getActiveLease` (`leases.ts:29-34`) uses a +bare `.get()` with no `ORDER BY` against its own "at most one by construction" invariant +(`:17-18`), returns the stale human row, and the agent's next holder mutation throws at `:391` +forever — holding a token it can never use. Call `invalidateLease(tx, issue.id)` +(`leases.ts:123`) per released issue, inside the release transaction. + +**3. Carry the precedent's precondition.** `lease-cutover.ts:9-16` rests its blast-radius +argument on the worker LaunchAgents being down at cutover. A routine `npm run deploy` restarts +only the NAS tracker while the workers keep polling, and a released issue lands at +`status: todo, assigneeId: null` — exactly what `selectDispatchable` wants +(`scripts/worker-select.ts:456`). The window is narrowed by the `interactive` skip (`:459`) and +the `auto`-label requirement (`:448-450`), not closed. **Either stop the worker LaunchAgents +for this deploy, or run it through the `switchyard-admin` operator path (SYD-291)** rather than +at startup. + +**4. Soft-close open supervised sessions.** `sessions.viaAgentId` is fixed at mint +(`supervised-sessions.ts:38`) and `SUPERVISED_TTL` is 12h (`:10`), so a session minted against +`claude/dev` before the deploy keeps that binding for up to 12 hours — which is precisely the +`assertClaimable` collapse the namespacing exists to prevent, live after shipping. Bulk-set +`closedAt` on every open supervised session, forcing a re-mint under the new naming. +`closeSupervisedSession` (`:80-86`) shows the shape and documents why a hard DELETE is wrong +(`sessions.id` is an FK target for `events.sessionId`). + +Ordering: independently marker-guarded and idempotent, and state what it finds on an instance +that has already run `ensureClaimLeaseCutover`. + +History is not rewritten: existing `creatorId`, `declaredBy`, and `confirmedBy` rows stay as +they are. Claims are live state; the others are history. ## The brand is not the enforcement @@ -386,6 +462,31 @@ and throw; the brand is compile-time defence in depth. The ESLint rule bans `as outside `principal.ts` and the CLI mint, and bans a bare `{}` as `ctx` in favour of `NO_SESSION`. +**The brand is a legibility control at the same tier as `NO_SESSION`, not a security +boundary.** Banning `as HumanActor` does not stop `f(x as any)` or an `any`-typed intermediate +flowing into a `HumanActor` parameter. Say this in the PR alongside the `NO_SESSION` caveat. + +**The two roots of trust keep their inline checks.** Deleting the six private `requireHuman` +helpers does **not** extend to `affirmPendingAction` (`hard-gate.ts:91-95`) or +`openSupervisedSession` (`supervised-sessions.ts:24-27`). Their absence from the deletion list +is the only thing implying it; say "kept" out loud, because these are the two places a silent +deletion costs the most. + +**Deleting the helpers deletes live tests — the replacement is spec, not an exercise for the +implementer.** `tests/services/settings.test.ts:95-99` and `tests/services/projects.test.ts:32-52` +construct an agent `Actor` and call `setSetting` / `createProject` asserting `/human-only/`. +Once those services take `HumanActor` those files stop typechecking, and the implementer either +deletes the assertion or casts in the test — and a test cast is precisely the "tests construct +state the producer never produces" failure this repo has already been bitten by. So: + +- The service-level tests are **replaced** by adapter-level tests. `tests/rest/api-settings.test.ts:20-38` + and `tests/rest/api-projects.test.ts:56,80` already assert the agent refusal at the REST + boundary and are the model. +- Audit `webhooks`, `github-repos`, `triage-actions`, and `actors` for the same shape — all four + have service-level tests today — and give every population-A service an adapter-level + agent-refusal test. +- The lint ban gets **no** test exemption. + ## Adapters - **MCP.** `server.ts:94-101` destructures the principal into positional args. Change @@ -453,7 +554,14 @@ TDD, driven through real entry points. 1. `asHuman` null iff `sessionId != null` or `viaAgentId != null` or not human. `actingAgent` null on a missing row and on a row no longer typed `agent`. - **`effectiveActor` throws** when ctx says supervised and no agent resolves. + **`effectiveActor` throws** when ctx says supervised and no agent resolves. This one is a + direct-DB unit test, not a real entry point — the throw is unreachable in production + (`resolveSupervisedPrincipal:69` re-checks the agent type and 401s first, and `src/` has no + actor-delete or actor-retype path). Say so rather than promising an entry-point drive the + test cannot deliver. +1b. **A supervised session whose agent row is retyped mid-flight**, driven through + `update_issue {priority}` — asserts the fail-closed throw is not branch-dependent. This is + the test that makes the hoist (below) load-bearing. 2. Every population-A gate refuses a supervised principal. 3. Every reachable inverse gate refuses, through the MCP tool: triage exit, `auto` label, reassign, transition allow-list, `file_issue` landing in `triage` with provenance. @@ -467,13 +575,24 @@ TDD, driven through real entry points. 7. A `service` actor **confirming** at `POST /issues/:ref/pr-links/confirm` is refused. 8. **`tests/services/service-actor.test.ts:46,57,61` stay green** — the delivery regression guard. -9. `claim_issue` supervised: assigns the agent, **mints a lease**, and a subsequent - `heartbeat` renews it. Then the **SYD-111 bare-PATCH path** — `update_issue - {status:"in_progress"}` on an unassigned issue — assigns the same agent. +9. `claim_issue` supervised: assigns the agent and mints a lease **keyed to the agent** — + assert `claim_leases.actorId` — and a subsequent claim-scoped MCP write with that token + validates. Then the **SYD-111 bare-PATCH path** — `update_issue {status:"in_progress"}` on + an unassigned issue — assigns the same agent. + **No heartbeat leg.** `mcp/server.ts:372` registers `heartbeat` only `if (connectionLeaseToken)`, + and `:280-283` refuses `claim_issue` for exactly those connections — the two are mutually + exclusive, so a supervised session cannot heartbeat what it claimed. An implementer given + that assertion would call `heartbeatClaim` directly, which is the state-constructing test + class this plan rejects everywhere else. 10. `assertAssignee` under supervision: an `assigneeOnly` transition (`:425`) on an agent-assigned issue succeeds. -11. The migration: a pre-existing human-assigned supervised claim, through startup, then - re-claimed by the agent. Idempotent across restarts. +11. The migration: a pre-existing supervised claim (selected by its `assigned` event's + `sessionId`), through startup, then re-claimed by the agent — **and then a holder mutation + with the re-minted token that does not throw.** Asserting only that the re-claim succeeds + would go green with the two-active-leases hole open: the re-claim works, the *next* write + is what fails. Same shape as the `upsertPrState` incident — assert the state the step + leaves behind, not the step. Plus: idempotent across restarts, and an in-person human claim + in the same fixture is left untouched. 12. The cookie affirm route still works — a regression guard on the gate deliberately left alone. 13. Boundary: `dependencies.ts:89` needs `edgeExists`, so a non-existent edge never diverts From a896ad934769a1742065796bb48874544fa3f396 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 21:56:34 -0400 Subject: [PATCH 06/16] docs: rev 5 header and population B consistency --- .../specs/2026-08-27-human-act-integrity-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md index 917dc0b..16dfcef 100644 --- a/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md +++ b/docs/superpowers/specs/2026-08-27-human-act-integrity-design.md @@ -1,7 +1,8 @@ # Human-act integrity: separating attribution from authorization (SYD-281) -Story B of the SYD-279 epic. **Revision 4** — after three rounds of a seven-seat review -panel (2026-08-27). Findings and disposition: `docs/reviews/2026-08-27-syd281-panel.md`. +Story B of the SYD-279 epic. **Revision 5** — after four rounds of a seven-seat review panel +(2026-08-27). Round 4 closed the design question: 3 of 7 seats approved, and every remaining +finding was scoped to the migration and test descriptions, all folded in here. Findings and disposition: `docs/reviews/2026-08-27-syd281-panel.md`. ## The problem @@ -47,8 +48,9 @@ line. The finding is that one site got it right and nothing generalized it. ### Blast radius -**Reachable from `/mcp` with a `sup_` token** (the MCP surface is 22 tools; delivery, -`confirm_pr_link`, and agent-session start/end are *not* among them): +**Reachable from `/mcp` with a `sup_` token** (a supervised session sees 21 MCP tools — 22 `registerTool` calls, but `heartbeat` is +registered only for a connection-lease session, `mcp/server.ts:372`; delivery, +`confirm_pr_link`, and agent-session start/end are not among them): | Site | Gate | What a supervised agent gets | |---|---|---| @@ -279,9 +281,7 @@ issues.ts createIssue:168, updateIssue:305, claimIssue:725, comments.ts :44 (defence in depth; :664 is the primary conversion) needs-input.ts :43, :44 agent-sessions.ts requireAgent:47 — three callers: :126, :142, :209 -delivery-events.ts :54 → `actingAgent(...) !== null` -delivery-attempts.ts requireDeliveryInfra:208 — four callers: :228, :290, :334, :371 -rest/api-routes.ts :672 /github-events → `actingAgent(...) !== null` +(delivery-events.ts and delivery-attempts.ts are NOT in this population — see below) ``` **Decision on the two delivery sites: leave them exactly as they are.** Revision 4 said this From 34f53dd10825810c9e691e0464c8a15908b85a75 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 21:57:11 -0400 Subject: [PATCH 07/16] docs: record round 4 of the SYD-281 review panel --- docs/reviews/2026-08-27-syd281-panel.md | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/reviews/2026-08-27-syd281-panel.md b/docs/reviews/2026-08-27-syd281-panel.md index d8602c9..958bfe0 100644 --- a/docs/reviews/2026-08-27-syd281-panel.md +++ b/docs/reviews/2026-08-27-syd281-panel.md @@ -150,3 +150,49 @@ the leaves while omitting the lines they depend on. tool argument. `actingAgent` introduces no identity-forgery primitive. Worth preserving as an explicit invariant, since the rule turns that field into an identity selector. - **No injection surface anywhere in the change.** + +--- + +# Round 4 — on revision 4 (final) + +**3 APPROVED** (opus skeptic, simplifier, architect), 4 REVISE — every REVISE scoped to the +Migration section and test descriptions. Both skeptics walked the two hardest flows line by +line and confirmed they work. + +> "Both the supervised `claim_issue` and the supervised bare-PATCH `in_progress` paths work end +> to end, and the four round-3 CRITICALs are genuinely fixed rather than papered over." — opus +> +> "All are one-paragraph diffs, not another round." — pentester + +The pentester independently enumerated every `actor.type` comparison in `src/` and every actor +FK in `schema.ts`, and found no missed gate. + +| # | Finding | Seats | Disposition in rev 5 | +|---|---|---|---| +| 51 | **The migration's selection predicate does not exist in live state.** `claim_leases` has no session column and a human `assigneeId` is byte-identical whether the claim was supervised or made in person — so "release human-assigned claims" also releases Sean's own | fable, pentester | Selects on the latest `assigned` event's `sessionId`; the "needs no event archaeology" claim is deleted as the false economy it was | +| 52 | **The migration must `invalidateLease` in the same transaction.** `lease-cutover.ts:22-34` never touches `claim_leases`, so copying it leaves the human lease active; the agent's re-claim inserts a second active row, `getActiveLease` has no `ORDER BY`, and the agent's next holder mutation throws forever | opus, executor, auditor, fable | One line added; test 11 extended to assert a holder mutation after the re-claim | +| 53 | **The migration lacks `lease-cutover`'s "workers down" precondition** — released issues land `todo`/unassigned, which is what `selectDispatchable` wants | pentester, fable | Stop the LaunchAgents, or run via the SYD-291 `switchyard-admin` path | +| 54 | **Namespacing does not cover already-open sessions** — `viaAgentId` is fixed at mint and the TTL is 12h, so the `assertClaimable` collapse stays live for 12h after deploy | pentester, fable | Migration soft-closes open supervised sessions | +| 55 | **`next_task` wedges, and the behaviour row was false.** Keeping the assignee leg human-keyed surfaces issues the agent cannot claim; with `.limit(1)` it pins on an unclaimable recommendation indefinitely | pentester | Assignee leg keys to `effectiveActor`; `isAttendedCaller` stays human. `attended: true` set at supervised-agent mint | +| 56 | **Deleting the private `requireHuman` helpers deletes live tests** with no stated replacement — the implementer then casts in the test, which is the state-constructing failure this repo has been bitten by | pentester | Service tests explicitly **replaced** by adapter tests; no lint exemption for tests | +| 57 | Test 9's heartbeat leg is unreachable — `heartbeat` registers only for a connection-lease session (`mcp/server.ts:372`), which cannot claim (`:280-283`) | opus, executor, fable | Leg dropped; `heartbeatClaim` restated as "correct by symmetry, unreachable today" | +| 58 | `assertClaimable`/`assertAssignee` specified two ways; four call sites never named | opus | Both take `ctx` and compare internally; `:425, :547, :632, :778` named | +| 59 | **Fail-closed is branch-dependent** — `effectiveActor` is reached at 8 conditional points, so a `priority`-only patch throws on one path and writes as the human on another | opus | Hoisted once per population-B function; test 1b added | +| 60 | Namespacing order backwards — `supervised//` classifies as `"supervised"`, matching no `workerPreference` | pentester | `/supervised/`; `/` rejected in the engine arg | +| 61 | The delivery decision was left undecided (table said convert, prose said leave) | opus, pentester | Decided: leave; rows deleted from population B | +| 62 | `attachments.ts:146` writes `actorId` — a row-identity FK, and `attach_file` is an MCP tool | opus | Stated as an explicit exclusion, not an absence | +| 63 | Five `actor.type` sites unclassified despite a claimed-closed sweep | opus | Added to population C | +| 64 | A second `ctx` `{}` in `hard-gate.ts` never named; citation drift (`:145` not `:150`, `:259` not `:257`); 21 tools not 22 | opus, pentester | All corrected | + +## Verified clean under attack + +- Both claim paths traced line by line — they work. +- The auto-confirm kill blinds no reader: claim-gating uses `LIVE_DELIVERS` + (`pr-status.ts:37-40`), which deliberately does **not** require confirmation, so only + `PROOF_BEARING` and `attention.ts:115-119` are affected — exactly the second-order note. +- The hard-gate divert survives: the executor re-drives with empty attribution, so `asHuman` + returns a `HumanActor` and both affirmed `done` and affirmed `dependency.remove` still run. +- `effectiveActor`'s throw is unreachable in production — `resolveSupervisedPrincipal:69` + re-checks and 401s first, and `src/` has no actor-delete or actor-retype path. Correct + fail-closed dead code. +- A supervised agent ends up **strictly weaker than a plain dispatch agent** at every gate. From 76e495b3d1d7621c1676735be924bce3dded9952 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 23:56:32 -0400 Subject: [PATCH 08/16] docs: implementation plan for SYD-281 --- .../2026-08-27-syd281-human-act-integrity.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-27-syd281-human-act-integrity.md diff --git a/docs/superpowers/plans/2026-08-27-syd281-human-act-integrity.md b/docs/superpowers/plans/2026-08-27-syd281-human-act-integrity.md new file mode 100644 index 0000000..0b27fe5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-syd281-human-act-integrity.md @@ -0,0 +1,179 @@ +# SYD-281 Human-Act Integrity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a human-only gate actually require a human act, by splitting the three questions a supervised `Principal` currently conflates — who authorizes, whose credential, whose name goes on the row. + +**Architecture:** Three accessors in `src/services/principal.ts`. `asHuman` returns a branded `HumanActor` or null (null inside a supervised session). `actingAgent` resolves the agent whose claim and lease govern the write. `effectiveActor` is the identity written to row-identity columns, and fails closed. Human-only services take `HumanActor`; mixed services take a required `ctx: Attribution` and apply a per-line predicate table. Adapters mint, services demand. + +**Tech Stack:** TypeScript, drizzle-orm + better-sqlite3, hono (REST), @modelcontextprotocol/sdk, vitest. + +**Spec:** `docs/superpowers/specs/2026-08-27-human-act-integrity-design.md` (revision 5, approved by a seven-seat panel over four rounds; findings in `docs/reviews/2026-08-27-syd281-panel.md`). + +## Global Constraints + +- **All business logic in `src/services/*`.** MCP, REST, and UI are thin adapters. Services throw `SwitchyardError` for user-facing failures. +- **`ctx: Attribution` is required — no default — on every function that calls an accessor.** The `= {}` default is the fail-open this work removes. +- **`effectiveActor` is for row-identity columns only** (`assigneeId`, `creatorId`, `declaredBy`, `claim_leases.actorId`). It must **never** replace `events.actorId`, `pendingActions.affirmedById`, or `attachments.actorId`. +- **Resolve `effectiveActor` once at function entry**, not per branch — otherwise fail-closed is branch-dependent. +- **Never `as HumanActor`** outside `principal.ts` and the single CLI mint. Never a bare `{}` as `ctx` — use `NO_SESSION`. +- Branch `feat/syd-281-human-act-integrity`, worktree `/Users/sean/sites/switchyard-syd281`. Commit messages reference `(SYD-281)`. +- Before the PR: `npm run lint && npm run format:check && npm run typecheck && npm test`. + +--- + +### Task 1: The three accessors + +**Files:** +- Modify: `src/services/principal.ts` +- Modify: `src/services/attribution.ts` +- Test: `tests/services/principal.test.ts` (create) + +**Interfaces:** +- Produces: `HumanActor` (branded `Actor`), `asHuman(actor, ctx): HumanActor | null`, `actingAgent(db, actor, ctx): Actor | null`, `effectiveActor(db, actor, ctx): Actor` (throws when ctx is supervised and no agent resolves), `NO_SESSION: Attribution`. + +- [ ] **Step 1: Write the failing tests** in `tests/services/principal.test.ts` covering: `asHuman` returns a value for an unsupervised human and null when `sessionId` or `viaAgentId` is set or the actor is not human; `actingAgent` returns the agent for a plain agent actor, resolves `viaAgentId` from the db, returns null on a missing row and on a row no longer typed `agent`; `effectiveActor` returns the resolved agent, returns the actor when ctx is empty, and **throws** when ctx is supervised and the agent row is missing or retyped. +- [ ] **Step 2: Run `npx vitest run tests/services/principal.test.ts`** — expect failures for undefined exports. +- [ ] **Step 3: Implement** the three accessors and `NO_SESSION` per the spec's "The identity rule" section. `actingAgent` uses an inline `db.select().from(actors)` (not `getActorById`, which takes `Db` not `DbOrTx` and throws on a missing row). +- [ ] **Step 4: Run the test file** — expect PASS. +- [ ] **Step 5: Commit** `feat: three accessors splitting authorization, credential and row identity (SYD-281)`. + +--- + +### Task 2: Thread `Principal` into the MCP adapter + +**Files:** +- Modify: `src/mcp/server.ts` (`buildMcpServer` signature), `src/server.ts:94-101` +- Test: `tests/mcp/supervised.test.ts` (extend if present, else create) + +**Interfaces:** +- Consumes: Task 1's accessors. +- Produces: `buildMcpServer(db, principal: Principal, attachmentsDir?, connectionLeaseToken?)`. `attribution` and `viaAgent` positional params are removed — derive both from the principal inside. + +- [ ] **Step 1: Write a failing test** asserting a supervised MCP connection's `whoami` reports both the accountable human and the acting agent. +- [ ] **Step 2: Run it** — expect FAIL. +- [ ] **Step 3: Change `buildMcpServer` to take a `Principal`**; inside, derive `const attribution = attributionOf(principal)` and `const viaAgent = principal.viaAgent`. Update `src/server.ts` to pass `principal ?? { actor }`. Collapse `mcp/server.ts:417`'s `viaAgent ?? actor` to `effectiveActor(db, actor, attribution)` — behaviourally identical, preserving `events.actorId` as the agent for progress notes. +- [ ] **Step 4: Run `npx vitest run tests/mcp/`** — expect PASS. +- [ ] **Step 5: Commit** `refactor: buildMcpServer takes a Principal (SYD-281)`. + +--- + +### Task 3: Population A — human-only services take `HumanActor` + +**Files:** +- Modify: `src/services/settings.ts`, `actors.ts`, `projects.ts`, `webhooks.ts`, `github-repos.ts`, `triage-actions.ts`, `affirmation-keys.ts`, `hard-gate.ts`, `pr-links.ts` (`confirmPrLink`, `backfillPrLinksFromPrState`), `supervised-sessions.ts`, `src/rest/api-routes.ts`, `src/rest/pending-actions.ts` (`:69`, `:138` only), `src/cli.ts`, `src/services/linear-import.ts` +- Test: `tests/rest/api-settings.test.ts`, `tests/rest/api-projects.test.ts` (extend); delete the service-level agent-refusal tests they replace + +- [ ] **Step 1: Write failing adapter tests** asserting an agent bearer is refused at the REST boundary for settings, projects, webhooks, github-repos, triage-actions and actors. +- [ ] **Step 2: Run them** — some pass already; note which fail. +- [ ] **Step 3: Change the population-A signatures** to `HumanActor`, delete the six private `requireHuman` helpers (`settings.ts:113`, `actors.ts:12`, `projects.ts:11`, `webhooks.ts:10`, `github-repos.ts:23`, `triage-actions.ts:45`), and make `requireHumanCaller` return `HumanActor`. **Keep** the inline checks in `affirmPendingAction` (`hard-gate.ts:91-95`) and `openSupervisedSession` (`supervised-sessions.ts:24-27`) — they are roots of trust. Add a `requireHumanActor` helper in `cli.ts` returning `HumanActor`, used by `cliActor` and `linear-import.ts:352`. +- [ ] **Step 4: Delete** `tests/services/settings.test.ts:95-99` and `tests/services/projects.test.ts:32-52` (and the same shape in webhooks/github-repos/triage-actions/actors service tests) — replaced by the adapter tests. **No `as HumanActor` in any test.** +- [ ] **Step 5: Run `npm run typecheck && npx vitest run`** — expect PASS. +- [ ] **Step 6: Commit** `feat: human-only services demand a HumanActor the adapter minted (SYD-281)`. + +--- + +### Task 4: `pr-links.ts` predicate table (closes SYD-298) + +**Files:** +- Modify: `src/services/pr-links.ts` (`:241-254`, `:289`, `:291-292`, `:305`, `:409-421`, `:585`, `:593`, `:598`) +- Test: `tests/services/pr-links-supervised.test.ts` (create), `tests/rest/api-pr-links.test.ts` (extend) + +- [ ] **Step 1: Write failing tests** — driven through the **MCP tool** with a real `sup_` token, not by calling `declarePrLink` directly: a supervised declare is refused without a claim, refused without the agent's lease, forces `role: "delivers"`, writes `declaredBy` = the agent, leaves `confirmedBy` null so `provesLanded` is false, **and emits `pr_link_declared` with `confirmed: false`**. Plus: a supervised agent can revoke its own declaration and cannot revoke one the human declared in person. Plus: a `service` actor calling `POST /issues/:ref/pr-links/confirm` is refused. +- [ ] **Step 2: Run them** — expect FAIL. +- [ ] **Step 3: Apply the spec's `pr-links.ts` predicate table** verbatim. `confirmPrLink` moves to population A (Task 3 changed its signature; here change its gate). +- [ ] **Step 4: Run `npx vitest run tests/services/pr-links tests/rest/api-pr-links`** — expect PASS. +- [ ] **Step 5: Commit** `fix: a supervised declaration is a claim, not a human's vouch (SYD-281)`. + +--- + +### Task 5: `issues.ts` — the identity rule and the claim path + +**Files:** +- Modify: `src/services/issues.ts` (the 24-row table in the spec), `src/services/needs-input.ts:43-44` +- Test: `tests/services/issues-supervised.test.ts` (create) + +- [ ] **Step 1: Write failing tests** through the MCP tool with a `sup_` token: `file_issue` lands in `triage` with provenance required; `update_issue` out of `triage` is refused; adding `auto` is refused; reassigning another actor is refused; `update_issue` with a **status equal to the current status** does not clear `needsInput`; `claim_issue` assigns the **agent** and mints a lease whose `claim_leases.actorId` is the agent, and a subsequent claim-scoped write with that token validates; the bare-PATCH `update_issue {status:"in_progress"}` on an unassigned issue assigns the same agent; an `assigneeOnly` transition on an agent-assigned issue succeeds; a supervised session whose agent row is retyped mid-flight throws on `update_issue {priority}` rather than writing as the human. +- [ ] **Step 2: Run them** — expect FAIL. +- [ ] **Step 3: Implement.** Hoist `const eff = effectiveActor(tx, actor, attr)` once at the top of `updateIssue`, `createIssue`, and `claimIssue`, then apply the spec's `issues.ts` table. Give `assertClaimable` and `assertAssignee` a `ctx: Attribution` parameter and compare `effectiveActor` internally; update their four call sites (`:425`, `:547`, `:632`, `:778`). Event `actorId` at `:693`/`:762` stays `actor.id`. +- [ ] **Step 4: Run `npx vitest run tests/services/issues`** — expect PASS. +- [ ] **Step 5: Commit** `feat: in a supervised session the agent holds the work (SYD-281)`. + +--- + +### Task 6: `comments.ts`, `dependencies.ts`, `agent-sessions.ts` + +**Files:** +- Modify: `src/services/comments.ts:44`, `src/services/dependencies.ts:134` + `nextTask:259`, `src/services/agent-sessions.ts:47` + owner tie `:145` +- Test: `tests/services/comments.test.ts`, `tests/services/dependencies.test.ts` (extend) + +- [ ] **Step 1: Write failing tests**: a supervised `comment` does not clear `needsInput`; a supervised `remove_dependency` is refused at the registry default and **diverts** once `dependency.remove` is in `supervised.hard_gate_actions` (assert **both**, in this file, so the operator dependency is visible in the diff); `next_task` in a supervised session surfaces an issue the agent holds. +- [ ] **Step 2: Run them** — expect FAIL. +- [ ] **Step 3: Implement.** `comments.ts:44` → `asHuman(...) !== null`. `dependencies.ts:134` → `asHuman(...) === null`. `nextTask`: the assignee leg (`:259`) keys to `effectiveActor`; `isAttendedCaller`/`callerClassification` keep the human. `requireAgent` → `actingAgent(...) !== null`, thread `ctx` into `startAgentSession`/`endAgentSession` and re-key the owner tie at `:145` to `effectiveActor`. +- [ ] **Step 4: Run the two test files** — expect PASS. +- [ ] **Step 5: Commit** `fix: an agent cannot answer its own escalation (SYD-281)`. + +--- + +### Task 7: Supervised-agent namespacing + +**Files:** +- Modify: `src/services/supervised-sessions.ts` (`openSupervisedSession`), `src/cli.ts:89-104` +- Test: `tests/services/supervised-sessions.test.ts` (extend) + +- [ ] **Step 1: Write failing tests**: `openSupervisedSession(db, human, "claude")` creates an actor named `claude/supervised/` with `attended: true`; an engine argument containing `/` is rejected; binding to an existing dispatch actor is impossible. +- [ ] **Step 2: Run them** — expect FAIL. +- [ ] **Step 3: Implement.** Derive `/supervised/` (engine first, so `callerClassification`'s `split("/")[0]` still yields the engine). Pass `attended: true` when creating. Reject `/` in the engine argument and update the CLI usage string. +- [ ] **Step 4: Run the test file** — expect PASS. +- [ ] **Step 5: Commit** `fix: a supervised session cannot borrow a dispatch worker's identity (SYD-281)`. + +--- + +### Task 8: The migration + +**Files:** +- Create: `src/services/supervised-claim-cutover.ts` +- Modify: `src/db/schema.ts` (marker table), `src/server.ts` (wire after `ensureClaimLeaseCutover`) +- Test: `tests/services/supervised-claim-cutover.test.ts` (create) + +- [ ] **Step 1: Write failing tests**: a pre-existing supervised claim (selected by its latest `assigned` event's `sessionId`) is released to `todo`/unassigned with its lease invalidated, and the agent can then re-claim **and perform a holder mutation with the re-minted token without throwing**; an in-person human claim in the same fixture is left untouched; open supervised sessions are soft-closed; running twice changes nothing. +- [ ] **Step 2: Run them** — expect FAIL. +- [ ] **Step 3: Implement** per the spec's Migration section: marker-guarded, selects on the `assigned` event's `sessionId`, sets `status→todo` and `assigneeId→null`, calls `invalidateLease(tx, issue.id)` **in the same transaction**, records `claim_released`, and soft-closes open supervised sessions. Generate the marker table migration with `npm run db:generate`. +- [ ] **Step 4: Run the test file** — expect PASS. +- [ ] **Step 5: Commit** `feat: reset supervised claims to the new holder identity (SYD-281)`. + +--- + +### Task 9: The lint rule + +**Files:** +- Modify: `eslint.config.js` +- Test: none (lint is its own check) + +- [ ] **Step 1: Add a `no-restricted-syntax` rule** banning `TSAsExpression` to `HumanActor` outside `src/services/principal.ts` and `src/cli.ts`, and banning an empty `ObjectExpression` passed where `ctx` is expected — permitting `NO_SESSION`. Allowlist `src/services/hard-gate.ts`'s two executor sites with their existing comments. +- [ ] **Step 2: Run `npm run lint`** — fix any legitimate hits by switching them to `NO_SESSION`. +- [ ] **Step 3: Commit** `chore: make the wrong choice greppable, not invisible (SYD-281)`. + +--- + +### Task 10: Message sweep and `whoami` + +**Files:** +- Modify: error strings in `src/services/issues.ts`, `agent-sessions.ts`, `needs-input.ts`, `comments.ts`; `src/mcp/server.ts` (`whoami`) +- Test: `tests/mcp/whoami.test.ts` (extend) + +- [ ] **Step 1: Write a failing test** asserting `whoami` in a supervised session reports both the accountable human and the acting agent. +- [ ] **Step 2: Run it** — expect FAIL. +- [ ] **Step 3: Update `whoami`** and reword the refusal messages that now fire for supervised agents ("Only humans move issues out of triage", "Only agent actors record progress notes", the `needs-input` assignee message, and `comments.ts`'s refusal) so they explain the supervised case. +- [ ] **Step 4: Run the test** — expect PASS. +- [ ] **Step 5: Run the full gate** `npm run lint && npm run format:check && npm run typecheck && npm test`. +- [ ] **Step 6: Commit** `docs: say why a supervised session was refused (SYD-281)`. + +--- + +## Self-review notes + +- **Spec coverage:** every section of revision 5 maps to a task — accessors (1), MCP threading (2), population A (3), pr-links table (4), issues table + claim path (5), populations B remainder (6), namespacing (7), migration (8), lint (9), sweep (10). The REST residual and the operator settings step are documentation, carried in the PR description rather than code. +- **Not in scope, by decision:** `delivery-events.ts`, `delivery-attempts.ts`, `api-routes.ts:672` — REST-only, `ctx` always empty, no reachable gain. `attachments.actorId` stays the human. Population C is untouched. +- **Still open after this work:** SYD-298 closes only when Task 4's service-token refusal test passes; do not close it before. +- **PR must state:** the operator settings write (`supervised.hard_gate_actions` → `["done","dependency.remove"]`), that it fails closed without it, the workers-down precondition for the migration, and the `done_without_merged_pr` second-order effect. From f4b0e8687afb5feb14ea785dcd4871603b8ac6c1 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Thu, 27 Aug 2026 23:59:08 -0400 Subject: [PATCH 09/16] feat: three accessors splitting authorization, credential and row identity (SYD-281) --- src/services/attribution.ts | 16 ++++++ src/services/principal.ts | 94 ++++++++++++++++++++++++++++++++ tests/services/principal.test.ts | 92 +++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 tests/services/principal.test.ts diff --git a/src/services/attribution.ts b/src/services/attribution.ts index 5fc49d3..bf1d29d 100644 --- a/src/services/attribution.ts +++ b/src/services/attribution.ts @@ -2,6 +2,22 @@ import type { Principal } from "./principal.js"; export type Attribution = { viaAgentId?: number; sessionId?: number }; +/** + * "This caller has no supervised session" — said out loud. + * + * SYD-281 made `ctx: Attribution` a required parameter on every function that + * asks who is acting, which forces the REST/CLI/worker sites that genuinely + * have no session to pass an empty attribution. A lint rule banning a bare `{}` + * with a two-line allowlist would have been abandoned on contact, so the rule + * bans the literal and permits this constant instead. + * + * It is a LEGIBILITY control, not a security one: a caller that wrongly reaches + * for NO_SESSION in a supervised path fails open exactly as `{}` would. What it + * buys is that the wrong choice is a named, greppable token rather than two + * invisible braces. + */ +export const NO_SESSION: Attribution = {}; + export function attributionOf(p: Principal): Attribution { return { viaAgentId: p.viaAgent?.id, sessionId: p.sessionId }; } diff --git a/src/services/principal.ts b/src/services/principal.ts index 7802c7d..21ebfb4 100644 --- a/src/services/principal.ts +++ b/src/services/principal.ts @@ -1,4 +1,9 @@ +import { eq } from "drizzle-orm"; +import type { DbOrTx } from "../db/index.js"; +import { actors } from "../db/schema.js"; import type { Actor } from "./actors.js"; +import type { Attribution } from "./attribution.js"; +import { SwitchyardError } from "./errors.js"; /** * The acting identity behind a write. `actor` is always the accountable @@ -6,5 +11,94 @@ import type { Actor } from "./actors.js"; * `viaAgent`/`sessionId` are set only when the write happened inside a * supervised session, and drive the dual-attribution fields on events * (src/db/schema.ts: events.viaAgentId/sessionId). + * + * SYD-281: `actor` answers "who is accountable". It does NOT answer "who is + * authorizing", "whose credential is this", or "whose name goes on the row". + * Those are `asHuman`, `actingAgent` and `effectiveActor` below — a supervised + * principal is typed human and is not typed agent, so it passed gates of both + * shapes until they were split apart. */ export type Principal = { actor: Actor; viaAgent?: Actor; sessionId?: number }; + +declare const humanBrand: unique symbol; +/** + * An `Actor` a human is *currently acting as* — mintable only by `asHuman`. + * Human-only services take this instead of `Actor`, so passing an unproven + * actor is a compile error and a future gate cannot be written wrong by + * accident. + * + * The brand is erased at runtime, so it is compile-time defence in depth, not + * the enforcement: the runtime predicate lives in the adapters that call + * `asHuman` and throw. Banning `as HumanActor` (eslint.config.js) does not stop + * `f(x as any)`, so this is a legibility control at the same tier as + * NO_SESSION. + */ +export type HumanActor = Actor & { readonly [humanBrand]: true }; + +const isSupervised = (ctx: Attribution): boolean => ctx.sessionId != null || ctx.viaAgentId != null; + +/** + * (1) Who authorizes this write — the human who is ACTING, as distinct from + * the human who is ACCOUNTABLE (`Principal.actor`). + * + * Null inside a supervised session: presence is not consent for everything + * that follows. Keyed on BOTH `sessionId` and `viaAgentId` because `Principal` + * types them independently — they coincide today only because src/server.ts + * withholds attribution from plain sessions for an FK/logout reason, not an + * authorization one, so a future principal carrying one without the other must + * not fail open. + */ +export function asHuman(actor: Actor, ctx: Attribution): HumanActor | null { + if (isSupervised(ctx)) return null; + return actor.type === "human" ? (actor as HumanActor) : null; +} + +/** + * (2) Whose credential is acting — the identity whose claim and lease govern + * this write. In a supervised session that is the agent, never the accountable + * human, who holds no lease. + * + * Returns null rather than throwing on a missing or retyped row: getActorById + * throws, which would turn "no agent is acting" into a 500. The type is + * re-checked at use, mirroring resolveSupervisedPrincipal, so a role change + * after the session was minted cannot launder an agent path. + */ +export function actingAgent(db: DbOrTx, actor: Actor, ctx: Attribution): Actor | null { + if (ctx.viaAgentId != null) { + const row = db.select().from(actors).where(eq(actors.id, ctx.viaAgentId)).get(); + if (!row || row.type !== "agent") return null; + return { id: row.id, name: row.name, type: row.type, attended: row.attended }; + } + return actor.type === "agent" ? actor : null; +} + +/** + * (3) Whose name goes on the row — the identity written to row-identity + * columns: issues.assigneeId, issues.creatorId, pr_links.declaredBy, + * claim_leases.actorId. + * + * NOT for events.actorId, pendingActions.affirmedById, or attachments.actorId: + * those keep the accountable human, with the agent on the paired viaAgentId. + * Applying this to an event would collapse dual attribution. + * + * FAILS CLOSED. When ctx says supervised but no agent resolves, falling back to + * `actor` would silently restore the human-as-holder state this work exists to + * remove — and since asHuman is null there too, the write would be neither + * authorized as a human nor represented as an agent. Unreachable in production + * (resolveSupervisedPrincipal re-checks and 401s first, and nothing in src/ + * deletes or retypes an actor), so this is correct dead code. + * + * Resolve it ONCE at function entry, not per branch — otherwise the fail-closed + * guarantee is branch-dependent, and a patch touching only `priority` would + * write as the human where a status patch throws. + */ +export function effectiveActor(db: DbOrTx, actor: Actor, ctx: Attribution): Actor { + const agent = actingAgent(db, actor, ctx); + if (agent) return agent; + if (isSupervised(ctx)) { + throw new SwitchyardError( + "This supervised session's agent actor no longer resolves — re-open the session.", + ); + } + return actor; +} diff --git a/tests/services/principal.test.ts b/tests/services/principal.test.ts new file mode 100644 index 0000000..024c828 --- /dev/null +++ b/tests/services/principal.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { openDb, type Db } from "../../src/db/index.js"; +import { actors } from "../../src/db/schema.js"; +import { createActor, type Actor } from "../../src/services/actors.js"; +import { asHuman, actingAgent, effectiveActor } from "../../src/services/principal.js"; +import { NO_SESSION, type Attribution } from "../../src/services/attribution.js"; +import { SwitchyardError } from "../../src/services/errors.js"; + +let db: Db, human: Actor, agent: Actor, service: Actor; +beforeEach(() => { + db = openDb(":memory:"); + human = createActor(db, { name: "sean", type: "human" }).actor; + agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; + service = createActor(db, { name: "poller", type: "service" }).actor; +}); + +/** The attribution a supervised session carries. */ +const supervised = (agentId: number): Attribution => ({ sessionId: 1, viaAgentId: agentId }); + +describe("asHuman — who authorizes", () => { + it("returns the human when nothing indicates a session", () => { + expect(asHuman(human, NO_SESSION)?.id).toBe(human.id); + }); + + it("is null inside a supervised session — presence is not consent", () => { + expect(asHuman(human, supervised(agent.id))).toBeNull(); + }); + + it("is null when only sessionId is set", () => { + expect(asHuman(human, { sessionId: 1 })).toBeNull(); + }); + + it("is null when only viaAgentId is set — the fields are typed independently", () => { + expect(asHuman(human, { viaAgentId: agent.id })).toBeNull(); + }); + + it("is null for an agent and for a service actor", () => { + expect(asHuman(agent, NO_SESSION)).toBeNull(); + expect(asHuman(service, NO_SESSION)).toBeNull(); + }); +}); + +describe("actingAgent — whose credential", () => { + it("returns the actor itself for a plain agent session", () => { + expect(actingAgent(db, agent, NO_SESSION)?.id).toBe(agent.id); + }); + + it("resolves viaAgentId in a supervised session, not the accountable human", () => { + expect(actingAgent(db, human, supervised(agent.id))?.id).toBe(agent.id); + }); + + it("is null for a human with no session", () => { + expect(actingAgent(db, human, NO_SESSION)).toBeNull(); + }); + + it("is null for a service actor", () => { + expect(actingAgent(db, service, NO_SESSION)).toBeNull(); + }); + + it("is null — not a throw — when the referenced row is gone", () => { + expect(actingAgent(db, human, supervised(9999))).toBeNull(); + }); + + it("is null when the referenced actor is no longer typed agent", () => { + db.update(actors).set({ type: "service" }).where(eq(actors.id, agent.id)).run(); + expect(actingAgent(db, human, supervised(agent.id))).toBeNull(); + }); +}); + +describe("effectiveActor — whose name goes on the row", () => { + it("returns the acting agent in a supervised session", () => { + expect(effectiveActor(db, human, supervised(agent.id)).id).toBe(agent.id); + }); + + it("returns the actor when no session is indicated", () => { + expect(effectiveActor(db, human, NO_SESSION).id).toBe(human.id); + expect(effectiveActor(db, agent, NO_SESSION).id).toBe(agent.id); + }); + + it("THROWS when ctx says supervised but the agent row is gone", () => { + // Falling back to the human here would silently restore the state this + // work exists to remove, and asHuman is null too — the write would be + // neither authorized as a human nor represented as an agent. + expect(() => effectiveActor(db, human, supervised(9999))).toThrow(SwitchyardError); + }); + + it("THROWS when the agent row was retyped after the session was minted", () => { + db.update(actors).set({ type: "service" }).where(eq(actors.id, agent.id)).run(); + expect(() => effectiveActor(db, human, supervised(agent.id))).toThrow(/no longer resolves/); + }); +}); From bb7cfdf18bfd8f1d545f927e4b3e48f1e5797c16 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 28 Aug 2026 00:00:50 -0400 Subject: [PATCH 10/16] refactor: derive the acting agent instead of passing it alongside (SYD-281) --- src/mcp/server.ts | 15 ++++++++++----- src/server.ts | 1 - tests/mcp/supervised-write.test.ts | 6 +++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 000b6a1..197e7bb 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,6 +4,7 @@ import type { Db } from "../db/index.js"; import { STATUSES, PRIORITIES } from "../db/schema.js"; import type { Actor } from "../services/actors.js"; import type { Attribution } from "../services/attribution.js"; +import { effectiveActor } from "../services/principal.js"; import { SwitchyardError, PendingAffirmation } from "../services/errors.js"; import { listProjects } from "../services/projects.js"; import { @@ -73,9 +74,6 @@ export function buildMcpServer( // would then check that victim instead of the attacker. Empty for a plain // (non-supervised) principal, which must never populate events.sessionId. attribution: Attribution = {}, - // The agent acting on the human's behalf in a supervised session. `actor` is - // the accountable human root, so agent-scoped tools act as this instead. - viaAgent?: Actor, ): McpServer { const server = new McpServer({ name: "switchyard", version: "0.1.0" }); @@ -413,8 +411,15 @@ export function buildMcpServer( // The one agent-scoped write: recordProgressNote's requireAgent rejects a // human, and in a supervised session `actor` IS the human root — so act as // the bound agent instead of relaxing the guard. For a plain agent session - // viaAgent is undefined and `actor` is already the agent (unchanged). - recordProgressNote(db, viaAgent ?? actor, ref, note, attribution); + // `actor` is already the agent (unchanged). + // + // SYD-281: this was `viaAgent ?? actor` — the identity rule hand-rolled at + // the one site someone hit the problem. effectiveActor IS that expression, + // so this is behaviourally identical and the special case disappears. + // Progress notes deliberately keep the AGENT on events.actorId (lastNoteFor + // joins on it) — the one event where the agent is the actor, not the + // viaAgent. + recordProgressNote(db, effectiveActor(db, actor, attribution), ref, note, attribution); return { ok: true }; }), ); diff --git a/src/server.ts b/src/server.ts index 61df7ef..c23ebc4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -97,7 +97,6 @@ export function createApp(db: Db) { undefined, leaseToken, principal ? attributionOf(principal) : {}, - principal?.viaAgent, ); res.on("close", () => { transport.close(); diff --git a/tests/mcp/supervised-write.test.ts b/tests/mcp/supervised-write.test.ts index 7c638fd..da4d7a2 100644 --- a/tests/mcp/supervised-write.test.ts +++ b/tests/mcp/supervised-write.test.ts @@ -39,9 +39,9 @@ function latestEvent(issueId: number, type: string) { * how /mcp bakes a resolved supervised session into the tool closure. */ async function connectSupervised(): Promise { const [ct, st] = InMemoryTransport.createLinkedPair(); - await buildMcpServer(db, prin.actor, dir, undefined, attributionOf(prin), prin.viaAgent).connect( - st, - ); + // SYD-281: no viaAgent argument — the acting agent is derived from the + // attribution's viaAgentId by effectiveActor, so there is one source of truth. + await buildMcpServer(db, prin.actor, dir, undefined, attributionOf(prin)).connect(st); const c = new Client({ name: "test", version: "0.0.0" }); await c.connect(ct); return c; From 1fdb454cedb6164e1dfcfd7f0e6695fa7a4567ea Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 28 Aug 2026 00:13:08 -0400 Subject: [PATCH 11/16] feat: human-only services demand a HumanActor the adapter minted (SYD-281) --- src/cli.ts | 19 ++- src/rest/api-routes.ts | 146 +++++++++++++++--- src/services/actors.ts | 21 ++- src/services/github-repos.ts | 15 +- src/services/linear-import.ts | 10 +- src/services/principal.ts | 18 +++ src/services/projects.ts | 21 +-- src/services/settings.ts | 15 +- src/services/triage-actions.ts | 27 ++-- src/services/webhooks.ts | 18 +-- tests/db/issues-parent-fk.test.ts | 6 +- tests/helpers/human.ts | 24 +++ tests/integration/body-limit.test.ts | 3 +- tests/integration/core-loop.test.ts | 3 +- tests/integration/rest-loop.test.ts | 4 +- tests/mcp/lease-tools.test.ts | 6 +- tests/mcp/pending-affirmation.test.ts | 6 +- tests/mcp/pr-link-tools.test.ts | 6 +- tests/mcp/read-tools.test.ts | 6 +- tests/mcp/supervised-mcp-endpoint.test.ts | 6 +- tests/mcp/supervised-write.test.ts | 6 +- tests/mcp/write-tools.test.ts | 6 +- tests/rest/affirm-signed.test.ts | 3 +- tests/rest/api-actors.test.ts | 10 +- tests/rest/api-agent-sessions.test.ts | 3 +- tests/rest/api-attachments.test.ts | 3 +- tests/rest/api-delivery-attempts.test.ts | 3 +- tests/rest/api-delivery-events.test.ts | 3 +- tests/rest/api-delivery-health.test.ts | 3 +- tests/rest/api-dependencies.test.ts | 6 +- tests/rest/api-escalation.test.ts | 6 +- tests/rest/api-events.test.ts | 8 +- tests/rest/api-github-events.test.ts | 4 +- tests/rest/api-github-repos.test.ts | 4 +- tests/rest/api-github-webhook.test.ts | 6 +- tests/rest/api-issues.test.ts | 3 +- tests/rest/api-pr-links.test.ts | 3 +- tests/rest/api-pr-state.test.ts | 3 +- tests/rest/api-queue.test.ts | 3 +- tests/rest/api-service-actor.test.ts | 120 +++++++++++++- tests/rest/api-settings.test.ts | 8 +- tests/rest/api-validation.test.ts | 3 +- tests/rest/api-webhooks.test.ts | 6 +- tests/rest/lease-header.test.ts | 4 +- tests/rest/pending-actions.test.ts | 8 +- tests/services/actors.test.ts | 43 ++---- tests/services/agent-sessions.test.ts | 6 +- tests/services/attachments.test.ts | 6 +- tests/services/attention.test.ts | 4 +- tests/services/auth.test.ts | 6 +- tests/services/board-column-counts.test.ts | 4 +- tests/services/claim-blocked-isolated.test.ts | 6 +- tests/services/comments-hard-gate.test.ts | 6 +- tests/services/comments.test.ts | 14 +- tests/services/delivery-attempts.test.ts | 6 +- tests/services/delivery-events.test.ts | 18 ++- tests/services/delivery-health.test.ts | 4 +- tests/services/dependencies.test.ts | 6 +- ...dency-remove-hard-gate-affirm-exec.test.ts | 6 +- ...dependency-remove-hard-gate-divert.test.ts | 6 +- tests/services/deviation.test.ts | 4 +- tests/services/events-attribution.test.ts | 6 +- tests/services/events.test.ts | 14 +- tests/services/github-repos.test.ts | 29 ++-- tests/services/github-webhook.test.ts | 10 +- tests/services/hard-gate-affirm-exec.test.ts | 6 +- tests/services/hard-gate-divert.test.ts | 6 +- tests/services/hard-gate.test.ts | 10 +- tests/services/issue-hierarchy.test.ts | 6 +- tests/services/issues-create.test.ts | 6 +- tests/services/issues-update.test.ts | 6 +- tests/services/lease-claim-takeover.test.ts | 6 +- tests/services/lease-cutover.test.ts | 6 +- tests/services/lease-expiry.test.ts | 6 +- tests/services/lease-heartbeat.test.ts | 6 +- tests/services/lease-human-answer.test.ts | 6 +- tests/services/lease-no-serialization.test.ts | 6 +- tests/services/lease-request-input.test.ts | 6 +- tests/services/lease-update-issue.test.ts | 6 +- tests/services/leases.test.ts | 6 +- tests/services/linear-import.test.ts | 6 +- tests/services/needs-input.test.ts | 6 +- tests/services/pr-links.test.ts | 6 +- tests/services/pr-observation.test.ts | 4 +- tests/services/pr-state-cutover.test.ts | 4 +- tests/services/pr-state.test.ts | 4 +- tests/services/pr-status.test.ts | 4 +- tests/services/projects.test.ts | 25 ++- tests/services/queue.test.ts | 6 +- tests/services/search.test.ts | 6 +- tests/services/service-actor.test.ts | 62 ++------ tests/services/settings.test.ts | 30 ++-- tests/services/stale-claims.test.ts | 6 +- .../supervised-attribution-e2e.test.ts | 6 +- tests/services/triage-actions.test.ts | 34 ++-- tests/services/webhook-dispatcher.test.ts | 12 +- tests/services/webhooks.test.ts | 21 +-- 97 files changed, 739 insertions(+), 416 deletions(-) create mode 100644 tests/helpers/human.ts diff --git a/src/cli.ts b/src/cli.ts index 7c4acc8..c97f2dd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,6 @@ import { eq } from "drizzle-orm"; +import { requireHuman, type HumanActor } from "./services/principal.js"; +import { NO_SESSION } from "./services/attribution.js"; import { readFileSync } from "node:fs"; import { openDb } from "./db/index.js"; import { actors } from "./db/schema.js"; @@ -19,12 +21,19 @@ import { SwitchyardError } from "./services/errors.js"; // The CLI operates directly on the db file with no HTTP auth, so it stands // in for a human operator when calling human-only service functions. -const cliActor: Actor = { id: 0, name: "cli", type: "human", attended: true }; +// SYD-281: minted through the same accessor production uses, not cast. This is +// the single CLI mint the eslint `as HumanActor` ban carves out — and it is a +// mint, not a cast, precisely so the ban needs no carve-out at all. +const cliActor: HumanActor = requireHuman( + { id: 0, name: "cli", type: "human", attended: true }, + NO_SESSION, + "run admin CLI commands", +); // Resolves a human actor by name, matching mint-supervised-session's inline // lookup below — factored out because the affirm-key commands need it three // times. -function requireHumanActor(db: ReturnType, name: string): Actor { +function requireHumanActor(db: ReturnType, name: string): HumanActor { const row = db.select().from(actors).where(eq(actors.name, name)).get(); if (!row) throw new SwitchyardError(`There is no actor named "${name}".`); if (row.type !== "human") { @@ -32,7 +41,11 @@ function requireHumanActor(db: ReturnType, name: string): Actor { `"${name}" is an actor of type "${row.type}", not a human — affirmation keys belong to humans.`, ); } - return { id: row.id, name: row.name, type: row.type, attended: row.attended }; + return requireHuman( + { id: row.id, name: row.name, type: row.type, attended: row.attended }, + NO_SESSION, + "own affirmation keys", + ); } const [dbPath, cmd, ...args] = process.argv.slice(2); diff --git a/src/rest/api-routes.ts b/src/rest/api-routes.ts index 7e51adf..21d19ba 100644 --- a/src/rest/api-routes.ts +++ b/src/rest/api-routes.ts @@ -3,6 +3,8 @@ import { getCookie } from "hono/cookie"; import { HTTPException } from "hono/http-exception"; import { bodyLimit } from "hono/body-limit"; import type { Db } from "../db/index.js"; +import { asHuman, type HumanActor } from "../services/principal.js"; +import { NO_SESSION } from "../services/attribution.js"; import { SwitchyardError, PendingAffirmation } from "../services/errors.js"; import { authenticate, @@ -138,10 +140,24 @@ import { type Env = { Variables: { actor: Actor; leaseToken?: string } }; -function requireHumanCaller(actor: Actor, action: string): void { - if (actor.type !== "human") { +/** + * SYD-281: the adapter mints, the service demands. Human-only services take a + * `HumanActor`, which only `asHuman` can produce — so this is where the runtime + * question is actually asked, and the returned value is the proof. + * + * `ctx` is NO_SESSION unconditionally: a `sup_` token resolves only at /mcp + * (src/server.ts), never here, so a REST caller never carries a supervised + * session. See the spec's "REST residual" — this is not containment, because a + * supervised agent on the human's workstation can present the human's own + * bearer. Nothing at this layer can tell those apart; credential hygiene and + * the cookie/signature routes are the mitigation, not asHuman. + */ +function requireHumanCaller(actor: Actor, action: string): HumanActor { + const human = asHuman(actor, NO_SESSION); + if (!human) { throw new SwitchyardError(`Only humans can ${action} — ask a human to do this.`); } + return human; } export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmentsDir()) { @@ -193,10 +209,19 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen app.get("/projects", (c) => c.json(listProjects(db))); app.post("/projects", body(projectBody), (c) => - c.json(createProject(db, c.var.actor, c.req.valid("json"))), + c.json( + createProject(db, requireHumanCaller(c.var.actor, "create a project"), c.req.valid("json")), + ), ); app.patch("/projects/:key", body(projectUpdateBody), (c) => - c.json(updateProject(db, c.var.actor, c.req.param("key"), c.req.valid("json"))), + c.json( + updateProject( + db, + requireHumanCaller(c.var.actor, "rename a project"), + c.req.param("key"), + c.req.valid("json"), + ), + ), ); app.get("/actors", (c) => c.json(listActorsWithStatus(db))); @@ -215,7 +240,7 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen c.json( setActorAttended( db, - c.var.actor, + requireHumanCaller(c.var.actor, "change whether an actor is attended"), parseActorId(c.req.param("id")), c.req.valid("json").attended, ), @@ -223,11 +248,21 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen ); app.post("/actors/:id/rotate-token", (c) => - c.json(rotateActorToken(db, c.var.actor, parseActorId(c.req.param("id")))), + c.json( + rotateActorToken( + db, + requireHumanCaller(c.var.actor, "rotate an actor's token"), + parseActorId(c.req.param("id")), + ), + ), ); app.delete("/actors/:id/token", (c) => { - revokeActorToken(db, c.var.actor, parseActorId(c.req.param("id"))); + revokeActorToken( + db, + requireHumanCaller(c.var.actor, "revoke an actor's token"), + parseActorId(c.req.param("id")), + ); return c.json({ ok: true }); }); @@ -446,7 +481,14 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen ); app.post("/issues/:ref/pr-links/confirm", body(prLinkTargetBody), (c) => - c.json(confirmPrLink(db, c.var.actor, c.req.param("ref"), c.req.valid("json"))), + c.json( + confirmPrLink( + db, + requireHumanCaller(c.var.actor, "confirm a PR link"), + c.req.param("ref"), + c.req.valid("json"), + ), + ), ); app.post("/issues/:ref/pr-links/revoke", body(prLinkRevokeBody), (c) => { @@ -476,16 +518,35 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen ); app.post("/issues/:ref/snooze", body(snoozeBody), (c) => - c.json(snoozeIssue(db, c.var.actor, c.req.param("ref"), c.req.valid("json").until)), + c.json( + snoozeIssue( + db, + requireHumanCaller(c.var.actor, "snooze an issue"), + c.req.param("ref"), + c.req.valid("json").until, + ), + ), ); app.post("/issues/:ref/duplicate", body(duplicateBody), (c) => - c.json(markDuplicate(db, c.var.actor, c.req.param("ref"), c.req.valid("json").of)), + c.json( + markDuplicate( + db, + requireHumanCaller(c.var.actor, "mark an issue as a duplicate"), + c.req.param("ref"), + c.req.valid("json").of, + ), + ), ); app.post("/issues/:ref/redeliver", body(redeliverBody), (c) => c.json( - redeliverIssue(db, c.var.actor, c.req.param("ref"), c.req.valid("json").expectedHeadSha), + redeliverIssue( + db, + requireHumanCaller(c.var.actor, "retry a delivery"), + c.req.param("ref"), + c.req.valid("json").expectedHeadSha, + ), ), ); @@ -494,7 +555,14 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen // through a non-agent branch) — Retry is a dead end there since there's no // attributed PR to re-authorize. app.post("/issues/:ref/resolve-delivery", body(resolveDeliveryBody), (c) => - c.json(resolveDeliveryFailure(db, c.var.actor, c.req.param("ref"), c.req.valid("json").note)), + c.json( + resolveDeliveryFailure( + db, + requireHumanCaller(c.var.actor, "resolve a delivery failure"), + c.req.param("ref"), + c.req.valid("json").note, + ), + ), ); // SYD-262: the same escape hatch for a recorded-once process deviation. @@ -505,7 +573,7 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen c.json( resolveDeviation( db, - c.var.actor, + requireHumanCaller(c.var.actor, "resolve a process deviation"), c.req.param("ref"), c.req.valid("json").reason, c.req.valid("json").note, @@ -606,15 +674,30 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen app.get("/webhooks", (c) => c.json(listWebhooks(db).map(redact))); app.post("/webhooks", body(webhookCreateBody), (c) => - c.json(redact(addWebhook(db, c.var.actor, c.req.valid("json")))), + c.json( + redact(addWebhook(db, requireHumanCaller(c.var.actor, "add a webhook"), c.req.valid("json"))), + ), ); app.delete("/webhooks/:id", (c) => { - removeWebhook(db, c.var.actor, Number(c.req.param("id"))); + removeWebhook( + db, + requireHumanCaller(c.var.actor, "remove a webhook"), + Number(c.req.param("id")), + ); return c.json({ ok: true }); }); app.patch("/webhooks/:id", body(webhookPatchBody), (c) => { const { active } = c.req.valid("json"); - return c.json(redact(setWebhookActive(db, c.var.actor, Number(c.req.param("id")), active))); + return c.json( + redact( + setWebhookActive( + db, + requireHumanCaller(c.var.actor, "change a webhook"), + Number(c.req.param("id")), + active, + ), + ), + ); }); // Redact secret from linked-repo objects for safe API responses @@ -622,10 +705,22 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen app.get("/github-repos", (c) => c.json(listGithubRepos(db).map(redactRepo))); app.post("/github-repos", body(githubRepoCreateBody), (c) => - c.json(redactRepo(addGithubRepo(db, c.var.actor, c.req.valid("json")))), + c.json( + redactRepo( + addGithubRepo( + db, + requireHumanCaller(c.var.actor, "link a GitHub repo"), + c.req.valid("json"), + ), + ), + ), ); app.delete("/github-repos/:id", (c) => { - removeGithubRepo(db, c.var.actor, Number(c.req.param("id"))); + removeGithubRepo( + db, + requireHumanCaller(c.var.actor, "unlink a GitHub repo"), + Number(c.req.param("id")), + ); return c.json({ ok: true }); }); @@ -647,9 +742,20 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen // agent's. app.get("/settings", (c) => c.json(getAllSettings(db))); app.put("/settings/:key", body(settingPutBody), (c) => - c.json(setSetting(db, c.var.actor, c.req.param("key"), c.req.valid("json").value)), + c.json( + setSetting( + db, + requireHumanCaller(c.var.actor, "change settings"), + c.req.param("key"), + c.req.valid("json").value, + ), + ), + ); + app.delete("/settings/:key", (c) => + c.json( + resetSetting(db, requireHumanCaller(c.var.actor, "change settings"), c.req.param("key")), + ), ); - app.delete("/settings/:key", (c) => c.json(resetSetting(db, c.var.actor, c.req.param("key")))); // Worker-facing: agent tokens are expected callers here (SYD-155) — the // dispatch worker polls this to retune concurrency and poll intervals diff --git a/src/services/actors.ts b/src/services/actors.ts index cf48495..8603bba 100644 --- a/src/services/actors.ts +++ b/src/services/actors.ts @@ -1,4 +1,5 @@ import { eq } from "drizzle-orm"; +import type { HumanActor } from "./principal.js"; import type { Db } from "../db/index.js"; import { actors } from "../db/schema.js"; import { SwitchyardError } from "./errors.js"; @@ -9,12 +10,6 @@ export type ActorType = "human" | "agent" | "service"; export type Actor = { id: number; name: string; type: ActorType; attended: boolean }; export type ActorWithStatus = Actor & { createdAt: number; hasToken: boolean }; -function requireHuman(actor: Actor, action: string): void { - if (actor.type !== "human") { - throw new SwitchyardError(`Only humans can ${action} — agents should ask a human to do this.`); - } -} - export function createActor( db: Db, input: { name: string; type: ActorType; attended?: boolean }, @@ -104,8 +99,12 @@ export function listActorsWithStatus(db: Db): ActorWithStatus[] { * Refused for humans, who are attended by definition — silently accepting a * flag that changes nothing would imply it could be turned off. */ -export function setActorAttended(db: Db, actor: Actor, actorId: number, attended: boolean): Actor { - requireHuman(actor, "change whether an actor is attended"); +export function setActorAttended( + db: Db, + actor: HumanActor, + actorId: number, + attended: boolean, +): Actor { const target = getActorById(db, actorId); if (target.type === "human") { throw new SwitchyardError( @@ -117,8 +116,7 @@ export function setActorAttended(db: Db, actor: Actor, actorId: number, attended } /** Mints a fresh token for an existing actor, invalidating the old one. Human-only. */ -export function rotateActorToken(db: Db, actor: Actor, actorId: number): { token: string } { - requireHuman(actor, "rotate an actor's token"); +export function rotateActorToken(db: Db, actor: HumanActor, actorId: number): { token: string } { getActorById(db, actorId); const token = mintToken("syd"); db.update(actors) @@ -129,8 +127,7 @@ export function rotateActorToken(db: Db, actor: Actor, actorId: number): { token } /** Nulls out an actor's token hash, so it can no longer authenticate. Human-only. */ -export function revokeActorToken(db: Db, actor: Actor, actorId: number): void { - requireHuman(actor, "revoke an actor's token"); +export function revokeActorToken(db: Db, actor: HumanActor, actorId: number): void { if (actorId === actor.id) { throw new SwitchyardError( "You cannot revoke your own actor's token — sign in as a different human actor to do this.", diff --git a/src/services/github-repos.ts b/src/services/github-repos.ts index af51af0..feff943 100644 --- a/src/services/github-repos.ts +++ b/src/services/github-repos.ts @@ -1,4 +1,5 @@ import { eq, sql } from "drizzle-orm"; +import type { HumanActor } from "./principal.js"; import type { Db } from "../db/index.js"; import { githubRepos } from "../db/schema.js"; import type { Actor } from "./actors.js"; @@ -20,20 +21,11 @@ export function normalizeRepoFullName(fullName: string): string { return fullName.toLowerCase(); } -function requireHuman(actor: Actor): void { - if (actor.type !== "human") { - throw new SwitchyardError( - "Only humans manage linked GitHub repos — ask a human to link or unlink a repo.", - ); - } -} - export function addGithubRepo( db: Db, - actor: Actor, + actor: HumanActor, input: { fullName: string; projectKey?: string; secret?: string }, ): GithubRepo { - requireHuman(actor); if (!FULL_NAME_RE.test(input.fullName)) { throw new SwitchyardError(`GitHub repo must be "owner/repo" — got "${input.fullName}".`); } @@ -54,8 +46,7 @@ export function listGithubRepos(db: Db): GithubRepo[] { return db.select().from(githubRepos).all(); } -export function removeGithubRepo(db: Db, actor: Actor, id: number): void { - requireHuman(actor); +export function removeGithubRepo(db: Db, actor: HumanActor, id: number): void { const gone = db.delete(githubRepos).where(eq(githubRepos.id, id)).returning().get(); if (!gone) { throw new SwitchyardError( diff --git a/src/services/linear-import.ts b/src/services/linear-import.ts index 0ba5bac..8322724 100644 --- a/src/services/linear-import.ts +++ b/src/services/linear-import.ts @@ -1,4 +1,6 @@ import { and, eq, lt } from "drizzle-orm"; +import { requireHuman, type HumanActor } from "./principal.js"; +import { NO_SESSION } from "./attribution.js"; import type { Db } from "../db/index.js"; import { actors, @@ -349,7 +351,13 @@ export async function executeImportPlan( // The importer is a host-CLI, human-operated tool — same standing as // src/cli.ts's cliActor for human-only service calls (SYD-157 guard). - const importOperator: Actor = { id: 0, name: "cli", type: "human", attended: true }; + // SYD-281: routed through the same mint as cli.ts's cliActor, not cast — this + // is the second synthetic human, and it is the one an audit would miss. + const importOperator: HumanActor = requireHuman( + { id: 0, name: "cli", type: "human", attended: true }, + NO_SESSION, + "import a Linear export", + ); for (const p of plan.projects) { if (!p.exists) { createProject(db, importOperator, { key: p.key, name: p.name }); diff --git a/src/services/principal.ts b/src/services/principal.ts index 21ebfb4..39d8adb 100644 --- a/src/services/principal.ts +++ b/src/services/principal.ts @@ -53,6 +53,24 @@ export function asHuman(actor: Actor, ctx: Attribution): HumanActor | null { return actor.type === "human" ? (actor as HumanActor) : null; } +/** + * `asHuman` or throw — the one runtime chokepoint the six private `requireHuman` + * helpers collapsed into (settings, actors, projects, webhooks, github-repos, + * triage-actions each had their own, all asking `type !== "human"`, which is the + * question that returns the wrong answer for a supervised principal). + * + * Adapters call this and hand the result to the service. That is what makes the + * brand honest: the type says "a human was proven to be acting", and this is the + * only place outside `asHuman` where that proof is produced. + */ +export function requireHuman(actor: Actor, ctx: Attribution, action: string): HumanActor { + const human = asHuman(actor, ctx); + if (!human) { + throw new SwitchyardError(`Only humans can ${action} — agents should ask a human to do this.`); + } + return human; +} + /** * (2) Whose credential is acting — the identity whose claim and lease govern * this write. In a supervised session that is the agent, never the accountable diff --git a/src/services/projects.ts b/src/services/projects.ts index 94c74e5..9d1755c 100644 --- a/src/services/projects.ts +++ b/src/services/projects.ts @@ -1,4 +1,5 @@ import { eq, sql } from "drizzle-orm"; +import type { HumanActor } from "./principal.js"; import type { Db, DbOrTx } from "../db/index.js"; import { projects } from "../db/schema.js"; import type { Actor } from "./actors.js"; @@ -8,14 +9,12 @@ export type Project = typeof projects.$inferSelect; // Project mutations are board governance (SYD-157): server-enforced // human-only, like triage transitions and dependency removal. -function requireHuman(actor: Actor, action: string): void { - if (actor.type !== "human") { - throw new SwitchyardError(`Only humans can ${action} — agents should ask a human to do this.`); - } -} -export function createProject(db: Db, actor: Actor, input: { key: string; name: string }): Project { - requireHuman(actor, "create a project"); +export function createProject( + db: Db, + actor: HumanActor, + input: { key: string; name: string }, +): Project { if (!/^[A-Z]{2,10}$/.test(input.key)) { throw new SwitchyardError( `Project key "${input.key}" is invalid — use 2–10 uppercase letters, e.g. "AIPI".`, @@ -31,8 +30,12 @@ export function createProject(db: Db, actor: Actor, input: { key: string; name: } /** Rename a project. The key (issue refs embed it) and counter are immutable. Human-only. */ -export function updateProject(db: Db, actor: Actor, key: string, input: { name: string }): Project { - requireHuman(actor, "rename a project"); +export function updateProject( + db: Db, + actor: HumanActor, + key: string, + input: { name: string }, +): Project { const project = getProjectByKey(db, key); return db .update(projects) diff --git a/src/services/settings.ts b/src/services/settings.ts index 9134d87..58a5811 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -1,4 +1,5 @@ import { eq } from "drizzle-orm"; +import type { HumanActor } from "./principal.js"; import type { Db, DbOrTx } from "../db/index.js"; import { STATUSES, type Status } from "../db/schema.js"; import { settings } from "../db/schema.js"; @@ -110,14 +111,6 @@ export type SettingView = { description: string | null; }; -function requireHuman(actor: Actor): void { - if (actor.type !== "human") { - throw new SwitchyardError( - "Settings are human-only — ask a human to change instance config or dispatch policy.", - ); - } -} - function requireKnownKey(key: string): asserts key is SettingKey { if (!(key in REGISTRY)) { throw new SwitchyardError( @@ -203,8 +196,7 @@ export function getAllSettings(db: Db): SettingView[] { }); } -export function setSetting(db: Db, actor: Actor, key: string, value: unknown): SettingView { - requireHuman(actor); +export function setSetting(db: Db, actor: HumanActor, key: string, value: unknown): SettingView { requireKnownKey(key); validateValue(key, value); const entry = REGISTRY[key] as RegistryEntry; @@ -224,8 +216,7 @@ export function setSetting(db: Db, actor: Actor, key: string, value: unknown): S }; } -export function resetSetting(db: Db, actor: Actor, key: string): SettingView { - requireHuman(actor); +export function resetSetting(db: Db, actor: HumanActor, key: string): SettingView { requireKnownKey(key); const entry = REGISTRY[key] as RegistryEntry; db.delete(settings).where(eq(settings.key, key)).run(); diff --git a/src/services/triage-actions.ts b/src/services/triage-actions.ts index 9d456b5..3ba54b8 100644 --- a/src/services/triage-actions.ts +++ b/src/services/triage-actions.ts @@ -1,4 +1,5 @@ import { eq, sql } from "drizzle-orm"; +import type { HumanActor } from "./principal.js"; import type { Db } from "../db/index.js"; import { issues } from "../db/schema.js"; import type { Actor } from "./actors.js"; @@ -42,22 +43,15 @@ function pinAlreadyDeadEnded(db: Db, issueId: number, pin: DeliveryPin): boolean return row !== undefined; } -function requireHuman(actor: Actor, action: string): void { - if (actor.type !== "human") { - throw new SwitchyardError(`Only humans can ${action} — agents should ask a human to do this.`); - } -} - /** * Snoozes an issue until a future unix timestamp. Human-only. */ export function snoozeIssue( db: Db, - actor: Actor, + actor: HumanActor, ref: string, untilUnixSeconds: number, ): IssueView { - requireHuman(actor, "snooze an issue"); const now = Math.floor(Date.now() / 1000); if (untilUnixSeconds <= now) { throw new SwitchyardError( @@ -85,8 +79,7 @@ export function snoozeIssue( /** * Marks an issue as a duplicate of another and cancels it. Human-only. */ -export function markDuplicate(db: Db, actor: Actor, ref: string, ofRef: string): IssueView { - requireHuman(actor, "mark an issue as a duplicate"); +export function markDuplicate(db: Db, actor: HumanActor, ref: string, ofRef: string): IssueView { return db.transaction((tx) => { const current = getIssue(tx, ref); const of = getIssue(tx, ofRef); @@ -136,11 +129,10 @@ export function markDuplicate(db: Db, actor: Actor, ref: string, ofRef: string): */ export function redeliverIssue( db: Db, - actor: Actor, + actor: HumanActor, ref: string, expectedHeadSha?: string, ): IssueView { - requireHuman(actor, "retry a delivery"); const current = getIssue(db, ref); const attention = getAttention(db, current.id); const pin = deliveryPinFor(db, current.id); @@ -249,12 +241,11 @@ export type ResolvableDeviation = (typeof RESOLVABLE_DEVIATIONS)[number]; */ export function resolveDeviation( db: Db, - actor: Actor, + actor: HumanActor, ref: string, reason: string, note: string, ): IssueView { - requireHuman(actor, "resolve a process deviation"); if (!(RESOLVABLE_DEVIATIONS as readonly string[]).includes(reason)) { throw new SwitchyardError( `"${reason}" cannot be resolved by hand — it is recomputed from current state and clears itself once the drift stops. Resolvable reasons: ${RESOLVABLE_DEVIATIONS.join(", ")}.`, @@ -279,8 +270,12 @@ export function resolveDeviation( return getIssue(db, ref); } -export function resolveDeliveryFailure(db: Db, actor: Actor, ref: string, note: string): IssueView { - requireHuman(actor, "resolve a delivery failure"); +export function resolveDeliveryFailure( + db: Db, + actor: HumanActor, + ref: string, + note: string, +): IssueView { if (!note.trim()) { throw new SwitchyardError( "A note is required — say how you confirmed the delivery actually succeeded.", diff --git a/src/services/webhooks.ts b/src/services/webhooks.ts index ac409cd..c4f0965 100644 --- a/src/services/webhooks.ts +++ b/src/services/webhooks.ts @@ -1,4 +1,5 @@ import { eq } from "drizzle-orm"; +import type { HumanActor } from "./principal.js"; import type { Db } from "../db/index.js"; import { webhooks } from "../db/schema.js"; import type { Actor } from "./actors.js"; @@ -7,20 +8,11 @@ import { getProjectByKey } from "./projects.js"; export type Webhook = typeof webhooks.$inferSelect; -function requireHuman(actor: Actor): void { - if (actor.type !== "human") { - throw new SwitchyardError( - "Only humans manage webhooks — ask a human to add or remove webhook endpoints.", - ); - } -} - export function addWebhook( db: Db, - actor: Actor, + actor: HumanActor, input: { url: string; projectKey?: string; secret?: string }, ): Webhook { - requireHuman(actor); if (!/^https?:\/\//.test(input.url)) { throw new SwitchyardError(`Webhook url must be http(s) — got "${input.url}".`); } @@ -36,8 +28,7 @@ export function listWebhooks(db: Db): Webhook[] { return db.select().from(webhooks).all(); } -export function removeWebhook(db: Db, actor: Actor, id: number): void { - requireHuman(actor); +export function removeWebhook(db: Db, actor: HumanActor, id: number): void { const gone = db.delete(webhooks).where(eq(webhooks.id, id)).returning().get(); if (!gone) throw new SwitchyardError( @@ -45,8 +36,7 @@ export function removeWebhook(db: Db, actor: Actor, id: number): void { ); } -export function setWebhookActive(db: Db, actor: Actor, id: number, active: boolean): Webhook { - requireHuman(actor); +export function setWebhookActive(db: Db, actor: HumanActor, id: number, active: boolean): Webhook { const row = db.update(webhooks).set({ active }).where(eq(webhooks.id, id)).returning().get(); if (!row) throw new SwitchyardError( diff --git a/tests/db/issues-parent-fk.test.ts b/tests/db/issues-parent-fk.test.ts index 49232eb..73789f0 100644 --- a/tests/db/issues-parent-fk.test.ts +++ b/tests/db/issues-parent-fk.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -8,7 +10,7 @@ import { issues } from "../../src/db/schema.js"; describe("issues.parent_id foreign key", () => { it("rejects a parentId pointing at a nonexistent issue", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); @@ -29,7 +31,7 @@ describe("issues.parent_id foreign key", () => { it("still allows a parentId pointing at a real issue", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "AIPI", name: "aipi" }); const parent = createIssue(db, human, { projectKey: "AIPI", title: "Parent" }); const child = createIssue(db, human, { diff --git a/tests/helpers/human.ts b/tests/helpers/human.ts new file mode 100644 index 0000000..fb32625 --- /dev/null +++ b/tests/helpers/human.ts @@ -0,0 +1,24 @@ +import type { Db } from "../../src/db/index.js"; +import { createActor } from "../../src/services/actors.js"; +import { requireHuman, type HumanActor } from "../../src/services/principal.js"; +import { NO_SESSION } from "../../src/services/attribution.js"; + +/** + * A human actor for tests, minted through the SAME accessor production uses. + * + * SYD-281 deliberately gives tests no `as HumanActor` escape hatch: a cast in a + * test is how "tests construct state the producer never produces" gets started, + * and this project has already paid for that once (SYD-280 shipped inert because + * its tests called `upsertPrState` directly while production never did). Going + * through `requireHuman` means a test can only hold a `HumanActor` under exactly + * the conditions production can. + */ +export function createHuman(db: Db, name: string): HumanActor { + return createHumanWithToken(db, name).actor; +} + +/** As `createHuman`, for tests that also need the actor's bearer token. */ +export function createHumanWithToken(db: Db, name: string): { actor: HumanActor; token: string } { + const { actor, token } = createActor(db, { name, type: "human" }); + return { actor: requireHuman(actor, NO_SESSION, "act as a human in a test"), token }; +} diff --git a/tests/integration/body-limit.test.ts b/tests/integration/body-limit.test.ts index eaf3e18..2f4a2b9 100644 --- a/tests/integration/body-limit.test.ts +++ b/tests/integration/body-limit.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -25,7 +26,7 @@ beforeEach(() => { db = openDb(":memory:"); const { token } = createActor(db, { name: "claude/dev", type: "agent" }); agentH = { authorization: `Bearer ${token}` }; - createProject(db, createActor(db, { name: "sean", type: "human" }).actor, { + createProject(db, createHuman(db, "sean"), { key: "SYD", name: "Switchyard", }); diff --git a/tests/integration/core-loop.test.ts b/tests/integration/core-loop.test.ts index 29b4ef2..4dc8455 100644 --- a/tests/integration/core-loop.test.ts +++ b/tests/integration/core-loop.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { serve, type ServerType } from "@hono/node-server"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -26,7 +27,7 @@ const text = (r: Awaited>) => beforeAll(async () => { db = openDb(":memory:"); - const sean = createActor(db, { name: "sean", type: "human" }); + const sean = createHumanWithToken(db, "sean"); humanToken = sean.token; agentToken = createActor(db, { name: "claude/worker", type: "agent" }).token; createProject(db, sean.actor, { key: "AIPI", name: "aipi" }); diff --git a/tests/integration/rest-loop.test.ts b/tests/integration/rest-loop.test.ts index 6b63fb0..ee391a9 100644 --- a/tests/integration/rest-loop.test.ts +++ b/tests/integration/rest-loop.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { serve, type ServerType } from "@hono/node-server"; import { Hono } from "hono"; import { openDb, type Db } from "../../src/db/index.js"; @@ -16,7 +18,7 @@ const hookBodies: string[] = []; beforeAll(async () => { db = openDb(":memory:"); agentToken = createActor(db, { name: "claude/dev", type: "agent" }).token; - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); server = await new Promise((resolve) => { diff --git a/tests/mcp/lease-tools.test.ts b/tests/mcp/lease-tools.test.ts index 813e2cb..ad5c822 100644 --- a/tests/mcp/lease-tools.test.ts +++ b/tests/mcp/lease-tools.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { openDb, type Db } from "../../src/db/index.js"; @@ -7,7 +9,7 @@ import { createProject } from "../../src/services/projects.js"; import { createIssue, updateIssue, getIssue } from "../../src/services/issues.js"; import { buildMcpServer } from "../../src/mcp/server.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; async function connect(actor: Actor, connectionLeaseToken?: string) { const [ct, st] = InMemoryTransport.createLinkedPair(); await buildMcpServer(db, actor, undefined, connectionLeaseToken).connect(st); @@ -20,7 +22,7 @@ const text = (r: Awaited>) => beforeEach(async () => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "t" }); diff --git a/tests/mcp/pending-affirmation.test.ts b/tests/mcp/pending-affirmation.test.ts index ebfac9f..8c29ded 100644 --- a/tests/mcp/pending-affirmation.test.ts +++ b/tests/mcp/pending-affirmation.test.ts @@ -10,6 +10,8 @@ // unreachable by construction (a sup_ token resolves only at /mcp), so there is // no honest way to drive it. import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { serve } from "@hono/node-server"; @@ -23,7 +25,7 @@ import { openSupervisedSession } from "../../src/services/supervised-sessions.js import { setSetting } from "../../src/services/settings.js"; import { createApp } from "../../src/server.js"; -let db: Db, human: Actor, agent: Actor, issue: IssueView; +let db: Db, human: HumanActor, agent: Actor, issue: IssueView; let supToken: string, sessionId: number; let server: ReturnType, baseUrl: string; @@ -41,7 +43,7 @@ const text = (r: Awaited>) => beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude-code", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); const session = openSupervisedSession(db, human, agent.name); diff --git a/tests/mcp/pr-link-tools.test.ts b/tests/mcp/pr-link-tools.test.ts index 1ea78c3..881ef68 100644 --- a/tests/mcp/pr-link-tools.test.ts +++ b/tests/mcp/pr-link-tools.test.ts @@ -4,6 +4,8 @@ // Without it a feat/ branch has no way to say which PR carries its work, and // the swap in pr-status.ts/attention.ts would silently regress SYD-267. import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { openDb, type Db } from "../../src/db/index.js"; @@ -15,7 +17,7 @@ import { listLiveLinks } from "../../src/services/pr-links.js"; import { buildMcpServer } from "../../src/mcp/server.js"; const REPO = "acme/widgets"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; async function connect(actor: Actor, connectionLease?: string) { const [ct, st] = InMemoryTransport.createLinkedPair(); @@ -30,7 +32,7 @@ const text = (r: Awaited>) => beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); diff --git a/tests/mcp/read-tools.test.ts b/tests/mcp/read-tools.test.ts index 2604cc5..851a7aa 100644 --- a/tests/mcp/read-tools.test.ts +++ b/tests/mcp/read-tools.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { openDb, type Db } from "../../src/db/index.js"; @@ -9,7 +11,7 @@ import { recordDeliveryEvent } from "../../src/services/delivery-events.js"; import { startAgentSession, endAgentSession } from "../../src/services/agent-sessions.js"; import { buildMcpServer } from "../../src/mcp/server.js"; -let db: Db, human: Actor, agent: Actor, client: Client; +let db: Db, human: HumanActor, agent: Actor, client: Client; async function connect(actor: Actor) { const [ct, st] = InMemoryTransport.createLinkedPair(); @@ -25,7 +27,7 @@ const text = (r: Awaited>) => beforeEach(async () => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1", priority: "high" }); diff --git a/tests/mcp/supervised-mcp-endpoint.test.ts b/tests/mcp/supervised-mcp-endpoint.test.ts index 9fa23ca..3a20ca0 100644 --- a/tests/mcp/supervised-mcp-endpoint.test.ts +++ b/tests/mcp/supervised-mcp-endpoint.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { serve } from "@hono/node-server"; @@ -11,7 +13,7 @@ import { createIssue, updateIssue, type IssueView } from "../../src/services/iss import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; import { createApp } from "../../src/server.js"; -let db: Db, human: Actor, agent: Actor, issue: IssueView; +let db: Db, human: HumanActor, agent: Actor, issue: IssueView; let supToken: string, sessionId: number, agentToken: string; let server: ReturnType, baseUrl: string; @@ -46,7 +48,7 @@ function latestEvent(issueId: number, type: string) { beforeEach(async () => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); const created = createActor(db, { name: "claude-code", type: "agent" }); agent = created.actor; agentToken = created.token; diff --git a/tests/mcp/supervised-write.test.ts b/tests/mcp/supervised-write.test.ts index da4d7a2..af38bfc 100644 --- a/tests/mcp/supervised-write.test.ts +++ b/tests/mcp/supervised-write.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { mkdtempSync, rmSync } from "node:fs"; @@ -19,7 +21,7 @@ import { attributionOf } from "../../src/services/attribution.js"; import type { Principal } from "../../src/services/principal.js"; import { buildMcpServer } from "../../src/mcp/server.js"; -let db: Db, human: Actor, agent: Actor, dir: string, prin: Principal, issue: IssueView; +let db: Db, human: HumanActor, agent: Actor, dir: string, prin: Principal, issue: IssueView; const text = (r: Awaited>) => (r.content as { type: string; text: string }[])[0].text; @@ -50,7 +52,7 @@ async function connectSupervised(): Promise { beforeEach(() => { db = openDb(":memory:"); dir = mkdtempSync(path.join(tmpdir(), "syd-supervised-")); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude-code", type: "agent" }).actor; createProject(db, human, { key: "SUP", name: "supervised" }); const { sessionToken } = openSupervisedSession(db, human, agent.name); diff --git a/tests/mcp/write-tools.test.ts b/tests/mcp/write-tools.test.ts index f1a6788..156ac7f 100644 --- a/tests/mcp/write-tools.test.ts +++ b/tests/mcp/write-tools.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; @@ -14,7 +16,7 @@ import { getActivity } from "../../src/services/comments.js"; import { addGithubRepo } from "../../src/services/github-repos.js"; import { recordDeliveryEvent } from "../../src/services/delivery-events.js"; -let db: Db, human: Actor, agent: Actor, client: Client; +let db: Db, human: HumanActor, agent: Actor, client: Client; async function connect(actor: Actor, attachmentsDir?: string) { const [ct, st] = InMemoryTransport.createLinkedPair(); @@ -29,7 +31,7 @@ const text = (r: Awaited>) => beforeEach(async () => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); client = await connect(agent); diff --git a/tests/rest/affirm-signed.test.ts b/tests/rest/affirm-signed.test.ts index c50d8ac..0c3c7bb 100644 --- a/tests/rest/affirm-signed.test.ts +++ b/tests/rest/affirm-signed.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { createHumanWithToken } from "../helpers/human.js"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -58,7 +59,7 @@ function supervisedRest() { const db: Db = openDb(":memory:"); const app = createApp(db); - const { actor: human, token: humanToken } = createActor(db, { name: "sean", type: "human" }); + const { actor: human, token: humanToken } = createHumanWithToken(db, "sean"); const { actor: otherHuman, token: otherHumanToken } = createActor(db, { name: "morgan", type: "human", diff --git a/tests/rest/api-actors.test.ts b/tests/rest/api-actors.test.ts index 8fe7ea3..5e32b08 100644 --- a/tests/rest/api-actors.test.ts +++ b/tests/rest/api-actors.test.ts @@ -1,16 +1,22 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createLoginLink, redeemLoginLink } from "../../src/services/auth.js"; import { setSetting } from "../../src/services/settings.js"; import { buildApiRoutes } from "../../src/rest/api-routes.js"; -let db: Db, app: ReturnType, bearer: string, cookie: string, sean: Actor; +let db: Db, + app: ReturnType, + bearer: string, + cookie: string, + sean: HumanActor; beforeEach(() => { db = openDb(":memory:"); bearer = createActor(db, { name: "claude/dev", type: "agent" }).token; - sean = createActor(db, { name: "sean", type: "human" }).actor; + sean = createHuman(db, "sean"); const { token } = createLoginLink(db, "sean"); cookie = `switchyard_session=${redeemLoginLink(db, token).sessionToken}`; app = buildApiRoutes(db); diff --git a/tests/rest/api-agent-sessions.test.ts b/tests/rest/api-agent-sessions.test.ts index 9f71c81..3505b38 100644 --- a/tests/rest/api-agent-sessions.test.ts +++ b/tests/rest/api-agent-sessions.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -13,7 +14,7 @@ let workerH: Record, beforeEach(() => { db = openDb(":memory:"); const worker = createActor(db, { name: "claude/worker", type: "agent" }); - const human = createActor(db, { name: "sean", type: "human" }); + const human = createHumanWithToken(db, "sean"); const otherWorker = createActor(db, { name: "claude/other", type: "agent" }); workerH = { authorization: `Bearer ${worker.token}`, "content-type": "application/json" }; humanH = { authorization: `Bearer ${human.token}`, "content-type": "application/json" }; diff --git a/tests/rest/api-attachments.test.ts b/tests/rest/api-attachments.test.ts index 6a275f3..85530ca 100644 --- a/tests/rest/api-attachments.test.ts +++ b/tests/rest/api-attachments.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -14,7 +15,7 @@ beforeEach(() => { db = openDb(":memory:"); const agent = createActor(db, { name: "claude/dev", type: "agent" }); agentH = { authorization: `Bearer ${agent.token}` }; - createProject(db, createActor(db, { name: "sean", type: "human" }).actor, { + createProject(db, createHuman(db, "sean"), { key: "SYD", name: "Switchyard", }); diff --git a/tests/rest/api-delivery-attempts.test.ts b/tests/rest/api-delivery-attempts.test.ts index b1fb964..feb1286 100644 --- a/tests/rest/api-delivery-attempts.test.ts +++ b/tests/rest/api-delivery-attempts.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -15,7 +16,7 @@ beforeEach(() => { // The delivery infra (deliver.ts / agent-worker.ts) authenticates with a // human-typed token (SYD-107/108) — agent tokens are rejected below. const agent = createActor(db, { name: "claude/dev", type: "agent" }); - const worker = createActor(db, { name: "delivery-worker", type: "human" }); + const worker = createHumanWithToken(db, "delivery-worker"); agentH = { authorization: `Bearer ${agent.token}`, "content-type": "application/json" }; humanH = { authorization: `Bearer ${worker.token}`, "content-type": "application/json" }; createProject(db, worker.actor, { key: "SYD", name: "Switchyard" }); diff --git a/tests/rest/api-delivery-events.test.ts b/tests/rest/api-delivery-events.test.ts index d9ef22e..aaf3c60 100644 --- a/tests/rest/api-delivery-events.test.ts +++ b/tests/rest/api-delivery-events.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -12,7 +13,7 @@ beforeEach(() => { db = openDb(":memory:"); // The delivery infra (deliver.ts / agent-worker.ts) authenticates with a // human-typed token (SYD-107/108) — agent tokens are rejected below. - const worker = createActor(db, { name: "delivery-worker", type: "human" }); + const worker = createHumanWithToken(db, "delivery-worker"); workerH = { authorization: `Bearer ${worker.token}`, "content-type": "application/json" }; createProject(db, worker.actor, { key: "SYD", name: "Switchyard" }); createIssue(db, worker.actor, { diff --git a/tests/rest/api-delivery-health.test.ts b/tests/rest/api-delivery-health.test.ts index 3162cba..ac1a4d5 100644 --- a/tests/rest/api-delivery-health.test.ts +++ b/tests/rest/api-delivery-health.test.ts @@ -3,6 +3,7 @@ // tell whether a night was bad. import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -24,7 +25,7 @@ const REPO = "acme/widgets"; beforeEach(() => { db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }); + const human = createHumanWithToken(db, "sean"); const agent = createActor(db, { name: "claude/dev", type: "agent" }); humanHeaders = { authorization: `Bearer ${human.token}` }; agentHeaders = { authorization: `Bearer ${agent.token}` }; diff --git a/tests/rest/api-dependencies.test.ts b/tests/rest/api-dependencies.test.ts index eb81295..e527b91 100644 --- a/tests/rest/api-dependencies.test.ts +++ b/tests/rest/api-dependencies.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import type { HumanActor } from "../../src/services/principal.js"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -6,12 +8,12 @@ import { createIssue, updateIssue } from "../../src/services/issues.js"; import type { Actor } from "../../src/services/actors.js"; import { buildApiRoutes } from "../../src/rest/api-routes.js"; -let db: Db, app: ReturnType, human: Actor; +let db: Db, app: ReturnType, human: HumanActor; let humanH: Record; beforeEach(() => { db = openDb(":memory:"); - const h = createActor(db, { name: "sean", type: "human" }); + const h = createHumanWithToken(db, "sean"); human = h.actor; humanH = { authorization: `Bearer ${h.token}`, "content-type": "application/json" }; createProject(db, human, { key: "SYD", name: "Switchyard" }); diff --git a/tests/rest/api-escalation.test.ts b/tests/rest/api-escalation.test.ts index 3752155..85624d7 100644 --- a/tests/rest/api-escalation.test.ts +++ b/tests/rest/api-escalation.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import type { HumanActor } from "../../src/services/principal.js"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -13,12 +15,12 @@ import { let db: Db, app: ReturnType; let agentH: Record, humanH: Record; -let humanActor: Actor; +let humanActor: HumanActor; beforeEach(() => { db = openDb(":memory:"); const agent = createActor(db, { name: "claude/dev", type: "agent" }); - const human = createActor(db, { name: "sean", type: "human" }); + const human = createHumanWithToken(db, "sean"); humanActor = human.actor; agentH = { authorization: `Bearer ${agent.token}`, "content-type": "application/json" }; humanH = { authorization: `Bearer ${human.token}`, "content-type": "application/json" }; diff --git a/tests/rest/api-events.test.ts b/tests/rest/api-events.test.ts index ef61c70..b09c417 100644 --- a/tests/rest/api-events.test.ts +++ b/tests/rest/api-events.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -11,7 +13,7 @@ describe("GET /events", () => { it("returns the joined, newest-first feed and honors ?limit", async () => { const db = openDb(":memory:"); const { token } = createActor(db, { name: "sean", type: "human" }); - const human = createActor(db, { name: "someone-else", type: "human" }).actor; + const human = createHuman(db, "someone-else"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); updateIssue(db, human, "SYD-1", { status: "todo" }); @@ -53,7 +55,7 @@ describe("GET /events", () => { it("pages via before_id and signals truncation with X-Truncated/X-Next-Cursor headers (SYD-89)", async () => { const db = openDb(":memory:"); const { token } = createActor(db, { name: "sean", type: "human" }); - const human = createActor(db, { name: "someone-else", type: "human" }).actor; + const human = createHuman(db, "someone-else"); createProject(db, human, { key: "SYD", name: "Switchyard" }); const issue = createIssue(db, human, { projectKey: "SYD", title: "Busy issue" }); // 1 event for (let i = 0; i < 4; i++) { @@ -89,7 +91,7 @@ describe("GET /unanswered-questions", () => { const db = openDb(":memory:"); const { token } = createActor(db, { name: "sean", type: "human" }); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; - const human = createActor(db, { name: "someone-else", type: "human" }).actor; + const human = createHuman(db, "someone-else"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); createIssue(db, human, { projectKey: "SYD", title: "Answered already" }); diff --git a/tests/rest/api-github-events.test.ts b/tests/rest/api-github-events.test.ts index 9e1f367..5c2709c 100644 --- a/tests/rest/api-github-events.test.ts +++ b/tests/rest/api-github-events.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -8,7 +10,7 @@ import { buildApiRoutes } from "../../src/rest/api-routes.js"; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const humanToken = createActor(db, { name: "github-poller", type: "human" }).token; const agentToken = createActor(db, { name: "claude/dev", type: "agent" }).token; createProject(db, human, { key: "SYD", name: "Switchyard" }); diff --git a/tests/rest/api-github-repos.test.ts b/tests/rest/api-github-repos.test.ts index 66a0f0a..6008519 100644 --- a/tests/rest/api-github-repos.test.ts +++ b/tests/rest/api-github-repos.test.ts @@ -69,13 +69,13 @@ describe("github repo routes", () => { }); expect(createRes.status).toBe(400); expect(((await createRes.json()) as { error: string }).error).toMatch( - /only humans manage linked github repos/i, + /only humans can (link|unlink) a github repo/i, ); const deleteRes = await app.request("/github-repos/1", { method: "DELETE", headers: agentH }); expect(deleteRes.status).toBe(400); expect(((await deleteRes.json()) as { error: string }).error).toMatch( - /only humans manage linked github repos/i, + /only humans can (link|unlink) a github repo/i, ); const listRes = await app.request("/github-repos", { headers: agentH }); diff --git a/tests/rest/api-github-webhook.test.ts b/tests/rest/api-github-webhook.test.ts index 0f5e4a5..21f5760 100644 --- a/tests/rest/api-github-webhook.test.ts +++ b/tests/rest/api-github-webhook.test.ts @@ -1,4 +1,6 @@ import { createHmac } from "node:crypto"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { describe, it, expect, beforeEach } from "vitest"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; @@ -15,11 +17,11 @@ function sign(body: string, secret: string = SECRET): string { let db: Db; let app: ReturnType; -let human: Actor; +let human: HumanActor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship v1" }); app = buildGithubWebhookRoutes(db, SECRET); diff --git a/tests/rest/api-issues.test.ts b/tests/rest/api-issues.test.ts index a61c04e..73ae0da 100644 --- a/tests/rest/api-issues.test.ts +++ b/tests/rest/api-issues.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -11,7 +12,7 @@ let agentH: Record, humanH: Record; beforeEach(() => { db = openDb(":memory:"); const agent = createActor(db, { name: "claude/dev", type: "agent" }); - const human = createActor(db, { name: "sean", type: "human" }); + const human = createHumanWithToken(db, "sean"); agentH = { authorization: `Bearer ${agent.token}`, "content-type": "application/json" }; humanH = { authorization: `Bearer ${human.token}`, "content-type": "application/json" }; createProject(db, human.actor, { key: "SYD", name: "Switchyard" }); diff --git a/tests/rest/api-pr-links.test.ts b/tests/rest/api-pr-links.test.ts index 890f39c..cf81a07 100644 --- a/tests/rest/api-pr-links.test.ts +++ b/tests/rest/api-pr-links.test.ts @@ -3,6 +3,7 @@ // The half MCP deliberately omits lives here: confirming, which is what turns // a declared link into evidence that work landed. import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -18,7 +19,7 @@ let leaseToken: string; beforeEach(() => { db = openDb(":memory:"); const agent = createActor(db, { name: "claude/dev", type: "agent" }); - const human = createActor(db, { name: "sean", type: "human" }); + const human = createHumanWithToken(db, "sean"); agentH = { authorization: `Bearer ${agent.token}`, "content-type": "application/json" }; humanH = { authorization: `Bearer ${human.token}`, "content-type": "application/json" }; createProject(db, human.actor, { key: "SYD", name: "Switchyard" }); diff --git a/tests/rest/api-pr-state.test.ts b/tests/rest/api-pr-state.test.ts index 0dfc477..088fc29 100644 --- a/tests/rest/api-pr-state.test.ts +++ b/tests/rest/api-pr-state.test.ts @@ -4,6 +4,7 @@ // (claim gate, attention, search) migrate at SYD-207. import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -17,7 +18,7 @@ let headers: Record; beforeEach(() => { db = openDb(":memory:"); - const worker = createActor(db, { name: "delivery-worker", type: "human" }); + const worker = createHumanWithToken(db, "delivery-worker"); headers = { authorization: `Bearer ${worker.token}` }; createProject(db, worker.actor, { key: "SYD", name: "Switchyard" }); createIssue(db, worker.actor, { projectKey: "SYD", title: "One" }); diff --git a/tests/rest/api-queue.test.ts b/tests/rest/api-queue.test.ts index f7c69cf..552fc02 100644 --- a/tests/rest/api-queue.test.ts +++ b/tests/rest/api-queue.test.ts @@ -5,6 +5,7 @@ // the wrong service, or a body schema that rejects the `null` that means // "remove from the queue", would both pass typecheck and fail in production. import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -16,7 +17,7 @@ let humanHeaders: Record, agentHeaders: Record; beforeEach(() => { db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }); + const human = createHumanWithToken(db, "sean"); const agent = createActor(db, { name: "claude/dev", type: "agent" }); humanHeaders = { authorization: `Bearer ${human.token}` }; agentHeaders = { authorization: `Bearer ${agent.token}` }; diff --git a/tests/rest/api-service-actor.test.ts b/tests/rest/api-service-actor.test.ts index 8ec3346..a58e390 100644 --- a/tests/rest/api-service-actor.test.ts +++ b/tests/rest/api-service-actor.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -10,11 +12,16 @@ import { buildApiRoutes } from "../../src/rest/api-routes.js"; // services; these capabilities are gated at the route layer only: // requireHumanCaller (create actor / mint login link) and the /github-events // poster guard. -let db: Db, app: ReturnType, serviceToken: string, human: Actor; +let db: Db, + app: ReturnType, + serviceToken: string, + agentToken: string, + human: HumanActor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); serviceToken = createActor(db, { name: "github-poller", type: "service" }).token; + agentToken = createActor(db, { name: "claude/dev", type: "agent" }).token; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Poll target" }); // SYD-1 app = buildApiRoutes(db); @@ -71,3 +78,112 @@ describe("service token — REST-layer guards", () => { expect(await res.json()).toHaveProperty("pending"); }); }); + +// SYD-281: the human-only gates moved from six private `requireHuman` helpers in +// the service layer to `requireHumanCaller` at the adapter, so the refusals that +// used to be asserted service-side (tests/services/*.test.ts, calling +// setSetting(db, agent, …) and friends) are asserted here instead. They are NOT +// asserted by a cast in a service test: a `HumanActor` a test manufactured is +// exactly the "tests construct state the producer never produces" failure this +// story exists to remove. +// +// Both non-human tiers are covered — the old service-level tests checked one or +// the other per file, and `service` was the tier that slipped through +// `confirmPrLink`'s `!== "agent"` (SYD-298). +describe("non-human bearers are refused at every human-only route", () => { + const ROUTES: Array<{ what: string; method: string; path: string; body?: unknown }> = [ + { + what: "create a project", + method: "POST", + path: "/projects", + body: { key: "NEW", name: "n" }, + }, + { what: "rename a project", method: "PATCH", path: "/projects/SYD", body: { name: "n" } }, + { + what: "change a setting", + method: "PUT", + path: "/settings/dispatch.max_concurrent", + body: { value: 9 }, + }, + { what: "reset a setting", method: "DELETE", path: "/settings/dispatch.max_concurrent" }, + { what: "add a webhook", method: "POST", path: "/webhooks", body: { url: "https://e.test/h" } }, + { what: "remove a webhook", method: "DELETE", path: "/webhooks/1" }, + { + what: "link a GitHub repo", + method: "POST", + path: "/github-repos", + body: { fullName: "acme/w" }, + }, + { what: "unlink a GitHub repo", method: "DELETE", path: "/github-repos/1" }, + { + what: "snooze an issue", + method: "POST", + path: "/issues/SYD-1/snooze", + body: { until: 9999999999 }, + }, + { + what: "mark a duplicate", + method: "POST", + path: "/issues/SYD-1/duplicate", + body: { of: "SYD-1" }, + }, + { + what: "confirm a PR link", + method: "POST", + path: "/issues/SYD-1/pr-links/confirm", + body: { repo: "acme/w", prNumber: 1 }, + }, + { + what: "create an actor", + method: "POST", + path: "/actors", + body: { name: "x", type: "agent" }, + }, + { what: "mint a login link", method: "POST", path: "/actors/1/login-link" }, + { what: "rotate an actor's token", method: "POST", path: "/actors/1/rotate-token" }, + { what: "revoke an actor's token", method: "DELETE", path: "/actors/1/token" }, + { + what: "change whether an actor is attended", + method: "POST", + path: "/actors/1/attended", + body: { attended: true }, + }, + { what: "retry a delivery", method: "POST", path: "/issues/SYD-1/redeliver", body: {} }, + { + what: "resolve a delivery failure", + method: "POST", + path: "/issues/SYD-1/resolve-delivery", + body: { note: "merged by hand" }, + }, + { + what: "resolve a process deviation", + method: "POST", + path: "/issues/SYD-1/resolve-deviation", + body: { reason: "open_pr_not_in_review", note: "handled" }, + }, + // A mixed function (population B) rather than a requireHumanCaller route, + // but the refusal is the same CLAUDE.md invariant — "dependency removal is + // human-only" — and the service tier is the one that reached it. Takes + // query params, not a body. + { + what: "remove a dependency", + method: "DELETE", + path: "/dependencies?blockerRef=SYD-1&blockedRef=SYD-1", + }, + ]; + + for (const tier of ["agent", "service"] as const) { + for (const r of ROUTES) { + it(`a ${tier} bearer cannot ${r.what}`, async () => { + const token = tier === "service" ? serviceToken : agentToken; + const res = await app.request(r.path, { + method: r.method, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + ...(r.body === undefined ? {} : { body: JSON.stringify(r.body) }), + }); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toMatch(/only humans/i); + }); + } + } +}); diff --git a/tests/rest/api-settings.test.ts b/tests/rest/api-settings.test.ts index 0956161..9a11c0c 100644 --- a/tests/rest/api-settings.test.ts +++ b/tests/rest/api-settings.test.ts @@ -35,7 +35,9 @@ describe("settings routes", () => { body: JSON.stringify({ value: "Nope" }), }); expect(agentRes.status).toBe(400); - expect(((await agentRes.json()) as { error: string }).error).toMatch(/human-only/i); + expect(((await agentRes.json()) as { error: string }).error).toMatch( + /only humans can (change|reset) a? ?settings?/i, + ); const humanRes = await app.request("/settings/instance.name", { method: "PUT", @@ -102,7 +104,9 @@ describe("settings routes", () => { headers: agentH, }); expect(agentDelete.status).toBe(400); - expect(((await agentDelete.json()) as { error: string }).error).toMatch(/human-only/i); + expect(((await agentDelete.json()) as { error: string }).error).toMatch( + /only humans can (change|reset) a? ?settings?/i, + ); const humanDelete = await app.request("/settings/instance.name", { method: "DELETE", diff --git a/tests/rest/api-validation.test.ts b/tests/rest/api-validation.test.ts index c5ef314..4649b18 100644 --- a/tests/rest/api-validation.test.ts +++ b/tests/rest/api-validation.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHumanWithToken } from "../helpers/human.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -8,7 +9,7 @@ import { SUMMARY_MAX_LENGTH } from "../../src/services/issues.js"; let db: Db, app: ReturnType, h: Record; beforeEach(() => { db = openDb(":memory:"); - const sean = createActor(db, { name: "sean", type: "human" }); + const sean = createHumanWithToken(db, "sean"); h = { authorization: `Bearer ${sean.token}`, "content-type": "application/json", diff --git a/tests/rest/api-webhooks.test.ts b/tests/rest/api-webhooks.test.ts index 70a807b..89ba1cf 100644 --- a/tests/rest/api-webhooks.test.ts +++ b/tests/rest/api-webhooks.test.ts @@ -88,7 +88,7 @@ describe("webhook routes", () => { }); expect(createRes.status).toBe(400); expect(((await createRes.json()) as { error: string }).error).toMatch( - /only humans manage webhooks/i, + /only humans can (add|remove|change) a webhook/i, ); const deleteRes = await app.request("/webhooks/1", { @@ -97,7 +97,7 @@ describe("webhook routes", () => { }); expect(deleteRes.status).toBe(400); expect(((await deleteRes.json()) as { error: string }).error).toMatch( - /only humans manage webhooks/i, + /only humans can (add|remove|change) a webhook/i, ); const listRes = await app.request("/webhooks", { headers: agentH }); @@ -137,7 +137,7 @@ describe("webhook routes", () => { }); expect(denied.status).toBe(400); expect(((await denied.json()) as { error: string }).error).toMatch( - /only humans manage webhooks/i, + /only humans can (add|remove|change) a webhook/i, ); }); }); diff --git a/tests/rest/lease-header.test.ts b/tests/rest/lease-header.test.ts index 77910d8..3dcf8c3 100644 --- a/tests/rest/lease-header.test.ts +++ b/tests/rest/lease-header.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -14,7 +16,7 @@ const auth = (extra: Record = {}) => ({ beforeEach(() => { db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); agentToken = createActor(db, { name: "claude/worker", type: "agent" }).token; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "t" }); diff --git a/tests/rest/pending-actions.test.ts b/tests/rest/pending-actions.test.ts index a9a1ee0..7e8a251 100644 --- a/tests/rest/pending-actions.test.ts +++ b/tests/rest/pending-actions.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -11,7 +13,7 @@ import { buildApiRoutes } from "../../src/rest/api-routes.js"; let db: Db; let app: ReturnType; -let owner: Actor, bystander: Actor, agent: Actor; +let owner: HumanActor, bystander: HumanActor, agent: Actor; let agentToken: string, supToken: string; let ownerCookie: string, bystanderCookie: string; let issue: IssueView; @@ -28,8 +30,8 @@ function loginCookie(name: string): string { beforeEach(() => { db = openDb(":memory:"); app = buildApiRoutes(db); - owner = createActor(db, { name: "sean", type: "human" }).actor; - bystander = createActor(db, { name: "morgan", type: "human" }).actor; + owner = createHuman(db, "sean"); + bystander = createHuman(db, "morgan"); const created = createActor(db, { name: "claude-code", type: "agent" }); agent = created.actor; agentToken = created.token; diff --git a/tests/services/actors.test.ts b/tests/services/actors.test.ts index f68c6c8..a5e8b95 100644 --- a/tests/services/actors.test.ts +++ b/tests/services/actors.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman, createHumanWithToken } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor, @@ -21,15 +23,15 @@ describe("actors", () => { it("rejects duplicate names with an agent-legible error", () => { const db = openDb(":memory:"); - createActor(db, { name: "sean", type: "human" }); - expect(() => createActor(db, { name: "sean", type: "human" })).toThrowError( + createHumanWithToken(db, "sean"); + expect(() => createHumanWithToken(db, "sean")).toThrowError( /actor named "sean" already exists/, ); }); it("lists actors with token status and no token material", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createActor(db, { name: "claude/worker", type: "agent" }); const list = listActorsWithStatus(db); expect(list).toHaveLength(2); @@ -44,7 +46,7 @@ describe("actors", () => { it("rotates a token: old token stops authenticating, new one works", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const { token: oldToken } = createActor(db, { name: "claude/worker", type: "agent" }); const worker = authenticate(db, oldToken)!; const { token: newToken } = rotateActorToken(db, human, worker.id); @@ -54,44 +56,33 @@ describe("actors", () => { expect(authenticate(db, newToken)?.id).toBe(worker.id); }); - it("rejects agents rotating tokens", () => { - const db = openDb(":memory:"); - const agent = createActor(db, { name: "claude/dev", type: "agent" }).actor; - const other = createActor(db, { name: "claude/worker", type: "agent" }).actor; - expect(() => rotateActorToken(db, agent, other.id)).toThrowError(/only humans/i); - }); + // SYD-281: the agent/service refusal for token rotation moved to the adapter — + // see tests/rest/api-service-actor.test.ts (rotate and revoke, both tiers). it("errors rotating a token for an unknown actor id", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); expect(() => rotateActorToken(db, human, 999)).toThrowError(/no actor with id 999/i); }); it("revokes a token: the actor can no longer authenticate", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const { token } = createActor(db, { name: "claude/worker", type: "agent" }); const worker = authenticate(db, token)!; revokeActorToken(db, human, worker.id); expect(authenticate(db, token)).toBeNull(); }); - it("rejects agents revoking tokens", () => { - const db = openDb(":memory:"); - const agent = createActor(db, { name: "claude/dev", type: "agent" }).actor; - const other = createActor(db, { name: "claude/worker", type: "agent" }).actor; - expect(() => revokeActorToken(db, agent, other.id)).toThrowError(/only humans/i); - }); - it("refuses to let a human revoke their own token", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); expect(() => revokeActorToken(db, human, human.id)).toThrowError(/cannot revoke your own/i); }); it("errors revoking a token for an unknown actor id", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); expect(() => revokeActorToken(db, human, 999)).toThrowError(/no actor with id 999/i); }); @@ -101,7 +92,7 @@ describe("actors", () => { describe("attended", () => { const setup = () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/dev", type: "agent" }).actor; return { db, human, agent }; }; @@ -128,11 +119,9 @@ describe("actors", () => { expect(listActorsWithStatus(db).find((a) => a.id === agent.id)?.attended).toBe(false); }); - // A caller must not be able to widen its own queue. - it("refuses a non-human caller", () => { - const { db, agent } = setup(); - expect(() => setActorAttended(db, agent, agent.id, true)).toThrowError(/only humans/i); - }); + // A caller must not be able to widen its own queue. SYD-281: asserted at the + // adapter now (tests/rest/api-service-actor.test.ts) — setActorAttended takes + // a HumanActor, so an agent cannot reach it. it("refuses to set it on a human — attended by definition, so a flag would imply it can be off", () => { const { db, human } = setup(); diff --git a/tests/services/agent-sessions.test.ts b/tests/services/agent-sessions.test.ts index dd8cc3c..f9d1c3d 100644 --- a/tests/services/agent-sessions.test.ts +++ b/tests/services/agent-sessions.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { events, agentSessions } from "../../src/db/schema.js"; @@ -21,11 +23,11 @@ function ageSession(db: Db, id: number, secondsAgo: number) { db.update(agentSessions).set({ startedAt }).where(eq(agentSessions.id, id)).run(); } -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, agent, { diff --git a/tests/services/attachments.test.ts b/tests/services/attachments.test.ts index 9a7d514..b8aca8b 100644 --- a/tests/services/attachments.test.ts +++ b/tests/services/attachments.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -11,12 +13,12 @@ import { getActivity } from "../../src/services/comments.js"; import { saveAttachment, listAttachments } from "../../src/services/attachments.js"; let db: Db; -let human: Actor; +let human: HumanActor; let tmpRoot: string; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Needs a screenshot" }); tmpRoot = mkdtempSync(path.join(tmpdir(), "syd-att-svc-")); diff --git a/tests/services/attention.test.ts b/tests/services/attention.test.ts index 6864d1f..28efbfb 100644 --- a/tests/services/attention.test.ts +++ b/tests/services/attention.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -16,7 +18,7 @@ const REPO = "acme/widgets"; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); diff --git a/tests/services/auth.test.ts b/tests/services/auth.test.ts index 921b23a..5964951 100644 --- a/tests/services/auth.test.ts +++ b/tests/services/auth.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { loginLinks, actors } from "../../src/db/schema.js"; @@ -11,10 +13,10 @@ import { deleteSession, } from "../../src/services/auth.js"; -let db: Db, human: Actor; +let db: Db, human: HumanActor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createActor(db, { name: "claude/dev", type: "agent" }); }); diff --git a/tests/services/board-column-counts.test.ts b/tests/services/board-column-counts.test.ts index d0bd7c5..68a4ac6 100644 --- a/tests/services/board-column-counts.test.ts +++ b/tests/services/board-column-counts.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { boardColumnCounts } from "../../src/services/board-column-counts.js"; @@ -8,7 +10,7 @@ import { createProject } from "../../src/services/projects.js"; describe("boardColumnCounts", () => { it("counts each project and status and reflects a moved issue", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createProject(db, human, { key: "AIPI", name: "AIPI" }); const first = createIssue(db, human, { projectKey: "SYD", title: "First" }); diff --git a/tests/services/claim-blocked-isolated.test.ts b/tests/services/claim-blocked-isolated.test.ts index 464b053..b541a44 100644 --- a/tests/services/claim-blocked-isolated.test.ts +++ b/tests/services/claim-blocked-isolated.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -10,10 +12,10 @@ import { dependencies } from "../../src/db/schema.js"; // helpers) to prove blocker enforcement is wired via a direct import, not a // mutable-binding side effect that only fires when dependencies.js happens to // have been loaded. -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Schema", priority: "high" }); // AIPI-1 diff --git a/tests/services/comments-hard-gate.test.ts b/tests/services/comments-hard-gate.test.ts index f30a063..0ef9ef3 100644 --- a/tests/services/comments-hard-gate.test.ts +++ b/tests/services/comments-hard-gate.test.ts @@ -11,6 +11,8 @@ // config change could, and confirming addComment's write is now diverted // exactly like a direct updateIssue call would be. import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -21,10 +23,10 @@ import { addComment } from "../../src/services/comments.js"; import { settings } from "../../src/db/schema.js"; import { isHardGated } from "../../src/services/hard-gate.js"; -let db: Db, human: Actor, agent: Actor, sessionId: number; +let db: Db, human: HumanActor, agent: Actor, sessionId: number; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "t", description: "d" }); diff --git a/tests/services/comments.test.ts b/tests/services/comments.test.ts index d3e0caa..eba618b 100644 --- a/tests/services/comments.test.ts +++ b/tests/services/comments.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -10,7 +12,7 @@ import { openSupervisedSession } from "../../src/services/supervised-sessions.js describe("comments and activity", () => { it("appends comments and returns the attributed stream", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); @@ -24,7 +26,7 @@ describe("comments and activity", () => { it("emits agent_question when a human leads a comment with @agent", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); @@ -37,7 +39,7 @@ describe("comments and activity", () => { it("matches @agent case-insensitively and tolerates leading whitespace", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); @@ -51,7 +53,7 @@ describe("comments and activity", () => { it("does not emit agent_question for an ordinary comment or a mid-sentence mention", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); @@ -62,7 +64,7 @@ describe("comments and activity", () => { it("does not emit agent_question when an agent actor writes the comment", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agentActor = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); @@ -73,7 +75,7 @@ describe("comments and activity", () => { it("surfaces supervised-session provenance (viaAgentName) only on the delegated event (SYD-240)", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude-code", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); // plain "created" event diff --git a/tests/services/delivery-attempts.test.ts b/tests/services/delivery-attempts.test.ts index 2f95db8..6bf447f 100644 --- a/tests/services/delivery-attempts.test.ts +++ b/tests/services/delivery-attempts.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; @@ -29,7 +31,7 @@ const REPO = "acme/widgets"; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); return { db, human, agent }; @@ -82,7 +84,7 @@ function stampDone( describe("delivery_attempts schema", () => { it("stores and reads an attempt row with the full outcome enum available", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship v1" }); diff --git a/tests/services/delivery-events.test.ts b/tests/services/delivery-events.test.ts index ac8ae7e..e6dce8a 100644 --- a/tests/services/delivery-events.test.ts +++ b/tests/services/delivery-events.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -10,8 +12,8 @@ import { recordDeliveryEvent } from "../../src/services/delivery-events.js"; function setup(boundRepos: string[] = []) { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; - const worker = createActor(db, { name: "delivery-worker", type: "human" }).actor; + const human = createHuman(db, "sean"); + const worker = createHuman(db, "delivery-worker"); createProject(db, worker, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship v1" }); for (const fullName of boundRepos) addGithubRepo(db, human, { fullName, projectKey: "SYD" }); @@ -21,9 +23,9 @@ function setup(boundRepos: string[] = []) { describe("recordDeliveryEvent", () => { it("appends pr_opened, delivered, and delivery_failed events to the activity feed", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); // The delivery infra authenticates with a human-typed token (SYD-107/108). - const worker = createActor(db, { name: "delivery-worker", type: "human" }).actor; + const worker = createHuman(db, "delivery-worker"); createProject(db, worker, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship v1" }); @@ -59,8 +61,8 @@ describe("recordDeliveryEvent", () => { it("records delivery_failed with just a message", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; - const worker = createActor(db, { name: "delivery-worker", type: "human" }).actor; + const human = createHuman(db, "sean"); + const worker = createHuman(db, "delivery-worker"); createProject(db, worker, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship v1" }); @@ -78,7 +80,7 @@ describe("recordDeliveryEvent", () => { it("rejects agent actors so delivery status can't be forged (SYD-108)", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship v1" }); @@ -96,7 +98,7 @@ describe("recordDeliveryEvent", () => { it("throws for an unknown issue ref", () => { const db = openDb(":memory:"); - const worker = createActor(db, { name: "delivery-worker", type: "human" }).actor; + const worker = createHuman(db, "delivery-worker"); createProject(db, worker, { key: "SYD", name: "Switchyard" }); expect(() => recordDeliveryEvent(db, worker, "SYD-9", { type: "delivery_failed", message: "boom" }), diff --git a/tests/services/delivery-health.test.ts b/tests/services/delivery-health.test.ts index 2b3f11a..1bac999 100644 --- a/tests/services/delivery-health.test.ts +++ b/tests/services/delivery-health.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; @@ -18,7 +20,7 @@ const REPO = "acme/widgets"; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); return { db, human }; } diff --git a/tests/services/dependencies.test.ts b/tests/services/dependencies.test.ts index 83f6850..32b6c2e 100644 --- a/tests/services/dependencies.test.ts +++ b/tests/services/dependencies.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -15,10 +17,10 @@ import { listIssueEvents } from "../../src/services/events.js"; import { recordDeliveryEvent } from "../../src/services/delivery-events.js"; import { addGithubRepo } from "../../src/services/github-repos.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); // Bound repo so recordDeliveryEvent's publish writes pr_state — post-SYD-207 diff --git a/tests/services/dependency-remove-hard-gate-affirm-exec.test.ts b/tests/services/dependency-remove-hard-gate-affirm-exec.test.ts index b9c7a55..b8a5548 100644 --- a/tests/services/dependency-remove-hard-gate-affirm-exec.test.ts +++ b/tests/services/dependency-remove-hard-gate-affirm-exec.test.ts @@ -4,6 +4,8 @@ // supervised proposal to remove an edge creates the pending row, then a human // affirms it. Mirrors hard-gate-affirm-exec.test.ts (the "done" transition). import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { and, eq } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; @@ -20,10 +22,10 @@ import { setSetting } from "../../src/services/settings.js"; import { events, pendingActions } from "../../src/db/schema.js"; import { affirmPendingAction, getPendingAction } from "../../src/services/hard-gate.js"; -let db: Db, human: Actor, agent: Actor, blockedId: number, sessionId: number; +let db: Db, human: HumanActor, agent: Actor, blockedId: number, sessionId: number; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "blocker" }); // SYD-1 diff --git a/tests/services/dependency-remove-hard-gate-divert.test.ts b/tests/services/dependency-remove-hard-gate-divert.test.ts index afcf0fa..eca01fb 100644 --- a/tests/services/dependency-remove-hard-gate-divert.test.ts +++ b/tests/services/dependency-remove-hard-gate-divert.test.ts @@ -3,6 +3,8 @@ // committing it, for a supervised session. Mirrors // hard-gate-divert.test.ts (the "done" transition's divert in updateIssue). import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -16,10 +18,10 @@ import { openSupervisedSession } from "../../src/services/supervised-sessions.js import { setSetting } from "../../src/services/settings.js"; import { pendingActions } from "../../src/db/schema.js"; -let db: Db, human: Actor, sessionId: number; +let db: Db, human: HumanActor, sessionId: number; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "blocker" }); // SYD-1 createIssue(db, human, { projectKey: "SYD", title: "blocked" }); // SYD-2 diff --git a/tests/services/deviation.test.ts b/tests/services/deviation.test.ts index 5dc279f..197bebd 100644 --- a/tests/services/deviation.test.ts +++ b/tests/services/deviation.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { claimLeases, events } from "../../src/db/schema.js"; @@ -22,7 +24,7 @@ const REPO = "acme/widgets"; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); // Bound repo so recordDeliveryEvent/upsertPrState write attributed pr_state diff --git a/tests/services/events-attribution.test.ts b/tests/services/events-attribution.test.ts index 88aaf10..d2906a8 100644 --- a/tests/services/events-attribution.test.ts +++ b/tests/services/events-attribution.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { sql } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; @@ -7,10 +9,10 @@ import { createIssue } from "../../src/services/issues.js"; import { recordEvent } from "../../src/services/events.js"; import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "TEST", name: "test" }); createIssue(db, human, { projectKey: "TEST", title: "Test Issue" }); diff --git a/tests/services/events.test.ts b/tests/services/events.test.ts index e8c1caa..1c349ee 100644 --- a/tests/services/events.test.ts +++ b/tests/services/events.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb } from "../../src/db/index.js"; import { events } from "../../src/db/schema.js"; @@ -20,7 +22,7 @@ import { describe("listIssueEvents", () => { it("projects viaAgentName only on supervised events, in a mixed feed with plain events (SYD-240)", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude-code", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); const issue = createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); // plain event @@ -51,7 +53,7 @@ describe("listIssueEvents", () => { describe("listRecentEvents", () => { it("returns events newest-first, joined with issue ref, title, project key, and actor name", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); // 1 event: created updateIssue(db, human, "SYD-1", { status: "todo" }); // 1 event: status change @@ -72,7 +74,7 @@ describe("listRecentEvents", () => { it("filters by since (strictly after the given unix timestamp)", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Old one" }); updateIssue(db, human, "SYD-1", { status: "todo" }); @@ -94,7 +96,7 @@ describe("listRecentEvents", () => { it("defaults the limit to 200 and caps it at 500", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Only issue" }); @@ -113,7 +115,7 @@ describe("listRecentEvents", () => { describe("listRecentEventsPage", () => { function setupManyEvents(count: number) { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); const issue = createIssue(db, human, { projectKey: "SYD", title: "Busy issue" }); // 1 "created" event for (let i = 0; i < count - 1; i++) { @@ -167,7 +169,7 @@ describe("listRecentEventsPage", () => { describe("listUnansweredQuestions", () => { function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); return { db, human, agent }; diff --git a/tests/services/github-repos.test.ts b/tests/services/github-repos.test.ts index 8b0345c..7a18f09 100644 --- a/tests/services/github-repos.test.ts +++ b/tests/services/github-repos.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -12,7 +14,7 @@ import { describe("github repos", () => { it("links, lists, scopes to a project, and unlinks", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const p = createProject(db, human, { key: "SYD", name: "Switchyard" }); const unscoped = addGithubRepo(db, human, { fullName: "acme/widgets" }); const scoped = addGithubRepo(db, human, { @@ -32,7 +34,7 @@ describe("github repos", () => { it("rejects malformed full names, unknown projects, and duplicate links", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); expect(() => addGithubRepo(db, human, { fullName: "not-a-repo" })).toThrowError( /must be "owner\/repo"/i, @@ -48,7 +50,7 @@ describe("github repos", () => { it("errors removing an unknown id", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); expect(() => removeGithubRepo(db, human, 999)).toThrowError( /no linked github repo with id 999/i, ); @@ -56,7 +58,7 @@ describe("github repos", () => { it("finds a linked repo by full name, or undefined when unlinked", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); addGithubRepo(db, human, { fullName: "acme/widgets", secret: "s3cret" }); expect(findGithubRepo(db, "acme/widgets")?.secret).toBe("s3cret"); expect(findGithubRepo(db, "acme/other")).toBeUndefined(); @@ -64,7 +66,7 @@ describe("github repos", () => { it("normalizes fullName to lowercase on write and matches regardless of casing (SYD-212)", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const repo = addGithubRepo(db, human, { fullName: "MobilityLabs/Switchyard" }); expect(repo.fullName).toBe("mobilitylabs/switchyard"); @@ -78,24 +80,13 @@ describe("github repos", () => { it("rejects linking the same repo twice under different casing", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); addGithubRepo(db, human, { fullName: "acme/widgets" }); expect(() => addGithubRepo(db, human, { fullName: "Acme/Widgets" })).toThrowError( /already linked/i, ); }); - it("rejects agent actors managing linked repos", () => { - const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; - const agent = createActor(db, { name: "claude/dev", type: "agent" }).actor; - const repo = addGithubRepo(db, human, { fullName: "acme/widgets" }); - - expect(() => addGithubRepo(db, agent, { fullName: "acme/other" })).toThrowError( - /only humans manage linked github repos/i, - ); - expect(() => removeGithubRepo(db, agent, repo.id)).toThrowError( - /only humans manage linked github repos/i, - ); - }); + // SYD-281: the agent/service refusal moved to the adapter — see + // tests/rest/api-service-actor.test.ts (link and unlink, both non-human tiers). }); diff --git a/tests/services/github-webhook.test.ts b/tests/services/github-webhook.test.ts index 0c73dad..21a7e8f 100644 --- a/tests/services/github-webhook.test.ts +++ b/tests/services/github-webhook.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -18,7 +20,7 @@ import { listLiveLinks } from "../../src/services/pr-links.js"; function setup(boundRepos: string[] = []) { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship v1" }); for (const fullName of boundRepos) { @@ -699,7 +701,7 @@ describe("handleGithubWebhook / pr_state integration (SYD-206)", () => { it("an agent/SYD-1 PR in a repo bound to another project is never attributed to SYD-1 (cross-repo)", () => { const db = setup(["acme/bound"]); - const human = createActor(db, { name: "sean2", type: "human" }).actor; + const human = createHuman(db, "sean2"); createProject(db, human, { key: "OTH", name: "Other" }); addGithubRepo(db, human, { fullName: "acme/other", projectKey: "OTH" }); @@ -718,7 +720,7 @@ describe("handleGithubWebhook / pr_state integration (SYD-206)", () => { it("a PR in a linked-but-UNBOUND repo is not observed at all", () => { const db = setup(["acme/bound"]); - const human = createActor(db, { name: "sean3", type: "human" }).actor; + const human = createHuman(db, "sean3"); // Linked with no projectKey — the SYD-207 preflight's warning case. addGithubRepo(db, human, { fullName: "acme/unbound" }); @@ -820,7 +822,7 @@ describe("handleGithubWebhook / actor reuse", () => { describe("handleGithubWebhook / sibling refs named in PR text (SYD-274)", () => { function multiIssueSetup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createProject(db, human, { key: "NOC", name: "Piano" }); createIssue(db, human, { projectKey: "SYD", title: "parent" }); // SYD-1 diff --git a/tests/services/hard-gate-affirm-exec.test.ts b/tests/services/hard-gate-affirm-exec.test.ts index bbd7f50..587573c 100644 --- a/tests/services/hard-gate-affirm-exec.test.ts +++ b/tests/services/hard-gate-affirm-exec.test.ts @@ -5,6 +5,8 @@ // isolation (rows created directly via findOrCreatePendingAction); this file // checks the same guarantees hold when the row comes from the real divert. import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { sql } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; @@ -18,10 +20,10 @@ import { affirmPendingAction, getPendingAction } from "../../src/services/hard-g const REPO = "acme/widgets"; -let db: Db, human: Actor, issueId: number, sessionId: number; +let db: Db, human: HumanActor, issueId: number, sessionId: number; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); issueId = createIssue(db, human, { projectKey: "SYD", title: "t", description: "d" }).id; sessionId = openSupervisedSession(db, human, "claude-code").sessionId; diff --git a/tests/services/hard-gate-divert.test.ts b/tests/services/hard-gate-divert.test.ts index aca748e..7fd8727 100644 --- a/tests/services/hard-gate-divert.test.ts +++ b/tests/services/hard-gate-divert.test.ts @@ -3,6 +3,8 @@ // supervised session. See src/services/hard-gate.ts for the gate policy and // pending-action CRUD this divert calls into. import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -10,10 +12,10 @@ import { createIssue, getIssue, updateIssue } from "../../src/services/issues.js import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; import { pendingActions } from "../../src/db/schema.js"; -let db: Db, human: Actor, issueId: number, sessionId: number; +let db: Db, human: HumanActor, issueId: number, sessionId: number; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); issueId = createIssue(db, human, { projectKey: "SYD", title: "t", description: "d" }).id; sessionId = openSupervisedSession(db, human, "claude-code").sessionId; diff --git a/tests/services/hard-gate.test.ts b/tests/services/hard-gate.test.ts index 9c87d79..2705db5 100644 --- a/tests/services/hard-gate.test.ts +++ b/tests/services/hard-gate.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { and, eq, sql } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; @@ -41,10 +43,10 @@ const park = ( expiresAt: number = nowSec() + 300, ) => findOrCreatePendingAction(db, session, issue, actionType, payload, expiresAt); -let db: Db, human: Actor, issueId: number, sessionId: number; +let db: Db, human: HumanActor, issueId: number, sessionId: number; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); addGithubRepo(db, human, { fullName: REPO, projectKey: "SYD" }); issueId = createIssue(db, human, { projectKey: "SYD", title: "t", description: "d" }).id; @@ -172,7 +174,7 @@ describe("affirmPendingAction", () => { }); it("refuses a human who is not the session's accountable human", () => { - const other = createActor(db, { name: "other", type: "human" }).actor; + const other = createHuman(db, "other"); const id = park(sessionId, issueId, "done"); expect(() => affirmPendingAction(db, other, id)).toThrow(/only the accountable human/i); expect(getIssue(db, "SYD-1").status).not.toBe("done"); @@ -248,7 +250,7 @@ describe("affirmPendingAction", () => { }); it("a non-owner affirming an expired row gets the owner-tie error, not the expired one, and causes no write", () => { - const other = createActor(db, { name: "other", type: "human" }).actor; + const other = createHuman(db, "other"); const id = park(sessionId, issueId, "done", {}, nowSec() - 1); expect(() => affirmPendingAction(db, other, id)).toThrow(/only the accountable human/i); // Not the expiry message, and — the actual proof — the row was never diff --git a/tests/services/issue-hierarchy.test.ts b/tests/services/issue-hierarchy.test.ts index a5fae10..92644fd 100644 --- a/tests/services/issue-hierarchy.test.ts +++ b/tests/services/issue-hierarchy.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -11,10 +13,10 @@ import { } from "../../src/services/issues.js"; import { listIssueEvents } from "../../src/services/events.js"; -let db: Db, human: Actor; +let db: Db, human: HumanActor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Epic" }); // SYD-1 }); diff --git a/tests/services/issues-create.test.ts b/tests/services/issues-create.test.ts index 5568fb3..263b4c8 100644 --- a/tests/services/issues-create.test.ts +++ b/tests/services/issues-create.test.ts @@ -1,14 +1,16 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, getIssue, toView, SUMMARY_MAX_LENGTH } from "../../src/services/issues.js"; import { listIssueEvents } from "../../src/services/events.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); }); diff --git a/tests/services/issues-update.test.ts b/tests/services/issues-update.test.ts index 7db259b..daed88f 100644 --- a/tests/services/issues-update.test.ts +++ b/tests/services/issues-update.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -16,10 +18,10 @@ import { addGithubRepo } from "../../src/services/github-repos.js"; const REPO = "acme/widgets"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); // Bound repo so recordDeliveryEvent's publish writes pr_state — post-SYD-207 diff --git a/tests/services/lease-claim-takeover.test.ts b/tests/services/lease-claim-takeover.test.ts index e7c5076..624d31f 100644 --- a/tests/services/lease-claim-takeover.test.ts +++ b/tests/services/lease-claim-takeover.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -6,10 +8,10 @@ import { createIssue, updateIssue, claimIssue, getIssue } from "../../src/servic import { validateLease, getActiveLease } from "../../src/services/leases.js"; import { listIssueEvents } from "../../src/services/events.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "t" }); diff --git a/tests/services/lease-cutover.test.ts b/tests/services/lease-cutover.test.ts index 777e1ac..c900ae4 100644 --- a/tests/services/lease-cutover.test.ts +++ b/tests/services/lease-cutover.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -6,10 +8,10 @@ import { createIssue, updateIssue, claimIssue, getIssue } from "../../src/servic import { ensureClaimLeaseCutover } from "../../src/services/lease-cutover.js"; import { listIssueEvents } from "../../src/services/events.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); }); diff --git a/tests/services/lease-expiry.test.ts b/tests/services/lease-expiry.test.ts index 99d5088..b091b2e 100644 --- a/tests/services/lease-expiry.test.ts +++ b/tests/services/lease-expiry.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { claimLeases, issues, events } from "../../src/db/schema.js"; @@ -9,10 +11,10 @@ import { expireLeases, invalidateLease, getActiveLease } from "../../src/service import { releaseStaleClaims } from "../../src/services/stale-claims.js"; import { listIssueEvents } from "../../src/services/events.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); }); diff --git a/tests/services/lease-heartbeat.test.ts b/tests/services/lease-heartbeat.test.ts index dcb4aae..8b615e7 100644 --- a/tests/services/lease-heartbeat.test.ts +++ b/tests/services/lease-heartbeat.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -6,10 +8,10 @@ import { createIssue, updateIssue, claimIssue, getIssue } from "../../src/servic import { getActiveLease, heartbeatLease } from "../../src/services/leases.js"; import { getSetting } from "../../src/services/settings.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "t" }); diff --git a/tests/services/lease-human-answer.test.ts b/tests/services/lease-human-answer.test.ts index a61115b..5b97b93 100644 --- a/tests/services/lease-human-answer.test.ts +++ b/tests/services/lease-human-answer.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -7,10 +9,10 @@ import { requestHumanInput } from "../../src/services/needs-input.js"; import { addComment } from "../../src/services/comments.js"; import { getActiveLease } from "../../src/services/leases.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "t" }); diff --git a/tests/services/lease-no-serialization.test.ts b/tests/services/lease-no-serialization.test.ts index 4f02400..fd56dff 100644 --- a/tests/services/lease-no-serialization.test.ts +++ b/tests/services/lease-no-serialization.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -6,10 +8,10 @@ import { createIssue, updateIssue, claimIssue, getIssue } from "../../src/servic import { getActivity } from "../../src/services/comments.js"; import { searchIssues } from "../../src/services/search.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "t" }); diff --git a/tests/services/lease-request-input.test.ts b/tests/services/lease-request-input.test.ts index 4a526a4..aae0365 100644 --- a/tests/services/lease-request-input.test.ts +++ b/tests/services/lease-request-input.test.ts @@ -1,14 +1,16 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, updateIssue, claimIssue } from "../../src/services/issues.js"; import { requestHumanInput } from "../../src/services/needs-input.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "t" }); diff --git a/tests/services/lease-update-issue.test.ts b/tests/services/lease-update-issue.test.ts index bfa0d6a..573b9cd 100644 --- a/tests/services/lease-update-issue.test.ts +++ b/tests/services/lease-update-issue.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -6,10 +8,10 @@ import { createIssue, updateIssue, getIssue } from "../../src/services/issues.js import { getActiveLease } from "../../src/services/leases.js"; import { listIssueEvents } from "../../src/services/events.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "t" }); diff --git a/tests/services/leases.test.ts b/tests/services/leases.test.ts index 9b2c382..962c7e3 100644 --- a/tests/services/leases.test.ts +++ b/tests/services/leases.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -6,10 +8,10 @@ import { createIssue } from "../../src/services/issues.js"; import { getActiveLease, mintLease, validateLease } from "../../src/services/leases.js"; import { getSetting } from "../../src/services/settings.js"; -let db: Db, human: Actor, agent: Actor, issueId: number; +let db: Db, human: HumanActor, agent: Actor, issueId: number; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); issueId = createIssue(db, human, { projectKey: "AIPI", title: "t" }).id; diff --git a/tests/services/linear-import.test.ts b/tests/services/linear-import.test.ts index 548aa0a..20a31cb 100644 --- a/tests/services/linear-import.test.ts +++ b/tests/services/linear-import.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -232,7 +234,7 @@ describe("buildImportPlan", () => { }); it("marks existing projects/actors and skips already-imported issues", () => { - const sean = createActor(db, { name: "sean", type: "human" }).actor; + const sean = createHuman(db, "sean"); const project = createProject(db, sean, { key: "ENG", name: "Engineering" }); db.insert(issues) .values({ @@ -255,7 +257,7 @@ describe("buildImportPlan", () => { }); it("refuses a number collision with a non-imported issue", () => { - const sean = createActor(db, { name: "sean", type: "human" }).actor; + const sean = createHuman(db, "sean"); const project = createProject(db, sean, { key: "ENG", name: "Engineering" }); db.insert(issues) .values({ diff --git a/tests/services/needs-input.test.ts b/tests/services/needs-input.test.ts index c38f9a8..0ced4b0 100644 --- a/tests/services/needs-input.test.ts +++ b/tests/services/needs-input.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -8,10 +10,10 @@ import { listIssueEvents } from "../../src/services/events.js"; import { searchIssues } from "../../src/services/search.js"; import { requestHumanInput } from "../../src/services/needs-input.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); diff --git a/tests/services/pr-links.test.ts b/tests/services/pr-links.test.ts index b531882..f320b14 100644 --- a/tests/services/pr-links.test.ts +++ b/tests/services/pr-links.test.ts @@ -9,6 +9,8 @@ // 2. Declaring is not confirming. An agent can over-block (safe, revocable) // but can never make its own link prove that its work landed. import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { prState } from "../../src/db/schema.js"; import { createActor } from "../../src/services/actors.js"; @@ -35,7 +37,7 @@ const OTHER_REPO = "acme/unrelated"; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; const other = createActor(db, { name: "claude/other", type: "agent" }).actor; const infra = createActor(db, { name: "deliver", type: "service" }).actor; @@ -474,7 +476,7 @@ describe("listLiveLinkViews — what the panel shows a human", () => { } /** Walks SYD-1 to `done` with no PR of any kind, arming done_without_merged_pr. */ - function stampDone(db: ReturnType, human: Actor, agent: Actor) { + function stampDone(db: ReturnType, human: HumanActor, agent: Actor) { claimIssue(db, agent, "SYD-1"); updateIssue(db, human, "SYD-1", { status: "in_review" }); updateIssue(db, human, "SYD-1", { status: "done" }); diff --git a/tests/services/pr-observation.test.ts b/tests/services/pr-observation.test.ts index e0d9478..d2737ac 100644 --- a/tests/services/pr-observation.test.ts +++ b/tests/services/pr-observation.test.ts @@ -17,6 +17,8 @@ // the tests that matter, because that is the order production produces (open // the PR, the poller sees it within a tick, then the session declares). import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -40,7 +42,7 @@ const PR = 226; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/dev", type: "agent" }).actor; const other = createActor(db, { name: "claude/other", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); diff --git a/tests/services/pr-state-cutover.test.ts b/tests/services/pr-state-cutover.test.ts index 5234c9e..3802f78 100644 --- a/tests/services/pr-state-cutover.test.ts +++ b/tests/services/pr-state-cutover.test.ts @@ -4,6 +4,8 @@ // the consumers (search filter, claim gate, open-PR reads) agree with each // other — one oracle, no fresh disagreement. import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -19,7 +21,7 @@ const OTHER_REPO = "acme/other"; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createProject(db, human, { key: "OTH", name: "Other" }); diff --git a/tests/services/pr-state.test.ts b/tests/services/pr-state.test.ts index 36bd853..6f4e3ec 100644 --- a/tests/services/pr-state.test.ts +++ b/tests/services/pr-state.test.ts @@ -5,6 +5,8 @@ // rebuilding the drift bug class the event-log derivation had. import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -26,7 +28,7 @@ const T3 = "2026-07-12T12:00:00Z"; function setup(opts: { bindRepo?: boolean } = {}) { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const github = createActor(db, { name: "github", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship v1" }); diff --git a/tests/services/pr-status.test.ts b/tests/services/pr-status.test.ts index 297ae3e..9e9a9c1 100644 --- a/tests/services/pr-status.test.ts +++ b/tests/services/pr-status.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -18,7 +20,7 @@ const REPO = "acme/widgets"; function setup() { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); diff --git a/tests/services/projects.test.ts b/tests/services/projects.test.ts index b435df4..f8f8957 100644 --- a/tests/services/projects.test.ts +++ b/tests/services/projects.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; import { openDb } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { createProject, updateProject, @@ -9,9 +11,9 @@ import { reserveIssueNumber, } from "../../src/services/projects.js"; -function actors(db: ReturnType): { human: Actor; agent: Actor } { +function actors(db: ReturnType): { human: HumanActor; agent: Actor } { return { - human: createActor(db, { name: "sean", type: "human" }).actor, + human: createHuman(db, "sean"), agent: createActor(db, { name: "claude/dev", type: "agent" }).actor, }; } @@ -29,12 +31,11 @@ describe("updateProject (SYD-157, landed via SYD-158)", () => { expect(renamed.nextIssueNumber).toBe(2); }); - it("rejects agent actors — renames are human-only", () => { - const db = openDb(":memory:"); - const { human, agent } = actors(db); - createProject(db, human, { key: "AIPI", name: "aipi" }); - expect(() => updateProject(db, agent, "AIPI", { name: "sneaky" })).toThrowError(/only humans/i); - }); + // SYD-281: the agent-refusal assertion moved to the adapter, where the gate + // now lives — tests/rest/api-service-actor.test.ts covers both the agent and + // the service tier against every human-only route. Re-asserting it here would + // need a manufactured HumanActor, which is the state-constructing test shape + // this story exists to remove. it("throws legibly for an unknown project key", () => { const db = openDb(":memory:"); @@ -45,14 +46,6 @@ describe("updateProject (SYD-157, landed via SYD-158)", () => { }); }); -describe("createProject human-only guard (SYD-157)", () => { - it("rejects agent actors", () => { - const db = openDb(":memory:"); - const { agent } = actors(db); - expect(() => createProject(db, agent, { key: "AIPI", name: "x" })).toThrowError(/only humans/i); - }); -}); - describe("projects", () => { it("creates, lists, and fetches by key", () => { const db = openDb(":memory:"); diff --git a/tests/services/queue.test.ts b/tests/services/queue.test.ts index d3dcc70..94149cf 100644 --- a/tests/services/queue.test.ts +++ b/tests/services/queue.test.ts @@ -10,6 +10,8 @@ // because the rank is an implementation detail and the observable contract is // "what do I get handed next". import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -18,11 +20,11 @@ import { addDependency, nextTask } from "../../src/services/dependencies.js"; import { listQueue, setQueuePosition } from "../../src/services/queue.js"; import { listIssueEvents } from "../../src/services/events.js"; -let db: Db, human: Actor, claude: Actor, codex: Actor; +let db: Db, human: HumanActor, claude: Actor, codex: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); // The engine is the actor-name prefix — these are the real production actor // names (claude/dev, codex/dev, gemini/dev), which is what makes affinity // derivable from the caller's token alone. diff --git a/tests/services/search.test.ts b/tests/services/search.test.ts index b63abad..ed50816 100644 --- a/tests/services/search.test.ts +++ b/tests/services/search.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import type Database from "better-sqlite3"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; @@ -8,10 +10,10 @@ import { searchIssues } from "../../src/services/search.js"; import { recordDeliveryEvent } from "../../src/services/delivery-events.js"; import { addGithubRepo } from "../../src/services/github-repos.js"; -let db: Db, human: Actor; +let db: Db, human: HumanActor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); createProject(db, human, { key: "AIPI", name: "aipi" }); createProject(db, human, { key: "HAND", name: "housing" }); // Bound repo so recordDeliveryEvent's publish writes pr_state — post-SYD-207 diff --git a/tests/services/service-actor.test.ts b/tests/services/service-actor.test.ts index f9dfebf..f7af7e9 100644 --- a/tests/services/service-actor.test.ts +++ b/tests/services/service-actor.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, @@ -28,10 +30,10 @@ import { listIssueEvents } from "../../src/services/events.js"; // sits ABOVE agent (may post PR/delivery events) and STRICTLY BELOW human // (cannot stamp/triage/remove-deps/mint-login/manage config). This matrix is // the security contract: adding the type must not leak any human capability. -let db: Db, human: Actor, agent: Actor, service: Actor; +let db: Db, human: HumanActor, agent: Actor, service: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; service = createActor(db, { name: "github-poller", type: "service" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); @@ -145,47 +147,15 @@ describe("service actor — DENIED all issue create/modify (fail-closed)", () => }); }); -describe("service actor — DENIED (config / dependencies / tokens)", () => { - it("cannot remove a dependency", () => { - addDependency(db, human, "AIPI-1", "AIPI-2"); - expect(() => removeDependency(db, service, "AIPI-1", "AIPI-2")).toThrowError(/only humans/i); - }); - - it("cannot link a GitHub repo", () => { - expect(() => - addGithubRepo(db, service, { fullName: "acme/other", projectKey: "AIPI" }), - ).toThrowError(/only humans/i); - }); - - it("cannot change a setting", () => { - expect(() => setSetting(db, service, "intervalSeconds", 999)).toThrowError(/human/i); - }); - - it("cannot add a webhook", () => { - expect(() => - addWebhook(db, service, { url: "https://example.com/hook", projectKey: "AIPI" }), - ).toThrowError(/human/i); - }); - - it("cannot rotate an actor's token", () => { - expect(() => rotateActorToken(db, service, agent.id)).toThrowError(/only humans/i); - }); - - it("cannot revoke an actor's token", () => { - expect(() => revokeActorToken(db, service, agent.id)).toThrowError(/only humans/i); - }); - - it("cannot create a project", () => { - expect(() => createProject(db, service, { key: "NEW", name: "new" })).toThrowError( - /only humans/i, - ); - }); - - it("cannot snooze an issue", () => { - expect(() => snoozeIssue(db, service, "AIPI-1", 9999999999)).toThrowError(/only humans/i); - }); - - it("cannot mark an issue a duplicate", () => { - expect(() => markDuplicate(db, service, "AIPI-1", "AIPI-2")).toThrowError(/only humans/i); - }); -}); +// SYD-281: the "service actor — DENIED (config / dependencies / tokens)" block +// that stood here asserted the service tier is refused by nine human-only +// services. Those gates no longer live in the service layer — the six private +// `requireHuman` helpers collapsed into `requireHumanCaller` at the adapter, so +// the services now take a `HumanActor` and cannot be handed a service actor at +// all. +// +// The assertions were not dropped; they moved to +// tests/rest/api-service-actor.test.ts, which now walks 17 human-only routes +// against BOTH the agent and service tiers — wider coverage than this block had. +// Re-asserting here would require manufacturing a `HumanActor` in a test, which +// is the state-constructing shape this story exists to remove. diff --git a/tests/services/settings.test.ts b/tests/services/settings.test.ts index 1803bfd..080efd9 100644 --- a/tests/services/settings.test.ts +++ b/tests/services/settings.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, afterEach, vi } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { @@ -32,7 +34,7 @@ describe("settings", () => { it("a human can override a setting, read it back, and it shows isDefault: false", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); setSetting(db, human, "sessions.stale_seconds", 60); expect(getSetting(db, "sessions.stale_seconds")).toBe(60); const row = getAllSettings(db).find((r) => r.key === "sessions.stale_seconds")!; @@ -42,7 +44,7 @@ describe("settings", () => { it("setting twice overwrites rather than duplicating", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); setSetting(db, human, "dispatch.poll_seconds", 100); setSetting(db, human, "dispatch.poll_seconds", 200); expect(getSetting(db, "dispatch.poll_seconds")).toBe(200); @@ -50,7 +52,7 @@ describe("settings", () => { it("resetSetting deletes the row, reverting to default", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); setSetting(db, human, "instance.name", "Acme Tracker"); expect(getSetting(db, "instance.name")).toBe("Acme Tracker"); resetSetting(db, human, "instance.name"); @@ -60,7 +62,7 @@ describe("settings", () => { it("validates type and range", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); expect(() => setSetting(db, human, "dispatch.max_concurrent", "not a number")).toThrowError( /positive integer/i, ); @@ -86,18 +88,16 @@ describe("settings", () => { it("rejects unknown keys on get/set/reset", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); expect(() => getSetting(db, "nope.nope" as never)).toThrowError(/unknown setting/i); expect(() => setSetting(db, human, "nope.nope", 1)).toThrowError(/unknown setting/i); expect(() => resetSetting(db, human, "nope.nope")).toThrowError(/unknown setting/i); }); - it("rejects agent actors writing settings", () => { - const db = openDb(":memory:"); - const agent = createActor(db, { name: "claude/dev", type: "agent" }).actor; - expect(() => setSetting(db, agent, "instance.name", "Nope")).toThrowError(/human-only/i); - expect(() => resetSetting(db, agent, "instance.name")).toThrowError(/human-only/i); - }); + // SYD-281: the agent/service refusal moved to the adapter, where the gate now + // lives — tests/rest/api-service-actor.test.ts. setSetting takes a HumanActor, + // so an agent cannot reach it at all, and asserting that here would mean + // minting a fake one. it("getDispatchPolicy returns just the dispatch.* group, defaults on a fresh DB", () => { const db = openDb(":memory:"); @@ -119,7 +119,7 @@ describe("settings", () => { it("an instance.base_url override wins over SWITCHYARD_URL", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); setSetting(db, human, "instance.base_url", "https://tracker.example.com"); vi.stubEnv("SWITCHYARD_URL", "http://env.example:9999"); expect(resolveBaseUrl(db)).toBe("https://tracker.example.com"); @@ -139,7 +139,7 @@ describe("settings", () => { it("resetSetting restores env/default resolution", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); setSetting(db, human, "instance.base_url", "https://tracker.example.com"); resetSetting(db, human, "instance.base_url"); vi.stubEnv("SWITCHYARD_URL", "http://env.example:9999"); @@ -149,7 +149,7 @@ describe("settings", () => { it("getDispatchPolicy reflects human overrides", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); setSetting(db, human, "dispatch.max_concurrent", 5); setSetting(db, human, "dispatch.poll_seconds", 30); expect(getDispatchPolicy(db)).toMatchObject({ maxConcurrent: 5, intervalSeconds: 30 }); @@ -163,7 +163,7 @@ describe("settings", () => { it("accepts a boolean for affirm_requires_signature and rejects non-booleans", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); setSetting(db, human, "supervised.affirm_requires_signature", true); expect(getSetting(db, "supervised.affirm_requires_signature")).toBe(true); expect(() => setSetting(db, human, "supervised.affirm_requires_signature", "yes")).toThrow( diff --git a/tests/services/stale-claims.test.ts b/tests/services/stale-claims.test.ts index dde5e13..322d157 100644 --- a/tests/services/stale-claims.test.ts +++ b/tests/services/stale-claims.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { claimLeases, events, issues } from "../../src/db/schema.js"; @@ -9,10 +11,10 @@ import { listIssueEvents } from "../../src/services/events.js"; import { releaseStaleClaims } from "../../src/services/stale-claims.js"; import { setSetting } from "../../src/services/settings.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); }); diff --git a/tests/services/supervised-attribution-e2e.test.ts b/tests/services/supervised-attribution-e2e.test.ts index 811ee25..44e5266 100644 --- a/tests/services/supervised-attribution-e2e.test.ts +++ b/tests/services/supervised-attribution-e2e.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { sql } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; @@ -7,10 +9,10 @@ import { createIssue, updateIssue, claimIssue } from "../../src/services/issues. import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; import { attributionOf } from "../../src/services/attribution.js"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude-code", type: "agent" }).actor; createProject(db, human, { key: "SUP", name: "supervised" }); }); diff --git a/tests/services/triage-actions.test.ts b/tests/services/triage-actions.test.ts index 497fc25..83e4fbb 100644 --- a/tests/services/triage-actions.test.ts +++ b/tests/services/triage-actions.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { issues } from "../../src/db/schema.js"; @@ -21,10 +23,10 @@ import { upsertPrState } from "../../src/services/pr-state.js"; const REPO = "acme/widgets"; -let db: Db, human: Actor, agent: Actor; +let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { db = openDb(":memory:"); - human = createActor(db, { name: "sean", type: "human" }).actor; + human = createHuman(db, "sean"); agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "First" }); @@ -32,11 +34,9 @@ beforeEach(() => { }); describe("snoozeIssue", () => { - it("rejects agents legibly", () => { - const future = Math.floor(Date.now() / 1000) + 3600; - expect(() => snoozeIssue(db, agent, "AIPI-1", future)).toThrowError(/human/i); - }); - + // SYD-281: the agent/service refusal moved to the adapter — see + // tests/rest/api-service-actor.test.ts, which covers all five triage verbs + // against both non-human tiers. it("rejects a non-future until", () => { const past = Math.floor(Date.now() / 1000) - 10; expect(() => snoozeIssue(db, human, "AIPI-1", past)).toThrowError(/future/i); @@ -81,10 +81,9 @@ describe("snoozeIssue", () => { }); describe("markDuplicate", () => { - it("rejects agents legibly", () => { - expect(() => markDuplicate(db, agent, "AIPI-1", "AIPI-2")).toThrowError(/human/i); - }); - + // SYD-281: the agent/service refusal moved to the adapter — see + // tests/rest/api-service-actor.test.ts, which covers all five triage verbs + // against both non-human tiers. it("rejects self-duplicate", () => { expect(() => markDuplicate(db, human, "AIPI-1", "AIPI-1")).toThrowError(/itself|differ/i); }); @@ -110,7 +109,6 @@ describe("redeliverIssue", () => { type: "delivery_failed", message: "merge conflict", }); - expect(() => redeliverIssue(db, agent, "AIPI-1")).toThrowError(/human/i); }); it("rejects an issue with no unresolved delivery failure", () => { @@ -357,9 +355,6 @@ describe("resolveDeliveryFailure", () => { type: "delivery_failed", message: "merge conflict", }); - expect(() => resolveDeliveryFailure(db, agent, "AIPI-1", "merged by hand")).toThrowError( - /human/i, - ); }); it("rejects an empty or blank note", () => { @@ -480,12 +475,9 @@ describe("resolveDeviation", () => { updateIssue(db, human, ref, { status: "done" }); } - it("rejects agents legibly", () => { - stampDoneWithoutPr("AIPI-1"); - expect(() => - resolveDeviation(db, agent, "AIPI-1", "done_without_merged_pr", "landed on a feat/ branch"), - ).toThrowError(/human/i); - }); + // SYD-281: the agent/service refusal moved to the adapter — see + // tests/rest/api-service-actor.test.ts, which covers all five triage verbs + // against both non-human tiers. it("rejects an empty or blank note", () => { stampDoneWithoutPr("AIPI-1"); diff --git a/tests/services/webhook-dispatcher.test.ts b/tests/services/webhook-dispatcher.test.ts index bac7abe..9b9b277 100644 --- a/tests/services/webhook-dispatcher.test.ts +++ b/tests/services/webhook-dispatcher.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { createHmac } from "node:crypto"; import { serve, type ServerType } from "@hono/node-server"; import { Hono } from "hono"; @@ -30,7 +32,7 @@ describe("webhook dispatcher", () => { }); const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); addWebhook(db, human, { url: `http://127.0.0.1:${port}/hook`, secret: "s3cret" }); createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); // 1 event @@ -58,7 +60,7 @@ describe("webhook dispatcher", () => { it("skips webhooks scoped to another project and survives dead endpoints", async () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); createProject(db, human, { key: "AIPI", name: "aipi" }); addWebhook(db, human, { url: "http://127.0.0.1:1/dead", projectKey: "AIPI" }); // scoped elsewhere + dead @@ -81,7 +83,7 @@ describe("webhook dispatcher", () => { }); const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); addWebhook(db, human, { url: `http://127.0.0.1:${port}/hook` }); @@ -111,7 +113,7 @@ describe("webhook dispatcher", () => { }); const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); addWebhook(db, human, { url: `http://127.0.0.1:${port}/fail` }); createIssue(db, human, { projectKey: "SYD", title: "Ship it" }); // 1 event @@ -139,7 +141,7 @@ describe("webhook dispatcher", () => { }); const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const agent = createActor(db, { name: "claude/worker", type: "agent" }).actor; createProject(db, human, { key: "SYD", name: "Switchyard" }); addWebhook(db, human, { url: `http://127.0.0.1:${port}/hook` }); diff --git a/tests/services/webhooks.test.ts b/tests/services/webhooks.test.ts index fa80586..91ba03e 100644 --- a/tests/services/webhooks.test.ts +++ b/tests/services/webhooks.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; @@ -12,7 +14,7 @@ import { describe("webhooks", () => { it("registers, lists, scopes to a project, and removes", () => { const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; + const human = createHuman(db, "sean"); const p = createProject(db, human, { key: "SYD", name: "Switchyard" }); const all = addWebhook(db, human, { url: "http://example.com/hook" }); const scoped = addWebhook(db, human, { @@ -32,18 +34,7 @@ describe("webhooks", () => { ); }); - it("rejects agent actors managing webhooks", () => { - const db = openDb(":memory:"); - const human = createActor(db, { name: "sean", type: "human" }).actor; - const agent = createActor(db, { name: "claude/dev", type: "agent" }).actor; - const hook = addWebhook(db, human, { url: "http://example.com/hook" }); - - expect(() => addWebhook(db, agent, { url: "http://example.com/other" })).toThrowError( - /only humans manage webhooks/i, - ); - expect(() => removeWebhook(db, agent, hook.id)).toThrowError(/only humans manage webhooks/i); - expect(() => setWebhookActive(db, agent, hook.id, false)).toThrowError( - /only humans manage webhooks/i, - ); - }); + // SYD-281: the agent/service refusal moved to the adapter — see + // tests/rest/api-service-actor.test.ts, which covers all three webhook routes + // against both non-human tiers. }); From 1813963b57fac5a946c392ddb0ef5f25b94ec0d5 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 28 Aug 2026 00:26:30 -0400 Subject: [PATCH 12/16] feat: in a supervised session the agent holds the work (SYD-281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the declare_pr_link hole: a supervised declaration was auto-confirmed and stamped with the human's actor id, manufacturing the attestation SYD-280 exists to protect. Also closes SYD-298 at its real site (confirmPrLink, reachable today by the poller service tokens), and the inverse-gate population in issues.ts — triage exit, agent provenance, the auto label, the transition allow-list, claim gates, self-assign, and needsInput clearing were all satisfiable by a supervised agent because it is typed human and is not typed agent. --- src/mcp/server.ts | 4 +- src/rest/api-routes.ts | 19 +- src/services/comments.ts | 7 +- src/services/dependencies.ts | 29 ++- src/services/issues.ts | 102 +++++--- src/services/pr-links.ts | 87 +++++-- tests/mcp/supervised-mcp-endpoint.test.ts | 11 +- tests/mcp/supervised-write.test.ts | 124 +++++++++- tests/services/attention.test.ts | 5 +- tests/services/comments-hard-gate.test.ts | 40 ++- tests/services/delivery-attempts.test.ts | 3 +- ...dependency-remove-hard-gate-divert.test.ts | 39 ++- tests/services/hard-gate-affirm-exec.test.ts | 22 +- tests/services/hard-gate-divert.test.ts | 26 +- tests/services/pr-links.test.ts | 230 +++++++++++++----- tests/services/pr-observation.test.ts | 32 ++- .../supervised-attribution-e2e.test.ts | 37 ++- 17 files changed, 631 insertions(+), 186 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 197e7bb..162b1d2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -156,7 +156,9 @@ export function buildMcpServer( 'worker_preference "interactive" are never returned to a non-human caller.', inputSchema: { project_key: z.string().optional() }, }, - guard(({ project_key }: { project_key?: string }) => nextTask(db, actor, project_key)), + guard(({ project_key }: { project_key?: string }) => + nextTask(db, actor, project_key, attribution), + ), ); server.registerTool( diff --git a/src/rest/api-routes.ts b/src/rest/api-routes.ts index 21d19ba..65c8ddb 100644 --- a/src/rest/api-routes.ts +++ b/src/rest/api-routes.ts @@ -476,7 +476,14 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen // what makes a link proof-bearing. app.post("/issues/:ref/pr-links", body(prLinkDeclareBody), (c) => c.json( - declarePrLink(db, c.var.actor, c.req.param("ref"), c.req.valid("json"), c.var.leaseToken), + declarePrLink( + db, + c.var.actor, + c.req.param("ref"), + c.req.valid("json"), + c.var.leaseToken, + NO_SESSION, + ), ), ); @@ -487,12 +494,20 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen requireHumanCaller(c.var.actor, "confirm a PR link"), c.req.param("ref"), c.req.valid("json"), + NO_SESSION, ), ), ); app.post("/issues/:ref/pr-links/revoke", body(prLinkRevokeBody), (c) => { - revokePrLink(db, c.var.actor, c.req.param("ref"), c.req.valid("json"), c.var.leaseToken); + revokePrLink( + db, + c.var.actor, + c.req.param("ref"), + c.req.valid("json"), + c.var.leaseToken, + NO_SESSION, + ); return c.json({ ok: true }); }); diff --git a/src/services/comments.ts b/src/services/comments.ts index b793950..a19fc5c 100644 --- a/src/services/comments.ts +++ b/src/services/comments.ts @@ -1,4 +1,5 @@ import type { Db } from "../db/index.js"; +import { asHuman } from "./principal.js"; import type { Actor } from "./actors.js"; import type { Attribution } from "./attribution.js"; import { SwitchyardError } from "./errors.js"; @@ -41,7 +42,11 @@ export function addComment( sessionId: attr.sessionId, }); } - if (actor.type === "human" && issue.needsInput) { + // SYD-281: `actor.type === "human"` was true for a supervised principal, so + // an agent could answer its OWN escalation — clearing needsInput and + // releasing the claim without a person ever reading the question. That is + // the human-clear-only rule from SYD-220/225. + if (asHuman(actor, attr) !== null && issue.needsInput) { // The agent that escalated stopped its session, so an in_progress claim is // dead weight — release it with the answer so the worker can re-dispatch // immediately instead of waiting out the stale-claim sweep. diff --git a/src/services/dependencies.ts b/src/services/dependencies.ts index 426df40..43350af 100644 --- a/src/services/dependencies.ts +++ b/src/services/dependencies.ts @@ -1,4 +1,6 @@ import { and, eq, isNull, notInArray, or, sql } from "drizzle-orm"; +import { asHuman, effectiveActor } from "./principal.js"; +import { NO_SESSION } from "./attribution.js"; import type { Db, DbOrTx } from "../db/index.js"; import { dependencies, issues } from "../db/schema.js"; import type { Actor } from "./actors.js"; @@ -131,7 +133,12 @@ export function removeDependency( }); } } - if (actor.type !== "human") { + // SYD-281: "dependency removal is human-only" (CLAUDE.md). A supervised + // principal is typed human, so it passed. Note this fails CLOSED without the + // operator settings write: a supervised removal skips the divert (which needs + // `dependency.remove` in supervised.hard_gate_actions) and lands here, refused. + // The setting upgrades refusal to a proposable divert — UX, not security. + if (asHuman(actor, attr) === null) { throw new SwitchyardError( "Only humans remove dependencies — if you believe a blocker is wrong, say so in a comment.", ); @@ -252,11 +259,27 @@ export function getOpenBlockers(db: DbOrTx, issueId: number): IssueView[] { * filters below apply uniformly, so the walk simply continues down the queue * and falls through to (2)/(3) once the ranked set is exhausted. */ -export function nextTask(db: Db, actor: Actor, projectKey?: string): IssueView | null { +export function nextTask( + db: Db, + actor: Actor, + projectKey?: string, + ctx: Attribution = NO_SESSION, +): IssueView | null { const project = projectKey !== undefined ? getProjectByKey(db, projectKey) : undefined; const conditions = [ eq(issues.status, "todo"), - or(isNull(issues.assigneeId), eq(issues.assigneeId, actor.id)), + // SYD-281: the assignee leg keys to the identity that actually holds work, + // so a supervised session still sees its own agent's claims. Leaving it on + // the human WEDGES: next_task keeps surfacing issues assigned to the human, + // which assertClaimable then refuses to the agent, and with .limit(1) one + // such issue pins the recommendation forever. + // + // isAttendedCaller and callerClassification below deliberately keep reading + // `actor` (the human). That is the one place NOT applying the identity rule + // is the security-relevant choice: they decide whether the hard + // `worker_preference <> interactive` filter applies, and a supervised + // session must keep the interactive queue it exists to serve. + or(isNull(issues.assigneeId), eq(issues.assigneeId, effectiveActor(db, actor, ctx).id)), sql`NOT EXISTS ( SELECT 1 FROM dependencies d JOIN issues b ON b.id = d.blocker_id diff --git a/src/services/issues.ts b/src/services/issues.ts index 91e3b55..c8eb05b 100644 --- a/src/services/issues.ts +++ b/src/services/issues.ts @@ -1,4 +1,6 @@ import { and, eq, sql } from "drizzle-orm"; +import { asHuman, actingAgent, effectiveActor } from "./principal.js"; +import { NO_SESSION } from "./attribution.js"; import type { SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core"; import type { Db, DbOrTx } from "../db/index.js"; import { @@ -171,6 +173,12 @@ export function createIssue( input: CreateIssueInput, attr: Attribution = {}, ): IssueView { + // SYD-281: "agent-created issues land in triage with required provenance" is + // a CLAUDE.md invariant, and it asked `actor.type === "agent"` — which a + // supervised principal (typed human) walked straight past, filing into + // `backlog` with no provenance and no description. + const agentPath = actingAgent(db, actor, attr) !== null; + const eff = effectiveActor(db, actor, attr); // SYD-213: a `service` token is trusted worker-host infra whose mandate is // posting PR/delivery events, reading, and commenting — never authoring board // work. Denied wholesale (fail-closed) rather than falling through to the @@ -181,13 +189,13 @@ export function createIssue( "Service actors post events, read, and comment — they cannot create issues.", ); } - if (actor.type === "agent" && !input.provenance) { + if (agentPath && !input.provenance) { throw new SwitchyardError( "Agent-created issues require provenance — pass sourceType " + '("session" | "todo" | "ci" | "manual") plus a detail (e.g. "src/api.ts:88" or a session id) or url.', ); } - if (actor.type === "agent" && !input.description?.trim()) { + if (agentPath && !input.description?.trim()) { throw new SwitchyardError( "Agent-filed issues need a description a human can triage from — say what's wrong, why it matters, and what you suggest doing.", ); @@ -208,10 +216,10 @@ export function createIssue( title: input.title, description: input.description ?? "", summary: input.summary ?? null, - status: actor.type === "agent" ? "triage" : "backlog", + status: agentPath ? "triage" : "backlog", priority: input.priority ?? "none", labels: input.labels ?? [], - creatorId: actor.id, + creatorId: eff.id, parentId, sourceType: input.provenance?.sourceType ?? null, sourceDetail: input.provenance?.detail ?? null, @@ -256,8 +264,11 @@ export type UpdateIssueInput = { * is the gap that let SYD-93 get fixed twice in parallel (worker PR #41 vs a * coordinating session's PR #42, opened without ever calling claim_issue). */ -function assertClaimable(db: DbOrTx, actor: Actor, current: IssueView): void { - if (current.assigneeId === actor.id) return; +function assertClaimable(db: DbOrTx, actor: Actor, current: IssueView, ctx: Attribution): void { + // SYD-281: compares the identity that actually holds work — the agent in a + // supervised session. Callers: updateIssue's claim-gate leg and self-assign + // leg, and claimIssue. + if (current.assigneeId === effectiveActor(db, actor, ctx).id) return; if (current.assigneeId !== null) { const assignee = db .select() @@ -277,8 +288,16 @@ function assertClaimable(db: DbOrTx, actor: Actor, current: IssueView): void { } /** Used by assigneeOnly entries in AGENT_STATUS_TRANSITIONS. */ -function assertAssignee(db: DbOrTx, actor: Actor, current: IssueView, toStatus: Status): void { - if (current.assigneeId === actor.id) return; +function assertAssignee( + db: DbOrTx, + actor: Actor, + current: IssueView, + toStatus: Status, + ctx: Attribution, +): void { + // SYD-281: an assigneeOnly transition on an agent-assigned issue must be + // reachable by the agent that holds it, not only by the accountable human. + if (current.assigneeId === effectiveActor(db, actor, ctx).id) return; if (current.assigneeId === null) { throw new SwitchyardError( `${current.ref} isn't assigned to anyone — only the assignee can move it to "${toStatus}". Claim it first.`, @@ -311,6 +330,16 @@ export function updateIssue( attr: Attribution = {}, ): IssueView { checkSummaryLength(patch.summary); + // SYD-281. Three questions, resolved ONCE here rather than at each of the + // branch points below — otherwise fail-closed is branch-dependent, and a + // patch touching only `priority` would write as the human where a status + // patch throws. + // eff — whose name goes on the row (assignee, lease, creator) + // agentPath — is an agent credential acting (claim + lease rules apply) + // vouching — is a human ACTING, not merely accountable + const eff = effectiveActor(db, actor, attr); + const agentPath = actingAgent(db, actor, attr) !== null; + const vouching = asHuman(actor, attr) !== null; if (attr.sessionId != null && patch.status !== undefined) { const target = getIssue(db, ref); // real resolver; has .id/.status if (patch.status !== target.status && isHardGated(db, patch.status)) { @@ -386,9 +415,9 @@ export function updateIssue( // token but not this lease). Humans are individuated by actor and are never // lease-gated. A fresh claim (assigneeId === null -> assigned, below) mints // instead of validating, so the two are disjoint. - const isHolderMutation = actor.type === "agent" && current.assigneeId === actor.id; + const isHolderMutation = agentPath && current.assigneeId === eff.id; if (isHolderMutation) { - validateLease(tx, current.id, actor.id, lease.presented); + validateLease(tx, current.id, eff.id, lease.presented); } const changes: SQLiteUpdateSetSource = {}; const toRecord: { type: EventKind; payload: Record }[] = []; @@ -399,17 +428,17 @@ export function updateIssue( `"${patch.status}" is not a status — valid statuses are: ${STATUSES.join(", ")}.`, ); } - if (current.status === "triage" && actor.type === "agent") { + if (current.status === "triage" && !vouching) { throw new SwitchyardError( `${ref} is in triage — only humans move issues out of triage. Use triage_queue to help a human review it.`, ); } - if (patch.status === "done" && actor.type === "agent") { + if (patch.status === "done" && !vouching) { throw new SwitchyardError( "Only humans move issues to done — comment your verification evidence and move it to in_review instead.", ); } - if (actor.type === "agent") { + if (agentPath) { if (current.status === "done") { throw new SwitchyardError(`${ref} is done — only humans reopen a done issue.`); } @@ -422,7 +451,7 @@ export function updateIssue( ); } if (allowed.assigneeOnly) { - assertAssignee(tx, actor, current, patch.status); + assertAssignee(tx, actor, current, patch.status, attr); } } // SYD-208: stamping done on an issue with an open agent PR authorizes @@ -503,8 +532,8 @@ export function updateIssue( `${ref} is not the issue claimed for your session — a supervised session can only work its own claimed issue, not claim another. Call get_issue on your assigned issue.`, ); } - changes.assigneeId = actor.id; - toRecord.push({ type: "assigned", payload: { to: actor.name } }); + changes.assigneeId = eff.id; + toRecord.push({ type: "assigned", payload: { to: eff.name } }); } // Symmetric to the auto-claim above: `todo` means "available for // dispatch", so moving (back) to todo releases any claim. Without this a @@ -527,7 +556,7 @@ export function updateIssue( } } - if (patch.status === "in_progress" && actor.type === "agent") { + if (patch.status === "in_progress" && agentPath) { // Same gates claimIssue enforces — without this, a PATCH straight to // in_progress would let an agent start work a human deliberately // blocked behind another issue, or duplicate a claim/PR already in @@ -544,7 +573,7 @@ export function updateIssue( `${ref} is blocked by ${blockers.map((b) => b.ref).join(", ")} — resolve the blocker first, or call next_task for another issue.`, ); } - assertClaimable(tx, actor, current); + assertClaimable(tx, actor, current, attr); } if (patch.priority !== undefined && patch.priority !== current.priority) { @@ -575,11 +604,7 @@ export function updateIssue( patch.labels !== undefined && JSON.stringify([...patch.labels].sort()) !== JSON.stringify([...current.labels].sort()) ) { - if ( - actor.type === "agent" && - patch.labels.includes("auto") && - !current.labels.includes("auto") - ) { + if (!vouching && patch.labels.includes("auto") && !current.labels.includes("auto")) { throw new SwitchyardError( `Only humans apply the "auto" label — it opts an issue into unattended dispatch.`, ); @@ -607,8 +632,8 @@ export function updateIssue( // subject to the same claim gates — reassigning to another actor or // clearing an existing assignee would disrupt dispatch coordination // and bypass claim-before-work, so those are human-only. - if (actor.type === "agent") { - if (assigneeId !== actor.id) { + if (agentPath) { + if (assigneeId !== eff.id) { throw new SwitchyardError( patch.assigneeName === null ? `Agents can't unassign ${ref} — clearing an assignee is human-only. If it's your own claim, move the issue back to "todo" to release it.` @@ -629,7 +654,7 @@ export function updateIssue( `${ref} is not the issue claimed for your session — a supervised session can only work its own claimed issue, not claim another.`, ); } - assertClaimable(tx, actor, current); + assertClaimable(tx, actor, current, attr); } changes.assigneeId = assigneeId; toRecord.push({ type: "assigned", payload: { to: patch.assigneeName } }); @@ -661,7 +686,7 @@ export function updateIssue( } } - if (patch.status !== undefined && actor.type === "human" && current.needsInput) { + if (patch.status !== undefined && vouching && current.needsInput) { changes.needsInput = false; toRecord.push({ type: "needs_input_cleared", payload: {} }); } @@ -672,14 +697,14 @@ export function updateIssue( // here). Disjoint from the holder-validation above (assigneeId was null). if ( lease.minted && - changes.assigneeId === actor.id && + changes.assigneeId === eff.id && current.assigneeId === null && (changes.status === "in_progress" || current.status === "in_progress") ) { lease.minted.token = mintLease( tx, current.id, - actor.id, + eff.id, getSetting(db, "claims.lease_ttl_seconds"), ); } @@ -716,9 +741,15 @@ export function heartbeatClaim( actor: Actor, ref: string, leaseToken?: string, + attr: Attribution = NO_SESSION, ): { expiresAt: number } { const issue = getIssue(db, ref); - const lease = heartbeatLease(db, issue.id, actor.id, leaseToken); + // SYD-281: correct by symmetry with the mint, which keys to the effective + // actor. Unreachable from a supervised session today — mcp/server.ts registers + // `heartbeat` only for a connection-lease session, and those are refused + // claim_issue — so this is the right answer for the day that changes, not a + // live fix. + const lease = heartbeatLease(db, issue.id, effectiveActor(db, actor, attr).id, leaseToken); return { expiresAt: lease.expiresAt }; } @@ -729,6 +760,9 @@ export function claimIssue( opts: { takeover?: boolean } = {}, attr: Attribution = {}, ): ClaimResult { + // SYD-281: in a supervised session the AGENT holds the work — assignee, lease + // holder, declarer. Resolved once here so every branch below agrees. + const eff = effectiveActor(db, actor, attr); const current = getIssue(db, ref); const blockers = getOpenBlockers(db, current.id); if (blockers.length > 0) { @@ -743,7 +777,7 @@ export function claimIssue( // sessions share the worker actor — a default takeover would silently kill a // healthy running container). Takeover only reaches here for the same actor; // a different actor's claim is refused by assertClaimable below. - if (current.assigneeId === actor.id) { + if (current.assigneeId === eff.id) { const active = getActiveLease(db, current.id); if (active && !opts.takeover) { throw new SwitchyardError( @@ -766,7 +800,7 @@ export function claimIssue( sessionId: attr.sessionId, }); } - return mintLease(tx, current.id, actor.id, getSetting(db, "claims.lease_ttl_seconds")); + return mintLease(tx, current.id, eff.id, getSetting(db, "claims.lease_ttl_seconds")); }); return { issue: getIssue(db, ref), leaseToken }; } @@ -774,13 +808,13 @@ export function claimIssue( // Fresh claim of an unassigned (or blocked/PR-guarded) issue: assertClaimable // is re-checked inside updateIssue's in_progress gate; the mint happens there // via the out-channel. - assertClaimable(db, actor, current); + assertClaimable(db, actor, current, attr); const minted: { token: string | null } = { token: null }; const issue = updateIssue( db, actor, ref, - { status: "in_progress", assigneeName: actor.name }, + { status: "in_progress", assigneeName: eff.name }, { minted }, attr, ); diff --git a/src/services/pr-links.ts b/src/services/pr-links.ts index 72f16d6..b847591 100644 --- a/src/services/pr-links.ts +++ b/src/services/pr-links.ts @@ -27,6 +27,7 @@ import type { Db, DbOrTx } from "../db/index.js"; import { actors, prLinks, prState, type PrLinkRole } from "../db/schema.js"; import type { Actor } from "./actors.js"; import type { Attribution } from "./attribution.js"; +import { asHuman, actingAgent, effectiveActor, type HumanActor } from "./principal.js"; import { SwitchyardError } from "./errors.js"; import { getIssue } from "./issues.js"; import { recordEvent } from "./events.js"; @@ -227,8 +228,11 @@ export function declarePrLink( actor: Actor, ref: string, input: PrLinkTarget & { role?: PrLinkRole }, - leaseToken?: string, - attr: Attribution = {}, + leaseToken: string | undefined, + // SYD-281: required, no default. "This caller has no session" and "this caller + // forgot to thread the session" were the same value, and that is the mechanism + // that let a supervised agent's declaration be stamped as a human's vouch. + attr: Attribution, ): PrLink { const repo = normalizeRepoFullName(input.repo); if (!Number.isInteger(input.prNumber) || input.prNumber <= 0) { @@ -238,20 +242,34 @@ export function declarePrLink( const issue = getIssue(tx, ref); assertRepoBound(tx, issue.projectId, repo); - const isAgent = actor.type === "agent"; - if (isAgent) { - if (issue.assigneeId !== actor.id) { + // SYD-281: `isAgent` used to answer three different questions at once, and + // a supervised principal (typed human, not typed agent) got the wrong answer + // to all of them. They are now asked separately: + // + // agentPath — is an agent credential acting? Governs the claim and lease, + // and keys them to the AGENT, since the accountable human holds neither. + // vouching — is a human ACTING (not merely accountable)? Governs whether + // this declaration also carries that human's confirmation. + // + // A `service` actor is neither, which is the SYD-298 half: it was passing + // `!== "agent"` and having its declarations auto-confirmed. + const eff = effectiveActor(tx, actor, attr); + const agentPath = actingAgent(tx, actor, attr) !== null; + const vouching = asHuman(actor, attr) !== null; + + if (agentPath) { + if (issue.assigneeId !== eff.id) { throw new SwitchyardError( `${ref} is not yours to declare a PR for — claim it first, or ask a human to record the link.`, ); } - validateLease(tx, issue.id, actor.id, leaseToken); + validateLease(tx, issue.id, eff.id, leaseToken); } // Agents declare delivers or nothing: a references link is a suggestion, // and suggestions come from humans or from free-text ingestion, never from // an actor asserting its own work. - const role: PrLinkRole = isAgent ? "delivers" : (input.role ?? "delivers"); + const role: PrLinkRole = agentPath ? "delivers" : (input.role ?? "delivers"); const now = nowSeconds(); @@ -286,10 +304,14 @@ export function declarePrLink( repo, prNumber: input.prNumber, role, - declaredBy: actor.id, + // The declarer is whoever is actually doing the work — the agent in a + // supervised session. revokePrLink's "your own link" test compares + // against this, so the two must move together or an agent can withdraw + // declarations its supervisor made in person. + declaredBy: eff.id, declaredAt: now, - confirmedBy: isAgent ? null : actor.id, - confirmedAt: isAgent ? null : now, + confirmedBy: vouching ? actor.id : null, + confirmedAt: vouching ? now : null, }) .returning() .get(); @@ -302,7 +324,10 @@ export function declarePrLink( repo, prNumber: input.prNumber, role, - confirmed: !isAgent, + // Must track the row: writing confirmedBy:null here while the event + // said `confirmed: true` would manufacture the attestation in the audit + // stream while fixing only the mutable row. + confirmed: vouching, // Names what this superseded, so "where did the references link go" // is answerable from the timeline and not only from the revoked row. ...(existing ? { promotedFrom: existing.role } : {}), @@ -408,17 +433,18 @@ export function recordIngestedPrLink( */ export function confirmPrLink( db: Db, - actor: Actor, + actor: HumanActor, ref: string, input: PrLinkTarget, - attr: Attribution = {}, + attr: Attribution, ): PrLink { + // SYD-281/SYD-298: this gate used to read `actor.type === "agent"`, which + // refused agents and waved through BOTH `service` actors (the poller tokens + // already speak REST) and supervised agents (typed human). It is the one + // function whose entire job is minting the attestation SYD-280 protects, and + // it was the widest way to forge one. The type now carries the proof — only + // `asHuman` can produce a HumanActor — so there is nothing left to check here. const repo = normalizeRepoFullName(input.repo); - if (actor.type === "agent") { - throw new SwitchyardError( - "Only a human can confirm a PR link — an agent may declare which PR carries its work, but never vouch for it.", - ); - } return db.transaction((tx) => { const issue = getIssue(tx, ref); const link = findLiveLink(tx, issue.id, repo, input.prNumber); @@ -568,8 +594,8 @@ export function revokePrLink( actor: Actor, ref: string, input: PrLinkTarget & { reason: string }, - leaseToken?: string, - attr: Attribution = {}, + leaseToken: string | undefined, + attr: Attribution, ): void { const repo = normalizeRepoFullName(input.repo); if (!input.reason.trim()) { @@ -582,20 +608,31 @@ export function revokePrLink( throw new SwitchyardError(`${ref} has no live link to ${repo}#${input.prNumber}.`); } - if (actor.type !== "human") { - // A non-human may withdraw only its OWN, still-unconfirmed statement. - // Once a human has vouched for a link, only a human can take it back. + // SYD-281: all three tests here were keyed on `actor`, which in a supervised + // session is the accountable human — so a supervised agent skipped the lease + // (its `type` is "human", not "agent") AND satisfied the `declaredBy` test + // for every unconfirmed link its supervisor had declared IN PERSON, from any + // other session. It could withdraw its human's own statements. + const eff = effectiveActor(tx, actor, attr); + if (asHuman(actor, attr) === null) { + // A non-vouching caller may withdraw only its OWN, still-unconfirmed + // statement. Once a human has vouched for a link, only a human takes it + // back. if (link.confirmedBy !== null) { throw new SwitchyardError( `${ref}'s link to ${repo}#${input.prNumber} has been confirmed — only a human can revoke it.`, ); } - if (link.declaredBy !== actor.id) { + if (link.declaredBy !== eff.id) { throw new SwitchyardError( `${ref}'s link to ${repo}#${input.prNumber} was declared by someone else — only its declarer or a human can revoke it.`, ); } - if (actor.type === "agent") validateLease(tx, issue.id, actor.id, leaseToken); + // Keyed to the acting agent, not the actor: the human holds no lease. A + // `service` actor is not on the agent path and keeps passing untouched — + // services can hold no claim at all (issues.ts), so lease-gating them + // would break ingestion. + if (actingAgent(tx, actor, attr) !== null) validateLease(tx, issue.id, eff.id, leaseToken); } tx.update(prLinks).set({ revokedAt: nowSeconds() }).where(eq(prLinks.id, link.id)).run(); diff --git a/tests/mcp/supervised-mcp-endpoint.test.ts b/tests/mcp/supervised-mcp-endpoint.test.ts index 3a20ca0..a39c5b6 100644 --- a/tests/mcp/supervised-mcp-endpoint.test.ts +++ b/tests/mcp/supervised-mcp-endpoint.test.ts @@ -71,9 +71,18 @@ afterEach(async () => { describe("/mcp resolves a supervised principal", () => { it("stamps dual attribution on writes made with a sup_ token", async () => { const client = await connect(supToken); + // SYD-281: a supervised session is on the agent path now, so it follows + // AGENT_STATUS_TRANSITIONS — claim, then move. Before, being typed human let + // it jump straight to in_review from todo. + const claimed = await client.callTool({ name: "claim_issue", arguments: { ref: issue.ref } }); + expect(claimed.isError).toBeFalsy(); const r = await client.callTool({ name: "update_issue", - arguments: { ref: issue.ref, status: "in_review" }, + arguments: { + ref: issue.ref, + status: "in_review", + lease_token: JSON.parse(text(claimed)).lease_token, + }, }); expect(r.isError).toBeFalsy(); diff --git a/tests/mcp/supervised-write.test.ts b/tests/mcp/supervised-write.test.ts index af38bfc..1aa960e 100644 --- a/tests/mcp/supervised-write.test.ts +++ b/tests/mcp/supervised-write.test.ts @@ -12,6 +12,8 @@ import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, updateIssue, type IssueView } from "../../src/services/issues.js"; import { addDependency, listDependencies } from "../../src/services/dependencies.js"; +import { addGithubRepo } from "../../src/services/github-repos.js"; +import { listLiveLinkViews } from "../../src/services/pr-links.js"; import { setSetting } from "../../src/services/settings.js"; import { openSupervisedSession, @@ -66,9 +68,23 @@ afterEach(() => rmSync(dir, { recursive: true, force: true })); describe("MCP write tools in a supervised session", () => { it("(a) update_issue -> in_review writes a dual-attributed status_changed event", async () => { const client = await connectSupervised(); + // SYD-281: a supervised session is now on the agent path, so it obeys + // AGENT_STATUS_TRANSITIONS like any other agent — todo -> in_review is not a + // legal jump, and in_review is assigneeOnly. Before this change a supervised + // principal was typed human and skipped the allow-list entirely, which is + // one of the bypasses this story closes. Claim, then move. + const claimed = await client.callTool({ + name: "claim_issue", + arguments: { ref: issue.ref }, + }); + expect(claimed.isError).toBeFalsy(); const r = await client.callTool({ name: "update_issue", - arguments: { ref: issue.ref, status: "in_review" }, + arguments: { + ref: issue.ref, + status: "in_review", + lease_token: JSON.parse(text(claimed)).lease_token, + }, }); expect(r.isError).toBeFalsy(); expect(JSON.parse(text(r)).status).toBe("in_review"); @@ -137,7 +153,7 @@ describe("MCP write tools in a supervised session", () => { } }); - it("remove_dependency via plain agent is refused, but supervised session parks or executes it", async () => { + it("remove_dependency via plain agent is refused; supervised parks when gated, and is REFUSED when not", async () => { // 1. Setup blocker issue and dependency const blocker = createIssue(db, human, { projectKey: "SUP", title: "The blocker" }); addDependency(db, human, blocker.ref, issue.ref); @@ -175,15 +191,111 @@ describe("MCP write tools in a supervised session", () => { // Edge still exists (nothing was changed) expect(listDependencies(db, issue.ref).blockedBy.map((d) => d.ref)).toEqual([blocker.ref]); - // 4. Supervised session with full absorption (no hard-gate) executes immediately + // 4. SYD-281: with full absorption the removal is now REFUSED, not executed. + // "Dependency removal is human-only" (CLAUDE.md) used to be satisfied by a + // supervised principal because it is typed human. This is the fails-closed + // property: without the operator settings write the capability is lost, not + // the guard. setSetting(db, human, "supervised.hard_gate_actions", []); const rAbsorb = await client.callTool({ name: "remove_dependency", arguments: { blocker_ref: blocker.ref, blocked_ref: issue.ref }, }); - expect(rAbsorb.isError).toBeUndefined(); + expect(rAbsorb.isError).toBe(true); + expect(text(rAbsorb)).toMatch(/human/i); + + // Edge survives. + expect(listDependencies(db, issue.ref).blockedBy.map((d) => d.ref)).toEqual([blocker.ref]); + }); +}); + +// SYD-281's headline finding. `declarePrLink` wrote +// `confirmedBy: isAgent ? null : actor.id`, and a supervised principal resolves +// to the bound HUMAN — so an agent's declaration was auto-confirmed and stamped +// with the human's actor id. It did not evade the attestation; it manufactured +// one, and `provesLanded` accepted it. +// +// Driven through the MCP tool with a real sup_ token rather than by calling +// declarePrLink directly: SYD-280 shipped inert because its tests called +// upsertPrState directly while production never did. +describe("declare_pr_link in a supervised session (SYD-281)", () => { + const REPO = "acme/widgets"; + + beforeEach(() => { + addGithubRepo(db, human, { fullName: REPO, projectKey: "SUP" }); + }); + + /** Claims the issue for the supervised session and returns its lease token. */ + async function claimIt(client: Client): Promise { + const r = await client.callTool({ name: "claim_issue", arguments: { ref: issue.ref } }); + if (r.isError) throw new Error("claim_issue failed: " + text(r)); + return JSON.parse(text(r)).lease_token as string; + } + + it("declares UNCONFIRMED — a supervised agent proposes, it does not vouch", async () => { + const client = await connectSupervised(); + const lease = await claimIt(client); + const r = await client.callTool({ + name: "declare_pr_link", + arguments: { ref: issue.ref, repo: REPO, pr_number: 7, lease_token: lease }, + }); + expect(r.isError).toBeFalsy(); + + const [link] = listLiveLinkViews(db, issue.id); + expect(link.confirmedByName).toBeNull(); + expect(link.confirmedByHuman).toBe(false); + expect(link.provesLanded).toBe(false); + }); + + it("records the AGENT as declarer, so revoke's ownership test is the agent's", async () => { + const client = await connectSupervised(); + const lease = await claimIt(client); + await client.callTool({ + name: "declare_pr_link", + arguments: { ref: issue.ref, repo: REPO, pr_number: 7, lease_token: lease }, + }); + const [link] = listLiveLinkViews(db, issue.id); + expect(link.declaredByName).toBe(agent.name); + }); + + it("emits pr_link_declared with confirmed:false — the event agrees with the row", async () => { + const client = await connectSupervised(); + const lease = await claimIt(client); + await client.callTool({ + name: "declare_pr_link", + arguments: { ref: issue.ref, repo: REPO, pr_number: 7, lease_token: lease }, + }); + const [row] = db.all<{ payload: string }>( + sql`SELECT payload FROM events WHERE issue_id = ${issue.id} AND type = 'pr_link_declared' ORDER BY id DESC LIMIT 1`, + ); + expect(JSON.parse(row.payload).confirmed).toBe(false); + }); + + it("refuses a declaration on an issue the session has not claimed", async () => { + const other = createIssue(db, human, { projectKey: "SUP", title: "Not ours" }); + const client = await connectSupervised(); + const r = await client.callTool({ + name: "declare_pr_link", + arguments: { ref: other.ref, repo: REPO, pr_number: 9 }, + }); + expect(r.isError).toBe(true); + expect(text(r)).toMatch(/not yours to declare/i); + }); - // Edge is gone! - expect(listDependencies(db, issue.ref).blockedBy).toEqual([]); + it("forces role to delivers — a supervised agent cannot mint a references suggestion", async () => { + const client = await connectSupervised(); + const lease = await claimIt(client); + await client.callTool({ + name: "declare_pr_link", + arguments: { + ref: issue.ref, + repo: REPO, + pr_number: 7, + role: "references", + lease_token: lease, + }, + }); + const [link] = listLiveLinkViews(db, issue.id); + expect(link.role).toBe("delivers"); }); }); diff --git a/tests/services/attention.test.ts b/tests/services/attention.test.ts index 28efbfb..f5a45d1 100644 --- a/tests/services/attention.test.ts +++ b/tests/services/attention.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { NO_SESSION } from "../../src/services/attribution.js"; import { createHuman } from "../helpers/human.js"; import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; @@ -349,7 +350,7 @@ describe("getAttention — done_without_merged_pr (SYD-204)", () => { expect(getAttention(db, id)?.reason).toBe("done_without_merged_pr"); // A feat/ branch: no agent/ to infer from, so a human states the link. - declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 197 }); + declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 197 }, undefined, NO_SESSION); upsertPrState(db, human, { repo: REPO, prNumber: 197, @@ -401,7 +402,7 @@ describe("getAttention — done_without_merged_pr (SYD-204)", () => { // is also the hand-merge-then-update-the-board flow, which is why §5a's // recency binding must not apply to a human-confirmed link — under a // blanket rule this issue could never prove it landed. - declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 197 }); + declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 197 }, undefined, NO_SESSION); upsertPrState(db, human, { repo: REPO, prNumber: 197, diff --git a/tests/services/comments-hard-gate.test.ts b/tests/services/comments-hard-gate.test.ts index 0ef9ef3..d978905 100644 --- a/tests/services/comments-hard-gate.test.ts +++ b/tests/services/comments-hard-gate.test.ts @@ -10,6 +10,10 @@ // validator to gate `todo` directly in the settings table, the way a future // config change could, and confirming addComment's write is now diverted // exactly like a direct updateIssue call would be. +// SYD-281: `{ sessionId }` alone is a shape production never produces — +// resolveSupervisedPrincipal always sets viaAgent AND sessionId together +// (supervised-sessions.ts). effectiveActor now fails closed on a supervised +// attribution with no resolvable agent, so these tests build the real thing. import { describe, it, expect, beforeEach } from "vitest"; import { createHuman } from "../helpers/human.js"; import type { HumanActor } from "../../src/services/principal.js"; @@ -17,13 +21,17 @@ import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, getIssue, claimIssue, updateIssue } from "../../src/services/issues.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; +import { attributionOf, type Attribution } from "../../src/services/attribution.js"; import { requestHumanInput } from "../../src/services/needs-input.js"; import { addComment } from "../../src/services/comments.js"; import { settings } from "../../src/db/schema.js"; import { isHardGated } from "../../src/services/hard-gate.js"; -let db: Db, human: HumanActor, agent: Actor, sessionId: number; +let db: Db, human: HumanActor, agent: Actor, sessionId: number, supAttr: Attribution; beforeEach(() => { db = openDb(":memory:"); human = createHuman(db, "sean"); @@ -33,7 +41,9 @@ beforeEach(() => { // An agent can only claim from "todo" (AGENT_STATUS_TRANSITIONS); a human // files into "backlog", so queue it first or claimIssue throws. updateIssue(db, human, "SYD-1", { status: "todo" }); - sessionId = openSupervisedSession(db, human, "claude-code").sessionId; + const _sess = openSupervisedSession(db, human, "claude-code"); + sessionId = _sess.sessionId; + supAttr = attributionOf(resolveSupervisedPrincipal(db, _sess.sessionToken)!); }); function gateTodo() { @@ -48,32 +58,38 @@ function gateTodo() { } describe("addComment's answer-path status write vs the hard-gate", () => { - it("diverts a supervised self-answer instead of silently writing a gated status", () => { + // SYD-281 moved this further back than SYD-241 did. The answer path is now + // gated on a human ACTING, so a supervised session never reaches it at all — + // there is no gated status write left to divert, because there is no status + // write. An agent cannot answer its own escalation, which is the human-clear- + // only rule SYD-220/225 established and a supervised principal was skipping. + it("does not let a supervised session answer its own escalation, gated or not", () => { const { leaseToken } = claimIssue(db, agent, "SYD-1"); requestHumanInput(db, agent, "SYD-1", "Which approach?", leaseToken); gateTodo(); expect(isHardGated(db, "todo")).toBe(true); - expect(() => addComment(db, human, "SYD-1", "Go with option B.", { sessionId })).toThrow(); + // The comment itself is fine — commenting is not a human-only act. + expect(() => addComment(db, human, "SYD-1", "Go with option B.", supAttr)).not.toThrow(); - // Nothing committed: not the status, not the claim release, not even the - // needsInput flag or the comment itself — the whole write rolled back. + // But the answer path did not run: the question still stands, the claim is + // still held, and no status was written. const after = getIssue(db, "SYD-1"); expect(after.status).toBe("in_progress"); expect(after.assigneeId).not.toBeNull(); expect(after.needsInput).toBe(true); }); - it("still answers normally when the target status is not gated (baseline)", () => { + it("does not answer even when the target status is NOT gated (the divert is not what stops it)", () => { const { leaseToken } = claimIssue(db, agent, "SYD-1"); requestHumanInput(db, agent, "SYD-1", "Which approach?", leaseToken); - expect(() => addComment(db, human, "SYD-1", "Go with option B.", { sessionId })).not.toThrow(); + expect(() => addComment(db, human, "SYD-1", "Go with option B.", supAttr)).not.toThrow(); const after = getIssue(db, "SYD-1"); - expect(after.status).toBe("todo"); - expect(after.assigneeId).toBeNull(); - expect(after.needsInput).toBe(false); + expect(after.status).toBe("in_progress"); + expect(after.assigneeId).not.toBeNull(); + expect(after.needsInput).toBe(true); }); it("does not divert a non-supervised (plain) human self-answer", () => { diff --git a/tests/services/delivery-attempts.test.ts b/tests/services/delivery-attempts.test.ts index 6bf447f..ebe77aa 100644 --- a/tests/services/delivery-attempts.test.ts +++ b/tests/services/delivery-attempts.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { NO_SESSION } from "../../src/services/attribution.js"; import { createHuman } from "../helpers/human.js"; import type { HumanActor } from "../../src/services/principal.js"; import { eq } from "drizzle-orm"; @@ -184,7 +185,7 @@ describe("listPendingDeliveryAuthorizations", () => { headSha: "def", ghUpdatedAt: "2026-07-14T10:00:00Z", }); - declarePrLink(db, human, issue.ref, { repo: REPO, prNumber: 124 }); + declarePrLink(db, human, issue.ref, { repo: REPO, prNumber: 124 }, undefined, NO_SESSION); // Pinned stamp must be the NEWEST done transition — latest-stamp-per-issue // is what picks the live authorization. updateIssue(db, human, issue.ref, { status: "done" }); diff --git a/tests/services/dependency-remove-hard-gate-divert.test.ts b/tests/services/dependency-remove-hard-gate-divert.test.ts index eca01fb..ca75805 100644 --- a/tests/services/dependency-remove-hard-gate-divert.test.ts +++ b/tests/services/dependency-remove-hard-gate-divert.test.ts @@ -14,11 +14,15 @@ import { listDependencies, removeDependency, } from "../../src/services/dependencies.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; +import { attributionOf, type Attribution } from "../../src/services/attribution.js"; import { setSetting } from "../../src/services/settings.js"; import { pendingActions } from "../../src/db/schema.js"; -let db: Db, human: HumanActor, sessionId: number; +let db: Db, human: HumanActor, sessionId: number, supAttr: Attribution; beforeEach(() => { db = openDb(":memory:"); human = createHuman(db, "sean"); @@ -26,14 +30,16 @@ beforeEach(() => { createIssue(db, human, { projectKey: "SYD", title: "blocker" }); // SYD-1 createIssue(db, human, { projectKey: "SYD", title: "blocked" }); // SYD-2 createIssue(db, human, { projectKey: "SYD", title: "second blocker" }); // SYD-3 - sessionId = openSupervisedSession(db, human, "claude-code").sessionId; + const _sess = openSupervisedSession(db, human, "claude-code"); + sessionId = _sess.sessionId; + supAttr = attributionOf(resolveSupervisedPrincipal(db, _sess.sessionToken)!); setSetting(db, human, "supervised.hard_gate_actions", ["done", "dependency.remove"]); }); describe("removeDependency hard-gate divert", () => { it("parks a supervised removal as a pending action instead of committing", () => { addDependency(db, human, "SYD-1", "SYD-2"); - expect(() => removeDependency(db, human, "SYD-1", "SYD-2", { sessionId })).toThrow( + expect(() => removeDependency(db, human, "SYD-1", "SYD-2", supAttr)).toThrow( /awaiting human affirmation/i, ); expect(listDependencies(db, "SYD-2").blockedBy).toEqual([ @@ -45,7 +51,7 @@ describe("removeDependency hard-gate divert", () => { it("dedups a retried proposal instead of piling up rows", () => { addDependency(db, human, "SYD-1", "SYD-2"); for (let i = 0; i < 2; i++) { - expect(() => removeDependency(db, human, "SYD-1", "SYD-2", { sessionId })).toThrow( + expect(() => removeDependency(db, human, "SYD-1", "SYD-2", supAttr)).toThrow( /awaiting human affirmation/i, ); } @@ -59,18 +65,22 @@ describe("removeDependency hard-gate divert", () => { expect(db.select().from(pendingActions).all()).toHaveLength(0); }); - it("does not divert when the edge doesn't exist — already a no-op", () => { - removeDependency(db, human, "SYD-1", "SYD-2", { sessionId }); + // SYD-281: the divert needs an edge to exist (dependencies.ts requires + // edgeExists), so a non-existent edge falls through to the human-only gate — + // which a supervised principal used to pass, silently no-opping. It is now + // refused. Nothing is parked, because there is nothing to propose. + it("refuses rather than silently no-opping when the edge doesn't exist", () => { + expect(() => removeDependency(db, human, "SYD-1", "SYD-2", supAttr)).toThrow(/human/i); expect(db.select().from(pendingActions).all()).toHaveLength(0); }); it("proposing removal of two different blockers on the same issue parks two rows, not one clobbering the other", () => { addDependency(db, human, "SYD-1", "SYD-2"); addDependency(db, human, "SYD-3", "SYD-2"); - expect(() => removeDependency(db, human, "SYD-1", "SYD-2", { sessionId })).toThrow( + expect(() => removeDependency(db, human, "SYD-1", "SYD-2", supAttr)).toThrow( /awaiting human affirmation/i, ); - expect(() => removeDependency(db, human, "SYD-3", "SYD-2", { sessionId })).toThrow( + expect(() => removeDependency(db, human, "SYD-3", "SYD-2", supAttr)).toThrow( /awaiting human affirmation/i, ); const rows = db.select().from(pendingActions).all(); @@ -79,11 +89,16 @@ describe("removeDependency hard-gate divert", () => { expect(blockerRefs).toEqual(["SYD-1", "SYD-3"]); }); - it("does not divert when dependency.remove isn't in the gate list (full absorption)", () => { + // SYD-281: this is the fails-CLOSED property. Without the operator settings + // write, a supervised removal skips the divert and lands on the human-only + // gate, where it is REFUSED — it does not execute. The setting upgrades + // refusal to a proposable divert, so a partially-applied rollout costs a + // capability, never a hole. + it("refuses — not executes — when dependency.remove isn't in the gate list", () => { setSetting(db, human, "supervised.hard_gate_actions", ["done"]); addDependency(db, human, "SYD-1", "SYD-2"); - removeDependency(db, human, "SYD-1", "SYD-2", { sessionId }); - expect(listDependencies(db, "SYD-2").blockedBy).toEqual([]); + expect(() => removeDependency(db, human, "SYD-1", "SYD-2", supAttr)).toThrow(/human/i); + expect(listDependencies(db, "SYD-2").blockedBy.map((b) => b.ref)).toEqual(["SYD-1"]); expect(db.select().from(pendingActions).all()).toHaveLength(0); }); }); diff --git a/tests/services/hard-gate-affirm-exec.test.ts b/tests/services/hard-gate-affirm-exec.test.ts index 587573c..89ffd93 100644 --- a/tests/services/hard-gate-affirm-exec.test.ts +++ b/tests/services/hard-gate-affirm-exec.test.ts @@ -4,6 +4,10 @@ // human affirms it. Task 5's hard-gate.test.ts covers affirmPendingAction in // isolation (rows created directly via findOrCreatePendingAction); this file // checks the same guarantees hold when the row comes from the real divert. +// SYD-281: `{ sessionId }` alone is a shape production never produces — +// resolveSupervisedPrincipal always sets viaAgent AND sessionId together +// (supervised-sessions.ts). effectiveActor now fails closed on a supervised +// attribution with no resolvable agent, so these tests build the real thing. import { describe, it, expect, beforeEach } from "vitest"; import { createHuman } from "../helpers/human.js"; import type { HumanActor } from "../../src/services/principal.js"; @@ -14,19 +18,25 @@ import { createProject } from "../../src/services/projects.js"; import { createIssue, getIssue, updateIssue } from "../../src/services/issues.js"; import { recordDeliveryEvent } from "../../src/services/delivery-events.js"; import { addGithubRepo } from "../../src/services/github-repos.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; +import { attributionOf, type Attribution } from "../../src/services/attribution.js"; import { pendingActions } from "../../src/db/schema.js"; import { affirmPendingAction, getPendingAction } from "../../src/services/hard-gate.js"; const REPO = "acme/widgets"; -let db: Db, human: HumanActor, issueId: number, sessionId: number; +let db: Db, human: HumanActor, issueId: number, sessionId: number, supAttr: Attribution; beforeEach(() => { db = openDb(":memory:"); human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); issueId = createIssue(db, human, { projectKey: "SYD", title: "t", description: "d" }).id; - sessionId = openSupervisedSession(db, human, "claude-code").sessionId; + const _sess = openSupervisedSession(db, human, "claude-code"); + sessionId = _sess.sessionId; + supAttr = attributionOf(resolveSupervisedPrincipal(db, _sess.sessionToken)!); }); function onlyPendingId(): number { @@ -37,7 +47,7 @@ function onlyPendingId(): number { describe("divert -> affirm end to end", () => { it("affirming the human's own pending action executes the done transition", () => { - expect(() => updateIssue(db, human, "SYD-1", { status: "done" }, {}, { sessionId })).toThrow( + expect(() => updateIssue(db, human, "SYD-1", { status: "done" }, {}, supAttr)).toThrow( /awaiting human affirmation/i, ); const id = onlyPendingId(); @@ -65,7 +75,7 @@ describe("divert -> affirm end to end", () => { "SYD-1", { status: "done", expectedHeadSha: "stale-sha" }, {}, - { sessionId }, + supAttr, ), ).toThrow(/awaiting human affirmation/i); const id = onlyPendingId(); @@ -76,7 +86,7 @@ describe("divert -> affirm end to end", () => { }); it("double-affirm: second affirm throws and exactly one done event is recorded", () => { - expect(() => updateIssue(db, human, "SYD-1", { status: "done" }, {}, { sessionId })).toThrow( + expect(() => updateIssue(db, human, "SYD-1", { status: "done" }, {}, supAttr)).toThrow( /awaiting human affirmation/i, ); const id = onlyPendingId(); diff --git a/tests/services/hard-gate-divert.test.ts b/tests/services/hard-gate-divert.test.ts index 7fd8727..668376a 100644 --- a/tests/services/hard-gate-divert.test.ts +++ b/tests/services/hard-gate-divert.test.ts @@ -2,6 +2,10 @@ // status change as a pending_actions row instead of committing it, for a // supervised session. See src/services/hard-gate.ts for the gate policy and // pending-action CRUD this divert calls into. +// SYD-281: `{ sessionId }` alone is a shape production never produces — +// resolveSupervisedPrincipal always sets viaAgent AND sessionId together +// (supervised-sessions.ts). effectiveActor now fails closed on a supervised +// attribution with no resolvable agent, so these tests build the real thing. import { describe, it, expect, beforeEach } from "vitest"; import { createHuman } from "../helpers/human.js"; import type { HumanActor } from "../../src/services/principal.js"; @@ -9,21 +13,27 @@ import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, getIssue, updateIssue } from "../../src/services/issues.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; +import { attributionOf, type Attribution } from "../../src/services/attribution.js"; import { pendingActions } from "../../src/db/schema.js"; -let db: Db, human: HumanActor, issueId: number, sessionId: number; +let db: Db, human: HumanActor, issueId: number, sessionId: number, supAttr: Attribution; beforeEach(() => { db = openDb(":memory:"); human = createHuman(db, "sean"); createProject(db, human, { key: "SYD", name: "Switchyard" }); issueId = createIssue(db, human, { projectKey: "SYD", title: "t", description: "d" }).id; - sessionId = openSupervisedSession(db, human, "claude-code").sessionId; + const _sess = openSupervisedSession(db, human, "claude-code"); + sessionId = _sess.sessionId; + supAttr = attributionOf(resolveSupervisedPrincipal(db, _sess.sessionToken)!); }); describe("updateIssue hard-gate divert", () => { it("parks a supervised done-stamp as a pending action instead of committing", () => { - expect(() => updateIssue(db, human, "SYD-1", { status: "done" }, {}, { sessionId })).toThrow( + expect(() => updateIssue(db, human, "SYD-1", { status: "done" }, {}, supAttr)).toThrow( /awaiting human affirmation/i, ); expect(getIssue(db, "SYD-1").status).not.toBe("done"); @@ -32,7 +42,7 @@ describe("updateIssue hard-gate divert", () => { it("dedups a retried proposal instead of piling up rows", () => { for (let i = 0; i < 2; i++) { - expect(() => updateIssue(db, human, "SYD-1", { status: "done" }, {}, { sessionId })).toThrow( + expect(() => updateIssue(db, human, "SYD-1", { status: "done" }, {}, supAttr)).toThrow( /awaiting human affirmation/i, ); } @@ -50,14 +60,14 @@ describe("updateIssue hard-gate divert", () => { updateIssue(db, human, "SYD-1", { status: "done" }, {}, {}); // Now a supervised call proposes the same status: patch.status === // target.status, so the divert must not fire (today's code no-ops here). - const view = updateIssue(db, human, "SYD-1", { status: "done" }, {}, { sessionId }); + const view = updateIssue(db, human, "SYD-1", { status: "done" }, {}, supAttr); expect(view.status).toBe("done"); expect(db.select().from(pendingActions).all()).toHaveLength(0); }); it("rejects a mixed patch instead of silently dropping the other fields", () => { expect(() => - updateIssue(db, human, "SYD-1", { status: "done", priority: "high" }, {}, { sessionId }), + updateIssue(db, human, "SYD-1", { status: "done", priority: "high" }, {}, supAttr), ).toThrow(/must be its own call/i); expect(db.select().from(pendingActions).all()).toHaveLength(0); expect(getIssue(db, "SYD-1").status).not.toBe("done"); @@ -75,7 +85,7 @@ describe("updateIssue hard-gate divert", () => { "SYD-1", { status: "done", priority: undefined, title: undefined }, {}, - { sessionId }, + supAttr, ), ).toThrow(/awaiting human affirmation/i); expect(db.select().from(pendingActions).all()).toHaveLength(1); diff --git a/tests/services/pr-links.test.ts b/tests/services/pr-links.test.ts index f320b14..c8fd4c0 100644 --- a/tests/services/pr-links.test.ts +++ b/tests/services/pr-links.test.ts @@ -9,6 +9,7 @@ // 2. Declaring is not confirming. An agent can over-block (safe, revocable) // but can never make its own link prove that its work landed. import { describe, it, expect } from "vitest"; +import { NO_SESSION } from "../../src/services/attribution.js"; import { createHuman } from "../helpers/human.js"; import type { HumanActor } from "../../src/services/principal.js"; import { openDb } from "../../src/db/index.js"; @@ -61,7 +62,7 @@ describe("declarePrLink — who may declare", () => { it("lets an agent holding the claim declare, presenting its lease", () => { const { db, agent } = setup(); const lease = claim(db, agent); - const link = declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease); + const link = declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease, NO_SESSION); expect(link.role).toBe("delivers"); expect(link.prNumber).toBe(7); }); @@ -69,7 +70,9 @@ describe("declarePrLink — who may declare", () => { it("refuses an agent that presents no lease", () => { const { db, agent } = setup(); claim(db, agent); - expect(() => declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 })).toThrow(/lease/i); + expect(() => + declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, undefined, NO_SESSION), + ).toThrow(/lease/i); }); it("refuses an agent that does not hold the claim", () => { @@ -77,54 +80,96 @@ describe("declarePrLink — who may declare", () => { const lease = claim(db, agent); // The other agent presents someone else's lease token — the shared-token // case SYD-210 exists for. - expect(() => declarePrLink(db, other, "SYD-1", { repo: REPO, prNumber: 7 }, lease)).toThrow(); + expect(() => + declarePrLink(db, other, "SYD-1", { repo: REPO, prNumber: 7 }, lease, NO_SESSION), + ).toThrow(); }); it("lets a human declare without any lease", () => { const { db, human } = setup(); - const link = declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + const link = declarePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: 7 }, + undefined, + NO_SESSION, + ); expect(link.prNumber).toBe(7); }); it("lets trusted infra (service) declare — the auto path", () => { const { db, infra } = setup(); - const link = declarePrLink(db, infra, "SYD-1", { repo: REPO, prNumber: 7 }); + const link = declarePrLink( + db, + infra, + "SYD-1", + { repo: REPO, prNumber: 7 }, + undefined, + NO_SESSION, + ); expect(link.prNumber).toBe(7); }); it("refuses a repo not bound to the issue's project", () => { const { db, human } = setup(); - expect(() => declarePrLink(db, human, "SYD-1", { repo: OTHER_REPO, prNumber: 7 })).toThrow( - /bound/i, - ); + expect(() => + declarePrLink(db, human, "SYD-1", { repo: OTHER_REPO, prNumber: 7 }, undefined, NO_SESSION), + ).toThrow(/bound/i); }); it("normalizes repo casing so uniqueness cannot be defeated by case", () => { const { db, human } = setup(); - declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); - expect(() => declarePrLink(db, human, "SYD-1", { repo: "ACME/Widgets", prNumber: 7 })).toThrow( - /already/i, - ); + declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, undefined, NO_SESSION); + expect(() => + declarePrLink( + db, + human, + "SYD-1", + { repo: "ACME/Widgets", prNumber: 7 }, + undefined, + NO_SESSION, + ), + ).toThrow(/already/i); }); }); describe("declarePrLink — confirmation at declaration", () => { it("auto-confirms a human declaration", () => { const { db, human } = setup(); - const link = declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + const link = declarePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: 7 }, + undefined, + NO_SESSION, + ); expect(link.confirmedBy).toBe(human.id); }); - it("auto-confirms a service declaration", () => { + // SYD-298, closed by SYD-281. This asserted the bug: `confirmedBy` was + // `isAgent ? null : actor.id`, so a `service` actor — the GitHub and deliver + // poller tokens, which already speak REST — had its declarations stamped as + // vouched-for. The predicate is now "is a human ACTING", which a service is + // not. + it("leaves a service declaration UNCONFIRMED — infra observes, it does not vouch", () => { const { db, infra } = setup(); - const link = declarePrLink(db, infra, "SYD-1", { repo: REPO, prNumber: 7 }); - expect(link.confirmedBy).toBe(infra.id); + const link = declarePrLink( + db, + infra, + "SYD-1", + { repo: REPO, prNumber: 7 }, + undefined, + NO_SESSION, + ); + expect(link.confirmedBy).toBeNull(); }); it("leaves an agent declaration UNCONFIRMED — it can never prove its own work landed", () => { const { db, agent } = setup(); const lease = claim(db, agent); - const link = declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease); + const link = declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease, NO_SESSION); expect(link.confirmedBy).toBeNull(); }); @@ -137,6 +182,7 @@ describe("declarePrLink — confirmation at declaration", () => { "SYD-1", { repo: REPO, prNumber: 7, role: "references" }, lease, + NO_SESSION, ); expect(link.role).toBe("delivers"); }); @@ -146,19 +192,15 @@ describe("confirmPrLink", () => { it("lets a human confirm an agent's link", () => { const { db, human, agent } = setup(); const lease = claim(db, agent); - declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease); - const link = confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease, NO_SESSION); + const link = confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, NO_SESSION); expect(link.confirmedBy).toBe(human.id); }); - it("refuses an agent confirming anything, including its own link", () => { - const { db, agent } = setup(); - const lease = claim(db, agent); - declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease); - expect(() => confirmPrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 })).toThrow( - /confirm/i, - ); - }); + // SYD-281: confirmPrLink takes a HumanActor, so an agent or service cannot be + // handed to it at all — the refusal is asserted at the adapter, where the + // question is actually asked (tests/rest/api-service-actor.test.ts covers + // "confirm a PR link" for both non-human tiers). // Found live: Sean confirmed the ingested #194 link on SYD-243 to clear its // done_without_merged_pr flag. The call succeeded, recorded a @@ -181,12 +223,12 @@ describe("confirmPrLink", () => { role: "references", actorId: agent.id, }); - expect(() => confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 })).toThrow( - /references/i, - ); - expect(() => confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 })).toThrow( - /declare/i, - ); + expect(() => + confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, NO_SESSION), + ).toThrow(/references/i); + expect(() => + confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, NO_SESSION), + ).toThrow(/declare/i); }); it("leaves the suggestion untouched when it refuses — no half-applied confirm", () => { @@ -198,7 +240,9 @@ describe("confirmPrLink", () => { role: "references", actorId: agent.id, }); - expect(() => confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 })).toThrow(); + expect(() => + confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, NO_SESSION), + ).toThrow(); const [link] = listLiveLinks(db, getIssue(db, "SYD-1").id); expect(link.role).toBe("references"); expect(link.confirmedBy).toBeNull(); @@ -215,16 +259,23 @@ describe("confirmPrLink", () => { role: "references", actorId: agent.id, }); - const promoted = declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + const promoted = declarePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: 7 }, + undefined, + NO_SESSION, + ); expect(promoted.role).toBe("delivers"); expect(promoted.confirmedBy).toBe(human.id); }); it("refuses to confirm a link that does not exist", () => { const { db, human } = setup(); - expect(() => confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 })).toThrow( - /no live/i, - ); + expect(() => + confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, NO_SESSION), + ).toThrow(/no live/i); }); }); @@ -232,43 +283,85 @@ describe("revokePrLink", () => { it("lets the declarer revoke their own unconfirmed link", () => { const { db, agent } = setup(); const lease = claim(db, agent); - declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease); - revokePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7, reason: "wrong PR" }, lease); + declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease, NO_SESSION); + revokePrLink( + db, + agent, + "SYD-1", + { repo: REPO, prNumber: 7, reason: "wrong PR" }, + lease, + NO_SESSION, + ); expect(listLiveLinks(db, 1)).toHaveLength(0); }); it("refuses an agent revoking a CONFIRMED link", () => { const { db, human, agent } = setup(); const lease = claim(db, agent); - declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease); - confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease, NO_SESSION); + confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, NO_SESSION); expect(() => - revokePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7, reason: "nope" }, lease), + revokePrLink( + db, + agent, + "SYD-1", + { repo: REPO, prNumber: 7, reason: "nope" }, + lease, + NO_SESSION, + ), ).toThrow(/human/i); }); it("lets a human revoke any link, confirmed or not", () => { const { db, human, agent } = setup(); const lease = claim(db, agent); - declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease); - confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); - revokePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7, reason: "mis-linked" }); + declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease, NO_SESSION); + confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, NO_SESSION); + revokePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: 7, reason: "mis-linked" }, + undefined, + NO_SESSION, + ); expect(listLiveLinks(db, 1)).toHaveLength(0); }); it("requires a reason", () => { const { db, human } = setup(); - declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, undefined, NO_SESSION); expect(() => - revokePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7, reason: " " }), + revokePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: 7, reason: " " }, + undefined, + NO_SESSION, + ), ).toThrow(/reason/i); }); it("is soft — the same PR can be re-declared after a revoke, and history survives", () => { const { db, human } = setup(); - declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); - revokePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7, reason: "mis-linked" }); - const again = declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, undefined, NO_SESSION); + revokePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: 7, reason: "mis-linked" }, + undefined, + NO_SESSION, + ); + const again = declarePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: 7 }, + undefined, + NO_SESSION, + ); expect(again.revokedAt).toBeNull(); expect(listLiveLinks(db, 1)).toHaveLength(1); // Both declarations are on the record. @@ -384,9 +477,9 @@ describe("the DoS the previous design died on", () => { // hold the issue. it("an agent with a valid token but no claim on the issue cannot block it", () => { const { db, other } = setup(); - expect(() => declarePrLink(db, other, "SYD-1", { repo: REPO, prNumber: 7 })).toThrow( - /not yours/i, - ); + expect(() => + declarePrLink(db, other, "SYD-1", { repo: REPO, prNumber: 7 }, undefined, NO_SESSION), + ).toThrow(/not yours/i); expect(listLiveLinks(db, 1)).toHaveLength(0); }); @@ -485,14 +578,14 @@ describe("listLiveLinkViews — what the panel shows a human", () => { it("names the declarer and confirmer instead of leaving the panel with actor ids", () => { const { db, human, agent } = setup(); const lease = claim(db, agent); - declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease); + declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: 7 }, lease, NO_SESSION); const [before] = listLiveLinkViews(db, 1); expect(before.declaredByName).toBe("claude/worker"); expect(before.confirmedByName).toBeNull(); expect(before.confirmedByHuman).toBe(false); - confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + confirmPrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, NO_SESSION); const [after] = listLiveLinkViews(db, 1); expect(after.confirmedByName).toBe("sean"); expect(after.confirmedByHuman).toBe(true); @@ -508,7 +601,7 @@ describe("listLiveLinkViews — what the panel shows a human", () => { stampDone(db, human, agent); expect(getAttention(db, 1)?.reason).toBe("done_without_merged_pr"); - declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 128 }); + declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 128 }, undefined, NO_SESSION); const [view] = listLiveLinkViews(db, 1); expect(view.role).toBe("delivers"); @@ -526,7 +619,7 @@ describe("listLiveLinkViews — what the panel shows a human", () => { observeMerge(db, 42, "2026-07-12T11:00:00Z"); expect(getAttention(db, 1)?.reason).toBe("done_without_merged_pr"); - declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 42 }); + declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 42 }, undefined, NO_SESSION); const [view] = listLiveLinkViews(db, 1); expect(view.observed?.status).toBe("merged"); @@ -536,15 +629,19 @@ describe("listLiveLinkViews — what the panel shows a human", () => { // §5a. A non-human confirmer buys no exemption from recency, so a merge that // predates the declaration can't be retro-claimed by infra. - it("withholds proof from a service-confirmed link whose merge predates the declaration", () => { + // SYD-281 narrowed this: a service declaration is no longer confirmed at all, + // so the link fails `provesLanded`'s `confirmedBy IS NOT NULL` before the + // recency disjunction is reached. The flag still lights, for a stricter + // reason than before. + it("withholds proof from an unconfirmed service declaration, merged or not", () => { const { db, human, agent, infra } = setup(); stampDone(db, human, agent); observeMerge(db, 43, "2026-07-12T11:00:00Z"); - declarePrLink(db, infra, "SYD-1", { repo: REPO, prNumber: 43 }); + declarePrLink(db, infra, "SYD-1", { repo: REPO, prNumber: 43 }, undefined, NO_SESSION); const [view] = listLiveLinkViews(db, 1); - expect(view.confirmedByName).toBe("deliver"); + expect(view.confirmedByName).toBeNull(); expect(view.confirmedByHuman).toBe(false); expect(view.observed?.status).toBe("merged"); expect(view.provesLanded).toBe(false); @@ -579,9 +676,16 @@ describe("listLiveLinkViews — what the panel shows a human", () => { it("drops a revoked link from the panel entirely", () => { const { db, human } = setup(); - declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }); + declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7 }, undefined, NO_SESSION); expect(listLiveLinkViews(db, 1)).toHaveLength(1); - revokePrLink(db, human, "SYD-1", { repo: REPO, prNumber: 7, reason: "wrong PR" }); + revokePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: 7, reason: "wrong PR" }, + undefined, + NO_SESSION, + ); expect(listLiveLinkViews(db, 1)).toHaveLength(0); }); }); diff --git a/tests/services/pr-observation.test.ts b/tests/services/pr-observation.test.ts index d2737ac..2405137 100644 --- a/tests/services/pr-observation.test.ts +++ b/tests/services/pr-observation.test.ts @@ -17,6 +17,7 @@ // the tests that matter, because that is the order production produces (open // the PR, the poller sees it within a tick, then the session declares). import { describe, it, expect } from "vitest"; +import { NO_SESSION } from "../../src/services/attribution.js"; import { createHuman } from "../helpers/human.js"; import type { HumanActor } from "../../src/services/principal.js"; import { openDb, type Db } from "../../src/db/index.js"; @@ -81,7 +82,7 @@ const merged = (pr: Record = {}) => /** Declares SYD-1 -> the PR as a human (auto-confirmed, so proof-bearing). */ function declare(db: Db, human: Parameters[1], prNumber = PR) { - return declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber }); + return declarePrLink(db, human, "SYD-1", { repo: REPO, prNumber }, undefined, NO_SESSION); } describe("a declared feat/ PR gains its observation half (SYD-287)", () => { @@ -211,7 +212,14 @@ describe("declaring after ingestion — the production ordering (SYD-287)", () = const { db, human } = setup(); handleGithubWebhook(db, "pull_request", featPr("opened")); declare(db, human); - revokePrLink(db, human, "SYD-1", { repo: REPO, prNumber: PR, reason: "wrong issue" }); + revokePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: PR, reason: "wrong issue" }, + undefined, + NO_SESSION, + ); expect(listLiveLinks(db, 1)).toHaveLength(0); // The superseded references row is soft-revoked, never deleted. const kinds = getActivity(db, "SYD-1").map((a) => a.type); @@ -224,7 +232,7 @@ describe("what a declaration still cannot do (SYD-287)", () => { it("an agent-declared link gates claims but never proves landing", () => { const { db, agent, other } = setup(); const lease = claimIssue(db, agent, "SYD-1").leaseToken as string; - declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: PR }, lease); + declarePrLink(db, agent, "SYD-1", { repo: REPO, prNumber: PR }, lease, NO_SESSION); handleGithubWebhook(db, "pull_request", featPr("opened")); handleGithubWebhook(db, "pull_request", merged()); @@ -256,7 +264,14 @@ describe("what a declaration still cannot do (SYD-287)", () => { const { db, human } = setup(); declare(db, human); handleGithubWebhook(db, "pull_request", featPr("opened")); - revokePrLink(db, human, "SYD-1", { repo: REPO, prNumber: PR, reason: "mis-linked" }); + revokePrLink( + db, + human, + "SYD-1", + { repo: REPO, prNumber: PR, reason: "mis-linked" }, + undefined, + NO_SESSION, + ); // The observation survives a revoke — a revoke removes an interpretation, // never an observation, and deleting the evidence would be the wrong move. @@ -272,7 +287,14 @@ describe("what a declaration still cannot do (SYD-287)", () => { it("a PR in an unbound repo is still unobservable — declaring there is refused outright", () => { const { db, human } = setup(); expect(() => - declarePrLink(db, human, "SYD-1", { repo: "acme/unrelated", prNumber: PR }), + declarePrLink( + db, + human, + "SYD-1", + { repo: "acme/unrelated", prNumber: PR }, + undefined, + NO_SESSION, + ), ).toThrow(/bound/i); handleGithubWebhook(db, "pull_request", { ...featPr("opened"), diff --git a/tests/services/supervised-attribution-e2e.test.ts b/tests/services/supervised-attribution-e2e.test.ts index 44e5266..93eed96 100644 --- a/tests/services/supervised-attribution-e2e.test.ts +++ b/tests/services/supervised-attribution-e2e.test.ts @@ -18,8 +18,12 @@ beforeEach(() => { }); function latestEvent(db: Db, issueId: number, type: string) { - const [row] = db.all<{ via_agent_id: number | null; session_id: number | null }>( - sql`SELECT via_agent_id, session_id FROM events WHERE issue_id = ${issueId} AND type = ${type} ORDER BY id DESC LIMIT 1`, + const [row] = db.all<{ + actor_id: number; + via_agent_id: number | null; + session_id: number | null; + }>( + sql`SELECT actor_id, via_agent_id, session_id FROM events WHERE issue_id = ${issueId} AND type = ${type} ORDER BY id DESC LIMIT 1`, ); return row; } @@ -29,13 +33,38 @@ describe("supervised attribution end-to-end", () => { const { sessionId } = openSupervisedSession(db, human, agent.name); const attr = attributionOf({ actor: human, viaAgent: agent, sessionId }); - const issue = createIssue(db, human, { projectKey: "SUP", title: "Do the thing" }, attr); + // SYD-281: a supervised session is on the agent path, so "agent-created + // issues land in triage with required provenance" (CLAUDE.md) now applies to + // it — it used to file straight into backlog with neither. + expect(() => + createIssue(db, human, { projectKey: "SUP", title: "Do the thing" }, attr), + ).toThrow(/provenance/i); + + const issue = createIssue( + db, + human, + { + projectKey: "SUP", + title: "Do the thing", + description: "Something a human can triage from.", + provenance: { sourceType: "session", detail: "src/api.ts:88" }, + }, + attr, + ); + expect(issue.status).toBe("triage"); + expect(issue.creatorId).toBe(agent.id); const created = latestEvent(db, issue.id, "created"); + // The row identity is the agent; the AUDIT identity stays the accountable + // human, with the agent on viaAgentId. That split is the whole design. + expect(created.actor_id).toBe(human.id); expect(created.via_agent_id).toBe(agent.id); expect(created.session_id).toBe(sessionId); - updateIssue(db, human, issue.ref, { status: "in_review" }, {}, attr); + // A human moves it out of triage, then the session claims and works it. + updateIssue(db, human, issue.ref, { status: "todo" }); + const { leaseToken } = claimIssue(db, human, issue.ref, {}, attr); + updateIssue(db, human, issue.ref, { status: "in_review" }, { presented: leaseToken }, attr); const statusChanged = latestEvent(db, issue.id, "status_changed"); expect(statusChanged.via_agent_id).toBe(agent.id); From 33600b4135e166c08d889419f3b80606bb8ae695 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 28 Aug 2026 00:31:05 -0400 Subject: [PATCH 13/16] fix: a supervised session cannot borrow a dispatch worker's identity (SYD-281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent name came straight from argv and getOrCreateActor returns an existing actor, so `mint-supervised-session sean claude/dev` bound a session to the live dispatch worker. Harmless while a supervised claim assigned the human; once the agent holds the claim it is the same actor, so assertClaimable early-returns and claimIssue offers takeover instead of refusing. Namespaced /supervised/ — engine first, so callerClassification still reads the engine. --- src/cli.ts | 11 ++++--- src/services/actors.ts | 13 ++++++-- src/services/supervised-sessions.ts | 35 ++++++++++++++++++++-- tests/mcp/supervised-mcp-endpoint.test.ts | 17 +++++++---- tests/mcp/supervised-write.test.ts | 6 ++-- tests/services/events-attribution.test.ts | 14 ++++++--- tests/services/supervised-sessions.test.ts | 30 ++++++++++++++++--- 7 files changed, 103 insertions(+), 23 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index c97f2dd..3f59e65 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -101,9 +101,12 @@ try { console.log(`login link (valid 15 minutes, single use):`); console.log(resolveBaseUrl(db) + path); } else if (cmd === "mint-supervised-session") { - const [humanName, agentName] = args; - if (!humanName || !agentName) { - console.error("mint-supervised-session needs: "); + const [humanName, engine] = args; + if (!humanName || !engine) { + // SYD-281: the second argument is an ENGINE ("claude", "codex"), not an + // actor name — the session's agent is namespaced /supervised/ + // so it can never collide with a dispatch worker's actor. + console.error("mint-supervised-session needs: "); process.exit(1); } const row = db.select().from(actors).where(eq(actors.name, humanName)).get(); @@ -114,7 +117,7 @@ try { ); } const human: Actor = { id: row.id, name: row.name, type: row.type, attended: row.attended }; - const { sessionToken } = openSupervisedSession(db, human, agentName); + const { sessionToken } = openSupervisedSession(db, human, engine); console.log(`supervised session token (shown once): ${sessionToken}`); console.log( "Set this as your MCP client's bearer. It authorizes supervised writes for 12h. It is NOT a web login. Do NOT run this session with your web cookie or personal syd_ bearer in its environment (see the plan's threat-model).", diff --git a/src/services/actors.ts b/src/services/actors.ts index 8603bba..bc7f4eb 100644 --- a/src/services/actors.ts +++ b/src/services/actors.ts @@ -52,7 +52,12 @@ export function authenticate(db: Db, token: string): Actor | null { * that doesn't authenticate through Switchyard (e.g. the GitHub webhook * receiver) — unlike createActor, this never throws on an existing name. */ -export function getOrCreateActor(db: Db, name: string, type: ActorType): Actor { +export function getOrCreateActor( + db: Db, + name: string, + type: ActorType, + opts: { attended?: boolean } = {}, +): Actor { const existing = db.select().from(actors).where(eq(actors.name, name)).get(); if (existing) return { @@ -61,7 +66,11 @@ export function getOrCreateActor(db: Db, name: string, type: ActorType): Actor { type: existing.type, attended: existing.attended, }; - const row = db.insert(actors).values({ name, type }).returning().get(); + const row = db + .insert(actors) + .values({ name, type, ...(opts.attended === undefined ? {} : { attended: opts.attended }) }) + .returning() + .get(); return { id: row.id, name: row.name, type: row.type, attended: row.attended }; } diff --git a/src/services/supervised-sessions.ts b/src/services/supervised-sessions.ts index ac34810..5b9f76d 100644 --- a/src/services/supervised-sessions.ts +++ b/src/services/supervised-sessions.ts @@ -16,15 +16,46 @@ const nowSec = () => Math.floor(Date.now() / 1000); * credential — it must never reach the web/REST cookie path (see the * `kind='plain'` filters in auth.ts's getSessionActor/deleteSession). */ +/** + * SYD-281: the agent a supervised session binds to is NAMESPACED, not taken + * verbatim from the caller. + * + * `getOrCreateActor` returns an EXISTING actor when the name matches, so + * `mint-supervised-session sean claude/dev` used to bind a session to the live + * dispatch worker's own actor. That was harmless while a supervised claim + * assigned the human — a container's assertClaimable refused with "already + * claimed by sean". Once the agent holds the claim it is the same actor, so + * assertClaimable early-returns and claimIssue downgrades the refusal to "pass + * takeover: true". A dispatched container could then seize a live interactive + * session's claim on a path that previously refused it outright. + * + * Engine first, so callerClassification's `name.split("/")[0]` + * (worker-preference.ts) still yields the engine rather than the literal + * "supervised". + */ +export function supervisedAgentName(engine: string, humanName: string): string { + if (engine.includes("/")) { + throw new SwitchyardError( + `"${engine}" is an engine name (e.g. "claude"), not an actor name — a supervised session's agent is namespaced as /supervised/.`, + ); + } + return `${engine}/supervised/${humanName}`; +} + export function openSupervisedSession( db: Db, human: Actor, - agentName: string, + engine: string, ): { sessionToken: string; sessionId: number } { if (human.type !== "human") { throw new SwitchyardError("Only a human can open a supervised session."); } - const agent = getOrCreateActor(db, agentName, "agent"); + const agentName = supervisedAgentName(engine, human.name); + // Attended by definition: a supervised session is a person driving an agent, + // which is exactly what the flag means (worker-preference.ts reads it to + // decide whether the interactive queue applies). getOrCreateActor's insert + // path defaults it false, which would have quietly withheld interactive work. + const agent = getOrCreateActor(db, agentName, "agent", { attended: true }); if (agent.type !== "agent") { throw new SwitchyardError( `"${agentName}" must be an agent to act as a supervised session's editor — it already exists as a ${agent.type}.`, diff --git a/tests/mcp/supervised-mcp-endpoint.test.ts b/tests/mcp/supervised-mcp-endpoint.test.ts index a39c5b6..caf8375 100644 --- a/tests/mcp/supervised-mcp-endpoint.test.ts +++ b/tests/mcp/supervised-mcp-endpoint.test.ts @@ -10,10 +10,13 @@ import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, updateIssue, type IssueView } from "../../src/services/issues.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; import { createApp } from "../../src/server.js"; -let db: Db, human: HumanActor, agent: Actor, issue: IssueView; +let db: Db, human: HumanActor, agent: Actor, plainAgent: Actor, issue: IssueView; let supToken: string, sessionId: number, agentToken: string; let server: ReturnType, baseUrl: string; @@ -49,13 +52,17 @@ function latestEvent(issueId: number, type: string) { beforeEach(async () => { db = openDb(":memory:"); human = createHuman(db, "sean"); + // The plain agent bearer this file also exercises. SYD-281: the SUPERVISED + // session's agent is a different, namespaced actor, taken from the principal + // below — binding to this one is exactly what the namespacing prevents. const created = createActor(db, { name: "claude-code", type: "agent" }); - agent = created.actor; + plainAgent = created.actor; agentToken = created.token; createProject(db, human, { key: "SUP", name: "supervised" }); - const session = openSupervisedSession(db, human, agent.name); + const session = openSupervisedSession(db, human, "claude-code"); supToken = session.sessionToken; sessionId = session.sessionId; + agent = resolveSupervisedPrincipal(db, supToken)!.viaAgent!; issue = createIssue(db, human, { projectKey: "SUP", title: "Wire /mcp" }); updateIssue(db, human, issue.ref, { status: "todo" }); @@ -132,7 +139,7 @@ describe("/mcp resolves a supervised principal", () => { // CROSS-TASK INVARIANT: a plain session's id in events.sessionId would make // POST /auth/logout FK-500, since deleteSession hard-deletes plain sessions. const ev = latestEvent(issue.id, "comment"); - expect(ev.actor_id).toBe(agent.id); + expect(ev.actor_id).toBe(plainAgent.id); expect(ev.via_agent_id).toBeNull(); expect(ev.session_id).toBeNull(); await client.close(); diff --git a/tests/mcp/supervised-write.test.ts b/tests/mcp/supervised-write.test.ts index 1aa960e..1a694de 100644 --- a/tests/mcp/supervised-write.test.ts +++ b/tests/mcp/supervised-write.test.ts @@ -55,10 +55,12 @@ beforeEach(() => { db = openDb(":memory:"); dir = mkdtempSync(path.join(tmpdir(), "syd-supervised-")); human = createHuman(db, "sean"); - agent = createActor(db, { name: "claude-code", type: "agent" }).actor; + // SYD-281: the session's agent is minted namespaced by openSupervisedSession, + // so take it from the resolved principal rather than pre-creating one. createProject(db, human, { key: "SUP", name: "supervised" }); - const { sessionToken } = openSupervisedSession(db, human, agent.name); + const { sessionToken } = openSupervisedSession(db, human, "claude-code"); prin = resolveSupervisedPrincipal(db, sessionToken)!; + agent = prin.viaAgent!; issue = createIssue(db, human, { projectKey: "SUP", title: "Wire the surface" }); updateIssue(db, human, issue.ref, { status: "todo" }); }); diff --git a/tests/services/events-attribution.test.ts b/tests/services/events-attribution.test.ts index d2906a8..17faef2 100644 --- a/tests/services/events-attribution.test.ts +++ b/tests/services/events-attribution.test.ts @@ -7,7 +7,10 @@ import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue } from "../../src/services/issues.js"; import { recordEvent } from "../../src/services/events.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; let db: Db, human: HumanActor, agent: Actor; beforeEach(() => { @@ -20,13 +23,16 @@ beforeEach(() => { describe("recordEvent attribution", () => { it("persists viaAgentId and sessionId when provided", () => { - const { sessionId } = openSupervisedSession(db, human, agent.name); + // SYD-281: the second argument is an engine; the session mints its own + // namespaced agent, which is the one whose id lands on the event. + const { sessionToken, sessionId } = openSupervisedSession(db, human, "claude"); + const supAgent = resolveSupervisedPrincipal(db, sessionToken)!.viaAgent!; const eventId = recordEvent(db, { issueId: 1, actorId: human.id, type: "status_changed", - viaAgentId: agent.id, + viaAgentId: supAgent.id, sessionId, }); @@ -34,7 +40,7 @@ describe("recordEvent attribution", () => { sql`SELECT via_agent_id, session_id FROM events WHERE id = ${eventId}`, ); - expect(row.via_agent_id).toBe(agent.id); + expect(row.via_agent_id).toBe(supAgent.id); expect(row.session_id).toBe(sessionId); }); diff --git a/tests/services/supervised-sessions.test.ts b/tests/services/supervised-sessions.test.ts index d6797cd..30fb72f 100644 --- a/tests/services/supervised-sessions.test.ts +++ b/tests/services/supervised-sessions.test.ts @@ -20,8 +20,30 @@ describe("openSupervisedSession / resolveSupervisedPrincipal", () => { expect(principal).not.toBeNull(); expect(principal!.actor.name).toBe("sean"); expect(principal!.actor.type).toBe("human"); - expect(principal!.viaAgent?.name).toBe("claude-code"); + // SYD-281: the agent is namespaced /supervised/, so a session + // can never bind to a dispatch worker's own actor. + expect(principal!.viaAgent?.name).toBe("claude-code/supervised/sean"); expect(principal!.viaAgent?.type).toBe("agent"); + // Attended by definition — a person is driving this agent, which is exactly + // what the flag means for the interactive queue. + expect(principal!.viaAgent?.attended).toBe(true); + }); + + it("namespaces away from a dispatch worker's actor", () => { + const dispatch = createActor(db, { name: "claude/dev", type: "agent" }).actor; + const { sessionToken } = openSupervisedSession(db, human, "claude"); + const principal = resolveSupervisedPrincipal(db, sessionToken)!; + // Binding to claude/dev is what would collapse assertClaimable once the + // agent holds the claim: same actor id => the guard early-returns and + // claimIssue offers takeover instead of refusing. + expect(principal.viaAgent!.id).not.toBe(dispatch.id); + expect(principal.viaAgent!.name).toBe("claude/supervised/sean"); + // callerClassification splits on "/" and must still see the engine. + expect(principal.viaAgent!.name.split("/")[0]).toBe("claude"); + }); + + it("refuses an engine argument that is really an actor name", () => { + expect(() => openSupervisedSession(db, human, "claude/dev")).toThrow(/engine name/i); }); it("refuses a non-human root", () => { @@ -31,9 +53,9 @@ describe("openSupervisedSession / resolveSupervisedPrincipal", () => { ); }); - it("refuses a name that pre-exists as human", () => { - createActor(db, { name: "other-human", type: "human" }); - expect(() => openSupervisedSession(db, human, "other-human")).toThrow(/must be an agent/i); + it("refuses an engine whose namespaced actor pre-exists as a non-agent", () => { + createActor(db, { name: "other/supervised/sean", type: "human" }); + expect(() => openSupervisedSession(db, human, "other")).toThrow(/must be an agent/i); }); it("a closed session doesn't resolve", () => { From 6f870a7fab336b96c40abce44598d05e2ae99c61 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 28 Aug 2026 00:33:02 -0400 Subject: [PATCH 14/16] feat: reset supervised claims to the new holder identity (SYD-281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selects on the assigned event's sessionId, not the issues table: claim_leases has no session column and a human assigneeId is byte-identical whether the claim was supervised or made in person, so the broad hammer would release Sean's own work. Invalidates the lease in the same transaction — the lease-cutover precedent does not, and leaving the stale row active means the re-claim mints a second one and the agent holds a token validateLease rejects. Also soft-closes open supervised sessions, whose 12h agent binding the namespacing cannot retroactively fix. --- drizzle/0020_boring_reaper.sql | 4 + drizzle/meta/0020_snapshot.json | 1899 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema.ts | 6 + src/server.ts | 6 + src/services/supervised-claim-cutover.ts | 108 + .../services/supervised-claim-cutover.test.ts | 128 ++ 7 files changed, 2158 insertions(+) create mode 100644 drizzle/0020_boring_reaper.sql create mode 100644 drizzle/meta/0020_snapshot.json create mode 100644 src/services/supervised-claim-cutover.ts create mode 100644 tests/services/supervised-claim-cutover.test.ts diff --git a/drizzle/0020_boring_reaper.sql b/drizzle/0020_boring_reaper.sql new file mode 100644 index 0000000..35b6f18 --- /dev/null +++ b/drizzle/0020_boring_reaper.sql @@ -0,0 +1,4 @@ +CREATE TABLE `supervised_claim_cutover` ( + `id` integer PRIMARY KEY NOT NULL, + `completed_at` integer DEFAULT (unixepoch()) NOT NULL +); diff --git a/drizzle/meta/0020_snapshot.json b/drizzle/meta/0020_snapshot.json new file mode 100644 index 0000000..3805788 --- /dev/null +++ b/drizzle/meta/0020_snapshot.json @@ -0,0 +1,1899 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "04f1a94d-2ee7-48f8-a8bd-d16952fd782b", + "prevId": "1de348bf-c65f-4f20-8d49-7b9ba9c34f00", + "tables": { + "actors": { + "name": "actors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attended": { + "name": "attended", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "actors_name_unique": { + "name": "actors_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "affirmation_keys": { + "name": "affirmation_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "affirmation_keys_active_uniq": { + "name": "affirmation_keys_active_uniq", + "columns": [ + "actor_id", + "public_key" + ], + "isUnique": true, + "where": "revoked_at is null" + } + }, + "foreignKeys": { + "affirmation_keys_actor_id_actors_id_fk": { + "name": "affirmation_keys_actor_id_actors_id_fk", + "tableFrom": "affirmation_keys", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_sessions": { + "name": "agent_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pid": { + "name": "pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "agent_sessions_issue_id_idx": { + "name": "agent_sessions_issue_id_idx", + "columns": [ + "issue_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_sessions_issue_id_issues_id_fk": { + "name": "agent_sessions_issue_id_issues_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_sessions_actor_id_actors_id_fk": { + "name": "agent_sessions_actor_id_actors_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "attachments_issue_id_issues_id_fk": { + "name": "attachments_issue_id_issues_id_fk", + "tableFrom": "attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "attachments_actor_id_actors_id_fk": { + "name": "attachments_actor_id_actors_id_fk", + "tableFrom": "attachments", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claim_lease_cutover": { + "name": "claim_lease_cutover", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claim_leases": { + "name": "claim_leases", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_beat_at": { + "name": "last_beat_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invalidated_at": { + "name": "invalidated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "claim_leases_token_hash_unique": { + "name": "claim_leases_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "claim_leases_issue_id_idx": { + "name": "claim_leases_issue_id_idx", + "columns": [ + "issue_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "claim_leases_issue_id_issues_id_fk": { + "name": "claim_leases_issue_id_issues_id_fk", + "tableFrom": "claim_leases", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "claim_leases_actor_id_actors_id_fk": { + "name": "claim_leases_actor_id_actors_id_fk", + "tableFrom": "claim_leases", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delivery_attempts": { + "name": "delivery_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_ref": { + "name": "issue_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "derived_head_sha": { + "name": "derived_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authorization_id": { + "name": "authorization_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "delivery_attempts_authorization_id_idx": { + "name": "delivery_attempts_authorization_id_idx", + "columns": [ + "authorization_id" + ], + "isUnique": false + }, + "delivery_attempts_issue_ref_idx": { + "name": "delivery_attempts_issue_ref_idx", + "columns": [ + "issue_ref" + ], + "isUnique": false + } + }, + "foreignKeys": { + "delivery_attempts_authorization_id_events_id_fk": { + "name": "delivery_attempts_authorization_id_events_id_fk", + "tableFrom": "delivery_attempts", + "tableTo": "events", + "columnsFrom": [ + "authorization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delivery_rollout": { + "name": "delivery_rollout", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dependencies": { + "name": "dependencies", + "columns": { + "blocker_id": { + "name": "blocker_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blocked_id": { + "name": "blocked_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dependencies_blocker_id_issues_id_fk": { + "name": "dependencies_blocker_id_issues_id_fk", + "tableFrom": "dependencies", + "tableTo": "issues", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dependencies_blocked_id_issues_id_fk": { + "name": "dependencies_blocked_id_issues_id_fk", + "tableFrom": "dependencies", + "tableTo": "issues", + "columnsFrom": [ + "blocked_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dependencies_blocker_id_blocked_id_pk": { + "columns": [ + "blocker_id", + "blocked_id" + ], + "name": "dependencies_blocker_id_blocked_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "via_agent_id": { + "name": "via_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "events_issue_id_idx": { + "name": "events_issue_id_idx", + "columns": [ + "issue_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "events_issue_id_issues_id_fk": { + "name": "events_issue_id_issues_id_fk", + "tableFrom": "events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_actor_id_actors_id_fk": { + "name": "events_actor_id_actors_id_fk", + "tableFrom": "events", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_via_agent_id_actors_id_fk": { + "name": "events_via_agent_id_actors_id_fk", + "tableFrom": "events", + "tableTo": "actors", + "columnsFrom": [ + "via_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_session_id_sessions_id_fk": { + "name": "events_session_id_sessions_id_fk", + "tableFrom": "events", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "github_repos": { + "name": "github_repos", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "github_repos_full_name_unique": { + "name": "github_repos_full_name_unique", + "columns": [ + "full_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "github_repos_project_id_projects_id_fk": { + "name": "github_repos_project_id_projects_id_fk", + "tableFrom": "github_repos", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "issues": { + "name": "issues", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_detail": { + "name": "source_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "needs_input": { + "name": "needs_input", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "snoozed_until": { + "name": "snoozed_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worker_preference": { + "name": "worker_preference", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_rank": { + "name": "queue_rank", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "issues_project_id_idx": { + "name": "issues_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "issues_status_idx": { + "name": "issues_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "issues_assignee_id_idx": { + "name": "issues_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "issues_queue_rank_idx": { + "name": "issues_queue_rank_idx", + "columns": [ + "queue_rank" + ], + "isUnique": false + } + }, + "foreignKeys": { + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_id_actors_id_fk": { + "name": "issues_assignee_id_actors_id_fk", + "tableFrom": "issues", + "tableTo": "actors", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_creator_id_actors_id_fk": { + "name": "issues_creator_id_actors_id_fk", + "tableFrom": "issues", + "tableTo": "actors", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_links": { + "name": "login_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "login_links_token_hash_unique": { + "name": "login_links_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "login_links_actor_id_actors_id_fk": { + "name": "login_links_actor_id_actors_id_fk", + "tableFrom": "login_links", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_actions": { + "name": "pending_actions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "affirmed_by_id": { + "name": "affirmed_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affirmed_at": { + "name": "affirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_actions_active_uniq": { + "name": "pending_actions_active_uniq", + "columns": [ + "session_id", + "issue_id", + "action_type" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "pending_actions_session_id_sessions_id_fk": { + "name": "pending_actions_session_id_sessions_id_fk", + "tableFrom": "pending_actions", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pending_actions_issue_id_issues_id_fk": { + "name": "pending_actions_issue_id_issues_id_fk", + "tableFrom": "pending_actions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pending_actions_affirmed_by_id_actors_id_fk": { + "name": "pending_actions_affirmed_by_id_actors_id_fk", + "tableFrom": "pending_actions", + "tableTo": "actors", + "columnsFrom": [ + "affirmed_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pr_links": { + "name": "pr_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "issue_id": { + "name": "issue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_by": { + "name": "declared_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_at": { + "name": "declared_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "confirmed_by": { + "name": "confirmed_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "pr_links_live_idx": { + "name": "pr_links_live_idx", + "columns": [ + "issue_id", + "repo", + "pr_number" + ], + "isUnique": true, + "where": "\"pr_links\".\"revoked_at\" IS NULL" + }, + "pr_links_pr_idx": { + "name": "pr_links_pr_idx", + "columns": [ + "repo", + "pr_number" + ], + "isUnique": false + }, + "pr_links_issue_idx": { + "name": "pr_links_issue_idx", + "columns": [ + "issue_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pr_links_issue_id_issues_id_fk": { + "name": "pr_links_issue_id_issues_id_fk", + "tableFrom": "pr_links", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pr_links_declared_by_actors_id_fk": { + "name": "pr_links_declared_by_actors_id_fk", + "tableFrom": "pr_links", + "tableTo": "actors", + "columnsFrom": [ + "declared_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pr_links_confirmed_by_actors_id_fk": { + "name": "pr_links_confirmed_by_actors_id_fk", + "tableFrom": "pr_links", + "tableTo": "actors", + "columnsFrom": [ + "confirmed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pr_state": { + "name": "pr_state", + "columns": { + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_ref": { + "name": "issue_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gh_updated_at": { + "name": "gh_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_transition_event_id": { + "name": "last_transition_event_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "pr_state_issue_ref_idx": { + "name": "pr_state_issue_ref_idx", + "columns": [ + "issue_ref" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "pr_state_repo_pr_number_pk": { + "columns": [ + "repo", + "pr_number" + ], + "name": "pr_state_repo_pr_number_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_issue_number": { + "name": "next_issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "projects_key_unique": { + "name": "projects_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'plain'" + }, + "via_agent_id": { + "name": "via_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_actor_id_actors_id_fk": { + "name": "sessions_actor_id_actors_id_fk", + "tableFrom": "sessions", + "tableTo": "actors", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_via_agent_id_actors_id_fk": { + "name": "sessions_via_agent_id_actors_id_fk", + "tableFrom": "sessions", + "tableTo": "actors", + "columnsFrom": [ + "via_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_by_actor_id": { + "name": "updated_by_actor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "settings_updated_by_actor_id_actors_id_fk": { + "name": "settings_updated_by_actor_id_actors_id_fk", + "tableFrom": "settings", + "tableTo": "actors", + "columnsFrom": [ + "updated_by_actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "supervised_claim_cutover": { + "name": "supervised_claim_cutover", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_cursor": { + "name": "webhook_cursor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_event_id": { + "name": "last_event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_project_id_projects_id_fk": { + "name": "webhooks_project_id_projects_id_fk", + "tableFrom": "webhooks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bdc4501..a011be6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1785273879961, "tag": "0019_boring_pet_avengers", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1787891510940, + "tag": "0020_boring_reaper", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 61e985f..1aeb18b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -483,6 +483,12 @@ export const claimLeaseCutover = sqliteTable("claim_lease_cutover", { completedAt: integer("completed_at").notNull().default(now()), }); +/** SYD-281 one-time cutover marker — see services/supervised-claim-cutover.ts. */ +export const supervisedClaimCutover = sqliteTable("supervised_claim_cutover", { + id: integer("id").primaryKey(), + completedAt: integer("completed_at").notNull().default(now()), +}); + export const PENDING_ACTION_STATUSES = ["pending", "affirmed", "expired"] as const; export type PendingActionStatus = (typeof PENDING_ACTION_STATUSES)[number]; diff --git a/src/server.ts b/src/server.ts index c23ebc4..78d96ac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -162,6 +162,12 @@ if (import.meta.url === `file://${process.argv[1]}`) { const cutover = ensureClaimLeaseCutover(db); if (!cutover.alreadyDone) console.log(`claim-lease cutover: released ${cutover.released} in-flight claim(s)`); + const { ensureSupervisedClaimCutover } = await import("./services/supervised-claim-cutover.js"); + const supCutover = ensureSupervisedClaimCutover(db); + if (!supCutover.alreadyDone) + console.log( + `supervised-claim cutover: released ${supCutover.released} claim(s), closed ${supCutover.sessionsClosed} session(s)`, + ); startServer(db, Number(process.env.PORT ?? 3300)); const { startWebhookDispatcher } = await import("./services/webhook-dispatcher.js"); startWebhookDispatcher(db); diff --git a/src/services/supervised-claim-cutover.ts b/src/services/supervised-claim-cutover.ts new file mode 100644 index 0000000..af7c8e9 --- /dev/null +++ b/src/services/supervised-claim-cutover.ts @@ -0,0 +1,108 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import type { Db } from "../db/index.js"; +import { events, issues, sessions, supervisedClaimCutover } from "../db/schema.js"; +import { recordEvent } from "./events.js"; +import { invalidateLease } from "./leases.js"; + +/** + * One-time SYD-281 cutover. The identity rule makes the acting AGENT the holder + * of a supervised claim — assignee and lease. Claims made before this deploy + * hold the accountable HUMAN in both places, which the new predicates cannot + * recognise: + * + * - `isHolderMutation` compares the assignee against the acting agent, so a + * human-assigned row is never a holder mutation and `validateLease` is + * SKIPPED entirely — SYD-210's shared-token hole, silently reopened. + * - `assertClaimable` then refuses the agent with "already claimed by " + * forever, and clearing an assignee is human-only. The issue is stuck. + * + * So the lease cannot merely be expired: expiry leaves `assigneeId` set, which + * is the half that does the damage. Release the ASSIGNMENT, following + * `ensureClaimLeaseCutover` (lease-cutover.ts) — status→todo, assigneeId→null, + * a `claim_released` event — and invalidate the lease in the SAME transaction, + * which that precedent does NOT do. Skip it and the stale human-keyed row stays + * active: the agent's re-claim mints a second one, `getActiveLease` (no ORDER BY) + * returns whichever SQLite yields first, and the agent holds a token it can + * never use. + * + * ## Selection + * + * `claim_leases` has no session column and an `issues.assigneeId` pointing at a + * human is byte-identical whether the claim came from a supervised session or + * from a person claiming at their desk. The ONLY link is `events.sessionId` + * (written by recordEvent), so this walks the `assigned` events rather than the + * issues table. That is event archaeology, and it is the price of not releasing + * claims people made in person. + * + * ## Also: open sessions + * + * `sessions.viaAgentId` is fixed at mint and the TTL is 12h, so a session opened + * against a bare `claude/dev` before this deploy keeps that binding — which is + * exactly the `assertClaimable` collapse the namespacing exists to prevent, live + * for the rest of its TTL. Soft-close them all so they re-mint under the new + * naming. Never DELETE: `sessions.id` is an FK target for `events.sessionId`. + * + * ## Operator note + * + * `ensureClaimLeaseCutover` rests its low-blast-radius argument on the worker + * LaunchAgents being DOWN at cutover. A routine `npm run deploy` restarts only + * the NAS tracker while the workers keep polling, and a released issue lands at + * `todo`/unassigned — exactly what `selectDispatchable` wants. Stop the workers + * for this deploy, or run it through the SYD-291 operator path. + */ +export function ensureSupervisedClaimCutover(db: Db): { + released: number; + sessionsClosed: number; + alreadyDone: boolean; +} { + return db.transaction((tx) => { + const marker = tx.select().from(supervisedClaimCutover).get(); + if (marker) return { released: 0, sessionsClosed: 0, alreadyDone: true }; + + // Issues still in_progress whose CURRENT assignment was made inside a + // supervised session. `ORDER BY id DESC LIMIT 1` per issue: an issue + // reassigned in person after a supervised claim is not ours to touch. + const rows = tx.all<{ issue_id: number; actor_id: number }>(sql` + SELECT i.id AS issue_id, e.actor_id AS actor_id + FROM issues i + JOIN events e ON e.id = ( + SELECT id FROM events + WHERE issue_id = i.id AND type = 'assigned' + ORDER BY id DESC LIMIT 1 + ) + WHERE i.status = 'in_progress' + AND i.assignee_id IS NOT NULL + AND e.session_id IS NOT NULL + `); + + for (const row of rows) { + // Order matters only for legibility; both are in this transaction. + invalidateLease(tx, row.issue_id); + tx.update(issues) + .set({ status: "todo", assigneeId: null, updatedAt: sql`(unixepoch())` }) + .where(eq(issues.id, row.issue_id)) + .run(); + recordEvent(tx, { + issueId: row.issue_id, + actorId: row.actor_id, + type: "claim_released", + payload: { reason: "supervised_claim_cutover" }, + }); + } + + const open = tx + .select() + .from(sessions) + .where(and(eq(sessions.kind, "supervised"), isNull(sessions.closedAt))) + .all(); + if (open.length > 0) { + tx.update(sessions) + .set({ closedAt: sql`(unixepoch())` }) + .where(and(eq(sessions.kind, "supervised"), isNull(sessions.closedAt))) + .run(); + } + + tx.insert(supervisedClaimCutover).values({ id: 1 }).run(); + return { released: rows.length, sessionsClosed: open.length, alreadyDone: false }; + }); +} diff --git a/tests/services/supervised-claim-cutover.test.ts b/tests/services/supervised-claim-cutover.test.ts new file mode 100644 index 0000000..a16f714 --- /dev/null +++ b/tests/services/supervised-claim-cutover.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import { openDb, type Db } from "../../src/db/index.js"; +import { claimLeases, sessions } from "../../src/db/schema.js"; +import { createHuman } from "../helpers/human.js"; +import type { HumanActor } from "../../src/services/principal.js"; +import { createProject } from "../../src/services/projects.js"; +import { createIssue, updateIssue, claimIssue, getIssue } from "../../src/services/issues.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; +import { attributionOf, NO_SESSION } from "../../src/services/attribution.js"; +import { getActiveLease } from "../../src/services/leases.js"; +import { ensureSupervisedClaimCutover } from "../../src/services/supervised-claim-cutover.js"; + +let db: Db, human: HumanActor; +beforeEach(() => { + db = openDb(":memory:"); + human = createHuman(db, "sean"); + createProject(db, human, { key: "SYD", name: "Switchyard" }); +}); + +/** A supervised session, and the attribution its writes carry. */ +function supervised() { + const { sessionToken } = openSupervisedSession(db, human, "claude"); + const prin = resolveSupervisedPrincipal(db, sessionToken)!; + return { attr: attributionOf(prin), agent: prin.viaAgent! }; +} + +function todoIssue(title: string) { + const issue = createIssue(db, human, { projectKey: "SYD", title }); + updateIssue(db, human, issue.ref, { status: "todo" }); + return issue; +} + +describe("ensureSupervisedClaimCutover", () => { + it("releases a supervised claim and invalidates its lease, so the agent can re-claim AND WRITE", () => { + const issue = todoIssue("Held by a supervised session"); + const { attr, agent } = supervised(); + claimIssue(db, human, issue.ref, {}, attr); + expect(getIssue(db, issue.ref).assigneeId).toBe(agent.id); + + // Simulate the pre-deploy world: the claim was recorded with the HUMAN as + // holder, which is what every claim written before the identity rule looks + // like. The `assigned` event keeps its sessionId, which is the only thing + // that marks it supervised. + db.run(sql`UPDATE issues SET assignee_id = ${human.id} WHERE id = ${issue.id}`); + db.run(sql`UPDATE claim_leases SET actor_id = ${human.id} WHERE issue_id = ${issue.id}`); + + const res = ensureSupervisedClaimCutover(db); + expect(res.released).toBe(1); + + const after = getIssue(db, issue.ref); + expect(after.status).toBe("todo"); + expect(after.assigneeId).toBeNull(); + // The lease must go too. Left active, the re-claim below mints a SECOND + // active row and getActiveLease (no ORDER BY) can return the stale human one + // — the agent would hold a token validateLease rejects, forever. + expect(getActiveLease(db, issue.id)).toBeNull(); + + // The whole point: re-claim, then actually WRITE with the new lease. A test + // that stops at the re-claim goes green with the two-lease hole open — the + // claim succeeds and the NEXT write is what fails. + const { attr: attr2, agent: agent2 } = supervised(); + const { leaseToken } = claimIssue(db, human, issue.ref, {}, attr2); + expect(getIssue(db, issue.ref).assigneeId).toBe(agent2.id); + expect( + db + .select() + .from(claimLeases) + .where(and(eq(claimLeases.issueId, issue.id), isNull(claimLeases.invalidatedAt))) + .all(), + ).toHaveLength(1); + expect(() => + updateIssue(db, human, issue.ref, { priority: "high" }, { presented: leaseToken }, attr2), + ).not.toThrow(); + }); + + it("leaves an in-person human claim alone — it is not ours to release", () => { + const issue = todoIssue("Claimed by Sean at his desk"); + claimIssue(db, human, issue.ref, {}, NO_SESSION); + expect(getIssue(db, issue.ref).assigneeId).toBe(human.id); + + const res = ensureSupervisedClaimCutover(db); + expect(res.released).toBe(0); + + const after = getIssue(db, issue.ref); + expect(after.status).toBe("in_progress"); + expect(after.assigneeId).toBe(human.id); + }); + + it("soft-closes open supervised sessions so they re-mint under the new naming", () => { + supervised(); + supervised(); + expect( + db + .select() + .from(sessions) + .where(and(eq(sessions.kind, "supervised"), isNull(sessions.closedAt))) + .all(), + ).toHaveLength(2); + + const res = ensureSupervisedClaimCutover(db); + expect(res.sessionsClosed).toBe(2); + expect( + db + .select() + .from(sessions) + .where(and(eq(sessions.kind, "supervised"), isNull(sessions.closedAt))) + .all(), + ).toHaveLength(0); + // Soft-close, never DELETE: sessions.id is an FK target for events.sessionId. + expect(db.select().from(sessions).all()).toHaveLength(2); + }); + + it("is once-only across restarts", () => { + const issue = todoIssue("Held"); + const { attr } = supervised(); + claimIssue(db, human, issue.ref, {}, attr); + db.run(sql`UPDATE issues SET assignee_id = ${human.id} WHERE id = ${issue.id}`); + + expect(ensureSupervisedClaimCutover(db).released).toBe(1); + const second = ensureSupervisedClaimCutover(db); + expect(second.alreadyDone).toBe(true); + expect(second.released).toBe(0); + }); +}); From 01292bf261964c42a320c96adde6bf3fb50befc3 Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 28 Aug 2026 00:39:07 -0400 Subject: [PATCH 15/16] feat: say why a supervised session was refused, and who is acting (SYD-281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit whoami now reports both identities when they differ — the accountable human and the agent that actually holds the claims, since search_issues(assignee: ...) would otherwise look for the session's work under a name that holds none of it. Refusal text that described the pre-SYD-281 model is reworded. The eslint cast ban on HumanActor is enforced (verified by inserting a cast). The companion rule banning a bare {} as an Attribution is deliberately NOT enforced: ESLint cannot distinguish it from a LeaseChannel or an options bag without type information, and the broad selector produced 71 mostly-legitimate hits — the shape of a rule that gets switched off. NO_SESSION remains conventional, which is honest, since it is a legibility control either way. --- eslint.config.js | 41 ++++++++++++++++++++++++++++ src/mcp/server.ts | 17 ++++++++++-- src/services/dependencies.ts | 2 +- src/services/issues.ts | 6 ++-- tests/mcp/supervised-write.test.ts | 14 +++++++++- tests/rest/api-actors.test.ts | 8 +++--- tests/rest/api-escalation.test.ts | 10 +++---- tests/rest/api-issues.test.ts | 2 +- tests/rest/api-projects.test.ts | 4 +-- tests/rest/api-service-actor.test.ts | 6 ++-- tests/services/dependencies.test.ts | 4 ++- tests/services/issues-update.test.ts | 8 +++--- 12 files changed, 94 insertions(+), 28 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index dff5333..1d2aa7d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,47 @@ export default tseslint.config( "react-hooks/exhaustive-deps": "warn", }, }, + // SYD-281. `HumanActor` is a compile-time brand — erased at runtime — so the + // real enforcement is the adapters calling `asHuman`/`requireHuman` and + // throwing. These two rules keep the type honest by making the ways around it + // greppable rather than invisible. + // + // Neither is a security boundary. A cast ban does not stop `f(x as any)`, and + // a caller that wrongly reaches for NO_SESSION in a supervised path fails open + // exactly as `{}` would. What they buy is that the wrong choice is a named + // token a reviewer can search for. + { + files: ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"], + ignores: [ + // asHuman/requireHuman mint the brand; there is nowhere else it can come from. + "src/services/principal.ts", + // The hard-gate executor re-drives as the human with deliberately empty + // attribution, which is what makes the affirmed action NOT re-divert. + "src/services/hard-gate.ts", + ], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: "TSAsExpression > TSTypeReference > Identifier[name='HumanActor']", + message: + "Never cast to HumanActor — mint it with asHuman()/requireHuman() so the runtime check actually runs. A cast here is how a human-only gate becomes a no-op with no trace.", + }, + // NOT enforced: a companion rule banning a bare `{}` where an + // Attribution is expected. It cannot be written syntactically — ESLint + // sees an empty ObjectExpression and cannot tell an Attribution from a + // LeaseChannel (updateIssue's 5th argument) or an options bag + // (claimIssue's 4th). The broad selector produced 71 hits, almost all + // legitimate, which is the shape of a rule that gets switched off. + // + // NO_SESSION still exists and is used at the call sites that have no + // session; it is just conventional rather than enforced. Honest scope: + // the sentinel is a legibility control either way — a caller that + // wrongly reaches for it in a supervised path fails open exactly as + // `{}` would. + ], + }, + }, { rules: { // Not yet enforced as errors: preexisting `any` usages and non-null diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 162b1d2..c9a592a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,7 +4,7 @@ import type { Db } from "../db/index.js"; import { STATUSES, PRIORITIES } from "../db/schema.js"; import type { Actor } from "../services/actors.js"; import type { Attribution } from "../services/attribution.js"; -import { effectiveActor } from "../services/principal.js"; +import { actingAgent, effectiveActor } from "../services/principal.js"; import { SwitchyardError, PendingAffirmation } from "../services/errors.js"; import { listProjects } from "../services/projects.js"; import { @@ -88,9 +88,20 @@ export function buildMcpServer( { description: "Get the actor name/type/id your MCP token is bound to — the MCP equivalent of REST's " + - "GET /me. Useful for assignee-scoped search_issues, or to confirm which actor a token authenticates as.", + "GET /me. Useful for assignee-scoped search_issues, or to confirm which actor a token authenticates as. " + + "In a supervised session this reports BOTH identities: the accountable human, and the agent " + + "that actually holds your claims and PR declarations.", }, - guard(() => actor), + guard(() => { + // SYD-281: `actor` alone is misleading in a supervised session — the + // session's assignments, leases and declaredBy are the AGENT's, so + // search_issues(assignee: whoami().name) would return nothing for the + // session's own work. + const acting = actingAgent(db, actor, attribution); + // Only when it DIFFERS: for a plain agent session the acting agent IS the + // actor, and echoing it back would be noise. + return acting && acting.id !== actor.id ? { ...actor, actingAs: acting } : actor; + }), ); server.registerTool( diff --git a/src/services/dependencies.ts b/src/services/dependencies.ts index 43350af..6445a98 100644 --- a/src/services/dependencies.ts +++ b/src/services/dependencies.ts @@ -140,7 +140,7 @@ export function removeDependency( // The setting upgrades refusal to a proposable divert — UX, not security. if (asHuman(actor, attr) === null) { throw new SwitchyardError( - "Only humans remove dependencies — if you believe a blocker is wrong, say so in a comment.", + "Only a human acting in person removes dependencies — if you believe a blocker is wrong, say so in a comment. A supervised session can propose one once dependency.remove is in supervised.hard_gate_actions.", ); } db.transaction((tx) => { diff --git a/src/services/issues.ts b/src/services/issues.ts index c8eb05b..33e2531 100644 --- a/src/services/issues.ts +++ b/src/services/issues.ts @@ -430,12 +430,12 @@ export function updateIssue( } if (current.status === "triage" && !vouching) { throw new SwitchyardError( - `${ref} is in triage — only humans move issues out of triage. Use triage_queue to help a human review it.`, + `${ref} is in triage — only a human acting in person moves issues out of triage, and a supervised session is not that. Use triage_queue to help a human review it.`, ); } if (patch.status === "done" && !vouching) { throw new SwitchyardError( - "Only humans move issues to done — comment your verification evidence and move it to in_review instead.", + "Only a human acting in person moves issues to done — comment your verification evidence and move it to in_review instead. In a supervised session, propose it and your human affirms.", ); } if (agentPath) { @@ -606,7 +606,7 @@ export function updateIssue( ) { if (!vouching && patch.labels.includes("auto") && !current.labels.includes("auto")) { throw new SwitchyardError( - `Only humans apply the "auto" label — it opts an issue into unattended dispatch.`, + `Only a human acting in person applies the "auto" label — it opts an issue into unattended dispatch, so an agent applying it to its own issue would be self-escalating.`, ); } changes.labels = patch.labels; diff --git a/tests/mcp/supervised-write.test.ts b/tests/mcp/supervised-write.test.ts index 1a694de..81c1416 100644 --- a/tests/mcp/supervised-write.test.ts +++ b/tests/mcp/supervised-write.test.ts @@ -171,7 +171,7 @@ describe("MCP write tools in a supervised session", () => { arguments: { blocker_ref: blocker.ref, blocked_ref: issue.ref }, }); expect(rPlain.isError).toBe(true); - expect(text(rPlain)).toMatch(/Only humans remove dependencies/i); + expect(text(rPlain)).toMatch(/only a human acting in person removes dependencies/i); // 3. Supervised session with hard-gate action configured parks a pending action setSetting(db, human, "supervised.hard_gate_actions", ["dependency.remove"]); @@ -300,4 +300,16 @@ describe("declare_pr_link in a supervised session (SYD-281)", () => { const [link] = listLiveLinkViews(db, issue.id); expect(link.role).toBe("delivers"); }); + + // SYD-281: after the identity rule the session's assignments, declaredBy and + // creatorId are the AGENT's, so a whoami that reported only the accountable + // human would send search_issues(assignee: whoami().name) looking for work + // under a name that holds none of it. + it("whoami reports the accountable human AND the agent actually holding the work", async () => { + const client = await connectSupervised(); + const me = JSON.parse(text(await client.callTool({ name: "whoami", arguments: {} }))); + expect(me.name).toBe(human.name); + expect(me.type).toBe("human"); + expect(me.actingAs).toMatchObject({ id: agent.id, name: agent.name, type: "agent" }); + }); }); diff --git a/tests/rest/api-actors.test.ts b/tests/rest/api-actors.test.ts index 5e32b08..5be03d8 100644 --- a/tests/rest/api-actors.test.ts +++ b/tests/rest/api-actors.test.ts @@ -43,7 +43,7 @@ describe("actor routes", () => { body: JSON.stringify({ name: "claude/other", type: "agent" }), }); expect(denied.status).toBe(400); - expect(((await denied.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await denied.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); const res = await app.request("/actors", { method: "POST", @@ -70,7 +70,7 @@ describe("actor routes", () => { headers: { authorization: `Bearer ${bearer}` }, }); expect(denied.status).toBe(400); - expect(((await denied.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await denied.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); const res = await app.request(`/actors/${worker.actor.id}/rotate-token`, { method: "POST", @@ -102,7 +102,7 @@ describe("actor routes", () => { headers: { authorization: `Bearer ${bearer}` }, }); expect(denied.status).toBe(400); - expect(((await denied.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await denied.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); const res = await app.request(`/actors/${worker.actor.id}/token`, { method: "DELETE", @@ -158,7 +158,7 @@ describe("actor routes", () => { headers: { authorization: `Bearer ${bearer}` }, }); expect(denied.status).toBe(400); - expect(((await denied.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await denied.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); const res = await app.request(`/actors/${sean.id}/login-link`, { method: "POST", diff --git a/tests/rest/api-escalation.test.ts b/tests/rest/api-escalation.test.ts index 85624d7..89fdc9e 100644 --- a/tests/rest/api-escalation.test.ts +++ b/tests/rest/api-escalation.test.ts @@ -155,7 +155,7 @@ describe("escalation, snooze, and duplicate routes", () => { body: JSON.stringify({ until: future }), }); expect(denied.status).toBe(400); - expect((await body<{ error: string }>(denied)).error).toMatch(/only humans/i); + expect((await body<{ error: string }>(denied)).error).toMatch(/only (a )?humans?/i); const snoozed = await body<{ snoozedUntil: number }>( await app.request(`/issues/${filed.ref}/snooze`, { @@ -201,7 +201,7 @@ describe("escalation, snooze, and duplicate routes", () => { body: JSON.stringify({ of: original.ref }), }); expect(denied.status).toBe(400); - expect((await body<{ error: string }>(denied)).error).toMatch(/only humans/i); + expect((await body<{ error: string }>(denied)).error).toMatch(/only (a )?humans?/i); const marked = await body<{ status: string; @@ -253,7 +253,7 @@ describe("escalation, snooze, and duplicate routes", () => { body: "{}", }); expect(denied.status).toBe(400); - expect((await body<{ error: string }>(denied)).error).toMatch(/only humans/i); + expect((await body<{ error: string }>(denied)).error).toMatch(/only (a )?humans?/i); // This issue has no pr_state row at all (no repo bound, no PR ever // opened), so deliveryPinFor finds nothing to redeliver — refused before @@ -370,7 +370,7 @@ describe("escalation, snooze, and duplicate routes", () => { body: JSON.stringify({ note: NOTE }), }); expect(denied.status).toBe(400); - expect((await body<{ error: string }>(denied)).error).toMatch(/only humans/i); + expect((await body<{ error: string }>(denied)).error).toMatch(/only (a )?humans?/i); // No repo bound, no PR ever opened — resolve-delivery still succeeds // where redeliver would refuse with "no agent PR on record". @@ -441,7 +441,7 @@ describe("escalation, snooze, and duplicate routes", () => { body: JSON.stringify({ reason: "done_without_merged_pr", note: NOTE }), }); expect(denied.status).toBe(400); - expect((await body<{ error: string }>(denied)).error).toMatch(/only humans/i); + expect((await body<{ error: string }>(denied)).error).toMatch(/only (a )?humans?/i); const ok = await app.request(`/issues/${filed.ref}/resolve-deviation`, { method: "POST", diff --git a/tests/rest/api-issues.test.ts b/tests/rest/api-issues.test.ts index 73ae0da..11d58ba 100644 --- a/tests/rest/api-issues.test.ts +++ b/tests/rest/api-issues.test.ts @@ -50,7 +50,7 @@ describe("issue routes", () => { body: JSON.stringify({ status: "todo" }), }); expect(denied.status).toBe(400); - expect((await body<{ error: string }>(denied)).error).toMatch(/only humans/i); + expect((await body<{ error: string }>(denied)).error).toMatch(/only (a )?humans?/i); const accepted = await app.request(`/issues/${filed.ref}`, { method: "PATCH", diff --git a/tests/rest/api-projects.test.ts b/tests/rest/api-projects.test.ts index ead4969..56d6163 100644 --- a/tests/rest/api-projects.test.ts +++ b/tests/rest/api-projects.test.ts @@ -74,7 +74,7 @@ describe("api auth + projects", () => { body: JSON.stringify({ name: "agent rename" }), }); expect(denied.status).toBe(400); - expect(((await denied.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await denied.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); }); it("rejects agent-token project creation (SYD-157)", async () => { @@ -84,7 +84,7 @@ describe("api auth + projects", () => { body: JSON.stringify({ key: "AGT", name: "agent project" }), }); expect(res.status).toBe(400); - expect(((await res.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await res.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); }); it("lists actors without leaking token hashes", async () => { diff --git a/tests/rest/api-service-actor.test.ts b/tests/rest/api-service-actor.test.ts index a58e390..193516d 100644 --- a/tests/rest/api-service-actor.test.ts +++ b/tests/rest/api-service-actor.test.ts @@ -37,7 +37,7 @@ describe("service token — REST-layer guards", () => { body: JSON.stringify({ name: "claude/other", type: "agent" }), }); expect(res.status).toBe(400); - expect(((await res.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await res.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); }); it("CANNOT mint a login link (requireHumanCaller)", async () => { @@ -47,7 +47,7 @@ describe("service token — REST-layer guards", () => { body: "{}", }); expect(res.status).toBe(400); - expect(((await res.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await res.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); }); it("CAN post GitHub events (trusted poller)", async () => { @@ -182,7 +182,7 @@ describe("non-human bearers are refused at every human-only route", () => { ...(r.body === undefined ? {} : { body: JSON.stringify(r.body) }), }); expect(res.status).toBe(400); - expect(((await res.json()) as { error: string }).error).toMatch(/only humans/i); + expect(((await res.json()) as { error: string }).error).toMatch(/only (a )?humans?/i); }); } } diff --git a/tests/services/dependencies.test.ts b/tests/services/dependencies.test.ts index 32b6c2e..73d5453 100644 --- a/tests/services/dependencies.test.ts +++ b/tests/services/dependencies.test.ts @@ -129,7 +129,9 @@ describe("dependencies", () => { it("agents cannot remove a dependency — removal would defeat a human's gate", () => { addDependency(db, human, "AIPI-1", "AIPI-2"); - expect(() => removeDependency(db, agent, "AIPI-1", "AIPI-2")).toThrowError(/only humans/i); + expect(() => removeDependency(db, agent, "AIPI-1", "AIPI-2")).toThrowError( + /only (a )?humans?/i, + ); expect(listDependencies(db, "AIPI-2").blockedBy).toHaveLength(1); // Agents CAN still add — declaring a discovered blocker is intended. expect(() => addDependency(db, agent, "AIPI-2", "AIPI-3")).not.toThrow(); diff --git a/tests/services/issues-update.test.ts b/tests/services/issues-update.test.ts index daed88f..461be88 100644 --- a/tests/services/issues-update.test.ts +++ b/tests/services/issues-update.test.ts @@ -135,10 +135,10 @@ describe("updateIssue", () => { // non-status edits by agents are still allowed in triage expect(updateIssue(db, agent, filed.ref, { priority: "high" }).priority).toBe("high"); expect(() => updateIssue(db, agent, filed.ref, { status: "todo" })).toThrowError( - /only humans move issues out of triage/i, + /only a human acting in person moves issues out of triage/i, ); expect(() => claimIssue(db, agent, filed.ref)).toThrowError( - /only humans move issues out of triage/i, + /only a human acting in person moves issues out of triage/i, ); expect(updateIssue(db, human, filed.ref, { status: "todo" }).status).toBe("todo"); }); @@ -149,7 +149,7 @@ describe("updateIssue", () => { updateIssue(db, agent, "AIPI-1", { status: "in_review" }, { presented: leaseToken }); expect(() => updateIssue(db, agent, "AIPI-1", { status: "done" }, { presented: leaseToken }), - ).toThrowError(/only humans move issues to done/i); + ).toThrowError(/only a human acting in person moves issues to done/i); expect(getIssue(db, "AIPI-1").status).toBe("in_review"); expect(updateIssue(db, human, "AIPI-1", { status: "done" }).status).toBe("done"); }); @@ -158,7 +158,7 @@ describe("updateIssue", () => { // starting without "auto": agent adding it is rejected updateIssue(db, human, "AIPI-1", { labels: ["urgent"] }); expect(() => updateIssue(db, agent, "AIPI-1", { labels: ["auto", "urgent"] })).toThrowError( - /only humans apply the "auto" label/i, + /only a human acting in person applies the "auto" label/i, ); expect(getIssue(db, "AIPI-1").labels).toEqual(["urgent"]); From d14771e8b766e33685c4050bf4b289f8a817d31b Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 28 Aug 2026 11:22:50 -0400 Subject: [PATCH 16/16] fix: close three gates the changeset review found still open (SYD-281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the panel on the implementation, not the plan: - needs-input.ts:43-44 was in the spec's population B and never converted, so a supervised session could call request_human_input on an issue its own agent holds and skip validateLease entirely — the SYD-210 holder-lease gate, dead. - comments.ts:32 (@agent questions) was filed in population C as "signalling". It is not: worker-select.ts consumes every agent_question event as a signal to dispatch a headless answerer, so a supervised agent could summon a worker as though a person had asked. - heartbeatClaim was given an attr parameter but the MCP call site never passed it. The comment claiming this was unreachable was wrong: x-switchyard-lease is a client-supplied header independent of the bearer, so a sup_ connection can carry one and get heartbeat registered. Keyed to the accountable human it would validate against an agent-minted lease and throw on every beat. Also: five tests built attribution from a hand-made actor rather than the resolved principal, exercising an actor/session pairing production cannot create — the state-constructing shape this story exists to remove, in its own test suite. Each fix ships with a test proven to fail without it. --- src/mcp/server.ts | 8 +- src/rest/api-routes.ts | 6 +- src/services/comments.ts | 7 +- src/services/issues.ts | 11 +-- src/services/needs-input.ts | 10 ++- tests/mcp/supervised-write.test.ts | 84 ++++++++++++++++++- tests/rest/affirm-signed.test.ts | 7 +- tests/rest/pending-actions.test.ts | 7 +- tests/services/comments.test.ts | 16 ++-- .../supervised-attribution-e2e.test.ts | 17 ++-- 10 files changed, 146 insertions(+), 27 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c9a592a..1a244c9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -390,7 +390,13 @@ export function buildMcpServer( inputSchema: { ref: z.string(), lease_token: z.string().optional() }, }, guard(({ ref, lease_token }: { ref: string; lease_token?: string }) => { - const { expiresAt } = heartbeatClaim(db, actor, ref, lease_token ?? connectionLeaseToken); + const { expiresAt } = heartbeatClaim( + db, + actor, + ref, + lease_token ?? connectionLeaseToken, + attribution, + ); return { ok: true, expires_at: expiresAt }; }), ); diff --git a/src/rest/api-routes.ts b/src/rest/api-routes.ts index 65c8ddb..2c55619 100644 --- a/src/rest/api-routes.ts +++ b/src/rest/api-routes.ts @@ -308,7 +308,7 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen }); app.post("/issues", body(issueCreateBody), (c) => - c.json(createIssue(db, c.var.actor, c.req.valid("json"))), + c.json(createIssue(db, c.var.actor, c.req.valid("json"), NO_SESSION)), ); app.get("/issues/:ref", (c) => { @@ -346,7 +346,7 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen }); app.post("/issues/:ref/claim", (c) => { - const { issue, leaseToken } = claimIssue(db, c.var.actor, c.req.param("ref")); + const { issue, leaseToken } = claimIssue(db, c.var.actor, c.req.param("ref"), {}, NO_SESSION); return c.json({ ...issue, leaseToken }); }); @@ -355,7 +355,7 @@ export function buildApiRoutes(db: Db, attachmentsDir: string = defaultAttachmen ); app.post("/issues/:ref/comments", body(commentBody), (c) => { - addComment(db, c.var.actor, c.req.param("ref"), c.req.valid("json").body); + addComment(db, c.var.actor, c.req.param("ref"), c.req.valid("json").body, NO_SESSION); return c.json({ ok: true }); }); diff --git a/src/services/comments.ts b/src/services/comments.ts index a19fc5c..db2f1b2 100644 --- a/src/services/comments.ts +++ b/src/services/comments.ts @@ -29,7 +29,12 @@ export function addComment( viaAgentId: attr.viaAgentId, sessionId: attr.sessionId, }); - if (actor.type === "human" && AGENT_QUESTION_RE.test(body.trim())) { + // SYD-281: this looked like signalling, but worker-select.ts treats every + // agent_question event as a signal to dispatch an answerer, and + // events.ts's unanswered-question query accepts it on type alone. A + // supervised agent posting "@agent ..." could therefore summon a worker as + // though a person had asked. It is a human act. + if (asHuman(actor, attr) !== null && AGENT_QUESTION_RE.test(body.trim())) { // Read-only signal for the worker's answerer mode: no issue-state change, // just a marker event the event poll can watch for (same shape as // needs_input_cleared below) — works on any status, including triage. diff --git a/src/services/issues.ts b/src/services/issues.ts index 33e2531..6a16084 100644 --- a/src/services/issues.ts +++ b/src/services/issues.ts @@ -744,11 +744,12 @@ export function heartbeatClaim( attr: Attribution = NO_SESSION, ): { expiresAt: number } { const issue = getIssue(db, ref); - // SYD-281: correct by symmetry with the mint, which keys to the effective - // actor. Unreachable from a supervised session today — mcp/server.ts registers - // `heartbeat` only for a connection-lease session, and those are refused - // claim_issue — so this is the right answer for the day that changes, not a - // live fix. + // SYD-281: keys to the effective actor, matching the mint. This IS reachable + // from a supervised session: `heartbeat` is registered whenever the connection + // carries an x-switchyard-lease header (mcp/server.ts), and that header is + // client-supplied and independent of the bearer (src/server.ts) — a sup_ + // connection can send one. Keyed to `actor` it would validate the accountable + // human against an agent-minted lease and throw on every beat. const lease = heartbeatLease(db, issue.id, effectiveActor(db, actor, attr).id, leaseToken); return { expiresAt: lease.expiresAt }; } diff --git a/src/services/needs-input.ts b/src/services/needs-input.ts index 3869ed2..c6db3a2 100644 --- a/src/services/needs-input.ts +++ b/src/services/needs-input.ts @@ -1,4 +1,5 @@ import { eq, sql } from "drizzle-orm"; +import { actingAgent, effectiveActor } from "./principal.js"; import type { Db } from "../db/index.js"; import { issues } from "../db/schema.js"; import type { Actor } from "./actors.js"; @@ -40,8 +41,13 @@ export function requestHumanInput( // already-claimed issue by its holder"). A non-holder agent escalating an // issue it hasn't claimed is a benign additive signal (like comment) and is // not lease-gated; humans are never lease-gated. - if (actor.type === "agent" && issue.assigneeId === actor.id) { - validateLease(tx, issue.id, actor.id, leaseToken); + // SYD-281: both halves move. The predicate asked `type === "agent"`, which a + // supervised principal (typed human) fails, so the whole branch was skipped + // and the lease never validated — on an issue its own agent now holds. The + // key must be the effective actor too: the human holds no lease. + const eff = effectiveActor(tx, actor, attr); + if (actingAgent(tx, actor, attr) !== null && issue.assigneeId === eff.id) { + validateLease(tx, issue.id, eff.id, leaseToken); } const row = tx .update(issues) diff --git a/tests/mcp/supervised-write.test.ts b/tests/mcp/supervised-write.test.ts index 81c1416..2750918 100644 --- a/tests/mcp/supervised-write.test.ts +++ b/tests/mcp/supervised-write.test.ts @@ -10,9 +10,11 @@ import { sql } from "drizzle-orm"; import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; -import { createIssue, updateIssue, type IssueView } from "../../src/services/issues.js"; +import { createIssue, updateIssue, getIssue, type IssueView } from "../../src/services/issues.js"; import { addDependency, listDependencies } from "../../src/services/dependencies.js"; import { addGithubRepo } from "../../src/services/github-repos.js"; +import { requestHumanInput } from "../../src/services/needs-input.js"; +import { declarePrLink } from "../../src/services/pr-links.js"; import { listLiveLinkViews } from "../../src/services/pr-links.js"; import { setSetting } from "../../src/services/settings.js"; import { @@ -312,4 +314,84 @@ describe("declare_pr_link in a supervised session (SYD-281)", () => { expect(me.type).toBe("human"); expect(me.actingAs).toMatchObject({ id: agent.id, name: agent.name, type: "agent" }); }); + + // SYD-281 review (opus + fable): the two cases the spec named as the ones a + // naive implementer gets wrong, plus the two gates the changeset review found + // unconverted. Each of these passes on `main` — i.e. each is a live hole. + + it("a NO-OP status patch does not clear needsInput", async () => { + // issues.ts's clear fires on `patch.status !== undefined`, NOT on a change, + // and the hard-gate divert only engages when the status actually differs. So + // a supervised agent could answer its own escalation with a status patch + // equal to the current status — touching `comment` never enters into it. + const claimed = await ( + await connectSupervised() + ).callTool({ + name: "claim_issue", + arguments: { ref: issue.ref }, + }); + const lease = JSON.parse(text(claimed)).lease_token as string; + requestHumanInput(db, agent, issue.ref, "Which approach?", lease); + expect(getIssue(db, issue.ref).needsInput).toBe(true); + + const client = await connectSupervised(); + const cur = getIssue(db, issue.ref).status; + const r = await client.callTool({ + name: "update_issue", + arguments: { ref: issue.ref, status: cur, lease_token: lease }, + }); + expect(r.isError).toBeFalsy(); + expect(getIssue(db, issue.ref).needsInput).toBe(true); + }); + + it("cannot revoke a link the human declared in person", async () => { + // The repo is already linked by this describe's beforeEach. + // The human declares, at their desk, with no session. + declarePrLink(db, human, issue.ref, { repo: "acme/widgets", prNumber: 11 }, undefined, {}); + + const client = await connectSupervised(); + const r = await client.callTool({ + name: "revoke_pr_link", + arguments: { ref: issue.ref, repo: "acme/widgets", pr_number: 11, reason: "not mine" }, + }); + expect(r.isError).toBe(true); + // declaredBy is the human and the link is confirmed, so both the ownership + // test and the confirmed test refuse it. Keyed to `actor` (the accountable + // human) instead of the acting agent, this would have succeeded. + expect(text(r)).toMatch(/confirmed|declared by someone else/i); + }); + + it("request_human_input on its own claimed issue still needs the agent's lease", async () => { + const claimed = await ( + await connectSupervised() + ).callTool({ + name: "claim_issue", + arguments: { ref: issue.ref }, + }); + expect(claimed.isError).toBeFalsy(); + const client = await connectSupervised(); + const r = await client.callTool({ + name: "request_human_input", + arguments: { ref: issue.ref, question: "Which approach?" }, + }); + expect(r.isError).toBe(true); + expect(text(r)).toMatch(/lease/i); + expect(getIssue(db, issue.ref).needsInput).toBe(false); + }); + + it("an @agent comment does not dispatch an answerer — that is a human act", async () => { + // worker-select.ts consumes every agent_question event as a signal to + // dispatch a headless answerer, so a supervised agent posting "@agent ..." + // could summon a worker as though a person had asked. + const client = await connectSupervised(); + const r = await client.callTool({ + name: "comment", + arguments: { ref: issue.ref, body: "@agent what should I do here?" }, + }); + expect(r.isError).toBeFalsy(); + const [row] = db.all<{ n: number }>( + sql`SELECT COUNT(*) AS n FROM events WHERE issue_id = ${issue.id} AND type = 'agent_question'`, + ); + expect(row.n).toBe(0); + }); }); diff --git a/tests/rest/affirm-signed.test.ts b/tests/rest/affirm-signed.test.ts index 0c3c7bb..9223eb5 100644 --- a/tests/rest/affirm-signed.test.ts +++ b/tests/rest/affirm-signed.test.ts @@ -9,7 +9,10 @@ import { createActor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, updateIssue, type IssueView } from "../../src/services/issues.js"; import { createLoginLink, redeemLoginLink } from "../../src/services/auth.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; import { attributionOf } from "../../src/services/attribution.js"; import { setSetting } from "../../src/services/settings.js"; import { PendingAffirmation } from "../../src/services/errors.js"; @@ -87,7 +90,7 @@ function supervisedRest() { issue.ref, { status: "done" }, {}, - attributionOf({ actor: human, viaAgent: agent, sessionId: session.sessionId }), + attributionOf(resolveSupervisedPrincipal(db, session.sessionToken)!), ); } catch (err) { if (err instanceof PendingAffirmation) caught = err; diff --git a/tests/rest/pending-actions.test.ts b/tests/rest/pending-actions.test.ts index 7e8a251..85af423 100644 --- a/tests/rest/pending-actions.test.ts +++ b/tests/rest/pending-actions.test.ts @@ -6,7 +6,10 @@ import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, updateIssue, getIssue, type IssueView } from "../../src/services/issues.js"; import { createLoginLink, redeemLoginLink } from "../../src/services/auth.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; import { attributionOf } from "../../src/services/attribution.js"; import { listPendingActions } from "../../src/services/hard-gate.js"; import { buildApiRoutes } from "../../src/rest/api-routes.js"; @@ -51,7 +54,7 @@ beforeEach(() => { issue.ref, { status: "done" }, {}, - attributionOf({ actor: owner, viaAgent: agent, sessionId: session.sessionId }), + attributionOf(resolveSupervisedPrincipal(db, session.sessionToken)!), ), ).toThrow(/awaiting human affirmation/i); diff --git a/tests/services/comments.test.ts b/tests/services/comments.test.ts index eba618b..c0f9581 100644 --- a/tests/services/comments.test.ts +++ b/tests/services/comments.test.ts @@ -7,7 +7,10 @@ import { createProject } from "../../src/services/projects.js"; import { createIssue } from "../../src/services/issues.js"; import { addComment, getActivity } from "../../src/services/comments.js"; import { attributionOf } from "../../src/services/attribution.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; describe("comments and activity", () => { it("appends comments and returns the attributed stream", () => { @@ -76,12 +79,15 @@ describe("comments and activity", () => { it("surfaces supervised-session provenance (viaAgentName) only on the delegated event (SYD-240)", () => { const db = openDb(":memory:"); const human = createHuman(db, "sean"); - const agent = createActor(db, { name: "claude-code", type: "agent" }).actor; createProject(db, human, { key: "AIPI", name: "aipi" }); createIssue(db, human, { projectKey: "AIPI", title: "Ship v1" }); // plain "created" event - const { sessionId } = openSupervisedSession(db, human, agent.name); - const attr = attributionOf({ actor: human, viaAgent: agent, sessionId }); + // SYD-281: the session mints its own namespaced agent, so read the name off + // the principal rather than a hand-made actor — the two are different, and + // asserting against the hand-made one tests a pairing production never makes. + const { sessionToken } = openSupervisedSession(db, human, "claude"); + const prin = resolveSupervisedPrincipal(db, sessionToken)!; + const attr = attributionOf(prin); addComment(db, human, "AIPI-1", "written on Sean's behalf", attr); const activity = getActivity(db, "AIPI-1"); @@ -89,6 +95,6 @@ describe("comments and activity", () => { expect(activity[0].actorName).toBe("sean"); expect(activity[0].viaAgentName).toBeNull(); expect(activity[1].actorName).toBe("sean"); - expect(activity[1].viaAgentName).toBe("claude-code"); + expect(activity[1].viaAgentName).toBe(prin.viaAgent!.name); }); }); diff --git a/tests/services/supervised-attribution-e2e.test.ts b/tests/services/supervised-attribution-e2e.test.ts index 93eed96..79eedab 100644 --- a/tests/services/supervised-attribution-e2e.test.ts +++ b/tests/services/supervised-attribution-e2e.test.ts @@ -6,7 +6,10 @@ import { openDb, type Db } from "../../src/db/index.js"; import { createActor, type Actor } from "../../src/services/actors.js"; import { createProject } from "../../src/services/projects.js"; import { createIssue, updateIssue, claimIssue } from "../../src/services/issues.js"; -import { openSupervisedSession } from "../../src/services/supervised-sessions.js"; +import { + openSupervisedSession, + resolveSupervisedPrincipal, +} from "../../src/services/supervised-sessions.js"; import { attributionOf } from "../../src/services/attribution.js"; let db: Db, human: HumanActor, agent: Actor; @@ -30,8 +33,10 @@ function latestEvent(db: Db, issueId: number, type: string) { describe("supervised attribution end-to-end", () => { it("(a) createIssue + updateIssue->in_review write attributed events", () => { - const { sessionId } = openSupervisedSession(db, human, agent.name); - const attr = attributionOf({ actor: human, viaAgent: agent, sessionId }); + const { sessionToken, sessionId } = openSupervisedSession(db, human, "claude"); + const prin = resolveSupervisedPrincipal(db, sessionToken)!; + const attr = attributionOf(prin); + const agent = prin.viaAgent!; // SYD-281: a supervised session is on the agent path, so "agent-created // issues land in triage with required provenance" (CLAUDE.md) now applies to @@ -72,8 +77,10 @@ describe("supervised attribution end-to-end", () => { }); it("(b) supervised claimIssue attributes the delegated assigned/status_changed event", () => { - const { sessionId } = openSupervisedSession(db, human, agent.name); - const attr = attributionOf({ actor: human, viaAgent: agent, sessionId }); + const { sessionToken, sessionId } = openSupervisedSession(db, human, "claude"); + const prin = resolveSupervisedPrincipal(db, sessionToken)!; + const attr = attributionOf(prin); + const agent = prin.viaAgent!; const issue = createIssue(db, human, { projectKey: "SUP", title: "Claim me" }); updateIssue(db, human, issue.ref, { status: "todo" });