@sporhq/spor 0.1.0 → 0.2.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 (106) hide show
  1. package/API.md +359 -0
  2. package/GRAPH.md +391 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +13 -0
  5. package/QUEUE.md +594 -0
  6. package/README.md +233 -7
  7. package/TRADEMARKS.md +102 -0
  8. package/adapters/README.md +58 -0
  9. package/adapters/codex/README.md +64 -0
  10. package/adapters/codex/hooks.json +34 -0
  11. package/adapters/copilot/README.md +40 -0
  12. package/adapters/copilot/spor.json +14 -0
  13. package/adapters/cursor/README.md +34 -0
  14. package/adapters/cursor/hooks.json +14 -0
  15. package/adapters/gemini/README.md +60 -0
  16. package/adapters/gemini/gemini-extension.json +5 -0
  17. package/adapters/gemini/hooks/hooks.json +33 -0
  18. package/adapters/opencode/README.md +51 -0
  19. package/adapters/opencode/spor.js +158 -0
  20. package/agents/backfill.md +41 -0
  21. package/bin/spor +6 -0
  22. package/bin/spor-hook +7 -0
  23. package/bin/spor-hook.cmd +3 -0
  24. package/bin/spor-hook.js +236 -0
  25. package/bin/spor.cmd +3 -0
  26. package/bin/spor.js +985 -0
  27. package/bin/substrate-hook +6 -0
  28. package/bin/substrate-hook.cmd +3 -0
  29. package/bin/substrate-hook.js +5 -0
  30. package/hooks/hooks.json +51 -0
  31. package/lib/commit-inference.js +61 -0
  32. package/lib/compile.js +96 -0
  33. package/lib/config.js +270 -0
  34. package/lib/cost.js +180 -0
  35. package/lib/graph.js +108 -0
  36. package/lib/kernel/commit-inference.js +337 -0
  37. package/lib/kernel/graph.js +960 -0
  38. package/lib/kernel/queue.js +556 -0
  39. package/lib/kernel/registry.js +441 -0
  40. package/lib/kernel/resolution.js +71 -0
  41. package/lib/queue.js +59 -0
  42. package/lib/registry.js +4 -0
  43. package/lib/remote.js +66 -0
  44. package/lib/resolution.js +4 -0
  45. package/lib/sandbox.js +106 -0
  46. package/lib/seed/schema-artifact.md +24 -0
  47. package/lib/seed/schema-briefing.md +25 -0
  48. package/lib/seed/schema-capture-pending.md +66 -0
  49. package/lib/seed/schema-correction.md +25 -0
  50. package/lib/seed/schema-decision.md +51 -0
  51. package/lib/seed/schema-edge-answers.md +24 -0
  52. package/lib/seed/schema-edge-assigned.md +22 -0
  53. package/lib/seed/schema-edge-blocks.md +22 -0
  54. package/lib/seed/schema-edge-compiled-for.md +21 -0
  55. package/lib/seed/schema-edge-constrained-by.md +21 -0
  56. package/lib/seed/schema-edge-decided-in.md +21 -0
  57. package/lib/seed/schema-edge-derived-from.md +22 -0
  58. package/lib/seed/schema-edge-governed-by.md +21 -0
  59. package/lib/seed/schema-edge-grouped-under.md +28 -0
  60. package/lib/seed/schema-edge-mentions.md +21 -0
  61. package/lib/seed/schema-edge-performs.md +24 -0
  62. package/lib/seed/schema-edge-relates-to.md +22 -0
  63. package/lib/seed/schema-edge-resolves.md +21 -0
  64. package/lib/seed/schema-edge-routed-to.md +22 -0
  65. package/lib/seed/schema-edge-shaped-by.md +21 -0
  66. package/lib/seed/schema-edge-stewards.md +21 -0
  67. package/lib/seed/schema-edge-supersedes.md +23 -0
  68. package/lib/seed/schema-edge-triggered-by.md +26 -0
  69. package/lib/seed/schema-finding.md +23 -0
  70. package/lib/seed/schema-incident.md +29 -0
  71. package/lib/seed/schema-issue.md +83 -0
  72. package/lib/seed/schema-norm.md +24 -0
  73. package/lib/seed/schema-person.md +37 -0
  74. package/lib/seed/schema-project.md +48 -0
  75. package/lib/seed/schema-question.md +58 -0
  76. package/lib/seed/schema-repo.md +72 -0
  77. package/lib/seed/schema-task.md +80 -0
  78. package/lib/seed/schema-workflow-run.md +126 -0
  79. package/lib/seed/schema-workflow.md +82 -0
  80. package/lib/shell/files.js +69 -0
  81. package/lib/shell/home.js +34 -0
  82. package/lib/validate.js +34 -0
  83. package/package.json +42 -11
  84. package/prompts/README.md +36 -0
  85. package/prompts/client/distill-local.md +36 -0
  86. package/prompts/client/distill-remote.md +26 -0
  87. package/prompts/client/nudge.md +21 -0
  88. package/scripts/distill-gemini.sh +34 -0
  89. package/scripts/engines/agents-md.js +134 -0
  90. package/scripts/engines/debounce-watcher.js +66 -0
  91. package/scripts/engines/distill.js +390 -0
  92. package/scripts/engines/drain-outbox.js +100 -0
  93. package/scripts/engines/infer-commits.js +116 -0
  94. package/scripts/engines/link-commits.js +95 -0
  95. package/scripts/engines/post-tool.js +261 -0
  96. package/scripts/engines/prompt-context.js +184 -0
  97. package/scripts/engines/session-start.js +418 -0
  98. package/scripts/engines/util.js +631 -0
  99. package/skills/brief/SKILL.md +64 -0
  100. package/skills/correct/SKILL.md +91 -0
  101. package/skills/defer/SKILL.md +105 -0
  102. package/skills/next/SKILL.md +85 -0
  103. package/skills/team-graph/SKILL.md +93 -0
  104. package/workers/shim/README.md +101 -0
  105. package/workers/shim/shim.js +327 -0
  106. package/index.js +0 -11
package/API.md ADDED
@@ -0,0 +1,359 @@
1
+ # Spor server — public API
2
+
3
+ The contract that adapters, hook engines, and third-party clients program
4
+ against. From a client's perspective the Spor server is one org graph behind
5
+ two doors: a **REST surface** (`/v1/*`, plain HTTPS + JSON) for deterministic
6
+ hook scripts and mechanical writers, and an **MCP surface** (Streamable HTTP
7
+ at `/mcp`) for model-driven clients — Cowork, claude.ai connectors,
8
+ in-session tool calls. Both doors require bearer auth (§4), are thin adapters
9
+ over the same core, and a tool call and its REST twin return byte-identical
10
+ payloads. Companion specs: [GRAPH.md](GRAPH.md) (node/edge format),
11
+ [QUEUE.md](QUEUE.md) (capture, decision queue, schema registry).
12
+
13
+ ## 1. Write semantics (both surfaces)
14
+
15
+ Every mutation is validated, attributed, serialized, and committed to the
16
+ graph's git repo. What a client sees:
17
+
18
+ - **Attribution**: the server stamps `author: <identity>` and
19
+ `authored_via: mcp|rest|capture` from the authenticated token — any
20
+ `author:` supplied in the payload is discarded.
21
+ - **Create**: `if_exists: "skip"` → id collision is reported as `skipped`
22
+ (the distiller default); `if_exists: "error"` → id collision is a
23
+ `conflict` error.
24
+ - **Update**: the caller must send `revision` — the git blob SHA of the
25
+ version it read (returned by `get_node`). Mismatch → `conflict` error with
26
+ the current revision; re-read and retry. No silent last-write-wins.
27
+ - **Validation**: id/filename agreement, kebab-case, type prefix, mandatory
28
+ standalone summary, known node type, `date:` format, edge syntax. Failures
29
+ return the validator's error list verbatim so a calling model can
30
+ self-correct. Size limits: body ≤ 8KB, summary ≤ 500 chars, ≤ 40 edges.
31
+ - **Edge normalization**: edge types accept canonical names, registry-declared
32
+ **aliases** (renamed in place), and **inverse labels** (the edge read from
33
+ the target's side — `{blocked-by, to: X}` on N is flipped and written to X
34
+ as `{blocks, to: N}`, reported in `warnings`; the target must exist).
35
+ Unknown edge types beyond that vocabulary are rejected, not defaulted.
36
+ Edges to nonexistent ids are allowed on full puts (they mark nodes worth
37
+ creating).
38
+ - **Schema gating**: the active schema's `transitions()` gate arbitrates
39
+ updates — a denial is `409 transition_denied` with the schema's reason.
40
+ Schema nodes created through the server are forced to `status: proposed`;
41
+ flipping one `proposed → active` requires an identity different from the
42
+ proposal's last author (the self-approval ban).
43
+ - Successful writes return `{status, id, revision, warnings}`
44
+ (`status: created|updated|skipped`).
45
+
46
+ ## 2. MCP surface (`/mcp`)
47
+
48
+ Streamable HTTP, implemented with the official SDK. All tool results are
49
+ returned as both human-readable text content and structured JSON.
50
+
51
+ The server advertises **`instructions`** (the SDK initialize result, surfaced
52
+ by clients as an "MCP Server Instructions" block). It frames the eleven tools
53
+ as an **ORIENT → TRAVERSE → COMMIT** loop rather than eleven independent
54
+ verbs, so an assistant can infer a recursive research chain — e.g. `my_queue`
55
+ → `query_graph` with `root_id` (deepen) → `render_lens` on a lineage lens →
56
+ `put_node`/`capture` the outcome — instead of reconstructing it from per-tool
57
+ descriptions. `query_graph`'s `root_id` is the recursive-deepen move (walk
58
+ neighbor → neighbor); `render_lens` lineage lenses trace why a node exists,
59
+ and `render_lens` with no `lens_id` returns the lens catalog (the discovery
60
+ step before rendering).
61
+
62
+ ### `query_graph`
63
+
64
+ The compiler over the wire. Input:
65
+
66
+ ```json
67
+ {
68
+ "query": "free text — the task, question, or prompt",
69
+ "root_id": "optional node id; overrides query (root-mode compile)",
70
+ "mode": "digest | full", // default digest
71
+ "min_sim": 0.08 // optional; relevance gate
72
+ }
73
+ ```
74
+
75
+ Output: `{ "found": bool, "text": "<digest or full neighborhood>",
76
+ "node_ids": [...], "top_sim": 0.31 }`. `found: false` (gate not met) is a
77
+ **successful empty result**, not an error.
78
+
79
+ ### `get_node`
80
+
81
+ Input `{ "id": "dec-..." }` → full raw markdown, parsed frontmatter, and
82
+ `revision` (git blob SHA) for use in updates. Unknown id → not-found error.
83
+
84
+ ### `put_node`
85
+
86
+ Input:
87
+
88
+ ```json
89
+ {
90
+ "node": "<full markdown file content, frontmatter + body>",
91
+ "if_exists": "skip | error | update", // default error
92
+ "revision": "<blob sha>" // required when update
93
+ }
94
+ ```
95
+
96
+ The server parses, validates, normalizes, stamps attribution (§1), writes,
97
+ commits. Output `{ "status": "created|skipped|updated", "id": ...,
98
+ "revision": ..., "warnings": [...] }`. Validation failure returns the
99
+ validator's error list verbatim.
100
+
101
+ The tool description **embeds the registry's edge vocabulary** — every
102
+ canonical edge type with its schema's one-line description (direction is
103
+ written from the source node's perspective) plus the accepted inverse forms.
104
+ Generated from the live registry, so an org schema that adds an edge type
105
+ changes the description without a deploy.
106
+
107
+ ### `add_edge`
108
+
109
+ Micro-mutation. Input `{ "id": "<node>", "type": "<edge type>",
110
+ "to": "<target>" }` — accepts canonical, alias, and inverse forms; inverse
111
+ forms are flipped onto the target before writing. No revision echo is needed.
112
+ Output `{ "status": "updated|skipped", "id": <node actually modified>,
113
+ "revision", "warnings" }` (`skipped` = edge already present — the call is
114
+ idempotent). Both nodes must exist. The tool description carries the same
115
+ registry-generated vocabulary as `put_node`.
116
+
117
+ ### `set_status`
118
+
119
+ Micro-mutation. Input `{ "id": "<node>", "status": "<value>" }`. Output
120
+ `{ "status": "updated", "id", "revision", "warnings" }`. Denials from the
121
+ schema's `transitions()` gate return `transition_denied` with the gate's
122
+ reason, exactly as on a full put. set_status on a `type: schema` node is how
123
+ a human flips `proposed → active` (it carries the same authority as the
124
+ equivalent put).
125
+
126
+ ### `propose_correction`
127
+
128
+ Sugar over `put_node` for the correction loop. Input:
129
+
130
+ ```json
131
+ {
132
+ "target": "node id | \"project:<slug>\" | \"global\"",
133
+ "pin": ["spec-actor-model"],
134
+ "exclude": ["art-stale-notes"],
135
+ "guidance": "free text injected into compiles for the target",
136
+ "title": "one line"
137
+ }
138
+ ```
139
+
140
+ The server generates the `corr-<target>-<n>` id (next free ordinal), builds
141
+ the node per GRAPH.md, and routes it through the same write path. `target`
142
+ is one of: an existing **node id** (fires when that node is the compile root
143
+ or a query-matched seed — query/digest mode included, per
144
+ issue-cc-corrections-silent-noop-query-mode); **`project:<slug>`** (fires on
145
+ every compile for that project, slug resolved through project aliases); or
146
+ **`global`** (every compile, graph-wide). A node-id target must exist; the
147
+ `project:`/`global` forms are accepted verbatim. The kernel `compile()`
148
+ honors all three when handed `opts.project` (the session slug).
149
+
150
+ ### `capture`
151
+
152
+ The default write door for sessions and Cowork: raw text in, typed nodes
153
+ out. Input:
154
+
155
+ ```json
156
+ {
157
+ "text": "2-3 standalone sentences — the fact, what + why",
158
+ "project": "optional project slug",
159
+ "during": "optional node id the work was discovered during (provenance)"
160
+ }
161
+ ```
162
+
163
+ The server-side ingestion model drafts node(s) against the live registry and
164
+ the similarity-ranked node index; the deterministic half validates (one
165
+ self-correction bounce), normalizes, stamps `authored_via: capture`, and
166
+ commits. Output `{ "status": "captured|pending", "node_ids": [...],
167
+ "nodes": [...], "summary": "...", "warnings": [...] }`. `pending` means the
168
+ text fit no schema (or failed validation twice) and was preserved as a
169
+ `cap-…` capture-pending node — ingestion-quality failures never lose text.
170
+ Only an unreachable ingestion model is an error (`ingestion_unavailable`).
171
+
172
+ ### `my_queue`
173
+
174
+ The decision queue (QUEUE.md §4/§5). Input `{ "project"?: "slug",
175
+ "limit"?: 20 }` → `{ "items": [{id, title, type, status, priority, score,
176
+ signals: {blocking, heat, staleness, age_days}, suggest: "do|close", why}],
177
+ "count": N, "questions": [] }` — queueable live nodes ranked by the default
178
+ blend, each with a one-line *why*. Items already retired by a live inbound
179
+ resolves/answers edge are excluded whatever their status field reads; open
180
+ gardener findings ride along per item as `findings`. Structured output
181
+ additionally carries `view` — the queue projected into the view-tree catalog
182
+ for the MCP-app widget (below).
183
+
184
+ ### `ask_question`
185
+
186
+ File a question the graph could not answer. Input `{ "text": "<the
187
+ question>", "title"?: "<short title>", "mentions"?: ["<node id>", ...] }`
188
+ (routing considers `mentions` first). The question becomes a durable node,
189
+ deterministically routed to the steward of the closest relevant node
190
+ (unrouted if none matches), and joins the decision queue until answered.
191
+ Answer by writing a node with an `answers` edge to the question.
192
+
193
+ ### `run_workflow`
194
+
195
+ Start a run of an ACTIVE workflow by hand. Input
196
+ `{ "workflow_id": "wf-...", "inputs"?: {...} }`. Creates a workflow-run node
197
+ with lineage and returns the run id and its initial step states. The
198
+ workflow must already be active (a proposed workflow must be activated by a
199
+ different identity first — the self-approval ban). This tool only starts the
200
+ run; workers then claim ready steps over the REST claim API (§3.1). It never
201
+ executes effects.
202
+
203
+ ### `render_lens`
204
+
205
+ Run a saved lens — a `type: lens` node (schema-lens) — against the live
206
+ graph. Input `{ "lens_id": "lens-...", "params"?: {"project": "wf", "focus":
207
+ "<node-id>", ...} }` → `{ "found": true, "lens_id", "count", "view",
208
+ "node_ids" }`, where `view` is the plain-JSON view tree
209
+ (view/list/group/item/tree/table/text catalog) and the text content is its
210
+ terminal rendering for the model.
211
+
212
+ `lens_id` is **optional**: call with no `lens_id` to get the lens catalog —
213
+ a successful `{ "found": true, "catalog": [{"id", "title"}], "lenses": [...],
214
+ "count" }` listing every available lens (the discovery step before you
215
+ render). Unknown `lens_id` still errors, carrying the same `catalog`/`lenses`
216
+ list; engine failures (missing param, broken blocks) error with the message
217
+ verbatim.
218
+
219
+ ### The MCP-app widget (`ui://spor/view-tree.html`)
220
+
221
+ `my_queue` and `render_lens` declare a UI resource via
222
+ `_meta.ui.resourceUri`: a single trusted interpreter of the view-tree
223
+ component catalog that MCP-apps hosts (Claude, Goose, VS Code) render as an
224
+ interactive iframe — status chips, progress bars, lineage trees, node detail
225
+ on click (`callServerTool(get_node)`, no model round-trip), and
226
+ conversational affordances (`sendMessage`) for queue items. Strictly
227
+ additive: hosts without the apps surface ignore `_meta.ui` and show the text
228
+ content. Write-path actions are not emitted; writes stay with the tools
229
+ above.
230
+
231
+ ## 3. REST surface (`/v1/*`)
232
+
233
+ Plain HTTPS + JSON, bearer auth on every route, versioned under `/v1/`. Each
234
+ endpoint is the REST twin of a core call:
235
+
236
+ | Endpoint | Typical caller | Semantics |
237
+ |---|---|---|
238
+ | `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. `?titles=1` adds `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
239
+ | `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin}`. `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 |
240
+ | `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. 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 |
241
+ | `POST /v1/digest` `{query, min_sim?}` | prompt-context | digest-mode compile → `{found, text}`; `found: false` is a successful empty result |
242
+ | `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; when a live inbound resolves/answers edge contradicts a still-open status the response carries `resolution`, and open gardener findings about the node ride along as `open_findings` |
243
+ | `POST /v1/nodes` | drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed |
244
+ | `POST /v1/nodes/{id}/edges` `{type, to}` | scripts, mechanical writers | `add_edge` semantics (§1): normalize/flip, dedupe, append — no revision echo |
245
+ | `POST /v1/nodes/{id}/status` `{status}` | scripts, mechanical writers | `set_status` semantics (§1): one-scalar update through the `transitions()` gate |
246
+ | `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 |
247
+ | `GET /v1/commits/{sha}?repo=` | 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 |
248
+ | `POST /v1/capture` | distill, /spor:defer | `capture` semantics: `{text, context: {project, during, blocks?, needed_by?}, source?}` → ingestion model + validate + commit → `{status, ids, nodes, summary, warnings}`. `source: "distill"` marks backstop captures in the journal. `context.blocks` (a node id, must exist) and `context.needed_by` (`YYYY-MM-DD`) declare a cross-project dependency (task-cc-xproject-dependency-loop): set `context.project` to the SERVING project and the server attaches a `blocks` edge to the requester + the deadline deterministically (not via the model) onto the primary node. A missing `blocks` target is `404`; a non-date `needed_by` is `422` — both rejected before any model call |
249
+ | `POST /v1/distill/report` | distill | sweep telemetry, journal-only (no store mutation): `{facts, captured?, spooled?, rejected?, project?, session?}` → `{status: "reported"}`; zero-fact sweeps report too |
250
+ | `POST /v1/corrections` | /spor:correct | `propose_correction` semantics → 201 `{status, id, revision, warnings}` |
251
+ | `GET /v1/queue?project=&limit=` | /spor:next, session-start | the ranked decision queue: `{items, count, muted?, dormant?, questions, findings, policy?, generated_at}` — items retired by a live resolves/answers edge are excluded; items hidden by the viewer's `queue_mute` or parked by a future `wake:` date (QUEUE.md §4) are counted, never silently dropped; `questions`/`findings` are the routed-to-me-plus-unrouted views for the authenticated identity |
252
+ | `POST /v1/questions` `{text, title?, mentions?}` | ask_question's REST twin | file a question node; deterministically routed to the steward of the closest relevance-neighborhood node, unrouted if none → 201 `{status, id, routed_to, via, asker, revision, warnings}` |
253
+ | `POST /v1/gardener` | ops cron / on demand | run a gardener sweep now; findings filed as queue items → `{filed, resolved, ..., generated_at}` |
254
+ | `GET /v1/lens/{id}/render?format=html\|text\|json` | browsers, teammates without a checkout | run a lens OR workspace node and render its view tree (html default, plain text, or the raw tree as json). Read-only — no action forms; writes stay with `/v1/nodes` and the MCP tools. Accepts `?token=<PAT>` on this route only (browser links can't carry an Authorization header; the request log records the pathname, never the query) |
255
+ | `GET /v1/export` | bootstrap/offline | ustar tarball of `nodes/` for seeding a local read replica (`?gzip=1` compresses); see §5 for the response headers. `curl … \| tar x` reproduces `nodes/` byte-for-byte |
256
+ | `GET /v1/admin/tokens` | offboarding / audit | list PATs → `{tokens: [{hash_prefix, person, name, email, created, expires, expired}], count}` — never plaintext, never full hashes. Admin-only (§4) |
257
+ | `POST /v1/admin/tokens` `{person, expires?}` | onboarding | mint a PAT bound to an existing person node (`expires` is `<N>d` or an ISO date) → 201 `{token, hash_prefix, person, name, email, expires}`; the plaintext `token` is returned **once**. Admin-only |
258
+ | `DELETE /v1/admin/tokens/{hash-prefix}` | offboarding / rotation | revoke the single PAT matching the hash prefix (≥8 hex chars; an ambiguous prefix is a 409) → `{revoked, hash_prefix}`. Admin-only |
259
+
260
+ Path parameters (node ids, project slugs) must match
261
+ `^[a-z0-9][a-z0-9-]*$`. Request bodies are capped at 1MB
262
+ (`413 too_large`).
263
+
264
+ ### 3.1 Workflow runs
265
+
266
+ The run engine's claim/complete API. Full contract and the reference worker
267
+ live with [workers/shim/README.md](workers/shim/README.md); a worker is
268
+ anything with a token.
269
+
270
+ | Endpoint | Semantics |
271
+ |---|---|
272
+ | `POST /v1/workflows/{id}/run` `{inputs?}` | start a run on an ACTIVE workflow → `{run_id, revision, workflow, workflow_version, state}` |
273
+ | `GET /v1/work?capability=a,b` | claimable steps across live runs, filtered by capability → `{work, count, generated_at}`; approval steps are excluded (they surface in the queue, not as worker-claimable work) |
274
+ | `POST /v1/runs/{id}/steps/{sid}/claim` `{iteration?}` | claim a ready step → `{run_id, step, lease, state}`; a step that isn't claimable is a 409 |
275
+ | `POST /v1/runs/{id}/steps/{sid}/complete` `{lease, status, result?, log?, iteration?}` | 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 |
276
+ | `GET /v1/runs/{id}` | full run record: `{run_id, status, project, title, initiator, workflow, workflow_version, lineage, state, revision, timestamps?}` |
277
+
278
+ ## 4. Identity and auth
279
+
280
+ - **Bearer tokens (REST + MCP).** Per-user tokens `spor_pat_…` (legacy
281
+ `sub_pat_…` tokens stay valid, no re-mint required). Minted by a server
282
+ admin with `spor-mint-token --person <person-node-id>` on the server box;
283
+ the token's canonical subject is that **person node**, and its
284
+ `{name, email}` attribution resolves from the node at read time, so an
285
+ email change re-points the token instead of severing it. Send
286
+ `Authorization: Bearer <token>` on every request. Tokens grant full
287
+ read/write — the trust model is "everyone on the team can read and write
288
+ the team graph", same as a shared repo. Transport is HTTPS only. A token
289
+ may carry an expiry (`spor-mint-token --expires <N>d|<date>`, or the REST
290
+ `expires` field); once past it the token is rejected like a revoked one.
291
+ - **Token lifecycle admin.** Mint, list, and revoke run over REST
292
+ (`/v1/admin/tokens`, §3) so onboarding/offboarding needs no server-box
293
+ shell — but every one of those operations is **admin-only**. A caller is an
294
+ admin iff their person node carries a `stewards` edge to the graph root
295
+ (`$SPOR_ROOT_ID`, default `org-root`); without it the admin routes return
296
+ `403 forbidden`. This is the one privileged distinction in the otherwise
297
+ flat trust model, and the seam the future fine-grained model generalizes.
298
+ The first admin is bootstrapped on the server box with `spor-mint-token
299
+ --admin --person <id>` (it writes that `stewards` edge, creating the person
300
+ node from `--name`/`--email` if needed). Hand-editing the token file stays
301
+ as the break-glass path.
302
+ - **OAuth 2.1 for MCP connectors** (Cowork/claude.ai, which cannot carry a
303
+ static bearer token): protected-resource metadata discovery (RFC 9728,
304
+ advertised on the `/mcp` 401 via `WWW-Authenticate`), authorization-server
305
+ metadata (RFC 8414), dynamic client registration (RFC 7591), and
306
+ authorization-code + PKCE (S256 only, public clients). The consent step is
307
+ a **PAT exchange**: the authorize page asks the user to paste their
308
+ existing `spor_pat_…` token into the server's own page — it never reaches
309
+ the connector host — so the OAuth identity is exactly the PAT's
310
+ `{name, email}` attribution record. Access tokens are `spor_oat_…` (30d;
311
+ legacy `sub_oat_…` accepted); refresh tokens are `spor_ort_…` (90d,
312
+ rotating, single-use). Authorization codes are single-use, 10-minute.
313
+
314
+ Unauthenticated MCP calls are hard-rejected — there is no anonymous author.
315
+
316
+ ## 5. Errors and wire constants
317
+
318
+ Non-2xx responses carry the envelope:
319
+
320
+ ```json
321
+ { "error": { "code": "...", "message": "...", "details": [...] } }
322
+ ```
323
+
324
+ Codes and their HTTP statuses: `unauthorized` 401, `forbidden` 403,
325
+ `not_found` 404, `conflict` 409, `transition_denied` 409, `lease_expired` 409,
326
+ `outcome_conflict` 409, `not_ready` 409, `invalid_node` 422, `rate_limited`
327
+ 429, `too_large` 413, `ingestion_unavailable` 503, `unimplemented` 501,
328
+ `internal` 500. Hooks never parse error bodies — any non-200 means "behave
329
+ as if the graph is empty" (§6).
330
+
331
+ A `429 rate_limited` response SHOULD carry a `Retry-After` header (delay
332
+ seconds or an HTTP-date); clients honor it, otherwise backing off
333
+ exponentially, capped, before retrying. Mechanical writers
334
+ (drain-outbox, distill) classify `401`, `400`, `413`, and `422` as
335
+ **permanent** — a revoked token will not un-revoke, so these are
336
+ dead-lettered to `outbox/dead/` with a loud `journal/remote.log` line
337
+ rather than re-POSTed forever; `429` and `5xx` stay transient and are
338
+ retried with backoff.
339
+
340
+ `GET /v1/export` response headers: `x-substrate-head` carries the graph
341
+ commit, `x-substrate-node-count` the entry count (plus
342
+ `x-substrate-skipped` when any entry was omitted). These header names are a
343
+ wire contract and were deliberately **not** renamed in the Spor rename —
344
+ clients should keep reading the `x-substrate-*` spellings.
345
+
346
+ ## 6. Client configuration
347
+
348
+ Two env vars switch a client into remote mode (the legacy `SUBSTRATE_*`
349
+ spellings are still read — dual-read back-compat window); unset means local
350
+ mode, reading `$SPOR_HOME` directly:
351
+
352
+ ```
353
+ SPOR_SERVER=https://spor.example.com
354
+ SPOR_TOKEN=spor_pat_... # per-user token (§4)
355
+ ```
356
+
357
+ Failure policy: **fail open, never block** — a hook must never break a
358
+ session; connection refused, timeout, 5xx, and auth failure all collapse to
359
+ "the graph has nothing for you".