wowbagger 0.1.0-alpha.1 → 0.1.0-alpha.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.
@@ -0,0 +1,1036 @@
1
+ # Local mutation contract
2
+
3
+ Status: versions 1 and 2 are defined; the pre-alpha standalone
4
+ local-filesystem runtime currently emits version 2.
5
+
6
+ This document defines the machine contract implemented by the local-filesystem
7
+ mutation backend. It supplements [SPEC.md](../SPEC.md) and
8
+ [ADR 0003](adr/0003-local-mutation-and-cas.md); it does not relax schema version
9
+ 1 or 2 lifecycle invariants.
10
+
11
+ The executable supports `validate`, `ready`, and the commands below. Clients
12
+ must still call `capabilities` and honor its advertised limits before assuming a
13
+ backend can provide a particular write guarantee.
14
+
15
+ ## Contract versions
16
+
17
+ Version 1 remains the frozen contract described by the version 1 envelopes and
18
+ capability example below. Version 2 retains every version 1 request, response,
19
+ state, exit, locking, CAS, publication, and recovery rule except for these
20
+ explicit deltas:
21
+
22
+ - every core command envelope in this contract carries `contract_version: 2`;
23
+ - one non-empty ledger may use schema version 1 or schema version 2, but never a
24
+ mixture;
25
+ - the capability envelope uses the fixed local mutation scope described in
26
+ section 4 and advertises `patch`; and
27
+ - adapter contract version 2 may invoke `patch` as an approved mutation.
28
+
29
+ The bootstrap wire, work-claim API, adapter approval, instruction, handoff, and
30
+ fixture-format versions are separate version domains and remain version 1.
31
+
32
+ Version negotiation uses distinct existing fields. A core consumer MUST read
33
+ the top-level `contract_version` from `capabilities --json`. A work-claim
34
+ consumer MUST read `result.operations.work_claim.api_version` from
35
+ `claim capabilities --ledger <dir> --json`. It MUST NOT compare a claim
36
+ response's top-level `contract_version` with the core version. That claim member
37
+ remains the version 1 envelope marker for exact version 1 consumers.
38
+
39
+ This rule is the migration path for generic consumers: dispatch by command
40
+ namespace first, then check the version field for that domain. No envelope
41
+ member changes, so existing exact-member consumers remain compatible.
42
+
43
+ ## 1. Scope
44
+
45
+ The contract keeps four concerns separate:
46
+
47
+ - capabilities describes guarantees and limitations;
48
+ - inspect reads one item and returns a revision from the same bytes it exposes;
49
+ - create publishes one caller-identified triage item;
50
+ - transition changes one existing item through a guarded lifecycle edge; and
51
+ - patch changes one existing item's caller-supplied fields (section 9).
52
+
53
+ Fenced work claiming is unsupported. Advisory claims may be visible through a
54
+ Git common directory, but they do not protect publication or coordinate a
55
+ mutation. A write lock protects a short mutation attempt; it is not a claim,
56
+ assignment, lease, or reservation. The separate [fenced work-claim
57
+ contract](work-claim-contract.md) defines a future backend protocol; it does
58
+ not add a fencing guarantee to either mutation-contract version.
59
+
60
+ If a future backend advertises safely fenced claims while retaining these
61
+ legacy entry points, it must run them through the same coordinator: transition
62
+ refuses an active claimed `(ledger_namespace, item_id)` and create refuses an
63
+ identity with claim history. Until then, the local runtime's unsupported
64
+ capability is authoritative; callers cannot combine this API with an external
65
+ claim hint and infer fencing.
66
+
67
+ The first backend coordinates only cooperative Wowbagger writers using the same
68
+ ledger directory in one working copy. It does not coordinate clones, worktrees,
69
+ machines, hostile or non-cooperating writers, or Git operations. Its write
70
+ scope is one Markdown item and it has no multi-item atomicity.
71
+
72
+ Schema versions 1 and 2 remain canonical Markdown. One non-empty ledger must
73
+ use one schema version; complete-ledger validation rejects a mixture. Revision
74
+ and lock data are transport state and are not persisted in item frontmatter.
75
+
76
+ Validation and ready selection use the versioned semantics in SPEC.md. Schema
77
+ version 1 ready tasks have an empty depends_on list. Schema version 2 ready
78
+ tasks may retain declared prerequisites, but every target must have status
79
+ done. These schema rules do not change the version 1 request or response
80
+ envelopes in this document.
81
+
82
+ ## 2. Commands and transport
83
+
84
+ The local commands are:
85
+
86
+ ~~~text
87
+ wowbagger capabilities --json
88
+ wowbagger inspect --ledger <dir> --id <id> --json
89
+ wowbagger create --ledger <dir> --input <json-file|-> --json
90
+ wowbagger transition --ledger <dir> --input <json-file|-> --json
91
+ wowbagger patch --ledger <dir> --input <json-file|-> --json
92
+ wowbagger mint-id [--date YYYY-MM-DD] --json
93
+ ~~~
94
+
95
+ A dash for --input means standard input. File and standard-input requests have
96
+ identical semantics. Request bytes must be valid UTF-8 JSON with one top-level
97
+ object and no duplicate member names at any depth. Duplicate members are
98
+ invalid; a parser must not apply last-member-wins behaviour.
99
+
100
+ Unknown, missing, and repeated command arguments are invalid-request. Create,
101
+ transition, and patch use JSON input rather than parallel field flags.
102
+
103
+ ### Standard output and standard error
104
+
105
+ For an invocation with --json, the process writes exactly one compact JSON
106
+ object followed by one LF to standard output.
107
+
108
+ Expected request, validation, lookup, conflict, lock, capability, and lifecycle
109
+ failures leave standard error empty. An unexpected operating failure may write
110
+ one UTF-8 diagnostic line of at most 1024 bytes to standard error. That line is
111
+ for a human and must not contain credentials, raw lock contents, or a path
112
+ outside the configured ledger. Automation uses the JSON envelope.
113
+
114
+ A process crash before an envelope is emitted is outside the command protocol.
115
+ A caller must treat the outcome of a mutating command as unknown and follow the
116
+ recovery rules in section 10.
117
+
118
+ ### Response envelopes
119
+
120
+ A successful read-only command has exactly:
121
+
122
+ ~~~json
123
+ {
124
+ "ok": true,
125
+ "command": "inspect",
126
+ "contract_version": 1,
127
+ "result": {}
128
+ }
129
+ ~~~
130
+
131
+ A successful create, transition, or patch adds state:
132
+
133
+ ~~~json
134
+ {
135
+ "ok": true,
136
+ "command": "create",
137
+ "contract_version": 1,
138
+ "state": "committed",
139
+ "result": {}
140
+ }
141
+ ~~~
142
+
143
+ A read-only error has exactly:
144
+
145
+ ~~~json
146
+ {
147
+ "ok": false,
148
+ "command": "inspect",
149
+ "contract_version": 1,
150
+ "error": {
151
+ "code": "item-not-found",
152
+ "message": "The requested item was not found.",
153
+ "details": {}
154
+ }
155
+ }
156
+ ~~~
157
+
158
+ Every create, transition, or patch error has a state member:
159
+
160
+ ~~~json
161
+ {
162
+ "ok": false,
163
+ "command": "transition",
164
+ "contract_version": 1,
165
+ "state": "unchanged",
166
+ "error": {
167
+ "code": "revision-conflict",
168
+ "message": "The item changed after it was inspected.",
169
+ "details": {}
170
+ }
171
+ }
172
+ ~~~
173
+
174
+ No expected envelope has undocumented root members.
175
+
176
+ Mutation state values mean:
177
+
178
+ | State | Meaning |
179
+ |---|---|
180
+ | unchanged | This invocation did not create, remove, rename, or byte-modify a Markdown item. |
181
+ | committed | The intended final path was re-read and contains exactly the expected published bytes. |
182
+ | unknown | Publication was attempted, but the process cannot establish which bytes are visible. |
183
+
184
+ Transient locks and temporary files are not Markdown items. Their possible
185
+ presence is reported separately as bounded recovery_artifacts.
186
+
187
+ ### Exit status
188
+
189
+ | Exit | Condition | Error codes |
190
+ |---:|---|---|
191
+ | 0 | Successful command; a mutation is state committed. | none |
192
+ | 2 | Argument, request, lookup, or candidate/lifecycle-precondition failure. | invalid-request, item-not-found, transition-precondition-failed, patch-precondition-failed, candidate-invalid |
193
+ | 3 | The complete configured ledger is invalid. | ledger-invalid |
194
+ | 4 | Cooperative comparison, lock, identity, or default-path conflict. | revision-conflict, lock-held, id-collision, path-collision |
195
+ | 5 | The backend lacks the required capability or write scope. | atomic-scope-required, capability-unavailable |
196
+ | 6 | An unexpected operating or post-publication recovery condition. | operation-failed, post-commit-recovery-required, write-outcome-unknown |
197
+
198
+ Only exit 0 is normal completion. A client must inspect mutation state on every
199
+ nonzero create, transition, or patch result.
200
+
201
+ ## 3. Deterministic invalid-request issues
202
+
203
+ invalid-request details are:
204
+
205
+ ~~~json
206
+ {
207
+ "issues": [
208
+ {
209
+ "path": "/body",
210
+ "code": "missing-member",
211
+ "message": "Required member body is missing."
212
+ }
213
+ ]
214
+ }
215
+ ~~~
216
+
217
+ Each issue has exactly path, code, and message:
218
+
219
+ - path is an RFC 6901 JSON Pointer into the decoded request;
220
+ - the empty string identifies the request root;
221
+ - command-line issues use the synthetic root /arguments followed by the
222
+ zero-based argument index when known;
223
+ - code is one of invalid-json, duplicate-key, missing-member, unknown-member,
224
+ invalid-type, invalid-value, missing-argument, repeated-argument, or
225
+ unknown-argument; and
226
+ - message is the stable sentence shown by the normative vector for that issue.
227
+
228
+ A duplicate-key path points to the duplicate occurrence. A syntactically
229
+ unrecoverable JSON document produces one invalid-json issue at the empty path.
230
+ Otherwise, issues are aggregated and sorted by path, then code, then message,
231
+ using ascending Unicode code-point order without locale collation.
232
+
233
+ Unknown members at the request root are rejected. Unknown members inside item
234
+ are schema extensions and are allowed only when they do not change core
235
+ semantics. Controlled core names forbidden by create are invalid-value issues.
236
+ The status refusal additionally teaches the lifecycle rule, with the stable
237
+ message `Item member status is controlled by Wowbagger. Create assigns triage;
238
+ a transition from triage to backlog accepts the item into ready.` A caller
239
+ therefore learns the assigned status from the create result's `core.status`
240
+ and the accepting transition from the refusal, without reading this document
241
+ first.
242
+
243
+ ## 4. Capabilities
244
+
245
+ The local backend, run from inside a git working copy, returns:
246
+
247
+ ~~~json
248
+ {
249
+ "ok": true,
250
+ "command": "capabilities",
251
+ "contract_version": 1,
252
+ "result": {
253
+ "backend": {
254
+ "name": "local-filesystem",
255
+ "coordination_scope": "shared-git-directory-cooperative-writers"
256
+ },
257
+ "operations": {
258
+ "inspect": {
259
+ "supported": true,
260
+ "write_scope": "none",
261
+ "cas_scope": "none"
262
+ },
263
+ "create": {
264
+ "supported": true,
265
+ "write_scope": "single-item",
266
+ "cas_scope": "requested-id-lock",
267
+ "publication_visibility": "atomic-no-clobber-or-fail",
268
+ "publication_probe": "per-ledger-operation"
269
+ },
270
+ "transition": {
271
+ "supported": true,
272
+ "write_scope": "single-item",
273
+ "cas_scope": "exact-byte-sha256"
274
+ },
275
+ "work_claim": {
276
+ "supported": true,
277
+ "api_version": 1,
278
+ "mode": "advisory",
279
+ "claim_protected_publication": false,
280
+ "fencing_enforced_at": "none",
281
+ "safe_exclusive_dispatch": false
282
+ }
283
+ },
284
+ "durability": {
285
+ "temporary_file_sync": "required-before-publication",
286
+ "directory_sync": "best-effort-when-supported",
287
+ "post_publication_verification": "exact-bytes-required",
288
+ "power_loss_guarantee": "none"
289
+ },
290
+ "limits": {
291
+ "multi_item_atomicity": false,
292
+ "cross_clone_coordination": false,
293
+ "cross_worktree_coordination": true,
294
+ "cross_machine_coordination": false,
295
+ "noncooperating_writer_protection": false,
296
+ "automatic_stale_lock_breaking": false
297
+ }
298
+ }
299
+ }
300
+ ~~~
301
+
302
+ Outside a git working copy, three members flip together:
303
+ `backend.coordination_scope` becomes `"same-working-copy-cooperative-writers"`,
304
+ `operations.work_claim.supported` becomes `false`, and
305
+ `limits.cross_worktree_coordination` becomes `false`. Every other member of the
306
+ envelope, including the rest of `operations.work_claim`
307
+ (`api_version`, `mode`, `claim_protected_publication`, `fencing_enforced_at`,
308
+ `safe_exclusive_dispatch`), is fixed regardless of git presence: work claims are
309
+ always advisory, never fence a writer, and never advertise safe exclusive
310
+ dispatch.
311
+
312
+ `capabilities` resolves the git common directory by walking upward from the
313
+ `--ledger` directory when given, or from the current working directory
314
+ otherwise (see `resolveGitCommonDir` in `src/claim-store.js`); presence of a
315
+ `.git` directory or file at or above that point is what flips the three
316
+ members above. This is the one input to `capabilities`, so the response is
317
+ deterministic for a given working directory but not fixed across working
318
+ directories.
319
+
320
+ ### Contract version 2 capability delta
321
+
322
+ The preceding JSON and three-member Git-dependent coupling remain the exact
323
+ version 1 definition. Version 2 changes only the following capability paths;
324
+ all omitted paths retain their version 1 values:
325
+
326
+ | Path | Version 2 value |
327
+ |---|---|
328
+ | `contract_version` | `2` |
329
+ | `result.backend.coordination_scope` | `"same-working-copy-cooperative-writers"` |
330
+ | `result.operations.patch` | `{"supported":true,"write_scope":"single-item","cas_scope":"exact-byte-sha256"}` |
331
+ | `result.limits.cross_worktree_coordination` | `false` |
332
+
333
+ `result.operations.work_claim.supported` remains independently derived from
334
+ Git-common-directory discovery: it is `true` when claims are visible there and
335
+ `false` otherwise. This core capability envelope reports the unbound default
336
+ claim profile. It does not read a provisioned ledger namespace and does not
337
+ prove that a ledger is provisioned. Use
338
+ `claim capabilities --ledger <dir> --json` to discover whether that ledger is
339
+ unprovisioned and advisory or provisioned and merge-coordinated. Automation
340
+ MUST gate `publish-claimed` on that ledger-specific response. Neither result
341
+ elevates the fixed mutation scope. Version 2 keeps
342
+ `transition.write_scope: "single-item"`,
343
+ `transition.cas_scope: "exact-byte-sha256"`, and
344
+ `limits.multi_item_atomicity: false`. Work-claim visibility across worktrees is
345
+ never cross-worktree mutation coordination.
346
+
347
+ Because capabilities takes no ledger content and does not write, it still
348
+ cannot prove that a particular filesystem supports the required atomic
349
+ no-clobber publication primitive. Create probes or attempts that primitive for
350
+ the configured ledger and returns capability-unavailable unchanged when it is
351
+ unavailable.
352
+
353
+ Directory fsync is capability-reported best effort. Neither a successful file
354
+ sync nor a directory sync is a universal power-loss durability guarantee.
355
+
356
+ ## 5. Reads, revisions, and lossless inspection
357
+
358
+ An item revision is:
359
+
360
+ sha256:<64 lowercase hexadecimal characters>
361
+
362
+ The digest covers the complete raw item-file bytes. It is not computed from
363
+ normalized YAML, JSON, line endings, the Markdown body, or a Git object.
364
+
365
+ Inspect loads and validates the complete ledger. For the requested item, one
366
+ validated regular-file handle supplies one raw byte buffer. The implementation
367
+ must parse the item, derive its normalized core view and body, compute the
368
+ revision, and produce source_base64 from that same buffer. It must never combine
369
+ parsed data from one file version with a digest or source from another.
370
+
371
+ The successful item shape is:
372
+
373
+ ~~~json
374
+ {
375
+ "id": "wb_...",
376
+ "path": "wb_....md",
377
+ "revision": "sha256:<lowercase hex digest>",
378
+ "source_encoding": "base64",
379
+ "source_media_type": "text/markdown; charset=utf-8",
380
+ "source_base64": "<RFC 4648 base64 without line breaks>",
381
+ "core": {},
382
+ "body": "exact decoded body suffix"
383
+ }
384
+ ~~~
385
+
386
+ path is a forward-slash, ledger-relative display path and is not identity.
387
+ Decoding source_base64 recovers every original byte. The decoded bytes must
388
+ hash to revision and must be valid UTF-8.
389
+
390
+ The promotion rule: the item level carries addressing and payload members
391
+ only — id, path, revision, source_encoding, source_media_type, source_base64,
392
+ and body. Every frontmatter field is read from core. id is the single member
393
+ present at both levels, because the item level must identify the resource it
394
+ addresses; no other frontmatter field is promoted, and none will be.
395
+
396
+ core contains only fields defined by supported item schema versions:
397
+
398
+ - schema_version, id, title, kind, status, created, updated;
399
+ - provenance.source and provenance.recorded_at;
400
+ - depends_on and related;
401
+ - optional parent, snoozed_until, completed, killed, archived;
402
+ - optional number and priority, the caller-supplied schema fields;
403
+ and
404
+ - decisions with only their defined action, date, summary, rationale, and
405
+ optional rollup id/status members.
406
+
407
+ Permitted unknown top-level fields, extra provenance members, and unknown
408
+ members or YAML representations inside extension data are omitted from core.
409
+ They remain recoverable from source_base64. This avoids pretending that every
410
+ valid YAML node, tag, anchor, integer, or mapping key has a lossless ordinary
411
+ JSON representation.
412
+
413
+ body is the exact UTF-8-decoded byte suffix after the closing frontmatter
414
+ delimiter. It is not trimmed or normalized. An item with no bytes after the
415
+ delimiter LF has body "". A conventional blank line before Markdown means body
416
+ begins with LF.
417
+
418
+ item-not-found exits 2 and has details containing only id. ledger-invalid exits
419
+ 3 and has details.validation_errors equal to the existing deterministic
420
+ SPEC.md validation-error sequence. Neither read-only error has state.
421
+
422
+ ## 6. Cooperative lock and snapshot protocol
423
+
424
+ Per-ID locks live at:
425
+
426
+ <ledger>/.wowbagger-locks/<item-id>.lock
427
+
428
+ Create locks its requested new ID and every existing parent or dependency ID.
429
+ Transition locks the target, its referenced parent and dependency items, every
430
+ item whose depends_on contains the target, and every direct child when the
431
+ target is an epic. IDs are unique and acquired in ascending immutable-ID order.
432
+
433
+ Because referring items can be discovered while another cooperative writer is
434
+ finishing, lock acquisition is a closure loop:
435
+
436
+ 1. load the complete valid ledger and determine the relevant IDs;
437
+ 2. acquire the ordered lock set;
438
+ 3. re-read the complete ledger and recompute the relevant set;
439
+ 4. if the set expanded, release all locks and retry with the expanded ordered
440
+ set; otherwise continue; and
441
+ 5. fail unchanged with operation-failed if a bounded implementation retry limit
442
+ is exhausted.
443
+
444
+ Cooperative create operations that add parent or dependency edges obey the same
445
+ protocol, so holding the target ID lock prevents a new incoming edge from being
446
+ published during terminalization.
447
+
448
+ After the stable lock set is held, the backend re-reads, re-parses, revalidates,
449
+ and re-hashes the target and every relevant referenced or referring item from
450
+ their validated file handles. It then validates the complete current ledger.
451
+ Transition compares expected_revision only after this locked re-read.
452
+
453
+ A transition constructs an in-memory complete ledger with exactly the proposed
454
+ target bytes substituted, then runs complete-ledger validation again before
455
+ publication. If another item would need mutation, the target is not published.
456
+
457
+ ### Lock metadata
458
+
459
+ A writer creates the lock file exclusively as valid UTF-8 JSON no larger than
460
+ 4096 bytes:
461
+
462
+ ~~~json
463
+ {
464
+ "lock_version": 1,
465
+ "item_id": "wb_...",
466
+ "operation": "transition",
467
+ "writer_id": "opaque-random-value",
468
+ "started_at": "2030-01-10T12:34:56.789Z"
469
+ }
470
+ ~~~
471
+
472
+ writer_id is an opaque ASCII string of 1 through 128 characters. operation is
473
+ create, transition, or patch. The remaining values must match their schema and
474
+ lock path. Metadata contains no credentials, user name, host name, or command
475
+ arguments.
476
+
477
+ A reader reads at most 4097 bytes. A lock larger than 4096 bytes, invalid UTF-8,
478
+ duplicate-key JSON, invalid JSON, unknown members, or invalid field values is
479
+ still held. lock-held details set owner to null and owner_diagnostic to exactly
480
+ one of too-large, invalid-utf8, duplicate-key, invalid-json, or invalid-shape.
481
+ Valid metadata returns owner and owner_diagnostic null. Raw invalid bytes are
482
+ never returned.
483
+
484
+ Locks are never removed automatically merely because started_at is old. Manual
485
+ recovery follows ADR 0003.
486
+
487
+ ## 7. Create
488
+
489
+ ### Request
490
+
491
+ Create accepts exactly:
492
+
493
+ ~~~json
494
+ {
495
+ "id": "wb_...",
496
+ "item": {
497
+ "title": "Map the fictional route",
498
+ "kind": "task",
499
+ "provenance": {
500
+ "source": "fixture/mutations",
501
+ "recorded_at": "2030-01-10T12:34:56.789Z"
502
+ },
503
+ "depends_on": [],
504
+ "related": []
505
+ },
506
+ "body": "\nA fictional Markdown body.\n"
507
+ }
508
+ ~~~
509
+
510
+ | Member | Required | Rules |
511
+ |---|---:|---|
512
+ | id | Yes | Caller-generated canonical Wowbagger ULID. |
513
+ | item | Yes | Frontmatter draft mapping. |
514
+ | item.title | Yes | Non-empty schema string. |
515
+ | item.kind | Yes | task or epic. |
516
+ | item.provenance | Yes | Valid required provenance; extension members are preserved. |
517
+ | item.depends_on | Yes | Valid relation list. |
518
+ | item.related | No | Valid relation list; omitted means empty. |
519
+ | item.parent | No | Valid epic ID. |
520
+ | item.snoozed_until | No | Valid ISO calendar date. |
521
+ | item.number | No | Positive integer; the caller-supplied schema handle. |
522
+ | item.priority | No | Non-negative integer; the caller-supplied schema priority. |
523
+ | item extension members | No | Permitted schema extensions. |
524
+ | body | Yes | JSON string; empty and LF-leading strings are distinct and valid. |
525
+
526
+ If a file named by `--input` cannot be read before a request ID is known,
527
+ create or transition returns `invalid-request` with one `invalid-value` issue at
528
+ `/input`, the stable message `Request input could not be read.`, and mutation
529
+ state `unchanged`.
530
+
531
+ The caller generates id with the timestamp for the intended creation instant
532
+ and at least 80 bits of collision-resistant entropy. Create validates its
533
+ canonical form before acquiring its per-ID lock. id is not accepted inside
534
+ item.
535
+
536
+ No caller writes the base32 encoding themselves: `wowbagger mint-id --json`
537
+ prints a canonical ID for now, `--date` selects another creation date, and
538
+ `src/mint.js` exports `mintId` so an adapter or plugin can mint one without
539
+ shelling out.
540
+
541
+ item must not supply schema_version, id, status, created, updated, completed,
542
+ killed, archived, decisions, or body. For a non-empty valid ledger, create
543
+ inserts the schema_version already used by every existing item. For an empty
544
+ ledger, it inserts schema_version 2. Create returns that selection in
545
+ `result.item.core.schema_version`. It also inserts status triage, created
546
+ and updated equal to the UTC date encoded by id, and related [] when omitted.
547
+ It adds no terminal date or decision.
548
+
549
+ The candidate complete ledger must validate before publication. After the
550
+ requested-ID lock and locked revalidation, create applies this collision
551
+ precedence:
552
+
553
+ 1. If the requested ID exists anywhere in the ledger, return id-collision,
554
+ exit 4, and unchanged. details contain id, the existing item's
555
+ ledger-relative path, and actual_revision.
556
+ 2. Otherwise, lstat the default path without following symbolic links. If any
557
+ filesystem object occupies it, return path-collision, exit 4, and unchanged.
558
+ details contain id, the ledger-relative default path, and occupant_kind.
559
+ occupant_kind is exactly item or directory. For item it also contains
560
+ occupying_id; for directory occupying_id is absent. The message is exactly
561
+ "The default item path is occupied by a different item." This stable human
562
+ message is shared by both kinds; automation distinguishes them through
563
+ occupant_kind.
564
+ 3. Otherwise, continue to candidate validation.
565
+
566
+ Create never chooses a different ID or path for the caller. Collision checks
567
+ do not infer identity from a filename: an item whose frontmatter has another
568
+ ID occupies a path without claiming the requested ID. Complete-ledger
569
+ validation precedes these collision checks. A symbolic link, special file, or
570
+ invalid regular .md occupant therefore produces ledger-invalid rather than
571
+ path-collision. A real directory whose name ends in .md and whose contents
572
+ leave the complete ledger valid is valid input under SPEC.md and produces
573
+ path-collision when it occupies the default path.
574
+
575
+ The default final path is:
576
+
577
+ <ledger>/<id>.md
578
+
579
+ The filename is a portable default, not identity. The request cannot supply an
580
+ arbitrary path: the no-clobber publication protocol and the collision rules
581
+ are defined against the identity-derived default, and an arbitrary path would
582
+ let a caller aim them at anything in the ledger. A repository that prefers a
583
+ naming convention renames the file in Git after create — a reviewable change
584
+ that validation does not care about, because identity resolves from
585
+ frontmatter, never from the filename.
586
+
587
+ ### Body
588
+
589
+ The generated source uses UTF-8 and LF for generated frontmatter lines. The
590
+ closing delimiter includes its required LF; body bytes are appended exactly as
591
+ the UTF-8 encoding of request.body.
592
+
593
+ - body "" produces no byte after the closing delimiter LF;
594
+ - body "\nText\n" produces the conventional blank line before Text; and
595
+ - create never invents, trims, or removes a body newline.
596
+
597
+ ### Atomic no-clobber publication
598
+
599
+ Create must never reveal an empty or partially written final item. It:
600
+
601
+ 1. creates a uniquely named non-.md temporary file in the final directory;
602
+ 2. writes the complete intended bytes;
603
+ 3. calls fsync or the platform-equivalent sync on the completed open temporary
604
+ file and waits for success;
605
+ 4. publishes the complete temporary file to the absent final name with one
606
+ atomic no-clobber primitive, such as same-filesystem hard-link publication;
607
+ 5. re-opens the final regular file without following a symbolic link and
608
+ verifies its exact expected bytes;
609
+ 6. attempts directory fsync when supported; and
610
+ 7. removes the temporary name and locks best effort.
611
+
612
+ A check-then-rename and a rename that can replace a destination are not
613
+ no-clobber publication. Opening the final path and copying bytes into it is
614
+ forbidden. Hard links are not assumed portable: if the configured filesystem
615
+ or platform cannot provide an atomic no-clobber primitive, create returns
616
+ capability-unavailable unchanged and cleans the temporary file best effort.
617
+
618
+ If a failure follows the publication attempt, the backend re-inspects the
619
+ known final path:
620
+
621
+ - exact expected bytes present means state committed; cleanup or sync failure
622
+ returns post-commit-recovery-required;
623
+ - proven absence means state unchanged and operation-failed; and
624
+ - a different or unreadable result means write-outcome-unknown with state
625
+ unknown.
626
+
627
+ Directory sync failure cannot turn verified exact bytes into unchanged. It
628
+ also does not justify a power-loss durability promise.
629
+
630
+ ### Recovery by known ID
631
+
632
+ After a crash or unknown create result, automation inspects the caller-known ID.
633
+ If exact intended bytes are present, the create committed. If a different item
634
+ exists, the result is a collision requiring human resolution. Retry is allowed
635
+ only after inspect returns item-not-found and any reported lock or temporary
636
+ artifact has been handled under the audited recovery procedure. The atomic
637
+ no-clobber publication still protects an intervening creator.
638
+
639
+ Successful create returns state committed and the inspect item shape from
640
+ section 5.
641
+
642
+ ## 8. Transition
643
+
644
+ ### Request
645
+
646
+ Transition accepts exactly:
647
+
648
+ ~~~json
649
+ {
650
+ "id": "wb_...",
651
+ "expected_revision": "sha256:<64 lowercase hexadecimal characters>",
652
+ "to_status": "backlog",
653
+ "date": "2030-01-11",
654
+ "decision": {
655
+ "summary": "Accept the fictional item.",
656
+ "rationale": "The fictional scope is ready."
657
+ }
658
+ }
659
+ ~~~
660
+
661
+ | Member | Required | Rules |
662
+ |---|---:|---|
663
+ | id | Yes | Canonical existing item ID. |
664
+ | expected_revision | Yes | Exact lowercase SHA-256 token returned by inspect. |
665
+ | to_status | Yes | Target in the allowed edge table. |
666
+ | date | Yes | ISO calendar date not earlier than existing created or updated. |
667
+ | decision | Conditional | Required exactly when the edge appends evidence. |
668
+ | decision.summary | Conditional | Required non-empty string. |
669
+ | decision.rationale | Conditional | Required non-empty string. |
670
+
671
+ The request cannot supply action, decision date, rollup, body, frontmatter
672
+ patches, or terminal dates. Wowbagger derives them. A decision is rejected for
673
+ an edge that does not append one.
674
+
675
+ ### Allowed edges
676
+
677
+ | Kind | From | To | Generated evidence |
678
+ |---|---|---|---|
679
+ | task or epic | triage | backlog | append accept decision |
680
+ | task or epic | triage | killed | set killed; append kill decision |
681
+ | task | backlog | in-progress | none |
682
+ | task | backlog | archived | set archived; append archive decision |
683
+ | task | backlog | killed | set killed; append kill decision |
684
+ | task | in-progress | backlog | none |
685
+ | task | in-progress | done | set completed; append complete decision |
686
+ | task | in-progress | killed | set killed; append kill decision |
687
+ | epic | backlog | done | set completed; append complete decision with generated rollup |
688
+ | epic | backlog | archived | set archived; append archive decision |
689
+ | epic | backlog | killed | set killed; append kill decision |
690
+ | task or epic | archived | backlog | clear archived; append restore decision |
691
+
692
+ Every transition sets updated to request.date. request.date must be greater than
693
+ or equal to both the existing created and existing updated dates. It cannot
694
+ move updated backwards.
695
+
696
+ A non-terminal target clears every terminal date. A terminal target sets only
697
+ its matching date. Existing decisions are retained and the required decision is
698
+ appended. Epic complete rollup is generated from every direct child, each of
699
+ which must already be done or killed, ordered by immutable ID.
700
+
701
+ No other edge is supported. Epics never enter in-progress; done and killed
702
+ items do not reopen; and transition cannot edit identity, title, relations,
703
+ parent, snooze, body, or extension fields. A schema version 2 done transition
704
+ therefore retains the target's depends_on and related lists unchanged.
705
+
706
+ ### Lossless preservation
707
+
708
+ Transition preserves body bytes exactly. It preserves every permitted extension
709
+ YAML node, including tags, aliases, mapping structure, scalar precision, and
710
+ extra provenance members, without coercing it through the normalized core JSON
711
+ view. The implementation may reserialize controlled core frontmatter, but must
712
+ retain extension semantics and must not drop extension nodes or comments
713
+ attached to them.
714
+
715
+ The successful response exposes the new complete source_base64 and revision, so
716
+ the caller can verify the exact rewritten bytes.
717
+
718
+ ### Revision and candidate validation
719
+
720
+ After the stable lock-set re-read in section 6, a target hash mismatch returns
721
+ revision-conflict, exit 4, and unchanged:
722
+
723
+ ~~~json
724
+ {
725
+ "id": "wb_...",
726
+ "expected_revision": "sha256:<request digest>",
727
+ "actual_revision": "sha256:<locked current digest>"
728
+ }
729
+ ~~~
730
+
731
+ For a matching revision, the backend builds the one-item proposed ledger and
732
+ validates it completely before publication.
733
+
734
+ Every item whose depends_on contains the target is considered when the target
735
+ would become done, killed, or archived, regardless of the referring item's own
736
+ status. Every direct child is considered for an epic transition. The backend
737
+ does not limit relation checks to ready or non-terminal referring items. For a
738
+ schema version 2 done transition, incoming depends_on references become
739
+ satisfied and remain in place; they do not require another item mutation and
740
+ do not create a multi-item blocker.
741
+
742
+ ### Deterministic precondition issues
743
+
744
+ A valid request that cannot produce a valid one-item successor returns
745
+ transition-precondition-failed, exit 2, and unchanged when no multi-item blocker
746
+ exists. details are:
747
+
748
+ ~~~json
749
+ {
750
+ "id": "wb_...",
751
+ "issues": [
752
+ {
753
+ "code": "date-before-updated",
754
+ "field": "date",
755
+ "message": "Transition date must not be earlier than the current updated date.",
756
+ "related_ids": []
757
+ }
758
+ ]
759
+ }
760
+ ~~~
761
+
762
+ Issue codes are date-before-created, date-before-updated, invalid-edge,
763
+ live-dependencies, or nonterminal-children. related_ids are unique immutable IDs
764
+ sorted ascending. Issues sort by code, then field, then their related ID
765
+ sequence. Date checks are all reported: a date earlier than both created and
766
+ updated produces both date issues.
767
+
768
+ For a schema version 1 done transition, any non-empty depends_on list produces
769
+ live-dependencies and related_ids contains the complete list. For a schema
770
+ version 2 done transition, only targets whose status is not done produce
771
+ live-dependencies, and related_ids contains only those unsatisfied prerequisite
772
+ IDs. A successful schema version 2 done transition retains every satisfied ID
773
+ in depends_on and does not append it to related.
774
+
775
+ ### Deterministic multi-item refusal
776
+
777
+ If any other item would need mutation, transition returns
778
+ atomic-scope-required, exit 5, and unchanged. It aggregates every blocker:
779
+
780
+ ~~~json
781
+ {
782
+ "id": "wb_...",
783
+ "blockers": [
784
+ {
785
+ "code": "child-disposition",
786
+ "item_id": "wb_...",
787
+ "field": "parent"
788
+ },
789
+ {
790
+ "code": "dependent-disposition",
791
+ "item_id": "wb_...",
792
+ "field": "depends_on"
793
+ }
794
+ ],
795
+ "precondition_issues": []
796
+ }
797
+ ~~~
798
+
799
+ Blocker codes are:
800
+
801
+ | Code | Condition |
802
+ |---|---|
803
+ | dependent-cleanup | Schema version 1 only: a done target is still present in another item's depends_on, regardless of that item's status. Schema version 2 keeps this reference as satisfied prerequisite history and does not produce this blocker. |
804
+ | dependent-disposition | A killed or archived target is still present in another item's depends_on, regardless of that item's status. |
805
+ | child-disposition | A killed or archived epic has a direct triage, backlog, or in-progress child. |
806
+
807
+ Blockers are unique by code, item_id, and field, then sorted by code, item_id,
808
+ and field using ascending code-point order. An item that is both a child and a
809
+ dependent contributes both blockers. precondition_issues uses the schema above
810
+ and is included even when nonempty, so combined conditions are never hidden by
811
+ an ambiguous precedence rule.
812
+
813
+ If blockers is nonempty, atomic-scope-required is returned after collecting
814
+ both all blockers and all precondition issues. If blockers is empty but
815
+ precondition issues is nonempty, transition-precondition-failed is returned.
816
+ Only an empty set of both proceeds to the candidate validator.
817
+
818
+ ### Candidate-validation refusal and precedence
819
+
820
+ The candidate complete-ledger validator is the final authority. When it rejects
821
+ a proposed single-item create or transition for a reason not already
822
+ represented by a more specific collision, multi-item blocker, or transition
823
+ precondition, the command returns candidate-invalid, exit 2, and unchanged:
824
+
825
+ ~~~json
826
+ {
827
+ "id": "wb_...",
828
+ "validation_errors": [
829
+ {
830
+ "path": "ledger/wb_....md",
831
+ "field": "parent",
832
+ "code": "nonterminal-child-of-terminal-epic",
833
+ "message": "Triage child wb_... cannot remain under archived epic wb_...; terminalize or reparent the child before the epic transition."
834
+ }
835
+ ]
836
+ }
837
+ ~~~
838
+
839
+ validation_errors is exactly the deterministic SPEC.md validator sequence for
840
+ the complete proposed ledger, sorted by path, field, code, and message under
841
+ the existing validator rules. The response message is exactly "The proposed
842
+ item would make the ledger invalid."
843
+
844
+ For transition, refusal precedence after locked revalidation is: revision and
845
+ lock conflicts; aggregate all multi-item blockers and ordinary precondition
846
+ issues; atomic-scope-required when blockers exist; otherwise
847
+ transition-precondition-failed when ordinary issues exist; otherwise
848
+ candidate-invalid when the candidate validator reports errors. For create,
849
+ id-collision precedes path-collision as specified in section 7, and both
850
+ precede candidate-invalid. No validator issue already represented by the
851
+ selected more-specific response is duplicated in a second envelope. A proposed
852
+ ledger that remains invalid for any reason is never published.
853
+
854
+ Transition publication uses a fully written and synced same-directory
855
+ temporary file followed by the platform's existing-file atomic replacement
856
+ primitive. It then re-reads exact final bytes. This remains a local filesystem
857
+ operation without universal crash durability or hostile-writer protection.
858
+
859
+ ## 9. Patch
860
+
861
+ Patch changes the caller-supplied fields common to schema versions 1 and 2 of
862
+ one existing item — nothing else. It runs under the same per-ID lock, locked re-read,
863
+ exact-byte revision compare-and-swap, candidate complete-ledger validation,
864
+ and atomic same-path publication protocol as transition (section 6), and it
865
+ shares transition's envelopes, exits, and recovery rules except where this
866
+ section says otherwise.
867
+
868
+ ### Request
869
+
870
+ Patch accepts exactly:
871
+
872
+ ~~~json
873
+ {
874
+ "id": "wb_...",
875
+ "expected_revision": "sha256:...",
876
+ "date": "2030-01-11",
877
+ "set": { "priority": 3, "number": null }
878
+ }
879
+ ~~~
880
+
881
+ | Member | Required | Rules |
882
+ |---|---:|---|
883
+ | id | Yes | Canonical existing item ID. |
884
+ | expected_revision | Yes | Exact lowercase SHA-256 token returned by inspect. |
885
+ | date | Yes | ISO calendar date not earlier than existing created or updated. |
886
+ | set | Yes | Mapping naming at least one patchable field. |
887
+
888
+ The patchable field set is exactly `number` and `priority`. A set member
889
+ outside it is an invalid-request issue at its /set pointer — the boundary is
890
+ stated here, not discovered from the implementation. An integer value sets
891
+ the field: number must be a positive integer, priority a non-negative
892
+ integer. null removes the field.
893
+
894
+ Patch sets updated to request.date. A date earlier than the existing created
895
+ or updated date returns patch-precondition-failed, exit 2, and unchanged,
896
+ with date-before-created and date-before-updated issue codes matching
897
+ transition's.
898
+
899
+ Patch appends no decision: the ledger's Git history is the audit trail for a
900
+ consumer-field change. Identity, lifecycle, title, relations, provenance,
901
+ snooze, decisions, body, and extension members cannot change through patch.
902
+
903
+ Patch never mutates another item. A candidate ledger that flags any other
904
+ item — for example a duplicate-number collision with an existing handle —
905
+ returns candidate-invalid, exit 2, and unchanged, and both items keep their
906
+ bytes.
907
+
908
+ ### Serialization
909
+
910
+ An updated field is rewritten in place. A newly added number serializes
911
+ directly after id; a newly added priority directly after kind. Body bytes and
912
+ extension nodes are preserved exactly as in transition.
913
+
914
+ ### Adapter advertisement
915
+
916
+ The version 1 capabilities envelope and the version 1 adapter core probe do
917
+ not advertise patch. Their operation and command lists are pinned by the
918
+ version 1 adapter contract, so widening them is an adapter contract version
919
+ change. Version 2 makes that change: its capability envelope includes the exact
920
+ `operations.patch` member from section 4 and adapter contract version 2 inserts
921
+ `patch` between `inspect` and `ready` in the fixed core-command order. The
922
+ fail-closed direction is preserved: a version 1 consumer never sees the wider
923
+ surface, and automation that plans from version 1 capabilities does not use
924
+ patch.
925
+
926
+ ## 10. Errors, artifacts, and recovery
927
+
928
+ ### Stable error details
929
+
930
+ | Code | Required details |
931
+ |---|---|
932
+ | invalid-request | issues |
933
+ | item-not-found | id |
934
+ | ledger-invalid | validation_errors |
935
+ | transition-precondition-failed | id, issues |
936
+ | patch-precondition-failed | id, issues |
937
+ | candidate-invalid | id, validation_errors |
938
+ | revision-conflict | id, expected_revision, actual_revision |
939
+ | lock-held | id, lock_path, owner, owner_diagnostic |
940
+ | id-collision | id, path, actual_revision |
941
+ | path-collision | id, path, occupant_kind; occupying_id iff occupant_kind is item |
942
+ | atomic-scope-required | id, blockers, precondition_issues |
943
+ | capability-unavailable | capability, reason, recovery_artifacts, recovery_artifacts_truncated |
944
+ | operation-failed | id, operation, reason, recovery_artifacts, recovery_artifacts_truncated |
945
+ | post-commit-recovery-required | id, revision, recovery_artifacts, recovery_artifacts_truncated |
946
+ | write-outcome-unknown | id, recovery_artifacts, recovery_artifacts_truncated |
947
+
948
+ ledger-invalid and candidate-invalid validation_errors are exactly the
949
+ deterministic SPEC.md error sequence for the current or proposed ledger,
950
+ respectively. Error messages are stable human summaries; automation branches
951
+ on code, mutation state, and documented details.
952
+
953
+ ### Recovery artifact shape
954
+
955
+ recovery_artifacts is an array of at most 16 objects:
956
+
957
+ ~~~json
958
+ {
959
+ "path": ".wowbagger-tmp-wb_...",
960
+ "kind": "temporary-file",
961
+ "sha256": "sha256:<lowercase hex digest>",
962
+ "size_bytes": 321
963
+ }
964
+ ~~~
965
+
966
+ path is ledger-relative, uses forward slashes, and is at most 1024 Unicode
967
+ scalar values. kind is temporary-file, lock-file, or final-item. sha256 and
968
+ size_bytes are null when the artifact cannot be safely read; otherwise the
969
+ digest covers its exact bytes and size_bytes is a nonnegative integer.
970
+
971
+ Artifacts are unique by path and sorted by path, then kind. If more than 16 are
972
+ observed, the first 16 are returned and recovery_artifacts_truncated is true.
973
+ Otherwise it is false. No artifact content is returned.
974
+
975
+ ### Deterministic operation failures and state mapping
976
+
977
+ operation-failed is a mutation-only error. operation is exactly one of:
978
+
979
+ - lock-closure;
980
+ - prepare-temporary;
981
+ - sync-temporary;
982
+ - publish;
983
+ - verify-publication; or
984
+ - cleanup.
985
+
986
+ reason is exactly retry-limit-exhausted, io-error, or verification-failed.
987
+ retry-limit-exhausted is used only with lock-closure. verification-failed is
988
+ used only when verification proves the expected publication is absent for
989
+ create or proves the original bytes remain for transition or patch. All other
990
+ handled filesystem failures use io-error. Platform exception text, errno names,
991
+ numeric OS error codes, and absolute paths are not members of the normative
992
+ JSON envelope and cannot alter operation or reason.
993
+
994
+ Before a publication attempt, lock-closure, prepare-temporary, sync-temporary,
995
+ or cleanup failure returns operation-failed with state unchanged. After a
996
+ publication attempt, the backend must inspect the final path before choosing a
997
+ response:
998
+
999
+ - exact expected final bytes produce state committed; a remaining verify or
1000
+ cleanup problem is post-commit-recovery-required;
1001
+ - proven absence for create or exact original bytes for transition or patch
1002
+ produce operation-failed with state unchanged, operation publish or
1003
+ verify-publication as applicable, and reason io-error or
1004
+ verification-failed as applicable; and
1005
+ - unreadable, different, or otherwise indeterminate final bytes produce
1006
+ write-outcome-unknown with state unknown.
1007
+
1008
+ operation-failed always contains the canonical target id and the bounded
1009
+ recovery artifact fields. Its message is exactly "The mutation operation
1010
+ failed before a commit was established."
1011
+
1012
+ A normal handled unchanged failure removes its own temporary files and locks.
1013
+ A cleanup failure reports the remaining bounded artifacts. Locks are never
1014
+ auto-broken by age. Clients must inspect after committed-recovery or unknown
1015
+ outcomes and must not retry blindly.
1016
+
1017
+ ## 11. Normative design vectors
1018
+
1019
+ The synthetic vectors under
1020
+ [spec/fixtures/mutations](../spec/fixtures/mutations/README.md) are the
1021
+ executable compatibility target for this contract. Each case has a manifest with
1022
+ arguments, input transport, expected exit, exact stdout/stderr expectations,
1023
+ and before/after file digests.
1024
+
1025
+ The matrix covers capability honesty; lossless inspect and failure envelopes;
1026
+ caller-known create identity, file/stdin equivalence, strict input, ID and path
1027
+ collision, candidate validation, body boundaries, publication limitation and
1028
+ recovery outcomes; transition revision, locking, date monotonicity, lifecycle
1029
+ edges, the schema version 1 dependent-cleanup blocker and the other two
1030
+ multi-item reasons, terminal referrers, combined blockers,
1031
+ candidate validation, deterministic operation failures, and
1032
+ unchanged/committed/unknown states; and patch field boundaries, CAS,
1033
+ serialization, and preconditions.
1034
+
1035
+ The runtime executes every vector as a black-box CLI test, including exact
1036
+ response bytes and the complete before/after ledger snapshot.