@journeykit/canon 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,21 +26,94 @@ diagnostic and reports `clean: false`.
26
26
  ## The starter policy `canon init` writes
27
27
 
28
28
  A fresh `canon init` writes `.canon/policy.yaml` (skip it with
29
- `--no-policy`; an existing file is never overwritten):
29
+ `--no-policy`; an existing file is never overwritten). `--policy
30
+ light|standard|strict` picks how much it asks for; the default is
31
+ `strict`:
32
+
33
+ | Level | `spec_coverage` |
34
+ |---|---|
35
+ | `light` | `require_evidence: true` — every scenario needs evidence |
36
+ | `standard` | light, plus `require_cases: [failure]` — every feature surface needs a `@case:failure` scenario |
37
+ | `strict` | standard, plus `require_review: {}` — verifying/shipped subjects need an independent review |
30
38
 
31
39
  ```yaml
40
+ # canon policy level: strict
32
41
  spec_coverage:
33
42
  require_evidence: true # every scenario needs evidence
34
43
  require_cases: [failure] # every feature surface needs a @case:failure scenario
35
44
  require_review: {} # verifying/shipped subjects need an independent review
36
45
  ```
37
46
 
38
- The file carries comments explaining each field. It loads with zero
39
- diagnostics. It is what `canon gate check` grades the work against, so a
40
- human should review and approve it, and every later change to it. The
41
- agent doing the work should not write its own grading policy. Narrow it
42
- with `scope: [building, verifying]` to check evidence only for subjects
43
- in those statuses (see `canon-gate`).
47
+ The first line names the level. The file carries comments explaining
48
+ each field, and a `light` or `standard` starter carries the next levels'
49
+ keys as commented lines: to raise the level, uncomment them and change
50
+ the level line, and a human approves the edit (next section). An
51
+ unknown `--policy` value exits 2, and so does `--policy` with `--no-policy`.
52
+
53
+ `canon status` prints the level under the policy settings
54
+ (`level: strict`, or `level: light — raise it by …` with how to raise
55
+ it) and in `--json` as `policy.level`. It reads the level line when the
56
+ file's leading comment block has one naming a known level; otherwise it
57
+ infers the level from the `spec_coverage` section, which must match a
58
+ level exactly (no `scope`, `exclude_lanes`, `require_audit`, or
59
+ non-default `require_review` settings); anything else is `custom`.
60
+ `policy.level` is `null` when there is no policy.
61
+
62
+ Every level loads with zero diagnostics. The policy is what `canon gate
63
+ check` grades the work against, so a human should review and approve
64
+ it, and every later change to it (next section). The agent doing the
65
+ work should not write its own grading policy. Narrow it with `scope:
66
+ [building, verifying]` to check evidence only for subjects in those
67
+ statuses (see `canon-gate`).
68
+
69
+ ## Approving the policy
70
+
71
+ ```bash
72
+ canon policy approve --by <who> [--note <text>] [--repo <dir>]
73
+ ```
74
+
75
+ A human approves the policy after reviewing `.canon/policy.yaml`, in the
76
+ same answer that approves a subject's brief (0.16; see the skill's working
77
+ loop). The human runs this, or the agent runs it right after that answer
78
+ as `canon policy approve --by <the human> --note "<their words>"`. It
79
+ writes `.canon/policy.approval.json` directly (nothing to promote):
80
+
81
+ ```json
82
+ {
83
+ "policy_digest": "sha256:<sha256 of the policy.yaml bytes>",
84
+ "approved_by": "<who>",
85
+ "approved_at": "2026-10-11T09:00:00Z",
86
+ "note": "<text, or null>"
87
+ }
88
+ ```
89
+
90
+ Commit it with the policy: the file is tracked, and neither canon's
91
+ `.gitignore` nor the one `canon init` writes ignores it. Running the
92
+ command again replaces the record. With no `.canon/policy.yaml` it is
93
+ refused (exit `2`), and so is an empty `--by`.
94
+
95
+ While a policy exists with no approval, `canon gate check` prints after
96
+ its result:
97
+
98
+ ```
99
+ advisory: policy not approved: .canon/policy.yaml has no approval record (.canon/policy.approval.json); a human reviews it and runs `canon policy approve --by <who>`
100
+ ```
101
+
102
+ Once the policy's bytes no longer match the approved digest, it prints
103
+ this instead:
104
+
105
+ ```
106
+ advisory: policy changed since approval: .canon/policy.yaml is sha256:<now>, but `<who>` approved sha256:<then> at <time>; a human reviews it and runs `canon policy approve --by <who>`
107
+ ```
108
+
109
+ An approval file that does not parse reads
110
+ `advisory: policy approval unreadable: …`.
111
+ `canon status` prints the same text as a `warning:` line (and in
112
+ `--json` `warnings`). The advisory never changes an exit code: canon
113
+ cannot tell whether a human or an agent ran the command, so the record
114
+ names who approved which bytes, and the advisory says when that no
115
+ longer holds. An agent runs `canon policy approve` only to record a
116
+ human's answer, never without one and never on its own behalf.
44
117
 
45
118
  ## The closed CEL profile
46
119
 
@@ -124,6 +197,40 @@ question rather than adding an expression field. Conditional logic about
124
197
  what a record *means* lives in `policy.yaml`/`applies_when:`, evaluated
125
198
  against the record's fields, never inside the record.
126
199
 
200
+ ## Sampled audits: `spec_coverage.require_audit`
201
+
202
+ Optional; absent means no audit is ever owed (a recorded `refuted` audit
203
+ is still `false-success` regardless — see `canon-gate`).
204
+
205
+ ```yaml
206
+ spec_coverage:
207
+ require_audit:
208
+ rate: 0.1 # required: a number in [0, 1]
209
+ seed: "2026-q4" # optional string; default ""
210
+ scope: [verifying, shipped] # optional subject statuses; this is the default; [] = every scenario
211
+ ```
212
+
213
+ `spec_coverage.exclude_lanes` applies. A malformed `require_audit` —
214
+ unknown key, missing `rate`, `rate` out of range or a string, bad
215
+ `scope`, `null` or a non-mapping — poisons the whole `spec_coverage`
216
+ section: the gate reports `uncovered-cell spec_coverage — ... unusable`
217
+ rather than checking anything under it.
218
+
219
+ Sampling is deterministic. A scenario is in the sample iff the first 8
220
+ bytes of
221
+
222
+ ```
223
+ sha256(seed || 0x00 || project_id || 0x00 || scenario_id)
224
+ ```
225
+
226
+ read as a big-endian u64, are less than `rate · 2^64` — computed exactly,
227
+ as a comparison against `ceil(rate · 2^64)`, so `rate: 0` samples nothing
228
+ and `rate: 1` samples everything. A sampled in-scope scenario whose
229
+ latest audit is missing or `indeterminate` is `uncovered-cell`; with
230
+ `shipped` in `scope`, `canon subject status <id> shipped` also refuses
231
+ it. `canon context` and the `canon status` policy line show
232
+ `require_audit=(rate=… seed=`…` scope=…)`.
233
+
127
234
  ## Effect-aware risk tiers
128
235
 
129
236
  The optional `risk_tiers` section is not CEL. Absent means no risk-approval
@@ -17,8 +17,10 @@ state they are in.
17
17
  The order an agent follows to claim a subject done:
18
18
 
19
19
  1. **Test-run evidence** per owned scenario (`canon evidence add
20
- --scenario-id <id> --project-id <root-id> ... --session-id <session>`,
21
- then `canon gate promote`). See `canon-gate`.
20
+ --scenario-id <id> --project-id <root-id> ... --session-id <session>`;
21
+ final when it returns under `write: { promote: auto }`, which
22
+ `canon init` writes, and followed by `canon gate promote` only under
23
+ `write.promote: manual`). See `canon-gate`.
22
24
  2. **Independent review** of the change(s) the subject adopted. Record
23
25
  each issue the reviewer raises as a Finding, fix it, then close it:
24
26
 
@@ -26,15 +28,17 @@ The order an agent follows to claim a subject done:
26
28
  canon finding add --change-id c-demo --round 1 --seq 1 \
27
29
  --severity blocker --disposition open --reviewer reviewer-2 \
28
30
  --summary "unbounded subprocess wait" --session-id review-session-1
29
- canon gate promote
30
31
  # ... fix, commit ...
31
32
  canon finding close --change-id c-demo --round 1 --seq 1 \
32
33
  --disposition fixed --resolution-sha "$(git rev-parse HEAD)"
33
- canon gate promote
34
34
  ```
35
35
 
36
+ Under `write.promote: manual` (or no `write:` key), run
37
+ `canon gate promote` after each of the two.
38
+
36
39
  3. **One Review per scenario**, by someone other than the evidence actor,
37
- from a session other than the one that attested the evidence:
40
+ from a session other than the one that attested the evidence, after
41
+ the evidence is in (the review pins what it saw):
38
42
 
39
43
  ```bash
40
44
  canon review add --project-id root --scenario-id world.demo.01 \
@@ -43,7 +47,13 @@ The order an agent follows to claim a subject done:
43
47
  --pin "$(git rev-parse HEAD)" --upstream-ref "pr#41"
44
48
  ```
45
49
 
46
- `review add` writes directly; there is nothing to promote.
50
+ `review add` writes directly; there is nothing to promote. If the
51
+ reviewer could not reach a verdict, record that instead of signing:
52
+ `--outcome indeterminate` (or `unavailable` when it could not run).
53
+ Changing the scenario or re-attesting it afterwards makes the review
54
+ stale; review it again. A reviewer of user-visible behavior attaches
55
+ the screenshots or recordings it looked at with `--artifact <path>`
56
+ (below).
47
57
 
48
58
  4. **Move the subject**: `canon subject status <id> verifying`, later
49
59
  `shipped`. See `canon-subject`.
@@ -65,22 +75,36 @@ canon finding close --change-id <c> --round <n> --seq <n> \
65
75
  --disposition fixed --resolution-sha <sha> [--session-id <id>] # or rejected / deferred / open
66
76
  ```
67
77
 
68
- Both stage a candidate; `canon gate promote` commits it, and both say so
69
- (``… — run `canon gate promote` to commit it``). `finding close`
78
+ Both stage a candidate. Under `write: { promote: auto }` (what
79
+ `canon init` writes) the same run promotes it and the success line names
80
+ the committed record (``wrote kind=finding/… — staged and promoted in this
81
+ run; nothing to promote``); under `write.promote: manual`
82
+ `canon gate promote` commits it, and the line says so (``staged … — run
83
+ `canon gate promote` to commit it``). `finding close`
70
84
  changes only the disposition (and `resolution_sha` with it); severity,
71
85
  reviewer, summary, and refs are copied from the committed record. It
72
86
  appends a second version at the same natural key — the first stays. Its
73
87
  `--actor-id` and `--session-id` name who authored the transition; the
74
88
  raiser's stay on the first version.
75
89
 
90
+ Every sha flag (`--reviewed-sha`, `--resolution-sha`, `--introduced-by`)
91
+ takes a full 40-character sha or a short one (4 or more lowercase hex
92
+ characters). A short sha is resolved with `git rev-parse --verify
93
+ <sha>^{commit}` in the repo, and the record stores the full sha. It is
94
+ refused (exit `2`) when it names no commit, when more than one commit
95
+ starts with it (pass more characters), when it only resolves by peeling a
96
+ tag, or outside a git work tree.
97
+
76
98
  ### A defect the author found in its own work
77
99
 
78
- Record it like any other finding, with `--reviewer` and `--actor-id` set
79
- to the author's own id. Nothing refuses a finding whose reviewer is also
80
- the implementer, and an open `blocker` the author raised blocks under
81
- `block_on_findings` exactly like a reviewer's. `--introduced-by` names the
82
- commit that introduced the defect, not who found it: `--introduced-by self`
83
- is a usage error (exit 2) that points at `--reviewer`.
100
+ Record it like any other finding, at the moment you find it and before
101
+ review, with `--reviewer` and `--actor-id` set to your own id; never ask
102
+ the reviewer to record it later under theirs. Nothing refuses a finding
103
+ whose reviewer is also the implementer, and an open `blocker` the author
104
+ raised blocks under `block_on_findings` exactly like a reviewer's.
105
+ `--introduced-by` names the commit that introduced the defect, not who
106
+ found it: `--introduced-by self` is a usage error (exit 2) that points at
107
+ `--reviewer`.
84
108
 
85
109
  ### Severities
86
110
 
@@ -113,19 +137,101 @@ fixed finding reopened with `--disposition open` counts as open again.
113
137
  ```bash
114
138
  canon review add --project-id <root-id> --scenario-id <id> \
115
139
  --reviewer <who> --pin <sha-or-ref> --role <role> \
116
- (--upstream-ref <ref> | --original-spec-ref <ref>) [--actor-id <id>] [--session-id <id>]
140
+ (--upstream-ref <ref> | --original-spec-ref <ref>) [--actor-id <id>] [--session-id <id>] \
141
+ [--outcome signed|indeterminate|unavailable] \
142
+ [--reviewer-provider <p>] [--reviewer-model <m>] [--reviewer-harness <h>] \
143
+ [--artifact <path>]... [--max-artifact-mib <MiB>]
117
144
  ```
118
145
 
119
146
  One attributed Review record for one scenario, joined on
120
147
  `(project_id, scenario_id)` — both are required because two spec roots
121
148
  may carry the same scenario id. Exactly one provenance ref is required.
122
149
  `--actor-id` defaults to `canon`; `--session-id` fills `actor.session_id`.
123
- A Review carries no verdict; the `verifying → shipped` ship gate still
124
- refuses missing or `Divergent` evidence verdicts on its own. It is
125
- written directly to the ledger, with no staging step, and says so:
126
- `canon review add: wrote <path> (new) — written directly; nothing to
150
+ A Review carries no verdict about the behavior; the `verifying → shipped`
151
+ ship gate still refuses missing or `Divergent` evidence verdicts on its
152
+ own. It is written directly to the ledger, with no staging step, and says
153
+ so: `canon review add: wrote <path> (new) — written directly; nothing to
127
154
  promote`.
128
155
 
156
+ ### What the reviewer looked at (artifacts)
157
+
158
+ `--artifact <path>` (repeatable) binds a file the reviewer looked at — a
159
+ screenshot, a recording, a trace — exactly as `canon evidence add
160
+ --artifact` binds one: the path must resolve to a file inside the
161
+ repository, a file over 25 MiB is refused unless `--max-artifact-mib <N>`
162
+ raises the limit, and the bytes are copied into
163
+ `.canon/artifacts/sha256/<hex>` (commit it with the ledger) before the
164
+ review is written. Any refusal exits 2 and writes and stores nothing. The
165
+ review records each distinct file once, in the order given:
166
+
167
+ ```json
168
+ "artifacts": [{"path": "reports/title.png", "sha256": "<64 hex>"}]
169
+ ```
170
+
171
+ The field is absent when no `--artifact` is given. `review add` prints
172
+ `canon review add: artifact store holds <n> bound file(s) under
173
+ .canon/artifacts/sha256/ (<k> newly stored) — commit them with the
174
+ ledger`.
175
+
176
+ `canon gate check` re-hashes the stored blob of every file bound to an
177
+ in-effect review (the latest per `(project_id, scenario_id, reviewer)`).
178
+ A missing or altered blob is `stale-evidence` on the review's scenario,
179
+ naming the review, even when the working-tree file still matches (see
180
+ `canon-gate`, "Bound evidence stays provable"):
181
+
182
+ ```
183
+ stale-evidence world.demo.01 — review by `reviewer-2` (actor `reviewer-2`, 2026-10-11T09:00:00Z) bound file `reports/title.png` recorded sha256 <hex>; stored blob .canon/artifacts/sha256/<hex>: missing; working tree: <hex> — the bound bytes are gone, look again and re-record the review with `canon review add --artifact <path>`
184
+ ```
185
+
186
+ The reviewer's later review replaces the earlier one, so its files are
187
+ the ones checked. Canon binds and re-checks bytes only; it never compares
188
+ images or judges what a screenshot shows.
189
+
190
+ ### What the review saw (pins)
191
+
192
+ `review add` reads the corpus and records `pins` on the review; the
193
+ reviewer never types them:
194
+
195
+ - `scenario_digest` — the scenario record's `scenario_digest`: a sha256
196
+ over that scenario alone, as `canon inventory sync` stores it. It
197
+ covers the scenario's tags and block (header, steps, doc strings,
198
+ tables, `Examples:`) and the context it inherits: the Feature's tags,
199
+ the Feature `Background:`, and an enclosing `Rule:` with its tags and
200
+ `Background:`. Layout does not count: comments, blank lines, line
201
+ indentation, tag order, and table alignment (cells are compared
202
+ trimmed and unescaped). Values do: any step, tag or cell change, and
203
+ whitespace inside a doc string after the delimiter's own indentation
204
+ is removed. Other scenarios never count. A record synced before 0.15 has no
205
+ `scenario_digest`; then the review pins its `source_digest` (the
206
+ sha256 of the whole `.feature` file) and records
207
+ `scenario_digest_scope: file`;
208
+ - `evidence` — the scenario's latest evidence record per role, each as
209
+ `{key, digest}`: the ledger file `kind=evidence_record/<key>__<digest>.json`.
210
+ A scenario with no evidence yet is pinned with an empty list.
211
+
212
+ It prints `canon review add: pinned <id> at scenario digest <12 hex> and
213
+ <n> latest evidence record(s); …` (`at source digest` for a file pin). A
214
+ scenario with no record (never synced) is refused with exit 2 and nothing
215
+ is written: there is nothing to pin.
216
+
217
+ The gate compares each pin like for like: a scenario pin with the
218
+ scenario's current `scenario_digest`, a file pin with its current
219
+ `source_digest`. Adding or editing another scenario in the file leaves a
220
+ scenario-pinned review current; editing this scenario, or a Background
221
+ or Rule it inherits, makes it stale. A file-pinned review stays a file
222
+ pin until it is reviewed again. Review after the spec and evidence are
223
+ final.
224
+
225
+ ### The reviewer's outcome and runtime
226
+
227
+ `--outcome` records how the reviewer's run ended: `signed` (the default:
228
+ an approval), `indeterminate` (it ran and reached no verdict) or
229
+ `unavailable` (it could not run). Only `signed` counts; the other two
230
+ are recorded so a failed review is visible, never mistaken for a
231
+ sign-off or a rejection, and `review add` says so. `--reviewer-provider`,
232
+ `--reviewer-model` and `--reviewer-harness` record the runtime that
233
+ produced the review, as given. An empty or padded label is a usage error.
234
+
129
235
  `--session-id` (on `evidence add`, `review add`, `finding add` and
130
236
  `finding close`) takes one opaque token: non-empty, no surrounding
131
237
  whitespace, no control characters. Anything else is a usage error
@@ -169,9 +275,64 @@ the starter policy `canon init` writes.
169
275
 
170
276
  ## How it meets the gate
171
277
 
278
+ ### Which review counts
279
+
280
+ The reviews of a scenario are folded to the **latest per `reviewer`**
281
+ first (by `at`, then schema, then content digest — the fold every reader
282
+ uses). A reviewer's later review replaces their earlier one: a reviewer
283
+ who signed and later recorded `indeterminate` no longer counts as
284
+ signing, and a later `signed` review counts again. Reviews by different
285
+ reviewers never replace each other.
286
+
287
+ The scenario is reviewed when at least one of those latest reviews:
288
+
289
+ 1. is `signed`;
290
+ 2. still matches its pins — the scenario record's current
291
+ `source_digest`, and its current latest evidence record per role —
292
+ when it has pins; or, written before 0.15 without pins, still
293
+ describes its scenario at the commit it pinned (below);
294
+ 3. passes `distinct_actor`, when that is on.
295
+
296
+ ### Reviews written before pinning
297
+
298
+ A review written before 0.15 has no pins, only `pin`: the commit it
299
+ reviewed. The gate reads the scenario's `.feature` file at that commit
300
+ (`git`, with the same per-scenario normalization `canon inventory sync`
301
+ uses for `scenario_digest`; if the scenario was in another file then, the
302
+ spec root's files at that commit are searched for its id) and decides:
303
+
304
+ - **current** when that digest equals the scenario's current one and no
305
+ declared evidence record of the scenario is newer than the review: it
306
+ counts under rules 1 and 3;
307
+ - **stale** when the scenario changed since the commit (`its spec changed
308
+ since <pin>`) or has evidence newer than the review (`it has declared
309
+ evidence newer than the review`): it no longer counts, exactly like a
310
+ stale pinned review;
311
+ - **undecidable** when the pin is not a commit in this clone (shallow
312
+ clone, rewritten history) or the scenario id is absent at it: it keeps
313
+ counting, as in 0.15. A missing commit never becomes a violation.
314
+
315
+ `canon gate check` prints one advisory line with the split (exit code
316
+ unchanged by the line itself). A stale one whose scenario already has
317
+ another qualifying review is counted as superseded and not named:
318
+
319
+ ```
320
+ advisory: 338 review(s) predate review pinning (0.15) and were checked against the commit each one pinned: 318 current, 12 stale, 8 undecidable; 7 stale one(s) superseded by a current review of the same scenario, so nothing is owed for them; a stale one with no current review in its place no longer counts — review its scenario again (`canon review add`): platformer.movement.01, platformer.movement.02, …; an undecidable one still counts unchecked, because its pinned commit or the scenario at it is not in this clone — check out full history (`fetch-depth: 0` in CI) to check it
321
+ ```
322
+
323
+ `canon status` prints the same line as a warning, and `canon subject
324
+ status` prints it on stderr for the subject's own scenarios; all three
325
+ make the same decision. Re-review exactly the scenarios the line names:
326
+ the new review pins what it saw and replaces the reviewer's legacy one.
327
+ In CI, check out full history (`fetch-depth: 0`) for the job that runs
328
+ the gate, or every legacy review is undecidable.
329
+
172
330
  ### `unreviewed-promotion`
173
331
 
174
- An in-scope scenario with no qualifying review:
332
+ An in-scope scenario with no qualifying review. When several latest
333
+ reviews fail for different reasons, the detail names the one closest to
334
+ counting: a current signed review failing only `distinct_actor`, then a
335
+ stale review, then non-signed outcomes.
175
336
 
176
337
  ```
177
338
  unreviewed-promotion world.demo.01 — no review record for this scenario; spec_coverage.require_review wants an independent review (`canon review add`) once its subject is claimed done
@@ -190,6 +351,22 @@ evidence:
190
351
  unreviewed-promotion world.demo.01 — every review of this scenario is by an actor that also attested its evidence (`impl-agent`) or from a session that did (`sess-impl`); spec_coverage.require_review.distinct_actor wants a reviewer other than the evidence actor, in a session other than any evidence session
191
352
  ```
192
353
 
354
+ or, when the scenario's spec, its evidence, or both changed after the
355
+ review (`its spec changed`, `its evidence changed`, `its spec and its
356
+ evidence changed`):
357
+
358
+ ```
359
+ unreviewed-promotion world.demo.01 — stale review by `reviewer-2`: its evidence changed (the latest evidence records are not the pinned ones) since the review; a pinned review counts only while what it saw is current — review it again (`canon review add`)
360
+ ```
361
+
362
+ or, when every latest review recorded no sign-off:
363
+
364
+ ```
365
+ unreviewed-promotion world.demo.01 — no signed review: the latest review recorded no sign-off (`reviewer-2` indeterminate); an indeterminate or unavailable review is a gap, never an approval — run the review again and record `--outcome signed`
366
+ ```
367
+
368
+ A non-signed review is a gap even without `distinct_actor`.
369
+
193
370
  ### `open-blocker`
194
371
 
195
372
  With `block_on_findings`, a Finding whose latest version is severity
@@ -222,7 +399,7 @@ prints on stderr which review checks it ran or skipped:
222
399
 
223
400
  ```
224
401
  canon subject status: review guard for building → verifying (spec_coverage.require_review, scope: verifying, shipped)
225
- ran unreviewed-promotion — every owned scenario needs a review by an actor, and from a session, other than its evidence's
402
+ ran unreviewed-promotion — every owned scenario needs a signed review, current with the spec and evidence it pinned, by an actor, and from a session, other than its evidence's
226
403
  ran open-blocker — 1 adopted change(s) checked for open blocker findings
227
404
  ```
228
405
 
@@ -146,7 +146,130 @@ query:
146
146
  ```
147
147
 
148
148
  Every query, export, and purge appends an actor-stamped record to
149
- `.canon/audit/access.jsonl` (mode `0600` on creation). `CANON_ACTOR` takes
149
+ `.canon/audit/access.jsonl` (mode `0600` on creation) in the repository
150
+ whose `canon.yaml` it read (through `--repo` or `--canon-yaml`), never in
151
+ the current directory; with no `canon.yaml` there is no audit and nothing
152
+ is written. The log is local operator state: `canon init` keeps
153
+ `.canon/audit/` out of git (below). `CANON_ACTOR` takes
150
154
  precedence over the OS username. Audit rows contain operation, kind, scope,
151
155
  sensitive flag, counts, and timestamp only—never query text, secrets, or
152
156
  record bodies.
157
+
158
+ ## Staged writes and promotion
159
+
160
+ Four writes stage their record instead of committing it: `canon evidence
161
+ add`, `canon finding add` and `canon finding close` (under
162
+ `.canon/ledger/_staging/`, committed by `canon gate promote`) and `canon
163
+ divergence stage` (committed by `canon divergence promote`). `canon.yaml`'s
164
+ `write:` section decides whether they promote in the same run:
165
+
166
+ ```yaml
167
+ write:
168
+ promote: auto # or manual
169
+ ```
170
+
171
+ - `auto` — the command stages, then promotes that one record through the
172
+ same validation and assignment `canon gate promote` (or `canon
173
+ divergence promote`) applies. Records other runs staged stay staged;
174
+ only `canon gate promote` / `canon divergence promote` commit
175
+ everything staged. The one success line names the committed record
176
+ (`wrote kind=…/….json`; promotion renames it, so the staged file is not
177
+ named) and ends `— staged and promoted in this run; nothing to
178
+ promote`. `canon init` writes `auto`.
179
+ - `manual` — the record stays staged and the line ends ``— run `canon
180
+ gate promote` to commit it``. An absent `write:` key (or `promote:`
181
+ field) is `manual`, so a repo that never set it is unchanged.
182
+
183
+ `--promote` / `--no-promote` decide for one write and win over the config
184
+ without reading it; both at once is a usage error (exit `2`). If the
185
+ promotion refuses the command's own record, the record stays staged, the
186
+ line ends ``— run `canon gate promote` to commit it`` (`canon divergence
187
+ promote` for a divergence), and the command exits `1`. With neither flag,
188
+ a malformed `write:` section (anything but `promote: auto | manual`)
189
+ refuses the write, exit `2`, before anything is staged. `canon init
190
+ --check-config` reports `[PASS] write (promote: auto)`, `[not configured]
191
+ write (promote: manual)`, or `[FAIL] write: …`. `canon gate promote` still
192
+ works under `auto` and is idempotent: with nothing staged it prints
193
+ `nothing staged` and exits `0`.
194
+
195
+ ## The scenario index
196
+
197
+ `canon inventory sync` materializes one `Scenario` record per
198
+ `(project_id, scenario_id)` under `.canon/ledger/kind=scenario/`. The
199
+ index is derived — a pure function of the `.feature` files — so the
200
+ commands that read it rebuild it first: `canon gate check`, `canon
201
+ status`, `canon subject status`, `canon review add` and `canon evidence
202
+ add`. The index is stale when a `.feature` file holds a scenario with no
203
+ live index record, or one whose recorded source digest differs from the
204
+ file's current bytes, or when the index holds a scenario of a spec root
205
+ that no `.feature` file of it declares any more. The reader then runs
206
+ `canon inventory sync`'s code on the changed files (validation still
207
+ covers the whole root) and prints one stderr line:
208
+
209
+ ```
210
+ canon status: refreshed the scenario index from 1 changed feature file(s) (specs/features/kind=feature/area=cart/add.feature): 1 record(s) written
211
+ ```
212
+
213
+ A deleted scenario is not erased: `canon inventory sync` — and a reader's
214
+ refresh, identically — appends a tombstone, a new generation of its
215
+ record with `removed: true`. The gate, `canon status`, `canon query` and
216
+ the report panels then treat it as absent; re-adding the scenario writes
217
+ a live generation that supersedes the tombstone. A root that fails to
218
+ sync writes no tombstone either.
219
+
220
+ ```
221
+ canon status: refreshed the scenario index from 1 scenario(s) no longer in any feature file (cart.pay.01): 1 record(s) written
222
+ ```
223
+
224
+ `canon inventory sync` and every refresh hold an OS file lock on
225
+ `.canon/scenario-index.lock` while they write, so concurrent runs never
226
+ index one change twice; a refresh that waited checks staleness again and
227
+ is silent if another run already did the work. The lock is released when
228
+ its holder exits, however it exits, so it never goes stale; a writer
229
+ gives up after 30 s with a message naming the file (`canon inventory
230
+ sync` exits `2`; a reader skips the refresh and still runs).
231
+
232
+ A fresh index prints nothing. A root that fails to sync (format
233
+ violations, a duplicate id) is named and left as it was, and the command
234
+ still runs; `canon gate check` then fails with `uncovered-cell <root>`,
235
+ the `verifying → shipped` guard refuses, and `canon status` lists only
236
+ `canon inventory sync`, while the reporting readers (`query`, `context`)
237
+ keep only this line:
238
+
239
+ ```
240
+ canon gate check: found the scenario index stale for 1 changed feature file(s) (…): 0 record(s) written; NOT refreshed: root `root` (2 format violation(s), 0 sync error(s)) — `canon inventory sync` lists them
241
+ ```
242
+
243
+ Nothing is written in a directory without `canon.yaml`. `canon inventory
244
+ sync` stays the explicit command, and the one that lists violations and
245
+ tag diagnostics.
246
+
247
+ ## The artifact store lock
248
+
249
+ Every write to the evidence artifact store (`.canon/artifacts/sha256/`:
250
+ `canon evidence add`, `canon review add`, `canon evidence vault`) holds an
251
+ OS file lock on `.canon/artifact-store.lock` from its "already stored?"
252
+ check through its last rename, so a write that fails and rolls back never
253
+ removes a blob another writer published or counted as stored. A rollback
254
+ removes only the blobs its own write renamed into place; a blob already
255
+ stored intact counts as stored and is not written again. The lock is
256
+ released when its holder exits, however it exits, so it never goes stale;
257
+ a writer gives up after 30 s with a message naming the file and saying to
258
+ retry (the command exits `2` and writes no record).
259
+
260
+ ## What `canon init` keeps out of git
261
+
262
+ A fresh `canon init` writes these lines to `.gitignore` (each under a
263
+ comment, only the missing ones):
264
+
265
+ - `.canon/hot.db*` — the sqlite hot tier and its WAL/SHM sidecars.
266
+ - `.canon/audit/` — the query/export/purge access log.
267
+ - `.canon/ledger/kind=scenario/` — the scenario index; every reader
268
+ rebuilds it.
269
+ - `.canon/scenario-index.lock` — the lock serializing index rebuilds.
270
+ - `.canon/artifact-store.lock` — the lock serializing artifact store
271
+ writes.
272
+
273
+ A rerun on a repo that already has `canon.yaml` only refreshes
274
+ `AGENTS.md`, so an existing repo's `.gitignore` is unchanged; add the
275
+ lines by hand to adopt them.