@journeykit/canon 0.6.0 → 0.8.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.
- package/dist/index.js +12 -2
- package/dist/skills/SKILL.src.md +46 -0
- package/dist/skills/reference/canon-artifact-ingest.md +72 -0
- package/dist/skills/reference/canon-authoring.md +155 -0
- package/dist/skills/reference/canon-context.md +137 -0
- package/dist/skills/reference/canon-fmt.md +43 -0
- package/dist/skills/reference/canon-gate.md +199 -0
- package/dist/skills/reference/canon-inventory.md +102 -0
- package/dist/skills/reference/canon-learn.md +245 -0
- package/dist/skills/reference/canon-plan-import.md +143 -0
- package/dist/skills/reference/canon-plugins.md +131 -0
- package/dist/skills/reference/canon-policy.md +119 -0
- package/dist/skills/reference/canon-report-dashboard.md +343 -0
- package/dist/skills/reference/canon-retrieve.md +123 -0
- package/dist/skills/reference/canon-session-ingest.md +214 -0
- package/dist/skills/reference/canon-storage.md +152 -0
- package/dist/skills/reference/canon-subject.md +161 -0
- package/dist/skills/reference/canon-vocab.md +115 -0
- package/dist/skills/scripts/canon-retrieve-pre-dispatch.sh +126 -0
- package/package.json +6 -4
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# canon-plan-import
|
|
2
|
+
|
|
3
|
+
`canon ingest plans` imports a FOREIGN plan dialect's on-disk state into
|
|
4
|
+
canon's `change`/`task` records, through the same validated path every
|
|
5
|
+
other ingest family uses. Imported rows carry no special marker — once
|
|
6
|
+
imported, `canon query` cannot tell an imported row from a hand-authored
|
|
7
|
+
one, and neither can `canon gate`.
|
|
8
|
+
|
|
9
|
+
## 1. Configure a `plans:` source
|
|
10
|
+
|
|
11
|
+
`canon.yaml`:
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
plans:
|
|
15
|
+
sources:
|
|
16
|
+
- dialect: openspec
|
|
17
|
+
root: . # resolved relative to canon.yaml's directory
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- An absent `plans:` section = zero sources (a clean no-op).
|
|
21
|
+
- A present section parses STRICTLY: a typo'd key, an unregistered
|
|
22
|
+
`dialect`, or a nonexistent `root` fails the command loud, naming the
|
|
23
|
+
offender.
|
|
24
|
+
- `root` is whatever the dialect's adapter scans — for `openspec`, a
|
|
25
|
+
repo root containing `openspec/changes/`, or that changes dir directly.
|
|
26
|
+
|
|
27
|
+
One-shot override (bypasses `canon.yaml` for a single ad hoc import;
|
|
28
|
+
either flag alone fails loud):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
canon ingest plans --dialect openspec --source ../other-repo
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 2. Run the import
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
canon ingest plans # canon.yaml-driven, every configured source
|
|
38
|
+
canon ingest plans --json # machine-readable outcome
|
|
39
|
+
canon ingest plans --repo ../svc # a specific repo root
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Per source the summary reports `changes_parsed`/`tasks_parsed`,
|
|
43
|
+
`changes_persisted`/`tasks_persisted`, `changes_unwritten`/
|
|
44
|
+
`tasks_unwritten` (an unreachable tier — printed, never silently
|
|
45
|
+
dropped), `duplicate_change_id` (two sources producing the same
|
|
46
|
+
`change_id` this pass — the LATER is skipped and counted, never merged),
|
|
47
|
+
the adapter's `unmapped` drop counts, and `malformed` (structurally
|
|
48
|
+
broken constructs). A source whose content is byte-identical to its last
|
|
49
|
+
successful run is `skipped_unchanged` — re-importing writes zero records.
|
|
50
|
+
|
|
51
|
+
## 3. Read the imported rows
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
canon query --kind change [--json]
|
|
55
|
+
canon query --kind task [--json]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
No dialect/plugin filter exists or is needed — an imported `change`/
|
|
59
|
+
`task` is an ordinary core record.
|
|
60
|
+
|
|
61
|
+
## The `openspec` dialect
|
|
62
|
+
|
|
63
|
+
Maps `openspec/changes/**` onto core kinds:
|
|
64
|
+
|
|
65
|
+
| openspec construct | Core mapping |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| change dir's basename | `change_id` (verbatim — no slug massaging) |
|
|
68
|
+
| `proposal.md`'s `## Why` first paragraph | `Change.summary` (absent heading → empty summary + a `proposal-missing-why` diagnostic) |
|
|
69
|
+
| archive location vs. checkbox tallies | `Change.status` (`archived` wins; else zero rows → `proposed`, all done → `completed`, mixed → `in_progress`) |
|
|
70
|
+
| each `tasks.md` checkbox row | `Task` (`task_id = <change_id>#<n>`, status verbatim, evidence_note from a ` — ✅ ` suffix or a `**DEFERRED**`/`**DROPPED**` annotation) |
|
|
71
|
+
| `specs/**/spec.md` `#### Scenario:` blocks | dropped, counted under `spec-delta-scenario` |
|
|
72
|
+
| `design.md` | dropped, counted under `design-doc` |
|
|
73
|
+
|
|
74
|
+
A missing/unreadable `proposal.md`, or a basename that isn't a valid
|
|
75
|
+
`change_id`, skips the WHOLE dir (counted `malformed`); siblings
|
|
76
|
+
unaffected. A proposal-only dir imports as `Change { status: proposed }`
|
|
77
|
+
with zero tasks and zero diagnostics.
|
|
78
|
+
|
|
79
|
+
## The `superpowers` dialect
|
|
80
|
+
|
|
81
|
+
Maps one plan document (an `*.md` immediate child of the plans root),
|
|
82
|
+
following the superpowers `writing-plans` skill's grammar:
|
|
83
|
+
|
|
84
|
+
| `writing-plans` construct | Core mapping |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| the filename stem, slugified (lowercased, each `[^a-z0-9]+` run → one `-`, edge `-` trimmed; date prefix kept verbatim) | `change_id` |
|
|
87
|
+
| the `**Goal:**` line's remainder, whitespace-normalized | `Change.summary` (absent → empty summary + a `goal-missing` diagnostic) |
|
|
88
|
+
| checkbox tallies across every `### Task N:` section | `Change.status` (zero tasks → `proposed`, all done → `completed`, mixed → `in_progress`) |
|
|
89
|
+
| each `### Task N: <name>` section | `Task` (`task_id = <change_id>#<N>`, title = text after the colon; status `done` iff the section has ≥1 checkbox line and ALL are checked, else `open`) |
|
|
90
|
+
| steps, `**Architecture:**`/`**Tech Stack:**` prose, Global Constraints, non-task headings | never read, never a diagnostic |
|
|
91
|
+
|
|
92
|
+
Worked example — `2026-07-14-website-design.md`:
|
|
93
|
+
|
|
94
|
+
```markdown
|
|
95
|
+
# Website Implementation Plan
|
|
96
|
+
|
|
97
|
+
**Goal:** Build the project website.
|
|
98
|
+
|
|
99
|
+
### Task 1: Layout
|
|
100
|
+
|
|
101
|
+
- [x] **Step 1: scaffold the grid**
|
|
102
|
+
- [x] **Step 2: wire the nav**
|
|
103
|
+
|
|
104
|
+
### Task 2: Copy
|
|
105
|
+
|
|
106
|
+
- [x] **Step 1: draft the homepage**
|
|
107
|
+
- [ ] **Step 2: proofread**
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
imports as `Change { change_id: "2026-07-14-website-design", summary:
|
|
111
|
+
"Build the project website.", status: in_progress }`, plus
|
|
112
|
+
`Task { task_id: "…#1", title: "Layout", status: done }` and
|
|
113
|
+
`Task { task_id: "…#2", title: "Copy", status: open }`. The `**Step N:**`
|
|
114
|
+
bolding is NOT load-bearing — a plain `- [ ]`/`- [x]` row still counts.
|
|
115
|
+
|
|
116
|
+
An invalid task-number heading is skipped and named `malformed`; a
|
|
117
|
+
duplicate `Task N` heading keeps the first section and names the later one
|
|
118
|
+
`malformed`. A filename stem that fails to slug into a valid `change_id`
|
|
119
|
+
is malformed too. A markdown file with neither a `**Goal:**` line nor any
|
|
120
|
+
`### Task N:` heading (a stray `README.md`) is skipped with a
|
|
121
|
+
`not-a-plan-doc` diagnostic, not imported as garbage. Only immediate-child
|
|
122
|
+
`*.md` files are read, byte-lexically sorted; an absent/unreadable root
|
|
123
|
+
yields zero records, never an error.
|
|
124
|
+
|
|
125
|
+
## The task_id join
|
|
126
|
+
|
|
127
|
+
An imported `Task.task_id` is byte-identical to the join key of the
|
|
128
|
+
openspec-task verdict events canon's artifact ingest derives from the same
|
|
129
|
+
checkbox tree — so `canon report`'s trust matrix joins the two sides on
|
|
130
|
+
`task_id` for its coverage columns, and `canon ingest plans` is that
|
|
131
|
+
join's bulk producer. `canon ingest plans` itself performs no join and
|
|
132
|
+
writes no verdict/evidence — joining plan state against evidence is a
|
|
133
|
+
downstream reader's job.
|
|
134
|
+
|
|
135
|
+
## What this skill does NOT cover
|
|
136
|
+
|
|
137
|
+
- **Authoring an openspec change dir** (`proposal.md`/`tasks.md`
|
|
138
|
+
conventions) — see the openspec CLI's own docs; this skill covers
|
|
139
|
+
IMPORTING that shape, not producing it.
|
|
140
|
+
- **`canon gate check`/`canon gate task`** (the hand-authored one-task-at-
|
|
141
|
+
a-time path this complements at bulk) — see `canon-gate`.
|
|
142
|
+
- **Wiring an imported `Task`/`Change` into a `canon gate` decision** —
|
|
143
|
+
out of scope; needs its own reviewed change.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# canon-plugins
|
|
2
|
+
|
|
3
|
+
A plugin adds fields to a core record WITHOUT changing that record's
|
|
4
|
+
schema. It declares a NAMESPACED overlay kind (`<namespace>.<kind>`, e.g.
|
|
5
|
+
`porting.coverage`), writes overlay records into that namespace, and a
|
|
6
|
+
read-time JOIN projects the declared fields onto the core view. Core
|
|
7
|
+
records are NEVER rewritten by any step — the overlay write path is
|
|
8
|
+
append-only under a foreign namespace, and a manifest cannot alias a
|
|
9
|
+
core kind's on-disk path even by accident. Today overlays attach to the
|
|
10
|
+
`scenario` core kind only.
|
|
11
|
+
|
|
12
|
+
## 1. Author a `plugin.yaml` manifest
|
|
13
|
+
|
|
14
|
+
`.canon/plugins/<id>/plugin.yaml`:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
id: porting
|
|
18
|
+
namespace: porting
|
|
19
|
+
overlays:
|
|
20
|
+
- kind: coverage
|
|
21
|
+
attaches_to:
|
|
22
|
+
core_kind: scenario # `scenario` ONLY
|
|
23
|
+
join_key: [project_id, scenario_id]
|
|
24
|
+
fields:
|
|
25
|
+
- name: covered
|
|
26
|
+
type: bool
|
|
27
|
+
- name: surface_ref
|
|
28
|
+
type: { list: string }
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `id`/`namespace`/`overlays[].kind` are REQUIRED — a missing field fails
|
|
32
|
+
the load outright, never defaulted.
|
|
33
|
+
- `namespace` and every overlay `kind` MUST match the kebab-token grammar
|
|
34
|
+
`[a-z0-9]+(-[a-z0-9]+)*` — else rejected with `E-PLUGIN-GRAMMAR`.
|
|
35
|
+
- `<namespace>.<kind>` MUST NOT collide with a core record kind name
|
|
36
|
+
(`E-PLUGIN-CORE-COLLISION`) and MUST be unique across installed plugins
|
|
37
|
+
(`E-PLUGIN-DUP-OVERLAY`).
|
|
38
|
+
- `attaches_to.core_kind` MUST be `scenario` — any other value fails with
|
|
39
|
+
`E-PLUGIN-CORE-KIND`.
|
|
40
|
+
- `type` is a bare scalar (`bool`/`number`/`string`), `{ enum: […] }`, or
|
|
41
|
+
`{ list: <type> }`.
|
|
42
|
+
- Two packages declaring the SAME `id`: the later (directory-sort order)
|
|
43
|
+
is dropped and diagnosed, never merged (`E-PLUGIN-DUP-ID`).
|
|
44
|
+
- An absent `.canon/plugins/` directory resolves an empty, valid snapshot —
|
|
45
|
+
never a panic.
|
|
46
|
+
|
|
47
|
+
## 2. Validate + write overlay records
|
|
48
|
+
|
|
49
|
+
Drive it through the sync command (never hand-write records):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
canon plugin sync porting # every canon.yaml specs.roots[] entry
|
|
53
|
+
canon plugin sync porting --repo ../svc # a specific repo
|
|
54
|
+
canon plugin sync porting --spec-root ./specs # ad hoc: ignore canon.yaml
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`canon plugin sync <plugin-id>` resolves the manifest, then for each
|
|
58
|
+
candidate validates FIRST (envelope present + typed; every required
|
|
59
|
+
`join_key` field present + a string; every declared field present + type-
|
|
60
|
+
matching; no field outside envelope ∪ join-key ∪ declared), then writes.
|
|
61
|
+
The on-disk path is derived from the join-key field values in declared
|
|
62
|
+
order plus a body digest — a byte-identical resubmission dedupes to the
|
|
63
|
+
same path; a different body for the same join key appends at a new path,
|
|
64
|
+
never overwriting.
|
|
65
|
+
|
|
66
|
+
## 3. Project a read
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
canon query --kind scenario --plugin porting # human table, projected fields appended
|
|
70
|
+
canon query --kind scenario --plugin porting --json # merged record bodies
|
|
71
|
+
canon query --kind scenario # no --plugin: byte-identical to the core view
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`--plugin <id>` folds the plugin's overlay records (latest-by-
|
|
75
|
+
`(join_key, at)`) onto each queried core record. Fail-soft everywhere:
|
|
76
|
+
|
|
77
|
+
- No overlay record for a core key → that record projects UNMODIFIED (no
|
|
78
|
+
value invented).
|
|
79
|
+
- A malformed overlay record (fails validation against the CURRENT
|
|
80
|
+
manifest — e.g. the schema drifted after the record was written) →
|
|
81
|
+
SKIPPED + diagnosed; siblings still project.
|
|
82
|
+
- An absent plugin/manifest → the unmodified core view + a diagnostic,
|
|
83
|
+
never a process error.
|
|
84
|
+
- Omitting `--plugin` is byte-identical to `canon query`'s output without
|
|
85
|
+
plugins — it's a purely additive layer.
|
|
86
|
+
|
|
87
|
+
## The `porting` worked example
|
|
88
|
+
|
|
89
|
+
`porting` moves the `covered`/`surface_ref` fields off the core
|
|
90
|
+
`scenario` record and into a plugin overlay:
|
|
91
|
+
|
|
92
|
+
1. `.canon/plugins/porting/plugin.yaml` — the manifest above.
|
|
93
|
+
2. Its overlay source reads a spec root's `inventory/**/*.yaml` (the same
|
|
94
|
+
inventory files `canon format --check` covers) and, for every
|
|
95
|
+
`(project_id, scenario_id)` `canon inventory sync` would index, emits
|
|
96
|
+
one candidate: `covered = true` iff that `scenario_id` appears in ANY
|
|
97
|
+
inventory entry's `covered_by` list; `surface_ref` = every inventory-
|
|
98
|
+
entry key whose `covered_by` contains it (empty when uncovered). The
|
|
99
|
+
candidate's envelope `at` is the source version (the max inventory-file
|
|
100
|
+
`at`), so an unchanged inventory re-syncs to a byte-identical body
|
|
101
|
+
(zero new files) and a genuine coverage edit supersedes the prior
|
|
102
|
+
overlay via latest-`at`-wins.
|
|
103
|
+
3. `canon plugin sync porting` writes/dedupes those candidates.
|
|
104
|
+
4. `canon query --kind scenario --plugin porting [--json]` projects
|
|
105
|
+
`covered`/`surface_ref` back onto each scenario.
|
|
106
|
+
|
|
107
|
+
`canon gate check`'s verdicts are byte-identical with and without a
|
|
108
|
+
`canon plugin sync porting` run — the gate reads NO plugin overlay. Wiring
|
|
109
|
+
an overlay field into a gate decision needs its own reviewed change
|
|
110
|
+
("covered ≠ coverage").
|
|
111
|
+
|
|
112
|
+
## Do not confuse the two `plugin.yaml` surfaces
|
|
113
|
+
|
|
114
|
+
The typed-authoring vocabulary (`.canon/vocab/<id>/plugin.yaml`: directives
|
|
115
|
+
+ enums for task-atom authoring — see `canon-vocab`) is a
|
|
116
|
+
DIFFERENT directory and a DIFFERENT schema. A ledger-overlay manifest
|
|
117
|
+
misplaced under `.canon/vocab/<id>/` fails that loader (missing
|
|
118
|
+
`directives`/`enums`), not silently parsing as an authoring vocabulary.
|
|
119
|
+
|
|
120
|
+
## What this skill does NOT cover
|
|
121
|
+
|
|
122
|
+
- **`canon inventory sync`** (the core index `porting`'s source reads) —
|
|
123
|
+
see `canon-inventory`.
|
|
124
|
+
- **`canon gate check`** — see `canon-gate`; the gate never reads a
|
|
125
|
+
plugin overlay.
|
|
126
|
+
- **`canon scenario new`/`canon feature new`** (corpus-authoring
|
|
127
|
+
scaffold, independent of the plugin machinery) — template generators
|
|
128
|
+
that write no ledger record.
|
|
129
|
+
- **A projection over a core kind other than `scenario`, or wiring an
|
|
130
|
+
overlay field into a `canon gate` decision** — out of scope; each needs
|
|
131
|
+
its own reviewed change.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# canon-policy
|
|
2
|
+
|
|
3
|
+
Canon uses ONE expression language — CEL — for every conditional knob a
|
|
4
|
+
spec needs: policy-routing predicates, tier-aging thresholds, verdict-
|
|
5
|
+
mapping guards, retrieval filters, and a template's `applies_when:`
|
|
6
|
+
section. Wherever a `policy.yaml` predicate or an `applies_when:` string
|
|
7
|
+
appears, it is a CEL expression evaluated against the target record.
|
|
8
|
+
|
|
9
|
+
## The closed CEL profile
|
|
10
|
+
|
|
11
|
+
Every expression is evaluated against exactly one variable, `record` (the
|
|
12
|
+
target kind's own fields — always `record.<field>`, never the raw
|
|
13
|
+
envelope), plus one registered function:
|
|
14
|
+
|
|
15
|
+
- `record.<field>` — any field the target kind declares. Nested objects
|
|
16
|
+
(e.g. `record.actor.agent_id`) resolve to a bounded depth; deeply-open
|
|
17
|
+
fields (an unconstrained `detail` blob) are readable but not field-
|
|
18
|
+
checked at write time.
|
|
19
|
+
- `age_days(ts) -> int` — whole days between a timestamp field and a
|
|
20
|
+
caller-supplied "now" (never the wall clock read inside the function, so
|
|
21
|
+
the same expression against the same facts and the same `now` always
|
|
22
|
+
evaluates identically).
|
|
23
|
+
- `has(record.<field>)` — CEL's built-in field-presence macro. Validates
|
|
24
|
+
like a normal field read.
|
|
25
|
+
- CEL's operator grammar (`==`, `!=`, `&&`, `||`, `>`, `<`, `in`, the
|
|
26
|
+
ternary `? :`) and macros (`map`, `filter`, `all`, `exists`,
|
|
27
|
+
`exists_one`) are always available.
|
|
28
|
+
|
|
29
|
+
Nothing else is. There is NO `size()`, `matches()`, `contains()`,
|
|
30
|
+
`string()`, or any other CEL standard-library method — the profile is a
|
|
31
|
+
closed allowlist of exactly the one function above. A
|
|
32
|
+
`record.title.contains('x')`-shaped expression is rejected at WRITE time
|
|
33
|
+
with an `UnknownFunction` diagnostic, not deferred to a runtime failure.
|
|
34
|
+
Express the same check with `record.<field> == <literal>` / `in` / `has`
|
|
35
|
+
instead.
|
|
36
|
+
|
|
37
|
+
## Discovering what's bindable for a kind
|
|
38
|
+
|
|
39
|
+
Run `canon context --json` and read its `cel` section: for each record
|
|
40
|
+
kind it lists the bindable `record.<field>` names + types and the
|
|
41
|
+
callable function allowlist a `policy.yaml` predicate may reference. This
|
|
42
|
+
is the source of truth for "what can a CEL predicate for this kind
|
|
43
|
+
reference?" — it's derived from the same schema `canon format` validates
|
|
44
|
+
against, so there is no second, hand-maintained list to drift. See the
|
|
45
|
+
`canon-context` skill.
|
|
46
|
+
|
|
47
|
+
## Writing and validating an expression
|
|
48
|
+
|
|
49
|
+
An expression is compiled BEFORE storage: both parsing AND a full walk
|
|
50
|
+
(checking every `record.<field>` chain and every function call) run up
|
|
51
|
+
front. There is no "accepted but fails on first evaluation" path — a
|
|
52
|
+
type-invalid expression is rejected at author time with every diagnostic,
|
|
53
|
+
not just the first.
|
|
54
|
+
|
|
55
|
+
Evaluation is bounded by a wall-clock deadline (default 200ms): a
|
|
56
|
+
pathological expression is reported as budget-exceeded rather than
|
|
57
|
+
blocking. `now` is always caller-supplied — one `canon gate` run scoring
|
|
58
|
+
many records uses the same `now` across the batch for mutually consistent
|
|
59
|
+
results.
|
|
60
|
+
|
|
61
|
+
## Reading a write-time "expected …" diagnostic
|
|
62
|
+
|
|
63
|
+
Every rejection names what was expected, never a bare "invalid
|
|
64
|
+
expression":
|
|
65
|
+
|
|
66
|
+
- **Undeclared field** — `` `record.severty` is not a declared field of
|
|
67
|
+
`task` (expected one of: actor, at, evidence_note, kind, schema,
|
|
68
|
+
status, task_id, title) `` — check the name against `canon context`'s
|
|
69
|
+
field list; a typo is the usual cause.
|
|
70
|
+
- **Undeclared variable** — `` `foo` is not a declared variable (expected
|
|
71
|
+
one of: record) `` — only `record` (plus a `map`/`filter` loop
|
|
72
|
+
variable) is ever in scope.
|
|
73
|
+
- **Unknown function** — `` `contains` is not an allowlisted function
|
|
74
|
+
(expected one of: age_days) `` — a typo, or a CEL standard-library
|
|
75
|
+
method the closed profile doesn't register.
|
|
76
|
+
- **Arity mismatch** — `` `age_days` expects 1 argument(s), got 0 ``.
|
|
77
|
+
- **Type mismatch** — `` `age_days` expects argument 0 of type
|
|
78
|
+
`timestamp`, got `string` `` — the field passed isn't the type the
|
|
79
|
+
function's signature declares.
|
|
80
|
+
|
|
81
|
+
## What never gets a CEL hook
|
|
82
|
+
|
|
83
|
+
Some knobs deliberately stay code, not configurable CEL: reward-scoring
|
|
84
|
+
functions, ingest/verdict-mapping transforms, and the content of an
|
|
85
|
+
evidence record itself. If a task looks like "let this record configure
|
|
86
|
+
its own evaluation logic," it is out of scope — raise it as a design
|
|
87
|
+
question rather than adding an expression field. Conditional logic about
|
|
88
|
+
what a record *means* lives in `policy.yaml`/`applies_when:`, evaluated
|
|
89
|
+
against the record's fields, never inside the record.
|
|
90
|
+
|
|
91
|
+
## Effect-aware risk tiers
|
|
92
|
+
|
|
93
|
+
The optional `risk_tiers` section is not CEL. Absent means no risk-approval
|
|
94
|
+
check. A present section must be a mapping of tier names to:
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
risk_tiers:
|
|
98
|
+
high:
|
|
99
|
+
rank: 3
|
|
100
|
+
paths: ["src/auth/**"]
|
|
101
|
+
effects: ["secret-access"]
|
|
102
|
+
min_human_approvals: 1
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Malformed sections or entries are retained as a policy diagnostic and make
|
|
106
|
+
`canon gate check` fail closed with `uncovered-cell risk_tiers — risk_tiers
|
|
107
|
+
policy-invalid: ...`; they are never treated as an empty map. The gate
|
|
108
|
+
selects the highest-ranked tier against the current, faithful
|
|
109
|
+
`EvidenceRecord` generation. A current `evidence_sha` and an explicit
|
|
110
|
+
`surface_ref`/`effect:<kebab-slug>` binding are required for a tier that
|
|
111
|
+
requires approval. Surface refs are validated syntax and attestation input,
|
|
112
|
+
not proof that the caller's path is authoritative; configured rules cannot
|
|
113
|
+
be bypassed by declaring a safe ref.
|
|
114
|
+
|
|
115
|
+
Approval text is also only an attestation unless identity is verified.
|
|
116
|
+
`canon evidence add` marks an approval verified only when `--approval-by`
|
|
117
|
+
equals non-empty `CANON_ACTOR` and `--approval-role human` is supplied.
|
|
118
|
+
The persisted verification bit is what later gates count; arbitrary caller
|
|
119
|
+
strings, empty identities, and agent roles do not count.
|