@dzhechkov/harness-core 0.5.1 → 0.5.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.
- package/.dz-manifest.json +68 -28
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-render.js +1 -1
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-trace.d.ts +75 -3
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +62 -6
- package/dist/loop-trace.js.map +1 -1
- package/dist/trace-corroborate.d.ts +48 -0
- package/dist/trace-corroborate.d.ts.map +1 -0
- package/dist/trace-corroborate.js +172 -0
- package/dist/trace-corroborate.js.map +1 -0
- package/dist/workflow-run.d.ts +15 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +19 -1
- package/dist/workflow-run.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +127 -27
- package/src/index.ts +1 -0
- package/src/loop-blobs.generated.ts +2 -2
- package/src/loop-render.ts +1 -1
- package/src/loop-trace.ts +123 -6
- package/src/trace-corroborate.ts +205 -0
- package/src/workflow-run.ts +31 -1
|
@@ -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
|
+
}
|
package/src/workflow-run.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|