@dzhechkov/harness-core 0.7.12 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.dz-manifest.json +210 -70
  2. package/README.md +34 -1
  3. package/dist/book-kb.d.ts.map +1 -1
  4. package/dist/book-kb.js +17 -0
  5. package/dist/book-kb.js.map +1 -1
  6. package/dist/feature-adr-checkpoints.d.ts +61 -0
  7. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  8. package/dist/feature-adr-checkpoints.js +116 -2
  9. package/dist/feature-adr-checkpoints.js.map +1 -1
  10. package/dist/index.d.ts +16 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +13 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/loop-blobs.generated.js +2 -2
  15. package/dist/loop-blobs.generated.js.map +1 -1
  16. package/dist/mutation-gate.d.ts +31 -8
  17. package/dist/mutation-gate.d.ts.map +1 -1
  18. package/dist/mutation-gate.js +57 -16
  19. package/dist/mutation-gate.js.map +1 -1
  20. package/dist/named-lock.d.ts.map +1 -1
  21. package/dist/named-lock.js +11 -9
  22. package/dist/named-lock.js.map +1 -1
  23. package/dist/native-dep-probe.d.ts +11 -0
  24. package/dist/native-dep-probe.d.ts.map +1 -0
  25. package/dist/native-dep-probe.js +100 -0
  26. package/dist/native-dep-probe.js.map +1 -0
  27. package/dist/operations.d.ts.map +1 -1
  28. package/dist/operations.js +82 -1
  29. package/dist/operations.js.map +1 -1
  30. package/dist/parser-safe-region.d.ts +47 -0
  31. package/dist/parser-safe-region.d.ts.map +1 -0
  32. package/dist/parser-safe-region.js +47 -0
  33. package/dist/parser-safe-region.js.map +1 -0
  34. package/dist/patterns.d.ts.map +1 -1
  35. package/dist/patterns.js +10 -1
  36. package/dist/patterns.js.map +1 -1
  37. package/dist/profile.d.ts +202 -0
  38. package/dist/profile.d.ts.map +1 -0
  39. package/dist/profile.js +536 -0
  40. package/dist/profile.js.map +1 -0
  41. package/dist/publish.d.ts +5 -3
  42. package/dist/publish.d.ts.map +1 -1
  43. package/dist/publish.js +46 -8
  44. package/dist/publish.js.map +1 -1
  45. package/dist/qe-rounds.d.ts +75 -0
  46. package/dist/qe-rounds.d.ts.map +1 -0
  47. package/dist/qe-rounds.js +174 -0
  48. package/dist/qe-rounds.js.map +1 -0
  49. package/dist/registry.d.ts.map +1 -1
  50. package/dist/registry.js +5 -1
  51. package/dist/registry.js.map +1 -1
  52. package/dist/run-records.d.ts.map +1 -1
  53. package/dist/run-records.js +8 -1
  54. package/dist/run-records.js.map +1 -1
  55. package/dist/setup.d.ts +35 -4
  56. package/dist/setup.d.ts.map +1 -1
  57. package/dist/setup.js +86 -23
  58. package/dist/setup.js.map +1 -1
  59. package/dist/sign.d.ts +29 -24
  60. package/dist/sign.d.ts.map +1 -1
  61. package/dist/sign.js +183 -61
  62. package/dist/sign.js.map +1 -1
  63. package/dist/store-location.d.ts +56 -0
  64. package/dist/store-location.d.ts.map +1 -0
  65. package/dist/store-location.js +56 -0
  66. package/dist/store-location.js.map +1 -0
  67. package/dist/store-lock.d.ts +1 -0
  68. package/dist/store-lock.d.ts.map +1 -1
  69. package/dist/store-lock.js +7 -1
  70. package/dist/store-lock.js.map +1 -1
  71. package/dist/store-merge.d.ts +55 -0
  72. package/dist/store-merge.d.ts.map +1 -0
  73. package/dist/store-merge.js +77 -0
  74. package/dist/store-merge.js.map +1 -0
  75. package/dist/teach-target.d.ts +61 -0
  76. package/dist/teach-target.d.ts.map +1 -0
  77. package/dist/teach-target.js +105 -0
  78. package/dist/teach-target.js.map +1 -0
  79. package/dist/vector-tier.d.ts.map +1 -1
  80. package/dist/vector-tier.js +63 -20
  81. package/dist/vector-tier.js.map +1 -1
  82. package/package.json +13 -13
  83. package/sbom.json +424 -70
  84. package/src/book-kb.ts +17 -0
  85. package/src/feature-adr-checkpoints.ts +111 -2
  86. package/src/index.ts +39 -1
  87. package/src/loop-blobs.generated.ts +2 -2
  88. package/src/mutation-gate.ts +74 -17
  89. package/src/named-lock.ts +10 -8
  90. package/src/native-dep-probe.ts +118 -0
  91. package/src/operations.ts +77 -1
  92. package/src/parser-safe-region.ts +65 -0
  93. package/src/patterns.ts +10 -1
  94. package/src/profile.ts +593 -0
  95. package/src/publish.ts +43 -7
  96. package/src/qe-rounds.ts +226 -0
  97. package/src/registry.ts +5 -1
  98. package/src/run-records.ts +9 -1
  99. package/src/setup.ts +87 -23
  100. package/src/sign.ts +180 -60
  101. package/src/store-location.ts +76 -0
  102. package/src/store-lock.ts +8 -1
  103. package/src/store-merge.ts +91 -0
  104. package/src/teach-target.ts +126 -0
  105. package/src/vector-tier.ts +58 -9
package/src/sign.ts CHANGED
@@ -14,11 +14,12 @@
14
14
 
15
15
  import { createHash, sign as cryptoSign, verify as cryptoVerify, createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
16
16
  import { readFileSync, existsSync, readdirSync, openSync, fstatSync, closeSync, constants as fsConstants } from 'node:fs';
17
- import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
17
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
18
18
 
19
19
  export const MANIFEST_NAME = '.dz-manifest.json';
20
20
  export const SBOM_NAME = 'sbom.json';
21
- export const MANIFEST_VERSION = 1;
21
+ /** v3 preserves order-sensitive package.json condition maps while canonicalising packer noise. */
22
+ export const MANIFEST_VERSION = 3;
22
23
 
23
24
  export interface ManifestEntry {
24
25
  readonly path: string;
@@ -48,6 +49,16 @@ export interface VerifyResult {
48
49
  }
49
50
 
50
51
 
52
+ /** CycloneDX 1.5 JSON for current output. Canonical JSON digests are always explicitly labelled. */
53
+ export function buildSbom(manifest: Manifest): Sbom {
54
+ return buildSbomEnvelope(manifest, false);
55
+ }
56
+
57
+ /** Compatibility-only decoder target for v1 packs that historically mislabeled package.json. */
58
+ function buildLegacySbomV1(manifest: Manifest): Sbom {
59
+ return buildSbomEnvelope(manifest, true);
60
+ }
61
+
51
62
  /**
52
63
  * Files the PACKER REWRITES, so their bytes are not stable across two packs of the same unchanged
53
64
  * tree — hashing them raw makes a signature that can never verify.
@@ -59,10 +70,12 @@ export interface VerifyResult {
59
70
  * the publisher packs again, and the consumer receives the second one.
60
71
  *
61
72
  * These files are therefore hashed in a CANONICAL form — parsed, keys sorted, stable separators.
62
- * HONEST LIMIT: a pure reordering of keys becomes invisible to the signature. It changes nothing a
63
- * consumer can observe, and the alternative excusing `package.json` from signing altogether — would
73
+ * HONEST LIMIT: key order, insignificant whitespace, and negative-zero spelling become invisible to
74
+ * the signature. JavaScript can distinguish negative zero with `Object.is`, but JSON/pnpm round-trips
75
+ * normalize it to zero; tolerating that packer rewrite is explicit here. The alternative — excusing
76
+ * `package.json` from signing altogether — would
64
77
  * leave the dependency pins and the bin map unsigned, which is the opposite of a fix. The two
65
- * differences that a consumer COULD observe — duplicate keys and precision-losing integers — are
78
+ * differences that remain unsafe across parsers — duplicate keys and precision-losing/non-finite numbers — are
66
79
  * refused canonicalisation outright; see `canonicalisationIsFaithful`.
67
80
  */
68
81
  const CANONICALISED_PACK_FILES: ReadonlySet<string> = new Set(['package.json']);
@@ -77,6 +90,28 @@ function stableJson(value: unknown): string {
77
90
  return JSON.stringify(value) ?? 'null';
78
91
  }
79
92
 
93
+ /**
94
+ * v3 package.json canonical form. pnpm may reorder ordinary object keys, but Node's conditional
95
+ * `exports`/`imports` objects are first-match maps and TypeScript consumers can likewise observe
96
+ * `typesVersions` order. Preserve every descendant object order under those roots while sorting
97
+ * ordinary metadata keys. This authenticates entry-point semantics without reintroducing pnpm's
98
+ * nondeterministic dependency-key ordering.
99
+ */
100
+ const ORDER_SENSITIVE_PACKAGE_ROOTS = new Set(['exports', 'imports', 'typesVersions']);
101
+ function stablePackageJsonV2(value: unknown, preserveOrder = false, atRoot = true): string {
102
+ if (Array.isArray(value)) return '[' + value.map((item) => stablePackageJsonV2(item, preserveOrder, false)).join(',') + ']';
103
+ if (value !== null && typeof value === 'object') {
104
+ const record = value as Record<string, unknown>;
105
+ const keys = Object.keys(record);
106
+ if (!preserveOrder) keys.sort();
107
+ return '{' + keys.map((key) => {
108
+ const childPreservesOrder = preserveOrder || (atRoot && ORDER_SENSITIVE_PACKAGE_ROOTS.has(key));
109
+ return JSON.stringify(key) + ':' + stablePackageJsonV2(record[key], childPreservesOrder, false);
110
+ }).join(',') + '}';
111
+ }
112
+ return JSON.stringify(value) ?? 'null';
113
+ }
114
+
80
115
  /**
81
116
  * Is this JSON text one whose PARSED value is a faithful stand-in for its BYTES?
82
117
  *
@@ -87,13 +122,28 @@ function stableJson(value: unknown): string {
87
122
  * - DUPLICATE KEYS. `{"bin":"evil","bin":"signed"}` parses to the signed value in every JavaScript
88
123
  * parser (last wins), so it would hash as clean — while a first-wins parser in another language
89
124
  * reads `evil` from the same signed bytes.
90
- * - LOSSY INTEGERS. `9007199254740993` parses to `...992`, so two different byte strings share one
91
- * canonical form.
125
+ * - LOSSY NUMBERS. `9007199254740993` parses to `...992`, a long decimal can collapse to a shorter
126
+ * value, and `1e999` becomes `Infinity` before JSON serialization turns it into `null`.
92
127
  *
93
128
  * Neither is reachable through `pnpm pack` — this is a guard against a hand-crafted artifact, not
94
- * against the packer. On either, the caller hashes the RAW bytes instead, so an injected duplicate
95
- * mismatches a canonical signature and the pack reads TAMPERED.
129
+ * against the packer. Current/v3 generation refuses either form instead of producing an SBOM that
130
+ * could mislabel a raw-byte fallback as a canonical digest. Consumer verification returns a mismatch
131
+ * for the same refusal because its no-follow hashing wrapper maps this throw to `null`.
96
132
  */
133
+ function normalizedJsonNumber(literal: string): string | null {
134
+ const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal);
135
+ if (!match || !Number.isFinite(Number(literal))) return null;
136
+ const negative = match[1] === '-';
137
+ const fraction = match[3] ?? '';
138
+ let digits = `${match[2]}${fraction}`.replace(/^0+/, '');
139
+ // pnpm/JSON round-trips collapse every mathematical negative zero spelling to `0`.
140
+ if (digits === '') return '0';
141
+ let exponent = Number(match[4] ?? '0') - fraction.length;
142
+ if (!Number.isSafeInteger(exponent)) return null;
143
+ while (digits.endsWith('0')) { digits = digits.slice(0, -1); exponent += 1; }
144
+ return `${negative ? '-' : ''}${digits}e${exponent}`;
145
+ }
146
+
97
147
  function canonicalisationIsFaithful(text: string): boolean {
98
148
  const scopes: Array<Set<string> | null> = []; // Set = inside an object, null = inside an array
99
149
  let i = 0;
@@ -126,9 +176,11 @@ function canonicalisationIsFaithful(text: string): boolean {
126
176
  const start = i;
127
177
  while (i < text.length && /[-+0-9eE.]/.test(text.charAt(i))) i++;
128
178
  const lit = text.slice(start, i);
129
- // Only plain integers can lose precision silently; `1.0` and `1e0` spell the same value in
130
- // every parser, so they are legitimate canonical equivalences, not a differential.
131
- if (/^-?\d+$/.test(lit) && String(Number(lit)) !== lit) return false;
179
+ // Compare exact decimal values after removing insignificant spelling differences. This keeps
180
+ // `1`, `1.0`, and `1e0` equivalent while rejecting IEEE-754 rounding, overflow and underflow.
181
+ const exact = normalizedJsonNumber(lit);
182
+ const parsed = normalizedJsonNumber(String(Number(lit)));
183
+ if (exact === null || parsed === null || exact !== parsed) return false;
132
184
  continue;
133
185
  }
134
186
  i++;
@@ -138,26 +190,32 @@ function canonicalisationIsFaithful(text: string): boolean {
138
190
 
139
191
  /**
140
192
  * Hash the bytes of one pack file. A packer-rewritten file is hashed canonically; everything else
141
- * byte-for-byte. Unparseable JSON falls back to the RAW bytes rather than passing — a corrupt
142
- * `package.json` must be a mismatch, never a free pass.
193
+ * byte-for-byte. Unparseable, ambiguous, or precision-losing root `package.json` bytes are refused:
194
+ * a current/v3 signer must never emit a raw digest under a canonical-digest SBOM label.
143
195
  */
144
- export function hashPackBytes(relPath: string, bytes: Buffer): string {
196
+ export function hashPackBytes(relPath: string, bytes: Buffer, manifestVersion = MANIFEST_VERSION): string {
145
197
  const rel = relPath.split(sep).join('/');
146
198
  if (CANONICALISED_PACK_FILES.has(rel)) {
147
199
  try {
148
200
  const text = bytes.toString('utf-8');
149
201
  if (!canonicalisationIsFaithful(text)) throw new Error('not canonicalisable');
150
- return createHash('sha256').update(Buffer.from(stableJson(JSON.parse(text)), 'utf-8')).digest('hex');
151
- } catch {
152
- /* fall through to the raw bytes */
202
+ const parsed = JSON.parse(text) as unknown;
203
+ const canonical = manifestVersion >= 3 ? stablePackageJsonV2(parsed) : stableJson(parsed);
204
+ return createHash('sha256').update(Buffer.from(canonical, 'utf-8')).digest('hex');
205
+ } catch (error) {
206
+ throw new Error(`refusing non-canonicalisable ${rel}: ${String((error as Error)?.message ?? error)}`);
153
207
  }
154
208
  }
155
209
  return createHash('sha256').update(bytes).digest('hex');
156
210
  }
157
211
 
158
- /** sha256 of a file's bytes, hex. Follows symlinks — used only when building a manifest we control. */
159
- export function hashFile(absPath: string, relPath?: string): string {
160
- return hashPackBytes(relPath ?? basename(absPath), readFileSync(absPath));
212
+ /**
213
+ * sha256 of a file's bytes, hex. Follows symlinks — used only when building a manifest we control.
214
+ * Canonical pack rewriting is opt-in through the explicit root-relative path. Omitting `relPath`
215
+ * hashes raw bytes, so an unrelated nested file merely named `package.json` is never relaxed.
216
+ */
217
+ export function hashFile(absPath: string, relPath?: string, manifestVersion = MANIFEST_VERSION): string {
218
+ return hashPackBytes(relPath ?? '', readFileSync(absPath), manifestVersion);
161
219
  }
162
220
 
163
221
  /**
@@ -167,14 +225,14 @@ export function hashFile(absPath: string, relPath?: string): string {
167
225
  *
168
226
  * Returns `null` when the path is not a regular file, or is a symlink.
169
227
  */
170
- export function hashRegularFileNoFollow(absPath: string, relPath?: string): string | null {
228
+ export function hashRegularFileNoFollow(absPath: string, relPath?: string, manifestVersion = MANIFEST_VERSION): string | null {
171
229
  let fd: number | undefined;
172
230
  try {
173
231
  fd = openSync(absPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
174
232
  if (!fstatSync(fd).isFile()) return null;
175
233
  // Canonicalise the bytes ALREADY READ from this descriptor — never re-open, or the TOCTOU
176
234
  // window this function exists to close would be reopened by the fix.
177
- return hashPackBytes(relPath ?? basename(absPath), readFileSync(fd));
235
+ return hashPackBytes(relPath ?? '', readFileSync(fd), manifestVersion);
178
236
  } catch {
179
237
  return null; // ELOOP on a symlink, ENOENT, EACCES — all fail closed
180
238
  } finally {
@@ -182,6 +240,24 @@ export function hashRegularFileNoFollow(absPath: string, relPath?: string): stri
182
240
  }
183
241
  }
184
242
 
243
+ /** Read one regular file through the same no-follow descriptor used by hashing. */
244
+ export function readRegularFileNoFollow(absPath: string, expectedLength?: number | readonly number[]): Buffer | null {
245
+ let fd: number | undefined;
246
+ try {
247
+ fd = openSync(absPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
248
+ const stat = fstatSync(fd);
249
+ const allowedLengths = Array.isArray(expectedLength) ? expectedLength : expectedLength === undefined ? null : [expectedLength];
250
+ if (!stat.isFile() || (allowedLengths !== null && !allowedLengths.includes(stat.size))) return null;
251
+ const bytes = readFileSync(fd);
252
+ if (allowedLengths !== null && !allowedLengths.includes(bytes.length)) return null;
253
+ return bytes;
254
+ } catch {
255
+ return null;
256
+ } finally {
257
+ if (fd !== undefined) closeSync(fd);
258
+ }
259
+ }
260
+
185
261
  /**
186
262
  * Cross-model security review (codex exec, 2026-07-10) — a SIGNED manifest is not a TRUSTED manifest.
187
263
  * Its own contents are attacker-controlled until the signature checks out, and even then they must be
@@ -224,31 +300,49 @@ export function checkManifestEntries(files: unknown): string | null {
224
300
  }
225
301
 
226
302
  /**
227
- * Directory names that are NOT pack content and are excluded from BOTH walks. node_modules/.git:
228
- * installed deps / VCS metadata. `.agentic-qe`/`.dz`: LOCAL RUNTIME STATE (learning DBs, RVF
303
+ * State-entry names that may be excluded from BOTH walks. node_modules/.git:
304
+ * installed deps / VCS metadata. `.agentic-qe`/`.dz`: LOCAL RUNTIME STATE entries (learning DBs, RVF
229
305
  * stores, witness keys) that background workers create inside pack cwds — gitignored, machine-
230
306
  * specific, never shipped. Signing them made 19 packs verify green LOCALLY (files present, hashes
231
307
  * match) and TAMPERED in CI (clean checkout lacks the gitignored state) — found live on GH run
232
308
  * #103, 2026-07-19. State is not content; the walks must never see it.
233
309
  */
234
- const EXCLUDED_DIRS = new Set(['node_modules', '.git', '.agentic-qe', '.dz']);
310
+ const EXCLUDED_STATE_NAMES = new Set(['node_modules', '.git', '.agentic-qe', '.dz']);
311
+
312
+ function isExcludedStateEntry(entry: {
313
+ name: string;
314
+ isDirectory(): boolean;
315
+ isSymbolicLink(): boolean;
316
+ }): boolean {
317
+ if (!EXCLUDED_STATE_NAMES.has(entry.name)) return false;
318
+ // A worktree's `.git` is commonly a regular pointer file, and workspace/local-state entries may be
319
+ // symlinks. They remain VCS/dependency/runtime state. A same-named ordinary file other than `.git`
320
+ // is package content and must stay visible.
321
+ if (entry.name === '.git') return true;
322
+ return entry.isDirectory() || entry.isSymbolicLink();
323
+ }
324
+
325
+ /** Generated metadata FILES are self-excluded only at the pack root; directories remain content. */
326
+ function isRootGeneratedMetadata(relDir: string, name: string, isFile: boolean): boolean {
327
+ return isFile && relDir === '' && (name === MANIFEST_NAME || name === SBOM_NAME);
328
+ }
235
329
 
236
330
  /** Every file under `root`, POSIX-relative, excluding the manifest and the SBOM themselves. */
237
331
  export function listPackFiles(root: string): string[] {
238
332
  const out: string[] = [];
239
333
  const walk = (dir: string, rel: string): void => {
240
334
  for (const e of readdirSync(dir, { withFileTypes: true })) {
241
- // Excluded dirs are unsigned territory BY DESIGN (see EXCLUDED_DIRS). The SIGN and VERIFY
335
+ // Role-classified state entries are unsigned territory BY DESIGN (see EXCLUDED_STATE_NAMES). The SIGN and VERIFY
242
336
  // walks MUST share these exclusions: an asymmetry here false-TAMPERs every pnpm workspace
243
337
  // pack whose node_modules holds symlinks (found live arming task #36 — 10 of 23 packs).
244
- if (EXCLUDED_DIRS.has(e.name)) continue;
245
- if (e.name === MANIFEST_NAME || e.name === SBOM_NAME) continue;
338
+ if (isExcludedStateEntry(e)) continue;
339
+ if (isRootGeneratedMetadata(rel, e.name, e.isFile())) continue;
246
340
  const abs = join(dir, e.name);
247
341
  const r = rel ? rel + '/' + e.name : e.name;
248
342
  if (e.isDirectory()) walk(abs, r);
249
- // NOT else-isFile: a symlink (or other non-file) OUTSIDE the excluded dirs must stay VISIBLE to the
343
+ // NOT else-isFile: a symlink (or other non-file) OUTSIDE role-classified state must stay VISIBLE to the
250
344
  // verify sweep — it fails as "present but not signed" / "is a symlink" (R3-4: a smuggled symlink is a
251
- // finding, not something to silently ignore). Only EXCLUDED_DIRS are exempt territory.
345
+ // finding, not something to silently ignore). Only EXCLUDED_STATE_NAMES entries are exempt territory.
252
346
  else out.push(r);
253
347
  }
254
348
  };
@@ -265,8 +359,8 @@ export function listSignablePackFiles(root: string): string[] {
265
359
  const out: string[] = [];
266
360
  const walk = (dir: string, rel: string): void => {
267
361
  for (const e of readdirSync(dir, { withFileTypes: true })) {
268
- if (EXCLUDED_DIRS.has(e.name)) continue;
269
- if (e.name === MANIFEST_NAME || e.name === SBOM_NAME) continue;
362
+ if (isExcludedStateEntry(e)) continue;
363
+ if (isRootGeneratedMetadata(rel, e.name, e.isFile())) continue;
270
364
  const abs = join(dir, e.name);
271
365
  const r = rel ? rel + '/' + e.name : e.name;
272
366
  if (e.isDirectory()) walk(abs, r);
@@ -356,19 +450,6 @@ export function verifyManifest(
356
450
  root: string,
357
451
  signed: SignedManifest | null | undefined,
358
452
  pubKeyPem: string,
359
- /**
360
- * The paths the pack actually SHIPS, when the caller can establish them (from `npm pack`). The
361
- * added-file sweep is then scoped to those, because a working-tree file `files[]` excludes was
362
- * never "added to the pack" — it simply is not part of it.
363
- *
364
- * Omit it and the sweep covers the whole tree, exactly as before. That is correct for the case that
365
- * matters most: a consumer verifying an EXTRACTED tarball, where the tree IS the shipped set.
366
- *
367
- * This parameter exists because the signer and the sweep must never disagree about what a pack
368
- * contains — the 10-false-TAMPERED lesson, recorded at `packFiles` and re-learned on 2026-08-21 the
369
- * moment the signer started scoping and the sweep did not.
370
- */
371
- shippedPaths?: readonly string[],
372
453
  ): VerifyResult {
373
454
  const fail = (path: string, reason: string): VerifyResult => ({ ok: false, failures: [{ path, reason }] });
374
455
 
@@ -378,6 +459,9 @@ export function verifyManifest(
378
459
  if (typeof manifest.version !== 'number' || !Number.isInteger(manifest.version)) {
379
460
  return fail(MANIFEST_NAME, 'manifest version is not an integer');
380
461
  }
462
+ if (![1, 2, MANIFEST_VERSION].includes(manifest.version)) {
463
+ return fail(MANIFEST_NAME, `unsupported manifest version ${manifest.version}`);
464
+ }
381
465
  if (!isSafeManifestPath(manifest.pack)) {
382
466
  return fail(MANIFEST_NAME, 'manifest pack name is unsafe: ' + JSON.stringify(manifest.pack));
383
467
  }
@@ -405,6 +489,8 @@ export function verifyManifest(
405
489
  if (!sigOk) return fail(MANIFEST_NAME, 'signature does not verify against the pinned key');
406
490
 
407
491
  const failures: VerifyFailure[] = [];
492
+ const sbomFailure = verifySbomAgainstManifest(root, manifest);
493
+ if (sbomFailure !== null) failures.push({ path: SBOM_NAME, reason: sbomFailure });
408
494
  for (const entry of manifest.files) {
409
495
  const abs = join(root, entry.path);
410
496
  if (!existsSync(abs)) {
@@ -412,9 +498,9 @@ export function verifyManifest(
412
498
  continue;
413
499
  }
414
500
  // One open, O_NOFOLLOW, fstat the descriptor, hash from it: no symlink follow, no TOCTOU window.
415
- const digest = hashRegularFileNoFollow(abs, entry.path);
501
+ const digest = hashRegularFileNoFollow(abs, entry.path, manifest.version);
416
502
  if (digest === null) {
417
- failures.push({ path: entry.path, reason: 'is a symlink or not a regular file — refusing to hash it' });
503
+ failures.push({ path: entry.path, reason: 'is a symlink, not a regular file, unreadable, or outside the canonical JSON domain — refusing to hash it' });
418
504
  continue;
419
505
  }
420
506
  if (digest !== entry.sha256) {
@@ -423,14 +509,10 @@ export function verifyManifest(
423
509
  }
424
510
 
425
511
  // Bidirectional, always: hashing only what the manifest lists lets an attacker ADD a file.
426
- const shipped = shippedPaths === undefined ? null : new Set(shippedPaths);
427
512
  const present = listPackFiles(root);
428
513
  const listed = new Set(manifest.files.map((f) => f.path));
429
514
  for (const rel of present) {
430
515
  const p = rel.split(sep).join('/');
431
- // A file the pack does not ship is not an ADDED file; scoping here is what keeps the sweep and the
432
- // signer describing the same object.
433
- if (shipped !== null && !shipped.has(p)) continue;
434
516
  if (!listed.has(p)) failures.push({ path: p, reason: 'present in the pack but not signed' });
435
517
  }
436
518
 
@@ -467,7 +549,10 @@ export function assertKeyOutsideTree(keyPath: string, repoRoot: string): void {
467
549
  export interface SbomComponent {
468
550
  readonly type: 'file';
469
551
  readonly name: string;
470
- readonly hashes: readonly { readonly alg: 'SHA-256'; readonly content: string }[];
552
+ /** Raw file-byte digests only. A canonical JSON verification digest must never masquerade here. */
553
+ readonly hashes?: readonly { readonly alg: 'SHA-256'; readonly content: string }[];
554
+ /** Explicit verification-digest metadata for packer-rewritten JSON such as package.json. */
555
+ readonly properties?: readonly { readonly name: string; readonly value: string }[];
471
556
  }
472
557
 
473
558
  export interface Sbom {
@@ -478,21 +563,56 @@ export interface Sbom {
478
563
  readonly components: readonly SbomComponent[];
479
564
  }
480
565
 
481
- /** CycloneDX 1.5 JSON, hand-built it is JSON against a schema, not a reason for a dependency. */
482
- export function buildSbom(manifest: Manifest): Sbom {
566
+ function buildSbomEnvelope(manifest: Manifest, legacyV1PackageHash: boolean): Sbom {
483
567
  return {
484
568
  bomFormat: 'CycloneDX',
485
569
  specVersion: '1.5',
486
570
  version: 1,
487
571
  metadata: { component: { type: 'library', name: manifest.pack } },
488
- components: manifest.files.map((f) => ({
489
- type: 'file' as const,
490
- name: f.path,
491
- hashes: [{ alg: 'SHA-256' as const, content: f.sha256 }],
492
- })),
572
+ components: [...manifest.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)).map((f) =>
573
+ !legacyV1PackageHash && CANONICALISED_PACK_FILES.has(f.path)
574
+ ? {
575
+ type: 'file' as const,
576
+ name: f.path,
577
+ properties: [
578
+ { name: 'dz:digest-basis', value: manifest.version >= 3
579
+ ? 'package-json-ordered-conditions-v2' : 'recursive-key-sorted-json-v1' },
580
+ { name: manifest.version >= 3
581
+ ? 'dz:canonical-json-sha256-v2' : 'dz:canonical-json-sha256-v1', value: f.sha256 },
582
+ ],
583
+ }
584
+ : {
585
+ type: 'file' as const,
586
+ name: f.path,
587
+ hashes: [{ alg: 'SHA-256' as const, content: f.sha256 }],
588
+ }),
493
589
  };
494
590
  }
495
591
 
592
+ /**
593
+ * ADR-003: SBOM authenticity is acyclic. The SBOM is not self-hashed; instead its sole canonical
594
+ * value is derived from the already signature-authenticated manifest and compared byte-for-byte.
595
+ */
596
+ export function verifySbomAgainstManifest(root: string, manifest: Manifest): string | null {
597
+ if (![1, 2, MANIFEST_VERSION].includes(manifest.version)) {
598
+ return `unsupported manifest version ${manifest.version}`;
599
+ }
600
+ const expected = JSON.stringify(buildSbom(manifest), null, 2) + '\n';
601
+ const accepted = manifest.version === 1
602
+ ? [expected, JSON.stringify(buildLegacySbomV1(manifest), null, 2) + '\n']
603
+ : [expected];
604
+ const bytes = readRegularFileNoFollow(join(root, SBOM_NAME), [...new Set(accepted.map((value) => Buffer.byteLength(value)))]);
605
+ if (bytes === null) return 'missing, symlinked, not a regular file, or has a non-canonical byte length';
606
+ const actual = bytes.toString('utf8');
607
+ if (!canonicalisationIsFaithful(actual)) return 'JSON is malformed, ambiguous, or precision-losing';
608
+ try {
609
+ JSON.parse(actual);
610
+ } catch {
611
+ return 'JSON is malformed';
612
+ }
613
+ return accepted.includes(actual) ? null : 'content does not equal canonical CycloneDX derived from the signed manifest';
614
+ }
615
+
496
616
  // ── The publish gate (FR-4), as a pure decision ─────────────────────────────
497
617
  //
498
618
  // The operator's decision was "the publish gate BLOCKS". Reality intervened: with no trust root
@@ -0,0 +1,76 @@
1
+ /**
2
+ * store-location — one sentence about WHERE a learning store lives, used by every command that
3
+ * touches one.
4
+ *
5
+ * MEASURED 2026-08-27: `dz teach` and `dz recall` both resolve their store as
6
+ * `resolve(cwd, --project ?? '.')` (`cli.ts:2882`, `:3450`) — plain cwd, no git-root walk, no
7
+ * upward search, and no config key or environment variable to pin a canonical root. Neither
8
+ * command printed the path.
9
+ *
10
+ * So a user who runs `dz teach` in eight project directories gets eight isolated stores and
11
+ * believes they are accumulating. The path alone does not reveal that: `/home/u/proj/.dz` looks
12
+ * equally deliberate whether it was chosen or defaulted into. **That difference is the defect**,
13
+ * so the line names the SOURCE, not only the location.
14
+ *
15
+ * This ships BEFORE the intended per-session mode on purpose. A mode set once and forgotten lies
16
+ * silently, and a mode is only safe when its effect is visible on every operation it governs.
17
+ */
18
+
19
+ import { join } from 'node:path';
20
+
21
+ /**
22
+ * Who decided this path. `global` is separate from `cwd`/`explicit` because the advice differs:
23
+ * "pass --project" is useful for a cwd-derived store and MISLEADING for the home store, which
24
+ * neither the cwd nor --project chose (cross-family QE, 2026-08-27: `--project /tmp/p --to global`
25
+ * printed the right path under the wrong explanation).
26
+ */
27
+ export type StoreOrigin = 'explicit' | 'cwd' | 'global';
28
+
29
+ export interface StoreLocation {
30
+ /** Absolute path of the store directory — `<projectRoot>/.dz`. */
31
+ readonly path: string;
32
+ /** Where the project root came from: an explicit `--project`, or the current directory. */
33
+ readonly origin: StoreOrigin;
34
+ }
35
+
36
+ /**
37
+ * Describe the store a command is about to use.
38
+ *
39
+ * `projectOption` is the RAW `--project` value as the user typed it (or `undefined`), and
40
+ * `resolvedRoot` is what the command already computed. Both are taken rather than re-resolving,
41
+ * so this helper can never disagree with the command about which directory was chosen — a second,
42
+ * independent resolution is exactly how two lookalike code paths come to name different stores.
43
+ */
44
+ export function describeStoreLocation(
45
+ resolvedRoot: string,
46
+ projectOption: string | undefined,
47
+ /**
48
+ * Overrides the derived origin when the caller KNOWS what chose the path — today only the
49
+ * cross-project home store, which neither the cwd nor `--project` selected. Omit it and the
50
+ * origin is derived exactly as before, so every existing call is unchanged.
51
+ */
52
+ origin?: StoreOrigin,
53
+ ): StoreLocation {
54
+ return {
55
+ path: join(resolvedRoot, '.dz'),
56
+ origin: origin ?? (projectOption === undefined ? 'cwd' : 'explicit'),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * The one human line, shared by every command.
62
+ *
63
+ * Two commands writing this sentence independently is how they come to disagree about what a store
64
+ * is — the lookalike-normalisation class this repo has already paid for once.
65
+ *
66
+ * `verb` is what the command did with the store, so the line reads naturally in both directions:
67
+ * "store (written): …" and "store (read): …".
68
+ */
69
+ export function storeLocationLine(loc: StoreLocation, verb: 'written' | 'read'): string {
70
+ const because = loc.origin === 'explicit'
71
+ ? 'from --project'
72
+ : loc.origin === 'global'
73
+ ? 'the cross-project store in your home directory'
74
+ : 'from the current directory — pass --project to choose deliberately';
75
+ return ` store (${verb}): ${loc.path} [${because}]`;
76
+ }
package/src/store-lock.ts CHANGED
@@ -68,6 +68,8 @@ export const LOCK_TIMEOUT_MS = 10_000;
68
68
 
69
69
  /** `proper-lockfile` silently clamps `stale` up to this minimum. */
70
70
  const MIN_STALE_MS = 2_000;
71
+ /** Highest accepted environment override; keeps proper-lockfile's heartbeat timer in range. */
72
+ const MAX_STALE_MS = 600_000;
71
73
 
72
74
  /** Path of the lock guarding a project's pattern store (a DIRECTORY when held). */
73
75
  export function storeLockPath(projectRoot: string): string {
@@ -115,6 +117,11 @@ const defaultTimeoutMs = (): number => {
115
117
  return Number.isFinite(env) && env > 0 ? env : LOCK_TIMEOUT_MS;
116
118
  };
117
119
 
120
+ export function resolveStaleMs(): number {
121
+ const env = Number(process.env['DZ_STORE_LOCK_STALE_MS']);
122
+ return Number.isFinite(env) && env > 0 && env <= MAX_STALE_MS ? env : STALE_LOCK_MS;
123
+ }
124
+
118
125
  const sleep = (ms: number): Promise<void> => new Promise((r) => { setTimeout(r, ms); });
119
126
 
120
127
  /** Synchronous sleep without spinning (Atomics.wait on a throwaway buffer). */
@@ -176,7 +183,7 @@ function tryAcquire(projectRoot: string, staleMs: number, onCompromised: (e: Err
176
183
 
177
184
  function resolveOpts(opts: StoreLockOptions): { staleMs: number; timeoutMs: number; pollMs: number } {
178
185
  return {
179
- staleMs: Math.max(opts.staleMs ?? STALE_LOCK_MS, MIN_STALE_MS),
186
+ staleMs: Math.max(opts.staleMs ?? resolveStaleMs(), MIN_STALE_MS),
180
187
  timeoutMs: opts.timeoutMs ?? defaultTimeoutMs(),
181
188
  pollMs: opts.pollMs ?? 25,
182
189
  };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * store-merge — read two learning stores together, and never merge them on disk.
3
+ *
4
+ * The design comes from our own shipped precedent, `learning_bridge.py:23`:
5
+ *
6
+ * "the compounding objection was answered by making RECALL read both stores rather than by
7
+ * merging them"
8
+ *
9
+ * and `:880`: "two stores are different stores, whatever mechanism might have made them the same."
10
+ *
11
+ * That precedent records an ASYMMETRY, and it is the whole design here: WRITING needs a choice —
12
+ * is this lesson about this project, or general? — while READING almost never does. You want your
13
+ * project's specifics AND your accumulated expertise. So recall reads both, always, with no mode:
14
+ * a mode here would only add a way to be wrong, and a mode set once and forgotten lies silently.
15
+ *
16
+ * The cross-project store is not a new kind of thing. MEASURED: `~/.dz` already exists, and
17
+ * `loadPatterns(projectRoot)` already takes a root — so the global store is simply a project store
18
+ * whose root is the home directory. Same code, same format, nothing to migrate.
19
+ */
20
+
21
+ import { homedir } from 'node:os';
22
+ import { resolve } from 'node:path';
23
+
24
+ /** Which store a record came from. `both` means the same record exists in each. */
25
+ export type HitOrigin = 'project' | 'global' | 'both';
26
+
27
+ export interface OriginTagged {
28
+ readonly origin: HitOrigin;
29
+ }
30
+
31
+ /**
32
+ * The cross-project store's root: the home directory.
33
+ *
34
+ * Taken as a parameter rather than read from the environment inside the merge, so the pure half
35
+ * stays testable without a filesystem and a caller cannot be surprised by which home it meant.
36
+ */
37
+ export function globalStoreRoot(home: string = homedir()): string {
38
+ return resolve(home);
39
+ }
40
+
41
+ /**
42
+ * Are these two roots the same store?
43
+ *
44
+ * If a user runs `dz recall` FROM their home directory, "read both" would read one file twice and
45
+ * double every hit. Resolved-path comparison, before either read — cheaper and more certain than
46
+ * de-duplicating afterwards.
47
+ */
48
+ export function sameStore(projectRoot: string, globalRoot: string): boolean {
49
+ return resolve(projectRoot) === resolve(globalRoot);
50
+ }
51
+
52
+ /**
53
+ * Merge two record sets into one origin-tagged list.
54
+ *
55
+ * `key` decides identity. It is supplied by the caller rather than assumed here, because two
56
+ * lookalike identity rules is how a merge quietly starts treating one record as two — the class
57
+ * this repo has already paid for.
58
+ *
59
+ * ORDER: project records first, then global-only ones. A project's own lessons are the more
60
+ * specific answer to a question asked inside that project, and a reader scanning from the top
61
+ * should meet them first. Records present in BOTH keep the project position and are labelled
62
+ * `both`, because that is where the reader will look for them.
63
+ */
64
+ export function mergeStoreHits<T>(
65
+ projectRecords: readonly T[],
66
+ globalRecords: readonly T[],
67
+ key: (r: T) => string,
68
+ ): (T & OriginTagged)[] {
69
+ const inGlobal = new Set(globalRecords.map(key));
70
+ const seen = new Set<string>();
71
+ const out: (T & OriginTagged)[] = [];
72
+
73
+ for (const r of projectRecords) {
74
+ const k = key(r);
75
+ if (seen.has(k)) continue; // a store with an internal duplicate stays one record here
76
+ seen.add(k);
77
+ out.push({ ...r, origin: inGlobal.has(k) ? 'both' : 'project' });
78
+ }
79
+ for (const r of globalRecords) {
80
+ const k = key(r);
81
+ if (seen.has(k)) continue;
82
+ seen.add(k);
83
+ out.push({ ...r, origin: 'global' });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** How the header should describe what was read — "1 store" is a fact worth stating, not assuming. */
89
+ export function storeCountLabel(count: number): string {
90
+ return count === 1 ? '1 store' : `${count} stores`;
91
+ }