@sporhq/spor 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/API.md +58 -7
  4. package/GRAPH.md +98 -5
  5. package/QUEUE.md +5 -1
  6. package/README.md +107 -20
  7. package/bin/spor.js +7279 -729
  8. package/lib/config.js +129 -8
  9. package/lib/graph.js +96 -7
  10. package/lib/kernel/candidate.js +558 -0
  11. package/lib/kernel/completion.js +311 -0
  12. package/lib/kernel/coupling.js +14 -2
  13. package/lib/kernel/execution.js +849 -0
  14. package/lib/kernel/gates.js +1891 -56
  15. package/lib/kernel/graph.js +134 -23
  16. package/lib/kernel/queue.js +42 -3
  17. package/lib/kernel/registry.js +142 -18
  18. package/lib/kernel/resolution.js +87 -1
  19. package/lib/kernel/satisfiability.js +7 -1
  20. package/lib/remote.js +15 -3
  21. package/lib/schema.js +7 -0
  22. package/lib/seed/candidates/schema-factory.md +130 -4
  23. package/lib/seed/candidates/schema-gate.md +44 -6
  24. package/lib/seed/schema-agent.md +16 -2
  25. package/lib/seed/schema-artifact.md +15 -1
  26. package/lib/seed/schema-briefing.md +16 -2
  27. package/lib/seed/schema-capture-pending.md +15 -1
  28. package/lib/seed/schema-correction.md +15 -1
  29. package/lib/seed/schema-decision.md +15 -1
  30. package/lib/seed/schema-finding.md +16 -2
  31. package/lib/seed/schema-incident.md +16 -2
  32. package/lib/seed/schema-issue.md +73 -1
  33. package/lib/seed/schema-lens.md +16 -2
  34. package/lib/seed/schema-norm.md +16 -2
  35. package/lib/seed/schema-organization.md +16 -2
  36. package/lib/seed/schema-person.md +15 -1
  37. package/lib/seed/schema-profile.md +16 -2
  38. package/lib/seed/schema-project.md +16 -2
  39. package/lib/seed/schema-question.md +15 -1
  40. package/lib/seed/schema-repo.md +16 -2
  41. package/lib/seed/schema-routine.md +16 -2
  42. package/lib/seed/schema-task.md +85 -1
  43. package/lib/seed/schema-workflow-run.md +15 -1
  44. package/lib/seed/schema-workflow.md +15 -1
  45. package/lib/shell/agent-dispatch-runner.js +970 -88
  46. package/lib/shell/attestation.js +822 -0
  47. package/lib/shell/candidate-publish.js +880 -0
  48. package/lib/shell/completion.js +424 -0
  49. package/lib/shell/dispatch-harnesses.js +153 -9
  50. package/lib/shell/dispatch-terminal.js +225 -65
  51. package/lib/shell/execution-store.js +921 -0
  52. package/lib/shell/factory-availability.js +97 -0
  53. package/lib/shell/gate-runner.js +1899 -142
  54. package/lib/shell/git-network.js +51 -0
  55. package/lib/shell/implementation-stage.js +544 -0
  56. package/lib/shell/integration-runner.js +436 -45
  57. package/lib/shell/local-execution-lock.js +67 -0
  58. package/lib/shell/person-force-release.js +86 -0
  59. package/lib/shell/preflight.js +526 -0
  60. package/lib/shell/work-loop.js +549 -54
  61. package/lib/shell/worker-contract.js +111 -17
  62. package/package.json +1 -1
  63. package/prompts/client/digest-intent.md +9 -4
  64. package/scripts/engines/distill.js +558 -48
  65. package/scripts/engines/nudge-worker.js +5 -2
  66. package/scripts/engines/post-tool.js +123 -33
  67. package/scripts/engines/prompt-context.js +357 -35
  68. package/scripts/engines/util.js +312 -14
  69. package/skills/factory/SKILL.md +26 -2
  70. package/skills/factory/fixtures/README.md +4 -2
  71. package/skills/factory/fixtures/interview-acme-checkout.md +23 -1
  72. package/skills/factory/fixtures/nodes/factory-acme-checkout.md +15 -0
  73. package/skills/factory/references/emitting.md +130 -5
  74. package/skills/factory/references/interview.md +38 -2
  75. package/skills/factory/references/maintenance.md +58 -0
  76. package/skills/next/SKILL.md +12 -0
  77. package/skills/spor/SKILL.md +6 -3
  78. package/skills/spor/references/authoring-schemas.md +13 -1
@@ -2,7 +2,7 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.28.0",
5
+ "version": "0.29.0",
6
6
  "author": {
7
7
  "name": "losthammer"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spor",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "author": {
6
6
  "name": "Spor",
package/API.md CHANGED
@@ -105,7 +105,8 @@ The compiler over the wire. Input:
105
105
  ```
106
106
 
107
107
  Output: `{ "found": bool, "text": "<digest or full neighborhood>",
108
- "node_ids": [...], "top_sim": 0.31 }`. `found: false` (gate not met) is a
108
+ "node_ids": [...], "top_sim": 0.31, "intent"?: {...} }` (`intent` is the
109
+ optional server-computed digest-intent verdict — see the `/v1/digest` row in §3). `found: false` (gate not met) is a
109
110
  **successful empty result**, not an error.
110
111
 
111
112
  ### `get_node`
@@ -592,15 +593,15 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
592
593
  | Endpoint | Typical caller | Semantics |
593
594
  |---|---|---|
594
595
  | `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. Graph counts/projects are the viewer-visible projection. `?titles=1` adds viewer-visible `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
595
- | `GET /v1/schema` | `spor schema`, agents introspecting the contract | the live schema registry as data (task-spor-schema-introspection-surface; server half task-spor-server-schema-endpoint): `{default_edge_weight, node_types: [{type, description, prefix, always_on, traversable, capturable, queueable, non_resolving, terminal, inert, inert_inherited, vocabulary, completion, resolver_required, hooks, schema_id, schema_version, source}], edge_types: [{type, description, weight, weight_default, inverse_label, aliases, capturable, hooks, ...}], queue_policy, policies, registers, stale_overrides, alias_collisions}` — the seed pack MERGED with graph-resident `type: schema` overrides, each entry tagged by `source` (`seed`/`graph`/`native`) and the active schema node's id+version. `?code=1` embeds each hook's source under `code: {name: src}` (omitted by default to keep the response lean). The registry IS the contract (norm-cc-registry-is-contract); this read surface closes the failure mode of agents reverse-engineering it from `lib/seed/` files (which miss resident overrides). `vocabulary`/`completion`/`resolver_required` are the declarative completion policy (task-spor-registry-declarative-terminal-status-policy): the closed status enum a type's `validate()` gates on, its single SUCCESS terminal value (`null` when the type's terminal values are several distinct outcomes rather than one success, i.e. no mechanical close exists), and whether that value also needs a live resolving decision/artifact. They DECLARE what the hooks enforce — a reader (the gardener's finding remedies) names the right terminal status from here instead of hand-mirroring hook source. The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
596
+ | `GET /v1/schema` | `spor schema`, agents introspecting the contract | the live schema registry as data (task-spor-schema-introspection-surface; server half task-spor-server-schema-endpoint): `{default_edge_weight, node_types: [{type, description, prefix, always_on, traversable, capturable, queueable, non_resolving, terminal, inert, inert_inherited, vocabulary, completion, resolver_required, resolution, hooks, schema_id, schema_version, source}], edge_types: [{type, description, weight, weight_default, inverse_label, aliases, capturable, hooks, ...}], queue_policy, policies, registers, stale_overrides, alias_collisions}` — the seed pack MERGED with graph-resident `type: schema` overrides, each entry tagged by `source` (`seed`/`graph`/`native`) and the active schema node's id+version. `?code=1` embeds each hook's source under `code: {name: src}` (omitted by default to keep the response lean). The registry IS the contract (norm-cc-registry-is-contract); this read surface closes the failure mode of agents reverse-engineering it from `lib/seed/` files (which miss resident overrides). `vocabulary`/`completion`/`resolver_required` are the declarative completion policy (task-spor-registry-declarative-terminal-status-policy): the closed status enum a type's `validate()` gates on, its single SUCCESS terminal value (`null` when the type's terminal values are several distinct outcomes rather than one success, i.e. no mechanical close exists), and whether that value also needs a live resolving decision/artifact. They DECLARE what the hooks enforce — a reader (the gardener's finding remedies) names the right terminal status from here instead of hand-mirroring hook source. `resolution` is the per-type ATTESTATION path (issue-spor-offline-check-get-hook-resolution-proxy): `edge` — this type's completion is attested only by a live inbound resolving edge — or `status` — its own terminal status retires it — or `null` when the schema declares neither, which is a resident schema authored before the key existed (a reader falls back to the legacy proxy, `hooks` containing `get`) rather than a graph asserting either answer. A client verifying a dispatched run's outcome reads this, never the `hooks` array. The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
596
597
  | `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin, org}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface. `org` is the slug this tenant routes to (`SPOR_ORG`/legacy `SUBSTRATE_ORG`, else `"local"`); it lets a client key its `(issuer, org)` credential store for an **opaque** `spor_oat_`/`spor_pat_` token that carries no readable `org` claim — the client falls back to it after `--org` and the JWT `org` claim (task-spor-frontdoor-me-org-echo). A connector JWT's `org` claim is enforced equal to this echo |
597
598
  | `GET /v1/me/org-choices` | `spor auth list` (live membership refresh) | re-queries the IdP's *current* org membership for the held credential's subject and returns `{org_choices: [{slug, label, default?}], source: "idp"\|"bound"}` — `source:"idp"` is a true live enumeration (orgs added/removed since the last login surface without re-authenticating); `source:"bound"` means a single org-scoped token the server couldn't expand (no enumeration). The client treats only `source:"idp"` as live and **fails open** to its cached tenant listing on anything else — `source:"bound"`, a `502 {error.code:"membership_requery_failed"}` (IdP unreachable), a `404` (older server without the endpoint), or any transport/parse error (task-spor-cli-auth-list-live-membership-requery; server half task-spor-frontdoor-held-credential-membership-requery) |
598
599
  | `GET /v1/me/tokens` | `spor token list` | list the caller's OWN personal access tokens → `{tokens: [{hash_prefix, person, label, name, email, created, expires, expired, last_used}], count}` — caller-scoped (only their person-bound PATs; agent session tokens excluded), never plaintext, never full hashes. `403 forbidden` if the bearer maps to **no person node** (you need a bound identity to own a PAT). The self-serve, no-admin twin of `GET /v1/admin/tokens` below (task-spor-app-me-tokens-self-serve) |
599
600
  | `POST /v1/me/tokens` `{expires?, label?}` | `spor token create` | mint a human-identity `spor_pat_` PAT bound to the CALLER's own person → 201 `{token, hash_prefix, person, name, email, label, expires}`; the plaintext `token` is returned **once**. `expires` is `<N>d` or an ISO date, user-set, defaulting to and **capped at 1 year** (a past date or beyond-cap is `422`, rejected not silently clamped); `label` is an optional ≤200-char note surfaced in the listing. `403` if unbound. The self-serve mint twin of admin `POST /v1/admin/tokens` (which binds someone *else*) |
600
601
  | `DELETE /v1/me/tokens/{hash-prefix}` | `spor token revoke` | revoke one of the caller's OWN PATs by hash prefix → `{revoked, hash_prefix, oauth_grants_revoked}`; a prefix that isn't one of the caller's is `404` (never another person's token). `403` if unbound. Shares the admin revoke's OAuth-grant cascade-completeness invariant (issue-cc-pat-revoke-cascades-all-oauth-grants) |
601
602
  | `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, project_brief?, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. A BARE repo slug also rides up to its home-project grouping: the grouping's `brief-<grouping>` node returns alongside as `project_brief` (the product context spanning sibling repos), matching the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping); passing the repo NODE id (`repo-<slug>`) is the escape hatch that returns only the repo brief, no `project_brief`. Optional `?fp=root:<sha>,remote:<host/path>,...` carries the repo's fingerprints: the server learns them onto the owning project node, and an unknown slug with a known fingerprint files an alias proposal in the queue |
602
- | `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected |
603
- | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. The node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live **visible** inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open visible gardener findings about the node ride along as `open_findings`, and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
603
+ | `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected. **Digest rerank (task-spor-compile-jev-rerank-stage, per-org opt-in, default OFF, server half task-split-spor-server-eb6de3fca31e):** when the org has opted in, the server scores the candidate pool (cosine top-30 ∪ top structural) with `jev.rerank` and hands the verdicts to `compile()` as `opts.rerankScores` — a `{ [id]: { score, noul } }` map, `score` an ordinal 0-3 (irrelevant / background / directly relevant / required) and `noul` a 0-1 probability that that candidate answers or resolves the query. `compile()` stays pure — it never calls Jev itself, it only reorders the section it already builds under `DIGEST_CAP`: pinned → scored candidates (`noul` desc, `score` secondary, cosine-sim tiebreak) → remaining structural (unscored, original score-desc order) → remaining content (unscored, original sim-desc order) — and reports it back as `meta.rerank: {applied: true, candidates: <n>}` (absent when rerank did not run) for the response's own additive `rerank` summary field to echo. Fail-open: an org that hasn't opted in, local mode (no server to call Jev with), or a call that omits/empties `opts.rerankScores` all render byte-identical to before (norm-cc-byte-identical-refactor). **Optional `intent`** (task-spor-digest-intent-jev-gate / task-split-spor-server-bca885114354): when the org has Jev enabled and the request carries a `query`, the server also returns `intent: {warranted: bool, needs_history: 0–1, digest_helps: 0–1, source: "jev"}` — the digest-intent verdict computed server-side (`warranted` = the deterministic prompt heuristics, then max(noul) ≥ 0.5). It is **absent** whenever it was not computed (Jev disabled/shed/timed out/failed, a `root` walk, an older server): fail-open, never `warranted: false` by default. The prompt-context hook suppresses THIS prompt's digest only on an explicit boolean `warranted: false`, and only when its digest intent gate is on (`digest.async`, a tri-state: explicit `true`/`false`, unset = the client default); an absent or non-boolean field is ignored, and so is one on a `found: false` response (its `digest_helps` judged an empty team digest, not the client's personal-graph merge) |
604
+ | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. The node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live **visible** inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open visible gardener findings about the node ride along as `open_findings` (`bin/spor.js`'s `dispatchDeclineFindingCheck` reads it to refuse re-dispatching a node a prior run DECLINED — task-spor-decline-finding-gates-redispatch — filtering for a live `find-declined-*` entry), and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
604
605
  | `POST /v1/nodes/batch` `{ids?\|cursor?}` | `get_nodes` | bounded plural `get_node`: start with 1–100 ids, deduplicated by first occurrence; hidden/absent ids appear only in `missing_ids`. Returns `{nodes, returned_ids, missing_ids, truncated, next_cursor}` with the same per-node read enrichment as the single route, capped at 48 KiB without splitting entries. Resume a truncated logical request by sending only its opaque `cursor`; the final page carries `next_cursor: null` |
605
606
  | `GET /v1/nodes/{id}/history?limit=N` | `spor history <id>`, the `node_history` MCP tool | per-node commit lineage — a `git log` projection over `nodes/{id}.md` → `{id, head, count, history: [{sha, short, actor, actor_name, actor_email, date, message, internal, person}]}`, newest first. Each revision is labeled `internal:true` for a server-internal write (boot reconcile / migration, `server@spor.invalid`) vs. a real actor, and mapped to its `person` node by author email. Deliberately NOT `git log --follow` (node files share heavy frontmatter boilerplate, so similarity-based rename detection crosses node boundaries — dec-spor-node-history-git-log-projection). `limit` defaults to 50, max 200. The frontmatter `author` re-stamps to the LAST editor on every write, so this is the only durable record of the full chain of editors. A node with no commit history (unknown id) is `404`; a bad id is `422`. The `spor history <id>` CLI verb is the shell front-door (remote reads this; local mode runs the same projection over the graph home) |
606
607
  | `GET /v1/nodes/{id}/history/{sha}` | `spor history <id> <sha>`, `node_history` (sha mode) | one revision's detail, the expensive half gated behind an explicit per-sha fetch → the history record for that commit plus `{change, patch, content}`: the change type (`A`/`M`/`D`/`R`), the patch this commit introduced to the node file, and the full node content at that revision (`null` when the commit deleted it). The `sha` must be one from the node's own history — a sha that didn't touch the node, or an unresolvable sha, is `404`; a malformed sha is `422` |
@@ -611,13 +612,13 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
611
612
  | `POST /v1/nodes/{id}/priority` `{priority}` | `spor priority`, queue triage | `set_priority` semantics (§1): one-scalar human-override update — `p1`/`p2`/`p3` or a clearing form (`none`/`clear`/`""`/`p0`). Server-side read-modify-write (no revision), stamping `priority_by`/`priority_at`/`priority_via` for the audit trail (issue-cc-priority-attribution-gap). Unknown value → `invalid_node` with the allowed list |
612
613
  | `POST /v1/nodes/{id}/readiness` `{readiness}` | `spor ready`, triage make-ready pass | `set_readiness` semantics (§1): one-scalar agent-readiness override — `agent` or a clearing form (`none`/`clear`/`""`) to demote back to derived. No hand-settable `human` value (always structurally derived, always wins). Server-side read-modify-write (no revision), stamping `readiness_by`/`readiness_at`/`readiness_via`. Unknown value → `invalid_node` with the allowed value |
613
614
  | `POST /v1/nodes/{id}/claim` `{session?}` | `claim`/`set_status` MCP tools, `spor claim` CLI, `spor dispatch` | take the heartbeat-renewed lease (dec-cc-task-claim-lease): writes the durable `assigned` edge once, attributes to `$viewer` from the token (never an argument), and creates the ephemeral lease → `{ok, status, lease: {node_id, by, expires, expires_at, session, claimed_at}, expires_in_ms, edge}`. `expires_in_ms` is the renewal horizon relative to when the call ran — skew-free against the caller's own clock (dec-spor-lease-auto-reclaim-and-deadline-exposure). A live lease held by ANOTHER person is `409 conflict` naming the holder + expiry (re-claiming your OWN live claim just renews it). `session` scopes the heartbeat (omit to leave it person-scoped, so any of the claimer's sessions may renew — what `spor claim` and `spor dispatch` do, since `claude --bg` self-allocates the run session only at launch; dispatch then renews with the real session once it has read it from `claude agents --json`, dec-spor-dispatch-bg-session-late-bind) |
614
- | `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor renew` CLI, `spor dispatch` | bump the live lease's expiry — no commit, UNLESS the lease had lapsed: renew then AUTO-RECLAIMS it first, under the same per-node lock (a real claim, durable `assigned` edge included), rather than refusing (dec-spor-lease-auto-reclaim-and-deadline-exposure) → `{ok, status, lease, expires_in_ms, reclaimed?}`, `reclaimed: true` marking a re-established lease apart from an ordinary bump. A LIVE lease held by SOMEONE ELSE is still `409 lease_lost`, naming the current holder. Person-scoped: any of the claimer's sessions may renew; a `session` binds the lease to that run (`spor dispatch` uses this to bind the captured `claude --bg` session post-launch) |
615
+ | `POST /v1/nodes/{id}/renew` `{session?}` | post-tool heartbeat, `renew` MCP tool, `spor renew` CLI, `spor dispatch` | bump the live lease's expiry — no commit, UNLESS the lease had lapsed: renew then AUTO-RECLAIMS it first, under the same per-node lock (a real claim, durable `assigned` edge included), rather than refusing (dec-spor-lease-auto-reclaim-and-deadline-exposure) → `{ok, status, lease, expires_in_ms, reclaimed?}`, `reclaimed: true` marking a re-established lease apart from an ordinary bump. A LIVE lease held by SOMEONE ELSE is still `409 lease_lost`, naming the current holder. Person-scoped: any of the claimer's sessions may renew; a `session` binds the lease to that run (`spor dispatch` uses this to bind the captured `claude --bg` session post-launch) Singular renewal returns optional `reclaimed: true` and `reclaimed_after: "release" | "lapse" | "never_held"` when it reacquires the claim; both fields are absent on an ordinary heartbeat. This is the singular `RenewResponse` contract. |
615
616
  | `POST /v1/nodes/{id}/extend` `{ms, session?}` | `extend` MCP tool, `spor extend` CLI | manually stretch your live lease by `ms` milliseconds for a known long idle gap, auto-reclaiming first if the lease had lapsed (same discipline as `renew`, reported `reclaimed: true`, dec-spor-lease-auto-reclaim-and-deadline-exposure) → `{ok, status, lease, expires_in_ms, reclaimed?, capped_to_max?, claim_ttl_max_ms?}`. Bounded by the tenant's `claim_ttl_max` policy (a request past the ceiling caps to it, flagged `capped_to_max`); never shortens a lease. `ms` must be a positive number (`spor extend <id> <2h|45m|…>` parses the human duration client-side). A LIVE lease held by SOMEONE ELSE is `409 lease_lost` naming the holder |
616
617
  | `POST /v1/nodes/{id}/release` | `release` MCP tool, `spor release` CLI | drop the lease AND retire the durable `assigned` edge, returning the node to the pool. Idempotent (releasing a node you hold no lease on still succeeds, cleaning up any lingering `assigned` edge of yours); releasing a claim someone else holds is `409` naming the holder |
617
618
  | `POST /v1/nodes/{id}/reserve` `{session?}` | `reserve` MCP tool, client SessionEnd hook (task-cc-client-sessionend-reserve-hook) | convert your live claim into an owner-exclusive resumption reservation (dec-cc-task-resumption-reservation) when a session ends cleanly with the task advanced but unfinished, auto-reclaiming first if the node is unheld — never claimed, or your own claim/reservation merely lapsed under it (reported `reclaimed: true`, dec-spor-lease-auto-reclaim-and-deadline-exposure) → `{ok, status: "reserved", lease, expires_in_ms, grace_window_ms, reclaimed?}`. Drops the heartbeat, re-points `expires` at a grace-window expiry (~2 days, tenant policy — a timestamp, not a graph edge), and keeps the durable `assigned` edge so a steward view still reads "reserved by you"; `rankQueue` floats it to the top of the owner's queue while dropping it from teammates' actionable lists until the grace window lapses (full pool, everyone) or the owner claims/renews/extends it within that window (drops the `reserved` flag, back to a normal heartbeat lease). `409 lease_lost` (naming the holder) only when SOMEONE ELSE holds a live claim — the one boundary that still matters |
618
619
  | `POST /v1/queue/release` `{session?}` | session-end handoff drain; bulk-lease clients | **`releaseAll`**: release every live claim the caller holds, optionally narrowed to one `session`, in ONE round-trip instead of N sequential `POST /v1/nodes/{id}/release` calls (task-spor-queue-bulk-release-for-handoff-drain). Delegates each drop to the same per-node `release` logic — durable `assigned` edge retirement included — so it only ever enumerates and releases the CALLER's own leases; a node re-claimed by the caller under a different session, or held by someone else, between the enumeration and its turn simply isn't counted as released, never a `409` → `{ok: true, status: "released", released: [ids], count}`. Unlike `claimAll`/`renewAll` below there is no holder mismatch to report, so it carries no `failed` array |
619
620
  | `POST /v1/queue/claim` `{ids, session?}` | `spor dispatch` claiming a working set; bulk-lease clients | **`claimAll`**: claim a whole working set in ONE round-trip instead of one `POST /v1/nodes/{id}/claim` per node (task-spor-bulk-claim-renew-apis). `ids` is REQUIRED (unlike `renewAll` below there is nothing to enumerate — a claim creates the lease it would have enumerated), bounded and deduped server-side. Each id runs through the same per-node `claim` logic, so one holder's node losing the race to another claimant lands in `failed` (`already_claimed`, naming the holder) while the rest of the batch still claims — a partial batch is reported, never rolled back → `{ok: true, status: "claimed"\|"partial"\|"refused", count, claimed: [ids], leases: [...], failed: [{node_id, code, message, holder?}], expires_in_ms?}`. `expires_in_ms` is the batch's own renewal horizon — the soonest deadline among the leases that landed, omitted (not nulled) on an empty or wholly-refused batch. `status` reads `"partial"` when some items landed and some didn't, `"refused"` when the whole batch was refused — **`ok: true` only means the call was well-formed; read `failed` for what didn't land, never `ok` alone**. The dispatch nonce and `force` (accepted on the singular `/claim`) are deliberately NOT accepted here — a dispatch tags a single agent launch at a single node, so that stays on the singular door |
620
- | `POST /v1/queue/renew` `{ids?, session?}` | post-tool heartbeat (batched), `spor dispatch`, bulk-lease clients | **`renewAll`**: the heartbeat for a whole working set in ONE round-trip (task-spor-bulk-claim-renew-apis) — what the client-side claim heartbeat uses instead of one `POST /v1/nodes/{id}/renew` per held node (task-spor-client-heartbeat-bulk-renew), through the **`ids`-omitted arm, with no `session` either** (dec-spor-heartbeat-adopts-blanket-renew-arm): a beat renews the person's whole live working set and never re-acquires what dropped out of it, and it omits `session` because in this arm that field is a FILTER, so sending it would skip the leases claimed outside a session (`spor claim`, `spor dispatch`'s pre-launch claim) and let them lapse mid-session. Two consequences ride with that choice: a beat renews the person's leases in EVERY project (this arm takes no project scope), so a lease nobody releases stops self-healing back into the pool at its TTL while its owner keeps writing anywhere; and since this arm SELECTS on `session` rather than stamping it, a lease's session binding is no longer re-pointed at the last editing session. Two modes, and they differ on auto-reclaim (dec-spor-lease-auto-reclaim-and-deadline-exposure — the ONE exemption, whose intended caller is the heartbeat): **`ids` omitted** enumerates every LIVE Tier-1 lease this caller holds (optionally narrowed to one `session`) and renews them all — the motivating case, one call per heartbeat instead of one per held node — but NEVER reclaims: its contract is "renew what you hold" from a snapshot, so a lease that lapsed (or was taken) lands in `failed` as `lease_lost` and simply drops out of the set, never silently re-acquired; **`ids` supplied** renews exactly that named set (bounded/deduped) with the SAME reclaim semantics as the singular `/renew` — a lapsed lease is auto-reclaimed (a real claim, durable `assigned` edge included), its id lands in `reclaimed`, and only a lease held by someone else still lands in `failed` as `lease_lost` (naming the holder). `session` is forwarded unchanged exactly as the singular `/renew` does — one contested node never costs the whole working set its heartbeat → `{ok: true, status: "renewed"\|"partial"\|"refused", count, renewed: [ids], leases: [...], failed: [...], expires_in_ms?, reclaimed?, skipped_other_session?, skipped_reserved?}`. `expires_in_ms` is the batch's own renewal horizon (soonest deadline among the leases that landed, omitted on an empty/wholly-refused batch); `reclaimed` (explicit-`ids` arm only) lists the ids whose lease had lapsed and was just re-established, so a batch reading "N/N renewed" doesn't hide that one of them was silently taken back off the pool. The two `skipped_*` counts ride ONLY on the enumerate arm: `skipped_other_session` names live leases excluded because they're bound to a different session (a zero renewed count there means "not under this session", not "you hold nothing"), `skipped_reserved` names Tier-2 resumption reservations a blanket heartbeat deliberately leaves parked at their grace-window expiry rather than demoting to a Tier-1 horizon |
621
+ | `POST /v1/queue/renew` `{ids?, session?}` | post-tool heartbeat (batched), `spor dispatch`, bulk-lease clients | **`renewAll`**: the heartbeat for a whole working set in ONE round-trip (task-spor-bulk-claim-renew-apis) — what the client-side claim heartbeat uses instead of one `POST /v1/nodes/{id}/renew` per held node (task-spor-client-heartbeat-bulk-renew), through the **`ids`-omitted arm, with no `session` either** (dec-spor-heartbeat-adopts-blanket-renew-arm): a beat renews the person's whole live working set and never re-acquires what dropped out of it, and it omits `session` because in this arm that field is a FILTER, so sending it would skip the leases claimed outside a session (`spor claim`, `spor dispatch`'s pre-launch claim) and let them lapse mid-session. Two consequences ride with that choice: a beat renews the person's leases in EVERY project (this arm takes no project scope), so a lease nobody releases stops self-healing back into the pool at its TTL while its owner keeps writing anywhere; and since this arm SELECTS on `session` rather than stamping it, a lease's session binding is no longer re-pointed at the last editing session. Two modes, and they differ on auto-reclaim (dec-spor-lease-auto-reclaim-and-deadline-exposure — the ONE exemption, whose intended caller is the heartbeat): **`ids` omitted** enumerates every LIVE Tier-1 lease this caller holds (optionally narrowed to one `session`) and renews them all — the motivating case, one call per heartbeat instead of one per held node — but NEVER reclaims: its contract is "renew what you hold" from a snapshot, so a lease that lapsed (or was taken) lands in `failed` as `lease_lost` and simply drops out of the set, never silently re-acquired; **`ids` supplied** renews exactly that named set (bounded/deduped) with the SAME reclaim semantics as the singular `/renew` — a lapsed lease is auto-reclaimed (a real claim, durable `assigned` edge included), its id lands in `reclaimed`, and only a lease held by someone else still lands in `failed` as `lease_lost` (naming the holder). `session` is forwarded unchanged exactly as the singular `/renew` does — one contested node never costs the whole working set its heartbeat → `{ok: true, status: "renewed"\|"partial"\|"refused", count, renewed: [ids], leases: [...], failed: [...], expires_in_ms?, reclaimed?, skipped_other_session?, skipped_reserved?}`. `expires_in_ms` is the batch's own renewal horizon (soonest deadline among the leases that landed, omitted on an empty/wholly-refused batch); `reclaimed` (explicit-`ids` arm only) lists the ids whose lease had lapsed and was just re-established, so a batch reading "N/N renewed" doesn't hide that one of them was silently taken back off the pool. The two `skipped_*` counts ride ONLY on the enumerate arm: `skipped_other_session` names live leases excluded because they're bound to a different session (a zero renewed count there means "not under this session", not "you hold nothing"), `skipped_reserved` names Tier-2 resumption reservations a blanket heartbeat deliberately leaves parked at their grace-window expiry rather than demoting to a Tier-1 horizon Bulk renewal uses the separate `RenewAllResponse` contract: optional `reclaimed: string[]` lists reacquired node IDs and `reclaimed_after: Record<node-id, "release" | "lapse" | "never_held">` gives each reason. Both fields are absent if none were reacquired; a blanket heartbeat never reclaims. Select the response type by the request route, not by assuming the singular boolean shape. MCP `renew` follows the same split (`id` selects `SingularRenewResult`; `ids` or `all` selects `BulkRenewResult`). Existing clients can ignore these additive fields. |
621
622
  | `POST /v1/nodes/{id}/commits` `{repo, sha}` | post-tool / link-commits | `link_commit`: append `repo@sha` to the node's `commits:` list (kebab-case repo slug, 7–40 lowercase hex, ≤40 commits per node); idempotent, prefix-aware dedup |
622
623
  | `POST /v1/nodes/{id}/elaborations` `{text, note?, date?}` | scripts, mechanical writers filing into a running log | the deterministic elaboration append: the same fold the capture ELABORATE outcome lands through, minus the model — the server appends a dated `> elaboration (date): note` block to the node's body, following the `art-<stem>-<n>` continuation chain to its live tail and, when that tail is at the 8KB body cap, rolling the next continuation part itself and folding there (dec-spor-capture-fold-auto-spill-continuation) → `{status, id, revision, warnings, spilled_from?}`. `id` is where the text LANDED (a continuation when it spilled), `status` the underlying put's token (`updated` in place / `created` on a spill); `200` in place, `201` with `spilled_from` naming the full part when a spill rolled. `date` is `YYYY-MM-DD` (defaults to today); a blank `text` is `422`; a missing target `404`; an elaboration too large for even an empty part is `body_full`. No idempotency of its own — a re-sent elaboration appends again, so a caller that must land exactly once scans the family first (`scripts/harvest-file-datapoints.js` dedups by tenant + window) |
623
624
  | `GET /v1/commits/{sha}?repo=` | `spor blame`/`commits` CLI verb; sessions doing git archaeology | sha → nodes lookup over the `commits:` fields (≥7 hex, abbreviated or full); each match carries `{repo, sha, id, type, title, summary, status, project}` — blame a line, get the why. The `spor blame <sha> [--repo <slug>]` CLI verb (alias `spor commits <sha>`) wraps this remotely and runs the same lookup over the local graph in local mode (`lib/query.js` `lookupCommit`) |
@@ -651,7 +652,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
651
652
  | `POST /v1/agents/{id}/capabilities` `{harnesses?, reachable_mcp?, skills?, plugins?, deny?}` | `spor capabilities publish`, session-start auto-publish | **publish** this box's machine capabilities to the fleet scheduler (task-spor-remote-fleet-scheduler, dec-spor-machine-profile-satisfiability). The remote twin of the machine-local `dispatch.capabilities` map: the server collapses the body with the SAME `effectiveCapabilities()` the client runs (so a raw `{probed,declared,deny}` map or the already-flat axes both work; also accepts a `{capabilities: {...}}` envelope) and stores it BESIDE the agent node (operational store, not the durable git-tracked node — capabilities are machine-local, probe-refreshed, never committed) → `200 {agent, capabilities, published_at, last_seen, published_by, session?, changed}`. Authorized iff the caller **owns** the agent (its `owned-by` edge) OR is the agent itself (a self-publish under an agent token) — else `403`; `404` unknown agent; `422` a malformed map. A publish stamps both `published_at` (when the CAPS last changed) and `last_seen` (last contact); `last_seen` ALSO advances on the cheap `POST .../heartbeat` below, and the host-match keys staleness off `last_seen` not `published_at`. Beyond the manual verb, `session-start` AUTO-publishes here in remote mode whenever a `dispatch.agent` is configured (task-spor-fleet-capabilities-autopublish-session-start) — bounded + fail-open, so every session refreshes this box's caps and last-contact without a manual call; disable with `SPOR_CAPABILITIES_PUBLISH=0` |
652
653
  | `GET /v1/agents/{id}/capabilities` | `spor capabilities show <agent>`, steward fleet view, debugging | read back an agent's published capabilities → `200 {agent, capabilities, published_at, last_seen, published_by, session?}`; `404` if none published. Readable by the **owner**, the **agent itself**, or an **admin** (a stewards→root fleet-capacity view) — else `403`. The CLIENT reader (task-spor-capabilities-read-agent-cli-verb): `spor capabilities show <agent-id>` (`me` = this box's `dispatch.agent`) renders the stored caps + timestamps without raw REST — the read twin of `spor capabilities publish` and the per-agent companion to `spor capabilities hosts`; remote-only, fail-soft |
653
654
  | `POST /v1/agents/{id}/heartbeat` | post-tool mid-session liveness tick | **liveness ping** (task-spor-fleet-scheduler-hardening): refresh this box's `last_seen` WITHOUT re-uploading capabilities — the cheap "still here" signal, decoupled from a caps re-publish, so a box that published once and runs for hours stays a live fleet host. The host-match keys staleness off `last_seen`, so a box that keeps heartbeating is never demoted while a genuinely dead one ages out under `max_age` → `200 {agent, capabilities, published_at, last_seen, …}` (the refreshed record). Same owner/self gate as publish — else `403`; `404` unknown agent OR nothing published yet (publish before heartbeat — liveness without caps is meaningless to the scheduler); `422` a malformed agent id. The CLIENT caller (task-spor-fleet-scheduler-client-heartbeat-tick): the `post-tool` hook ticks this in REMOTE mode whenever a `dispatch.agent` is configured (the SAME opt-in as the session-start auto-publish), piggybacking on write-activity but THROTTLED to one ping per `dispatch.heartbeatIntervalMs` (default 5min) — so a long session keeps `last_seen` fresh between session-starts (which today refresh it the expensive way, via a full re-publish) without re-probing. Bounded + fail-open; disable with `SPOR_HEARTBEAT=0` |
654
- | `GET /v1/profiles/{id}/hosts` `?owner=me\|person-X&max_age=<dur>` | `spor capabilities hosts <profile>`; `spor dispatch` (auto on a FORK B refusal) | **host-match** a `type: profile` against every agent's published capabilities using the SAME pure `satisfies()` matcher the client runs locally → `200 {profile, satisfiable: [{agent, owner, published_at, last_seen, age_seconds}], unsatisfiable: [{agent, owner, published_at, last_seen, age_seconds, reasons}], counts}`. Satisfiable hosts are freshest-first (by `last_seen`); the unsatisfiable carry the matcher's own reasons (the failing atoms), enabling **substitution-free re-routing** — pick a box that satisfies the profile, NEVER substitute a different one (dec-spor-machine-profile-satisfiability FORK B). The CLIENT consumer (task-spor-fleet-scheduler-autoroute-dispatch): `spor capabilities hosts` lists the re-route targets directly, and when `spor dispatch` refuses because THIS box can't satisfy the resolved profile it calls this endpoint and names the satisfiable hosts to re-route to — or, when none satisfy it, escalates to the owner (fail-soft: an unreachable scheduler degrades to a generic hint). **Visibility is steward-scoped** (task-spor-fleet-scheduler-hardening): the whole-fleet view (every member's boxes + caps) is a multi-tenant cross-member disclosure, so an **admin** (stewards→root) sees the whole fleet and may scope to any `owner=person-X`, while an ordinary **member** is scoped to THEIR OWN boxes (default `owner` = the caller's person; an agent token resolves to its owner; `owner=me` is the explicit form) and a member asking for a colleague's `owner=person-X` is `403`. `max_age` (`30m`/`12h`/`7d`/ms) demotes hosts whose `last_seen` is older than it to unsatisfiable (the liveness filter). `404` unknown/non-profile id; `422` bad `max_age`/`owner` |
655
+ | `GET /v1/profiles/{id}/hosts` `?owner=me\|person-X&max_age=<dur>` | `spor capabilities hosts <profile>`; `spor dispatch` (auto on a FORK B refusal) | **host-match** a `type: profile` against every agent's published capabilities using the SAME pure `satisfies()` matcher the client runs locally → `200 {profile, satisfiable: [{agent, owner, published_at, last_seen, age_seconds}], unsatisfiable: [{agent, owner, published_at, last_seen, age_seconds, reasons}], counts}`. Satisfiable hosts are freshest-first (by `last_seen`); the unsatisfiable carry the matcher's own reasons (the failing atoms), enabling **substitution-free re-routing** — pick a box that satisfies the profile, NEVER substitute a different one (dec-spor-machine-profile-satisfiability FORK B). The CLIENT consumer (task-spor-fleet-scheduler-autoroute-dispatch): `spor capabilities hosts` lists the re-route targets directly, and when `spor dispatch` refuses because THIS box can't satisfy the resolved profile it calls this endpoint and names the satisfiable hosts to re-route to — or, when none satisfy it, escalates to the owner (fail-soft: an unreachable scheduler degrades to a generic hint). Its AUTONOMOUS tier (task-spor-fleet-autoroute-auto-tier-consumer, opt-in `spor dispatch --auto-route` / `dispatch.autoRoute`) takes that same host-match and closes the loop with no human step: for a NODE dispatch it hands the item to the freshest satisfying box by writing `assigned -> <host agent>` with the SAME profile pinned as the edge's `profile:` attribute (`POST /v1/nodes/{id}/edges`, §3), so that box's own `spor work` picks it up — routing is explicit assignment (dec-spor-agent-orchestration-layer), so the graph is the handoff and no cross-machine exec channel is involved. It asks `owner=me` so only the caller's OWN boxes are ever routed to (an admin's default whole-fleet view would otherwise hand their work to a colleague's machine), bounds a target's staleness with `dispatch.autoRouteMaxAge` (default `24h`), never routes to the refusing box itself (a stale self-match is skipped), and degrades to the human-tier report on anything it can't act on — no satisfying host (still an escalation), a free-text dispatch with no node to assign, a scheduler outage, or a refused edge write. **Visibility is steward-scoped** (task-spor-fleet-scheduler-hardening): the whole-fleet view (every member's boxes + caps) is a multi-tenant cross-member disclosure, so an **admin** (stewards→root) sees the whole fleet and may scope to any `owner=person-X`, while an ordinary **member** is scoped to THEIR OWN boxes (default `owner` = the caller's person; an agent token resolves to its owner; `owner=me` is the explicit form) and a member asking for a colleague's `owner=person-X` is `403`. `max_age` (`30m`/`12h`/`7d`/ms) demotes hosts whose `last_seen` is older than it to unsatisfiable (the liveness filter). `404` unknown/non-profile id; `422` bad `max_age`/`owner` |
655
656
 
656
657
  Path parameters (node ids, project slugs) must match
657
658
  `^[a-z0-9][a-z0-9-]*$`. Request bodies are capped at 1MB
@@ -670,6 +671,14 @@ anything with a token.
670
671
  | `POST /v1/runs/{id}/steps/{sid}/claim` `{iteration?}` | run worker (workers/shim) | claim a ready step → `{run_id, step, lease, state}`; a step that isn't claimable is a 409 |
671
672
  | `POST /v1/runs/{id}/steps/{sid}/complete` `{lease, status, result?, log?, iteration?}` | run worker (workers/shim) | report a verdict (`status: succeeded \| failed` only — anything else is 422). An expired/superseded lease is `409 lease_expired`; a same-generation retry that disagrees with the recorded outcome is `409 outcome_conflict` — redo the work under a fresh lease |
672
673
  | `GET /v1/runs/{id}` | `spor run status` | full run record: `{run_id, status, project, title, initiator, workflow, workflow_version, lineage, state, revision, timestamps?}` |
674
+ | `POST /v1/executions` `{node_id, factory, gates: [{id, node_id?}], boundary, repo?, machine?, ttl_ms?}` | `spor work` (the factory controller, at the execution hold) | **factory execution state** (task-spor-hosted-factory-execution-state server-side; the CLIENT half is task-spor-client-execution-store-adapter, EXECUTION-STATE.md in spor-server is the contract): open — or idempotently re-read — the execution for a work item under a factory, PINNING the item's, the factory's and each named gate node's revision at open, taking the first lease at **fence 1** → `200 {execution, fence, replayed?}`. The execution id is content-addressed over `(tenant, node_id, factory, pipeline_attempt)` — `exec-<16 hex>` of the NUL-joined tuple — with the tenant derived from the identity (never the body) and the attempt from the item's own settled history, so a retried open lands on the same record (`replayed: true`, the fence echoed only to its owner). A live execution under a DIFFERENT factory is `409 execution_open`; a server with no execution store configured answers `503 unavailable`, and an older server that does not serve the route at all 404s — the client falls back to its machine-local store on either and stamps which store it opened (`impl_claim.store`); a merely UNREACHABLE server is a refusal, never a fallback |
675
+ | `GET /v1/executions?node_id=&stage=&limit=` | `spor executions`, monitors | the tenant's executions, newest-updated first, each with the derived `boundary_reached`/`terminal` → `{executions, count}`. The read half of "a dead worker cannot leave invisible gating slots": every live execution is listed with its owner, lease expiry and stage, renewed or not |
676
+ | `GET /v1/executions/{id}` | `spor executions <exec-id>` | one execution: `spec_version`, the pinned `item`/`factory`/gates, `stage`, every attempt, every gate's verdict, the candidate, the owner `{worker, machine, lease_expires_at, fence}`, `completion {boundary, written_at, resolver}`, `seq`, plus the derived `boundary_reached` and `terminal`. A cross-tenant id is `404`, not `403` |
677
+ | `GET /v1/executions/{id}/events` | `spor executions <exec-id> --events` | the durable ordered event log, oldest first → `{events, count}` — the record of last resort the record is a materialized view of |
678
+ | `POST /v1/executions/{id}/claim` `{takeover?, machine?, ttl_ms?}` | the controller at every pipeline pass (launch, resume, re-gate) | take the lease: a free, expired or same-owner execution is claimable (a same-owner re-claim KEEPS its fence; a different worker's advances it); an unexpired lease held by someone else is `409 already_owned` (or `409 lease_live` with `takeover: true`) and is never stolen. The client treats `already_owned` as the foreign hold H2 refuses, and settles a resumed pipeline `blocked` naming the holder rather than judging an execution it does not own |
679
+ | `POST /v1/executions/{id}/renew` `{fence, ttl_ms?}` | the controller's per-pass heartbeat, and the completion write's ownership check | push the lease out without moving the fence; `409 fence_stale` after a takeover (a fenced-out worker cannot resurrect its lease), `409 lease_expired`/`not_owned` otherwise. The client renews every execution it holds once per `spor work` pass, and CONFIRMS ownership with exactly this call before writing a resolving edge |
680
+ | `POST /v1/executions/{id}/release` `{fence}` | `spor release --execution` | hand the execution back; only the live fence-holder may |
681
+ | `POST /v1/executions/{id}/events` `{fence, event: {type, …, idempotency_key?}}` | the controller, at every §7.3 transition | record one event of the fixed vocabulary (`stage.started`, `stage.observed`, `candidate.submitted`, `gate.started`, `gate.settled`, `rescue.started`, `integration.started`, `integration.settled`, `escalation.filed`, `completion.written`) under the fence → `200 {execution, seq, idempotency_key}`. A key already on the durable log is a no-op `{replayed: true}` — checked BEFORE ownership, so a retry from a fenced-out worker reads "already recorded", not a conflict; a terminal execution answers `409 execution_terminal` to everyone. `422 invalid_event`/`unknown_gate` for an off-vocabulary or off-pinned-list event; `409 candidate_conflict`/`no_candidate`/`gates_unsettled`/`boundary_not_reached` for a transition the record's state refuses. The client stamps the deterministic key on every event it sends (EXECUTION-STATE.md §5's table), spools an undeliverable one to a per-execution OUTBOX (`journal/executions/<tenant>/exec/<id>.outbox.jsonl`) before the attempt, and replays the outbox in order ahead of every later event — so a partition keeps its durable local evidence and replay converges. The server's own enforcement rides beside this door: a `put_node`/`add_edge` that ADDS a `resolves`/`answers` edge into an item whose live execution has not reached its pinned boundary is `409 execution_boundary` (EXECUTION-STATE.md §6.1) |
673
682
 
674
683
  ## 4. Identity and auth
675
684
 
@@ -920,6 +929,19 @@ Failure policy: **fail open, never block** — a hook must never break a
920
929
  session; connection refused, timeout, 5xx, and auth failure all collapse to
921
930
  "the graph has nothing for you".
922
931
 
932
+ **The execution store (task-spor-client-execution-store-adapter).** The factory
933
+ controller's coordination state — one execution per (item, factory, pipeline
934
+ attempt), a fenced lease, an ordered idempotent event log, a pinned completion
935
+ boundary — lives in the SERVER's `/v1/executions` store in remote mode and in a
936
+ shape-compatible machine-local store (`$SPOR_HOME/journal/executions/<tenant>/`,
937
+ the server's own layout and ids, tenant `local`) in personal mode; `spor
938
+ executions` reads either, WORKERS.md §10.15 is the contract. Two knobs:
939
+ `execution.leaseTtlMs` (`SPOR_EXECUTION_TTL`, default 15min, clamped to the
940
+ server's [60s, 8h]) is the lease a claim asks for, `execution.timeoutMs`
941
+ (`SPOR_EXECUTION_TIMEOUT`, default 8s) bounds each round trip. Neither changes a
942
+ factory that declares no `completion: {by: controller}` — a legacy run opens no
943
+ execution.
944
+
923
945
  Because fail-open hides degradation by design — a crashing engine and a
924
946
  quiet success look identical, and stranded captures pile up unseen in
925
947
  `outbox/dead/` — the client carries three operability surfaces
@@ -971,6 +993,20 @@ Contract:
971
993
  `nodes/` and brief `history/` are committed. The SessionEnd distiller leaves
972
994
  distilled nodes **uncommitted** (for the human PR flow) instead of
973
995
  auto-committing when the graph home is the same git repo as the code repo.
996
+ - **Factory candidate bundles are a THIRD, separate home** — they do not
997
+ follow this marker binding at all
998
+ (task-spor-candidate-store-home-vs-shared-graph-home-trap). A factory's
999
+ `implementation.candidate.bundle_store` defaults to
1000
+ `file://<userConfigHome>/candidates`, i.e. this machine's personal env home,
1001
+ never this marker's shared graph home — binary bundle artifacts have no
1002
+ business riding a shared repo's git flow by default. Its `.gitignore` line
1003
+ is therefore maintained separately, at whichever directory the store
1004
+ actually resolves to (`ensureStoreGitignore` in
1005
+ `lib/shell/candidate-publish.js`), only when that directory is itself a git
1006
+ working tree — so an operator who deliberately declares `bundle_store`
1007
+ *inside* this marker home gets the same hygiene here, and everyone else's
1008
+ personal home gets it there instead, rather than a fixed line landing in
1009
+ whichever home happens to be wrong.
974
1010
 
975
1011
  ### 6.2 Multi-tenant credentials (the credential store + tenant selector)
976
1012
 
@@ -1004,6 +1040,21 @@ machine-local — never committed, always in the shared-graph `.gitignore`):
1004
1040
  `.spor` `org:` marker (committable, nearest-ancestor — the remote-mode sibling
1005
1041
  of the `graph:` binding in §6.1) > store `default` > legacy flat config.json
1006
1042
  `server`+`token` (migrated on read) > local.
1043
+ - **Unknown `--org` refuses.** `--org` is the one selector that does not fall
1044
+ through: naming an org with no stored credential is an error (exit 1, the
1045
+ stored orgs listed), not a quiet demotion to the active tenant — which would
1046
+ answer a read from the wrong graph and land a write in it while the operator
1047
+ believes they are scoped elsewhere
1048
+ (issue-spor-cli-unrecognized-org-fallback). Only the credential-**acquiring
1049
+ invocations** are exempt — `spor login`, `spor join`, `spor auth login` —
1050
+ because naming an org you have no credential for **yet** is what those are
1051
+ for; the other `auth` subcommands (`logout`, `switch`, `whoami`, `list`)
1052
+ refuse like any other verb, since acting on the active tenant is exactly the
1053
+ hazard. An `--org` given an **empty** value (an unset shell variable, in
1054
+ either the `--org ""` or the dangling `--org` spelling) refuses everywhere,
1055
+ acquisition included: it is malformed input, not "use the default". The
1056
+ ambient selectors (`SPOR_ORG`, the repo `org:` marker) still fall
1057
+ through — they also ride the fail-open hook engines.
1007
1058
  - **Refresh.** A 401/403 on a tenant carrying a `refresh_token` transparently
1008
1059
  refreshes against its issuer (`grant_type=refresh_token`) and retries once.
1009
1060
  - **Byte-identical.** With no credential store and only a flat
package/GRAPH.md CHANGED
@@ -96,6 +96,21 @@ Rules:
96
96
  viewer — the renew-the-cert / schedule-the-audit shape, kept with the
97
97
  work instead of in one person's calendar. Everything else (compiles,
98
98
  briefings, edges) sees a dormant node normally.
99
+ - `execution` / `execution_at` are optional flat scalars a factory worker
100
+ running under `completion.by: controller` stamps on the work item it is
101
+ about to implement (the **execution hold**, WORKERS.md §10.13,
102
+ FACTORY-IMPLEMENTATION-STAGE.md §4.5): `execution: exec-<16 hex>` names the
103
+ execution, `execution_at` when it was stamped, and a person's release adds
104
+ `execution_released_by`. While `execution:` is present the item is HELD:
105
+ `resolutionMap` counts no inbound resolving edge into it and `isLive` reads
106
+ it live whatever its status says, so a premature `resolves` edge or a
107
+ hand-flipped `done` retires nothing until the controller's completion write
108
+ removes the key in the same write as the terminal status. The seed task and
109
+ issue schemas refuse a completion status on a node still carrying the key,
110
+ and their `get()` rides `execution_hold` (the id, the stamp, the inert
111
+ resolvers) instead of `resolution`. A person ends a hold explicitly with
112
+ `spor release <id> --execution <exec>`; never hand-delete the key on a node a
113
+ live worker holds.
99
114
  - One fact per node. If you're writing "also" a lot, split it.
100
115
  - A node file the parser cannot read is **skipped, not fatal**
101
116
  (dec-spor-buildgraph-per-node-fault-isolation). `loadGraph` isolates each
@@ -340,6 +355,29 @@ and hook are pinned together by `test/seed-declarative-status-policy.test.js`,
340
355
  which drives every seed schema's hooks through the sandbox and fails if the two
341
356
  disagree; read the live values with `spor schema <type>`.
342
357
 
358
+ **The attestation path — how completion is verified**
359
+ (issue-spor-offline-check-get-hook-resolution-proxy). A separate top-level
360
+ `resolution` block, one key: `resolution.verified_by`, either `edge` (only a
361
+ live inbound resolving `resolves`/`answers` edge attests this type's
362
+ completion — task, issue, question, incident) or `status` (the node's own
363
+ terminal status retires it — decision, finding, capture-pending, and every
364
+ other type). Remote dispatch's terminal-state verify reads it to decide what
365
+ it is looking for on a finished run (WORKERS.md §7), through
366
+ `registry.isEdgeVerified(type)` locally and the `resolution` key of
367
+ `GET /v1/schema` remotely. It USED to infer the answer from whether the
368
+ schema attached a `get()` hook at all: that verb is the general-purpose
369
+ read-time enrichment hook, so its presence was never evidence about
370
+ resolution, and the first status-retired type to adopt one for anything else
371
+ (a held-note, an execution hold) would have flipped to edge-verified — a run
372
+ that genuinely finished the work then reads as unattested, files a report
373
+ claiming a missing edge that type never has, and hands its lease back. A
374
+ schema declaring neither value still falls back to that proxy, so a resident
375
+ override written before the key existed is honored unchanged; every schema in
376
+ the seed and candidate packs declares one, pinned against its own hook by
377
+ `test/seed-declarative-status-policy.test.js`. Like the completion policy
378
+ above, **declaring it gates nothing** — it tells a reader what the hooks
379
+ already do.
380
+
343
381
  Otherwise there is **no declarative field list and no status enum.** Custom fields are free-form: any flat frontmatter key the
344
382
  regex parser accepts (simple `key: value` scalars, YAML-folded multi-line
345
383
  values, `pin:`/`exclude:` inline lists, `- {type: X, to: Y}` edges — and nothing
@@ -458,12 +496,53 @@ beside `gates`, parsed the same fail-closed way — and an optional `rescue`
458
496
  block (task-spor-factory-rescue-lane): a strong-model profile the runner
459
497
  dispatches at a gate's exhaustion BEFORE any human escalation, to diagnose,
460
498
  fix and file factory-improvement tasks, re-running the gates on what it
461
- commits (WORKERS.md §10.10). Both `gate` and `factory` are
499
+ commits (WORKERS.md §10.10). Two further optional blocks declare the step
500
+ BEFORE the gates, and who writes the outcome of them all
501
+ (dec-spor-factory-implementation-stage-contract): `implementation` is the stage
502
+ that PRODUCES the candidate the gates judge — the one step of the pipeline a
503
+ factory could not describe, its budget the worker's global watchdog and its
504
+ only retry a machine-local cooldown while every judging step was declared data
505
+ — and `completion` says WHO writes the resolving edge that retires the work
506
+ item, and WHEN. The stage routes by PROFILE and by nothing else:
507
+ `command`/`args`/`argv`/`bin`/`exec`/`entrypoint`/`env`/`report`/`session`/
508
+ `launch_mode`/`identity_mode` are refused BY NAME rather than dropped, because
509
+ a graph write must never define what a machine executes
510
+ (dec-spor-declarative-harness-machine-binds-execution) — a bespoke implementer
511
+ is a `dispatch.harness.<id>` declaration on the MACHINE. Beside `profile` it
512
+ declares `instructions` (appended to the worker contract, never replacing it),
513
+ `author_checks` (the command gate ids the implementer runs itself — default
514
+ NONE, since the gate re-runs the suite from the trusted ref regardless and an
515
+ author run of the same suite is duplicate spend), `budget`
516
+ (`run_max_ms`/`run_idle_ms` INHERIT the worker's own ceilings when undeclared,
517
+ `attempts` is the code pool), `retry` (the separate infrastructure pool an
518
+ outage spends instead of the code's) and `candidate` (`require_clean`, and
519
+ `publish: bundle|branch|both` with `bundle_store`/`remote` — a candidate always
520
+ carries a portable reference, so there is deliberately no `none`).
521
+ `completion.by` is `agent` (today's behavior: the implementer writes the edge
522
+ and flips the status) or `controller` (the runner writes both at the boundary,
523
+ so a pending or refused pipeline releases none of the item's dependents — the
524
+ `execution` hold above is what keeps a premature edge inert meanwhile), and it
525
+ defaults to `controller` for a factory whose `implementation` block PARSES,
526
+ `agent` otherwise. `completion.after` is the boundary — `gates` or
527
+ `integration` — defaulting to the LAST stage the factory actually declares so
528
+ it is reachable by construction; naming `integration` with no integration block
529
+ is fatal, since a boundary that can never be reached leaves every item
530
+ unresolved forever. WORKERS.md §10.12-§10.14 documents what runs today (the
531
+ candidate, the execution hold, the controller's completion write, candidate
532
+ publication to a `bundle`/`branch`, and `candidate.require_clean`'s own
533
+ refusal at the pin) and what is parsed and pinned but not yet executed (the
534
+ stage's own dispatch loop). Both `gate` and `factory` are
462
535
  `capturable: false` (the distiller never drafts one: a factory changes what a
463
536
  worker will accept, so it is written deliberately) and both arrive by adoption
464
537
  rather than in the seed, for the same reason. WORKERS.md §10 documents the
465
538
  runtime contract; the payload keys are documented on the candidate nodes
466
- themselves.
539
+ themselves. Every declared count or ms field in a gate/factory payload
540
+ (`cycles`, `reruns`, `timeout_ms`, `poll_ms`, `approval_timeout_ms`,
541
+ `await_ms`, rescue's `attempts`) is parsed through a guarded helper
542
+ (`countOr` in `lib/kernel/gates.js`): a value that isn't
543
+ readable as a number — blank, `null`, `false`, an array, or the wrong type —
544
+ takes the field's documented default rather than silently clamping to the
545
+ floor, while a readable but out-of-range number still clamps.
467
546
 
468
547
  A complete worked example — a `escalation` type with a required `severity`
469
548
  field (enforced in `validate`) and an `open → mitigated → closed` status machine
@@ -713,7 +792,11 @@ edges:
713
792
  marker dir) and overrides `SPOR_HOME` in local mode; a contributor with their
714
793
  own personal `SPOR_HOME` still inherits the shared graph inside the repo. See
715
794
  API.md §6.1 for the full contract (precedence, the generated `.gitignore`, and
716
- the distiller's PR-flow behavior).
795
+ the distiller's PR-flow behavior). A factory's candidate bundle store does
796
+ **not** follow this binding — it stays machine-local under `userConfigHome()`
797
+ by default even when a `graph:` binding is active, so the two homes can
798
+ legitimately differ; API.md §6.1 says where each home's `.gitignore` hygiene
799
+ actually lands.
717
800
  - **Git worktrees** resolve to their main repo, not the worktree directory's
718
801
  basename. A linked worktree shares the main repo's root-commit sha and
719
802
  remotes, so inferring identity from its (markerless, often throwaway-named)
@@ -983,11 +1066,21 @@ date: 2026-06-18
983
1066
  launcher the client ships no in-code adapter for — a team's modified build,
984
1067
  an internal wrapper — in which case the profile still carries nothing but the
985
1068
  id, and each machine that should run it declares `dispatch.harness.<id>`
986
- ({`command`, `args`, `label`, `report`, `session`}) in its own
1069
+ ({`command`, `args`, `label`, `report`, `session`, optional `posture`}) in its own
987
1070
  `$SPOR_HOME/config.json`. **A graph write must never define what a machine
988
1071
  executes:** a profile carrying `command`, `args`, `argv`, `bin`, `exec`,
989
1072
  `entrypoint`, `env`, `report`, `session`, `launch_mode` or `identity_mode` is
990
- REFUSED by `spor dispatch`, not honoured and not silently ignored. So an org
1073
+ REFUSED by `spor dispatch`, not honoured and not silently ignored.
1074
+ A declaration may add `posture: "unattended" | "attended" | "read-only"`
1075
+ to describe the permissions behavior of its machine-bound command and args.
1076
+ The declaration grants no permissions and changes no argv; the operator
1077
+ must configure the launcher to enforce the stated posture. Preflight uses
1078
+ the same posture rules as built-in adapters: unattended workers require
1079
+ unattended writes, while `--read-only` requires a read-only launcher.
1080
+ Stricter invocation flags are never silently discarded or widened; foreign
1081
+ harness flags still refuse. Omission preserves the existing operator-bound
1082
+ warning and behavior; unknown values are rejected. The capability probe's
1083
+ `machine.harnesses` shape is unchanged. A team
991
1084
  can publish a profile naming an unadapted harness and only the boxes whose
992
1085
  OWNER bound that id will take the work (a machine with no binding fails
993
1086
  satisfiability below).
package/QUEUE.md CHANGED
@@ -431,7 +431,11 @@ signals via its schema's `queueSignals()`:
431
431
  `get()` hook, which rides a `held` note along on `get_node` (it shares the
432
432
  reference-edge narrowing; it has no `front`, so the floor has no twin there).
433
433
  - **staleness** — anchors superseded or gone; high staleness suggests
434
- closing, not doing.
434
+ closing, not doing. A node's own outbound `supersedes` edges are excluded
435
+ from the fraction (numerator and denominator both) — their target is
436
+ retired by definition, so counting them as rot would permanently inflate
437
+ staleness for a consolidator node doing exactly what a `supersedes` edge is
438
+ for (issue-spor-queue-staleness-supersedes-false-positive).
435
439
  - **cold_neighbors** — the count of the node's traversable neighbors whose
436
440
  git-derived `updated_at` is newer than its own: a node that went cold while its
437
441
  neighborhood kept moving ("context moved around it"). Fed by the
package/README.md CHANGED
@@ -237,6 +237,14 @@ To see what would be launched without starting anything:
237
237
  spor dispatch issue-86 --print
238
238
  ```
239
239
 
240
+ `--print` is a full diagnostic, not just a prompt preview: it runs the same
241
+ resolution path a real dispatch does and reports the **effective tenant and the
242
+ selector that chose it**, the profile and harness, the **write posture**, the
243
+ **candidate workspace and whether it is isolated**, and a `preflight:` verdict
244
+ saying whether an unattended worker would be refused there and why. It performs
245
+ none of a dispatch's side effects — no claim, no child, no worktree, no config
246
+ write — and never echoes a credential.
247
+
240
248
  To provide your own prompt wrapper:
241
249
 
242
250
  ```bash
@@ -285,30 +293,46 @@ Terminal records age out after `dispatch.runRetentionMs` (default 14 days).
285
293
 
286
294
  That state describes how the **process** ended. Alongside it every run also
287
295
  carries its **outcome** — what the run did to the graph — as exactly one of
288
- `resolved`, `reported`, or `failed`:
296
+ `resolved`, `reported`, `declined`, or `failed`:
289
297
 
290
298
  | outcome | meaning |
291
299
  |---|---|
292
- | `resolved` | the graph itself shows a live resolving edge (`resolves`/`answers`) onto the target node. Verified by re-reading the node after the run, never inferred from an exit code and never taken from the agent's own word |
293
- | `reported` | no resolution, but the agent left a final report. It is filed as an artifact node linked to the target (`relates-to`) and **then** the lease is released, so the item returns to the queue carrying the work instead of vanishing into a dead run. `report_node_id` names the artifact — a filed report always reads `reported`, enforced or not |
294
- | `failed` | no resolution and no report filed — a launch failure, a crash before any report, an empty one, or a graph that refused the write. `terminal_note` carries the failure note. The lease is released, except where the report could not be filed (see the ordering rule below) or the target was one this runner cannot judge |
300
+ | `resolved` | the graph itself attests completion — a live resolving edge (`resolves`/`answers`) onto the target node, or, for a type retired by its own status rather than by an edge, a status that has reached that type's terminal partition. Verified by re-reading the node after the run, never inferred from an exit code and never taken from the agent's own word |
301
+ | `reported` | no attestation of completion, but the agent left a final report. An enforced run files it as an artifact node linked to the target (`relates-to`) and **then** hands the lease back, so the item returns to the queue carrying the work instead of vanishing into a dead run. `report_node_id` names the artifact — a filed report always reads `reported`, enforced or not; the converse does not hold, since an unenforced `reported` filed nothing |
302
+ | `declined` | the agent declared the ITEM wrong rather than the work unfinished, by making the first line of its final report `DECLINED: <reason>`. An enforced run files the reason as a `finding` on the target, clears the target's `readiness: agent` stamp, and hands the lease back — the item goes to triage, never into a gate. The state is the agent's own declaration, so it stands whether or not those three land: an unenforced decline performs none of them, a finding the graph refuses leaves the lease deliberately held, and a refused readiness clear is recorded rather than fatal |
303
+ | `failed` | no attestation and no report filed — a launch failure, a crash before any report, an empty one, or a graph that refused the write. `terminal_note` carries the failure note. An enforced run hands the lease back, except where the report (or a decline's finding) could not be filed at all — see the ordering rule below |
295
304
 
296
305
  The ordering is the contract: the report is filed before the lease goes back to
297
306
  the pool, so an interrupted run can leave a held lease with the report filed but
298
307
  never a released lease with nothing attached.
299
308
 
309
+ Those legs are attempts, not postconditions of the outcome: each records its own
310
+ verdict on the run record rather than being implied by the state. `report_node_id`
311
+ / `finding_node_id` are present only where the artifact actually landed,
312
+ `readiness_cleared` says whether the stamp was cleared, and `lease_released` says
313
+ whether the server *confirmed* the handback — a `false` there is an unconfirmed
314
+ handback, not proof the lease is still held, and the remedy reconciles either way
315
+ (`spor release <id>` is idempotent, and a claim someone else now holds answers
316
+ `409`). WORKERS.md §8 is the field-by-field contract.
317
+
300
318
  Enforcement covers **supervised** launches (Claude Code, Codex, OpenCode, Copilot
301
- CLI — every built-in) against a team graph, targeting a node type whose
302
- completion is a resolving edge (`task`, `issue`, `question`, `incident`). A
319
+ CLI — every built-in). A target's TYPE never costs a run its enforcement: both
320
+ attestation paths — the resolving edge, and the terminal own-status a
321
+ status-only type is retired by — are judged, and a target that either path finds
322
+ unfinished takes the same file-then-hand-back route, whichever one judged it.
323
+ What does cost it is a posture where nothing could be verified at all: a
303
324
  native-background run (`spor dispatch --bg`, the opt-in `claude --bg` launch), a
304
- local-mode dispatch, a free-text dispatch, a target retired by status instead of
305
- by an edge, and a run whose graph could not be reached are all classified
306
- best-effort and marked `terminal_enforced: false` — an unenforced run can never
307
- read `resolved`, and only an **enforced** `reported` promises a `report_node_id`
308
- (an unenforced run that merely ended cleanly reads `reported` with no artifact).
309
- A report is still filed wherever one exists and the graph is reachable, including
310
- for a target this runner cannot judge — the verdict is scoped, the agent's work
311
- reaching the graph is not.
325
+ free-text dispatch with no target node, and a graph that could not be reached are
326
+ classified best-effort and marked `terminal_enforced: false` — an unenforced run
327
+ can never read `resolved`. Local mode is not excluded: it has no server door to
328
+ file a report or hand a lease back through, but it does have a graph, so a local
329
+ target that reads attested complete is an enforced `resolved`, and only its other
330
+ outcomes are unenforced. A report is still filed wherever one exists and the
331
+ graph is reachable. Filing sits downstream of that verify leg, so a record this
332
+ client writes unenforced today carries no `report_node_id` — but reach for the
333
+ artifact by testing that key's presence, not by testing `terminal_enforced`,
334
+ which is the flag for whether to trust `terminal_state` (WORKERS.md §8 has the
335
+ retained-record case that distinction exists for).
312
336
  `spor runs` prints the outcome (tagging `(unenforced)`), the note, and the report
313
337
  artifact id; `spor runs --json` carries the same fields on each record.
314
338
 
@@ -321,7 +345,7 @@ routed profile, waits for its **terminal state**, and goes round again.
321
345
  ```bash
322
346
  spor work # work the whole queue, one run at a time
323
347
  spor work --project spor --concurrency 2 # two runs in flight, scoped to one project
324
- spor work --once --print # show scope, pacing and candidates; launch nothing
348
+ spor work --once --print # tenant, posture, workspace isolation, gate coverage, candidates — launch nothing
325
349
  ```
326
350
 
327
351
  It is pull, not push: nothing schedules a worker, it takes work. That is safe
@@ -336,6 +360,16 @@ It adds no guards of its own. Every launch goes through the same code path as
336
360
  this box cannot satisfy (never substituted), a profile that tries to declare
337
361
  what to execute, the same-machine duplicate guard, the auto-claim, worktree
338
362
  isolation and the terminal-state contract all apply exactly as they do one-shot.
363
+ Two of those guards exist for the unattended case specifically (WORKERS.md §3.1)
364
+ and are checked **before the claim**: the resolved harness must have a
365
+ non-interactive **write posture** — a Claude Code worker with no
366
+ `--permission-mode` is refused rather than launched into a run where every write
367
+ comes back permission-blocked, and preflight never sets a posture for you — and
368
+ the **candidate workspace** must be free of other live writers, which with
369
+ `dispatch.worktree` off means one dispatch at a time per checkout (turn
370
+ `dispatch.worktree` on before running a worker at `--concurrency` above 1). (A
371
+ `dispatch.worktreeSetup` hook does not turn isolation on; declaring one without
372
+ `dispatch.worktree` is diagnosed, not silently honoured by halves.)
339
373
  Selection is the same filtered page `--from-queue` picks its one item from,
340
374
  minus anything whose derived readiness is `human` — a worker never claims work
341
375
  meant for a person — and minus anything already in flight on this machine. An
@@ -412,6 +446,26 @@ nodes. It also maintains one from its own telemetry ("why did the last three
412
446
  fail review"), and seeds a test-writer lane when there is no acceptance suite
413
447
  to gate on yet. It authors data only; enforcement stays in `spor work`.
414
448
 
449
+ A factory that completes items itself (`completion: {by: controller}`) keeps
450
+ its coordination state in an **execution store**: one execution per item and
451
+ pipeline attempt, pinned to the definition it was opened under, leased with a
452
+ fence, moved by an ordered idempotent event log, and completed only at its
453
+ declared boundary. On a team server that store is the server's own
454
+ (`/v1/executions`, where a resolving edge into a held item is refused at the
455
+ door); in personal mode it is a shape-compatible local store under your Spor
456
+ home. `spor executions` reads either:
457
+
458
+ ```bash
459
+ spor executions # every execution this box drives
460
+ spor executions --node task-x # one item's executions
461
+ spor executions exec-4da6d4763543a301 --events
462
+ ```
463
+
464
+ A worker that loses its lease — its box went quiet and another took the
465
+ execution over — never writes the resolving edge on the strength of its local
466
+ state; what it recorded while partitioned is replayed idempotently when it
467
+ reconnects. [WORKERS.md](WORKERS.md) §10.15 has the contract.
468
+
415
469
  ### Choosing a harness
416
470
 
417
471
  By default, `spor dispatch` launches a Claude Code agent in headless print mode
@@ -465,12 +519,14 @@ ones (`--permission-mode`, `--agent`) are mutually exclusive — passing the
465
519
  wrong one for the resolved harness is a hard error, so a dispatch can't launch
466
520
  half-configured for the wrong CLI. The one exception: `--permission-mode
467
521
  bypassPermissions` against a Codex profile has a real Codex equivalent
468
- ("run fully unattended"), so instead of erroring it translates to `--sandbox
469
- danger-full-access --approval-policy never` (an explicit `--sandbox`/
470
- `--approval-policy` you also pass wins over that default) and prints a loud
522
+ ("run fully unattended"), so instead of erroring it translates to exactly
523
+ `--sandbox danger-full-access --approval-policy never` and prints a loud
471
524
  warning naming the translation — so an orchestrator or script that passes the
472
- same bypass flag to every dispatch regardless of harness keeps working.
473
- Every other permission-mode value still hard-errors against Codex.
525
+ same bypass flag to every dispatch regardless of harness keeps working. The
526
+ translation is fixed: an explicit `--sandbox`/`--approval-policy` beside the
527
+ bypass is a contradiction and is refused, not an override (say the posture in
528
+ Codex flags alone if you want a different one). Every other permission-mode
529
+ value still hard-errors against Codex.
474
530
 
475
531
  The harnesses do not all confine a run the same way. Codex dispatch defaults to
476
532
  `--sandbox workspace-write`, so its filesystem reach is bounded. OpenCode
@@ -866,3 +922,34 @@ Spor is licensed under Apache-2.0. See `LICENSE` and `NOTICE`.
866
922
 
867
923
  Contributions are welcome under inbound = outbound Apache-2.0.
868
924
 
925
+
926
+ To cancel a factory execution still owned by another worker or machine, use the
927
+ explicit person door:
928
+
929
+ ```sh
930
+ spor release <node-id> --execution <execution-id> --force --reason "Why this execution must stop"
931
+ ```
932
+
933
+ This requires a person credential on a server. Agent credentials cannot use the
934
+ command, including an agent owned by that person. Authorization uses the existing
935
+ tenant graph boundary; it does not introduce a separate project ACL. In local
936
+ mode, the graph home's Git email must bind to a person node and `dispatch.agent`
937
+ must be unset. That local check trusts filesystem/configuration access; it is not
938
+ cryptographic proof of a human at the keyboard.
939
+
940
+ The command inspects the exact execution, then releases it only if its observed
941
+ state is unchanged. The journal records the person, time, reason, prior owner and
942
+ fence. Late writes from the old worker are refused; release does not mark the
943
+ work complete. The execution acknowledgement precedes graph hold cleanup, and
944
+ cleanup never clears a different execution's hold. No local dispatch record is
945
+ required. If the response is lost or graph cleanup fails, a credential-bound
946
+ cleanup intent stays in `journal/person-force-release` under the user config
947
+ home. Repeat the same explicit command and reason to recover. Receipt recovery
948
+ only reads the authoritative acknowledgement and retries exact graph cleanup;
949
+ it never submits a force release unattended.
950
+
951
+ Local execution mutations use a filesystem lock across processes. A live writer
952
+ keeps its lock regardless of elapsed time; a verifiably dead process can be
953
+ recovered. Malformed ownership or an abandoned breaker fails closed. Stop all
954
+ local execution writers before manually removing such a lock from the execution
955
+ item index directory; ordinary execution reads never repair or delete it.