@intx/hub-sessions 0.1.2 → 0.2.2

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 (87) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +84 -1
  3. package/dist/agent-repo.d.ts +89 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +185 -0
  7. package/dist/asset-service.d.ts +123 -0
  8. package/dist/asset-service.js +349 -0
  9. package/dist/available-skills-stanza.d.ts +21 -0
  10. package/dist/available-skills-stanza.js +32 -0
  11. package/dist/credential-push.d.ts +32 -0
  12. package/dist/credential-push.js +85 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +357 -0
  17. package/dist/hub-session-lookups.d.ts +17 -0
  18. package/dist/hub-session-lookups.js +204 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +122 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +16 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1692 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +53 -0
  30. package/dist/repo-store/subscribe-kind.js +179 -0
  31. package/dist/repo-store/types.d.ts +483 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +235 -0
  34. package/dist/session-service.js +997 -0
  35. package/dist/skill-kind.d.ts +41 -0
  36. package/dist/skill-kind.js +288 -0
  37. package/dist/substrate.d.ts +8 -0
  38. package/dist/substrate.js +21 -0
  39. package/dist/workflow-kind.d.ts +21 -0
  40. package/dist/workflow-kind.js +263 -0
  41. package/dist/workflow-run-event-log.d.ts +21 -0
  42. package/dist/workflow-run-event-log.js +51 -0
  43. package/dist/workflow-run-kind.d.ts +326 -0
  44. package/dist/workflow-run-kind.js +2646 -0
  45. package/dist/workflow-run-reader.d.ts +47 -0
  46. package/dist/workflow-run-reader.js +157 -0
  47. package/dist/ws/index.d.ts +3 -0
  48. package/dist/ws/index.js +3 -0
  49. package/dist/ws/sidecar-events.d.ts +134 -0
  50. package/dist/ws/sidecar-events.js +70 -0
  51. package/dist/ws/sidecar-handler.d.ts +184 -0
  52. package/dist/ws/sidecar-handler.js +1603 -0
  53. package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
  54. package/dist/ws/sidecar-token-authenticator.js +24 -0
  55. package/package.json +34 -12
  56. package/src/agent-repo.test.ts +0 -310
  57. package/src/agent-repo.ts +0 -165
  58. package/src/agent-state-kind.test.ts +0 -247
  59. package/src/agent-state-kind.ts +0 -204
  60. package/src/asset-service.test.ts +0 -540
  61. package/src/asset-service.ts +0 -378
  62. package/src/available-skills-stanza.test.ts +0 -87
  63. package/src/available-skills-stanza.ts +0 -47
  64. package/src/credential-push.ts +0 -65
  65. package/src/event-collector-registry.test.ts +0 -73
  66. package/src/event-collector-registry.ts +0 -171
  67. package/src/event-collector.test.ts +0 -1387
  68. package/src/event-collector.ts +0 -424
  69. package/src/hub-session-lookups.ts +0 -206
  70. package/src/hub-session-orchestrator.test.ts +0 -510
  71. package/src/hub-session-orchestrator.ts +0 -213
  72. package/src/index.ts +0 -78
  73. package/src/repo-store/index.ts +0 -15
  74. package/src/repo-store/store.test.ts +0 -1169
  75. package/src/repo-store/store.ts +0 -428
  76. package/src/repo-store/types.ts +0 -253
  77. package/src/session-service.test.ts +0 -895
  78. package/src/session-service.ts +0 -464
  79. package/src/skill-kind.test.ts +0 -599
  80. package/src/skill-kind.ts +0 -350
  81. package/src/ws/index.ts +0 -18
  82. package/src/ws/sidecar-events.test.ts +0 -96
  83. package/src/ws/sidecar-events.ts +0 -231
  84. package/src/ws/sidecar-handler.test.ts +0 -2217
  85. package/src/ws/sidecar-handler.ts +0 -1574
  86. package/tsconfig.json +0 -4
  87. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,2646 @@
1
+ // KindHandler for the `workflow-run` repo kind.
2
+ //
3
+ // A workflow-run repo holds per-deployment runtime state for one or
4
+ // more in-flight workflow runs. `RepoId.id` is the owning deployment
5
+ // id. The repo's top-level layout is:
6
+ //
7
+ // - `runs/<runId>/events/<seq>.json` — per-run event log entries.
8
+ // Each entry is a JSON object whose body carries a `type`
9
+ // discriminator (the on-disk event vocabulary used by the
10
+ // workflow-run repo) and a `seq` field that matches the integer
11
+ // in the filename. Filenames are decimal integers ranging from
12
+ // `0` upward; the on-disk seq numbering owns the ordering and
13
+ // the per-blob `seq` field is the redundant cross-check.
14
+ // - `runs/<runId>/blobs/<sha256-hex>` — content-addressed step
15
+ // outputs the production `BlobSubstrate` adapter spills here when
16
+ // a value's JSON-stringified form exceeds the inline-encoding
17
+ // threshold. The filename is a lowercase 64-character sha256 hex
18
+ // string; the blob value is opaque bytes. Blobs are append-only
19
+ // and immutable: any blob present in the prior tree must carry
20
+ // byte-identical contents in the prospective tree.
21
+ // - `addresses/<urlEncoded(address)>/inbox/<receivedAt>-<messageId>.json`
22
+ // — pending inbound mail for the address, FIFO-ordered by the
23
+ // filename's parsed numeric `receivedAt` prefix (with a
24
+ // lexicographic messageId tiebreak). The filename keeps the
25
+ // decimal `<receivedAt>` form unpadded; the substrate sorts by
26
+ // parsed integer rather than string so the FIFO invariant holds
27
+ // for non-uniform digit widths (e.g. `99-…` precedes `100-…`).
28
+ // - `addresses/<urlEncoded(address)>/processing/<receivedAt>-<messageId>.json`
29
+ // — messages currently being handled. Same filename shape and
30
+ // JSON envelope as the inbox entry; a `dequeueToProcessing`
31
+ // commit atomically removes the inbox entry and adds the
32
+ // processing entry preserving the filename key.
33
+ // - `addresses/<urlEncoded(address)>/consumed/<messageId>.json` —
34
+ // dedup index keyed by messageId. A `markConsumed` commit
35
+ // atomically removes the matching processing entry and writes
36
+ // this dedup entry. The dedup index is bounded by a per-address
37
+ // retention watermark (see `watermark.json`): a `markConsumed`
38
+ // commit prunes consumed entries whose `receivedAt` falls below
39
+ // the watermark so the index reaches a bounded steady state
40
+ // instead of growing one entry per message forever.
41
+ // - `addresses/<urlEncoded(address)>/watermark.json` — the
42
+ // per-address retention watermark. Carries a single
43
+ // `receivedAt`-horizon value: the oldest `receivedAt` a consumed
44
+ // entry may still retain. The watermark only ever advances
45
+ // (monotonic non-decreasing). `enqueueInbox` rejects any inbound
46
+ // whose `receivedAt` is strictly below the watermark as
47
+ // definitively-stale (its dedup entry may have been pruned, so a
48
+ // duplicate cannot be ruled out -- refuse loudly rather than risk
49
+ // reprocessing). Above the watermark the `consumed/` index is
50
+ // authoritative; below it, refuse. The watermark advances only as
51
+ // the prune advances, both under the single writer, so the two
52
+ // never diverge. The stale-reject applies ONLY to fresh inbound at
53
+ // `enqueueInbox`; `replayProcessingToInbox` is intentionally exempt
54
+ // (a recovered in-flight `processing/` entry is already past dedup
55
+ // -- see that function's note).
56
+ // - `.gitignore` — supplied by the asset routes' genesis init body.
57
+ //
58
+ // The control-plane subtree (`control/...`) is not part of this
59
+ // commit's surface and has no v1 use case.
60
+ //
61
+ // Event-log invariants enforced at push:
62
+ // - Each event body's `seq` matches the integer in its filename.
63
+ // - Per-run event filenames are unique decimal integers (guaranteed
64
+ // by the tree shape) and validatePush verifies the body's `seq`
65
+ // field carries the same number, so the on-disk seq sequence and
66
+ // the per-blob seq cannot diverge.
67
+ // - Terminal-phase lock: once a run's events include a `RunCompleted`,
68
+ // `RunFailed`, or `RunCancelled` entry, no event with a strictly
69
+ // greater seq may appear for the same run.
70
+ // - Append-only via prior-tree byte comparison: every event blob
71
+ // that exists at the same path in the parent commit's tree must
72
+ // match the prospective blob byte-for-byte. Newly-added event
73
+ // paths (those absent from the prior tree) are accepted. The
74
+ // substrate exposes the prior tree via `priorReadBlob` /
75
+ // `priorListDir` on the validatePush args so the constraint is
76
+ // owned by this handler rather than relying on caller-layer
77
+ // discipline.
78
+ // - A `CancelRequested` event must carry an `origin` in the known
79
+ // set (`self`, `supervisor-drain`, `supervisor-operator`,
80
+ // `hub-admin`) and a non-empty `reason`.
81
+ // - Principal-vs-origin enforcement for `CancelRequested`: a
82
+ // `hub-admin` origin requires the signing principal to be `hub`;
83
+ // the other three
84
+ // origins (`self`, `supervisor-drain`, `supervisor-operator`)
85
+ // require the signing principal to be `supervisor` — the
86
+ // supervisor signs on the child's behalf for `self`, and signs
87
+ // for itself on the drain / operator cases. A principal that does
88
+ // not match the declared origin produces a rejection naming both
89
+ // sides so a misconfigured writer surfaces at the boundary
90
+ // rather than as a downstream mystery.
91
+ //
92
+ // Claim-check subtree invariants enforced at push:
93
+ // - The `<urlEncoded>` segment under `addresses/` must round-trip
94
+ // cleanly through `decodeURIComponent` followed by
95
+ // `encodeURIComponent`. A segment that does not round-trip is
96
+ // rejected so consumers can rely on a single canonical encoding.
97
+ // - The only entries permitted under an `addresses/<urlEncoded>/`
98
+ // subtree are the directories `inbox`, `processing`, and
99
+ // `consumed`, plus the single `watermark.json` file. Other names
100
+ // under an address fail the push.
101
+ // - Inbox and processing filenames must match
102
+ // `<receivedAt>-<messageId>.json` where `receivedAt` is a decimal
103
+ // epoch-ms integer. The body's `receivedAt` matches the filename
104
+ // `receivedAt` and the body's `messageId` matches the filename
105
+ // `messageId`. The body's `address` field must decode to the
106
+ // URL-encoded segment.
107
+ // - Consumed filenames must match `<messageId>.json`. The body's
108
+ // `messageId` matches the filename `messageId`. The body carries
109
+ // a `consumedBy` run id and the `receivedAt` of the original
110
+ // consume for audit.
111
+ // - Atomicity: a given `<messageId>` appears in at most one
112
+ // filename across `inbox`, `processing`, and `consumed` combined,
113
+ // per address per prospective commit. Two inbox entries with the
114
+ // same `<messageId>` but different `<receivedAt>` are rejected as
115
+ // a same-state collision; the cross-state check fires when the
116
+ // same messageId appears in inbox+processing, inbox+consumed, or
117
+ // processing+consumed.
118
+ // - `consumed/<messageId>.json` bytes are immutable: a prospective
119
+ // commit that mutates the bytes of a consumed entry RETAINED from
120
+ // the prior tree is rejected by the same prior-tree byte-equality
121
+ // guard used for run events. A retained consumed entry may be
122
+ // DELETED only as a watermark-consistent retention prune (see the
123
+ // watermark invariants below); any other deletion is rejected.
124
+ // - Retention prune (the bounded-`consumed/` contract): the consumed
125
+ // dedup index may shrink only by a watermark-passed prune. A
126
+ // consumed entry present in the prior tree may be absent from the
127
+ // prospective tree only when (a) its `receivedAt` is strictly
128
+ // below the prospective `watermark.json` value (you may prune only
129
+ // what the watermark passed) and (b) the watermark did not regress
130
+ // (`prospective watermark >= prior watermark`). A RETAINED entry is
131
+ // NOT required to sit at or above the watermark: a message consumed
132
+ // long after receipt (or one replayed back in-flight after a crash)
133
+ // may legitimately carry a below-watermark `receivedAt` and survive
134
+ // until a later commit prunes it. Retaining it gives only EXTRA
135
+ // dedup -- a re-submission at or above the watermark still hits the
136
+ // entry, one below it is stale-rejected at enqueue -- so it never
137
+ // weakens exactly-once.
138
+ // - Inbox→processing transition: a processing entry that is newly
139
+ // added (not present in the prior tree) must be backed by a
140
+ // matching inbox entry in the prior tree at the same
141
+ // `<receivedAt>-<messageId>.json` key. If the prior tree does
142
+ // not show that inbox entry the transition is rejected so a
143
+ // direct write into `processing/` cannot bypass the inbox.
144
+ // - Processing→consumed transition: a consumed entry that is
145
+ // newly added (not present in the prior tree) must be backed by
146
+ // a processing entry in the prior tree at the same address with
147
+ // the same messageId. The receivedAt and messageId carried in
148
+ // the prior processing envelope must equal the values carried in
149
+ // the new consumed envelope so the audit trail is unambiguous.
150
+ //
151
+ // Authz:
152
+ // - `hub` principal: full access.
153
+ // - `workflow-process` principal: read/write its own deployment's
154
+ // event log. The principal carries `{ deploymentId, runId? }`;
155
+ // this handler verifies `repoId.id === deploymentId`.
156
+ // - `supervisor` principal: read/write its own deployment's event
157
+ // log. The principal carries `{ deploymentId }`; this handler
158
+ // verifies `repoId.id === deploymentId`.
159
+ // - `sidecar` principal: read-only (createPack, resolveRef) for
160
+ // resume.
161
+ // - `user` principal: gated by bearer-token claims and the route
162
+ // layer's pre-resolved authz verdict, mirroring the convention
163
+ // used by the other kinds.
164
+ import fs from "node:fs";
165
+ import git from "isomorphic-git";
166
+ import { type } from "arktype";
167
+ import { getLogger } from "@intx/log";
168
+ import { glob, repoActionToGrantVerb } from "@intx/hub-common";
169
+ import { UserPrincipal, } from "./repo-store/index.js";
170
+ import { WORKFLOW_RUN_EVENTS_FILE, splitCombinedEventLog, encodeCombinedEventLog, } from "./workflow-run-event-log.js";
171
+ const logger = getLogger(["hub-sessions", "workflow-run-kind"]);
172
+ export const WORKFLOW_RUN_GITIGNORE_PATH = ".gitignore";
173
+ export const WORKFLOW_RUN_RUNS_PREFIX = "runs";
174
+ export const WORKFLOW_RUN_EVENTS_DIR = "events";
175
+ export const WORKFLOW_RUN_BLOBS_DIR = "blobs";
176
+ export const WORKFLOW_RUN_ADDRESSES_PREFIX = "addresses";
177
+ export const WORKFLOW_RUN_CONTROL_PREFIX = "control";
178
+ export const WORKFLOW_RUN_INBOX_DIR = "inbox";
179
+ export const WORKFLOW_RUN_PROCESSING_DIR = "processing";
180
+ export const WORKFLOW_RUN_CONSUMED_DIR = "consumed";
181
+ /**
182
+ * Filename of the per-address retention watermark blob, a direct child
183
+ * of `addresses/<urlEncoded>/` (a file, not a directory). Carries the
184
+ * monotonic `receivedAt`-horizon below which consumed entries may be
185
+ * pruned and at-or-below which inbound enqueues are refused as stale.
186
+ */
187
+ export const WORKFLOW_RUN_WATERMARK_FILE = "watermark.json";
188
+ /**
189
+ * Default retention horizon for the consumed dedup index, in
190
+ * milliseconds. The boot edge resolves the operator's
191
+ * `CONSUMED_RETENTION_MS` config to a concrete value and threads it
192
+ * into `markConsumed`; this default applies only when no operator
193
+ * value is supplied. 24 hours is the conservative default: long enough
194
+ * that a duplicate from a retrying upstream within a day is still
195
+ * deduped by a retained consumed entry, short enough that `consumed/`
196
+ * reaches a bounded steady state of one day's message volume.
197
+ *
198
+ * INVARIANT (operator-owned): the horizon must be >= the longest
199
+ * window in which the same `messageId` could legitimately be
200
+ * re-submitted and still must be caught as a duplicate. There is no
201
+ * automatic internal mail redelivery in the system today, so this is
202
+ * the external re-submission window. If an at-least-once redelivery
203
+ * source is ever added, the horizon must be >= its maximum redelivery
204
+ * window or dedup breaks; a breach surfaces LOUDLY (a too-late
205
+ * re-submission carrying an old `receivedAt` is refused at enqueue,
206
+ * not silently reprocessed) rather than as silent double-processing.
207
+ */
208
+ export const DEFAULT_CONSUMED_RETENTION_MS = 24 * 60 * 60 * 1000;
209
+ /**
210
+ * Per-agent durable conversation-state subtree (design §3c). A
211
+ * long-lived single-step agent's multi-turn conversation context is
212
+ * committed under `agent-state/<agentKey>/...` so it survives child
213
+ * respawn: on respawn the rebuilt warm agent reads its prior
214
+ * conversation back from here before the resumed run replays.
215
+ *
216
+ * Unlike `runs/` (append-only events, immutable blobs) this subtree is
217
+ * MUTABLE: each run boundary overwrites the agent's conversation
218
+ * snapshot with the latest turns. It is therefore exempt from the
219
+ * append-only / deletion-direction walks `runs/` is subject to; the
220
+ * only push-time constraint is segment shape (a single round-trip-safe
221
+ * `<agentKey>` directory layer below the prefix).
222
+ */
223
+ export const WORKFLOW_RUN_AGENT_STATE_PREFIX = "agent-state";
224
+ /**
225
+ * Allowed top-level entries in the prospective tree. Anything else
226
+ * fails the push. `control/` has no v1 use and stays absent.
227
+ */
228
+ const ALLOWED_TOP_LEVEL = new Set([
229
+ WORKFLOW_RUN_RUNS_PREFIX,
230
+ WORKFLOW_RUN_ADDRESSES_PREFIX,
231
+ WORKFLOW_RUN_AGENT_STATE_PREFIX,
232
+ WORKFLOW_RUN_GITIGNORE_PATH,
233
+ ]);
234
+ const CLAIM_CHECK_SUBDIRS = new Set([
235
+ WORKFLOW_RUN_INBOX_DIR,
236
+ WORKFLOW_RUN_PROCESSING_DIR,
237
+ WORKFLOW_RUN_CONSUMED_DIR,
238
+ ]);
239
+ /** Per-event filename shape: a decimal integer followed by `.json`. */
240
+ const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
241
+ /**
242
+ * Per-blob filename shape for the `runs/<runId>/blobs/` subtree: a
243
+ * lowercase 64-character sha256 hex string. Pins the regex to the key
244
+ * the production `BlobSubstrate` adapter computes via `sha256Hex` so a
245
+ * non-canonical key (uppercase hex, truncated digest, alternate
246
+ * encoding) fails the push at the boundary rather than landing
247
+ * silently.
248
+ */
249
+ const BLOB_FILENAME_RE = /^[0-9a-f]{64}$/;
250
+ /**
251
+ * Subdirectories the kind handler accepts under `runs/<runId>/`. The
252
+ * `events/` subtree carries the append-only event log; the `blobs/`
253
+ * subtree carries opaque, content-addressed step outputs the
254
+ * `BlobSubstrate` adapter spills there when a value exceeds the
255
+ * inline-encoding threshold.
256
+ */
257
+ const RUN_DIR_ALLOWED_CHILDREN = new Set([
258
+ WORKFLOW_RUN_EVENTS_DIR,
259
+ WORKFLOW_RUN_BLOBS_DIR,
260
+ // A terminated run's event log, sealed from the per-event `events/`
261
+ // files into one combined file by a compaction commit.
262
+ WORKFLOW_RUN_EVENTS_FILE,
263
+ ]);
264
+ /**
265
+ * Filename shape for inbox and processing entries:
266
+ * `<receivedAt>-<messageId>.json`. `receivedAt` is a decimal integer
267
+ * (epoch ms); `messageId` is captured as the rest of the basename and
268
+ * is validated separately against the body's `messageId`.
269
+ */
270
+ const QUEUE_FILENAME_RE = /^(0|[1-9][0-9]*)-(.+)\.json$/;
271
+ /** Filename shape for consumed entries: `<messageId>.json`. */
272
+ const CONSUMED_FILENAME_RE = /^(.+)\.json$/;
273
+ /**
274
+ * JSON envelope carried by inbox and processing entries. Keys:
275
+ * - `messageId`: dedup key for the inbound message.
276
+ * - `receivedAt`: epoch-ms timestamp the reactor accepted the
277
+ * message; sortable FIFO key prefix.
278
+ * - `address`: decoded canonical address (not URL-encoded).
279
+ * - `mailAuditRef`: pointer to the raw mail bytes in the mail-audit
280
+ * store. For the in-process single-agent path a separate
281
+ * `MailAuditStore` holds the authoritative bytes and this ref joins
282
+ * onto it.
283
+ * - `rawMessage`: base64 of the inbound mail's raw MIME bytes,
284
+ * inlined so the workflow-process child can read its step input by
285
+ * messageId at `trigger.fired` time. The supervisor is the sole
286
+ * mail owner under the unified-execution host (§3a); it has no
287
+ * separate durable byte store the child can read, so the bytes ride
288
+ * the claim-check envelope itself. Present whenever the supervisor
289
+ * enqueued the entry; omitted by callers that only stamp the audit
290
+ * ref. The bytes survive the inbox→processing transition verbatim
291
+ * (the dequeue copies the entry bytes), so a `trigger.fired` for a
292
+ * processing entry can always recover the input.
293
+ */
294
+ const ClaimCheckEnvelope = type({
295
+ messageId: "string > 0",
296
+ receivedAt: "number >= 0",
297
+ address: "string > 0",
298
+ mailAuditRef: {
299
+ store: "string > 0",
300
+ path: "string > 0",
301
+ },
302
+ "rawMessage?": "string > 0",
303
+ "+": "ignore",
304
+ });
305
+ /**
306
+ * JSON envelope carried by consumed entries. The consumed entry is the
307
+ * canonical dedup index keyed by messageId; the envelope preserves
308
+ * the originating receivedAt for audit and carries the runId that
309
+ * consumed the message.
310
+ */
311
+ const ConsumedEnvelope = type({
312
+ messageId: "string > 0",
313
+ receivedAt: "number >= 0",
314
+ address: "string > 0",
315
+ runId: "string > 0",
316
+ consumedAt: "number >= 0",
317
+ mailAuditRef: {
318
+ store: "string > 0",
319
+ path: "string > 0",
320
+ },
321
+ "+": "ignore",
322
+ });
323
+ /**
324
+ * JSON envelope carried by the per-address `watermark.json` blob. The
325
+ * `watermark` is a `receivedAt` horizon (epoch ms): the oldest
326
+ * `receivedAt` a consumed entry may still retain. It only ever
327
+ * advances. A retention prune drops consumed entries strictly below
328
+ * it; `enqueueInbox` refuses any inbound strictly below it.
329
+ */
330
+ const WatermarkEnvelope = type({
331
+ watermark: "number >= 0",
332
+ "+": "ignore",
333
+ });
334
+ /**
335
+ * Terminal event discriminators. A run whose log contains an entry
336
+ * with one of these `type` values must not receive any event with a
337
+ * strictly greater seq.
338
+ *
339
+ * This set is a hand-rolled copy of the runtime's terminal-run vocabulary
340
+ * (`isTerminalRunPhase` in `@intx/workflow` state-machine `state.ts`,
341
+ * re-exported from the state-machine index and consumed by
342
+ * `transition.ts`), duplicated here because `@intx/hub-sessions` must not
343
+ * depend on `@intx/workflow`. It MUST stay in sync with that canonical
344
+ * definition:
345
+ * if the runtime adds or removes a terminal run phase, update this set too.
346
+ * Drift silently reopens the restore-time double-driver collision that
347
+ * `readOwnedMessageIds` (below) exists to prevent.
348
+ */
349
+ const TERMINAL_EVENT_TYPES = new Set([
350
+ "RunCompleted",
351
+ "RunFailed",
352
+ "RunCancelled",
353
+ ]);
354
+ /**
355
+ * Recognised CancelRequested origins. Mirrors the workflow package's
356
+ * `CANCEL_ORIGINS` vocabulary; inlined here so the substrate does
357
+ * not depend on `@intx/workflow`.
358
+ */
359
+ const CANCEL_REQUESTED_ORIGINS = new Set([
360
+ "self",
361
+ "supervisor-drain",
362
+ "supervisor-operator",
363
+ "hub-admin",
364
+ ]);
365
+ /**
366
+ * Per-origin signing-principal kind. `hub-admin` is the only origin
367
+ * a `hub` principal may mint; the other three originate inside the
368
+ * supervisor's trust boundary (the supervisor signs `self` on behalf
369
+ * of the workflow-process since the child has no asymmetric keypair,
370
+ * and signs the `supervisor-drain` / `supervisor-operator` audit-
371
+ * distinction
372
+ * cases for itself). Lookup misses fail the push.
373
+ */
374
+ const CANCEL_ORIGIN_TO_PRINCIPAL_KIND = new Map([
375
+ ["self", "supervisor"],
376
+ ["supervisor-drain", "supervisor"],
377
+ ["supervisor-operator", "supervisor"],
378
+ ["hub-admin", "hub"],
379
+ ]);
380
+ /**
381
+ * Cross-event shape carried by every blob committed under
382
+ * `runs/<runId>/events/`. The discriminator field on disk is `type`,
383
+ * matching the convention used by the substrate's `subscribeKind`
384
+ * helper and the workflow-host scheduler.
385
+ */
386
+ const EventEnvelope = type({
387
+ type: "string",
388
+ seq: "number >= 0",
389
+ "+": "ignore",
390
+ });
391
+ /**
392
+ * Structural validator for the `CancelRequested` payload's
393
+ * cancellation-specific fields. The kind handler verifies the origin
394
+ * is a known CancelOrigin and the reason is a non-empty string; the
395
+ * principal-vs-origin map collapses because every origin is
396
+ * supervisor-signed in this design.
397
+ */
398
+ const CancelRequestedFields = type({
399
+ origin: "string",
400
+ reason: "string > 0",
401
+ "+": "ignore",
402
+ });
403
+ const SidecarPrincipal = type({
404
+ kind: "'sidecar'",
405
+ agentId: "string",
406
+ });
407
+ const WorkflowProcessPrincipal = type({
408
+ kind: "'workflow-process'",
409
+ deploymentId: "string",
410
+ "runId?": "string",
411
+ });
412
+ const SupervisorPrincipal = type({
413
+ kind: "'supervisor'",
414
+ deploymentId: "string",
415
+ });
416
+ /**
417
+ * Resolve the substrate's `changedPathPrefixes` into the set of run ids
418
+ * the commit could have touched, or `undefined` to validate every run.
419
+ *
420
+ * Returns `undefined` (validate-all) when the substrate could not bound
421
+ * the change set, or when a change prefix reaches into `runs/` without
422
+ * naming a specific run (`runs` or `runs/` alone). A change prefix that
423
+ * never touches `runs/` -- e.g. a claim-check write under `addresses/`
424
+ * -- contributes no run ids; an empty result set means the commit
425
+ * touched no run, so the per-run walks legitimately validate nothing.
426
+ */
427
+ function runScopeFromChangedPrefixes(changedPathPrefixes) {
428
+ if (changedPathPrefixes === undefined)
429
+ return undefined;
430
+ const runsPrefix = `${WORKFLOW_RUN_RUNS_PREFIX}/`;
431
+ const runIds = new Set();
432
+ for (const prefix of changedPathPrefixes) {
433
+ if (prefix === WORKFLOW_RUN_RUNS_PREFIX || prefix === runsPrefix) {
434
+ // The `runs/` subtree changed but the substrate could not name
435
+ // which run; fall back to validating every run.
436
+ return undefined;
437
+ }
438
+ if (!prefix.startsWith(runsPrefix))
439
+ continue;
440
+ const rest = prefix.slice(runsPrefix.length);
441
+ const slash = rest.indexOf("/");
442
+ if (slash <= 0)
443
+ return undefined;
444
+ runIds.add(rest.slice(0, slash));
445
+ }
446
+ return runIds;
447
+ }
448
+ /**
449
+ * Build the (runId → events[]) map by walking the prospective tree.
450
+ * The substrate's listDir yields names directly under the given
451
+ * directory, so the walk is `runs/` → run-id subdirs → `events/` →
452
+ * event filenames. Filenames outside the `<seq>.json` shape fail the
453
+ * push. When `scopeRunIds` is supplied, only those runs are walked --
454
+ * see the substrate's `changedPathPrefixes` contract.
455
+ */
456
+ async function enumerateEventBlobs(listDir, scopeRunIds) {
457
+ const runs = new Map();
458
+ // When the substrate bounds the commit's change set to a specific set
459
+ // of runs, walk only those `runs/<runId>/` directories instead of
460
+ // listing every run. An untouched run is carried forward
461
+ // byte-identical by the substrate's prefix-preserving commit, so its
462
+ // per-run invariants -- already validated when it was written --
463
+ // cannot change. `scopeRunIds` may name a run absent from the tree
464
+ // (e.g. a prior-tree walk for a run the prospective tree dropped);
465
+ // `listDir` on a missing directory returns `[]`, which the
466
+ // empty-children guards below handle.
467
+ const runIds = scopeRunIds === undefined
468
+ ? await listDir(WORKFLOW_RUN_RUNS_PREFIX)
469
+ : Array.from(scopeRunIds);
470
+ for (const runId of runIds) {
471
+ const runDirPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`;
472
+ const runChildren = await listDir(runDirPath);
473
+ // A scoped run id can name a run that is absent from the tree being
474
+ // walked -- the substrate's change set is the union of prospective
475
+ // and prior touched runs, so the prospective walk may receive a run
476
+ // that exists only in the prior tree (and vice versa). An absent run
477
+ // directory lists as empty; skip it here so only runs actually
478
+ // present in this tree are validated. The unscoped walk never
479
+ // reaches this branch because its run ids come from listing the
480
+ // present `runs/` directory.
481
+ if (scopeRunIds !== undefined && runChildren.length === 0)
482
+ continue;
483
+ const offender = runChildren.find((c) => !RUN_DIR_ALLOWED_CHILDREN.has(c));
484
+ if (offender !== undefined) {
485
+ return {
486
+ ok: false,
487
+ reason: `run directory ${runDirPath} contains unexpected entry ${JSON.stringify(offender)}; only "${WORKFLOW_RUN_EVENTS_DIR}", "${WORKFLOW_RUN_BLOBS_DIR}", and "${WORKFLOW_RUN_EVENTS_FILE}" are allowed`,
488
+ };
489
+ }
490
+ const hasCombined = runChildren.includes(WORKFLOW_RUN_EVENTS_FILE);
491
+ const hasPerEvent = runChildren.includes(WORKFLOW_RUN_EVENTS_DIR);
492
+ if (hasCombined && hasPerEvent) {
493
+ return {
494
+ ok: false,
495
+ reason: `run directory ${runDirPath} carries both a combined "${WORKFLOW_RUN_EVENTS_FILE}" and a per-event "${WORKFLOW_RUN_EVENTS_DIR}" subtree`,
496
+ };
497
+ }
498
+ // A sealed (combined) run carries no per-event entries; it is validated
499
+ // by the combined-form path, not this per-event enumeration.
500
+ if (hasCombined)
501
+ continue;
502
+ if (!hasPerEvent) {
503
+ return {
504
+ ok: false,
505
+ reason: `run directory ${runDirPath} is missing required "${WORKFLOW_RUN_EVENTS_DIR}" subdirectory`,
506
+ };
507
+ }
508
+ const eventsDirPath = `${runDirPath}/${WORKFLOW_RUN_EVENTS_DIR}`;
509
+ const filenames = await listDir(eventsDirPath);
510
+ const entries = [];
511
+ for (const filename of filenames) {
512
+ const match = EVENT_FILENAME_RE.exec(filename);
513
+ if (match === null) {
514
+ return {
515
+ ok: false,
516
+ reason: `event filename ${eventsDirPath}/${filename} does not match <seq>.json`,
517
+ };
518
+ }
519
+ const seqStr = match[1];
520
+ if (seqStr === undefined) {
521
+ return {
522
+ ok: false,
523
+ reason: `event filename ${eventsDirPath}/${filename} produced no seq capture`,
524
+ };
525
+ }
526
+ entries.push({
527
+ runId,
528
+ filename,
529
+ filenameSeq: Number.parseInt(seqStr, 10),
530
+ blobPath: `${eventsDirPath}/${filename}`,
531
+ });
532
+ }
533
+ entries.sort((a, b) => a.filenameSeq - b.filenameSeq);
534
+ runs.set(runId, entries);
535
+ }
536
+ return { ok: true, runs };
537
+ }
538
+ /**
539
+ * Validate the prospective tree's combined-form (sealed) runs and return
540
+ * the set of run ids that legitimately carry a combined `events.jsonl`.
541
+ * The deletion-direction guard uses that set to allow a run's per-event
542
+ * files to disappear when (and only when) they were folded into the
543
+ * combined file under this same validation.
544
+ *
545
+ * Three prior states are accepted:
546
+ * - prior already combined -> the sealed file is immutable; prospective
547
+ * bytes must equal prior bytes.
548
+ * - prior per-event -> the compaction transition; the combined
549
+ * file must be the byte-for-byte fold of the prior per-event blobs in
550
+ * seq order. This is the audit-integrity boundary: a loose check here
551
+ * would let compaction silently rewrite history.
552
+ * - prior absent -> a freshly-delivered sealed run (e.g. a
553
+ * pack receive); its own structure is validated.
554
+ */
555
+ async function validateCombinedEventRuns(listDir, readBlob, priorListDir, priorReadBlob, scopeRunIds) {
556
+ const combinedRunIds = new Set();
557
+ const runIds = scopeRunIds === undefined
558
+ ? await listDir(WORKFLOW_RUN_RUNS_PREFIX)
559
+ : Array.from(scopeRunIds);
560
+ for (const runId of runIds) {
561
+ const runDirPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`;
562
+ const children = await listDir(runDirPath);
563
+ if (!children.includes(WORKFLOW_RUN_EVENTS_FILE))
564
+ continue;
565
+ const combinedPath = `${runDirPath}/${WORKFLOW_RUN_EVENTS_FILE}`;
566
+ const combinedBytes = await readBlob(combinedPath);
567
+ const content = new TextDecoder().decode(combinedBytes);
568
+ const priorChildren = await priorListDir(runDirPath);
569
+ if (priorChildren.includes(WORKFLOW_RUN_EVENTS_FILE)) {
570
+ // Sealed once, immutable thereafter.
571
+ const immutable = await checkPriorByteEquality(combinedPath, readBlob, priorReadBlob);
572
+ if (!immutable.ok)
573
+ return immutable;
574
+ }
575
+ else if (priorChildren.includes(WORKFLOW_RUN_EVENTS_DIR)) {
576
+ const structure = checkCombinedStructure(runId, combinedPath, content);
577
+ if (!structure.ok)
578
+ return structure;
579
+ const fold = await checkCompactionFold(runId, runDirPath, combinedBytes, priorListDir, priorReadBlob);
580
+ if (!fold.ok)
581
+ return fold;
582
+ }
583
+ else {
584
+ const structure = checkCombinedStructure(runId, combinedPath, content);
585
+ if (!structure.ok)
586
+ return structure;
587
+ }
588
+ combinedRunIds.add(runId);
589
+ }
590
+ return { ok: true, combinedRunIds };
591
+ }
592
+ /**
593
+ * The audit-integrity bridge. A compaction commit replaces a run's prior
594
+ * `events/<seq>.json` files with one combined file; this asserts the
595
+ * combined file reproduces those prior blobs' bytes verbatim, in seq
596
+ * order, with nothing added, dropped, reordered, or mutated. It rebuilds
597
+ * the expected combined bytes from the prior tree through the same encoder
598
+ * the writer uses, so the two cannot drift, and compares for exact
599
+ * equality.
600
+ */
601
+ async function checkCompactionFold(runId, runDirPath, combinedBytes, priorListDir, priorReadBlob) {
602
+ const priorEventsDir = `${runDirPath}/${WORKFLOW_RUN_EVENTS_DIR}`;
603
+ const priorEntries = [];
604
+ for (const filename of await priorListDir(priorEventsDir)) {
605
+ const match = EVENT_FILENAME_RE.exec(filename);
606
+ if (match === null || match[1] === undefined) {
607
+ return {
608
+ ok: false,
609
+ reason: `prior event filename ${priorEventsDir}/${filename} does not match <seq>.json; cannot validate compaction of run ${runId}`,
610
+ };
611
+ }
612
+ priorEntries.push({
613
+ seq: Number.parseInt(match[1], 10),
614
+ path: `${priorEventsDir}/${filename}`,
615
+ });
616
+ }
617
+ priorEntries.sort((a, b) => a.seq - b.seq);
618
+ const priorBlobs = [];
619
+ for (const entry of priorEntries) {
620
+ const bytes = await priorReadBlob(entry.path);
621
+ if (bytes === null) {
622
+ return {
623
+ ok: false,
624
+ reason: `prior event ${entry.path} is unreadable; cannot validate compaction of run ${runId}`,
625
+ };
626
+ }
627
+ priorBlobs.push(bytes);
628
+ }
629
+ // Byte equality, not decoded-string equality: each event is signed over
630
+ // its own bytes, so the sealed file must be the verbatim concatenation
631
+ // of the prior blobs, not merely decode-equivalent to it.
632
+ const expected = encodeCombinedEventLog(priorBlobs);
633
+ const sameBytes = combinedBytes.byteLength === expected.byteLength &&
634
+ combinedBytes.every((b, i) => b === expected[i]);
635
+ if (!sameBytes) {
636
+ return {
637
+ ok: false,
638
+ reason: `run ${runId} compaction does not fold its prior events verbatim: ${runDirPath}/${WORKFLOW_RUN_EVENTS_FILE} must equal the run's prior events/<seq>.json blobs joined in seq order`,
639
+ };
640
+ }
641
+ return { ok: true };
642
+ }
643
+ /**
644
+ * Validate a combined event log's own structure: every line a valid event
645
+ * envelope, contiguous seqs, exactly one terminal event and it is last
646
+ * (so a sealed run is genuinely terminal). Used for a sealed run with no
647
+ * prior per-event form to bridge against.
648
+ */
649
+ function checkCombinedStructure(runId, combinedPath, content) {
650
+ const lines = splitCombinedEventLog(content);
651
+ if (lines.length === 0) {
652
+ return { ok: false, reason: `combined event log ${combinedPath} is empty` };
653
+ }
654
+ let baseSeq = null;
655
+ let terminalSeq = null;
656
+ for (const [i, line] of lines.entries()) {
657
+ let body;
658
+ try {
659
+ body = JSON.parse(line);
660
+ }
661
+ catch {
662
+ return {
663
+ ok: false,
664
+ reason: `combined event log ${combinedPath} line ${String(i)} is not valid JSON`,
665
+ };
666
+ }
667
+ const validated = EventEnvelope(body);
668
+ if (validated instanceof type.errors) {
669
+ return {
670
+ ok: false,
671
+ reason: `combined event log ${combinedPath} line ${String(i)} envelope invalid: ${validated.summary}`,
672
+ };
673
+ }
674
+ if (baseSeq === null) {
675
+ baseSeq = validated.seq;
676
+ }
677
+ else if (validated.seq !== baseSeq + i) {
678
+ return {
679
+ ok: false,
680
+ reason: `combined event log ${combinedPath} has a sequence gap at line ${String(i)} (expected seq ${String(baseSeq + i)}, got ${String(validated.seq)})`,
681
+ };
682
+ }
683
+ if (terminalSeq !== null) {
684
+ return {
685
+ ok: false,
686
+ reason: `combined event log ${combinedPath} has an event at seq ${String(validated.seq)} after terminal at seq ${String(terminalSeq)}`,
687
+ };
688
+ }
689
+ if (TERMINAL_EVENT_TYPES.has(validated.type)) {
690
+ terminalSeq = validated.seq;
691
+ }
692
+ }
693
+ if (terminalSeq === null) {
694
+ return {
695
+ ok: false,
696
+ reason: `combined event log ${combinedPath} for run ${runId} has no terminal event; only a terminated run is sealed`,
697
+ };
698
+ }
699
+ return { ok: true };
700
+ }
701
+ /**
702
+ * Walk every `runs/<runId>/blobs/` directory and validate each blob
703
+ * filename matches the sha256-hex shape the production `BlobSubstrate`
704
+ * adapter writes. The `blobs/` subdirectory itself is optional: a run
705
+ * that has not yet spilled an output to a blob never produces a
706
+ * `blobs/` directory, and a run with only inline-encoded outputs never
707
+ * will. Returns the flat list of blob entries so the caller can apply
708
+ * immutability checks against the prior tree.
709
+ */
710
+ async function enumerateRunBlobs(listDir, scopeRunIds) {
711
+ const out = [];
712
+ // See enumerateEventBlobs: a defined `scopeRunIds` walks only the
713
+ // commit's touched runs; an untouched run's blobs are carried forward
714
+ // byte-identical and were validated when written.
715
+ const runIds = scopeRunIds === undefined
716
+ ? await listDir(WORKFLOW_RUN_RUNS_PREFIX)
717
+ : Array.from(scopeRunIds);
718
+ for (const runId of runIds) {
719
+ const runDirPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`;
720
+ const runChildren = await listDir(runDirPath);
721
+ if (!runChildren.includes(WORKFLOW_RUN_BLOBS_DIR))
722
+ continue;
723
+ const blobsDirPath = `${runDirPath}/${WORKFLOW_RUN_BLOBS_DIR}`;
724
+ const filenames = await listDir(blobsDirPath);
725
+ for (const filename of filenames) {
726
+ if (!BLOB_FILENAME_RE.test(filename)) {
727
+ return {
728
+ ok: false,
729
+ reason: `blob filename ${blobsDirPath}/${filename} does not match a lowercase 64-character sha256 hex string`,
730
+ };
731
+ }
732
+ out.push({
733
+ runId,
734
+ filename,
735
+ blobPath: `${blobsDirPath}/${filename}`,
736
+ });
737
+ }
738
+ }
739
+ return { ok: true, blobs: out };
740
+ }
741
+ /**
742
+ * Enforce blob immutability via prior-tree byte equality. The blob
743
+ * value itself is opaque bytes (no JSON envelope, no arktype
744
+ * validation); the only structural rule beyond filename shape is that
745
+ * a blob entry present in the prior tree must carry byte-identical
746
+ * contents in the prospective tree. Mirrors the consumed-entry
747
+ * discipline in the claim-check subtree.
748
+ */
749
+ async function checkBlobPriorByteEquality(blobPath, readBlob, priorReadBlob) {
750
+ const prior = await priorReadBlob(blobPath);
751
+ if (prior === null)
752
+ return { ok: true };
753
+ const prospective = await readBlob(blobPath);
754
+ if (prior.byteLength !== prospective.byteLength) {
755
+ return {
756
+ ok: false,
757
+ reason: `blob ${blobPath} bytes diverge from the prior tree (lengths ${String(prior.byteLength)} vs ${String(prospective.byteLength)}); blob entries are immutable once written`,
758
+ };
759
+ }
760
+ for (let i = 0; i < prior.byteLength; i++) {
761
+ if (prior[i] !== prospective[i]) {
762
+ return {
763
+ ok: false,
764
+ reason: `blob ${blobPath} bytes diverge from the prior tree at offset ${String(i)}; blob entries are immutable once written`,
765
+ };
766
+ }
767
+ }
768
+ return { ok: true };
769
+ }
770
+ async function parseEventBlob(entry, readBlob) {
771
+ let raw;
772
+ try {
773
+ raw = await readBlob(entry.blobPath);
774
+ }
775
+ catch (cause) {
776
+ return {
777
+ ok: false,
778
+ reason: `event ${entry.blobPath} could not be read from the tree: ${cause instanceof Error ? cause.message : String(cause)}`,
779
+ };
780
+ }
781
+ let body;
782
+ try {
783
+ body = JSON.parse(new TextDecoder().decode(raw));
784
+ }
785
+ catch (cause) {
786
+ return {
787
+ ok: false,
788
+ reason: `event ${entry.blobPath} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
789
+ };
790
+ }
791
+ const validated = EventEnvelope(body);
792
+ if (validated instanceof type.errors) {
793
+ return {
794
+ ok: false,
795
+ reason: `event ${entry.blobPath} envelope invalid: ${validated.summary}`,
796
+ };
797
+ }
798
+ if (validated.seq !== entry.filenameSeq) {
799
+ return {
800
+ ok: false,
801
+ reason: `event ${entry.blobPath} body.seq ${String(validated.seq)} does not match filename seq ${String(entry.filenameSeq)}`,
802
+ };
803
+ }
804
+ if (validated.type === "CancelRequested") {
805
+ const cancelFields = CancelRequestedFields(body);
806
+ if (cancelFields instanceof type.errors) {
807
+ return {
808
+ ok: false,
809
+ reason: `event ${entry.blobPath} CancelRequested payload invalid: ${cancelFields.summary}`,
810
+ };
811
+ }
812
+ if (!CANCEL_REQUESTED_ORIGINS.has(cancelFields.origin)) {
813
+ return {
814
+ ok: false,
815
+ reason: `event ${entry.blobPath} CancelRequested origin ${JSON.stringify(cancelFields.origin)} is not a recognised CancelOrigin`,
816
+ };
817
+ }
818
+ }
819
+ return { ok: true, parsed: { entry, body: validated } };
820
+ }
821
+ /**
822
+ * Compare the prospective bytes of `blobPath` against the bytes at
823
+ * the same path in the prior tree. Returns `{ ok: true }` when the
824
+ * blob is newly added (no prior entry) or when the prior and
825
+ * prospective bytes are byte-identical; returns a rejection otherwise.
826
+ * Surfaces append-only at the handler scope: the event log invariant
827
+ * lives here rather than relying on caller-layer discipline at
828
+ * `writeTreePreservingPrefix`.
829
+ */
830
+ async function checkPriorByteEquality(blobPath, readBlob, priorReadBlob) {
831
+ const prior = await priorReadBlob(blobPath);
832
+ if (prior === null)
833
+ return { ok: true };
834
+ const prospective = await readBlob(blobPath);
835
+ if (prior.byteLength !== prospective.byteLength) {
836
+ return {
837
+ ok: false,
838
+ reason: `event ${blobPath} bytes diverge from the prior tree (lengths ${String(prior.byteLength)} vs ${String(prospective.byteLength)}); event blobs are append-only`,
839
+ };
840
+ }
841
+ for (let i = 0; i < prior.byteLength; i++) {
842
+ if (prior[i] !== prospective[i]) {
843
+ return {
844
+ ok: false,
845
+ reason: `event ${blobPath} bytes diverge from the prior tree at offset ${String(i)}; event blobs are append-only`,
846
+ };
847
+ }
848
+ }
849
+ return { ok: true };
850
+ }
851
+ /**
852
+ * Round-trip an `<urlEncoded(address)>` segment through decode then
853
+ * encode. A divergence means the segment is not the canonical
854
+ * encoding of any address, which would leave consumers guessing
855
+ * which encoding to use when reading the subtree. Surface as a
856
+ * concrete rejection at push time.
857
+ */
858
+ function checkAddressSegmentRoundTrip(segment) {
859
+ let decoded;
860
+ try {
861
+ decoded = decodeURIComponent(segment);
862
+ }
863
+ catch (cause) {
864
+ return {
865
+ ok: false,
866
+ reason: `address segment ${JSON.stringify(segment)} is not a valid URL-encoded string: ${cause instanceof Error ? cause.message : String(cause)}`,
867
+ };
868
+ }
869
+ const reencoded = encodeURIComponent(decoded);
870
+ if (reencoded !== segment) {
871
+ return {
872
+ ok: false,
873
+ reason: `address segment ${JSON.stringify(segment)} does not round-trip URL-encoding (re-encoded as ${JSON.stringify(reencoded)})`,
874
+ };
875
+ }
876
+ return { ok: true, decoded };
877
+ }
878
+ /**
879
+ * FIFO comparator for inbox/processing entries. Sorts by the parsed
880
+ * numeric `receivedAt` (filename prefix); ties break on the
881
+ * messageId tail. The numeric compare is the load-bearing piece —
882
+ * lexicographic compare on `<receivedAt>-…` filenames with
883
+ * non-uniform digit widths disagrees with chronological order
884
+ * (e.g. `"100-…"` < `"99-…"` because `'1' < '9'`).
885
+ */
886
+ function compareQueueEntries(a, b) {
887
+ const aReceivedAt = a.receivedAtFromFilename;
888
+ const bReceivedAt = b.receivedAtFromFilename;
889
+ if (aReceivedAt === null || bReceivedAt === null) {
890
+ throw new Error("compareQueueEntries: queue entries must carry a parsed receivedAt");
891
+ }
892
+ if (aReceivedAt !== bReceivedAt)
893
+ return aReceivedAt - bReceivedAt;
894
+ const aId = a.messageIdFromFilename;
895
+ const bId = b.messageIdFromFilename;
896
+ if (aId < bId)
897
+ return -1;
898
+ if (aId > bId)
899
+ return 1;
900
+ return 0;
901
+ }
902
+ async function enumerateClaimCheckBlobs(listDir, resolveConsumedOid) {
903
+ const perAddress = new Map();
904
+ const segments = await listDir(WORKFLOW_RUN_ADDRESSES_PREFIX);
905
+ for (const segment of segments) {
906
+ const roundTrip = checkAddressSegmentRoundTrip(segment);
907
+ if (!roundTrip.ok)
908
+ return roundTrip;
909
+ const addrDir = `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${segment}`;
910
+ const children = await listDir(addrDir);
911
+ for (const child of children) {
912
+ if (CLAIM_CHECK_SUBDIRS.has(child))
913
+ continue;
914
+ if (child === WORKFLOW_RUN_WATERMARK_FILE)
915
+ continue;
916
+ return {
917
+ ok: false,
918
+ reason: `address directory ${addrDir} contains unexpected entry ${JSON.stringify(child)}; allowed: "${WORKFLOW_RUN_INBOX_DIR}", "${WORKFLOW_RUN_PROCESSING_DIR}", "${WORKFLOW_RUN_CONSUMED_DIR}", "${WORKFLOW_RUN_WATERMARK_FILE}"`,
919
+ };
920
+ }
921
+ const bucket = perAddress.get(segment) ?? {
922
+ decodedAddress: roundTrip.decoded,
923
+ inbox: [],
924
+ processing: [],
925
+ consumed: [],
926
+ watermarkPath: null,
927
+ };
928
+ if (children.includes(WORKFLOW_RUN_WATERMARK_FILE)) {
929
+ bucket.watermarkPath = `${addrDir}/${WORKFLOW_RUN_WATERMARK_FILE}`;
930
+ }
931
+ for (const subdir of CLAIM_CHECK_SUBDIRS) {
932
+ if (!children.includes(subdir))
933
+ continue;
934
+ const dirPath = `${addrDir}/${subdir}`;
935
+ const filenames = await listDir(dirPath);
936
+ for (const filename of filenames) {
937
+ if (subdir === WORKFLOW_RUN_INBOX_DIR ||
938
+ subdir === WORKFLOW_RUN_PROCESSING_DIR) {
939
+ const match = QUEUE_FILENAME_RE.exec(filename);
940
+ if (match === null) {
941
+ return {
942
+ ok: false,
943
+ reason: `${subdir} filename ${dirPath}/${filename} does not match <receivedAt>-<messageId>.json`,
944
+ };
945
+ }
946
+ const receivedAtStr = match[1];
947
+ const messageId = match[2];
948
+ if (receivedAtStr === undefined || messageId === undefined) {
949
+ return {
950
+ ok: false,
951
+ reason: `${subdir} filename ${dirPath}/${filename} produced no captures`,
952
+ };
953
+ }
954
+ const entry = {
955
+ kind: subdir === WORKFLOW_RUN_INBOX_DIR ? "inbox" : "processing",
956
+ addressSegment: segment,
957
+ decodedAddress: roundTrip.decoded,
958
+ filename,
959
+ receivedAtFromFilename: Number.parseInt(receivedAtStr, 10),
960
+ messageIdFromFilename: messageId,
961
+ blobPath: `${dirPath}/${filename}`,
962
+ };
963
+ if (subdir === WORKFLOW_RUN_INBOX_DIR)
964
+ bucket.inbox.push(entry);
965
+ else
966
+ bucket.processing.push(entry);
967
+ }
968
+ else {
969
+ const match = CONSUMED_FILENAME_RE.exec(filename);
970
+ if (match === null) {
971
+ return {
972
+ ok: false,
973
+ reason: `${WORKFLOW_RUN_CONSUMED_DIR} filename ${dirPath}/${filename} does not match <messageId>.json`,
974
+ };
975
+ }
976
+ const messageId = match[1];
977
+ if (messageId === undefined) {
978
+ return {
979
+ ok: false,
980
+ reason: `${WORKFLOW_RUN_CONSUMED_DIR} filename ${dirPath}/${filename} produced no message-id capture`,
981
+ };
982
+ }
983
+ const consumedBlobPath = `${dirPath}/${filename}`;
984
+ const consumedEntry = {
985
+ kind: "consumed",
986
+ addressSegment: segment,
987
+ decodedAddress: roundTrip.decoded,
988
+ filename,
989
+ receivedAtFromFilename: null,
990
+ messageIdFromFilename: messageId,
991
+ blobPath: consumedBlobPath,
992
+ };
993
+ if (resolveConsumedOid !== undefined) {
994
+ consumedEntry.oid = await resolveConsumedOid(consumedBlobPath);
995
+ }
996
+ bucket.consumed.push(consumedEntry);
997
+ }
998
+ }
999
+ }
1000
+ // FIFO ordering: sort by the parsed numeric receivedAt prefix
1001
+ // with a lexicographic messageId tiebreak. String-sorting the
1002
+ // raw filename would put "99-…" after "100-…" because '9' > '1',
1003
+ // breaking the FIFO invariant for non-uniform digit widths.
1004
+ bucket.inbox.sort(compareQueueEntries);
1005
+ bucket.processing.sort(compareQueueEntries);
1006
+ bucket.consumed.sort((a, b) => a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0);
1007
+ perAddress.set(segment, bucket);
1008
+ }
1009
+ return { ok: true, perAddress };
1010
+ }
1011
+ /**
1012
+ * Read + validate the per-address `watermark.json` value from a blob
1013
+ * reader. `null` means the tree has no watermark blob (treated as
1014
+ * watermark 0 -- no entry pruned, nothing refused). The reader may be
1015
+ * the prospective `readBlob` or the `priorReadBlob` (the latter
1016
+ * returns `null` for an absent path, which is the legitimate
1017
+ * never-pruned genesis state).
1018
+ */
1019
+ async function readWatermark(watermarkPath, readBlob) {
1020
+ let raw;
1021
+ try {
1022
+ raw = await readBlob(watermarkPath);
1023
+ }
1024
+ catch (cause) {
1025
+ return {
1026
+ ok: false,
1027
+ reason: `watermark ${watermarkPath} could not be read from the tree: ${cause instanceof Error ? cause.message : String(cause)}`,
1028
+ };
1029
+ }
1030
+ if (raw === null)
1031
+ return { ok: true, watermark: 0 };
1032
+ let bodyJson;
1033
+ try {
1034
+ bodyJson = JSON.parse(new TextDecoder().decode(raw));
1035
+ }
1036
+ catch (cause) {
1037
+ return {
1038
+ ok: false,
1039
+ reason: `watermark ${watermarkPath} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
1040
+ };
1041
+ }
1042
+ const validated = WatermarkEnvelope(bodyJson);
1043
+ if (validated instanceof type.errors) {
1044
+ return {
1045
+ ok: false,
1046
+ reason: `watermark ${watermarkPath} envelope invalid: ${validated.summary}`,
1047
+ };
1048
+ }
1049
+ return { ok: true, watermark: validated.watermark };
1050
+ }
1051
+ async function parseConsumedBlob(entry, readBlob) {
1052
+ return parseConsumedBlobFrom(entry, readBlob);
1053
+ }
1054
+ /**
1055
+ * Read + validate a consumed entry's envelope from a blob reader that
1056
+ * may return `null` for an absent path (the `priorReadBlob` shape).
1057
+ * `null` is treated as a read failure: the caller only passes a path
1058
+ * the prior tree is known to carry, so a `null` is structural damage.
1059
+ */
1060
+ async function parseConsumedBlobFrom(entry, readBlob) {
1061
+ let raw;
1062
+ try {
1063
+ raw = await readBlob(entry.blobPath);
1064
+ }
1065
+ catch (cause) {
1066
+ return {
1067
+ ok: false,
1068
+ reason: `consumed ${entry.blobPath} could not be read from the tree: ${cause instanceof Error ? cause.message : String(cause)}`,
1069
+ };
1070
+ }
1071
+ if (raw === null) {
1072
+ return {
1073
+ ok: false,
1074
+ reason: `consumed ${entry.blobPath} was enumerated in the tree but its bytes could not be read`,
1075
+ };
1076
+ }
1077
+ let bodyJson;
1078
+ try {
1079
+ bodyJson = JSON.parse(new TextDecoder().decode(raw));
1080
+ }
1081
+ catch (cause) {
1082
+ return {
1083
+ ok: false,
1084
+ reason: `consumed ${entry.blobPath} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
1085
+ };
1086
+ }
1087
+ const validated = ConsumedEnvelope(bodyJson);
1088
+ if (validated instanceof type.errors) {
1089
+ return {
1090
+ ok: false,
1091
+ reason: `consumed ${entry.blobPath} envelope invalid: ${validated.summary}`,
1092
+ };
1093
+ }
1094
+ if (validated.messageId !== entry.messageIdFromFilename) {
1095
+ return {
1096
+ ok: false,
1097
+ reason: `consumed ${entry.blobPath} body.messageId ${JSON.stringify(validated.messageId)} does not match filename messageId ${JSON.stringify(entry.messageIdFromFilename)}`,
1098
+ };
1099
+ }
1100
+ if (validated.address !== entry.decodedAddress) {
1101
+ return {
1102
+ ok: false,
1103
+ reason: `consumed ${entry.blobPath} body.address ${JSON.stringify(validated.address)} does not match decoded address segment ${JSON.stringify(entry.decodedAddress)}`,
1104
+ };
1105
+ }
1106
+ return { ok: true, body: validated };
1107
+ }
1108
+ async function parseQueueBlob(entry, readBlob) {
1109
+ let raw;
1110
+ try {
1111
+ raw = await readBlob(entry.blobPath);
1112
+ }
1113
+ catch (cause) {
1114
+ return {
1115
+ ok: false,
1116
+ reason: `${entry.kind} ${entry.blobPath} could not be read from the tree: ${cause instanceof Error ? cause.message : String(cause)}`,
1117
+ };
1118
+ }
1119
+ let bodyJson;
1120
+ try {
1121
+ bodyJson = JSON.parse(new TextDecoder().decode(raw));
1122
+ }
1123
+ catch (cause) {
1124
+ return {
1125
+ ok: false,
1126
+ reason: `${entry.kind} ${entry.blobPath} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
1127
+ };
1128
+ }
1129
+ const validated = ClaimCheckEnvelope(bodyJson);
1130
+ if (validated instanceof type.errors) {
1131
+ return {
1132
+ ok: false,
1133
+ reason: `${entry.kind} ${entry.blobPath} envelope invalid: ${validated.summary}`,
1134
+ };
1135
+ }
1136
+ if (validated.messageId !== entry.messageIdFromFilename) {
1137
+ return {
1138
+ ok: false,
1139
+ reason: `${entry.kind} ${entry.blobPath} body.messageId ${JSON.stringify(validated.messageId)} does not match filename messageId ${JSON.stringify(entry.messageIdFromFilename)}`,
1140
+ };
1141
+ }
1142
+ if (validated.receivedAt !== entry.receivedAtFromFilename) {
1143
+ return {
1144
+ ok: false,
1145
+ reason: `${entry.kind} ${entry.blobPath} body.receivedAt ${String(validated.receivedAt)} does not match filename receivedAt ${String(entry.receivedAtFromFilename)}`,
1146
+ };
1147
+ }
1148
+ if (validated.address !== entry.decodedAddress) {
1149
+ return {
1150
+ ok: false,
1151
+ reason: `${entry.kind} ${entry.blobPath} body.address ${JSON.stringify(validated.address)} does not match decoded address segment ${JSON.stringify(entry.decodedAddress)}`,
1152
+ };
1153
+ }
1154
+ return { ok: true, body: validated };
1155
+ }
1156
+ /**
1157
+ * Compute the git blob OID of a consumed entry from a byte reader,
1158
+ * used only when the delta-scoped path lacks a substrate-provided prior
1159
+ * OID listing (e.g. a hand-built test validatePush). `git.hashBlob`
1160
+ * reproduces the same content-addressed OID a `git.readTree` listing
1161
+ * carries, so the delta path's intersection compare is identical
1162
+ * whether the OID came from the tree listing or from hashing the bytes.
1163
+ */
1164
+ async function hashConsumedBlobOid(bytes) {
1165
+ const { oid } = await git.hashBlob({ object: bytes });
1166
+ return oid;
1167
+ }
1168
+ /**
1169
+ * Resolve each consumed entry's git blob OID for the delta-scoped path.
1170
+ * When the substrate supplies a directory OID listing (`listDirOids`) the
1171
+ * OID comes straight from the tree — one `readTree` per consumed
1172
+ * directory, cached — so that side is not re-read blob-by-blob. When the
1173
+ * listing is absent (a hand-built validatePush in a unit test) each OID
1174
+ * falls back to hashing the entry's bytes, which preserves identical
1175
+ * semantics at O(retained) cost. Both the prior and prospective sides use
1176
+ * this; `sideLabel` distinguishes them in the missing-OID error.
1177
+ */
1178
+ function makeListingOidResolver(sideLabel, listDirOids, hashFallback) {
1179
+ const dirOidCache = new Map();
1180
+ return async (blobPath) => {
1181
+ if (listDirOids !== undefined) {
1182
+ const slash = blobPath.lastIndexOf("/");
1183
+ const dir = blobPath.slice(0, slash);
1184
+ const name = blobPath.slice(slash + 1);
1185
+ let byName = dirOidCache.get(dir);
1186
+ if (byName === undefined) {
1187
+ byName = new Map();
1188
+ for (const entry of await listDirOids(dir)) {
1189
+ byName.set(entry.name, entry.oid);
1190
+ }
1191
+ dirOidCache.set(dir, byName);
1192
+ }
1193
+ const oid = byName.get(name);
1194
+ if (oid === undefined) {
1195
+ throw new Error(`delta claim-check: ${sideLabel} tree listing has no OID for enumerated consumed entry ${blobPath}`);
1196
+ }
1197
+ return oid;
1198
+ }
1199
+ return hashFallback(blobPath);
1200
+ };
1201
+ }
1202
+ function makePriorConsumedOidResolver(priorReadBlob, priorListDirOids) {
1203
+ return makeListingOidResolver("prior", priorListDirOids, async (blobPath) => {
1204
+ const bytes = await priorReadBlob(blobPath);
1205
+ if (bytes === null) {
1206
+ throw new Error(`delta claim-check: consumed entry ${blobPath} was enumerated in the prior tree but its bytes could not be read`);
1207
+ }
1208
+ return hashConsumedBlobOid(bytes);
1209
+ });
1210
+ }
1211
+ /**
1212
+ * Validate the `addresses/<urlEncoded>/{inbox,processing,consumed}`
1213
+ * subtree as a whole. The walk enforces filename shape, JSON envelope
1214
+ * structure, address round-trip, per-messageId atomicity across the
1215
+ * three queue states, consumed-blob immutability, and the
1216
+ * inbox→processing / processing→consumed transition invariants against
1217
+ * the prior tree.
1218
+ *
1219
+ * The consumed dedup index is validated by its per-commit DELTA against
1220
+ * the prior tree rather than by re-walking the whole retained set:
1221
+ * retained entries (same filename, same blob OID) are skipped as
1222
+ * already-validated-and-immutable, added entries are parsed and
1223
+ * validated, and removed entries are checked against the retention
1224
+ * watermark. `priorListDirOids` and `listDirOids`, when supplied by the
1225
+ * substrate, surface the prior and prospective consumed OIDs straight
1226
+ * from their tree listings so neither side is re-read blob-by-blob.
1227
+ */
1228
+ async function validateClaimCheckSubtree(listDir, readBlob, priorReadBlob, priorListDir, priorListDirOids, listDirOids) {
1229
+ // Surface each consumed entry's git blob OID during enumeration
1230
+ // straight from the tree listing on both sides when the substrate
1231
+ // provides it, falling back to hashing the bytes otherwise.
1232
+ const prospectiveConsumedOid = makeListingOidResolver("prospective", listDirOids, async (blobPath) => hashConsumedBlobOid(await readBlob(blobPath)));
1233
+ const priorConsumedOid = makePriorConsumedOidResolver(priorReadBlob, priorListDirOids);
1234
+ const enumerated = await enumerateClaimCheckBlobs(listDir, prospectiveConsumedOid);
1235
+ if (!enumerated.ok)
1236
+ return enumerated;
1237
+ const priorEnumerated = await enumerateClaimCheckBlobs(priorListDir, priorConsumedOid);
1238
+ if (!priorEnumerated.ok) {
1239
+ // The prior tree is the committed state — if its claim-check
1240
+ // shape is already broken, surface it with a distinct rejection
1241
+ // prefix so an operator can tell prior-state damage from a
1242
+ // misconfigured push.
1243
+ return {
1244
+ ok: false,
1245
+ reason: `prior tree's claim-check subtree is structurally invalid: ${priorEnumerated.reason}`,
1246
+ };
1247
+ }
1248
+ const emptyBucket = (decodedAddress) => ({
1249
+ decodedAddress,
1250
+ inbox: [],
1251
+ processing: [],
1252
+ consumed: [],
1253
+ watermarkPath: null,
1254
+ });
1255
+ // Iterate the UNION of prospective and prior address segments so a
1256
+ // prospective tree that wipes an address subtree entirely still
1257
+ // runs the prior-retention checks against that segment's
1258
+ // prior-tree consumed/processing entries.
1259
+ const allSegments = new Set([
1260
+ ...enumerated.perAddress.keys(),
1261
+ ...priorEnumerated.perAddress.keys(),
1262
+ ]);
1263
+ for (const segment of allSegments) {
1264
+ const priorBucket = priorEnumerated.perAddress.get(segment);
1265
+ const prospectiveBucketForSegment = enumerated.perAddress.get(segment);
1266
+ const decodedAddress = prospectiveBucketForSegment?.decodedAddress ??
1267
+ priorBucket?.decodedAddress;
1268
+ if (decodedAddress === undefined) {
1269
+ throw new Error(`validateClaimCheckSubtree: segment ${JSON.stringify(segment)} appeared in the union of prospective and prior segments but neither bucket carries a decoded address`);
1270
+ }
1271
+ const bucket = prospectiveBucketForSegment ?? emptyBucket(decodedAddress);
1272
+ // Per-messageId atomicity: each messageId may appear at most
1273
+ // once across inbox/processing/consumed combined. The check
1274
+ // keys on (messageId, kind, filename) so two inbox entries with
1275
+ // the same messageId at different `receivedAt` values surface
1276
+ // as a same-state collision (the Set-of-kinds shape would
1277
+ // collapse both into a single "inbox" member and miss the
1278
+ // case).
1279
+ const messageIdToLocations = new Map();
1280
+ for (const entry of [...bucket.inbox, ...bucket.processing]) {
1281
+ const parsed = await parseQueueBlob(entry, readBlob);
1282
+ if (!parsed.ok)
1283
+ return parsed;
1284
+ const list = messageIdToLocations.get(entry.messageIdFromFilename) ?? [];
1285
+ list.push({ kind: entry.kind, filename: entry.filename });
1286
+ messageIdToLocations.set(entry.messageIdFromFilename, list);
1287
+ }
1288
+ for (const entry of bucket.consumed) {
1289
+ // Cross-state atomicity needs each consumed messageId in the map;
1290
+ // the messageId is the filename stem, so this needs no blob read.
1291
+ // Retained consumed entries are not re-parsed (their envelope was
1292
+ // validated when first written and their bytes are proven
1293
+ // immutable by the OID compare below); added consumed entries are
1294
+ // parsed and validated by the transition check further down.
1295
+ const list = messageIdToLocations.get(entry.messageIdFromFilename) ?? [];
1296
+ list.push({ kind: entry.kind, filename: entry.filename });
1297
+ messageIdToLocations.set(entry.messageIdFromFilename, list);
1298
+ }
1299
+ for (const [messageId, locations] of messageIdToLocations) {
1300
+ if (locations.length > 1) {
1301
+ const sorted = [...locations].sort((a, b) => {
1302
+ if (a.kind !== b.kind)
1303
+ return a.kind < b.kind ? -1 : 1;
1304
+ if (a.filename !== b.filename)
1305
+ return a.filename < b.filename ? -1 : 1;
1306
+ return 0;
1307
+ });
1308
+ const kinds = new Set(sorted.map((l) => l.kind));
1309
+ if (kinds.size > 1) {
1310
+ return {
1311
+ ok: false,
1312
+ reason: `address ${JSON.stringify(bucket.decodedAddress)} message ${JSON.stringify(messageId)} appears in multiple queue states ${JSON.stringify(Array.from(kinds).sort())}; at most one of inbox/processing/consumed is permitted`,
1313
+ };
1314
+ }
1315
+ const kind = sorted[0]?.kind;
1316
+ if (kind === undefined)
1317
+ throw new Error("unreachable");
1318
+ return {
1319
+ ok: false,
1320
+ reason: `address ${JSON.stringify(bucket.decodedAddress)} message ${JSON.stringify(messageId)} appears at multiple ${kind} positions ${JSON.stringify(sorted.map((l) => l.filename))}; at most one entry per messageId is permitted`,
1321
+ };
1322
+ }
1323
+ }
1324
+ // Consumed entries are immutable. Compare the git blob OID the
1325
+ // enumeration surfaced: a consumed entry present in the prior tree
1326
+ // at the same path must carry the same OID (git trees are
1327
+ // content-addressed, so equal OID proves byte-equality without
1328
+ // reading either blob). A diverging OID is an immutability
1329
+ // violation. Immutability is load-bearing for exactly-once: a
1330
+ // mutated `receivedAt` on a retained consumed entry could fake it
1331
+ // below the watermark, get it pruned, and let a re-submission miss
1332
+ // dedup -- so this compare is not optional.
1333
+ const priorConsumedOidByPath = new Map();
1334
+ for (const e of priorBucket?.consumed ?? []) {
1335
+ if (e.oid === undefined) {
1336
+ throw new Error(`delta claim-check: prior consumed entry ${e.blobPath} was enumerated without an OID`);
1337
+ }
1338
+ priorConsumedOidByPath.set(e.blobPath, e.oid);
1339
+ }
1340
+ for (const entry of bucket.consumed) {
1341
+ const priorOid = priorConsumedOidByPath.get(entry.blobPath);
1342
+ if (priorOid === undefined)
1343
+ continue; // newly added; validated below
1344
+ if (entry.oid === undefined) {
1345
+ throw new Error(`delta claim-check: prospective consumed entry ${entry.blobPath} was enumerated without an OID`);
1346
+ }
1347
+ if (entry.oid !== priorOid) {
1348
+ return {
1349
+ ok: false,
1350
+ reason: `consumed ${entry.blobPath} bytes diverge from the prior tree (blob OID ${entry.oid} vs ${priorOid}); consumed entries are immutable once written`,
1351
+ };
1352
+ }
1353
+ }
1354
+ const prospectiveConsumedPaths = new Set(bucket.consumed.map((e) => e.blobPath));
1355
+ const prospectiveProcessingPaths = new Set(bucket.processing.map((e) => e.blobPath));
1356
+ const prospectiveInboxByFilename = new Map();
1357
+ const prospectiveInboxPaths = new Set();
1358
+ for (const e of bucket.inbox) {
1359
+ prospectiveInboxByFilename.set(e.filename, e);
1360
+ prospectiveInboxPaths.add(e.blobPath);
1361
+ }
1362
+ const prospectiveProcessingByFilename = new Map();
1363
+ for (const e of bucket.processing)
1364
+ prospectiveProcessingByFilename.set(e.filename, e);
1365
+ const prospectiveConsumedByMessageId = new Map();
1366
+ for (const e of bucket.consumed)
1367
+ prospectiveConsumedByMessageId.set(e.messageIdFromFilename, e);
1368
+ // Deletion-direction guards: walk every entry the prior tree
1369
+ // carried under `consumed/`, `processing/`, and `inbox/` and reject
1370
+ // any prior path that vanishes from the prospective tree except via
1371
+ // a permitted transition (or, for consumed, a watermark-passed
1372
+ // retention prune). Without this walk a prospective tree that
1373
+ // simply omits a prior entry would slip past the prospective-tree
1374
+ // by-presence checks above.
1375
+ //
1376
+ // Retention-watermark contract for the consumed dedup index. The
1377
+ // watermark is a monotonic `receivedAt` horizon; a `markConsumed`
1378
+ // commit may drop the oldest consumed tail (entries strictly below
1379
+ // the watermark) and the watermark may only advance. Resolve both
1380
+ // the prospective and prior watermark up front so the consumed
1381
+ // deletion check below can bind every drop to the watermark.
1382
+ let prospectiveWatermark = 0;
1383
+ if (bucket.watermarkPath !== null) {
1384
+ const wm = await readWatermark(bucket.watermarkPath, (p) => readBlob(p));
1385
+ if (!wm.ok)
1386
+ return wm;
1387
+ prospectiveWatermark = wm.watermark;
1388
+ }
1389
+ let priorWatermark = 0;
1390
+ if (priorBucket?.watermarkPath != null) {
1391
+ const wm = await readWatermark(priorBucket.watermarkPath, priorReadBlob);
1392
+ if (!wm.ok)
1393
+ return wm;
1394
+ priorWatermark = wm.watermark;
1395
+ }
1396
+ if (prospectiveWatermark < priorWatermark) {
1397
+ return {
1398
+ ok: false,
1399
+ reason: `address ${JSON.stringify(decodedAddress)} retention watermark regressed from ${String(priorWatermark)} to ${String(prospectiveWatermark)}; the watermark is monotonically non-decreasing`,
1400
+ };
1401
+ }
1402
+ if (priorBucket !== undefined) {
1403
+ // The consumed dedup index may shrink only by a watermark-passed
1404
+ // prune: a consumed entry dropped from the prior tree must have a
1405
+ // receivedAt strictly below the prospective watermark (you may
1406
+ // prune only what the watermark passed). Combined with the
1407
+ // already-verified watermark monotonicity, this is the whole of
1408
+ // the exactly-once retention contract: pruning is bound to the
1409
+ // watermark and the watermark only advances.
1410
+ //
1411
+ // The suffix relation (dropped entries older than every retained
1412
+ // entry) is deliberately NOT enforced. A RETAINED entry is NOT
1413
+ // required to sit at or above the watermark: a message consumed
1414
+ // long after receipt (or replayed back in-flight) may
1415
+ // legitimately carry a below-watermark receivedAt and survive
1416
+ // until a later commit prunes it. Holding it gives EXTRA dedup (a
1417
+ // re-submission at or above the watermark still hits the retained
1418
+ // entry; one below is stale-rejected at enqueue), so a hole left
1419
+ // by an out-of-order prune weakens nothing.
1420
+ //
1421
+ // Only the dropped entries are read. A retained entry (present in
1422
+ // both trees) is proven byte-identical by the OID compare above,
1423
+ // so its receivedAt is unchanged and need not be read. The
1424
+ // receivedAt lives in the body; read it from the prior tree
1425
+ // (retained bytes are immutable, so prior and prospective agree).
1426
+ for (const e of priorBucket.consumed) {
1427
+ if (prospectiveConsumedPaths.has(e.blobPath))
1428
+ continue;
1429
+ const priorParsed = await parseConsumedBlobFrom(e, priorReadBlob);
1430
+ if (!priorParsed.ok)
1431
+ return priorParsed;
1432
+ const receivedAt = priorParsed.body.receivedAt;
1433
+ if (receivedAt >= prospectiveWatermark) {
1434
+ return {
1435
+ ok: false,
1436
+ reason: `consumed ${e.blobPath} present in the prior tree is missing from the prospective tree but its receivedAt ${String(receivedAt)} is not below the retention watermark ${String(prospectiveWatermark)}; consumed entries may be pruned only once the watermark has passed them`,
1437
+ };
1438
+ }
1439
+ }
1440
+ for (const e of priorBucket.processing) {
1441
+ if (prospectiveProcessingPaths.has(e.blobPath))
1442
+ continue;
1443
+ // A processing entry may legitimately disappear in two
1444
+ // shapes: (1) markConsumed wrote a matching consumed entry
1445
+ // keyed by the same messageId, or (2) replayProcessingToInbox
1446
+ // moved the entry back to inbox preserving the
1447
+ // `<receivedAt>-<messageId>.json` filename. Anything else is
1448
+ // an in-flight loss.
1449
+ const consumedMatch = prospectiveConsumedByMessageId.get(e.messageIdFromFilename);
1450
+ const inboxMatch = prospectiveInboxByFilename.get(e.filename);
1451
+ if (consumedMatch !== undefined || inboxMatch !== undefined)
1452
+ continue;
1453
+ return {
1454
+ ok: false,
1455
+ reason: `processing ${e.blobPath} present in the prior tree is missing from the prospective tree without a matching consumed or inbox transition; in-flight processing entries cannot be silently dropped`,
1456
+ };
1457
+ }
1458
+ for (const e of priorBucket.inbox) {
1459
+ if (prospectiveInboxPaths.has(e.blobPath))
1460
+ continue;
1461
+ // A prior inbox entry may legitimately disappear when it
1462
+ // transitions to processing (same `<receivedAt>-<messageId>`
1463
+ // filename) or directly to consumed (matching messageId).
1464
+ // Anything else is an inbound-mail loss — the FIFO claim-check
1465
+ // contract requires the entry to reappear somewhere.
1466
+ const processingMatch = prospectiveProcessingByFilename.get(e.filename);
1467
+ const consumedMatch = prospectiveConsumedByMessageId.get(e.messageIdFromFilename);
1468
+ if (processingMatch !== undefined || consumedMatch !== undefined)
1469
+ continue;
1470
+ return {
1471
+ ok: false,
1472
+ reason: `inbox ${e.blobPath} present in the prior tree is missing from the prospective tree without a matching processing or consumed transition; pending inbox entries cannot be silently dropped`,
1473
+ };
1474
+ }
1475
+ }
1476
+ const priorInboxByFilename = new Map();
1477
+ const priorProcessingByMessageId = new Map();
1478
+ if (priorBucket !== undefined) {
1479
+ for (const e of priorBucket.inbox)
1480
+ priorInboxByFilename.set(e.filename, e);
1481
+ for (const e of priorBucket.processing)
1482
+ priorProcessingByMessageId.set(e.messageIdFromFilename, e);
1483
+ }
1484
+ const priorProcessingPaths = new Set((priorBucket?.processing ?? []).map((e) => e.blobPath));
1485
+ const priorConsumedPaths = new Set((priorBucket?.consumed ?? []).map((e) => e.blobPath));
1486
+ // Newly-added processing entries must match an inbox entry that
1487
+ // existed in the prior tree at the same `<receivedAt>-<messageId>`
1488
+ // filename. This makes inbox→processing the only legal way to
1489
+ // grow processing/.
1490
+ for (const entry of bucket.processing) {
1491
+ if (priorProcessingPaths.has(entry.blobPath))
1492
+ continue;
1493
+ const priorInbox = priorInboxByFilename.get(entry.filename);
1494
+ if (priorInbox === undefined) {
1495
+ return {
1496
+ ok: false,
1497
+ reason: `processing ${entry.blobPath} is newly added but the prior tree has no matching inbox entry ${JSON.stringify(`${WORKFLOW_RUN_ADDRESSES_PREFIX}/${segment}/${WORKFLOW_RUN_INBOX_DIR}/${entry.filename}`)}; processing entries must originate from a prior-tree inbox entry`,
1498
+ };
1499
+ }
1500
+ }
1501
+ // Newly-added consumed entries must match a processing entry that
1502
+ // existed in the prior tree at the same address+messageId, and
1503
+ // the receivedAt carried in the consumed envelope must equal the
1504
+ // receivedAt the processing entry's filename carried.
1505
+ for (const entry of bucket.consumed) {
1506
+ if (priorConsumedPaths.has(entry.blobPath))
1507
+ continue;
1508
+ const priorProcessing = priorProcessingByMessageId.get(entry.messageIdFromFilename);
1509
+ if (priorProcessing === undefined) {
1510
+ return {
1511
+ ok: false,
1512
+ reason: `consumed ${entry.blobPath} is newly added but the prior tree has no matching processing entry for messageId ${JSON.stringify(entry.messageIdFromFilename)}; consumed entries must originate from a prior-tree processing entry`,
1513
+ };
1514
+ }
1515
+ const parsed = await parseConsumedBlob(entry, readBlob);
1516
+ if (!parsed.ok)
1517
+ return parsed;
1518
+ const consumedBody = parsed.body;
1519
+ if (consumedBody.receivedAt !== priorProcessing.receivedAtFromFilename) {
1520
+ return {
1521
+ ok: false,
1522
+ reason: `consumed ${entry.blobPath} body.receivedAt ${String(consumedBody.receivedAt)} does not match the prior processing entry's receivedAt ${String(priorProcessing.receivedAtFromFilename)} for messageId ${JSON.stringify(entry.messageIdFromFilename)}`,
1523
+ };
1524
+ }
1525
+ }
1526
+ }
1527
+ return { ok: true };
1528
+ }
1529
+ /**
1530
+ * Enforce the Q3 principal-vs-origin map for a parsed
1531
+ * `CancelRequested` event. The principal kind is matched against the
1532
+ * origin's required-signer kind; a mismatch rejects with both the
1533
+ * declared origin and the actual principal kind in the message so a
1534
+ * misconfigured writer surfaces concretely at the push boundary.
1535
+ */
1536
+ function checkCancelOriginPrincipal(blobPath, origin, principal) {
1537
+ const required = CANCEL_ORIGIN_TO_PRINCIPAL_KIND.get(origin);
1538
+ if (required === undefined) {
1539
+ return {
1540
+ ok: false,
1541
+ reason: `event ${blobPath} CancelRequested origin ${JSON.stringify(origin)} has no principal-kind binding`,
1542
+ };
1543
+ }
1544
+ if (principal.kind !== required) {
1545
+ return {
1546
+ ok: false,
1547
+ reason: `event ${blobPath} CancelRequested origin ${JSON.stringify(origin)} requires principal.kind=${JSON.stringify(required)} but the push was signed by principal.kind=${JSON.stringify(principal.kind)}`,
1548
+ };
1549
+ }
1550
+ return { ok: true };
1551
+ }
1552
+ /**
1553
+ * Path-scoping for the `workflow-process` principal. A workflow-process
1554
+ * proxies writes for the workflow-run repo's `runs/<runId>/` subtree
1555
+ * only; the supervisor owns the `addresses/...` claim-check subtree.
1556
+ * If the principal carries a `runId`, every prospective `runs/<X>/`
1557
+ * subtree must use `X === principal.runId`. A workflow-process that
1558
+ * touches the `addresses/...` subtree is rejected outright so the
1559
+ * single-writer contract on inbox/processing/consumed holds at the
1560
+ * substrate boundary.
1561
+ *
1562
+ * The check only fires for `workflow-process` principals; `hub` and
1563
+ * `supervisor` have broader write authority by design.
1564
+ */
1565
+ async function enforceWorkflowProcessPathScope(principal, topLevelTreePaths, listDir) {
1566
+ if (principal.kind !== "workflow-process")
1567
+ return { ok: true };
1568
+ const parsed = WorkflowProcessPrincipal(principal);
1569
+ if (parsed instanceof type.errors) {
1570
+ // `workflowRunAuthorize` already rejects malformed
1571
+ // `workflow-process` principals at `gateAccess`, so this branch is
1572
+ // unreachable when the substrate is wired against the real
1573
+ // authorize callback. Fail closed so a future wiring that supplies
1574
+ // a permissive authorize (e.g. test substrates using `allowAll`)
1575
+ // cannot silently bypass the path-scope enforcement below.
1576
+ return {
1577
+ ok: false,
1578
+ reason: `workflow-process principal is malformed: ${parsed.summary}`,
1579
+ };
1580
+ }
1581
+ if (topLevelTreePaths.includes(WORKFLOW_RUN_ADDRESSES_PREFIX)) {
1582
+ return {
1583
+ ok: false,
1584
+ reason: `workflow-process principal may not write under ${WORKFLOW_RUN_ADDRESSES_PREFIX}/; the supervisor owns the claim-check subtree`,
1585
+ };
1586
+ }
1587
+ if (parsed.runId !== undefined &&
1588
+ topLevelTreePaths.includes(WORKFLOW_RUN_RUNS_PREFIX)) {
1589
+ const runIds = await listDir(WORKFLOW_RUN_RUNS_PREFIX);
1590
+ for (const runId of runIds) {
1591
+ if (runId !== parsed.runId) {
1592
+ return {
1593
+ ok: false,
1594
+ reason: `workflow-process principal scoped to runId ${JSON.stringify(parsed.runId)} may not write under ${WORKFLOW_RUN_RUNS_PREFIX}/${runId}/`,
1595
+ };
1596
+ }
1597
+ }
1598
+ }
1599
+ return { ok: true };
1600
+ }
1601
+ /**
1602
+ * Validate the `agent-state/` subtree shape (design §3c). The subtree
1603
+ * holds one MUTABLE per-agent conversation snapshot directory per agent
1604
+ * below the prefix; each entry directly under `agent-state/` must be a
1605
+ * `<agentKey>/` DIRECTORY (not a dangling blob), and each `<agentKey>`
1606
+ * segment must round-trip URL-encoding so a reader can recover the
1607
+ * agent's identity from the path. The conversation blobs inside a
1608
+ * `<agentKey>/` directory are opaque to the substrate (the warm agent's
1609
+ * ContextStore owns their shape), so no file-level shape is enforced
1610
+ * here.
1611
+ *
1612
+ * A blob written DIRECTLY at `agent-state/<name>` (with no `<agentKey>/`
1613
+ * layer) is rejected: it would not be keyed by an agent and would not be
1614
+ * recoverable by any reader walking the per-agent layout.
1615
+ */
1616
+ async function validateAgentStateSubtree(topLevelTreePaths, listDir) {
1617
+ if (!topLevelTreePaths.includes(WORKFLOW_RUN_AGENT_STATE_PREFIX)) {
1618
+ return { ok: true };
1619
+ }
1620
+ const segments = await listDir(WORKFLOW_RUN_AGENT_STATE_PREFIX);
1621
+ for (const segment of segments) {
1622
+ const roundTrip = checkAddressSegmentRoundTrip(segment);
1623
+ if (!roundTrip.ok) {
1624
+ return {
1625
+ ok: false,
1626
+ reason: `agent-state segment ${JSON.stringify(segment)} does not round-trip URL-encoding; ${roundTrip.reason}`,
1627
+ };
1628
+ }
1629
+ // Reject a blob dangling directly at `agent-state/<segment>`: every
1630
+ // entry under the prefix must be a `<agentKey>/` directory carrying
1631
+ // the agent's snapshot files. A directory has children under
1632
+ // `agent-state/<segment>/`; a direct blob has none.
1633
+ const children = await listDir(`${WORKFLOW_RUN_AGENT_STATE_PREFIX}/${segment}`);
1634
+ if (children.length === 0) {
1635
+ return {
1636
+ ok: false,
1637
+ reason: `agent-state entry ${JSON.stringify(segment)} is a blob directly under ${WORKFLOW_RUN_AGENT_STATE_PREFIX}/; entries must be a <agentKey>/ directory carrying the agent's snapshot files`,
1638
+ };
1639
+ }
1640
+ }
1641
+ return { ok: true };
1642
+ }
1643
+ export const workflowRunKindHandler = {
1644
+ kind: "workflow-run",
1645
+ directoryPrefix: "workflow-runs",
1646
+ async validatePush({ repoId, ref, principal, topLevelTreePaths, readBlob, listDir, listDirOids, priorReadBlob, priorListDir, priorListDirOids, changedPathPrefixes, }) {
1647
+ // Bound the per-run event/blob walks to the runs this commit could
1648
+ // have touched. The substrate guarantees a prefix-preserving commit
1649
+ // mutates only paths under `changedPathPrefixes`; every run outside
1650
+ // them is carried forward byte-identical, so its per-run invariants
1651
+ // (seq-contiguity, terminal-lock, append-only, blob-immutability)
1652
+ // cannot change and were already validated when the run was last
1653
+ // written. `scopeRunIds` is the set of run ids under a
1654
+ // `runs/<runId>/` change prefix. It stays `undefined` -- validate
1655
+ // every run -- whenever the substrate could not bound the change set
1656
+ // (`changedPathPrefixes` is undefined) OR a change prefix touches the
1657
+ // `runs/` subtree at a coarser-than-per-run granularity (a bare
1658
+ // `runs/` prefix, which cannot identify which run changed), so the
1659
+ // scoping never narrows below what the substrate can prove.
1660
+ const scopeRunIds = runScopeFromChangedPrefixes(changedPathPrefixes);
1661
+ for (const entry of topLevelTreePaths) {
1662
+ if (entry.startsWith(`${WORKFLOW_RUN_CONTROL_PREFIX}/`) ||
1663
+ entry === WORKFLOW_RUN_CONTROL_PREFIX) {
1664
+ return {
1665
+ ok: false,
1666
+ reason: `top-level entry ${JSON.stringify(entry)} is under the unsupported ${WORKFLOW_RUN_CONTROL_PREFIX}/ subtree`,
1667
+ };
1668
+ }
1669
+ if (!ALLOWED_TOP_LEVEL.has(entry)) {
1670
+ return {
1671
+ ok: false,
1672
+ reason: `unexpected top-level entry ${JSON.stringify(entry)}; allowed: "${WORKFLOW_RUN_RUNS_PREFIX}", "${WORKFLOW_RUN_ADDRESSES_PREFIX}", "${WORKFLOW_RUN_AGENT_STATE_PREFIX}", "${WORKFLOW_RUN_GITIGNORE_PATH}"`,
1673
+ };
1674
+ }
1675
+ }
1676
+ const scopingCheck = await enforceWorkflowProcessPathScope(principal, topLevelTreePaths, listDir);
1677
+ if (!scopingCheck.ok) {
1678
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${scopingCheck.reason}`;
1679
+ return scopingCheck;
1680
+ }
1681
+ const agentStateCheck = await validateAgentStateSubtree(topLevelTreePaths, listDir);
1682
+ if (!agentStateCheck.ok) {
1683
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${agentStateCheck.reason}`;
1684
+ return agentStateCheck;
1685
+ }
1686
+ const priorTopLevels = await priorListDir("");
1687
+ const addressesPresent = topLevelTreePaths.includes(WORKFLOW_RUN_ADDRESSES_PREFIX) ||
1688
+ priorTopLevels.includes(WORKFLOW_RUN_ADDRESSES_PREFIX);
1689
+ if (addressesPresent) {
1690
+ // Enter claim-check validation when the prospective OR prior
1691
+ // tree carries an `addresses/` subtree. A prospective tree that
1692
+ // omits `addresses/` while the prior tree had consumed or
1693
+ // processing entries must still go through the subtree walk so
1694
+ // those prior entries' deletion-direction invariants fire.
1695
+ const claimCheck = await validateClaimCheckSubtree(listDir, readBlob, priorReadBlob, priorListDir, priorListDirOids, listDirOids);
1696
+ if (!claimCheck.ok) {
1697
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${claimCheck.reason}`;
1698
+ return claimCheck;
1699
+ }
1700
+ }
1701
+ const runsPresent = topLevelTreePaths.includes(WORKFLOW_RUN_RUNS_PREFIX) ||
1702
+ priorTopLevels.includes(WORKFLOW_RUN_RUNS_PREFIX);
1703
+ if (!runsPresent) {
1704
+ // A workflow-run repo without any `runs/` directory in either
1705
+ // the prior or the prospective tree is a genesis state for the
1706
+ // events subtree — `.gitignore`-only or claim-check-only trees
1707
+ // are accepted so the asset routes' init can land before any
1708
+ // run has produced an event.
1709
+ return { ok: true };
1710
+ }
1711
+ const enumerated = await enumerateEventBlobs(listDir, scopeRunIds);
1712
+ if (!enumerated.ok) {
1713
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${enumerated.reason}`;
1714
+ return { ok: false, reason: enumerated.reason };
1715
+ }
1716
+ const newlyTerminalRuns = [];
1717
+ for (const [runId, entries] of enumerated.runs) {
1718
+ if (entries.length === 0) {
1719
+ return {
1720
+ ok: false,
1721
+ reason: `run ${runId} has an empty events directory`,
1722
+ };
1723
+ }
1724
+ // Sequence contiguity: per-run events must run contiguously
1725
+ // through the tip from whatever seq the first entry uses. Without
1726
+ // this, a downstream consumer that iterates the log by seq would
1727
+ // skip past a gap silently. `entries` is sorted by filenameSeq
1728
+ // above. The first seq is not pinned to 0 because the runtime
1729
+ // body's emptyState carries `lastSeq = 0` and emits its first
1730
+ // event at `seq = lastSeq + 1 = 1`, while the supervisor's
1731
+ // self-signed CancelRequested path lands seq=0 against an empty
1732
+ // events tree.
1733
+ const firstEntry = entries[0];
1734
+ if (firstEntry === undefined)
1735
+ throw new Error("unreachable");
1736
+ const baseSeq = firstEntry.filenameSeq;
1737
+ for (let i = 0; i < entries.length; i++) {
1738
+ const e = entries[i];
1739
+ if (e === undefined)
1740
+ throw new Error("unreachable");
1741
+ const expectedSeq = baseSeq + i;
1742
+ if (e.filenameSeq !== expectedSeq) {
1743
+ const expectedPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}/${WORKFLOW_RUN_EVENTS_DIR}/${String(expectedSeq)}.json`;
1744
+ return {
1745
+ ok: false,
1746
+ reason: `run ${runId} events have a sequence gap: ${expectedPath} is missing (next observed is ${e.blobPath})`,
1747
+ };
1748
+ }
1749
+ }
1750
+ let terminalSeq = null;
1751
+ let terminalType = null;
1752
+ for (const entry of entries) {
1753
+ const priorCheck = await checkPriorByteEquality(entry.blobPath, readBlob, priorReadBlob);
1754
+ if (!priorCheck.ok) {
1755
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${priorCheck.reason}`;
1756
+ return priorCheck;
1757
+ }
1758
+ const parsed = await parseEventBlob(entry, readBlob);
1759
+ if (!parsed.ok) {
1760
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${parsed.reason}`;
1761
+ return { ok: false, reason: parsed.reason };
1762
+ }
1763
+ if (parsed.parsed.body.type === "CancelRequested") {
1764
+ const origin = parsed.parsed.body.origin;
1765
+ if (typeof origin !== "string") {
1766
+ return {
1767
+ ok: false,
1768
+ reason: `event ${entry.blobPath} CancelRequested origin must be a string`,
1769
+ };
1770
+ }
1771
+ const principalCheck = checkCancelOriginPrincipal(entry.blobPath, origin, principal);
1772
+ if (!principalCheck.ok) {
1773
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${principalCheck.reason}`;
1774
+ return principalCheck;
1775
+ }
1776
+ }
1777
+ if (terminalSeq !== null) {
1778
+ return {
1779
+ ok: false,
1780
+ reason: `run ${runId} has event at seq ${String(entry.filenameSeq)} after terminal ${terminalType} at seq ${String(terminalSeq)}`,
1781
+ };
1782
+ }
1783
+ if (TERMINAL_EVENT_TYPES.has(parsed.parsed.body.type)) {
1784
+ terminalSeq = entry.filenameSeq;
1785
+ terminalType = parsed.parsed.body.type;
1786
+ // Surface the run as newly terminal only when this commit is
1787
+ // the one that ADDS the terminal event -- i.e. the terminal
1788
+ // blob is absent from the prior tree. A commit that carries an
1789
+ // already-terminal run forward unchanged (a later compaction
1790
+ // commit folding the per-event files into one) finds the
1791
+ // terminal blob already present in the prior tree and emits no
1792
+ // signal, so a downstream consumer keyed on the signal does
1793
+ // not double-fire.
1794
+ if ((await priorReadBlob(entry.blobPath)) === null) {
1795
+ const terminalBytes = await readBlob(entry.blobPath);
1796
+ newlyTerminalRuns.push({
1797
+ runId,
1798
+ terminalEventJson: new TextDecoder().decode(terminalBytes),
1799
+ });
1800
+ }
1801
+ }
1802
+ }
1803
+ }
1804
+ const combinedRuns = await validateCombinedEventRuns(listDir, readBlob, priorListDir, priorReadBlob, scopeRunIds);
1805
+ if (!combinedRuns.ok) {
1806
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${combinedRuns.reason}`;
1807
+ return { ok: false, reason: combinedRuns.reason };
1808
+ }
1809
+ const blobsEnumerated = await enumerateRunBlobs(listDir, scopeRunIds);
1810
+ if (!blobsEnumerated.ok) {
1811
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${blobsEnumerated.reason}`;
1812
+ return { ok: false, reason: blobsEnumerated.reason };
1813
+ }
1814
+ for (const blob of blobsEnumerated.blobs) {
1815
+ const immutability = await checkBlobPriorByteEquality(blob.blobPath, readBlob, priorReadBlob);
1816
+ if (!immutability.ok) {
1817
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${immutability.reason}`;
1818
+ return immutability;
1819
+ }
1820
+ }
1821
+ // Append-only / immutability extended to the deletion direction
1822
+ // for the runs subtree. The prospective-tree walks above only
1823
+ // see paths PRESENT in the prospective tree; a prospective tree
1824
+ // that omits a prior `runs/<runId>/events/<seq>.json` or
1825
+ // `runs/<runId>/blobs/<sha>` slips past those iterations
1826
+ // entirely. Enumerate the prior tree's runs subtree under the
1827
+ // same shapes and reject any prior path that does not reappear.
1828
+ const priorEnumerated = await enumerateEventBlobs(priorListDir, scopeRunIds);
1829
+ if (!priorEnumerated.ok) {
1830
+ return {
1831
+ ok: false,
1832
+ reason: `prior tree's runs subtree is structurally invalid: ${priorEnumerated.reason}`,
1833
+ };
1834
+ }
1835
+ const prospectiveEventPaths = new Set();
1836
+ for (const entries of enumerated.runs.values()) {
1837
+ for (const e of entries)
1838
+ prospectiveEventPaths.add(e.blobPath);
1839
+ }
1840
+ for (const entries of priorEnumerated.runs.values()) {
1841
+ for (const e of entries) {
1842
+ if (prospectiveEventPaths.has(e.blobPath))
1843
+ continue;
1844
+ // A run sealed into its combined events.jsonl by this commit
1845
+ // legitimately drops its per-event files; the fold was validated
1846
+ // byte-for-byte against these same prior blobs above.
1847
+ if (combinedRuns.combinedRunIds.has(e.runId))
1848
+ continue;
1849
+ return {
1850
+ ok: false,
1851
+ reason: `event ${e.blobPath} present in the prior tree is missing from the prospective tree; event blobs are append-only`,
1852
+ };
1853
+ }
1854
+ }
1855
+ const priorBlobsEnumerated = await enumerateRunBlobs(priorListDir, scopeRunIds);
1856
+ if (!priorBlobsEnumerated.ok) {
1857
+ return {
1858
+ ok: false,
1859
+ reason: `prior tree's blobs subtree is structurally invalid: ${priorBlobsEnumerated.reason}`,
1860
+ };
1861
+ }
1862
+ const prospectiveBlobPaths = new Set(blobsEnumerated.blobs.map((b) => b.blobPath));
1863
+ for (const b of priorBlobsEnumerated.blobs) {
1864
+ if (prospectiveBlobPaths.has(b.blobPath))
1865
+ continue;
1866
+ return {
1867
+ ok: false,
1868
+ reason: `blob ${b.blobPath} present in the prior tree is missing from the prospective tree; blob entries are immutable once written`,
1869
+ };
1870
+ }
1871
+ return { ok: true, newlyTerminalRuns };
1872
+ },
1873
+ onRefUpdated() {
1874
+ // No cached index today. Consumers read events through the
1875
+ // substrate's subscribe / blob-read API.
1876
+ },
1877
+ };
1878
+ export const workflowRunAuthorize = (principal, repoId, ref, action) => {
1879
+ if (repoId.kind !== "workflow-run") {
1880
+ return {
1881
+ allowed: false,
1882
+ reason: `workflow-run authorize received non-workflow-run repo ${repoId.kind}/${repoId.id}`,
1883
+ };
1884
+ }
1885
+ if (principal.kind === "hub") {
1886
+ return { allowed: true };
1887
+ }
1888
+ if (principal.kind === "workflow-process") {
1889
+ const parsed = WorkflowProcessPrincipal(principal);
1890
+ if (parsed instanceof type.errors) {
1891
+ return {
1892
+ allowed: false,
1893
+ reason: `workflow-process principal is malformed: ${parsed.summary}`,
1894
+ };
1895
+ }
1896
+ if (parsed.deploymentId !== repoId.id) {
1897
+ return {
1898
+ allowed: false,
1899
+ reason: `workflow-process deployment ${parsed.deploymentId} cannot access workflow-run ${repoId.id}`,
1900
+ };
1901
+ }
1902
+ switch (action) {
1903
+ case "init":
1904
+ case "writeTree":
1905
+ case "receivePack":
1906
+ case "createPack":
1907
+ case "resolveRef":
1908
+ return { allowed: true };
1909
+ default: {
1910
+ const _exhaustive = action;
1911
+ return {
1912
+ allowed: false,
1913
+ reason: `unhandled action: ${String(_exhaustive)}`,
1914
+ };
1915
+ }
1916
+ }
1917
+ }
1918
+ if (principal.kind === "supervisor") {
1919
+ const parsed = SupervisorPrincipal(principal);
1920
+ if (parsed instanceof type.errors) {
1921
+ return {
1922
+ allowed: false,
1923
+ reason: `supervisor principal is malformed: ${parsed.summary}`,
1924
+ };
1925
+ }
1926
+ if (parsed.deploymentId !== repoId.id) {
1927
+ return {
1928
+ allowed: false,
1929
+ reason: `supervisor deployment ${parsed.deploymentId} cannot access workflow-run ${repoId.id}`,
1930
+ };
1931
+ }
1932
+ switch (action) {
1933
+ case "init":
1934
+ case "writeTree":
1935
+ case "receivePack":
1936
+ case "createPack":
1937
+ case "resolveRef":
1938
+ return { allowed: true };
1939
+ default: {
1940
+ const _exhaustive = action;
1941
+ return {
1942
+ allowed: false,
1943
+ reason: `unhandled action: ${String(_exhaustive)}`,
1944
+ };
1945
+ }
1946
+ }
1947
+ }
1948
+ if (principal.kind === "sidecar") {
1949
+ const parsed = SidecarPrincipal(principal);
1950
+ if (parsed instanceof type.errors) {
1951
+ return {
1952
+ allowed: false,
1953
+ reason: `sidecar principal is malformed: ${parsed.summary}`,
1954
+ };
1955
+ }
1956
+ switch (action) {
1957
+ case "createPack":
1958
+ case "resolveRef":
1959
+ return { allowed: true };
1960
+ case "init":
1961
+ case "writeTree":
1962
+ case "receivePack":
1963
+ return {
1964
+ allowed: false,
1965
+ reason: `sidecars may only read workflow-run repos, not ${action}`,
1966
+ };
1967
+ default: {
1968
+ const _exhaustive = action;
1969
+ return {
1970
+ allowed: false,
1971
+ reason: `unhandled action: ${String(_exhaustive)}`,
1972
+ };
1973
+ }
1974
+ }
1975
+ }
1976
+ if (principal.kind === "user") {
1977
+ // The route layer has already pre-resolved the grant verdict and
1978
+ // attached it as `authz`. The substrate does NOT re-query the
1979
+ // grant store here; it (a) checks the bearer-token's claims
1980
+ // bound the requested (ref, action) and have not expired, and
1981
+ // (b) sanity-checks that the pre-resolved verdict targets this
1982
+ // exact resource and grant verb. Both gates must pass before the
1983
+ // verdict's `effect` is honoured.
1984
+ const parsed = UserPrincipal(principal);
1985
+ if (parsed instanceof type.errors) {
1986
+ return {
1987
+ allowed: false,
1988
+ reason: `user principal is malformed: ${parsed.summary}`,
1989
+ };
1990
+ }
1991
+ if (!parsed.tokenClaims.actions.includes(action)) {
1992
+ return {
1993
+ allowed: false,
1994
+ reason: `token does not grant action ${action}`,
1995
+ };
1996
+ }
1997
+ // `ref === "*"` is the substrate's sentinel for the bulk read
1998
+ // performed by `listRefs`. Per-ref filtering is the advertise-refs
1999
+ // layer's responsibility, so the bulk read is gated on action and
2000
+ // expiry alone.
2001
+ if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
2002
+ return {
2003
+ allowed: false,
2004
+ reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
2005
+ };
2006
+ }
2007
+ if (Date.now() >= parsed.tokenClaims.expiresAt) {
2008
+ return {
2009
+ allowed: false,
2010
+ reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
2011
+ };
2012
+ }
2013
+ const expectedResource = `workflow-run:${repoId.id}`;
2014
+ if (parsed.authz.resource !== expectedResource) {
2015
+ return {
2016
+ allowed: false,
2017
+ reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
2018
+ };
2019
+ }
2020
+ const expectedGrantVerb = repoActionToGrantVerb(action);
2021
+ if (parsed.authz.grantVerb !== expectedGrantVerb) {
2022
+ return {
2023
+ allowed: false,
2024
+ reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
2025
+ };
2026
+ }
2027
+ if (parsed.authz.effect === "allow") {
2028
+ return { allowed: true };
2029
+ }
2030
+ return {
2031
+ allowed: false,
2032
+ reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
2033
+ };
2034
+ }
2035
+ return {
2036
+ allowed: false,
2037
+ reason: `unknown principal kind: ${principal.kind}`,
2038
+ };
2039
+ };
2040
+ // ---------------------------------------------------------------------
2041
+ // Claim-check API.
2042
+ //
2043
+ // Four operations layer on top of `RepoStore.writeTreeDelta` to give
2044
+ // the workflow runtime a FIFO claim-check queue per address:
2045
+ //
2046
+ // enqueueInbox — append a new inbox entry for an inbound
2047
+ // message.
2048
+ // dequeueToProcessing — pick the lexicographically-first inbox
2049
+ // entry and atomically move it to
2050
+ // processing.
2051
+ // markConsumed — atomically remove the processing entry
2052
+ // and write the canonical
2053
+ // consumed/<messageId>.json dedup index
2054
+ // entry.
2055
+ // replayProcessingToInbox — recovery path that moves every
2056
+ // processing entry back to inbox preserving
2057
+ // its `<receivedAt>-<messageId>` filename
2058
+ // key so FIFO ordering survives a crash.
2059
+ //
2060
+ // All four route through `writeTreeDelta`, scoped to the per-address
2061
+ // subtree via `changedPathPrefixes`. The substrate serializes concurrent
2062
+ // claim-check operations on the per-repo lock and invokes each
2063
+ // operation's `computeDelta` callback with a `prior` view of the
2064
+ // committed tree. The callback reads only what it needs directly --
2065
+ // `prior.listDirOids` for a directory's names and OIDs, and
2066
+ // `prior.readBlobByOid` for a specific entry's bytes -- and returns a
2067
+ // TARGETED delta (the `puts` and `deletes` for the paths that change),
2068
+ // not the full subtree. The substrate applies that delta atomically over
2069
+ // the prior tree, carrying every untouched entry forward by OID and
2070
+ // landing the whole delta in a single commit, which is the atomic-commit
2071
+ // guarantee these operations require.
2072
+ function claimCheckCommitRef() {
2073
+ // Every claim-check operation targets the same canonical ref used by
2074
+ // the workflow-run kind handler's event log so subscribers see a
2075
+ // single coherent commit stream.
2076
+ return "refs/heads/events";
2077
+ }
2078
+ function addressSegmentFor(address) {
2079
+ // The substrate boundary is the only place URL-encoding happens.
2080
+ // `validatePush` rejects non-round-trip segments; mirroring the same
2081
+ // encoder here is the only legitimate way to produce one.
2082
+ return encodeURIComponent(address);
2083
+ }
2084
+ function addressPrefix(addressSegment) {
2085
+ return `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressSegment}/`;
2086
+ }
2087
+ function inboxPath(addressSegment, key) {
2088
+ return `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressSegment}/${WORKFLOW_RUN_INBOX_DIR}/${key}.json`;
2089
+ }
2090
+ function processingPath(addressSegment, key) {
2091
+ return `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressSegment}/${WORKFLOW_RUN_PROCESSING_DIR}/${key}.json`;
2092
+ }
2093
+ function consumedPath(addressSegment, messageId) {
2094
+ return `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressSegment}/${WORKFLOW_RUN_CONSUMED_DIR}/${messageId}.json`;
2095
+ }
2096
+ function watermarkPath(addressSegment) {
2097
+ return `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressSegment}/${WORKFLOW_RUN_WATERMARK_FILE}`;
2098
+ }
2099
+ function filenameKey(receivedAt, messageId) {
2100
+ return `${String(receivedAt)}-${messageId}`;
2101
+ }
2102
+ /**
2103
+ * Read one address's claim-check listing from the parent commit: the
2104
+ * filenames and blob OIDs directly under
2105
+ * `addresses/<addressSegment>/{inbox,processing,consumed}/` (NOT their
2106
+ * bytes), plus the retention watermark. The bytes of the single entry a
2107
+ * leg actually moves are read separately by OID via
2108
+ * `prior.readBlobByOid`, so the unbounded consumed/ dedup index is
2109
+ * enumerated (one `listDirOids` per bucket, names and OIDs only) but
2110
+ * never read blob-by-blob. Every read goes through the store's
2111
+ * cache-backed `prior` closures under the write lock. An empty listing
2112
+ * covers the repo/ref/address-absent first-write states -- all
2113
+ * legitimate for a brand-new operation.
2114
+ */
2115
+ async function readAddressListing(prior, addressSegment) {
2116
+ const listing = {
2117
+ inbox: [],
2118
+ processing: [],
2119
+ consumed: [],
2120
+ watermark: 0,
2121
+ };
2122
+ const addrDir = `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressSegment}`;
2123
+ for (const child of await prior.listDirOids(addrDir)) {
2124
+ if (child.name === WORKFLOW_RUN_WATERMARK_FILE) {
2125
+ const blob = await prior.readBlobByOid(child.oid);
2126
+ listing.watermark = parseWatermark(blob, watermarkPath(addressSegment));
2127
+ continue;
2128
+ }
2129
+ const bucket = child.name === WORKFLOW_RUN_INBOX_DIR
2130
+ ? listing.inbox
2131
+ : child.name === WORKFLOW_RUN_PROCESSING_DIR
2132
+ ? listing.processing
2133
+ : child.name === WORKFLOW_RUN_CONSUMED_DIR
2134
+ ? listing.consumed
2135
+ : null;
2136
+ if (bucket === null)
2137
+ continue;
2138
+ for (const entry of await prior.listDirOids(`${addrDir}/${child.name}`)) {
2139
+ bucket.push({ name: entry.name, oid: entry.oid });
2140
+ }
2141
+ }
2142
+ return listing;
2143
+ }
2144
+ function utf8(s) {
2145
+ return new TextEncoder().encode(s);
2146
+ }
2147
+ function decodeQueueEnvelopeOrThrow(bytes, blobPath) {
2148
+ let parsed;
2149
+ try {
2150
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
2151
+ }
2152
+ catch (cause) {
2153
+ throw new Error(`claim_check_corrupt_json: ${blobPath}`, { cause });
2154
+ }
2155
+ const validated = ClaimCheckEnvelope(parsed);
2156
+ if (validated instanceof type.errors) {
2157
+ throw new Error(`claim_check_envelope_invalid: ${blobPath}: ${validated.summary}`);
2158
+ }
2159
+ return validated;
2160
+ }
2161
+ function decodeConsumedReceivedAtOrThrow(bytes, blobPath) {
2162
+ let parsed;
2163
+ try {
2164
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
2165
+ }
2166
+ catch (cause) {
2167
+ throw new Error(`claim_check_corrupt_json: ${blobPath}`, { cause });
2168
+ }
2169
+ const validated = ConsumedEnvelope(parsed);
2170
+ if (validated instanceof type.errors) {
2171
+ throw new Error(`claim_check_consumed_invalid: ${blobPath}: ${validated.summary}`);
2172
+ }
2173
+ return validated.receivedAt;
2174
+ }
2175
+ /**
2176
+ * Decode the per-address retention watermark from its blob bytes. The
2177
+ * caller treats an absent watermark blob as 0 (the address has never
2178
+ * pruned; nothing refused).
2179
+ */
2180
+ function parseWatermark(bytes, watermarkFull) {
2181
+ let parsed;
2182
+ try {
2183
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
2184
+ }
2185
+ catch (cause) {
2186
+ throw new Error(`claim_check_corrupt_json: ${watermarkFull}`, { cause });
2187
+ }
2188
+ const validated = WatermarkEnvelope(parsed);
2189
+ if (validated instanceof type.errors) {
2190
+ throw new Error(`claim_check_watermark_invalid: ${watermarkFull}: ${validated.summary}`);
2191
+ }
2192
+ return validated.watermark;
2193
+ }
2194
+ /**
2195
+ * Append a new inbox entry for `address`. The merge callback reads
2196
+ * the address subtree under the per-repo lock, augments the inbox
2197
+ * with the new entry, and returns the full set of address files. The
2198
+ * substrate replaces the address subtree wholesale.
2199
+ *
2200
+ * Rejects if a same-messageId entry already exists in any queue
2201
+ * state at the address — including a prior inbox entry at a
2202
+ * different `receivedAt`. The caller is expected to consult the
2203
+ * dedup index (consumed/) before calling, but enforcing the
2204
+ * invariant here also catches the concurrent-enqueue race that the
2205
+ * per-repo lock alone cannot surface.
2206
+ */
2207
+ export async function enqueueInbox(store, principal, repoId, args) {
2208
+ const addressSegment = addressSegmentFor(args.address);
2209
+ const ref = claimCheckCommitRef();
2210
+ const inboxKey = filenameKey(args.receivedAt, args.messageId);
2211
+ const envelope = {
2212
+ messageId: args.messageId,
2213
+ receivedAt: args.receivedAt,
2214
+ address: args.address,
2215
+ mailAuditRef: args.mailAuditRef,
2216
+ ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}),
2217
+ };
2218
+ const newInboxPath = inboxPath(addressSegment, inboxKey);
2219
+ const inboxFname = `${inboxKey}.json`;
2220
+ const consumedFname = `${args.messageId}.json`;
2221
+ const messageIdSuffix = `-${args.messageId}.json`;
2222
+ const { commitSha } = await store.writeTreeDelta(principal, repoId, ref, {
2223
+ changedPathPrefixes: new Set([addressPrefix(addressSegment)]),
2224
+ message: `enqueue inbox ${args.address} ${args.messageId}`,
2225
+ computeDelta: async (_parentCommitSha, prior) => {
2226
+ const listing = await readAddressListing(prior, addressSegment);
2227
+ // Refuse a definitively-stale enqueue: a message whose receivedAt
2228
+ // is strictly below the retention watermark could have had its
2229
+ // consumed/ dedup entry pruned, so a duplicate can no longer be
2230
+ // ruled out. Reject it LOUDLY rather than risk reprocessing. This
2231
+ // is the second half of the exactly-once guarantee: above the
2232
+ // watermark the consumed/ index is authoritative; below it, refuse.
2233
+ if (args.receivedAt < listing.watermark) {
2234
+ throw new Error(`claim_check_stale_enqueue: address ${args.address} message ${args.messageId} receivedAt ${String(args.receivedAt)} is below the retention watermark ${String(listing.watermark)}; its dedup entry may have been pruned, so it is refused as definitively-stale`);
2235
+ }
2236
+ if (listing.inbox.some((e) => e.name === inboxFname)) {
2237
+ throw new Error(`claim_check_duplicate_inbox: ${newInboxPath} already exists`);
2238
+ }
2239
+ // consumed/ is keyed by messageId alone, so this is an exact
2240
+ // filename lookup against the dedup index.
2241
+ if (listing.consumed.some((e) => e.name === consumedFname)) {
2242
+ throw new Error(`claim_check_already_consumed: address ${args.address} message ${args.messageId} is already in the consumed dedup index`);
2243
+ }
2244
+ if (listing.processing.some((e) => e.name.endsWith(messageIdSuffix))) {
2245
+ throw new Error(`claim_check_already_processing: address ${args.address} message ${args.messageId} is currently in processing`);
2246
+ }
2247
+ // Reject a second inbox entry for the same messageId at a
2248
+ // different receivedAt. The validatePush atomicity check also
2249
+ // catches this on the commit path, but surfacing it here gives the
2250
+ // caller a precise error and keeps the bad tree off the substrate.
2251
+ const inboxDup = listing.inbox.find((e) => e.name.endsWith(messageIdSuffix));
2252
+ if (inboxDup !== undefined) {
2253
+ throw new Error(`claim_check_already_inbox: address ${args.address} message ${args.messageId} is already in the inbox at ${inboxPath(addressSegment, inboxDup.name.slice(0, -".json".length))}`);
2254
+ }
2255
+ return {
2256
+ puts: { [newInboxPath]: utf8(JSON.stringify(envelope)) },
2257
+ deletes: [],
2258
+ };
2259
+ },
2260
+ });
2261
+ return { commitSha, inboxKey, envelope };
2262
+ }
2263
+ /**
2264
+ * Move the FIFO-first inbox entry for `address` to processing.
2265
+ * Returns `null` when the inbox is empty so the caller can
2266
+ * distinguish "nothing to do" from "operation failed".
2267
+ *
2268
+ * FIFO is keyed on the parsed numeric `receivedAt` prefix of the
2269
+ * inbox filename, with a lexicographic messageId tiebreak. The
2270
+ * substrate does NOT rely on uniform digit widths — sorting raw
2271
+ * filenames would put `"100-…"` ahead of `"99-…"` since `'1' < '9'`,
2272
+ * which violates the FIFO invariant.
2273
+ */
2274
+ export async function dequeueToProcessing(store, principal, repoId, address) {
2275
+ const addressSegment = addressSegmentFor(address);
2276
+ const ref = claimCheckCommitRef();
2277
+ let dequeued = null;
2278
+ const { commitSha } = await store.writeTreeDelta(principal, repoId, ref, {
2279
+ changedPathPrefixes: new Set([addressPrefix(addressSegment)]),
2280
+ message: `dequeue ${address}`,
2281
+ computeDelta: async (_parentCommitSha, prior) => {
2282
+ const listing = await readAddressListing(prior, addressSegment);
2283
+ const inboxDir = `${addressPrefix(addressSegment)}${WORKFLOW_RUN_INBOX_DIR}/`;
2284
+ const candidates = [];
2285
+ for (const entry of listing.inbox) {
2286
+ const m = QUEUE_FILENAME_RE.exec(entry.name);
2287
+ if (m === null || m[1] === undefined || m[2] === undefined) {
2288
+ throw new Error(`claim_check_invalid_inbox_filename: ${inboxDir}${entry.name}`);
2289
+ }
2290
+ candidates.push({
2291
+ entry,
2292
+ receivedAt: Number.parseInt(m[1], 10),
2293
+ messageId: m[2],
2294
+ });
2295
+ }
2296
+ candidates.sort((a, b) => {
2297
+ if (a.receivedAt !== b.receivedAt)
2298
+ return a.receivedAt - b.receivedAt;
2299
+ if (a.messageId < b.messageId)
2300
+ return -1;
2301
+ if (a.messageId > b.messageId)
2302
+ return 1;
2303
+ return 0;
2304
+ });
2305
+ const first = candidates[0];
2306
+ if (first === undefined) {
2307
+ // Empty inbox: nothing to move. The commit is a no-op rewrite of
2308
+ // the same tree; the caller reads `dequeued === null`.
2309
+ dequeued = null;
2310
+ return { puts: {}, deletes: [] };
2311
+ }
2312
+ const firstPath = `${inboxDir}${first.entry.name}`;
2313
+ const key = first.entry.name.slice(0, -".json".length);
2314
+ const bytes = await prior.readBlobByOid(first.entry.oid);
2315
+ const envelope = decodeQueueEnvelopeOrThrow(bytes, firstPath);
2316
+ dequeued = { key, envelope };
2317
+ return {
2318
+ puts: { [processingPath(addressSegment, key)]: bytes },
2319
+ deletes: [firstPath],
2320
+ };
2321
+ },
2322
+ });
2323
+ if (dequeued === null)
2324
+ return null;
2325
+ const captured = dequeued;
2326
+ return { commitSha, key: captured.key, envelope: captured.envelope };
2327
+ }
2328
+ /**
2329
+ * Read the processing-queue entry for `messageId` at `address` without
2330
+ * mutating the tree. Returns the decoded claim-check envelope (carrying
2331
+ * `mailAuditRef` and, when the enqueuer inlined them, the base64
2332
+ * `rawMessage` bytes) or `null` when no processing entry exists for the
2333
+ * messageId.
2334
+ *
2335
+ * This is the read half of mailbox ownership (§3a): the supervisor's
2336
+ * dispatch loop moves an inbox entry to processing and forwards a
2337
+ * `trigger.fired{messageId}` to the workflow-process child; the child
2338
+ * calls this to recover the inbound message bytes that become its step
2339
+ * input.
2340
+ *
2341
+ * The read is a flat working-tree read of
2342
+ * `addresses/<seg>/processing/`. The substrate materializes each
2343
+ * claim-check commit's touched paths into the repo's working tree (the
2344
+ * delta write removes each deleted path and writes each put after
2345
+ * validation passes), so a read issued after `dequeueToProcessing`
2346
+ * committed -- which is exactly when the supervisor forwards
2347
+ * `trigger.fired` -- observes the processing entry. Reading the working tree (rather than walking the
2348
+ * committed git tree) matches the workflow-process child's sibling
2349
+ * reads of `workflow.json` and `runs/<runId>/events/`. Because the
2350
+ * read issues no commit it cannot race the supervisor's `markConsumed`
2351
+ * write; it returns a point-in-time snapshot of the directory.
2352
+ */
2353
+ export async function readProcessingEntry(store, _principal, repoId, address, messageId) {
2354
+ const addressSegment = addressSegmentFor(address);
2355
+ const repoDir = store.getRepoDir(repoId);
2356
+ const processingDir = `${repoDir}/${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressSegment}/${WORKFLOW_RUN_PROCESSING_DIR}`;
2357
+ const suffix = `-${messageId}.json`;
2358
+ let filenames;
2359
+ try {
2360
+ filenames = await fs.promises.readdir(processingDir);
2361
+ }
2362
+ catch (cause) {
2363
+ // A missing processing directory is the legitimate "no entry yet"
2364
+ // state; any other failure surfaces.
2365
+ if (cause instanceof Error &&
2366
+ cause.code === "ENOENT") {
2367
+ return null;
2368
+ }
2369
+ throw cause;
2370
+ }
2371
+ for (const filename of filenames) {
2372
+ if (!filename.endsWith(suffix))
2373
+ continue;
2374
+ const blobPath = `${processingDir}/${filename}`;
2375
+ const bytes = await fs.promises.readFile(blobPath);
2376
+ const envelope = decodeQueueEnvelopeOrThrow(new Uint8Array(bytes), blobPath);
2377
+ return { envelope };
2378
+ }
2379
+ return null;
2380
+ }
2381
+ /**
2382
+ * Atomically remove the processing entry for `messageId` at `address`,
2383
+ * write the canonical `consumed/<messageId>.json` dedup index entry,
2384
+ * advance the per-address retention watermark, and prune consumed
2385
+ * entries the watermark has passed. The caller is expected to have
2386
+ * called `dequeueToProcessing` for this messageId; calling
2387
+ * `markConsumed` without a matching processing entry throws.
2388
+ *
2389
+ * The consumed envelope preserves the original `receivedAt` and
2390
+ * `mailAuditRef` from the processing entry so the dedup index doubles
2391
+ * as an audit record.
2392
+ *
2393
+ * Retention (the bounded-`consumed/` contract): the watermark advances
2394
+ * to `max(priorWatermark, min(consumedAt - retentionHorizonMs,
2395
+ * thisEntry.receivedAt))` -- monotonic, and never past the entry being
2396
+ * written so the new entry is always retained. Every consumed entry
2397
+ * whose `receivedAt` is strictly below the new watermark is dropped
2398
+ * (the oldest age-ordered tail). `consumed/` therefore reaches a
2399
+ * bounded steady state of roughly one horizon's worth of entries
2400
+ * instead of growing one entry per message forever.
2401
+ */
2402
+ export async function markConsumed(store, principal, repoId, args) {
2403
+ const addressSegment = addressSegmentFor(args.address);
2404
+ const ref = claimCheckCommitRef();
2405
+ const retentionHorizonMs = args.retentionHorizonMs ?? DEFAULT_CONSUMED_RETENTION_MS;
2406
+ let consumedEnvelope = null;
2407
+ let advancedWatermark = 0;
2408
+ const prunedMessageIds = [];
2409
+ const { commitSha } = await store.writeTreeDelta(principal, repoId, ref, {
2410
+ changedPathPrefixes: new Set([addressPrefix(addressSegment)]),
2411
+ message: `consume ${args.address} ${args.messageId}`,
2412
+ computeDelta: async (_parentCommitSha, prior) => {
2413
+ const listing = await readAddressListing(prior, addressSegment);
2414
+ const consumedFull = consumedPath(addressSegment, args.messageId);
2415
+ const consumedFname = `${args.messageId}.json`;
2416
+ if (listing.consumed.some((e) => e.name === consumedFname)) {
2417
+ throw new Error(`claim_check_already_consumed: ${consumedFull} already in the dedup index`);
2418
+ }
2419
+ const processingDir = `${addressPrefix(addressSegment)}${WORKFLOW_RUN_PROCESSING_DIR}/`;
2420
+ const processingEntry = listing.processing.find((e) => e.name.endsWith(`-${args.messageId}.json`));
2421
+ if (processingEntry === undefined) {
2422
+ throw new Error(`claim_check_processing_not_found: address ${args.address} message ${args.messageId} has no processing entry`);
2423
+ }
2424
+ const processingFull = `${processingDir}${processingEntry.name}`;
2425
+ const processingBytes = await prior.readBlobByOid(processingEntry.oid);
2426
+ const processingEnvelope = decodeQueueEnvelopeOrThrow(processingBytes, processingFull);
2427
+ const envelope = {
2428
+ messageId: args.messageId,
2429
+ receivedAt: processingEnvelope.receivedAt,
2430
+ address: args.address,
2431
+ runId: args.runId,
2432
+ consumedAt: args.consumedAt,
2433
+ mailAuditRef: processingEnvelope.mailAuditRef,
2434
+ };
2435
+ consumedEnvelope = envelope;
2436
+ // The watermark may only advance, and never past the entry this
2437
+ // commit writes (so the new entry is always retained -- a message
2438
+ // consumed long after receipt may legitimately sit below
2439
+ // `consumedAt - horizon`, and it is pruned on a later commit once
2440
+ // the watermark passes ITS receivedAt).
2441
+ const horizonBoundary = args.consumedAt - retentionHorizonMs;
2442
+ const newWatermark = Math.max(listing.watermark, Math.min(horizonBoundary, envelope.receivedAt));
2443
+ advancedWatermark = newWatermark;
2444
+ // Prune the oldest consumed tail: read each retained consumed
2445
+ // entry's receivedAt and drop any that has fallen strictly below
2446
+ // the new watermark. This is the one leg that must scan the
2447
+ // consumed index — its filenames carry only the messageId, so the
2448
+ // receivedAt lives in the bytes — and is the residual the
2449
+ // consumed-shard lever removes. The new entry (added via puts) is
2450
+ // never below the watermark by construction.
2451
+ const consumedDir = `${addressPrefix(addressSegment)}${WORKFLOW_RUN_CONSUMED_DIR}/`;
2452
+ const deletes = [processingFull];
2453
+ for (const entry of listing.consumed) {
2454
+ const blobPath = `${consumedDir}${entry.name}`;
2455
+ const bytes = await prior.readBlobByOid(entry.oid);
2456
+ const consumedReceivedAt = decodeConsumedReceivedAtOrThrow(bytes, blobPath);
2457
+ if (consumedReceivedAt < newWatermark) {
2458
+ prunedMessageIds.push(entry.name.slice(0, -".json".length));
2459
+ deletes.push(blobPath);
2460
+ }
2461
+ }
2462
+ return {
2463
+ puts: {
2464
+ [consumedFull]: utf8(JSON.stringify(envelope)),
2465
+ [watermarkPath(addressSegment)]: utf8(JSON.stringify({ watermark: newWatermark })),
2466
+ },
2467
+ deletes,
2468
+ };
2469
+ },
2470
+ });
2471
+ if (consumedEnvelope === null)
2472
+ throw new Error("unreachable");
2473
+ const captured = consumedEnvelope;
2474
+ return {
2475
+ commitSha,
2476
+ envelope: captured,
2477
+ watermark: advancedWatermark,
2478
+ prunedMessageIds,
2479
+ };
2480
+ }
2481
+ /**
2482
+ * Read the run event logs under `runs/` and return the set of
2483
+ * `consumedMessageId`s belonging to NON-terminal runs -- the messages a
2484
+ * live run still owns. The caller (the supervisor's spawn-time replay)
2485
+ * feeds this into `replayProcessingToInbox`'s `ownedMessageIds` so a
2486
+ * parked run's message is not re-admitted to inbox and dispatched a
2487
+ * second time while the run is recovered by re-driving its durable log.
2488
+ * Without this, the re-drive AND the re-triggered fresh run both re-park
2489
+ * the same awaitSignal gate on the same runId, and the two concurrent
2490
+ * runtime bodies race to a corrupt terminal.
2491
+ *
2492
+ * Reads the substrate's working tree via `getRepoDir`, mirroring the
2493
+ * child's `discoverInFlightRuns`. The working tree tracks the run-event
2494
+ * ref (`refs/heads/main`); the claim-check ref (`refs/heads/events`)
2495
+ * cannot see it, which is why this lives at the caller rather than inside
2496
+ * `replayProcessingToInbox`'s single-ref delta. A run whose log is sealed
2497
+ * (combined `events.json`, only permitted for a terminated run) or
2498
+ * carries a terminal event is excluded; an absent `runs/` directory
2499
+ * yields an empty set.
2500
+ */
2501
+ export async function readOwnedMessageIds(store, repoId) {
2502
+ const fs = await import("node:fs/promises");
2503
+ const path = await import("node:path");
2504
+ const repoDir = store.getRepoDir(repoId);
2505
+ const runsDir = path.join(repoDir, WORKFLOW_RUN_RUNS_PREFIX);
2506
+ let runIds;
2507
+ try {
2508
+ runIds = await fs.readdir(runsDir);
2509
+ }
2510
+ catch (cause) {
2511
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") {
2512
+ return new Set();
2513
+ }
2514
+ throw cause;
2515
+ }
2516
+ const owned = new Set();
2517
+ for (const runId of runIds) {
2518
+ const runDir = path.join(runsDir, runId);
2519
+ // A sealed run (combined events file) is terminal by the handler's
2520
+ // own invariant -- only a terminated run is sealed -- so it owns
2521
+ // nothing. Its presence also means the per-event directory is absent.
2522
+ let sealed = false;
2523
+ try {
2524
+ await fs.access(path.join(runDir, WORKFLOW_RUN_EVENTS_FILE));
2525
+ sealed = true;
2526
+ }
2527
+ catch {
2528
+ sealed = false;
2529
+ }
2530
+ if (sealed)
2531
+ continue;
2532
+ const eventsDir = path.join(runDir, WORKFLOW_RUN_EVENTS_DIR);
2533
+ let files;
2534
+ try {
2535
+ files = await fs.readdir(eventsDir);
2536
+ }
2537
+ catch {
2538
+ continue;
2539
+ }
2540
+ let terminal = false;
2541
+ let consumedMessageId;
2542
+ for (const file of files) {
2543
+ if (!file.endsWith(".json"))
2544
+ continue;
2545
+ let parsed;
2546
+ try {
2547
+ parsed = JSON.parse(await fs.readFile(path.join(eventsDir, file), "utf8"));
2548
+ }
2549
+ catch {
2550
+ continue;
2551
+ }
2552
+ if (typeof parsed !== "object" ||
2553
+ parsed === null ||
2554
+ !("type" in parsed)) {
2555
+ continue;
2556
+ }
2557
+ const type = parsed.type;
2558
+ if (typeof type !== "string")
2559
+ continue;
2560
+ if (TERMINAL_EVENT_TYPES.has(type)) {
2561
+ terminal = true;
2562
+ break;
2563
+ }
2564
+ if (type === "RunStarted" && "consumedMessageId" in parsed) {
2565
+ const mid = parsed.consumedMessageId;
2566
+ if (typeof mid === "string")
2567
+ consumedMessageId = mid;
2568
+ }
2569
+ }
2570
+ if (terminal)
2571
+ continue;
2572
+ if (consumedMessageId !== undefined)
2573
+ owned.add(consumedMessageId);
2574
+ }
2575
+ return owned;
2576
+ }
2577
+ /**
2578
+ * Recovery path: move every processing entry at `address` back to
2579
+ * inbox preserving the original `<receivedAt>-<messageId>` filename
2580
+ * key so FIFO ordering survives a workflow-process crash. Returns
2581
+ * the set of keys that were moved; when nothing was in processing
2582
+ * the returned `replayedKeys` is empty (and the commit is a no-op
2583
+ * rewrite of the same tree).
2584
+ *
2585
+ * The replay is atomic across all processing entries — a partial
2586
+ * replay that left some entries in processing would corrupt the
2587
+ * FIFO discipline (the next dequeue would pull the wrong entry).
2588
+ *
2589
+ * Watermark carve-out (load-bearing — do NOT "tighten" this): the
2590
+ * replay deliberately does NOT apply the `receivedAt < watermark`
2591
+ * stale-reject that `enqueueInbox` applies. A `processing/` entry was
2592
+ * already dequeued past the dedup index, so re-admitting it to
2593
+ * `inbox/` even when its `receivedAt` has fallen below an advanced
2594
+ * watermark is correct — the message is a legitimately in-flight one
2595
+ * recovered after a crash, not a fresh inbound that could be a
2596
+ * duplicate. Applying the stale-reject here would silently LOSE that
2597
+ * message. The watermark only ever gates fresh inbound at the enqueue
2598
+ * boundary; the recovery replay is exempt by design.
2599
+ */
2600
+ export async function replayProcessingToInbox(store, principal, repoId, address, opts = {}) {
2601
+ const addressSegment = addressSegmentFor(address);
2602
+ const ref = claimCheckCommitRef();
2603
+ const ownedMessageIds = opts.ownedMessageIds ?? new Set();
2604
+ const replayedKeys = [];
2605
+ const { commitSha } = await store.writeTreeDelta(principal, repoId, ref, {
2606
+ changedPathPrefixes: new Set([addressPrefix(addressSegment)]),
2607
+ message: `replay processing ${address}`,
2608
+ computeDelta: async (_parentCommitSha, prior) => {
2609
+ const listing = await readAddressListing(prior, addressSegment);
2610
+ const processingDir = `${addressPrefix(addressSegment)}${WORKFLOW_RUN_PROCESSING_DIR}/`;
2611
+ const inboxDir = `${addressPrefix(addressSegment)}${WORKFLOW_RUN_INBOX_DIR}/`;
2612
+ const inboxNames = new Set(listing.inbox.map((e) => e.name));
2613
+ const puts = {};
2614
+ const deletes = [];
2615
+ for (const entry of listing.processing) {
2616
+ const bytes = await prior.readBlobByOid(entry.oid);
2617
+ // A processing entry whose run is still live (non-terminal durable
2618
+ // log) is owned by the recovery re-drive of that same run. Re-
2619
+ // admitting the message to inbox would dispatch a SECOND run for
2620
+ // it, colliding with the re-drive on the shared runId. Leave such
2621
+ // an entry in processing untouched; the run's eventual
2622
+ // `markConsumed` clears it. Only genuinely orphaned entries (no
2623
+ // run, or a terminal run) are replayed. The run logs live on a
2624
+ // different ref, so the caller precomputes the owned set.
2625
+ const envelope = decodeQueueEnvelopeOrThrow(bytes, `${processingDir}${entry.name}`);
2626
+ if (ownedMessageIds.has(envelope.messageId)) {
2627
+ continue;
2628
+ }
2629
+ const inboxFull = `${inboxDir}${entry.name}`;
2630
+ if (inboxNames.has(entry.name)) {
2631
+ throw new Error(`claim_check_replay_collision: ${inboxFull} already exists; cannot replay processing entry`);
2632
+ }
2633
+ // Re-admit the in-flight entry WITHOUT the watermark stale-reject
2634
+ // enqueueInbox applies: it was already past dedup, so a
2635
+ // below-watermark receivedAt is no reason to refuse it. Applying
2636
+ // the stale-check here would lose a legitimately in-flight
2637
+ // message after a crash. Do not tighten this.
2638
+ puts[inboxFull] = bytes;
2639
+ deletes.push(`${processingDir}${entry.name}`);
2640
+ replayedKeys.push(entry.name.slice(0, -".json".length));
2641
+ }
2642
+ return { puts, deletes };
2643
+ },
2644
+ });
2645
+ return { commitSha, replayedKeys };
2646
+ }