From 6564f0de157406867bdf804af82b3d2a565c793c Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 3 Aug 2026 19:18:53 +0800 Subject: [PATCH 1/2] refactor(headless): unify canonicalJson into single serializer (#1404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the six scattered `canonicalJson` copies in @maka/headless into a single authoritative implementation exported from ab-manifest.ts, next to buildRunManifestFingerprint. The divergent copy in harness-oracle-registry.ts was missing the `undefined`-field filter, producing invalid JSON for objects with undefined values; traced all call sites and confirmed no current input can contain undefined fields, so the fix is behavior-neutral today and removes a latent footgun. Also collapses the hand-rolled `sha256:...update(canonicalJson(...))` expressions in runtime-policy-ab-run.ts, kimi-protocol-ab.ts, and prompt-ab-manifest.ts into buildRunManifestFingerprint, and removes the duplicate `fingerprintValue` wrapper in harness-oracle-registry.ts. The frozen legacy oracle in prompt-ab-fingerprints.test.ts is intentionally left as an independent reference implementation — coupling it to the authoritative copy would defeat its purpose as a drift sentinel. Adds behavior tests (per #1403) pinning the canonicalJson and buildRunManifestFingerprint invariants: undefined filtering, key-order independence, array-order preservation, and fingerprint stability. --- .../src/__tests__/ab-manifest.test.ts | 65 ++++++++++++++++++- .../__tests__/harness-oracle-registry.test.ts | 29 +++------ .../src/__tests__/prompt-ab-manifest.test.ts | 15 +---- packages/headless/src/ab-manifest.ts | 10 ++- packages/headless/src/ab-summary.ts | 12 +--- .../headless/src/harness-oracle-registry.ts | 29 ++------- packages/headless/src/kimi-protocol-ab.ts | 18 +---- packages/headless/src/prompt-ab-manifest.ts | 20 ++---- .../headless/src/runtime-policy-ab-run.ts | 42 ++++-------- 9 files changed, 111 insertions(+), 129 deletions(-) diff --git a/packages/headless/src/__tests__/ab-manifest.test.ts b/packages/headless/src/__tests__/ab-manifest.test.ts index b0b399bef1..30c70b25ee 100644 --- a/packages/headless/src/__tests__/ab-manifest.test.ts +++ b/packages/headless/src/__tests__/ab-manifest.test.ts @@ -3,7 +3,13 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { buildAbRunManifest, ensureAbRunManifest, readAbRunManifest } from '../ab-manifest.js'; +import { + buildAbRunManifest, + buildRunManifestFingerprint, + canonicalJson, + ensureAbRunManifest, + readAbRunManifest, +} from '../ab-manifest.js'; import { sha256 } from './helpers/hash-fixture.js'; describe('buildAbRunManifest', () => { @@ -152,3 +158,60 @@ describe('buildAbRunManifest', () => { } }); }); + +// `canonicalJson` and `buildRunManifestFingerprint` are the load-bearing +// determinism contract for A/B resume identities. These behavior tests pin the +// invariants (per #1403): key-order independence, `undefined`-field dropping, +// array-order preservation, and fingerprint stability — rather than asserting on +// the exact serialized bytes, which would over-constrain the implementation. +describe('canonicalJson', () => { + test('drops undefined object fields so {a:1,b:undefined} equals {a:1}', () => { + assert.equal(canonicalJson({ a: 1, b: undefined }), canonicalJson({ a: 1 })); + assert.equal(canonicalJson({ a: 1, b: undefined }), '{"a":1}'); + }); + + test('is independent of object key insertion order', () => { + assert.equal(canonicalJson({ b: 2, a: 1 }), canonicalJson({ a: 1, b: 2 })); + }); + + test('preserves array order (order is semantically meaningful)', () => { + assert.equal(canonicalJson([3, 1, 2]), '[3,1,2]'); + assert.notEqual(canonicalJson([3, 1, 2]), canonicalJson([1, 2, 3])); + }); + + test('recursively sorts keys in nested objects', () => { + assert.equal(canonicalJson({ outer: { z: 1, a: 2 } }), '{"outer":{"a":2,"z":1}}'); + }); + + test('serializes scalars via JSON.stringify', () => { + assert.equal(canonicalJson(null), 'null'); + assert.equal(canonicalJson('hi'), '"hi"'); + assert.equal(canonicalJson(42), '42'); + assert.equal(canonicalJson(true), 'true'); + }); +}); + +describe('buildRunManifestFingerprint', () => { + test('produces the same fingerprint for logically equal payloads regardless of key order', () => { + assert.equal( + buildRunManifestFingerprint({ b: 2, a: 1 }), + buildRunManifestFingerprint({ a: 1, b: 2 }), + ); + }); + + test('is stable across repeated calls (idempotent)', () => { + const payload = { arms: [{ id: 'on' }, { id: 'off' }], reps: 3 }; + assert.equal(buildRunManifestFingerprint(payload), buildRunManifestFingerprint(payload)); + }); + + test('ignores undefined fields when computing the fingerprint', () => { + assert.equal( + buildRunManifestFingerprint({ a: 1, b: undefined }), + buildRunManifestFingerprint({ a: 1 }), + ); + }); + + test('yields a sha256:<64 lowercase hex> string', () => { + assert.match(buildRunManifestFingerprint({ a: 1 }), /^sha256:[a-f0-9]{64}$/); + }); +}); diff --git a/packages/headless/src/__tests__/harness-oracle-registry.test.ts b/packages/headless/src/__tests__/harness-oracle-registry.test.ts index 3b717d1774..ab4039633d 100644 --- a/packages/headless/src/__tests__/harness-oracle-registry.test.ts +++ b/packages/headless/src/__tests__/harness-oracle-registry.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; +import { buildRunManifestFingerprint } from '../ab-manifest.js'; import { auditHarnessOracleRegistry, HarnessOracleAuditExecutionError, @@ -298,8 +298,10 @@ describe('harness Oracle evidence registry', () => { }); const invalid = structuredClone(baseline.snapshot); invalid.entries[0]!.oracle!.reward = 0; - invalid.entries[0]!.fingerprint = fingerprintFixture(withoutFingerprint(invalid.entries[0]!)); - invalid.fingerprint = fingerprintFixture(withoutFingerprint(invalid)); + invalid.entries[0]!.fingerprint = buildRunManifestFingerprint( + withoutFingerprint(invalid.entries[0]!), + ); + invalid.fingerprint = buildRunManifestFingerprint(withoutFingerprint(invalid)); assert.throws( () => planHarnessOracleRegistryAudit(tasks, invalid), @@ -308,10 +310,12 @@ describe('harness Oracle evidence registry', () => { const excessiveAttempts = structuredClone(baseline.snapshot); excessiveAttempts.entries[0]!.oracle!.attempts = HARBOR_ORACLE_MAX_ATTEMPTS + 1; - excessiveAttempts.entries[0]!.fingerprint = fingerprintFixture( + excessiveAttempts.entries[0]!.fingerprint = buildRunManifestFingerprint( withoutFingerprint(excessiveAttempts.entries[0]!), ); - excessiveAttempts.fingerprint = fingerprintFixture(withoutFingerprint(excessiveAttempts)); + excessiveAttempts.fingerprint = buildRunManifestFingerprint( + withoutFingerprint(excessiveAttempts), + ); assert.throws( () => planHarnessOracleRegistryAudit(tasks, excessiveAttempts), /registry entry is malformed/, @@ -536,18 +540,3 @@ function oracleExecutionProvenance(runId: string) { }, }; } - -function fingerprintFixture(value: unknown): string { - return `sha256:${createHash('sha256').update(canonicalJsonFixture(value)).digest('hex')}`; -} - -function canonicalJsonFixture(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJsonFixture).join(',')}]`; - if (value && typeof value === 'object') { - return `{${Object.entries(value) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJsonFixture(item)}`) - .join(',')}}`; - } - return JSON.stringify(value); -} diff --git a/packages/headless/src/__tests__/prompt-ab-manifest.test.ts b/packages/headless/src/__tests__/prompt-ab-manifest.test.ts index 5c8e228da9..ad86201052 100644 --- a/packages/headless/src/__tests__/prompt-ab-manifest.test.ts +++ b/packages/headless/src/__tests__/prompt-ab-manifest.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, test } from 'node:test'; +import { buildRunManifestFingerprint } from '../ab-manifest.js'; import { buildPromptAbRunManifest, ensurePromptAbRunManifest } from '../prompt-ab-manifest.js'; import type { PromptAbRunManifestInput } from '../prompt-ab-types.js'; import { sha256 } from './helpers/hash-fixture.js'; @@ -201,21 +201,10 @@ function buildLegacyPromptAbRunManifest( }); return { ...manifestWithoutFingerprint, - fingerprint: `sha256:${createHash('sha256').update(canonicalJson(manifestWithoutFingerprint)).digest('hex')}`, + fingerprint: buildRunManifestFingerprint(manifestWithoutFingerprint), }; } -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`; - if (value && typeof value === 'object') { - const entries = Object.entries(value) - .filter(([, entryValue]) => entryValue !== undefined) - .sort(([a], [b]) => a.localeCompare(b)); - return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`).join(',')}}`; - } - return JSON.stringify(value); -} - function withoutUndefined>(value: T): T { return Object.fromEntries( Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), diff --git a/packages/headless/src/ab-manifest.ts b/packages/headless/src/ab-manifest.ts index 25afac61e2..3805cb3f5b 100644 --- a/packages/headless/src/ab-manifest.ts +++ b/packages/headless/src/ab-manifest.ts @@ -104,7 +104,15 @@ function hasFullBodyFingerprint( return schemaVersion === 'maka.ab.run_manifest.v1'; } -function canonicalJson(value: unknown): string { +/** + * Deterministic JSON serializer shared across `@maka/headless`: drops `undefined` + * object fields and recursively sorts object keys, so that two logically equal + * objects always serialize to the same bytes. Arrays preserve order (order is + * semantically meaningful). This is the single authority — every fingerprint in + * the package (`buildRunManifestFingerprint` and its callers) must go through + * here so resume identities never drift. + */ +export function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`; if (value && typeof value === 'object') { const entries = Object.entries(value) diff --git a/packages/headless/src/ab-summary.ts b/packages/headless/src/ab-summary.ts index ce94d5da8f..358896ec0c 100644 --- a/packages/headless/src/ab-summary.ts +++ b/packages/headless/src/ab-summary.ts @@ -1,3 +1,4 @@ +import { canonicalJson } from './ab-manifest.js'; import { BUDGET_EXHAUSTED_RUNTIME_UNAVAILABLE_REASON } from './fixed-prompt-controller.js'; import type { FixedPromptTaskWalEvent } from './fixed-prompt-wal-types.js'; import type { HarborCellTokenSummary } from './cell-output.js'; @@ -842,17 +843,6 @@ function clampRateDelta(value: number): number { return Math.min(1, Math.max(-1, value)); } -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`; - if (value && typeof value === 'object') { - const entries = Object.entries(value) - .filter(([, entryValue]) => entryValue !== undefined) - .sort(([a], [b]) => a.localeCompare(b)); - return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`).join(',')}}`; - } - return JSON.stringify(value); -} - function assertSameRunCount( baselineRuns: readonly unknown[], candidateRuns: readonly unknown[], diff --git a/packages/headless/src/harness-oracle-registry.ts b/packages/headless/src/harness-oracle-registry.ts index 4d85a67688..bacbfcde64 100644 --- a/packages/headless/src/harness-oracle-registry.ts +++ b/packages/headless/src/harness-oracle-registry.ts @@ -1,6 +1,6 @@ -import { createHash } from 'node:crypto'; import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; +import { buildRunManifestFingerprint } from './ab-manifest.js'; import type { FixedPromptTask } from './fixed-prompt-controller.js'; import { fingerprintFixedPromptTask } from './fixed-prompt-task-source.js'; import { @@ -233,7 +233,7 @@ export function buildHarnessOracleEnvironmentFingerprint( baseImages.some((image) => image.reference.length === 0 || image.digest.length === 0) ) throw new Error('Oracle environment identity is malformed'); - return fingerprintValue({ + return buildRunManifestFingerprint({ schemaVersion: 1, environment: input.environment, platform: input.platform, @@ -368,7 +368,7 @@ export function parseHarnessOracleRegistrySnapshot(value: unknown): HarnessOracl } export function fingerprintHarnessOracleDocument(value: unknown): string { - return fingerprintValue(value); + return buildRunManifestFingerprint(value); } function annotationState(entry: HarnessOracleRegistryEntry): HarnessOracleAnnotationState { @@ -380,12 +380,12 @@ function annotationState(entry: HarnessOracleRegistryEntry): HarnessOracleAnnota } function qualificationKeyFor(taskId: string, identity: HarnessOracleQualificationIdentity): string { - return fingerprintValue({ schemaVersion: 1, taskId, identity }); + return buildRunManifestFingerprint({ schemaVersion: 1, taskId, identity }); } function assertSnapshotFingerprint(snapshot: HarnessOracleRegistrySnapshot): void { const { fingerprint, ...body } = snapshot; - if (fingerprint !== fingerprintValue(body)) { + if (fingerprint !== buildRunManifestFingerprint(body)) { throw new Error('Oracle registry snapshot fingerprint is invalid'); } if ( @@ -440,7 +440,7 @@ function registryEntryIsValid( if ( entry.schemaVersion !== 1 || entry.taskId !== expectedTaskId || - entry.fingerprint !== fingerprintValue(withoutFingerprint(entry)) || + entry.fingerprint !== buildRunManifestFingerprint(withoutFingerprint(entry)) || entry.qualificationKey !== qualificationKeyFor(entry.taskId, entry.identity) || !qualificationIdentityIsValid(entry.identity) || !executionProvenanceIsValid(entry.executionProvenance) @@ -524,22 +524,7 @@ function withoutFingerprint(value: T): Omit>(body: T): T & { fingerprint: string } { - return { ...body, fingerprint: fingerprintValue(body) }; -} - -function fingerprintValue(value: unknown): string { - return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`; -} - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; - if (isRecord(value)) { - return `{${Object.entries(value) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) - .join(',')}}`; - } - return JSON.stringify(value); + return { ...body, fingerprint: buildRunManifestFingerprint(body) }; } function isRecord(value: unknown): value is Record { diff --git a/packages/headless/src/kimi-protocol-ab.ts b/packages/headless/src/kimi-protocol-ab.ts index e45aa5b36d..08f4b102b7 100644 --- a/packages/headless/src/kimi-protocol-ab.ts +++ b/packages/headless/src/kimi-protocol-ab.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import type { ModelInfo } from '@maka/core'; import { renderAbComparisonMarkdown } from './ab-render.js'; +import { buildRunManifestFingerprint, canonicalJson } from './ab-manifest.js'; import { buildAbRoundId, runAbComparison } from './ab-run.js'; import { summarizeAbComparison } from './ab-summary.js'; import type { AbArmSpec, AbComparisonSummary } from './ab-types.js'; @@ -634,9 +635,7 @@ function protocolArmSpec(arm: (typeof KIMI_PROTOCOL_ARMS)[number]): AbArmSpec { } function armResumeFingerprint(base: string | undefined, protocol: KimiProtocol): string { - return `sha256:${createHash('sha256') - .update(canonicalJson({ version: 1, base: base ?? null, protocol })) - .digest('hex')}`; + return buildRunManifestFingerprint({ version: 1, base: base ?? null, protocol }); } function requireCaptures( @@ -662,16 +661,3 @@ function mean(values: readonly number[]): number | null { ? values.reduce((total, value) => total + value, 0) / values.length : null; } - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; - if (value && typeof value === 'object') { - const entries = Object.entries(value) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => left.localeCompare(right)); - return `{${entries - .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) - .join(',')}}`; - } - return JSON.stringify(value); -} diff --git a/packages/headless/src/prompt-ab-manifest.ts b/packages/headless/src/prompt-ab-manifest.ts index 09f871934f..b8b5ae87f2 100644 --- a/packages/headless/src/prompt-ab-manifest.ts +++ b/packages/headless/src/prompt-ab-manifest.ts @@ -1,5 +1,8 @@ -import { createHash } from 'node:crypto'; -import { ensureAbRunManifest, buildAbRunManifest } from './ab-manifest.js'; +import { + ensureAbRunManifest, + buildAbRunManifest, + buildRunManifestFingerprint, +} from './ab-manifest.js'; import type { PromptAbRunManifest, PromptAbRunManifestInput } from './prompt-ab-types.js'; export function buildPromptAbRunManifest(input: PromptAbRunManifestInput): PromptAbRunManifest { @@ -68,7 +71,7 @@ export function buildPromptAbRunManifest(input: PromptAbRunManifestInput): Promp ...promptManifestWithoutFingerprint, experimentKind: 'prompt', arms: genericManifest.arms as PromptAbRunManifest['arms'], - fingerprint: `sha256:${createHash('sha256').update(canonicalJson(promptManifestWithoutFingerprint)).digest('hex')}`, + fingerprint: buildRunManifestFingerprint(promptManifestWithoutFingerprint), }; } @@ -98,17 +101,6 @@ export async function ensurePromptAbRunManifest( } } -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`; - if (value && typeof value === 'object') { - const entries = Object.entries(value) - .filter(([, entryValue]) => entryValue !== undefined) - .sort(([a], [b]) => a.localeCompare(b)); - return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`).join(',')}}`; - } - return JSON.stringify(value); -} - function withoutUndefined>(value: T): T { return Object.fromEntries( Object.entries(value).filter(([, entryValue]) => entryValue !== undefined), diff --git a/packages/headless/src/runtime-policy-ab-run.ts b/packages/headless/src/runtime-policy-ab-run.ts index bc8ae7237f..e063d27ac0 100644 --- a/packages/headless/src/runtime-policy-ab-run.ts +++ b/packages/headless/src/runtime-policy-ab-run.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import { runFixedPromptController, type FixedPromptTask, @@ -240,22 +239,18 @@ function sanitizeSharedAgentEnv( } function contextEnvFingerprint(env: Partial>): string { - return `sha256:${createHash('sha256') - .update(canonicalJson(sanitizeContextEnv(env))) - .digest('hex')}`; + return buildRunManifestFingerprint(sanitizeContextEnv(env)); } function sharedAgentEnvFingerprint( env: Partial>, ): string { - return `sha256:${createHash('sha256') - .update(canonicalJson(sanitizeSharedAgentEnv(env))) - .digest('hex')}`; + return buildRunManifestFingerprint(sanitizeSharedAgentEnv(env)); } function runtimePolicySharedConfigFingerprint(config: Config): string { const { systemPrompt: _systemPrompt, ...effectiveConfig } = config; - return `sha256:${createHash('sha256').update(canonicalJson(effectiveConfig)).digest('hex')}`; + return buildRunManifestFingerprint(effectiveConfig); } function runtimePolicyExecutionFingerprint(profile: RuntimePolicyAbExecutionProfile): string { @@ -274,18 +269,14 @@ function runtimePolicyResumeFingerprint(input: { armContextEnvFingerprint: string; callerResumeFingerprint?: string; }): string { - return `sha256:${createHash('sha256') - .update( - canonicalJson({ - version: 'maka-runtime-policy-resume-v2', - sharedConfigFingerprint: input.sharedConfigFingerprint, - executionProfileFingerprint: input.executionProfileFingerprint, - sharedAgentEnvFingerprint: input.sharedAgentEnvFingerprint, - armContextEnvFingerprint: input.armContextEnvFingerprint, - callerResumeFingerprint: input.callerResumeFingerprint, - }), - ) - .digest('hex')}`; + return buildRunManifestFingerprint({ + version: 'maka-runtime-policy-resume-v2', + sharedConfigFingerprint: input.sharedConfigFingerprint, + executionProfileFingerprint: input.executionProfileFingerprint, + sharedAgentEnvFingerprint: input.sharedAgentEnvFingerprint, + armContextEnvFingerprint: input.armContextEnvFingerprint, + callerResumeFingerprint: input.callerResumeFingerprint, + }); } export function runtimePolicyArmResumeFingerprint( @@ -305,14 +296,3 @@ export function runtimePolicyArmResumeFingerprint( callerResumeFingerprint: input.resumeFingerprint, }); } - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`; - if (value && typeof value === 'object') { - const entries = Object.entries(value) - .filter(([, entryValue]) => entryValue !== undefined) - .sort(([a], [b]) => a.localeCompare(b)); - return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`).join(',')}}`; - } - return JSON.stringify(value); -} From fc2331fbca2c130b99ab4241374d3a2b2f3e8fc6 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 3 Aug 2026 22:12:39 +0800 Subject: [PATCH 2/2] docs(headless): clarify canonicalJson scope + add registry divergence regression (#1404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address non-blocking review feedback: - Tighten the canonicalJson docstring: it no longer claims "every fingerprint must go through here" — harness-oracle-policy.ts keeps its own JSON.stringify-based fingerprintValue for the execution-policy fingerprint (a single frozen as-const object, deliberately not canonicalized). - Add a regression test pinning the harness-oracle-registry divergence scenario: a registry-shaped body with a nested undefined field must hash identically to the all-defined body. Before unification the divergent local copy omitted the undefined-field filter and would emit invalid JSON ({"field":undefined}), never matching the canonical computation. --- .../src/__tests__/ab-manifest.test.ts | 26 +++++++++++++++++++ packages/headless/src/ab-manifest.ts | 10 ++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/headless/src/__tests__/ab-manifest.test.ts b/packages/headless/src/__tests__/ab-manifest.test.ts index 30c70b25ee..6513df4905 100644 --- a/packages/headless/src/__tests__/ab-manifest.test.ts +++ b/packages/headless/src/__tests__/ab-manifest.test.ts @@ -211,6 +211,32 @@ describe('buildRunManifestFingerprint', () => { ); }); + // Regression guard for the harness-oracle-registry divergence: the registry + // path recomputes a fingerprint over entry/snapshot bodies and compares it to + // a stored value. Before unification, harness-oracle-registry.ts carried a + // local canonicalJson copy that omitted the undefined-field filter, so a body + // containing an undefined-valued nested field would hash to invalid JSON + // ({"field":undefined}) and never match the canonical computation. This pins + // the contract through buildRunManifestFingerprint using a registry-shaped + // payload with a nested undefined field. + test('registry-shaped body with a nested undefined field matches the all-defined body', () => { + const allDefined = { + schemaVersion: 1, + taskId: 'task-a', + identity: { taskFingerprint: 'sha256:aaa', executionPolicyFingerprint: 'sha256:bbb' }, + execution: { status: 'completed' }, + oracle: { outcome: 'passed', reward: 1, attempts: 1 }, + }; + const withUndefined = { + ...allDefined, + identity: { ...allDefined.identity, evidenceFingerprint: undefined }, + }; + assert.equal( + buildRunManifestFingerprint(withUndefined), + buildRunManifestFingerprint(allDefined), + ); + }); + test('yields a sha256:<64 lowercase hex> string', () => { assert.match(buildRunManifestFingerprint({ a: 1 }), /^sha256:[a-f0-9]{64}$/); }); diff --git a/packages/headless/src/ab-manifest.ts b/packages/headless/src/ab-manifest.ts index 3805cb3f5b..9dc0fed8d1 100644 --- a/packages/headless/src/ab-manifest.ts +++ b/packages/headless/src/ab-manifest.ts @@ -108,9 +108,13 @@ function hasFullBodyFingerprint( * Deterministic JSON serializer shared across `@maka/headless`: drops `undefined` * object fields and recursively sorts object keys, so that two logically equal * objects always serialize to the same bytes. Arrays preserve order (order is - * semantically meaningful). This is the single authority — every fingerprint in - * the package (`buildRunManifestFingerprint` and its callers) must go through - * here so resume identities never drift. + * semantically meaningful). Every resume-identity and integrity fingerprint in + * the package goes through here via `buildRunManifestFingerprint`, so that + * canonicalization never drifts across call sites. + * + * Note: `harness-oracle-policy.ts` keeps its own `JSON.stringify`-based + * `fingerprintValue` for the execution-policy fingerprint, deliberately not + * canonicalized (its inputs are a single frozen `as const` object). */ export function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`;