@shardworks/clerk-apparatus 0.1.222 → 0.1.224

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.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # `@shardworks/clerk-apparatus`
2
2
 
3
- The Clerk manages the lifecycle of **writs** — lightweight work orders that flow through a fixed status machine. Writs are created as commissions and ultimately completed, failed, or cancelled. Writs may also enter a `stuck` state when their rig encounters an engine failure — a non-terminal "needs attention" status that preserves the obligation for future retry. Writs can be organized into parent/child hierarchies for decomposing complex work.
3
+ The Clerk manages the lifecycle of **writs** — lightweight work orders that flow through a fixed phase machine. Writs are created as commissions and ultimately completed, failed, or cancelled. Writs may also enter a `stuck` phase when their rig encounters an engine failure — a non-terminal "needs attention" phase that preserves the obligation for future retry. Writs can be organized into parent/child hierarchies for decomposing complex work.
4
+
5
+ Writ documents follow a Kubernetes-style spec/status split: **`phase`** is the Clerk-owned lifecycle state (the phase machine below), and **`status`** is a plugin-owned observation slot keyed by plugin id — a place for apparatuses like Spider to record side-channel observations (last rig, stuck cause, progress ratchets) without mutating the phase. See [Spec/Status Convention](#specstatus-convention) below.
4
6
 
5
7
  The Clerk sits downstream of The Stacks: `stacks ← clerk`.
6
8
 
@@ -32,7 +34,7 @@ const clerk = guild().apparatus<ClerkApi>('clerk');
32
34
 
33
35
  ### `post(request): Promise<WritDoc>`
34
36
 
35
- Post a new commission, creating a writ in `open` status.
37
+ Post a new commission, creating a writ in `open` phase.
36
38
 
37
39
  ```typescript
38
40
  const writ = await clerk.post({
@@ -53,7 +55,7 @@ const writ = await clerk.post({
53
55
  | `parentId` | `string` | Parent writ id for hierarchical decomposition (optional) |
54
56
 
55
57
  When `parentId` is provided:
56
- - The parent must exist and be in `new`, `open`, or `stuck` status.
58
+ - The parent must exist and be in `new`, `open`, or `stuck` phase.
57
59
  - The child inherits the parent's `codex` if no explicit codex is provided.
58
60
  - The entire operation is atomic.
59
61
 
@@ -68,13 +70,13 @@ Show a writ by id. Throws if not found.
68
70
  List writs with optional filters, ordered by `createdAt` descending (newest first).
69
71
 
70
72
  ```typescript
71
- const openWrits = await clerk.list({ status: 'open', limit: 10 });
73
+ const openWrits = await clerk.list({ phase: 'open', limit: 10 });
72
74
  const children = await clerk.list({ parentId: parent.id });
73
75
  ```
74
76
 
75
77
  | Filter | Type | Description |
76
78
  |---|---|---|
77
- | `status` | `WritStatus \| WritStatus[]` | Filter by status (single or array — multiple values OR together) |
79
+ | `phase` | `WritPhase \| WritPhase[]` | Filter by phase (single or array — multiple values OR together) |
78
80
  | `type` | `string` | Filter by writ type |
79
81
  | `parentId` | `string` | Filter to children of this parent writ |
80
82
  | `limit` | `number` | Maximum results (default: 20) |
@@ -85,12 +87,12 @@ const children = await clerk.list({ parentId: parent.id });
85
87
  Count writs matching optional filters. Accepts the same filters as `list()` (except `limit` and `offset`).
86
88
 
87
89
  ```typescript
88
- const total = await clerk.count({ status: 'open' });
90
+ const total = await clerk.count({ phase: 'open' });
89
91
  ```
90
92
 
91
93
  ### `listWritTypes(): WritTypeInfo[]`
92
94
 
93
- List all registered writ types with full metadata — descriptions, source tracking, and default status.
95
+ List all registered writ types with full metadata — descriptions, source tracking, and default flag.
94
96
 
95
97
  ```typescript
96
98
  const types = clerk.listWritTypes();
@@ -174,11 +176,11 @@ const edited = await clerk.edit({
174
176
  | `type` | `string` | New writ type — must be declared or built-in (optional) |
175
177
  | `codex` | `string` | New target codex name; empty string clears it (optional) |
176
178
 
177
- At least one field besides `id` must be provided. Title and body can be edited in any status. Type and codex can only be changed while the writ is in `new` (draft) status.
179
+ At least one field besides `id` must be provided. Title and body can be edited in any phase. Type and codex can only be changed while the writ is in `new` (draft) phase.
178
180
 
179
181
  ### `transition(id, to, fields?): Promise<WritDoc>`
180
182
 
181
- Transition a writ to a new status, optionally setting additional fields atomically.
183
+ Transition a writ to a new phase, optionally setting additional fields atomically.
182
184
 
183
185
  ```typescript
184
186
  // Complete with resolution
@@ -191,13 +193,31 @@ await clerk.transition(id, 'failed', { resolution: 'Build pipeline broke' });
191
193
  await clerk.transition(id, 'cancelled', { resolution: 'No longer needed' });
192
194
  ```
193
195
 
194
- Throws if the transition is not legal for the writ's current status.
196
+ Throws if the transition is not legal for the writ's current phase.
197
+
198
+ `transition()` silently strips the phase machine's managed fields from the body — `id`, `phase`, `status`, `createdAt`, `updatedAt`, `resolvedAt`, and `parentId`. The observation slot `status` is writable only via `setWritStatus()` (the one sanctioned slot-write path), which performs a transactional read-modify-write on the sub-slot keyed by `pluginId` so sibling sub-slots are preserved under concurrent writers. See [Spec/Status Convention](#specstatus-convention).
195
199
 
196
200
  **Cascade behavior:** When a writ with children transitions to `failed` or `cancelled`, all non-terminal children are automatically cancelled with resolution `'Automatically cancelled due to parent termination'` (exported as `CASCADE_PARENT_TERMINATION_RESOLUTION`). When a parent transitions to `completed`, non-terminal children are **not** cancelled — instead a warning is logged (their existence indicates an upstream bookkeeping gap). When a child fails and its parent is `open` or `stuck`, the parent is failed and remaining siblings are cancelled.
197
201
 
202
+ ### `setWritStatus(writId, pluginId, value): Promise<WritDoc>`
203
+
204
+ Write (or overwrite) a plugin-owned sub-slot inside the writ's observation `status` map. The `status` field on `WritDoc` is a `Record<string, unknown>` keyed by plugin id — each plugin reads and writes under its own key.
205
+
206
+ ```typescript
207
+ // Spider records stuck cause
208
+ await clerk.setWritStatus(writ.id, 'spider', { stuckCause: 'engine-failed', lastRig: 'rig-01' });
209
+
210
+ // Astrolabe records a progress ratchet in the same writ — does not clobber spider's slot.
211
+ await clerk.setWritStatus(writ.id, 'astrolabe', { planVersion: 3 });
212
+ ```
213
+
214
+ The write runs in a Stacks transaction (read-modify-write), so concurrent writes from different plugins to different sub-slots are disjoint and safe. The slot is not cleared on terminal transitions — observations persist for post-mortem analysis. See [Spec/Status Convention](#specstatus-convention) for conventions and a worked example.
215
+
216
+ Throws if `writId` or `pluginId` is missing, or if the writ does not exist.
217
+
198
218
  ---
199
219
 
200
- ## Status Machine
220
+ ## Phase Machine
201
221
 
202
222
  ```
203
223
  new ──► open ──┬──► completed
@@ -215,7 +235,7 @@ new ──► open ──┬──► completed
215
235
  ```
216
236
 
217
237
  - `completed`, `failed`, and `cancelled` are **terminal** — no transitions out.
218
- - `stuck` is **non-terminal** — a "needs attention" state for writs whose rig hit an engine failure. Recovery (future retry) transitions back to `open`; giving up transitions to `failed` or `cancelled`.
238
+ - `stuck` is **non-terminal** — a "needs attention" phase for writs whose rig hit an engine failure. Recovery (future retry) transitions back to `open`; giving up transitions to `failed` or `cancelled`.
219
239
 
220
240
  ### Allowed transitions
221
241
 
@@ -233,7 +253,7 @@ new ──► open ──┬──► completed
233
253
 
234
254
  Writs can be organized into parent/child relationships for decomposing complex work:
235
255
 
236
- - **Creating children:** Pass `parentId` to `post()`. The parent stays in its current status. Parents in `new`, `open`, or `stuck` status accept children.
256
+ - **Creating children:** Pass `parentId` to `post()`. The parent stays in its current phase. Parents in `new`, `open`, or `stuck` phase accept children.
237
257
  - **Failure cascade:** When a child fails and the parent is `open` or `stuck`, the parent is failed and remaining non-terminal siblings are cancelled.
238
258
  - **Cancellation cascade:** When a parent reaches `failed` or `cancelled`, all non-terminal children are cancelled. When a parent reaches `completed` with non-terminal children still present, the Clerk logs a warning and leaves the children alone — this signals an upstream bookkeeping gap rather than normal flow.
239
259
  - **Codex inheritance:** Children inherit the parent's codex if none is specified.
@@ -241,6 +261,54 @@ Writs can be organized into parent/child relationships for decomposing complex w
241
261
 
242
262
  ---
243
263
 
264
+ ## Spec/Status Convention
265
+
266
+ Clerk follows a Kubernetes-style spec/status split:
267
+
268
+ - **Spec fields** are the declared intent of the writ — `title`, `body`, `type`, `codex`, `parentId`, and the Clerk-owned lifecycle field `phase`. These describe *what should happen* and *where the writ currently sits on the phase machine*.
269
+ - **Status slot** (the `status` field on `WritDoc`) is a `Record<string, unknown>` — a free-form observation map keyed by plugin id. Each plugin owns one sub-slot and uses it to record side-channel observations about the writ: last rig used, stuck cause, progress ratchets, planner version, etc.
270
+
271
+ The slot is a soft convention rather than a hard enforcement boundary:
272
+
273
+ - No runtime guard stops a plugin from reading another plugin's sub-slot — the convention is *write only your own key*, and the `setWritStatus()` API makes the right thing easy.
274
+ - **One sanctioned slot-write path.** The observation slot is writable only via `setWritStatus(writId, pluginId, value)`, which performs a transactional read-modify-write on the sub-slot keyed by `pluginId` so sibling sub-slots are preserved under concurrent writers. `transition()` silently strips `status` from its body alongside the other managed fields (`id`, `phase`, `createdAt`, `updatedAt`, `resolvedAt`, `parentId`). The generic `put()` / `patch()` paths on the `clerk/writs` book are not supported slot-write mechanisms — every route other than `setWritStatus()` would wholesale-replace the slot and clobber sibling sub-slots.
275
+ - Concurrent writes from different plugins to different sub-slots are disjoint and safe: `setWritStatus()` runs its read-modify-write inside a Stacks transaction.
276
+ - Within a single plugin's sub-slot, concurrent writes are last-writer-wins at the sub-slot level — `setWritStatus()` replaces the plugin's sub-slot value wholesale. Per-key atomicity inside a sub-slot is deferred until real contention appears.
277
+ - Slot writes emit CDC events like any other field change. Downstream observers (page renderers, audits, further observation pipelines) can watch the writs book for `update` events and react to the new `status` contents.
278
+ - Terminal transitions do **not** clear the slot. Observations persist on the writ for post-mortem inspection.
279
+
280
+ ### Worked example: `status.spider.stuckCause`
281
+
282
+ When Spider's engine fails for a writ, it transitions the writ to `stuck` (a phase change) *and* records the diagnostic cause in its sub-slot (an observation):
283
+
284
+ ```typescript
285
+ // In Spider's engine-failure handler:
286
+ await clerk.transition(writ.id, 'stuck', { resolution: 'Engine "implement-loop" failed' });
287
+ await clerk.setWritStatus(writ.id, 'spider', {
288
+ stuckCause: 'engine-failed',
289
+ lastRig: rig.id,
290
+ failedEngine: 'implement-loop',
291
+ });
292
+
293
+ // Later, a human or tool reads the slot to triage:
294
+ const writ = await clerk.show(writId);
295
+ const spiderStatus = (writ.status ?? {})['spider'] as
296
+ | { stuckCause?: string; lastRig?: string; failedEngine?: string }
297
+ | undefined;
298
+
299
+ if (spiderStatus?.stuckCause === 'engine-failed') {
300
+ console.log(`Stuck in rig ${spiderStatus.lastRig} at engine ${spiderStatus.failedEngine}`);
301
+ }
302
+ ```
303
+
304
+ The phase (`stuck`) is the authoritative lifecycle state — queries, cascades, and the phase machine all reason from it. The observation (`status.spider.stuckCause`) is diagnostic context that survives alongside the phase without becoming part of the state machine itself.
305
+
306
+ ### Guild-wide extensibility
307
+
308
+ The spec/status split is guild-wide in intent, not a writs-only pattern. Other runtime objects — **rigs**, **engines**, **sessions**, **input requests**, **clicks**, and future apparatuses' primary objects — will adopt the same split on a per-consumer basis: the owning apparatus keeps the lifecycle field (renaming its current `status` to `phase` when the time comes), and a new plugin-keyed `status: Record<string, unknown>` slot appears when the first observation-slot consumer materializes. Until that trigger arrives, those objects keep their existing `status` field unchanged — the convention rolls out one object at a time, not in a big-bang migration. When you author a new apparatus whose primary object may gain observations, reach for the spec/status shape from day one to avoid the rename later.
309
+
310
+ ---
311
+
244
312
  ## Configuration
245
313
 
246
314
  Configure The Clerk under the `"clerk"` key in your guild config:
@@ -276,7 +344,7 @@ The Clerk contributes books, tools, and pages to the guild:
276
344
 
277
345
  | Book | Indexes | Contents |
278
346
  |---|---|---|
279
- | `writs` | `status`, `type`, `createdAt`, `parentId`, `[status, type]`, `[status, createdAt]`, `[parentId, status]` | Writ documents |
347
+ | `writs` | `phase`, `type`, `createdAt`, `parentId`, `[phase, type]`, `[phase, createdAt]`, `[parentId, phase]` | Writ documents |
280
348
  | `links` | `sourceId`, `targetId`, `label`, `[sourceId, label]`, `[targetId, label]` | Writ relationship links |
281
349
 
282
350
  ### Tools
@@ -285,8 +353,8 @@ The Clerk contributes books, tools, and pages to the guild:
285
353
  |---|---|---|
286
354
  | `commission-post` | `clerk:write` | Post a new commission (create a writ, optionally as child) |
287
355
  | `writ-show` | `clerk:read` | Show full detail for a writ (includes parent/children context) |
288
- | `writ-list` | `clerk:read` | List writs with optional filters (status, type, parentId) |
289
- | `writ-edit` | `clerk:write` | Edit a writ (title/body any status; type/codex draft only) |
356
+ | `writ-list` | `clerk:read` | List writs with optional filters (phase, type, parentId) |
357
+ | `writ-edit` | `clerk:write` | Edit a writ (title/body any phase; type/codex draft only) |
290
358
  | `writ-complete` | `clerk:write` | Complete a writ (open → completed) |
291
359
  | `writ-fail` | `clerk:write` | Fail a writ (open/stuck → failed) |
292
360
  | `writ-cancel` | `clerk:write` | Cancel a writ (new/open/stuck → cancelled) |
@@ -302,12 +370,12 @@ The Clerk contributes books, tools, and pages to the guild:
302
370
  ## Key Types
303
371
 
304
372
  ```typescript
305
- type WritStatus = 'new' | 'open' | 'stuck' | 'completed' | 'failed' | 'cancelled';
373
+ type WritPhase = 'new' | 'open' | 'stuck' | 'completed' | 'failed' | 'cancelled';
306
374
 
307
375
  interface WritDoc {
308
376
  id: string; // ULID-like, prefixed "w-"
309
377
  type: string; // declared or built-in type
310
- status: WritStatus;
378
+ phase: WritPhase; // Clerk-owned lifecycle state
311
379
  title: string;
312
380
  body: string;
313
381
  codex?: string; // target codex name
@@ -316,6 +384,7 @@ interface WritDoc {
316
384
  updatedAt: string; // ISO timestamp, updated on every mutation
317
385
  resolvedAt?: string; // ISO timestamp, set on any terminal transition
318
386
  resolution?: string; // summary of how the writ resolved
387
+ status?: Record<string, unknown>; // plugin-owned observation slot (keyed by plugin id)
319
388
  }
320
389
 
321
390
  interface PostCommissionRequest {
@@ -335,7 +404,7 @@ interface EditWritRequest {
335
404
  }
336
405
 
337
406
  interface WritFilters {
338
- status?: WritStatus | WritStatus[];
407
+ phase?: WritPhase | WritPhase[];
339
408
  type?: string;
340
409
  parentId?: string; // filter to children of this parent
341
410
  limit?: number;
package/dist/clerk.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * The Clerk — writ lifecycle management apparatus.
3
3
  *
4
4
  * The Clerk manages the lifecycle of writs: lightweight work orders that flow
5
- * through a fixed status machine (new → open → completed/failed/cancelled,
5
+ * through a fixed phase machine (new → open → completed/failed/cancelled,
6
6
  * with stuck as a non-terminal "needs attention" state off open).
7
7
  * Each writ has a type, a title, a body, and optional codex and resolution
8
8
  * fields.
@@ -31,7 +31,7 @@ export interface ClerkKit {
31
31
  /**
32
32
  * Resolution string applied to non-terminal children that are cancelled by
33
33
  * the downward cascade when their parent transitions to a terminal failure
34
- * or cancellation status. Single source of truth — referenced by code,
34
+ * or cancellation phase. Single source of truth — referenced by code,
35
35
  * tests, and documentation. Modeled on `PIECE_EXECUTION_EPILOGUE` in the
36
36
  * Spider plugin.
37
37
  */
@@ -1 +1 @@
1
- {"version":3,"file":"clerk.d.ts","sourceRoot":"","sources":["../src/clerk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,wBAAwB,CAAC;AAKrE,OAAO,KAAK,EAWV,aAAa,EACb,SAAS,EAEV,MAAM,YAAY,CAAC;AAsBpB,mEAAmE;AACnE,MAAM,WAAW,QAAQ;IACvB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,EAAE,CAAC;CACzB;AAQD;;;;;;GAMG;AACH,eAAO,MAAM,qCAAqC,sDACG,CAAC;AAoBtD,wBAAgB,WAAW,IAAI,MAAM,CA2sBpC"}
1
+ {"version":3,"file":"clerk.d.ts","sourceRoot":"","sources":["../src/clerk.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,wBAAwB,CAAC;AAKrE,OAAO,KAAK,EAWV,aAAa,EACb,SAAS,EAEV,MAAM,YAAY,CAAC;AAsBpB,mEAAmE;AACnE,MAAM,WAAW,QAAQ;IACvB,+EAA+E;IAC/E,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,EAAE,CAAC;CACzB;AAQD;;;;;;GAMG;AACH,eAAO,MAAM,qCAAqC,sDACG,CAAC;AAoBtD,wBAAgB,WAAW,IAAI,MAAM,CAiyBpC"}
package/dist/clerk.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * The Clerk — writ lifecycle management apparatus.
3
3
  *
4
4
  * The Clerk manages the lifecycle of writs: lightweight work orders that flow
5
- * through a fixed status machine (new → open → completed/failed/cancelled,
5
+ * through a fixed phase machine (new → open → completed/failed/cancelled,
6
6
  * with stuck as a non-terminal "needs attention" state off open).
7
7
  * Each writ has a type, a title, a body, and optional codex and resolution
8
8
  * fields.
@@ -23,12 +23,12 @@ const BUILTIN_TYPES = new Set(['mandate']);
23
23
  /**
24
24
  * Resolution string applied to non-terminal children that are cancelled by
25
25
  * the downward cascade when their parent transitions to a terminal failure
26
- * or cancellation status. Single source of truth — referenced by code,
26
+ * or cancellation phase. Single source of truth — referenced by code,
27
27
  * tests, and documentation. Modeled on `PIECE_EXECUTION_EPILOGUE` in the
28
28
  * Spider plugin.
29
29
  */
30
30
  export const CASCADE_PARENT_TERMINATION_RESOLUTION = 'Automatically cancelled due to parent termination';
31
- // ── Status machine ───────────────────────────────────────────────────
31
+ // ── Phase machine ────────────────────────────────────────────────────
32
32
  const ALLOWED_FROM = {
33
33
  open: ['new', 'stuck'],
34
34
  stuck: ['open'],
@@ -37,10 +37,10 @@ const ALLOWED_FROM = {
37
37
  cancelled: ['new', 'open', 'stuck'],
38
38
  new: [],
39
39
  };
40
- const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
40
+ const TERMINAL_PHASES = new Set(['completed', 'failed', 'cancelled']);
41
41
  // ── Factory ──────────────────────────────────────────────────────────
42
- /** Parent statuses that allow adding children. */
43
- const CHILD_ALLOWED_PARENT_STATUSES = new Set(['new', 'open', 'stuck']);
42
+ /** Parent phases that allow adding children. */
43
+ const CHILD_ALLOWED_PARENT_PHASES = new Set(['new', 'open', 'stuck']);
44
44
  export function createClerk() {
45
45
  let stacks;
46
46
  let writs;
@@ -71,13 +71,13 @@ export function createClerk() {
71
71
  }
72
72
  function buildWhereClause(filters) {
73
73
  const conditions = [];
74
- if (filters?.status) {
75
- const statuses = Array.isArray(filters.status) ? filters.status : [filters.status];
76
- if (statuses.length === 1) {
77
- conditions.push(['status', '=', statuses[0]]);
74
+ if (filters?.phase) {
75
+ const phases = Array.isArray(filters.phase) ? filters.phase : [filters.phase];
76
+ if (phases.length === 1) {
77
+ conditions.push(['phase', '=', phases[0]]);
78
78
  }
79
- else if (statuses.length > 1) {
80
- conditions.push(['status', 'IN', statuses]);
79
+ else if (phases.length > 1) {
80
+ conditions.push(['phase', 'IN', phases]);
81
81
  }
82
82
  }
83
83
  if (filters?.type) {
@@ -178,13 +178,13 @@ export function createClerk() {
178
178
  // Wrap in a transaction for atomicity
179
179
  return stacks.transaction(async (tx) => {
180
180
  const txWrits = tx.book('clerk', 'writs');
181
- // Validate parent exists and is in an allowed status
181
+ // Validate parent exists and is in an allowed phase
182
182
  const parent = await txWrits.get(request.parentId);
183
183
  if (!parent) {
184
184
  throw new Error(`Parent writ "${request.parentId}" not found.`);
185
185
  }
186
- if (!CHILD_ALLOWED_PARENT_STATUSES.has(parent.status)) {
187
- throw new Error(`Cannot add children to writ "${request.parentId}": status is "${parent.status}", expected one of: ${[...CHILD_ALLOWED_PARENT_STATUSES].join(', ')}.`);
186
+ if (!CHILD_ALLOWED_PARENT_PHASES.has(parent.phase)) {
187
+ throw new Error(`Cannot add children to writ "${request.parentId}": phase is "${parent.phase}", expected one of: ${[...CHILD_ALLOWED_PARENT_PHASES].join(', ')}.`);
188
188
  }
189
189
  // Defensive self-parenting check
190
190
  if (request.parentId === childId) {
@@ -197,7 +197,7 @@ export function createClerk() {
197
197
  const writ = {
198
198
  id: childId,
199
199
  type,
200
- status: request.draft === true ? 'new' : 'open',
200
+ phase: request.draft === true ? 'new' : 'open',
201
201
  title: request.title,
202
202
  body: request.body,
203
203
  ...(codex !== undefined ? { codex } : {}),
@@ -212,7 +212,7 @@ export function createClerk() {
212
212
  const writ = {
213
213
  id: childId,
214
214
  type,
215
- status: request.draft === true ? 'new' : 'open',
215
+ phase: request.draft === true ? 'new' : 'open',
216
216
  title: request.title,
217
217
  body: request.body,
218
218
  ...(codex !== undefined ? { codex } : {}),
@@ -348,12 +348,12 @@ export function createClerk() {
348
348
  throw new Error(`Writ "${request.id}" not found.`);
349
349
  }
350
350
  // Type and codex can only be changed while the writ is still a draft
351
- if (writ.status !== 'new') {
351
+ if (writ.phase !== 'new') {
352
352
  if (request.type !== undefined) {
353
- throw new Error(`Cannot change type on writ "${request.id}": status is "${writ.status}". Type can only be changed while the writ is in "new" status.`);
353
+ throw new Error(`Cannot change type on writ "${request.id}": phase is "${writ.phase}". Type can only be changed while the writ is in "new" phase.`);
354
354
  }
355
355
  if (request.codex !== undefined) {
356
- throw new Error(`Cannot change codex on writ "${request.id}": status is "${writ.status}". Codex can only be changed while the writ is in "new" status.`);
356
+ throw new Error(`Cannot change codex on writ "${request.id}": phase is "${writ.phase}". Codex can only be changed while the writ is in "new" phase.`);
357
357
  }
358
358
  }
359
359
  // Validate type if provided
@@ -389,31 +389,67 @@ export function createClerk() {
389
389
  throw new Error(`Writ "${id}" not found.`);
390
390
  }
391
391
  const allowedFrom = ALLOWED_FROM[to];
392
- if (!allowedFrom.includes(writ.status)) {
393
- throw new Error(`Cannot transition writ "${id}" to "${to}": status is "${writ.status}", expected one of: ${allowedFrom.join(', ')}.`);
392
+ if (!allowedFrom.includes(writ.phase)) {
393
+ throw new Error(`Cannot transition writ "${id}" to "${to}": phase is "${writ.phase}", expected one of: ${allowedFrom.join(', ')}.`);
394
394
  }
395
395
  const now = new Date().toISOString();
396
- const isTerminal = TERMINAL_STATUSES.has(to);
397
- // Strip managed fields — callers cannot override id, status, or timestamps
398
- // controlled by the status machine.
399
- const { id: _id, status: _status, createdAt: _c, updatedAt: _u, resolvedAt: _r, parentId: _p, ...safeFields } = (fields ?? {});
396
+ const isTerminal = TERMINAL_PHASES.has(to);
397
+ // Strip managed fields — callers cannot override id, phase, the
398
+ // plugin-owned observation slot `status`, or timestamps controlled
399
+ // by the phase machine.
400
+ //
401
+ // The observation slot is a plugin-keyed map (`Record<PluginId,
402
+ // unknown>`) whose sub-slots are owned by different plugins. The
403
+ // only slot-write path that preserves sibling sub-slots under
404
+ // concurrent writers is ClerkApi.setWritStatus(writId, pluginId,
405
+ // value), which performs a transactional read-modify-write on the
406
+ // sub-slot keyed by pluginId. Because patch() is a top-level
407
+ // shallow merge, a `status` value smuggled through transition()
408
+ // would wholesale-replace the slot and silently clobber sibling
409
+ // sub-slots — so `status` is silently dropped here alongside the
410
+ // other managed fields. There is exactly one sanctioned slot-write
411
+ // path, and it is setWritStatus().
412
+ const { id: _id, phase: _phase, status: _status, createdAt: _c, updatedAt: _u, resolvedAt: _r, parentId: _p, ...safeFields } = (fields ?? {});
400
413
  const patch = {
401
- status: to,
414
+ phase: to,
402
415
  updatedAt: now,
403
416
  ...(isTerminal ? { resolvedAt: now } : {}),
404
417
  ...safeFields,
405
418
  };
406
419
  return writs.patch(id, patch);
407
420
  },
421
+ async setWritStatus(writId, pluginId, value) {
422
+ if (!writId) {
423
+ throw new Error('setWritStatus: writId is required.');
424
+ }
425
+ if (!pluginId) {
426
+ throw new Error('setWritStatus: pluginId is required.');
427
+ }
428
+ // Read-modify-write in a single transaction so we do not clobber
429
+ // sibling sub-slots written concurrently by other plugins.
430
+ return stacks.transaction(async (tx) => {
431
+ const txWrits = tx.book('clerk', 'writs');
432
+ const existing = await txWrits.get(writId);
433
+ if (!existing) {
434
+ throw new Error(`Writ "${writId}" not found.`);
435
+ }
436
+ const prevStatus = (existing.status ?? {});
437
+ const nextStatus = { ...prevStatus, [pluginId]: value };
438
+ return txWrits.patch(writId, {
439
+ status: nextStatus,
440
+ updatedAt: new Date().toISOString(),
441
+ });
442
+ });
443
+ },
408
444
  };
409
445
  // ── CDC cascade handlers ─────────────────────────────────────────
410
446
  async function handleChildTerminal(child) {
411
447
  if (!child.parentId)
412
448
  return;
413
449
  const parent = await writs.get(child.parentId);
414
- if (!parent || (parent.status !== 'open' && parent.status !== 'stuck'))
450
+ if (!parent || (parent.phase !== 'open' && parent.phase !== 'stuck'))
415
451
  return;
416
- if (child.status === 'failed') {
452
+ if (child.phase === 'failed') {
417
453
  const childResolution = child.resolution ?? 'unknown';
418
454
  await api.transition(parent.id, 'failed', {
419
455
  resolution: `Child "${child.id}" failed: ${childResolution}`,
@@ -424,18 +460,18 @@ export function createClerk() {
424
460
  const children = await writs.find({ where: [['parentId', '=', parent.id]] });
425
461
  if (children.length === 0)
426
462
  return;
427
- const nonTerminalChildren = children.filter((c) => !TERMINAL_STATUSES.has(c.status));
463
+ const nonTerminalChildren = children.filter((c) => !TERMINAL_PHASES.has(c.phase));
428
464
  if (nonTerminalChildren.length === 0)
429
465
  return;
430
466
  // When the parent reached `completed`, non-terminal children shouldn't
431
467
  // exist — their presence indicates an upstream bookkeeping gap (e.g. a
432
468
  // child-writ transition lost a race). Warn loudly rather than masking
433
469
  // the discrepancy by cancelling.
434
- if (parent.status === 'completed') {
470
+ if (parent.phase === 'completed') {
435
471
  for (const child of nonTerminalChildren) {
436
472
  console.warn(`[clerk] Parent writ "${parent.id}" transitioned to "completed" but ` +
437
- `child writ "${child.id}" is still in non-terminal status ` +
438
- `"${child.status}". Leaving the child as-is; this indicates an ` +
473
+ `child writ "${child.id}" is still in non-terminal phase ` +
474
+ `"${child.phase}". Leaving the child as-is; this indicates an ` +
439
475
  `upstream bookkeeping gap that should be investigated.`);
440
476
  }
441
477
  return;
@@ -468,7 +504,7 @@ export function createClerk() {
468
504
  supportKit: {
469
505
  books: {
470
506
  writs: {
471
- indexes: ['status', 'type', 'createdAt', 'parentId', ['status', 'type'], ['status', 'createdAt'], ['parentId', 'status']],
507
+ indexes: ['phase', 'type', 'createdAt', 'parentId', ['phase', 'type'], ['phase', 'createdAt'], ['parentId', 'phase']],
472
508
  },
473
509
  links: {
474
510
  indexes: ['sourceId', 'targetId', 'label', ['sourceId', 'label'], ['targetId', 'label']],
@@ -526,32 +562,72 @@ export function createClerk() {
526
562
  return;
527
563
  const writ = event.entry;
528
564
  const prev = event.prev;
529
- // Only act on status changes
530
- if (writ.status === prev.status)
565
+ // Only act on phase changes
566
+ if (writ.phase === prev.phase)
531
567
  return;
532
568
  // ── Upward cascade: child → parent ──
533
- if (writ.parentId && TERMINAL_STATUSES.has(writ.status)) {
569
+ if (writ.parentId && TERMINAL_PHASES.has(writ.phase)) {
534
570
  await handleChildTerminal(writ);
535
571
  }
536
572
  // ── Downward cascade: parent → children ──
537
- if (TERMINAL_STATUSES.has(writ.status)) {
573
+ if (TERMINAL_PHASES.has(writ.phase)) {
538
574
  await handleParentTerminal(writ);
539
575
  }
540
576
  }, { failOnError: true });
541
- // ── One-shot migration: collapse legacy statuses to 'open' ──
577
+ // ── One-shot migration: rename `status` `phase`, subsume legacy values ──
542
578
  // Safe to run inside start(): stacks only seals the CDC registry
543
579
  // at phase:started (after every apparatus has started), so these
544
580
  // writes don't lock out downstream apparatuses that register
545
581
  // watchers in their own start().
546
- const legacyStatuses = ['ready', 'active', 'waiting'];
547
- for (const oldStatus of legacyStatuses) {
548
- const found = await writs.find({ where: [['status', '=', oldStatus]] });
549
- for (const writ of found) {
550
- await writs.patch(writ.id, {
551
- status: 'open',
552
- updatedAt: new Date().toISOString(),
553
- });
582
+ //
583
+ // Every pre-rename row carries its lifecycle value in `status`.
584
+ // Post-rename rows carry `phase` instead, and `status` becomes the
585
+ // plugin-owned observation slot. We iterate every row, compute a
586
+ // clean post-rename document, and `put()` it back — `patch()` can't
587
+ // remove a field, so the full rewrite is required.
588
+ //
589
+ // Legacy lifecycle values (`ready`, `active`, `waiting`) are
590
+ // collapsed into `open` along the way (this subsumes the older
591
+ // `legacyStatuses` migration). Any unrecognized value aborts
592
+ // startup — unknown phase is a data-integrity issue.
593
+ const LEGACY_COLLAPSE = {
594
+ ready: 'open',
595
+ active: 'open',
596
+ waiting: 'open',
597
+ };
598
+ const VALID_PHASES = new Set([
599
+ 'new', 'open', 'stuck', 'completed', 'failed', 'cancelled',
600
+ ]);
601
+ const allWrits = await writs.find({});
602
+ for (const row of allWrits) {
603
+ // Already migrated — skip (idempotent).
604
+ if (typeof row.phase === 'string')
605
+ continue;
606
+ const legacyStatus = row.status;
607
+ if (typeof legacyStatus !== 'string') {
608
+ throw new Error(`[clerk] Migration: writ "${row.id}" has neither \`phase\` nor a string \`status\` field; cannot migrate (got ${legacyStatus === undefined ? 'undefined' : typeof legacyStatus}).`);
609
+ }
610
+ let nextPhase;
611
+ if (LEGACY_COLLAPSE[legacyStatus]) {
612
+ nextPhase = LEGACY_COLLAPSE[legacyStatus];
613
+ }
614
+ else if (VALID_PHASES.has(legacyStatus)) {
615
+ nextPhase = legacyStatus;
616
+ }
617
+ else {
618
+ throw new Error(`[clerk] Migration: writ "${row.id}" has unrecognized status value "${legacyStatus}". Expected one of: ${[...VALID_PHASES].join(', ')} (or legacy: ${Object.keys(LEGACY_COLLAPSE).join(', ')}).`);
619
+ }
620
+ // Build a clean post-rename document — no `status` key, `phase`
621
+ // set. `updatedAt` is preserved exactly as stored (this is a
622
+ // storage-format change, not a logical edit).
623
+ const migrated = {};
624
+ for (const [k, v] of Object.entries(row)) {
625
+ if (k === 'status')
626
+ continue;
627
+ migrated[k] = v;
554
628
  }
629
+ migrated.phase = nextPhase;
630
+ await writs.put(migrated);
555
631
  }
556
632
  // ── One-shot migration: normalize link rows ──────────────────
557
633
  // Two-pass flow per D7: