@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,972 @@
1
+ # Remote BuildStore protocol
2
+
3
+ This document is the complete normative HTTP contract for Autobuild's remote
4
+ `BuildStore`. Implement this protocol to provide build storage on any database,
5
+ blob store, or service stack; Autobuild does not load in-process `BuildStore`
6
+ plugins. Compatibility means that an Autobuild remote client can use the server
7
+ and the implementation passes the shared `BuildStore` contract exported by
8
+ `@defrex/autobuild/plugin-sdk`.
9
+
10
+ The transport, wire shapes, validation order, atomicity, lease behavior,
11
+ persistence requirements, and conformance bar for the protocol shipped in this
12
+ distribution are all defined here. A protocol implementation must target the
13
+ same Autobuild package version used by its clients; protocol changes update this
14
+ document and the package's executable contracts together.
15
+
16
+ ## 1. Transport and conventions
17
+
18
+ - The transport is JSON over HTTP or HTTPS. There is no API version prefix.
19
+ Point Autobuild at the server's base URL with `--store`, or set that URL in
20
+ `AB_STORE`; the shipped client appends the routes below.
21
+ - Every `/builds`, `/repos`, and `/sessions` request sends
22
+ `X-Autobuild-Version` with the exact client package version and
23
+ `X-Autobuild-Protocol-Version` with the remote protocol version (`2` in this
24
+ distribution). Before authentication,
25
+ body parsing, or resource lookup, the server compares both exact strings.
26
+ Missing or different values receive `409 {"kind":"conflict","error":"…"}`;
27
+ the diagnostic names the client and server package and protocol versions.
28
+ - Clients send `Content-Type: application/json` for requests with bodies. Every
29
+ response, including errors and `null` artifact results, has
30
+ `Content-Type: application/json`.
31
+ - Build slugs, repository ids, artifact kinds, and holders are case-sensitive.
32
+ - A build slug or repository id in a path is one percent-encoded path segment.
33
+ In particular, a repository id such as `acme/widgets` is sent as
34
+ `acme%2Fwidgets`, not as two segments.
35
+ - Artifact bytes are represented by `contentBase64`, using strict, canonical,
36
+ padded standard base64 (not base64url). A server may impose a documented
37
+ decoded-byte ceiling. It decodes and checks every standalone or bundled
38
+ artifact before any backing mutation; excess content receives `413
39
+ {"kind":"validation","error":"…"}` naming the byte ceiling. The shipped
40
+ hosted service ceiling is 1,048,576 decoded bytes (1 MiB).
41
+ - Store-assigned times are ISO-8601 UTC strings in JavaScript
42
+ `Date.toISOString()` form, for example `2026-07-15T12:00:00.000Z`.
43
+ - Event sequence numbers and artifact revisions are integers assigned by the
44
+ server. Clients must not assign either except for the negative deposit
45
+ placeholders described in [Atomic deposits](#7-atomic-deposits).
46
+ - Optional object members are omitted, not represented as `null`, unless a
47
+ response below explicitly specifies JSON `null`.
48
+ - The server owns durability. Events and artifact metadata visible in a
49
+ successful response must remain available across server restarts.
50
+
51
+ ## 2. Common wire types
52
+
53
+ The examples in this section use TypeScript-like optional markers and JSON
54
+ comments for compactness. Actual messages are JSON.
55
+
56
+ ### Ticket and records
57
+
58
+ ```jsonc
59
+ // TicketRef
60
+ {
61
+ "source": "linear", // nonempty string
62
+ "id": "AUT-123", // nonempty string
63
+ "url": "https://…", // optional string
64
+ "title": "Document the store" // optional string
65
+ }
66
+
67
+ // BuildRecord
68
+ {
69
+ "slug": "remote-store-protocol",
70
+ "repo": "acme/autobuild",
71
+ "repoOrigin": "https://github.com/acme/autobuild", // optional
72
+ "ticket": { /* TicketRef */ }, // optional
73
+ "branch": "ab/remote-store-protocol", // optional
74
+ "createdAt": "2026-07-15T12:00:00.000Z",
75
+ "updatedAt": "2026-07-15T12:01:00.000Z",
76
+ "lease": { // optional
77
+ "holder": "runner-1",
78
+ "expiresAt": "2026-07-15T12:02:00.000Z"
79
+ },
80
+ "heartbeatAt": "2026-07-15T12:01:30.000Z" // optional
81
+ }
82
+
83
+ // RepositoryRecord
84
+ {
85
+ "repo": "acme/autobuild",
86
+ "createdAt": "2026-07-15T12:00:00.000Z",
87
+ "updatedAt": "2026-07-15T12:01:00.000Z",
88
+ "lease": { // optional
89
+ "holder": "harvest-1",
90
+ "expiresAt": "2026-07-15T12:02:00.000Z"
91
+ },
92
+ "heartbeatAt": "2026-07-15T12:01:30.000Z" // optional
93
+ }
94
+ ```
95
+
96
+ Build records do not contain derived build status. Status is reduced from the
97
+ build event stream. `createdAt`, `updatedAt`, lease expiry, and heartbeat time
98
+ are server-owned.
99
+
100
+ `repoOrigin` is the build repository's normalized git origin URL (scp-like and
101
+ ssh remotes mapped to their `https://` spelling, host lowercased, trailing
102
+ `.git` and slashes stripped). It is a location-independent secondary identity:
103
+ clients may accept a differently located checkout of the same repository by
104
+ origin equality. It is optional on both create and record shapes, and the
105
+ field is simply absent when the repository has no origin remote. The field is
106
+ an additive, optional member: servers that predate it strip it from create
107
+ requests (clients fall back to path-only identity) and never return it, and
108
+ clients that predate it never send or expect it — so neither side requires a
109
+ protocol-version bump.
110
+
111
+ ### Actors, event writes, and envelopes
112
+
113
+ An actor is exactly one of:
114
+
115
+ ```jsonc
116
+ { "kind": "kernel" }
117
+ { "kind": "agent", "role": "implement", "session": "s_123" }
118
+ { "kind": "human", "user": "alice" }
119
+ { "kind": "human", "user": "alice", "via": { "kind": "session", "id": "os_123" } }
120
+ { "kind": "human", "user": "alice", "via": { "kind": "mcp", "client": "claude-code" } }
121
+ { "kind": "dispatcher" }
122
+ { "kind": "ingester", "source": "sentry" }
123
+ ```
124
+
125
+ `role`, `session`, `user`, and `source` are nonempty strings. A human actor may
126
+ carry an optional `via` marker naming the delegate that performed the write on
127
+ the person's behalf: `{ "kind": "session", "id": nonempty }` (an operator's
128
+ orchestrator session) or `{ "kind": "mcp", "client": nonempty }` (an MCP
129
+ client). `via` is valid only on `human` actors and only on build and
130
+ repository events — session events are the delegate's own log — and a `via`
131
+ key on any other actor variant is a validation failure. These are the
132
+ complete actor variants; unknown actor kinds or extra fields are invalid.
133
+
134
+ An event append request contains no resource id, sequence, or timestamp:
135
+
136
+ ```jsonc
137
+ {
138
+ "actor": { /* Actor */ },
139
+ "type": "observation.recorded", // nonempty string at the wire boundary
140
+ "payload": { /* type-specific JSON */ }
141
+ }
142
+ ```
143
+
144
+ A successful build event response is:
145
+
146
+ ```jsonc
147
+ {
148
+ "build": "remote-store-protocol",
149
+ "seq": 1, // positive integer
150
+ "ts": "2026-07-15T12:00:00.000Z",
151
+ "actor": { /* Actor */ },
152
+ "type": "observation.recorded",
153
+ "payload": { /* validated payload */ }
154
+ }
155
+ ```
156
+
157
+ A repository event envelope has the same shape with `"repo"` in place of
158
+ `"build"`. Event-list responses are JSON arrays of the corresponding
159
+ envelopes.
160
+
161
+ A conditional build-stream append request wraps the same event write with the
162
+ currently observed sequence (`0` means an empty stream):
163
+
164
+ ```jsonc
165
+ {
166
+ "expectedSeq": 17, // nonnegative integer
167
+ "event": { /* event write */ }
168
+ }
169
+ ```
170
+
171
+ Its response is either the appended build event envelope or JSON `null` when
172
+ the stream no longer has that sequence.
173
+
174
+ The wire accepts the generic event-write shape so that the backing server can
175
+ return a precise catalog validation error. A conforming server **must** validate
176
+ the complete actor, type, payload, and actor-per-type rule before mutation.
177
+ Build, repository, and session events have separate catalogs; each catalog
178
+ defines its allowed event names, exact payload shape, and allowed actor kinds.
179
+ Unknown types, missing or extra payload fields, malformed artifact references,
180
+ and an actor kind not authorized for that event are validation failures. A
181
+ `via` member on any non-human actor is a validation failure with an explicit
182
+ rule message (see [Operator-session operations](#5-operator-session-operations)
183
+ and section 7).
184
+
185
+ Those event catalogs evolve with Autobuild's build lifecycle rather than with
186
+ the storage transport. A server therefore must use or faithfully implement the
187
+ catalog from the same Autobuild package version as its client. The public
188
+ `BuildStore` type (including its typed write methods), representative valid
189
+ writes, and unchanged contract suite exported by `@defrex/autobuild/plugin-sdk` are the
190
+ package-versioned compatibility surface. Wire validation remains mandatory even
191
+ in a language that cannot consume those TypeScript types directly.
192
+
193
+ ### Artifacts
194
+
195
+ A build artifact input is:
196
+
197
+ ```jsonc
198
+ {
199
+ "kind": "plan", // nonempty string
200
+ "contentBase64": "IyBQbGFuCg==", // standard base64
201
+ "metadata": { "round": 1 } // optional JSON object; defaults to {}
202
+ }
203
+ ```
204
+
205
+ A build artifact metadata response is:
206
+
207
+ ```jsonc
208
+ {
209
+ "build": "remote-store-protocol",
210
+ "kind": "plan",
211
+ "revision": 0, // nonnegative integer
212
+ "blobRef": "64-lowercase-hex-sha256",
213
+ "metadata": { "round": 1 },
214
+ "createdAt": "2026-07-15T12:00:00.000Z"
215
+ }
216
+ ```
217
+
218
+ Repository artifact metadata has `"repo"` instead of `"build"`. An artifact
219
+ read returns either JSON `null` or:
220
+
221
+ ```jsonc
222
+ {
223
+ "meta": { /* build or repository artifact metadata */ },
224
+ "contentBase64": "IyBQbGFuCg=="
225
+ }
226
+ ```
227
+
228
+ `blobRef` is the lowercase hexadecimal SHA-256 digest of the decoded bytes.
229
+ Revisions start at 0 independently for each artifact kind. Depositing identical
230
+ bytes again may reuse the blob but still creates the next metadata revision.
231
+
232
+ ### Atomic deposit messages
233
+
234
+ Both resource families use this request shape:
235
+
236
+ ```jsonc
237
+ {
238
+ "artifacts": [
239
+ { "kind": "plan", "contentBase64": "…", "metadata": { "round": 1 } }
240
+ ],
241
+ "event": {
242
+ "actor": { /* Actor */ },
243
+ "type": "plan.completed",
244
+ "payload": {
245
+ "artifact": { "kind": "plan", "rev": -1 }
246
+ }
247
+ }
248
+ }
249
+ ```
250
+
251
+ The response is:
252
+
253
+ ```jsonc
254
+ {
255
+ "event": { /* build or repository event envelope */ },
256
+ "artifacts": [ /* assigned metadata, in request order */ ]
257
+ }
258
+ ```
259
+
260
+ Section 7 defines the placeholder and transaction semantics.
261
+
262
+ ### Lease and error messages
263
+
264
+ ```jsonc
265
+ // claim request
266
+ { "holder": "runner-1", "ttlMs": 60000 }
267
+
268
+ // heartbeat or release request
269
+ { "holder": "runner-1" }
270
+
271
+ // every lease response
272
+ { "ok": true }
273
+
274
+ // every non-success response
275
+ {
276
+ "error": "human-readable message",
277
+ "kind": "validation" // validation | not-found | auth | conflict | internal
278
+ }
279
+ ```
280
+
281
+ `holder` is nonempty. `ttlMs` is a nonnegative integer measured in
282
+ milliseconds. A zero TTL is valid on the wire and produces an immediately
283
+ expired lease.
284
+
285
+ ## 3. Build-stream operations
286
+
287
+ `{slug}` means one percent-encoded build slug. Except for `POST /builds` and
288
+ `GET /builds`, all routes first authorize the addressed build and then require
289
+ it to exist. An unknown build returns `404 not-found`.
290
+
291
+ | BuildStore operation | HTTP route | Request | Success |
292
+ |---|---|---|---|
293
+ | `createBuild` | `POST /builds` | `{slug: string, repo: string, repoOrigin?: string, ticket?: TicketRef, branch?: string}`; `slug`, `repo`, and a supplied `branch` are nonempty | `201` + `BuildRecord`; duplicate slug is `409 conflict` |
294
+ | `listBuilds` | `GET /builds` | none | `200` + `BuildRecord[]`; list order is unspecified |
295
+ | `getBuild` | `GET /builds/{slug}` | none | `200` + `BuildRecord`; absent is `404` (the shipped client maps this to `null`) |
296
+ | `append` | `POST /builds/{slug}/events` | event write | `201` + build event envelope |
297
+ | `appendIfCurrent` | `POST /builds/{slug}/events/conditional` | `{"expectedSeq": nonnegative integer, "event": event write}` | appended: `201` + build event envelope; stream advanced: `200 null` |
298
+ | `getEvents` | `GET /builds/{slug}/events?since={n}&wait={s}` | optional `since` query value, parsed below; absence defaults to `0`. Optional `wait` in whole seconds, parsed per the event-wait rules below | `200` + envelopes whose `seq` is strictly greater than parsed `since`, in increasing sequence order |
299
+ | `appendWithArtifacts` | `POST /builds/{slug}/deposits` | atomic deposit request | `201` + `{event, artifacts}` |
300
+ | `putArtifact` | `POST /builds/{slug}/artifacts` | artifact input | `201` + build artifact metadata |
301
+ | `getArtifact` | `GET /builds/{slug}/artifacts?kind={kind}&rev={n}` | `kind` is required and nonempty; optional `rev` is parsed below | `200` + artifact read; an absent `rev` parameter selects the latest revision; a missing kind/revision is `200 null` |
302
+ | `listArtifacts` | `GET /builds/{slug}/artifact-list?kind={kind}` | optional `kind`; absence means all kinds | `200` + metadata ordered by kind and then increasing revision |
303
+ | `claimLease` | `POST /builds/{slug}/lease/claim` | claim request | `200 {"ok": boolean}` |
304
+ | `heartbeat` | `POST /builds/{slug}/lease/heartbeat` | holder request | `200 {"ok": boolean}` |
305
+ | `releaseLease` | `POST /builds/{slug}/lease/release` | holder request | `200 {"ok": true}`; a wrong holder is a successful no-op |
306
+
307
+ The event stream is append-only. The server assigns each build's sequences
308
+ independently, starting at 1, and assigns the envelope timestamp. Successful
309
+ appends preserve append order. Event and artifact writes update the record's
310
+ server-owned timestamps according to the backing `BuildStore` contract.
311
+
312
+ A conditional append validates the candidate exactly like an ordinary append,
313
+ including actor rules, even when its expected sequence is stale. The backing
314
+ store must compare the stream tail and append as one atomic operation. At most
315
+ one caller can succeed for a given current sequence. A comparison miss returns
316
+ `null` and must not append, consume a sequence, or update the build record's
317
+ timestamps. An unknown build remains `404 not-found`; malformed or negative
318
+ `expectedSeq` is `400 validation`.
319
+
320
+ For a present `since` or `rev`, the shipped server applies JavaScript
321
+ `Number(rawValue)` and accepts the result only when `Number.isInteger` is true.
322
+ Consequently hexadecimal and exponent forms such as `0x10` and `1e1`,
323
+ whitespace-padded values, and empty or whitespace-only values are accepted;
324
+ empty and whitespace-only values convert to `0`. Values converting to `NaN`, infinity, or a
325
+ non-integral number produce `400 validation`. An absent `since` defaults to
326
+ `0`. An absent `rev` selects the latest artifact, but a present empty `rev=`
327
+ selects revision `0`. Sequence filtering is always strict `>`, so a client can
328
+ pass the last sequence it has processed without receiving it again. Artifact
329
+ revisions are nonnegative; a requested revision that does not exist, including
330
+ a negative one, produces `null`. A missing or empty `kind` on the artifact-read
331
+ route is `400 validation`.
332
+
333
+ The artifact-list `kind` is optional and is normally a nonempty stored kind.
334
+ No pagination, deletion, retention, range request, or streaming endpoint is
335
+ part of this protocol. Retention is enforced server-side inside the store at
336
+ deposit time (bounded pruning of dispatcher run/config artifact revisions),
337
+ not through any endpoint.
338
+
339
+ ### The event-read bounded wait
340
+
341
+ Both event reads (`getEvents` and, symmetrically, the `getRepoEvents` read of
342
+ section 4) accept an optional `wait` parameter in whole seconds. When at least
343
+ one event with `seq` strictly greater than parsed `since` already exists, the
344
+ response is returned immediately, exactly as without `wait`. Otherwise the
345
+ server may hold the request until such an event is appended or the bound
346
+ elapses, whichever comes first; at the bound it answers `200 []`. Omitting
347
+ `wait` (or `wait=0`) is byte-identical behavior to the immediate form.
348
+
349
+ `wait` is parsed with a dedicated digits-only grammar — one or more ASCII
350
+ digits — deliberately stricter than the lenient `since`/`rev` conversion above:
351
+ empty, whitespace-padded, hexadecimal, signed, and non-integral values are `400
352
+ validation`, not coerced. Values above the server's documented ceiling are
353
+ clamped to it, never rejected; the shipped self-hosted server's ceiling is 30
354
+ seconds (the stream/session wait ceiling) and the shipped hosted service's is
355
+ 25 seconds, stated in its README together with the route `maxDuration` the hold
356
+ requires. A held request's authentication, token scope, and version-skew rules
357
+ are identical to the immediate form: a `401`, `403`, or `409` identity failure
358
+ is answered immediately, never held.
359
+
360
+ The parameter is purely additive, so the protocol version stays `2`: a server
361
+ that ignores `wait` remains conforming — its immediate empty answers only raise
362
+ the client's request rate — and a client that sends `wait` to such a server
363
+ observes no other difference. This is the same reasoning the stream and session
364
+ waits apply. There is no push transport; a held request is the whole mechanism.
365
+
366
+ ## 4. Repository-journal operations
367
+
368
+ Repository journals are separate resources with independent event sequences,
369
+ artifacts, and leases. `{repo}` is one percent-encoded repository id; encode
370
+ embedded `/` characters. All resource routes authorize and require the
371
+ repository to exist. An unknown repository returns `404 not-found`.
372
+
373
+ | BuildStore operation | HTTP route | Request | Success |
374
+ |---|---|---|---|
375
+ | `ensureRepo` | `POST /repos` | `{"repo": string}` with a nonempty id | `200` + `RepositoryRecord`; idempotently returns the existing record |
376
+ | `getRepo` | `GET /repos/{repo}` | none | `200` + `RepositoryRecord`; absent is `404` (the shipped client maps this to `null`) |
377
+ | `appendRepo` | `POST /repos/{repo}/events` | event write | `201` + repository event envelope |
378
+ | `getRepoEvents` | `GET /repos/{repo}/events?since={n}&wait={s}` | optional `since` query value, parsed as in section 3; absence defaults to `0`. Optional `wait` in whole seconds, per the event-wait rules of section 3 | `200` + envelopes with `seq >` parsed `since`, in increasing sequence order |
379
+ | `appendRepoWithArtifacts` | `POST /repos/{repo}/deposits` | atomic deposit request | `201` + `{event, artifacts}` using repository shapes |
380
+ | `putRepoArtifact` | `POST /repos/{repo}/artifacts` | artifact input | `201` + repository artifact metadata |
381
+ | `getRepoArtifact` | `GET /repos/{repo}/artifacts?kind={kind}&rev={n}` | required nonempty `kind`; optional `rev` is parsed as in section 3 | `200` + artifact read; latest only when the `rev` parameter is absent; missing kind/revision is `200 null` |
382
+ | `listRepoArtifacts` | `GET /repos/{repo}/artifact-list?kind={kind}` | optional `kind`; absence means all kinds | `200` + metadata ordered by kind and then increasing revision |
383
+ | `claimRepoLease` | `POST /repos/{repo}/lease/claim` | claim request | `200 {"ok": boolean}` |
384
+ | `heartbeatRepo` | `POST /repos/{repo}/lease/heartbeat` | holder request | `200 {"ok": boolean}` |
385
+ | `releaseRepoLease` | `POST /repos/{repo}/lease/release` | holder request | `200 {"ok": true}`; a wrong holder is a successful no-op |
386
+
387
+ There is no repository-list operation. `ensureRepo` is the only repository
388
+ creation operation and must not reset timestamps, events, artifacts, or lease
389
+ state when the repository already exists. Event sequence numbering starts at 1
390
+ for each repository independently of every build and other repository.
391
+
392
+ The build-stream query, artifact, ordering, timestamp, and validation rules
393
+ apply symmetrically to repository journals, including the event-read bounded
394
+ wait of section 3.
395
+
396
+ ## 5. Operator-session operations
397
+
398
+ Operator sessions are a third resource kind: durable orchestrator-conversation
399
+ state keyed by repository and operator. They are hosted-only — local installs
400
+ interact through the CLI and dashboard and never create one — but the resource
401
+ contract is uniform, and a conforming server implements it the same way it
402
+ implements the repository journal. A session record is:
403
+
404
+ ```jsonc
405
+ // SessionRecord
406
+ {
407
+ "id": "os_9f2c…", // server-assigned, `os_`-prefixed
408
+ "repo": "acme/autobuild",
409
+ "operator": "alice", // trimmed nonblank operator identity
410
+ "title": "fix the login flow", // optional
411
+ "createdAt": "2026-07-15T12:00:00.000Z",
412
+ "updatedAt": "2026-07-15T12:03:00.000Z"
413
+ }
414
+ ```
415
+
416
+ Creating a session makes the record and its first event visible together: the
417
+ record's creation atomically appends a `session.created` event as sequence 1
418
+ with the operator as a human actor. The session event catalog is closed and
419
+ separate from the build and repository catalogs (`session.created`,
420
+ `message.posted`, `session.wake-set`, `turn.started`, `turn.suspended`,
421
+ `turn.resumed`, `approval.requested`, `approval.answered`, `turn.completed`,
422
+ `turn.failed`, `session.archived`), with its own per-session sequence starting
423
+ at 1 and its own actor-per-type rules. A `via` member is never valid on a
424
+ session event: session events are the delegate's own log, and the operator's
425
+ identity is already the actor.
426
+
427
+ | BuildStore operation | HTTP route | Request | Success |
428
+ |---|---|---|---|
429
+ | `createSession` | `POST /repos/{repo}/sessions` | `{"repo": string, "operator": string, "title"?: string}`; the body `repo` must equal the path repository | `201` + `SessionRecord` |
430
+ | `listSessions` | `GET /repos/{repo}/sessions` | none | `200` + `SessionRecord[]`, creation order (`createdAt`, then the store's creation counter for same-millisecond ties) |
431
+ | `getSession` | `GET /sessions/{id}` | none | `200` + `SessionRecord`; absent is `404` (the shipped client maps this to `null`) |
432
+ | `appendSessionEvent` | `POST /sessions/{id}/events` | event write | `201` + session event envelope (same envelope shape with `"session"` in place of `"build"`) |
433
+ | `getSessionEvents` | `GET /sessions/{id}/events?since={n}&wait={s}` | optional `since` query value, parsed as in section 3; absence defaults to `0`. Optional `wait` in whole seconds, per the event-wait rules of section 3 | `200` + session event envelopes with `seq >` parsed `since`, in increasing sequence order |
434
+ | `appendSessionWithArtifacts` | `POST /sessions/{id}/deposits` | atomic deposit request | `201` + `{event, artifacts}` using session shapes; the substitution algorithm of section 8 applies unchanged |
435
+ | `putSessionArtifact` | `POST /sessions/{id}/artifacts` | artifact input | `201` + session artifact metadata |
436
+ | `getSessionArtifact` | `GET /sessions/{id}/artifacts?kind={kind}&rev={n}` | required nonempty `kind`; optional `rev` | `200` + artifact read; missing kind/revision is `200 null` |
437
+ | `listSessionArtifacts` | `GET /sessions/{id}/artifact-list?kind={kind}` | optional `kind` | `200` + metadata ordered by kind and then increasing revision |
438
+
439
+ Session-scoped streams: the stream scope vocabulary gains
440
+ `{ "kind": "session", "session": "os_…" }`. `createStream` and `listStreams`
441
+ exist under the session family (`POST|GET /sessions/{id}/streams`), the
442
+ addressed chunk/close operations work unchanged, and a close deposits its
443
+ finalized artifact on the session's artifact table. All stream rules of
444
+ section 6 apply to session-scoped streams unchanged.
445
+
446
+ Event reads honor the bounded wait: when no newer event exists, the server may
447
+ hold the request up to the parsed `wait` bound in whole seconds, returning as
448
+ soon as an event is appended and no later than the bound; a `wait` above the
449
+ event-wait ceiling of section 3 — 30 seconds on the shipped self-hosted server,
450
+ 25 on the shipped hosted service — is clamped to it. Held session-event reads poll at the hosted
451
+ one-second budget (`EVENT_WAIT_POLL_MS = 1000`), matching the build and
452
+ repository event reads, so append-to-wake is typically under one second. The
453
+ clamp and early-return semantics are exactly the
454
+ stream read's, so a poll loop cannot drift between the two.
455
+
456
+ Deposits, artifacts, and per-session sequencing follow the same contracts as
457
+ the repository journal: the atomic-deposit guarantee of section 8, latest or
458
+ pinned artifact reads, and independent per-session sequence numbering. There
459
+ is no session lease family.
460
+
461
+ ## 6. Stream operations
462
+
463
+ Streams are the BuildStore's third primitive: an append-only, per-stream
464
+ sequenced log of chunks with an open-then-closed lifecycle that finalizes into
465
+ an artifact. A chunk is a nonempty batch of protocol parts from the Vercel AI
466
+ SDK UI Message Stream protocol, version 1 of the current major (AI SDK 7):
467
+ each part is a JSON object with a nonempty-string `type`, and part types are
468
+ exactly that protocol's (`text-start`/`text-delta`/`text-end`,
469
+ `reasoning-start`/`-delta`/`-end`, `tool-input-*`, `tool-output-*`,
470
+ `tool-approval-*`, `start-step`, `finish-step`, `start`, `finish`, `abort`,
471
+ `error`, `message-metadata`, `source-url`, `source-document`, `file`,
472
+ `reasoning-file`, `custom`, and the open `data-*` namespace). The store
473
+ performs no protocol validation on append; validation happens once, at close,
474
+ when the chunks assemble into the protocol's `UIMessage[]` document. Streams
475
+ are presentation, never routing: no kernel, engine, reducer, or dispatcher
476
+ decision reads stream content.
477
+
478
+ Every stream record carries a server-assigned id (`st_`-prefixed), its scope,
479
+ a caller-supplied label, the literal format `ai-ui-message-stream/v1`, a
480
+ status of `open` or `closed`, `createdAt`, and, once closed, its outcome
481
+ (`completed` or `aborted`), `closedAt`, and the reference of its finalized
482
+ artifact:
483
+
484
+ ```jsonc
485
+ // StreamRecord
486
+ {
487
+ "id": "st_9f2c…",
488
+ "scope": { "kind": "build", "build": "remote-store-protocol" },
489
+ // or { "kind": "repo", "repo": "acme/autobuild" }
490
+ "label": "implement round 1",
491
+ "format": "ai-ui-message-stream/v1",
492
+ "status": "closed",
493
+ "createdAt": "2026-07-15T12:00:00.000Z",
494
+ "closedAt": "2026-07-15T12:03:00.000Z", // optional; closed only
495
+ "outcome": "completed", // optional; closed only
496
+ "artifact": { // optional; closed only
497
+ "kind": "stream:st_9f2c…",
498
+ "revision": 0,
499
+ "blobRef": "64-lowercase-hex-sha256"
500
+ }
501
+ }
502
+ ```
503
+
504
+ A chunk response is:
505
+
506
+ ```jsonc
507
+ {
508
+ "stream": "st_9f2c…",
509
+ "seq": 1, // per-stream, assigned by the server from 1
510
+ "ts": "2026-07-15T12:00:00.000Z",
511
+ "parts": [ { "type": "text-delta", "id": "t1", "delta": "…" } ]
512
+ }
513
+ ```
514
+
515
+ A read response carries the chunks plus the stream's current status and,
516
+ when closed, its outcome and artifact reference:
517
+
518
+ ```jsonc
519
+ {
520
+ "chunks": [ /* StreamChunk */ ],
521
+ "status": "open",
522
+ "outcome": "completed", // optional; closed only
523
+ "artifact": { /* StreamArtifactRef */ } // optional; closed only
524
+ }
525
+ ```
526
+
527
+ `{slug}` and `{repo}` mean the same percent-encoded path segments as above.
528
+ All six operations exist under both resource families; the scope is fixed at
529
+ create and comes from the path. All stream routes authorize like the event
530
+ routes (section 7: resource scope gates everything) and carry no
531
+ session-attribution dimension — stream parts have no actor.
532
+
533
+ | BuildStore operation | HTTP route | Request | Success |
534
+ |---|---|---|---|
535
+ | `createStream` | `POST /builds/{slug}/streams` and `POST /repos/{repo}/streams` | `{"label": string}` with a nonempty label | `201` + `StreamRecord` |
536
+ | `listStreams` | `GET /builds/{slug}/streams` and `GET /repos/{repo}/streams` | none | `200` + `StreamRecord[]`, creation order (`createdAt`, then the store's creation counter for same-millisecond ties) |
537
+ | `getStream` | `GET /builds/{slug}/streams/{id}` and `GET /repos/{repo}/streams/{id}` | none | `200` + `StreamRecord`; `404` when unknown **or** scoped to another resource |
538
+ | `appendStreamParts` | `POST /builds/{slug}/streams/{id}/chunks` and `POST /repos/{repo}/streams/{id}/chunks` | `{"parts": [ { "type": nonempty string, … } ]}`, nonempty | `201` + `StreamChunk` |
539
+ | `readStream` | `GET /builds/{slug}/streams/{id}/chunks?since={n}&wait={n}` and the `/repos/{repo}` form | optional `since` (default `0`) parsed leniently like the `since` of section 3; `wait` (whole seconds) parsed by the digits-only grammar of section 3's event-wait rules — one or more ASCII digits, anything else `400 validation` per section 9 — and clamped to the 30-second stream ceiling below | `200` + read response: chunks with `seq >` parsed `since`, in increasing order |
540
+ | `closeStream` | `POST /builds/{slug}/streams/{id}/close` and `POST /repos/{repo}/streams/{id}/close` | `{"outcome": "completed" \| "aborted"}` | `200` + the closed `StreamRecord` |
541
+
542
+ Because a stream id is globally unique but names no scope, the shipped server
543
+ and client additionally expose the four addressed operations as top-level
544
+ routes, which resolve the stream's own scope and then authorize against it:
545
+
546
+ | Operation | Top-level route | Notes |
547
+ |---|---|---|
548
+ | `getStream` | `GET /streams/{id}` | `404` maps to `null` in the shipped client |
549
+ | `appendStreamParts` | `POST /streams/{id}/chunks` | |
550
+ | `readStream` | `GET /streams/{id}/chunks?since={n}&wait={n}` | |
551
+ | `closeStream` | `POST /streams/{id}/close` | |
552
+
553
+ On both route families, a stream that does not exist and one scoped to a
554
+ different resource are the same `404 not-found` (`unknown stream "…"`), so no
555
+ cross-scope existence leaks; a token that does not cover the resolved scope
556
+ receives `401`/`403` per section 7.
557
+
558
+ Append semantics, enforced before any mutation:
559
+
560
+ - Each part must be a JSON object whose `type` is a nonempty string; anything
561
+ else is `400 validation`. Unknown part keys must survive the wire untouched.
562
+ - The server assigns each appended batch a per-stream sequence starting at 1
563
+ and the timestamp; producers cannot fake ordering.
564
+ - A batch whose serialized JSON exceeds 1,048,576 bytes is `413 validation`
565
+ naming the ceiling — the same bound and shape as the artifact ceiling.
566
+ - Appending to a closed stream is `409 conflict` (`stream "…" is closed`);
567
+ appending to an unknown stream is `404 not-found`. Neither writes anything.
568
+ - The server resolves and authorizes the stream **before** parsing the append
569
+ body, so a malformed or oversized batch on an unknown or foreign-scoped
570
+ stream is `404 not-found`, not `400 validation` — and the `413` ceiling is
571
+ likewise never reached, since the body is never evaluated. The `400` and
572
+ `413` rejections apply only after the stream resolves.
573
+
574
+ Read wait semantics: when no newer chunk exists and the stream is open, the
575
+ server may hold the request up to the parsed `wait` bound in whole seconds,
576
+ returning as soon as a chunk is appended or the stream closes, and no later
577
+ than the bound. A `wait` above 30 seconds is clamped to 30. Reads of a closed
578
+ stream never wait. A server may return before the bound at any time. Unlike
579
+ `since`, the `wait` value follows the strict digits grammar of section 3's
580
+ event-wait rules (a value that is not one or more ASCII digits is `400`
581
+ `validation` per section 9), with the 30-second stream ceiling as its clamp.
582
+
583
+ Close semantics: one atomic operation that assembles the chunks into the
584
+ protocol's `UIMessage[]` document — following the protocol for its defined
585
+ part types and dropping parts of undefined types while counting them —
586
+ deposits that document as an artifact on the owning scope with kind
587
+ `stream:<streamId>` at revision 0 and metadata naming the stream id, label,
588
+ scope, outcome, chunk count, and dropped-part count, and marks the stream
589
+ closed. If the artifact deposit fails, the stream stays open and nothing is
590
+ written. Closing an already-closed stream is a no-op that returns the record
591
+ (regardless of the requested outcome).
592
+
593
+ Chunk retention is deposit-path and count-based, like artifact retention: a
594
+ closed stream's chunks remain readable until the next stream is created in
595
+ the same scope, at which point the chunks of every previously closed stream
596
+ in that scope except the most recently closed one are deleted in the same
597
+ transaction as the create. Finalized `stream:*` artifacts are never touched
598
+ by this rule (their retention belongs to the open archival thread), and open
599
+ streams are never pruned. A read of a stream whose chunks were pruned returns
600
+ no chunks, `status: "closed"`, and the artifact reference.
601
+
602
+ These additions are purely additive: the protocol version stays `2`, and a
603
+ conforming server implements the stream routes together with this document's
604
+ rules exactly as it does for the event and artifact routes.
605
+
606
+ ## 7. Authentication and token scope
607
+
608
+ ### Open and authenticated modes
609
+
610
+ A server configured without a signing secret is in open mode: no route checks
611
+ a token or event session attribution. This is intended for local development
612
+ and the open contract harness.
613
+
614
+ A server configured with a secret requires this header on every `/builds`,
615
+ `/repos`, and `/sessions` route:
616
+
617
+ ```http
618
+ Authorization: Bearer <token>
619
+ ```
620
+
621
+ `GET /health` is always unauthenticated. Package/protocol identity validation
622
+ occurs first on machine routes. Authorization then occurs before request body
623
+ processing on collection routes and before resource lookup on resource routes.
624
+ A caller with the wrong scope therefore cannot use existence or validation
625
+ differences to inspect another resource.
626
+
627
+ ### Token encoding
628
+
629
+ A token has two dot-separated base64url segments:
630
+
631
+ ```text
632
+ base64url(UTF-8 JSON scope) + "." + base64url(HMAC-SHA256(secret, payloadSegment))
633
+ ```
634
+
635
+ The HMAC input is the first token segment exactly as encoded, not the decoded
636
+ JSON bytes. The signature is compared in constant time. The scope JSON must
637
+ match exactly one of these shapes; unknown keys are invalid:
638
+
639
+ ```jsonc
640
+ // Legacy build scope, retained for wire compatibility
641
+ { "build": "build-slug", "session": "s_123", "exp": 1784116800000 }
642
+
643
+ // Explicit resource scope
644
+ {
645
+ "resource": { "kind": "build", "id": "build-slug" },
646
+ "session": "s_123",
647
+ "exp": 1784116800000
648
+ }
649
+
650
+ {
651
+ "resource": { "kind": "repo", "id": "acme/autobuild" },
652
+ "session": "hs_123",
653
+ "exp": 1784116800000
654
+ }
655
+
656
+ {
657
+ "resource": { "kind": "session", "id": "os_123" },
658
+ "session": "*",
659
+ "exp": 1784116800000
660
+ }
661
+ ```
662
+
663
+ `build`, resource `id`, and `session` are nonempty strings. A resource or
664
+ operator scope may carry an optional `via` claim (the same shape as the
665
+ actor's `via` marker). `exp` is an integer
666
+ Unix epoch in **milliseconds**. A token is expired when `exp <=` the server's
667
+ current epoch milliseconds. A malformed token, malformed scope, bad signature,
668
+ or expired token is invalid and receives `401 auth` without a more specific
669
+ verification diagnostic.
670
+
671
+ The legacy scope `{"build":"*", ...}` is the admin resource scope. The
672
+ normal runner/admin form also uses `"session":"*"`, allowing event writes on
673
+ behalf of any valid actor. Only the legacy `build: "*"` spelling grants admin
674
+ resource access; an explicit resource whose id is `"*"` is not admin.
675
+
676
+ ### Resource authorization matrix
677
+
678
+ | Token resource | `/builds` create/list | one matching build | `/repos` ensure | one matching repo | `/repos/{repo}/sessions` create/list | one matching session |
679
+ |---|---:|---:|---:|---:|---:|---:|
680
+ | admin (`build: "*"`) | yes | any | yes | any | yes | any |
681
+ | build | no | exact id only | no | no | no | no |
682
+ | repo | no | no | no | exact id only | yes | no |
683
+ | session | no | no | no | no | no | exact id only |
684
+
685
+ A valid token used for the wrong resource receives `403 auth`. Resource scope
686
+ gates all operations, including reads, artifact operations, and leases. A
687
+ session resource token's authority is exactly its one session — it cannot
688
+ create or list sessions, and it cannot touch another session's events,
689
+ artifacts, or streams. A repo token owns its repository's session collection
690
+ routes but not any session resource. Session resource and repo/build tokens
691
+ never cross.
692
+
693
+ The token's session dimension adds a second gate only to event-bearing writes:
694
+ `POST .../events`, `POST .../events/conditional`, and `POST .../deposits`.
695
+
696
+ - `session: "*"` may submit any actor that the event catalog itself allows.
697
+ - Any other session may submit only an actor with `kind: "agent"` and a
698
+ `session` exactly equal to the token session.
699
+ - Another agent session or a kernel, human, dispatcher, or ingester actor
700
+ receives `403 auth` before catalog validation.
701
+ - Reads, standalone artifact puts, and lease operations do not apply session
702
+ attribution beyond the resource-scope check.
703
+
704
+ ### Delegated-write attribution (`via`)
705
+
706
+ On every event-bearing write (`POST .../events`, `POST .../events/conditional`,
707
+ and `POST .../deposits` on build, repository, and session resources), a server
708
+ in authenticated mode enforces `via` attribution after token verification and
709
+ before catalog validation:
710
+
711
+ - A human actor claiming a `via` the token does not carry is `403 auth` — the
712
+ caller must be able to distinguish "not your delegate" from "malformed
713
+ event", which is why this is authority (403) and not validation (422).
714
+ - A token carrying a `via` claim stamps that `via` onto every human actor in
715
+ the write: the token is authoritative, and a matching claim or no claim both
716
+ end with the token's `via` on the stored event.
717
+ - A non-human actor carrying a `via` key is not an authority question; it falls
718
+ through to catalog validation, which rejects it with the rule message.
719
+ - An unscoped operator token carries no `via` and never authorizes raw store
720
+ routes at all.
721
+
722
+ In open mode there is no token to be authoritative and writes pass through
723
+ unchanged. Events without a `via` member are stored, replayed, and read
724
+ byte-identically to before.
725
+
726
+ Build and repository scopes never cross: a harvest repository token cannot
727
+ read a build stream, and a build token cannot read a repository journal.
728
+ Attributed human-operator scopes have
729
+ `{ "operator": { "user": "nonblank identity" }, "exp": … }`. They are accepted
730
+ only by the [operator API](operator-api.md), never by raw store or ticket routes;
731
+ conversely, deployment/admin/build/repository scopes are refused there.
732
+ Deployment scopes have `{ "operator": true, "session": "*", "exp": … }` and
733
+ cover raw store and [hosted ticket](remote-ticket-protocol.md) routes without
734
+ granting attributed operator controls.
735
+
736
+ ## 8. Atomic deposits
737
+
738
+ The in-process `appendWithArtifacts` APIs take a callback that receives
739
+ server-assigned artifact revisions. A callback cannot cross HTTP, so the wire
740
+ uses negative revision placeholders.
741
+
742
+ For request artifact at zero-based index `i`, the placeholder revision is:
743
+
744
+ ```text
745
+ -(i + 1)
746
+ ```
747
+
748
+ Thus the first artifact uses `-1`, the second `-2`, and so on. The client runs
749
+ its callback against sentinel metadata and sends the resulting event write.
750
+ The server must perform this algorithm:
751
+
752
+ 1. Decode and prepare all request artifacts without publishing any artifact
753
+ metadata or event.
754
+ 2. Inside the backing store's atomic `appendWithArtifacts` operation, assign
755
+ each artifact's real per-kind revision.
756
+ 3. Recursively walk **the event payload**. Arrays and object values are walked.
757
+ 4. Replace a value only when it is an object with exactly two own keys,
758
+ `kind` and `rev`, where `kind` is a string and `rev` is a negative integer.
759
+ 5. Convert `rev` to index `-rev - 1`. If that request artifact exists, replace
760
+ the whole object with the assigned metadata's
761
+ `{"kind": meta.kind, "rev": meta.revision}`. The placeholder's supplied
762
+ `kind` does not override the deposited artifact's kind.
763
+ 6. Leave an out-of-range placeholder unchanged. Catalog validation will reject
764
+ a negative revision wherever that value is governed by an artifact-ref
765
+ schema.
766
+ 7. Validate the substituted event write, then make all artifact metadata and
767
+ the event visible as one commit.
768
+
769
+ Substitution does not inspect actor, type, artifact metadata, or arbitrary
770
+ objects with extra keys. It supports exact `{kind, rev}` references only;
771
+ arithmetic or string computation over a future revision is not part of the
772
+ protocol.
773
+
774
+ The all-or-nothing guarantee covers artifact metadata and the event: no
775
+ failure may expose one without the other, and a failed bundle consumes no
776
+ artifact revision or event sequence. A backing implementation may leave an
777
+ unreferenced content-addressed blob after failure, because blobs are immutable
778
+ and metadata is the visibility boundary. Revision assignment, substitution,
779
+ event validation, and metadata/event commit must be serialized or
780
+ transactional so concurrent deposits receive distinct revisions and events.
781
+
782
+ These rules apply identically to build, repository, and session deposits.
783
+
784
+ ## 9. Errors and validation
785
+
786
+ Every non-success response is exactly the JSON error shape from section 2.
787
+ The shipped server maps failures as follows:
788
+
789
+ | Status | `kind` | Meaning |
790
+ |---:|---|---|
791
+ | `400` | `validation` | Invalid JSON, request body schema, malformed percent-encoding, missing/empty required `kind`, a `since`/`rev` value whose JavaScript `Number` conversion is not an integer, or a `wait` value that is not one or more ASCII digits |
792
+ | `401` | `auth` | Missing bearer credentials or an invalid, malformed, badly signed, or expired token |
793
+ | `403` | `auth` | Valid token with the wrong resource scope or event-session attribution |
794
+ | `404` | `not-found` | Unknown route, unsupported method, unknown build, unknown repository, unknown session, or an unknown or foreign-scoped stream |
795
+ | `409` | `conflict` | Missing/mismatched package or protocol identity, duplicate build creation, appending to a closed stream (`stream "…" is closed`), or a backing conflict reported as already existing |
796
+ | `413` | `validation` | Decoded artifact or serialized stream batch exceeds the configured ceiling; the message names the ceiling |
797
+ | `422` | `validation` | `EventValidationError` from build, repository, or session catalog validation; its message is preserved verbatim |
798
+ | `500` | `internal` | Any other unexpected backing or server failure; the thrown error message is returned |
799
+
800
+ Authentication runs before resource lookup, and session authorization runs
801
+ before event catalog validation. A request may therefore receive `401` or
802
+ `403` even if its resource or event body is also invalid.
803
+
804
+ Stream appends are evaluated in a fixed order — authentication, stream
805
+ resolution and scope authorization, body schema, backing-store validation and
806
+ batch ceiling, closed check — so each earlier rejection masks the later ones:
807
+ a request with both an unknown stream and an invalid body is `404`, and an
808
+ oversized batch addressed to an unknown stream is `404` too (the body, and
809
+ with it the ceiling, is never evaluated). Note this ordering differs from the
810
+ local store adapters, which validate the batch before resolving the stream —
811
+ the streams section of the store specification states both orders
812
+ authoritatively.
813
+
814
+ A `400` wire-schema rejection performs no backing mutation. A `422` event
815
+ rejection appends no event. For a deposit, either class of validation failure
816
+ also exposes no artifact metadata and consumes no revisions or sequences.
817
+ Unknown-resource writes are rejected and perform no mutation.
818
+
819
+ The generic wire event schema deliberately does not duplicate the event
820
+ catalog. Servers must call the matched validator — `validateEventWrite`,
821
+ `validateRepositoryEventWrite`, or `validateSessionEventWrite` — before every
822
+ ordinary append and inside every atomic deposit.
823
+
824
+ ## 10. Lease and persistence requirements
825
+
826
+ A conforming backing server provides these behaviors for both build and
827
+ repository leases:
828
+
829
+ - A claim succeeds when the lease is absent, expired, or already belongs to
830
+ the same holder. Success stores the holder, the claim's TTL, and expiry
831
+ `now + ttlMs` and returns `{"ok":true}`.
832
+ - A different holder is rejected with `{"ok":false}` while the current lease
833
+ is unexpired. Expiry is strict: at `expiresAt <= now`, the lease is expired
834
+ and claimable.
835
+ - A same-holder claim renews the lease using the new claim TTL.
836
+ - A heartbeat succeeds only for the current holder while the lease is
837
+ unexpired. It records `heartbeatAt` and extends expiry to
838
+ `now +` the TTL from the successful claim. It does not accept a new TTL.
839
+ - A missing, expired, or wrong-holder heartbeat is a no-op returning
840
+ `{"ok":false}`.
841
+ - Release removes the lease only for its current holder. A wrong-holder
842
+ release is a no-op, but the HTTP response remains `{"ok":true}`.
843
+ - Claims, heartbeat checks, and release decisions are atomic under concurrent
844
+ callers.
845
+
846
+ Beyond leases, the backing store must maintain:
847
+
848
+ - per-build and per-repository monotonically assigned event sequences starting
849
+ at 1, preserving append order and continuity across server restarts;
850
+ - atomic build-stream conditional append, including a mutation-free stale
851
+ comparison result;
852
+ - server-assigned event, record, artifact, lease, and heartbeat times;
853
+ - 0-based revisions independently per resource and artifact kind, including
854
+ distinct concurrent assignments;
855
+ - SHA-256 content addresses and exact byte, metadata, and metadata-list round
856
+ trips;
857
+ - artifact lists ordered by kind and revision;
858
+ - strict event payload and actor validation before mutation;
859
+ - atomic deposit visibility and rollback as described in section 8; and
860
+ - rejection of event, artifact, and lease writes to unknown resources.
861
+
862
+ For streams, the backing store must additionally maintain:
863
+
864
+ - per-stream monotonically assigned chunk sequences starting at 1, preserving
865
+ append order and continuity across server restarts;
866
+ - append rejection (with no mutation) for closed and unknown streams and for
867
+ batches above the serialized-byte ceiling;
868
+ - the bounded read wait exactly as specified in section 6, including the
869
+ 30-second clamp and no wait on closed streams;
870
+ - the bounded wait on the build, repository, and session event reads exactly
871
+ as specified in section 3, with no per-request database poll faster than once
872
+ per second (the shipped PostgreSQL adapter polls held reads at
873
+ `EVENT_WAIT_POLL_MS = 1000`, so an append by another connection is observed
874
+ at the next poll — typically within about one second, worst case one poll
875
+ interval plus the query round-trip; a hard ≤1 s worst case would need
876
+ LISTEN/NOTIFY wake-on-append at a dedicated connection per held request on
877
+ a pooled provider, a cost considered and declined);
878
+ - the atomic close — document assembly, artifact deposit, and the closed
879
+ record visible together or not at all, with the stream left open and
880
+ unwritten when the deposit fails;
881
+ - the chunk-retention prune at the next create in a scope, in the same
882
+ transaction, keeping the most recently closed stream's chunks; and
883
+ - stream ids that are globally unique and `st_`-prefixed.
884
+
885
+ ## 11. Client-only behavior and health
886
+
887
+ Four shipped behaviors do not add `BuildStore` routes:
888
+
889
+ - `RemoteBuildStore.scopeBuild(slug)` and `RemoteBuildStore.scopeSession(id)`
890
+ return interface-enforced client handles for exactly that build or session.
891
+ Own-resource record, event, artifact, and (for builds) lease calls use the
892
+ existing routes below. Foreign-resource calls, collection/admin operations,
893
+ nested foreign scope, `close`, and every other family's operations fail in
894
+ the client before a request. This is required even with an open server or an
895
+ admin token: HTTP token scope carries authority over the wire as defense in
896
+ depth; it does not create Store scope.
897
+ - `RemoteBuildStore.subscribe(slug, options, onEvent)` polls
898
+ `GET /builds/{slug}/events?since=<lastSeq>`. It starts with
899
+ `options.fromSeq ?? 0`, delivers increasing sequence numbers exactly once
900
+ within that subscription, does not overlap polls, and ignores a polling
901
+ error, retrying on the next cycle. Calling the returned unsubscribe
902
+ function stops future delivery and aborts an in-flight held request. There
903
+ is no repository subscribe method.
904
+ Its cadence follows `options.waitSeconds` (AUT-334), which defaults to the
905
+ client's bounded window (25 s, `REMOTE_EVENT_WAIT_SECONDS`) when the caller
906
+ omits it — so a plain `subscribe` holds one request per wait window on a
907
+ quiet stream, and `waitSeconds: 0` restores the immediate interval loop
908
+ (one read, then one read per `options.pollMs ?? 250`). With a positive
909
+ `waitSeconds`, each cycle issues one held request (`wait=<waitSeconds>`, so
910
+ the server honors the section 3 bound) and then gap-fills so request
911
+ *starts* stay at least `options.pollMs ?? 250` apart, elapsed request time
912
+ counting toward the gap — degrading to exactly the interval rate against a
913
+ server that answers immediately. Delivery order and exactly-once semantics
914
+ are identical in all modes.
915
+ - `RemoteBuildStore.close()` is a no-op. The remote server owns its backing
916
+ store lifecycle; there is no close endpoint.
917
+ - `GET /health` is outside the `BuildStore` interface. It is always open and
918
+ returns exactly `200 {"ok":true,"autobuildVersion":"<package>",
919
+ "protocolVersion":"2"}`. It reports HTTP-process availability and identity,
920
+ not a deeper backing-store transaction or migration check.
921
+
922
+ There is no push/WebSocket subscription protocol, batch-read route,
923
+ repository listing, artifact deletion, or server deployment API.
924
+
925
+ ## 12. Conformance
926
+
927
+ The compatibility bar is an HTTP-backed `BuildStore` client driving the
928
+ complete `describeBuildStoreContract` suite against a clean server. Both the
929
+ contract function and the `BuildStore` type are public exports from
930
+ `@defrex/autobuild/plugin-sdk`. A test registration has this shape:
931
+
932
+ ```ts
933
+ import {
934
+ describeBuildStoreContract,
935
+ type BuildStore,
936
+ } from '@defrex/autobuild/plugin-sdk'
937
+
938
+ describeBuildStoreContract('my remote store', async (opts) => {
939
+ // Start a fresh, isolated server and database for every factory call.
940
+ // Pass opts?.clock to the server/backing store, or connect equivalent
941
+ // deterministic clock control in the external test harness.
942
+ const server = await startMyStore({ clock: opts?.clock })
943
+ const store: BuildStore = createHttpBackedBuildStore({
944
+ url: server.url,
945
+ // Omit against an open test server, or supply an admin token.
946
+ token: server.adminToken,
947
+ })
948
+
949
+ return {
950
+ store,
951
+ cleanup: async () => server.stopAndDeleteState(),
952
+ }
953
+ })
954
+ ```
955
+
956
+ The factory must isolate state between tests. It must also connect the suite's
957
+ injected clock—or an equivalent controllable server clock—because the contract
958
+ asserts exact store timestamps, lease expiry, renewal, and heartbeat behavior.
959
+ If authentication is enabled, the harness must mint a valid admin token for
960
+ the controlled server time.
961
+
962
+ Add protocol-specific tests alongside the shared contract for token encoding
963
+ and resource isolation, session-attributed writes, exact validation feedback,
964
+ atomic placeholder substitution, event paging, unauthenticated health, and
965
+ continuity across server restarts. The session family (section 5), the
966
+ session-scoped token's authority matrix, and the `via` stamp/reject order
967
+ (section 7) are part of the contract suite and of the protocol-specific cases. Exercise all routes through HTTP, not by
968
+ calling the backing store directly.
969
+
970
+ Passing only route smoke tests is not conformance. The complete shared contract
971
+ driven through an HTTP-backed client, plus the protocol-specific cases above,
972
+ is the required compatibility test.