@journeykit/canon 0.15.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.
@@ -13,31 +13,60 @@ where each subject stands and lists the next commands.
13
13
  ## The working loop
14
14
 
15
15
  Follow these steps in order for every request that builds, changes or fixes
16
- a feature, a one-line request included. Two points stop for a human:
17
-
18
- - **The brief** (step 1): the request, your assumptions, your open questions.
19
- - **The policy**: `.canon/policy.yaml` (written by `canon init`) is what the
20
- gate grades the work against. A human approves it with
21
- `canon policy approve --by <who>` (and again after every change to it);
22
- never edit it to make the gate pass, and never approve it yourself.
23
-
24
- When no human can answer, write the question into the brief, proceed on the
25
- assumption you state there, and say so in your final report.
26
-
27
- 1. **Brief.** Write down the request verbatim, your assumptions, and the
28
- questions you could not ask. It becomes the change's
29
- `openspec/changes/<slug>/proposal.md`, which
16
+ a feature, a one-line request included. A human is needed once per scope:
17
+
18
+ - **The approval** (step 1). A new subject, or a change to an approved
19
+ subject's scope, needs the human to approve its brief: the request, your
20
+ assumptions, your open questions. Write the brief, then stop and report
21
+ it: build nothing for that scope (no scenarios, code or evidence) before
22
+ the human answers. The approval is the one thing you never assume, even
23
+ when the human said they cannot answer until you report: the brief is
24
+ that report. In the same answer the human approves the policy,
25
+ `.canon/policy.yaml` (written by `canon init`), which is what the gate
26
+ grades the work against. When the human answers, record both at once:
27
+ an `Approved: <who> <when> — "<the human's words>"` line in the change's
28
+ `proposal.md`, and
29
+ `canon policy approve --by <the human> --note "<their words>"`. Never
30
+ approve without a human answer, and never edit the policy to make the
31
+ gate pass.
32
+ - **Inside an approved scope, never stop for a human.** Fixes, follow-ups,
33
+ review rounds and re-reviews go straight on: add or extend the scenario,
34
+ implement, gather evidence, and get an independent review in another
35
+ session. Independent review happens on every change. A fix or follow-up
36
+ stays in the approved change: add it as tasks in that change's
37
+ `tasks.md`, never with `canon change new`, because a new change is a new
38
+ scope and needs the human's approval.
39
+
40
+ When no human can answer one of the brief's questions, write the question
41
+ into the brief, state the assumption you will build on, and say so in your
42
+ report. Proceeding on an assumption applies to the brief's questions and to
43
+ work inside an approved scope, never to the approval itself.
44
+
45
+ 1. **Brief.** For a new subject or a scope change, write down the request
46
+ verbatim, your assumptions, and the questions you could not ask. It
47
+ becomes the change's `openspec/changes/<slug>/proposal.md`, which
30
48
  `canon change new <slug> --subject <id> --title "…"` scaffolds once the
31
- subject exists (step 2). A human approves the brief.
49
+ subject exists (step 2). Once it is there, stop and report the brief,
50
+ asking the human once for the brief and the policy; go on to step 3
51
+ only after the human answers, and record the answer as above. Inside an
52
+ approved scope there is no new brief: go to step 3 under the existing
53
+ subject and change.
32
54
  2. **Subject.** `canon subject new <id> --domain <domain> --title "…"`, then
33
55
  `canon change new <slug> --subject <id> --title "…"`, and put the brief in
34
- the change's `proposal.md`.
56
+ the change's `proposal.md`. For a new subject or a scope change, this is
57
+ where you stop until the human answers (step 1).
35
58
  3. **Scenarios.** `canon feature new <area>.<surface> --title "…"`, then one
36
59
  `canon scenario new <area>.<surface>.<nn> --subject <id> --case <case> --title "…"`
37
60
  per behavior, with failure paths (`--case failure`) as well as `happy`
38
61
  and `edge`. When the gate asks for a missing case (`require_cases`), add
39
- a new scenario; never retag an existing one to satisfy it. Check the
40
- corpus with `canon format specs` and index it with `canon inventory sync`.
62
+ a new scenario; never retag an existing one to satisfy it. There is no
63
+ sync or format step: `canon status`, `canon gate check`,
64
+ `canon evidence add`, `canon review add` and `canon subject status`
65
+ index added, edited or deleted `.feature` files themselves. Only when
66
+ one of them prints `NOT refreshed` (a format violation, such as a
67
+ hand-written header with no `# canon:` provenance comment, or a
68
+ duplicate id) run `canon inventory sync`: it names each problem; fix
69
+ them, and the next command indexes the files.
41
70
  4. **Units.** Split the work into units (for example specs, domain code,
42
71
  assets, review). Give each unit its own actor id and session id, and pass
43
72
  them on everything the unit records (`--actor-id <unit> --session-id <session>`),
@@ -52,31 +81,54 @@ assumption you state there, and say so in your final report.
52
81
  `canon evidence add --project-id <root-id> --scenario-id <id> --kind test-run --role implementer --ref "<command>" --actor-id <unit> --session-id <session>`,
53
82
  with `--report junit:<path>` or `--artifact <path>` when files prove it.
54
83
  canon stores those bytes in `.canon/artifacts/sha256/` (commit it with the
55
- ledger) and `canon gate check` re-checks them. Then `canon gate promote`.
84
+ ledger) and `canon gate check` re-checks them. For a scenario about what
85
+ users see, bind the screenshot or recording that shows it
86
+ (`--artifact <path>`). Under `write: { promote: auto }` (what
87
+ `canon init` writes) the record is final when the command returns; only
88
+ in a repo with `write.promote: manual` (or no `write:` key) follow it
89
+ with `canon gate promote`.
56
90
  An external evaluator's audit is recorded with `canon evidence add --kind
57
91
  audit`; canon never generates one.
58
92
  Evidence for a plan task (`--task <id>`, before `canon gate task`) must
59
93
  bind the artifact or report that proves the task; a canon command
60
- (`canon status`, `canon gate check`) is never that proof.
94
+ (`canon status`, `canon gate check`) is never that proof. Both read the
95
+ change's `tasks.md` as it is now, so a task row you add needs no
96
+ `canon ingest plans` first.
61
97
  7. **Independent review.** A different session from the one that wrote the
62
98
  code and evidence reviews the change. The reviewer records every defect
63
99
  the review finds as a finding:
64
- `canon finding add --change-id <slug> --round <n> --seq <n> --severity <blocker|should-fix|note> --reviewer <me> --actor-id <me> --session-id <session> --summary "…"`,
65
- then `canon gate promote`; once fixed,
100
+ `canon finding add --change-id <slug> --round <n> --seq <n> --severity <blocker|should-fix|note> --reviewer <me> --actor-id <me> --session-id <session> --summary "…"`;
101
+ once fixed,
66
102
  `canon finding close --change-id <slug> --round <n> --seq <n> --disposition fixed --resolution-sha <sha>`
67
- (a short sha is fine; canon resolves it)
68
- and `canon gate promote`. Every bug fix adds or extends a scenario. The
103
+ (a short sha is fine; canon resolves it). With `write.promote: manual`,
104
+ follow each with `canon gate promote`. Every bug fix adds or extends a scenario. The
69
105
  reviewer signs off each scenario:
70
106
  `canon review add --project-id <root-id> --scenario-id <id> --reviewer <who> --actor-id <who> --role reviewer --session-id <review-session> --pin <sha> --upstream-ref <ref>`
71
- (written directly; nothing to promote).
107
+ (written directly; nothing to promote). A reviewer of user-visible
108
+ behavior attaches what it looked at with `--artifact <path>`
109
+ (repeatable); canon stores and re-checks those bytes and never compares
110
+ images.
72
111
  8. **Transition.** `canon subject status <id> <state>` along
73
112
  `proposed → specced → building → verifying → shipped`. It refuses a move
74
113
  the policy does not yet allow (missing evidence, missing reviews, open
75
114
  blocker findings); fix the gap rather than overriding, and finish with
76
- `canon gate check` clean.
77
-
78
- Every write command says whether it staged the record (then
79
- `canon gate promote`) or wrote it directly.
115
+ `canon gate check` clean. A root whose `.feature` edits could not be
116
+ indexed fails the gate and refuses `shipped` (`uncovered-cell <root>`;
117
+ see step 3). `shipped` also refuses while the change's `proposal.md`
118
+ has no `Approved:` line, and `canon evidence add` refuses the subject's
119
+ scenarios and tasks the same way. Both are backstops: the stop itself
120
+ is at step 1, so you never reach them for a brief the human has not
121
+ answered. You record the line only from the human's answer (with no
122
+ `proposal.md` there is no brief to answer: neither refuses, and the
123
+ ship guard only prints an advisory). Move the subject to
124
+ `shipped` yourself once `canon subject status <id> shipped` passes its
125
+ guard, unless the brief says the human ships. `canon status` shows
126
+ whether each subject's brief is approved, by the same check, and names
127
+ the next step.
128
+
129
+ Every write command says whether the record is final (`written directly`,
130
+ or `staged and promoted in this run`) or staged; only a staged record
131
+ needs `canon gate promote`, and only `write.promote: manual` leaves one.
80
132
 
81
133
  ## Command reference
82
134
 
@@ -85,7 +137,7 @@ Load only the reference the current step needs.
85
137
  ### Setup and provider projection
86
138
 
87
139
  ```bash
88
- canon init # canon.yaml, starter policy, AGENTS.md block, plans home
140
+ canon init # canon.yaml, starter policy (--policy light|standard|strict, default strict; see canon-policy), AGENTS.md block, plans home
89
141
  canon skills install # detects .claude/.agents|.codex/.omp/.pi; if none exists, installs Claude+Codex
90
142
  canon skills install --providers=claude,codex,omp,pi
91
143
  canon skills check # read-only drift check
@@ -85,7 +85,7 @@ enum, like `lane`.
85
85
 
86
86
  Every scenario belongs to a Subject (the durable product unit, see
87
87
  `canon-subject`). Write the Subject first, then pin with `--subject`;
88
- `canon inventory sync` carries the tag onto the index record and
88
+ the scenario index carries the tag onto the index record and
89
89
  `spec_coverage.scope` filters on it. An unpinned scenario is invisible to
90
90
  a scoped gate.
91
91
 
@@ -97,10 +97,17 @@ canon scenario new checkout.cart.01 --title "Adding an item raises the count" \
97
97
  --subject checkout-core --lane behavior --case happy # once per behavior
98
98
  canon scenario new checkout.cart.04 --title "Adding an out-of-stock item is refused" \
99
99
  --subject checkout-core --lane behavior --case failure # and its failure path
100
- canon format specs # clean?
101
- canon inventory sync # index it
102
100
  ```
103
101
 
102
+ There is no format or sync step after these: the scaffold writes a
103
+ format-clean scenario, and `canon status`, `canon gate check`,
104
+ `canon evidence add`, `canon review add` and `canon subject status`
105
+ index added, edited and deleted `.feature` files before they read. When
106
+ one of them prints `NOT refreshed`, the corpus has a format violation
107
+ (for example a hand-written header with no `# canon:` line) or a
108
+ duplicate id; `canon inventory sync` (or `canon format specs`) names it.
109
+ Fix it and the next command indexes the files (see `canon-inventory`).
110
+
104
111
  - `--actor <id>` (or `CANON_ACTOR` in the environment) puts YOUR id on the
105
112
  provenance line. Default is `canon-scaffold`.
106
113
  - **Never hand-type, move, or edit a `# canon:` line.** The scaffold
@@ -24,7 +24,7 @@ Every violation carries one of these stable, grep-able strings:
24
24
  | `uncovered-cell` | Either a policy-required evidence cell (role × artifact) with no matching record, or — with `spec_coverage` enabled — a spec scenario that is unimplemented or mismatched, or a feature surface missing a `require_cases` case. The detail string distinguishes them. Coverage means "a test exists", not "a test passed": even a `Divergent` verdict satisfies the role-cell form. |
25
25
  | `unreviewed-promotion` | An artifact tagged `reviewed` has no matching ledger review record — or, with `spec_coverage.require_review` set, an in-scope scenario has no qualifying `Review`: none at all, only self-reviews, only stale reviews (the spec or evidence it pinned changed since), or only `indeterminate`/`unavailable` ones. See `canon-review`. |
26
26
  | `trust-below-required` | Achieved trust level is below `policy.yaml`'s `trust_required` for its class — RELEASE-scoped only (`canon gate check --release`); never fires on an ordinary run. |
27
- | `stale-evidence` | A passing record degraded to stale: its declared surface changed since its `evidence_sha`, or HEAD moved past `staleness.max_commits_behind`. Only degrades an already-green record. Also: a file bound to a latest evidence record whose bytes are gone — no intact blob in `.canon/artifacts/sha256/` and no working-tree copy with the recorded digest (see Bound evidence stays provable below). |
27
+ | `stale-evidence` | A passing record degraded to stale: its declared surface changed since its `evidence_sha`, or HEAD moved past `staleness.max_commits_behind`. Only degrades an already-green record. Also: a file bound to a latest evidence record whose bytes are gone — no intact blob in `.canon/artifacts/sha256/` and no working-tree copy with the recorded digest — or a file bound to an in-effect review with no intact blob (see Bound evidence stays provable below). |
28
28
  | `malformed-evidence` | A candidate record doesn't parse, is misfiled, or carries an unparseable interim tag. Malformed evidence is no evidence. |
29
29
  | `flagged` | The human-only `flagged` overlay is set — never green regardless of passing evidence. |
30
30
  | `unevidenced-flip` | `canon gate task <task_id>` was asked to flip a checkbox with no matching, non-`Divergent` evidence record. |
@@ -57,6 +57,26 @@ approved: …` or `advisory: policy changed since approval: …` after its
57
57
  result, naming `canon policy approve --by <who>`; the exit code is
58
58
  unchanged (see `canon-policy`).
59
59
 
60
+ Before it reads the scenario index, the gate rebuilds any stale part of
61
+ it from the changed `.feature` files (the same code `canon inventory
62
+ sync` runs) and says so in one stderr line, silent when the index is
63
+ fresh:
64
+
65
+ ```
66
+ canon gate check: refreshed the scenario index from 1 changed feature file(s) (specs/features/kind=feature/area=cart/add.feature): 1 record(s) written
67
+ ```
68
+
69
+ A root that fails to sync (format violations, a duplicate id) is named
70
+ instead, ending ``NOT refreshed: root `x` (N format violation(s), 0 sync
71
+ error(s)) — `canon inventory sync` lists them``, and the gate fails
72
+ closed: it adds `uncovered-cell <root> — the scenario index for spec root
73
+ `<root>` is stale and could not be refreshed (…); `canon inventory sync`
74
+ lists the problems to fix` and exits 1, since the index it would grade
75
+ does not hold the edits. The `verifying → shipped` guard refuses with
76
+ the same violation, and `canon status` lists the sync as its only step.
77
+ Fix what the sync lists and run the gate again. See `canon-storage`
78
+ (The scenario index).
79
+
60
80
  ### The spec-derived worklist (`spec_coverage`, opt-in)
61
81
 
62
82
  Coverage groups the EVIDENCE corpus, so it can only ever enumerate
@@ -219,6 +239,48 @@ actually dropped, a multi-line reason, or a drop without
219
239
  `--scenario-id` (task-only evidence has no drop guard). `canon status`
220
240
  lists recorded drops (`dropped bindings (N):`; `drops[]` in `--json`).
221
241
 
242
+ ### Nothing is attested before the brief is approved (`--override-reason`)
243
+
244
+ The approval is never assumed: the agent writes the brief, stops and
245
+ reports it, and builds nothing for that scope until the human answers.
246
+ `canon evidence add` is the backstop for that stop (0.16 M7). It
247
+ refuses (exit `1`, nothing staged or stored) while a subject the record
248
+ attests for has an enforced, unapproved brief: the newest adopted
249
+ change has a `proposal.md` under the openspec plan sources with no
250
+ `Approved:` line (the check `canon status` and the ship guard use; see
251
+ `canon-subject`). The subjects are the scenario's `@subject` owner for
252
+ `--scenario-id`, and every subject adopting the task's change for
253
+ `--task`:
254
+
255
+ ```
256
+ canon evidence add: unreviewed-promotion demo-subject — evidence for world.demo.01: brief not approved — openspec/changes/c-demo/proposal.md has no `Approved:` line, so the human has not answered it
257
+ canon evidence add: refused — stop, report the brief to the human and wait for their answer: nothing is built or attested for a subject before its brief is approved. Record their words as `Approved: <who> <when> — "<their words>"` in that proposal.md only from that answer; to attest anyway, pass --override-reason <text>, which is recorded on the evidence record
258
+ ```
259
+
260
+ A subject with no brief to answer (no openspec source, no adopted change,
261
+ no `proposal.md`) and a scenario no subject owns are unaffected.
262
+ `canon review add` and `canon finding add` are never refused.
263
+ `--override-reason "<one line>"` attests anyway, prints the `waived …`
264
+ lines and `override recorded by `<actor>` for the N brief refusal(s)
265
+ above: <reason>`, and records on the evidence record:
266
+
267
+ ```yaml
268
+ brief_override:
269
+ reason: the human approved in chat; the line follows
270
+ waived:
271
+ - { class: unreviewed-promotion, subject: demo-subject }
272
+ actor: { agent_id: lead } # from --actor-id
273
+ ```
274
+
275
+ A blank or multi-line reason exits 2; an override when nothing refused is
276
+ not recorded. `canon status` lists each such record as a `warning:`, and
277
+ `canon gate check` prints them after the result without failing:
278
+
279
+ ```
280
+ brief overrides: 1 evidence record(s) attested before their brief was approved — not failing the gate:
281
+ evidence for world.demo.01 was attested by `lead` at 2026-10-12T07:00:00Z while the brief of demo-subject was not approved (override: the human approved in chat; the line follows)
282
+ ```
283
+
222
284
  ### Review-waiver advisories
223
285
 
224
286
  A subject moved into a review-scoped status with `canon subject status
@@ -235,17 +297,58 @@ review waivers: 1 advisory(ies) — not failing the gate:
235
297
 
236
298
  The waiver lasts only until the subject's next status write.
237
299
 
238
- ### Reviews that predate pinning
300
+ `canon subject status <id> shipped` also refuses where a brief exists and
301
+ has no `Approved:` line (the subject's newest adopted change has a
302
+ `proposal.md`; see `canon-subject`), as an `unreviewed-promotion` naming
303
+ the subject. With no brief to answer it prints an advisory and does not
304
+ refuse. Like the evidence refusal above, it is a backstop: the stop is at
305
+ the brief. `canon gate check` does not grade briefs, so a waived ship
306
+ brief prints no advisory here; `canon status` keeps showing the brief as
307
+ not approved.
308
+
309
+ ### Reviews that predate pinning (checked against their pin)
239
310
 
240
311
  With `require_review` on, the reviews in effect (latest per reviewer and
241
- scenario) that carry no pins were written before 0.15. They keep
242
- counting, and one advisory line after the violations counts them; the
243
- exit code is unchanged:
312
+ scenario) that carry no pins were written before 0.15. Each still names
313
+ `pin`, the commit it reviewed, and the gate checks it there (0.16):
314
+
315
+ - **current** — the scenario's digest at that commit (read with `git`,
316
+ normalized exactly as `canon inventory sync` computes
317
+ `scenario_digest`) equals its current digest, and no declared evidence
318
+ record of the scenario is newer than the review. It counts. When the
319
+ scenario lived in another `.feature` file at that commit, the spec
320
+ root's files at that commit are searched for its id.
321
+ - **stale** — the scenario changed since the commit, or it has newer
322
+ evidence. It no longer counts, and an in-scope scenario left with no
323
+ other qualifying review is `unreviewed-promotion`:
324
+
325
+ ```
326
+ unreviewed-promotion gate.check.15 — stale review by `canon-reviewer-g1` (written before review pinning; checked against commit b556ab7529a332aa0b28d0a0f98bdd62fcbb9a63): it has declared evidence newer than the review; a review counts only while what it saw is current — review it again (`canon review add`)
327
+ ```
328
+
329
+ A spec change reads `its spec changed since <pin> (the scenario's
330
+ digest at that commit is not its current one)`.
331
+ - **undecidable** — the pin is not a commit in this clone (a shallow
332
+ clone, rewritten history), or no feature file of the scenario's root
333
+ holds its id at that commit. It keeps counting, as in 0.15: a missing
334
+ commit never becomes a violation.
335
+
336
+ One advisory line after the violations counts the three. A stale one
337
+ whose scenario already has another qualifying review (current, signed,
338
+ distinct) is counted as superseded, since nothing is owed for it; only
339
+ the scenarios a stale one leaves without a qualifying review are named
340
+ for re-review. The exit code is unchanged by the line itself:
244
341
 
245
342
  ```
246
- advisory: 340 review(s) predate review pinning (0.15) and still count without a check that their scenario's spec and evidence are unchanged; a new `canon review add` pins what it saw
343
+ 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
247
344
  ```
248
345
 
346
+ A review of a scenario no feature file declares any more is not counted.
347
+ In CI, check out full history (`actions/checkout` with `fetch-depth: 0`)
348
+ for the job that runs `canon gate check`, or every legacy review is
349
+ undecidable. Each pinned commit is read once per run through one `git
350
+ cat-file --batch` process, and never fetched over the network.
351
+
249
352
  ## Experimental: evidence binding (`experimental.evidence_binding`, off by default)
250
353
 
251
354
  An evidence record is an attestation: canon never runs `--ref`, and it
@@ -338,6 +441,15 @@ subject's status, and a repo with no `spec_coverage` would otherwise never
338
441
  have its bindings checked. A superseded record is not checked —
339
442
  re-attesting is the fix.
340
443
 
444
+ Files bound to a review (`canon review add --artifact`, see
445
+ `canon-review`) are checked the same way for every in-effect review (the
446
+ latest per `(project_id, scenario_id, reviewer)`), with one difference:
447
+ they were stored when the review was written, so only an intact blob is
448
+ clean. A missing or altered blob is `stale-evidence` on the review's
449
+ scenario, and the detail names the review: ``review by `<reviewer>`
450
+ (actor `<id>`, <time>)``. The working-tree file is reported but never
451
+ vouches for it.
452
+
341
453
  `canon evidence vault [--max-artifact-mib <N>]` stores the bytes for
342
454
  existing records: every attachment whose working-tree file still matches
343
455
  its digest is copied in, and everything else is listed as not stored,
@@ -431,13 +543,26 @@ canon gate promote --repo . --dry-run # preview only
431
543
  ```
432
544
 
433
545
  Which writes need this step: `evidence add`, `finding add` and `finding
434
- close` stage, and their success line ends ``— run `canon gate promote`
435
- to commit it``. `divergence stage` stages for `canon divergence promote`
436
- and names it. Every other write — `review add`, `divergence
546
+ close` stage, and under `write: { promote: manual }` (or an absent
547
+ `write:` key) their success line ends ``— run `canon gate promote` to
548
+ commit it``. Under `write: { promote: auto }` (what `canon init` writes)
549
+ or with `--promote`, they promote the one record they just staged —
550
+ same validation and `run_seq` assignment as this command — and print
551
+ one success line naming the committed record (`wrote kind=…/….json`), not
552
+ the staged file, ending `— staged and promoted in this run; nothing to
553
+ promote`, so no separate
554
+ `canon gate promote` is needed for it; `--no-promote` keeps one write
555
+ staged. A record another run staged is not touched: only `gate promote`
556
+ commits everything staged. It stays safe to run anyway: with nothing
557
+ staged it prints
558
+ `nothing staged` and exits `0`. `divergence stage` stages for `canon
559
+ divergence promote` and names it, under the same `write:` rule. Every
560
+ other write — `review add`, `divergence
437
561
  resolve|defer`, `subject new|adopt|status`, `change new`, `scenario new`, `feature new`,
438
562
  `gate task` (it edits the plan document and re-ingests it)
439
563
  — is final when it returns, and its success line ends `— written
440
- directly; nothing to promote`.
564
+ directly; nothing to promote`. Full promote-on-write contract:
565
+ `canon-storage` (Staged writes and promotion).
441
566
 
442
567
  ## `canon gate install-hooks [--repo] [--event] [--matcher] [--command] [--timeout]`
443
568
 
@@ -1,16 +1,43 @@
1
1
  # canon-inventory
2
2
 
3
- `canon inventory sync` is the FIRST stage of canon's unified spec loop:
4
- it turns a hand-authored, `canon format`-validated `.feature` corpus into
5
- the scenario ledger-INDEX records every downstream stage joins against.
3
+ `canon inventory sync` builds canon's scenario ledger-INDEX: it turns a
4
+ hand-authored, `canon format`-validated `.feature` corpus into the scenario
5
+ records every downstream stage joins against.
6
6
 
7
- ## The unified loop
7
+ ## You rarely run it yourself
8
+
9
+ Since 0.16 the commands that read the index refresh it first:
10
+ `canon status`, `canon gate check`, `canon evidence add`,
11
+ `canon review add` and `canon subject status` index added, edited and
12
+ deleted `.feature` files themselves, with the same code this command runs,
13
+ scoped to the changed files, and say so in one stderr line:
14
+
15
+ ```
16
+ canon status: refreshed the scenario index from 1 changed feature file(s) (specs/features/…): 1 record(s) written
17
+ ```
18
+
19
+ So the working loop has no sync step and no `canon format` step: write the
20
+ scenario (`canon scenario new` writes it format-clean) and run the next
21
+ command. Run `canon inventory sync` when:
22
+
23
+ - a reader prints `NOT refreshed` — a root has a format violation (a
24
+ hand-written `Feature:`/`Scenario:` header with no `# canon:` provenance
25
+ comment, an emptied file, …) or a duplicate id, so its files were not
26
+ indexed. `canon gate check` then fails (`uncovered-cell <root>`),
27
+ `shipped` is refused and `canon status` lists only the sync. `canon
28
+ inventory sync` names each problem (`canon format specs` names the format ones);
29
+ fix them and the next command indexes the files;
30
+ - `canon status` says the ledger holds no scenario records while the
31
+ `.feature` files hold some (the same refusal, on an index never built);
32
+ - you want the whole index rebuilt from every root at once.
33
+
34
+ ## Where the index sits in the loop
8
35
 
9
36
  ```
10
- author .feature corpus → canon format --check (validate)
37
+ author .feature corpus (canon scenario new)
11
38
  │
12
39
  ▼
13
- canon inventory sync # THIS skill — materializes scenario index records
40
+ scenario index # refreshed by every reader; `canon inventory sync` rebuilds it
14
41
  │
15
42
  ▼
16
43
  canon review add # attests a review — see canon-inventory downstream / canon-gate
@@ -23,8 +50,8 @@ canon ingest artifacts # with artifacts.native_records: true in canon.y
23
50
  # (see canon-artifact-ingest)
24
51
  ```
25
52
 
26
- Every stage after `sync` joins on the SAME `(project_id, scenario_id)`
27
- pair `sync` materializes — one identity scheme, no second one.
53
+ Every stage after the index joins on the SAME `(project_id, scenario_id)`
54
+ pair it materializes — one identity scheme, no second one.
28
55
 
29
56
  ## `canon inventory sync [--repo <dir>] [--spec-root <dir>]`
30
57
 
@@ -26,21 +26,45 @@ 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 (next
41
- section). The agent doing the work should not write its own grading
42
- policy. Narrow it with `scope: [building, verifying]` to check evidence
43
- only for subjects 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`).
44
68
 
45
69
  ## Approving the policy
46
70
 
@@ -48,8 +72,11 @@ only for subjects in those statuses (see `canon-gate`).
48
72
  canon policy approve --by <who> [--note <text>] [--repo <dir>]
49
73
  ```
50
74
 
51
- A human runs this after reviewing `.canon/policy.yaml`. It writes
52
- `.canon/policy.approval.json` directly (nothing to promote):
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):
53
80
 
54
81
  ```json
55
82
  {
@@ -85,8 +112,8 @@ An approval file that does not parse reads
85
112
  `--json` `warnings`). The advisory never changes an exit code: canon
86
113
  cannot tell whether a human or an agent ran the command, so the record
87
114
  names who approved which bytes, and the advisory says when that no
88
- longer holds. An agent never runs `canon policy approve` on its own
89
- behalf.
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.
90
117
 
91
118
  ## The closed CEL profile
92
119
 
@@ -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,13 +28,14 @@ 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
40
  from a session other than the one that attested the evidence, after
38
41
  the evidence is in (the review pins what it saw):
@@ -48,7 +51,9 @@ The order an agent follows to claim a subject done:
48
51
  reviewer could not reach a verdict, record that instead of signing:
49
52
  `--outcome indeterminate` (or `unavailable` when it could not run).
50
53
  Changing the scenario or re-attesting it afterwards makes the review
51
- stale; review it again.
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).
52
57
 
53
58
  4. **Move the subject**: `canon subject status <id> verifying`, later
54
59
  `shipped`. See `canon-subject`.
@@ -70,8 +75,12 @@ canon finding close --change-id <c> --round <n> --seq <n> \
70
75
  --disposition fixed --resolution-sha <sha> [--session-id <id>] # or rejected / deferred / open
71
76
  ```
72
77
 
73
- Both stage a candidate; `canon gate promote` commits it, and both say so
74
- (``… — 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`
75
84
  changes only the disposition (and `resolution_sha` with it); severity,
76
85
  reviewer, summary, and refs are copied from the committed record. It
77
86
  appends a second version at the same natural key — the first stays. Its
@@ -130,7 +139,8 @@ canon review add --project-id <root-id> --scenario-id <id> \
130
139
  --reviewer <who> --pin <sha-or-ref> --role <role> \
131
140
  (--upstream-ref <ref> | --original-spec-ref <ref>) [--actor-id <id>] [--session-id <id>] \
132
141
  [--outcome signed|indeterminate|unavailable] \
133
- [--reviewer-provider <p>] [--reviewer-model <m>] [--reviewer-harness <h>]
142
+ [--reviewer-provider <p>] [--reviewer-model <m>] [--reviewer-harness <h>] \
143
+ [--artifact <path>]... [--max-artifact-mib <MiB>]
134
144
  ```
135
145
 
136
146
  One attributed Review record for one scenario, joined on
@@ -143,6 +153,40 @@ own. It is written directly to the ledger, with no staging step, and says
143
153
  so: `canon review add: wrote <path> (new) — written directly; nothing to
144
154
  promote`.
145
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
+
146
190
  ### What the review saw (pins)
147
191
 
148
192
  `review add` reads the corpus and records `pins` on the review; the
@@ -245,20 +289,43 @@ The scenario is reviewed when at least one of those latest reviews:
245
289
  1. is `signed`;
246
290
  2. still matches its pins — the scenario record's current
247
291
  `source_digest`, and its current latest evidence record per role —
248
- when it has pins;
292
+ when it has pins; or, written before 0.15 without pins, still
293
+ describes its scenario at the commit it pinned (below);
249
294
  3. passes `distinct_actor`, when that is on.
250
295
 
251
- A review written before 0.15 has no pins. It keeps counting under rules
252
- 1 and 3, without the freshness check, and `canon gate check` prints one
253
- advisory line counting such in-effect reviews (exit code unchanged):
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:
254
318
 
255
319
  ```
256
- advisory: 340 review(s) predate review pinning (0.15) and still count without a check that their scenario's spec and evidence are unchanged; a new `canon review add` pins what it saw
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
257
321
  ```
258
322
 
259
323
  `canon status` prints the same line as a warning, and `canon subject
260
- status` prints it on stderr for the subject's own scenarios. Reviewing
261
- the scenario again pins it and drops it from the count.
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.
262
329
 
263
330
  ### `unreviewed-promotion`
264
331
 
@@ -149,7 +149,127 @@ Every query, export, and purge appends an actor-stamped record to
149
149
  `.canon/audit/access.jsonl` (mode `0600` on creation) in the repository
150
150
  whose `canon.yaml` it read (through `--repo` or `--canon-yaml`), never in
151
151
  the current directory; with no `canon.yaml` there is no audit and nothing
152
- is written. `CANON_ACTOR` takes
152
+ is written. The log is local operator state: `canon init` keeps
153
+ `.canon/audit/` out of git (below). `CANON_ACTOR` takes
153
154
  precedence over the OS username. Audit rows contain operation, kind, scope,
154
155
  sensitive flag, counts, and timestamp only—never query text, secrets, or
155
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.
@@ -20,11 +20,13 @@ authored in one place:
20
20
  - **Changes**: `change_ids` on the Subject, appended by `canon subject
21
21
  adopt`.
22
22
  - **Scenarios**: the `@subject:<id>` tag on each Gherkin scenario,
23
- indexed onto `Scenario.subject_id` by `canon inventory sync`. The
24
- Subject record carries no scenario list — the spec corpus is the only
25
- source, and the Subjects report panel, `spec_coverage.scope`, and the
26
- shipped gate all read it from there. To link a scenario, tag it and
27
- re-sync; there is no subject-side command for it.
23
+ indexed onto `Scenario.subject_id` by the scenario index (rebuilt by
24
+ `canon inventory sync`, and refreshed by every command that reads it).
25
+ The Subject record carries no scenario list — the spec corpus is the
26
+ only source, and the Subjects report panel, `spec_coverage.scope`, and
27
+ the shipped gate all read it from there. To link a scenario, tag it;
28
+ the next `canon status` or `canon gate check` indexes the tag. There is
29
+ no subject-side command for it.
28
30
 
29
31
  ## The 4-step loop
30
32
 
@@ -81,9 +83,10 @@ canon retrieve --role dev --domain dev --subject subject-domain-loop --k 5
81
83
  ### 3. Work → pin every artifact to `subject_id`
82
84
 
83
85
  - **Scenarios**: `canon scenario new <tag> --subject <subject-id>` emits
84
- the `@subject:<subject-id>` tag; `canon inventory sync` maps the tag
85
- onto the scenario's `subject_id`. A malformed/absent tag leaves it
86
- unset (fail-soft), never an error. See `canon-authoring`.
86
+ the `@subject:<subject-id>` tag; the scenario index maps the tag onto
87
+ the scenario's `subject_id` the next time a command reads it (no sync
88
+ step; see `canon-inventory`). A malformed/absent tag leaves it unset
89
+ (fail-soft), never an error. See `canon-authoring`.
87
90
  - **Changes**: adopt imported plan changes under the Subject (see
88
91
  [Adopt flow](#adopt-flow)).
89
92
  - **Reviews / evidence / divergences**: authored as usual (`canon review
@@ -145,6 +148,36 @@ empty set; so is one whose `scenario` records route away from the
145
148
  gate's rung. Each refusal prints by failure class (`uncovered-cell`),
146
149
  exits 1, and the status stays `verifying`. `retired` is not gated.
147
150
 
151
+ **The brief (0.16):** `verifying → shipped` also refuses where a brief
152
+ exists and is not approved, read exactly as `canon status` reads it (one
153
+ shared check, so status says `ship` only when this passes): the subject's
154
+ newest adopted change has a `proposal.md` under the openspec plan sources,
155
+ and it carries no `Approved:` line outside HTML comments and code fences.
156
+ It runs with or without a policy. The refusal is an `unreviewed-promotion`
157
+ naming the subject, and says what to do:
158
+
159
+ ```
160
+ canon subject status: unreviewed-promotion demo-subject — verifying → shipped: brief not approved — openspec/changes/c-demo/proposal.md has no `Approved:` line; a human must answer the brief, and the agent records `Approved: <who> <when> — "<their words>"` there only from that answer
161
+ canon subject status: refused — get the human's answer to the brief and record it as its `Approved:` line, or pass --override-reason <text> to move anyway; the waiver is recorded on the subject
162
+ ```
163
+
164
+ Never write the line without the human's answer. When there is no brief to
165
+ answer (no openspec plan source, no adopted change, or a change with no
166
+ `proposal.md`), the move is not refused: it prints
167
+ `canon subject status: advisory: <id>'s brief is not approved — <why>, so
168
+ there is no brief to answer and shipping is not refused`, and status shows
169
+ the brief as not approved, as an advisory. `subjects[].brief.enforced` in
170
+ `canon status --json` says which case applies. A brief that says the human
171
+ ships changes who runs the transition, not the check.
172
+
173
+ This refusal is a backstop, and so is the evidence one: shipping is not
174
+ the first thing an unapproved brief stops. The agent writes the brief,
175
+ then stops and reports it, and nothing is built or attested for the
176
+ subject until the human answers. `canon evidence add` refuses (exit 1,
177
+ nothing staged) a scenario whose `@subject` owner, or a task whose change
178
+ an adopting subject, has an enforced, unapproved brief, through the same
179
+ check; see `canon-gate`.
180
+
148
181
  ### The review guard (`spec_coverage.require_review`, opt-in)
149
182
 
150
183
  When `.canon/policy.yaml` sets `spec_coverage.require_review` (see
@@ -172,9 +205,11 @@ canon subject status demo-subject verifying \
172
205
  --override-reason "reviewer out until Monday" --actor-id lead
173
206
  ```
174
207
 
175
- `--override-reason "<one line>"` waives ONLY `unreviewed-promotion` and
176
- `open-blocker` — never a ship-gate verdict or case gap, never unreadable
177
- routing. It prints the `waived ...` lines and `override recorded by
208
+ `--override-reason "<one line>"` waives ONLY `unreviewed-promotion` (the
209
+ review gaps and the brief check) and `open-blocker` — never a ship-gate
210
+ verdict or case gap, never unreadable routing. A waived brief is recorded
211
+ as `{ class: unreviewed-promotion, subject: <subject id> }`. It prints
212
+ the `waived ...` lines and `override recorded by
178
213
  `lead` for the N violation(s) above: <reason>`, and records on the new
179
214
  Subject record exactly the violations it let through:
180
215
 
@@ -192,13 +227,14 @@ subject's next status write, so it never outlives the status it was
192
227
  granted for. While it stands, `canon gate check` lists the recorded gaps
193
228
  as advisories instead of failing; a gap it did not record (a blocker
194
229
  raised later, a scenario tagged later) still fails the gate. Without
195
- `require_review`, nothing is printed and nothing changes.
230
+ `require_review`, nothing is printed and nothing changes, except that
231
+ `shipped` still runs the brief check above.
196
232
 
197
233
  ## Adopt flow
198
234
 
199
- Planning docs become change/task records via `canon ingest plans` (see
200
- `canon-plan-import`), but that stops at import. `canon subject adopt`
201
- lifts an imported change into a managed Subject:
235
+ Planning docs written outside canon become change/task records via
236
+ `canon ingest plans` (see `canon-plan-import`), but that stops at import.
237
+ `canon subject adopt` lifts an imported change into a managed Subject:
202
238
 
203
239
  ```bash
204
240
  canon ingest plans --repo . # import → change/task rows
@@ -206,6 +242,13 @@ canon subject adopt subject-domain-loop-plan \
206
242
  --subject subject-domain-loop # link change → subject
207
243
  ```
208
244
 
245
+ A change made with `canon change new` (below) is recorded and adopted
246
+ already, and editing its `tasks.md` needs no import either:
247
+ `canon evidence add --task` and `canon gate task` read the file as it is,
248
+ and a flip re-imports that change itself. Run `canon ingest plans` for a
249
+ plan written outside canon, or when `canon query --kind task` must list
250
+ rows nobody has flipped yet.
251
+
209
252
  `adopt` stamps the change's `subject_id` and adds it to the Subject's
210
253
  `change_ids`, so `canon query --kind change --change-id …` and `canon
211
254
  query --kind subject` agree on the link from both ends. It writes only
@@ -251,9 +294,10 @@ canon change new add-login --subject auth --title "Add login"
251
294
  exist, when the slug already has a change dir (active or archived) or a
252
295
  `change` record, or when `canon.yaml` has no `openspec` plans source.
253
296
 
254
- Add task rows to `tasks.md` as `- [ ] <n> <title>`, rerun `canon ingest
255
- plans`, and flip each with `canon gate task <slug>#<n>` once evidence
256
- exists.
297
+ Add task rows to `tasks.md` as `- [ ] <n> <title>`, and flip each with
298
+ `canon gate task <slug>#<n>` once evidence exists. `canon evidence add
299
+ --task` and the flip read `tasks.md` as it is, so a new row needs no
300
+ `canon ingest plans` run.
257
301
 
258
302
  ## Where are we: `canon status`
259
303
 
@@ -286,7 +330,26 @@ uses, so status and the gate cannot disagree about a gap:
286
330
  stale or whose latest reviews are all `indeterminate`/`unavailable`
287
331
  (see `canon-review`), and the `canon review add` step names both. With
288
332
  `require_review` on, a `warning:` line counts the in-effect reviews that
289
- predate review pinning (0.15).
333
+ predate review pinning (0.15), checked against the commit each pinned:
334
+ current, stale (named) and undecidable.
335
+ - **Brief** (0.16) — for each `building`, `verifying` or `shipped`
336
+ subject, and each `proposed` subject whose newest adopted change has a
337
+ `proposal.md`, a `brief:` line under it says whether the brief is approved:
338
+ `brief: approved in <proposal.md> — <the recorded words>`, or
339
+ `brief: not approved (…)`. Approval is an `Approved:` line in the
340
+ `proposal.md` of the subject's newest adopted change (the last one
341
+ adopted): a line that starts with `Approved:` (indent allowed), says
342
+ something after it, and sits outside HTML comments (`<!-- … -->`,
343
+ multi-line included) and fenced code blocks (```` ``` ```` or `~~~`),
344
+ written by the agent once the human answers, as
345
+ `Approved: <who> <when> — "<the human's words>"`. Fixes and follow-ups
346
+ live as tasks in the approved change, so a newly adopted change is a
347
+ new scope: an older change's approval does not cover it. Status reads
348
+ the line from the change dir under the repo's `openspec` plans sources,
349
+ in config order (the first source holding the change owns it, a live
350
+ dir before an archived one; with no `plans:` section, the `openspec`
351
+ source rooted at the repo that `canon gate task` also falls back to).
352
+ There is no record kind for it; the file is the record.
290
353
  - **Unowned** — scenarios with no `@subject:` tag.
291
354
  - **Audits** — how many scenarios are sampled for
292
355
  `spec_coverage.require_audit`, confirmed, owed (no audit or
@@ -296,23 +359,54 @@ uses, so status and the gate cannot disagree about a gap:
296
359
  one).
297
360
  - **Dropped bindings** — `dropped bindings (N):` lists every recorded
298
361
  `--drop-attachment` (scenario, paths, reason); `drops[]` in `--json`.
299
- - **`next:`** — at most five commands, each with a one-line reason; the
362
+ - **Brief overrides** — a `warning:` line per evidence record attested
363
+ with `canon evidence add --override-reason` while its subject's brief
364
+ was not approved: what it attests, who, when, the subject and the
365
+ reason (see `canon-gate`).
366
+ - **`next:`** — at most five steps, each with a one-line reason; the
300
367
  rest are counted. Rules, in order: no `canon.yaml` → `canon init`; an
301
368
  unreadable one → `canon init --check-config`; a scenario tagged with a
302
369
  subject that has no record → `canon subject new <that id>`; no subject
303
- at all → `canon subject new`; subjects but no scenario records →
304
- `canon inventory sync`. Then per subject, verifying first, then
305
- building, specced, proposed, shipped: no scenario → `canon scenario new
306
- … --subject <id>`; a missing case → `canon scenario new <surface>.<next
307
- nn> … --case <case>`; (building and later) an unevidenced or divergent
308
- scenario → `canon evidence add --scenario-id …`; an open blocker →
309
- `canon finding close …`; a due, unreviewed, evidenced scenario →
310
- `canon review add …` from another session; none of these →
311
- `canon subject status <id> <next status>`. Last, unowned scenarios →
312
- tag them and `canon inventory sync`. With more than one `specs.roots[]`
313
- entry, every suggested `canon scenario new` carries `--project`: the
314
- owning root for a missing case, `<root-id>` (the reason lists the
315
- roots) for a subject that owns no scenario yet.
370
+ at all → `canon subject new`; subjects and `.feature` scenarios but no
371
+ scenario records → `canon inventory sync` (status refreshes the index
372
+ itself first, so this means the refresh refused on a format violation
373
+ or a duplicate id, which the sync names). Then per subject, verifying
374
+ first, then building, specced, proposed, shipped: an unapproved brief
375
+ (building and later, and proposed once its change has a `proposal.md`)
376
+ → the human step below, which is then the subject's
377
+ only step; no scenario → `canon scenario new … --subject <id>` (and, when no
378
+ `.feature` file holds a scenario yet, `canon feature new` first, unless
379
+ every subject waits on its brief); a missing case → `canon scenario new
380
+ <surface>.<next nn> … --case <case>`; (building and later) an
381
+ unevidenced or divergent scenario → `canon evidence add --scenario-id
382
+ …` (its reason adds "then `canon gate promote`" only under
383
+ `write.promote: manual`); an open blocker → `canon finding close …`; a
384
+ due, unreviewed, evidenced scenario → `canon review add …` from another
385
+ session; none of these → `canon subject status <id> <next status>`.
386
+ Last, unowned scenarios → `canon status`, after tagging them in their
387
+ `.feature` files (status re-indexes the edit; no sync step). With more
388
+ than one `specs.roots[]` entry, every suggested `canon scenario new`
389
+ carries `--project`: the owning root for a missing case, `<root-id>`
390
+ (the reason lists the roots) for a subject that owns no scenario yet.
391
+ - **The one human step.** Every step is the agent's to run except one:
392
+ a subject whose brief is enforced (its newest change has a
393
+ `proposal.md` with no `Approved:` line) gets
394
+ `human: stop and report the brief in <proposal.md>; build and attest nothing for <id> until the human answers (one answer approves brief and policy)`,
395
+ whose reason names that `proposal.md`, says `canon evidence add`
396
+ refuses the subject's scenarios and tasks until the line is recorded,
397
+ and names `canon policy approve --by <the human> --note "<their words>"`.
398
+ It is the subject's only step, whatever its status: nothing suggests
399
+ `canon feature new`, `canon scenario new`, `canon evidence add`, a
400
+ review or a move-on for it before the human answers, and
401
+ `canon subject status <id> shipped` refuses on the same check. The
402
+ approval is never assumed. A subject with no brief to answer gets no human step;
403
+ its `brief:` line says
404
+ `not approved (no adopted change; advisory, nothing to approve yet)` or
405
+ ``not approved (change `<id>` has no proposal.md; advisory, nothing to
406
+ approve yet)`` and nothing is withheld (a `proposed` subject with no
407
+ `proposal.md`, and a `specced` subject, show no `brief:` line at all). Once nothing blocks, a
408
+ ship-ready verifying subject gets the agent's own
409
+ `canon subject status <id> shipped`; status never asks a human to ship.
316
410
 
317
411
  Status fails closed rather than suggest progress the gate would refuse:
318
412
 
@@ -330,19 +424,24 @@ Status fails closed rather than suggest progress the gate would refuse:
330
424
 
331
425
  ```json
332
426
  {
333
- "statusVersion": 1,
334
- "canonVersion": "0.14.0",
427
+ "statusVersion": 2,
428
+ "canonVersion": "0.16.0",
335
429
  "policy": { "present": true, "spec_coverage": { "require_evidence": true, "scope": [], "exclude_lanes": [], "require_cases": ["failure"], "require_review": { "scope": ["verifying", "shipped"], "distinct_actor": true, "block_on_findings": true } } },
336
430
  "warnings": [],
337
431
  "subjects": [
338
432
  { "id": "cart", "title": "Cart", "status": "building", "scenarios": 2, "excluded": 0, "evidenced": 2, "divergent": 0,
339
- "reviewed": 1, "staleReviews": 0, "unsignedReviews": 0, "reviewDue": true, "openBlockers": 1, "missingCases": [] }
433
+ "reviewed": 1, "staleReviews": 0, "unsignedReviews": 0, "reviewDue": true, "openBlockers": 1, "missingCases": [],
434
+ "brief": { "approved": true, "enforced": false, "change": "c-cart", "proposal": "openspec/changes/c-cart/proposal.md", "approval": "Journey 2026-10-11 — \"approved\"" } },
435
+ { "id": "promo", "title": "Promo", "status": "verifying", "scenarios": 1, "excluded": 0, "evidenced": 1, "divergent": 0,
436
+ "reviewed": 1, "staleReviews": 0, "unsignedReviews": 0, "reviewDue": true, "openBlockers": 0, "missingCases": [],
437
+ "brief": { "approved": false, "enforced": true, "change": "c-promo", "proposal": "openspec/changes/c-promo/proposal.md" } }
340
438
  ],
341
439
  "unowned": ["cart.promo.01"],
342
440
  "next": [
343
- { "command": "canon finding close --change-id c-cart --round 1 --seq 1 --disposition fixed --resolution-sha <sha> --actor-id <unit> --session-id <session>", "why": "cart: 1 open blocker finding(s) (first: …); fix it, commit, then close it" },
344
- { "command": "canon review add --project-id root --scenario-id cart.add.02 --reviewer <reviewer> --actor-id <reviewer> --session-id <review-session> --role reviewer --pin <sha> --original-spec-ref \"<feature file>\"", "why": "cart: 1 of 2 scenario(s) lack a qualifying review; …" },
345
- { "command": "canon inventory sync", "why": "1 scenario(s) carry no @subject tag (first: cart.promo.01); …" }
441
+ { "command": "stop and report the brief in openspec/changes/c-promo/proposal.md; build and attest nothing for promo until the human answers (one answer approves brief and policy)", "why": "promo: its newest adopted change's proposal.md carries no `Approved:` line …", "actor": "human" },
442
+ { "command": "canon finding close --change-id c-cart --round 1 --seq 1 --disposition fixed --resolution-sha <sha> --actor-id <unit> --session-id <session>", "why": "cart: 1 open blocker finding(s) (first: …); fix it, commit, then close it", "actor": "agent" },
443
+ { "command": "canon review add --project-id root --scenario-id cart.add.02 --reviewer <reviewer> --actor-id <reviewer> --session-id <review-session> --role reviewer --pin <sha> --original-spec-ref \"<feature file>\"", "why": "cart: 1 of 2 scenario(s) lack a qualifying review; …", "actor": "agent" },
444
+ { "command": "canon status", "why": "1 scenario(s) carry no @subject tag (first: cart.promo.01); add `@subject:<id>` above each in its .feature file, then check again: …", "actor": "agent" }
346
445
  ],
347
446
  "nextOmitted": 0
348
447
  }
@@ -351,9 +450,22 @@ Status fails closed rather than suggest progress the gate would refuse:
351
450
  Each `missingCases` entry is `{ "projectId", "surface", "case" }`: the
352
451
  spec root, the `<area>.<surface>`, and the required case it lacks.
353
452
 
453
+ `brief` is present for `building`, `verifying` and `shipped` subjects,
454
+ and for a `proposed` subject whose newest adopted change has a
455
+ `proposal.md`. `change` is the newest adopted change (omitted when the
456
+ subject adopts none), `proposal` its `proposal.md` (omitted when no
457
+ `openspec` source holds one), `approval` the text after `Approved:`, and
458
+ `enforced` whether the missing approval blocks (a `proposal.md` exists to
459
+ carry it).
460
+
461
+ Each `next` entry's `actor` is `agent` (its `command` is a canon command
462
+ line) or `human` (its `command` says what to get from the human).
463
+
354
464
  `policy.spec_coverage` has the shape `canon context --json` prints
355
465
  (`null` when absent, `{"invalid": …}` when unusable). `statusVersion`
356
- changes only when a field changes meaning or is removed.
466
+ changes only when a field changes meaning or is removed. It is `2`
467
+ since 0.16, when `next[].command` stopped always being a command line
468
+ (`actor` tells which); `brief` was added alongside.
357
469
 
358
470
  `subject new`, `adopt` and `status` write their records directly; the
359
471
  human-readable success line ends `— written directly; nothing to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@journeykit/canon",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "canon — harness knowledge substrate. JS launcher: platform detection + native binary resolution.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {},
32
32
  "optionalDependencies": {
33
- "@journeykit/canon-core-darwin-arm64": "0.15.0",
34
- "@journeykit/canon-core-linux-x64": "0.15.0",
35
- "@journeykit/canon-core-win32-x64": "0.15.0"
33
+ "@journeykit/canon-core-darwin-arm64": "0.16.0",
34
+ "@journeykit/canon-core-linux-x64": "0.16.0",
35
+ "@journeykit/canon-core-win32-x64": "0.16.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^20.0.0",