@shardworks/clerk-apparatus 0.1.270 → 0.1.271

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 (57) hide show
  1. package/README.md +208 -48
  2. package/dist/children-behavior-engine.d.ts +163 -0
  3. package/dist/children-behavior-engine.d.ts.map +1 -0
  4. package/dist/children-behavior-engine.js +353 -0
  5. package/dist/children-behavior-engine.js.map +1 -0
  6. package/dist/clerk.d.ts +14 -28
  7. package/dist/clerk.d.ts.map +1 -1
  8. package/dist/clerk.js +515 -206
  9. package/dist/clerk.js.map +1 -1
  10. package/dist/index.d.ts +10 -5
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +10 -5
  13. package/dist/index.js.map +1 -1
  14. package/dist/testing.d.ts +43 -0
  15. package/dist/testing.d.ts.map +1 -0
  16. package/dist/testing.js +68 -0
  17. package/dist/testing.js.map +1 -0
  18. package/dist/tools/commission-post.d.ts.map +1 -1
  19. package/dist/tools/commission-post.js +14 -2
  20. package/dist/tools/commission-post.js.map +1 -1
  21. package/dist/tools/index.d.ts +1 -1
  22. package/dist/tools/index.d.ts.map +1 -1
  23. package/dist/tools/index.js +1 -1
  24. package/dist/tools/index.js.map +1 -1
  25. package/dist/tools/{piece-add.d.ts → step-add.d.ts} +1 -1
  26. package/dist/tools/step-add.d.ts.map +1 -0
  27. package/dist/tools/{piece-add.js → step-add.js} +12 -7
  28. package/dist/tools/step-add.js.map +1 -0
  29. package/dist/tools/writ-list.d.ts +17 -4
  30. package/dist/tools/writ-list.d.ts.map +1 -1
  31. package/dist/tools/writ-list.js +88 -3
  32. package/dist/tools/writ-list.js.map +1 -1
  33. package/dist/tools/writ-show.d.ts +4 -0
  34. package/dist/tools/writ-show.d.ts.map +1 -1
  35. package/dist/tools/writ-show.js +121 -8
  36. package/dist/tools/writ-show.js.map +1 -1
  37. package/dist/tools/writ-tree.d.ts +13 -4
  38. package/dist/tools/writ-tree.d.ts.map +1 -1
  39. package/dist/tools/writ-tree.js +34 -17
  40. package/dist/tools/writ-tree.js.map +1 -1
  41. package/dist/types.d.ts +320 -40
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js +6 -1
  44. package/dist/types.js.map +1 -1
  45. package/dist/writ-presentation.d.ts +92 -0
  46. package/dist/writ-presentation.d.ts.map +1 -0
  47. package/dist/writ-presentation.js +67 -0
  48. package/dist/writ-presentation.js.map +1 -0
  49. package/dist/writ-type-config.d.ts +222 -0
  50. package/dist/writ-type-config.d.ts.map +1 -0
  51. package/dist/writ-type-config.js +305 -0
  52. package/dist/writ-type-config.js.map +1 -0
  53. package/package.json +8 -4
  54. package/pages/writs/index.html +304 -54
  55. package/pages/writs/writs-hierarchy.test.js +291 -29
  56. package/dist/tools/piece-add.d.ts.map +0 -1
  57. package/dist/tools/piece-add.js.map +0 -1
package/README.md CHANGED
@@ -1,8 +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 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.
3
+ The Clerk manages the lifecycle of **writs** — lightweight work orders whose state machine is declared per writ type via a `WritTypeConfig`. The Clerk's own built-in `mandate` type flows through a six-state lifecycle (below); other plugin-registered types declare their own states and transitions. Writs can be organized into parent/child hierarchies for decomposing complex work.
4
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.
5
+ Writ documents follow a Kubernetes-style spec/status split: **`phase`** is the Clerk-owned lifecycle state (the phase machine below), **`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 — and **`ext`** is a sibling plugin-owned metadata slot of the same shape, reserved for metadata-shape data (provenance, cross-references, classifier tags) attached at registration time rather than the post-hoc observation `status` records. See [Spec/Status Convention](#specstatus-convention) below.
6
6
 
7
7
  The Clerk sits downstream of The Stacks: `stacks ← clerk`.
8
8
 
@@ -34,7 +34,14 @@ const clerk = guild().apparatus<ClerkApi>('clerk');
34
34
 
35
35
  ### `post(request): Promise<WritDoc>`
36
36
 
37
- Post a new commission, creating a writ in `open` phase.
37
+ Post a new commission, creating a writ in its registered type's declared `initial` state. For mandate that's `new` (a draft).
38
+
39
+ > **API vs. tool — auto-publish UX lives in the tool layer.** `clerk.post()` always lands the writ in the type's `initial` state and never advances it on its own — the API surface is intentionally minimal and predictable. The two tool wrappers reach `open` for you:
40
+ >
41
+ > - **`commission-post`** transitions newly-posted **mandate** writs to `open` automatically unless `draft: true` is passed. Other types (anything plugin-registered) are left in their `initial` state — advancing them without a type-specific tool would be silent coupling, so the auto-advance is confined to mandate.
42
+ > - **`piece-add`** unconditionally transitions the new piece to `open` (the tool has no `draft` parameter).
43
+ >
44
+ > Direct `clerk.post()` callers — including most plugin code — keep the `initial`-state landing semantics; if you want the writ to be dispatchable, follow up with `clerk.transition(id, 'open')`.
38
45
 
39
46
  ```typescript
40
47
  const writ = await clerk.post({
@@ -50,16 +57,16 @@ const writ = await clerk.post({
50
57
  |---|---|---|
51
58
  | `title` | `string` | Short human-readable title |
52
59
  | `body` | `string` | Detail text (required) |
53
- | `type` | `string` | Writ type — must be declared or built-in (optional) |
60
+ | `type` | `string` | Writ type — must be a registered type (optional) |
54
61
  | `codex` | `string` | Target codex name (optional, inherited from parent if omitted) |
55
62
  | `parentId` | `string` | Parent writ id for hierarchical decomposition (optional) |
56
63
 
57
64
  When `parentId` is provided:
58
- - The parent must exist and be in `new`, `open`, or `stuck` phase.
65
+ - The parent must exist and not be in a terminal state (as classified by its type config).
59
66
  - The child inherits the parent's `codex` if no explicit codex is provided.
60
67
  - The entire operation is atomic.
61
68
 
62
- Throws if the writ type is not declared in the guild config and is not a built-in type (`mandate`, `summon`).
69
+ Throws if the writ type is not registered register types with [`registerWritType`](#registerwrittypeconfig-void) from your plugin's `start()`.
63
70
 
64
71
  ### `show(id): Promise<WritDoc>`
65
72
 
@@ -118,14 +125,13 @@ const total = await clerk.count({ phase: 'open' });
118
125
 
119
126
  ### `listWritTypes(): WritTypeInfo[]`
120
127
 
121
- List all registered writ types with full metadata descriptions, source tracking, and default flag.
128
+ List all registered writ types. Returns an entry for each type registered via [`registerWritType`](#registerwrittypeconfig-void), including the Clerk's own `mandate`.
122
129
 
123
130
  ```typescript
124
131
  const types = clerk.listWritTypes();
125
132
  // [
126
133
  // { name: 'mandate', description: null, source: 'builtin', isDefault: true },
127
- // { name: 'task', description: 'A task', source: 'guild', isDefault: false },
128
- // { name: 'quality-audit', description: 'Code quality audit', source: 'my-kit', isDefault: false },
134
+ // { name: 'piece', description: null, source: 'plugin', isDefault: false },
129
135
  // ]
130
136
  ```
131
137
 
@@ -134,11 +140,47 @@ Each entry includes:
134
140
  | Field | Type | Description |
135
141
  |---|---|---|
136
142
  | `name` | `string` | The writ type name |
137
- | `description` | `string \| null` | Human-readable description, or `null` if none was provided |
138
- | `source` | `string` | Origin: `"builtin"`, `"guild"`, or the contributing plugin's id |
143
+ | `description` | `string \| null` | Reserved; always `null` today `WritTypeConfig` does not currently model a description field |
144
+ | `source` | `"builtin" \| "plugin"` | `"builtin"` for mandate (registered by the Clerk plugin itself); `"plugin"` for every other registered type |
139
145
  | `isDefault` | `boolean` | Whether this is the guild's default writ type |
140
146
 
141
- Source precedence: guild config entries fully shadow kit contributions with the same name (including description).
147
+ Use [`getWritTypeConfig(name)`](#getwrittypeconfigname-writtypeconfig--undefined) when you need the full `WritTypeConfig` (states, transitions, etc.).
148
+
149
+ ### `registerWritType(config): void`
150
+
151
+ Register a writ type's state machine with the Clerk. The config is validated via `validateWritTypeConfig` (validator errors propagate verbatim); registration-specific failures — duplicate names, or calls after the startup window has closed — throw with a `[clerk] registerWritType:` prefix.
152
+
153
+ ```typescript
154
+ // From a plugin's own start():
155
+ const clerk = guild().apparatus<ClerkApi>('clerk');
156
+ clerk.registerWritType({
157
+ name: 'audit',
158
+ states: [
159
+ { name: 'new', classification: 'initial', allowedTransitions: ['open', 'cancelled'] },
160
+ { name: 'open', classification: 'active', allowedTransitions: ['completed', 'failed', 'cancelled'] },
161
+ { name: 'completed', classification: 'terminal', attrs: ['success'], allowedTransitions: [] },
162
+ { name: 'failed', classification: 'terminal', attrs: ['failure'], allowedTransitions: [] },
163
+ { name: 'cancelled', classification: 'terminal', attrs: ['cancelled'], allowedTransitions: [] },
164
+ ],
165
+ });
166
+ ```
167
+
168
+ `registerWritType` is the only path for a plugin to contribute a writ type. The registry is sealed at the framework's global `phase:started` signal — call it from your apparatus's `start()`. There is no guild-config writTypes field and no kit-contribution channel.
169
+
170
+ ### `getWritTypeConfig(name): WritTypeConfig | undefined`
171
+
172
+ Return the registered `WritTypeConfig` for a writ type, or `undefined` when the name is not registered. Use this accessor when composing higher-level predicates or inspecting a type abstractly (e.g. to render a state-machine diagram).
173
+
174
+ ### `isInitial(writ): boolean` / `isActive(writ): boolean` / `isTerminal(writ): boolean`
175
+
176
+ Classify a writ's current state by consulting its type's registered `WritTypeConfig`. Each predicate throws the fail-loud diagnostic when the writ's type is not registered, or when its stored state is not declared in that type's config.
177
+
178
+ ```typescript
179
+ const writ = await clerk.show(id);
180
+ if (clerk.isTerminal(writ)) { /* ... */ }
181
+ ```
182
+
183
+ The three predicates partition registered states cleanly — every state in a validated `WritTypeConfig` carries exactly one classification.
142
184
 
143
185
  ### `link(sourceId, targetId, label, kind?): Promise<WritLinkDoc>`
144
186
 
@@ -219,11 +261,17 @@ await clerk.transition(id, 'failed', { resolution: 'Build pipeline broke' });
219
261
  await clerk.transition(id, 'cancelled', { resolution: 'No longer needed' });
220
262
  ```
221
263
 
222
- Throws if the transition is not legal for the writ's current phase.
264
+ Throws if the transition is not legal for the writ's current phase. The rejection message carries the writ id, current state, attempted target, and the list of legal transitions declared by the writ's type config (or `none (terminal state)` when the current state is terminal).
265
+
266
+ `transition()` strips the phase machine's managed fields from the body — `id`, `createdAt`, `updatedAt`, `resolvedAt`, and `parentId` — and rejects attempts to override `phase` through the `fields` argument with `[clerk] transition: cannot override phase via fields argument`. The plugin-owned slots `status` and `ext` are writable only via their dedicated writers (`setWritStatus()` for the observation slot, `setWritExt()` for the metadata slot — the two sanctioned slot-write paths), each of 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).
223
267
 
224
- `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).
268
+ The children-behavior engine a Phase 1 watcher on the writs book drives **three** cascade-engine branches. When any writ transitions to a terminal state, the engine evaluates the relevant `WritTypeConfig.childrenBehavior` block(s) and applies the configured actions via `transition`:
225
269
 
226
- **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.
270
+ - **Upward** (terminal child parent lift) reads the *parent's* `childrenBehavior`. `anyFailure` is evaluated first; if it fires, `allSuccess` is skipped. When the firing trigger declares `copyResolution: true`, the triggering child's `resolution` string is copied verbatim onto the parent. On every upward fire the engine *also* publishes the immediate triggering child's id under the parent's Clerk-owned status sub-slot (`status['clerk'].triggeringChildId`) **before** the transition records see [Worked example: `status.clerk.triggeringChildId`](#worked-example-statusclerktriggeringchildid) so downstream observers (the Reckoner today) can chase the cascade chain back to the leaf cause without parsing the parent's resolution string.
271
+ - **Downward** (terminal parent → non-terminal-children cancellation) reads the *triggering writ's own* `childrenBehavior.parentTerminal`. The downward branch fires only on `failure`- or `cancelled`-attr terminals; the `success` attr is handled by the tripwire branch instead. For each non-terminal child, the engine calls `transition` with the action's configured target and resolution. Already-terminal children are skipped (idempotent on re-fire); a child whose type cannot accept the configured target throws and rolls back the cascade.
272
+ - **Tripwire** (success-attr terminal with non-terminal descendant — enforced invariant). When a writ whose type opts into `childrenBehavior` reaches a `success`-attr terminal state and any non-terminal descendant remains, the engine throws inside the firing transaction and Phase 1 atomicity rolls the offending parent transition back. The cascade engine itself can never produce this state — `allSuccess` enumerates every direct sibling and requires terminal-success — so any path that does is a direct `clerk.transition()` caller bypassing the cascade. Surfacing the gap as a hard error (rather than a log-only warn) makes it unrepresentable in the writs book and catches caller bugs at commit time. The branch walks the descendant subtree directly through the writs book (recursing through terminal nodes too — a bypass further down the tree could leave a non-terminal grandchild beneath an already-terminal child) and the throw message names the offending writ id, the success-attr state, and the non-terminal descendants.
273
+
274
+ Types whose configs omit `childrenBehavior` are silent no-ops across all three branches — they have announced they do not couple parent and child outcomes. Mandate opts into all three upward/downward triggers (`allSuccess`, `anyFailure`, `parentTerminal`) and is therefore covered by the tripwire too; piece and observation-set declare none. Cascade writes join the triggering transaction (Phase 1 atomicity); grandparent lift and grandchild cancel both fall out naturally as the next update event re-enters the watcher.
227
275
 
228
276
  ### `setWritStatus(writId, pluginId, value): Promise<WritDoc>`
229
277
 
@@ -241,9 +289,27 @@ The write runs in a Stacks transaction (read-modify-write), so concurrent writes
241
289
 
242
290
  Throws if `writId` or `pluginId` is missing, or if the writ does not exist.
243
291
 
292
+ ### `setWritExt(writId, pluginId, value): Promise<WritDoc>`
293
+
294
+ Write (or overwrite) a plugin-owned sub-slot inside the writ's metadata `ext` map. Sibling to `setWritStatus`: the `ext` field on `WritDoc` is the same plugin-keyed `Record<string, unknown>` shape, written through the same transactional read-modify-write contract, with the same CDC event emission and the same terminal-survival guarantee. The semantic distinction is what each slot is meant to hold — `ext` carries metadata-shape data attached at registration time, while `status` records post-hoc observations. See [`ext` (metadata) vs `status` (observation)](#ext-metadata-vs-status-observation).
295
+
296
+ ```typescript
297
+ // Reckoner attaches the originating petition id when the writ is created
298
+ await clerk.setWritExt(writ.id, 'reckoner', { petitionId: 'pet-01' });
299
+
300
+ // A different plugin writes its own metadata key in the same slot — disjoint sub-slot, no clobber.
301
+ await clerk.setWritExt(writ.id, 'astrolabe', { tag: 'spike' });
302
+ ```
303
+
304
+ 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 optional and absent on freshly-posted writs (reads `ext === undefined` until the first sub-slot is written), and is not cleared on terminal transitions — metadata persists for cross-reference reads.
305
+
306
+ Throws if `writId` or `pluginId` is missing, or if the writ does not exist.
307
+
244
308
  ---
245
309
 
246
- ## Phase Machine
310
+ ## Mandate's lifecycle (an example registered type)
311
+
312
+ Mandate is the one writ type the Clerk plugin registers for itself. Its lifecycle — six states, the transitions below — is just one example of a `WritTypeConfig`; other plugin-registered types declare their own state machines.
247
313
 
248
314
  ```
249
315
  new ──► open ──┬──► completed
@@ -260,10 +326,10 @@ new ──► open ──┬──► completed
260
326
  └───────┴────┴──► cancelled
261
327
  ```
262
328
 
263
- - `completed`, `failed`, and `cancelled` are **terminal** — no transitions out.
264
- - `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`.
329
+ - `completed`, `failed`, and `cancelled` are **terminal** (classification: `terminal`) — no transitions out.
330
+ - `stuck` is **non-terminal** (classification: `active`) — a "needs attention" phase for writs whose rig hit an engine failure. Recovery transitions back to `open`; giving up transitions to `failed` or `cancelled`.
265
331
 
266
- ### Allowed transitions
332
+ ### Mandate's allowed transitions
267
333
 
268
334
  | To | From |
269
335
  |---|---|
@@ -273,15 +339,16 @@ new ──► open ──┬──► completed
273
339
  | `failed` | `open`, `stuck` |
274
340
  | `cancelled` | `new`, `open`, `stuck` |
275
341
 
342
+ The same table is carried as `allowedTransitions` on each state in mandate's `WritTypeConfig`. Other types (`piece`, `observation-set`, your own) live alongside mandate in the Clerk's runtime registry — their allowed transitions come from their own configs, not this table.
343
+
276
344
  ---
277
345
 
278
346
  ## Parent/Child Hierarchies
279
347
 
280
348
  Writs can be organized into parent/child relationships for decomposing complex work:
281
349
 
282
- - **Creating children:** Pass `parentId` to `post()`. The parent stays in its current phase. Parents in `new`, `open`, or `stuck` phase accept children.
283
- - **Failure cascade:** When a child fails and the parent is `open` or `stuck`, the parent is failed and remaining non-terminal siblings are cancelled.
284
- - **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.
350
+ - **Creating children:** Pass `parentId` to `post()`. The parent stays in its current phase. Parents accept children in any non-terminal state; a parent in a terminal state (as classified by its type config) rejects new children with a clear error.
351
+ - **Children-behavior cascade:** the children-behavior engine drives three branches. Upward (terminal child parent lift) evaluates the parent's `childrenBehavior` (`anyFailure` before `allSuccess`; a failing child wins precedence; `copyResolution: true` copies the triggering child's resolution onto the parent verbatim). Downward (terminal parent → non-terminal-children cancellation) evaluates the triggering writ's own type's `parentTerminal` action when the writ reaches a `failure`- or `cancelled`-attr terminal — every non-terminal descendant is driven to the configured target with the configured resolution string. Tripwire (enforced invariant) throws and rolls back when a cascade-opt-in writ would reach a `success`-attr terminal with any non-terminal descendant: the cascade itself can never produce this state, so any path that does is upstream-broken (a direct `transition()` caller bypassing the cascade), and Phase 1 atomicity makes the gap unrepresentable in the writs book. Cascade is opt-in per type: a type with no `childrenBehavior` block is a silent no-op across all three branches. Mandate opts into all three triggers; piece and observation-set declare none. Cascade fires inside the transaction that triggered it (Phase 1); grandparent lift and grandchild cancel both fall out naturally as the next update event re-enters the watcher.
285
352
  - **Codex inheritance:** Children inherit the parent's codex if none is specified.
286
353
  - **Immutability:** `parentId` cannot be changed after creation.
287
354
 
@@ -293,15 +360,73 @@ Clerk follows a Kubernetes-style spec/status split:
293
360
 
294
361
  - **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*.
295
362
  - **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.
363
+ - **Ext slot** (the `ext` field on `WritDoc`) is the structural sibling of `status` — same plugin-keyed `Record<string, unknown>` shape, same transactional write contract, same terminal-survival rule — but reserved for metadata-shape data (petition ids, cross-references, classifier tags, configuration extensions) attached at registration time rather than the post-hoc observation `status` records. See [`ext` (metadata) vs `status` (observation)](#ext-metadata-vs-status-observation) below.
364
+
365
+ Both slots are soft conventions rather than hard enforcement boundaries. Wherever a rule below names one slot and writer, the same rule holds for the sibling.
366
+
367
+ - No runtime guard stops a plugin from reading another plugin's sub-slot — the convention is *write only your own key*, and the dedicated APIs (`setWritStatus()` / `setWritExt()`) make the right thing easy.
368
+ - **One sanctioned slot-write path per slot.** The observation slot is writable only via `setWritStatus(writId, pluginId, value)`; the metadata slot only via `setWritExt(writId, pluginId, value)`. Each 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 both `status` and `ext` 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 the dedicated writer would wholesale-replace the slot and clobber sibling sub-slots.
369
+ - Concurrent writes from different plugins to different sub-slots are disjoint and safe: `setWritStatus()` and `setWritExt()` each run their read-modify-write inside a Stacks transaction.
370
+ - Within a single plugin's sub-slot, concurrent writes are last-writer-wins at the sub-slot level — the dedicated writer replaces the plugin's sub-slot value wholesale. Per-key atomicity inside a sub-slot is deferred until real contention appears.
371
+ - 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` / `ext` contents.
372
+ - Terminal transitions do **not** clear the slots. Observations and metadata persist on the writ for post-mortem inspection and ongoing cross-reference reads.
296
373
 
297
- The slot is a soft convention rather than a hard enforcement boundary:
374
+ ### `ext` (metadata) vs `status` (observation)
298
375
 
299
- - 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.
300
- - **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.
301
- - Concurrent writes from different plugins to different sub-slots are disjoint and safe: `setWritStatus()` runs its read-modify-write inside a Stacks transaction.
302
- - 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.
303
- - 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.
304
- - Terminal transitions do **not** clear the slot. Observations persist on the writ for post-mortem inspection.
376
+ Both slots are plugin-keyed `Record<string, unknown>` maps with identical mechanics. The semantic distinction is the *kind* of data each is meant to hold:
377
+
378
+ - **`status` is for post-hoc observation** what a plugin has *observed* about a writ after the fact. Examples: a stuck cause recorded by Spider's engine-failure handler, a triggering child id recorded by the Clerk's children-behavior cascade, a gate result recorded by an evaluator. The defining feature is that the observation is the plugin's reaction to something the writ has been through.
379
+ - **`ext` is for attached metadata** — what a plugin needs the writ to *carry* as an attribute of its identity. Examples: a petition id linking a writ back to its originating registration, a foreign-system reference, a classifier tag baked in at creation. The defining feature is that the metadata is part of *what the writ is*, not a record of what has happened to it.
380
+
381
+ Picking the wrong slot layers metadata under an observation contract or vice versa, so plugin authors should choose consciously. When in doubt: ask whether the data is set as the writ comes into being (or is registered with another system) — that points to `ext` — versus updated reactively as the writ evolves — that points to `status`.
382
+
383
+ #### Worked example: `ext['reckoner'].petitionId`
384
+
385
+ The Reckoner registers a petition for a writ at the moment the writ is created on its behalf, and attaches the petition id under `ext['reckoner']` so downstream consumers can chase the cross-reference back to the petition record without a separate index. The shape is established at attach time and stable for the writ's lifetime — a textbook metadata-shape consumer rather than an observation. See `docs/architecture/petitioner-registration.md` for the full Reckoner contract; the slot itself is opaque to the Clerk and validated only by the Reckoner.
386
+
387
+ ### Worked example: `status.clerk.triggeringChildId`
388
+
389
+ The Clerk's children-behavior cascade engine writes a sub-slot of its
390
+ own. When a parent writ is lifted into a terminal state by the cascade
391
+ (one of its children's terminal transitions fired the parent's
392
+ `WritTypeConfig.childrenBehavior` trigger), the engine records the
393
+ immediate triggering child's id under `status['clerk']` *before* the
394
+ parent's `transition()` call:
395
+
396
+ ```typescript
397
+ interface ClerkWritStatus {
398
+ /**
399
+ * Id of the immediate child whose terminal transition fired the
400
+ * children-behavior cascade onto this writ. Absent on writs that
401
+ * reached terminal through a direct (non-cascaded) transition.
402
+ */
403
+ triggeringChildId?: string;
404
+ }
405
+ ```
406
+
407
+ The slot is owned by the Clerk; downstream observers (today, the
408
+ Reckoner) read it through the standard plugin convention:
409
+
410
+ ```typescript
411
+ const clerkStatus = writ.status?.clerk as
412
+ | { triggeringChildId?: string }
413
+ | undefined;
414
+ ```
415
+
416
+ **Why the ordering matters.** Phase 2 CDC observers read `event.entry`
417
+ (the post-commit snapshot) at emit time, keyed on the terminal-
418
+ transition's `updatedAt`. Writing the slot *after* the transition would
419
+ deliver the pulse against a snapshot that pre-dates the slot's
420
+ existence and degrade the leaf-cause surface. The dual-write sequence
421
+ (`setWritStatus(parent, 'clerk', …)` then `transition(parent, …)`) is
422
+ preserved instead of relaxing `transition()`'s safe-fields strip —
423
+ `status` continues to be writable only through `setWritStatus()`.
424
+
425
+ **Chase-chain on the consumer side.** Multi-level cascades (root → mid
426
+ → leaf) leave each parent in the chain carrying its own immediate
427
+ triggering child id; consumers walk the chain by reading each successive
428
+ writ's own `status['clerk'].triggeringChildId`. Cascade depth is bounded
429
+ by Stacks' `MAX_CASCADE_DEPTH = 16` invariant.
305
430
 
306
431
  ### Worked example: `status.spider.stuckCause`
307
432
 
@@ -347,18 +472,9 @@ Configure The Clerk under the `"clerk"` key in your guild config:
347
472
  }
348
473
  ```
349
474
 
350
- Writ types are declared at the top level of the guild config:
475
+ Only `defaultType` is honored. It must name a writ type registered with the Clerk (via `registerWritType` from a plugin's `start()`); an unregistered default fails the guild's startup with a clear `[clerk] guild config:` error.
351
476
 
352
- ```json
353
- {
354
- "writTypes": {
355
- "epic": { "description": "A significant multi-step task" },
356
- "errand": { "description": "A small one-off task" }
357
- }
358
- }
359
- ```
360
-
361
- The built-in types `mandate` and `summon` are always available without declaration.
477
+ There is no guild-config `writTypes` field. Plugins contribute writ types via `ClerkApi.registerWritType(config)` from their own apparatus's `start()`. The Clerk itself registers `mandate` the same way.
362
478
 
363
479
  ---
364
480
 
@@ -377,7 +493,8 @@ The Clerk contributes books, tools, and pages to the guild:
377
493
 
378
494
  | Tool | Permission | Description |
379
495
  |---|---|---|
380
- | `commission-post` | `clerk:write` | Post a new commission (create a writ, optionally as child) |
496
+ | `commission-post` | `clerk:write` | Post a new commission (create a writ, optionally as child). Auto-publishes mandate writs to `open` unless `draft: true` is passed; other types stay in their `initial` state. |
497
+ | `piece-add` | `clerk:write` | Add a child `piece` writ to a mandate from a structured task description; the piece is auto-published to `open` and joins the implement-loop queue. |
381
498
  | `writ-show` | `clerk:read` | Show full detail for a writ (includes parent/children context) |
382
499
  | `writ-list` | `clerk:read` | List writs with optional filters (phase, type, parentId) |
383
500
  | `writ-tree` | `clerk:read` | Render the writ hierarchy as a depth-aware tree (forest or single subtree); supports `phase` / `type` filters with prune semantics and a `depth` cap. Output is a box-drawing ASCII tree by default (`--format text`) or the structured `WritTree[]` forest (`--format json`). |
@@ -397,12 +514,15 @@ The Clerk contributes books, tools, and pages to the guild:
397
514
  ## Key Types
398
515
 
399
516
  ```typescript
517
+ // Mandate-specific state union (kept for callers that knowingly downcast).
518
+ // The structural type of WritDoc.phase is `string` so any plugin-registered
519
+ // writ type's state name round-trips through the book.
400
520
  type WritPhase = 'new' | 'open' | 'stuck' | 'completed' | 'failed' | 'cancelled';
401
521
 
402
522
  interface WritDoc {
403
523
  id: string; // ULID-like, prefixed "w-"
404
- type: string; // declared or built-in type
405
- phase: WritPhase; // Clerk-owned lifecycle state
524
+ type: string; // a registered writ type
525
+ phase: string; // Clerk-owned lifecycle state (any registered type's state name)
406
526
  title: string;
407
527
  body: string;
408
528
  codex?: string; // target codex name
@@ -412,6 +532,7 @@ interface WritDoc {
412
532
  resolvedAt?: string; // ISO timestamp, set on any terminal transition
413
533
  resolution?: string; // summary of how the writ resolved
414
534
  status?: Record<string, unknown>; // plugin-owned observation slot (keyed by plugin id)
535
+ ext?: Record<string, unknown>; // plugin-owned metadata slot (keyed by plugin id)
415
536
  }
416
537
 
417
538
  interface PostCommissionRequest {
@@ -454,10 +575,10 @@ interface WritTreeParams {
454
575
  }
455
576
 
456
577
  interface WritTypeInfo {
457
- name: string; // writ type name
458
- description: string | null; // human-readable description
459
- source: string; // "builtin", "guild", or plugin id
460
- isDefault: boolean; // whether this is the default type
578
+ name: string; // writ type name
579
+ description: string | null; // reserved; always null today
580
+ source: 'builtin' | 'plugin'; // "builtin" for mandate; "plugin" otherwise
581
+ isDefault: boolean; // whether this is the default type
461
582
  }
462
583
 
463
584
  interface WritLinkDoc {
@@ -497,7 +618,46 @@ See `src/types.ts` for the complete type definitions.
497
618
  The package exports all public types and the `createClerk()` factory:
498
619
 
499
620
  ```typescript
500
- import clerkPlugin, { createClerk, type ClerkApi, type WritTypeInfo } from '@shardworks/clerk-apparatus';
621
+ import clerkPlugin, {
622
+ createClerk,
623
+ CLERK_PLUGIN_ID,
624
+ type ClerkApi,
625
+ type ClerkWritStatus,
626
+ type WritTypeInfo,
627
+ } from '@shardworks/clerk-apparatus';
501
628
  ```
502
629
 
630
+ `CLERK_PLUGIN_ID` is the constant (`'clerk'`) used as the `status` sub-slot key for the Clerk's own observations (see [Worked example: `status.clerk.triggeringChildId`](#worked-example-statusclerktriggeringchildid)). `ClerkWritStatus` is the writer-side shape of that slot.
631
+
503
632
  The default export is a pre-built plugin instance, ready for guild installation.
633
+
634
+ ### Writ-type configuration
635
+
636
+ The package also exports the structural shape that describes a writ type's state machine and lifecycle behavior, plus a pure structural validator. These primitives are the foundation for plugin-registered writ types; they do not yet participate in the runtime lifecycle.
637
+
638
+ ```typescript
639
+ import {
640
+ validateWritTypeConfig,
641
+ type WritTypeConfig,
642
+ type WritTypeStateDefinition,
643
+ type WritTypeStateClassification,
644
+ type WritTypeStateAttr,
645
+ type KnownWritTypeStateAttr,
646
+ type WritTypeChildrenBehavior,
647
+ type WritTypeChildrenBehaviorAction,
648
+ } from '@shardworks/clerk-apparatus';
649
+ ```
650
+
651
+ A `WritTypeConfig` names the type, enumerates its lifecycle states (each with a `classification` of `'initial' | 'active' | 'terminal'`, an optional `attrs` vocabulary, and per-state `allowedTransitions`), and optionally declares `childrenBehavior` triggers (`allSuccess`, `anyFailure`) that lift terminal-child outcomes back onto the parent.
652
+
653
+ `validateWritTypeConfig(config)` throws a plain `Error` on the first structural violation it encounters and returns `void` on success. Error messages take the shape `[clerk] writTypeConfig.<path>: <problem>; received <value>` with the path naming the offending field (e.g. `states[2].classification`, `childrenBehavior.anyFailure.transition`). The validator enforces:
654
+
655
+ - non-empty `name`
656
+ - non-empty `states` array with unique non-empty state names
657
+ - every `classification` drawn from the known vocabulary
658
+ - every `allowedTransitions` entry references an existing state
659
+ - exactly one state classified `initial`
660
+ - every non-initial state has at least one inbound transition
661
+ - terminal states declare no outbound transitions
662
+ - every declared `childrenBehavior` trigger carries an action with a `transition` field that references an existing state
663
+ - every `childrenBehavior` transition target is reachable from every non-terminal state via `allowedTransitions`
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Children-behavior cascade engine.
3
+ *
4
+ * Subscribes to `update` events on the `clerk/writs` book and, when a writ
5
+ * transitions to a terminal state, evaluates the relevant
6
+ * `WritTypeConfig.childrenBehavior` block(s) and applies the configured
7
+ * action via the supplied `transition` callback. The engine is generic in
8
+ * writ type — any registered type whose config declares a
9
+ * `childrenBehavior` block opts in; types that omit the block are no-ops.
10
+ *
11
+ * Three cascade-engine branches, all driven by the same firing rule
12
+ * (terminal transition on `update` events) and dispatched from the same
13
+ * `handle` function:
14
+ *
15
+ * - **Upward** (terminal child → parent lift). Drives the parent through
16
+ * the parent type's `allSuccess` / `anyFailure` triggers when the
17
+ * triggering *child* reaches a `success`- or `failure`-attr terminal
18
+ * state respectively.
19
+ * - **Downward** (terminal parent → non-terminal children cancellation).
20
+ * Drives every non-terminal descendant through the parent type's
21
+ * `parentTerminal` trigger when the *parent itself* reaches a
22
+ * `failure`- or `cancelled`-attr terminal state. Recursion to
23
+ * grandchildren happens via natural CDC re-fire on each child's own
24
+ * transition, not by an in-handler walk.
25
+ * - **Tripwire** (success-attr terminal with non-terminal descendant —
26
+ * enforced invariant). Throws when an entry whose type opts into
27
+ * `childrenBehavior` reaches a `success`-attr terminal state while
28
+ * any non-terminal descendant remains. The throw rolls the entry's
29
+ * transition back via Phase 1 atomicity, so the bookkeeping gap is
30
+ * unrepresentable in the writs book rather than a log-only signal.
31
+ * The cascade engine itself can never produce this state —
32
+ * `allSuccess` enumerates every sibling and requires terminal-success
33
+ * — so any path that does produce it is upstream-broken (a direct
34
+ * `clerk.transition()` caller bypassing the cascade); surfacing it as
35
+ * a hard error catches caller bugs early.
36
+ *
37
+ * Upward firing rule (in order — first-fail short-circuits):
38
+ *
39
+ * 1. Event is an `update`.
40
+ * 2. The entry's phase actually changed (`entry.phase !== prev.phase`).
41
+ * 3. The entry is in a terminal state.
42
+ * 4. The entry has a `parentId`.
43
+ * 5. The parent writ exists. (Throws if not — dangling parent is a
44
+ * data-integrity violation.)
45
+ * 6. The parent's writ-type is registered. (Throws if not — same
46
+ * fail-loud shape as `classifyWritState`.)
47
+ * 7. The parent type declares a `childrenBehavior` block. (Silent
48
+ * no-op when absent.)
49
+ * 8. The parent itself is non-terminal. (Idempotent short-circuit.)
50
+ *
51
+ * Upward trigger evaluation order: `anyFailure` is evaluated first; if it
52
+ * fires, `allSuccess` is skipped. Otherwise `allSuccess` is evaluated by
53
+ * enumerating *every* sibling under the same parent (not via the limited
54
+ * `api.list` path) and checking that every sibling has reached a terminal
55
+ * state and every terminal state carries the `success` attr.
56
+ *
57
+ * Downward firing rule (in order — first-fail short-circuits):
58
+ *
59
+ * 1. Event is an `update`.
60
+ * 2. The entry's phase actually changed (`entry.phase !== prev.phase`).
61
+ * 3. The entry is in a terminal state carrying the `failure` or
62
+ * `cancelled` attr (the parent-itself-terminated signal). The
63
+ * `success` attr — i.e. natural completion — is handled by the
64
+ * tripwire branch instead.
65
+ * 4. The entry's own writ-type is registered.
66
+ * 5. The entry's type declares a `parentTerminal` action. (Silent
67
+ * no-op when absent.)
68
+ * 6. The entry has at least one non-terminal child. The branch
69
+ * enumerates children via direct-book read (bypassing `api.list`'s
70
+ * 20-row default) and skips already-terminal children using
71
+ * `isTerminal`.
72
+ *
73
+ * For each non-terminal child, the engine calls `api.transition` with the
74
+ * action's configured `transition` target and `resolution` string (or the
75
+ * child's own resolution when `copyResolution: true`). If a child cannot
76
+ * accept the configured target — typically a child-type declaring no
77
+ * transition into the configured state from the child's current state —
78
+ * `api.transition` throws and the Phase 1 transaction rolls back per the
79
+ * engine's existing fail-loud convention. Already-terminal children are
80
+ * skipped (idempotent on re-fire).
81
+ *
82
+ * Tripwire firing rule (in order — first-fail short-circuits):
83
+ *
84
+ * 1. Event is an `update`.
85
+ * 2. The entry's phase actually changed (`entry.phase !== prev.phase`).
86
+ * 3. The entry is in a terminal state carrying the `success` attr.
87
+ * 4. The entry's own writ-type is registered.
88
+ * 5. The entry's type declares a `childrenBehavior` block. (Silent
89
+ * no-op when absent — types that decline `childrenBehavior` have
90
+ * announced they do not couple parent and child outcomes.)
91
+ * 6. The entry has at least one non-terminal descendant (direct child
92
+ * OR deeper). Descendants are enumerated by walking the subtree
93
+ * through the writs book directly (bypassing `api.list`'s 20-row
94
+ * default), recursing through terminal nodes too — a terminal
95
+ * child can still hide a non-terminal grandchild when an upstream
96
+ * caller bypassed the cascade.
97
+ *
98
+ * On all six conditions, the tripwire throws a fail-loud error naming
99
+ * the offending entry id, the `success`-attr terminal state, and the
100
+ * non-terminal descendants. Phase 1 atomicity rolls the entry's
101
+ * transition back, so a parent cannot land in a `success`-attr terminal
102
+ * with open descendants — the gap is unrepresentable in the writs book.
103
+ * The branch reuses the same enumeration approach as the downward branch
104
+ * (direct-book read, type-aware `isTerminal` filtering) and stays
105
+ * idempotent under CDC re-fire: a re-fire with no phase change (rule 2)
106
+ * short-circuits before evaluation, and a re-fire with all descendants
107
+ * terminal is a no-op.
108
+ *
109
+ * Cascade ordering when both directions fire on the same chain (e.g. an
110
+ * upward `anyFailure` that lifts a parent into `failed`, which then
111
+ * needs to push down into the parent's other open siblings) is handled
112
+ * by natural CDC re-fire: the parent's own update event re-enters this
113
+ * handler and triggers the downward branch on the parent's now-terminal
114
+ * transition.
115
+ *
116
+ * When a trigger fires with `copyResolution: true`, the triggering
117
+ * writ's `resolution` string is copied onto the target as part of the
118
+ * transition. When a trigger fires with `resolution: '...'`, that static
119
+ * string is written onto every transitioned writ.
120
+ *
121
+ * On every upward fire, before the parent's transition is recorded, the
122
+ * engine publishes a structured record onto the parent's Clerk-owned
123
+ * status sub-slot (`status['clerk']`) containing the immediate triggering
124
+ * child's id. The write must precede the transition: downstream observers
125
+ * (notably the Reckoner) are CDC-driven from the terminal transition's
126
+ * `updatedAt`, and they read `status['clerk']` from the post-commit
127
+ * `entry` snapshot at that moment. Writing the slot *after* the transition
128
+ * would deliver the pulse against a snapshot that pre-dates the slot's
129
+ * existence and degrade the leaf-cause surface.
130
+ *
131
+ * The engine never writes to the writ document directly — every state
132
+ * change goes through `transition`, so allowedTransitions enforcement,
133
+ * terminal `resolvedAt` tagging, and CDC re-fire all behave identically
134
+ * to a direct caller.
135
+ */
136
+ import type { Book, ChangeEvent } from '@shardworks/stacks-apparatus';
137
+ import type { WritDoc, WritPhase } from './types.ts';
138
+ import type { WritTypeConfig } from './writ-type-config.ts';
139
+ /**
140
+ * Dependencies the engine needs from the surrounding Clerk runtime.
141
+ *
142
+ * `writs` is the book the engine reads sibling lists from directly (the
143
+ * limited `api.list` default would silently truncate parents with >20
144
+ * children). `getWritTypeConfig` is the registry accessor.
145
+ * `isTerminal` is the writ-type-classification predicate.
146
+ * `transition` is the sole sanctioned phase-change surface.
147
+ * `setWritStatus` is the sanctioned slot-write path; the engine uses it
148
+ * to publish `status['clerk']` immediately before the parent's terminal
149
+ * transition.
150
+ */
151
+ export interface ChildrenBehaviorEngineDeps {
152
+ writs: Book<WritDoc>;
153
+ getWritTypeConfig(name: string): WritTypeConfig | undefined;
154
+ isTerminal(writ: WritDoc): boolean;
155
+ transition(id: string, to: WritPhase, fields?: Partial<WritDoc>): Promise<WritDoc>;
156
+ setWritStatus(writId: string, pluginId: string, value: unknown): Promise<WritDoc>;
157
+ }
158
+ /**
159
+ * Build the engine handler. Returns an async function suitable for
160
+ * passing to `stacks.watch('clerk', 'writs', handler, { failOnError: true })`.
161
+ */
162
+ export declare function createChildrenBehaviorEngine(deps: ChildrenBehaviorEngineDeps): (event: ChangeEvent<WritDoc>) => Promise<void>;
163
+ //# sourceMappingURL=children-behavior-engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"children-behavior-engine.d.ts","sourceRoot":"","sources":["../src/children-behavior-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsIG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAEtE,OAAO,KAAK,EAAmB,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEtE,OAAO,KAAK,EAEV,cAAc,EAEf,MAAM,uBAAuB,CAAC;AAE/B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IACrB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAAC;IAC5D,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;IACnC,UAAU,CACR,EAAE,EAAE,MAAM,EACV,EAAE,EAAE,SAAS,EACb,MAAM,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,GACxB,OAAO,CAAC,OAAO,CAAC,CAAC;IACpB,aAAa,CACX,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO,GACb,OAAO,CAAC,OAAO,CAAC,CAAC;CACrB;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,0BAA0B,GAC/B,CAAC,KAAK,EAAE,WAAW,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CA+OhD"}