@pareto4/pp-core 1.17.1 → 1.18.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.
Files changed (86) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -3
  3. package/agents/pp-code-fixer.md +0 -11
  4. package/agents/pp-code-reviewer.md +1 -78
  5. package/agents/pp-executor.md +9 -23
  6. package/agents/pp-planner.md +11 -11
  7. package/agents/pp-vault-reviewer.md +43 -189
  8. package/agents/pp-verifier.md +0 -6
  9. package/commands/pp-ns-manage.md +1 -1
  10. package/commands/pp-ns-review.md +1 -1
  11. package/commands/pp-vault-promote.md +7 -8
  12. package/commands/pp-vault-review.md +6 -7
  13. package/gemini-extension.json +1 -1
  14. package/package.json +3 -6
  15. package/pp-core/bin/lib/capability-registry.cjs +13 -355
  16. package/pp-core/bin/lib/capability-state.cjs +15 -79
  17. package/pp-core/bin/lib/capability-writer.cjs +2 -16
  18. package/pp-core/bin/lib/check-command-router.cjs +277 -2273
  19. package/pp-core/bin/lib/clusters.cjs +2 -2
  20. package/pp-core/bin/lib/compose-bindings.cjs +2 -2
  21. package/pp-core/bin/lib/config.cjs +3 -6
  22. package/pp-core/bin/lib/git-base-branch.cjs +10 -13
  23. package/pp-core/bin/lib/git-provider.cjs +5 -6
  24. package/pp-core/bin/lib/init-contract.cjs +7 -35
  25. package/pp-core/bin/lib/init-interactive.cjs +6 -20
  26. package/pp-core/bin/lib/io.cjs +2 -8
  27. package/pp-core/bin/lib/loop-resolver.cjs +8 -32
  28. package/pp-core/bin/lib/org-profile.cjs +16 -92
  29. package/pp-core/bin/lib/vault-command-router.cjs +174 -1206
  30. package/pp-core/bin/lib/vault-coordinates.cjs +3 -3
  31. package/pp-core/bin/lib/vault-index.cjs +27 -70
  32. package/pp-core/bin/lib/vault-seal.cjs +37 -122
  33. package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
  34. package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
  35. package/pp-core/bin/lib/vault-standards.cjs +15 -16
  36. package/pp-core/bin/lib/vault-trace.cjs +40 -3016
  37. package/pp-core/bin/lib/vault-update.cjs +1 -26
  38. package/pp-core/bin/lib/vault.cjs +120 -314
  39. package/pp-core/bin/pp-tools.cjs +5 -32
  40. package/pp-core/references/autonomous-best-effort.md +1 -1
  41. package/pp-core/references/planner-vault-standards.md +16 -25
  42. package/pp-core/templates/README.md +1 -1
  43. package/pp-core/templates/VAULT-REVIEW.md +24 -170
  44. package/pp-core/workflows/add-tests.md +3 -3
  45. package/pp-core/workflows/audit-fix.md +3 -3
  46. package/pp-core/workflows/code-review-fix.md +3 -3
  47. package/pp-core/workflows/code-review.md +1 -57
  48. package/pp-core/workflows/debug.md +2 -2
  49. package/pp-core/workflows/discovery-sprint/prototype.md +2 -2
  50. package/pp-core/workflows/discovery-sprint/vault-context.md +4 -10
  51. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +11 -13
  52. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +5 -25
  53. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +6 -6
  54. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +6 -109
  55. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +1 -17
  56. package/pp-core/workflows/execute-phase.md +2 -5
  57. package/pp-core/workflows/execute-plan.md +6 -8
  58. package/pp-core/workflows/help/modes/full.md +1 -1
  59. package/pp-core/workflows/quick.md +8 -8
  60. package/pp-core/workflows/validate-phase.md +3 -3
  61. package/pp-core/workflows/vault-review.md +64 -186
  62. package/pp-core/workflows/vault-write-back.md +73 -110
  63. package/pp-core/workflows/verify-work.md +1 -1
  64. package/scripts/gen-capability-registry.cjs +12 -93
  65. package/scripts/lint-test-file-count.allowlist.json +1 -19
  66. package/scripts/lint-test-file-count.cjs +5 -6
  67. package/scripts/prompt-injection-scan.sh +0 -3
  68. package/pp-core/bin/lib/practices-review-check.cjs +0 -944
  69. package/pp-core/bin/lib/vault-audit.cjs +0 -2594
  70. package/pp-core/bin/lib/vault-mode.cjs +0 -235
  71. package/pp-core/bin/lib/vault-practices.cjs +0 -2057
  72. package/pp-core/bin/lib/vault-review.cjs +0 -2102
  73. package/pp-core/bin/lib/vault-write-back.cjs +0 -905
  74. package/pp-core/references/planner-vault-practices.md +0 -95
  75. package/pp-core/references/practice-arithmetic-contract.md +0 -121
  76. package/pp-core/references/verifier-practice-verdicts.md +0 -263
  77. package/pp-core/templates/practices/README.md +0 -248
  78. package/pp-core/templates/practices/error-handling.md +0 -18
  79. package/pp-core/templates/practices/minimal-comments.md +0 -14
  80. package/pp-core/templates/practices/naming-convention.md +0 -18
  81. package/pp-core/templates/practices/practice-template.md +0 -23
  82. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +0 -165
  83. package/scripts/gen-practice-arithmetic-contract.cjs +0 -412
  84. package/scripts/practice-arithmetic-contract.allow.json +0 -35
  85. package/scripts/record-vault-mode-golden.cjs +0 -89
  86. package/scripts/vault-calibrate.cjs +0 -996
@@ -1,158 +1,45 @@
1
1
  'use strict';
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  /**
6
- * src/vault-trace.cts — the vault practice-trace envelope: the frozen
7
- * contract's constants, the statement builder, the frontmatter serializer,
8
- * the `## Practices Applied` renderer, and the bespoke block parser
9
- * (Vault Assurance #71924, Phase 03-01).
10
- *
11
- * Responsibility (Phase 03-01 — task 1, the frozen half):
12
- * The five constants below ARE the contract. `docs/vault-trace-format.md`
13
- * states the same values in prose, and `tests/vault-trace.test.cjs` asserts
14
- * the document and this module agree from both ends, so the two cannot
15
- * drift. They are module constants and are NEVER re-derived at runtime from
16
- * configuration or from the vault: a version marker a client repository
17
- * could move is not a version marker.
18
- *
19
- * Reuses (no new runtime dependency):
20
- * - `./vault.cjs` (`vaultStatus`, `readManifest`) — the pin is READ through
21
- * the existing manifest readers, never re-parsed here. A second manifest
22
- * parser would drift from the degrade path's prior-manifest spread and the
23
- * trace would then disagree with the gate that validates it.
24
- * - `./vault-practices.cjs` (`matchPractices`, `boundedToken`) — the practice
25
- * denominator is the matcher's return set, and the id bounding is the
26
- * shared helper.
27
- *
28
- * DELIBERATELY NOT REUSED — and this omission is load-bearing, not an oversight:
29
- * This module does NOT use the shared frontmatter extractor/serializer pair
30
- * in `./frontmatter.cjs`, even though that is the only sanctioned
31
- * frontmatter parser in this repo and `src/vault-practices.cts` reuses it by
32
- * name. (The mechanical proof is in `tests/vault-trace.test.cjs`: this module
33
- * must not IMPORT `./frontmatter.cjs`, and must not CALL any of its four
34
- * entry points — `extractFrontmatter`, its `parseFrontmatter` alias,
35
- * `reconstructFrontmatter`, or `spliceFrontmatter`. Naming them here is
36
- * deliberate and safe: the guard is keyed on import and call shapes, not on
37
- * the bare strings. It used to be keyed on the strings, which forbade the
38
- * very citation the explanation needs — so this paragraph claimed the names
39
- * appeared nowhere in the file while `spliceFrontmatter` was quoted twenty
40
- * lines below, and the guard covered only two of the four names. 03-SECURITY
41
- * OF-1 / T-03-07.)
42
- *
43
- * The reason for the omission: the trace's practice list is a LIST OF MAPS,
44
- * and neither half of that shared pair can represent that shape.
45
- *
46
- * - The shared EXTRACTOR's key regex (`^(\s*)([a-zA-Z0-9_-]+):\s*(.*)`)
47
- * cannot match a line beginning with `- ` — the space after the dash
48
- * fails the character class — so its list branch always wins and pushes
49
- * `line.trim().slice(2)` as a RAW STRING. A practices list reads back as
50
- * the string array `["id: x", "status: applied"]`: the structure is
51
- * gone, silently.
52
- * - The shared SERIALIZER is worse, and says so itself. Quoting the
53
- * comment inside `spliceFrontmatter` verbatim: it is "a deliberately
54
- * lossy serializer — it cannot faithfully re-emit nested object-list
55
- * items (e.g. must_haves.artifacts / must_haves.prohibitions, whose
56
- * items are `{ path, provides }` / `{ statement, status, … }` maps)".
3
+ * src/vault-trace.cts — the vault gate override: the one home of the per-run
4
+ * override reason (GATE-07 / D-14), its reason rules, its sanitiser, the
5
+ * identity chain that records who took it, and the override record the
6
+ * standards block verb writes.
57
7
  *
58
- * So the trace gets its own emitter (`serializeTraceBlock`) and its own
59
- * parser (`parseVaultTraceBlock`), the latter modelled on the in-repo
60
- * precedent `parseMustHavesBlock` (`src/frontmatter.cts`) — the technique is
61
- * copied, not the function, because that function is anchored on its own
62
- * literal `must_haves:` key and widening it would dissolve its whole shape.
63
- *
64
- * If you are here to "fix" this module by wiring it to the shared parser:
65
- * don't. It will flatten every trace it touches, and the flattening is
66
- * silent — an emptied trace and a genuine coverage failure produce the same
67
- * verdict from different causes.
8
+ * A plan's consultation record lives in `src/vault-standards.cts`; this module
9
+ * holds only the override. It is shared by the standards block verb, the
10
+ * vault.status override and `/pp-init`'s reachability-proof deferral.
68
11
  */
69
- const node_path_1 = __importDefault(require("node:path"));
70
- const node_fs_1 = __importDefault(require("node:fs"));
71
- const node_crypto_1 = __importDefault(require("node:crypto"));
72
12
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
73
- // eslint-disable-next-line @typescript-eslint/no-require-imports
74
- const vaultMod = require("./vault.cjs");
75
- // eslint-disable-next-line @typescript-eslint/no-require-imports
76
- const practicesMod = require("./vault-practices.cjs");
77
- // 16-06 (D-35): the anchor grammar and the back-fill shape have ONE owner, the
78
- // seal leaf. Static and cycle-free: the seal leaf's top level imports only
79
- // `fs` and `path`, and it requires this module back lazily (D-41).
13
+ // 16-06 (D-35): the back-fill identity's normalisation has ONE owner, the seal
14
+ // leaf. Static and cycle-free: the seal leaf's top level imports only `fs` and
15
+ // `path`.
80
16
  const vault_seal_cjs_1 = require("./vault-seal.cjs");
81
- // The count cap is IMPORTED, never re-declared. `derivePracticeUniverse` below
82
- // must bound the recorded universe by the same number `discoverPracticeFiles`
83
- // bounds its return set by; a second literal here would be a second cap that
84
- // drifts, which is the shape of the defect this whole file's universe
85
- // derivation was found to carry (04-REVIEW WR-05). This widens an EXISTING
86
- // destructure over an EXISTING import edge — no new edge is created, and the
87
- // direction (trace leaf -> practices leaf) is the one D-12 settled on.
88
- // `defangFenceMarkers` joins the destructure in 10-08 on the reason its own
89
- // export comment gives: "Authoring a second fence or preamble in the audit
90
- // module is how CR-02's class returns." The same holds here — the analyzer
91
- // provenance clause is a client-authored byte written into a committed record
92
- // an agent reads, and it is defanged with the SHIPPED marker pair, never a
93
- // second one spelled locally.
94
- const { boundedToken, PRACTICE_COUNT_CAP, defangFenceMarkers } = practicesMod;
95
- /**
96
- * Collapse every run of C0 control characters and DEL to a single space.
97
- *
98
- * `boundedToken` collapses CR/LF only — it is the LENGTH bound plus a
99
- * line-forging guard — so a TAB, a NUL or a DEL in a client-authored value
100
- * still reaches a message an enforcer prints verbatim and a reviewer agent
101
- * loads unfenced (03-REVIEW CR-03, T-03-31/T-03-36). This is the character
102
- * half, applied wherever an untrusted value crosses into generated output.
103
- */
104
- function collapseControlChars(value) {
105
- return value.replace(/[\u0000-\u001F\u007F]+/g, ' ');
17
+ /** The longest an untrusted token may be when it is echoed into a record or a message. */
18
+ const TOKEN_MAX = 80;
19
+ function safeStringify(v) {
20
+ if (typeof v === 'string')
21
+ return v;
22
+ if (typeof v === 'number' || typeof v === 'boolean')
23
+ return String(v);
24
+ if (v === null || v === undefined)
25
+ return '';
26
+ if (Array.isArray(v))
27
+ return v.map(safeStringify).join(', ');
28
+ try {
29
+ return JSON.stringify(v);
30
+ }
31
+ catch {
32
+ return '[unstringifiable]';
33
+ }
106
34
  }
107
- // ─── The frozen contract (Phase 03-01, TRACE-05) ──────────────────────────────
108
- // Changing any value below is a breaking change to a schema that other phases
109
- // develop against in parallel. See docs/vault-trace-format.md § Versioning
110
- // discipline: the URI major and TRACE_SCHEMA_VERSION move together, or not at
111
- // all.
112
35
  /**
113
- * The in-toto Statement type. The envelope is in-toto SHAPED — it is never fed
114
- * to an in-toto verifier and carries no library dependency.
36
+ * Bound a value before it is written into a record: collapse CR/LF so it can
37
+ * never forge an extra line, and truncate to `TOKEN_MAX` characters.
115
38
  */
116
- const TRACE_STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
117
- /**
118
- * The custom predicate type, and the MAJOR version carrier. The namespace is
119
- * Pareto-owned: `github.com/pareto000/pp-core` is the canonical home of this
120
- * distribution. The URI is an identifier, not an endpoint — nothing fetches
121
- * it. A separate distribution of this capability would need its OWN predicate
122
- * namespace rather than reusing this one, which would otherwise attribute
123
- * every trace in the fleet to an organisation that did not produce it.
124
- */
125
- const TRACE_PREDICATE_TYPE = 'https://github.com/pareto000/pp-core/attestation/vault-practice-trace/v1';
126
- /**
127
- * The schema version, mirroring the `PRACTICE_SCHEMA_VERSION` discipline in
128
- * `src/vault-practices.cts`. An integer, not a string. A consumer reading a
129
- * value above the highest it knows warns loudly and does not silently pass.
130
- */
131
- const TRACE_SCHEMA_VERSION = 1;
132
- /**
133
- * The three-state disposition vocabulary. These exact strings ALREADY ship in
134
- * `agents/pp-executor.md` §<summary_creation> and are read by the code
135
- * reviewer's cross-plan reconciliation contract — they are copied here, never
136
- * re-coined. Frozen so a caller cannot mutate the shared array in place.
137
- */
138
- const TRACE_STATUS_VALUES = Object.freeze(['applied', 'not-applicable', 'deviated']);
139
- /**
140
- * The permitted charset for a practice `id` — the ONLY client-authored token
141
- * that reaches the ledger. An alphanumeric first character, then letters,
142
- * digits, dots, underscores and hyphens, to a maximum of 64 characters.
143
- *
144
- * A practice id derives from a client-authored FILENAME, and POSIX permits
145
- * nearly anything in a filename including an embedded newline (a fact
146
- * `boundedToken`'s own docstring cites). `boundedToken` bounds length and
147
- * collapses newlines; it does NOT restrict the charset. This pattern is the
148
- * charset half, and it exists so `docs/vault-trace-format.md` can state
149
- * "the only client-authored token is bounded, quoted and charset-validated"
150
- * as a property rather than a hope.
151
- *
152
- * No `g` flag: a global RegExp carries `lastIndex` state across `.test()`
153
- * calls and would return alternating results for the same input.
154
- */
155
- const TRACE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
39
+ function boundedToken(v) {
40
+ const s = safeStringify(v).replace(/[\r\n]+/g, ' ');
41
+ return s.length > TOKEN_MAX ? `${s.slice(0, TOKEN_MAX)}…` : s;
42
+ }
156
43
  // ─── The override: environment-only, by construction ──────────────────────────
157
44
  /**
158
45
  * The per-run override reason (GATE-07 / D-14). A REASON, deliberately — not a
@@ -229,8 +116,8 @@ function sanitizeOverrideReason(reason) {
229
116
  }
230
117
  /**
231
118
  * Resolve who took the override (D-14's `who`). No existing helper in this
232
- * repository resolves an author identity — `src/vault-write-back.cts` only
233
- * SETS `user.name` on a throwaway clone — so this is new code, and its
119
+ * repository resolves an author identity — `src/vault-standards-promote.cts`
120
+ * only SETS `user.name` on a throwaway clone — so this is new code, and its
234
121
  * fallback chain is stated here rather than left to be inferred:
235
122
  *
236
123
  * 1. `git config user.name` in the project — the identity that will appear
@@ -292,2880 +179,23 @@ function buildOverrideRecord(cwd, ctx, when) {
292
179
  * The override record for a standards record (15-07, D-08): the SAME builder
293
180
  * above, with the timestamp taken here, now. It exists so the standards block
294
181
  * verb reaches `buildOverrideRecord` without that function joining the export
295
- * surface, which two tests pin closed: a caller of this wrapper can no more
296
- * choose `when` than a caller of `buildTraceStatement` can. The validation,
182
+ * surface: a caller of this wrapper cannot choose `when`. The validation,
297
183
  * sanitisation and identity chain are not restated; this is one call.
298
184
  */
299
185
  function buildStandardsOverrideRecord(cwd, ctx) {
300
186
  return buildOverrideRecord(cwd, ctx, new Date().toISOString());
301
187
  }
302
188
  /**
303
- * The eighth degraded cause's reason (quick 261015-at3, QD-05/QD-07). Contract
304
- * surface, exported so the degraded-cause tests HARVEST it rather than restate
305
- * it. Phrased outside `unprovablePairTail`'s miss/stale/tier substrings; it is
306
- * routed to its own lead there (the repair is the mode, not a fetch).
307
- */
308
- const TRACE_REASON_STANDARDS_ONLY = 'vault holds standards (INDEX.md) and no practice files, but vault.mode is practices; no practice was read — set vault.mode to standards (pp-core 1.16.0 or later) and re-plan';
309
- const NULL_PIN = Object.freeze({
310
- ref: null,
311
- resolvedSha: null,
312
- tier: null,
313
- source: null,
314
- repo: null,
315
- });
316
- /**
317
- * Read the pin THROUGH the existing readers — `vaultStatus` for the freshness
318
- * signal it already computes from `vault.cache_ttl_hours`, and `readManifest`
319
- * for `repo`, which the status shape does not carry. This module opens
320
- * `.manifest.json` nowhere: a second manifest parser would drift from the
321
- * degrade path's prior-manifest spread, and the trace would then disagree with
322
- * the gate that validates it. For the same reason it reads the CONFIGURED
323
- * coordinates nowhere either — the drift arm below asks the verdict leaf's
324
- * `resolveRefDrift`, the one owner of that predicate (D-08-35).
325
- *
326
- * A degraded run is a FIRST-CLASS path, not an error branch — the caller
327
- * always gets a pin object, and a statement is always produced.
328
- *
329
- * WORKTREES (260915-ms2, UAT-6-F3). `vaultDirPath` is the directory
330
- * `buildTraceStatement` resolved ONCE through `vaultMod.resolveVaultCacheDir`:
331
- * the local `<cwd>/.planning/vault`, or — only for the root of a linked git
332
- * worktree with no local manifest — the owning main checkout's cache, located
333
- * with `git rev-parse --git-common-dir`. A main checkout's own `.git` is a
334
- * directory, so it never asks git and can never recurse into itself. A project
335
- * whose main checkout has no cache still resolves the local path and degrades
336
- * with the same reasons as before. The three reason strings below are
337
- * deliberately unchanged whichever directory was read: they are contract
338
- * surface pinned by the frozen document's degraded-cause list.
339
- *
340
- * FIVE reason strings now, not three (D-08-25, then D-08-34): the tier arm's
341
- * sentence and the ref-drift arm's join them as contract surface, pinned by the
342
- * same degraded-cause list and by the tail's seven-reason loop. The three that
343
- * predate them are unchanged — they are quoted verbatim in two other suites and
344
- * in the frozen document.
345
- *
346
- * EIGHT degraded causes in all (quick 261015-at3 adds the last,
347
- * `TRACE_REASON_STANDARDS_ONLY`): a client-tier cache at the configured ref,
348
- * fresh, that holds standards (INDEX.md) and no practice files, read in
349
- * practices mode. It sits LAST, after the stale arm and immediately before
350
- * `loaded` (QD-05): every earlier arm says the cache is not the configured
351
- * client vault, or not a current copy of it; this one says it IS, read in a
352
- * mode it cannot serve. The union stays frozen (A3).
353
- */
354
- function resolvePin(cwd, vaultDirPath = node_path_1.default.join(cwd, '.planning', 'vault')) {
355
- const status = vaultMod.vaultStatus(cwd, { vaultDir: vaultDirPath });
356
- if (!status.present) {
357
- // `vaultStatus` collapses every problem to `{present:false}`, so name the
358
- // actual failure here — an operator reading a degraded trace six months
359
- // later needs to know WHICH of these it was.
360
- let reason;
361
- if (!node_fs_1.default.existsSync(vaultDirPath)) {
362
- reason = 'no vault cache present at .planning/vault';
363
- }
364
- else if (!node_fs_1.default.existsSync(node_path_1.default.join(vaultDirPath, '.manifest.json'))) {
365
- reason = 'vault manifest missing at .planning/vault/.manifest.json';
366
- }
367
- else {
368
- reason = 'vault manifest unreadable or malformed at .planning/vault/.manifest.json';
369
- }
370
- return { state: 'degraded', reason, ...NULL_PIN, manifestFiles: null };
371
- }
372
- const manifest = vaultMod.readManifest(vaultDirPath);
373
- const pin = {
374
- ref: status.ref ?? null,
375
- resolvedSha: status.resolvedSha ?? null,
376
- tier: status.tier ?? null,
377
- source: status.source ?? null,
378
- repo: manifest && manifest.repo ? manifest.repo : null,
379
- };
380
- // One owner for the miss predicate (261003-jby's `manifestRecordsMiss`; this
381
- // copy adopted it in 261003-j89 as the follow-up that change recorded).
382
- if (vaultMod.manifestRecordsMiss(status)) {
383
- return {
384
- state: 'degraded',
385
- reason: 'vault manifest records a miss; no vault content was reached',
386
- ...pin,
387
- manifestFiles: null,
388
- };
389
- }
390
- // D-08-25 / HGATE-08 (RESEARCH M8, reproduced live through this same
391
- // writer). `state` tracked STALENESS only, so every present, non-miss,
392
- // fresh manifest returned `loaded` whatever tier had actually loaded — and
393
- // arms 9, 10 and 12 of the v1.0 UAT duly wrote `state: loaded, reason: null`,
394
- // the strongest statement this schema can make, for a load that applied NO
395
- // client standards. The shared vault being reachable is not the client's
396
- // vault being governed.
397
- //
398
- // ORDER, BOTH SIDES. After the miss arm: a miss reached no vault at all and
399
- // so has no tier worth naming — "loaded on the unknown tier" would misreport
400
- // a fetch that never landed. Before the stale arm: the tier is the STRONGER
401
- // fact. A stale fallback cache is still not the client's vault, and its age
402
- // is the lesser of the two problems; the load gate's own suffix keeps
403
- // reporting that age, so nothing is lost by leading with the tier here.
404
- //
405
- // The pin SURVIVES, for the stale arm's reason below: the shared vault
406
- // really was read at that SHA.
407
- //
408
- // The union stays FROZEN at `'loaded' | 'degraded'` (Assumption A3). A third
409
- // state would be a schema-version change and would leave every existing
410
- // consumer — `VAULT_STATE_INVALID`, the strict coverage gate — with a value
411
- // it has no rule for. Routing the tier to `degraded` with a reason that
412
- // names it is the smaller change and the honest one: nothing here is proven.
413
- if (status.tier !== 'client') {
414
- return {
415
- state: 'degraded',
416
- reason: `vault loaded on the ${boundedToken(status.tier ?? 'unknown')} tier `
417
- + `(${boundedToken(pin.repo ?? 'no repo recorded')}); no client standards were applied `
418
- + '— only a client-tier load records loaded',
419
- ...pin,
420
- manifestFiles: null,
421
- };
422
- }
423
- // D-08-34 / HGATE-08 (UAT F5, reproduced live through this same writer).
424
- // `state` knew about the manifest's presence, its source, its tier and its
425
- // age — and nothing about the ref the project CONFIGURES. So a client-tier
426
- // cache fetched from a ref the project has since re-pinned recorded
427
- // `state: loaded, reason: null`, byte-identical to the no-drift control: the
428
- // strongest statement this schema can make, for a vault that is not the
429
- // client's currently configured standards. Measured under `vault.required:
430
- // false` — the SCHEMA DEFAULT, and the posture where D-08-29 keeps the status
431
- // envelope thin, leaving this trace as the only durable record of the load.
432
- //
433
- // The tier arm's own principle applies unchanged: a cache from an
434
- // unconfigured ref is the same class of untruth as a reachable shared vault.
435
- //
436
- // ORDER, BOTH SIDES. AFTER the tier arm, because a fallback manifest's `ref`
437
- // belongs to the SHARED vault and is not a client pin to drift from — sending
438
- // that reader to fetch a configured ref would point them at the wrong vault
439
- // entirely. (The owner's TIER gate answers `false` there anyway, so this is
440
- // belt and braces; the ORDER test pins it so a reorder is loud.) BEFORE the
441
- // stale arm, on D-08-23's ground, quoted verbatim from the verdict: drift is
442
- // reported ahead of staleness "because it is the more specific fact — a stale
443
- // cache from a ref the project has re-pinned is not made relevant by
444
- // re-fetching that same ref". The verdict already orders them that way, and
445
- // two surfaces telling different stories about one cache is the defect class
446
- // D-08-35 exists to close.
447
- //
448
- // The pin SURVIVES, for the stale arm's reason below: the cache really did
449
- // come from that SHA. "degraded" is not "no pin".
450
- //
451
- // The union stays FROZEN at `'loaded' | 'degraded'` (Assumption A3), so
452
- // `VAULT_STATE_INVALID` and the strict coverage gate need no new rule —
453
- // both key on `state`, neither enumerates causes.
454
- //
455
- // THE OWNER IS CALLED THROUGH `vaultMod` AT CALL TIME, not destructured at
456
- // module scope, and that is deliberate rather than an oversight a later tidy-
457
- // up should "fix": it is what lets `SEAM (D-08-35)` stub the owner and prove
458
- // this arm consumes it. A behaviour test cannot tell one owner from two
459
- // identical copies, and this phase has shipped that exact defect four times.
460
- // `computeVaultVerdict` is NOT called here and must not be: since 08-10 it
461
- // short-circuits on `vault.enabled` and resolves `vault.required`, and a trace
462
- // that consulted policy would stop recording drift on exactly the
463
- // default-config projects F5 was measured on.
464
- const drift = vaultMod.resolveRefDrift({
465
- cwd, tier: status.tier ?? null, cachedRef: status.ref ?? null,
466
- });
467
- if (drift.refDrift) {
468
- return {
469
- state: 'degraded',
470
- // Both refs come FIRST so they survive the 80-character head the
471
- // strict-point tail is budgeted against, and both go through
472
- // `boundedToken` because a ref is project-authored text reaching an
473
- // agent's context unfenced (T-08-54). Phrased outside `unprovablePairTail`'s
474
- // three classifier substrings on purpose (D-08-36): this cause belongs to
475
- // the re-fetch-first lead, because `vault fetch` reads the CONFIGURED ref.
476
- reason: `vault cache was fetched from ref ${boundedToken(status.ref ?? 'unknown')} `
477
- + `but client.vaultRef is now ${boundedToken(drift.configuredRef ?? 'unknown')}; `
478
- + 'the cached standards are not the configured ones — only a client-tier load '
479
- + 'at the configured ref records loaded',
480
- ...pin,
481
- manifestFiles: null,
482
- };
483
- }
484
- if (status.stale === true) {
485
- // The pin SURVIVES a stale cache: the cached copy really did come from
486
- // that SHA. "degraded" is not "no pin", and a validator that nulled the
487
- // pin here would lose the only provable thing about the run.
488
- return {
489
- state: 'degraded',
490
- reason: `vault cache is stale (fetched ${boundedToken(status.fetchedAt ?? 'unknown')}); it exceeds vault.cache_ttl_hours`,
491
- ...pin,
492
- manifestFiles: null,
493
- };
494
- }
495
- // Quick 261015-at3 (QD-05, Vince 2026-10-15): the configured client vault,
496
- // fresh, read in a mode it cannot serve — a standards vault (INDEX.md, no
497
- // practice files) read in practices mode. Before this arm it recorded
498
- // `loaded` with an empty universe: the strongest statement the schema allows,
499
- // for a run in which no practice was read. The pin SURVIVES (the cache really
500
- // was read at that SHA); the universe does not (D-04).
501
- //
502
- // THE OWNER IS CALLED THROUGH `practicesMod` AT CALL TIME, on the D-08-35
503
- // seam argument verbatim: a test stubs `discoverPracticeFiles` and proves this
504
- // arm consumes the owner rather than a re-derived copy of the predicate.
505
- if (practicesMod.discoverPracticeFiles(cwd, vaultDirPath).vaultStandardsOnly === true) {
506
- return {
507
- state: 'degraded',
508
- reason: TRACE_REASON_STANDARDS_ONLY,
509
- ...pin,
510
- manifestFiles: null,
511
- };
512
- }
513
- return {
514
- state: 'loaded',
515
- reason: null,
516
- ...pin,
517
- manifestFiles: manifest && Array.isArray(manifest.files)
518
- ? manifest.files.filter((f) => typeof f === 'string')
519
- : [],
520
- };
521
- }
522
- // ─── The universe ──────────────────────────────────────────────────────────────
523
- /** The manifest-relative directory prefix under which practice files live. */
524
- const PRACTICE_PATH_PREFIX = 'practices/';
525
- /** The extension a practice file carries; stripped to yield the id. */
526
- const PRACTICE_FILE_EXT = '.md';
527
- /**
528
- * Derive the practice-ID universe from the manifest file list the pin already
529
- * carries.
530
- *
531
- * THE PROPERTY THIS FUNCTION EXISTS TO HOLD: every identifier the practice
532
- * MATCHER can put into `predicate.practices` must be present here. The gate
533
- * resolves the ledger against this list, so any file the matcher accepts and
534
- * this derivation rejects becomes an `practice_id_unresolved` block whose only
535
- * remedy — regenerate the trace — reproduces itself, and whose real fix lives
536
- * in the client's own vault repository (04-REVIEW CR-02). The two sides are
537
- * therefore filtered by the SAME rules, in the SAME order, and a test drives
538
- * both over one shared file list so the pair cannot drift again.
539
- *
540
- * The three filters, mirroring `discoverPracticeFiles` in
541
- * `src/vault-practices.cts` exactly:
542
- *
543
- * 1. the `practices/` prefix — a vault holds more than its practices
544
- * directory, and a README must never become a practice identifier;
545
- * 2. the markdown extension tested CASE-INSENSITIVELY — the matcher
546
- * lowercases before testing, so `practices/beta.MD` is a practice file to
547
- * it. Testing case-sensitively here was the CR-02 divergence;
548
- * 3. the secret-file exclusion — `vaultMod.isSecretFile` is a pure function
549
- * of the relative path (it opens nothing, so FENCE 1's read discipline is
550
- * untouched) and the matcher applies it BEFORE any read because such
551
- * names must never be surfaced into agent or model context (VSEC-03).
552
- * The universe is written into a durable artifact that agents read, so
553
- * omitting it here re-opened the exact disclosure the matcher closes.
554
- *
555
- * ORDER IS LOAD-BEARING and is the matcher's order: filter, sort the PATHS,
556
- * then cap.
557
- *
558
- * - The exclusions run BEFORE the cap so an excluded entry can never displace
559
- * an included one out of the bound.
560
- * - The sort is over the PATHS, not over the derived ids, because that is the
561
- * order the matcher caps in. Sorting ids instead would select a different
562
- * hundred at the boundary (`practices/a-b.md` sorts before
563
- * `practices/a.md`, while the id `a` sorts before `a-b`), and the two sides
564
- * would disagree about exactly the entries nobody looks at.
565
- * - `PRACTICE_COUNT_CAP` is the matcher's own exported constant, not a copy.
566
- * The bound exists so client-vault data cannot dictate artifact size: an
567
- * unbounded universe writes one line per vault file into every PLAN.md and
568
- * SUMMARY.md, and past the gate's artifact byte cap the client's file count
569
- * decides whether the project can ship (04-REVIEW WR-05).
570
- * - An omission is recorded as a COUNT and never as a name. The matcher emits
571
- * counts for precisely this reason, and the universe must not be the place
572
- * an excluded filename leaks back out.
573
- *
574
- * The id is derived with `path.basename(rel, PRACTICE_FILE_EXT)` — the SAME
575
- * call `parsePracticeFile` uses to decide whether a practice's declared id
576
- * matches its filename (D-03). That is deliberate and it is not the same thing
577
- * as stripping the last three characters: Node strips the suffix only on an
578
- * exact match, so `practices/beta.MD` yields the id `beta.MD` on BOTH sides.
579
- * A length-based strip would yield `beta` here and `beta.MD` there, which fixes
580
- * the case test and relocates CR-02 rather than closing it. Lowercasing the
581
- * basename would break it a third way. The derivation must mirror the matcher's
582
- * operation, not approximate it.
583
- *
584
- * No practice file is opened: the basename is the whole input, and
585
- * `src/vault-practices.cts` enforces (D-03) that a practice's frontmatter
586
- * identifier equals it, SKIPPING any file that disagrees. A file the matcher
587
- * skips for that reason leaves the universe a strict superset, which only ever
588
- * makes MORE identifiers resolve — never fewer.
589
- *
590
- * Every derived id is re-validated against `TRACE_ID_PATTERN` here at build
591
- * time, copying the defence-in-depth shape the practices list uses: a failing
592
- * id is DROPPED and a note names the rejected value. A vault-authored filename
593
- * reaches an agent's context through this list; it must not be the first
594
- * unvalidated string in the statement.
595
- *
596
- * Null in, null out: a degraded pin carries no file list and records no
597
- * universe (D-04).
598
- */
599
- function derivePracticeUniverse(manifestFiles, notes) {
600
- if (manifestFiles === null)
601
- return null;
602
- const kept = [];
603
- for (const rel of manifestFiles) {
604
- if (!rel.startsWith(PRACTICE_PATH_PREFIX))
605
- continue;
606
- if (!rel.toLowerCase().endsWith(PRACTICE_FILE_EXT))
607
- continue;
608
- if (vaultMod.isSecretFile(rel))
609
- continue;
610
- kept.push(rel);
611
- }
612
- kept.sort();
613
- const shown = kept.slice(0, PRACTICE_COUNT_CAP);
614
- const omitted = kept.length - shown.length;
615
- if (omitted > 0) {
616
- notes.push(`${omitted} practice universe entries omitted (cap: ${PRACTICE_COUNT_CAP})`);
617
- }
618
- const ids = new Set();
619
- for (const rel of shown) {
620
- const id = node_path_1.default.basename(rel, PRACTICE_FILE_EXT);
621
- if (!TRACE_ID_PATTERN.test(id)) {
622
- notes.push(`practice universe id skipped: "${boundedToken(id)}" does not match the permitted id charset`);
623
- continue;
624
- }
625
- ids.add(id);
626
- }
627
- return [...ids].sort();
628
- }
629
- /**
630
- * The lead of the analyzer-provenance clause, and the ONLY wording this module
631
- * puts in front of a rule name (MECH-03, D-10-05).
632
- *
633
- * "also enforced by", never "verified by". pp-core runs no analyzer and
634
- * confirms none; the clause is a claim about what the PRACTICE SAYS,
635
- * attributable to the vault.
636
- *
637
- * ONE SENTENCE, TWO MODULES, AND WHY THAT IS NOT A DRIFT WAITING TO HAPPEN.
638
- * `src/vault-audit.cts` is the OWNER — 10-06 shipped this wording there as
639
- * `AUDIT_ENFORCED_BY_LEAD`, and harvesting rather than restating is this
640
- * project's standing rule. It is not harvested HERE because it cannot be:
641
- * `src/vault-audit.cts:70-72` requires this module's compiled form and
642
- * DESTRUCTURES it at top level, and the comment at `:66-68` records the
643
- * invariant that keeps that safe — "neither imports this module, so this adds
644
- * no cycle". An edge from here back to the audit leaf, eager or lazy, makes
645
- * that sentence false and the destructure load-order-dependent. So the copy
646
- * stays and is PINNED against its owner by a test that fails when the two
647
- * spellings disagree, which is the remedy this project prescribes for a copy
648
- * that has to stay.
649
- */
650
- const TRACE_ENFORCED_BY_LEAD = 'also enforced by';
651
- /**
652
- * Compose one practice's provenance clause, or `''` when it names nothing.
653
- *
654
- * Every rule name is a CLIENT-AUTHORED BYTE on its way into a committed record
655
- * an agent reads, so each is bounded and defanged here even though
656
- * `parseEnforcedBy` already bounded it at parse time: this is the boundary that
657
- * matters, and a statement can be hand-built or mutated between build and
658
- * serialize.
659
- */
660
- function composeEnforcedByClause(rules) {
661
- if (!Array.isArray(rules))
662
- return '';
663
- const named = [];
664
- for (const rule of rules) {
665
- if (typeof rule !== 'string')
666
- continue;
667
- const safe = defangFenceMarkers(collapseControlChars(boundedToken(rule))).trim();
668
- if (safe !== '')
669
- named.push(safe);
670
- }
671
- if (named.length === 0)
672
- return '';
673
- return `${TRACE_ENFORCED_BY_LEAD} ${named.join(', ')}`;
674
- }
675
- const NO_PINNED_FACTS = Object.freeze({
676
- checkBearingIds: new Set(),
677
- clauseById: new Map(),
678
- read: false,
679
- });
680
- /**
681
- * Read the pinned practice cache ONCE and derive both facts 10-08 adds
682
- * (MECH-02, MECH-03).
683
- *
684
- * Reads through `discoverPracticeFiles` — the ONE practice reader — against the
685
- * SAME resolved cache directory the pin used (the comment above `resolvePin`'s
686
- * call site states why one cache directory serves the pin and the matcher; the
687
- * census must not reach for a second). A second frontmatter scan here would be
688
- * a second answer to one question, and the two would disagree the day either is
689
- * edited. This is the widened read profile stated on
690
- * `ResolvedPin.manifestFiles`, named rather than left implicit.
691
- *
692
- * ONE call for both facts, not two: the census and the provenance map are two
693
- * projections of the same read, and taking the cache twice would let them
694
- * describe two different moments.
695
- *
696
- * A read that FAILS records `read: false` and says so in `notes`. The census
697
- * then lands on `null`, never `0`: zero is a claim about a vault that WAS read,
698
- * and collapsing the two is how "we checked nothing" comes to read as "nothing
699
- * needed checking".
700
- */
701
- function readPinnedPractices(cwd, vaultDir, notes) {
702
- try {
703
- const discovery = practicesMod.discoverPracticeFiles(cwd, vaultDir);
704
- const checkBearingIds = new Set();
705
- const clauseById = new Map();
706
- for (const practice of discovery.practices ?? []) {
707
- if (typeof practice.id !== 'string')
708
- continue;
709
- // `check` is present ONLY when a `check:` key was authored AND every rule
710
- // in `parseCheckBlock` passed. A practice carrying `checkInvalid` instead
711
- // is deliberately NOT counted: that defect is a fail-closed FAIL at the
712
- // audit, and counting it as adoption would overstate the vault's
713
- // mechanical coverage — the over-claim this milestone exists to prevent
714
- // (T-10-33).
715
- if (practice.check !== undefined && practice.check !== null) {
716
- checkBearingIds.add(practice.id);
717
- }
718
- const clause = composeEnforcedByClause(practice.enforcedBy);
719
- if (clause !== '')
720
- clauseById.set(practice.id, clause);
721
- }
722
- return { checkBearingIds, clauseById, read: true };
723
- }
724
- catch (e) {
725
- notes.push(`the pinned practice cache could not be read for the check-bearing census (${boundedToken(e?.message ?? e)})`);
726
- return NO_PINNED_FACTS;
727
- }
728
- }
729
- /**
730
- * Take the check-block CENSUS of the recorded universe (MECH-02, D-10-13).
731
- *
732
- * `universe === null` in, `null` out: a degraded run has no universe to take a
733
- * census of (D-04), and the census carries the rule of the sibling it sits
734
- * beside rather than of the per-practice fields further down the statement.
735
- *
736
- * Counting is confined to ids the universe records, which is what makes
737
- * "N of `universe.length`" exactly true of one statement read on its own: a
738
- * practice file present on disk but absent from the manifest at the pinned SHA
739
- * is not at that ref.
740
- */
741
- function deriveCheckBearingCensus(universe, facts) {
742
- if (universe === null || !facts.read)
743
- return null;
744
- let checkBearing = 0;
745
- for (const id of universe) {
746
- if (facts.checkBearingIds.has(id))
747
- checkBearing += 1;
748
- }
749
- return checkBearing;
750
- }
751
- // ─── The builder ───────────────────────────────────────────────────────────────
752
- /**
753
- * The statement note recorded when the cache was read from the owning main git
754
- * worktree (260915-ms2, UAT-6-F3). A fact, never a path.
755
- */
756
- const TRACE_WORKTREE_CACHE_NOTE = 'vault cache read from the owning main git worktree; this checkout carries no local .planning/vault';
757
- /**
758
- * Build a complete trace statement.
759
- *
760
- * ALWAYS returns a statement. A vault that cannot be read produces a
761
- * `state: degraded` record with a non-null reason, never an omitted artifact —
762
- * an absent trace would be indistinguishable from a phase that was never
763
- * vault-enabled, and that silence is exactly what this schema exists to end.
764
- */
765
- function buildTraceStatement(opts) {
766
- const { cwd, stage, plan } = opts;
767
- const files = Array.isArray(opts.files) ? opts.files : [];
768
- const notes = [];
769
- const generatedAt = new Date().toISOString();
770
- // ONE cache directory for the pin AND the matcher (260915-ms2, UAT-6-F3), so
771
- // the practice set and the ref it is recorded against come from one vault.
772
- const cache = vaultMod.resolveVaultCacheDir(cwd);
773
- if (cache.source === 'main-worktree') {
774
- // The fact, never the path: an absolute path of the owning checkout would
775
- // leak a machine layout into a committed record.
776
- notes.push(TRACE_WORKTREE_CACHE_NOTE);
777
- }
778
- const pin = resolvePin(cwd, cache.dir);
779
- // The universe comes from the SAME manifest read that produced the pin
780
- // (D-01); charset rejects are dropped and noted onto THIS statement's notes.
781
- const practiceUniverse = derivePracticeUniverse(pin.manifestFiles, notes);
782
- // ONE read of the pinned cache, two facts (10-08): the check-block census of
783
- // the universe above, and the per-practice analyzer-provenance clauses the
784
- // ledger entries below carry. This is the widened read profile this path now
785
- // owns; it is stated on `ResolvedPin.manifestFiles` rather than left implicit.
786
- const pinnedFacts = readPinnedPractices(cwd, cache.dir, notes);
787
- const checkBearingPractices = deriveCheckBearingCensus(practiceUniverse, pinnedFacts);
788
- // ── subject ──
789
- const subject = [];
790
- const names = [];
791
- for (const entry of files) {
792
- const rawName = typeof entry === 'string' ? entry : entry?.name;
793
- if (typeof rawName !== 'string' || rawName === '')
794
- continue;
795
- const name = rawName.replace(/\\/g, '/');
796
- names.push(name);
797
- if (stage === 'execute') {
798
- const digest = typeof entry === 'string' ? undefined : entry.gitCommit;
799
- if (typeof digest === 'string' && /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(digest)) {
800
- subject.push({ name, digest: { gitCommit: digest } });
801
- }
802
- else {
803
- // Loud, not silent: an execute-stage entry with no usable digest is a
804
- // real coverage gap and the reader must be able to see it.
805
- notes.push(`subject "${boundedToken(name)}" has no usable gitCommit digest at execute stage`);
806
- subject.push({ name, uri: name });
807
- }
808
- }
809
- else {
810
- // Plan stage: no digest key at all. `uri` is set so the entry still
811
- // satisfies in-toto's ResourceDescriptor minimum.
812
- subject.push({ name, uri: name });
813
- }
814
- }
815
- // ── practices: the matcher's return set, and nothing else ──
816
- const dispositionById = new Map();
817
- for (const d of opts.dispositions ?? []) {
818
- if (d && typeof d.id === 'string')
819
- dispositionById.set(d.id, d);
820
- }
821
- const practices = [];
822
- try {
823
- const match = practicesMod.matchPractices({ cwd, files: names, vaultDir: cache.dir });
824
- // Concatenate matched THEN global, preserving the matcher's own sort. Do
825
- // not re-glob, do not read the practices directory, and do not filter by
826
- // severity — a `not-applicable` entry is exactly the record TRACE-02
827
- // exists to produce.
828
- for (const p of [...match.matched, ...match.global]) {
829
- const id = boundedToken(p.id);
830
- if (!TRACE_ID_PATTERN.test(id)) {
831
- notes.push(`practice id skipped: "${id}" does not match the permitted id charset`);
832
- continue;
833
- }
834
- const d = dispositionById.get(id);
835
- const entry = {
836
- id,
837
- scope: p.scope ?? null,
838
- severity: p.severity ?? null,
839
- status: typeof d?.status === 'string' ? d.status : '',
840
- reason: typeof d?.reason === 'string' ? d.reason : '',
841
- tasks: Array.isArray(d?.tasks) ? d.tasks.map((t) => String(t)) : [],
842
- };
843
- // Set ONLY when the practice carries a claim. An `undefined` property
844
- // would change every deepEqual in every consumer for no behavioural gain
845
- // — the same reasoning the validator's optional `tail` field already
846
- // carries — and an entry for a practice with no claim must stay
847
- // byte-identical to the one it produced before this phase (MECH-03).
848
- const clause = pinnedFacts.clauseById.get(id);
849
- if (typeof clause === 'string' && clause !== '')
850
- entry.enforcedBy = clause;
851
- // THE TEXT DIGEST (261003-j89). Digested from the SAME unwrapped excerpt
852
- // `renderPracticeEntry` places under `ask:`/`excerpt:` in the body block
853
- // — `unwrapFencedExcerpt` is the practices module's own, exported for
854
- // exactly this, so the two readers cannot strip the fence differently.
855
- // The scaffold and `vault practices match --block` run the same matcher
856
- // on the same cache in the same session, which is what makes a digest
857
- // written here comparable to a block embedded there.
858
- const fenced = p.scope === 'scoped'
859
- ? p.ask
860
- : p.excerpt;
861
- if (typeof fenced === 'string') {
862
- entry.textSha256 = practiceTextDigest(practicesMod.unwrapFencedExcerpt(fenced));
863
- }
864
- practices.push(entry);
865
- }
866
- }
867
- catch (e) {
868
- // The matcher degrades internally rather than throwing, but a trace is
869
- // never worth losing to an unexpected read failure.
870
- notes.push(`practice matching failed: ${boundedToken(e.message)}`);
871
- }
872
- // 16-06: the anchors sit right after `generatedAt`, the order the serializer
873
- // writes them in; the spread of an empty object adds no key, so a plan-stage
874
- // statement is the object it was before.
875
- const anchorFields = {};
876
- if (stage === 'execute' && opts.anchors) {
877
- if (typeof opts.anchors.startCommit === 'string' && vault_seal_cjs_1.ANCHOR_SHA_RE.test(opts.anchors.startCommit)) {
878
- anchorFields.startCommit = opts.anchors.startCommit;
879
- }
880
- if (typeof opts.anchors.endCommit === 'string' && vault_seal_cjs_1.ANCHOR_SHA_RE.test(opts.anchors.endCommit)) {
881
- anchorFields.endCommit = opts.anchors.endCommit;
882
- }
883
- }
884
- return {
885
- _type: TRACE_STATEMENT_TYPE,
886
- predicateType: TRACE_PREDICATE_TYPE,
887
- schemaVersion: TRACE_SCHEMA_VERSION,
888
- subject,
889
- predicate: {
890
- stage,
891
- plan,
892
- generatedAt,
893
- ...anchorFields,
894
- vault: {
895
- state: pin.state,
896
- reason: pin.reason,
897
- ref: pin.ref,
898
- resolvedSha: pin.resolvedSha,
899
- practiceUniverse,
900
- checkBearingPractices,
901
- tier: pin.tier,
902
- source: pin.source,
903
- repo: pin.repo,
904
- },
905
- practices,
906
- notes,
907
- override: buildOverrideRecord(cwd, opts.override, generatedAt),
908
- },
909
- };
910
- }
911
- // ─── The serializer ────────────────────────────────────────────────────────────
912
- const INDENT = ' ';
913
- /**
914
- * Quote a scalar for the emitted block. Every string value is emitted
915
- * double-quoted — uniformly, rather than only when it "looks dangerous",
916
- * because a conditional quoting rule is one review away from being widened to
917
- * the wrong case. Backslashes and quotes are escaped, and any CR/LF is
918
- * collapsed to a space so no value can ever add a line to the document.
919
- */
920
- function yamlQuote(value) {
921
- const flat = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ');
922
- return `"${flat}"`;
923
- }
924
- /** Emit `key: value` where value is a string, a number, or null. */
925
- function scalarLine(depth, key, value) {
926
- const rendered = value === null ? 'null' : typeof value === 'number' ? String(value) : yamlQuote(value);
927
- return `${INDENT.repeat(depth)}${key}: ${rendered}`;
928
- }
929
- /** Emit a list of quoted strings, or an explicit `[]`. */
930
- function stringListLines(depth, key, values) {
931
- if (values.length === 0)
932
- return [`${INDENT.repeat(depth)}${key}: []`];
933
- return [
934
- `${INDENT.repeat(depth)}${key}:`,
935
- ...values.map((v) => `${INDENT.repeat(depth + 1)}- ${yamlQuote(v)}`),
936
- ];
937
- }
938
- /** Emit an inline list of quoted strings (`tasks: ["T1", "T3"]`). */
939
- function inlineList(values) {
940
- if (values.length === 0)
941
- return '[]';
942
- return `[${values.map(yamlQuote).join(', ')}]`;
943
- }
944
- /**
945
- * Serialize a statement as the `vault:` frontmatter block.
946
- *
947
- * Hand-written, with no YAML library: `pp-core/bin/lib/*.cjs` takes no external
948
- * runtime dependency, and TRACE-01 adopts the in-toto shape without one.
949
- *
950
- * The practice list is emitted as a LIST OF MAPS so every id sits in a VALUE
951
- * position (`- id: "db-naming"`). A map keyed by id would put a
952
- * client-authored token in a KEY position, where a `:`, a `#`, a leading `-`
953
- * or a newline changes the document's structure instead of a value's content.
954
- *
955
- * An id that fails the charset is SKIPPED with a note recorded inside the
956
- * emitted statement, never dropped silently — a silent drop is
957
- * indistinguishable from an agent omission and would break the 1:1 coverage
958
- * check a gate performs against this list.
959
- */
960
- function serializeTraceBlock(statement) {
961
- const p = statement.predicate;
962
- const notes = [...(p.notes ?? [])];
963
- const lines = ['vault:'];
964
- lines.push(scalarLine(1, '_type', statement._type));
965
- lines.push(scalarLine(1, 'predicateType', statement.predicateType));
966
- lines.push(`${INDENT}schemaVersion: ${Number(statement.schemaVersion)}`);
967
- // ── subject ──
968
- const subjects = statement.subject ?? [];
969
- if (subjects.length === 0) {
970
- lines.push(`${INDENT}subject: []`);
971
- }
972
- else {
973
- lines.push(`${INDENT}subject:`);
974
- for (const s of subjects) {
975
- lines.push(`${INDENT.repeat(2)}- name: ${yamlQuote(s.name)}`);
976
- if (typeof s.uri === 'string')
977
- lines.push(scalarLine(3, 'uri', s.uri));
978
- if (s.digest && typeof s.digest.gitCommit === 'string') {
979
- lines.push(`${INDENT.repeat(3)}digest:`);
980
- lines.push(scalarLine(4, 'gitCommit', s.digest.gitCommit));
981
- }
982
- }
983
- }
984
- // ── predicate ──
985
- lines.push(`${INDENT}predicate:`);
986
- lines.push(scalarLine(2, 'stage', p.stage));
987
- lines.push(scalarLine(2, 'plan', p.plan));
988
- lines.push(scalarLine(2, 'generatedAt', p.generatedAt));
989
- // 16-06 (D-35): the anchors, right after `generatedAt`. NOTHING is emitted
990
- // for an absent key, so a plan-stage ledger and a pre-16 ledger keep their
991
- // bytes (golden-pinned). Re-validated at emit time on the id filter's rule:
992
- // a value that is not an anchor is dropped WITH a note, never written as a
993
- // commit a later gate would place in a git argv.
994
- lines.push(...anchorLines(p, notes));
995
- lines.push(`${INDENT.repeat(2)}vault:`);
996
- lines.push(scalarLine(3, 'state', p.vault.state));
997
- lines.push(scalarLine(3, 'reason', p.vault.reason));
998
- lines.push(scalarLine(3, 'ref', p.vault.ref));
999
- lines.push(scalarLine(3, 'resolvedSha', p.vault.resolvedSha));
1000
- // practiceUniverse sits immediately after `resolvedSha`: the two facts come
1001
- // from the same manifest read (D-01). When null or absent, NO line is
1002
- // emitted — an empty universe (`[]`) and an unrecorded one are different
1003
- // facts and plan 04-05 must be able to tell them apart. Re-validated at emit
1004
- // time exactly like the practices list below (defence in depth): the
1005
- // statement may have been hand-built or mutated between build and serialize.
1006
- if (Array.isArray(p.vault.practiceUniverse)) {
1007
- const emittableIds = p.vault.practiceUniverse.filter((id) => {
1008
- const ok = typeof id === 'string' && TRACE_ID_PATTERN.test(id);
1009
- if (!ok) {
1010
- notes.push(`practice universe id skipped at serialize time: "${boundedToken(id)}" does not match the permitted id charset`);
1011
- }
1012
- return ok;
1013
- });
1014
- lines.push(...stringListLines(3, 'practiceUniverse', emittableIds));
1015
- }
1016
- // The census sits with the universe it is a census OF, on the same rule the
1017
- // universe follows: when the value is not a usable count, NO line is emitted,
1018
- // because an unrecorded census and a census of ZERO are different facts and a
1019
- // consumer must be able to tell them apart. Zero IS emitted — "0 of 55" is
1020
- // the fact MECH-02 exists to make legible, and a fact nobody writes down is
1021
- // not a record.
1022
- //
1023
- // Re-validated at emit time exactly like the practices list below (defence in
1024
- // depth): the statement may have been hand-built or mutated between build and
1025
- // serialize, and the YAML boundary is the one that matters. A value that is
1026
- // not an integer >= 0 is DROPPED WITH A NOTE rather than emitted — a silent
1027
- // drop would be indistinguishable from a census that was never taken.
1028
- const census = p.vault.checkBearingPractices;
1029
- if (typeof census === 'number' && Number.isInteger(census) && census >= 0) {
1030
- lines.push(scalarLine(3, 'checkBearingPractices', census));
1031
- }
1032
- else if (census !== null && census !== undefined) {
1033
- notes.push(`check-bearing census dropped at serialize time: "${boundedToken(census)}" is not an integer count of zero or more`);
1034
- }
1035
- lines.push(scalarLine(3, 'tier', p.vault.tier));
1036
- lines.push(scalarLine(3, 'source', p.vault.source));
1037
- lines.push(scalarLine(3, 'repo', p.vault.repo));
1038
- // ── practices ──
1039
- // Re-validate at emit time (defence in depth): the statement may have been
1040
- // hand-built or mutated between build and serialize, and the YAML boundary
1041
- // is the one that matters.
1042
- const emittable = (p.practices ?? []).filter((entry) => {
1043
- const ok = typeof entry.id === 'string' && TRACE_ID_PATTERN.test(entry.id);
1044
- if (!ok) {
1045
- notes.push(`practice id skipped at serialize time: "${boundedToken(entry.id)}" does not match the permitted id charset`);
1046
- }
1047
- return ok;
1048
- });
1049
- if (emittable.length === 0) {
1050
- lines.push(`${INDENT.repeat(2)}practices: []`);
1051
- }
1052
- else {
1053
- lines.push(`${INDENT.repeat(2)}practices:`);
1054
- for (const entry of emittable) {
1055
- lines.push(`${INDENT.repeat(3)}- id: ${yamlQuote(entry.id)}`);
1056
- lines.push(scalarLine(4, 'scope', entry.scope ?? null));
1057
- lines.push(scalarLine(4, 'severity', entry.severity ?? null));
1058
- lines.push(scalarLine(4, 'status', entry.status ?? ''));
1059
- lines.push(scalarLine(4, 'reason', entry.reason ?? ''));
1060
- // Analyzer provenance, between `reason` and `tasks` (10-08, MECH-03).
1061
- // NOTHING is emitted when the practice carries no claim, so an entry with
1062
- // no new fact keeps the shape it had before this phase.
1063
- //
1064
- // Re-bounded and re-defanged HERE even though the builder already did it,
1065
- // on the emit-time re-validation rule the id filter below states: the
1066
- // statement may have been hand-built or mutated between build and
1067
- // serialize, and the YAML boundary is the one that matters. Three
1068
- // distinct hazards, three shipped guards: `renderSafeField` collapses the
1069
- // control characters `boundedToken` does not (it handles CR/LF only) and
1070
- // caps the length so one ledger entry cannot fill an agent's context;
1071
- // `defangFenceMarkers` neutralises the SHIPPED external-content markers,
1072
- // which `yamlQuote` does not touch at all; and `yamlQuote` below owns the
1073
- // CR/LF half, so a newline cannot add a line to the document.
1074
- const clause = defangFenceMarkers(renderSafeField(entry.enforcedBy));
1075
- if (clause !== '')
1076
- lines.push(scalarLine(4, 'enforcedBy', clause));
1077
- // The text digest (261003-j89), between `enforcedBy` and `tasks`. NOTHING
1078
- // is emitted when the entry carries none, so a ledger written from a
1079
- // statement without digests keeps the shape it had. Re-validated at emit
1080
- // time on the same rule as the id filter: only a 64-character lowercase
1081
- // hex string is a digest, and anything else is dropped WITH a note rather
1082
- // than written as a value the gate would then compare against.
1083
- const digest = entry.textSha256;
1084
- if (typeof digest === 'string' && TEXT_DIGEST_PATTERN.test(digest)) {
1085
- lines.push(scalarLine(4, 'textSha256', digest));
1086
- }
1087
- else if (digest !== undefined && digest !== null) {
1088
- notes.push(`text digest dropped at serialize time for "${boundedToken(entry.id)}": not a sha256 hex digest`);
1089
- }
1090
- lines.push(`${INDENT.repeat(4)}tasks: ${inlineList(entry.tasks ?? [])}`);
1091
- }
1092
- }
1093
- lines.push(...stringListLines(2, 'notes', notes));
1094
- // ── override ──
1095
- if (!p.override) {
1096
- lines.push(`${INDENT.repeat(2)}override: null`);
1097
- }
1098
- else {
1099
- lines.push(`${INDENT.repeat(2)}override:`);
1100
- lines.push(scalarLine(3, 'gate', p.override.gate));
1101
- lines.push(scalarLine(3, 'reason', p.override.reason));
1102
- lines.push(scalarLine(3, 'suppressedReason', p.override.suppressedReason));
1103
- lines.push(scalarLine(3, 'who', p.override.who));
1104
- lines.push(scalarLine(3, 'when', p.override.when));
1105
- }
1106
- // TERMINATED, and the trailing newline is load-bearing (04-REVIEW IN-01).
1107
- // The block's destination is a document's YAML frontmatter, and the agent
1108
- // that puts it there splices by concatenation. Without a terminator the final
1109
- // line runs into whatever follows — `override: null---` — and
1110
- // `collectBlockLines`' frontmatter match then fails against a block that is
1111
- // physically present. The verdict is `trace_absent`, and its remedy is to
1112
- // regenerate the block that is already there: an unrecoverable false block of
1113
- // exactly the shape D-11 exists to prevent. One character removes the failure
1114
- // mode; a caller that joins with its own newline sees a blank line, which
1115
- // `collectBlockLines` already skips.
1116
- return `${lines.join('\n')}\n`;
1117
- }
1118
- /**
1119
- * The predicate's anchor lines (16-06), in the one order they are written and
1120
- * back-filled: `startCommit`, `endCommit`, then the `anchorBackfill` block.
1121
- * Exported (as `traceAnchorLines`) so `vault anchor backfill` splices exactly
1122
- * the lines the serializer would have written, never a second spelling.
1123
- */
1124
- function anchorLines(p, notes) {
1125
- const out = [];
1126
- for (const key of ['startCommit', 'endCommit']) {
1127
- const v = p[key];
1128
- if (v === undefined || v === null)
1129
- continue;
1130
- if (typeof v === 'string' && vault_seal_cjs_1.ANCHOR_SHA_RE.test(v))
1131
- out.push(scalarLine(2, key, v));
1132
- else
1133
- notes.push(`${key} dropped at serialize time: not a full lowercase commit id`);
1134
- }
1135
- const b = p.anchorBackfill;
1136
- if (b !== undefined && b !== null) {
1137
- if ((0, vault_seal_cjs_1.isAnchorBackfill)(b)) {
1138
- out.push(`${INDENT.repeat(2)}anchorBackfill:`);
1139
- out.push(scalarLine(3, 'by', b.by));
1140
- out.push(scalarLine(3, 'at', b.at));
1141
- }
1142
- else {
1143
- notes.push('anchorBackfill dropped at serialize time: not { by, at }');
1144
- }
1145
- }
1146
- return out;
1147
- }
1148
- /**
1149
- * The anchor lines a back-fill splices into an existing ledger, after its
1150
- * `generatedAt` line (16-06, D-32). The SAME `anchorLines` the serializer
1151
- * uses, so a later `trace render` round trip of the back-filled ledger and a
1152
- * fresh serialization of the same statement agree. Throws on a value that
1153
- * would be dropped: the back-fill verb validated it first, so a throw is an
1154
- * engine bug.
1155
- */
1156
- function traceAnchorLines(anchors) {
1157
- const notes = [];
1158
- const out = anchorLines(anchors, notes);
1159
- if (notes.length > 0)
1160
- throw new Error(`traceAnchorLines: ${notes[0]}`);
1161
- return out;
1162
- }
1163
- /**
1164
- * The `by` a back-fill records: the trace's one identity chain
189
+ * The `by` a back-fill records: the one identity chain
1165
190
  * (`resolveOverrideIdentity`: `git config user.name`, then the environment,
1166
- * then `unknown`), normalised by the seal leaf so it is accepted by both record
1167
- * families' parsers.
191
+ * then `unknown`), normalised by the seal leaf so the standards record's
192
+ * parser accepts it.
1168
193
  */
1169
194
  function resolveBackfillIdentity(cwd) {
1170
195
  return (0, vault_seal_cjs_1.normaliseAnchorBackfillWho)(resolveOverrideIdentity(cwd));
1171
196
  }
1172
- // ─── The rendered surface ──────────────────────────────────────────────────────
1173
- const PRACTICES_APPLIED_HEADING = '## Practices Applied';
1174
- /**
1175
- * The cap applied to every client-influenced field the renderer interpolates
1176
- * into a generated markdown line (03-REVIEW WR-12, T-03-34). Wider than
1177
- * `WARNING_TOKEN_MAX` (80) because a one-line reason is not a warning token,
1178
- * and `boundedToken` is deliberately NOT reused here — widening its bound
1179
- * would silently change unrelated warning messages across two modules.
1180
- */
1181
- const TRACE_RENDERED_FIELD_MAX = 300;
1182
- /**
1183
- * The single line emitted in place of an entry whose id fails the charset. It
1184
- * has to teach a human reader three things at once, because it is the only
1185
- * thing they will see: that an id was refused AT THIS POSITION (the section
1186
- * never silently shrinks), that the reason is the permitted id charset, and
1187
- * that the record is regenerated rather than hand-repaired.
1188
- */
1189
- const TRACE_REFUSED_ID_LINE = '- (a practice id was refused here: it does not match the permitted id charset — regenerate the trace block rather than hand-repairing it)';
1190
- /**
1191
- * Bound and flatten one client-influenced value for a generated markdown line.
1192
- *
1193
- * The `## Practices Applied` section is loaded UNFENCED by name — the code
1194
- * reviewer's cross-plan reconciliation contract reads it — so this is the
1195
- * shortest injection path in the capability (T-03-34). A non-string coerces to
1196
- * the empty string rather than to `[object Object]`, control characters cannot
1197
- * forge a second line, and the length cap keeps one ledger entry from filling a
1198
- * downstream agent's context.
1199
- */
1200
- function renderSafeField(value) {
1201
- if (typeof value !== 'string')
1202
- return '';
1203
- const flat = collapseControlChars(value).trim();
1204
- return flat.length > TRACE_RENDERED_FIELD_MAX
1205
- ? `${flat.slice(0, TRACE_RENDERED_FIELD_MAX)}…`
1206
- : flat;
1207
- }
1208
- /**
1209
- * The rendered line for an empty ledger whose trace is DEGRADED (quick
1210
- * 261015-at3). Constant text: the recorded `reason` is artifact text and is not
1211
- * echoed into a section another agent reads unfenced.
1212
- */
1213
- const TRACE_DEGRADED_RENDER_LINE = 'No client-vault practice was read for this plan: its recorded vault trace is degraded (see its `reason`), so no practice is recorded here as applied or as out of scope.';
1214
- /**
1215
- * Render the `## Practices Applied` section from the ledger.
1216
- *
1217
- * D-02 retires the hand-authoring of this section; D-03 PRESERVES the surface
1218
- * by generating it, so the two can never disagree and nothing downstream has
1219
- * to move. The heading has far more reach than its size suggests — the code
1220
- * reviewer's cross-plan reconciliation contract reads it by name, a capability
1221
- * contribution reads it, and four test files pin the literal string. The
1222
- * output must therefore be substitutable for what the executor writes today,
1223
- * which is why the line shape and the three-word vocabulary are reproduced
1224
- * rather than improved.
1225
- */
1226
- function renderPracticesApplied(statement) {
1227
- const entries = statement?.predicate?.practices ?? [];
1228
- const lines = [PRACTICES_APPLIED_HEADING, ''];
1229
- if (entries.length === 0) {
1230
- // Quick 261015-at3 (entry-point sweep): a DEGRADED ledger is empty because
1231
- // no practice was read, not because none was in scope — "were in scope" is
1232
- // a claim about a practice set that was never computed, written into a
1233
- // SUMMARY on every one of the eight degraded causes. The empty-and-loaded
1234
- // line below is unchanged (pinned byte-identical).
1235
- if (statement?.predicate?.vault?.state === 'degraded') {
1236
- lines.push(TRACE_DEGRADED_RENDER_LINE);
1237
- return lines.join('\n');
1238
- }
1239
- // Never an empty section: D-04 requires a degraded or empty run to be
1240
- // READABLE as a fact. An omitted section and an unrecorded one look
1241
- // identical to every downstream reader.
1242
- lines.push('No client-vault practices were in scope for this plan (the recorded practice ledger is empty).');
1243
- return lines.join('\n');
1244
- }
1245
- for (const entry of entries) {
1246
- // 03-REVIEW CR-03, render half. The id is the only client-authored token
1247
- // that reaches this line, and this line is read UNFENCED by another agent.
1248
- // A refusal keeps the entry's POSITION — pushing nothing would let a
1249
- // hostile ledger quietly shorten the section, which reads as "fewer
1250
- // practices were in scope" rather than "something was refused here".
1251
- if (typeof entry?.id !== 'string' || !TRACE_ID_PATTERN.test(entry.id)) {
1252
- lines.push(TRACE_REFUSED_ID_LINE);
1253
- continue;
1254
- }
1255
- const statusText = renderSafeField(entry.status);
1256
- const status = statusText !== '' ? statusText : 'not-applicable';
1257
- const reason = renderSafeField(entry.reason);
1258
- lines.push(reason === '' ? `- ${entry.id}: ${status}` : `- ${entry.id}: ${status} — ${reason}`);
1259
- }
1260
- return lines.join('\n');
1261
- }
1262
- // ─── The parser ────────────────────────────────────────────────────────────────
1263
- /**
1264
- * The frontmatter key the trace is anchored on. The parser locates THIS key and
1265
- * its indent, exactly as `parseMustHavesBlock` anchors on `must_haves:`.
1266
- */
1267
- const TRACE_BLOCK_KEY = 'vault';
1268
- /** A complete double-quoted scalar, allowing escaped quotes and backslashes. */
1269
- const QUOTED_RE = /^"(?:[^"\\]|\\.)*"$/;
1270
- /**
1271
- * Key-value recognizer, carrying the whitespace-after-colon guard.
1272
- *
1273
- * The `\s+` after the colon is NOT cosmetic. YAML key-value always has at
1274
- * least one space after the colon, and requiring it is what stops
1275
- * `Class::Method` and `db:seed` being mis-read as a key-value pair when they
1276
- * appear as a plain scalar (#2757). Without it, a hand-edited note reading
1277
- * `db:seed was used` silently becomes the object `{ db: 'seed was used' }` and
1278
- * the round-trip stops being an identity.
1279
- */
1280
- const KV_RE = /^([A-Za-z0-9_-]+):(?:\s+(.*))?$/;
1281
- function unquoteScalar(s) {
1282
- return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
1283
- }
1284
- function parseScalar(raw) {
1285
- const s = raw.trim();
1286
- if (s === '')
1287
- return '';
1288
- if (s === 'null')
1289
- return null;
1290
- if (s === '[]')
1291
- return [];
1292
- if (QUOTED_RE.test(s))
1293
- return unquoteScalar(s);
1294
- if (/^\[.*\]$/.test(s)) {
1295
- const inner = s.slice(1, -1);
1296
- const quoted = inner.match(/"(?:[^"\\]|\\.)*"/g);
1297
- if (quoted)
1298
- return quoted.map(unquoteScalar);
1299
- return inner
1300
- .split(',')
1301
- .map((x) => x.trim())
1302
- .filter((x) => x !== '');
1303
- }
1304
- if (/^-?\d+$/.test(s))
1305
- return Number(s);
1306
- return s;
1307
- }
1308
- /**
1309
- * The three keys that are never assigned from document text (03-REVIEW CR-05).
1310
- *
1311
- * `KV_RE`'s character class matches `__proto__`, and a key carrying a nested
1312
- * block is assigned an OBJECT — which reassigns the prototype of the plain
1313
- * accumulator the parser is building. A block whose whole body sat under
1314
- * `__proto__` therefore validated clean while neither frozen literal appeared
1315
- * anywhere a `grep` would find it (T-03-32/T-03-33). For a capability whose
1316
- * product is a provable, READABLE audit record, the gate and the document text
1317
- * disagreeing is the defect itself.
1318
- *
1319
- * The guard sits at the key-recognition boundary — this project's stated
1320
- * convention for prototype guards, and the same three-key shape the phase's own
1321
- * integration-test helper already implements — so BOTH `parseListItem` and
1322
- * `parseValueLines` inherit one guard instead of each growing their own.
1323
- */
1324
- const FORBIDDEN_KEYS = Object.freeze(new Set(['__proto__', 'constructor', 'prototype']));
1325
- function matchKeyValue(text) {
1326
- const m = text.match(KV_RE);
1327
- if (!m || FORBIDDEN_KEYS.has(m[1]))
1328
- return null;
1329
- return { key: m[1], value: (m[2] ?? '').trim() };
1330
- }
1331
- function parseListItem(head, children) {
1332
- const trimmed = head.trim();
1333
- // A fully-quoted item is a STRING, checked before the key-value branch so a
1334
- // quoted value containing a colon can never be re-read as a mapping.
1335
- if (QUOTED_RE.test(trimmed) && children.length === 0)
1336
- return unquoteScalar(trimmed);
1337
- const kv = matchKeyValue(trimmed);
1338
- if (!kv) {
1339
- if (children.length === 0)
1340
- return parseScalar(trimmed);
1341
- return parseValueLines(children);
1342
- }
1343
- // Null prototype as defence in depth (CR-05): a key that ever slipped past
1344
- // the recognizer above still cannot reach a prototype from here.
1345
- const obj = Object.create(null);
1346
- obj[kv.key] = kv.value === '' ? {} : parseScalar(kv.value);
1347
- if (children.length > 0) {
1348
- const rest = parseValueLines(children);
1349
- if (rest && typeof rest === 'object' && !Array.isArray(rest)) {
1350
- Object.assign(obj, rest);
1351
- }
1352
- }
1353
- return obj;
1354
- }
1355
- /**
1356
- * Parse a run of block lines that are all more indented than their parent.
1357
- *
1358
- * The technique is copied from `parseMustHavesBlock` and not the function
1359
- * itself: that one is anchored on its own literal key, and widening it would
1360
- * dissolve the very shape that makes it work. The five moves reproduced here
1361
- * are — capture the parent indent; require the nested block be more indented;
1362
- * detect the list-item indent from the first `- `; treat a deeper indent under
1363
- * an object item as a continuation key-value; and stop at the first line whose
1364
- * indent is not greater than the parent's.
1365
- */
1366
- function parseValueLines(lines) {
1367
- if (lines.length === 0)
1368
- return {};
1369
- const base = lines[0].indent;
1370
- if (lines[0].text.startsWith('- ')) {
1371
- const items = [];
1372
- let i = 0;
1373
- while (i < lines.length) {
1374
- if (lines[i].indent !== base || !lines[i].text.startsWith('- ')) {
1375
- i += 1;
1376
- continue;
1377
- }
1378
- const head = lines[i].text.slice(2);
1379
- const children = [];
1380
- let j = i + 1;
1381
- while (j < lines.length && lines[j].indent > base) {
1382
- children.push(lines[j]);
1383
- j += 1;
1384
- }
1385
- items.push(parseListItem(head, children));
1386
- i = j;
1387
- }
1388
- return items;
1389
- }
1390
- // Null prototype as defence in depth (CR-05) — see `FORBIDDEN_KEYS`.
1391
- const obj = Object.create(null);
1392
- let i = 0;
1393
- while (i < lines.length) {
1394
- if (lines[i].indent !== base) {
1395
- i += 1;
1396
- continue;
1397
- }
1398
- const kv = matchKeyValue(lines[i].text);
1399
- if (!kv) {
1400
- i += 1;
1401
- continue;
1402
- }
1403
- const children = [];
1404
- let j = i + 1;
1405
- while (j < lines.length && lines[j].indent > base) {
1406
- children.push(lines[j]);
1407
- j += 1;
1408
- }
1409
- obj[kv.key] = kv.value === '' ? parseValueLines(children) : parseScalar(kv.value);
1410
- i = j;
1411
- }
1412
- return obj;
1413
- }
1414
- /** Collect the block's lines, anchored on `vault:` and bounded by its indent. */
1415
- function collectBlockLines(content) {
1416
- const fm = String(content ?? '').match(/^---\r?\n([\s\S]+?)\r?\n---/);
1417
- if (!fm)
1418
- return null;
1419
- const lines = fm[1].split(/\r?\n/);
1420
- // 03-REVIEW WR-09: anchor on the TOP-LEVEL key only — no leading-whitespace
1421
- // capture. The trace is only ever WRITTEN at the top level of the
1422
- // frontmatter, so a nested `vault:` sub-key is not a trace. Taking the first
1423
- // match at any indent let an unrelated nested key (a config echo, a tool
1424
- // block, a hand-added note) shadow the real ledger: the collected lines were
1425
- // that node's children, the statement normalised to empty, and the gate
1426
- // reported `trace_unparsable`. Fail-closed, so never a bypass — but an
1427
- // UNRECOVERABLE false block, because the block's own remedy ("regenerate the
1428
- // block") re-emits the same second `vault:` key (T-03-35).
1429
- const anchorRe = new RegExp(`^${TRACE_BLOCK_KEY}:\\s*$`);
1430
- const anchorIndent = 0;
1431
- let anchor = -1;
1432
- for (let i = 0; i < lines.length; i += 1) {
1433
- if (anchorRe.test(lines[i])) {
1434
- anchor = i;
1435
- break;
1436
- }
1437
- }
1438
- if (anchor === -1)
1439
- return null;
1440
- const out = [];
1441
- for (let i = anchor + 1; i < lines.length; i += 1) {
1442
- const line = lines[i];
1443
- if (line.trim() === '')
1444
- continue;
1445
- const indent = line.match(/^(\s*)/)[1].length;
1446
- if (indent <= anchorIndent)
1447
- break;
1448
- out.push({ indent, text: line.trim() });
1449
- }
1450
- return out;
1451
- }
1452
- /** How many content lines sit under a nested `practices:` key (0 if inline). */
1453
- function practicesContentLineCount(lines) {
1454
- const idx = lines.findIndex((l) => l.text === 'practices:');
1455
- if (idx === -1)
1456
- return 0;
1457
- const base = lines[idx].indent;
1458
- let n = 0;
1459
- for (let i = idx + 1; i < lines.length; i += 1) {
1460
- if (lines[i].indent <= base)
1461
- break;
1462
- n += 1;
1463
- }
1464
- return n;
1465
- }
1466
- function asObject(v) {
1467
- return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
1468
- }
1469
- function asArray(v) {
1470
- return Array.isArray(v) ? v : [];
1471
- }
1472
- /**
1473
- * Render a parsed scalar as a string WITHOUT ever falling through to
1474
- * `[object Object]`. A string passes through byte-identically, which is what
1475
- * keeps the round-trip an identity; anything else is a shape the emitter never
1476
- * produces, so it is rendered defensively rather than trusted.
1477
- */
1478
- function stringifyScalar(v) {
1479
- if (typeof v === 'string')
1480
- return v;
1481
- if (typeof v === 'number' || typeof v === 'boolean')
1482
- return String(v);
1483
- if (v === null || v === undefined)
1484
- return '';
1485
- try {
1486
- return JSON.stringify(v) ?? '';
1487
- }
1488
- catch {
1489
- return '';
1490
- }
1491
- }
1492
- function asStringOrNull(v) {
1493
- if (v === null || v === undefined)
1494
- return null;
1495
- return stringifyScalar(v);
1496
- }
1497
- function asString(v) {
1498
- return stringifyScalar(v);
1499
- }
1500
- const ABSENT = Object.freeze({
1501
- present: false,
1502
- reason: 'no_vault_block',
1503
- statement: null,
1504
- });
1505
- /**
1506
- * Read a `vault:` trace block back out of a document.
1507
- *
1508
- * Returns a documented ABSENT result rather than throwing when the document
1509
- * carries no block: handlers in this repository never throw and adapters
1510
- * return a structured reason, and the parser sits on the adapter side of that
1511
- * line.
1512
- *
1513
- * @param content The full document text (frontmatter included).
1514
- * @param artifactPath Named in the empty-parse diagnostic so an operator knows
1515
- * WHICH file to look at.
1516
- */
1517
- function parseVaultTraceBlock(content, artifactPath = 'the artifact') {
1518
- const lines = collectBlockLines(content);
1519
- if (lines === null || lines.length === 0)
1520
- return ABSENT;
1521
- const root = asObject(parseValueLines(lines));
1522
- const predicate = asObject(root.predicate);
1523
- const vault = asObject(predicate.vault);
1524
- const practices = asArray(predicate.practices).map((raw) => {
1525
- const p = asObject(raw);
1526
- const entry = {
1527
- id: asString(p.id),
1528
- scope: asStringOrNull(p.scope),
1529
- severity: asStringOrNull(p.severity),
1530
- status: typeof p.status === 'string' ? p.status : '',
1531
- reason: typeof p.reason === 'string' ? p.reason : '',
1532
- tasks: asArray(p.tasks).map((t) => asString(t)),
1533
- };
1534
- // Optional (10-08): reconstructed ONLY when the document carried it, so a
1535
- // trace written before this phase parses back into the exact entry it
1536
- // parsed into then. An `undefined` property would not be that.
1537
- if (typeof p.enforcedBy === 'string' && p.enforcedBy !== '')
1538
- entry.enforcedBy = p.enforcedBy;
1539
- // Optional (261003-j89): reconstructed ONLY when the document carried it,
1540
- // on the same reasoning as `enforcedBy` above.
1541
- if (typeof p.textSha256 === 'string' && p.textSha256 !== '')
1542
- entry.textSha256 = p.textSha256;
1543
- return entry;
1544
- });
1545
- // A silently empty parse is not merely unhelpful here: a downstream
1546
- // validator checks that dispositions cover the emitted id set one-to-one, so
1547
- // an empty parse and a genuine coverage failure would produce the same
1548
- // verdict from different causes. Say which it was.
1549
- const contentLines = practicesContentLineCount(lines);
1550
- if (practices.length === 0 && contentLines > 0) {
1551
- // 03-REVIEW WR-03: `artifactPath` is built from `fs.readdirSync` output or
1552
- // straight from argv, and POSIX permits a newline in a filename — the same
1553
- // fact `boundedToken`'s docstring cites. Sanitized HERE, inside the leaf,
1554
- // so the guard does not depend on every caller remembering; the check
1555
- // router sanitizes independently and both defences are wanted (T-03-36).
1556
- const safeArtifactPath = boundedToken(collapseControlChars(String(artifactPath)));
1557
- process.stderr.write(`[pp-tools] WARNING: the \`${TRACE_BLOCK_KEY}:\` trace block in ${safeArtifactPath} has ` +
1558
- `${contentLines} practice content line(s) but parsed 0 entries. The trace is unreadable, ` +
1559
- `which is NOT the same as an empty practice set — do not treat this as a clean run. ` +
1560
- `Regenerate the block rather than hand-repairing it.\n`);
1561
- }
1562
- const subject = asArray(root.subject).map((raw) => {
1563
- const s = asObject(raw);
1564
- const entry = { name: asString(s.name) };
1565
- if (typeof s.uri === 'string')
1566
- entry.uri = s.uri;
1567
- const digest = asObject(s.digest);
1568
- if (typeof digest.gitCommit === 'string')
1569
- entry.digest = { gitCommit: digest.gitCommit };
1570
- return entry;
1571
- });
1572
- const rawOverride = predicate.override;
1573
- let override = null;
1574
- if (rawOverride && typeof rawOverride === 'object' && !Array.isArray(rawOverride)) {
1575
- const o = asObject(rawOverride);
1576
- override = {
1577
- gate: asString(o.gate),
1578
- reason: asString(o.reason),
1579
- suppressedReason: asString(o.suppressedReason),
1580
- who: asString(o.who),
1581
- when: asString(o.when),
1582
- };
1583
- }
1584
- // 16-06 (D-35): the optional anchors, reconstructed ONLY when the document
1585
- // carried them and they pass their grammar (the `enforcedBy` precedent), so a
1586
- // ledger written before 16-06 parses into the exact statement it did then. A
1587
- // key that is present but fails is DROPPED, and its NAME (never its value)
1588
- // is listed in `anchorsDropped`, so the validator can type it
1589
- // (`anchor_invalid`) and no caller ever reads it as a commit.
1590
- const anchorFields = {};
1591
- const dropped = [];
1592
- for (const key of ['startCommit', 'endCommit']) {
1593
- if (!(key in predicate))
1594
- continue;
1595
- const v = predicate[key];
1596
- if (typeof v === 'string' && vault_seal_cjs_1.ANCHOR_SHA_RE.test(v))
1597
- anchorFields[key] = v;
1598
- else
1599
- dropped.push(key);
1600
- }
1601
- if ('anchorBackfill' in predicate) {
1602
- const b = asObject(predicate.anchorBackfill);
1603
- const candidate = { by: b.by, at: b.at };
1604
- if ((0, vault_seal_cjs_1.isAnchorBackfill)(candidate))
1605
- anchorFields.anchorBackfill = { by: candidate.by, at: candidate.at };
1606
- else
1607
- dropped.push('anchorBackfill');
1608
- }
1609
- if (dropped.length > 0)
1610
- anchorFields.anchorsDropped = dropped;
1611
- const statement = {
1612
- _type: asString(root._type),
1613
- predicateType: asString(root.predicateType),
1614
- schemaVersion: typeof root.schemaVersion === 'number' ? root.schemaVersion : Number(root.schemaVersion),
1615
- subject,
1616
- predicate: {
1617
- stage: asString(predicate.stage),
1618
- plan: asString(predicate.plan),
1619
- generatedAt: asString(predicate.generatedAt),
1620
- ...anchorFields,
1621
- vault: {
1622
- state: asString(vault.state),
1623
- reason: asStringOrNull(vault.reason),
1624
- ref: asStringOrNull(vault.ref),
1625
- resolvedSha: asStringOrNull(vault.resolvedSha),
1626
- // The `notes` parse partner's shape, with the optional-key absent case
1627
- // made explicit: a present list (even `[]`) reconstructs element by
1628
- // element; an absent or non-list value reconstructs as null, which is
1629
- // how the writer spells "no universe was recorded" (degraded run).
1630
- practiceUniverse: Array.isArray(vault.practiceUniverse)
1631
- ? asArray(vault.practiceUniverse).map((v) => asString(v))
1632
- : null,
1633
- // The universe's absent-case rule, applied to a number: a present
1634
- // integer count (INCLUDING zero) reconstructs as itself; anything else
1635
- // — absent, a string, a float, a negative — reconstructs as null, which
1636
- // is how the writer spells "no census was recorded". It is deliberately
1637
- // NOT coerced with `Number(...)`: a coercion would turn a hand-edited
1638
- // `checkBearingPractices: none` into `NaN` and a blank into `0`, and
1639
- // that `0` would be a claim the document never made.
1640
- checkBearingPractices: typeof vault.checkBearingPractices === 'number' &&
1641
- Number.isInteger(vault.checkBearingPractices) &&
1642
- vault.checkBearingPractices >= 0
1643
- ? vault.checkBearingPractices
1644
- : null,
1645
- tier: asStringOrNull(vault.tier),
1646
- source: asStringOrNull(vault.source),
1647
- // VAULT-EGRESS-1: a trace written before the pin was display-safe carries the raw
1648
- // coordinate; normalised here, at the one parse site every persisted-trace reader shares.
1649
- repo: vaultMod.redactCloneTarget(asStringOrNull(vault.repo)),
1650
- },
1651
- practices,
1652
- notes: asArray(predicate.notes).map((n) => asString(n)),
1653
- override,
1654
- },
1655
- };
1656
- return { present: true, reason: null, statement };
1657
- }
1658
- // ─── The validator (Phase 03-04, TRACE-04 / D-09) ─────────────────────────────
1659
- //
1660
- // SCOPE, AND WHY THE SEAM SITS EXACTLY HERE. This validator implements the
1661
- // checks that need NO vault read: the block is there, it parsed, the required
1662
- // keys are present, the pin is non-null when the state is `loaded`, the
1663
- // dispositions cover the emitted id set one-to-one, every status is in the
1664
- // three-value vocabulary, and every `deviated` entry carries a non-empty
1665
- // reason. Plus the version discipline the frozen document states.
1666
- //
1667
- // Phase 3 named TWO checks as belonging to Phase 4 and deliberately did not
1668
- // build them. BOTH are now closed, and the record of what was deferred is kept
1669
- // rather than deleted — a deleted note loses the fact that the gap existed:
1670
- //
1671
- // 1. resolving each practice id against the vault manifest at the pinned SHA
1672
- // — CLOSED by plan 04-05 (TGATE-01), and closed WITHOUT crossing the seam.
1673
- // It never needed a vault read: 04-02 recorded the manifest's practice-ID
1674
- // universe INTO the statement (D-01), so resolution is set membership over
1675
- // data already in hand. The seam Phase 3 drew — "does the check have to
1676
- // read the vault" — is exactly what let it close: the answer was no.
1677
- // 2. proving the executor's disposition list covers the planner's — CLOSED by
1678
- // plan 04-07 (TGATE-01, D-09), and NOT inside this function. It compares
1679
- // TWO statements, which a single-statement validator cannot do, so the
1680
- // closure took the form of a SECOND entry point in this leaf,
1681
- // `comparePracticeCoverage`, which the gate handler composes into its own
1682
- // verdict. Nothing below compares two statements, and nothing below should.
1683
- //
1684
- // The seam is a PROPERTY OF THE CHECK — whether it has to read the vault — not
1685
- // a judgement about difficulty. Nothing in this region calls the manifest
1686
- // reader or the practice matcher; an integration fence asserts that
1687
- // mechanically over this function's source, and it is what keeps the verdict a
1688
- // function of the record rather than of the machine it ran on.
1689
- //
1690
- // A NOTE ON THE PARSER AND THE REQUIRED-KEY CHECK. `parseVaultTraceBlock`
1691
- // NORMALISES: it always constructs a full statement, filling an absent scalar
1692
- // with `''` or `null`. So on the handler's path a missing key arrives as a
1693
- // present-but-empty one, and the required-key check is not what catches it —
1694
- // the VALUE checks below are (an empty `_type` fails the literal comparison, a
1695
- // null `ref` at `loaded` fails the pin, an empty `status` at execute fails
1696
- // coverage). The required-key list is nonetheless the load-bearing contract
1697
- // surface: it is what `docs/vault-trace-format.md` states, what plan 03-08
1698
- // compares the document against from both ends, and what a caller building a
1699
- // statement by hand — as Phase 4 and Phase 5 will — is checked against.
1700
- /**
1701
- * The required-key list, per stage, written in the SAME path vocabulary as the
1702
- * freeze table in `docs/vault-trace-format.md`: the `vault.` prefix is the
1703
- * frontmatter key, and `[]` means "for every element of that list".
1704
- *
1705
- * The two stages differ in exactly one entry each, which is the document's own
1706
- * plan/execute split: a plan-stage subject entry carries `uri` and no digest
1707
- * (the governed files do not exist yet), an execute-stage one carries the
1708
- * `gitCommit` digest that makes the statement falsifiable.
1709
- *
1710
- * Keys the document marks **required key** (present, value may be null) are in
1711
- * this list; keys it marks optional — `reason`, `scope`, `severity`, `tasks`,
1712
- * `notes` — are not.
1713
- */
1714
- const TRACE_REQUIRED_KEYS_COMMON = [
1715
- 'vault._type',
1716
- 'vault.predicateType',
1717
- 'vault.schemaVersion',
1718
- 'vault.subject',
1719
- 'vault.subject[].name',
1720
- 'vault.predicate.stage',
1721
- 'vault.predicate.plan',
1722
- 'vault.predicate.generatedAt',
1723
- 'vault.predicate.vault.state',
1724
- 'vault.predicate.vault.reason',
1725
- 'vault.predicate.vault.ref',
1726
- 'vault.predicate.vault.resolvedSha',
1727
- 'vault.predicate.vault.tier',
1728
- 'vault.predicate.vault.source',
1729
- 'vault.predicate.vault.repo',
1730
- 'vault.predicate.practices',
1731
- 'vault.predicate.practices[].id',
1732
- 'vault.predicate.practices[].status',
1733
- 'vault.predicate.override',
1734
- ];
1735
- const TRACE_REQUIRED_KEYS = Object.freeze({
1736
- plan: Object.freeze([...TRACE_REQUIRED_KEYS_COMMON, 'vault.subject[].uri']),
1737
- execute: Object.freeze([...TRACE_REQUIRED_KEYS_COMMON, 'vault.subject[].digest.gitCommit']),
1738
- });
1739
- /**
1740
- * The one required key whose failure the generic message could not resolve
1741
- * (03-REVIEW WR-10). A governed file with no commit yields no digest, so the
1742
- * execute-stage trace blocks — and the generic message sent the operator back
1743
- * to the scaffold, which re-emits the same digest-less entry and the same
1744
- * block, indefinitely.
1745
- *
1746
- * The requirement itself is NOT relaxed: the per-stage table is the frozen,
1747
- * documented contract, and a digest is what makes an execute-stage statement
1748
- * falsifiable. Only the message changes, and only for this one path.
1749
- */
1750
- const EXECUTE_DIGEST_KEY = 'vault.subject[].digest.gitCommit';
1751
- const EXECUTE_DIGEST_REMEDY = ' A governed file in this trace has no git commit, so no digest could be recorded.' +
1752
- ' Commit the governed file first, then re-run the execute-stage trace scaffold —' +
1753
- ' re-running the scaffold against an uncommitted file produces the same digest-less' +
1754
- ' entry and the same block.';
1755
- /**
1756
- * The command that regenerates a trace block, named in any failure message that
1757
- * has a regeneration remedy.
1758
- *
1759
- * D-12: the execute-side gate runs in the ORCHESTRATOR, after the SUMMARY has
1760
- * been rescued out of the executor's worktree and before wave cleanup. By then
1761
- * the worktree that wrote the artifact is gone, so the remedy can never be
1762
- * phrased as re-running or asking whoever produced it — it has to be something
1763
- * the orchestrator can do on its own, against the file named in the message.
1764
- *
1765
- * ONE constant, so the plan that ships the writer changes it in one place. It
1766
- * lives in this leaf rather than in the gate handler (04-05) because the
1767
- * validator now needs it too, and the handler already imports this module —
1768
- * the reverse edge would be a cycle (the D-12 split's own lesson).
1769
- *
1770
- * UAT-4-F2: the remedy names `pp_run`, the launcher every workflow binds in
1771
- * its preamble and ships as `pp-core/bin/pp_run`, because a bare `pp-tools`
1772
- * resolved to a different install on a consultant machine. INVARIANT 1 derives
1773
- * its floor from the command's first token, so the floor moved with the rename.
1774
- * The literal pins live in the two vault-trace suites and vault-status-check.
1775
- */
1776
- const TRACE_REGENERATE_COMMAND = 'pp_run vault trace scaffold --stage <plan|execute> --plan <plan-id> <files_modified...>';
1777
- /**
1778
- * The trusted tail of every `anchor_invalid` detail: the repair, never a
1779
- * deletion. 16 review WR-03: it names only a repair that cannot widen
1780
- * coverage, restoring the value the CLI wrote. Regenerating the ledger is NOT
1781
- * offered (16-06 offered it): it records today's HEAD as the end, so the
1782
- * record would claim every commit since its real end, the restamp D-32
1783
- * forbids and D-63 removed from the SEAL-01 footer for the same reason. A
1784
- * back-fill is not offered either: the verb refuses a record that carries an
1785
- * anchor, malformed or not (D-61), so naming it would be a remedy that cannot
1786
- * clear the problem. A value that cannot be recovered is the operator's call.
1787
- */
1788
- const TRACE_ANCHOR_TAIL = ' Restore the value exactly as the CLI wrote it when the record was made; an anchor is written by the CLI, never typed. Regenerating the ledger is not a repair here: it would record today\'s HEAD as the end and claim commits this record never covered. If the value cannot be recovered, the operator decides how this record is re-anchored.';
1789
- /**
1790
- * Stable, machine-readable problem kinds. A consumer branches on these; the
1791
- * `detail` string is for a human and is not a contract.
1792
- *
1793
- * The two coverage directions carry DIFFERENT kinds on purpose: "an emitted id
1794
- * has no disposition" and "a disposition names an id nobody emitted" are two
1795
- * defects with two different fixes, and collapsing them into one message makes
1796
- * the second unfixable.
1797
- */
1798
- const TRACE_PROBLEM_KIND = Object.freeze({
1799
- ABSENT: 'trace_absent',
1800
- UNPARSABLE: 'trace_unparsable',
1801
- MALFORMED_INPUT: 'trace_malformed_input',
1802
- MISSING_REQUIRED_KEY: 'missing_required_key',
1803
- STAGE_MISMATCH: 'stage_mismatch',
1804
- STATEMENT_TYPE_MISMATCH: 'statement_type_mismatch',
1805
- PREDICATE_TYPE_MISMATCH: 'predicate_type_mismatch',
1806
- SCHEMA_VERSION_INVALID: 'schema_version_invalid',
1807
- SCHEMA_VERSION_UNSUPPORTED: 'schema_version_unsupported',
1808
- VAULT_STATE_INVALID: 'vault_state_invalid',
1809
- PIN_MISSING: 'pin_missing',
1810
- // 04-05 (TGATE-01, D-01): the statement-shape sibling of PIN_MISSING, and it
1811
- // sits here beside it rather than with the practice-entry family because the
1812
- // defect is of the RECORD AS A WHOLE — this trace is incomplete — not of any
1813
- // one entry. It arises from the same read as the pin: a loaded run that wrote
1814
- // a pin but no universe recorded half of what it saw. Filing it against an
1815
- // entry would mean naming an offending id, and there is none to name — every
1816
- // id is equally unresolvable when the universe is absent.
1817
- UNIVERSE_MISSING: 'practice_universe_missing',
1818
- // 04-07 (TGATE-01, D-03): the statement-shape sibling of PIN_MISSING and
1819
- // UNIVERSE_MISSING, and — uniquely in this table — ADVISORY. The handler
1820
- // classifies it so, and it must NEVER contribute to a block.
1821
- //
1822
- // WHY ADVISORY, ARGUED. Whether the vault has MOVED since this trace was
1823
- // written is a different question from whether this trace is VALID. The
1824
- // statement is self-verifying: it carries its own pin and its own universe,
1825
- // so its verdict is a function of the record rather than of the machine that
1826
- // happens to hold a cache. A blocking divergence would invert that — every
1827
- // machine whose cache advanced past the pin would fail its build for a fact
1828
- // about the cache, not about the artifact. D-03's binding content is "a note,
1829
- // never a block", and this member is that note's carrier.
1830
- //
1831
- // It is a ROUTER-side emission, never a validator one: detecting it requires
1832
- // reading the local manifest, and FENCE 1 asserts the validator reads no
1833
- // manifest. The kind lives here because this table owns STATEMENT-LEVEL kinds
1834
- // — defects of a trace statement, which is what `pin_diverged` names — not
1835
- // because the validator emits it.
1836
- //
1837
- // 04-11 CORRECTS THE CLAIM THAT USED TO SIT HERE. It read: "the table is the
1838
- // one home for a machine-readable kind". That was never true — the gate
1839
- // handler emitted nine ENVELOPE kinds as inline string literals with no table
1840
- // at all, and this comment's confidence was part of why nobody noticed. There
1841
- // are now two tables and the split is BY CATEGORY:
1842
- //
1843
- // - this one owns kinds that describe a STATEMENT: its shape, its version
1844
- // discipline, its pin, its ledger entries, and the comparison of two
1845
- // statements against each other;
1846
- // - `TRACE_HANDLER_KIND` (src/check-command-router.cts) owns kinds that
1847
- // describe the ARGUMENT the gate was invoked with, or the artifact SET it
1848
- // found — neither of which a statement validator can see, because it is
1849
- // handed one statement at a time and no argv at all.
1850
- //
1851
- // Stated as a category, and NO MEMBER of the other table is named here. A
1852
- // list of the router's kinds inside a leaf comment is a stale claim waiting
1853
- // to happen, and a stale list of somewhere else's kinds is the exact defect
1854
- // 04-11 exists to stop repeating.
1855
- PIN_DIVERGED: 'pin_diverged',
1856
- COVERAGE_MISSING_DISPOSITION: 'coverage_missing_disposition',
1857
- COVERAGE_UNKNOWN_DISPOSITION: 'coverage_unknown_disposition',
1858
- COVERAGE_DUPLICATE_ID: 'coverage_duplicate_id',
1859
- // 04-07 (TGATE-01, TGATE-03, D-09): the coverage family's THIRD direction,
1860
- // and the first one that spans TWO artifacts. The two members above compare
1861
- // an emitted set against a disposition set INSIDE one statement; this one
1862
- // asks whether the executor's record accounts for the planner's, plan by
1863
- // plan. One problem PER uncovered identifier, and filed against the SUMMARY —
1864
- // the plan is not the file an operator has to fix.
1865
- //
1866
- // A PHASE AGGREGATE WAS REJECTED, and the reason belongs here beside the
1867
- // member: an aggregate union passes VACUOUSLY. If one plan dropped every
1868
- // practice while a sibling happened to carry the same identifiers, the union
1869
- // still covers, and a gate that cannot fail is not a gate. It also cannot
1870
- // satisfy TGATE-03, which requires naming the file to fix — an aggregate has
1871
- // no file to name.
1872
- COVERAGE_PLAN_UNCOVERED: 'coverage_plan_uncovered',
1873
- // 04-07 (TGATE-01, D-10): a plan statement with no summary counterpart at
1874
- // all. Coverage family, because "nothing covered any of it" is the limiting
1875
- // case of the member above rather than a different defect.
1876
- //
1877
- // Emitted only where the CALLER declares the point strict. Mid-phase an
1878
- // unpaired plan is normal — waves are still running and the execute-side
1879
- // point fires once per wave — so a gate that always emitted this would have
1880
- // blocked Phase 3 continuously across all seven of its waves. At `ship:pre`
1881
- // and `verify:post` every plan must have an executed counterpart, and a phase
1882
- // shipping with a planned-but-never-executed plan is a real defect this gate
1883
- // can see cheaply.
1884
- COVERAGE_PAIR_MISSING: 'coverage_pair_missing',
1885
- // 04-07 (TGATE-01, D-11): the pair exists but the summary was generated
1886
- // STRICTLY BEFORE its plan — a re-planned plan whose summary answers an
1887
- // earlier version of it. Its own defect, not a coverage gap: the identifiers
1888
- // may all match and the record still prove nothing.
1889
- //
1890
- // CR-02's lesson binds this member's DETAIL, not just its existence. Phase 3
1891
- // shipped a block of exactly this shape whose message named no remedy the
1892
- // reader could perform, and it had to be fixed. So the detail extends the
1893
- // single `TRACE_REGENERATE_COMMAND` constant and is phrased for the
1894
- // ORCHESTRATOR: at the execute-side point the executor's worktree is already
1895
- // gone, and a remedy addressed to whoever wrote the artifact is a trap.
1896
- COVERAGE_PAIR_STALE: 'coverage_pair_stale',
1897
- // 04-13 (TGATE-01, CR-01): the pair family's FOURTH member, and the one that
1898
- // closes this phase's own fail-open. A pair whose halves are both present and
1899
- // both well-formed can still prove nothing. If either half records a vault
1900
- // state other than `loaded`, the ledger answering the plan was written
1901
- // against a vault that was never read — so an empty ledger and a complete one
1902
- // are indistinguishable, and the two judgements above are both vacuous.
1903
- // Until this member the comparison returned early on that state with no
1904
- // problem of any kind, so the strict `ship:pre` and `verify:post` gates
1905
- // answered `block: false` with ZERO problems over a record accounting for
1906
- // nothing, and the phase shipped recorded as governed.
1907
- //
1908
- // A DEFECT OF THE PAIR — argued, not asserted, which is why it sits here
1909
- // beside COVERAGE_PAIR_MISSING and COVERAGE_PAIR_STALE.
1910
- //
1911
- // - NOT the practice-entry family. An unprovable pair names NO offending
1912
- // identifier, because when the answering half proved nothing every
1913
- // identifier is equally unaccounted for. Filing one problem per promised
1914
- // id would hand an operator N things to chase for ONE cause, and point
1915
- // each of them at a ledger section where the fix does not live.
1916
- // - NOT the statement-shape family. Neither half is malformed: each is a
1917
- // CORRECT record of what really happened (D-04), and Phase 3 requires a
1918
- // degraded run to write exactly this record. Filing it against a
1919
- // statement would say the record is wrong when the record is right.
1920
- // - It IS the relation that fails: this summary cannot answer this plan.
1921
- // That is the same category as "there is no summary" and "the summary
1922
- // answers an earlier plan", and it is one problem per PAIR for the same
1923
- // reason those are.
1924
- //
1925
- // EMITTED ONLY UNDER THE STRICT MODE, on COVERAGE_PAIR_MISSING's reasoning
1926
- // exactly (D-10). At the two write points the question is whether the record
1927
- // is CORRECT, and a degraded record is correct. At `ship:pre` and
1928
- // `verify:post` the question is whether it PROVES the phase was governed, and
1929
- // it does not. One artifact, two questions, one flag already parsed for the
1930
- // sibling member above — see the state gate in `comparePracticeCoverage` for
1931
- // the whole argument, including the remedy asymmetry that makes the strict
1932
- // arm a gate rather than a trap.
1933
- COVERAGE_PAIR_UNPROVABLE: 'coverage_pair_unprovable',
1934
- // 03-REVIEW CR-03: the READ path's half of the charset the write path has
1935
- // always applied. Sits with the practice-entry family because it is a defect
1936
- // of one entry, not of the statement's shape.
1937
- ID_CHARSET_INVALID: 'practice_id_invalid',
1938
- // 04-05 (TGATE-01, D-01): the read side's verdict on the universe 04-02 put
1939
- // into the statement. Sits with the practice-entry family beside the charset
1940
- // kind — and for the same reason CR-03 put that one here — because it is a
1941
- // defect of ONE entry: this id, in this ledger, resolves nowhere. That
1942
- // entry-level placement is what lets the detail name the id at fault, which
1943
- // is TGATE-03's core clause and is only possible at all because D-02 chose an
1944
- // id LIST over a digest: a digest could prove disagreement but never say
1945
- // which id caused it.
1946
- ID_UNRESOLVED: 'practice_id_unresolved',
1947
- STATUS_OUT_OF_VOCABULARY: 'status_out_of_vocabulary',
1948
- DEVIATED_REASON_MISSING: 'deviated_reason_missing',
1949
- // ── THE BODY-BLOCK FAMILY (261003-j89; closes D-05-07) ────────────────────
1950
- //
1951
- // Seven kinds that compare the `vault:` LEDGER against the `<vault_practices>`
1952
- // BODY BLOCK of the same plan artifact. Measured 2026-10-02 on a fresh 1.14.3
1953
- // project (vault-gate-probe, test 1): seven perturbations that removed or
1954
- // gutted the body block — the only practice text the executor reads — all
1955
- // answered `block: false`, because nothing read the block back. A plan could
1956
- // record full governance and deliver none. These kinds end that silence.
1957
- //
1958
- // WHY THE LEAF TABLE. They are defects of ONE artifact's record — its two
1959
- // halves disagree — not of the argument the gate was invoked with nor of the
1960
- // artifact SET, which is the category split the two tables draw. The body is
1961
- // read by `validatePracticesBody`, which is handed the artifact text and the
1962
- // parsed statement and nothing else: no manifest, no matcher, no disk (D-01).
1963
- //
1964
- // ONE-PER-ARTIFACT vs ONE-PER-ID, argued. `absent`, `duplicate`,
1965
- // `state_mismatch` and `empty` are facts about the block as a whole and name
1966
- // no identifier, so each is
1967
- // emitted once and the per-id checks are skipped behind it — a block that
1968
- // renders nothing would otherwise yield one problem per promised id for ONE
1969
- // cause (the COVERAGE_PAIR_UNPROVABLE argument). The id and text kinds name
1970
- // the identifier at fault, one problem each (TGATE-03).
1971
- //
1972
- // ALL SEVEN BLOCK. None is advisory: each means the executor is handed less
1973
- // than, or other than, what the ledger records, which is exactly a record
1974
- // claiming more governance than was delivered.
1975
- PRACTICES_BLOCK_ABSENT: 'practices_block_absent',
1976
- PRACTICES_BLOCK_DUPLICATE: 'practices_block_duplicate',
1977
- // The block states the vault was NOT read (the absent / unreadable /
1978
- // could-not-be-checked sentence) while the ledger records `state: loaded`.
1979
- // Its own kind rather than a clause on `empty`, because it fires whatever the
1980
- // ledger promises — including nothing — and names a different repair target:
1981
- // the two halves disagree about the RUN, not merely about a count.
1982
- PRACTICES_BLOCK_STATE_MISMATCH: 'practices_block_state_mismatch',
1983
- PRACTICES_BLOCK_EMPTY: 'practices_block_empty',
1984
- PRACTICES_BLOCK_ID_MISSING: 'practices_block_id_missing',
1985
- PRACTICES_BLOCK_ID_UNEXPECTED: 'practices_block_id_unexpected',
1986
- PRACTICES_BLOCK_TEXT_MISMATCH: 'practices_block_text_mismatch',
1987
- VALIDATOR_ERROR: 'validator_error',
1988
- // 16-06 (D-35; T-16-23): a statement-shape kind, appended LAST. An anchor key
1989
- // (`startCommit`, `endCommit`, `anchorBackfill`) that the record carries but
1990
- // that fails its grammar — a short or uppercase sha, an option-shaped value,
1991
- // a back-fill without a valid `by` or `at`. The parser dropped it, so it can
1992
- // never reach a git argv; this kind makes the drop visible and blocking,
1993
- // because an anchor is CLI-written and a hand-edited one is a record that
1994
- // claims coverage it was never given. A record with NO anchor has no problem
1995
- // here: write points are silent on a missing anchor (D-16), and the strict
1996
- // points' `anchor_missing` is the handler's (16-07).
1997
- ANCHOR_INVALID: 'anchor_invalid',
1998
- });
1999
- function isPlainObject(v) {
2000
- return typeof v === 'object' && v !== null && !Array.isArray(v);
2001
- }
2002
- /** Does `obj` own every segment of a dotted path with no `[]` in it? */
2003
- function hasPlainPath(obj, dotted) {
2004
- if (dotted === '')
2005
- return isPlainObject(obj);
2006
- let cursor = obj;
2007
- for (const seg of dotted.split('.')) {
2008
- if (!isPlainObject(cursor))
2009
- return false;
2010
- if (!Object.prototype.hasOwnProperty.call(cursor, seg))
2011
- return false;
2012
- cursor = cursor[seg];
2013
- }
2014
- return true;
2015
- }
2016
- function readPlainPath(obj, dotted) {
2017
- if (dotted === '')
2018
- return obj;
2019
- let cursor = obj;
2020
- for (const seg of dotted.split('.')) {
2021
- if (!isPlainObject(cursor))
2022
- return undefined;
2023
- cursor = cursor[seg];
2024
- }
2025
- return cursor;
2026
- }
2027
- /**
2028
- * Presence of one required-key path against a statement. The `vault.` prefix is
2029
- * stripped because it names the frontmatter key, not a key of the statement.
2030
- *
2031
- * An EMPTY list satisfies a `[]` path vacuously and deliberately: the document
2032
- * says `subject: []` is legal for a doc-only plan, and requiring `name` of a
2033
- * list with no elements would reject that legal shape.
2034
- */
2035
- function hasRequiredKey(statement, docPath) {
2036
- const rel = docPath.replace(/^vault\.?/, '');
2037
- const arrIdx = rel.indexOf('[]');
2038
- if (arrIdx === -1)
2039
- return hasPlainPath(statement, rel);
2040
- const head = rel.slice(0, arrIdx);
2041
- const tail = rel.slice(arrIdx + 2).replace(/^\./, '');
2042
- const arr = readPlainPath(statement, head);
2043
- if (!Array.isArray(arr))
2044
- return false;
2045
- if (tail === '')
2046
- return true;
2047
- return arr.every((el) => hasPlainPath(el, tail));
2048
- }
2049
- /** A short, bounded rendering of an arbitrary value for a problem detail. */
2050
- function describeValue(v) {
2051
- if (v === null)
2052
- return 'null';
2053
- if (v === undefined)
2054
- return 'absent';
2055
- if (typeof v === 'string')
2056
- return v.length > 120 ? `${v.slice(0, 120)}…` : v;
2057
- if (typeof v === 'number' || typeof v === 'boolean')
2058
- return String(v);
2059
- if (Array.isArray(v))
2060
- return `a list of ${v.length}`;
2061
- return 'an object';
2062
- }
2063
- /**
2064
- * A statement that reached the parser but from which nothing was read. The
2065
- * parser NORMALISES an unreadable block into a full-shaped statement with empty
2066
- * scalars, so "did not parse" cannot be detected from key presence — it is
2067
- * detected from every identifying value being empty at once. A genuine
2068
- * statement always carries `_type`.
2069
- */
2070
- function statementReadNothing(st) {
2071
- const predicate = isPlainObject(st['predicate']) ? st['predicate'] : {};
2072
- const subject = st['subject'];
2073
- const practices = predicate['practices'];
2074
- return ((st['_type'] ?? '') === '' &&
2075
- (st['predicateType'] ?? '') === '' &&
2076
- (predicate['stage'] ?? '') === '' &&
2077
- (predicate['plan'] ?? '') === '' &&
2078
- (!Array.isArray(subject) || subject.length === 0) &&
2079
- (!Array.isArray(practices) || practices.length === 0));
2080
- }
2081
- /**
2082
- * Validate a trace statement against the structure-and-pin half of the contract
2083
- * (D-09). NEVER throws, for any input: this sits on the read path for an
2084
- * attacker-influenceable document, handlers in this repository never throw, and
2085
- * a handler is what calls this.
2086
- *
2087
- * Returns EVERY defect rather than the first — an operator fixing a trace wants
2088
- * the whole list at once, and Phase 4's gate output builds on the same list.
2089
- *
2090
- * @param input A statement, a `parseVaultTraceBlock` result, or nothing.
2091
- * @param options `stage` is the stage being validated; `file` names the
2092
- * artifact in every problem; `dispositions` supplies the
2093
- * judgment half when the caller holds it separately.
2094
- */
2095
- function validateTraceStatement(input, options) {
2096
- const opts = isPlainObject(options) ? options : {};
2097
- const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the artifact';
2098
- const stage = typeof opts.stage === 'string' ? opts.stage : '';
2099
- const problems = [];
2100
- // `tail` is optional and is omitted from the pushed object when absent, so a
2101
- // problem that carries no remedy is byte-identical to what it was before the
2102
- // field existed — an `undefined` property would change every deepEqual in
2103
- // every consumer for no behavioural gain.
2104
- const add = (kind, detail, tail) => {
2105
- problems.push(tail === undefined ? { file, kind, detail } : { file, kind, detail, tail });
2106
- };
2107
- try {
2108
- // ── the block is there at all (D-11: a hard cut, no grandfather clause) ──
2109
- let statement = input;
2110
- if (isPlainObject(input) && typeof input['present'] === 'boolean' && 'statement' in input) {
2111
- if (input['present'] !== true) {
2112
- add(TRACE_PROBLEM_KIND.ABSENT, `${file} carries no \`${TRACE_BLOCK_KEY}:\` trace block. A vault-enabled phase records its practice trace in every plan artifact; there is no backfill path and no pre-schema exemption.`);
2113
- return { block: true, problems };
2114
- }
2115
- statement = input['statement'];
2116
- }
2117
- if (statement === null || statement === undefined) {
2118
- add(TRACE_PROBLEM_KIND.ABSENT, `${file} carries no \`${TRACE_BLOCK_KEY}:\` trace block. A vault-enabled phase records its practice trace in every plan artifact; there is no backfill path and no pre-schema exemption.`);
2119
- return { block: true, problems };
2120
- }
2121
- if (!isPlainObject(statement)) {
2122
- add(TRACE_PROBLEM_KIND.MALFORMED_INPUT, `the \`${TRACE_BLOCK_KEY}:\` block in ${file} is not a mapping (found ${describeValue(statement)}).`);
2123
- return { block: true, problems };
2124
- }
2125
- // ── it parsed ──
2126
- if (statementReadNothing(statement)) {
2127
- add(TRACE_PROBLEM_KIND.UNPARSABLE, `the \`${TRACE_BLOCK_KEY}:\` block in ${file} is present but nothing could be read from it — no statement type, no stage and no practice entries parsed. The block is unreadable, which is NOT the same as an empty practice set.`);
2128
- return { block: true, problems };
2129
- }
2130
- // ── required keys present ──
2131
- const requiredKeys = stage === 'plan' || stage === 'execute'
2132
- ? TRACE_REQUIRED_KEYS[stage]
2133
- : TRACE_REQUIRED_KEYS.plan;
2134
- for (const docPath of requiredKeys) {
2135
- if (!hasRequiredKey(statement, docPath)) {
2136
- add(TRACE_PROBLEM_KIND.MISSING_REQUIRED_KEY, `required key ${docPath} is missing from the trace in ${file}.` +
2137
- (docPath === EXECUTE_DIGEST_KEY ? EXECUTE_DIGEST_REMEDY : ''));
2138
- }
2139
- }
2140
- // ── version discipline (TRACE-05) ──
2141
- // An unknown OPTIONAL key is never inspected, and that silence is the point:
2142
- // it is what makes an additive minor change non-breaking for the Phase 4 and
2143
- // Phase 5 consumers. An unexpected MAJOR marker, by contrast, fails loudly
2144
- // and names both values — a consumer silently accepting a future major is
2145
- // the failure mode the whole freeze exists to prevent.
2146
- if (statement['_type'] !== TRACE_STATEMENT_TYPE) {
2147
- add(TRACE_PROBLEM_KIND.STATEMENT_TYPE_MISMATCH, `_type must be exactly "${TRACE_STATEMENT_TYPE}"; found "${describeValue(statement['_type'])}".`);
2148
- }
2149
- if (statement['predicateType'] !== TRACE_PREDICATE_TYPE) {
2150
- add(TRACE_PROBLEM_KIND.PREDICATE_TYPE_MISMATCH, `predicateType must be exactly "${TRACE_PREDICATE_TYPE}"; found "${describeValue(statement['predicateType'])}". The URI carries the MAJOR version — this reader is pinned to it and will not silently accept another.`);
2151
- }
2152
- const schemaVersion = statement['schemaVersion'];
2153
- if (typeof schemaVersion !== 'number' || !Number.isInteger(schemaVersion)) {
2154
- add(TRACE_PROBLEM_KIND.SCHEMA_VERSION_INVALID, `schemaVersion must be an integer; found "${describeValue(schemaVersion)}".`);
2155
- }
2156
- else if (schemaVersion > TRACE_SCHEMA_VERSION) {
2157
- add(TRACE_PROBLEM_KIND.SCHEMA_VERSION_UNSUPPORTED, `schemaVersion ${schemaVersion} is above the highest version this reader knows (${TRACE_SCHEMA_VERSION}). A newer major is refused loudly rather than read as if it were understood.`);
2158
- }
2159
- const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2160
- // The stage is the one field that tells the two traces apart, so a required
2161
- // key whose value could be anything would not be a required key in any
2162
- // useful sense. Validated against the stage the caller asked for.
2163
- if (stage === 'plan' || stage === 'execute') {
2164
- if (predicate['stage'] !== stage) {
2165
- add(TRACE_PROBLEM_KIND.STAGE_MISMATCH, `predicate.stage must be "${stage}" for this artifact; found "${describeValue(predicate['stage'])}".`);
2166
- }
2167
- }
2168
- // ── the anchors (16-06, D-35) ──
2169
- // Present-but-invalid only: an absent anchor is not this check's business.
2170
- // The detail names the KEY, never the value (the seal leaf's message
2171
- // discipline): the value failed its grammar, so it is not trusted text.
2172
- for (const key of ['startCommit', 'endCommit']) {
2173
- if (!(key in predicate))
2174
- continue;
2175
- const v = predicate[key];
2176
- if (!(typeof v === 'string' && vault_seal_cjs_1.ANCHOR_SHA_RE.test(v))) {
2177
- add(TRACE_PROBLEM_KIND.ANCHOR_INVALID, `predicate.${key} in ${file} is not a full lowercase commit id (40 or 64 hex), so it was not read as an anchor.`, TRACE_ANCHOR_TAIL);
2178
- }
2179
- }
2180
- if ('anchorBackfill' in predicate && !(0, vault_seal_cjs_1.isAnchorBackfill)(predicate['anchorBackfill'])) {
2181
- add(TRACE_PROBLEM_KIND.ANCHOR_INVALID, `predicate.anchorBackfill in ${file} is not { by, at } with a bounded name and an ISO-8601 instant, so the back-fill was not read.`, TRACE_ANCHOR_TAIL);
2182
- }
2183
- if (Array.isArray(predicate['anchorsDropped'])) {
2184
- for (const key of predicate['anchorsDropped']) {
2185
- if (key !== 'startCommit' && key !== 'endCommit' && key !== 'anchorBackfill')
2186
- continue;
2187
- add(TRACE_PROBLEM_KIND.ANCHOR_INVALID, `predicate.${key} in ${file} failed its grammar and was dropped when the ledger was read, so it was not read as an anchor.`, TRACE_ANCHOR_TAIL);
2188
- }
2189
- }
2190
- // ── the pin, scoped to a LOADED state ──
2191
- // A degraded statement legitimately carries a null pin (D-04), and a stale
2192
- // cache is degraded WITH a live pin. A validator that checked the pin
2193
- // unconditionally would reject exactly the record D-04 exists to produce.
2194
- const vaultBlock = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
2195
- const vaultState = vaultBlock['state'];
2196
- if (vaultState !== 'loaded' && vaultState !== 'degraded') {
2197
- add(TRACE_PROBLEM_KIND.VAULT_STATE_INVALID, `predicate.vault.state must be "loaded" or "degraded"; found "${describeValue(vaultState)}".`);
2198
- }
2199
- else if (vaultState === 'loaded') {
2200
- for (const half of ['ref', 'resolvedSha']) {
2201
- const value = vaultBlock[half];
2202
- if (typeof value !== 'string' || value.trim() === '') {
2203
- add(TRACE_PROBLEM_KIND.PIN_MISSING, `predicate.vault.${half} must be non-null when predicate.vault.state is "loaded"; found "${describeValue(value)}". Without both halves of the pin nothing about this run is provable.`);
2204
- }
2205
- }
2206
- }
2207
- // ── the practice entries: vocabulary, deviated reasons, coverage ──
2208
- const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
2209
- const emittedIds = [];
2210
- const seenIds = new Set();
2211
- const duplicateIds = new Set();
2212
- const decidedIds = new Set();
2213
- for (const raw of practices) {
2214
- const entry = isPlainObject(raw) ? raw : {};
2215
- const id = typeof entry['id'] === 'string' ? entry['id'] : '';
2216
- // 03-REVIEW CR-03: the charset the write path has always applied, applied
2217
- // here too. `docs/vault-trace-format.md` states as a FACT that the id is
2218
- // "bounded, charset-validated, and always emitted quoted"; without this
2219
- // the claim held only where the engine writes an id, and a ledger whose
2220
- // id was a whole English sentence validated clean (T-03-31).
2221
- //
2222
- // Reads ONLY the module-local pattern — nothing on disk, and nothing from
2223
- // the vault. Resolving an id against the manifest is Phase 4's.
2224
- //
2225
- // Deliberately does NOT `continue`: the entry still joins the duplicate
2226
- // and coverage sets below, because suppressing it would turn one defect
2227
- // into a second, quieter one.
2228
- if (typeof entry['id'] !== 'string' || !TRACE_ID_PATTERN.test(entry['id'])) {
2229
- add(TRACE_PROBLEM_KIND.ID_CHARSET_INVALID, `practice id "${collapseControlChars(boundedToken(entry['id']))}" is not in the permitted charset (alphanumerics plus . _ -, first character alphanumeric, max 64).`);
2230
- }
2231
- emittedIds.push(id);
2232
- if (seenIds.has(id))
2233
- duplicateIds.add(id);
2234
- seenIds.add(id);
2235
- const status = entry['status'];
2236
- const statusText = typeof status === 'string' ? status : '';
2237
- const inVocabulary = TRACE_STATUS_VALUES.includes(statusText);
2238
- // The empty string is the documented PLAN-stage value — the slot the
2239
- // executor fills in later — so it is not an out-of-vocabulary word there.
2240
- const emptyIsLegal = stage !== 'execute';
2241
- if (!inVocabulary && !(statusText === '' && emptyIsLegal)) {
2242
- add(TRACE_PROBLEM_KIND.STATUS_OUT_OF_VOCABULARY, `practice "${id}" carries status "${describeValue(status)}", which is not one of ${TRACE_STATUS_VALUES.join(', ')}.`);
2243
- }
2244
- if (inVocabulary)
2245
- decidedIds.add(id);
2246
- if (statusText === 'deviated') {
2247
- const reason = entry['reason'];
2248
- if (typeof reason !== 'string' || reason.trim() === '') {
2249
- add(TRACE_PROBLEM_KIND.DEVIATED_REASON_MISSING, `practice "${id}" is recorded as deviated with no reason. A deviation without a stated reason is indistinguishable from a practice that was never considered.`);
2250
- }
2251
- }
2252
- }
2253
- for (const dup of duplicateIds) {
2254
- add(TRACE_PROBLEM_KIND.COVERAGE_DUPLICATE_ID, `practice id "${dup}" appears more than once in the emitted set, so its disposition is ambiguous.`);
2255
- }
2256
- // ── identifier resolution, scoped to a LOADED state (04-05, TGATE-01) ────
2257
- //
2258
- // A PURE SET OPERATION over data already in the statement. The universe is
2259
- // an INPUT — 04-02 recorded it from the SAME vault read that produced the
2260
- // pin — and is never re-derived here. A validator that went to disk for it
2261
- // would make the verdict a function of the machine it ran on rather than of
2262
- // the record, which is precisely the environment-dependence D-01 was
2263
- // written to remove: the same trace must yield the same verdict on any
2264
- // machine, with or without a cache. Nothing below opens a file, spawns a
2265
- // process, reads an environment variable or consults the practice matcher.
2266
- //
2267
- // SUPPRESSED — not failed, and not silently skipped — when the state is not
2268
- // `loaded`, on the pin check's reasoning verbatim. A degraded run
2269
- // legitimately carries a null pin and no universe (D-04), and Phase 3
2270
- // REQUIRES such a run to write a trace recording the degraded state and its
2271
- // reason. The miss is therefore already a recorded fact in the artifact; a
2272
- // validator that additionally rejected the record would be rejecting a
2273
- // correct record of a real failure. What keeps this from being an escape
2274
- // hatch is the other half 04-02 pinned: the state is derived by the CLI
2275
- // from the vault fixture and cannot be selected through any
2276
- // statement-builder option.
2277
- if (vaultState === 'loaded') {
2278
- const universe = vaultBlock['practiceUniverse'];
2279
- if (!Array.isArray(universe)) {
2280
- // Fail closed, on the posture the handler already holds at its
2281
- // unresolved-phase branch: a check that proved nothing must not read as
2282
- // a check that passed. Unreachable from the shipped writer — every
2283
- // loaded statement it emits carries a universe (04-02) — so this names
2284
- // a hand-authored statement, and the remedy is one the reader can
2285
- // perform without the worktree that wrote the artifact.
2286
- //
2287
- // 04-11 (CR-05): the remedy is the TAIL, never the head. The head names
2288
- // the artifact — an untrusted, unbounded-in-practice path — and the
2289
- // whole of that sentence used to be followed by the command inside a
2290
- // single 400-character budget. A 138-character artifact path, which is
2291
- // an ordinary workstream phase path rather than a hostile one, printed
2292
- // a PARTIAL command an orchestrator agent could run.
2293
- add(TRACE_PROBLEM_KIND.UNIVERSE_MISSING, `the trace in ${file} records no practice-identifier universe (predicate.vault.practiceUniverse), so no identifier it lists can be resolved and the ledger proves nothing.`, ` Regenerate the trace in place: ${TRACE_REGENERATE_COMMAND}`);
2294
- }
2295
- else {
2296
- // Built ONCE per validation rather than scanned per id: a linear scan
2297
- // per identifier turns a large universe into a quadratic walk (T-4-22).
2298
- const universeIds = new Set(universe.filter((v) => typeof v === 'string'));
2299
- // `seenIds` is the emitted set already de-duplicated, so a duplicated
2300
- // unresolvable id yields one problem, not one per occurrence — the
2301
- // duplication is its own defect above and has its own kind.
2302
- for (const id of seenIds) {
2303
- if (universeIds.has(id))
2304
- continue;
2305
- // ONE problem PER unresolved id, never one aggregate: the detail has
2306
- // to name the id at fault (TGATE-03), and a single problem standing
2307
- // for three broken ids makes the problem count meaningless.
2308
- add(TRACE_PROBLEM_KIND.ID_UNRESOLVED, `practice id "${collapseControlChars(boundedToken(id))}" is not in the practice-identifier universe this trace records for the vault at its pinned SHA, so it resolves to no practice.`);
2309
- }
2310
- }
2311
- }
2312
- // The 1:1 coverage check runs at EXECUTE stage: at plan stage nothing has
2313
- // been decided yet by design, so every id would read as uncovered.
2314
- if (stage === 'execute') {
2315
- const supplied = Array.isArray(opts.dispositions) ? opts.dispositions : null;
2316
- const dispositionIds = supplied
2317
- ? new Set(supplied.map((d) => (isPlainObject(d) && typeof d['id'] === 'string' ? d['id'] : '')))
2318
- : decidedIds;
2319
- const emittedSet = new Set(emittedIds);
2320
- const uncovered = [...emittedSet].filter((id) => !dispositionIds.has(id));
2321
- if (uncovered.length > 0) {
2322
- add(TRACE_PROBLEM_KIND.COVERAGE_MISSING_DISPOSITION, `these practice ids are in the emitted set with no recorded disposition: ${uncovered.map((id) => `"${id}"`).join(', ')}.`);
2323
- }
2324
- const unknown = [...dispositionIds].filter((id) => !emittedSet.has(id));
2325
- if (unknown.length > 0) {
2326
- add(TRACE_PROBLEM_KIND.COVERAGE_UNKNOWN_DISPOSITION, `these dispositions name a practice id the emitted set does not contain: ${unknown.map((id) => `"${id}"`).join(', ')}.`);
2327
- }
2328
- }
2329
- return { block: problems.length > 0, problems };
2330
- }
2331
- catch (e) {
2332
- // Totality is the contract, not a courtesy: a throw here would turn a
2333
- // halt-on-error gate into a permanently stopped build (T-03-16).
2334
- return {
2335
- block: true,
2336
- problems: [
2337
- {
2338
- file,
2339
- kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
2340
- detail: `the trace validator could not complete: ${boundedToken(String(e?.message ?? e))}`,
2341
- },
2342
- ],
2343
- };
2344
- }
2345
- }
2346
- /** The vault state a statement records, or `''` when it records none. */
2347
- function traceVaultStateOf(statement) {
2348
- if (!isPlainObject(statement))
2349
- return '';
2350
- const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2351
- const vault = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
2352
- return typeof vault['state'] === 'string' ? vault['state'] : '';
2353
- }
2354
- /**
2355
- * The degraded reason a statement records, or `''` (04-13).
2356
- *
2357
- * The fifth sibling of the four accessors around it, in their register and
2358
- * with their defensive shape: every hop is type-checked, and a statement that
2359
- * carries none of them yields the empty string rather than throwing. The
2360
- * comparison never throws, so nothing it calls may either.
2361
- */
2362
- function traceVaultReasonOf(statement) {
2363
- if (!isPlainObject(statement))
2364
- return '';
2365
- const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2366
- const vault = isPlainObject(predicate['vault']) ? predicate['vault'] : {};
2367
- return typeof vault['reason'] === 'string' ? vault['reason'] : '';
2368
- }
2369
- /** The non-empty practice identifiers a statement emits, in recorded order. */
2370
- function traceEmittedIdsOf(statement) {
2371
- if (!isPlainObject(statement))
2372
- return [];
2373
- const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2374
- const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
2375
- const out = [];
2376
- for (const raw of practices) {
2377
- const entry = isPlainObject(raw) ? raw : {};
2378
- const id = entry['id'];
2379
- if (typeof id === 'string' && id !== '')
2380
- out.push(id);
2381
- }
2382
- return out;
2383
- }
2384
- /** The plan identifier a statement records, or `''`. */
2385
- function tracePlanIdOf(statement) {
2386
- if (!isPlainObject(statement))
2387
- return '';
2388
- const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2389
- return typeof predicate['plan'] === 'string' ? predicate['plan'] : '';
2390
- }
2391
- /** The generation timestamp a statement records, or `''`. */
2392
- function traceGeneratedAtOf(statement) {
2393
- if (!isPlainObject(statement))
2394
- return '';
2395
- const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2396
- return typeof predicate['generatedAt'] === 'string' ? predicate['generatedAt'] : '';
2397
- }
2398
- /**
2399
- * The performable remedy for an unprovable pair, as a TRUSTED TAIL (04-13).
2400
- *
2401
- * TWO ROUTES, ALWAYS BOTH, AND THAT IS THE WHOLE POINT. Phase 3's CR-02
2402
- * established that a block naming no remedy the reader can perform is a trap,
2403
- * and this block is emitted on SEVEN distinct degraded causes that do not behave
2404
- * the same way. Read from the pin resolver above rather than from memory:
2405
- *
2406
- * - no vault cache directory, an absent manifest, an unreadable or malformed
2407
- * manifest — and a stale cache whenever the vault is REACHABLE. Three (and
2408
- * a half) causes a re-fetch clears: the strict points run on the main
2409
- * checkout, so regenerating really does produce a loaded statement.
2410
- * - a recorded MISS, and a stale cache whose refresh FAILS. Two causes
2411
- * regeneration cannot clear at all: the fetch itself failed on credentials,
2412
- * network or an unreachable repository, so re-fetching reproduces the miss
2413
- * and regenerating reproduces the identical record. A tail offering only
2414
- * "regenerate" would be CR-02's trap reintroduced on exactly these two.
2415
- *
2416
- * - a load at a tier that is not `client` (D-08-25). The sixth cause, and
2417
- * the one NEITHER route above clears on its own: the vault was reached,
2418
- * freshly and successfully, and it was the wrong vault. Re-fetching
2419
- * reproduces the same tier and regenerating reproduces the same record,
2420
- * because the project's own `client.vaultRepo` / `client.vaultRef` point
2421
- * where they point. Its lead therefore names the configuration edit.
2422
- *
2423
- * - a client-tier cache fetched from a ref other than the configured
2424
- * `client.vaultRef` (D-08-34, UAT F5). The seventh cause, and it needs NO
2425
- * fourth lead: `vault fetch` reads the CONFIGURED ref, so a successful
2426
- * fetch genuinely replaces the drifted cache and the default re-fetch lead
2427
- * clears it. It reproduces only when that ref cannot be fetched — the same
2428
- * "and a half" the stale cause already carries — and the tail names the
2429
- * disable route in every case regardless. So its reason is phrased OUTSIDE
2430
- * all three classifier substrings below, deliberately, and lands on the
2431
- * re-fetch-first lead (D-08-36).
2432
- *
2433
- * - a fresh client-tier cache at the configured ref that holds standards
2434
- * (INDEX.md) and no practice files, read in practices mode (quick
2435
- * 261015-at3). The eighth cause, and its own lead: the vault is the right
2436
- * one, so neither re-fetching nor restoring access clears it — only setting
2437
- * `vault.mode` to `standards` does.
2438
- *
2439
- * So ALL THREE tails name BOTH routes, and the second route — turning the practices
2440
- * capability off for this project, a local `.planning/config.json` edit — is
2441
- * performable with the vault down, from the same checkout the gate ran in.
2442
- * There is no third route: `PP_VAULT_OVERRIDE_REASON` records a `vault.status`
2443
- * override, not a trace-gate one, and does not suppress this kind.
2444
- *
2445
- * WHAT THE CLASSIFIER DOES, AND WHAT IT DELIBERATELY CANNOT DO. It reads the
2446
- * recorded reason only to choose which route to state FIRST, so a reader on the
2447
- * unreachable arm is not sent to re-run a command that will come back
2448
- * identical. It selects between three FIXED strings; nothing from the artifact is
2449
- * interpolated, so a hand-edited or hostile reason can change the emphasis and
2450
- * can never change what is offered. Misclassification therefore costs an
2451
- * operator one sentence of reading order, never a performable remedy — which is
2452
- * why a substring probe over our own generated prose is an acceptable
2453
- * mechanism here and would not be if it gated the routes themselves.
2454
- */
2455
- function unprovablePairTail(reason) {
2456
- // FOUR leads for EIGHT causes now (D-08-25, D-08-34, then 261015-at3). The
2457
- // standards-only cause is tested first only because its phrase is unique to
2458
- // it; among the original causes the tier cause is tested FIRST
2459
- // because it is the only one neither existing lead describes: the vault was
2460
- // reached, freshly, successfully — at the WRONG TIER. Regenerating alone
2461
- // reproduces it (the scaffold reads the cache, it does not fetch), and so
2462
- // does restoring reachability (the shared vault was already reachable; that
2463
- // is how it loaded). The project is pointed at the wrong vault, and only a
2464
- // configuration edit moves it — which is why this lead names the key.
2465
- //
2466
- // A stale FALLBACK cache lands here rather than on the unreachable lead,
2467
- // because `resolvePin` returns the tier reason ahead of the stale one: the
2468
- // tier is the stronger fact and its remedy is the one that clears both.
2469
- //
2470
- // THE LEAD IS LENGTH-BUDGETED, NOT PHRASED TO TASTE, AND THE BUDGET WAS
2471
- // MEASURED RATHER THAN ESTIMATED. `TRACE_DETAIL_CAP` is 400 and the router
2472
- // gives the head `cap - tail.length - 1`. The tier cause's worst realistic
2473
- // head is the BOTH-HALVES case — a fallback-tier plan and its summary are
2474
- // written from the same cache, so both degrade together — which opens "both
2475
- // halves of this pair record" and carries the reason bounded to
2476
- // `boundedToken`'s 80 characters: 187 characters, measured. With the command
2477
- // (87) and the disable sentence (63) fixed, the lead has 62 characters. A
2478
- // longer one truncates the head that names the plan, which is the
2479
- // truncated-remedy bug arriving from the other end.
2480
- //
2481
- // So the lead states the DIAGNOSIS the other two leads do not give — the
2482
- // project is pointed at the wrong vault — plus the key to change and the
2483
- // fact that a fetch must follow it. The fuller remedy (re-point
2484
- // `client.vaultRepo` / `client.vaultRef`, re-fetch, regenerate) is spelled
2485
- // out in `docs/vault-trace-format.md`, which has no cap.
2486
- // The fourth lead (quick 261015-at3): the standards-only cause. The vault was
2487
- // reached, freshly, at the configured ref and tier — it is the RIGHT vault,
2488
- // read in a mode it cannot serve. Neither a re-fetch nor restored access
2489
- // moves it; only the mode does. Kept inside the same 62-character budget as
2490
- // the tier lead.
2491
- const lead = /but vault\.mode is practices/.test(reason)
2492
- ? ' Standards vault: set vault.mode to standards, then '
2493
- : /no client standards were applied/.test(reason)
2494
- ? ' Wrong vault: fix client.vaultRepo, re-fetch, then '
2495
- // The two reasons the resolver emits when the vault was REACHED FOR and not
2496
- // reached. Anything else — including an empty reason — falls to the
2497
- // re-fetch-first tail, which also names the disable route.
2498
- : /records a miss|is stale/.test(reason)
2499
- ? ' Regenerating alone reproduces this: restore vault access first, then '
2500
- : " Re-fetch the vault and regenerate that stage's trace: ";
2501
- return (lead
2502
- + TRACE_REGENERATE_COMMAND
2503
- // The SECOND route, and it is the same sentence in all three tails on
2504
- // purpose: it is the one action performable while the vault is down, so it
2505
- // must not be the clause that a rewording of a first route quietly drops.
2506
- + ' Or set vault.practices_enabled=false in .planning/config.json.');
2507
- }
2508
- /**
2509
- * Compare ONE plan statement against ONE summary statement — the first relation
2510
- * between two artifacts this module has ever expressed (TGATE-01's third
2511
- * clause, under D-09).
2512
- *
2513
- * THE RELATION. A summary's identifier set must COVER its own plan's,
2514
- * plan by plan. Not the intersection, not a phase-wide union: a SUPERSET test
2515
- * against exactly one counterpart. Both rejected alternatives fail the same
2516
- * way, VACUOUSLY. An intersection test passes for a summary that kept one
2517
- * identifier and dropped the rest. A phase aggregate passes when one plan
2518
- * dropped every practice and a sibling happened to carry the same identifiers.
2519
- * A gate that cannot fail is not a gate, and this workstream has already
2520
- * shipped one coverage gate that reported `passed` while parsing nothing.
2521
- *
2522
- * The aggregate also cannot satisfy TGATE-03, which requires the failure to
2523
- * name the file and section to fix: an aggregate has no file to name. That is
2524
- * why every problem returned here is filed against the SUMMARY — it is the
2525
- * artifact whose record is incomplete and the one an operator must repair.
2526
- *
2527
- * RETURNS PROBLEMS, NEVER A BOOLEAN. The caller composes them into its own
2528
- * verdict alongside every other problem it holds, exactly as it does with the
2529
- * single-statement validator's. A boolean here would force a second, parallel
2530
- * verdict composition in the handler.
2531
- *
2532
- * PURE. Two parsed statements in, problems out. It opens no file, stats
2533
- * nothing, and invokes no git — deliberately, and not merely as tidiness. The
2534
- * two alternatives for pairing were considered and are unusable in this
2535
- * workflow: artifacts are copied out of executor worktrees as a routine step,
2536
- * which destroys modification times, and `.planning/` is gitignored, so there
2537
- * is no history to read. Only the recorded plan identifier and the recorded
2538
- * generation timestamp — both frozen REQUIRED keys in both stage variants —
2539
- * survive both. The divergence check, which does read a manifest, is the
2540
- * router's and lives there.
2541
- *
2542
- * NEVER THROWS, on the validator's reasoning verbatim: this sits on the read
2543
- * path for an attacker-influenceable document, and a throw would turn a
2544
- * halt-on-error gate into a permanently stopped build.
2545
- */
2546
- function comparePracticeCoverage(planStatement, summaryStatement, options) {
2547
- const opts = isPlainObject(options) ? options : {};
2548
- const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the summary artifact';
2549
- const planFile = typeof opts.planFile === 'string' && opts.planFile !== '' ? opts.planFile : 'its plan artifact';
2550
- // Anything that is not the strict word is tolerant, so an absent option, an
2551
- // undefined one and a misspelt one all land on the silent arm rather than on
2552
- // the blocking one. Fail-open is the correct default for THIS parameter and
2553
- // only for this one: it is the caller's declaration of which question is
2554
- // being asked, and a typo must not invent a halt nobody declared.
2555
- const strictness = opts.strictness === 'strict' ? 'strict' : 'tolerant';
2556
- const problems = [];
2557
- try {
2558
- // ── the state gate: THREE outcomes, not two (04-13, CR-01) ─────────────
2559
- //
2560
- // THE TOLERANT ARM, and its reasoning is 04-07's PRESERVED VERBATIM below
2561
- // because 04-13 does not challenge a word of it. What 04-13 changes is that
2562
- // this paragraph used to be the WHOLE answer, at every call site:
2563
- //
2564
- // > SUPPRESSED — not failed — unless BOTH halves record a `loaded` vault. A
2565
- // > degraded run legitimately recorded no universe and may legitimately have
2566
- // > recorded no practices at all (D-04), and Phase 3 REQUIRES it to write a
2567
- // > trace stating the degraded state and its reason. The miss is therefore
2568
- // > already a recorded fact in the artifact; a comparison that additionally
2569
- // > blocked would be rejecting a correct record of a real failure. What keeps
2570
- // > this from being an escape hatch is 04-02's pin: the state is derived by
2571
- // > the CLI from the vault fixture and cannot be selected through any
2572
- // > statement-builder option.
2573
- //
2574
- // THE STRICT ARM, and why the paragraph above stops being the whole answer
2575
- // there. At the two WRITE points the gate asks *is this a correct record?*
2576
- // and a degraded record is correct. At `ship:pre` and `verify:post` it asks
2577
- // a different question: *does this record prove the phase was governed?* A
2578
- // degraded half answering a loaded plan proves nothing — it records that
2579
- // the vault was unreachable, which is a fact about the machine and not
2580
- // about the work. Scoring "proved nothing" as "passed" was the fail-open
2581
- // (CR-01): a summary written while `.planning/vault/` was absent recorded
2582
- // an empty ledger and this gate returned zero problems, so a phase that
2583
- // promised practices and accounted for none SHIPPED RECORDED AS GOVERNED.
2584
- // The record is neither a valid proof nor a rejectable record; it is an
2585
- // UNPROVEN one, and that third outcome had no name.
2586
- //
2587
- // STRICTNESS IS A PROPERTY OF THE CALL SITE, exactly as D-10 already made
2588
- // it one flag ago for an unpaired plan — normal mid-phase, a defect at
2589
- // ship. The same flag carries this, so the tolerant points stay
2590
- // byte-identically silent on the very fixture the strict points block on.
2591
- //
2592
- // WHY THE STRICT ARM IS A GATE AND NOT A TRAP, stated honestly rather than
2593
- // rounded off: the remedy's availability is PARTIAL. Three of the
2594
- // resolver's five degraded causes are cleared by re-fetching and
2595
- // regenerating from the main checkout the strict points run on; a recorded
2596
- // miss and a stale cache whose refresh fails are NOT — regeneration
2597
- // reproduces them exactly. There the block is still deliberate, because the
2598
- // remedy is OPERATIONAL rather than a re-run: restore vault reachability,
2599
- // or turn the practices capability off for this project. Both are stated in
2600
- // the tail, both are performable with the vault down, and the residual is
2601
- // accepted: while a client vault is genuinely unreachable, a correct record
2602
- // and a blocked ship coexist. That is the intended behaviour of a
2603
- // workstream whose premise is that a vault miss must never silently produce
2604
- // ungoverned code.
2605
- //
2606
- // REJECTED, one sentence each, so the next reader does not re-open them.
2607
- // Rejecting a degraded statement at VALIDATION time blocks everywhere and
2608
- // fails a correct record of a real failure — D-04 is right, and at the
2609
- // write points the remedy reproduces the block. An ADVISORY-only problem is
2610
- // measurably invisible: a non-blocking verdict sets no message and the
2611
- // enforcer prints only the message, so it would restate the silence in a
2612
- // quieter channel. Letting the existing uncovered-identifier kind fire on
2613
- // an empty ledger yields one problem per promised id for ONE cause, and
2614
- // points each at a section where the fix does not live. A phase-level
2615
- // aggregate cannot name a file, which TGATE-03 requires.
2616
- //
2617
- // SYMMETRIC ON PURPOSE — either half, not only the summary. A degraded PLAN
2618
- // at ship time is equally unproven: it says no practices governed the work
2619
- // because the vault was unreachable when it was planned. Closing only the
2620
- // summary arm would leave the mirror-image hole, which is this phase's own
2621
- // signature failure.
2622
- //
2623
- // THE BOUNDARY, AND IT IS A PROHIBITION. `validateTraceStatement`'s
2624
- // degraded suppression is UNCHANGED, at every strictness, and must stay so.
2625
- // It takes no strictness parameter and must not be given one. The reason is
2626
- // informational, not stylistic: a lone degraded statement records no
2627
- // universe and no practices, so there is nothing to compare and identifier
2628
- // resolution there is VACUOUS rather than suppressed-with-content. The
2629
- // judgement below is possible only because the PLAN half says what was
2630
- // promised. Do not "complete the set" by adding a strict arm there — that
2631
- // is precisely the everywhere-block D-04 argues against, arrived at from
2632
- // the other side.
2633
- const planState = traceVaultStateOf(planStatement);
2634
- const summaryState = traceVaultStateOf(summaryStatement);
2635
- if (planState !== 'loaded' || summaryState !== 'loaded') {
2636
- if (strictness !== 'strict')
2637
- return problems;
2638
- const summaryUnproven = summaryState !== 'loaded';
2639
- const planUnproven = planState !== 'loaded';
2640
- // ONE problem per pair, and it RETURNS: a ledger that proved nothing
2641
- // cannot be compared, so continuing into the coverage loop and the
2642
- // staleness check would report three problems for one cause and give an
2643
- // operator three things to chase.
2644
- //
2645
- // The reason quoted is the SUMMARY's when the summary is unproven, and
2646
- // the plan's otherwise — one reason, not two, because both halves
2647
- // degraded is overwhelmingly one environment failing once, and a second
2648
- // bounded artifact value would double the untrusted share of a head that
2649
- // already carries two.
2650
- const reason = summaryUnproven
2651
- ? traceVaultReasonOf(summaryStatement)
2652
- : traceVaultReasonOf(planStatement);
2653
- problems.push({
2654
- // Filed against the UNPROVEN half rather than always the summary, which
2655
- // is this function's only departure from that rule and is TGATE-03's
2656
- // own clause: the artifact to regenerate is the one whose run could not
2657
- // read the vault. With both halves unproven the summary is named, as
2658
- // the artifact whose record was supposed to answer.
2659
- file: summaryUnproven ? file : planFile,
2660
- kind: TRACE_PROBLEM_KIND.COVERAGE_PAIR_UNPROVABLE,
2661
- detail: `${planUnproven && summaryUnproven
2662
- ? 'both halves of this pair record'
2663
- : summaryUnproven
2664
- ? 'this summary records'
2665
- : 'the plan this summary answers records'} no loaded vault, so nothing proves plan ` +
2666
- `"${collapseControlChars(boundedToken(tracePlanIdOf(planStatement)))}" was governed ` +
2667
- `— reason: "${collapseControlChars(boundedToken(reason))}".`,
2668
- // 04-11's mechanism, and the head above contains NO part of the
2669
- // command: this is the fourth remedy-bearing kind, added by the plan
2670
- // that follows the fix for the truncated-remedy bug's third occurrence.
2671
- tail: unprovablePairTail(reason),
2672
- });
2673
- return problems;
2674
- }
2675
- // Built ONCE, then probed per identifier — a scan per id turns a large
2676
- // ledger into a quadratic walk (T-4-22), the same bound the resolution
2677
- // check is written to.
2678
- const covered = new Set(traceEmittedIdsOf(summaryStatement));
2679
- const seen = new Set();
2680
- for (const id of traceEmittedIdsOf(planStatement)) {
2681
- // De-duplicated: a plan that recorded one id twice has a duplication
2682
- // defect with its own kind, and must not also produce two coverage
2683
- // problems for one gap.
2684
- if (seen.has(id))
2685
- continue;
2686
- seen.add(id);
2687
- if (covered.has(id))
2688
- continue;
2689
- // ONE problem PER uncovered identifier, never one aggregate: TGATE-03
2690
- // requires the failure to name the identifier at fault, and a single
2691
- // problem standing for three dropped practices makes the count
2692
- // meaningless.
2693
- problems.push({
2694
- file,
2695
- kind: TRACE_PROBLEM_KIND.COVERAGE_PLAN_UNCOVERED,
2696
- detail: `practice id "${collapseControlChars(boundedToken(id))}" is recorded in the plan trace of ` +
2697
- `${planFile} and appears nowhere in this summary's ledger, so the executor's record does ` +
2698
- `not account for it. A partial overlap is not coverage.`,
2699
- });
2700
- }
2701
- // ── staleness: the recorded timestamps, compared STRICTLY (D-11) ────────
2702
- //
2703
- // Strict on purpose. Equal timestamps are not stale — a plan and a summary
2704
- // generated inside the same clock tick is an ordinary fast run, and
2705
- // blocking it would fail correct records for a clock's resolution.
2706
- // Unparseable on either side yields NO stale problem: an unreadable
2707
- // timestamp is a required-key defect the single-statement validator already
2708
- // owns, and reporting it twice from two places gives an operator two
2709
- // problems for one cause.
2710
- const planAt = Date.parse(traceGeneratedAtOf(planStatement));
2711
- const summaryAt = Date.parse(traceGeneratedAtOf(summaryStatement));
2712
- if (Number.isFinite(planAt) && Number.isFinite(summaryAt) && summaryAt < planAt) {
2713
- problems.push({
2714
- file,
2715
- kind: TRACE_PROBLEM_KIND.COVERAGE_PAIR_STALE,
2716
- // SHORTNESS WAS THE MITIGATION; THE TAIL IS NOW THE MECHANISM (04-11,
2717
- // CR-05). The previous reasoning, quoted verbatim so this reads as the
2718
- // upgrade it is rather than as a loss:
2719
- //
2720
- // "THE DETAIL IS DELIBERATELY SHORT, and the reason is CR-02's, not
2721
- // style. The router bounds every detail at `TRACE_DETAIL_CAP` before
2722
- // it reaches the message, so a detail that runs long loses its TAIL
2723
- // — and the tail is the remedy. A stale-pair block whose remedy was
2724
- // truncated away is exactly the trap D-11 exists to prevent, so the
2725
- // prose names the plan IDENTIFIER rather than repeating the plan's
2726
- // path (the summary's own path is already the problem's `file`),
2727
- // keeping the whole detail inside the cap with the remedy intact."
2728
- //
2729
- // Every sentence of that was true and the detail still overflowed. A
2730
- // budget argument is not a mechanism: the three values interpolated
2731
- // here are read straight out of an artifact, `boundedToken` allows 80
2732
- // characters apiece, V8's `Date.parse` accepts trailing parenthesised
2733
- // text so a 62-character timestamp is trivially reachable, and the
2734
- // measured worst case came to 418 against the 399-character budget. The
2735
- // remedy was the part that fell off.
2736
- //
2737
- // THIS WAS THE THIRD OCCURRENCE OF ONE BUG. 04-07 shipped a
2738
- // 480-character stale-pair detail against the same cap and lost the
2739
- // same remedy; a guard was added. 04-08 built `traceDetail`'s head/tail
2740
- // split and two cap tests. EACH FIX ADDRESSED ONE SITE — 04-08's guard
2741
- // measures the tails the ROUTER writes, and a remedy living in a leaf's
2742
- // HEAD was invisible to it. The prose below is still short, because a
2743
- // message a reader can read is worth having, but shortness no longer
2744
- // protects the command: the command is in the tail, concatenated after
2745
- // the truncation has already happened, so prose and remedy no longer
2746
- // compete for the same 400 characters at all.
2747
- detail: `this summary's trace was generated at ` +
2748
- `"${collapseControlChars(boundedToken(traceGeneratedAtOf(summaryStatement)))}", BEFORE the ` +
2749
- `plan trace it answers at ` +
2750
- `"${collapseControlChars(boundedToken(traceGeneratedAtOf(planStatement)))}", so it records ` +
2751
- `an earlier version of plan ` +
2752
- `"${collapseControlChars(boundedToken(tracePlanIdOf(planStatement)))}".`,
2753
- tail: ` Regenerate the execute-stage trace in place, against the file named here: ` +
2754
- TRACE_REGENERATE_COMMAND,
2755
- });
2756
- }
2757
- return problems;
2758
- }
2759
- catch (e) {
2760
- return [
2761
- {
2762
- file,
2763
- kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
2764
- detail: `the coverage comparison could not complete: ${boundedToken(String(e?.message ?? e))}`,
2765
- },
2766
- ];
2767
- }
2768
- }
2769
- // ─── The body block, read back against the ledger (261003-j89; closes D-05-07) ─
2770
- //
2771
- // A vault-enabled plan carries TWO surfaces: the `vault:` frontmatter LEDGER
2772
- // (the audit record) and the `<vault_practices>` BODY BLOCK (the practice text
2773
- // the executor re-anchors against per task). One CLI emits both from one
2774
- // matcher run, which is what makes a cross-check cheap — and until this section
2775
- // nothing performed it. `docs/vault-trace-format.md` § "The two blocks" records
2776
- // the history: the check was named as Phase 4's, then Phase 5's, then recorded
2777
- // UNASSIGNED (D-05-07) because no requirement named it. The operator's request
2778
- // of 2026-10-03, after the 2026-10-02 probe, is the amendment that assigns it.
2779
- //
2780
- // PURE, like the validator above it: artifact TEXT and the PARSED STATEMENT in,
2781
- // problems out. It opens no file, reads no manifest and runs no matcher. Text
2782
- // is verified against the `textSha256` the ledger RECORDS, never by re-rendering
2783
- // — a second matcher run would make the verdict a function of this machine's
2784
- // vault cache, which D-01 removed from this gate on purpose, and would block
2785
- // every machine whose cache has moved past the pin.
2786
- const PRACTICES_BLOCK_OPEN = '<vault_practices>';
2787
- const PRACTICES_BLOCK_CLOSE = '</vault_practices>';
2788
- /** A sha256 hex digest, and nothing else, is a text digest. */
2789
- const TEXT_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
2790
- /**
2791
- * The body-block family as a SET, so the router's message footer can tell a
2792
- * body remedy from a ledger remedy without spelling the kinds a second time.
2793
- */
2794
- const PRACTICES_BLOCK_KINDS = new Set([
2795
- TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT,
2796
- TRACE_PROBLEM_KIND.PRACTICES_BLOCK_DUPLICATE,
2797
- TRACE_PROBLEM_KIND.PRACTICES_BLOCK_STATE_MISMATCH,
2798
- TRACE_PROBLEM_KIND.PRACTICES_BLOCK_EMPTY,
2799
- TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_MISSING,
2800
- TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_UNEXPECTED,
2801
- TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH,
2802
- ]);
2803
- /**
2804
- * The command that regenerates a body block, named in every body-block remedy.
2805
- * ONE constant, exported, for the reason `TRACE_REGENERATE_COMMAND` is one.
2806
- */
2807
- const PRACTICES_BLOCK_REGENERATE_COMMAND = 'pp_run vault practices match <files_modified...> --block';
2808
- /**
2809
- * The trusted tail every body-block problem carries. Author-written, constant,
2810
- * independent of every fixture input (T-4-43), and measured against the ONE
2811
- * detail cap: 148 characters, leaving the head 251.
2812
- */
2813
- const PRACTICES_BLOCK_TAIL = ` Regenerate the block and embed the output verbatim, from ${PRACTICES_BLOCK_OPEN} to ` +
2814
- `${PRACTICES_BLOCK_CLOSE}: ${PRACTICES_BLOCK_REGENERATE_COMMAND}`;
2815
- /**
2816
- * The normalisation BOTH digest sites apply, so copy drift that changes no
2817
- * character of the practice text — CRLF line endings, trailing whitespace a
2818
- * planner's editor stripped or added, blank lines at either end — does not read
2819
- * as a content change, while any edit to the text itself does.
2820
- */
2821
- function normalizePracticeText(text) {
2822
- return (typeof text === 'string' ? text : '')
2823
- .replace(/\r\n?/g, '\n')
2824
- .split('\n')
2825
- .map((line) => line.replace(/[ \t]+$/, ''))
2826
- .join('\n')
2827
- .trim();
2828
- }
2829
- /** sha256 hex of the normalised practice text. */
2830
- function practiceTextDigest(text) {
2831
- return node_crypto_1.default.createHash('sha256').update(normalizePracticeText(text), 'utf8').digest('hex');
2832
- }
2833
- /**
2834
- * Read the body block(s) out of an artifact.
2835
- *
2836
- * The frontmatter is skipped first: the block is a BODY construct and a `---`
2837
- * document can carry anything in a YAML block scalar. Outside a block, a
2838
- * markdown code fence and an HTML comment are skipped too — a tag quoted as an
2839
- * example or commented out is not a block the executor reads as one, and
2840
- * counting it would let an example satisfy the presence check (plan-checker
2841
- * remark 2 on 261003-j89). Tags are matched at column 0 only, which is where
2842
- * the renderer puts them and where the planner reference says they go; an
2843
- * entry is a fenced region whose FIRST `id:` line
2844
- * precedes its `ask:`/`excerpt:` label — the degraded and refused sections are
2845
- * fenced too, but render `- **id** — …` lines and never an `id:` line, so they
2846
- * are not entries. Everything after the label up to the fence end is the text.
2847
- */
2848
- function parsePracticesBlock(content) {
2849
- const lines = String(content ?? '').replace(/\r\n?/g, '\n').split('\n');
2850
- let start = 0;
2851
- if (lines[0] === '---') {
2852
- const close = lines.indexOf('---', 1);
2853
- if (close > 0)
2854
- start = close + 1;
2855
- }
2856
- const out = { blocks: 0, unterminated: false, entries: [], prose: [] };
2857
- let i = start;
2858
- let inCodeFence = false;
2859
- let inComment = false;
2860
- while (i < lines.length) {
2861
- const head = lines[i].trimEnd();
2862
- if (inComment) {
2863
- if (head.includes('-->'))
2864
- inComment = false;
2865
- i += 1;
2866
- continue;
2867
- }
2868
- if (inCodeFence) {
2869
- if (/^(`{3,}|~{3,})/.test(head))
2870
- inCodeFence = false;
2871
- i += 1;
2872
- continue;
2873
- }
2874
- if (/^(`{3,}|~{3,})/.test(head)) {
2875
- inCodeFence = true;
2876
- i += 1;
2877
- continue;
2878
- }
2879
- if (head.startsWith('<!--') && !head.includes('-->')) {
2880
- inComment = true;
2881
- i += 1;
2882
- continue;
2883
- }
2884
- if (head !== PRACTICES_BLOCK_OPEN) {
2885
- i += 1;
2886
- continue;
2887
- }
2888
- let j = i + 1;
2889
- while (j < lines.length && lines[j].trimEnd() !== PRACTICES_BLOCK_CLOSE)
2890
- j += 1;
2891
- if (j >= lines.length) {
2892
- out.unterminated = true;
2893
- return out;
2894
- }
2895
- out.blocks += 1;
2896
- let cur = null;
2897
- for (let k = i + 1; k < j; k += 1) {
2898
- const t = lines[k].trimEnd();
2899
- if (t === practicesMod.EXTERNAL_CONTENT_FENCE_START) {
2900
- cur = { id: '', labelled: false, text: [] };
2901
- continue;
2902
- }
2903
- if (t === practicesMod.EXTERNAL_CONTENT_FENCE_END) {
2904
- if (cur !== null && cur.id !== '') {
2905
- out.entries.push({ id: cur.id, text: cur.labelled ? cur.text.join('\n') : null });
2906
- }
2907
- cur = null;
2908
- continue;
2909
- }
2910
- if (cur !== null) {
2911
- if (cur.labelled) {
2912
- cur.text.push(lines[k]);
2913
- }
2914
- else if (cur.id === '' && t.startsWith('id: ')) {
2915
- cur.id = t.slice(4).trim();
2916
- }
2917
- else if (cur.id !== '' && (t === 'ask:' || t === 'excerpt:')) {
2918
- cur.labelled = true;
2919
- }
2920
- continue;
2921
- }
2922
- // Prose is what is left: not a heading, not a quote, not an HTML comment
2923
- // (the renderer may stamp the block's first line with a `<!-- … -->`
2924
- // provenance comment; it is neither an entry nor a sentence).
2925
- const bare = t.trim();
2926
- if (bare !== '' && !bare.startsWith('#') && !bare.startsWith('>') && !bare.startsWith('<!--')) {
2927
- out.prose.push(bare);
2928
- }
2929
- }
2930
- i = j + 1;
2931
- }
2932
- return out;
2933
- }
2934
- /** Does a prose line state that the vault itself was never read? */
2935
- function statesVaultNotRead(line) {
2936
- return (line === practicesMod.PRACTICES_VAULT_ABSENT_SENTENCE
2937
- || line === practicesMod.PRACTICES_VAULT_UNREADABLE_SENTENCE
2938
- || line.startsWith('Practices could not be checked'));
2939
- }
2940
- /**
2941
- * Compare ONE plan artifact's `<vault_practices>` body block against its parsed
2942
- * `vault:` ledger statement. Returns problems, never a boolean, on the
2943
- * validator's reasoning; NEVER THROWS, on the validator's reasoning too.
2944
- *
2945
- * THE FIVE QUESTIONS, in order, each one short-circuiting the next:
2946
- * 1. Is there exactly one block? → absent / duplicate
2947
- * 2. Does the block agree with the ledger about whether the vault was READ?
2948
- * A loaded ledger beside a vault-not-read sentence → state_mismatch
2949
- * 3. Does it deliver ANY entry when the ledger promises ≥1? → empty
2950
- * 4. Do the two id sets agree, both ways? → id_missing / id_unexpected
2951
- * 5. For every entry: with a `textSha256` on its ledger line, does the text
2952
- * under `ask:`/`excerpt:` digest to it; without one, is there any text
2953
- * there at all? → text_mismatch
2954
- *
2955
- * An absent block is a defect WHATEVER the ledger lists, including nothing: the
2956
- * block is written in every degraded state too, and `docs/practices-format.md`
2957
- * is explicit that a silent omission is indistinguishable from the capability
2958
- * being off. The `empty` question is asked only when the ledger promises
2959
- * something, because a block that correctly says "none apply" over an empty
2960
- * ledger has delivered exactly what was recorded.
2961
- *
2962
- * Question 5's DIGEST half is SKIPPED, not failed, for a ledger entry without a
2963
- * digest: the key is optional (a minor addition), a ledger written before it
2964
- * existed is a correct record, and blocking it on text alone would halt every
2965
- * phase planned on an earlier engine at ship time. What such an entry still
2966
- * must have is SOME text — the renderer never emits a labelled entry with none,
2967
- * so a blank or label-less entry is a defect whatever the ledger's age. A
2968
- * placeholder like "(see vault)" on a pre-digest ledger is NOT caught, and is
2969
- * recorded as the one shape this check leaves open (plan-checker remark 1).
2970
- * Questions 1–4 need no digest and run on every ledger.
2971
- */
2972
- function validatePracticesBody(content, statement, options) {
2973
- const opts = isPlainObject(options) ? options : {};
2974
- const file = typeof opts.file === 'string' && opts.file !== '' ? opts.file : 'the artifact';
2975
- const problems = [];
2976
- const add = (kind, detail) => {
2977
- problems.push({ file, kind, detail, tail: PRACTICES_BLOCK_TAIL });
2978
- };
2979
- const safe = (v) => collapseControlChars(boundedToken(v));
2980
- try {
2981
- if (opts.stage !== 'plan')
2982
- return problems;
2983
- if (!isPlainObject(statement))
2984
- return problems;
2985
- // The ledger's half: ids in recorded order, de-duplicated (a duplicated id
2986
- // is its own defect with its own kind), and the digest each carries.
2987
- const ledgerIds = [];
2988
- const digests = new Map();
2989
- const predicate = isPlainObject(statement['predicate']) ? statement['predicate'] : {};
2990
- const practices = Array.isArray(predicate['practices']) ? predicate['practices'] : [];
2991
- for (const raw of practices) {
2992
- const entry = isPlainObject(raw) ? raw : {};
2993
- const id = typeof entry['id'] === 'string' ? entry['id'] : '';
2994
- if (id === '' || ledgerIds.includes(id))
2995
- continue;
2996
- ledgerIds.push(id);
2997
- const digest = entry['textSha256'];
2998
- if (typeof digest === 'string' && digest !== '')
2999
- digests.set(id, digest);
3000
- }
3001
- const promised = ledgerIds.length;
3002
- const promisedText = `${promised} practice id${promised === 1 ? '' : 's'}`;
3003
- const parsed = parsePracticesBlock(typeof content === 'string' ? content : '');
3004
- // ── 1. exactly one block ──────────────────────────────────────────────
3005
- if (parsed.unterminated) {
3006
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT, `${file} opens a ${PRACTICES_BLOCK_OPEN} block that is never closed, so no executor can tell ` +
3007
- `where the client's practice text ends; its vault: ledger records ${promisedText}.`);
3008
- return problems;
3009
- }
3010
- if (parsed.blocks === 0) {
3011
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ABSENT, `${file} carries a vault: ledger recording ${promisedText} but no ${PRACTICES_BLOCK_OPEN} block, ` +
3012
- `so the executor that reads this plan receives no practice text at all — the ledger records ` +
3013
- `governance the plan does not deliver.`);
3014
- return problems;
3015
- }
3016
- if (parsed.blocks > 1) {
3017
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_DUPLICATE, `${file} carries ${parsed.blocks} ${PRACTICES_BLOCK_OPEN} blocks where a plan carries exactly one, ` +
3018
- `so which practice text governs this plan is ambiguous.`);
3019
- return problems;
3020
- }
3021
- // ── 2. the block and the ledger agree about whether the vault was READ ─
3022
- //
3023
- // One direction only, and argued: a `degraded` ledger beside a block full
3024
- // of entries is CONSISTENT (a stale cache still matches), and a `degraded`
3025
- // ledger beside the absent sentence is consistent too. The disagreement
3026
- // that can mislead is a block telling the executor the vault was never
3027
- // fetched about a run whose ledger records a loaded pin — the "fake
3028
- // degraded" shape the probe planted. Fires whatever the ledger promises,
3029
- // including nothing: it is a statement about the run, not about a count.
3030
- if (traceVaultStateOf(statement) === 'loaded' && parsed.prose.some(statesVaultNotRead)) {
3031
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_STATE_MISMATCH, `the ${PRACTICES_BLOCK_OPEN} block in ${file} states that the client vault was not read, while ` +
3032
- `its vault: ledger records state "loaded" with ${promisedText}. The block tells the executor ` +
3033
- `nothing was fetched about a run that recorded a pin, so the two halves of this plan's record ` +
3034
- `disagree about what happened.`);
3035
- return problems;
3036
- }
3037
- // ── 3. the block delivers something when the ledger promises something ─
3038
- if (promised > 0 && parsed.entries.length === 0) {
3039
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_EMPTY, `the ${PRACTICES_BLOCK_OPEN} block in ${file} renders no practice entry while its vault: ledger ` +
3040
- `records ${promisedText}. The executor applies what the block delivers, so these practices ` +
3041
- `govern nothing.`);
3042
- return problems;
3043
- }
3044
- // ── 4. the two id sets agree, both ways ───────────────────────────────
3045
- const bodyIds = [];
3046
- for (const e of parsed.entries)
3047
- if (!bodyIds.includes(e.id))
3048
- bodyIds.push(e.id);
3049
- const bodySet = new Set(bodyIds);
3050
- const ledgerSet = new Set(ledgerIds);
3051
- for (const id of ledgerIds) {
3052
- if (bodySet.has(id))
3053
- continue;
3054
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_MISSING, `practice "${safe(id)}" is recorded in the vault: ledger of ${file} and has no entry in its ` +
3055
- `${PRACTICES_BLOCK_OPEN} block, so the executor never sees its text.`);
3056
- }
3057
- for (const id of bodyIds) {
3058
- if (ledgerSet.has(id))
3059
- continue;
3060
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_ID_UNEXPECTED, `the ${PRACTICES_BLOCK_OPEN} block in ${file} carries an entry for practice "${safe(id)}" that its ` +
3061
- `vault: ledger does not record, so the two halves of this plan's record disagree about which ` +
3062
- `practices apply.`);
3063
- }
3064
- // ── 5. the text is the text the matcher rendered — or, without a digest,
3065
- // is at least THERE ────────────────────────────────────────────────
3066
- const checked = new Set();
3067
- for (const e of parsed.entries) {
3068
- if (checked.has(e.id) || !ledgerSet.has(e.id))
3069
- continue;
3070
- checked.add(e.id);
3071
- const want = digests.get(e.id);
3072
- const blank = e.text === null || normalizePracticeText(e.text) === '';
3073
- if (want === undefined) {
3074
- // The digest-free floor. The renderer always emits the label and the
3075
- // text under it, so an entry with neither is not a copy of anything
3076
- // the matcher produced.
3077
- if (!blank)
3078
- continue;
3079
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH, `practice "${safe(e.id)}" in the ${PRACTICES_BLOCK_OPEN} block of ${file} carries no practice ` +
3080
- `text under ask:/excerpt:, so the executor is handed an identifier and nothing to apply.`);
3081
- continue;
3082
- }
3083
- const got = blank ? '' : practiceTextDigest(e.text);
3084
- if (got === want)
3085
- continue;
3086
- add(TRACE_PROBLEM_KIND.PRACTICES_BLOCK_TEXT_MISMATCH, `the text under practice "${safe(e.id)}" in the ${PRACTICES_BLOCK_OPEN} block of ${file} is not ` +
3087
- `the text the matcher rendered when its vault: ledger was written` +
3088
- (e.text === null
3089
- ? ' (the entry carries no ask:/excerpt: section)'
3090
- : ' (it was altered, shortened or replaced)') +
3091
- `, so the executor is not reading the client's practice.`);
3092
- }
3093
- return problems;
3094
- }
3095
- catch (e) {
3096
- return [
3097
- {
3098
- file,
3099
- kind: TRACE_PROBLEM_KIND.VALIDATOR_ERROR,
3100
- detail: `the body-block check could not complete: ${boundedToken(String(e?.message ?? e))}`,
3101
- },
3102
- ];
3103
- }
3104
- }
3105
197
  module.exports = {
3106
- buildTraceStatement,
3107
- serializeTraceBlock,
3108
- renderPracticesApplied,
3109
- parseVaultTraceBlock,
3110
- validateTraceStatement,
3111
- // 04-07 (D-09): the pair comparison. Exported because the RELATION is the
3112
- // router's to compose — the leaf compares one pair at a time and returns
3113
- // problems, and the router enumerates, pairs and folds them into its
3114
- // envelope. Keeping it here rather than in the handler keeps every structural
3115
- // judgement about a trace in the leaf, which is the property that stops a
3116
- // second, drifting implementation from growing in an adapter.
3117
- comparePracticeCoverage,
3118
- // 261003-j89 (closes D-05-07): the body-block check, its digest and its remedy
3119
- // command. The router composes the problems exactly as it composes the
3120
- // validator's; the two digest sites (builder and body check) share
3121
- // `practiceTextDigest`, so the normalisation is written once.
3122
- validatePracticesBody,
3123
- practiceTextDigest,
3124
- PRACTICES_BLOCK_REGENERATE_COMMAND,
3125
- PRACTICES_BLOCK_KINDS,
3126
- TRACE_REQUIRED_KEYS,
3127
- TRACE_PROBLEM_KIND,
3128
- TRACE_BLOCK_KEY,
3129
- TRACE_STATEMENT_TYPE,
3130
- TRACE_PREDICATE_TYPE,
3131
- TRACE_SCHEMA_VERSION,
3132
- TRACE_STATUS_VALUES,
3133
- TRACE_ID_PATTERN,
3134
- // 10-08 (MECH-03, D-10-05): the analyzer-provenance lead. Exported so the
3135
- // test that PINS it against its owner — `AUDIT_ENFORCED_BY_LEAD` in
3136
- // `src/vault-audit.cts` — can compare the two shipped strings rather than a
3137
- // copy of either. See the constant's own comment for why this module holds a
3138
- // second spelling instead of importing the first.
3139
- TRACE_ENFORCED_BY_LEAD,
3140
- // Moved here from src/check-command-router.cts by 04-05 and exported: the
3141
- // validator's missing-universe remedy and the handler's block message must
3142
- // name the SAME command, and the handler already imports this module.
3143
- TRACE_REGENERATE_COMMAND,
3144
- // Quick 261015-at3: the eighth degraded cause's reason, harvested by tests.
3145
- TRACE_REASON_STANDARDS_ONLY,
3146
- // Quick 261015-at3: the degraded empty-ledger render line, harvested by tests.
3147
- TRACE_DEGRADED_RENDER_LINE,
3148
198
  VAULT_OVERRIDE_ENV,
3149
- PRACTICES_APPLIED_HEADING,
3150
- // Additive exports (Phase 04-01): both functions already existed above —
3151
- // this widens the export list ONLY; no function body and no call site
3152
- // changes. Reason: src/check-command-router.cts carries a duplicated
3153
- // override evaluator and sanitiser which plan 04-03 deletes and rewires to
3154
- // THIS pair, and two copies of an override validator would let a future
3155
- // tightening land in one and not the other. `buildOverrideRecord` stays
3156
- // deliberately UNexported — it is reached through `buildTraceStatement`,
3157
- // which owns the `generatedAt` value the override's `when` field reuses,
3158
- // and exporting it would invite the scaffold arm to bypass that seam.
3159
- //
3160
- // Widened again additively by Phase 7 (plan 07-01) with the pair below:
3161
- // `/pp-init`'s reachability-proof deferral (D-07-03, OD-2) is an ATTRIBUTED
3162
- // bypass of exactly the same shape as the trace override, so it reuses this
3163
- // module's reason triad (`validateOverrideReason`, extracted out of
3164
- // `evaluateTraceOverride` for that purpose) and its identity chain
3165
- // (`resolveOverrideIdentity`) rather than growing a second copy in
3166
- // `src/init-contract.cts`. `buildOverrideRecord` STILL stays unexported for
3167
- // the reason stated just above — Phase 7 builds its own record shape from
3168
- // the four `init.probe_*` config keys and never borrows the trace record's.
3169
199
  evaluateTraceOverride,
3170
200
  sanitizeOverrideReason,
3171
201
  validateOverrideReason,
@@ -3173,12 +203,6 @@ module.exports = {
3173
203
  // 15-07 (D-08): the standards block verb's override line. A wrapper that
3174
204
  // stamps its own `when`, so `buildOverrideRecord` itself stays unexported.
3175
205
  buildStandardsOverrideRecord,
3176
- // 16-06 (D-32): what `vault anchor backfill` splices into a practices
3177
- // ledger, and who it records as having done it. One spelling of each.
3178
- traceAnchorLines,
206
+ // 16-06 (D-32): who `vault anchor backfill` records as having done it.
3179
207
  resolveBackfillIdentity,
3180
- // 16-06 (D-12): the scaffold's own pin reader, so `vault door-record` in
3181
- // practices mode records the pin the practices record would, never a third
3182
- // reading of the manifest.
3183
- resolvePin,
3184
208
  };