From 736c8ae9db5d81c3e2ec1ecc8deac9e0b3e3e32d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:16:13 -0700 Subject: [PATCH 1/8] docs(plans): pod playbooks, a team's procedure as a pod workflow (TASK-274) A playbook is an Installable, a run is board rows, an approval point is an approval card bound to a row, and connector access stays the pod grant. The note answers triggers (a scheduled-job time trigger, labelled), the pod rule for mixing a connector with a BYO agent (two pods joined by a handoff), write gaps (draft here, a person files), the GitHub issue wait, and how Scout turns a dropped document into an Off draft. Two seed playbooks are mapped, with build slices and named tests. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/pod-playbooks.md | 153 ++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/plans/pod-playbooks.md diff --git a/docs/plans/pod-playbooks.md b/docs/plans/pod-playbooks.md new file mode 100644 index 000000000..70614631f --- /dev/null +++ b/docs/plans/pod-playbooks.md @@ -0,0 +1,153 @@ +# Pod playbooks: a team's procedure as a pod workflow (TASK-274) + +**Status:** design, 2026-10-22. **Gate:** Vera. **Build:** TASK-275 builds slices 1–3 of §9. **Decided before this note:** the approval model in §3 is the CTO's, as owner of the primitive; this note designs against it and does not reopen it. Code is read at `main` `a44787fc`. + +A playbook is a team's written procedure (an SOP, a runbook, a page exported from Notion) run as a pod workflow: a trigger, steps that agents and people do, approval points where a person rules, and a done check. Scout drafts one from a dropped document; the team edits it, binds who does what, and turns it on. Nothing here is a new runtime. A playbook is an Installable, a run is board rows, an approval point is an approval card bound to a row, and connector access stays the pod grant it already is. What this adds is a runner (the service that reads a playbook and moves its rows) and three fields on Task. + +**What the code says, before the design leans on it.** + +1. Nothing runs a `scheduled-job` or a playbook-shaped `event-handler`. Both are in the component union (`Installable.ts:68–69`, fields at `:182–187`), but the install service projects only `webhook` and `event-handler` (`services/installable/projectors/index.ts:5–8`) and throws `Unsupported component type` on the rest (`installableInstallationService.ts:387`). Its event-handler projector is a connector's: it refuses a handler outside the registry (`eventHandlerProjector.ts:14`) and needs the connector's webhook Integration row (`:22`), and the registry holds two chat relays (`eventHandlers.ts:33–34`). ADR-001 Phase 5 would ship both component types (ADR-001:159–161) and is paused (ADR-011:60). The runner is the playbook slice of that phase; the general projector stays paused. +2. Handler dispatch on `chat.message` fires for agent posts, to relay them out (`agentMessageService.ts:1900`). A person's message reaches agents through `deliverMessageToAgents` (`messageAgentDeliveryService.ts:56`). +3. Native agents cannot claim, note or complete a row: their task tools are `create_task` and `list_tasks` (`nativeRuntimeService.ts:283`, `:299`). In a pod with a live hosted grant, a native turn narrows to reading context and posting (`:523`, `:1735–1743`) plus the grant's read tools (`grantBrokerProjectionService.ts:180`). +4. The installable reconciler marks every `active` installation that has no Integration row with "This connector's channel is gone" (`installableReconciler.ts:80–112`), every five minutes (`schedulerService.ts:478`). A playbook installation has none. +5. Claiming a `blocked` row resumes it (`tasksApi.ts:592`). Creating a row whose `(sourceRef, title)` matches a done row reopens it (`tasksApi.ts:264–311`). +6. A `tool_call` card's decider is the person its proposer names; every other card falls to the agent's installer or the pod's creator (`approvalActionService.ts:444–446`), and only that owner, as a human, can rule (`:786–797`). Executors live in the same file (`executeAction`, `:1021`), not in the `services/approvalExecutors.ts` that `ApprovalAction.ts:29–32` names. +7. Scout has no file tool (`config/native-agents/scout.ts:242–254`), and the runtime file route returns text only for textual types (`agentsRuntime.ts:1850`). + +## 1. Where a playbook lives + +**The record.** A playbook is an `Installable` with `kind: 'app'` and `scope: 'pod'`. Its `source` is `user` (drafted by Scout or a person), `template` (a copy of a seed, with `forkedFrom`) or `builtin` (the seeds). It always carries one `event-handler` component (`eventType: 'playbook.run'`, `eventHandler: 'internal:playbook.start'`) that holds the playbook in its component `metadata.playbook` (`Installable.ts:221`); Run and a handoff (§5) start it. A trigger adds a second component: an `event-handler` with `eventType: 'pod.message'` (push), or a `scheduled-job` with `cron` and `jobHandler: 'internal:playbook.start'` (schedule). The runner resolves `internal:playbook.*` itself, so `eventHandlers.ts` stays the relay registry. In v1 a `user` or `template` playbook has one installation; using it in a second pod copies it, so an edit never reaches another pod's runs. + +**The installation.** One `InstallableInstallation` per pod: `targetType: 'pod'`, `installSource: 'ui'`, `installedBy` its creator (`InstallableInstallation.ts:222`). The live-target index allows one per playbook per pod (`:272–282`). It starts `paused` (Off); `active` is On. `components[].config` (`:191`) holds the role bindings, each point's approver, the timezone, and `onBy`/`onAt`; `onBy` is whoever turned it on, owns its runs, and is the default approver. `playbookService` writes these rows itself. The install service's projection stays connector-only, and the connector install route refuses an Installable that carries a playbook. + +**The reconciler fix.** The active sweep checks only installations that projected an Integration, that is, with a component whose `projectionIds` holds `integrationId` (`webhookProjector.ts:66`, `eventHandlerProjector.ts:24`). A playbook projects none and is skipped. Playbooks do not reach the Connectors page, which lists provider manifests (`installableCatalogService.ts:25–27`). + +**The playbook.** + +``` +{ v: 1, + roles: [{ key, kind: 'agent' | 'person' }], + steps: [{ key, title, role, do, reads?: [toolName], delivers?: 'post' | 'pr', + approval?: { by: , act: 'kernel' | 'actor', + effect?: 'none' | 'post' | 'connector' | 'handoff' }, + writeGap?: }], + done: [{ says, check: 'done' | 'approved' | 'by-person' | 'has-pr', step }] } +``` + +Roles, not names; the team binds them at turn-on. The source document is kept as a pod skill (`podAssetService.ts:296`; the first 8,000 characters, `:321`). An edit is refused while a run is open, and the panel offers Stop. + +**A run is board rows.** A trigger creates a run row (`source: 'playbook'`, `sourceRef: 'playbook::'`, `guard: 'kernel'`). Step rows follow one at a time as its children (`parentTask`), each with `sourceRef` `…::` and a fixed title, assigned to the role's member. Every row has its own ref, so under both the pair index (`Task.ts:111–118`) and the legacy ref-only index a second insert fails and reads the winner. The runner inserts directly, never through the create route, which would reopen a done step (fact 5). Only the insert's winner sends a wake; the board gets the socket update, not the ambient agent notice. + +**Three Task fields.** `ruling: {state: 'awaiting' | 'approved' | 'declined', approvalId, decidedBy?, decidedAt?}`, with a sparse index on `ruling.approvalId`; `guard: 'kernel' | 'person'`; and `completedBy`, set by every complete (today the completer is only in an update's text, `tasksApi.ts:637`). + +**Guards, in one place.** The claim CAS (`tasksApi.ts:585–593`) and the complete CAS (`:640–645`) move into shared functions that the routes and the runner both call. Each filter adds three conditions: `ruling` absent or `approved`, so a declined row's `blocked` status cannot be claimed back; a `guard: 'kernel'` row refuses every API caller; a `guard: 'person'` row refuses a bot. A ruling refusal is a 409 `awaiting_ruling` naming the card; a guard refusal names the guard. On a playbook row, PATCH (`:878`) takes only `notes`, `prUrl` and `status: 'blocked'`. `notifyPodAgents` (`taskEventService.ts:156`) and `notifyFoundWork` (`:343`) skip `source: 'playbook'`. + +**Who acts.** + +- A BYO seat gets an addressed wake shaped like the lease warning (`taskEventService.ts:447`, enqueued at `:491–505`): `type: 'message.posted'`, `triggerAuthor: 'kernel'`, `payload.playbookStep`. It names the step, quotes the input and says how to finish; the seat claims, notes and completes with its own task tools. +- For an in-process agent (`runsInNarrowedProcess`, `hostedEngine.ts:93`) the runner claims through the shared function and sends the same event. `NativeRunResult` (`nativeRuntimeService.ts:68–76`) gains `postedMessageIds`, covering tool posts and the fallback post (`:2151–2172`). When `dispatchInProcess` (`agentEventService.ts:1333–1372`) settles a run for an event with `playbookStep`, it passes them to the runner, which completes the row as the agent with the last post linked. No post: one retry, then `blocked`. +- A person's step is a `guard: 'person'` row, completed on the board. + +`onStepDone` runs from the shared complete, a kernel act (§3) and the native settle. It inserts the next step or, after the last, evaluates the done check (§4). The runner's reconciler rides the kernel sweep (`schedulerService.ts:459`, every ten minutes): it inserts a missing next step, applies a decided card whose row still says `awaiting`, re-wakes a BYO step left unclaimed for 30 minutes once and then notes it, retries or blocks a stuck native step, and sets the run row from its steps and done check. No agent ever moves a run row. + +## 2. Triggers, and what the UI says about each + +Each label shows on the playbook, its run rows and each step row. + +- **Run** (always): a button and an input box. Label: "Runs when someone presses Run". +- **Schedule:** yes, the runner adds a time trigger (`scheduled-job`). Heartbeats run every 1–1440 minutes with no time of day, so they cannot carry one. The vocabulary is daily HH:MM, weekdays HH:MM and weekly DAY HH:MM in the turn-on timezone, stored as `M H * * *`, `M H * * 1-5` and `M H * * D`; save refuses any other cron. Every minute a job finds each On playbook's latest slot at or before now, in local wall time, and starts a run if the slot is after `onAt`, under an hour old, and has no run yet. It needs no lease, because the run row's ref makes a duplicate a no-op (`schedulerService.ts:472–477` argues the same for the reconcile job). The ref key is the local date and time, so a repeated hour runs once and a skipped hour runs at the first instant after it; `Intl` is enough, with no new dependency. A slot missed by an hour or more is not backfilled, and the panel shows the last and next run. Label: "Scheduled · Mondays 09:00 (America/Los_Angeles) · not instant". +- **Push** (slice 4): a person's message in the pod, from a hook after `deliverMessageToAgents`. A Slack or Telegram message counts once a person has linked it, and arrives as that person (`slackBridgeService.ts:504`, `telegramBridgeService.ts:610`). Discord activity is buffered into summaries (`discordGatewayService.ts:95`, `discordService.ts:749–753`) and never reaches that seam, so it starts no run. An agent's message never fires a push. An optional phrase filter narrows it; without one, the panel says every message starts a run. The message reaches step 1 as quoted input, labelled with its source. GitHub issue → card waits on the GitHub activity work (#2383, open), and the trigger picker says so. Sentry, Linear, Airtable, Google Calendar and Atlassian have no push; they are read on a schedule. Label: "Runs on each message". +- **Interval check** (slice 4): every 15, 30 or 60 minutes. The first step is an in-process agent woken before any row exists; only its post creates the run. Label: "Checks every 30 min · not instant". + +Meeting follow-ups and call prep run from Run with pasted notes; with a calendar grant in the pod they can move to a daily schedule whose first step reads the calendar. The weekly pipeline review uses the weekly form. + +## 3. Approval points + +The CTO's decision, applied to the code. + +**The card.** A step with a point is inserted with `ruling.state: 'awaiting'`, and the guards refuse its claim and complete, for every agent type, until a person rules. One PR adds a type, `playbook_step`, to the `ApprovalAction` enum, `KNOWN_ACTION_TYPES`, `validateParams` and `executeAction`, and fixes the comment at `ApprovalAction.ts:29–32`. A card needs an agent (`ApprovalAction.ts:93`); it is attributed to the agent whose work the point follows, else the first agent role, so a playbook with points needs an agent role. `params` carries the playbook, run and step, the output link and excerpt, and the effect. The owner is the bound approver through `requestedOwnerUserId`: the derivation at `approvalActionService.ts:444–446` widens to `playbook_step`, and pre-flight (§7) checks the approver is a human member. `V2ApprovalCard`'s `describeAction` (`:65`) gains the type, with its keys in both locale files. + +**Deciding.** `resolveApproval` (`approvalActionService.ts:781`) calls `applyRuling` on approve, through `executeAction`, and on decline, beside the parked-call decline (`:937`). `applyRuling` is a CAS on the row's `approvalId`, `state: 'awaiting'` and an open run. Approve records `decidedBy` and `decidedAt`. Decline blocks the row and the run and tells the run's owner; "Ask again" raises a new card. Expiry stays advisory, as for every non-tool card (ADR-020:71–76; `approvalActionService.ts:859–862`). + +**Two ways a point binds.** + +- `act: 'kernel'`, labelled "Runs when approved". The step is `guard: 'kernel'`. The kernel performs the effect with the ruling id on its receipt and completes the row, so the person is asked once. The effects: + - `none`; + - `post`: the exact text, posted as the step's agent; + - `connector`: a broker call as the step's agent, with JSON args from the prior post checked against the pin's `inputSchema`. Here the point's card is the existing `tool_call` card, parked for the connection's owner, and pre-flight makes that owner the point's approver. `executeApprovedToolCall` (`:959`) runs it; its expiry is terminal (ADR-017:207) and counts as a decline; + - `handoff` (§5). +- `act: 'actor'`, labelled "Enforced on the record, not on the action". After Approve the row opens to its actor, and the done check refuses a run whose pointed step lacks an approved ruling. + +There are no implicit points: native turns get connector reads only, and a BYO seat holds no grant. Send-back (return the step to its actor with a note) comes later. + +## 4. The done check + +The done list maps statements onto record facts: a step is done, is approved, was completed by a person (`completedBy` is not a bot), or has a `prUrl`. A native `post` step is done only through a post. A statement about the outside world (the issue is closed, the customer replied) becomes a person step. The kernel evaluates after the last step and on each reconciler pass: pass → `done`; fail → `blocked`, naming the statement. Every step with a point needs an approved ruling whatever the list says (the CTO's record rule). Scout drafts the list and the team edits it. No agent closes a run. A model-judged check, advisory only, could come later. + +## 5. A connector and a BYO agent in one playbook + +§12 of `hosted-mcp-connection-scope.md` admits a grant only into a private, invite-only, unbridged pod whose every agent runs in the narrowed process (`outsiderGrantPodEligibility.ts:41–80`, refusals at `:8–17`), and every hosted source counts as outsider-writable today. So: + +- A bridged pod can hold its bridge, people, BYO seats, native agents without a grant, and the GitHub App, which is not a bridge (`podIsBridged`, `:26–30`). It cannot hold a grant (`pod_bridged`). +- A grant pod holds the grant, in-process agents and people. It cannot hold a bridge or a BYO seat (`non_native_agent`), and a BYO seat cannot join it while the grant is live (`outsiderGrantAgentJoinPolicy.ts:24–29`). + +Two agents in one pod therefore does not work. A mixed playbook is two pods and two playbooks, joined by a `handoff` point: on Approve the kernel starts the other pod's playbook with the approved text as input, which equals pasting it into that pod's Run box. The approver must be a human member of both pods, and the target must be On. Whether a confined BYO seat may later share a grant pod is §12's question, not this note's. + +- **Support.** An intake pod: a person's message starts it, typed in the pod or arriving from their linked Slack or Telegram. A native agent drafts a reply from the docs skill; a "Send this reply" point (`post`) posts it, and the pod's bridge relays it. A "File a ticket" point (`handoff`) passes the case to a tickets pod holding an Atlassian grant at write-with-confirm and a native agent, which drafts the fields; a point with a `connector` effect then parks `create_jira_issue` for the connection's owner. +- **Incident.** An incidents pod with a Sentry grant at read and an interval check (slice 4). A native agent triages, and a `handoff` point passes the case to the fix pod, which runs bug to merged fix (§8). + +## 6. Write gaps + +Linear and Airtable are read-only: Linear's credential requests no write scope (`linear.ts:92–93`), and Airtable pins read tools only, under `:read` scopes (`airtable.ts:9`). Atlassian and Sentry pin write tools, and a write needs a write-with-confirm grant (`hostedMcpEntryService.ts:136–137`). A step that needs a missing write becomes two: an agent step that drafts the record, and a person step labelled "Drafted here; a person files it in Airtable (writes not connected)". Pre-flight refuses a write tool the entry does not pin. When the writes ship, the person step becomes a point with a `connector` effect. + +## 7. Scout: from a dropped document to a draft + +1. **Input.** A text-like file or pasted text, wherever Scout is: the person's Scout room, or a pod with no live grant. Scout reads a file with a new `commonly_read_file` that shares one reader with the route (`agentsRuntime.ts:1850`). For a PDF or Word file, Scout asks for a Markdown export. +2. **What the pod can do.** A read-only `commonly_playbook_options({podId})`, answered only for pods the asking person belongs to, returns the trigger kinds, the schedule vocabulary, members by kind and in-process status, live grants with tool names and read/write class, and §12 status. +3. **The draft.** Roles, not names; write gaps and waits (#2383, a calendar grant) marked; "done" mapped onto record facts. Scout proposes it through `propose_action` with a new `actionType: 'create_playbook'` (enum at `nativeRuntimeService.ts:333`). The kernel validates at proposal and returns a refusal to Scout. The card shows the draft, its labels and gaps, and the target pod; its approver must be a human member of that pod. Scout may propose `create_pod` first. +4. **Approve.** The kernel re-validates, creates the Installable and an Off installation, keeps the document as a pod skill, and posts a link to the pod's Playbooks panel. +5. **Edit and turn on.** The team binds roles, approvers and the timezone, edits, and a human member turns it On. + +Pre-flight runs at turn-on and at every run start. It refuses a role bound to a non-member or to the wrong kind; an approver who is not a human member; a named tool not granted in the pod; a pod failing §12 where a step uses a grant; a write given to a native agent; and an invalid trigger. Seeds are `builtin` playbooks in a gallery, and "Use this" copies one, Off. The routes are human-only, under `/api/pods/:podId/playbooks`, and membership-gated. + +## 8. Two seed playbooks, mapped + +**Bug to merged fix.** One pod, BYO seats, no grant. + +- Trigger: Run, with the issue link. The picker shows "Issue → card waits on #2383". +- Roles: `fixer` and `reviewer` (agents from different vendors), and `approver` (a person). +- Steps: (1) Fix: claim, note, open the PR, complete with `prUrl`. (2) Review: iterate in the PR thread; complete on a pass. (3) Merge: a point for `approver`, `act: 'actor'`, `guard: 'person'`. The approver merges by hand, closes the issue and completes the row, labelled "Enforced on the record, not on the action". +- Done: steps 1–3 done; 1 has a `prUrl`; 3 approved and completed by a person. + +**Weekly pipeline review.** One private, invite-only, unbridged pod holding a person, an in-process agent and the person's Airtable grant at read. + +- Trigger: weekly, Mondays 09:00, in the owner's timezone. +- Steps: (1) Read `list_records_for_table`, count deals by stage, flag those untouched for 14 days (flag, never chase), and post a summary with at most 3 decide items; the post completes the step. (2) A point for the owner, `act: 'kernel'`, effect `none`; a card per stuck deal comes later. (3) A person step with the write-gap label: the owner files the agreed changes in Airtable. +- Done: 1 done; 2 approved; 3 completed by a person. + +## 9. Build order and named tests + +| Slice | Builds | Proves | +|---|---|---| +| 1 | Task fields and guards; shared claim and complete; PATCH limits; `playbookService` with validation and pre-flight; the runner (Run, rows, wakes, `onStepDone`, reconciler, done check); the `playbook_step` card, executor and `describeAction`; the reconciler skip; a minimal Playbooks panel | Bug to merged fix | +| 2 | The schedule trigger; the native-step path (`postedMessageIds`); the write-gap label | Weekly pipeline review | +| 3 | Scout's `commonly_read_file` and `commonly_playbook_options`; the `create_playbook` card and executor; the seed gallery; the editor | A dropped document becomes an Off playbook | +| 4 | Push; `post`, `connector` and `handoff` effects; interval checks; send-back | Support and incident layouts | + +TASK-275 is slices 1–3. Named tests: + +- the guards refuse while a ruling awaits or after a decline (including a claim on the blocked row), and pass after approve; +- an approve racing a complete never completes an unapproved step; +- a bot or a non-owner cannot rule; +- a double fire across replicas makes one run under both index shapes; +- `onStepDone` twice makes one next step and reopens nothing; +- a schedule fires once per slot in both DST directions, never an hour late and never before `onAt`; save refuses a cron outside the vocabulary; +- a native step completes from its post, by tool or by fallback; no post → one retry → `blocked`; +- the done check blocks a missing ruling, and a person step completed by a bot; +- no agent moves a run row; +- each pre-flight refusal; +- an agent's message never fires a push; +- the ambient and found-work notices skip playbook rows; +- an active playbook installation survives a reconciler pass; +- PATCH refusals on playbook rows; +- the connector install route refuses a playbook Installable. From 5bf527c31f92303c5623f0ead3ecaf051ef8d3c7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:46:38 -0700 Subject: [PATCH 2/8] docs(plans): pod playbooks trimmed to about 2,600 words, every MUST ANSWER item kept (TASK-274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row asks for one page. The note was 3,440 words, and most of the excess was an opening list of code facts restated later. Each fact now sits in the section that uses it, with its cite. - §2 gives each trigger its label: Run, Schedule, Push, the GitHub issue trigger and the interval check. The issue trigger is off until githubGig.fromSecret is set. - §1 names #2383's merge hook, which completes an open row by prUrl past every guard, and slice 1 adds a playbook skip with a named test. - Every cite is re-read at main 14cc3e511. About 20 had drifted since a44787fca: the task routes, the task wakes, the native tool list and run result, the scheduler jobs and the approval service. - §2: the chat.message handler dispatch fires only for agent posts, so push hooks a person's message after deliverMessageToAgents. - §6: Atlassian and Sentry writes are write gaps in slices 1-3 too, because a native turn is offered only a grant's read tools. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/pod-playbooks.md | 141 ++++++++++++------------------------ 1 file changed, 46 insertions(+), 95 deletions(-) diff --git a/docs/plans/pod-playbooks.md b/docs/plans/pod-playbooks.md index 70614631f..28e7ff37f 100644 --- a/docs/plans/pod-playbooks.md +++ b/docs/plans/pod-playbooks.md @@ -1,26 +1,14 @@ # Pod playbooks: a team's procedure as a pod workflow (TASK-274) -**Status:** design, 2026-10-22. **Gate:** Vera. **Build:** TASK-275 builds slices 1–3 of §9. **Decided before this note:** the approval model in §3 is the CTO's, as owner of the primitive; this note designs against it and does not reopen it. Code is read at `main` `a44787fc`. +**Status:** design, 2026-10-22; revised 2026-10-24. **Gate:** Vera. **Build:** TASK-275, slices 1–3 of §9. §3 builds on the CTO's approval model and does not reopen it. Code is read at `main` `14cc3e511`. -A playbook is a team's written procedure (an SOP, a runbook, a page exported from Notion) run as a pod workflow: a trigger, steps that agents and people do, approval points where a person rules, and a done check. Scout drafts one from a dropped document; the team edits it, binds who does what, and turns it on. Nothing here is a new runtime. A playbook is an Installable, a run is board rows, an approval point is an approval card bound to a row, and connector access stays the pod grant it already is. What this adds is a runner (the service that reads a playbook and moves its rows) and three fields on Task. - -**What the code says, before the design leans on it.** - -1. Nothing runs a `scheduled-job` or a playbook-shaped `event-handler`. Both are in the component union (`Installable.ts:68–69`, fields at `:182–187`), but the install service projects only `webhook` and `event-handler` (`services/installable/projectors/index.ts:5–8`) and throws `Unsupported component type` on the rest (`installableInstallationService.ts:387`). Its event-handler projector is a connector's: it refuses a handler outside the registry (`eventHandlerProjector.ts:14`) and needs the connector's webhook Integration row (`:22`), and the registry holds two chat relays (`eventHandlers.ts:33–34`). ADR-001 Phase 5 would ship both component types (ADR-001:159–161) and is paused (ADR-011:60). The runner is the playbook slice of that phase; the general projector stays paused. -2. Handler dispatch on `chat.message` fires for agent posts, to relay them out (`agentMessageService.ts:1900`). A person's message reaches agents through `deliverMessageToAgents` (`messageAgentDeliveryService.ts:56`). -3. Native agents cannot claim, note or complete a row: their task tools are `create_task` and `list_tasks` (`nativeRuntimeService.ts:283`, `:299`). In a pod with a live hosted grant, a native turn narrows to reading context and posting (`:523`, `:1735–1743`) plus the grant's read tools (`grantBrokerProjectionService.ts:180`). -4. The installable reconciler marks every `active` installation that has no Integration row with "This connector's channel is gone" (`installableReconciler.ts:80–112`), every five minutes (`schedulerService.ts:478`). A playbook installation has none. -5. Claiming a `blocked` row resumes it (`tasksApi.ts:592`). Creating a row whose `(sourceRef, title)` matches a done row reopens it (`tasksApi.ts:264–311`). -6. A `tool_call` card's decider is the person its proposer names; every other card falls to the agent's installer or the pod's creator (`approvalActionService.ts:444–446`), and only that owner, as a human, can rule (`:786–797`). Executors live in the same file (`executeAction`, `:1021`), not in the `services/approvalExecutors.ts` that `ApprovalAction.ts:29–32` names. -7. Scout has no file tool (`config/native-agents/scout.ts:242–254`), and the runtime file route returns text only for textual types (`agentsRuntime.ts:1850`). +A playbook is a team's written procedure run as a pod workflow: a trigger, steps that agents and people do, approval points where a person rules, and a done check. Scout drafts one from a dropped document; the team binds who does what and turns it on. A playbook is an Installable, a run is board rows, and an approval point is an approval card bound to a row. What is new is a runner, which moves a playbook's rows, and three fields on Task. ## 1. Where a playbook lives -**The record.** A playbook is an `Installable` with `kind: 'app'` and `scope: 'pod'`. Its `source` is `user` (drafted by Scout or a person), `template` (a copy of a seed, with `forkedFrom`) or `builtin` (the seeds). It always carries one `event-handler` component (`eventType: 'playbook.run'`, `eventHandler: 'internal:playbook.start'`) that holds the playbook in its component `metadata.playbook` (`Installable.ts:221`); Run and a handoff (§5) start it. A trigger adds a second component: an `event-handler` with `eventType: 'pod.message'` (push), or a `scheduled-job` with `cron` and `jobHandler: 'internal:playbook.start'` (schedule). The runner resolves `internal:playbook.*` itself, so `eventHandlers.ts` stays the relay registry. In v1 a `user` or `template` playbook has one installation; using it in a second pod copies it, so an edit never reaches another pod's runs. - -**The installation.** One `InstallableInstallation` per pod: `targetType: 'pod'`, `installSource: 'ui'`, `installedBy` its creator (`InstallableInstallation.ts:222`). The live-target index allows one per playbook per pod (`:272–282`). It starts `paused` (Off); `active` is On. `components[].config` (`:191`) holds the role bindings, each point's approver, the timezone, and `onBy`/`onAt`; `onBy` is whoever turned it on, owns its runs, and is the default approver. `playbookService` writes these rows itself. The install service's projection stays connector-only, and the connector install route refuses an Installable that carries a playbook. +**The record.** An `Installable` (`kind: 'app'`, `scope: 'pod'`, `source` `user`, `template` or `builtin`) whose `event-handler` component (`eventHandler: 'internal:playbook.start'`) holds the playbook in `metadata.playbook` (`Installable.ts:221`); a schedule adds a `scheduled-job`. Nothing projects either for a playbook (`services/installable/projectors/index.ts:5–8`, `eventHandlerProjector.ts:22`; ADR-001 Phase 5 is paused, ADR-011:60), so the runner resolves `internal:playbook.*` itself. Each pod runs its own copy. -**The reconciler fix.** The active sweep checks only installations that projected an Integration, that is, with a component whose `projectionIds` holds `integrationId` (`webhookProjector.ts:66`, `eventHandlerProjector.ts:24`). A playbook projects none and is skipped. Playbooks do not reach the Connectors page, which lists provider manifests (`installableCatalogService.ts:25–27`). +**The installation.** One `InstallableInstallation` per pod, unique under the live-target index (`InstallableInstallation.ts:272–281`), `paused` while Off and `active` while On. Its `components[].config` (`:191`) holds role bindings, approvers, the timezone and `onBy`/`onAt`; whoever turns it on owns its runs. Only `playbookService` writes these rows; the connector install route refuses a playbook. Every five minutes (`schedulerService.ts:497–498`) the installable reconciler marks an `active` installation with no Integration row as a dead channel (`installableReconciler.ts:80–112`), so it must check only installations with an `integrationId` projection (`webhookProjector.ts:66`, `eventHandlerProjector.ts:24`). **The playbook.** @@ -34,120 +22,83 @@ A playbook is a team's written procedure (an SOP, a runbook, a page exported fro done: [{ says, check: 'done' | 'approved' | 'by-person' | 'has-pr', step }] } ``` -Roles, not names; the team binds them at turn-on. The source document is kept as a pod skill (`podAssetService.ts:296`; the first 8,000 characters, `:321`). An edit is refused while a run is open, and the panel offers Stop. +Roles, not names; the team binds them at turn-on. The source document is kept as a pod skill (`podAssetService.ts:301`), which stores its first 8,000 characters (`:326`). An edit is refused while a run is open. -**A run is board rows.** A trigger creates a run row (`source: 'playbook'`, `sourceRef: 'playbook::'`, `guard: 'kernel'`). Step rows follow one at a time as its children (`parentTask`), each with `sourceRef` `…::` and a fixed title, assigned to the role's member. Every row has its own ref, so under both the pair index (`Task.ts:111–118`) and the legacy ref-only index a second insert fails and reads the winner. The runner inserts directly, never through the create route, which would reopen a done step (fact 5). Only the insert's winner sends a wake; the board gets the socket update, not the ambient agent notice. +**A run is board rows.** A trigger inserts a run row (`source: 'playbook'`, `sourceRef: 'playbook::'`, `guard: 'kernel'`), then one row per step in turn, ref `…::`, assigned to the role's member. Refs and titles are fixed, so a duplicate insert fails under both the pair index (`Task.ts:126–133`) and the legacy ref-only index an unmigrated database keeps (`:121–125`), and only the winner wakes anyone. The runner never inserts through the create route, which reopens a done row with the same `(sourceRef, title)` (`tasksApi.ts:340–387`). -**Three Task fields.** `ruling: {state: 'awaiting' | 'approved' | 'declined', approvalId, decidedBy?, decidedAt?}`, with a sparse index on `ruling.approvalId`; `guard: 'kernel' | 'person'`; and `completedBy`, set by every complete (today the completer is only in an update's text, `tasksApi.ts:637`). +**Three Task fields.** `ruling: {state: 'awaiting' | 'approved' | 'declined', approvalId, decidedBy?, decidedAt?}`, sparse-indexed on `ruling.approvalId`; `guard: 'kernel' | 'person'`; and `completedBy`, set by every complete (today the completer is only in an `updates` entry, `tasksApi.ts:714–716`). -**Guards, in one place.** The claim CAS (`tasksApi.ts:585–593`) and the complete CAS (`:640–645`) move into shared functions that the routes and the runner both call. Each filter adds three conditions: `ruling` absent or `approved`, so a declined row's `blocked` status cannot be claimed back; a `guard: 'kernel'` row refuses every API caller; a `guard: 'person'` row refuses a bot. A ruling refusal is a 409 `awaiting_ruling` naming the card; a guard refusal names the guard. On a playbook row, PATCH (`:878`) takes only `notes`, `prUrl` and `status: 'blocked'`. `notifyPodAgents` (`taskEventService.ts:156`) and `notifyFoundWork` (`:343`) skip `source: 'playbook'`. +**Guards, in one place.** The claim CAS (`tasksApi.ts:661–669`) and the complete CAS (`:717–722`) move into shared functions that the routes and the runner call, each adding three conditions: `ruling` absent or `approved`, because claiming a `blocked` row resumes it (`:668`) and a declined row must stay blocked; `guard: 'kernel'` refuses every API caller; `guard: 'person'` refuses a bot. A refusal is a 409 naming the card (`awaiting_ruling`) or the guard. On a playbook row PATCH (`:997`) takes only `notes`, `prUrl` and `status: 'blocked'`, and `notifyPodAgents` (`taskEventService.ts:221`) and `notifyFoundWork` (`:548`) skip it. One writer completes rows outside both routes: on a merged PR from a bound repo, #2383 marks an open row in the pod with that `prUrl` done, with no ruling, guard or completer (`githubGigService.ts:80–90`). Its filter gains `source: {$ne: 'playbook'}`. -**Who acts.** +**Who acts.** A BYO seat gets an addressed wake shaped like the lease warning (`taskEventService.ts:725`, `:756–783`) and completes with its own task tools. A native agent's only task tools are create and list (`nativeRuntimeService.ts:289`, `:306`), so for an in-process agent the runner claims, and when `dispatchInProcess` settles (`agentEventService.ts:1496–1557`) completes the row from the agent's posts, through a new `NativeRunResult.postedMessageIds` (`nativeRuntimeService.ts:74–82`) that counts the fallback post (`:2264–2285`). No post means one retry, then `blocked`. A person's step is a `guard: 'person'` row, completed on the board. -- A BYO seat gets an addressed wake shaped like the lease warning (`taskEventService.ts:447`, enqueued at `:491–505`): `type: 'message.posted'`, `triggerAuthor: 'kernel'`, `payload.playbookStep`. It names the step, quotes the input and says how to finish; the seat claims, notes and completes with its own task tools. -- For an in-process agent (`runsInNarrowedProcess`, `hostedEngine.ts:93`) the runner claims through the shared function and sends the same event. `NativeRunResult` (`nativeRuntimeService.ts:68–76`) gains `postedMessageIds`, covering tool posts and the fallback post (`:2151–2172`). When `dispatchInProcess` (`agentEventService.ts:1333–1372`) settles a run for an event with `playbookStep`, it passes them to the runner, which completes the row as the agent with the last post linked. No post: one retry, then `blocked`. -- A person's step is a `guard: 'person'` row, completed on the board. +**Moving a run.** `onStepDone`, called from the shared complete, a kernel act and the native settle, inserts the next step or runs the done check. On the ten-minute sweep (`schedulerService.ts:461–462`) the runner's reconciler inserts a missing next step, applies a decided card whose row still says `awaiting`, re-wakes an unclaimed BYO step once after 30 minutes, retries or blocks a stuck native step, and sets the run row. No agent moves a run row. -`onStepDone` runs from the shared complete, a kernel act (§3) and the native settle. It inserts the next step or, after the last, evaluates the done check (§4). The runner's reconciler rides the kernel sweep (`schedulerService.ts:459`, every ten minutes): it inserts a missing next step, applies a decided card whose row still says `awaiting`, re-wakes a BYO step left unclaimed for 30 minutes once and then notes it, retries or blocks a stuck native step, and sets the run row from its steps and done check. No agent ever moves a run row. +**Pre-flight**, at turn-on and every run start, refuses a role bound to a non-member or the wrong kind, an approver who is not a human member, a tool not granted in the pod or not pinned by its entry, a grant step in a pod failing §5's rule, a write given to a native agent, and an invalid trigger. The routes are human-only and membership-gated, under `/api/pods/:podId/playbooks`. -## 2. Triggers, and what the UI says about each +## 2. Triggers, and the label each shows Each label shows on the playbook, its run rows and each step row. -- **Run** (always): a button and an input box. Label: "Runs when someone presses Run". -- **Schedule:** yes, the runner adds a time trigger (`scheduled-job`). Heartbeats run every 1–1440 minutes with no time of day, so they cannot carry one. The vocabulary is daily HH:MM, weekdays HH:MM and weekly DAY HH:MM in the turn-on timezone, stored as `M H * * *`, `M H * * 1-5` and `M H * * D`; save refuses any other cron. Every minute a job finds each On playbook's latest slot at or before now, in local wall time, and starts a run if the slot is after `onAt`, under an hour old, and has no run yet. It needs no lease, because the run row's ref makes a duplicate a no-op (`schedulerService.ts:472–477` argues the same for the reconcile job). The ref key is the local date and time, so a repeated hour runs once and a skipped hour runs at the first instant after it; `Intl` is enough, with no new dependency. A slot missed by an hour or more is not backfilled, and the panel shows the last and next run. Label: "Scheduled · Mondays 09:00 (America/Los_Angeles) · not instant". -- **Push** (slice 4): a person's message in the pod, from a hook after `deliverMessageToAgents`. A Slack or Telegram message counts once a person has linked it, and arrives as that person (`slackBridgeService.ts:504`, `telegramBridgeService.ts:610`). Discord activity is buffered into summaries (`discordGatewayService.ts:95`, `discordService.ts:749–753`) and never reaches that seam, so it starts no run. An agent's message never fires a push. An optional phrase filter narrows it; without one, the panel says every message starts a run. The message reaches step 1 as quoted input, labelled with its source. GitHub issue → card waits on the GitHub activity work (#2383, open), and the trigger picker says so. Sentry, Linear, Airtable, Google Calendar and Atlassian have no push; they are read on a schedule. Label: "Runs on each message". -- **Interval check** (slice 4): every 15, 30 or 60 minutes. The first step is an in-process agent woken before any row exists; only its post creates the run. Label: "Checks every 30 min · not instant". - -Meeting follow-ups and call prep run from Run with pasted notes; with a calendar grant in the pod they can move to a daily schedule whose first step reads the calendar. The weekly pipeline review uses the weekly form. +- **Run**, always: a button with an input box. "Runs when someone presses Run". +- **Schedule** (slice 2): a `scheduled-job`, because heartbeats have no time of day. Daily, weekday or weekly at HH:MM in the turn-on timezone, stored as `M H * * *`, `M H * * 1-5` or `M H * * D`; save refuses any other cron. Each minute a job starts a run for each On playbook's latest local slot that is after `onAt`, under an hour old and not yet run. The run row's ref carries the local date and time, so a repeated hour runs once and a skipped hour runs at the first instant after it. "Scheduled · Mondays 09:00 (America/Los_Angeles) · not instant". +- **Push** (slice 4) hooks a person's message, after `deliverMessageToAgents` (`messageAgentDeliveryService.ts:71`), and not the `chat.message` handler dispatch, which fires only for agent posts, to relay them out (`agentMessageService.ts:1926`). A linked person's Slack or Telegram message reaches that hook as the person (`slackBridgeService.ts:504`, `telegramBridgeService.ts:610`); a Discord message is buffered for a summary (`discordGatewayService.ts:159–191`, `discordService.ts:749–753`), so it starts no run. "Runs on each message". #2383's GitHub card is an agent post (`githubGigService.ts:103–113`), so the issue trigger hooks `handleGithubGig` (`:92`) on an opened issue: "Runs when an issue is opened in owner/repo". It is off until `githubGig.fromSecret` is set (`values.yaml:289–290`, no dev override; `routes/webhooks/github.ts:49`). +- **Interval check** (slice 4): every 15, 30 or 60 minutes an in-process agent is woken before any row exists, and only its post creates the run. A hosted connector has no inbound path, so this or a schedule is how its data starts a run. "Checks every 30 min · not instant". ## 3. Approval points -The CTO's decision, applied to the code. - -**The card.** A step with a point is inserted with `ruling.state: 'awaiting'`, and the guards refuse its claim and complete, for every agent type, until a person rules. One PR adds a type, `playbook_step`, to the `ApprovalAction` enum, `KNOWN_ACTION_TYPES`, `validateParams` and `executeAction`, and fixes the comment at `ApprovalAction.ts:29–32`. A card needs an agent (`ApprovalAction.ts:93`); it is attributed to the agent whose work the point follows, else the first agent role, so a playbook with points needs an agent role. `params` carries the playbook, run and step, the output link and excerpt, and the effect. The owner is the bound approver through `requestedOwnerUserId`: the derivation at `approvalActionService.ts:444–446` widens to `playbook_step`, and pre-flight (§7) checks the approver is a human member. `V2ApprovalCard`'s `describeAction` (`:65`) gains the type, with its keys in both locale files. +A `tool_call` card's decider is the person its proposer names; every other card falls to the agent's installer or the pod's creator (`approvalActionService.ts:444–446`), and only that person, as a human, can rule (`:796–807`). A new `playbook_step` type joins the `tool_call` side through `requestedOwnerUserId`, so the bound approver rules. -**Deciding.** `resolveApproval` (`approvalActionService.ts:781`) calls `applyRuling` on approve, through `executeAction`, and on decline, beside the parked-call decline (`:937`). `applyRuling` is a CAS on the row's `approvalId`, `state: 'awaiting'` and an open run. Approve records `decidedBy` and `decidedAt`. Decline blocks the row and the run and tells the run's owner; "Ask again" raises a new card. Expiry stays advisory, as for every non-tool card (ADR-020:71–76; `approvalActionService.ts:859–862`). +**The card.** A pointed step is inserted with `ruling.state: 'awaiting'`, and the guards refuse its claim and complete until a person rules. `playbook_step` joins the `ApprovalAction` enum, `KNOWN_ACTION_TYPES`, `validateParams` and `executeAction` (`approvalActionService.ts:34`, `:155`, `:1030`). A card needs an agent (`ApprovalAction.ts:93`): the one whose work the point follows, else the first agent role. `describeAction` (`V2ApprovalCard.tsx:65`) renders the run, the step, the output's link and excerpt, and the effect. -**Two ways a point binds.** +**Deciding.** `resolveApproval` (`approvalActionService.ts:790`) calls `applyRuling` on approve, through `executeAction`, and on decline, beside the parked-call decline (`:945–950`). It is a CAS on the row's `approvalId`, `state: 'awaiting'` and an open run, and records `decidedBy` and `decidedAt`. Decline blocks the row and the run and tells the run's owner; "Ask again" raises a new card. Expiry stays advisory, as for every non-tool card (ADR-020:71–76). -- `act: 'kernel'`, labelled "Runs when approved". The step is `guard: 'kernel'`. The kernel performs the effect with the ruling id on its receipt and completes the row, so the person is asked once. The effects: - - `none`; - - `post`: the exact text, posted as the step's agent; - - `connector`: a broker call as the step's agent, with JSON args from the prior post checked against the pin's `inputSchema`. Here the point's card is the existing `tool_call` card, parked for the connection's owner, and pre-flight makes that owner the point's approver. `executeApprovedToolCall` (`:959`) runs it; its expiry is terminal (ADR-017:207) and counts as a decline; - - `handoff` (§5). -- `act: 'actor'`, labelled "Enforced on the record, not on the action". After Approve the row opens to its actor, and the done check refuses a run whose pointed step lacks an approved ruling. +**Two bindings.** `act: 'kernel'`, labelled "Runs when approved", makes the step `guard: 'kernel'`: the kernel performs the effect, with the ruling id on its receipt, and completes the row, so the person is asked once. Slices 1–3 use effect `none`. Slice 4 adds `post`, `handoff` (§5) and `connector`, which parks the existing `tool_call` card for the connection's owner; that card's expiry is terminal (ADR-017:207) and counts as a decline. `act: 'actor'`, labelled "Enforced on the record, not on the action", opens the row to its actor after Approve, and the done check refuses a run whose pointed step lacks an approved ruling. -There are no implicit points: native turns get connector reads only, and a BYO seat holds no grant. Send-back (return the step to its actor with a note) comes later. +No point is implicit, because no step's agent can write to a connector: in a grant pod a native turn can only read context, post (`nativeRuntimeService.ts:546`, `:613`, `:1999–2003`) and call the grant's read tools (`grantBrokerProjectionService.ts:180`), and no BYO seat holds a grant today (§5). ## 4. The done check -The done list maps statements onto record facts: a step is done, is approved, was completed by a person (`completedBy` is not a bot), or has a `prUrl`. A native `post` step is done only through a post. A statement about the outside world (the issue is closed, the customer replied) becomes a person step. The kernel evaluates after the last step and on each reconciler pass: pass → `done`; fail → `blocked`, naming the statement. Every step with a point needs an approved ruling whatever the list says (the CTO's record rule). Scout drafts the list and the team edits it. No agent closes a run. A model-judged check, advisory only, could come later. +Each statement maps onto a record fact: a step is done, is approved, was completed by a person (`completedBy` is not a bot), or has a `prUrl`. A native `post` step is done only through a post, and a statement about the outside world (the issue is closed) becomes a person step. The kernel evaluates after the last step and on each reconciler pass: pass → `done`; fail → `blocked`, naming the statement. Every pointed step needs an approved ruling, whatever the list says. No agent closes a run. ## 5. A connector and a BYO agent in one playbook -§12 of `hosted-mcp-connection-scope.md` admits a grant only into a private, invite-only, unbridged pod whose every agent runs in the narrowed process (`outsiderGrantPodEligibility.ts:41–80`, refusals at `:8–17`), and every hosted source counts as outsider-writable today. So: - -- A bridged pod can hold its bridge, people, BYO seats, native agents without a grant, and the GitHub App, which is not a bridge (`podIsBridged`, `:26–30`). It cannot hold a grant (`pod_bridged`). -- A grant pod holds the grant, in-process agents and people. It cannot hold a bridge or a BYO seat (`non_native_agent`), and a BYO seat cannot join it while the grant is live (`outsiderGrantAgentJoinPolicy.ts:24–29`). +Today they never share a pod, because a grant is admitted only into a private, invite-only, unbridged pod whose every agent runs in-process (`outsiderGrantPodEligibility.ts:41–84`). A bridged pod can hold people, BYO seats, native agents without a grant, and the GitHub App, which is not a bridge (`podIsBridged`, `:26–30`); it cannot hold a grant (`pod_bridged`). A grant pod holds the grant, in-process agents and people; it cannot hold a bridge or a BYO seat (`non_native_agent`), and a BYO seat cannot join it while the grant is live (`outsiderGrantAgentJoinPolicy.ts:24–29`). So a playbook that mixes them, like the seed catalog's P1 (a Slack question becomes a Jira ticket) and P2 (a Sentry error becomes a fix PR), is two pods and two playbooks joined by a `handoff` point (slice 4). Its approver is a human member of both, and Approve starts the other pod's On playbook with the approved text as its Run input. -Two agents in one pod therefore does not work. A mixed playbook is two pods and two playbooks, joined by a `handoff` point: on Approve the kernel starts the other pod's playbook with the approved text as input, which equals pasting it into that pod's Run box. The approver must be a human member of both pods, and the target must be On. Whether a confined BYO seat may later share a grant pod is §12's question, not this note's. - -- **Support.** An intake pod: a person's message starts it, typed in the pod or arriving from their linked Slack or Telegram. A native agent drafts a reply from the docs skill; a "Send this reply" point (`post`) posts it, and the pod's bridge relays it. A "File a ticket" point (`handoff`) passes the case to a tickets pod holding an Atlassian grant at write-with-confirm and a native agent, which drafts the fields; a point with a `connector` effect then parks `create_jira_issue` for the connection's owner. -- **Incident.** An incidents pod with a Sentry grant at read and an interval check (slice 4). A native agent triages, and a `handoff` point passes the case to the fix pod, which runs bug to merged fix (§8). +§12 of `hosted-mcp-connection-scope.md` proposes, unbuilt, that a seat placed through "on my computer" whose confinement the server can see may join a grant pod (TASK-213 B; the Connect page's bring-your-own path never qualifies), and that its post into another pod, or a DM opening a new room, parks as a card in the grant pod for whoever installed it there (TASK-214). Once both ship, such a seat can hold a role in that pod's own playbook. Its parked cards are §12's, not playbook points. ## 6. Write gaps -Linear and Airtable are read-only: Linear's credential requests no write scope (`linear.ts:92–93`), and Airtable pins read tools only, under `:read` scopes (`airtable.ts:9`). Atlassian and Sentry pin write tools, and a write needs a write-with-confirm grant (`hostedMcpEntryService.ts:136–137`). A step that needs a missing write becomes two: an agent step that drafts the record, and a person step labelled "Drafted here; a person files it in Airtable (writes not connected)". Pre-flight refuses a write tool the entry does not pin. When the writes ship, the person step becomes a point with a `connector` effect. - -## 7. Scout: from a dropped document to a draft - -1. **Input.** A text-like file or pasted text, wherever Scout is: the person's Scout room, or a pod with no live grant. Scout reads a file with a new `commonly_read_file` that shares one reader with the route (`agentsRuntime.ts:1850`). For a PDF or Word file, Scout asks for a Markdown export. -2. **What the pod can do.** A read-only `commonly_playbook_options({podId})`, answered only for pods the asking person belongs to, returns the trigger kinds, the schedule vocabulary, members by kind and in-process status, live grants with tool names and read/write class, and §12 status. -3. **The draft.** Roles, not names; write gaps and waits (#2383, a calendar grant) marked; "done" mapped onto record facts. Scout proposes it through `propose_action` with a new `actionType: 'create_playbook'` (enum at `nativeRuntimeService.ts:333`). The kernel validates at proposal and returns a refusal to Scout. The card shows the draft, its labels and gaps, and the target pod; its approver must be a human member of that pod. Scout may propose `create_pod` first. -4. **Approve.** The kernel re-validates, creates the Installable and an Off installation, keeps the document as a pod skill, and posts a link to the pod's Playbooks panel. -5. **Edit and turn on.** The team binds roles, approvers and the timezone, edits, and a human member turns it On. +Linear's credential requests no write scope (`linear.ts:92–93`) and Airtable pins read tools only (`airtable.ts:9`). Atlassian and Sentry writes need a write-with-confirm grant (`hostedMcpEntryService.ts:136–137`), and even then no native turn is offered them (§3), so all four are write gaps in slices 1–3. A step that needs a missing write becomes an agent step that drafts the record and a person step labelled "Drafted here; a person files it in Airtable (writes not connected)". It becomes a `connector` point in slice 4 for Atlassian and Sentry, and for Linear and Airtable once their writes ship. -Pre-flight runs at turn-on and at every run start. It refuses a role bound to a non-member or to the wrong kind; an approver who is not a human member; a named tool not granted in the pod; a pod failing §12 where a step uses a grant; a write given to a native agent; and an invalid trigger. Seeds are `builtin` playbooks in a gallery, and "Use this" copies one, Off. The routes are human-only, under `/api/pods/:podId/playbooks`, and membership-gated. +## 7. Scout: from a dropped document to a draft (slice 3) -## 8. Two seed playbooks, mapped +Scout has no file tool (`config/native-agents/scout.ts:254–266`). A new `commonly_read_file` shares the runtime file route's reader, which returns text only for textual types (`agentsRuntime.ts:1993–1994`), so for a PDF or Word file Scout asks for a Markdown export. A read-only `commonly_playbook_options({podId})`, answered only for the asker's pods, returns what the pod can do: triggers, members by kind, live grants with their tools' read/write class, and whether the pod meets §5's rule. Scout proposes the draft through `propose_action` with a new `actionType: 'create_playbook'` (`nativeRuntimeService.ts:340`), for a human member of the target pod to approve. The kernel validates it at proposal, returning any refusal to Scout, and again on Approve, then creates the Installable with an Off installation. The team binds it in the pod's Playbooks panel, and a human member turns it On. -**Bug to merged fix.** One pod, BYO seats, no grant. +## 8. Two seeds -- Trigger: Run, with the issue link. The picker shows "Issue → card waits on #2383". -- Roles: `fixer` and `reviewer` (agents from different vendors), and `approver` (a person). -- Steps: (1) Fix: claim, note, open the PR, complete with `prUrl`. (2) Review: iterate in the PR thread; complete on a pass. (3) Merge: a point for `approver`, `act: 'actor'`, `guard: 'person'`. The approver merges by hand, closes the issue and completes the row, labelled "Enforced on the record, not on the action". -- Done: steps 1–3 done; 1 has a `prUrl`; 3 approved and completed by a person. +Seeds are `builtin` playbooks in a gallery; "Use this" copies one into the pod, Off. -**Weekly pipeline review.** One private, invite-only, unbridged pod holding a person, an in-process agent and the person's Airtable grant at read. +**Bug to merged fix**, the catalog's P0 (slice 1): BYO seats, no grant, started by Run with the issue link; starting from the issue itself is §2's slice-4 trigger. `fixer` opens the PR and completes with `prUrl`; `reviewer`, an agent from another vendor, completes on a pass in the PR thread; `approver`, a person, holds an `actor` point on the `guard: 'person'` Merge step and merges by hand. Done: all three steps, step 1's `prUrl`, and step 3 approved and completed by a person. In a pod bound to the repo, the merge posts #2383's card, and the approver still completes the Merge step (§1). -- Trigger: weekly, Mondays 09:00, in the owner's timezone. -- Steps: (1) Read `list_records_for_table`, count deals by stage, flag those untouched for 14 days (flag, never chase), and post a summary with at most 3 decide items; the post completes the step. (2) A point for the owner, `act: 'kernel'`, effect `none`; a card per stuck deal comes later. (3) A person step with the write-gap label: the owner files the agreed changes in Airtable. -- Done: 1 done; 2 approved; 3 completed by a person. +**Weekly pipeline review**, the catalog's S4 (slice 2): a pod that meets §5's rule, with a person, an in-process agent and the person's Airtable grant at read, run Mondays 09:00 in the owner's timezone. The agent reads `list_records_for_table`, counts deals by stage, flags those untouched for 14 days and posts at most 3 decide items. A `kernel` point with effect `none` follows, then a write-gap person step in which the owner files the agreed changes. Done: step 1 done, step 2 approved, step 3 completed by a person. ## 9. Build order and named tests | Slice | Builds | Proves | |---|---|---| -| 1 | Task fields and guards; shared claim and complete; PATCH limits; `playbookService` with validation and pre-flight; the runner (Run, rows, wakes, `onStepDone`, reconciler, done check); the `playbook_step` card, executor and `describeAction`; the reconciler skip; a minimal Playbooks panel | Bug to merged fix | -| 2 | The schedule trigger; the native-step path (`postedMessageIds`); the write-gap label | Weekly pipeline review | -| 3 | Scout's `commonly_read_file` and `commonly_playbook_options`; the `create_playbook` card and executor; the seed gallery; the editor | A dropped document becomes an Off playbook | -| 4 | Push; `post`, `connector` and `handoff` effects; interval checks; send-back | Support and incident layouts | - -TASK-275 is slices 1–3. Named tests: - -- the guards refuse while a ruling awaits or after a decline (including a claim on the blocked row), and pass after approve; -- an approve racing a complete never completes an unapproved step; -- a bot or a non-owner cannot rule; -- a double fire across replicas makes one run under both index shapes; -- `onStepDone` twice makes one next step and reopens nothing; -- a schedule fires once per slot in both DST directions, never an hour late and never before `onAt`; save refuses a cron outside the vocabulary; -- a native step completes from its post, by tool or by fallback; no post → one retry → `blocked`; -- the done check blocks a missing ruling, and a person step completed by a bot; -- no agent moves a run row; -- each pre-flight refusal; -- an agent's message never fires a push; -- the ambient and found-work notices skip playbook rows; -- an active playbook installation survives a reconciler pass; -- PATCH refusals on playbook rows; -- the connector install route refuses a playbook Installable. +| 1 | Task fields and guards; shared claim and complete; PATCH limits; the merge hook's skip; `playbookService` with validation and pre-flight; the runner (Run, rows, wakes, `onStepDone`, reconciler, done check); the `playbook_step` card; the installable reconciler's skip; a minimal Playbooks panel | Bug to merged fix | +| 2 | Schedule; native steps (`postedMessageIds`); the write-gap label | Weekly pipeline review | +| 3 | `commonly_read_file` and `commonly_playbook_options`; the `create_playbook` card; the seed gallery; the editor | A dropped document becomes an Off playbook | +| 4 | Push; the GitHub issue trigger; interval checks; `post`, `connector` and `handoff` effects; send-back (a step returned to its actor with a note) | A mixed connector and BYO playbook | + +TASK-275 is slices 1–3, with these named tests: + +- The guards refuse an awaiting or declined ruling, a claim on the blocked row included, and pass after approve. An approve racing a complete never completes an unapproved step. A bot or a non-owner cannot rule. +- A double fire across replicas makes one run under both index shapes. `onStepDone` twice makes one next step and reopens nothing. A merged-PR delivery naming a playbook row's `prUrl` completes nothing. +- A schedule fires once per slot in both DST directions, never an hour late or before `onAt`, and save refuses any other cron. +- A native step completes from its post, by tool or fallback, and with no post retries once, then blocks. +- The done check blocks a missing ruling and a person step completed by a bot. No agent moves a run row. +- Each pre-flight refusal, each PATCH refusal on a playbook row and the connector install route's refusal of a playbook has a test. The ambient and found-work notices skip playbook rows. An active playbook installation survives an installable reconciler pass. + +Slice 4 adds one: an agent's message, #2383's card included, never fires a push. From d197192d6c7a8e468db36cdbdf3fea99099e9560 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:55:41 -0700 Subject: [PATCH 3/8] docs(plans): pod playbooks refuse a point with no agent role, and slice 1 narrows the reconciler (TASK-274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit found two gaps at 5bf527c31, and both hold at main 86c6dc1d1. - A card needs an agent (ApprovalAction.ts:93), so a playbook with no agent role can hold no point. Pre-flight now refuses one, and §9's pre-flight tests name it. - The installable reconciler marks every active installation with no Integration row as a dead channel (installableReconciler.ts:80-112). The note said it "must check only" projected installations, which read as current behaviour. It now says that today the sweep would catch a playbook that is On, and that slice 1 narrows it. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/pod-playbooks.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/pod-playbooks.md b/docs/plans/pod-playbooks.md index 28e7ff37f..64279cee2 100644 --- a/docs/plans/pod-playbooks.md +++ b/docs/plans/pod-playbooks.md @@ -8,7 +8,7 @@ A playbook is a team's written procedure run as a pod workflow: a trigger, steps **The record.** An `Installable` (`kind: 'app'`, `scope: 'pod'`, `source` `user`, `template` or `builtin`) whose `event-handler` component (`eventHandler: 'internal:playbook.start'`) holds the playbook in `metadata.playbook` (`Installable.ts:221`); a schedule adds a `scheduled-job`. Nothing projects either for a playbook (`services/installable/projectors/index.ts:5–8`, `eventHandlerProjector.ts:22`; ADR-001 Phase 5 is paused, ADR-011:60), so the runner resolves `internal:playbook.*` itself. Each pod runs its own copy. -**The installation.** One `InstallableInstallation` per pod, unique under the live-target index (`InstallableInstallation.ts:272–281`), `paused` while Off and `active` while On. Its `components[].config` (`:191`) holds role bindings, approvers, the timezone and `onBy`/`onAt`; whoever turns it on owns its runs. Only `playbookService` writes these rows; the connector install route refuses a playbook. Every five minutes (`schedulerService.ts:497–498`) the installable reconciler marks an `active` installation with no Integration row as a dead channel (`installableReconciler.ts:80–112`), so it must check only installations with an `integrationId` projection (`webhookProjector.ts:66`, `eventHandlerProjector.ts:24`). +**The installation.** One `InstallableInstallation` per pod, unique under the live-target index (`InstallableInstallation.ts:272–281`), `paused` while Off and `active` while On. Its `components[].config` (`:191`) holds role bindings, approvers, the timezone and `onBy`/`onAt`; whoever turns it on owns its runs. Only `playbookService` writes these rows; the connector install route refuses a playbook. Every five minutes (`schedulerService.ts:497–498`) the installable reconciler marks every `active` installation with no Integration row as a dead channel (`installableReconciler.ts:80–112`), and today that includes a playbook that is On. Slice 1 narrows the sweep to installations with an `integrationId` projection (`webhookProjector.ts:66`, `eventHandlerProjector.ts:24`). **The playbook.** @@ -34,7 +34,7 @@ Roles, not names; the team binds them at turn-on. The source document is kept as **Moving a run.** `onStepDone`, called from the shared complete, a kernel act and the native settle, inserts the next step or runs the done check. On the ten-minute sweep (`schedulerService.ts:461–462`) the runner's reconciler inserts a missing next step, applies a decided card whose row still says `awaiting`, re-wakes an unclaimed BYO step once after 30 minutes, retries or blocks a stuck native step, and sets the run row. No agent moves a run row. -**Pre-flight**, at turn-on and every run start, refuses a role bound to a non-member or the wrong kind, an approver who is not a human member, a tool not granted in the pod or not pinned by its entry, a grant step in a pod failing §5's rule, a write given to a native agent, and an invalid trigger. The routes are human-only and membership-gated, under `/api/pods/:podId/playbooks`. +**Pre-flight**, at turn-on and every run start, refuses a role bound to a non-member or the wrong kind, an approver who is not a human member, a tool not granted in the pod or not pinned by its entry, a grant step in a pod failing §5's rule, a write given to a native agent, a point in a playbook with no agent role (§3), and an invalid trigger. The routes are human-only and membership-gated, under `/api/pods/:podId/playbooks`. ## 2. Triggers, and the label each shows @@ -49,7 +49,7 @@ Each label shows on the playbook, its run rows and each step row. A `tool_call` card's decider is the person its proposer names; every other card falls to the agent's installer or the pod's creator (`approvalActionService.ts:444–446`), and only that person, as a human, can rule (`:796–807`). A new `playbook_step` type joins the `tool_call` side through `requestedOwnerUserId`, so the bound approver rules. -**The card.** A pointed step is inserted with `ruling.state: 'awaiting'`, and the guards refuse its claim and complete until a person rules. `playbook_step` joins the `ApprovalAction` enum, `KNOWN_ACTION_TYPES`, `validateParams` and `executeAction` (`approvalActionService.ts:34`, `:155`, `:1030`). A card needs an agent (`ApprovalAction.ts:93`): the one whose work the point follows, else the first agent role. `describeAction` (`V2ApprovalCard.tsx:65`) renders the run, the step, the output's link and excerpt, and the effect. +**The card.** A pointed step is inserted with `ruling.state: 'awaiting'`, and the guards refuse its claim and complete until a person rules. `playbook_step` joins the `ApprovalAction` enum, `KNOWN_ACTION_TYPES`, `validateParams` and `executeAction` (`approvalActionService.ts:34`, `:155`, `:1030`). A card needs an agent (`ApprovalAction.ts:93`): the one whose work the point follows, else the first agent role, so a playbook with no agent role can hold no point. `describeAction` (`V2ApprovalCard.tsx:65`) renders the run, the step, the output's link and excerpt, and the effect. **Deciding.** `resolveApproval` (`approvalActionService.ts:790`) calls `applyRuling` on approve, through `executeAction`, and on decline, beside the parked-call decline (`:945–950`). It is a CAS on the row's `approvalId`, `state: 'awaiting'` and an open run, and records `decidedBy` and `decidedAt`. Decline blocks the row and the run and tells the run's owner; "Ask again" raises a new card. Expiry stays advisory, as for every non-tool card (ADR-020:71–76). @@ -99,6 +99,6 @@ TASK-275 is slices 1–3, with these named tests: - A schedule fires once per slot in both DST directions, never an hour late or before `onAt`, and save refuses any other cron. - A native step completes from its post, by tool or fallback, and with no post retries once, then blocks. - The done check blocks a missing ruling and a person step completed by a bot. No agent moves a run row. -- Each pre-flight refusal, each PATCH refusal on a playbook row and the connector install route's refusal of a playbook has a test. The ambient and found-work notices skip playbook rows. An active playbook installation survives an installable reconciler pass. +- Each pre-flight refusal, a point in a playbook with no agent role among them, each PATCH refusal on a playbook row and the connector install route's refusal of a playbook has a test. The ambient and found-work notices skip playbook rows. An active playbook installation survives an installable reconciler pass. Slice 4 adds one: an agent's message, #2383's card included, never fires a push. From 65b1e7530058a2881e5befcc4063c2cbeecb1aee Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:13:14 -0700 Subject: [PATCH 4/8] docs(plans): only the runner writes playbook rows; the create route and role are guarded (TASK-274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vera's gate HOLD at d197192d6: the generic create route reopens a done row by (sourceRef, title) whatever its source (tasksApi.ts:334-387), and a caller can omit source entirely, so a replayed ref and title would reopen a finished run despite guard: 'kernel'. - §1: slice 1 makes the create route refuse source: 'playbook' and any playbook: ref from every caller, before its lookup, and never reopen a source: 'playbook' or guarded row. Refs and titles are predictable, so without the reservation a step's row inserted ahead of the runner would also win the pair index with no ruling on it. Every other ref keeps its idempotent retry and reopen. - §1: the same class one route over. Claim and complete never check the assignee (claimableConditions, tasksApi.ts:489-497; complete takes a pending row, :720), so the shared guards gain a fourth condition: on a playbook row an API caller must be its assignee. PATCH takes prUrl only from the assignee, and the lapse rescue, which clears the assignee (kernelWorkSweepService.ts:251-263), skips playbook rows; the reconciler re-wakes a lapsed BYO step instead. - §9 names both: the create-route replay regression, and the non-assignee claim/complete refusal. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/pod-playbooks.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/plans/pod-playbooks.md b/docs/plans/pod-playbooks.md index 64279cee2..0804faa39 100644 --- a/docs/plans/pod-playbooks.md +++ b/docs/plans/pod-playbooks.md @@ -24,15 +24,15 @@ A playbook is a team's written procedure run as a pod workflow: a trigger, steps Roles, not names; the team binds them at turn-on. The source document is kept as a pod skill (`podAssetService.ts:301`), which stores its first 8,000 characters (`:326`). An edit is refused while a run is open. -**A run is board rows.** A trigger inserts a run row (`source: 'playbook'`, `sourceRef: 'playbook::'`, `guard: 'kernel'`), then one row per step in turn, ref `…::`, assigned to the role's member. Refs and titles are fixed, so a duplicate insert fails under both the pair index (`Task.ts:126–133`) and the legacy ref-only index an unmigrated database keeps (`:121–125`), and only the winner wakes anyone. The runner never inserts through the create route, which reopens a done row with the same `(sourceRef, title)` (`tasksApi.ts:340–387`). +**A run is board rows.** A trigger inserts a run row (`source: 'playbook'`, `sourceRef: 'playbook::'`, `guard: 'kernel'`), then one row per step in turn, ref `…::`, assigned to the role's member. Refs and titles are fixed, so a duplicate insert fails under both the pair index (`Task.ts:126–133`) and the legacy ref-only index an unmigrated database keeps (`:121–125`), and only the winner wakes anyone. Only the runner may insert playbook rows. Today the create route (`tasksApi.ts:270`) takes any writer's `sourceRef` and a person's `source` (`:288–289`, `:401–403`) and reopens a done row with the same `(sourceRef, title)` whatever its source (`:334–387`), so replaying a finished run's ref and title would reopen it, and a step's row inserted ahead of the runner would win the index with no ruling on it. Slice 1 makes the route refuse `source: 'playbook'` and any `playbook:` ref from every caller, before its lookup, and never reopen a `source: 'playbook'` or guarded row. Every other ref keeps its idempotent retry and reopen. **Three Task fields.** `ruling: {state: 'awaiting' | 'approved' | 'declined', approvalId, decidedBy?, decidedAt?}`, sparse-indexed on `ruling.approvalId`; `guard: 'kernel' | 'person'`; and `completedBy`, set by every complete (today the completer is only in an `updates` entry, `tasksApi.ts:714–716`). -**Guards, in one place.** The claim CAS (`tasksApi.ts:661–669`) and the complete CAS (`:717–722`) move into shared functions that the routes and the runner call, each adding three conditions: `ruling` absent or `approved`, because claiming a `blocked` row resumes it (`:668`) and a declined row must stay blocked; `guard: 'kernel'` refuses every API caller; `guard: 'person'` refuses a bot. A refusal is a 409 naming the card (`awaiting_ruling`) or the guard. On a playbook row PATCH (`:997`) takes only `notes`, `prUrl` and `status: 'blocked'`, and `notifyPodAgents` (`taskEventService.ts:221`) and `notifyFoundWork` (`:548`) skip it. One writer completes rows outside both routes: on a merged PR from a bound repo, #2383 marks an open row in the pod with that `prUrl` done, with no ruling, guard or completer (`githubGigService.ts:80–90`). Its filter gains `source: {$ne: 'playbook'}`. +**Guards, in one place.** The claim CAS (`tasksApi.ts:661–669`) and the complete CAS (`:717–722`) move into shared functions that the routes and the runner call, each adding four conditions: `ruling` absent or `approved`, because claiming a `blocked` row resumes it (`:668`) and a declined row must stay blocked; `guard: 'kernel'` refuses every API caller; `guard: 'person'` refuses a bot; and on a playbook row an API caller must be its assignee, because today any member may claim a pending row (`:489–497`) or complete one unclaimed (`:720`). A refusal is a 409 naming the card (`awaiting_ruling`), the guard or the role. On a playbook row PATCH (`:997`) takes only `notes`, `status: 'blocked'` and, from its assignee, `prUrl`, and `notifyPodAgents` (`taskEventService.ts:221`), `notifyFoundWork` (`:548`) and the lapse rescue, which clears the assignee (`kernelWorkSweepService.ts:251–263`), skip it. One writer completes rows outside both routes: on a merged PR from a bound repo, #2383 marks an open row in the pod with that `prUrl` done, with no ruling, guard or completer (`githubGigService.ts:80–90`). Its filter gains `source: {$ne: 'playbook'}`. **Who acts.** A BYO seat gets an addressed wake shaped like the lease warning (`taskEventService.ts:725`, `:756–783`) and completes with its own task tools. A native agent's only task tools are create and list (`nativeRuntimeService.ts:289`, `:306`), so for an in-process agent the runner claims, and when `dispatchInProcess` settles (`agentEventService.ts:1496–1557`) completes the row from the agent's posts, through a new `NativeRunResult.postedMessageIds` (`nativeRuntimeService.ts:74–82`) that counts the fallback post (`:2264–2285`). No post means one retry, then `blocked`. A person's step is a `guard: 'person'` row, completed on the board. -**Moving a run.** `onStepDone`, called from the shared complete, a kernel act and the native settle, inserts the next step or runs the done check. On the ten-minute sweep (`schedulerService.ts:461–462`) the runner's reconciler inserts a missing next step, applies a decided card whose row still says `awaiting`, re-wakes an unclaimed BYO step once after 30 minutes, retries or blocks a stuck native step, and sets the run row. No agent moves a run row. +**Moving a run.** `onStepDone`, called from the shared complete, a kernel act and the native settle, inserts the next step or runs the done check. On the ten-minute sweep (`schedulerService.ts:461–462`) the runner's reconciler inserts a missing next step, applies a decided card whose row still says `awaiting`, re-wakes an unclaimed or lapsed BYO step once after 30 minutes, retries or blocks a stuck native step, and sets the run row. No agent moves a run row. **Pre-flight**, at turn-on and every run start, refuses a role bound to a non-member or the wrong kind, an approver who is not a human member, a tool not granted in the pod or not pinned by its entry, a grant step in a pod failing §5's rule, a write given to a native agent, a point in a playbook with no agent role (§3), and an invalid trigger. The routes are human-only and membership-gated, under `/api/pods/:podId/playbooks`. @@ -87,15 +87,16 @@ Seeds are `builtin` playbooks in a gallery; "Use this" copies one into the pod, | Slice | Builds | Proves | |---|---|---| -| 1 | Task fields and guards; shared claim and complete; PATCH limits; the merge hook's skip; `playbookService` with validation and pre-flight; the runner (Run, rows, wakes, `onStepDone`, reconciler, done check); the `playbook_step` card; the installable reconciler's skip; a minimal Playbooks panel | Bug to merged fix | +| 1 | Task fields and guards; shared claim and complete; PATCH limits; the create route's reservation; the merge hook's skip; `playbookService` with validation and pre-flight; the runner (Run, rows, wakes, `onStepDone`, reconciler, done check); the `playbook_step` card; the installable reconciler's skip; a minimal Playbooks panel | Bug to merged fix | | 2 | Schedule; native steps (`postedMessageIds`); the write-gap label | Weekly pipeline review | | 3 | `commonly_read_file` and `commonly_playbook_options`; the `create_playbook` card; the seed gallery; the editor | A dropped document becomes an Off playbook | | 4 | Push; the GitHub issue trigger; interval checks; `post`, `connector` and `handoff` effects; send-back (a step returned to its actor with a note) | A mixed connector and BYO playbook | TASK-275 is slices 1–3, with these named tests: -- The guards refuse an awaiting or declined ruling, a claim on the blocked row included, and pass after approve. An approve racing a complete never completes an unapproved step. A bot or a non-owner cannot rule. +- The guards refuse an awaiting or declined ruling, a claim on the blocked row included, and pass after approve. An approve racing a complete never completes an unapproved step. A bot or a non-owner cannot rule. A member who is not a playbook row's assignee, agent or person, can neither claim nor complete it, and the lapse rescue leaves the row and its assignee as they are. - A double fire across replicas makes one run under both index shapes. `onStepDone` twice makes one next step and reopens nothing. A merged-PR delivery naming a playbook row's `prUrl` completes nothing. +- Replaying a completed run row's exact ref and title through the create route leaves it done, with no write and no wake. The route refuses a `playbook:` ref and `source: 'playbook'` from a person and from an agent, and a non-playbook ref keeps its idempotent retry and its done-row reopen. - A schedule fires once per slot in both DST directions, never an hour late or before `onAt`, and save refuses any other cron. - A native step completes from its post, by tool or fallback, and with no post retries once, then blocks. - The done check blocks a missing ruling and a person step completed by a bot. No agent moves a run row. From 25f48ce9677668375688e5e4e044349b243f5c71 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:32:29 -0700 Subject: [PATCH 5/8] docs(plans): playbook rollout, the assigned-task wake skip, and a ref collision test (TASK-274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rollout, after the §9 table: no migration or backfill. The three Task fields are optional and the ruling.approvalId index is sparse and built at boot, since nothing turns autoIndex off (config/db.ts:6-8, Task.ts:121-122). The window is the rolling deploy, so playbooks ship behind a flag that is off by default and set only after slice 1 runs on every backend. - The assigned-task wake (assignedTaskWakeService.ts:57-65) joins the skip list: it would wake a seat on a step it may not claim yet. - The replay test gains the collision case: a playbook: ref that matches an existing non-playbook row is refused before the lookup, from a person and from an agent, and leaves that row as it was. - §5 and §8 name each procedure by what it does. - The ADR-017 cite covers the whole scope-boundary paragraph (207-212). Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/pod-playbooks.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/plans/pod-playbooks.md b/docs/plans/pod-playbooks.md index 0804faa39..1089635c0 100644 --- a/docs/plans/pod-playbooks.md +++ b/docs/plans/pod-playbooks.md @@ -28,7 +28,7 @@ Roles, not names; the team binds them at turn-on. The source document is kept as **Three Task fields.** `ruling: {state: 'awaiting' | 'approved' | 'declined', approvalId, decidedBy?, decidedAt?}`, sparse-indexed on `ruling.approvalId`; `guard: 'kernel' | 'person'`; and `completedBy`, set by every complete (today the completer is only in an `updates` entry, `tasksApi.ts:714–716`). -**Guards, in one place.** The claim CAS (`tasksApi.ts:661–669`) and the complete CAS (`:717–722`) move into shared functions that the routes and the runner call, each adding four conditions: `ruling` absent or `approved`, because claiming a `blocked` row resumes it (`:668`) and a declined row must stay blocked; `guard: 'kernel'` refuses every API caller; `guard: 'person'` refuses a bot; and on a playbook row an API caller must be its assignee, because today any member may claim a pending row (`:489–497`) or complete one unclaimed (`:720`). A refusal is a 409 naming the card (`awaiting_ruling`), the guard or the role. On a playbook row PATCH (`:997`) takes only `notes`, `status: 'blocked'` and, from its assignee, `prUrl`, and `notifyPodAgents` (`taskEventService.ts:221`), `notifyFoundWork` (`:548`) and the lapse rescue, which clears the assignee (`kernelWorkSweepService.ts:251–263`), skip it. One writer completes rows outside both routes: on a merged PR from a bound repo, #2383 marks an open row in the pod with that `prUrl` done, with no ruling, guard or completer (`githubGigService.ts:80–90`). Its filter gains `source: {$ne: 'playbook'}`. +**Guards, in one place.** The claim CAS (`tasksApi.ts:661–669`) and the complete CAS (`:717–722`) move into shared functions that the routes and the runner call, each adding four conditions: `ruling` absent or `approved`, because claiming a `blocked` row resumes it (`:668`) and a declined row must stay blocked; `guard: 'kernel'` refuses every API caller; `guard: 'person'` refuses a bot; and on a playbook row an API caller must be its assignee, because today any member may claim a pending row (`:489–497`) or complete one unclaimed (`:720`). A refusal is a 409 naming the card (`awaiting_ruling`), the guard or the role. On a playbook row PATCH (`:997`) takes only `notes`, `status: 'blocked'` and, from its assignee, `prUrl`, and `notifyPodAgents` (`taskEventService.ts:221`), `notifyFoundWork` (`:548`), the assigned-task wake, which would wake a seat on a step it may not claim yet (`assignedTaskWakeService.ts:57–65`), and the lapse rescue, which clears the assignee (`kernelWorkSweepService.ts:251–263`), skip it. One writer completes rows outside both routes: on a merged PR from a bound repo, #2383 marks an open row in the pod with that `prUrl` done, with no ruling, guard or completer (`githubGigService.ts:80–90`). Its filter gains `source: {$ne: 'playbook'}`. **Who acts.** A BYO seat gets an addressed wake shaped like the lease warning (`taskEventService.ts:725`, `:756–783`) and completes with its own task tools. A native agent's only task tools are create and list (`nativeRuntimeService.ts:289`, `:306`), so for an in-process agent the runner claims, and when `dispatchInProcess` settles (`agentEventService.ts:1496–1557`) completes the row from the agent's posts, through a new `NativeRunResult.postedMessageIds` (`nativeRuntimeService.ts:74–82`) that counts the fallback post (`:2264–2285`). No post means one retry, then `blocked`. A person's step is a `guard: 'person'` row, completed on the board. @@ -53,7 +53,7 @@ A `tool_call` card's decider is the person its proposer names; every other card **Deciding.** `resolveApproval` (`approvalActionService.ts:790`) calls `applyRuling` on approve, through `executeAction`, and on decline, beside the parked-call decline (`:945–950`). It is a CAS on the row's `approvalId`, `state: 'awaiting'` and an open run, and records `decidedBy` and `decidedAt`. Decline blocks the row and the run and tells the run's owner; "Ask again" raises a new card. Expiry stays advisory, as for every non-tool card (ADR-020:71–76). -**Two bindings.** `act: 'kernel'`, labelled "Runs when approved", makes the step `guard: 'kernel'`: the kernel performs the effect, with the ruling id on its receipt, and completes the row, so the person is asked once. Slices 1–3 use effect `none`. Slice 4 adds `post`, `handoff` (§5) and `connector`, which parks the existing `tool_call` card for the connection's owner; that card's expiry is terminal (ADR-017:207) and counts as a decline. `act: 'actor'`, labelled "Enforced on the record, not on the action", opens the row to its actor after Approve, and the done check refuses a run whose pointed step lacks an approved ruling. +**Two bindings.** `act: 'kernel'`, labelled "Runs when approved", makes the step `guard: 'kernel'`: the kernel performs the effect, with the ruling id on its receipt, and completes the row, so the person is asked once. Slices 1–3 use effect `none`. Slice 4 adds `post`, `handoff` (§5) and `connector`, which parks the existing `tool_call` card for the connection's owner; that card's expiry is terminal (ADR-017:207–212) and counts as a decline. `act: 'actor'`, labelled "Enforced on the record, not on the action", opens the row to its actor after Approve, and the done check refuses a run whose pointed step lacks an approved ruling. No point is implicit, because no step's agent can write to a connector: in a grant pod a native turn can only read context, post (`nativeRuntimeService.ts:546`, `:613`, `:1999–2003`) and call the grant's read tools (`grantBrokerProjectionService.ts:180`), and no BYO seat holds a grant today (§5). @@ -63,7 +63,7 @@ Each statement maps onto a record fact: a step is done, is approved, was complet ## 5. A connector and a BYO agent in one playbook -Today they never share a pod, because a grant is admitted only into a private, invite-only, unbridged pod whose every agent runs in-process (`outsiderGrantPodEligibility.ts:41–84`). A bridged pod can hold people, BYO seats, native agents without a grant, and the GitHub App, which is not a bridge (`podIsBridged`, `:26–30`); it cannot hold a grant (`pod_bridged`). A grant pod holds the grant, in-process agents and people; it cannot hold a bridge or a BYO seat (`non_native_agent`), and a BYO seat cannot join it while the grant is live (`outsiderGrantAgentJoinPolicy.ts:24–29`). So a playbook that mixes them, like the seed catalog's P1 (a Slack question becomes a Jira ticket) and P2 (a Sentry error becomes a fix PR), is two pods and two playbooks joined by a `handoff` point (slice 4). Its approver is a human member of both, and Approve starts the other pod's On playbook with the approved text as its Run input. +Today they never share a pod, because a grant is admitted only into a private, invite-only, unbridged pod whose every agent runs in-process (`outsiderGrantPodEligibility.ts:41–84`). A bridged pod can hold people, BYO seats, native agents without a grant, and the GitHub App, which is not a bridge (`podIsBridged`, `:26–30`); it cannot hold a grant (`pod_bridged`). A grant pod holds the grant, in-process agents and people; it cannot hold a bridge or a BYO seat (`non_native_agent`), and a BYO seat cannot join it while the grant is live (`outsiderGrantAgentJoinPolicy.ts:24–29`). So a playbook that mixes them, such as a Slack question that becomes a Jira ticket or a Sentry error that becomes a fix PR, is two pods and two playbooks joined by a `handoff` point (slice 4). Its approver is a human member of both, and Approve starts the other pod's On playbook with the approved text as its Run input. §12 of `hosted-mcp-connection-scope.md` proposes, unbuilt, that a seat placed through "on my computer" whose confinement the server can see may join a grant pod (TASK-213 B; the Connect page's bring-your-own path never qualifies), and that its post into another pod, or a DM opening a new room, parks as a card in the grant pod for whoever installed it there (TASK-214). Once both ship, such a seat can hold a role in that pod's own playbook. Its parked cards are §12's, not playbook points. @@ -79,9 +79,9 @@ Scout has no file tool (`config/native-agents/scout.ts:254–266`). A new `commo Seeds are `builtin` playbooks in a gallery; "Use this" copies one into the pod, Off. -**Bug to merged fix**, the catalog's P0 (slice 1): BYO seats, no grant, started by Run with the issue link; starting from the issue itself is §2's slice-4 trigger. `fixer` opens the PR and completes with `prUrl`; `reviewer`, an agent from another vendor, completes on a pass in the PR thread; `approver`, a person, holds an `actor` point on the `guard: 'person'` Merge step and merges by hand. Done: all three steps, step 1's `prUrl`, and step 3 approved and completed by a person. In a pod bound to the repo, the merge posts #2383's card, and the approver still completes the Merge step (§1). +**Bug to merged fix** (slice 1): BYO seats, no grant, started by Run with the issue link; starting from the issue itself is §2's slice-4 trigger. `fixer` opens the PR and completes with `prUrl`; `reviewer`, an agent from another vendor, completes on a pass in the PR thread; `approver`, a person, holds an `actor` point on the `guard: 'person'` Merge step and merges by hand. Done: all three steps, step 1's `prUrl`, and step 3 approved and completed by a person. In a pod bound to the repo, the merge posts #2383's card, and the approver still completes the Merge step (§1). -**Weekly pipeline review**, the catalog's S4 (slice 2): a pod that meets §5's rule, with a person, an in-process agent and the person's Airtable grant at read, run Mondays 09:00 in the owner's timezone. The agent reads `list_records_for_table`, counts deals by stage, flags those untouched for 14 days and posts at most 3 decide items. A `kernel` point with effect `none` follows, then a write-gap person step in which the owner files the agreed changes. Done: step 1 done, step 2 approved, step 3 completed by a person. +**Weekly pipeline review** (slice 2): a pod that meets §5's rule, with a person, an in-process agent and the person's Airtable grant at read, run Mondays 09:00 in the owner's timezone. The agent reads `list_records_for_table`, counts deals by stage, flags those untouched for 14 days and posts at most 3 decide items. A `kernel` point with effect `none` follows, then a write-gap person step in which the owner files the agreed changes. Done: step 1 done, step 2 approved, step 3 completed by a person. ## 9. Build order and named tests @@ -92,14 +92,16 @@ Seeds are `builtin` playbooks in a gallery; "Use this" copies one into the pod, | 3 | `commonly_read_file` and `commonly_playbook_options`; the `create_playbook` card; the seed gallery; the editor | A dropped document becomes an Off playbook | | 4 | Push; the GitHub issue trigger; interval checks; `post`, `connector` and `handoff` effects; send-back (a step returned to its actor with a note) | A mixed connector and BYO playbook | +**Rollout.** No row needs a migration or a backfill. The three Task fields are optional and absent on every existing row, and each guard reads absent as no ruling and no guard, so existing rows behave as they do today. The `ruling.approvalId` index is declared on the model and built at boot, as the pair index is, because nothing turns autoIndex off (`config/db.ts:6–8`, `Task.ts:121–122`); it is sparse, so no existing row enters it. The compatibility window is the rolling deploy: a backend on older code has none of the guards. So playbooks ship behind a flag that is off by default, and turn-on is refused until it is set, after slice 1 runs on every backend. No playbook row exists before its guards do. + TASK-275 is slices 1–3, with these named tests: - The guards refuse an awaiting or declined ruling, a claim on the blocked row included, and pass after approve. An approve racing a complete never completes an unapproved step. A bot or a non-owner cannot rule. A member who is not a playbook row's assignee, agent or person, can neither claim nor complete it, and the lapse rescue leaves the row and its assignee as they are. - A double fire across replicas makes one run under both index shapes. `onStepDone` twice makes one next step and reopens nothing. A merged-PR delivery naming a playbook row's `prUrl` completes nothing. -- Replaying a completed run row's exact ref and title through the create route leaves it done, with no write and no wake. The route refuses a `playbook:` ref and `source: 'playbook'` from a person and from an agent, and a non-playbook ref keeps its idempotent retry and its done-row reopen. +- Replaying a completed run row's exact ref and title through the create route leaves it done, with no write and no wake. The route refuses a `playbook:` ref and `source: 'playbook'` from a person and from an agent before its lookup, so a `playbook:` ref that matches an existing non-playbook row is refused too and leaves that row as it was, with no write and no wake. A non-playbook ref keeps its idempotent retry and its done-row reopen. - A schedule fires once per slot in both DST directions, never an hour late or before `onAt`, and save refuses any other cron. - A native step completes from its post, by tool or fallback, and with no post retries once, then blocks. - The done check blocks a missing ruling and a person step completed by a bot. No agent moves a run row. -- Each pre-flight refusal, a point in a playbook with no agent role among them, each PATCH refusal on a playbook row and the connector install route's refusal of a playbook has a test. The ambient and found-work notices skip playbook rows. An active playbook installation survives an installable reconciler pass. +- Each pre-flight refusal, a point in a playbook with no agent role among them, each PATCH refusal on a playbook row and the connector install route's refusal of a playbook has a test. The ambient and found-work notices and the assigned-task wake skip playbook rows. Turn-on is refused while the flag is off. An active playbook installation survives an installable reconciler pass. Slice 4 adds one: an agent's message, #2383's card included, never fires a push. From b57f44b020c38e0dac7193986795df331a3c2d3b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:45:58 -0700 Subject: [PATCH 6/8] docs(plans): key each playbook effect by its card, and scope completedBy (TASK-274) - Each effect runs once: the ruling's CAS comes first, then the effect, then the complete. Each slice-4 effect is keyed by its card's approvalId at its target (the handoff run's trigger key, a sparse unique index on the parked card, a unique column on the post), so a retry after a crash, or from two sweeps at once, makes no second effect. The reconciler finishes an approved kernel step that is not done. With effect none, approve and complete are one write. - completedBy is set by the shared complete. The merged-PR hook still completes non-playbook rows with no completer, and a named test covers it. - Slice 4 adds the crash-window test for each effect. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/pod-playbooks.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/plans/pod-playbooks.md b/docs/plans/pod-playbooks.md index 1089635c0..1828a0780 100644 --- a/docs/plans/pod-playbooks.md +++ b/docs/plans/pod-playbooks.md @@ -26,13 +26,13 @@ Roles, not names; the team binds them at turn-on. The source document is kept as **A run is board rows.** A trigger inserts a run row (`source: 'playbook'`, `sourceRef: 'playbook::'`, `guard: 'kernel'`), then one row per step in turn, ref `…::`, assigned to the role's member. Refs and titles are fixed, so a duplicate insert fails under both the pair index (`Task.ts:126–133`) and the legacy ref-only index an unmigrated database keeps (`:121–125`), and only the winner wakes anyone. Only the runner may insert playbook rows. Today the create route (`tasksApi.ts:270`) takes any writer's `sourceRef` and a person's `source` (`:288–289`, `:401–403`) and reopens a done row with the same `(sourceRef, title)` whatever its source (`:334–387`), so replaying a finished run's ref and title would reopen it, and a step's row inserted ahead of the runner would win the index with no ruling on it. Slice 1 makes the route refuse `source: 'playbook'` and any `playbook:` ref from every caller, before its lookup, and never reopen a `source: 'playbook'` or guarded row. Every other ref keeps its idempotent retry and reopen. -**Three Task fields.** `ruling: {state: 'awaiting' | 'approved' | 'declined', approvalId, decidedBy?, decidedAt?}`, sparse-indexed on `ruling.approvalId`; `guard: 'kernel' | 'person'`; and `completedBy`, set by every complete (today the completer is only in an `updates` entry, `tasksApi.ts:714–716`). +**Three Task fields.** `ruling: {state: 'awaiting' | 'approved' | 'declined', approvalId, decidedBy?, decidedAt?}`, sparse-indexed on `ruling.approvalId`; `guard: 'kernel' | 'person'`; and `completedBy`, set by the shared complete below (today the completer is only in an `updates` entry, `tasksApi.ts:714–716`). -**Guards, in one place.** The claim CAS (`tasksApi.ts:661–669`) and the complete CAS (`:717–722`) move into shared functions that the routes and the runner call, each adding four conditions: `ruling` absent or `approved`, because claiming a `blocked` row resumes it (`:668`) and a declined row must stay blocked; `guard: 'kernel'` refuses every API caller; `guard: 'person'` refuses a bot; and on a playbook row an API caller must be its assignee, because today any member may claim a pending row (`:489–497`) or complete one unclaimed (`:720`). A refusal is a 409 naming the card (`awaiting_ruling`), the guard or the role. On a playbook row PATCH (`:997`) takes only `notes`, `status: 'blocked'` and, from its assignee, `prUrl`, and `notifyPodAgents` (`taskEventService.ts:221`), `notifyFoundWork` (`:548`), the assigned-task wake, which would wake a seat on a step it may not claim yet (`assignedTaskWakeService.ts:57–65`), and the lapse rescue, which clears the assignee (`kernelWorkSweepService.ts:251–263`), skip it. One writer completes rows outside both routes: on a merged PR from a bound repo, #2383 marks an open row in the pod with that `prUrl` done, with no ruling, guard or completer (`githubGigService.ts:80–90`). Its filter gains `source: {$ne: 'playbook'}`. +**Guards, in one place.** The claim CAS (`tasksApi.ts:661–669`) and the complete CAS (`:717–722`) move into shared functions that the routes and the runner call, each adding four conditions: `ruling` absent or `approved`, because claiming a `blocked` row resumes it (`:668`) and a declined row must stay blocked; `guard: 'kernel'` refuses every API caller; `guard: 'person'` refuses a bot; and on a playbook row an API caller must be its assignee, because today any member may claim a pending row (`:489–497`) or complete one unclaimed (`:720`). A refusal is a 409 naming the card (`awaiting_ruling`), the guard or the role. On a playbook row PATCH (`:997`) takes only `notes`, `status: 'blocked'` and, from its assignee, `prUrl`, and `notifyPodAgents` (`taskEventService.ts:221`), `notifyFoundWork` (`:548`), the assigned-task wake, which would wake a seat on a step it may not claim yet (`assignedTaskWakeService.ts:57–65`), and the lapse rescue, which clears the assignee (`kernelWorkSweepService.ts:251–263`), skip it. One writer completes rows outside both routes: on a merged PR from a bound repo, #2383 marks an open row in the pod with that `prUrl` done, with no ruling, guard or completer (`githubGigService.ts:80–90`). Its filter gains `source: {$ne: 'playbook'}`, and a row it still completes keeps no `completedBy`, because no member completed it. Only the done check reads the field, and only on playbook rows. **Who acts.** A BYO seat gets an addressed wake shaped like the lease warning (`taskEventService.ts:725`, `:756–783`) and completes with its own task tools. A native agent's only task tools are create and list (`nativeRuntimeService.ts:289`, `:306`), so for an in-process agent the runner claims, and when `dispatchInProcess` settles (`agentEventService.ts:1496–1557`) completes the row from the agent's posts, through a new `NativeRunResult.postedMessageIds` (`nativeRuntimeService.ts:74–82`) that counts the fallback post (`:2264–2285`). No post means one retry, then `blocked`. A person's step is a `guard: 'person'` row, completed on the board. -**Moving a run.** `onStepDone`, called from the shared complete, a kernel act and the native settle, inserts the next step or runs the done check. On the ten-minute sweep (`schedulerService.ts:461–462`) the runner's reconciler inserts a missing next step, applies a decided card whose row still says `awaiting`, re-wakes an unclaimed or lapsed BYO step once after 30 minutes, retries or blocks a stuck native step, and sets the run row. No agent moves a run row. +**Moving a run.** `onStepDone`, called from the shared complete, a kernel act and the native settle, inserts the next step or runs the done check. On the ten-minute sweep (`schedulerService.ts:461–462`) the runner's reconciler inserts a missing next step, applies a decided card whose row still says `awaiting`, finishes an approved kernel step that is not done (§3), re-wakes an unclaimed or lapsed BYO step once after 30 minutes, retries or blocks a stuck native step, and sets the run row. No agent moves a run row. **Pre-flight**, at turn-on and every run start, refuses a role bound to a non-member or the wrong kind, an approver who is not a human member, a tool not granted in the pod or not pinned by its entry, a grant step in a pod failing §5's rule, a write given to a native agent, a point in a playbook with no agent role (§3), and an invalid trigger. The routes are human-only and membership-gated, under `/api/pods/:podId/playbooks`. @@ -53,7 +53,9 @@ A `tool_call` card's decider is the person its proposer names; every other card **Deciding.** `resolveApproval` (`approvalActionService.ts:790`) calls `applyRuling` on approve, through `executeAction`, and on decline, beside the parked-call decline (`:945–950`). It is a CAS on the row's `approvalId`, `state: 'awaiting'` and an open run, and records `decidedBy` and `decidedAt`. Decline blocks the row and the run and tells the run's owner; "Ask again" raises a new card. Expiry stays advisory, as for every non-tool card (ADR-020:71–76). -**Two bindings.** `act: 'kernel'`, labelled "Runs when approved", makes the step `guard: 'kernel'`: the kernel performs the effect, with the ruling id on its receipt, and completes the row, so the person is asked once. Slices 1–3 use effect `none`. Slice 4 adds `post`, `handoff` (§5) and `connector`, which parks the existing `tool_call` card for the connection's owner; that card's expiry is terminal (ADR-017:207–212) and counts as a decline. `act: 'actor'`, labelled "Enforced on the record, not on the action", opens the row to its actor after Approve, and the done check refuses a run whose pointed step lacks an approved ruling. +**Two bindings.** `act: 'kernel'`, labelled "Runs when approved", makes the step `guard: 'kernel'`: the kernel performs the effect, with the ruling id on its receipt, and completes the row, so the person is asked once. Slices 1–3 use effect `none`, where approve and complete are one write. Slice 4 adds `post`, `handoff` (§5) and `connector`, which parks the existing `tool_call` card for the connection's owner; that card's expiry is terminal (ADR-017:207–212) and counts as a decline. `act: 'actor'`, labelled "Enforced on the record, not on the action", opens the row to its actor after Approve, and the done check refuses a run whose pointed step lacks an approved ruling. + +**Each effect runs once.** On approve the ruling's CAS comes first, then the effect, then the complete. A crash can fall between the effect and the complete, and during a rollout two replicas' sweeps can retry the same step at once (`schedulerService.ts:492–496`), so a retry can meet an effect that already ran. Each slice 4 effect is keyed by its card's `approvalId` at its target. A `handoff` run takes it as its trigger key, so the pair index refuses a second run row. A `connector` card stores it under a new sparse unique index on `ApprovalAction`, which has no unique index today (`ApprovalAction.ts:124–125`). A `post` stores it in a new nullable `messages` column under a unique index, built as the legacy-id index is (`pgMessageReconcileService.ts:88–107`), and fails rather than fall back to Mongo, as a thread-scoped post does (`agentMessageService.ts:1798–1803`). A retry that meets the key takes the first effect as its own: a `post` or `handoff` row completes, and a `connector` row waits on the card it found. The receipt cannot guard a retry, because the crash can come before it is written. No point is implicit, because no step's agent can write to a connector: in a grant pod a native turn can only read context, post (`nativeRuntimeService.ts:546`, `:613`, `:1999–2003`) and call the grant's read tools (`grantBrokerProjectionService.ts:180`), and no BYO seat holds a grant today (§5). @@ -97,11 +99,11 @@ Seeds are `builtin` playbooks in a gallery; "Use this" copies one into the pod, TASK-275 is slices 1–3, with these named tests: - The guards refuse an awaiting or declined ruling, a claim on the blocked row included, and pass after approve. An approve racing a complete never completes an unapproved step. A bot or a non-owner cannot rule. A member who is not a playbook row's assignee, agent or person, can neither claim nor complete it, and the lapse rescue leaves the row and its assignee as they are. -- A double fire across replicas makes one run under both index shapes. `onStepDone` twice makes one next step and reopens nothing. A merged-PR delivery naming a playbook row's `prUrl` completes nothing. +- A double fire across replicas makes one run under both index shapes. `onStepDone` twice makes one next step and reopens nothing. A merged-PR delivery naming a playbook row's `prUrl` completes nothing, and one naming another row completes it with no `completedBy`. - Replaying a completed run row's exact ref and title through the create route leaves it done, with no write and no wake. The route refuses a `playbook:` ref and `source: 'playbook'` from a person and from an agent before its lookup, so a `playbook:` ref that matches an existing non-playbook row is refused too and leaves that row as it was, with no write and no wake. A non-playbook ref keeps its idempotent retry and its done-row reopen. - A schedule fires once per slot in both DST directions, never an hour late or before `onAt`, and save refuses any other cron. - A native step completes from its post, by tool or fallback, and with no post retries once, then blocks. - The done check blocks a missing ruling and a person step completed by a bot. No agent moves a run row. - Each pre-flight refusal, a point in a playbook with no agent role among them, each PATCH refusal on a playbook row and the connector install route's refusal of a playbook has a test. The ambient and found-work notices and the assigned-task wake skip playbook rows. Turn-on is refused while the flag is off. An active playbook installation survives an installable reconciler pass. -Slice 4 adds one: an agent's message, #2383's card included, never fires a push. +Slice 4 adds two: an agent's message, #2383's card included, never fires a push; and for each of the three effects, a crash after it and before the row moves, retried by two sweeps at once, makes one post, one run or one card. From efa8f30ac72546db0a4297e78d78e4474e92bbde Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:00:29 -0700 Subject: [PATCH 7/8] docs(plans): a connector row follows its card's ending, and the merge-hook skip leaves the row as it was (TASK-274) A retry that met an existing connector card waited on it, so a card that had already ended, or never rendered, left its row waiting on nothing. The row now takes the card's state: `ran` completes it; `declined`, `expired`, `failed`, `refused` or a `moot` card blocks it as a decline does. The same CAS runs beside the event a parked call's ending already enqueues, and the slice-4 tests cover the retry that finds the card ended. The merge-hook test now asserts that a playbook row is left unchanged and that no task event is emitted, not only that nothing completes. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/pod-playbooks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plans/pod-playbooks.md b/docs/plans/pod-playbooks.md index 1828a0780..4bacba334 100644 --- a/docs/plans/pod-playbooks.md +++ b/docs/plans/pod-playbooks.md @@ -55,7 +55,7 @@ A `tool_call` card's decider is the person its proposer names; every other card **Two bindings.** `act: 'kernel'`, labelled "Runs when approved", makes the step `guard: 'kernel'`: the kernel performs the effect, with the ruling id on its receipt, and completes the row, so the person is asked once. Slices 1–3 use effect `none`, where approve and complete are one write. Slice 4 adds `post`, `handoff` (§5) and `connector`, which parks the existing `tool_call` card for the connection's owner; that card's expiry is terminal (ADR-017:207–212) and counts as a decline. `act: 'actor'`, labelled "Enforced on the record, not on the action", opens the row to its actor after Approve, and the done check refuses a run whose pointed step lacks an approved ruling. -**Each effect runs once.** On approve the ruling's CAS comes first, then the effect, then the complete. A crash can fall between the effect and the complete, and during a rollout two replicas' sweeps can retry the same step at once (`schedulerService.ts:492–496`), so a retry can meet an effect that already ran. Each slice 4 effect is keyed by its card's `approvalId` at its target. A `handoff` run takes it as its trigger key, so the pair index refuses a second run row. A `connector` card stores it under a new sparse unique index on `ApprovalAction`, which has no unique index today (`ApprovalAction.ts:124–125`). A `post` stores it in a new nullable `messages` column under a unique index, built as the legacy-id index is (`pgMessageReconcileService.ts:88–107`), and fails rather than fall back to Mongo, as a thread-scoped post does (`agentMessageService.ts:1798–1803`). A retry that meets the key takes the first effect as its own: a `post` or `handoff` row completes, and a `connector` row waits on the card it found. The receipt cannot guard a retry, because the crash can come before it is written. +**Each effect runs once.** On approve the ruling's CAS comes first, then the effect, then the complete. A crash can fall between the effect and the complete, and during a rollout two replicas' sweeps can retry the same step at once (`schedulerService.ts:492–496`), so a retry can meet an effect that already ran. Each slice 4 effect is keyed by its card's `approvalId` at its target. A `handoff` run takes it as its trigger key, so the pair index refuses a second run row. A `connector` card stores it under a new sparse unique index on `ApprovalAction`, which has no unique index today (`ApprovalAction.ts:124–125`). A `post` stores it in a new nullable `messages` column under a unique index, built as the legacy-id index is (`pgMessageReconcileService.ts:88–107`), and fails rather than fall back to Mongo, as a thread-scoped post does (`agentMessageService.ts:1798–1803`). A retry that meets the key takes the first effect as its own: a `post` or `handoff` row completes, and a `connector` row takes the state of the card it found. The receipt cannot guard a retry, because the crash can come before it is written. A `connector` row follows its card's ending, applied by a CAS on the open row found through `ruling.approvalId`, both beside the event that ending already enqueues (`approvalActionService.ts:719`) and when a retry finds the card ended: `ran` completes it; `declined`, `expired`, `failed` or `refused` blocks it and the run as a decline does, naming the ending, and so does a `moot` card, whose render never posted (`:519–534`). A card still `flagged` leaves it waiting. No point is implicit, because no step's agent can write to a connector: in a grant pod a native turn can only read context, post (`nativeRuntimeService.ts:546`, `:613`, `:1999–2003`) and call the grant's read tools (`grantBrokerProjectionService.ts:180`), and no BYO seat holds a grant today (§5). @@ -99,11 +99,11 @@ Seeds are `builtin` playbooks in a gallery; "Use this" copies one into the pod, TASK-275 is slices 1–3, with these named tests: - The guards refuse an awaiting or declined ruling, a claim on the blocked row included, and pass after approve. An approve racing a complete never completes an unapproved step. A bot or a non-owner cannot rule. A member who is not a playbook row's assignee, agent or person, can neither claim nor complete it, and the lapse rescue leaves the row and its assignee as they are. -- A double fire across replicas makes one run under both index shapes. `onStepDone` twice makes one next step and reopens nothing. A merged-PR delivery naming a playbook row's `prUrl` completes nothing, and one naming another row completes it with no `completedBy`. +- A double fire across replicas makes one run under both index shapes. `onStepDone` twice makes one next step and reopens nothing. A merged-PR delivery naming a playbook row's `prUrl` leaves that row as it was, its status, assignee, `completedBy`, updates and `updatedAt` included, and emits no task event; one naming another row completes it with no `completedBy`. - Replaying a completed run row's exact ref and title through the create route leaves it done, with no write and no wake. The route refuses a `playbook:` ref and `source: 'playbook'` from a person and from an agent before its lookup, so a `playbook:` ref that matches an existing non-playbook row is refused too and leaves that row as it was, with no write and no wake. A non-playbook ref keeps its idempotent retry and its done-row reopen. - A schedule fires once per slot in both DST directions, never an hour late or before `onAt`, and save refuses any other cron. - A native step completes from its post, by tool or fallback, and with no post retries once, then blocks. - The done check blocks a missing ruling and a person step completed by a bot. No agent moves a run row. - Each pre-flight refusal, a point in a playbook with no agent role among them, each PATCH refusal on a playbook row and the connector install route's refusal of a playbook has a test. The ambient and found-work notices and the assigned-task wake skip playbook rows. Turn-on is refused while the flag is off. An active playbook installation survives an installable reconciler pass. -Slice 4 adds two: an agent's message, #2383's card included, never fires a push; and for each of the three effects, a crash after it and before the row moves, retried by two sweeps at once, makes one post, one run or one card. +Slice 4 adds three: an agent's message, #2383's card included, never fires a push; for each of the three effects, a crash after it and before the row moves, retried by two sweeps at once, makes one post, one run or one card; and a retry that finds its connector card already ended, by each of the five endings or `moot`, moves the row once by that state, racing the ending's hook included. From 14929bd367962bfbd5bf6832829385f66f4c9d26 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:17:17 -0700 Subject: [PATCH 8/8] docs(plans): the crash-window test asserts the row recovers, not only that each effect ran once (TASK-274) The slice-4 crash-window test counted effects but never checked the row, so a retry that made one post and left its row waiting would have passed. After both sweeps it now asserts that a `post` or `handoff` row is done with one next step, and that a `connector` row waits on its one card with its run open. Co-Authored-By: Claude Opus 5.5 Co-authored-by: wren Agent-Model: claude/claude-opus-5-5/max --- docs/plans/pod-playbooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/pod-playbooks.md b/docs/plans/pod-playbooks.md index 4bacba334..5772b3300 100644 --- a/docs/plans/pod-playbooks.md +++ b/docs/plans/pod-playbooks.md @@ -106,4 +106,4 @@ TASK-275 is slices 1–3, with these named tests: - The done check blocks a missing ruling and a person step completed by a bot. No agent moves a run row. - Each pre-flight refusal, a point in a playbook with no agent role among them, each PATCH refusal on a playbook row and the connector install route's refusal of a playbook has a test. The ambient and found-work notices and the assigned-task wake skip playbook rows. Turn-on is refused while the flag is off. An active playbook installation survives an installable reconciler pass. -Slice 4 adds three: an agent's message, #2383's card included, never fires a push; for each of the three effects, a crash after it and before the row moves, retried by two sweeps at once, makes one post, one run or one card; and a retry that finds its connector card already ended, by each of the five endings or `moot`, moves the row once by that state, racing the ending's hook included. +Slice 4 adds three tests. An agent's message, #2383's card included, never fires a push. For each of the three effects, a crash after it and before the row moves, retried by two sweeps at once, makes one post, one run or one card; after both sweeps a `post` or `handoff` row is done, with one next step, and a `connector` row waits on that card, its run open. A retry that finds its connector card already ended, by each of the five endings or `moot`, moves the row once by that state, racing the ending's hook included.