@dzhechkov/harness-core 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/loop-trace.ts CHANGED
@@ -60,6 +60,15 @@ export interface TraceSettleEvent {
60
60
  wallTime?: string | null;
61
61
  }
62
62
 
63
+ /**
64
+ * WHICH CODE PATH was meant to write this file. A HINT, never a trust statement — the rendered
65
+ * script's flush is performed BY AN AGENT, which therefore controls these bytes before the file
66
+ * exists and could write any value here. It is deliberately NOT called `attestedBy`, so nothing in
67
+ * the codebase can read it as provenance (ADR-001 round 1 made exactly that mistake). The trust
68
+ * question is answered by `deriveAttestation`, from an artifact the sandboxed script cannot write.
69
+ */
70
+ export type TraceEmitterPath = 'dz-process' | 'rendered-script';
71
+
63
72
  export interface TraceRunOpened {
64
73
  v: 1;
65
74
  runId: string;
@@ -67,6 +76,9 @@ export interface TraceRunOpened {
67
76
  event: 'run.opened';
68
77
  planDigest: string;
69
78
  execFp: string;
79
+ /** Optional ON THE WIRE (NFR-1): an older reader ignores it, a newer reader over an older trace
80
+ * gets `unknown` — which is never `instrument`. */
81
+ emitterPath?: TraceEmitterPath;
70
82
  }
71
83
 
72
84
  export interface TraceRunClosed {
@@ -122,6 +134,10 @@ export function traceValidateEvent(e: unknown): string | null {
122
134
  }
123
135
  if (kind === 'run.opened') {
124
136
  if (typeof ev['planDigest'] !== 'string' || typeof ev['execFp'] !== 'string') return 'run.opened needs planDigest + execFp';
137
+ const ep = ev['emitterPath'];
138
+ // Absent is legal (NFR-1). Present-but-outside-the-union is a REFUSAL, not a downgrade: a value
139
+ // like 'trusted' is someone trying to say something the vocabulary does not permit.
140
+ if (ep !== undefined && ep !== 'dz-process' && ep !== 'rendered-script') return 'emitterPath must be dz-process|rendered-script';
125
141
  return null;
126
142
  }
127
143
  if (kind === 'run.closed') {
@@ -132,11 +148,17 @@ export function traceValidateEvent(e: unknown): string | null {
132
148
  return 'unknown event kind';
133
149
  }
134
150
 
135
- /** Open a trace state and buffer the run.opened frame. Throws on an invalid runId (fail-closed). */
136
- export function traceInit(runId: string, planDigest: string, execFp: string): TraceState {
151
+ /**
152
+ * Open a trace state and buffer the run.opened frame. Throws on an invalid runId (fail-closed).
153
+ *
154
+ * `emitterPath` is REQUIRED and has NO DEFAULT, on purpose: a default would be chosen once, by
155
+ * whoever added the parameter, and every future caller that forgot it would silently inherit that
156
+ * choice. A missing argument must be a compile error instead.
157
+ */
158
+ export function traceInit(runId: string, planDigest: string, execFp: string, emitterPath: TraceEmitterPath): TraceState {
137
159
  if (!TRACE_RUNID_RE.test(runId)) throw new Error('loop-trace: runId fails ' + String(TRACE_RUNID_RE));
138
160
  const state: TraceState = { runId, seq: 0, dispatched: 0, settled: 0, buffer: [] };
139
- const opened: TraceRunOpened = { v: 1, runId, seq: ++state.seq, event: 'run.opened', planDigest, execFp };
161
+ const opened: TraceRunOpened = { v: 1, runId, seq: ++state.seq, event: 'run.opened', planDigest, execFp, emitterPath };
140
162
  traceBuffer(state, opened);
141
163
  return state;
142
164
  }
@@ -314,6 +336,11 @@ export interface TraceRun {
314
336
  /** No run.closed frame ⇒ the tail may be lost; truncated-window invariants report inconclusive. */
315
337
  incomplete: boolean;
316
338
  parseErrors: string[];
339
+ /** The self-declared HINT (see TraceEmitterPath) — null when absent. NEVER read as provenance. */
340
+ emitterPath: TraceEmitterPath | null;
341
+ /** Two `run.opened` frames that DISAGREE. The scan used to keep the last one silently, which
342
+ * would let a spliced frame overwrite the genuine one; a conflict now forbids `instrument`. */
343
+ openConflict: boolean;
317
344
  }
318
345
 
319
346
  /**
@@ -339,7 +366,7 @@ export interface TraceRun {
339
366
  * the only ordering the file can still testify to. INV-14 fails such a trace on uniqueness anyway.
340
367
  */
341
368
  export function parseTrace(text: string): TraceRun {
342
- const run: TraceRun = { runId: null, planDigest: null, execFp: null, events: [], incomplete: true, parseErrors: [] };
369
+ const run: TraceRun = { runId: null, planDigest: null, execFp: null, events: [], incomplete: true, parseErrors: [], emitterPath: null, openConflict: false };
343
370
  const scanned: TraceEvent[] = [];
344
371
  for (const line of String(text ?? '').split('\n')) {
345
372
  const t = line.trim();
@@ -364,9 +391,22 @@ export function parseTrace(text: string): TraceRun {
364
391
  const dispatchSeen = new Set<string>();
365
392
  for (const ev of scanned) {
366
393
  if (ev.event === 'run.opened') {
394
+ const prior = run.events.some((p) => p.event === 'run.opened');
395
+ if (prior) {
396
+ const disagrees =
397
+ run.runId !== ev.runId ||
398
+ run.planDigest !== ev.planDigest ||
399
+ run.execFp !== ev.execFp ||
400
+ run.emitterPath !== (ev.emitterPath ?? null);
401
+ if (disagrees) {
402
+ run.openConflict = true;
403
+ run.parseErrors.push('conflicting run.opened frames — refusing to prefer either (ADR-001)');
404
+ }
405
+ }
367
406
  run.runId = ev.runId;
368
407
  run.planDigest = ev.planDigest;
369
408
  run.execFp = ev.execFp;
409
+ run.emitterPath = ev.emitterPath ?? null;
370
410
  }
371
411
  if (ev.event === 'run.closed') run.incomplete = false;
372
412
  if (ev.event === 'dispatched') {
@@ -386,12 +426,84 @@ export function parseTrace(text: string): TraceRun {
386
426
  return run;
387
427
  }
388
428
 
429
+ /**
430
+ * ATTESTATION — the trust question, answered by the READER, never by the file.
431
+ *
432
+ * `instrument` means, EXACTLY (ADR-001 round 3, wording taken from the cross-family reviewer):
433
+ * *the bytes read match the identifiers, the hash and the line count asserted by the co-located
434
+ * `run-state.json`.* It does NOT mean `dz` historically wrote those bytes, nor that this is the
435
+ * directory it wrote them in. A byte-identical replay passes. Copying the matched trace+run-state
436
+ * pair elsewhere passes. Both are accepted and tested, not patched.
437
+ *
438
+ * What it DOES buy: `run-state.json` is written by the dz process, and the rendered script runs in a
439
+ * sandbox with no filesystem — so an agent that mis-writes, truncates, reorders or invents its
440
+ * flush cannot reach `instrument`. What it cannot buy: a defence against an actor with write access
441
+ * to the run directory. On this machine the agent IS such an actor and the signing key is readable
442
+ * by the same account, so no local artifact — signature included — closes that. Named, not hidden.
443
+ *
444
+ * `unknown` is kept DISTINCT from `agent` (a legacy instrument run, a stripped field and explicit
445
+ * agent testimony are different facts) while sharing its conservative tier for every decision.
446
+ */
447
+ export type TraceAttestation = 'instrument' | 'agent' | 'unknown';
448
+
449
+ /** The subset of `run-state.json` the attestation reads. Unknown-shaped input is not an error — it
450
+ * simply fails to bind, which yields `unknown`. */
451
+ export interface TraceRunStateBinding {
452
+ runId?: unknown;
453
+ planDigest?: unknown;
454
+ execFp?: unknown;
455
+ traceSha256?: unknown;
456
+ traceLines?: unknown;
457
+ }
458
+
459
+ /** What the CALLER measured about the trace text it actually read. Passed in rather than computed
460
+ * here so this module keeps its zero dependencies (no node:crypto) and stays trivially testable. */
461
+ export interface TraceObserved {
462
+ sha256: string;
463
+ lines: number;
464
+ }
465
+
466
+ export function deriveAttestation(run: TraceRun, state: TraceRunStateBinding | null | undefined, observed: TraceObserved): TraceAttestation {
467
+ // A conflict between two run.opened frames forbids the favourable reading outright: preferring
468
+ // either one is exactly the silent choice this feature exists to remove.
469
+ if (run.openConflict) return 'unknown';
470
+ // QE round 1 / H1: EVERY bound value must be non-empty. Equality alone let a trace whose
471
+ // planDigest and execFp are both '' bind against a state carrying the same two empty strings —
472
+ // an "identity" that identifies nothing. A binding over absent values is not a binding.
473
+ const nonEmpty = (a: unknown, b: unknown): boolean => typeof a === 'string' && a !== '' && a === b;
474
+ const bound =
475
+ state != null &&
476
+ nonEmpty(state.runId, run.runId) &&
477
+ nonEmpty(state.planDigest, run.planDigest) &&
478
+ nonEmpty(state.execFp, run.execFp) &&
479
+ // CONTENT binding, not just identifiers: a legacy run-state with no traceSha256 can never mint
480
+ // `instrument` (that was the reviewer's stale-directory counterexample).
481
+ nonEmpty(state.traceSha256, observed.sha256) &&
482
+ typeof state.traceLines === 'number' && state.traceLines === observed.lines;
483
+ if (bound) return 'instrument';
484
+ if (run.emitterPath === 'rendered-script') return 'agent';
485
+ return 'unknown';
486
+ }
487
+
389
488
  export type InvariantStatus = 'pass' | 'fail' | 'inconclusive';
390
489
 
391
490
  export interface InvariantVerdict {
392
491
  id: string;
393
492
  status: InvariantStatus;
394
493
  message: string;
494
+ /**
495
+ * FR-3 — the qualifier travels IN the verdict, never beside it. A caller that stores a verdict
496
+ * and reads it back later must not be able to end up holding a bare `pass` whose attestation was
497
+ * dropped in transit. Optional only so a caller that has not derived one is a compile-time
498
+ * possibility; `stampAttestation` is how the reader attaches it.
499
+ */
500
+ attestation?: TraceAttestation;
501
+ }
502
+
503
+ /** Attach one attestation to every verdict in a batch. Kept as a named function rather than a spread
504
+ * at each call site so a NEW verdict producer cannot silently ship unstamped verdicts. */
505
+ export function stampAttestation(verdicts: InvariantVerdict[], attestation: TraceAttestation): InvariantVerdict[] {
506
+ return verdicts.map((v) => ({ ...v, attestation }));
395
507
  }
396
508
 
397
509
  interface Invocation {
@@ -697,7 +809,12 @@ export interface Timeline {
697
809
  /**
698
810
  * Merge one timeline: trace.jsonl is the AUTHORITATIVE order (rows sorted by seq); checkpoints,
699
811
  * cost-ledger lines and usageEvents are appended as unordered context rows (seq 0); journal.jsonl
700
- * contributes DIAGNOSTIC agentId correlation only, never ordering (the ACL of 04 §8).
812
+ * contributes a DIAGNOSTIC LINE COUNT only, never ordering (the ACL of 04 §8).
813
+ *
814
+ * Honesty fix (2026-08-20): this note used to promise "agentId correlation", which the code never
815
+ * did — it counts lines. A comment claiming an analysis that does not exist is the same defect class
816
+ * this feature was built to remove, one layer up. The real correlation now lives in
817
+ * `trace-corroborate.ts`, behind `dz workflow-trace --corroborate`, where it is scoped and tested.
701
818
  */
702
819
  export function assembleTimeline(input: {
703
820
  trace: string;
@@ -749,7 +866,7 @@ export function assembleTimeline(input: {
749
866
  sources.push('journal (diagnostic only — never ordering)');
750
867
  let n = 0;
751
868
  for (const line of input.journal.split('\n')) if (line.trim() !== '') n++;
752
- rows.push({ seq: 0, kind: 'journal', label: 'journal', detail: `${n} host-journal line(s) — agentId correlation only; the host journal carries no seq/ts and NEVER orders this timeline`, wallTime: null });
869
+ rows.push({ seq: 0, kind: 'journal', label: 'journal', detail: n + ' host-journal line(s) — COUNT only; the host journal carries no seq/ts and NEVER orders this timeline. For an actual comparison run: dz workflow-trace --corroborate <hostRunDir>', wallTime: null });
753
870
  }
754
871
  return { runId: run.runId, incomplete: run.incomplete, rows, sources };
755
872
  }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `trace-corroborate` — the Claude host's OWN records, for the half of a trace they can witness.
3
+ *
4
+ * ADR-002. Pure over already-read strings: no `fs`, so it is testable with fixtures alone.
5
+ *
6
+ * The design is shaped by one MEASURED fact and one refuted design. The fact: the trace and the
7
+ * host's `journal.jsonl` share NO identifier — trace dispatch events carry `invocationId` /
8
+ * `stepId`, the journal carries `agentId` and a `v2:<sha>` key. There is no run nonce to bind them
9
+ * with, and we do not control the host's format, so DIRECTORY CONTAINMENT is the only binding
10
+ * available and every result says so. The refuted design: a bare `agrees` over "agent set +
11
+ * wall-clock order", which a cross-family reviewer defeated with a trace that matched on agents
12
+ * while inventing a join, a gate redo, a typed pause and a file deliverable — every consequential
13
+ * claim fabricated, the verdict green. Hence `agreesWithinScope`, and hence `notWitnessed` being
14
+ * non-empty BY TYPE rather than by discipline.
15
+ */
16
+
17
+ /** Non-empty by construction: a tuple type, so no edit can empty it and silently unscope a result. */
18
+ export type NotWitnessed = readonly ['join', 'gate-redo', 'typed-pause', 'file-deliverable'];
19
+ export const NOT_WITNESSED: NotWitnessed = ['join', 'gate-redo', 'typed-pause', 'file-deliverable'] as const;
20
+
21
+ export type WitnessedClaim = 'agent-multiset' | 'agent-count' | 'wall-clock-order';
22
+ export const WITNESSED: readonly WitnessedClaim[] = ['agent-multiset', 'agent-count', 'wall-clock-order'] as const;
23
+
24
+ /** Deliberately NOT `agrees`. The scope lives in the word, so a stored verdict carries it too. */
25
+ export type CorroborationVerdict = 'agreesWithinScope' | 'disagrees' | 'inconclusive';
26
+
27
+ export interface CorroborationResult {
28
+ verdict: CorroborationVerdict;
29
+ /** The ONLY binding available — see the module note. Never omitted. */
30
+ binding: 'by-directory';
31
+ hostDir: string;
32
+ witnessed: readonly WitnessedClaim[];
33
+ notWitnessed: NotWitnessed;
34
+ /** Why, in words, for the human-readable report. */
35
+ detail: string;
36
+ /** Counts, so a caller can render the disagreement rather than re-deriving it. */
37
+ traceAgentCount: number;
38
+ hostAgentCount: number;
39
+ }
40
+
41
+ /** One host record set, already read from disk by the caller. */
42
+ export interface HostRecords {
43
+ /** Raw `journal.jsonl` text, or null when the file is absent/unreadable. */
44
+ journal: string | null;
45
+ /** Raw `agent-<id>.jsonl` texts, keyed by agent id. Empty when none were found. */
46
+ agentTranscripts: Record<string, string>;
47
+ }
48
+
49
+ /** The trace side, projected by the caller: the agent ids the trace claims took part, in order. */
50
+ export interface TraceAgentProjection {
51
+ agentIds: string[];
52
+ }
53
+
54
+ /**
55
+ * Read the agent ids the host journal STARTED, or null when the journal cannot be trusted to be
56
+ * complete. QE round 1 closed three ways this used to lie:
57
+ * H4 — a `started` row whose `agentId` is not a string was silently DROPPED, so a malformed row
58
+ * that may well represent a real extra agent made the sets look equal.
59
+ * M2 — a non-empty journal with zero usable start rows returned `[]`, and an empty trace then
60
+ * "agreed" with it. A journal that records no starts is not evidence that none happened.
61
+ * M3 — `JSON.parse('null')` is valid JSON, and indexing the result threw.
62
+ */
63
+ function journalAgentIds(journal: string): string[] | null {
64
+ const ids: string[] = [];
65
+ let sawAny = false;
66
+ for (const line of journal.split('\n')) {
67
+ const t = line.trim();
68
+ if (t === '') continue;
69
+ sawAny = true;
70
+ let o: unknown;
71
+ try {
72
+ o = JSON.parse(t);
73
+ } catch {
74
+ // A malformed journal is INCONCLUSIVE, not "the agents we could still parse". A partially
75
+ // readable independent record is not an independent record.
76
+ return null;
77
+ }
78
+ // M3: `null`, a number and a string are all valid JSON and none of them are records.
79
+ if (typeof o !== 'object' || o === null || Array.isArray(o)) return null;
80
+ const rec = o as Record<string, unknown>;
81
+ if (rec['type'] !== 'started') continue;
82
+ // H4: a start row we cannot read is a start row we cannot account for.
83
+ if (typeof rec['agentId'] !== 'string' || rec['agentId'] === '') return null;
84
+ ids.push(rec['agentId']);
85
+ }
86
+ // M2: a non-empty journal that yielded no starts tells us nothing about how many agents ran.
87
+ if (!sawAny || ids.length === 0) return null;
88
+ return ids;
89
+ }
90
+
91
+ /**
92
+ * The FIRST readable timestamp in a transcript, or null when the answer cannot be trusted.
93
+ *
94
+ * QE round 1 / M1: this used to skip unparseable lines and keep scanning. But an unparseable line
95
+ * EARLIER in the file may carry an earlier timestamp — so a corrupt prefix does not just cost us a
96
+ * line, it invalidates the whole "first" claim. Returning the next readable stamp presents a
97
+ * possibly-late time as the earliest one, which is exactly how a wrong order reads as right.
98
+ */
99
+ function firstTimestamp(text: string): number | null {
100
+ for (const line of text.split('\n')) {
101
+ const t = line.trim();
102
+ if (t === '') continue;
103
+ let o: unknown;
104
+ try {
105
+ o = JSON.parse(t);
106
+ } catch {
107
+ return null; // a corrupt line before any stamp ⇒ the earliest is unknowable
108
+ }
109
+ if (typeof o !== 'object' || o === null || Array.isArray(o)) return null;
110
+ const ts = (o as Record<string, unknown>)['timestamp'];
111
+ if (typeof ts === 'string') {
112
+ const ms = Date.parse(ts);
113
+ if (Number.isFinite(ms)) return ms;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function multisetEqual(a: string[], b: string[]): boolean {
120
+ if (a.length !== b.length) return false;
121
+ const count = new Map<string, number>();
122
+ for (const x of a) count.set(x, (count.get(x) ?? 0) + 1);
123
+ for (const x of b) {
124
+ const n = count.get(x);
125
+ if (n === undefined || n === 0) return false;
126
+ count.set(x, n - 1);
127
+ }
128
+ return true;
129
+ }
130
+
131
+ export function corroborate(trace: TraceAgentProjection, host: HostRecords, hostDir: string): CorroborationResult {
132
+ const base = {
133
+ binding: 'by-directory' as const,
134
+ hostDir,
135
+ witnessed: WITNESSED,
136
+ notWitnessed: NOT_WITNESSED,
137
+ traceAgentCount: trace.agentIds.length,
138
+ };
139
+
140
+ if (host.journal === null) {
141
+ return { ...base, verdict: 'inconclusive', hostAgentCount: 0, detail: 'no host journal at ' + hostDir + ' — absent evidence is never agreement' };
142
+ }
143
+ const hostIds = journalAgentIds(host.journal);
144
+ if (hostIds === null) {
145
+ return { ...base, verdict: 'inconclusive', hostAgentCount: 0, detail: 'the host journal is empty or malformed — a partially readable record is not an independent one' };
146
+ }
147
+
148
+ // COUNT and MULTISET before ordering: a mismatch here is a real disagreement, and comparing the
149
+ // order of two different sets would be meaningless anyway.
150
+ if (!multisetEqual(trace.agentIds, hostIds)) {
151
+ return {
152
+ ...base,
153
+ verdict: 'disagrees',
154
+ hostAgentCount: hostIds.length,
155
+ detail: `the trace claims ${trace.agentIds.length} agent run(s), the host journal records ${hostIds.length}` +
156
+ (trace.agentIds.length === hostIds.length ? ' — same count, different ids' : ''),
157
+ };
158
+ }
159
+
160
+ // Wall-clock order, from the per-agent transcripts (the journal carries no ts). An agent with no
161
+ // readable timestamp makes the ORDER unwitnessable — inconclusive, not a pass on the rest.
162
+ //
163
+ // QE round 1 / M4: `host.agentTranscripts[id]` walks the PROTOTYPE, so an agent literally named
164
+ // `__proto__` (or `constructor`, `toString`, …) read a function off Object.prototype and threw.
165
+ // An own-property check is the fix; the id is data from an outside file and must be treated as such.
166
+ const own = (o: Record<string, string>, k: string): string | undefined =>
167
+ Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined;
168
+
169
+ const stamps: Array<{ id: string; at: number }> = [];
170
+ for (const id of hostIds) {
171
+ const text = own(host.agentTranscripts, id);
172
+ const at = typeof text !== 'string' ? null : firstTimestamp(text);
173
+ if (at === null) {
174
+ return { ...base, verdict: 'inconclusive', hostAgentCount: hostIds.length, detail: `no readable timestamp for agent ${id} — the wall-clock order cannot be witnessed` };
175
+ }
176
+ stamps.push({ id, at });
177
+ }
178
+
179
+ // QE round 1 / H3: two agents sharing a timestamp make their relative order UNKNOWABLE. A stable
180
+ // sort preserved the journal's own order and reported agreement — the sort's tie-breaking rule
181
+ // was silently doing duty as evidence.
182
+ const sorted = [...stamps].sort((a, b) => a.at - b.at);
183
+ for (let i = 1; i < sorted.length; i++) {
184
+ if (sorted[i]!.at === sorted[i - 1]!.at) {
185
+ return { ...base, verdict: 'inconclusive', hostAgentCount: hostIds.length, detail: `agents ${sorted[i - 1]!.id} and ${sorted[i]!.id} share a timestamp — their relative order cannot be witnessed` };
186
+ }
187
+ }
188
+
189
+ // QE round 1 / H2: compare ELEMENT-WISE. `join('|')` collapsed ['x','x|x'] and ['x|x','x'] to the
190
+ // same string, so a reversed order read as agreement — a delimiter chosen for display doing duty
191
+ // as an equality operator.
192
+ const byClock = sorted.map((sv) => sv.id);
193
+ const orderMatches = byClock.length === trace.agentIds.length && byClock.every((id, i) => id === trace.agentIds[i]);
194
+ if (!orderMatches) {
195
+ return { ...base, verdict: 'disagrees', hostAgentCount: hostIds.length, detail: 'the host wall-clock order of the agents differs from the order the trace claims' };
196
+ }
197
+
198
+ return {
199
+ ...base,
200
+ verdict: 'agreesWithinScope',
201
+ hostAgentCount: hostIds.length,
202
+ detail: 'the host records agree on which agents ran and in what order. They CANNOT witness ' +
203
+ NOT_WITNESSED.join(', ') + ' — a fabricated one of those would still land here, which is why this is never a bare "agrees"',
204
+ };
205
+ }
@@ -112,6 +112,15 @@ export interface WfRunOwner {
112
112
 
113
113
  export interface WfRunState {
114
114
  schema: typeof WF_RUN_STATE_SCHEMA;
115
+ /**
116
+ * CONTENT binding for the attestation (feature honest-trace-provenance, ADR-001 round 3).
117
+ * Identifiers alone were not enough: a fabricated trace dropped beside a genuine run-state with
118
+ * matching ids satisfied the binding, which is exactly the counterexample that failed round 2.
119
+ * Optional because a state written before this feature has none — and a state with NO binding can
120
+ * never mint `instrument`, which is the fail-closed direction.
121
+ */
122
+ traceSha256?: string;
123
+ traceLines?: number;
115
124
  owner: WfRunOwner;
116
125
  status: 'running' | 'paused' | 'completed' | 'failed';
117
126
  runId: string;
@@ -895,6 +904,9 @@ export interface RunStore {
895
904
  * instead would make a diagnostic field load-bearing (the W10 mistake).
896
905
  */
897
906
  readTraceText(): string | null;
907
+ /** sha256 + non-empty line count of trace.jsonl AS IT NOW STANDS ON DISK, or null when absent.
908
+ * Impure by nature (hashing a file), so it lives behind the store seam like every other fs read. */
909
+ measureTrace(): { sha256: string; lines: number } | null;
898
910
  readRunState(): WfRunState | null;
899
911
  /** Atomic in the fs impl (temp + rename): a half-written state is a foreign run forever. */
900
912
  writeRunState(s: WfRunState): void;
@@ -1279,6 +1291,7 @@ export async function runWorkflow(inputs: RunnerInputs, pre: PreflightOk, deps:
1279
1291
  ctx.state.status = 'completed';
1280
1292
  ctx.state.completedSteps = pre.projection.boundaries.map((b) => b.boundaryId);
1281
1293
  ctx.state.updatedAt = deps.now();
1294
+ stampTraceBinding(ctx.state, store);
1282
1295
  deps.lock(() => store.writeRunState(ctx.state));
1283
1296
  appendLedger(ctx, 'completed');
1284
1297
  return {
@@ -1302,7 +1315,9 @@ export async function runWorkflow(inputs: RunnerInputs, pre: PreflightOk, deps:
1302
1315
  * run rather than its last leg.
1303
1316
  */
1304
1317
  function openTrace(inputs: RunnerInputs, pre: PreflightOk, store: RunStore, resuming: boolean): { trace: TraceState; settleSeq: Record<string, number> } {
1305
- const st = traceInit(inputs.runId, pre.planDigest, pre.execFp);
1318
+ // 'dz-process': this runner IS the dz process, and it appends trace.jsonl itself. The value is a
1319
+ // fact about which code path is running, not a claim about trustworthiness — see ADR-001.
1320
+ const st = traceInit(inputs.runId, pre.planDigest, pre.execFp, 'dz-process');
1306
1321
  if (!resuming) return { trace: st, settleSeq: {} };
1307
1322
  const priorText = store.readTraceText();
1308
1323
  if (priorText === null || priorText.trim() === '') return { trace: st, settleSeq: {} };
@@ -1656,6 +1671,19 @@ function checkpoint(ctx: RunCtx, b: RunBoundary, value: unknown): void {
1656
1671
  if (line !== null) ctx.deps.store.appendCheckpointLine(line);
1657
1672
  }
1658
1673
 
1674
+ /**
1675
+ * THE ONE PLACE the content binding is refreshed. A helper rather than three inline copies, because
1676
+ * three call sites mean three chances to forget — and a forgotten one degrades silently to
1677
+ * `unknown` (fail-closed, but a real dz run would then read as un-attested).
1678
+ * Called AFTER the final trace flush, so it describes the bytes a reader will actually see.
1679
+ */
1680
+ function stampTraceBinding(state: WfRunState, store: RunStore): void {
1681
+ const m = store.measureTrace();
1682
+ if (m === null) return; // no trace ⇒ no binding to make; the reader will say `unknown`
1683
+ state.traceSha256 = m.sha256;
1684
+ state.traceLines = m.lines;
1685
+ }
1686
+
1659
1687
  function appendLedger(ctx: RunCtx, outcome: string): void {
1660
1688
  const line = traceLedgerLine({
1661
1689
  slug: ctx.deps.slug ?? ctx.inputs.runId,
@@ -1683,6 +1711,7 @@ function pauseOutcome(ctx: RunCtx, b: RunBoundary, pauseState: string, reason: W
1683
1711
  reservationNote: detail,
1684
1712
  };
1685
1713
  ctx.state.updatedAt = ctx.deps.now();
1714
+ stampTraceBinding(ctx.state, ctx.deps.store);
1686
1715
  ctx.deps.lock(() => ctx.deps.store.writeRunState(ctx.state));
1687
1716
  appendLedger(ctx, 'paused');
1688
1717
  return {
@@ -1699,6 +1728,7 @@ function terminalOutcome(ctx: RunCtx, route: string, _startedAt: string): RunOut
1699
1728
  flush(ctx); // NO traceClose: parity with the render's terminal return skipping the epilogue
1700
1729
  ctx.state.status = 'completed';
1701
1730
  ctx.state.updatedAt = ctx.deps.now();
1731
+ stampTraceBinding(ctx.state, ctx.deps.store);
1702
1732
  ctx.deps.lock(() => ctx.deps.store.writeRunState(ctx.state));
1703
1733
  appendLedger(ctx, route);
1704
1734
  return {
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 dzhechko
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.