From d7907843bcb195670ce6391e81fca5afb9a938b3 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 4 Sep 2026 20:29:56 -0700 Subject: [PATCH] refactor(candidate): share canonical serialization --- src/candidate-execution/digest.ts | 30 +++++++++--------- tests/candidate-execution-core.test.ts | 43 +++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/candidate-execution/digest.ts b/src/candidate-execution/digest.ts index a1871626b..19878a25c 100644 --- a/src/candidate-execution/digest.ts +++ b/src/candidate-execution/digest.ts @@ -1,20 +1,21 @@ import { createHash } from 'node:crypto' -import { canonicalJson } from '@tangle-network/agent-eval' -import type { AgentCandidateEmbeddedArtifact, Sha256Digest } from '@tangle-network/agent-interface' +import { + type AgentCandidateEmbeddedArtifact, + canonicalCandidateBytes, + type Sha256Digest, +} from '@tangle-network/agent-interface' -import { contentAddress } from '../durable/spawn-journal' import type { CanonicalCandidateDocument } from './types' +export { canonicalCandidateBytes } + +/** Use native hashing for workspace archives. */ export function sha256Bytes(bytes: Uint8Array): Sha256Digest { return `sha256:${createHash('sha256').update(bytes).digest('hex')}` } -export function canonicalCandidateBytes(value: unknown): Uint8Array { - return Buffer.from(canonicalJson(value), 'utf8') -} - export function canonicalCandidateDigest(value: unknown): Sha256Digest { - return contentAddress(value) as Sha256Digest + return sha256Bytes(canonicalCandidateBytes(value)) } /** Returns a detached, deeply frozen JSON value with canonical number normalization. */ @@ -28,16 +29,15 @@ export function canonicalCandidateDocument( valueWithoutDigest: Omit, ): CanonicalCandidateDocument { const bytes = canonicalCandidateBytes(valueWithoutDigest) - const digest = canonicalCandidateDigest(valueWithoutDigest) - if (sha256Bytes(bytes) !== digest) { - throw new Error('canonical candidate serializers disagree on document digest') - } - const storedBytes = Uint8Array.from(bytes) - const value = immutableCandidateValue({ ...valueWithoutDigest, digest }) as T + const digest = sha256Bytes(bytes) + const value = deepFreezeCandidate({ + ...JSON.parse(Buffer.from(bytes).toString('utf8')), + digest, + }) as T return Object.freeze({ value, get bytes(): Uint8Array { - return Uint8Array.from(storedBytes) + return Uint8Array.from(bytes) }, digest, }) diff --git a/tests/candidate-execution-core.test.ts b/tests/candidate-execution-core.test.ts index 751f7e639..7b0dde4b8 100644 --- a/tests/candidate-execution-core.test.ts +++ b/tests/candidate-execution-core.test.ts @@ -14,6 +14,7 @@ import { join } from 'node:path' import type { AgentCandidateGitPatch, AgentCandidateWorkspaceManifestMaterial, + Sha256Digest, } from '@tangle-network/agent-interface' import { afterEach, describe, expect, it } from 'vitest' @@ -25,7 +26,10 @@ import { import { canonicalCandidateBytes, canonicalCandidateDigest, + canonicalCandidateDocument, embeddedCandidateArtifact, + omitTopLevelDigest, + sha256Bytes, } from '../src/candidate-execution/digest' import { verifyCandidateCode } from '../src/candidate-execution/git-materialize' @@ -83,11 +87,48 @@ function codeFixture(fixture: ReturnType): AgentCandid } describe('candidate canonical bytes and artifacts', () => { - it('uses the existing stable content address for exact canonical bytes', () => { + it('preserves canonical bytes and persisted candidate digests', () => { const first = { z: [3, { b: true, a: 'x' }], a: -0 } const second = { a: 0, z: [3, { a: 'x', b: true }] } expect(canonicalCandidateBytes(first)).toEqual(canonicalCandidateBytes(second)) expect(canonicalCandidateDigest(first)).toBe(canonicalCandidateDigest(second)) + expect(Buffer.from(canonicalCandidateBytes(first)).toString('utf8')).toBe( + '{"a":0,"z":[3,{"a":"x","b":true}]}', + ) + expect(canonicalCandidateDigest(first)).toBe( + 'sha256:81a9344f7f972ea820b2d8b32c7a2d9a5edf1822eddb6817a52442b69eb6f6a0', + ) + }) + + it.each([undefined, NaN, Infinity, new Date(0), '\uD800'])( + 'refuses non-JSON candidate material %s', + (payload) => { + const material = { payload } + expect(() => canonicalCandidateBytes(material)).toThrow() + expect(() => canonicalCandidateDigest(material)).toThrow() + expect(() => canonicalCandidateDocument(material)).toThrow() + }, + ) + + it('binds the frozen document to its captured bytes when input reads change', () => { + let reads = 0 + const document = canonicalCandidateDocument<{ + digest: Sha256Digest + payload: { read: number } + }>({ + get payload() { + return { read: ++reads } + }, + }) + const material = omitTopLevelDigest(document.value) + expect(JSON.parse(Buffer.from(document.bytes).toString('utf8'))).toEqual(material) + expect(sha256Bytes(document.bytes)).toBe(document.digest) + expect(canonicalCandidateDigest(material)).toBe(document.digest) + expect(Object.isFrozen(document.value)).toBe(true) + expect(Object.isFrozen(material.payload)).toBe(true) + + document.bytes.fill(0) + expect(sha256Bytes(document.bytes)).toBe(document.digest) }) it('rejects an artifact whose claimed hash does not match its bytes', async () => {