From ecbcdaa303627febf62d235d74cf2db6172d05fe Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 01:32:59 +0200 Subject: [PATCH 001/292] feat(core): add bounded RFC 8785 canonical JSON --- packages/core/src/canonical-json.ts | 484 ++++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/test/canonical-json.test.ts | 191 +++++++++ 3 files changed, 676 insertions(+) create mode 100644 packages/core/src/canonical-json.ts create mode 100644 packages/core/test/canonical-json.test.ts diff --git a/packages/core/src/canonical-json.ts b/packages/core/src/canonical-json.ts new file mode 100644 index 0000000000..b8458cf2b6 --- /dev/null +++ b/packages/core/src/canonical-json.ts @@ -0,0 +1,484 @@ +/** RFC 8785 / I-JSON value accepted by the Track-2 control-object codec. */ +export type CanonicalJsonPrimitive = null | boolean | number | string; + +export type CanonicalJsonValue = + | CanonicalJsonPrimitive + | readonly CanonicalJsonValue[] + | { readonly [key: string]: CanonicalJsonValue }; + +export interface CanonicalJsonOptions { + /** Hard input-byte ceiling. Callers should normally pass their object-type cap. */ + maxBytes?: number; + /** Defensive nesting ceiling independent of JavaScript call-stack limits. */ + maxDepth?: number; +} + +export type StrictJsonParseOptions = CanonicalJsonOptions; + +export const MAX_CANONICAL_JSON_BYTES = 8 * 1024 * 1024; +export const MAX_CANONICAL_JSON_DEPTH = 64; +const UTF8 = new TextEncoder(); +// Preserve a leading BOM so the explicit wire-level rejection below can see it. +const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +export class CanonicalJsonError extends Error { + constructor(message: string) { + super(message); + this.name = 'CanonicalJsonError'; + } +} + +/** + * Serialize one already parsed JSON value using RFC 8785 JCS. + * + * JavaScript's JSON number/string primitive serialization matches JCS; object keys + * are sorted by UTF-16 code units as required by RFC 8785 section 3.2.3. This + * implementation rejects values outside the I-JSON data model instead of silently + * dropping or coercing them like JSON.stringify does. + */ +export function canonicalizeJson( + value: CanonicalJsonValue, + options: CanonicalJsonOptions = {}, +): string { + const { maxBytes, maxDepth } = resolveLimits(options); + const ancestors = new Set(); + const writer = new BoundedJsonWriter(maxBytes); + + const encode = (input: unknown, depth: number): void => { + if (depth > maxDepth) { + throw new CanonicalJsonError(`JSON nesting exceeds ${maxDepth}`); + } + + if (input === null) { + writer.appendAscii('null'); + return; + } + + switch (typeof input) { + case 'boolean': + writer.appendAscii(input ? 'true' : 'false'); + return; + case 'number': { + if (!Number.isFinite(input)) { + throw new CanonicalJsonError('JCS numbers must be finite IEEE-754 values'); + } + writer.appendAscii(JSON.stringify(input)); + return; + } + case 'string': { + writeCanonicalJsonString(input, writer, 'JSON string'); + return; + } + case 'object': + break; + default: + throw new CanonicalJsonError(`Unsupported JSON value type: ${typeof input}`); + } + + const object = input as object; + if (ancestors.has(object)) { + throw new CanonicalJsonError('Cyclic values are not JSON'); + } + ancestors.add(object); + + try { + if (Array.isArray(input)) { + assertJsonArrayShape(input); + writer.appendAscii('['); + for (let index = 0; index < input.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(input, index)) { + throw new CanonicalJsonError('Sparse arrays are not accepted'); + } + if (index !== 0) writer.appendAscii(','); + encode(input[index], depth + 1); + } + writer.appendAscii(']'); + return; + } + + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new CanonicalJsonError('Only plain JSON objects are accepted'); + } + + const record = input as Record; + assertJsonObjectShape(record); + const keys = Object.keys(record).sort(); + writer.appendAscii('{'); + for (let index = 0; index < keys.length; index += 1) { + if (index !== 0) writer.appendAscii(','); + const key = keys[index]; + writeCanonicalJsonString(key, writer, 'JSON object key'); + writer.appendAscii(':'); + encode(record[key], depth + 1); + } + writer.appendAscii('}'); + } finally { + ancestors.delete(object); + } + }; + + encode(value, 0); + return writer.finish(); +} + +export function canonicalizeJsonBytes( + value: CanonicalJsonValue, + options: CanonicalJsonOptions = {}, +): Uint8Array { + return UTF8.encode(canonicalizeJson(value, options)); +} + +/** Parse JSON while rejecting duplicate decoded keys, invalid UTF-8, and non-I-JSON values. */ +export function parseJsonStrict( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): CanonicalJsonValue { + const { maxBytes, maxDepth } = resolveLimits(options); + + const byteLength = typeof input === 'string' ? UTF8.encode(input).byteLength : input.byteLength; + if (byteLength > maxBytes) { + throw new CanonicalJsonError(`JSON input exceeds ${maxBytes} bytes`); + } + + let text: string; + try { + text = typeof input === 'string' ? input : UTF8_FATAL.decode(input); + } catch { + throw new CanonicalJsonError('JSON input is not valid UTF-8'); + } + if (text.charCodeAt(0) === 0xfeff) { + throw new CanonicalJsonError('A UTF-8 BOM is not accepted'); + } + + const parser = new StrictJsonParser(text, maxDepth); + return parser.parse(); +} + +/** Parse one wire object and require that its bytes already equal RFC 8785 JCS. */ +export function parseCanonicalJson( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): CanonicalJsonValue { + const value = parseJsonStrict(input, options); + const text = typeof input === 'string' ? input : UTF8_FATAL.decode(input); + if (canonicalizeJson(value, options) !== text) { + throw new CanonicalJsonError('JSON bytes are valid but not RFC 8785 canonical'); + } + return value; +} + +function resolveLimits(options: CanonicalJsonOptions): { + maxBytes: number; + maxDepth: number; +} { + const maxBytes = options.maxBytes ?? MAX_CANONICAL_JSON_BYTES; + const maxDepth = options.maxDepth ?? MAX_CANONICAL_JSON_DEPTH; + if ( + !Number.isSafeInteger(maxBytes) + || maxBytes <= 0 + || maxBytes > MAX_CANONICAL_JSON_BYTES + ) { + throw new CanonicalJsonError( + `maxBytes must be a positive safe integer no greater than ${MAX_CANONICAL_JSON_BYTES}`, + ); + } + if ( + !Number.isSafeInteger(maxDepth) + || maxDepth < 0 + || maxDepth > MAX_CANONICAL_JSON_DEPTH + ) { + throw new CanonicalJsonError( + `maxDepth must be a non-negative safe integer no greater than ${MAX_CANONICAL_JSON_DEPTH}`, + ); + } + return { maxBytes, maxDepth }; +} + +/** + * Bounded text sink used by the serializer. It coalesces small punctuation/number + * writes so a near-limit array does not allocate one JavaScript string per token. + */ +class BoundedJsonWriter { + private readonly chunks: string[] = []; + private pending = ''; + private byteLength = 0; + + constructor(private readonly maxBytes: number) {} + + appendAscii(value: string): void { + this.appendMeasured(value, value.length); + } + + appendUtf8(value: string): void { + this.appendMeasured(value, UTF8.encode(value).byteLength); + } + + finish(): string { + if (this.pending.length > 0) { + this.chunks.push(this.pending); + this.pending = ''; + } + return this.chunks.join(''); + } + + private appendMeasured(value: string, bytes: number): void { + if (this.byteLength + bytes > this.maxBytes) { + throw new CanonicalJsonError(`Canonical JSON exceeds ${this.maxBytes} bytes`); + } + this.byteLength += bytes; + this.pending += value; + if (this.pending.length >= 8192) { + this.chunks.push(this.pending); + this.pending = ''; + } + } +} + +/** Write one JCS string without first allocating an unbounded JSON.stringify result. */ +function writeCanonicalJsonString( + value: string, + writer: BoundedJsonWriter, + label: string, +): void { + writer.appendAscii('"'); + let rawStart = 0; + let index = 0; + + const flushRaw = (end: number): void => { + // Keep TextEncoder allocations bounded even when the source string is enormous. + if (end > rawStart) writer.appendUtf8(value.slice(rawStart, end)); + rawStart = end; + }; + + while (index < value.length) { + const unit = value.charCodeAt(index); + let escaped: string | undefined; + if (unit === 0x22) escaped = '\\"'; + else if (unit === 0x5c) escaped = '\\\\'; + else if (unit === 0x08) escaped = '\\b'; + else if (unit === 0x09) escaped = '\\t'; + else if (unit === 0x0a) escaped = '\\n'; + else if (unit === 0x0c) escaped = '\\f'; + else if (unit === 0x0d) escaped = '\\r'; + else if (unit < 0x20) escaped = `\\u${unit.toString(16).padStart(4, '0')}`; + + if (escaped !== undefined) { + flushRaw(index); + writer.appendAscii(escaped); + index += 1; + rawStart = index; + continue; + } + + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new CanonicalJsonError(`${label} contains an unpaired high surrogate`); + } + index += 2; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new CanonicalJsonError(`${label} contains an unpaired low surrogate`); + } else { + index += 1; + } + + if (index - rawStart >= 4096) flushRaw(index); + } + + flushRaw(value.length); + writer.appendAscii('"'); +} + +function assertUnicodeScalarString(value: string, label: string): void { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + throw new CanonicalJsonError(`${label} contains an unpaired high surrogate`); + } + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new CanonicalJsonError(`${label} contains an unpaired low surrogate`); + } + } +} + +class StrictJsonParser { + private index = 0; + + constructor( + private readonly text: string, + private readonly maxDepth: number, + ) {} + + parse(): CanonicalJsonValue { + this.skipWhitespace(); + const value = this.parseValue(0); + this.skipWhitespace(); + if (this.index !== this.text.length) this.fail('Unexpected trailing input'); + return value; + } + + private parseValue(depth: number): CanonicalJsonValue { + if (depth > this.maxDepth) this.fail(`JSON nesting exceeds ${this.maxDepth}`); + const char = this.text[this.index]; + if (char === '"') return this.parseString(); + if (char === '{') return this.parseObject(depth + 1); + if (char === '[') return this.parseArray(depth + 1); + if (char === 't') return this.parseLiteral('true', true); + if (char === 'f') return this.parseLiteral('false', false); + if (char === 'n') return this.parseLiteral('null', null); + if (char === '-' || (char >= '0' && char <= '9')) return this.parseNumber(); + this.fail('Expected a JSON value'); + } + + private parseObject(depth: number): CanonicalJsonValue { + if (depth > this.maxDepth) this.fail(`JSON nesting exceeds ${this.maxDepth}`); + this.index += 1; + this.skipWhitespace(); + const result: Record = Object.create(null) as Record< + string, + CanonicalJsonValue + >; + const seen = new Set(); + if (this.consume('}')) return result; + + while (true) { + if (this.text[this.index] !== '"') this.fail('Expected an object key'); + const key = this.parseString(); + if (seen.has(key)) this.fail(`Duplicate object key ${JSON.stringify(key)}`); + seen.add(key); + this.skipWhitespace(); + this.expect(':'); + this.skipWhitespace(); + result[key] = this.parseValue(depth); + this.skipWhitespace(); + if (this.consume('}')) return result; + this.expect(','); + this.skipWhitespace(); + } + } + + private parseArray(depth: number): CanonicalJsonValue { + if (depth > this.maxDepth) this.fail(`JSON nesting exceeds ${this.maxDepth}`); + this.index += 1; + this.skipWhitespace(); + const result: CanonicalJsonValue[] = []; + if (this.consume(']')) return result; + + while (true) { + result.push(this.parseValue(depth)); + this.skipWhitespace(); + if (this.consume(']')) return result; + this.expect(','); + this.skipWhitespace(); + } + } + + private parseString(): string { + const start = this.index; + this.index += 1; + let escaped = false; + while (this.index < this.text.length) { + const code = this.text.charCodeAt(this.index); + if (!escaped && code === 0x22) { + this.index += 1; + const token = this.text.slice(start, this.index); + let value: string; + try { + value = JSON.parse(token) as string; + } catch { + this.fail('Invalid JSON string escape'); + } + assertUnicodeScalarString(value, 'JSON string'); + return value; + } + if (!escaped && code < 0x20) this.fail('Unescaped control character in string'); + if (!escaped && code === 0x5c) { + escaped = true; + } else { + escaped = false; + } + this.index += 1; + } + this.fail('Unterminated JSON string'); + } + + private parseNumber(): number { + const rest = this.text.slice(this.index); + const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(rest); + if (!match) this.fail('Invalid JSON number'); + const token = match[0]; + this.index += token.length; + const value = Number(token); + if (!Number.isFinite(value)) this.fail('JSON number is outside finite IEEE-754 range'); + return value; + } + + private parseLiteral(token: string, value: T): T { + if (!this.text.startsWith(token, this.index)) this.fail(`Expected ${token}`); + this.index += token.length; + return value; + } + + private skipWhitespace(): void { + while (this.index < this.text.length) { + const char = this.text.charCodeAt(this.index); + if (char !== 0x20 && char !== 0x09 && char !== 0x0a && char !== 0x0d) return; + this.index += 1; + } + } + + private consume(expected: string): boolean { + if (this.text[this.index] !== expected) return false; + this.index += 1; + return true; + } + + private expect(expected: string): void { + if (!this.consume(expected)) this.fail(`Expected ${JSON.stringify(expected)}`); + } + + private fail(message: string): never { + throw new CanonicalJsonError(`${message} at character ${this.index}`); + } +} + +function assertJsonObjectShape(record: Record): void { + for (const key of Reflect.ownKeys(record)) { + if (typeof key !== 'string') { + throw new CanonicalJsonError('JSON objects must not contain symbol properties'); + } + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor?.enumerable) { + throw new CanonicalJsonError('JSON objects must not contain non-enumerable properties'); + } + if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new CanonicalJsonError('JSON objects must not contain accessor properties'); + } + } +} + +function assertJsonArrayShape(array: readonly unknown[]): void { + for (const key of Reflect.ownKeys(array)) { + if (typeof key !== 'string') { + throw new CanonicalJsonError('JSON arrays must not contain symbol properties'); + } + if (key === 'length') continue; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= array.length) { + throw new CanonicalJsonError('JSON arrays must not contain non-index properties'); + } + const descriptor = Object.getOwnPropertyDescriptor(array, key); + if ( + !descriptor?.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new CanonicalJsonError( + 'JSON arrays must contain only enumerable data elements', + ); + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08c42e3540..6c50e7ac94 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from './sparql-operation.js'; export * from './publisher-extension.js'; export * from './imported-artifact-bytes.js'; export * from './imported-artifact-metadata.js'; +export * from './canonical-json.js'; export * from './event-bus.js'; export { Logger, diff --git a/packages/core/test/canonical-json.test.ts b/packages/core/test/canonical-json.test.ts new file mode 100644 index 0000000000..294b47145e --- /dev/null +++ b/packages/core/test/canonical-json.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; + +import { + CanonicalJsonError, + MAX_CANONICAL_JSON_BYTES, + MAX_CANONICAL_JSON_DEPTH, + canonicalizeJson, + parseCanonicalJson, + parseJsonStrict, + type CanonicalJsonValue, +} from '../src/canonical-json.js'; + +describe('RFC 8785 canonical JSON', () => { + it('matches the RFC 8785 section 3.2.2 canonicalization sample', () => { + const value = { + numbers: [333333333.33333329, 1e30, 4.5, 2e-3, 1e-27], + string: '\u20ac$\u000f\nA\'B"\\\\"/', + literals: [null, true, false], + }; + + expect(canonicalizeJson(value)).toBe( + '{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\\u000f\\nA\'B\\"\\\\\\\\\\"/"}', + ); + }); + + it('sorts property names by UTF-16 code units rather than Unicode code points', () => { + expect(canonicalizeJson({ '\ue000': 'bmp', '\u{10000}': 'supplementary' })).toBe( + '{"𐀀":"supplementary","":"bmp"}', + ); + }); + + it('matches the RFC 8785 Appendix B IEEE-754 number boundary vectors', () => { + const vectors: Array<[string, string]> = [ + ['0000000000000000', '0'], + ['8000000000000000', '0'], + ['0000000000000001', '5e-324'], + ['8000000000000001', '-5e-324'], + ['7fefffffffffffff', '1.7976931348623157e+308'], + ['ffefffffffffffff', '-1.7976931348623157e+308'], + ['4340000000000000', '9007199254740992'], + ['c340000000000000', '-9007199254740992'], + ['4430000000000000', '295147905179352830000'], + ['44b52d02c7e14af5', '9.999999999999997e+22'], + ['44b52d02c7e14af6', '1e+23'], + ['44b52d02c7e14af7', '1.0000000000000001e+23'], + ['444b1ae4d6e2ef4e', '999999999999999700000'], + ['444b1ae4d6e2ef4f', '999999999999999900000'], + ['444b1ae4d6e2ef50', '1e+21'], + ['3eb0c6f7a0b5ed8c', '9.999999999999997e-7'], + ['3eb0c6f7a0b5ed8d', '0.000001'], + ['41b3de4355555553', '333333333.3333332'], + ['41b3de4355555554', '333333333.33333325'], + ['41b3de4355555555', '333333333.3333333'], + ['41b3de4355555556', '333333333.3333334'], + ['41b3de4355555557', '333333333.33333343'], + ['becbf647612f3696', '-0.0000033333333333333333'], + ['43143ff3c1cb0959', '1424953923781206.2'], + ]; + + for (const [bits, expected] of vectors) { + expect(canonicalizeJson(float64FromBits(bits)), bits).toBe(expected); + } + expect(() => canonicalizeJson(float64FromBits('7fffffffffffffff'))).toThrow( + /finite IEEE-754/, + ); + expect(() => canonicalizeJson(float64FromBits('7ff0000000000000'))).toThrow( + /finite IEEE-754/, + ); + }); + + it('parses canonical bytes and rejects merely valid, non-canonical JSON', () => { + expect(parseCanonicalJson('{"a":0,"b":[true,null]}')).toEqual({ + a: 0, + b: [true, null], + }); + + for (const input of [ + ' {"a":0,"b":[true,null]}', + '{"b":[true,null],"a":0}', + '{"a":-0,"b":[true,null]}', + '{"a":0.0,"b":[true,null]}', + '{"a":0,"b":[true,null]}\n', + ]) { + expect(() => parseCanonicalJson(input)).toThrow(/not RFC 8785 canonical/); + } + }); + + it('rejects duplicate decoded keys, including differently escaped spellings', () => { + expect(() => parseJsonStrict('{"a":1,"a":2}')).toThrow(/Duplicate object key/); + expect(() => parseJsonStrict(String.raw`{"a":1,"\u0061":2}`)).toThrow( + /Duplicate object key/, + ); + }); + + it('rejects invalid UTF-8, a BOM, invalid grammar, and unpaired surrogates', () => { + expect(() => parseJsonStrict(new Uint8Array([0xc3, 0x28]))).toThrow(/not valid UTF-8/); + expect(() => parseJsonStrict(new Uint8Array([0xef, 0xbb, 0xbf, 0x6e, 0x75, 0x6c, 0x6c]))) + .toThrow(/BOM/); + + for (const input of ['01', '1.', '[1,]', '{"a":1,}', 'true false']) { + expect(() => parseJsonStrict(input)).toThrow(CanonicalJsonError); + } + + expect(() => parseJsonStrict(String.raw`"\ud800"`)).toThrow(/unpaired high surrogate/); + expect(() => parseJsonStrict(String.raw`"\udc00"`)).toThrow(/unpaired low surrogate/); + }); + + it('enforces byte and nesting ceilings even for empty nested containers', () => { + expect(() => parseJsonStrict('{"a":1}', { maxBytes: 6 })).toThrow(/exceeds 6 bytes/); + expect(parseJsonStrict('{"a":1}', { maxDepth: 1 })).toEqual({ a: 1 }); + expect(() => parseJsonStrict('{"a":{}}', { maxDepth: 1 })).toThrow( + /nesting exceeds 1/, + ); + expect(() => parseJsonStrict('[[]]', { maxDepth: 1 })).toThrow(/nesting exceeds 1/); + }); + + it('enforces exact UTF-8 byte and protocol depth boundaries in both directions', () => { + expect(canonicalizeJson('é', { maxBytes: 4 })).toBe('"é"'); + expect(parseCanonicalJson('"é"', { maxBytes: 4 })).toBe('é'); + expect(() => canonicalizeJson('é', { maxBytes: 3 })).toThrow(/exceeds 3 bytes/); + expect(() => parseCanonicalJson('"é"', { maxBytes: 3 })).toThrow( + /exceeds 3 bytes/, + ); + + const atLimit = nestedArrayJson(MAX_CANONICAL_JSON_DEPTH); + expect(parseCanonicalJson(atLimit, { maxDepth: MAX_CANONICAL_JSON_DEPTH })).toBeTruthy(); + expect(canonicalizeJson(parseJsonStrict(atLimit))).toBe(atLimit); + const overLimit = nestedArrayJson(MAX_CANONICAL_JSON_DEPTH + 1); + expect(() => parseCanonicalJson(overLimit)).toThrow(/nesting exceeds 64/); + + expect(() => parseJsonStrict('null', { + maxBytes: MAX_CANONICAL_JSON_BYTES + 1, + })).toThrow(/no greater than/); + expect(() => parseJsonStrict('null', { + maxDepth: MAX_CANONICAL_JSON_DEPTH + 1, + })).toThrow(/no greater than/); + }); + + it('aborts bounded serialization while traversing an oversized JavaScript value', () => { + expect(() => canonicalizeJson({ payload: 'x'.repeat(128) }, { maxBytes: 32 })).toThrow( + /Canonical JSON exceeds 32 bytes/, + ); + }); + + it('rejects JavaScript values that are not lossless plain JSON data', () => { + expect(() => canonicalizeJson(Number.NaN as unknown as CanonicalJsonValue)).toThrow( + /finite IEEE-754/, + ); + expect(() => canonicalizeJson([1, , 3] as unknown as CanonicalJsonValue)).toThrow( + /Sparse arrays/, + ); + expect(() => canonicalizeJson(new Date() as unknown as CanonicalJsonValue)).toThrow( + /plain JSON objects/, + ); + + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => canonicalizeJson(cyclic)).toThrow(/Cyclic values/); + + const symbolObject = { a: 1 } as Record; + symbolObject[Symbol('hidden')] = 2; + expect(() => canonicalizeJson(symbolObject as CanonicalJsonValue)).toThrow( + /symbol properties/, + ); + + const accessorObject = {}; + Object.defineProperty(accessorObject, 'a', { enumerable: true, get: () => 1 }); + expect(() => canonicalizeJson(accessorObject as CanonicalJsonValue)).toThrow( + /accessor properties/, + ); + + const decoratedArray = [1] as unknown[] & { extra?: number }; + decoratedArray.extra = 2; + expect(() => canonicalizeJson(decoratedArray as CanonicalJsonValue)).toThrow( + /non-index properties/, + ); + }); +}); + +function float64FromBits(hex: string): number { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setBigUint64(0, BigInt(`0x${hex}`)); + return view.getFloat64(0); +} + +function nestedArrayJson(depth: number): string { + let value = '0'; + for (let index = 0; index < depth; index += 1) value = `[${value}]`; + return value; +} From 2e4ca22f7c9be78a2488bfa9c6105263d6110f7d Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 01:33:10 +0200 Subject: [PATCH 002/292] feat(core): add signed sync control object codec --- packages/core/src/index.ts | 1 + packages/core/src/sync-control-object.ts | 469 ++++++++++++++++++ .../core/test/sync-control-object.test.ts | 330 ++++++++++++ 3 files changed, 800 insertions(+) create mode 100644 packages/core/src/sync-control-object.ts create mode 100644 packages/core/test/sync-control-object.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c50e7ac94..3da7e4ee23 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export * from './publisher-extension.js'; export * from './imported-artifact-bytes.js'; export * from './imported-artifact-metadata.js'; export * from './canonical-json.js'; +export * from './sync-control-object.js'; export * from './event-bus.js'; export { Logger, diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts new file mode 100644 index 0000000000..a278873154 --- /dev/null +++ b/packages/core/src/sync-control-object.ts @@ -0,0 +1,469 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + +import { + MAX_CANONICAL_JSON_BYTES, + MAX_CANONICAL_JSON_DEPTH, + canonicalizeJsonBytes, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; + +export const CONTROL_OBJECT_DIGEST_DOMAIN = 'dkg-control-object-v1\n' as const; +export const CONTROL_SIGNATURE_VARIANT_DIGEST_DOMAIN = + 'dkg-control-signature-variant-v1\n' as const; +export const MAX_CONTROL_OBJECT_BYTES = MAX_CANONICAL_JSON_BYTES; +export const MAX_CONTROL_OBJECT_DEPTH = MAX_CANONICAL_JSON_DEPTH; +export const EIP191_SIGNATURE_BYTES = 65; +export const MAX_EIP1271_SIGNATURE_BYTES = 4096; +export const MAX_CONTROL_SIGNATURE_VARIANT_BYTES = 16 * 1024; + +export const CONTROL_OBJECT_SIGNATURE_SUITES = [ + 'eip191-personal-sign-digest-v1', + 'eip1271-current-finalized-v1', +] as const; + +export type ControlObjectSignatureSuite = (typeof CONTROL_OBJECT_SIGNATURE_SUITES)[number]; + +export type ControlObjectSignatureEvidence = + | { readonly kind: 'none' } + | { + readonly kind: 'eip1271-current-finalized'; + /** Canonical unsigned decimal integer; JSON numbers are not used for chain IDs. */ + readonly chainId: string; + readonly contractAddress: string; + }; + +export interface UnsignedControlEnvelopeV1 { + readonly objectType: string; + readonly payload: Payload; + readonly signatureSuite: ControlObjectSignatureSuite; + readonly issuer: string; + readonly signatureEvidence: ControlObjectSignatureEvidence; +} + +export interface SignedControlEnvelopeV1 + extends UnsignedControlEnvelopeV1 { + readonly objectDigest: string; + readonly signature: string; +} + +export interface ControlObjectSignatureVariantV1 { + readonly objectDigest: string; + readonly signatureVariantDigest: string; + readonly signature: string; +} + +const UTF8 = new TextEncoder(); +const DOMAIN_BYTES = UTF8.encode(CONTROL_OBJECT_DIGEST_DOMAIN); +const SIGNATURE_VARIANT_DOMAIN_BYTES = UTF8.encode(CONTROL_SIGNATURE_VARIANT_DIGEST_DOMAIN); +const EVM_ADDRESS = /^0x[0-9a-f]{40}$/; +const LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})+$/; +const CANONICAL_UNSIGNED_DECIMAL = /^(?:0|[1-9][0-9]*)$/; + +/** + * Return the exact RFC-64 unsigned-envelope bytes used by every digest/signature + * implementation. Validation and bounded serialization happen in the same traversal. + */ +export function canonicalizeUnsignedControlEnvelopeBytes( + envelope: UnsignedControlEnvelopeV1, +): Uint8Array { + assertUnsignedControlEnvelopeFields(envelope); + return canonicalizeUnsignedControlEnvelopeBytesAfterFields(envelope); +} + +function canonicalizeUnsignedControlEnvelopeBytesAfterFields( + envelope: UnsignedControlEnvelopeV1, +): Uint8Array { + return canonicalizeJsonBytes(toCanonicalUnsignedEnvelope(envelope), { + maxBytes: MAX_CONTROL_OBJECT_BYTES, + maxDepth: MAX_CONTROL_OBJECT_DEPTH, + }); +} + +/** Compute the raw 32-byte Track-2 object digest over the exact unsigned envelope. */ +export function computeControlObjectDigest( + envelope: UnsignedControlEnvelopeV1, +): Uint8Array { + return digestWithDomain(DOMAIN_BYTES, canonicalizeUnsignedControlEnvelopeBytes(envelope)); +} + +export function computeControlObjectDigestHex( + envelope: UnsignedControlEnvelopeV1, +): string { + return bytesToLowerHex(computeControlObjectDigest(envelope)); +} + +export function assertControlObjectDigest( + envelope: UnsignedControlEnvelopeV1, + claimedDigest: string, +): void { + assertCanonicalDigest(claimedDigest, 'objectDigest'); + const expected = computeControlObjectDigestHex(envelope); + if (claimedDigest !== expected) { + throw new Error(`Control-object digest mismatch: expected ${expected}`); + } +} + +/** Validate an in-memory unsigned envelope, including the generic byte/depth caps. */ +export function assertUnsignedControlEnvelope( + envelope: UnsignedControlEnvelopeV1, +): void { + canonicalizeUnsignedControlEnvelopeBytes(envelope); +} + +/** + * Decode a received unsigned envelope. Wire bytes must already be canonical JCS; + * the returned object has exactly the five unsigned-envelope fields. + */ +export function parseCanonicalUnsignedControlEnvelope( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedControlEnvelopeV1 { + const parsed = parseCanonicalJson(input, options); + if (!isPlainRecord(parsed)) { + throw new Error('Control-object envelope must be a plain JSON object'); + } + const envelope = parsed as unknown as UnsignedControlEnvelopeV1; + // parseCanonicalJson already enforced I-JSON, the caller's lower cap, and the + // protocol hard caps. Only schema/evidence constraints remain here. + assertUnsignedControlEnvelopeFields(envelope); + return envelope; +} + +/** Validate a complete signed wire envelope without performing authority verification. */ +export function assertSignedControlEnvelope( + envelope: SignedControlEnvelopeV1, +): void { + validateSignedControlEnvelope(envelope, true); +} + +/** Strictly decode and validate a canonical signed wire envelope. */ +export function parseCanonicalSignedControlEnvelope( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedControlEnvelopeV1 { + const parsed = parseCanonicalJson(input, options); + if (!isPlainRecord(parsed)) { + throw new Error('Signed control-object envelope must be a plain JSON object'); + } + const envelope = parsed as unknown as SignedControlEnvelopeV1; + validateSignedControlEnvelope(envelope, false); + return envelope; +} + +/** Compute the RFC-64 detached-signature record digest. */ +export function computeControlSignatureVariantDigest( + objectDigest: string, + signature: string, +): Uint8Array { + assertCanonicalDigest(objectDigest, 'objectDigest'); + assertCanonicalHexBytes( + signature, + 'signature', + 1, + MAX_EIP1271_SIGNATURE_BYTES, + ); + const canonicalVariant = canonicalizeJsonBytes({ objectDigest, signature }, { + maxBytes: MAX_CONTROL_OBJECT_BYTES, + maxDepth: MAX_CONTROL_OBJECT_DEPTH, + }); + return digestWithDomain(SIGNATURE_VARIANT_DOMAIN_BYTES, canonicalVariant); +} + +export function computeControlSignatureVariantDigestHex( + objectDigest: string, + signature: string, +): string { + return bytesToLowerHex(computeControlSignatureVariantDigest(objectDigest, signature)); +} + +export function assertControlSignatureVariantDigest( + objectDigest: string, + signature: string, + claimedDigest: string, +): void { + assertCanonicalDigest(claimedDigest, 'signatureVariantDigest'); + const expected = computeControlSignatureVariantDigestHex(objectDigest, signature); + if (claimedDigest !== expected) { + throw new Error(`Control signature-variant digest mismatch: expected ${expected}`); + } +} + +/** Strictly decode the normalized detached-signature record defined by RFC-64. */ +export function parseCanonicalControlSignatureVariant( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): ControlObjectSignatureVariantV1 { + const parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + ), + maxDepth: Math.min(options.maxDepth ?? 1, 1), + }); + if (!isPlainRecord(parsed)) { + throw new Error('Control signature variant must be a plain JSON object'); + } + assertExactKeys( + parsed, + ['objectDigest', 'signature', 'signatureVariantDigest'], + 'control signature variant', + ); + const variant = parsed as unknown as ControlObjectSignatureVariantV1; + assertControlSignatureVariantDigest( + variant.objectDigest, + variant.signature, + variant.signatureVariantDigest, + ); + return variant; +} + +export function assertCanonicalEvmAddress(value: string, label = 'address'): void { + if (typeof value !== 'string' || !EVM_ADDRESS.test(value)) { + throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); + } + if (value === '0x0000000000000000000000000000000000000000') { + throw new Error(`${label} must not be the zero address`); + } +} + +export function bytesToLowerHex(bytes: Uint8Array): string { + let result = '0x'; + for (const byte of bytes) result += byte.toString(16).padStart(2, '0'); + return result; +} + +function validateSignedControlEnvelope( + envelope: SignedControlEnvelopeV1, + enforceSignedSize: boolean, +): void { + if (!isPlainRecord(envelope)) { + throw new Error('Signed control-object envelope must be a plain JSON object'); + } + assertExactKeys(envelope, [ + 'issuer', + 'objectDigest', + 'objectType', + 'payload', + 'signature', + 'signatureEvidence', + 'signatureSuite', + ], 'signed control-object envelope'); + + const unsigned = extractUnsignedEnvelope(envelope); + assertUnsignedControlEnvelopeFields(unsigned); + assertCanonicalDigest(envelope.objectDigest, 'objectDigest'); + assertSignatureForSuite(envelope.signature, envelope.signatureSuite); + const canonicalUnsigned = canonicalizeUnsignedControlEnvelopeBytesAfterFields(unsigned); + if ( + enforceSignedSize + && canonicalUnsigned.byteLength + signedEnvelopeAdditionalBytes(envelope) + > MAX_CONTROL_OBJECT_BYTES + ) { + throw new Error(`Signed control-object envelope exceeds ${MAX_CONTROL_OBJECT_BYTES} bytes`); + } + const expectedDigest = bytesToLowerHex(digestWithDomain(DOMAIN_BYTES, canonicalUnsigned)); + if (envelope.objectDigest !== expectedDigest) { + throw new Error(`Control-object digest mismatch: expected ${expectedDigest}`); + } +} + +function assertUnsignedControlEnvelopeFields( + envelope: UnsignedControlEnvelopeV1, +): void { + if (!isPlainRecord(envelope)) { + throw new Error('Control-object envelope must be a plain JSON object'); + } + assertExactKeys(envelope, [ + 'issuer', + 'objectType', + 'payload', + 'signatureEvidence', + 'signatureSuite', + ], 'control-object envelope'); + if (typeof envelope.objectType !== 'string' || envelope.objectType.length === 0) { + throw new Error('Control-object type must be a non-empty string'); + } + let objectTypeScalars = 0; + for (const _scalar of envelope.objectType) { + objectTypeScalars += 1; + if (objectTypeScalars > 128) { + throw new Error('Control-object type is outside the canonical string bounds'); + } + } + if (/[-]/u.test(envelope.objectType)) { + throw new Error('Control-object type is outside the canonical string bounds'); + } + if (!CONTROL_OBJECT_SIGNATURE_SUITES.includes(envelope.signatureSuite)) { + throw new Error(`Unsupported control-object signature suite: ${String(envelope.signatureSuite)}`); + } + assertCanonicalEvmAddress(envelope.issuer, 'issuer'); + + if (!isPlainRecord(envelope.signatureEvidence)) { + throw new Error('Control-object signature evidence must be a plain JSON object'); + } + + if (envelope.signatureSuite === 'eip191-personal-sign-digest-v1') { + assertExactKeys(envelope.signatureEvidence, ['kind'], 'EIP-191 signature evidence'); + if (envelope.signatureEvidence.kind !== 'none') { + throw new Error('EIP-191 control objects require signatureEvidence.kind=none'); + } + } else { + assertExactKeys( + envelope.signatureEvidence, + ['chainId', 'contractAddress', 'kind'], + 'EIP-1271 signature evidence', + ); + if (envelope.signatureEvidence.kind !== 'eip1271-current-finalized') { + throw new Error('EIP-1271 control objects require current-finalized evidence'); + } + if ( + typeof envelope.signatureEvidence.chainId !== 'string' + || !CANONICAL_UNSIGNED_DECIMAL.test(envelope.signatureEvidence.chainId) + ) { + throw new Error('EIP-1271 chainId must be a canonical unsigned decimal string'); + } + assertCanonicalEvmAddress( + envelope.signatureEvidence.contractAddress, + 'signatureEvidence.contractAddress', + ); + if (envelope.signatureEvidence.contractAddress !== envelope.issuer) { + throw new Error('EIP-1271 evidence contract must equal the envelope issuer'); + } + } +} + +function assertSignatureForSuite( + signature: string, + suite: ControlObjectSignatureSuite, +): void { + if (suite === 'eip191-personal-sign-digest-v1') { + assertCanonicalHexBytes( + signature, + 'EIP-191 signature', + EIP191_SIGNATURE_BYTES, + EIP191_SIGNATURE_BYTES, + ); + return; + } + assertCanonicalHexBytes( + signature, + 'EIP-1271 signature', + 1, + MAX_EIP1271_SIGNATURE_BYTES, + ); +} + +function assertCanonicalDigest(value: string, label: string): void { + if ( + typeof value !== 'string' + || value.length !== 66 + || !/^0x[0-9a-f]{64}$/.test(value) + ) { + throw new Error(`${label} must be a lowercase 32-byte 0x digest`); + } +} + +function assertCanonicalHexBytes( + value: string, + label: string, + minBytes: number, + maxBytes: number, +): void { + if (typeof value !== 'string' || !value.startsWith('0x')) { + throw new Error(`${label} must be lowercase 0x-prefixed bytes`); + } + const hexLength = value.length - 2; + if ( + hexLength % 2 !== 0 + || hexLength < minBytes * 2 + || hexLength > maxBytes * 2 + || !LOWER_HEX_BYTES.test(value) + ) { + throw new Error( + `${label} must be ${minBytes === maxBytes ? `${minBytes}` : `${minBytes}-${maxBytes}`} lowercase bytes`, + ); + } +} + +function extractUnsignedEnvelope( + envelope: SignedControlEnvelopeV1, +): UnsignedControlEnvelopeV1 { + return { + issuer: envelope.issuer, + objectType: envelope.objectType, + payload: envelope.payload, + signatureEvidence: envelope.signatureEvidence, + signatureSuite: envelope.signatureSuite, + }; +} + +function toCanonicalUnsignedEnvelope( + envelope: UnsignedControlEnvelopeV1, +): CanonicalJsonValue { + const evidence: CanonicalJsonValue = envelope.signatureEvidence.kind === 'none' + ? { kind: 'none' } + : { + chainId: envelope.signatureEvidence.chainId, + contractAddress: envelope.signatureEvidence.contractAddress, + kind: 'eip1271-current-finalized', + }; + + return { + issuer: envelope.issuer, + objectType: envelope.objectType, + payload: envelope.payload, + signatureEvidence: evidence, + signatureSuite: envelope.signatureSuite, + }; +} + +function signedEnvelopeAdditionalBytes( + envelope: SignedControlEnvelopeV1, +): number { + // The signed form adds exactly two comma-delimited ASCII fields to the already + // non-empty unsigned object. Sorting changes placement, never byte length. + return ( + 1 + '"objectDigest":'.length + envelope.objectDigest.length + 2 + + 1 + '"signature":'.length + envelope.signature.length + 2 + ); +} + +function digestWithDomain(domain: Uint8Array, payload: Uint8Array): Uint8Array { + const preimage = new Uint8Array(domain.length + payload.length); + preimage.set(domain); + preimage.set(payload, domain.length); + return sha256(preimage); +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertExactKeys( + record: Record, + expected: readonly string[], + label: string, +): void { + const actual = Reflect.ownKeys(record); + if (actual.some((key) => typeof key !== 'string')) { + throw new Error(`${label} must not contain symbol properties`); + } + const strings = actual as string[]; + if ( + strings.length !== expected.length + || [...strings].sort().some((key, index) => key !== expected[index]) + ) { + throw new Error(`${label} has unknown or missing fields`); + } + for (const key of strings) { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} fields must be enumerable data properties`); + } + } +} diff --git a/packages/core/test/sync-control-object.test.ts b/packages/core/test/sync-control-object.test.ts new file mode 100644 index 0000000000..7e29b3e13c --- /dev/null +++ b/packages/core/test/sync-control-object.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_CONTROL_OBJECT_BYTES, + MAX_EIP1271_SIGNATURE_BYTES, + assertControlObjectDigest, + assertSignedControlEnvelope, + assertUnsignedControlEnvelope, + canonicalizeUnsignedControlEnvelopeBytes, + computeControlObjectDigestHex, + computeControlSignatureVariantDigestHex, + parseCanonicalControlSignatureVariant, + parseCanonicalSignedControlEnvelope, + parseCanonicalUnsignedControlEnvelope, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '../src/sync-control-object.js'; + +const CG_ID = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/threat-intel'; +const EOA_ISSUER = '0x1111111111111111111111111111111111111111'; +const SAFE_ISSUER = '0x2222222222222222222222222222222222222222'; +const EOA_DIGEST = '0xf76fc6928b271be4cdd13b2e463a8f303af95b84be619c6aea0fad1ab8259da3'; +const SAFE_DIGEST = '0xbaa5433cc34c3b621c556c408b1b290382cbd97f2fa99e2df714439f819bc072'; +const EOA_SIGNATURE = `0x${'11'.repeat(65)}`; + +// Normative bytes generated independently with Python hashlib over these literal +// UTF-8 fixtures. Do not regenerate expected values from the implementation under test. +const EOA_CANONICAL_UNSIGNED = + '{"issuer":"0x1111111111111111111111111111111111111111","objectType":"ContextGraphPolicyV1","payload":{"accessPolicy":"invite-only","contextGraphId":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/threat-intel","era":"0","networkId":"otp:2043","publishPolicy":"curated","version":"1"},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}'; +const SAFE_CANONICAL_UNSIGNED = + '{"issuer":"0x2222222222222222222222222222222222222222","objectType":"ContextGraphCheckpointV1","payload":{"authorityEpoch":"7","checkpointVersion":"42","contextGraphId":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/threat-intel","networkId":"otp:2043"},"signatureEvidence":{"chainId":"2043","contractAddress":"0x2222222222222222222222222222222222222222","kind":"eip1271-current-finalized"},"signatureSuite":"eip1271-current-finalized-v1"}'; +const EOA_CANONICAL_SIGNED = + '{"issuer":"0x1111111111111111111111111111111111111111","objectDigest":"0xf76fc6928b271be4cdd13b2e463a8f303af95b84be619c6aea0fad1ab8259da3","objectType":"ContextGraphPolicyV1","payload":{"accessPolicy":"invite-only","contextGraphId":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/threat-intel","era":"0","networkId":"otp:2043","publishPolicy":"curated","version":"1"},"signature":"0x1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111","signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}'; +const EOA_SIGNATURE_VARIANT_DIGEST = + '0x4ee07bedb94b7050086d75012344b1fc0b883ded2b36b7b0f987d1235b360509'; +const EOA_CANONICAL_SIGNATURE_VARIANT = + '{"objectDigest":"0xf76fc6928b271be4cdd13b2e463a8f303af95b84be619c6aea0fad1ab8259da3","signature":"0x1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111","signatureVariantDigest":"0x4ee07bedb94b7050086d75012344b1fc0b883ded2b36b7b0f987d1235b360509"}'; + +const EOA_VECTOR: UnsignedControlEnvelopeV1 = { + objectType: 'ContextGraphPolicyV1', + payload: { + accessPolicy: 'invite-only', + contextGraphId: CG_ID, + era: '0', + networkId: 'otp:2043', + publishPolicy: 'curated', + version: '1', + }, + signatureSuite: 'eip191-personal-sign-digest-v1', + issuer: EOA_ISSUER, + signatureEvidence: { kind: 'none' }, +}; + +const SAFE_VECTOR: UnsignedControlEnvelopeV1 = { + objectType: 'ContextGraphCheckpointV1', + payload: { + authorityEpoch: '7', + checkpointVersion: '42', + contextGraphId: CG_ID, + networkId: 'otp:2043', + }, + signatureSuite: 'eip1271-current-finalized-v1', + issuer: SAFE_ISSUER, + signatureEvidence: { + kind: 'eip1271-current-finalized', + chainId: '2043', + contractAddress: SAFE_ISSUER, + }, +}; + +const EOA_SIGNED: SignedControlEnvelopeV1 = { + ...EOA_VECTOR, + objectDigest: EOA_DIGEST, + signature: EOA_SIGNATURE, +}; + +const SAFE_SIGNED: SignedControlEnvelopeV1 = { + ...SAFE_VECTOR, + objectDigest: SAFE_DIGEST, + signature: '0x1234', +}; + +describe('Track-2 control-object envelopes', () => { + it('pins the exact EIP-191 canonical preimage and domain-separated SHA-256 vector', () => { + expect(new TextDecoder().decode(canonicalizeUnsignedControlEnvelopeBytes(EOA_VECTOR))) + .toBe(EOA_CANONICAL_UNSIGNED); + expect(computeControlObjectDigestHex(EOA_VECTOR)).toBe( + EOA_DIGEST, + ); + }); + + it('pins the exact EIP-1271 canonical preimage and domain-separated SHA-256 vector', () => { + expect(new TextDecoder().decode(canonicalizeUnsignedControlEnvelopeBytes(SAFE_VECTOR))) + .toBe(SAFE_CANONICAL_UNSIGNED); + expect(computeControlObjectDigestHex(SAFE_VECTOR)).toBe( + SAFE_DIGEST, + ); + }); + + it('strictly decodes only canonical unsigned envelopes with the exact field set', () => { + expect(parseCanonicalUnsignedControlEnvelope(EOA_CANONICAL_UNSIGNED)).toMatchObject({ + objectType: 'ContextGraphPolicyV1', + issuer: EOA_ISSUER, + }); + expect(() => parseCanonicalUnsignedControlEnvelope(` ${EOA_CANONICAL_UNSIGNED}`)).toThrow( + /not RFC 8785 canonical/, + ); + const unknownField = `${EOA_CANONICAL_UNSIGNED.slice(0, -1)},"unknown":true}`; + expect(() => parseCanonicalUnsignedControlEnvelope(unknownField)).toThrow( + /unknown or missing fields/, + ); + }); + + it('strictly decodes and validates canonical EIP-191 and EIP-1271 signed envelopes', () => { + expect(parseCanonicalSignedControlEnvelope(EOA_CANONICAL_SIGNED)).toMatchObject({ + objectDigest: EOA_DIGEST, + signature: EOA_SIGNATURE, + }); + expect(() => assertSignedControlEnvelope(EOA_SIGNED)).not.toThrow(); + expect(() => assertSignedControlEnvelope(SAFE_SIGNED)).not.toThrow(); + expect(() => parseCanonicalSignedControlEnvelope(` ${EOA_CANONICAL_SIGNED}`)).toThrow( + /not RFC 8785 canonical/, + ); + }); + + it('pins and strictly decodes the detached signature-variant digest', () => { + expect(computeControlSignatureVariantDigestHex(EOA_DIGEST, EOA_SIGNATURE)).toBe( + EOA_SIGNATURE_VARIANT_DIGEST, + ); + expect(parseCanonicalControlSignatureVariant(EOA_CANONICAL_SIGNATURE_VARIANT)) + .toEqual({ + objectDigest: EOA_DIGEST, + signature: EOA_SIGNATURE, + signatureVariantDigest: EOA_SIGNATURE_VARIANT_DIGEST, + }); + }); + + it('is independent of JavaScript insertion order and verifies exact claims', () => { + const reordered = { + signatureEvidence: { kind: 'none' }, + issuer: EOA_ISSUER, + signatureSuite: 'eip191-personal-sign-digest-v1', + payload: { + version: '1', + publishPolicy: 'curated', + networkId: 'otp:2043', + era: '0', + contextGraphId: CG_ID, + accessPolicy: 'invite-only', + }, + objectType: 'ContextGraphPolicyV1', + } satisfies UnsignedControlEnvelopeV1; + + const digest = computeControlObjectDigestHex(EOA_VECTOR); + expect(computeControlObjectDigestHex(reordered)).toBe(digest); + expect(() => assertControlObjectDigest(reordered, digest)).not.toThrow(); + expect(() => assertControlObjectDigest(reordered, `0x${'00'.repeat(32)}`)).toThrow( + /digest mismatch/, + ); + }); + + it('fails closed on suite/evidence substitution', () => { + expect(() => assertUnsignedControlEnvelope({ + ...EOA_VECTOR, + signatureEvidence: { + kind: 'eip1271-current-finalized', + chainId: '2043', + contractAddress: EOA_ISSUER, + }, + })).toThrow(/EIP-191 signature evidence|unknown or missing fields/); + + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { kind: 'none' }, + })).toThrow(/EIP-1271 signature evidence|unknown or missing fields/); + }); + + it.each(['01', '+1', '-1', '1.0', '1e3', ''])('rejects non-canonical chainId %j', (chainId) => { + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { ...SAFE_VECTOR.signatureEvidence, chainId }, + })).toThrow(/canonical unsigned decimal/); + }); + + it('rejects a non-string chain ID and evidence for a different contract', () => { + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { + ...SAFE_VECTOR.signatureEvidence, + chainId: 2043 as unknown as string, + }, + })).toThrow(/canonical unsigned decimal/); + + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { + ...SAFE_VECTOR.signatureEvidence, + contractAddress: EOA_ISSUER, + }, + })).toThrow(/must equal the envelope issuer/); + }); + + it('rejects non-canonical and zero EVM issuers', () => { + expect(() => assertUnsignedControlEnvelope({ + ...EOA_VECTOR, + issuer: '0x111111111111111111111111111111111111111A', + })).toThrow(/lowercase 20-byte/); + expect(() => assertUnsignedControlEnvelope({ + ...EOA_VECTOR, + issuer: `0x${'00'.repeat(20)}`, + })).toThrow(/zero address/); + }); + + it('rejects malformed, non-canonical, and mismatched signed-envelope digests', () => { + expect(() => assertSignedControlEnvelope({ + ...EOA_SIGNED, + objectDigest: `0x${'00'.repeat(32)}`, + })).toThrow(/digest mismatch/); + expect(() => assertSignedControlEnvelope({ + ...EOA_SIGNED, + objectDigest: `0x${'AA'.repeat(32)}`, + })).toThrow(/lowercase 32-byte/); + + const signedWithUnknown = `${EOA_CANONICAL_SIGNED.slice(0, -1)},"unknown":true}`; + expect(() => parseCanonicalSignedControlEnvelope(signedWithUnknown)).toThrow( + /unknown or missing fields/, + ); + }); + + it('enforces exact EIP-191 and bounded EIP-1271 signature encodings', () => { + expect(() => assertSignedControlEnvelope({ + ...EOA_SIGNED, + signature: `0x${'11'.repeat(64)}`, + })).toThrow(/65 lowercase bytes/); + expect(() => assertSignedControlEnvelope({ + ...EOA_SIGNED, + signature: `0x${'AA'.repeat(65)}`, + })).toThrow(/65 lowercase bytes/); + expect(() => assertSignedControlEnvelope({ + ...SAFE_SIGNED, + signature: '0x', + })).toThrow(/1-4096 lowercase bytes/); + expect(() => assertSignedControlEnvelope({ + ...SAFE_SIGNED, + signature: '0xabc', + })).toThrow(/1-4096 lowercase bytes/); + expect(() => assertSignedControlEnvelope({ + ...SAFE_SIGNED, + signature: `0x${'aa'.repeat(MAX_EIP1271_SIGNATURE_BYTES + 1)}`, + })).toThrow(/1-4096 lowercase bytes/); + expect(() => assertSignedControlEnvelope({ + ...SAFE_SIGNED, + signature: `0x${'aa'.repeat(MAX_EIP1271_SIGNATURE_BYTES)}`, + })).not.toThrow(); + }); + + it('measures the object-type limit in Unicode scalar values', () => { + expect(() => assertUnsignedControlEnvelope({ + ...EOA_VECTOR, + objectType: '🚀'.repeat(128), + })).not.toThrow(); + expect(() => assertUnsignedControlEnvelope({ + ...EOA_VECTOR, + objectType: '🚀'.repeat(129), + })).toThrow(/canonical string bounds/); + }); + + it('rejects unknown, missing, accessor, and symbol envelope/evidence fields', () => { + expect(() => assertUnsignedControlEnvelope({ + ...EOA_VECTOR, + extra: true, + } as unknown as UnsignedControlEnvelopeV1)).toThrow(/unknown or missing fields/); + + const missing = { ...EOA_VECTOR } as Partial; + delete missing.payload; + expect(() => assertUnsignedControlEnvelope(missing as UnsignedControlEnvelopeV1)).toThrow( + /unknown or missing fields/, + ); + + const evidenceWithExtra = { + kind: 'none', + staleBlock: '7', + } as unknown as UnsignedControlEnvelopeV1['signatureEvidence']; + expect(() => assertUnsignedControlEnvelope({ + ...EOA_VECTOR, + signatureEvidence: evidenceWithExtra, + })).toThrow(/unknown or missing fields/); + + const accessor = { ...EOA_VECTOR }; + Object.defineProperty(accessor, 'issuer', { enumerable: true, get: () => EOA_ISSUER }); + expect(() => assertUnsignedControlEnvelope(accessor)).toThrow(/enumerable data properties/); + + const symbolEnvelope = { ...EOA_VECTOR } as UnsignedControlEnvelopeV1 & Record; + symbolEnvelope[Symbol('hidden')] = true; + expect(() => assertUnsignedControlEnvelope(symbolEnvelope)).toThrow(/symbol properties/); + }); + + it('rejects non-I-JSON payloads before computing a digest', () => { + const payload = { value: Number.POSITIVE_INFINITY } as unknown as UnsignedControlEnvelopeV1['payload']; + expect(() => computeControlObjectDigestHex({ ...EOA_VECTOR, payload })).toThrow( + /finite IEEE-754/, + ); + }); + + it('fails oversized in-memory envelopes during bounded traversal', () => { + const payload = { + blob: 'x'.repeat(MAX_CONTROL_OBJECT_BYTES), + } as UnsignedControlEnvelopeV1['payload']; + expect(() => computeControlObjectDigestHex({ ...EOA_VECTOR, payload })).toThrow( + /Canonical JSON exceeds/, + ); + }); + + it('rejects malformed or mismatched detached signature variants', () => { + const mismatched = EOA_CANONICAL_SIGNATURE_VARIANT.replace( + EOA_SIGNATURE_VARIANT_DIGEST, + `0x${'00'.repeat(32)}`, + ); + expect(() => parseCanonicalControlSignatureVariant(mismatched)).toThrow( + /digest mismatch/, + ); + const uppercaseSignature = EOA_CANONICAL_SIGNATURE_VARIANT.replace( + EOA_SIGNATURE, + `0x${'AA'.repeat(65)}`, + ); + expect(() => parseCanonicalControlSignatureVariant(uppercaseSignature)).toThrow( + /lowercase bytes/, + ); + }); +}); From b71b0dc69fb31254c8c830b4f2abb67141d70b80 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 01:51:34 +0200 Subject: [PATCH 003/292] feat(core): add RFC-64 transfer descriptor codec --- packages/core/src/index.ts | 31 +++ packages/core/src/ka-transfer-descriptor.ts | 214 ++++++++++++++++++ packages/core/src/sync-control-object.ts | 91 ++------ packages/core/src/sync-wire-scalars.ts | 185 +++++++++++++++ .../core/test/ka-transfer-descriptor.test.ts | 169 ++++++++++++++ .../core/test/sync-control-object.test.ts | 17 ++ packages/core/test/sync-wire-scalars.test.ts | 68 ++++++ 7 files changed, 697 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/ka-transfer-descriptor.ts create mode 100644 packages/core/src/sync-wire-scalars.ts create mode 100644 packages/core/test/ka-transfer-descriptor.test.ts create mode 100644 packages/core/test/sync-wire-scalars.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3da7e4ee23..a8b70c760f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,37 @@ export * from './imported-artifact-bytes.js'; export * from './imported-artifact-metadata.js'; export * from './canonical-json.js'; export * from './sync-control-object.js'; +export { + MAX_DECIMAL_U64, + MAX_DECIMAL_U256, + assertCanonicalChainId, + assertCanonicalDecimalU64, + assertCanonicalDecimalU256, + assertCanonicalDigest, + assertCanonicalHexBytes, + assertCanonicalKaId, + assertCanonicalTimestampMs, + parseCanonicalDecimalU64, + parseCanonicalDecimalU256, +} from './sync-wire-scalars.js'; +export type { + BatchIdV1, + BlockNumberV1, + ByteLengthV1, + ChainIdV1, + CountV1, + DecimalU64V1, + DecimalU256V1, + Digest32V1, + EndKaIdV1, + EvmAddressV1, + IndexV1, + KaIdV1, + ReservedKaIdV1, + StartKaIdV1, + TimestampMsV1, +} from './sync-wire-scalars.js'; +export * from './ka-transfer-descriptor.js'; export * from './event-bus.js'; export { Logger, diff --git a/packages/core/src/ka-transfer-descriptor.ts b/packages/core/src/ka-transfer-descriptor.ts new file mode 100644 index 0000000000..fc719c8329 --- /dev/null +++ b/packages/core/src/ka-transfer-descriptor.ts @@ -0,0 +1,214 @@ +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + assertCanonicalDigest, + assertExactKeys, + isPlainRecord, + parseCanonicalDecimalU64, + type ByteLengthV1, + type CountV1, + type Digest32V1, +} from './sync-wire-scalars.js'; + +export const KA_TRANSFER_CODEC_V1 = 'dkg-ka-bundle-v1' as const; +export const KA_TRANSFER_PROJECTION_V1 = 'cg-shared-v1' as const; +export const KA_TRANSFER_CHUNK_SIZE_V1 = '262144' as const; +export const KA_TRANSFER_CHUNK_SIZE_BYTES_V1 = 262_144n; +export const MIN_KA_TRANSFER_BYTES_V1 = 16n; +export const MAX_KA_TRANSFER_BYTES_V1 = 1_073_741_824n; +export const MAX_KA_TRANSFER_CHUNKS_V1 = 4096n; +export const MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1 = 512; +export const MAX_KA_TRANSFER_DESCRIPTOR_DEPTH_V1 = 1; +const UTF8 = new TextEncoder(); + +/** + * Exact nested author-catalog transfer value. It is not a signed control object + * and deliberately has no objectType or wrapper field. + */ +export interface KaTransferDescriptorV1 { + readonly codec: typeof KA_TRANSFER_CODEC_V1; + readonly projectionId: typeof KA_TRANSFER_PROJECTION_V1; + readonly projectionDigest: Digest32V1; + readonly byteLength: ByteLengthV1; + readonly chunkSize: typeof KA_TRANSFER_CHUNK_SIZE_V1; + readonly chunkCount: CountV1; + readonly blobDigest: Digest32V1; + readonly chunkTreeRoot: Digest32V1; +} + +export type KaTransferDescriptorErrorCode = + | 'descriptor-schema' + | 'descriptor-byte-length' + | 'descriptor-chunk-count' + | 'descriptor-chunk-size' + | 'noncanonical-unsigned-integer' + | 'noncanonical-digest' + | 'object-too-large'; + +export class KaTransferDescriptorError extends Error { + constructor( + readonly code: KaTransferDescriptorErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'KaTransferDescriptorError'; + } +} + +/** Validate one in-memory descriptor without signing, hashing, or storage side effects. */ +export function assertKaTransferDescriptorV1( + descriptor: unknown, +): asserts descriptor is KaTransferDescriptorV1 { + if (!isPlainRecord(descriptor)) { + fail('descriptor-schema', 'KA transfer descriptor must be a plain JSON object'); + } + + try { + assertExactKeys(descriptor, [ + 'blobDigest', + 'byteLength', + 'chunkCount', + 'chunkSize', + 'chunkTreeRoot', + 'codec', + 'projectionDigest', + 'projectionId', + ], 'KA transfer descriptor'); + } catch (cause) { + fail('descriptor-schema', 'KA transfer descriptor has an invalid field set', cause); + } + + if ( + descriptor.codec !== KA_TRANSFER_CODEC_V1 + || descriptor.projectionId !== KA_TRANSFER_PROJECTION_V1 + ) { + fail( + 'descriptor-schema', + `codec and projectionId must be ${KA_TRANSFER_CODEC_V1} and ${KA_TRANSFER_PROJECTION_V1}`, + ); + } + + assertDescriptorDigest(descriptor.projectionDigest, 'projectionDigest'); + assertDescriptorDigest(descriptor.blobDigest, 'blobDigest'); + assertDescriptorDigest(descriptor.chunkTreeRoot, 'chunkTreeRoot'); + + const byteLength = parseDescriptorU64(descriptor.byteLength, 'byteLength'); + if (byteLength < MIN_KA_TRANSFER_BYTES_V1 || byteLength > MAX_KA_TRANSFER_BYTES_V1) { + fail( + 'descriptor-byte-length', + `byteLength must be ${MIN_KA_TRANSFER_BYTES_V1}-${MAX_KA_TRANSFER_BYTES_V1}`, + ); + } + + if (descriptor.chunkSize !== KA_TRANSFER_CHUNK_SIZE_V1) { + fail('descriptor-chunk-size', `chunkSize must be ${KA_TRANSFER_CHUNK_SIZE_V1}`); + } + + const chunkCount = parseDescriptorU64(descriptor.chunkCount, 'chunkCount'); + const expectedChunkCount = + ((byteLength - 1n) / KA_TRANSFER_CHUNK_SIZE_BYTES_V1) + 1n; + if ( + chunkCount < 1n + || chunkCount > MAX_KA_TRANSFER_CHUNKS_V1 + || chunkCount !== expectedChunkCount + ) { + fail( + 'descriptor-chunk-count', + `chunkCount must equal ceil(byteLength/${KA_TRANSFER_CHUNK_SIZE_BYTES_V1})`, + ); + } +} + +/** Return the exact bounded RFC 8785 JCS descriptor string. */ +export function canonicalizeKaTransferDescriptorV1( + descriptor: KaTransferDescriptorV1, +): string { + assertKaTransferDescriptorV1(descriptor); + return canonicalizeJson(descriptor as unknown as CanonicalJsonValue, { + maxBytes: MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1, + maxDepth: MAX_KA_TRANSFER_DESCRIPTOR_DEPTH_V1, + }); +} + +/** Return the exact bounded RFC 8785 JCS descriptor bytes. */ +export function canonicalizeKaTransferDescriptorBytesV1( + descriptor: KaTransferDescriptorV1, +): Uint8Array { + return UTF8.encode(canonicalizeKaTransferDescriptorV1(descriptor)); +} + +/** + * Strictly decode a descriptor whose received bytes are already canonical JCS. + * The protocol's 512-byte ceiling is checked before parsing/materialization. + */ +export function parseCanonicalKaTransferDescriptorV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): KaTransferDescriptorV1 { + if (wireByteLength(input) > MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1) { + fail( + 'object-too-large', + `KA transfer descriptor exceeds ${MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1} bytes`, + ); + } + + const parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1, + MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1, + ), + maxDepth: Math.min( + options.maxDepth ?? MAX_KA_TRANSFER_DESCRIPTOR_DEPTH_V1, + MAX_KA_TRANSFER_DESCRIPTOR_DEPTH_V1, + ), + }); + assertKaTransferDescriptorV1(parsed); + return parsed; +} + +function assertDescriptorDigest(value: unknown, label: string): void { + if (value === null || typeof value !== 'string') { + fail('descriptor-schema', `${label} must be a string`); + } + try { + assertCanonicalDigest(value, label); + } catch (cause) { + fail('noncanonical-digest', `${label} is not a canonical Digest32V1`, cause); + } +} + +function parseDescriptorU64(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU64(value, label); + } catch (cause) { + fail( + 'noncanonical-unsigned-integer', + `${label} is not a canonical DecimalU64V1`, + cause, + ); + } +} + +function wireByteLength(input: string | Uint8Array): number { + if (typeof input !== 'string') return input.byteLength; + // UTF-8 length is never smaller than UTF-16 code-unit length. Avoid allocating + // a second hostile-size buffer merely to reject a string over the wire cap. + if (input.length > MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1) { + return MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1 + 1; + } + return UTF8.encode(input).byteLength; +} + +function fail( + code: KaTransferDescriptorErrorCode, + message: string, + cause?: unknown, +): never { + throw new KaTransferDescriptorError(code, message, cause === undefined ? {} : { cause }); +} diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index a278873154..4ec2ba7eff 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -8,6 +8,16 @@ import { type CanonicalJsonValue, type StrictJsonParseOptions, } from './canonical-json.js'; +import { + assertCanonicalChainId, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalHexBytes, + assertExactKeys, + isPlainRecord, +} from './sync-wire-scalars.js'; + +export { assertCanonicalEvmAddress } from './sync-wire-scalars.js'; export const CONTROL_OBJECT_DIGEST_DOMAIN = 'dkg-control-object-v1\n' as const; export const CONTROL_SIGNATURE_VARIANT_DIGEST_DOMAIN = @@ -57,9 +67,6 @@ export interface ControlObjectSignatureVariantV1 { const UTF8 = new TextEncoder(); const DOMAIN_BYTES = UTF8.encode(CONTROL_OBJECT_DIGEST_DOMAIN); const SIGNATURE_VARIANT_DOMAIN_BYTES = UTF8.encode(CONTROL_SIGNATURE_VARIANT_DIGEST_DOMAIN); -const EVM_ADDRESS = /^0x[0-9a-f]{40}$/; -const LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})+$/; -const CANONICAL_UNSIGNED_DECIMAL = /^(?:0|[1-9][0-9]*)$/; /** * Return the exact RFC-64 unsigned-envelope bytes used by every digest/signature @@ -220,15 +227,6 @@ export function parseCanonicalControlSignatureVariant( return variant; } -export function assertCanonicalEvmAddress(value: string, label = 'address'): void { - if (typeof value !== 'string' || !EVM_ADDRESS.test(value)) { - throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); - } - if (value === '0x0000000000000000000000000000000000000000') { - throw new Error(`${label} must not be the zero address`); - } -} - export function bytesToLowerHex(bytes: Uint8Array): string { let result = '0x'; for (const byte of bytes) result += byte.toString(16).padStart(2, '0'); @@ -319,10 +317,9 @@ function assertUnsignedControlEnvelopeFields( if (envelope.signatureEvidence.kind !== 'eip1271-current-finalized') { throw new Error('EIP-1271 control objects require current-finalized evidence'); } - if ( - typeof envelope.signatureEvidence.chainId !== 'string' - || !CANONICAL_UNSIGNED_DECIMAL.test(envelope.signatureEvidence.chainId) - ) { + try { + assertCanonicalChainId(envelope.signatureEvidence.chainId, 'EIP-1271 chainId'); + } catch { throw new Error('EIP-1271 chainId must be a canonical unsigned decimal string'); } assertCanonicalEvmAddress( @@ -356,38 +353,6 @@ function assertSignatureForSuite( ); } -function assertCanonicalDigest(value: string, label: string): void { - if ( - typeof value !== 'string' - || value.length !== 66 - || !/^0x[0-9a-f]{64}$/.test(value) - ) { - throw new Error(`${label} must be a lowercase 32-byte 0x digest`); - } -} - -function assertCanonicalHexBytes( - value: string, - label: string, - minBytes: number, - maxBytes: number, -): void { - if (typeof value !== 'string' || !value.startsWith('0x')) { - throw new Error(`${label} must be lowercase 0x-prefixed bytes`); - } - const hexLength = value.length - 2; - if ( - hexLength % 2 !== 0 - || hexLength < minBytes * 2 - || hexLength > maxBytes * 2 - || !LOWER_HEX_BYTES.test(value) - ) { - throw new Error( - `${label} must be ${minBytes === maxBytes ? `${minBytes}` : `${minBytes}-${maxBytes}`} lowercase bytes`, - ); - } -} - function extractUnsignedEnvelope( envelope: SignedControlEnvelopeV1, ): UnsignedControlEnvelopeV1 { @@ -437,33 +402,3 @@ function digestWithDomain(domain: Uint8Array, payload: Uint8Array): Uint8Array { preimage.set(payload, domain.length); return sha256(preimage); } - -function isPlainRecord(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function assertExactKeys( - record: Record, - expected: readonly string[], - label: string, -): void { - const actual = Reflect.ownKeys(record); - if (actual.some((key) => typeof key !== 'string')) { - throw new Error(`${label} must not contain symbol properties`); - } - const strings = actual as string[]; - if ( - strings.length !== expected.length - || [...strings].sort().some((key, index) => key !== expected[index]) - ) { - throw new Error(`${label} has unknown or missing fields`); - } - for (const key of strings) { - const descriptor = Object.getOwnPropertyDescriptor(record, key); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - throw new Error(`${label} fields must be enumerable data properties`); - } - } -} diff --git a/packages/core/src/sync-wire-scalars.ts b/packages/core/src/sync-wire-scalars.ts new file mode 100644 index 0000000000..4cf3c841c7 --- /dev/null +++ b/packages/core/src/sync-wire-scalars.ts @@ -0,0 +1,185 @@ +/** + * Closed RFC-64 wire-scalar validators shared by dormant sync codecs. + * + * Unsigned integers are decimal JSON strings so their full u64/u256 ranges do + * not pass through JavaScript's binary floating-point number representation. + * Block, index, count, length, and Unix-millisecond timestamp fields use u64; + * chain IDs use u256. + */ +export type DecimalU64V1 = string; +export type DecimalU256V1 = string; +export type ChainIdV1 = DecimalU256V1; +export type KaIdV1 = DecimalU256V1; +export type ReservedKaIdV1 = KaIdV1; +export type BatchIdV1 = KaIdV1; +export type StartKaIdV1 = KaIdV1; +export type EndKaIdV1 = KaIdV1; +export type BlockNumberV1 = DecimalU64V1; +export type IndexV1 = DecimalU64V1; +export type CountV1 = DecimalU64V1; +export type ByteLengthV1 = DecimalU64V1; +export type TimestampMsV1 = DecimalU64V1; +export type Digest32V1 = string; +export type EvmAddressV1 = string; + +export const MAX_DECIMAL_U64 = 18_446_744_073_709_551_615n; +export const MAX_DECIMAL_U256 = + 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; + +const CANONICAL_UNSIGNED_DECIMAL = /^(?:0|[1-9][0-9]*)$/; +const EVM_ADDRESS = /^0x[0-9a-f]{40}$/; +const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; +const LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})+$/; +const ZERO_EVM_ADDRESS = `0x${'00'.repeat(20)}`; + +export function parseCanonicalDecimalU64( + value: unknown, + label = 'value', +): bigint { + return parseCanonicalUnsignedDecimal(value, MAX_DECIMAL_U64, 'u64', label); +} + +export function assertCanonicalDecimalU64( + value: unknown, + label = 'value', +): asserts value is DecimalU64V1 { + parseCanonicalDecimalU64(value, label); +} + +export function parseCanonicalDecimalU256( + value: unknown, + label = 'value', +): bigint { + return parseCanonicalUnsignedDecimal(value, MAX_DECIMAL_U256, 'u256', label); +} + +export function assertCanonicalDecimalU256( + value: unknown, + label = 'value', +): asserts value is DecimalU256V1 { + parseCanonicalDecimalU256(value, label); +} + +/** ChainIdV1 is the full DecimalU256V1 structural domain, including zero. */ +export function assertCanonicalChainId( + value: unknown, + label = 'chainId', +): asserts value is DecimalU256V1 { + assertCanonicalDecimalU256(value, label); +} + +/** Packed v10 KA IDs use the full DecimalU256V1 domain, never u64. */ +export function assertCanonicalKaId( + value: unknown, + label = 'kaId', +): asserts value is KaIdV1 { + assertCanonicalDecimalU256(value, label); +} + +/** TimestampMsV1 is a u64 count of milliseconds from the Unix epoch. */ +export function assertCanonicalTimestampMs( + value: unknown, + label = 'timestampMs', +): asserts value is DecimalU64V1 { + assertCanonicalDecimalU64(value, label); +} + +export function assertCanonicalDigest( + value: unknown, + label = 'digest', +): asserts value is Digest32V1 { + if (typeof value !== 'string' || !CANONICAL_DIGEST_32.test(value)) { + throw new Error(`${label} must be a lowercase 32-byte 0x digest`); + } +} + +export function assertCanonicalEvmAddress( + value: unknown, + label = 'address', +): asserts value is EvmAddressV1 { + if (typeof value !== 'string' || !EVM_ADDRESS.test(value)) { + throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); + } + if (value === ZERO_EVM_ADDRESS) { + throw new Error(`${label} must not be the zero address`); + } +} + +export function assertCanonicalHexBytes( + value: unknown, + label: string, + minBytes: number, + maxBytes: number, +): asserts value is string { + if (typeof value !== 'string' || !value.startsWith('0x')) { + throw new Error(`${label} must be lowercase 0x-prefixed bytes`); + } + const hexLength = value.length - 2; + if ( + hexLength % 2 !== 0 + || hexLength < minBytes * 2 + || hexLength > maxBytes * 2 + || !LOWER_HEX_BYTES.test(value) + ) { + throw new Error( + `${label} must be ${minBytes === maxBytes ? `${minBytes}` : `${minBytes}-${maxBytes}`} lowercase bytes`, + ); + } +} + +export function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** Require one plain record to contain exactly enumerable string data fields. */ +export function assertExactKeys( + record: Record, + expected: readonly string[], + label: string, +): void { + const actual = Reflect.ownKeys(record); + if (actual.some((key) => typeof key !== 'string')) { + throw new Error(`${label} must not contain symbol properties`); + } + const strings = actual as string[]; + const sortedExpected = [...expected].sort(); + if ( + strings.length !== sortedExpected.length + || [...strings].sort().some((key, index) => key !== sortedExpected[index]) + ) { + throw new Error(`${label} has unknown or missing fields`); + } + for (const key of strings) { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} fields must be enumerable data properties`); + } + } +} + +function parseCanonicalUnsignedDecimal( + value: unknown, + max: bigint, + width: 'u64' | 'u256', + label: string, +): bigint { + if (typeof value !== 'string') { + throw new Error(`${label} must be a canonical unsigned decimal ${width} string`); + } + + // Bound both regex work and BigInt construction for hostile in-memory callers. + const maxDigits = width === 'u64' ? 20 : 78; + if (value.length > maxDigits) { + throw new Error(`${label} is outside the ${width} range`); + } + if (!CANONICAL_UNSIGNED_DECIMAL.test(value)) { + throw new Error(`${label} must be a canonical unsigned decimal ${width} string`); + } + const parsed = BigInt(value); + if (parsed > max) { + throw new Error(`${label} is outside the ${width} range`); + } + return parsed; +} diff --git a/packages/core/test/ka-transfer-descriptor.test.ts b/packages/core/test/ka-transfer-descriptor.test.ts new file mode 100644 index 0000000000..853c625f06 --- /dev/null +++ b/packages/core/test/ka-transfer-descriptor.test.ts @@ -0,0 +1,169 @@ +import { sha256 } from '@noble/hashes/sha2.js'; +import { describe, expect, it } from 'vitest'; + +import { + MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1, + assertKaTransferDescriptorV1, + canonicalizeKaTransferDescriptorBytesV1, + canonicalizeKaTransferDescriptorV1, + parseCanonicalKaTransferDescriptorV1, + type KaTransferDescriptorV1, +} from '../src/ka-transfer-descriptor.js'; + +const ZERO_DIGEST = `0x${'00'.repeat(32)}`; +const BLOB_DIGEST = `0x${'11'.repeat(32)}`; +const CHUNK_TREE_ROOT = `0x${'22'.repeat(32)}`; + +const VALID_MIN: KaTransferDescriptorV1 = { + codec: 'dkg-ka-bundle-v1', + projectionId: 'cg-shared-v1', + projectionDigest: ZERO_DIGEST, + byteLength: '16', + chunkSize: '262144', + chunkCount: '1', + blobDigest: BLOB_DIGEST, + chunkTreeRoot: CHUNK_TREE_ROOT, +}; + +// Normative RFC-64 fixture. Keep this literal independent from implementation output. +const VALID_MIN_CANONICAL = + '{"blobDigest":"0x1111111111111111111111111111111111111111111111111111111111111111","byteLength":"16","chunkCount":"1","chunkSize":"262144","chunkTreeRoot":"0x2222222222222222222222222222222222222222222222222222222222222222","codec":"dkg-ka-bundle-v1","projectionDigest":"0x0000000000000000000000000000000000000000000000000000000000000000","projectionId":"cg-shared-v1"}'; +const VALID_MIN_FIXTURE_SHA256 = + 'd3f1088dd50f7077865e8cf49e4e22d0aedba63720faa025ec8c89537cb7c80a'; + +describe('KaTransferDescriptorV1', () => { + it('pins the exact canonical valid-min fixture and independent fixture digest', () => { + expect(canonicalizeKaTransferDescriptorV1(VALID_MIN)).toBe(VALID_MIN_CANONICAL); + const bytes = canonicalizeKaTransferDescriptorBytesV1(VALID_MIN); + expect(bytes.byteLength).toBe(369); + expect(lowerHex(sha256(bytes))).toBe(VALID_MIN_FIXTURE_SHA256); + expect(parseCanonicalKaTransferDescriptorV1(bytes)).toEqual(VALID_MIN); + }); + + it.each([ + ['minimum', '16', '1', 369], + ['last byte of first chunk', '262144', '1', 373], + ['first byte of second chunk', '262145', '2', 373], + ['maximum', '1073741824', '4096', 380], + ])('accepts the %s boundary', (_name, byteLength, chunkCount, canonicalLength) => { + const descriptor = { ...VALID_MIN, byteLength, chunkCount }; + expect(() => assertKaTransferDescriptorV1(descriptor)).not.toThrow(); + expect(canonicalizeKaTransferDescriptorV1(descriptor).length).toBe(canonicalLength); + }); + + it('strictly accepts canonical bytes only', () => { + expect(parseCanonicalKaTransferDescriptorV1(VALID_MIN_CANONICAL)).toEqual(VALID_MIN); + expect(() => parseCanonicalKaTransferDescriptorV1(` ${VALID_MIN_CANONICAL}`)).toThrow( + /not RFC 8785 canonical/, + ); + const reordered = JSON.stringify(VALID_MIN); + expect(reordered).not.toBe(VALID_MIN_CANONICAL); + expect(() => parseCanonicalKaTransferDescriptorV1(reordered)).toThrow( + /not RFC 8785 canonical/, + ); + }); + + it.each([ + ['under-min', '15', '1'], + ['over-max', '1073741825', '4097'], + ])('rejects the %s byte-length boundary', (_name, byteLength, chunkCount) => { + expect(() => assertKaTransferDescriptorV1({ + ...VALID_MIN, + byteLength, + chunkCount, + })).toThrow(/descriptor-byte-length/); + }); + + it.each([ + ['262145', '1'], + ['16', '0'], + ['1073741824', '4095'], + ['1073741824', '4097'], + ])('rejects byteLength=%s with chunkCount=%s', (byteLength, chunkCount) => { + expect(() => assertKaTransferDescriptorV1({ + ...VALID_MIN, + byteLength, + chunkCount, + })).toThrow(/descriptor-chunk-count/); + }); + + it.each([ + ['byteLength', 16], + ['byteLength', '016'], + ['byteLength', '+16'], + ['byteLength', '18446744073709551616'], + ['chunkCount', 1], + ['chunkCount', '01'], + ])('rejects noncanonical unsigned %s=%j', (field, value) => { + expect(() => assertKaTransferDescriptorV1({ + ...VALID_MIN, + [field]: value, + })).toThrow(/noncanonical-unsigned-integer/); + }); + + it.each(['262143', 262144, null])('rejects noncanonical chunkSize=%j', (chunkSize) => { + expect(() => assertKaTransferDescriptorV1({ + ...VALID_MIN, + chunkSize, + })).toThrow(/descriptor-chunk-size/); + }); + + it('rejects malformed digest strings but permits the all-zero structural digest', () => { + expect(() => assertKaTransferDescriptorV1(VALID_MIN)).not.toThrow(); + expect(() => assertKaTransferDescriptorV1({ + ...VALID_MIN, + projectionDigest: `0x${'AA'.repeat(32)}`, + })).toThrow(/noncanonical-digest/); + expect(() => assertKaTransferDescriptorV1({ + ...VALID_MIN, + projectionDigest: `0x${'00'.repeat(31)}`, + })).toThrow(/noncanonical-digest/); + expect(() => assertKaTransferDescriptorV1({ + ...VALID_MIN, + blobDigest: null, + })).toThrow(/descriptor-schema/); + }); + + it('rejects missing, extra, objectType, wrapper, accessor, and symbol fields', () => { + const missing = { ...VALID_MIN } as Partial; + delete missing.blobDigest; + expect(() => assertKaTransferDescriptorV1(missing)).toThrow(/descriptor-schema/); + expect(() => assertKaTransferDescriptorV1({ ...VALID_MIN, extra: true })).toThrow( + /descriptor-schema/, + ); + expect(() => assertKaTransferDescriptorV1({ + ...VALID_MIN, + objectType: 'KaTransferDescriptorV1', + })).toThrow(/descriptor-schema/); + expect(() => assertKaTransferDescriptorV1({ transfer: VALID_MIN })).toThrow( + /descriptor-schema/, + ); + + const accessor = { ...VALID_MIN }; + Object.defineProperty(accessor, 'blobDigest', { + enumerable: true, + get: () => BLOB_DIGEST, + }); + expect(() => assertKaTransferDescriptorV1(accessor)).toThrow(/descriptor-schema/); + + const symbol = { ...VALID_MIN } as Record; + symbol[Symbol('hidden')] = true; + expect(() => assertKaTransferDescriptorV1(symbol)).toThrow(/descriptor-schema/); + }); + + it('rejects a 513-byte input before attempting JSON materialization', () => { + const hostile = '{'.padEnd(MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1 + 1, 'x'); + expect(new TextEncoder().encode(hostile).byteLength).toBe(513); + expect(() => parseCanonicalKaTransferDescriptorV1(hostile)).toThrow(/object-too-large/); + }); + + it('enforces the flat-object depth cap before schema validation', () => { + expect(() => parseCanonicalKaTransferDescriptorV1('{"nested":{}}')).toThrow( + /nesting exceeds 1/, + ); + }); +}); + +function lowerHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/packages/core/test/sync-control-object.test.ts b/packages/core/test/sync-control-object.test.ts index 7e29b3e13c..bd85d34f12 100644 --- a/packages/core/test/sync-control-object.test.ts +++ b/packages/core/test/sync-control-object.test.ts @@ -182,6 +182,23 @@ describe('Track-2 control-object envelopes', () => { })).toThrow(/canonical unsigned decimal/); }); + it('uses the complete canonical u256 range for EIP-1271 chain IDs', () => { + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { + ...SAFE_VECTOR.signatureEvidence, + chainId: '115792089237316195423570985008687907853269984665640564039457584007913129639935', + }, + })).not.toThrow(); + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { + ...SAFE_VECTOR.signatureEvidence, + chainId: '115792089237316195423570985008687907853269984665640564039457584007913129639936', + }, + })).toThrow(/canonical unsigned decimal/); + }); + it('rejects a non-string chain ID and evidence for a different contract', () => { expect(() => assertUnsignedControlEnvelope({ ...SAFE_VECTOR, diff --git a/packages/core/test/sync-wire-scalars.test.ts b/packages/core/test/sync-wire-scalars.test.ts new file mode 100644 index 0000000000..6b59c32c59 --- /dev/null +++ b/packages/core/test/sync-wire-scalars.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_DECIMAL_U64, + MAX_DECIMAL_U256, + assertCanonicalChainId, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalKaId, + assertCanonicalTimestampMs, + parseCanonicalDecimalU256, +} from '../src/sync-wire-scalars.js'; + +describe('RFC-64 sync wire scalar profile', () => { + it('accepts exact u64 boundaries and rejects noncanonical/out-of-range forms', () => { + expect(() => assertCanonicalDecimalU64('0')).not.toThrow(); + expect(() => assertCanonicalDecimalU64(MAX_DECIMAL_U64.toString())).not.toThrow(); + for (const value of [ + 0, + '00', + '+1', + '-1', + '1.0', + '1e3', + '', + (MAX_DECIMAL_U64 + 1n).toString(), + ]) { + expect(() => assertCanonicalDecimalU64(value), String(value)).toThrow(); + } + }); + + it('uses the full u256 domain for chain IDs', () => { + expect(parseCanonicalDecimalU256(MAX_DECIMAL_U256.toString())).toBe(MAX_DECIMAL_U256); + expect(() => assertCanonicalChainId('0')).not.toThrow(); + expect(() => assertCanonicalChainId((MAX_DECIMAL_U256 + 1n).toString())).toThrow( + /outside the u256 range/, + ); + }); + + it('keeps packed KA identifiers in the full u256 domain rather than u64', () => { + expect(() => assertCanonicalKaId(MAX_DECIMAL_U256.toString())).not.toThrow(); + expect(() => assertCanonicalKaId((MAX_DECIMAL_U256 + 1n).toString())).toThrow( + /outside the u256 range/, + ); + expect(() => assertCanonicalKaId('01')).toThrow(/canonical unsigned decimal u256/); + }); + + it('treats TimestampMsV1 as an exact Unix-millisecond u64', () => { + expect(() => assertCanonicalTimestampMs('1700000000123')).not.toThrow(); + expect(new Date(Number('1700000000123')).toISOString()).toBe('2023-11-14T22:13:20.123Z'); + expect(() => assertCanonicalTimestampMs('-1')).toThrow(); + }); + + it('accepts zero digests structurally and enforces lowercase fixed-width hex', () => { + expect(() => assertCanonicalDigest(`0x${'00'.repeat(32)}`)).not.toThrow(); + expect(() => assertCanonicalDigest(`0x${'AA'.repeat(32)}`)).toThrow(/lowercase 32-byte/); + expect(() => assertCanonicalDigest(`0x${'00'.repeat(31)}`)).toThrow(/lowercase 32-byte/); + }); + + it('accepts only lowercase nonzero fixed-width EVM addresses', () => { + expect(() => assertCanonicalEvmAddress(`0x${'11'.repeat(20)}`)).not.toThrow(); + expect(() => assertCanonicalEvmAddress(`0x${'AA'.repeat(20)}`)).toThrow( + /lowercase 20-byte/, + ); + expect(() => assertCanonicalEvmAddress(`0x${'00'.repeat(20)}`)).toThrow(/zero address/); + }); +}); From abcd3d48d84706ee762029f3210e8f6cab2ff1db Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 02:08:56 +0200 Subject: [PATCH 004/292] fix(core): reject lossy canonical JSON inputs --- packages/core/src/canonical-json.ts | 132 +++++++++++++++++++++- packages/core/test/canonical-json.test.ts | 20 ++++ 2 files changed, 147 insertions(+), 5 deletions(-) diff --git a/packages/core/src/canonical-json.ts b/packages/core/src/canonical-json.ts index b8458cf2b6..2806165b67 100644 --- a/packages/core/src/canonical-json.ts +++ b/packages/core/src/canonical-json.ts @@ -76,6 +76,12 @@ export function canonicalizeJson( } const object = input as object; + // Container depth is counted when entering the container, including when it + // is empty. This mirrors StrictJsonParser instead of relying on a recursive + // child visit to discover an over-limit empty object or array. + if (depth + 1 > maxDepth) { + throw new CanonicalJsonError(`JSON nesting exceeds ${maxDepth}`); + } if (ancestors.has(object)) { throw new CanonicalJsonError('Cyclic values are not JSON'); } @@ -407,13 +413,46 @@ class StrictJsonParser { } private parseNumber(): number { - const rest = this.text.slice(this.index); - const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(rest); - if (!match) this.fail('Invalid JSON number'); - const token = match[0]; - this.index += token.length; + const start = this.index; + + if (this.text[this.index] === '-') this.index += 1; + + if (this.text[this.index] === '0') { + this.index += 1; + } else if (isDigitOneToNine(this.text[this.index])) { + this.index += 1; + while (isDigit(this.text[this.index])) this.index += 1; + } else { + this.fail('Invalid JSON number'); + } + + if (this.text[this.index] === '.') { + this.index += 1; + if (!isDigit(this.text[this.index])) this.fail('Invalid JSON number'); + while (isDigit(this.text[this.index])) this.index += 1; + } + + if (this.text[this.index] === 'e' || this.text[this.index] === 'E') { + this.index += 1; + if (this.text[this.index] === '+' || this.text[this.index] === '-') { + this.index += 1; + } + if (!isDigit(this.text[this.index])) this.fail('Invalid JSON number'); + while (isDigit(this.text[this.index])) this.index += 1; + } + + const token = this.text.slice(start, this.index); const value = Number(token); if (!Number.isFinite(value)) this.fail('JSON number is outside finite IEEE-754 range'); + + // A strict parse must not silently replace the supplied decimal value with + // another finite double. Compare the exact decimal value with the JCS/JSON + // rendering of the parsed double. This still accepts equivalent spellings + // such as 1.0 and 1e0, while rejecting unsafe integers, underflow, and + // non-canonical decimals that round to a different mathematical value. + if (!sameExactDecimalValue(token, JSON.stringify(value))) { + this.fail('JSON number loses information when converted to IEEE-754'); + } return value; } @@ -446,6 +485,89 @@ class StrictJsonParser { } } +interface NormalizedDecimal { + readonly negative: boolean; + readonly digits: string; + readonly exponent: number; +} + +function sameExactDecimalValue(left: string, right: string): boolean { + const normalizedLeft = normalizeDecimalToken(left); + const normalizedRight = normalizeDecimalToken(right); + return normalizedLeft !== null + && normalizedRight !== null + && normalizedLeft.negative === normalizedRight.negative + && normalizedLeft.digits === normalizedRight.digits + && normalizedLeft.exponent === normalizedRight.exponent; +} + +/** Normalize one already-tokenized JSON decimal as digits * 10^exponent. */ +function normalizeDecimalToken(token: string): NormalizedDecimal | null { + const match = /^(-)?([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?$/.exec(token); + if (match === null) return null; + + const integerDigits = match[2]; + const fractionDigits = match[3] ?? ''; + let digits = integerDigits + fractionDigits; + + let firstNonZero = 0; + while (firstNonZero < digits.length && digits.charCodeAt(firstNonZero) === 0x30) { + firstNonZero += 1; + } + if (firstNonZero === digits.length) { + // All textual zero spellings, including -0 and large zero exponents, + // preserve the JSON numeric value zero. + return { negative: false, digits: '0', exponent: 0 }; + } + digits = digits.slice(firstNonZero); + + const explicitExponent = parseBoundedDecimalExponent(match[4]); + if (explicitExponent === null) return null; + let exponent = explicitExponent - fractionDigits.length; + + let end = digits.length; + while (end > 1 && digits.charCodeAt(end - 1) === 0x30) { + end -= 1; + exponent += 1; + } + + return { + negative: match[1] === '-', + digits: digits.slice(0, end), + exponent, + }; +} + +function parseBoundedDecimalExponent(raw: string | undefined): number | null { + if (raw === undefined) return 0; + let index = 0; + let sign = 1; + if (raw[index] === '+' || raw[index] === '-') { + if (raw[index] === '-') sign = -1; + index += 1; + } + while (raw.charCodeAt(index) === 0x30) index += 1; + const significant = raw.slice(index); + if (significant.length === 0) return 0; + + // With an 8 MiB input ceiling, a finite nonzero token can need at most a + // seven-digit exponent to offset its coefficient length. A longer value + // cannot be made exact by the bounded fraction and is rejected without + // constructing an attacker-sized BigInt. + if (significant.length > 15) return null; + const magnitude = Number(significant); + if (!Number.isSafeInteger(magnitude)) return null; + return sign * magnitude; +} + +function isDigit(value: string | undefined): boolean { + return value !== undefined && value >= '0' && value <= '9'; +} + +function isDigitOneToNine(value: string | undefined): boolean { + return value !== undefined && value >= '1' && value <= '9'; +} + function assertJsonObjectShape(record: Record): void { for (const key of Reflect.ownKeys(record)) { if (typeof key !== 'string') { diff --git a/packages/core/test/canonical-json.test.ts b/packages/core/test/canonical-json.test.ts index 294b47145e..746fb50ea2 100644 --- a/packages/core/test/canonical-json.test.ts +++ b/packages/core/test/canonical-json.test.ts @@ -112,6 +112,26 @@ describe('RFC 8785 canonical JSON', () => { /nesting exceeds 1/, ); expect(() => parseJsonStrict('[[]]', { maxDepth: 1 })).toThrow(/nesting exceeds 1/); + expect(() => canonicalizeJson({ a: {} }, { maxDepth: 1 })).toThrow( + /nesting exceeds 1/, + ); + expect(() => canonicalizeJson([[]], { maxDepth: 1 })).toThrow(/nesting exceeds 1/); + expect(canonicalizeJson({ a: 1 }, { maxDepth: 1 })).toBe('{"a":1}'); + expect(canonicalizeJson([1], { maxDepth: 1 })).toBe('[1]'); + expect(() => canonicalizeJson({}, { maxDepth: 0 })).toThrow(/nesting exceeds 0/); + expect(() => parseJsonStrict('{}', { maxDepth: 0 })).toThrow(/nesting exceeds 0/); + }); + + it('rejects decimal tokens that silently change when converted to IEEE-754', () => { + expect(() => parseJsonStrict('9007199254740993')).toThrow(/loses information/); + expect(() => parseJsonStrict('1e-324')).toThrow(/loses information/); + expect(() => parseJsonStrict('0.10000000000000001')).toThrow(/loses information/); + + expect(parseJsonStrict('9007199254740992')).toBe(9_007_199_254_740_992); + expect(parseJsonStrict('5e-324')).toBe(5e-324); + expect(parseJsonStrict('1.0')).toBe(1); + expect(parseJsonStrict('1e0')).toBe(1); + expect(parseJsonStrict('100000000000000000000000')).toBe(1e23); }); it('enforces exact UTF-8 byte and protocol depth boundaries in both directions', () => { From e6d8971f86e526f19d5942ca02530790dd9f90db Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 02:09:49 +0200 Subject: [PATCH 005/292] refactor(core): keep canonical JSON helpers internal --- packages/core/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c50e7ac94..08c42e3540 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,7 +13,6 @@ export * from './sparql-operation.js'; export * from './publisher-extension.js'; export * from './imported-artifact-bytes.js'; export * from './imported-artifact-metadata.js'; -export * from './canonical-json.js'; export * from './event-bus.js'; export { Logger, From d3d1f03854bd3f47d55858bc75e143e7e97273ed Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 02:14:57 +0200 Subject: [PATCH 006/292] refactor(core): tighten signed control envelopes --- packages/core/src/sync-control-object.ts | 96 +++++++++++++------ .../core/test/sync-control-object.test.ts | 64 +++++++++++++ 2 files changed, 133 insertions(+), 27 deletions(-) diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index a278873154..89a6966723 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -34,20 +34,43 @@ export type ControlObjectSignatureEvidence = readonly contractAddress: string; }; -export interface UnsignedControlEnvelopeV1 { +interface ControlEnvelopeBaseV1 { readonly objectType: string; readonly payload: Payload; - readonly signatureSuite: ControlObjectSignatureSuite; readonly issuer: string; - readonly signatureEvidence: ControlObjectSignatureEvidence; } -export interface SignedControlEnvelopeV1 - extends UnsignedControlEnvelopeV1 { +export interface Eip191UnsignedControlEnvelopeV1< + Payload extends CanonicalJsonValue = CanonicalJsonValue, +> extends ControlEnvelopeBaseV1 { + readonly signatureSuite: 'eip191-personal-sign-digest-v1'; + readonly signatureEvidence: { readonly kind: 'none' }; +} + +export interface Eip1271UnsignedControlEnvelopeV1< + Payload extends CanonicalJsonValue = CanonicalJsonValue, +> extends ControlEnvelopeBaseV1 { + readonly signatureSuite: 'eip1271-current-finalized-v1'; + readonly signatureEvidence: { + readonly kind: 'eip1271-current-finalized'; + readonly chainId: string; + readonly contractAddress: string; + }; +} + +export type UnsignedControlEnvelopeV1< + Payload extends CanonicalJsonValue = CanonicalJsonValue, +> = Eip191UnsignedControlEnvelopeV1 | Eip1271UnsignedControlEnvelopeV1; + +interface SignedControlEnvelopeFieldsV1 { readonly objectDigest: string; readonly signature: string; } +export type SignedControlEnvelopeV1< + Payload extends CanonicalJsonValue = CanonicalJsonValue, +> = UnsignedControlEnvelopeV1 & SignedControlEnvelopeFieldsV1; + export interface ControlObjectSignatureVariantV1 { readonly objectDigest: string; readonly signatureVariantDigest: string; @@ -60,6 +83,7 @@ const SIGNATURE_VARIANT_DOMAIN_BYTES = UTF8.encode(CONTROL_SIGNATURE_VARIANT_DIG const EVM_ADDRESS = /^0x[0-9a-f]{40}$/; const LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})+$/; const CANONICAL_UNSIGNED_DECIMAL = /^(?:0|[1-9][0-9]*)$/; +const MAX_U256 = (1n << 256n) - 1n; /** * Return the exact RFC-64 unsigned-envelope bytes used by every digest/signature @@ -135,7 +159,14 @@ export function parseCanonicalUnsignedControlEnvelope( export function assertSignedControlEnvelope( envelope: SignedControlEnvelopeV1, ): void { - validateSignedControlEnvelope(envelope, true); + validateSignedControlEnvelope(envelope); +} + +/** Return the exact bounded canonical bytes of a complete signed envelope. */ +export function canonicalizeSignedControlEnvelopeBytes( + envelope: SignedControlEnvelopeV1, +): Uint8Array { + return validateSignedControlEnvelope(envelope); } /** Strictly decode and validate a canonical signed wire envelope. */ @@ -148,7 +179,7 @@ export function parseCanonicalSignedControlEnvelope( throw new Error('Signed control-object envelope must be a plain JSON object'); } const envelope = parsed as unknown as SignedControlEnvelopeV1; - validateSignedControlEnvelope(envelope, false); + validateSignedControlEnvelope(envelope); return envelope; } @@ -220,7 +251,7 @@ export function parseCanonicalControlSignatureVariant( return variant; } -export function assertCanonicalEvmAddress(value: string, label = 'address'): void { +function assertCanonicalEvmAddress(value: string, label = 'address'): void { if (typeof value !== 'string' || !EVM_ADDRESS.test(value)) { throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); } @@ -229,7 +260,7 @@ export function assertCanonicalEvmAddress(value: string, label = 'address'): voi } } -export function bytesToLowerHex(bytes: Uint8Array): string { +function bytesToLowerHex(bytes: Uint8Array): string { let result = '0x'; for (const byte of bytes) result += byte.toString(16).padStart(2, '0'); return result; @@ -237,8 +268,7 @@ export function bytesToLowerHex(bytes: Uint8Array): string { function validateSignedControlEnvelope( envelope: SignedControlEnvelopeV1, - enforceSignedSize: boolean, -): void { +): Uint8Array { if (!isPlainRecord(envelope)) { throw new Error('Signed control-object envelope must be a plain JSON object'); } @@ -257,17 +287,15 @@ function validateSignedControlEnvelope( assertCanonicalDigest(envelope.objectDigest, 'objectDigest'); assertSignatureForSuite(envelope.signature, envelope.signatureSuite); const canonicalUnsigned = canonicalizeUnsignedControlEnvelopeBytesAfterFields(unsigned); - if ( - enforceSignedSize - && canonicalUnsigned.byteLength + signedEnvelopeAdditionalBytes(envelope) - > MAX_CONTROL_OBJECT_BYTES - ) { - throw new Error(`Signed control-object envelope exceeds ${MAX_CONTROL_OBJECT_BYTES} bytes`); - } + const canonicalSigned = canonicalizeJsonBytes(toCanonicalSignedEnvelope(envelope), { + maxBytes: MAX_CONTROL_OBJECT_BYTES, + maxDepth: MAX_CONTROL_OBJECT_DEPTH, + }); const expectedDigest = bytesToLowerHex(digestWithDomain(DOMAIN_BYTES, canonicalUnsigned)); if (envelope.objectDigest !== expectedDigest) { throw new Error(`Control-object digest mismatch: expected ${expectedDigest}`); } + return canonicalSigned; } function assertUnsignedControlEnvelopeFields( @@ -321,9 +349,11 @@ function assertUnsignedControlEnvelopeFields( } if ( typeof envelope.signatureEvidence.chainId !== 'string' + || envelope.signatureEvidence.chainId.length > 78 || !CANONICAL_UNSIGNED_DECIMAL.test(envelope.signatureEvidence.chainId) + || BigInt(envelope.signatureEvidence.chainId) > MAX_U256 ) { - throw new Error('EIP-1271 chainId must be a canonical unsigned decimal string'); + throw new Error('EIP-1271 chainId must be a canonical unsigned decimal u256 string'); } assertCanonicalEvmAddress( envelope.signatureEvidence.contractAddress, @@ -391,6 +421,15 @@ function assertCanonicalHexBytes( function extractUnsignedEnvelope( envelope: SignedControlEnvelopeV1, ): UnsignedControlEnvelopeV1 { + if (envelope.signatureSuite === 'eip191-personal-sign-digest-v1') { + return { + issuer: envelope.issuer, + objectType: envelope.objectType, + payload: envelope.payload, + signatureEvidence: envelope.signatureEvidence, + signatureSuite: envelope.signatureSuite, + }; + } return { issuer: envelope.issuer, objectType: envelope.objectType, @@ -420,15 +459,18 @@ function toCanonicalUnsignedEnvelope( }; } -function signedEnvelopeAdditionalBytes( +function toCanonicalSignedEnvelope( envelope: SignedControlEnvelopeV1, -): number { - // The signed form adds exactly two comma-delimited ASCII fields to the already - // non-empty unsigned object. Sorting changes placement, never byte length. - return ( - 1 + '"objectDigest":'.length + envelope.objectDigest.length + 2 - + 1 + '"signature":'.length + envelope.signature.length + 2 - ); +): CanonicalJsonValue { + const unsigned = toCanonicalUnsignedEnvelope(envelope) as Record< + string, + CanonicalJsonValue + >; + return { + ...unsigned, + objectDigest: envelope.objectDigest, + signature: envelope.signature, + }; } function digestWithDomain(domain: Uint8Array, payload: Uint8Array): Uint8Array { diff --git a/packages/core/test/sync-control-object.test.ts b/packages/core/test/sync-control-object.test.ts index 7e29b3e13c..b1fa5ede07 100644 --- a/packages/core/test/sync-control-object.test.ts +++ b/packages/core/test/sync-control-object.test.ts @@ -6,6 +6,7 @@ import { assertControlObjectDigest, assertSignedControlEnvelope, assertUnsignedControlEnvelope, + canonicalizeSignedControlEnvelopeBytes, canonicalizeUnsignedControlEnvelopeBytes, computeControlObjectDigestHex, computeControlSignatureVariantDigestHex, @@ -112,6 +113,8 @@ describe('Track-2 control-object envelopes', () => { }); it('strictly decodes and validates canonical EIP-191 and EIP-1271 signed envelopes', () => { + expect(new TextDecoder().decode(canonicalizeSignedControlEnvelopeBytes(EOA_SIGNED))) + .toBe(EOA_CANONICAL_SIGNED); expect(parseCanonicalSignedControlEnvelope(EOA_CANONICAL_SIGNED)).toMatchObject({ objectDigest: EOA_DIGEST, signature: EOA_SIGNATURE, @@ -200,6 +203,25 @@ describe('Track-2 control-object envelopes', () => { })).toThrow(/must equal the envelope issuer/); }); + it('enforces the full unsigned 256-bit chainId boundary before BigInt conversion', () => { + const maxU256 = ((1n << 256n) - 1n).toString(); + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { ...SAFE_VECTOR.signatureEvidence, chainId: maxU256 }, + })).not.toThrow(); + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { + ...SAFE_VECTOR.signatureEvidence, + chainId: (1n << 256n).toString(), + }, + })).toThrow(/u256/); + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureEvidence: { ...SAFE_VECTOR.signatureEvidence, chainId: '9'.repeat(100_000) }, + })).toThrow(/u256/); + }); + it('rejects non-canonical and zero EVM issuers', () => { expect(() => assertUnsignedControlEnvelope({ ...EOA_VECTOR, @@ -311,6 +333,48 @@ describe('Track-2 control-object envelopes', () => { ); }); + it('enforces the complete signed-envelope byte ceiling through canonical serialization', () => { + const emptyPayloadEnvelope: UnsignedControlEnvelopeV1 = { + ...EOA_VECTOR, + payload: { blob: '' }, + }; + const unsignedBaseBytes = canonicalizeUnsignedControlEnvelopeBytes( + emptyPayloadEnvelope, + ).byteLength; + const unsignedAtLimit: UnsignedControlEnvelopeV1 = { + ...emptyPayloadEnvelope, + payload: { blob: 'x'.repeat(MAX_CONTROL_OBJECT_BYTES - unsignedBaseBytes) }, + }; + expect(canonicalizeUnsignedControlEnvelopeBytes(unsignedAtLimit)).toHaveLength( + MAX_CONTROL_OBJECT_BYTES, + ); + expect(() => assertSignedControlEnvelope({ + ...unsignedAtLimit, + objectDigest: computeControlObjectDigestHex(unsignedAtLimit), + signature: EOA_SIGNATURE, + })).toThrow(/Canonical JSON exceeds/); + + const emptySigned: SignedControlEnvelopeV1 = { + ...emptyPayloadEnvelope, + objectDigest: computeControlObjectDigestHex(emptyPayloadEnvelope), + signature: EOA_SIGNATURE, + }; + const signedBaseBytes = canonicalizeSignedControlEnvelopeBytes(emptySigned).byteLength; + const signedAtLimitUnsigned: UnsignedControlEnvelopeV1 = { + ...emptyPayloadEnvelope, + payload: { blob: 'x'.repeat(MAX_CONTROL_OBJECT_BYTES - signedBaseBytes) }, + }; + const signedAtLimit: SignedControlEnvelopeV1 = { + ...signedAtLimitUnsigned, + objectDigest: computeControlObjectDigestHex(signedAtLimitUnsigned), + signature: EOA_SIGNATURE, + }; + expect(canonicalizeSignedControlEnvelopeBytes(signedAtLimit)).toHaveLength( + MAX_CONTROL_OBJECT_BYTES, + ); + expect(() => assertSignedControlEnvelope(signedAtLimit)).not.toThrow(); + }); + it('rejects malformed or mismatched detached signature variants', () => { const mismatched = EOA_CANONICAL_SIGNATURE_VARIANT.replace( EOA_SIGNATURE_VARIANT_DIGEST, From 6a440830d28f40751d0d68c44cade2113a0c80c2 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 02:17:51 +0200 Subject: [PATCH 007/292] feat(core): add RFC-64 opaque bundle framing --- packages/core/src/ka-bundle-v1.ts | 250 ++++++++++++++++++++++++ packages/core/test/ka-bundle-v1.test.ts | 208 ++++++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 packages/core/src/ka-bundle-v1.ts create mode 100644 packages/core/test/ka-bundle-v1.test.ts diff --git a/packages/core/src/ka-bundle-v1.ts b/packages/core/src/ka-bundle-v1.ts new file mode 100644 index 0000000000..6446adb713 --- /dev/null +++ b/packages/core/src/ka-bundle-v1.ts @@ -0,0 +1,250 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + +import { + MAX_KA_TRANSFER_BYTES_V1, + MIN_KA_TRANSFER_BYTES_V1, +} from './ka-transfer-descriptor.js'; +import type { Digest32V1 } from './sync-wire-scalars.js'; + +export const KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1 = 'dkg-ka-projection-v1\n' as const; +export const KA_BUNDLE_BLOB_DIGEST_DOMAIN_V1 = 'dkg-ka-transfer-v1\n' as const; +export const KA_BUNDLE_U64_PREFIX_BYTES_V1 = 8; +export const MIN_KA_BUNDLE_BYTES_V1 = MIN_KA_TRANSFER_BYTES_V1; +export const MAX_KA_BUNDLE_BYTES_V1 = MAX_KA_TRANSFER_BYTES_V1; + +const MAX_U64 = 18_446_744_073_709_551_615n; +const UTF8 = new TextEncoder(); +const PROJECTION_DIGEST_DOMAIN_BYTES = UTF8.encode(KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1); +const BLOB_DIGEST_DOMAIN_BYTES = UTF8.encode(KA_BUNDLE_BLOB_DIGEST_DOMAIN_V1); + +export type KaBundleV1ErrorCode = + | 'bundle-byte-length' + | 'bundle-truncated' + | 'bundle-length-overflow' + | 'bundle-trailing-bytes'; + +export class KaBundleV1Error extends Error { + constructor( + readonly code: KaBundleV1ErrorCode, + message: string, + ) { + super(`[${code}] ${message}`); + this.name = 'KaBundleV1Error'; + } +} + +export interface EncodedOpaqueKaBundleV1 { + /** Exact `u64be(projection length) || projection || u64be(seal length) || seal`. */ + readonly bundleBytes: Uint8Array; + readonly projectionDigest: Digest32V1; + readonly blobDigest: Digest32V1; +} + +export interface DecodedOpaqueKaBundleV1 { + /** + * Borrowed zero-copy view into the received bundle. It carries no RDF semantic + * claim and is valid only while the caller leaves the input bytes unchanged. + */ + readonly projectionBytes: Uint8Array; + /** + * Borrowed zero-copy view into the received bundle. It carries no seal semantic + * claim and must be copied or ownership-pinned before asynchronous retention. + */ + readonly sealBytes: Uint8Array; + readonly projectionDigest: Digest32V1; + readonly blobDigest: Digest32V1; +} + +/** + * Validate the exact v1 component-length arithmetic without allocating a bundle. + * Inputs are bigint so no candidate u64 ever passes through binary floating point. + */ +export function calculateOpaqueKaBundleByteLengthV1( + projectionByteLength: bigint, + sealByteLength: bigint, +): bigint { + assertU64Length(projectionByteLength, 'projectionByteLength'); + assertU64Length(sealByteLength, 'sealByteLength'); + + const payloadLimit = MAX_KA_BUNDLE_BYTES_V1 - MIN_KA_BUNDLE_BYTES_V1; + if ( + projectionByteLength > payloadLimit + || sealByteLength > payloadLimit - projectionByteLength + ) { + fail( + 'bundle-length-overflow', + `component lengths exceed the ${MAX_KA_BUNDLE_BYTES_V1}-byte v1 bundle limit`, + ); + } + + const totalByteLength = MIN_KA_BUNDLE_BYTES_V1 + + projectionByteLength + + sealByteLength; + // Keep the complete-length gate shared with the decoder and advertised-length + // admission path even though the component checks above already prove this bound. + assertOpaqueKaBundleByteLengthV1(totalByteLength); + return totalByteLength; +} + +/** Validate an advertised/received complete bundle length before any payload work. */ +export function assertOpaqueKaBundleByteLengthV1(totalByteLength: bigint): void { + if ( + typeof totalByteLength !== 'bigint' + || totalByteLength < MIN_KA_BUNDLE_BYTES_V1 + || totalByteLength > MAX_KA_BUNDLE_BYTES_V1 + ) { + fail( + 'bundle-byte-length', + `bundle length must be ${MIN_KA_BUNDLE_BYTES_V1}-${MAX_KA_BUNDLE_BYTES_V1} bytes`, + ); + } +} + +/** + * Frame two opaque byte strings and compute both RFC-64 digests incrementally. + * This function does not validate RDF, a structured root, or an author seal. + */ +export function encodeOpaqueKaBundleV1( + projectionBytes: Uint8Array, + sealBytes: Uint8Array, +): EncodedOpaqueKaBundleV1 { + assertUint8Array(projectionBytes, 'projectionBytes'); + assertUint8Array(sealBytes, 'sealBytes'); + + const projectionByteLength = BigInt(projectionBytes.byteLength); + const sealByteLength = BigInt(sealBytes.byteLength); + const totalByteLength = calculateOpaqueKaBundleByteLengthV1( + projectionByteLength, + sealByteLength, + ); + + // The protocol ceiling is 1 GiB, so the checked bigint is exactly representable. + const bundleBytes = new Uint8Array(Number(totalByteLength)); + const projectionPrefix = encodeU64Be(projectionByteLength); + const sealPrefix = encodeU64Be(sealByteLength); + let cursor = 0; + bundleBytes.set(projectionPrefix, cursor); + cursor += KA_BUNDLE_U64_PREFIX_BYTES_V1; + bundleBytes.set(projectionBytes, cursor); + cursor += projectionBytes.byteLength; + bundleBytes.set(sealPrefix, cursor); + cursor += KA_BUNDLE_U64_PREFIX_BYTES_V1; + bundleBytes.set(sealBytes, cursor); + + const finalizedProjectionBytes = bundleBytes.subarray( + KA_BUNDLE_U64_PREFIX_BYTES_V1, + KA_BUNDLE_U64_PREFIX_BYTES_V1 + projectionBytes.byteLength, + ); + + return { + bundleBytes, + projectionDigest: digestToLowerHex( + PROJECTION_DIGEST_DOMAIN_BYTES, + finalizedProjectionBytes, + ), + blobDigest: digestToLowerHex(BLOB_DIGEST_DOMAIN_BYTES, bundleBytes), + }; +} + +/** + * Strictly decode one complete frame. Component bytes are returned as zero-copy views; + * all framing checks finish before either digest is updated. + */ +export function decodeOpaqueKaBundleV1(bundleBytes: Uint8Array): DecodedOpaqueKaBundleV1 { + assertUint8Array(bundleBytes, 'bundleBytes'); + + const receivedByteLength = BigInt(bundleBytes.byteLength); + assertOpaqueKaBundleByteLengthV1(receivedByteLength); + + const projectionByteLength = decodeU64Be(bundleBytes, 0); + // This shared arithmetic check distinguishes an impossible v1 declaration from a + // merely truncated received frame, without converting an unchecked u64 to number. + const minimumWithProjection = calculateOpaqueKaBundleByteLengthV1( + projectionByteLength, + 0n, + ); + if (minimumWithProjection > receivedByteLength) { + fail('bundle-truncated', 'projection payload or seal-length prefix is truncated'); + } + + const projectionStart = KA_BUNDLE_U64_PREFIX_BYTES_V1; + const projectionEnd = projectionStart + Number(projectionByteLength); + const sealByteLength = decodeU64Be(bundleBytes, projectionEnd); + const expectedByteLength = calculateOpaqueKaBundleByteLengthV1( + projectionByteLength, + sealByteLength, + ); + + if (expectedByteLength > receivedByteLength) { + fail('bundle-truncated', 'seal payload is truncated'); + } + if (expectedByteLength < receivedByteLength) { + fail('bundle-trailing-bytes', 'bundle contains bytes after the declared seal payload'); + } + + const sealStart = projectionEnd + KA_BUNDLE_U64_PREFIX_BYTES_V1; + const sealEnd = sealStart + Number(sealByteLength); + const projectionBytes = bundleBytes.subarray(projectionStart, projectionEnd); + const sealBytes = bundleBytes.subarray(sealStart, sealEnd); + + return { + projectionBytes, + sealBytes, + projectionDigest: digestToLowerHex(PROJECTION_DIGEST_DOMAIN_BYTES, projectionBytes), + blobDigest: digestToLowerHex(BLOB_DIGEST_DOMAIN_BYTES, bundleBytes), + }; +} + +function assertU64Length(value: bigint, label: string): void { + if (typeof value !== 'bigint' || value < 0n || value > MAX_U64) { + fail('bundle-length-overflow', `${label} is not an unsigned 64-bit length`); + } +} + +function assertUint8Array(value: unknown, label: string): asserts value is Uint8Array { + if (!(value instanceof Uint8Array)) { + throw new TypeError(`${label} must be a Uint8Array`); + } + if (!(value.buffer instanceof ArrayBuffer)) { + throw new TypeError(`${label} must not use shared backing memory`); + } + if ((value.buffer as ArrayBuffer & { readonly resizable?: boolean }).resizable === true) { + throw new TypeError(`${label} must not use resizable backing memory`); + } +} + +function encodeU64Be(value: bigint): Uint8Array { + assertU64Length(value, 'u64be value'); + const encoded = new Uint8Array(KA_BUNDLE_U64_PREFIX_BYTES_V1); + let remaining = value; + for (let index = encoded.length - 1; index >= 0; index -= 1) { + encoded[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return encoded; +} + +function decodeU64Be(bytes: Uint8Array, offset: number): bigint { + if (offset < 0 || offset + KA_BUNDLE_U64_PREFIX_BYTES_V1 > bytes.byteLength) { + fail('bundle-truncated', 'u64be length prefix is truncated'); + } + let value = 0n; + for (let index = offset; index < offset + KA_BUNDLE_U64_PREFIX_BYTES_V1; index += 1) { + value = (value << 8n) | BigInt(bytes[index]); + } + return value; +} + +function digestToLowerHex(domain: Uint8Array, ...chunks: readonly Uint8Array[]): Digest32V1 { + const hasher = sha256.create(); + hasher.update(domain); + for (const chunk of chunks) hasher.update(chunk); + const digest = hasher.digest(); + let result = '0x'; + for (const byte of digest) result += byte.toString(16).padStart(2, '0'); + return result; +} + +function fail(code: KaBundleV1ErrorCode, message: string): never { + throw new KaBundleV1Error(code, message); +} diff --git a/packages/core/test/ka-bundle-v1.test.ts b/packages/core/test/ka-bundle-v1.test.ts new file mode 100644 index 0000000000..236b043c68 --- /dev/null +++ b/packages/core/test/ka-bundle-v1.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_KA_BUNDLE_BYTES_V1, + assertOpaqueKaBundleByteLengthV1, + calculateOpaqueKaBundleByteLengthV1, + decodeOpaqueKaBundleV1, + encodeOpaqueKaBundleV1, + type KaBundleV1ErrorCode, +} from '../src/ka-bundle-v1.js'; + +const EMPTY_EMPTY_BUNDLE = '00000000000000000000000000000000'; +const EMPTY_PROJECTION_DIGEST = + '0x4d798c66290f2feed54b20ad25eab62df38360cab298332be5e6d921ad1b5f3c'; +const EMPTY_EMPTY_BLOB_DIGEST = + '0x83bb2c1cb5e6d45dd63f06f085d1167f0a8b8b504be1117b839c479f1546ea18'; + +const A_BC_BUNDLE = '00000000000000016100000000000000026263'; +const A_PROJECTION_DIGEST = + '0xdae61dbbf6d5ff323951a573fda1ec18a51c77a00811a4574adc2639e0ed72cb'; +const A_BC_BLOB_DIGEST = + '0xd632761c3a93b54f7e84ec45934bec3eb4a32508f50e8d37927f7dff0e52f17c'; + +describe('RFC-64 dormant opaque KA bundle framing', () => { + it('matches the exact empty-empty conformance vector', () => { + const encoded = encodeOpaqueKaBundleV1(new Uint8Array(), new Uint8Array()); + expect(lowerHex(encoded.bundleBytes)).toBe(EMPTY_EMPTY_BUNDLE); + expect(encoded.projectionDigest).toBe(EMPTY_PROJECTION_DIGEST); + expect(encoded.blobDigest).toBe(EMPTY_EMPTY_BLOB_DIGEST); + + const decoded = decodeOpaqueKaBundleV1(fromHex(EMPTY_EMPTY_BUNDLE)); + expect(decoded.projectionBytes).toEqual(new Uint8Array()); + expect(decoded.sealBytes).toEqual(new Uint8Array()); + expect(decoded.projectionDigest).toBe(EMPTY_PROJECTION_DIGEST); + expect(decoded.blobDigest).toBe(EMPTY_EMPTY_BLOB_DIGEST); + }); + + it('matches the exact a-bc conformance vector in network byte order', () => { + const encoded = encodeOpaqueKaBundleV1(fromHex('61'), fromHex('6263')); + expect(lowerHex(encoded.bundleBytes)).toBe(A_BC_BUNDLE); + expect(encoded.projectionDigest).toBe(A_PROJECTION_DIGEST); + expect(encoded.blobDigest).toBe(A_BC_BLOB_DIGEST); + + const decoded = decodeOpaqueKaBundleV1(fromHex(A_BC_BUNDLE)); + expect(lowerHex(decoded.projectionBytes)).toBe('61'); + expect(lowerHex(decoded.sealBytes)).toBe('6263'); + expect(decoded.projectionDigest).toBe(A_PROJECTION_DIGEST); + expect(decoded.blobDigest).toBe(A_BC_BLOB_DIGEST); + }); + + it('returns zero-copy component views from a decoded bundle', () => { + const bundle = fromHex(A_BC_BUNDLE); + const decoded = decodeOpaqueKaBundleV1(bundle); + expect(decoded.projectionBytes.buffer).toBe(bundle.buffer); + expect(decoded.sealBytes.buffer).toBe(bundle.buffer); + expect(decoded.projectionBytes.byteOffset).toBe(bundle.byteOffset + 8); + expect(decoded.sealBytes.byteOffset).toBe(bundle.byteOffset + 17); + }); + + it('rejects shared backing memory before copying, parsing, or hashing', () => { + const sharedProjection = new Uint8Array(new SharedArrayBuffer(1)); + sharedProjection[0] = 0x61; + const sharedBundle = new Uint8Array(new SharedArrayBuffer(16)); + + expect(() => encodeOpaqueKaBundleV1(sharedProjection, new Uint8Array())) + .toThrow(/shared backing memory/); + expect(() => decodeOpaqueKaBundleV1(sharedBundle)).toThrow(/shared backing memory/); + }); + + it('rejects resizable ArrayBuffer views when the runtime supports them', () => { + const ResizableArrayBuffer = ArrayBuffer as unknown as new ( + byteLength: number, + options: { maxByteLength: number }, + ) => ArrayBuffer; + let backing: ArrayBuffer; + try { + backing = new ResizableArrayBuffer(16, { maxByteLength: 32 }); + } catch { + return; + } + if ((backing as ArrayBuffer & { readonly resizable?: boolean }).resizable !== true) return; + expect(() => decodeOpaqueKaBundleV1(new Uint8Array(backing))) + .toThrow(/resizable backing memory/); + }); + + it('validates maximum arithmetic without allocating a 1 GiB fixture', () => { + expect(calculateOpaqueKaBundleByteLengthV1(1_073_741_808n, 0n)).toBe( + MAX_KA_BUNDLE_BYTES_V1, + ); + expectFailureCode( + () => calculateOpaqueKaBundleByteLengthV1(1_073_741_809n, 0n), + 'bundle-length-overflow', + ); + expectFailureCode( + () => calculateOpaqueKaBundleByteLengthV1(1_073_741_808n, 1n), + 'bundle-length-overflow', + ); + }); + + it('rejects total lengths outside 16..1 GiB before frame parsing', () => { + expectFailureCode( + () => assertOpaqueKaBundleByteLengthV1(15n), + 'bundle-byte-length', + ); + expectFailureCode( + () => assertOpaqueKaBundleByteLengthV1(1_073_741_825n), + 'bundle-byte-length', + ); + expectFailureCode( + () => decodeOpaqueKaBundleV1(new Uint8Array(15)), + 'bundle-byte-length', + ); + expectFailureCode( + () => decodeOpaqueKaBundleV1(new Uint8Array(7)), + 'bundle-byte-length', + ); + }); + + it('rejects truncated projection and seal payloads', () => { + // projection length 1, but no projection byte plus complete second prefix + expectFailureCode( + () => decodeOpaqueKaBundleV1(fromHex( + '00000000000000010000000000000000', + )), + 'bundle-truncated', + ); + + // empty projection, declared one-byte seal, no seal payload + expectFailureCode( + () => decodeOpaqueKaBundleV1(fromHex( + '00000000000000000000000000000001', + )), + 'bundle-truncated', + ); + }); + + it('rejects projection and seal u64 declarations outside the v1 total bound', () => { + expectFailureCode( + () => decodeOpaqueKaBundleV1(fromHex( + 'ffffffffffffffff0000000000000000', + )), + 'bundle-length-overflow', + ); + expectFailureCode( + () => decodeOpaqueKaBundleV1(fromHex( + '0000000000000000ffffffffffffffff', + )), + 'bundle-length-overflow', + ); + }); + + it('rejects trailing bytes and never treats them as a second record', () => { + expectFailureCode( + () => decodeOpaqueKaBundleV1(fromHex(`${EMPTY_EMPTY_BUNDLE}00`)), + 'bundle-trailing-bytes', + ); + expectFailureCode( + () => decodeOpaqueKaBundleV1(fromHex( + `${EMPTY_EMPTY_BUNDLE}${EMPTY_EMPTY_BUNDLE}`, + )), + 'bundle-trailing-bytes', + ); + }); + + it('never reinterprets a little-endian projection length', () => { + expectFailureCode( + () => decodeOpaqueKaBundleV1(fromHex( + '01000000000000006100000000000000026263', + )), + 'bundle-length-overflow', + ); + }); + + it('rejects non-u64 and negative component arithmetic inputs', () => { + expectFailureCode( + () => calculateOpaqueKaBundleByteLengthV1(-1n, 0n), + 'bundle-length-overflow', + ); + expectFailureCode( + () => calculateOpaqueKaBundleByteLengthV1(18_446_744_073_709_551_616n, 0n), + 'bundle-length-overflow', + ); + }); +}); + +function expectFailureCode(operation: () => unknown, expected: KaBundleV1ErrorCode): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error & { code?: unknown }).code).toBe(expected); + return; + } + throw new Error(`expected operation to fail with ${expected}`); +} + +function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0 || !/^[0-9a-f]*$/.test(hex)) throw new Error('invalid test hex'); + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +function lowerHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} From 52e7155b537aa7f7a8326457bb7cd2368c585dd7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 02:22:55 +0200 Subject: [PATCH 008/292] refactor(core): harden RFC-64 wire types --- packages/core/src/ka-transfer-descriptor.ts | 3 +- packages/core/src/sync-control-object.ts | 3 +- packages/core/src/sync-wire-objects.ts | 32 ++++++++++ packages/core/src/sync-wire-scalars.ts | 57 ++++++------------ .../core/test/ka-transfer-descriptor.test.ts | 11 +++- packages/core/test/sync-wire-objects.test.ts | 59 +++++++++++++++++++ 6 files changed, 120 insertions(+), 45 deletions(-) create mode 100644 packages/core/src/sync-wire-objects.ts create mode 100644 packages/core/test/sync-wire-objects.test.ts diff --git a/packages/core/src/ka-transfer-descriptor.ts b/packages/core/src/ka-transfer-descriptor.ts index fc719c8329..c38b9110d1 100644 --- a/packages/core/src/ka-transfer-descriptor.ts +++ b/packages/core/src/ka-transfer-descriptor.ts @@ -6,13 +6,12 @@ import { } from './canonical-json.js'; import { assertCanonicalDigest, - assertExactKeys, - isPlainRecord, parseCanonicalDecimalU64, type ByteLengthV1, type CountV1, type Digest32V1, } from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; export const KA_TRANSFER_CODEC_V1 = 'dkg-ka-bundle-v1' as const; export const KA_TRANSFER_PROJECTION_V1 = 'cg-shared-v1' as const; diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index 30f99ff2b0..ccee6317c9 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -13,9 +13,8 @@ import { assertCanonicalDigest, assertCanonicalEvmAddress, assertCanonicalHexBytes, - assertExactKeys, - isPlainRecord, } from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; export const CONTROL_OBJECT_DIGEST_DOMAIN = 'dkg-control-object-v1\n' as const; export const CONTROL_SIGNATURE_VARIANT_DIGEST_DOMAIN = diff --git a/packages/core/src/sync-wire-objects.ts b/packages/core/src/sync-wire-objects.ts new file mode 100644 index 0000000000..677abc0c1f --- /dev/null +++ b/packages/core/src/sync-wire-objects.ts @@ -0,0 +1,32 @@ +/** Internal closed-object helpers shared by dormant RFC-64 wire codecs. */ +export function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** Require one plain record to contain exactly enumerable string data fields. */ +export function assertExactKeys( + record: Record, + expected: readonly string[], + label: string, +): void { + const actual = Reflect.ownKeys(record); + if (actual.some((key) => typeof key !== 'string')) { + throw new Error(`${label} must not contain symbol properties`); + } + const strings = actual as string[]; + const sortedExpected = [...expected].sort(); + if ( + strings.length !== sortedExpected.length + || [...strings].sort().some((key, index) => key !== sortedExpected[index]) + ) { + throw new Error(`${label} has unknown or missing fields`); + } + for (const key of strings) { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} fields must be enumerable data properties`); + } + } +} diff --git a/packages/core/src/sync-wire-scalars.ts b/packages/core/src/sync-wire-scalars.ts index 4cf3c841c7..78d1221099 100644 --- a/packages/core/src/sync-wire-scalars.ts +++ b/packages/core/src/sync-wire-scalars.ts @@ -6,8 +6,17 @@ * Block, index, count, length, and Unix-millisecond timestamp fields use u64; * chain IDs use u256. */ -export type DecimalU64V1 = string; -export type DecimalU256V1 = string; +declare const DECIMAL_U64_V1_BRAND: unique symbol; +declare const DECIMAL_U256_V1_BRAND: unique symbol; +declare const DIGEST_32_V1_BRAND: unique symbol; +declare const EVM_ADDRESS_V1_BRAND: unique symbol; + +export type DecimalU64V1 = string & { + readonly [DECIMAL_U64_V1_BRAND]: true; +}; +export type DecimalU256V1 = string & { + readonly [DECIMAL_U256_V1_BRAND]: true; +}; export type ChainIdV1 = DecimalU256V1; export type KaIdV1 = DecimalU256V1; export type ReservedKaIdV1 = KaIdV1; @@ -19,8 +28,12 @@ export type IndexV1 = DecimalU64V1; export type CountV1 = DecimalU64V1; export type ByteLengthV1 = DecimalU64V1; export type TimestampMsV1 = DecimalU64V1; -export type Digest32V1 = string; -export type EvmAddressV1 = string; +export type Digest32V1 = string & { + readonly [DIGEST_32_V1_BRAND]: true; +}; +export type EvmAddressV1 = string & { + readonly [EVM_ADDRESS_V1_BRAND]: true; +}; export const MAX_DECIMAL_U64 = 18_446_744_073_709_551_615n; export const MAX_DECIMAL_U256 = @@ -64,7 +77,7 @@ export function assertCanonicalDecimalU256( export function assertCanonicalChainId( value: unknown, label = 'chainId', -): asserts value is DecimalU256V1 { +): asserts value is ChainIdV1 { assertCanonicalDecimalU256(value, label); } @@ -80,7 +93,7 @@ export function assertCanonicalKaId( export function assertCanonicalTimestampMs( value: unknown, label = 'timestampMs', -): asserts value is DecimalU64V1 { +): asserts value is TimestampMsV1 { assertCanonicalDecimalU64(value, label); } @@ -127,38 +140,6 @@ export function assertCanonicalHexBytes( } } -export function isPlainRecord(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -/** Require one plain record to contain exactly enumerable string data fields. */ -export function assertExactKeys( - record: Record, - expected: readonly string[], - label: string, -): void { - const actual = Reflect.ownKeys(record); - if (actual.some((key) => typeof key !== 'string')) { - throw new Error(`${label} must not contain symbol properties`); - } - const strings = actual as string[]; - const sortedExpected = [...expected].sort(); - if ( - strings.length !== sortedExpected.length - || [...strings].sort().some((key, index) => key !== sortedExpected[index]) - ) { - throw new Error(`${label} has unknown or missing fields`); - } - for (const key of strings) { - const descriptor = Object.getOwnPropertyDescriptor(record, key); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - throw new Error(`${label} fields must be enumerable data properties`); - } - } -} - function parseCanonicalUnsignedDecimal( value: unknown, max: bigint, diff --git a/packages/core/test/ka-transfer-descriptor.test.ts b/packages/core/test/ka-transfer-descriptor.test.ts index 853c625f06..a458fd2b3f 100644 --- a/packages/core/test/ka-transfer-descriptor.test.ts +++ b/packages/core/test/ka-transfer-descriptor.test.ts @@ -14,7 +14,7 @@ const ZERO_DIGEST = `0x${'00'.repeat(32)}`; const BLOB_DIGEST = `0x${'11'.repeat(32)}`; const CHUNK_TREE_ROOT = `0x${'22'.repeat(32)}`; -const VALID_MIN: KaTransferDescriptorV1 = { +const VALID_MIN = validatedDescriptor({ codec: 'dkg-ka-bundle-v1', projectionId: 'cg-shared-v1', projectionDigest: ZERO_DIGEST, @@ -23,7 +23,7 @@ const VALID_MIN: KaTransferDescriptorV1 = { chunkCount: '1', blobDigest: BLOB_DIGEST, chunkTreeRoot: CHUNK_TREE_ROOT, -}; +}); // Normative RFC-64 fixture. Keep this literal independent from implementation output. const VALID_MIN_CANONICAL = @@ -47,7 +47,7 @@ describe('KaTransferDescriptorV1', () => { ['maximum', '1073741824', '4096', 380], ])('accepts the %s boundary', (_name, byteLength, chunkCount, canonicalLength) => { const descriptor = { ...VALID_MIN, byteLength, chunkCount }; - expect(() => assertKaTransferDescriptorV1(descriptor)).not.toThrow(); + assertKaTransferDescriptorV1(descriptor); expect(canonicalizeKaTransferDescriptorV1(descriptor).length).toBe(canonicalLength); }); @@ -167,3 +167,8 @@ describe('KaTransferDescriptorV1', () => { function lowerHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); } + +function validatedDescriptor(value: unknown): KaTransferDescriptorV1 { + assertKaTransferDescriptorV1(value); + return value; +} diff --git a/packages/core/test/sync-wire-objects.test.ts b/packages/core/test/sync-wire-objects.test.ts new file mode 100644 index 0000000000..01793c7999 --- /dev/null +++ b/packages/core/test/sync-wire-objects.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { assertExactKeys, isPlainRecord } from '../src/sync-wire-objects.js'; + +describe('RFC-64 sync wire object helpers', () => { + it('accepts ordinary and null-prototype records', () => { + const nullPrototype = Object.create(null) as Record; + nullPrototype.value = 'ok'; + + expect(isPlainRecord({ value: 'ok' })).toBe(true); + expect(isPlainRecord(nullPrototype)).toBe(true); + expect(() => assertExactKeys(nullPrototype, ['value'], 'fixture')).not.toThrow(); + }); + + it('rejects null, arrays, and class instances as non-plain records', () => { + class Fixture { + value = 'ok'; + } + + expect(isPlainRecord(null)).toBe(false); + expect(isPlainRecord([])).toBe(false); + expect(isPlainRecord(new Fixture())).toBe(false); + }); + + it('rejects unknown, missing, and symbol keys', () => { + expect(() => assertExactKeys({ value: 'ok', extra: true }, ['value'], 'fixture')).toThrow( + /unknown or missing fields/, + ); + expect(() => assertExactKeys({}, ['value'], 'fixture')).toThrow( + /unknown or missing fields/, + ); + + const symbolRecord = { value: 'ok' } as Record; + symbolRecord[Symbol('hidden')] = true; + expect(() => assertExactKeys(symbolRecord, ['value'], 'fixture')).toThrow( + /symbol properties/, + ); + }); + + it('rejects non-enumerable and accessor property descriptors', () => { + const nonEnumerable = {} as Record; + Object.defineProperty(nonEnumerable, 'value', { + enumerable: false, + value: 'ok', + }); + expect(() => assertExactKeys(nonEnumerable, ['value'], 'fixture')).toThrow( + /enumerable data properties/, + ); + + const accessor = {} as Record; + Object.defineProperty(accessor, 'value', { + enumerable: true, + get: () => 'ok', + }); + expect(() => assertExactKeys(accessor, ['value'], 'fixture')).toThrow( + /enumerable data properties/, + ); + }); +}); From 4bbd4746885e1bf8ca33c21d701182f4240d28ab Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 02:31:12 +0200 Subject: [PATCH 009/292] refactor(core): unify canonical JSON string decoding --- packages/core/src/canonical-json.ts | 160 +++++++++++++++++----- packages/core/test/canonical-json.test.ts | 24 ++++ 2 files changed, 150 insertions(+), 34 deletions(-) diff --git a/packages/core/src/canonical-json.ts b/packages/core/src/canonical-json.ts index 2806165b67..f45f77680e 100644 --- a/packages/core/src/canonical-json.ts +++ b/packages/core/src/canonical-json.ts @@ -277,13 +277,13 @@ function writeCanonicalJsonString( continue; } - if (unit >= 0xd800 && unit <= 0xdbff) { + if (isHighSurrogate(unit)) { const next = value.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) { + if (!isLowSurrogate(next)) { throw new CanonicalJsonError(`${label} contains an unpaired high surrogate`); } index += 2; - } else if (unit >= 0xdc00 && unit <= 0xdfff) { + } else if (isLowSurrogate(unit)) { throw new CanonicalJsonError(`${label} contains an unpaired low surrogate`); } else { index += 1; @@ -296,21 +296,6 @@ function writeCanonicalJsonString( writer.appendAscii('"'); } -function assertUnicodeScalarString(value: string, label: string): void { - for (let index = 0; index < value.length; index += 1) { - const unit = value.charCodeAt(index); - if (unit >= 0xd800 && unit <= 0xdbff) { - const next = value.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) { - throw new CanonicalJsonError(`${label} contains an unpaired high surrogate`); - } - index += 1; - } else if (unit >= 0xdc00 && unit <= 0xdfff) { - throw new CanonicalJsonError(`${label} contains an unpaired low surrogate`); - } - } -} - class StrictJsonParser { private index = 0; @@ -384,34 +369,126 @@ class StrictJsonParser { } private parseString(): string { - const start = this.index; + // Decode and validate the JSON string in this one path. Keeping the escape + // grammar and Unicode-scalar invariant together avoids relying on a second + // parser whose accepted boundary could drift from the local scanner. this.index += 1; - let escaped = false; + const chunks: string[] = []; + let parts: string[] = []; + let partsLength = 0; + let rawStart = this.index; + let pendingHighSurrogate: number | undefined; + + const flushParts = (): void => { + if (parts.length === 0) return; + chunks.push(parts.join('')); + parts = []; + partsLength = 0; + }; + const appendPart = (value: string): void => { + if (value.length === 0) return; + parts.push(value); + partsLength += value.length; + // Bound both the number of retained fragments and temporary join size for + // escape-heavy hostile strings while preserving the no-escape slice path. + if (partsLength >= 8192 || parts.length >= 1024) flushParts(); + }; + const flushRaw = (end: number): void => { + if (end > rawStart) appendPart(this.text.slice(rawStart, end)); + rawStart = end; + }; + const appendCodeUnit = (unit: number): void => { + if (pendingHighSurrogate !== undefined) { + if (!isLowSurrogate(unit)) { + this.fail('JSON string contains an unpaired high surrogate'); + } + appendPart(String.fromCharCode(pendingHighSurrogate, unit)); + pendingHighSurrogate = undefined; + } else if (isHighSurrogate(unit)) { + pendingHighSurrogate = unit; + } else if (isLowSurrogate(unit)) { + this.fail('JSON string contains an unpaired low surrogate'); + } else { + appendPart(String.fromCharCode(unit)); + } + }; + while (this.index < this.text.length) { const code = this.text.charCodeAt(this.index); - if (!escaped && code === 0x22) { + if (code === 0x22) { + if (pendingHighSurrogate !== undefined) { + this.fail('JSON string contains an unpaired high surrogate'); + } + flushRaw(this.index); this.index += 1; - const token = this.text.slice(start, this.index); - let value: string; - try { - value = JSON.parse(token) as string; - } catch { - this.fail('Invalid JSON string escape'); + flushParts(); + return chunks.join(''); + } + + if (code < 0x20) this.fail('Unescaped control character in string'); + if (code !== 0x5c) { + if (pendingHighSurrogate !== undefined) { + flushRaw(this.index); + appendCodeUnit(code); + this.index += 1; + rawStart = this.index; + } else if (isHighSurrogate(code)) { + const next = this.text.charCodeAt(this.index + 1); + if (isLowSurrogate(next)) { + this.index += 2; + } else { + flushRaw(this.index); + pendingHighSurrogate = code; + this.index += 1; + rawStart = this.index; + } + } else if (isLowSurrogate(code)) { + this.fail('JSON string contains an unpaired low surrogate'); + } else { + this.index += 1; } - assertUnicodeScalarString(value, 'JSON string'); - return value; + continue; } - if (!escaped && code < 0x20) this.fail('Unescaped control character in string'); - if (!escaped && code === 0x5c) { - escaped = true; + + flushRaw(this.index); + this.index += 1; + if (this.index >= this.text.length) this.fail('Unterminated JSON string'); + const escape = this.text.charCodeAt(this.index); + this.index += 1; + if (escape === 0x22 || escape === 0x2f || escape === 0x5c) { + appendCodeUnit(escape); + } else if (escape === 0x62) { + appendCodeUnit(0x08); + } else if (escape === 0x66) { + appendCodeUnit(0x0c); + } else if (escape === 0x6e) { + appendCodeUnit(0x0a); + } else if (escape === 0x72) { + appendCodeUnit(0x0d); + } else if (escape === 0x74) { + appendCodeUnit(0x09); + } else if (escape === 0x75) { + appendCodeUnit(this.parseEscapedHexCodeUnit()); } else { - escaped = false; + this.fail('Invalid JSON string escape'); } - this.index += 1; + rawStart = this.index; } this.fail('Unterminated JSON string'); } + private parseEscapedHexCodeUnit(): number { + if (this.index + 4 > this.text.length) this.fail('Truncated JSON unicode escape'); + let value = 0; + for (let offset = 0; offset < 4; offset += 1) { + const nibble = hexNibble(this.text.charCodeAt(this.index + offset)); + if (nibble < 0) this.fail('Invalid JSON unicode escape'); + value = (value << 4) | nibble; + } + this.index += 4; + return value; + } + private parseNumber(): number { const start = this.index; @@ -568,6 +645,21 @@ function isDigitOneToNine(value: string | undefined): boolean { return value !== undefined && value >= '1' && value <= '9'; } +function isHighSurrogate(unit: number): boolean { + return unit >= 0xd800 && unit <= 0xdbff; +} + +function isLowSurrogate(unit: number): boolean { + return unit >= 0xdc00 && unit <= 0xdfff; +} + +function hexNibble(unit: number): number { + if (unit >= 0x30 && unit <= 0x39) return unit - 0x30; + if (unit >= 0x41 && unit <= 0x46) return unit - 0x41 + 10; + if (unit >= 0x61 && unit <= 0x66) return unit - 0x61 + 10; + return -1; +} + function assertJsonObjectShape(record: Record): void { for (const key of Reflect.ownKeys(record)) { if (typeof key !== 'string') { diff --git a/packages/core/test/canonical-json.test.ts b/packages/core/test/canonical-json.test.ts index 746fb50ea2..6298b71dfc 100644 --- a/packages/core/test/canonical-json.test.ts +++ b/packages/core/test/canonical-json.test.ts @@ -92,6 +92,30 @@ describe('RFC 8785 canonical JSON', () => { ); }); + it('decodes the complete JSON escape grammar in one Unicode-scalar path', () => { + expect(parseJsonStrict( + String.raw`["\"","\\","\/","\b","\f","\n","\r","\t","\u0061","\uD834\uDD1E"]`, + )).toEqual(['"', '\\', '/', '\b', '\f', '\n', '\r', '\t', 'a', '𝄞']); + expect(parseJsonStrict('"𝄞"')).toBe('𝄞'); + // Preserve scalar semantics even when a direct JavaScript-string caller + // splits one pair across raw and escaped UTF-16 code units. Byte callers + // cannot carry the raw lone half because the UTF-8 decoder is fatal. + expect(parseJsonStrict(`"${'\ud834'}\\uDD1E"`)).toBe('𝄞'); + expect(parseJsonStrict(`"\\uD834${'\udd1e'}"`)).toBe('𝄞'); + + for (const input of [ + String.raw`"\uD834x"`, + String.raw`"\uD834\u0061"`, + String.raw`"\uDD1E"`, + String.raw`"\u12"`, + String.raw`"\u12xz"`, + String.raw`"\x61"`, + '"line\nbreak"', + ]) { + expect(() => parseJsonStrict(input), input).toThrow(CanonicalJsonError); + } + }); + it('rejects invalid UTF-8, a BOM, invalid grammar, and unpaired surrogates', () => { expect(() => parseJsonStrict(new Uint8Array([0xc3, 0x28]))).toThrow(/not valid UTF-8/); expect(() => parseJsonStrict(new Uint8Array([0xef, 0xbb, 0xbf, 0x6e, 0x75, 0x6c, 0x6c]))) From ec791a720ce7f596ce603de93e37e639737e90c3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 02:34:06 +0200 Subject: [PATCH 010/292] fix(core): adapt bundle digests to branded wire scalar --- packages/core/src/ka-bundle-v1.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ka-bundle-v1.ts b/packages/core/src/ka-bundle-v1.ts index 6446adb713..420ed75ae0 100644 --- a/packages/core/src/ka-bundle-v1.ts +++ b/packages/core/src/ka-bundle-v1.ts @@ -4,7 +4,10 @@ import { MAX_KA_TRANSFER_BYTES_V1, MIN_KA_TRANSFER_BYTES_V1, } from './ka-transfer-descriptor.js'; -import type { Digest32V1 } from './sync-wire-scalars.js'; +import { + assertCanonicalDigest, + type Digest32V1, +} from './sync-wire-scalars.js'; export const KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1 = 'dkg-ka-projection-v1\n' as const; export const KA_BUNDLE_BLOB_DIGEST_DOMAIN_V1 = 'dkg-ka-transfer-v1\n' as const; @@ -242,6 +245,7 @@ function digestToLowerHex(domain: Uint8Array, ...chunks: readonly Uint8Array[]): const digest = hasher.digest(); let result = '0x'; for (const byte of digest) result += byte.toString(16).padStart(2, '0'); + assertCanonicalDigest(result); return result; } From f57690ca355b9b4e0e734e6c421796168606911f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 02:42:58 +0200 Subject: [PATCH 011/292] feat(core): add RFC-64 chunk tree --- packages/core/src/ka-chunk-tree.ts | 170 +++++++++++++++++++++++ packages/core/test/ka-chunk-tree.test.ts | 141 +++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 packages/core/src/ka-chunk-tree.ts create mode 100644 packages/core/test/ka-chunk-tree.test.ts diff --git a/packages/core/src/ka-chunk-tree.ts b/packages/core/src/ka-chunk-tree.ts new file mode 100644 index 0000000000..00d9b12830 --- /dev/null +++ b/packages/core/src/ka-chunk-tree.ts @@ -0,0 +1,170 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + +import { + KA_TRANSFER_CHUNK_SIZE_BYTES_V1, + MAX_KA_TRANSFER_BYTES_V1, + MAX_KA_TRANSFER_CHUNKS_V1, + MIN_KA_TRANSFER_BYTES_V1, +} from './ka-transfer-descriptor.js'; +import { + assertCanonicalDigest, + type Digest32V1, +} from './sync-wire-scalars.js'; + +export const KA_CHUNK_LEAF_DIGEST_DOMAIN_V1 = 'dkg-ka-chunk-leaf-v1\n' as const; +export const KA_CHUNK_NODE_DIGEST_DOMAIN_V1 = 'dkg-ka-chunk-node-v1\n' as const; +export const KA_CHUNK_ODD_DIGEST_DOMAIN_V1 = 'dkg-ka-chunk-odd-v1\n' as const; +export const KA_CHUNK_EMPTY_DIGEST_DOMAIN_V1 = 'dkg-ka-chunk-empty-v1\n' as const; + +const UTF8 = new TextEncoder(); +const LEAF_DOMAIN_BYTES = UTF8.encode(KA_CHUNK_LEAF_DIGEST_DOMAIN_V1); +const NODE_DOMAIN_BYTES = UTF8.encode(KA_CHUNK_NODE_DIGEST_DOMAIN_V1); +const ODD_DOMAIN_BYTES = UTF8.encode(KA_CHUNK_ODD_DIGEST_DOMAIN_V1); +const EMPTY_DOMAIN_BYTES = UTF8.encode(KA_CHUNK_EMPTY_DIGEST_DOMAIN_V1); +const U64_BYTES = 8; + +export type KaChunkTreeV1ErrorCode = + | 'chunk-tree-byte-length' + | 'chunk-index' + | 'chunk-byte-length' + | 'chunk-count'; + +export class KaChunkTreeV1Error extends Error { + constructor( + readonly code: KaChunkTreeV1ErrorCode, + message: string, + ) { + super(`[${code}] ${message}`); + this.name = 'KaChunkTreeV1Error'; + } +} + +/** + * Compute the exact index- and length-bound RFC-64 leaf digest for one non-empty + * transfer chunk. This is a dormant structural primitive; it makes no RDF or seal claim. + */ +export function computeKaChunkLeafDigestV1( + chunkIndex: bigint, + chunkBytes: Uint8Array, +): Digest32V1 { + assertOwnedFixedUint8Array(chunkBytes, 'chunkBytes'); + if ( + typeof chunkIndex !== 'bigint' + || chunkIndex < 0n + || chunkIndex >= MAX_KA_TRANSFER_CHUNKS_V1 + ) { + fail( + 'chunk-index', + `chunkIndex must be in 0..${MAX_KA_TRANSFER_CHUNKS_V1 - 1n}`, + ); + } + const byteLength = BigInt(chunkBytes.byteLength); + if (byteLength < 1n || byteLength > KA_TRANSFER_CHUNK_SIZE_BYTES_V1) { + fail( + 'chunk-byte-length', + `chunk length must be in 1..${KA_TRANSFER_CHUNK_SIZE_BYTES_V1}`, + ); + } + return digestBytesToLowerHex(computeLeafDigestBytes(chunkIndex, chunkBytes)); +} + +function computeLeafDigestBytes(chunkIndex: bigint, chunkBytes: Uint8Array): Uint8Array { + return digestBytes( + LEAF_DOMAIN_BYTES, + encodeU64Be(chunkIndex), + encodeU64Be(BigInt(chunkBytes.byteLength)), + chunkBytes, + ); +} + +/** + * Compute the canonical root for one complete transfer blob. Chunks are the exact + * consecutive 256 KiB slices of `bundleBytes`; only the final chunk may be shorter. + */ +export function computeKaChunkTreeRootV1(bundleBytes: Uint8Array): Digest32V1 { + assertOwnedFixedUint8Array(bundleBytes, 'bundleBytes'); + const byteLength = BigInt(bundleBytes.byteLength); + if (byteLength < MIN_KA_TRANSFER_BYTES_V1 || byteLength > MAX_KA_TRANSFER_BYTES_V1) { + fail( + 'chunk-tree-byte-length', + `bundle length must be ${MIN_KA_TRANSFER_BYTES_V1}-${MAX_KA_TRANSFER_BYTES_V1} bytes`, + ); + } + + const chunkCount = ((byteLength - 1n) / KA_TRANSFER_CHUNK_SIZE_BYTES_V1) + 1n; + if (chunkCount < 1n || chunkCount > MAX_KA_TRANSFER_CHUNKS_V1) { + fail('chunk-count', `chunk count must be in 1..${MAX_KA_TRANSFER_CHUNKS_V1}`); + } + + const chunkSize = Number(KA_TRANSFER_CHUNK_SIZE_BYTES_V1); + let level: Uint8Array[] = []; + for (let index = 0; index < Number(chunkCount); index += 1) { + const start = index * chunkSize; + const end = Math.min(start + chunkSize, bundleBytes.byteLength); + const chunkBytes = bundleBytes.subarray(start, end); + // The complete bundle bounds above prove the index and chunk-length domains. + level.push(computeLeafDigestBytes(BigInt(index), chunkBytes)); + } + + while (level.length > 1) { + const parentLevel: Uint8Array[] = []; + for (let index = 0; index < level.length; index += 2) { + parentLevel.push( + index + 1 < level.length + ? digestBytes(NODE_DOMAIN_BYTES, level[index], level[index + 1]) + : digestBytes(ODD_DOMAIN_BYTES, level[index]), + ); + } + level = parentLevel; + } + return digestBytesToLowerHex(level[0]); +} + +/** Exact domain-separated root of an empty tree; no valid v1 transfer uses it. */ +export function computeEmptyKaChunkTreeRootV1(): Digest32V1 { + return digestBytesToLowerHex(digestBytes(EMPTY_DOMAIN_BYTES)); +} + +function assertOwnedFixedUint8Array( + value: unknown, + label: string, +): asserts value is Uint8Array { + if (!(value instanceof Uint8Array)) { + throw new TypeError(`${label} must be a Uint8Array`); + } + if (!(value.buffer instanceof ArrayBuffer)) { + throw new TypeError(`${label} must not use shared backing memory`); + } + if ((value.buffer as ArrayBuffer & { readonly resizable?: boolean }).resizable === true) { + throw new TypeError(`${label} must not use resizable backing memory`); + } +} + +function encodeU64Be(value: bigint): Uint8Array { + const encoded = new Uint8Array(U64_BYTES); + let remaining = value; + for (let index = encoded.length - 1; index >= 0; index -= 1) { + encoded[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + if (remaining !== 0n) fail('chunk-index', 'u64be value exceeds the unsigned 64-bit range'); + return encoded; +} + +function digestBytes(domain: Uint8Array, ...chunks: readonly Uint8Array[]): Uint8Array { + const hasher = sha256.create(); + hasher.update(domain); + for (const chunk of chunks) hasher.update(chunk); + return hasher.digest(); +} + +function digestBytesToLowerHex(digest: Uint8Array): Digest32V1 { + let result = '0x'; + for (const byte of digest) result += byte.toString(16).padStart(2, '0'); + assertCanonicalDigest(result); + return result; +} + +function fail(code: KaChunkTreeV1ErrorCode, message: string): never { + throw new KaChunkTreeV1Error(code, message); +} diff --git a/packages/core/test/ka-chunk-tree.test.ts b/packages/core/test/ka-chunk-tree.test.ts new file mode 100644 index 0000000000..7f9a6947ec --- /dev/null +++ b/packages/core/test/ka-chunk-tree.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeEmptyKaChunkTreeRootV1, + computeKaChunkLeafDigestV1, + computeKaChunkTreeRootV1, + type KaChunkTreeV1ErrorCode, +} from '../src/ka-chunk-tree.js'; + +const EMPTY_EMPTY_BUNDLE = fromHex('00000000000000000000000000000000'); + +describe('RFC-64 dormant KA chunk tree', () => { + it('matches the empty-tree and one-bundle-chunk conformance roots', () => { + expect(computeEmptyKaChunkTreeRootV1()).toBe( + '0x558df3a0a75720f66c27b95906ed3992256105f43694c9d7f592cd334662f0f1', + ); + expect(computeKaChunkTreeRootV1(EMPTY_EMPTY_BUNDLE)).toBe( + '0xfb8fec167dc39ee7bc316afaff45d2d259e8a183af31a97a7cb736fc41c5b12f', + ); + }); + + it('binds every leaf to its zero-based index and exact byte length', () => { + const bytes = fromHex('616263'); + expect(computeKaChunkLeafDigestV1(0n, bytes)).toBe( + '0xcad222080b737db774460c9e8618ff9dc4537917a44715b8804fe73915562f6c', + ); + expect(computeKaChunkLeafDigestV1(1n, bytes)).not.toBe( + computeKaChunkLeafDigestV1(0n, bytes), + ); + expect(computeKaChunkLeafDigestV1(0n, fromHex('6162'))).not.toBe( + computeKaChunkLeafDigestV1(0n, bytes), + ); + }); + + it('matches deterministic two-, three-, and five-chunk odd-tree vectors', () => { + const chunks = Array.from({ length: 5 }, (_, index) => { + const chunk = new Uint8Array(262_144); + chunk.fill(index); + return chunk; + }); + const final = fromHex('a0a1a2a3a4a5a6'); + + expect(computeKaChunkTreeRootV1(concat(chunks[0], final))).toBe( + '0xdff4f667510303304a1fa0fab500d5f889089ce3baac72c213fdb4df9b5c5493', + ); + expect(computeKaChunkTreeRootV1(concat(chunks[0], chunks[1], final))).toBe( + '0x80f90776eb169f7d125c41fb842f00c8a6d08093ef522ef8c5015bf169898338', + ); + expect( + computeKaChunkTreeRootV1( + concat(chunks[0], chunks[1], chunks[2], chunks[3], final), + ), + ).toBe('0x1177b2daacaca40ff38bc21641ae3f729956708f22c9485e9854f5343692ce2d'); + }); + + it('uses exact consecutive 256 KiB slices and a shorter final chunk', () => { + const bytes = new Uint8Array(262_145); + bytes[262_144] = 0xff; + const twoChunkRoot = computeKaChunkTreeRootV1(bytes); + bytes[262_143] = 0xff; + expect(computeKaChunkTreeRootV1(bytes)).not.toBe(twoChunkRoot); + }); + + it('rejects transfer lengths outside 16..1 GiB and empty leaves', () => { + expectFailureCode( + () => computeKaChunkTreeRootV1(new Uint8Array(15)), + 'chunk-tree-byte-length', + ); + expectFailureCode( + () => computeKaChunkLeafDigestV1(0n, new Uint8Array()), + 'chunk-byte-length', + ); + expectFailureCode( + () => computeKaChunkLeafDigestV1(0n, new Uint8Array(262_145)), + 'chunk-byte-length', + ); + }); + + it('rejects chunk indexes outside 0..4095', () => { + expectFailureCode( + () => computeKaChunkLeafDigestV1(-1n, fromHex('00')), + 'chunk-index', + ); + expectFailureCode( + () => computeKaChunkLeafDigestV1(4096n, fromHex('00')), + 'chunk-index', + ); + }); + + it('rejects shared or resizable backing memory before hashing', () => { + expect(() => computeKaChunkTreeRootV1(new Uint8Array(new SharedArrayBuffer(16)))) + .toThrow(/shared backing memory/); + expect(() => computeKaChunkLeafDigestV1(0n, new Uint8Array(new SharedArrayBuffer(1)))) + .toThrow(/shared backing memory/); + + const ResizableArrayBuffer = ArrayBuffer as unknown as new ( + byteLength: number, + options: { maxByteLength: number }, + ) => ArrayBuffer; + let backing: ArrayBuffer; + try { + backing = new ResizableArrayBuffer(16, { maxByteLength: 32 }); + } catch { + return; + } + if ((backing as ArrayBuffer & { readonly resizable?: boolean }).resizable !== true) return; + expect(() => computeKaChunkTreeRootV1(new Uint8Array(backing))) + .toThrow(/resizable backing memory/); + }); +}); + +function expectFailureCode(operation: () => unknown, expected: KaChunkTreeV1ErrorCode): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error & { code?: unknown }).code).toBe(expected); + return; + } + throw new Error(`expected operation to fail with ${expected}`); +} + +function concat(...chunks: readonly Uint8Array[]): Uint8Array { + const byteLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0 || !/^[0-9a-f]*$/.test(hex)) throw new Error('invalid test hex'); + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} From 6f4e9362914405dc0e9b4c785204c633b98878ca Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 03:01:04 +0200 Subject: [PATCH 012/292] feat(core): add RFC-64 chunk proofs --- packages/core/src/index.ts | 3 + packages/core/src/ka-chunk-proof.ts | 443 ++++++++++++++++++ packages/core/src/ka-transfer-descriptor.ts | 21 + packages/core/test/ka-chunk-proof.test.ts | 306 ++++++++++++ .../core/test/ka-transfer-descriptor.test.ts | 6 + 5 files changed, 779 insertions(+) create mode 100644 packages/core/src/ka-chunk-proof.ts create mode 100644 packages/core/test/ka-chunk-proof.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e70157b3c..dd1c312d7f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,6 +45,9 @@ export type { TimestampMsV1, } from './sync-wire-scalars.js'; export * from './ka-transfer-descriptor.js'; +export * from './ka-bundle-v1.js'; +export * from './ka-chunk-tree.js'; +export * from './ka-chunk-proof.js'; export * from './event-bus.js'; export { Logger, diff --git a/packages/core/src/ka-chunk-proof.ts b/packages/core/src/ka-chunk-proof.ts new file mode 100644 index 0000000000..71d5296d2a --- /dev/null +++ b/packages/core/src/ka-chunk-proof.ts @@ -0,0 +1,443 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + KA_CHUNK_NODE_DIGEST_DOMAIN_V1, + KA_CHUNK_ODD_DIGEST_DOMAIN_V1, + computeKaChunkLeafDigestV1, +} from './ka-chunk-tree.js'; +import { + KA_TRANSFER_CHUNK_SIZE_BYTES_V1, + MAX_KA_TRANSFER_BYTES_V1, + MAX_KA_TRANSFER_CHUNKS_V1, + MIN_KA_TRANSFER_BYTES_V1, +} from './ka-transfer-descriptor.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; +import { + assertCanonicalDecimalU64, + assertCanonicalDigest, + parseCanonicalDecimalU64, + type Digest32V1, + type IndexV1, +} from './sync-wire-scalars.js'; + +export const MAX_KA_CHUNK_PROOF_BYTES_V1 = 1280; +export const MAX_KA_CHUNK_PROOF_DEPTH_V1 = 3; +export const MAX_KA_CHUNK_PROOF_STEPS_V1 = 12; +export const MAX_KA_CHUNK_PROOFS_PER_REQUEST_V1 = 16; + +const UTF8 = new TextEncoder(); +const NODE_DOMAIN_BYTES = UTF8.encode(KA_CHUNK_NODE_DIGEST_DOMAIN_V1); +const ODD_DOMAIN_BYTES = UTF8.encode(KA_CHUNK_ODD_DIGEST_DOMAIN_V1); +const DIGEST_BYTES = 32; + +export type KaChunkProofStepV1 = + | { readonly kind: 'left'; readonly digest: Digest32V1 } + | { readonly kind: 'right'; readonly digest: Digest32V1 } + | { readonly kind: 'odd' }; + +export interface KaChunkProofV1 { + readonly chunkIndex: IndexV1; + readonly steps: readonly KaChunkProofStepV1[]; +} + +export type KaChunkProofV1ErrorCode = + | 'proof-schema' + | 'proof-object-too-large' + | 'proof-chunk-count' + | 'proof-chunk-index' + | 'proof-request-indexes' + | 'proof-topology' + | 'proof-chunk-byte-length' + | 'proof-root-mismatch'; + +export class KaChunkProofV1Error extends Error { + constructor( + readonly code: KaChunkProofV1ErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'KaChunkProofV1Error'; + } +} + +/** Validate a proof's exact closed schema and descriptor-derived topology. */ +export function assertKaChunkProofV1( + proof: unknown, + chunkCount: bigint, +): asserts proof is KaChunkProofV1 { + assertChunkCount(chunkCount); + if (!isPlainRecord(proof)) fail('proof-schema', 'chunk proof must be a plain JSON object'); + try { + assertExactKeys(proof, ['chunkIndex', 'steps'], 'chunk proof'); + } catch (cause) { + fail('proof-schema', 'chunk proof has an invalid field set', cause); + } + + try { + assertCanonicalDecimalU64(proof.chunkIndex, 'chunkIndex'); + } catch (cause) { + fail('proof-chunk-index', 'chunkIndex must be a canonical DecimalU64V1', cause); + } + const chunkIndex = parseCanonicalDecimalU64(proof.chunkIndex, 'chunkIndex'); + if (chunkIndex >= chunkCount) { + fail('proof-chunk-index', `chunkIndex must be less than chunkCount ${chunkCount}`); + } + if (!Array.isArray(proof.steps) || proof.steps.length > MAX_KA_CHUNK_PROOF_STEPS_V1) { + fail( + 'proof-schema', + `steps must be an array of at most ${MAX_KA_CHUNK_PROOF_STEPS_V1} entries`, + ); + } + assertClosedDenseArray(proof.steps, 'steps', 'proof-schema'); + + let position = chunkIndex; + let width = chunkCount; + let stepIndex = 0; + while (width > 1n) { + if (stepIndex >= proof.steps.length) { + fail('proof-topology', 'proof omits a required tree level'); + } + const step = proof.steps[stepIndex]; + const expectedKind = position % 2n === 1n + ? 'left' + : position + 1n < width + ? 'right' + : 'odd'; + assertProofStep(step, expectedKind, stepIndex); + position /= 2n; + width = (width + 1n) / 2n; + stepIndex += 1; + } + if (stepIndex !== proof.steps.length) { + fail('proof-topology', 'proof contains steps after the tree root'); + } +} + +/** Return the exact bounded RFC 8785 JCS proof string. */ +export function canonicalizeKaChunkProofV1( + proof: KaChunkProofV1, + chunkCount: bigint, +): string { + assertKaChunkProofV1(proof, chunkCount); + return canonicalizeJson(proof as unknown as CanonicalJsonValue, { + maxBytes: MAX_KA_CHUNK_PROOF_BYTES_V1, + maxDepth: MAX_KA_CHUNK_PROOF_DEPTH_V1, + }); +} + +/** Strictly decode one canonical proof using the descriptor-derived chunk count. */ +export function parseCanonicalKaChunkProofV1( + input: string | Uint8Array, + chunkCount: bigint, + options: StrictJsonParseOptions = {}, +): KaChunkProofV1 { + if (wireByteLength(input) > MAX_KA_CHUNK_PROOF_BYTES_V1) { + fail( + 'proof-object-too-large', + `chunk proof exceeds ${MAX_KA_CHUNK_PROOF_BYTES_V1} bytes`, + ); + } + const parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_KA_CHUNK_PROOF_BYTES_V1, + MAX_KA_CHUNK_PROOF_BYTES_V1, + ), + maxDepth: Math.min( + options.maxDepth ?? MAX_KA_CHUNK_PROOF_DEPTH_V1, + MAX_KA_CHUNK_PROOF_DEPTH_V1, + ), + }); + assertKaChunkProofV1(parsed, chunkCount); + return parsed; +} + +/** Build the one canonical proof for `chunkIndex` from a complete bounded bundle. */ +export function buildKaChunkProofV1( + bundleBytes: Uint8Array, + chunkIndex: bigint, +): KaChunkProofV1 { + return buildKaChunkProofsV1(bundleBytes, [chunkIndex])[0]; +} + +/** + * Build one logical request's sorted unique proofs while hashing the bundle tree once. + * This is the provider-side primitive used to avoid O(request indexes × bundle bytes). + */ +export function buildKaChunkProofsV1( + bundleBytes: Uint8Array, + chunkIndexes: readonly bigint[], +): readonly KaChunkProofV1[] { + assertOwnedFixedUint8Array(bundleBytes, 'bundleBytes'); + const byteLength = BigInt(bundleBytes.byteLength); + if (byteLength < MIN_KA_TRANSFER_BYTES_V1 || byteLength > MAX_KA_TRANSFER_BYTES_V1) { + fail( + 'proof-chunk-byte-length', + `bundle length must be ${MIN_KA_TRANSFER_BYTES_V1}-${MAX_KA_TRANSFER_BYTES_V1}`, + ); + } + const chunkCount = expectedChunkCount(byteLength); + if ( + !Array.isArray(chunkIndexes) + || chunkIndexes.length < 1 + || chunkIndexes.length > MAX_KA_CHUNK_PROOFS_PER_REQUEST_V1 + ) { + fail( + 'proof-request-indexes', + `chunkIndexes must contain 1..${MAX_KA_CHUNK_PROOFS_PER_REQUEST_V1} entries`, + ); + } + assertClosedDenseArray(chunkIndexes, 'chunkIndexes', 'proof-request-indexes'); + let previous = -1n; + for (let requestIndex = 0; requestIndex < chunkIndexes.length; requestIndex += 1) { + const chunkIndex = chunkIndexes[requestIndex]; + if ( + typeof chunkIndex !== 'bigint' + || chunkIndex < 0n + || chunkIndex >= chunkCount + || chunkIndex <= previous + ) { + fail( + 'proof-request-indexes', + `chunkIndexes must be strictly increasing unique values in 0..${chunkCount - 1n}`, + ); + } + previous = chunkIndex; + } + + const chunkSize = Number(KA_TRANSFER_CHUNK_SIZE_BYTES_V1); + let level: Digest32V1[] = []; + for (let index = 0; index < Number(chunkCount); index += 1) { + const start = index * chunkSize; + const end = Math.min(start + chunkSize, bundleBytes.byteLength); + level.push(computeKaChunkLeafDigestV1(BigInt(index), bundleBytes.subarray(start, end))); + } + + const levels: Digest32V1[][] = []; + while (level.length > 1) { + levels.push(level); + const parentLevel: Digest32V1[] = []; + for (let index = 0; index < level.length; index += 2) { + parentLevel.push( + index + 1 < level.length + ? combineNodeDigests(level[index], level[index + 1]) + : combineOddDigest(level[index]), + ); + } + level = parentLevel; + } + + const proofs: KaChunkProofV1[] = []; + for (let requestIndex = 0; requestIndex < chunkIndexes.length; requestIndex += 1) { + const chunkIndex = chunkIndexes[requestIndex]; + const steps: KaChunkProofStepV1[] = []; + let position = Number(chunkIndex); + for (const proofLevel of levels) { + if (position % 2 === 1) { + steps.push({ kind: 'left', digest: proofLevel[position - 1] }); + } else if (position + 1 < proofLevel.length) { + steps.push({ kind: 'right', digest: proofLevel[position + 1] }); + } else { + steps.push({ kind: 'odd' }); + } + position = Math.floor(position / 2); + } + const proof = { + chunkIndex: chunkIndex.toString() as IndexV1, + steps, + }; + assertKaChunkProofV1(proof, chunkCount); + proofs.push(proof); + } + return proofs; +} + +/** + * Verify one exact chunk against a structurally valid descriptor tuple. A malformed + * proof throws a stable reason; a well-shaped proof for the wrong bytes/root also fails. + */ +export function assertValidKaChunkProofV1( + proof: KaChunkProofV1, + chunkBytes: Uint8Array, + transferByteLength: bigint, + expectedRoot: Digest32V1, +): void { + assertOwnedFixedUint8Array(chunkBytes, 'chunkBytes'); + if ( + typeof transferByteLength !== 'bigint' + || transferByteLength < MIN_KA_TRANSFER_BYTES_V1 + || transferByteLength > MAX_KA_TRANSFER_BYTES_V1 + ) { + fail( + 'proof-chunk-byte-length', + `transferByteLength must be ${MIN_KA_TRANSFER_BYTES_V1}-${MAX_KA_TRANSFER_BYTES_V1}`, + ); + } + try { + assertCanonicalDigest(expectedRoot, 'chunkTreeRoot'); + } catch (cause) { + fail('proof-schema', 'chunkTreeRoot must be a canonical Digest32V1', cause); + } + + const chunkCount = expectedChunkCount(transferByteLength); + assertKaChunkProofV1(proof, chunkCount); + const chunkIndex = parseCanonicalDecimalU64(proof.chunkIndex, 'chunkIndex'); + const expectedLength = chunkIndex + 1n < chunkCount + ? KA_TRANSFER_CHUNK_SIZE_BYTES_V1 + : transferByteLength - (chunkCount - 1n) * KA_TRANSFER_CHUNK_SIZE_BYTES_V1; + if (BigInt(chunkBytes.byteLength) !== expectedLength) { + fail( + 'proof-chunk-byte-length', + `chunk ${chunkIndex} must contain exactly ${expectedLength} bytes`, + ); + } + + let digest = computeKaChunkLeafDigestV1(chunkIndex, chunkBytes); + for (let stepIndex = 0; stepIndex < proof.steps.length; stepIndex += 1) { + const step = proof.steps[stepIndex]; + if (step.kind === 'left') digest = combineNodeDigests(step.digest, digest); + else if (step.kind === 'right') digest = combineNodeDigests(digest, step.digest); + else digest = combineOddDigest(digest); + } + if (digest !== expectedRoot) { + fail( + 'proof-root-mismatch', + "chunk proof does not produce the exact descriptor's chunkTreeRoot", + ); + } +} + +function assertProofStep( + step: unknown, + expectedKind: KaChunkProofStepV1['kind'], + index: number, +): void { + if (!isPlainRecord(step)) fail('proof-schema', `steps[${index}] must be a plain object`); + try { + assertExactKeys( + step, + expectedKind === 'odd' ? ['kind'] : ['digest', 'kind'], + `steps[${index}]`, + ); + } catch (cause) { + fail('proof-schema', `steps[${index}] has an invalid field set`, cause); + } + if (step.kind !== expectedKind) { + fail( + 'proof-topology', + `steps[${index}] must be ${expectedKind} for this chunk position and tree width`, + ); + } + if (expectedKind !== 'odd') { + try { + assertCanonicalDigest(step.digest, `steps[${index}].digest`); + } catch (cause) { + fail('proof-schema', `steps[${index}].digest is not a canonical Digest32V1`, cause); + } + } +} + +function assertChunkCount(chunkCount: bigint): void { + if ( + typeof chunkCount !== 'bigint' + || chunkCount < 1n + || chunkCount > MAX_KA_TRANSFER_CHUNKS_V1 + ) { + fail('proof-chunk-count', `chunkCount must be in 1..${MAX_KA_TRANSFER_CHUNKS_V1}`); + } +} + +function assertClosedDenseArray( + value: readonly unknown[], + label: string, + code: KaChunkProofV1ErrorCode, +): void { + if (Object.getPrototypeOf(value) !== Array.prototype) { + fail(code, `${label} must use the ordinary Array prototype`); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) { + fail(code, `${label} must not contain symbol properties`); + } + const expected = new Set(['length']); + for (let index = 0; index < value.length; index += 1) expected.add(String(index)); + if (keys.length !== expected.size || keys.some((key) => !expected.has(key as string))) { + fail(code, `${label} must be a dense array without custom properties`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail(code, `${label}[${index}] must be an enumerable data property`); + } + } +} + +function expectedChunkCount(byteLength: bigint): bigint { + const chunkCount = ((byteLength - 1n) / KA_TRANSFER_CHUNK_SIZE_BYTES_V1) + 1n; + assertChunkCount(chunkCount); + return chunkCount; +} + +function combineNodeDigests(left: Digest32V1, right: Digest32V1): Digest32V1 { + return digestToLowerHex(NODE_DOMAIN_BYTES, digestFromLowerHex(left), digestFromLowerHex(right)); +} + +function combineOddDigest(child: Digest32V1): Digest32V1 { + return digestToLowerHex(ODD_DOMAIN_BYTES, digestFromLowerHex(child)); +} + +function digestFromLowerHex(digest: Digest32V1): Uint8Array { + assertCanonicalDigest(digest); + const bytes = new Uint8Array(DIGEST_BYTES); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(digest.slice(2 + index * 2, 4 + index * 2), 16); + } + return bytes; +} + +function digestToLowerHex(domain: Uint8Array, ...chunks: readonly Uint8Array[]): Digest32V1 { + const hasher = sha256.create(); + hasher.update(domain); + for (const chunk of chunks) hasher.update(chunk); + const digest = hasher.digest(); + let result = '0x'; + for (const byte of digest) result += byte.toString(16).padStart(2, '0'); + assertCanonicalDigest(result); + return result; +} + +function assertOwnedFixedUint8Array( + value: unknown, + label: string, +): asserts value is Uint8Array { + if (!(value instanceof Uint8Array)) throw new TypeError(`${label} must be a Uint8Array`); + if (!(value.buffer instanceof ArrayBuffer)) { + throw new TypeError(`${label} must not use shared backing memory`); + } + if ((value.buffer as ArrayBuffer & { readonly resizable?: boolean }).resizable === true) { + throw new TypeError(`${label} must not use resizable backing memory`); + } +} + +function wireByteLength(input: string | Uint8Array): number { + if (typeof input !== 'string') return input.byteLength; + if (input.length > MAX_KA_CHUNK_PROOF_BYTES_V1) { + return MAX_KA_CHUNK_PROOF_BYTES_V1 + 1; + } + return UTF8.encode(input).byteLength; +} + +function fail( + code: KaChunkProofV1ErrorCode, + message: string, + cause?: unknown, +): never { + throw new KaChunkProofV1Error(code, message, cause === undefined ? {} : { cause }); +} diff --git a/packages/core/src/ka-transfer-descriptor.ts b/packages/core/src/ka-transfer-descriptor.ts index c38b9110d1..bccf00257b 100644 --- a/packages/core/src/ka-transfer-descriptor.ts +++ b/packages/core/src/ka-transfer-descriptor.ts @@ -1,3 +1,5 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + import { canonicalizeJson, parseCanonicalJson, @@ -22,7 +24,10 @@ export const MAX_KA_TRANSFER_BYTES_V1 = 1_073_741_824n; export const MAX_KA_TRANSFER_CHUNKS_V1 = 4096n; export const MAX_KA_TRANSFER_DESCRIPTOR_BYTES_V1 = 512; export const MAX_KA_TRANSFER_DESCRIPTOR_DEPTH_V1 = 1; +export const KA_TRANSFER_IDENTITY_DIGEST_DOMAIN_V1 = + 'dkg-ka-transfer-descriptor-v1\n' as const; const UTF8 = new TextEncoder(); +const IDENTITY_DOMAIN_BYTES = UTF8.encode(KA_TRANSFER_IDENTITY_DIGEST_DOMAIN_V1); /** * Exact nested author-catalog transfer value. It is not a signed control object @@ -141,6 +146,22 @@ export function canonicalizeKaTransferDescriptorBytesV1( return UTF8.encode(canonicalizeKaTransferDescriptorV1(descriptor)); } +/** + * Bind verified partial chunks to every canonical descriptor field rather than + * merely to the final blob digest. This is the exact durable-resume/cache key. + */ +export function computeKaTransferIdentityDigestV1( + descriptor: KaTransferDescriptorV1, +): Digest32V1 { + const hasher = sha256.create(); + hasher.update(IDENTITY_DOMAIN_BYTES); + hasher.update(canonicalizeKaTransferDescriptorBytesV1(descriptor)); + let result = '0x'; + for (const byte of hasher.digest()) result += byte.toString(16).padStart(2, '0'); + assertCanonicalDigest(result, 'transferIdentityDigest'); + return result; +} + /** * Strictly decode a descriptor whose received bytes are already canonical JCS. * The protocol's 512-byte ceiling is checked before parsing/materialization. diff --git a/packages/core/test/ka-chunk-proof.test.ts b/packages/core/test/ka-chunk-proof.test.ts new file mode 100644 index 0000000000..43eadafbae --- /dev/null +++ b/packages/core/test/ka-chunk-proof.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_KA_CHUNK_PROOF_BYTES_V1, + assertKaChunkProofV1, + assertValidKaChunkProofV1, + buildKaChunkProofV1, + buildKaChunkProofsV1, + canonicalizeKaChunkProofV1, + parseCanonicalKaChunkProofV1, + type KaChunkProofV1, + type KaChunkProofV1ErrorCode, +} from '../src/ka-chunk-proof.js'; +import { computeKaChunkTreeRootV1 } from '../src/ka-chunk-tree.js'; +import type { Digest32V1, IndexV1 } from '../src/sync-wire-scalars.js'; + +const ZERO_DIGEST = `0x${'00'.repeat(32)}` as Digest32V1; +const THREE_CHUNK_ROOT = + '0x80f90776eb169f7d125c41fb842f00c8a6d08093ef522ef8c5015bf169898338' as Digest32V1; +const THREE_CHUNK_PROOF = + '{"chunkIndex":"2","steps":[{"kind":"odd"},{"digest":"0x12b230a0573621cb375b62f8f3f46cc6e8c05d3d0a84a50cd5d097132f86f58e","kind":"left"}]}'; + +describe('RFC-64 dormant KA chunk proof codec', () => { + it('builds, canonicalizes, parses, and verifies the exact three-chunk vector', () => { + const bundle = fixtureBundle(3); + const proof = buildKaChunkProofV1(bundle, 2n); + expect(canonicalizeKaChunkProofV1(proof, 3n)).toBe(THREE_CHUNK_PROOF); + expect(new TextEncoder().encode(THREE_CHUNK_PROOF).byteLength).toBe(137); + expect(computeKaChunkTreeRootV1(bundle)).toBe(THREE_CHUNK_ROOT); + + const parsed = parseCanonicalKaChunkProofV1(THREE_CHUNK_PROOF, 3n); + expect(parsed).toEqual(proof); + expect(() => assertValidKaChunkProofV1( + parsed, + finalChunk(), + 524_295n, + THREE_CHUNK_ROOT, + )).not.toThrow(); + }); + + it('builds and verifies every leaf in two-, three-, and five-chunk trees', () => { + for (const chunkCount of [2, 3, 5]) { + const bundle = fixtureBundle(chunkCount); + const root = computeKaChunkTreeRootV1(bundle); + const proofs = buildKaChunkProofsV1( + bundle, + Array.from({ length: chunkCount }, (_, index) => BigInt(index)), + ); + for (let index = 0; index < chunkCount; index += 1) { + const proof = proofs[index]; + const chunk = index + 1 === chunkCount ? finalChunk() : fullChunk(index); + expect(() => assertValidKaChunkProofV1( + proof, + chunk, + BigInt(bundle.byteLength), + root, + )).not.toThrow(); + } + } + }); + + it('requires a bounded strictly increasing unique logical index request', () => { + const bundle = fixtureBundle(5); + expectFailureCode(() => buildKaChunkProofsV1(bundle, []), 'proof-request-indexes'); + expectFailureCode( + () => buildKaChunkProofsV1(bundle, [0n, 0n]), + 'proof-request-indexes', + ); + expectFailureCode( + () => buildKaChunkProofsV1(bundle, [1n, 0n]), + 'proof-request-indexes', + ); + expectFailureCode( + () => buildKaChunkProofsV1(bundle, [0n, 5n]), + 'proof-request-indexes', + ); + const many = new Uint8Array(17 * 262_144); + expectFailureCode( + () => buildKaChunkProofsV1(many, Array.from({ length: 17 }, (_, index) => BigInt(index))), + 'proof-request-indexes', + ); + }); + + it('rejects custom or symbolic array properties in the closed schema', () => { + const proof = parseCanonicalKaChunkProofV1(THREE_CHUNK_PROOF, 3n); + const steps = [...proof.steps] as KaChunkProofV1['steps'] & { extra?: boolean }; + steps.extra = true; + expectFailureCode( + () => assertKaChunkProofV1({ ...proof, steps }, 3n), + 'proof-schema', + ); + const symbolic = [...proof.steps]; + Object.defineProperty(symbolic, Symbol('extra'), { value: true }); + expectFailureCode( + () => assertKaChunkProofV1({ ...proof, steps: symbolic }, 3n), + 'proof-schema', + ); + }); + + it('rejects inherited iterators that differ from the validated indexed elements', () => { + const proof = parseCanonicalKaChunkProofV1(THREE_CHUNK_PROOF, 3n); + const indexedSteps = [ + { kind: 'odd' as const }, + { kind: 'left' as const, digest: ZERO_DIGEST }, + ]; + Object.setPrototypeOf(indexedSteps, { + *[Symbol.iterator]() { + yield* proof.steps; + }, + }); + expectFailureCode( + () => assertValidKaChunkProofV1( + { ...proof, steps: indexedSteps }, + finalChunk(), + 524_295n, + THREE_CHUNK_ROOT, + ), + 'proof-schema', + ); + + const indexes = [1n, 0n]; + Object.setPrototypeOf(indexes, { + *[Symbol.iterator]() { + yield 0n; + yield 1n; + }, + }); + expectFailureCode( + () => buildKaChunkProofsV1(fixtureBundle(3), indexes), + 'proof-request-indexes', + ); + }); + + it('requires topology-derived kind, direction, and exact step count', () => { + const valid = parseCanonicalKaChunkProofV1(THREE_CHUNK_PROOF, 3n); + const reversed = { ...valid, steps: [...valid.steps].reverse() }; + expectFailureCode(() => assertKaChunkProofV1(reversed, 3n), 'proof-schema'); + expectFailureCode( + () => assertKaChunkProofV1({ ...valid, steps: valid.steps.slice(0, 1) }, 3n), + 'proof-topology', + ); + expectFailureCode( + () => assertKaChunkProofV1({ + ...valid, + steps: [{ kind: 'odd' }, { kind: 'right', digest: valid.steps[1].kind === 'left' + ? valid.steps[1].digest + : ZERO_DIGEST }], + }, 3n), + 'proof-topology', + ); + expectFailureCode( + () => assertKaChunkProofV1({ ...valid, steps: [...valid.steps, { kind: 'odd' }] }, 3n), + 'proof-topology', + ); + }); + + it('rejects odd steps with a digest and sibling steps without one', () => { + expectFailureCode( + () => assertKaChunkProofV1({ + chunkIndex: '2', + steps: [{ kind: 'odd', digest: ZERO_DIGEST }, { kind: 'left', digest: ZERO_DIGEST }], + }, 3n), + 'proof-schema', + ); + expectFailureCode( + () => assertKaChunkProofV1({ + chunkIndex: '1', + steps: [{ kind: 'left' }, { kind: 'right', digest: ZERO_DIGEST }], + }, 3n), + 'proof-schema', + ); + }); + + it('rejects wrong bytes, root, index, and final-chunk length', () => { + const proof = parseCanonicalKaChunkProofV1(THREE_CHUNK_PROOF, 3n); + const wrongBytes = finalChunk(); + wrongBytes[0] ^= 0xff; + expectFailureCode( + () => assertValidKaChunkProofV1(proof, wrongBytes, 524_295n, THREE_CHUNK_ROOT), + 'proof-root-mismatch', + ); + expectFailureCode( + () => assertValidKaChunkProofV1(proof, finalChunk(), 524_295n, ZERO_DIGEST), + 'proof-root-mismatch', + ); + expectFailureCode( + () => assertKaChunkProofV1({ ...proof, chunkIndex: '3' }, 3n), + 'proof-chunk-index', + ); + expectFailureCode( + () => assertValidKaChunkProofV1( + proof, + new Uint8Array(262_144), + 524_295n, + THREE_CHUNK_ROOT, + ), + 'proof-chunk-byte-length', + ); + }); + + it('rejects noncanonical/unknown fields and inputs over the proof byte cap', () => { + expectFailureCode( + () => parseCanonicalKaChunkProofV1(` ${THREE_CHUNK_PROOF}`, 3n), + 'proof-schema', + /not RFC 8785 canonical/, + ); + expectFailureCode( + () => assertKaChunkProofV1({ + chunkIndex: '00', + steps: [{ kind: 'odd' }, { kind: 'left', digest: ZERO_DIGEST }], + }, 3n), + 'proof-chunk-index', + ); + expectFailureCode( + () => assertKaChunkProofV1({ + chunkIndex: '2', + steps: [{ kind: 'odd' }, { kind: 'left', digest: ZERO_DIGEST, extra: true }], + }, 3n), + 'proof-schema', + ); + expectFailureCode( + () => parseCanonicalKaChunkProofV1('x'.repeat(MAX_KA_CHUNK_PROOF_BYTES_V1 + 1), 1n), + 'proof-object-too-large', + ); + }); + + it('accepts the largest topology under the 1,280-byte proof cap', () => { + const proof: KaChunkProofV1 = { + chunkIndex: '4095' as IndexV1, + steps: Array.from({ length: 12 }, () => ({ kind: 'left', digest: ZERO_DIGEST })), + }; + assertKaChunkProofV1(proof, 4096n); + const canonical = canonicalizeKaChunkProofV1(proof, 4096n); + expect(new TextEncoder().encode(canonical).byteLength).toBe(1159); + expect(parseCanonicalKaChunkProofV1(canonical, 4096n)).toEqual(proof); + }); + + it('rejects invalid chunk-count bounds and hostile backing memory', () => { + expectFailureCode( + () => assertKaChunkProofV1({ chunkIndex: '0', steps: [] }, 0n), + 'proof-chunk-count', + ); + expectFailureCode( + () => assertKaChunkProofV1({ chunkIndex: '0', steps: [] }, 4097n), + 'proof-chunk-count', + ); + const shared = new Uint8Array(new SharedArrayBuffer(7)); + expect(() => assertValidKaChunkProofV1( + parseCanonicalKaChunkProofV1(THREE_CHUNK_PROOF, 3n), + shared, + 524_295n, + THREE_CHUNK_ROOT, + )).toThrow(/shared backing memory/); + }); +}); + +function fixtureBundle(chunkCount: number): Uint8Array { + const chunks = Array.from({ length: chunkCount - 1 }, (_, index) => fullChunk(index)); + chunks.push(finalChunk()); + return concat(...chunks); +} + +function fullChunk(index: number): Uint8Array { + const chunk = new Uint8Array(262_144); + chunk.fill(index); + return chunk; +} + +function finalChunk(): Uint8Array { + return fromHex('a0a1a2a3a4a5a6'); +} + +function concat(...chunks: readonly Uint8Array[]): Uint8Array { + const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function expectFailureCode( + operation: () => unknown, + expected: KaChunkProofV1ErrorCode, + message?: RegExp, +): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + if (message?.test((error as Error).message)) return; + expect((error as Error & { code?: unknown }).code).toBe(expected); + return; + } + throw new Error(`expected operation to fail with ${expected}`); +} + +function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0 || !/^[0-9a-f]*$/.test(hex)) throw new Error('invalid test hex'); + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} diff --git a/packages/core/test/ka-transfer-descriptor.test.ts b/packages/core/test/ka-transfer-descriptor.test.ts index a458fd2b3f..7d316a7f1b 100644 --- a/packages/core/test/ka-transfer-descriptor.test.ts +++ b/packages/core/test/ka-transfer-descriptor.test.ts @@ -6,6 +6,7 @@ import { assertKaTransferDescriptorV1, canonicalizeKaTransferDescriptorBytesV1, canonicalizeKaTransferDescriptorV1, + computeKaTransferIdentityDigestV1, parseCanonicalKaTransferDescriptorV1, type KaTransferDescriptorV1, } from '../src/ka-transfer-descriptor.js'; @@ -30,6 +31,8 @@ const VALID_MIN_CANONICAL = '{"blobDigest":"0x1111111111111111111111111111111111111111111111111111111111111111","byteLength":"16","chunkCount":"1","chunkSize":"262144","chunkTreeRoot":"0x2222222222222222222222222222222222222222222222222222222222222222","codec":"dkg-ka-bundle-v1","projectionDigest":"0x0000000000000000000000000000000000000000000000000000000000000000","projectionId":"cg-shared-v1"}'; const VALID_MIN_FIXTURE_SHA256 = 'd3f1088dd50f7077865e8cf49e4e22d0aedba63720faa025ec8c89537cb7c80a'; +const VALID_MIN_TRANSFER_IDENTITY_DIGEST = + '0x007c995fd7d001736d39af9f2d3c79177c99b55682a1ff4d002fbc3e0345db25'; describe('KaTransferDescriptorV1', () => { it('pins the exact canonical valid-min fixture and independent fixture digest', () => { @@ -37,6 +40,9 @@ describe('KaTransferDescriptorV1', () => { const bytes = canonicalizeKaTransferDescriptorBytesV1(VALID_MIN); expect(bytes.byteLength).toBe(369); expect(lowerHex(sha256(bytes))).toBe(VALID_MIN_FIXTURE_SHA256); + expect(computeKaTransferIdentityDigestV1(VALID_MIN)).toBe( + VALID_MIN_TRANSFER_IDENTITY_DIGEST, + ); expect(parseCanonicalKaTransferDescriptorV1(bytes)).toEqual(VALID_MIN); }); From b4467ddbe143bcea489aaa3bb09221159fbb422c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 03:16:53 +0200 Subject: [PATCH 013/292] feat(core): add RFC-64 author catalog primitives --- packages/core/src/author-catalog-codec.ts | 657 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../core/test/author-catalog-codec.test.ts | 334 +++++++++ 3 files changed, 992 insertions(+) create mode 100644 packages/core/src/author-catalog-codec.ts create mode 100644 packages/core/test/author-catalog-codec.test.ts diff --git a/packages/core/src/author-catalog-codec.ts b/packages/core/src/author-catalog-codec.ts new file mode 100644 index 0000000000..68b2f1118f --- /dev/null +++ b/packages/core/src/author-catalog-codec.ts @@ -0,0 +1,657 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + KA_TRANSFER_PROJECTION_V1, + assertKaTransferDescriptorV1, + type KaTransferDescriptorV1, +} from './ka-transfer-descriptor.js'; +import { + assertCanonicalChainId, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalKaId, + parseCanonicalDecimalU64, + parseCanonicalDecimalU256, + type ChainIdV1, + type CountV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type KaIdV1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +declare const NETWORK_ID_V1_BRAND: unique symbol; +declare const CONTEXT_GRAPH_ID_V1_BRAND: unique symbol; +declare const SUBGRAPH_NAME_V1_BRAND: unique symbol; +declare const ASSERTION_COORDINATE_V1_BRAND: unique symbol; +declare const CATALOG_ASSERTION_SCOPE_V1_BRAND: unique symbol; +declare const CATALOG_ASSERTION_SUBJECT_V1_BRAND: unique symbol; + +export type NetworkIdV1 = string & { readonly [NETWORK_ID_V1_BRAND]: true }; +export type ContextGraphIdV1 = string & { readonly [CONTEXT_GRAPH_ID_V1_BRAND]: true }; +export type SubGraphNameV1 = string & { readonly [SUBGRAPH_NAME_V1_BRAND]: true }; +export type AssertionCoordinateV1 = string & { + readonly [ASSERTION_COORDINATE_V1_BRAND]: true; +}; +export type CatalogAssertionScopeV1 = string & { + readonly [CATALOG_ASSERTION_SCOPE_V1_BRAND]: true; +}; +export type CatalogAssertionSubjectV1 = string & { + readonly [CATALOG_ASSERTION_SUBJECT_V1_BRAND]: true; +}; + +export const AUTHOR_CATALOG_SCOPE_DIGEST_DOMAIN_V1 = + 'dkg-author-catalog-scope-v1\n' as const; +export const AUTHOR_CATALOG_KEY_DIGEST_DOMAIN_V1 = + 'dkg-author-catalog-key-v1\n' as const; +export const AUTHOR_CATALOG_ROW_DIGEST_DOMAIN_V1 = + 'dkg-author-catalog-row-v1\n' as const; + +export const MAX_AUTHOR_CATALOG_NETWORK_ID_BYTES_V1 = 128; +export const MAX_AUTHOR_CATALOG_IDENTIFIER_BYTES_V1 = 256; +export const MAX_AUTHOR_CATALOG_SCOPE_BYTES_V1 = 2048; +export const MAX_AUTHOR_CATALOG_ROW_BYTES_V1 = 2048; +export const MAX_AUTHOR_CATALOG_ROW_DIGEST_INPUT_BYTES_V1 = 4096; +export const MAX_AUTHOR_CATALOG_BUCKET_COUNT_V1 = 9_223_372_036_854_775_808n; +export const PACKED_KA_NUMBER_BITS_V1 = 96n; + +const NETWORK_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; +const CONTEXT_GRAPH_ID_PATTERN = /^[A-Za-z0-9_:/\.@-]+$/; +const CATALOG_IDENTIFIER_ASCII_FORBIDDEN = new Set([ + '<', + '>', + '"', + '{', + '}', + '|', + '^', + '`', + '\\', +]); +const RESERVED_SUBGRAPH_NAMES = new Set(['context', 'assertion', 'draft']); +const UTF8 = new TextEncoder(); +const SCOPE_DOMAIN_BYTES = UTF8.encode(AUTHOR_CATALOG_SCOPE_DIGEST_DOMAIN_V1); +const KEY_DOMAIN_BYTES = UTF8.encode(AUTHOR_CATALOG_KEY_DIGEST_DOMAIN_V1); +const ROW_DOMAIN_BYTES = UTF8.encode(AUTHOR_CATALOG_ROW_DIGEST_DOMAIN_V1); + +export interface CatalogLaneV1 { + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; +} + +/** Exact nine-key scope committed by every author-catalog era. */ +export interface AuthorCatalogScopeV1 extends CatalogLaneV1 { + readonly networkId: NetworkIdV1; + readonly governanceChainId: ChainIdV1 | null; + readonly governanceContractAddress: EvmAddressV1 | null; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly authorAddress: EvmAddressV1; + readonly era: DecimalU64V1; + readonly bucketCount: CountV1; +} + +/** Exact seven-key live author-catalog row. */ +export interface AuthorCatalogRowV1 { + readonly kaId: KaIdV1; + readonly assertionCoordinate: AssertionCoordinateV1; + readonly assertionVersion: DecimalU64V1; + readonly projectionId: typeof KA_TRANSFER_PROJECTION_V1; + readonly projectionDigest: Digest32V1; + readonly sealDigest: Digest32V1; + readonly transfer: KaTransferDescriptorV1; +} + +export type AuthorCatalogCodecErrorCode = + | 'catalog-schema' + | 'catalog-identifier' + | 'catalog-scalar' + | 'catalog-governance-tuple' + | 'catalog-bucket-count' + | 'catalog-transfer-mismatch' + | 'catalog-packed-author-mismatch' + | 'object-too-large'; + +export class AuthorCatalogCodecError extends Error { + constructor( + readonly code: AuthorCatalogCodecErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'AuthorCatalogCodecError'; + } +} + +export function assertNetworkIdV1( + value: unknown, + label = 'networkId', +): asserts value is NetworkIdV1 { + const identifier = assertNfcUtf8Identifier( + value, + label, + MAX_AUTHOR_CATALOG_NETWORK_ID_BYTES_V1, + ); + if (!NETWORK_ID_PATTERN.test(identifier)) { + fail('catalog-identifier', `${label} contains a character outside the networkId grammar`); + } +} + +export function assertContextGraphIdV1( + value: unknown, + label = 'contextGraphId', +): asserts value is ContextGraphIdV1 { + const identifier = assertNfcUtf8Identifier( + value, + label, + MAX_AUTHOR_CATALOG_IDENTIFIER_BYTES_V1, + ); + if (!CONTEXT_GRAPH_ID_PATTERN.test(identifier)) { + fail( + 'catalog-identifier', + `${label} contains a character outside the contextGraphId grammar`, + ); + } +} + +export function assertSubGraphNameV1( + value: unknown, + label = 'subGraphName', +): asserts value is SubGraphNameV1 { + const identifier = assertCatalogLaneIdentifier(value, label); + if (identifier.startsWith('_')) { + fail('catalog-identifier', `${label} must not start with underscore`); + } + if (RESERVED_SUBGRAPH_NAMES.has(identifier)) { + fail('catalog-identifier', `${label} is a reserved subgraph name`); + } +} + +export function assertAssertionCoordinateV1( + value: unknown, + label = 'assertionCoordinate', +): asserts value is AssertionCoordinateV1 { + assertCatalogLaneIdentifier(value, label); +} + +/** Exact RFC-64 forbidden-code-point predicate, intentionally permitting U+200C. */ +export function isCatalogForbiddenCodePointV1(codePoint: number): boolean { + return ( + (codePoint >= 0x0000 && codePoint <= 0x001f) + || (codePoint >= 0x007f && codePoint <= 0x009f) + || codePoint === 0x00a0 + || codePoint === 0x1680 + || (codePoint >= 0x2000 && codePoint <= 0x200b) + || codePoint === 0x2028 + || codePoint === 0x2029 + || codePoint === 0x202f + || codePoint === 0x205f + || codePoint === 0x3000 + || codePoint === 0xfeff + ); +} + +/** Percent-encode one already canonical catalog-v1 identifier component. */ +export function iriComponentV1(value: string): string { + const identifier = assertNfcUtf8Identifier( + value, + 'IRI component', + MAX_AUTHOR_CATALOG_IDENTIFIER_BYTES_V1, + ); + const bytes = UTF8.encode(identifier); + let encoded = ''; + for (const byte of bytes) { + if (isUnescapedIriComponentByte(byte)) { + encoded += String.fromCharCode(byte); + } else { + encoded += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + } + return encoded; +} + +/** Build the prefix-free root/subgraph assertion scope used by catalog-v1 seals. */ +export function buildCatalogAssertionScopeV1(lane: CatalogLaneV1): CatalogAssertionScopeV1 { + assertCatalogLaneV1(lane); + const context = iriComponentV1(lane.contextGraphId); + const result = lane.subGraphName === null + ? `v1/root/${context}` + : `v1/subgraph/${context}/${iriComponentV1(lane.subGraphName)}`; + return result as CatalogAssertionScopeV1; +} + +/** Build the exact graph-scoped author-seal subject for one catalog row. */ +export function buildCatalogAssertionSubjectV1( + lane: CatalogLaneV1, + authorAddress: EvmAddressV1, + assertionCoordinate: AssertionCoordinateV1, +): CatalogAssertionSubjectV1 { + assertCatalogLaneV1(lane); + assertCatalogScalar(() => assertCanonicalEvmAddress(authorAddress, 'authorAddress')); + assertAssertionCoordinateV1(assertionCoordinate); + return ( + `did:dkg:context-graph:${buildCatalogAssertionScopeV1(lane)}` + + `/assertion/${authorAddress}/${iriComponentV1(assertionCoordinate)}` + ) as CatalogAssertionSubjectV1; +} + +/** Validate the complete in-memory catalog scope without signing or authority checks. */ +export function assertAuthorCatalogScopeV1( + scope: unknown, +): asserts scope is AuthorCatalogScopeV1 { + if (!isPlainRecord(scope)) { + fail('catalog-schema', 'author catalog scope must be a plain JSON object'); + } + assertClosedKeys(scope, [ + 'authorAddress', + 'bucketCount', + 'contextGraphId', + 'era', + 'governanceChainId', + 'governanceContractAddress', + 'networkId', + 'ownershipTransitionDigest', + 'subGraphName', + ], 'author catalog scope'); + + assertNetworkIdV1(scope.networkId); + assertContextGraphIdV1(scope.contextGraphId); + if (scope.subGraphName !== null) assertSubGraphNameV1(scope.subGraphName); + assertCatalogScalar(() => assertCanonicalEvmAddress(scope.authorAddress, 'authorAddress')); + assertCatalogU64(scope.era, 'era'); + assertAuthorCatalogBucketCountV1(scope.bucketCount); + + const chainIsNull = scope.governanceChainId === null; + const contractIsNull = scope.governanceContractAddress === null; + if (chainIsNull !== contractIsNull) { + fail( + 'catalog-governance-tuple', + 'governanceChainId and governanceContractAddress must both be null or both be non-null', + ); + } + if (!chainIsNull) { + assertCatalogScalar(() => assertCanonicalChainId(scope.governanceChainId, 'governanceChainId')); + assertCatalogScalar(() => assertCanonicalEvmAddress( + scope.governanceContractAddress, + 'governanceContractAddress', + )); + } + if (scope.ownershipTransitionDigest !== null) { + assertCatalogScalar(() => assertCanonicalDigest( + scope.ownershipTransitionDigest, + 'ownershipTransitionDigest', + )); + } +} + +export function canonicalizeAuthorCatalogScopeV1(scope: AuthorCatalogScopeV1): string { + assertAuthorCatalogScopeV1(scope); + return canonicalizeJson(scope as unknown as CanonicalJsonValue, { + maxBytes: MAX_AUTHOR_CATALOG_SCOPE_BYTES_V1, + maxDepth: 1, + }); +} + +export function parseCanonicalAuthorCatalogScopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): AuthorCatalogScopeV1 { + rejectOversizedWireInput(input, MAX_AUTHOR_CATALOG_SCOPE_BYTES_V1, 'author catalog scope'); + const parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_AUTHOR_CATALOG_SCOPE_BYTES_V1, + MAX_AUTHOR_CATALOG_SCOPE_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 1, 1), + }); + assertAuthorCatalogScopeV1(parsed); + return parsed; +} + +export function computeAuthorCatalogScopeDigestV1( + scope: AuthorCatalogScopeV1, +): Digest32V1 { + return digestWithDomain( + SCOPE_DOMAIN_BYTES, + UTF8.encode(canonicalizeAuthorCatalogScopeV1(scope)), + ); +} + +export function computeAuthorCatalogKeyDigestV1(kaId: KaIdV1): Digest32V1 { + assertCatalogScalar(() => assertCanonicalKaId(kaId, 'kaId')); + const canonical = canonicalizeJson({ kaId } as CanonicalJsonValue, { + maxBytes: 128, + maxDepth: 1, + }); + return digestWithDomain(KEY_DOMAIN_BYTES, UTF8.encode(canonical)); +} + +/** Compare canonical u256 KA identifiers by mathematical value, never lexically. */ +export function compareAuthorCatalogKaIdsV1(left: KaIdV1, right: KaIdV1): -1 | 0 | 1 { + const leftValue = parseCatalogU256(left, 'left kaId'); + const rightValue = parseCatalogU256(right, 'right kaId'); + if (leftValue < rightValue) return -1; + if (leftValue > rightValue) return 1; + return 0; +} + +export function assertAuthorCatalogBucketCountV1( + value: unknown, + label = 'bucketCount', +): asserts value is CountV1 { + const count = assertCatalogU64(value, label); + if ( + count < 1n + || count > MAX_AUTHOR_CATALOG_BUCKET_COUNT_V1 + || (count & (count - 1n)) !== 0n + ) { + fail( + 'catalog-bucket-count', + `${label} must be a power of two in 1..${MAX_AUTHOR_CATALOG_BUCKET_COUNT_V1}`, + ); + } +} + +/** Map a key digest to the unsigned integer formed by its first log2(bucketCount) bits. */ +export function catalogKeyDigestToBucketIdV1( + catalogKeyDigest: Digest32V1, + bucketCount: CountV1, +): DecimalU64V1 { + assertCatalogScalar(() => assertCanonicalDigest(catalogKeyDigest, 'catalogKeyDigest')); + assertAuthorCatalogBucketCountV1(bucketCount); + const count = BigInt(bucketCount); + if (count === 1n) return '0' as DecimalU64V1; + + let prefixBits = 0n; + for (let remaining = count; remaining > 1n; remaining >>= 1n) prefixBits += 1n; + const bucketId = BigInt(catalogKeyDigest) >> (256n - prefixBits); + const result = bucketId.toString(); + parseCanonicalDecimalU64(result, 'bucketId'); + return result as DecimalU64V1; +} + +export function catalogKeyToBucketIdV1( + kaId: KaIdV1, + bucketCount: CountV1, +): DecimalU64V1 { + return catalogKeyDigestToBucketIdV1( + computeAuthorCatalogKeyDigestV1(kaId), + bucketCount, + ); +} + +/** Validate the complete in-memory row, including transfer/row projection equality. */ +export function assertAuthorCatalogRowV1( + row: unknown, +): asserts row is AuthorCatalogRowV1 { + if (!isPlainRecord(row)) { + fail('catalog-schema', 'author catalog row must be a plain JSON object'); + } + assertClosedKeys(row, [ + 'assertionCoordinate', + 'assertionVersion', + 'kaId', + 'projectionDigest', + 'projectionId', + 'sealDigest', + 'transfer', + ], 'author catalog row'); + + assertCatalogScalar(() => assertCanonicalKaId(row.kaId, 'kaId')); + assertAssertionCoordinateV1(row.assertionCoordinate); + assertCatalogU64(row.assertionVersion, 'assertionVersion'); + if (row.projectionId !== KA_TRANSFER_PROJECTION_V1) { + fail('catalog-schema', `projectionId must be ${KA_TRANSFER_PROJECTION_V1}`); + } + assertCatalogScalar(() => assertCanonicalDigest(row.projectionDigest, 'projectionDigest')); + assertCatalogScalar(() => assertCanonicalDigest(row.sealDigest, 'sealDigest')); + + try { + assertKaTransferDescriptorV1(row.transfer); + } catch (cause) { + fail('catalog-schema', 'transfer is not a structurally valid KaTransferDescriptorV1', cause); + } + if ( + row.transfer.projectionId !== row.projectionId + || row.transfer.projectionDigest !== row.projectionDigest + ) { + fail( + 'catalog-transfer-mismatch', + 'transfer projectionId and projectionDigest must equal the row values', + ); + } +} + +export function canonicalizeAuthorCatalogRowV1(row: AuthorCatalogRowV1): string { + assertAuthorCatalogRowV1(row); + return canonicalizeJson(row as unknown as CanonicalJsonValue, { + maxBytes: MAX_AUTHOR_CATALOG_ROW_BYTES_V1, + maxDepth: 2, + }); +} + +export function parseCanonicalAuthorCatalogRowV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): AuthorCatalogRowV1 { + rejectOversizedWireInput(input, MAX_AUTHOR_CATALOG_ROW_BYTES_V1, 'author catalog row'); + const parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_AUTHOR_CATALOG_ROW_BYTES_V1, + MAX_AUTHOR_CATALOG_ROW_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 2, 2), + }); + assertAuthorCatalogRowV1(parsed); + return parsed; +} + +export function computeAuthorCatalogRowDigestV1( + catalogScopeDigest: Digest32V1, + row: AuthorCatalogRowV1, +): Digest32V1 { + assertCatalogScalar(() => assertCanonicalDigest(catalogScopeDigest, 'catalogScopeDigest')); + assertAuthorCatalogRowV1(row); + const canonical = canonicalizeJson( + { catalogScopeDigest, row } as unknown as CanonicalJsonValue, + { maxBytes: MAX_AUTHOR_CATALOG_ROW_DIGEST_INPUT_BYTES_V1, maxDepth: 3 }, + ); + return digestWithDomain(ROW_DOMAIN_BYTES, UTF8.encode(canonical)); +} + +/** + * Check the structural portion of Option-1 identity available without parsing a + * seal/UAL: the high 160 bits of the packed u256 KA id must equal the scope author. + */ +export function assertPackedKaIdAuthorBindingV1( + kaId: KaIdV1, + authorAddress: EvmAddressV1, +): void { + const packedKaId = parseCatalogU256(kaId, 'kaId'); + assertCatalogScalar(() => assertCanonicalEvmAddress(authorAddress, 'authorAddress')); + const packedAuthor = packedKaId >> PACKED_KA_NUMBER_BITS_V1; + if (packedAuthor !== BigInt(authorAddress)) { + fail( + 'catalog-packed-author-mismatch', + 'the high 160 bits of kaId must equal authorAddress', + ); + } +} + +/** Validate all structural row/scope bindings available before RDF seal admission. */ +export function assertAuthorCatalogRowScopeBindingV1( + row: AuthorCatalogRowV1, + scope: AuthorCatalogScopeV1, +): void { + assertAuthorCatalogRowV1(row); + assertAuthorCatalogScopeV1(scope); + assertPackedKaIdAuthorBindingV1(row.kaId, scope.authorAddress); +} + +function assertCatalogLaneV1(lane: unknown): asserts lane is CatalogLaneV1 { + if (!isPlainRecord(lane)) { + fail('catalog-schema', 'catalog lane must be a plain JSON object'); + } + // AuthorCatalogScopeV1 is itself a CatalogLaneV1, so these helpers must admit + // that structural superset. Still require the two consumed fields to be own, + // enumerable data properties before reading them. + for (const key of ['contextGraphId', 'subGraphName']) { + const descriptor = Object.getOwnPropertyDescriptor(lane, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('catalog-schema', `catalog lane ${key} must be an enumerable data property`); + } + } + assertContextGraphIdV1(lane.contextGraphId); + if (lane.subGraphName !== null) assertSubGraphNameV1(lane.subGraphName); +} + +function assertCatalogLaneIdentifier(value: unknown, label: string): string { + const identifier = assertNfcUtf8Identifier( + value, + label, + MAX_AUTHOR_CATALOG_IDENTIFIER_BYTES_V1, + ); + for (const character of identifier) { + const codePoint = character.codePointAt(0) as number; + if ( + character === '/' + || CATALOG_IDENTIFIER_ASCII_FORBIDDEN.has(character) + || isCatalogForbiddenCodePointV1(codePoint) + ) { + fail('catalog-identifier', `${label} contains a forbidden character or code point`); + } + } + return identifier; +} + +function assertNfcUtf8Identifier( + value: unknown, + label: string, + maxBytes: number, +): string { + if (typeof value !== 'string' || value.length === 0) { + fail('catalog-identifier', `${label} must be a non-empty string`); + } + // UTF-8 byte length is never less than UTF-16 code-unit length. Reject a + // hostile oversized input before normalization or encoding allocates a copy. + if (value.length > maxBytes) { + fail('catalog-identifier', `${label} exceeds ${maxBytes} UTF-8 bytes`); + } + assertWellFormedUnicode(value, label); + if (value.normalize('NFC') !== value) { + fail('catalog-identifier', `${label} must already be NFC-normalized`); + } + if (UTF8.encode(value).byteLength > maxBytes) { + fail('catalog-identifier', `${label} exceeds ${maxBytes} UTF-8 bytes`); + } + return value; +} + +function assertWellFormedUnicode(value: string, label: string): void { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + fail('catalog-identifier', `${label} contains an unpaired UTF-16 surrogate`); + } + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + fail('catalog-identifier', `${label} contains an unpaired UTF-16 surrogate`); + } + } +} + +function isUnescapedIriComponentByte(byte: number): boolean { + return ( + (byte >= 0x41 && byte <= 0x5a) + || (byte >= 0x61 && byte <= 0x7a) + || (byte >= 0x30 && byte <= 0x39) + || byte === 0x2d + || byte === 0x2e + || byte === 0x5f + || byte === 0x7e + ); +} + +function assertClosedKeys( + record: Record, + keys: readonly string[], + label: string, +): void { + try { + assertExactKeys(record, keys, label); + } catch (cause) { + fail('catalog-schema', `${label} has an invalid field set`, cause); + } +} + +function assertCatalogScalar(operation: () => void): void { + try { + operation(); + } catch (cause) { + fail('catalog-scalar', 'catalog scalar is not canonical', cause); + } +} + +function assertCatalogU64(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU64(value, label); + } catch (cause) { + fail('catalog-scalar', `${label} is not a canonical DecimalU64V1`, cause); + } +} + +function parseCatalogU256(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU256(value, label); + } catch (cause) { + fail('catalog-scalar', `${label} is not a canonical DecimalU256V1`, cause); + } +} + +function digestWithDomain(domain: Uint8Array, canonicalBytes: Uint8Array): Digest32V1 { + const hasher = sha256.create(); + hasher.update(domain); + hasher.update(canonicalBytes); + let result = '0x'; + for (const byte of hasher.digest()) result += byte.toString(16).padStart(2, '0'); + assertCanonicalDigest(result, 'computed catalog digest'); + return result; +} + +function rejectOversizedWireInput( + input: string | Uint8Array, + maxBytes: number, + label: string, +): void { + if (typeof input !== 'string') { + if (input.byteLength > maxBytes) { + fail('object-too-large', `${label} exceeds ${maxBytes} bytes`); + } + return; + } + if (input.length > maxBytes || UTF8.encode(input).byteLength > maxBytes) { + fail('object-too-large', `${label} exceeds ${maxBytes} bytes`); + } +} + +function fail( + code: AuthorCatalogCodecErrorCode, + message: string, + cause?: unknown, +): never { + throw new AuthorCatalogCodecError( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd1c312d7f..edc71da1aa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,6 +48,7 @@ export * from './ka-transfer-descriptor.js'; export * from './ka-bundle-v1.js'; export * from './ka-chunk-tree.js'; export * from './ka-chunk-proof.js'; +export * from './author-catalog-codec.js'; export * from './event-bus.js'; export { Logger, diff --git a/packages/core/test/author-catalog-codec.test.ts b/packages/core/test/author-catalog-codec.test.ts new file mode 100644 index 0000000000..ef20e16f0d --- /dev/null +++ b/packages/core/test/author-catalog-codec.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_AUTHOR_CATALOG_BUCKET_COUNT_V1, + MAX_AUTHOR_CATALOG_ROW_BYTES_V1, + MAX_AUTHOR_CATALOG_ROW_DIGEST_INPUT_BYTES_V1, + MAX_AUTHOR_CATALOG_SCOPE_BYTES_V1, + assertAssertionCoordinateV1, + assertAuthorCatalogBucketCountV1, + assertAuthorCatalogRowScopeBindingV1, + assertAuthorCatalogRowV1, + assertAuthorCatalogScopeV1, + assertContextGraphIdV1, + assertNetworkIdV1, + assertPackedKaIdAuthorBindingV1, + assertSubGraphNameV1, + buildCatalogAssertionScopeV1, + buildCatalogAssertionSubjectV1, + canonicalizeAuthorCatalogRowV1, + canonicalizeAuthorCatalogScopeV1, + catalogKeyDigestToBucketIdV1, + catalogKeyToBucketIdV1, + compareAuthorCatalogKaIdsV1, + computeAuthorCatalogKeyDigestV1, + computeAuthorCatalogRowDigestV1, + computeAuthorCatalogScopeDigestV1, + iriComponentV1, + parseCanonicalAuthorCatalogRowV1, + parseCanonicalAuthorCatalogScopeV1, + type AssertionCoordinateV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type CatalogLaneV1, + type ContextGraphIdV1, + type EvmAddressV1, + type KaIdV1, + type SubGraphNameV1, +} from '../src/index.js'; + +const ZERO_DIGEST = `0x${'00'.repeat(32)}`; +const BLOB_DIGEST = `0x${'11'.repeat(32)}`; +const CHUNK_TREE_ROOT = `0x${'22'.repeat(32)}`; +const SEAL_DIGEST = `0x${'44'.repeat(32)}`; +const AUTHOR = `0x${'33'.repeat(20)}`; +const GOVERNANCE_CONTRACT = `0x${'22'.repeat(20)}`; + +const SCOPE_CANONICAL = + '{"authorAddress":"0x3333333333333333333333333333333333333333","bucketCount":"1","contextGraphId":"0x1111111111111111111111111111111111111111/catalog-fixture","era":"0","governanceChainId":"20430","governanceContractAddress":"0x2222222222222222222222222222222222222222","networkId":"otp:20430","ownershipTransitionDigest":null,"subGraphName":null}'; +const SCOPE_DIGEST = + '0x7b18d141cbb6af4e7fdabe2e4d7d0b9512b042eb079b89a4797d3a0a7f1d4537'; + +const ROW_CANONICAL = + '{"assertionCoordinate":"fixture","assertionVersion":"1","kaId":"1","projectionDigest":"0x0000000000000000000000000000000000000000000000000000000000000000","projectionId":"cg-shared-v1","sealDigest":"0x4444444444444444444444444444444444444444444444444444444444444444","transfer":{"blobDigest":"0x1111111111111111111111111111111111111111111111111111111111111111","byteLength":"16","chunkCount":"1","chunkSize":"262144","chunkTreeRoot":"0x2222222222222222222222222222222222222222222222222222222222222222","codec":"dkg-ka-bundle-v1","projectionDigest":"0x0000000000000000000000000000000000000000000000000000000000000000","projectionId":"cg-shared-v1"}}'; +const ROW_DIGEST = + '0x14820ff6ae773dc2e6496419e059f3a423bd99572b8f828de01deedbec76aad7'; +const KEY_DIGEST = + '0x3e8dfe2886837263349072315b5308d53ed04a8593c7c74124bcf56b218037de'; + +const VALID_SCOPE = validatedScope(JSON.parse(SCOPE_CANONICAL)); +const VALID_ROW = validatedRow(JSON.parse(ROW_CANONICAL)); + +describe('RFC-64 author catalog identifiers and graph names', () => { + it('pins the prefix-free root/subgraph names and exact UTF-8 IRI encoding', () => { + const rootLane = validatedLane({ contextGraphId: 'a/b', subGraphName: null }); + const subgraphLane = validatedLane({ contextGraphId: 'a', subGraphName: 'b' }); + expect(buildCatalogAssertionScopeV1(rootLane)).toBe('v1/root/a%2Fb'); + expect(buildCatalogAssertionScopeV1(subgraphLane)).toBe('v1/subgraph/a/b'); + expect(buildCatalogAssertionScopeV1(rootLane)).not.toBe( + buildCatalogAssertionScopeV1(subgraphLane), + ); + + const unicodeLane = validatedLane({ contextGraphId: 'cg', subGraphName: 'café' }); + const coordinate = validatedCoordinate('name λ'); + expect(iriComponentV1('AZaz09-._~ /é')).toBe('AZaz09-._~%20%2F%C3%A9'); + expect(buildCatalogAssertionSubjectV1(unicodeLane, AUTHOR as EvmAddressV1, coordinate)) + .toBe( + `did:dkg:context-graph:v1/subgraph/cg/caf%C3%A9/assertion/${AUTHOR}/name%20%CE%BB`, + ); + }); + + it('enforces exact network/context grammar, NFC, and UTF-8 byte ceilings', () => { + expect(() => assertNetworkIdV1('otp:20430')).not.toThrow(); + expect(() => assertContextGraphIdV1('owner/cg.name@v1')).not.toThrow(); + expect(() => assertNetworkIdV1('otp/20430')).toThrow(/catalog-identifier/); + expect(() => assertContextGraphIdV1('café')).toThrow(/catalog-identifier/); + expect(() => assertSubGraphNameV1('e\u0301')).toThrow(/NFC-normalized/); + expect(() => assertSubGraphNameV1('é')).not.toThrow(); + expect(() => assertAssertionCoordinateV1('\ud800')).toThrow(/unpaired/); + expect(() => assertNetworkIdV1('a'.repeat(129))).toThrow(/128 UTF-8 bytes/); + expect(() => assertSubGraphNameV1('é'.repeat(129))).toThrow(/256 UTF-8 bytes/); + }); + + it.each(['\u00a0', '\u2028', '\u2029', '\u200b', '\ufeff']) + ('rejects catalog-forbidden code point %j', (codePoint) => { + expect(() => assertSubGraphNameV1(`a${codePoint}b`)).toThrow(/forbidden/); + expect(() => assertAssertionCoordinateV1(`a${codePoint}b`)).toThrow(/forbidden/); + }); + + it('accepts U+200C while rejecting slash, forbidden ASCII, and reserved lanes', () => { + expect(() => assertSubGraphNameV1('a\u200cb')).not.toThrow(); + expect(() => assertAssertionCoordinateV1('a\u200cb')).not.toThrow(); + for (const value of ['a/b', 'ab', 'a"b', 'a{b', 'a}b', 'a|b', 'a^b', 'a`b', 'a\\b']) { + expect(() => assertSubGraphNameV1(value)).toThrow(/forbidden/); + expect(() => assertAssertionCoordinateV1(value)).toThrow(/forbidden/); + } + for (const value of ['_private', 'context', 'assertion', 'draft']) { + expect(() => assertSubGraphNameV1(value)).toThrow(/catalog-identifier/); + } + expect(() => assertAssertionCoordinateV1('_private')).not.toThrow(); + }); +}); + +describe('AuthorCatalogScopeV1', () => { + it('pins the exact 346-byte canonical fixture and scope digest', () => { + expect(canonicalizeAuthorCatalogScopeV1(VALID_SCOPE)).toBe(SCOPE_CANONICAL); + expect(new TextEncoder().encode(SCOPE_CANONICAL).byteLength).toBe(346); + expect(computeAuthorCatalogScopeDigestV1(VALID_SCOPE)).toBe(SCOPE_DIGEST); + expect(parseCanonicalAuthorCatalogScopeV1(SCOPE_CANONICAL)).toEqual(VALID_SCOPE); + expect(buildCatalogAssertionScopeV1(VALID_SCOPE)).toBe( + 'v1/root/0x1111111111111111111111111111111111111111%2Fcatalog-fixture', + ); + }); + + it('accepts only paired governance fields and exact closed scalar fields', () => { + const unregistered = validatedScope({ + ...VALID_SCOPE, + governanceChainId: null, + governanceContractAddress: null, + }); + expect(() => assertAuthorCatalogScopeV1(unregistered)).not.toThrow(); + expect(() => assertAuthorCatalogScopeV1({ + ...VALID_SCOPE, + governanceContractAddress: null, + })).toThrow(/catalog-governance-tuple/); + expect(() => assertAuthorCatalogScopeV1({ ...VALID_SCOPE, era: 0 })).toThrow( + /catalog-scalar/, + ); + expect(() => assertAuthorCatalogScopeV1({ + ...VALID_SCOPE, + ownershipTransitionDigest: `0x${'AA'.repeat(32)}`, + })).toThrow(/catalog-scalar/); + expect(() => assertAuthorCatalogScopeV1({ + ...VALID_SCOPE, + governanceContractAddress: `0x${'00'.repeat(20)}`, + })).toThrow(/catalog-scalar/); + }); + + it('requires a canonical power-of-two bucket count through 2^63', () => { + for (const value of ['1', '2', '256', MAX_AUTHOR_CATALOG_BUCKET_COUNT_V1.toString()]) { + expect(() => assertAuthorCatalogBucketCountV1(value)).not.toThrow(); + } + for (const value of ['0', '3', '9223372036854775809', 1, '01']) { + expect(() => assertAuthorCatalogBucketCountV1(value)).toThrow(); + } + }); + + it('rejects missing, extra, symbol, accessor, noncanonical, and duplicate wire fields', () => { + const missing = { ...VALID_SCOPE } as Record; + delete missing.era; + expect(() => assertAuthorCatalogScopeV1(missing)).toThrow(/catalog-schema/); + expect(() => assertAuthorCatalogScopeV1({ ...VALID_SCOPE, tier: 'swm' })).toThrow( + /catalog-schema/, + ); + const symbol = { ...VALID_SCOPE } as Record; + symbol[Symbol('hidden')] = true; + expect(() => assertAuthorCatalogScopeV1(symbol)).toThrow(/catalog-schema/); + const accessor = { ...VALID_SCOPE }; + Object.defineProperty(accessor, 'era', { enumerable: true, get: () => '0' }); + expect(() => assertAuthorCatalogScopeV1(accessor)).toThrow(/catalog-schema/); + expect(() => parseCanonicalAuthorCatalogScopeV1( + SCOPE_CANONICAL.replace('"era":"0"', '"era":"0","era":"0"'), + )).toThrow(/Duplicate object key/); + expect(() => parseCanonicalAuthorCatalogScopeV1(` ${SCOPE_CANONICAL}`)).toThrow( + /not RFC 8785 canonical/, + ); + }); +}); + +describe('AuthorCatalogRowV1 and key placement', () => { + it('pins the exact key and seven-key row digest fixtures', () => { + expect(new TextEncoder().encode('{"kaId":"1"}').byteLength).toBe(12); + expect(computeAuthorCatalogKeyDigestV1('1' as KaIdV1)).toBe(KEY_DIGEST); + expect(canonicalizeAuthorCatalogRowV1(VALID_ROW)).toBe(ROW_CANONICAL); + expect(new TextEncoder().encode( + `{"catalogScopeDigest":"${SCOPE_DIGEST}","row":${ROW_CANONICAL}}`, + ).byteLength).toBe(746); + expect(computeAuthorCatalogRowDigestV1(SCOPE_DIGEST, VALID_ROW)).toBe(ROW_DIGEST); + expect(parseCanonicalAuthorCatalogRowV1(ROW_CANONICAL)).toEqual(VALID_ROW); + }); + + it('requires exact row/transfer projection equality', () => { + expect(() => assertAuthorCatalogRowV1({ + ...VALID_ROW, + transfer: { ...VALID_ROW.transfer, projectionDigest: BLOB_DIGEST }, + })).toThrow(/catalog-transfer-mismatch/); + expect(() => assertAuthorCatalogRowV1({ + ...VALID_ROW, + projectionId: 'private-v1', + })).toThrow(/catalog-schema/); + expect(() => assertAuthorCatalogRowV1({ + ...VALID_ROW, + transfer: { ...VALID_ROW.transfer, chunkCount: '2' }, + })).toThrow(/catalog-schema/); + }); + + it('rejects hostile row shape, scalars, coordinate, and oversized wire input', () => { + const missing = { ...VALID_ROW } as Record; + delete missing.sealDigest; + expect(() => assertAuthorCatalogRowV1(missing)).toThrow(/catalog-schema/); + expect(() => assertAuthorCatalogRowV1({ ...VALID_ROW, tier: 'swm' })).toThrow( + /catalog-schema/, + ); + const symbol = { ...VALID_ROW } as Record; + symbol[Symbol('hidden')] = true; + expect(() => assertAuthorCatalogRowV1(symbol)).toThrow(/catalog-schema/); + const accessor = { ...VALID_ROW }; + Object.defineProperty(accessor, 'sealDigest', { + enumerable: true, + get: () => SEAL_DIGEST, + }); + expect(() => assertAuthorCatalogRowV1(accessor)).toThrow(/catalog-schema/); + expect(() => assertAuthorCatalogRowV1({ ...VALID_ROW, kaId: 1 })).toThrow( + /catalog-scalar/, + ); + expect(() => assertAuthorCatalogRowV1({ ...VALID_ROW, assertionVersion: '01' })).toThrow( + /catalog-scalar/, + ); + expect(() => assertAuthorCatalogRowV1({ + ...VALID_ROW, + assertionCoordinate: 'bad/name', + })).toThrow(/catalog-identifier/); + expect(() => parseCanonicalAuthorCatalogRowV1( + '{'.padEnd(MAX_AUTHOR_CATALOG_ROW_BYTES_V1 + 1, 'x'), + )).toThrow(/object-too-large/); + }); + + it('compares full u256 KA ids numerically and maps key prefixes to buckets', () => { + expect(compareAuthorCatalogKaIdsV1('2' as KaIdV1, '10' as KaIdV1)).toBe(-1); + expect(compareAuthorCatalogKaIdsV1('10' as KaIdV1, '2' as KaIdV1)).toBe(1); + expect(compareAuthorCatalogKaIdsV1('10' as KaIdV1, '10' as KaIdV1)).toBe(0); + expect(compareAuthorCatalogKaIdsV1( + '115792089237316195423570985008687907853269984665640564039457584007913129639935' as KaIdV1, + '10' as KaIdV1, + )).toBe(1); + + expect(catalogKeyDigestToBucketIdV1(KEY_DIGEST, '1')).toBe('0'); + expect(catalogKeyDigestToBucketIdV1(KEY_DIGEST, '8')).toBe('1'); + expect(catalogKeyDigestToBucketIdV1(KEY_DIGEST, '16')).toBe('3'); + expect(catalogKeyDigestToBucketIdV1(KEY_DIGEST, '256')).toBe('62'); + expect(catalogKeyDigestToBucketIdV1( + KEY_DIGEST, + MAX_AUTHOR_CATALOG_BUCKET_COUNT_V1.toString() as AuthorCatalogScopeV1['bucketCount'], + )).toBe('2253769126038321457'); + expect(catalogKeyToBucketIdV1('1' as KaIdV1, '256')).toBe('62'); + }); + + it('keeps defensive codec ceilings above maximum-width valid scope and row values', () => { + const maximumScope = validatedScope({ + ...VALID_SCOPE, + networkId: 'n'.repeat(128), + contextGraphId: 'c'.repeat(256), + governanceChainId: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + ownershipTransitionDigest: ZERO_DIGEST, + subGraphName: 'é'.repeat(128), + era: '18446744073709551615', + bucketCount: MAX_AUTHOR_CATALOG_BUCKET_COUNT_V1.toString(), + }); + const maximumRow = validatedRow({ + ...VALID_ROW, + kaId: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + assertionCoordinate: 'é'.repeat(128), + assertionVersion: '18446744073709551615', + transfer: { + ...VALID_ROW.transfer, + byteLength: '1073741824', + chunkCount: '4096', + }, + }); + + expect(new TextEncoder().encode(canonicalizeAuthorCatalogScopeV1(maximumScope)).byteLength) + .toBeLessThanOrEqual(MAX_AUTHOR_CATALOG_SCOPE_BYTES_V1); + expect(new TextEncoder().encode(canonicalizeAuthorCatalogRowV1(maximumRow)).byteLength) + .toBeLessThanOrEqual(MAX_AUTHOR_CATALOG_ROW_BYTES_V1); + expect(() => computeAuthorCatalogRowDigestV1(SCOPE_DIGEST, maximumRow)).not.toThrow(); + expect(MAX_AUTHOR_CATALOG_ROW_DIGEST_INPUT_BYTES_V1).toBeGreaterThan( + MAX_AUTHOR_CATALOG_ROW_BYTES_V1, + ); + }); + + it('checks the safely derivable packed high-160 author binding', () => { + const packed = ((BigInt(AUTHOR) << 96n) | 7n).toString() as KaIdV1; + expect(() => assertPackedKaIdAuthorBindingV1(packed, AUTHOR as EvmAddressV1)) + .not.toThrow(); + expect(() => assertPackedKaIdAuthorBindingV1( + packed, + `0x${'55'.repeat(20)}` as EvmAddressV1, + )).toThrow(/catalog-packed-author-mismatch/); + + const boundRow = validatedRow({ ...VALID_ROW, kaId: packed }); + expect(() => assertAuthorCatalogRowScopeBindingV1(boundRow, VALID_SCOPE)).not.toThrow(); + expect(() => assertAuthorCatalogRowScopeBindingV1(VALID_ROW, VALID_SCOPE)).toThrow( + /catalog-packed-author-mismatch/, + ); + }); +}); + +function validatedScope(value: unknown): AuthorCatalogScopeV1 { + assertAuthorCatalogScopeV1(value); + return value; +} + +function validatedRow(value: unknown): AuthorCatalogRowV1 { + assertAuthorCatalogRowV1(value); + return value; +} + +function validatedCoordinate(value: unknown): AssertionCoordinateV1 { + assertAssertionCoordinateV1(value); + return value; +} + +function validatedLane(value: { + contextGraphId: string; + subGraphName: string | null; +}): CatalogLaneV1 { + assertContextGraphIdV1(value.contextGraphId); + if (value.subGraphName !== null) assertSubGraphNameV1(value.subGraphName); + return value as { + contextGraphId: ContextGraphIdV1; + subGraphName: SubGraphNameV1 | null; + }; +} From 75c2b4adced4c0f609161dede2acfba836379b18 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 03:24:10 +0200 Subject: [PATCH 014/292] fix(core): reject zero catalog assertion versions --- packages/core/src/author-catalog-codec.ts | 10 +++++++++- packages/core/test/author-catalog-codec.test.ts | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/author-catalog-codec.ts b/packages/core/src/author-catalog-codec.ts index 68b2f1118f..def95e3682 100644 --- a/packages/core/src/author-catalog-codec.ts +++ b/packages/core/src/author-catalog-codec.ts @@ -406,7 +406,7 @@ export function assertAuthorCatalogRowV1( assertCatalogScalar(() => assertCanonicalKaId(row.kaId, 'kaId')); assertAssertionCoordinateV1(row.assertionCoordinate); - assertCatalogU64(row.assertionVersion, 'assertionVersion'); + assertCatalogPositiveU64(row.assertionVersion, 'assertionVersion'); if (row.projectionId !== KA_TRANSFER_PROJECTION_V1) { fail('catalog-schema', `projectionId must be ${KA_TRANSFER_PROJECTION_V1}`); } @@ -610,6 +610,14 @@ function assertCatalogU64(value: unknown, label: string): bigint { } } +function assertCatalogPositiveU64(value: unknown, label: string): bigint { + const parsed = assertCatalogU64(value, label); + if (parsed < 1n) { + fail('catalog-scalar', `${label} must be a positive DecimalU64V1`); + } + return parsed; +} + function parseCatalogU256(value: unknown, label: string): bigint { try { return parseCanonicalDecimalU256(value, label); diff --git a/packages/core/test/author-catalog-codec.test.ts b/packages/core/test/author-catalog-codec.test.ts index ef20e16f0d..1fe44340f4 100644 --- a/packages/core/test/author-catalog-codec.test.ts +++ b/packages/core/test/author-catalog-codec.test.ts @@ -225,6 +225,12 @@ describe('AuthorCatalogRowV1 and key placement', () => { expect(() => assertAuthorCatalogRowV1({ ...VALID_ROW, assertionVersion: '01' })).toThrow( /catalog-scalar/, ); + expect(() => assertAuthorCatalogRowV1({ ...VALID_ROW, assertionVersion: '0' })).toThrow( + /positive DecimalU64V1/, + ); + expect(() => parseCanonicalAuthorCatalogRowV1( + ROW_CANONICAL.replace('"assertionVersion":"1"', '"assertionVersion":"0"'), + )).toThrow(/positive DecimalU64V1/); expect(() => assertAuthorCatalogRowV1({ ...VALID_ROW, assertionCoordinate: 'bad/name', From 7327cf736fb0ae6f19f0b88702eb1b8ebda16b3b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 03:28:47 +0200 Subject: [PATCH 015/292] feat(core): add RFC-64 catalog bucket and head codecs --- packages/core/src/author-catalog-objects.ts | 710 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../core/test/author-catalog-objects.test.ts | 358 +++++++++ 3 files changed, 1069 insertions(+) create mode 100644 packages/core/src/author-catalog-objects.ts create mode 100644 packages/core/test/author-catalog-objects.test.ts diff --git a/packages/core/src/author-catalog-objects.ts b/packages/core/src/author-catalog-objects.ts new file mode 100644 index 0000000000..1aef3f1e93 --- /dev/null +++ b/packages/core/src/author-catalog-objects.ts @@ -0,0 +1,710 @@ +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + assertAuthorCatalogBucketCountV1, + assertAuthorCatalogRowScopeBindingV1, + assertAuthorCatalogRowV1, + assertAuthorCatalogScopeV1, + assertContextGraphIdV1, + assertNetworkIdV1, + assertSubGraphNameV1, + canonicalizeAuthorCatalogScopeV1, + catalogKeyToBucketIdV1, + compareAuthorCatalogKaIdsV1, + computeAuthorCatalogKeyDigestV1, + computeAuthorCatalogScopeDigestV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type ContextGraphIdV1, + type NetworkIdV1, + type SubGraphNameV1, +} from './author-catalog-codec.js'; +import { + assertSignedControlEnvelope, + assertUnsignedControlEnvelope, + canonicalizeSignedControlEnvelopeBytes, + canonicalizeUnsignedControlEnvelopeBytes, + computeControlObjectDigestHex, + parseCanonicalSignedControlEnvelope, + parseCanonicalUnsignedControlEnvelope, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from './sync-control-object.js'; +import { + assertCanonicalChainId, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalTimestampMs, + parseCanonicalDecimalU64, + type ChainIdV1, + type CountV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type TimestampMsV1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +export const AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1 = 'AuthorCatalogBucketV1' as const; +export const AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1 = 'AuthorCatalogHeadV1' as const; +export const MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 = 1024; +export const MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1 = 1024 * 1024; +export const MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1 = 4 * 1024; +export const MAX_AUTHOR_CATALOG_DIRECTORY_HEIGHT_V1 = 7n; +export const AUTHOR_CATALOG_DIRECTORY_FANOUT_V1 = 256n; +export const ZERO_DIGEST32_V1 = `0x${'00'.repeat(32)}` as Digest32V1; + +const UTF8 = new TextEncoder(); + +/** Exact five-key immutable non-empty bucket payload. */ +export interface AuthorCatalogBucketV1 { + readonly catalogScopeDigest: Digest32V1; + readonly era: DecimalU64V1; + readonly bucketCount: CountV1; + readonly bucketId: DecimalU64V1; + readonly rows: readonly AuthorCatalogRowV1[]; +} + +/** Exact sixteen-key constant-size author-catalog head payload. */ +export interface AuthorCatalogHeadV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly governanceChainId: ChainIdV1 | null; + readonly governanceContractAddress: EvmAddressV1 | null; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogIssuerDelegationDigest: Digest32V1; + readonly era: DecimalU64V1; + readonly version: DecimalU64V1; + readonly previousHeadDigest: Digest32V1 | null; + readonly bucketCount: CountV1; + readonly totalRows: CountV1; + readonly directoryHeight: DecimalU64V1; + readonly directoryRootDigest: Digest32V1; + readonly issuedAt: TimestampMsV1; +} + +export type UnsignedAuthorCatalogBucketEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1; + readonly payload: AuthorCatalogBucketV1; +}; +export type SignedAuthorCatalogBucketEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1; + readonly payload: AuthorCatalogBucketV1; +}; +export type UnsignedAuthorCatalogHeadEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1; + readonly payload: AuthorCatalogHeadV1; +}; +export type SignedAuthorCatalogHeadEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1; + readonly payload: AuthorCatalogHeadV1; +}; + +export type AuthorCatalogObjectCodecErrorCode = + | 'catalog-object-schema' + | 'catalog-object-scalar' + | 'catalog-object-type' + | 'catalog-object-array' + | 'catalog-object-row-order' + | 'catalog-object-duplicate' + | 'catalog-object-bucket-mapping' + | 'catalog-object-scope-mismatch' + | 'catalog-object-directory-height' + | 'catalog-object-payload-too-large'; + +export class AuthorCatalogObjectCodecError extends Error { + constructor( + readonly code: AuthorCatalogObjectCodecErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'AuthorCatalogObjectCodecError'; + } +} + +/** Validate one complete non-empty bucket, including its canonical 1 MiB cap. */ +export function assertAuthorCatalogBucketV1( + bucket: unknown, +): asserts bucket is AuthorCatalogBucketV1 { + assertAuthorCatalogBucketStructureV1(bucket); + canonicalizeBucketAfterStructure(bucket); +} + +/** + * Bind a structurally valid bucket to the exact scope carried by its enclosing + * head. This is candidate staging only; it deliberately performs no RDF/seal work. + */ +export function assertAuthorCatalogBucketScopeBindingV1( + bucket: AuthorCatalogBucketV1, + scope: AuthorCatalogScopeV1, +): void { + assertAuthorCatalogBucketV1(bucket); + assertAuthorCatalogScopeV1(scope); + const expectedScopeDigest = computeAuthorCatalogScopeDigestV1(scope); + if (bucket.catalogScopeDigest !== expectedScopeDigest) { + fail( + 'catalog-object-scope-mismatch', + 'bucket catalogScopeDigest does not match the contextual author catalog scope', + ); + } + if (bucket.era !== scope.era || bucket.bucketCount !== scope.bucketCount) { + fail( + 'catalog-object-scope-mismatch', + 'bucket era and bucketCount must equal the contextual scope', + ); + } + for (let index = 0; index < bucket.rows.length; index += 1) { + assertAuthorCatalogRowScopeBindingV1(bucket.rows[index], scope); + } +} + +/** Return exact RFC 8785 payload bytes and enforce the bucket's 1 MiB cap. */ +export function canonicalizeAuthorCatalogBucketPayloadBytesV1( + bucket: AuthorCatalogBucketV1, +): Uint8Array { + assertAuthorCatalogBucketStructureV1(bucket); + return UTF8.encode(canonicalizeBucketAfterStructure(bucket)); +} + +export function canonicalizeAuthorCatalogBucketPayloadV1( + bucket: AuthorCatalogBucketV1, +): string { + assertAuthorCatalogBucketStructureV1(bucket); + return canonicalizeBucketAfterStructure(bucket); +} + +/** Strict direct-payload decoder; envelope decoders intentionally use the generic cap first. */ +export function parseCanonicalAuthorCatalogBucketPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): AuthorCatalogBucketV1 { + rejectOversizedWireInput( + input, + MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + 'author catalog bucket payload', + ); + const parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 4, 4), + }); + assertAuthorCatalogBucketV1(parsed); + return parsed; +} + +/** Validate one head, its self-derived scope, height formula, and 4 KiB cap. */ +export function assertAuthorCatalogHeadV1( + head: unknown, +): asserts head is AuthorCatalogHeadV1 { + assertAuthorCatalogHeadStructureV1(head); + canonicalizeHeadAfterStructure(head); +} + +/** Derive the exact nine-key catalog scope committed by this head. */ +export function deriveAuthorCatalogScopeFromHeadV1( + head: AuthorCatalogHeadV1, +): AuthorCatalogScopeV1 { + assertAuthorCatalogHeadV1(head); + return deriveScopeAfterHeadStructure(head); +} + +/** Require a head to be in exactly the externally pinned catalog scope. */ +export function assertAuthorCatalogHeadScopeBindingV1( + head: AuthorCatalogHeadV1, + expectedScope: AuthorCatalogScopeV1, +): void { + assertAuthorCatalogHeadV1(head); + assertAuthorCatalogScopeV1(expectedScope); + const derived = deriveScopeAfterHeadStructure(head); + if ( + canonicalizeAuthorCatalogScopeV1(derived) + !== canonicalizeAuthorCatalogScopeV1(expectedScope) + ) { + fail( + 'catalog-object-scope-mismatch', + 'head fields do not equal the externally pinned author catalog scope', + ); + } +} + +/** Return the canonical zero-based directory height for a valid bucket count. */ +export function computeAuthorCatalogDirectoryHeightV1( + bucketCount: CountV1, +): DecimalU64V1 { + assertAuthorCatalogBucketCountV1(bucketCount); + const count = BigInt(bucketCount); + let height = 0n; + let coveredBuckets = AUTHOR_CATALOG_DIRECTORY_FANOUT_V1; + while (count > coveredBuckets) { + coveredBuckets *= AUTHOR_CATALOG_DIRECTORY_FANOUT_V1; + height += 1n; + } + if (height > MAX_AUTHOR_CATALOG_DIRECTORY_HEIGHT_V1) { + fail('catalog-object-directory-height', 'derived directory height exceeds v1'); + } + return height.toString() as DecimalU64V1; +} + +export function canonicalizeAuthorCatalogHeadPayloadBytesV1( + head: AuthorCatalogHeadV1, +): Uint8Array { + assertAuthorCatalogHeadStructureV1(head); + return UTF8.encode(canonicalizeHeadAfterStructure(head)); +} + +export function canonicalizeAuthorCatalogHeadPayloadV1( + head: AuthorCatalogHeadV1, +): string { + assertAuthorCatalogHeadStructureV1(head); + return canonicalizeHeadAfterStructure(head); +} + +export function parseCanonicalAuthorCatalogHeadPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): AuthorCatalogHeadV1 { + rejectOversizedWireInput( + input, + MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1, + 'author catalog head payload', + ); + const parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 1, 1), + }); + assertAuthorCatalogHeadV1(parsed); + return parsed; +} + +export function assertUnsignedAuthorCatalogBucketEnvelopeV1( + envelope: UnsignedControlEnvelopeV1, +): asserts envelope is UnsignedAuthorCatalogBucketEnvelopeV1 { + assertUnsignedControlEnvelope(envelope); + assertEnvelopeObjectType(envelope.objectType, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1); + assertAuthorCatalogBucketV1(envelope.payload); +} + +export function assertSignedAuthorCatalogBucketEnvelopeV1( + envelope: SignedControlEnvelopeV1, +): asserts envelope is SignedAuthorCatalogBucketEnvelopeV1 { + assertSignedControlEnvelope(envelope); + assertEnvelopeObjectType(envelope.objectType, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1); + assertAuthorCatalogBucketV1(envelope.payload); +} + +export function assertUnsignedAuthorCatalogHeadEnvelopeV1( + envelope: UnsignedControlEnvelopeV1, +): asserts envelope is UnsignedAuthorCatalogHeadEnvelopeV1 { + assertUnsignedControlEnvelope(envelope); + assertEnvelopeObjectType(envelope.objectType, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1); + assertAuthorCatalogHeadV1(envelope.payload); +} + +export function assertSignedAuthorCatalogHeadEnvelopeV1( + envelope: SignedControlEnvelopeV1, +): asserts envelope is SignedAuthorCatalogHeadEnvelopeV1 { + assertSignedControlEnvelope(envelope); + assertEnvelopeObjectType(envelope.objectType, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1); + assertAuthorCatalogHeadV1(envelope.payload); +} + +export function canonicalizeUnsignedAuthorCatalogBucketEnvelopeBytesV1( + envelope: UnsignedControlEnvelopeV1, +): Uint8Array { + assertUnsignedAuthorCatalogBucketEnvelopeV1(envelope); + return canonicalizeUnsignedControlEnvelopeBytes(envelope); +} + +export function canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1( + envelope: SignedControlEnvelopeV1, +): Uint8Array { + assertSignedAuthorCatalogBucketEnvelopeV1(envelope); + return canonicalizeSignedControlEnvelopeBytes(envelope); +} + +export function canonicalizeUnsignedAuthorCatalogHeadEnvelopeBytesV1( + envelope: UnsignedControlEnvelopeV1, +): Uint8Array { + assertUnsignedAuthorCatalogHeadEnvelopeV1(envelope); + return canonicalizeUnsignedControlEnvelopeBytes(envelope); +} + +export function canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1( + envelope: SignedControlEnvelopeV1, +): Uint8Array { + assertSignedAuthorCatalogHeadEnvelopeV1(envelope); + return canonicalizeSignedControlEnvelopeBytes(envelope); +} + +export function computeAuthorCatalogBucketObjectDigestV1( + envelope: UnsignedControlEnvelopeV1, +): Digest32V1 { + assertUnsignedAuthorCatalogBucketEnvelopeV1(envelope); + const digest = computeControlObjectDigestHex(envelope); + assertCanonicalDigest(digest, 'bucket objectDigest'); + return digest; +} + +export function computeAuthorCatalogHeadObjectDigestV1( + envelope: UnsignedControlEnvelopeV1, +): Digest32V1 { + assertUnsignedAuthorCatalogHeadEnvelopeV1(envelope); + const digest = computeControlObjectDigestHex(envelope); + assertCanonicalDigest(digest, 'head objectDigest'); + return digest; +} + +/** Parse the generic envelope first, then enforce the lower payload cap and schema. */ +export function parseCanonicalUnsignedAuthorCatalogBucketEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedAuthorCatalogBucketEnvelopeV1 { + const envelope = parseCanonicalUnsignedControlEnvelope(input, options); + assertUnsignedAuthorCatalogBucketEnvelopeV1(envelope); + return envelope; +} + +export function parseCanonicalSignedAuthorCatalogBucketEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedAuthorCatalogBucketEnvelopeV1 { + const envelope = parseCanonicalSignedControlEnvelope(input, options); + assertSignedAuthorCatalogBucketEnvelopeV1(envelope); + return envelope; +} + +export function parseCanonicalUnsignedAuthorCatalogHeadEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedAuthorCatalogHeadEnvelopeV1 { + const envelope = parseCanonicalUnsignedControlEnvelope(input, options); + assertUnsignedAuthorCatalogHeadEnvelopeV1(envelope); + return envelope; +} + +export function parseCanonicalSignedAuthorCatalogHeadEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedAuthorCatalogHeadEnvelopeV1 { + const envelope = parseCanonicalSignedControlEnvelope(input, options); + assertSignedAuthorCatalogHeadEnvelopeV1(envelope); + return envelope; +} + +function assertAuthorCatalogBucketStructureV1( + bucket: unknown, +): asserts bucket is AuthorCatalogBucketV1 { + if (!isPlainRecord(bucket)) { + fail('catalog-object-schema', 'author catalog bucket must be a plain JSON object'); + } + assertClosedKeys(bucket, [ + 'bucketCount', + 'bucketId', + 'catalogScopeDigest', + 'era', + 'rows', + ], 'author catalog bucket'); + assertObjectScalar(() => assertCanonicalDigest( + bucket.catalogScopeDigest, + 'catalogScopeDigest', + )); + assertObjectU64(bucket.era, 'era'); + assertAuthorCatalogBucketCountV1(bucket.bucketCount); + const bucketId = assertObjectU64(bucket.bucketId, 'bucketId'); + if (bucketId >= BigInt(bucket.bucketCount)) { + fail('catalog-object-bucket-mapping', 'bucketId must be less than bucketCount'); + } + + assertDenseOrdinaryRowsArray(bucket.rows); + const seenKaIds = new Set(); + const seenKeyDigests = new Set(); + const seenCoordinates = new Set(); + let previousRow: AuthorCatalogRowV1 | undefined; + for (let index = 0; index < bucket.rows.length; index += 1) { + const row = bucket.rows[index]; + assertAuthorCatalogRowV1(row); + if (seenKaIds.has(row.kaId)) { + fail('catalog-object-duplicate', `duplicate kaId ${row.kaId}`); + } + seenKaIds.add(row.kaId); + + const keyDigest = computeAuthorCatalogKeyDigestV1(row.kaId); + if (seenKeyDigests.has(keyDigest)) { + fail('catalog-object-duplicate', `duplicate catalogKeyDigest ${keyDigest}`); + } + seenKeyDigests.add(keyDigest); + if (seenCoordinates.has(row.assertionCoordinate)) { + fail( + 'catalog-object-duplicate', + `duplicate assertionCoordinate ${row.assertionCoordinate}`, + ); + } + seenCoordinates.add(row.assertionCoordinate); + + if (previousRow !== undefined && compareAuthorCatalogKaIdsV1(previousRow.kaId, row.kaId) >= 0) { + fail('catalog-object-row-order', 'bucket rows must be strictly increasing by numeric kaId'); + } + previousRow = row; + + if (catalogKeyToBucketIdV1(row.kaId, bucket.bucketCount) !== bucket.bucketId) { + fail( + 'catalog-object-bucket-mapping', + `row kaId ${row.kaId} does not map to bucketId ${bucket.bucketId}`, + ); + } + } +} + +function assertAuthorCatalogHeadStructureV1( + head: unknown, +): asserts head is AuthorCatalogHeadV1 { + if (!isPlainRecord(head)) { + fail('catalog-object-schema', 'author catalog head must be a plain JSON object'); + } + assertClosedKeys(head, [ + 'authorAddress', + 'bucketCount', + 'catalogIssuerDelegationDigest', + 'contextGraphId', + 'directoryHeight', + 'directoryRootDigest', + 'era', + 'governanceChainId', + 'governanceContractAddress', + 'issuedAt', + 'networkId', + 'ownershipTransitionDigest', + 'previousHeadDigest', + 'subGraphName', + 'totalRows', + 'version', + ], 'author catalog head'); + + // Scope derivation performs the identifier, governance-pair, author, era, and + // bucket-count checks once over the exact same values committed by the head. + const derivedScope = deriveScopeAfterHeadKeys(head); + assertObjectScalar(() => assertCanonicalDigest( + head.catalogIssuerDelegationDigest, + 'catalogIssuerDelegationDigest', + )); + assertObjectU64(head.version, 'version'); + if (head.previousHeadDigest !== null) { + assertObjectScalar(() => assertCanonicalDigest(head.previousHeadDigest, 'previousHeadDigest')); + } + assertObjectU64(head.totalRows, 'totalRows'); + const height = assertObjectU64(head.directoryHeight, 'directoryHeight'); + if (height > MAX_AUTHOR_CATALOG_DIRECTORY_HEIGHT_V1) { + fail('catalog-object-directory-height', 'directoryHeight must be in 0..7'); + } + const expectedHeight = computeAuthorCatalogDirectoryHeightV1(derivedScope.bucketCount); + if (head.directoryHeight !== expectedHeight) { + fail( + 'catalog-object-directory-height', + `directoryHeight must be ${expectedHeight} for bucketCount ${head.bucketCount}`, + ); + } + assertObjectScalar(() => assertCanonicalDigest(head.directoryRootDigest, 'directoryRootDigest')); + if (head.directoryRootDigest === ZERO_DIGEST32_V1) { + fail('catalog-object-schema', 'directoryRootDigest must name a nonzero directory object'); + } + assertObjectScalar(() => assertCanonicalTimestampMs(head.issuedAt, 'issuedAt')); +} + +function deriveScopeAfterHeadKeys( + head: Record, +): AuthorCatalogScopeV1 { + assertNetworkIdV1(head.networkId); + assertContextGraphIdV1(head.contextGraphId); + if (head.subGraphName !== null) assertSubGraphNameV1(head.subGraphName); + assertObjectScalar(() => assertCanonicalEvmAddress(head.authorAddress, 'authorAddress')); + assertObjectU64(head.era, 'era'); + assertAuthorCatalogBucketCountV1(head.bucketCount); + + const chainIsNull = head.governanceChainId === null; + const contractIsNull = head.governanceContractAddress === null; + if (chainIsNull !== contractIsNull) { + fail( + 'catalog-object-schema', + 'governanceChainId and governanceContractAddress must both be null or both non-null', + ); + } + if (!chainIsNull) { + assertObjectScalar(() => assertCanonicalChainId(head.governanceChainId, 'governanceChainId')); + assertObjectScalar(() => assertCanonicalEvmAddress( + head.governanceContractAddress, + 'governanceContractAddress', + )); + } + if (head.ownershipTransitionDigest !== null) { + assertObjectScalar(() => assertCanonicalDigest( + head.ownershipTransitionDigest, + 'ownershipTransitionDigest', + )); + } + + const scope = { + networkId: head.networkId, + contextGraphId: head.contextGraphId, + governanceChainId: head.governanceChainId, + governanceContractAddress: head.governanceContractAddress, + ownershipTransitionDigest: head.ownershipTransitionDigest, + subGraphName: head.subGraphName, + authorAddress: head.authorAddress, + era: head.era, + bucketCount: head.bucketCount, + }; + assertAuthorCatalogScopeV1(scope); + return scope; +} + +function deriveScopeAfterHeadStructure(head: AuthorCatalogHeadV1): AuthorCatalogScopeV1 { + return { + networkId: head.networkId, + contextGraphId: head.contextGraphId, + governanceChainId: head.governanceChainId, + governanceContractAddress: head.governanceContractAddress, + ownershipTransitionDigest: head.ownershipTransitionDigest, + subGraphName: head.subGraphName, + authorAddress: head.authorAddress, + era: head.era, + bucketCount: head.bucketCount, + }; +} + +function assertDenseOrdinaryRowsArray( + value: unknown, +): asserts value is AuthorCatalogRowV1[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + fail('catalog-object-array', 'rows must be an ordinary Array'); + } + if (value.length < 1 || value.length > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) { + fail( + 'catalog-object-array', + `rows must contain 1..${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1} entries`, + ); + } + + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== 'string')) { + fail('catalog-object-array', 'rows must not contain symbol properties'); + } + if (ownKeys.length !== value.length + 1 || !ownKeys.includes('length')) { + fail('catalog-object-array', 'rows must be dense and contain no custom properties'); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (!lengthDescriptor || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value')) { + fail('catalog-object-array', 'rows length must be an ordinary data property'); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('catalog-object-array', 'rows must be a dense array of enumerable data properties'); + } + } +} + +function canonicalizeBucketAfterStructure(bucket: AuthorCatalogBucketV1): string { + try { + return canonicalizeJson(bucket as unknown as CanonicalJsonValue, { + maxBytes: MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + maxDepth: 4, + }); + } catch (cause) { + fail( + 'catalog-object-payload-too-large', + `author catalog bucket payload exceeds ${MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1} bytes or depth`, + cause, + ); + } +} + +function canonicalizeHeadAfterStructure(head: AuthorCatalogHeadV1): string { + try { + return canonicalizeJson(head as unknown as CanonicalJsonValue, { + maxBytes: MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1, + maxDepth: 1, + }); + } catch (cause) { + fail( + 'catalog-object-payload-too-large', + `author catalog head payload exceeds ${MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1} bytes or depth`, + cause, + ); + } +} + +function assertEnvelopeObjectType(actual: string, expected: string): void { + if (actual !== expected) { + fail('catalog-object-type', `objectType must be exactly ${expected}`); + } +} + +function assertClosedKeys( + record: Record, + keys: readonly string[], + label: string, +): void { + try { + assertExactKeys(record, keys, label); + } catch (cause) { + fail('catalog-object-schema', `${label} has an invalid field set`, cause); + } +} + +function assertObjectScalar(operation: () => void): void { + try { + operation(); + } catch (cause) { + fail('catalog-object-scalar', 'catalog object scalar is not canonical', cause); + } +} + +function assertObjectU64(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU64(value, label); + } catch (cause) { + fail('catalog-object-scalar', `${label} is not a canonical DecimalU64V1`, cause); + } +} + +function rejectOversizedWireInput( + input: string | Uint8Array, + maxBytes: number, + label: string, +): void { + if (typeof input !== 'string') { + if (input.byteLength > maxBytes) { + fail('catalog-object-payload-too-large', `${label} exceeds ${maxBytes} bytes`); + } + return; + } + if (input.length > maxBytes || UTF8.encode(input).byteLength > maxBytes) { + fail('catalog-object-payload-too-large', `${label} exceeds ${maxBytes} bytes`); + } +} + +function fail( + code: AuthorCatalogObjectCodecErrorCode, + message: string, + cause?: unknown, +): never { + throw new AuthorCatalogObjectCodecError( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index edc71da1aa..b48f8eac2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,6 +49,7 @@ export * from './ka-bundle-v1.js'; export * from './ka-chunk-tree.js'; export * from './ka-chunk-proof.js'; export * from './author-catalog-codec.js'; +export * from './author-catalog-objects.js'; export * from './event-bus.js'; export { Logger, diff --git a/packages/core/test/author-catalog-objects.test.ts b/packages/core/test/author-catalog-objects.test.ts new file mode 100644 index 0000000000..9e3abb8f76 --- /dev/null +++ b/packages/core/test/author-catalog-objects.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from 'vitest'; + +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + assertAuthorCatalogBucketScopeBindingV1, + assertAuthorCatalogBucketV1, + assertAuthorCatalogHeadScopeBindingV1, + assertAuthorCatalogHeadV1, + assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + assertUnsignedAuthorCatalogBucketEnvelopeV1, + assertUnsignedAuthorCatalogHeadEnvelopeV1, + canonicalizeAuthorCatalogBucketPayloadV1, + canonicalizeAuthorCatalogHeadPayloadV1, + canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1, + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, + canonicalizeUnsignedAuthorCatalogBucketEnvelopeBytesV1, + canonicalizeUnsignedAuthorCatalogHeadEnvelopeBytesV1, + computeAuthorCatalogBucketObjectDigestV1, + computeAuthorCatalogDirectoryHeightV1, + computeAuthorCatalogHeadObjectDigestV1, + deriveAuthorCatalogScopeFromHeadV1, + parseCanonicalAuthorCatalogBucketPayloadV1, + parseCanonicalAuthorCatalogHeadPayloadV1, + parseCanonicalSignedAuthorCatalogBucketEnvelopeV1, + parseCanonicalSignedAuthorCatalogHeadEnvelopeV1, + parseCanonicalUnsignedAuthorCatalogBucketEnvelopeV1, + parseCanonicalUnsignedAuthorCatalogHeadEnvelopeV1, + type AuthorCatalogBucketV1, + type AuthorCatalogHeadV1, +} from '../src/author-catalog-objects.js'; +import { + assertAuthorCatalogRowV1, + assertAuthorCatalogScopeV1, + computeAuthorCatalogKeyDigestV1, + computeAuthorCatalogRowDigestV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, +} from '../src/author-catalog-codec.js'; +import { + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '../src/sync-control-object.js'; + +const SCOPE_DIGEST = + '0x7b18d141cbb6af4e7fdabe2e4d7d0b9512b042eb079b89a4797d3a0a7f1d4537'; +const DIRECTORY_ROOT_DIGEST = + '0x0163c048997ddaeb984d10a06f98064739a95546cf71d142c0d0a3de19f65f52'; +const HEAD_DIGEST = + '0x1c5e2fffa5c62a3d4c00e879c31e9b36e9d4c419b41ad124e30a23f082635af6'; +const BUCKET_DIGEST = + '0xddedcd25a1fd2afb797f146b04fec735fd3b341d2f10293a1db9fd915e701866'; +const AUTHOR_BOUND_KA_ID = + '23158417847463239084714197001737581570653996933112267175388663934063917137921'; +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const ISSUER = '0x5555555555555555555555555555555555555555'; +const EIP191_SIGNATURE = `0x${'77'.repeat(65)}`; + +const SCOPE_CANONICAL = + '{"authorAddress":"0x3333333333333333333333333333333333333333","bucketCount":"1","contextGraphId":"0x1111111111111111111111111111111111111111/catalog-fixture","era":"0","governanceChainId":"20430","governanceContractAddress":"0x2222222222222222222222222222222222222222","networkId":"otp:20430","ownershipTransitionDigest":null,"subGraphName":null}'; +const ROW_CANONICAL = + '{"assertionCoordinate":"fixture","assertionVersion":"1","kaId":"23158417847463239084714197001737581570653996933112267175388663934063917137921","projectionDigest":"0x0000000000000000000000000000000000000000000000000000000000000000","projectionId":"cg-shared-v1","sealDigest":"0x4444444444444444444444444444444444444444444444444444444444444444","transfer":{"blobDigest":"0x1111111111111111111111111111111111111111111111111111111111111111","byteLength":"16","chunkCount":"1","chunkSize":"262144","chunkTreeRoot":"0x2222222222222222222222222222222222222222222222222222222222222222","codec":"dkg-ka-bundle-v1","projectionDigest":"0x0000000000000000000000000000000000000000000000000000000000000000","projectionId":"cg-shared-v1"}}'; +const BUCKET_CANONICAL = + `{"bucketCount":"1","bucketId":"0","catalogScopeDigest":"${SCOPE_DIGEST}","era":"0","rows":[${ROW_CANONICAL}]}`; +const BUCKET_UNSIGNED_CANONICAL = + `{"issuer":"${ISSUER}","objectType":"AuthorCatalogBucketV1","payload":${BUCKET_CANONICAL},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}`; + +const HEAD_CANONICAL = + '{"authorAddress":"0x3333333333333333333333333333333333333333","bucketCount":"1","catalogIssuerDelegationDigest":"0x6666666666666666666666666666666666666666666666666666666666666666","contextGraphId":"0x1111111111111111111111111111111111111111/catalog-fixture","directoryHeight":"0","directoryRootDigest":"0x0163c048997ddaeb984d10a06f98064739a95546cf71d142c0d0a3de19f65f52","era":"0","governanceChainId":"20430","governanceContractAddress":"0x2222222222222222222222222222222222222222","issuedAt":"1700000000123","networkId":"otp:20430","ownershipTransitionDigest":null,"previousHeadDigest":null,"subGraphName":null,"totalRows":"0","version":"0"}'; +const HEAD_UNSIGNED_CANONICAL = + `{"issuer":"${ISSUER}","objectType":"AuthorCatalogHeadV1","payload":${HEAD_CANONICAL},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}`; + +const VALID_SCOPE = validatedScope(JSON.parse(SCOPE_CANONICAL)); +const VALID_ROW = validatedRow(JSON.parse(ROW_CANONICAL)); +const VALID_BUCKET = validatedBucket(JSON.parse(BUCKET_CANONICAL)); +const VALID_HEAD = validatedHead(JSON.parse(HEAD_CANONICAL)); +const BUCKET_UNSIGNED = JSON.parse(BUCKET_UNSIGNED_CANONICAL) as UnsignedControlEnvelopeV1; +const HEAD_UNSIGNED = JSON.parse(HEAD_UNSIGNED_CANONICAL) as UnsignedControlEnvelopeV1; +const BUCKET_SIGNED = { + ...BUCKET_UNSIGNED, + objectDigest: BUCKET_DIGEST, + signature: EIP191_SIGNATURE, +} as SignedControlEnvelopeV1; +const HEAD_SIGNED = { + ...HEAD_UNSIGNED, + objectDigest: HEAD_DIGEST, + signature: EIP191_SIGNATURE, +} as SignedControlEnvelopeV1; + +describe('AuthorCatalogBucketV1 structural codec', () => { + it('pins the exact author-bound SQL-1 payload, envelope, key, row, and object vectors', () => { + expect(canonicalizeAuthorCatalogBucketPayloadV1(VALID_BUCKET)).toBe(BUCKET_CANONICAL); + expect(new TextEncoder().encode(BUCKET_CANONICAL).byteLength).toBe(868); + expect(computeAuthorCatalogKeyDigestV1(VALID_ROW.kaId)).toBe( + '0x5f49f03c5a2480a80ee4b7dadff8b7c8e18a69358bfdf64a1420dddf513de2e5', + ); + expect(computeAuthorCatalogRowDigestV1(SCOPE_DIGEST, VALID_ROW)).toBe( + '0x893392ecdfbf47fac3eb3290bc6f69b9472952439b9233555c523ed8e28f3179', + ); + expect(new TextDecoder().decode( + canonicalizeUnsignedAuthorCatalogBucketEnvelopeBytesV1(BUCKET_UNSIGNED), + )).toBe(BUCKET_UNSIGNED_CANONICAL); + expect(new TextEncoder().encode(BUCKET_UNSIGNED_CANONICAL).byteLength).toBe(1057); + expect(computeAuthorCatalogBucketObjectDigestV1(BUCKET_UNSIGNED)).toBe(BUCKET_DIGEST); + expect(parseCanonicalAuthorCatalogBucketPayloadV1(BUCKET_CANONICAL)).toEqual( + VALID_BUCKET, + ); + expect(parseCanonicalUnsignedAuthorCatalogBucketEnvelopeV1(BUCKET_UNSIGNED_CANONICAL)) + .toEqual(BUCKET_UNSIGNED); + }); + + it('validates the exact contextual scope and high-160 author binding', () => { + expect(() => assertAuthorCatalogBucketScopeBindingV1(VALID_BUCKET, VALID_SCOPE)) + .not.toThrow(); + const unboundRow = validatedRow({ ...VALID_ROW, kaId: '1' }); + const unboundBucket = validatedBucket({ ...VALID_BUCKET, rows: [unboundRow] }); + expect(() => assertAuthorCatalogBucketScopeBindingV1(unboundBucket, VALID_SCOPE)) + .toThrow(/catalog-packed-author-mismatch/); + expect(() => assertAuthorCatalogBucketScopeBindingV1( + { ...VALID_BUCKET, catalogScopeDigest: `0x${'99'.repeat(32)}` }, + VALID_SCOPE, + )).toThrow(/catalog-object-scope-mismatch/); + expect(() => assertAuthorCatalogBucketScopeBindingV1( + { ...VALID_BUCKET, era: '1' }, + VALID_SCOPE, + )).toThrow(/catalog-object-scope-mismatch/); + }); + + it('requires strictly numeric row order and rejects duplicate KA/key/coordinate', () => { + const second = rowForNumber(2n, 'fixture-2'); + const ordered = { ...VALID_BUCKET, rows: [VALID_ROW, second] }; + expect(() => assertAuthorCatalogBucketV1(ordered)).not.toThrow(); + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + rows: [second, VALID_ROW], + })).toThrow(/catalog-object-row-order/); + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + rows: [VALID_ROW, VALID_ROW], + })).toThrow(/catalog-object-duplicate/); + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + rows: [VALID_ROW, { ...second, assertionCoordinate: VALID_ROW.assertionCoordinate }], + })).toThrow(/catalog-object-duplicate/); + }); + + it('requires every row to map to the signed bucket and bucketId to be in range', () => { + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + bucketCount: '2', + bucketId: '1', + })).toThrow(/catalog-object-bucket-mapping/); + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + bucketCount: '1', + bucketId: '1', + })).toThrow(/catalog-object-bucket-mapping/); + }); + + it('rejects empty, sparse, subclassed, accessor, symbol, and custom-property arrays', () => { + expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, rows: [] })).toThrow( + /catalog-object-array/, + ); + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + rows: new Array(1), + })).toThrow(/catalog-object-array/); + + class Rows extends Array {} + const subclassed = new Rows(VALID_ROW); + expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, rows: subclassed })) + .toThrow(/catalog-object-array/); + + const accessor = [VALID_ROW]; + Object.defineProperty(accessor, '0', { enumerable: true, get: () => VALID_ROW }); + expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, rows: accessor })) + .toThrow(/catalog-object-array/); + + const withSymbol = [VALID_ROW] as Array & Record; + withSymbol[Symbol('hidden')] = true; + expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, rows: withSymbol })) + .toThrow(/catalog-object-array/); + + const withCustom = [VALID_ROW] as Array & { extra?: boolean }; + withCustom.extra = true; + expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, rows: withCustom })) + .toThrow(/catalog-object-array/); + }); + + it('never consumes a poisoned inherited rows iterator', () => { + const rows = [VALID_ROW]; + const originalIterator = Array.prototype[Symbol.iterator]; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value(this: unknown[]) { + if (this === rows) throw new Error('poisoned rows iterator was consumed'); + return originalIterator.call(this); + }, + }); + try { + const bucket = { ...VALID_BUCKET, rows }; + expect(() => assertAuthorCatalogBucketV1(bucket)).not.toThrow(); + expect(() => assertAuthorCatalogBucketScopeBindingV1(bucket, VALID_SCOPE)).not.toThrow(); + } finally { + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: originalIterator, + }); + } + }); + + it('rejects hostile payload fields, row count, wire cap, and wrong object type', () => { + expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, headDigest: SCOPE_DIGEST })) + .toThrow(/catalog-object-schema/); + const accessor = { ...VALID_BUCKET }; + Object.defineProperty(accessor, 'era', { enumerable: true, get: () => '0' }); + expect(() => assertAuthorCatalogBucketV1(accessor)).toThrow(/catalog-object-schema/); + const symbol = { ...VALID_BUCKET } as Record; + symbol[Symbol('hidden')] = true; + expect(() => assertAuthorCatalogBucketV1(symbol)).toThrow(/catalog-object-schema/); + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + rows: Array.from({ length: 1025 }, () => VALID_ROW), + })).toThrow(/catalog-object-array/); + expect(() => parseCanonicalAuthorCatalogBucketPayloadV1( + '{'.padEnd(MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1 + 1, 'x'), + )).toThrow(/catalog-object-payload-too-large/); + expect(() => assertUnsignedAuthorCatalogBucketEnvelopeV1({ + ...BUCKET_UNSIGNED, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + })).toThrow(/catalog-object-type/); + }); + + it('wraps and strictly parses the generic signed envelope without authority checks', () => { + expect(() => assertSignedAuthorCatalogBucketEnvelopeV1(BUCKET_SIGNED)).not.toThrow(); + const bytes = canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1(BUCKET_SIGNED); + expect(parseCanonicalSignedAuthorCatalogBucketEnvelopeV1(bytes)).toEqual(BUCKET_SIGNED); + expect(() => parseCanonicalUnsignedAuthorCatalogBucketEnvelopeV1( + ` ${BUCKET_UNSIGNED_CANONICAL}`, + )).toThrow(/not RFC 8785 canonical/); + }); +}); + +describe('AuthorCatalogHeadV1 structural codec', () => { + it('pins the exact empty-genesis payload, unsigned envelope, and object digest', () => { + expect(canonicalizeAuthorCatalogHeadPayloadV1(VALID_HEAD)).toBe(HEAD_CANONICAL); + expect(new TextEncoder().encode(HEAD_CANONICAL).byteLength).toBe(643); + expect(new TextDecoder().decode( + canonicalizeUnsignedAuthorCatalogHeadEnvelopeBytesV1(HEAD_UNSIGNED), + )).toBe(HEAD_UNSIGNED_CANONICAL); + expect(new TextEncoder().encode(HEAD_UNSIGNED_CANONICAL).byteLength).toBe(830); + expect(computeAuthorCatalogHeadObjectDigestV1(HEAD_UNSIGNED)).toBe(HEAD_DIGEST); + expect(parseCanonicalAuthorCatalogHeadPayloadV1(HEAD_CANONICAL)).toEqual(VALID_HEAD); + expect(parseCanonicalUnsignedAuthorCatalogHeadEnvelopeV1(HEAD_UNSIGNED_CANONICAL)) + .toEqual(HEAD_UNSIGNED); + }); + + it('derives and binds the exact nine-key scope', () => { + expect(deriveAuthorCatalogScopeFromHeadV1(VALID_HEAD)).toEqual(VALID_SCOPE); + expect(() => assertAuthorCatalogHeadScopeBindingV1(VALID_HEAD, VALID_SCOPE)).not.toThrow(); + const otherScope = validatedScope({ ...VALID_SCOPE, era: '1' }); + expect(() => assertAuthorCatalogHeadScopeBindingV1(VALID_HEAD, otherScope)).toThrow( + /catalog-object-scope-mismatch/, + ); + }); + + it.each([ + ['1', '0'], + ['256', '0'], + ['512', '1'], + ['65536', '1'], + ['131072', '2'], + ['9223372036854775808', '7'], + ])('derives directory height %s -> %s', (bucketCount, expectedHeight) => { + expect(computeAuthorCatalogDirectoryHeightV1(bucketCount)).toBe(expectedHeight); + expect(() => assertAuthorCatalogHeadV1({ + ...VALID_HEAD, + bucketCount, + directoryHeight: expectedHeight, + })).not.toThrow(); + }); + + it('rejects a mismatched height, zero root, malformed scalar, and half-null governance tuple', () => { + expect(() => assertAuthorCatalogHeadV1({ + ...VALID_HEAD, + bucketCount: '512', + directoryHeight: '0', + })).toThrow(/catalog-object-directory-height/); + expect(() => assertAuthorCatalogHeadV1({ + ...VALID_HEAD, + directoryRootDigest: `0x${'00'.repeat(32)}`, + })).toThrow(/nonzero directory object/); + expect(() => assertAuthorCatalogHeadV1({ ...VALID_HEAD, totalRows: 0 })).toThrow( + /catalog-object-scalar/, + ); + expect(() => assertAuthorCatalogHeadV1({ + ...VALID_HEAD, + governanceContractAddress: null, + })).toThrow(/both be null or both non-null/); + }); + + it('rejects missing, extra, accessor, symbol, and wrong object-type fields', () => { + const missing = { ...VALID_HEAD } as Record; + delete missing.version; + expect(() => assertAuthorCatalogHeadV1(missing)).toThrow(/catalog-object-schema/); + expect(() => assertAuthorCatalogHeadV1({ ...VALID_HEAD, tier: 'swm' })).toThrow( + /catalog-object-schema/, + ); + const accessor = { ...VALID_HEAD }; + Object.defineProperty(accessor, 'version', { enumerable: true, get: () => '0' }); + expect(() => assertAuthorCatalogHeadV1(accessor)).toThrow(/catalog-object-schema/); + const symbol = { ...VALID_HEAD } as Record; + symbol[Symbol('hidden')] = true; + expect(() => assertAuthorCatalogHeadV1(symbol)).toThrow(/catalog-object-schema/); + expect(() => assertUnsignedAuthorCatalogHeadEnvelopeV1({ + ...HEAD_UNSIGNED, + objectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + })).toThrow(/catalog-object-type/); + }); + + it('wraps and strictly parses the generic signed envelope without authority checks', () => { + expect(() => assertSignedAuthorCatalogHeadEnvelopeV1(HEAD_SIGNED)).not.toThrow(); + const bytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(HEAD_SIGNED); + expect(parseCanonicalSignedAuthorCatalogHeadEnvelopeV1(bytes)).toEqual(HEAD_SIGNED); + expect(() => parseCanonicalUnsignedAuthorCatalogHeadEnvelopeV1( + HEAD_UNSIGNED_CANONICAL.replace('"version":"0"', '"version":"0","version":"0"'), + )).toThrow(/Duplicate object key/); + }); +}); + +function rowForNumber(number: bigint, coordinate: string): AuthorCatalogRowV1 { + const kaId = ((BigInt(AUTHOR) << 96n) | number).toString(); + return validatedRow({ ...VALID_ROW, kaId, assertionCoordinate: coordinate }); +} + +function validatedScope(value: unknown): AuthorCatalogScopeV1 { + assertAuthorCatalogScopeV1(value); + return value; +} + +function validatedRow(value: unknown): AuthorCatalogRowV1 { + assertAuthorCatalogRowV1(value); + return value; +} + +function validatedBucket(value: unknown): AuthorCatalogBucketV1 { + assertAuthorCatalogBucketV1(value); + return value; +} + +function validatedHead(value: unknown): AuthorCatalogHeadV1 { + assertAuthorCatalogHeadV1(value); + return value; +} From 61393148a9807dfd4d71e6567bf59f1e02213e69 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 03:29:15 +0200 Subject: [PATCH 016/292] test(core): cover catalog version admission --- .../core/test/author-catalog-objects.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/core/test/author-catalog-objects.test.ts b/packages/core/test/author-catalog-objects.test.ts index 9e3abb8f76..7d66f9be9c 100644 --- a/packages/core/test/author-catalog-objects.test.ts +++ b/packages/core/test/author-catalog-objects.test.ts @@ -235,6 +235,26 @@ describe('AuthorCatalogBucketV1 structural codec', () => { })).toThrow(/catalog-object-type/); }); + it('rejects assertion version zero through bucket and envelope admission', () => { + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + rows: [{ ...VALID_ROW, assertionVersion: '0' }], + })).toThrow(/positive DecimalU64V1/); + const zeroVersionBucket = BUCKET_CANONICAL.replace( + '"assertionVersion":"1"', + '"assertionVersion":"0"', + ); + expect(() => parseCanonicalAuthorCatalogBucketPayloadV1(zeroVersionBucket)).toThrow( + /positive DecimalU64V1/, + ); + expect(() => parseCanonicalUnsignedAuthorCatalogBucketEnvelopeV1( + BUCKET_UNSIGNED_CANONICAL.replace( + '"assertionVersion":"1"', + '"assertionVersion":"0"', + ), + )).toThrow(/positive DecimalU64V1/); + }); + it('wraps and strictly parses the generic signed envelope without authority checks', () => { expect(() => assertSignedAuthorCatalogBucketEnvelopeV1(BUCKET_SIGNED)).not.toThrow(); const bytes = canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1(BUCKET_SIGNED); From f3de33c1910d11e82bbf7ee438b07637855c7cb4 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 03:49:40 +0200 Subject: [PATCH 017/292] feat(core): add RFC-64 catalog directory codec --- packages/core/src/author-catalog-directory.ts | 665 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../test/author-catalog-directory.test.ts | 658 +++++++++++++++++ 3 files changed, 1324 insertions(+) create mode 100644 packages/core/src/author-catalog-directory.ts create mode 100644 packages/core/test/author-catalog-directory.test.ts diff --git a/packages/core/src/author-catalog-directory.ts b/packages/core/src/author-catalog-directory.ts new file mode 100644 index 0000000000..3e0eb132e4 --- /dev/null +++ b/packages/core/src/author-catalog-directory.ts @@ -0,0 +1,665 @@ +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + assertAuthorCatalogBucketCountV1, + assertAuthorCatalogScopeV1, + computeAuthorCatalogScopeDigestV1, + type AuthorCatalogScopeV1, +} from './author-catalog-codec.js'; +import { + AUTHOR_CATALOG_DIRECTORY_FANOUT_V1, + MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, + MAX_AUTHOR_CATALOG_DIRECTORY_HEIGHT_V1, + ZERO_DIGEST32_V1, + assertAuthorCatalogHeadV1, + computeAuthorCatalogDirectoryHeightV1, + deriveAuthorCatalogScopeFromHeadV1, + type AuthorCatalogHeadV1, +} from './author-catalog-objects.js'; +import { + assertSignedControlEnvelope, + assertUnsignedControlEnvelope, + canonicalizeSignedControlEnvelopeBytes, + canonicalizeUnsignedControlEnvelopeBytes, + computeControlObjectDigestHex, + parseCanonicalSignedControlEnvelope, + parseCanonicalUnsignedControlEnvelope, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from './sync-control-object.js'; +import { + MAX_DECIMAL_U64, + assertCanonicalDigest, + parseCanonicalDecimalU64, + type ByteLengthV1, + type CountV1, + type DecimalU64V1, + type Digest32V1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +export const AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1 = + 'AuthorCatalogDirectoryNodeV1' as const; +export const MAX_AUTHOR_CATALOG_DIRECTORY_ENTRIES_V1 = 256; +export const MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1 = 256 * 1024; +export const MAX_AUTHOR_CATALOG_DIRECTORY_PATH_NODES_V1 = 8; + +const UTF8 = new TextEncoder(); + +/** Canonical level-zero descriptor. Zero values denote the absence of a bucket object. */ +export interface AuthorCatalogBucketDescriptorV1 { + readonly bucketId: DecimalU64V1; + readonly rowCount: CountV1; + readonly byteLength: ByteLengthV1; + readonly bucketDigest: Digest32V1; +} + +/** Canonical higher-level descriptor for one immediately referenced child node. */ +export interface AuthorCatalogChildDescriptorV1 { + readonly firstBucketId: DecimalU64V1; + readonly bucketSpan: CountV1; + readonly rowCount: CountV1; + readonly byteLength: ByteLengthV1; + readonly childDigest: Digest32V1; +} + +export type AuthorCatalogDirectoryEntryV1 = + | AuthorCatalogBucketDescriptorV1 + | AuthorCatalogChildDescriptorV1; + +/** Exact five-key directory-node payload; entry schema is selected by `level`. */ +export interface AuthorCatalogDirectoryNodeV1 { + readonly catalogScopeDigest: Digest32V1; + readonly era: DecimalU64V1; + readonly level: DecimalU64V1; + readonly firstBucketId: DecimalU64V1; + readonly entries: readonly AuthorCatalogDirectoryEntryV1[]; +} + +export type UnsignedAuthorCatalogDirectoryNodeEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1; + readonly payload: AuthorCatalogDirectoryNodeV1; +}; + +export type SignedAuthorCatalogDirectoryNodeEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1; + readonly payload: AuthorCatalogDirectoryNodeV1; +}; + +export type AuthorCatalogDirectoryErrorCode = + | 'catalog-directory-schema' + | 'catalog-directory-scalar' + | 'catalog-directory-type' + | 'catalog-directory-array' + | 'catalog-directory-layout' + | 'catalog-directory-descriptor' + | 'catalog-directory-scope-mismatch' + | 'catalog-directory-payload-too-large' + | 'catalog-directory-path'; + +export class AuthorCatalogDirectoryError extends Error { + constructor( + readonly code: AuthorCatalogDirectoryErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'AuthorCatalogDirectoryError'; + } +} + +/** Validate one canonical directory-node layout in the enclosing catalog's bucket domain. */ +export function assertAuthorCatalogDirectoryNodeV1( + node: unknown, + bucketCount: CountV1, +): asserts node is AuthorCatalogDirectoryNodeV1 { + assertAuthorCatalogDirectoryNodeStructureV1(node, bucketCount); + canonicalizeDirectoryNodeAfterStructure(node); +} + +/** Bind a structurally valid node to the exact scope carried by its enclosing head. */ +export function assertAuthorCatalogDirectoryNodeScopeBindingV1( + node: AuthorCatalogDirectoryNodeV1, + scope: AuthorCatalogScopeV1, +): void { + assertAuthorCatalogScopeV1(scope); + assertAuthorCatalogDirectoryNodeV1(node, scope.bucketCount); + if (node.catalogScopeDigest !== computeAuthorCatalogScopeDigestV1(scope)) { + fail( + 'catalog-directory-scope-mismatch', + 'directory node catalogScopeDigest does not match the contextual catalog scope', + ); + } + if (node.era !== scope.era) { + fail( + 'catalog-directory-scope-mismatch', + 'directory node era does not match the contextual catalog scope', + ); + } +} + +export function canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1( + node: AuthorCatalogDirectoryNodeV1, + bucketCount: CountV1, +): Uint8Array { + assertAuthorCatalogDirectoryNodeStructureV1(node, bucketCount); + return UTF8.encode(canonicalizeDirectoryNodeAfterStructure(node)); +} + +export function canonicalizeAuthorCatalogDirectoryNodePayloadV1( + node: AuthorCatalogDirectoryNodeV1, + bucketCount: CountV1, +): string { + assertAuthorCatalogDirectoryNodeStructureV1(node, bucketCount); + return canonicalizeDirectoryNodeAfterStructure(node); +} + +export function parseCanonicalAuthorCatalogDirectoryNodePayloadV1( + input: string | Uint8Array, + bucketCount: CountV1, + options: StrictJsonParseOptions = {}, +): AuthorCatalogDirectoryNodeV1 { + rejectOversizedWireInput( + input, + MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1, + 'author catalog directory payload', + ); + const parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 3, 3), + }); + assertAuthorCatalogDirectoryNodeV1(parsed, bucketCount); + return parsed; +} + +export function assertUnsignedAuthorCatalogDirectoryNodeEnvelopeV1( + envelope: UnsignedControlEnvelopeV1, + bucketCount: CountV1, +): asserts envelope is UnsignedAuthorCatalogDirectoryNodeEnvelopeV1 { + assertUnsignedControlEnvelope(envelope); + assertEnvelopeObjectType(envelope.objectType); + assertAuthorCatalogDirectoryNodeV1(envelope.payload, bucketCount); +} + +export function assertSignedAuthorCatalogDirectoryNodeEnvelopeV1( + envelope: SignedControlEnvelopeV1, + bucketCount: CountV1, +): asserts envelope is SignedAuthorCatalogDirectoryNodeEnvelopeV1 { + assertSignedControlEnvelope(envelope); + assertEnvelopeObjectType(envelope.objectType); + assertAuthorCatalogDirectoryNodeV1(envelope.payload, bucketCount); +} + +export function canonicalizeUnsignedAuthorCatalogDirectoryNodeEnvelopeBytesV1( + envelope: UnsignedControlEnvelopeV1, + bucketCount: CountV1, +): Uint8Array { + assertUnsignedAuthorCatalogDirectoryNodeEnvelopeV1(envelope, bucketCount); + return canonicalizeUnsignedControlEnvelopeBytes(envelope); +} + +export function canonicalizeSignedAuthorCatalogDirectoryNodeEnvelopeBytesV1( + envelope: SignedControlEnvelopeV1, + bucketCount: CountV1, +): Uint8Array { + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(envelope, bucketCount); + return canonicalizeSignedControlEnvelopeBytes(envelope); +} + +export function computeAuthorCatalogDirectoryNodeObjectDigestV1( + envelope: UnsignedControlEnvelopeV1, + bucketCount: CountV1, +): Digest32V1 { + assertUnsignedAuthorCatalogDirectoryNodeEnvelopeV1(envelope, bucketCount); + const digest = computeControlObjectDigestHex(envelope); + assertCanonicalDigest(digest, 'directory node objectDigest'); + return digest; +} + +/** Parse the generic envelope first, then enforce the independent 256 KiB payload cap. */ +export function parseCanonicalUnsignedAuthorCatalogDirectoryNodeEnvelopeV1( + input: string | Uint8Array, + bucketCount: CountV1, + options: StrictJsonParseOptions = {}, +): UnsignedAuthorCatalogDirectoryNodeEnvelopeV1 { + const envelope = parseCanonicalUnsignedControlEnvelope(input, options); + assertUnsignedAuthorCatalogDirectoryNodeEnvelopeV1(envelope, bucketCount); + return envelope; +} + +export function parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1( + input: string | Uint8Array, + bucketCount: CountV1, + options: StrictJsonParseOptions = {}, +): SignedAuthorCatalogDirectoryNodeEnvelopeV1 { + const envelope = parseCanonicalSignedControlEnvelope(input, options); + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(envelope, bucketCount); + return envelope; +} + +/** + * Verify one selected root-to-leaf path under a structurally valid catalog head. + * + * This checks canonical object digests, topology, selected child indexes, immediate + * child byte lengths, and selected-subtree row counts. It deliberately performs no + * signature authority, history, full-directory closure, persistence, or RDF work. + */ +export function verifyAuthorCatalogDirectoryPathV1( + head: AuthorCatalogHeadV1, + path: unknown, + selectedBucketId: DecimalU64V1, +): AuthorCatalogBucketDescriptorV1 { + assertAuthorCatalogHeadV1(head); + const scope = deriveAuthorCatalogScopeFromHeadV1(head); + const bucketCount = BigInt(scope.bucketCount); + const bucketId = assertDirectoryU64(selectedBucketId, 'selectedBucketId'); + if (bucketId >= bucketCount) { + fail('catalog-directory-path', 'selectedBucketId must be less than bucketCount'); + } + + const expectedHeight = BigInt(head.directoryHeight); + const expectedPathLength = Number(expectedHeight + 1n); + assertDenseOrdinaryArray( + path, + 'directory path', + expectedPathLength, + expectedPathLength, + 'catalog-directory-path', + ); + if (path.length > MAX_AUTHOR_CATALOG_DIRECTORY_PATH_NODES_V1) { + fail( + 'catalog-directory-path', + `directory path must contain at most ${MAX_AUTHOR_CATALOG_DIRECTORY_PATH_NODES_V1} nodes`, + ); + } + + const unvalidatedEnvelopes = path as SignedControlEnvelopeV1[]; + const envelopes: SignedAuthorCatalogDirectoryNodeEnvelopeV1[] = new Array( + unvalidatedEnvelopes.length, + ); + const seenDigests = new Set(); + for (let pathIndex = 0; pathIndex < unvalidatedEnvelopes.length; pathIndex += 1) { + const envelope = unvalidatedEnvelopes[pathIndex]; + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(envelope, scope.bucketCount); + assertAuthorCatalogDirectoryNodeScopeBindingV1(envelope.payload, scope); + envelopes[pathIndex] = envelope; + const expectedLevel = expectedHeight - BigInt(pathIndex); + if (envelope.payload.level !== expectedLevel.toString()) { + fail( + 'catalog-directory-path', + `directory path node ${pathIndex} must have level ${expectedLevel}`, + ); + } + if (seenDigests.has(envelope.objectDigest)) { + fail('catalog-directory-path', 'directory path must not repeat an object digest'); + } + seenDigests.add(envelope.objectDigest); + } + + const root = envelopes[0]; + if (root.objectDigest !== head.directoryRootDigest) { + fail('catalog-directory-path', 'root objectDigest does not match directoryRootDigest'); + } + if (root.payload.firstBucketId !== '0') { + fail('catalog-directory-path', 'root directory node must start at bucket zero'); + } + if (sumEntryRows(root.payload.entries) !== BigInt(head.totalRows)) { + fail('catalog-directory-path', 'root rowCount sum does not match head totalRows'); + } + + for (let pathIndex = 0; pathIndex + 1 < envelopes.length; pathIndex += 1) { + const current = envelopes[pathIndex].payload; + const next = envelopes[pathIndex + 1]; + const level = BigInt(current.level); + const currentFirst = BigInt(current.firstBucketId); + const childWidth = directoryPower(level); + if (bucketId < currentFirst) { + fail('catalog-directory-path', 'selected bucket precedes the current node range'); + } + const selectedIndex = (bucketId - currentFirst) / childWidth; + if (selectedIndex >= BigInt(current.entries.length)) { + fail('catalog-directory-path', 'selected bucket is outside the current node range'); + } + const selected = current.entries[Number(selectedIndex)] as AuthorCatalogChildDescriptorV1; + if (selected.childDigest !== next.objectDigest) { + fail('catalog-directory-path', 'selected childDigest does not match the next path node'); + } + if (selected.firstBucketId !== next.payload.firstBucketId) { + fail('catalog-directory-path', 'selected child range does not match the next path node'); + } + const nextPayloadBytes = canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1( + next.payload, + scope.bucketCount, + ); + if (BigInt(nextPayloadBytes.byteLength) !== BigInt(selected.byteLength)) { + fail('catalog-directory-path', 'selected child byteLength does not match the next path node'); + } + if (sumEntryRows(next.payload.entries) !== BigInt(selected.rowCount)) { + fail('catalog-directory-path', 'selected child rowCount does not match the next path node'); + } + } + + const leaf = envelopes[envelopes.length - 1].payload; + const leafFirst = BigInt(leaf.firstBucketId); + if (bucketId < leafFirst) { + fail('catalog-directory-path', 'selected bucket precedes the leaf node range'); + } + const selectedLeafIndex = bucketId - leafFirst; + if (selectedLeafIndex >= BigInt(leaf.entries.length)) { + fail('catalog-directory-path', 'selected bucket is outside the leaf node range'); + } + const descriptor = leaf.entries[Number(selectedLeafIndex)] as AuthorCatalogBucketDescriptorV1; + if (descriptor.bucketId !== selectedBucketId) { + fail('catalog-directory-path', 'selected leaf descriptor does not match selectedBucketId'); + } + return descriptor; +} + +function assertAuthorCatalogDirectoryNodeStructureV1( + node: unknown, + bucketCountValue: CountV1, +): asserts node is AuthorCatalogDirectoryNodeV1 { + assertAuthorCatalogBucketCountV1(bucketCountValue); + const bucketCount = BigInt(bucketCountValue); + if (!isPlainRecord(node)) { + fail('catalog-directory-schema', 'directory node must be a plain JSON object'); + } + assertClosedKeys(node, [ + 'catalogScopeDigest', + 'entries', + 'era', + 'firstBucketId', + 'level', + ], 'author catalog directory node'); + assertDirectoryScalar(() => assertCanonicalDigest(node.catalogScopeDigest, 'catalogScopeDigest')); + assertDirectoryU64(node.era, 'era'); + const level = assertDirectoryU64(node.level, 'level'); + const maximumLevel = BigInt(computeAuthorCatalogDirectoryHeightV1(bucketCountValue)); + if (level > MAX_AUTHOR_CATALOG_DIRECTORY_HEIGHT_V1 || level > maximumLevel) { + fail( + 'catalog-directory-layout', + `level must be in 0..${maximumLevel} for bucketCount ${bucketCountValue}`, + ); + } + const firstBucketId = assertDirectoryU64(node.firstBucketId, 'firstBucketId'); + const nodeWidth = directoryPower(level + 1n); + if ( + firstBucketId >= bucketCount + || firstBucketId % nodeWidth !== 0n + ) { + fail( + 'catalog-directory-layout', + `firstBucketId must identify a canonical level-${level} node`, + ); + } + + const remainingBuckets = bucketCount - firstBucketId; + const coverage = remainingBuckets < nodeWidth ? remainingBuckets : nodeWidth; + const entryWidth = directoryPower(level); + const expectedEntries = Number((coverage + entryWidth - 1n) / entryWidth); + assertDenseOrdinaryArray( + node.entries, + 'directory entries', + expectedEntries, + expectedEntries, + 'catalog-directory-array', + ); + if (node.entries.length > MAX_AUTHOR_CATALOG_DIRECTORY_ENTRIES_V1) { + fail( + 'catalog-directory-array', + `directory entries must contain at most ${MAX_AUTHOR_CATALOG_DIRECTORY_ENTRIES_V1} values`, + ); + } + + let rowCountSum = 0n; + for (let entryIndex = 0; entryIndex < node.entries.length; entryIndex += 1) { + const entry = node.entries[entryIndex]; + const expectedFirst = firstBucketId + BigInt(entryIndex) * entryWidth; + const expectedSpan = bucketCount - expectedFirst < entryWidth + ? bucketCount - expectedFirst + : entryWidth; + const rowCount = level === 0n + ? assertBucketDescriptor(entry, expectedFirst, entryIndex) + : assertChildDescriptor(entry, expectedFirst, expectedSpan, entryIndex); + rowCountSum += rowCount; + if (rowCountSum > MAX_DECIMAL_U64) { + fail('catalog-directory-descriptor', 'directory rowCount sum exceeds u64'); + } + } +} + +function assertBucketDescriptor( + entry: unknown, + expectedBucketId: bigint, + index: number, +): bigint { + if (!isPlainRecord(entry)) { + fail('catalog-directory-descriptor', `entries[${index}] must be a plain bucket descriptor`); + } + assertClosedKeys(entry, [ + 'bucketDigest', + 'bucketId', + 'byteLength', + 'rowCount', + ], `entries[${index}]`); + const bucketId = assertDirectoryU64(entry.bucketId, `entries[${index}].bucketId`); + if (bucketId !== expectedBucketId) { + fail('catalog-directory-layout', `entries[${index}].bucketId is not consecutive`); + } + const rowCount = assertDirectoryU64(entry.rowCount, `entries[${index}].rowCount`); + const byteLength = assertDirectoryU64(entry.byteLength, `entries[${index}].byteLength`); + assertDirectoryScalar(() => assertCanonicalDigest( + entry.bucketDigest, + `entries[${index}].bucketDigest`, + )); + + if (rowCount === 0n) { + if (byteLength !== 0n || entry.bucketDigest !== ZERO_DIGEST32_V1) { + fail( + 'catalog-directory-descriptor', + `entries[${index}] must use the canonical empty bucket descriptor`, + ); + } + return rowCount; + } + if ( + rowCount > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) + || byteLength < 1n + || byteLength > BigInt(MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1) + || entry.bucketDigest === ZERO_DIGEST32_V1 + ) { + fail( + 'catalog-directory-descriptor', + `entries[${index}] is outside the non-empty bucket bounds`, + ); + } + return rowCount; +} + +function assertChildDescriptor( + entry: unknown, + expectedFirstBucketId: bigint, + expectedBucketSpan: bigint, + index: number, +): bigint { + if (!isPlainRecord(entry)) { + fail('catalog-directory-descriptor', `entries[${index}] must be a plain child descriptor`); + } + assertClosedKeys(entry, [ + 'bucketSpan', + 'byteLength', + 'childDigest', + 'firstBucketId', + 'rowCount', + ], `entries[${index}]`); + const firstBucketId = assertDirectoryU64( + entry.firstBucketId, + `entries[${index}].firstBucketId`, + ); + const bucketSpan = assertDirectoryU64(entry.bucketSpan, `entries[${index}].bucketSpan`); + if (firstBucketId !== expectedFirstBucketId || bucketSpan !== expectedBucketSpan) { + fail('catalog-directory-layout', `entries[${index}] has a non-canonical child range`); + } + const rowCount = assertDirectoryU64(entry.rowCount, `entries[${index}].rowCount`); + const maximumRows = expectedBucketSpan * BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1); + if (rowCount > maximumRows) { + fail('catalog-directory-descriptor', `entries[${index}].rowCount exceeds its bucket span`); + } + const byteLength = assertDirectoryU64(entry.byteLength, `entries[${index}].byteLength`); + if (byteLength < 1n || byteLength > BigInt(MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1)) { + fail( + 'catalog-directory-descriptor', + `entries[${index}].byteLength is outside the directory payload bounds`, + ); + } + assertDirectoryScalar(() => assertCanonicalDigest( + entry.childDigest, + `entries[${index}].childDigest`, + )); + if (entry.childDigest === ZERO_DIGEST32_V1) { + fail('catalog-directory-descriptor', `entries[${index}].childDigest must be nonzero`); + } + return rowCount; +} + +function assertDenseOrdinaryArray( + value: unknown, + label: string, + minimumLength: number, + maximumLength: number, + code: AuthorCatalogDirectoryErrorCode, +): asserts value is unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + fail(code, `${label} must be an ordinary Array`); + } + if (value.length < minimumLength || value.length > maximumLength) { + fail(code, `${label} must contain exactly ${minimumLength}..${maximumLength} entries`); + } + const keys = Reflect.ownKeys(value); + for (let keyIndex = 0; keyIndex < keys.length; keyIndex += 1) { + if (typeof keys[keyIndex] !== 'string') { + fail(code, `${label} must not contain symbol properties`); + } + } + if (keys.length !== value.length + 1) { + fail(code, `${label} must be dense and contain no custom properties`); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (!lengthDescriptor || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value')) { + fail(code, `${label} length must be an ordinary data property`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail(code, `${label}[${index}] must be an enumerable data property`); + } + } +} + +function canonicalizeDirectoryNodeAfterStructure(node: AuthorCatalogDirectoryNodeV1): string { + try { + return canonicalizeJson(node as unknown as CanonicalJsonValue, { + maxBytes: MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1, + maxDepth: 3, + }); + } catch (cause) { + fail( + 'catalog-directory-payload-too-large', + `directory payload exceeds ${MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1} bytes or depth`, + cause, + ); + } +} + +function sumEntryRows(entries: readonly AuthorCatalogDirectoryEntryV1[]): bigint { + let total = 0n; + for (let index = 0; index < entries.length; index += 1) { + total += BigInt(entries[index].rowCount); + if (total > MAX_DECIMAL_U64) { + fail('catalog-directory-descriptor', 'directory rowCount sum exceeds u64'); + } + } + return total; +} + +function directoryPower(exponent: bigint): bigint { + let value = 1n; + for (let index = 0n; index < exponent; index += 1n) { + value *= AUTHOR_CATALOG_DIRECTORY_FANOUT_V1; + } + return value; +} + +function assertEnvelopeObjectType(actual: string): void { + if (actual !== AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1) { + fail( + 'catalog-directory-type', + `objectType must be exactly ${AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1}`, + ); + } +} + +function assertClosedKeys( + record: Record, + keys: readonly string[], + label: string, +): void { + try { + assertExactKeys(record, keys, label); + } catch (cause) { + fail('catalog-directory-schema', `${label} has an invalid field set`, cause); + } +} + +function assertDirectoryScalar(operation: () => void): void { + try { + operation(); + } catch (cause) { + fail('catalog-directory-scalar', 'directory scalar is not canonical', cause); + } +} + +function assertDirectoryU64(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU64(value, label); + } catch (cause) { + fail('catalog-directory-scalar', `${label} is not a canonical DecimalU64V1`, cause); + } +} + +function rejectOversizedWireInput( + input: string | Uint8Array, + maxBytes: number, + label: string, +): void { + if (typeof input !== 'string') { + if (input.byteLength > maxBytes) { + fail('catalog-directory-payload-too-large', `${label} exceeds ${maxBytes} bytes`); + } + return; + } + if (input.length > maxBytes || UTF8.encode(input).byteLength > maxBytes) { + fail('catalog-directory-payload-too-large', `${label} exceeds ${maxBytes} bytes`); + } +} + +function fail( + code: AuthorCatalogDirectoryErrorCode, + message: string, + cause?: unknown, +): never { + throw new AuthorCatalogDirectoryError( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b48f8eac2c..1c887518a7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,6 +50,7 @@ export * from './ka-chunk-tree.js'; export * from './ka-chunk-proof.js'; export * from './author-catalog-codec.js'; export * from './author-catalog-objects.js'; +export * from './author-catalog-directory.js'; export * from './event-bus.js'; export { Logger, diff --git a/packages/core/test/author-catalog-directory.test.ts b/packages/core/test/author-catalog-directory.test.ts new file mode 100644 index 0000000000..ce10b5aa9e --- /dev/null +++ b/packages/core/test/author-catalog-directory.test.ts @@ -0,0 +1,658 @@ +import { describe, expect, it } from 'vitest'; + +import { + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + MAX_AUTHOR_CATALOG_DIRECTORY_PATH_NODES_V1, + MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1, + assertAuthorCatalogDirectoryNodeScopeBindingV1, + assertAuthorCatalogDirectoryNodeV1, + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + assertUnsignedAuthorCatalogDirectoryNodeEnvelopeV1, + canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1, + canonicalizeAuthorCatalogDirectoryNodePayloadV1, + canonicalizeSignedAuthorCatalogDirectoryNodeEnvelopeBytesV1, + canonicalizeUnsignedAuthorCatalogDirectoryNodeEnvelopeBytesV1, + computeAuthorCatalogDirectoryNodeObjectDigestV1, + parseCanonicalAuthorCatalogDirectoryNodePayloadV1, + parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1, + parseCanonicalUnsignedAuthorCatalogDirectoryNodeEnvelopeV1, + verifyAuthorCatalogDirectoryPathV1, + type AuthorCatalogBucketDescriptorV1, + type AuthorCatalogChildDescriptorV1, + type AuthorCatalogDirectoryNodeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, +} from '../src/author-catalog-directory.js'; +import { + assertAuthorCatalogScopeV1, + computeAuthorCatalogScopeDigestV1, + type AuthorCatalogScopeV1, +} from '../src/author-catalog-codec.js'; +import { + ZERO_DIGEST32_V1, + assertAuthorCatalogHeadV1, + type AuthorCatalogHeadV1, +} from '../src/author-catalog-objects.js'; +import type { + SignedControlEnvelopeV1, + UnsignedControlEnvelopeV1, +} from '../src/sync-control-object.js'; +import type { CountV1, Digest32V1 } from '../src/sync-wire-scalars.js'; + +const SCOPE_DIGEST = + '0x7b18d141cbb6af4e7fdabe2e4d7d0b9512b042eb079b89a4797d3a0a7f1d4537'; +const EMPTY_ROOT_DIGEST = + '0x0163c048997ddaeb984d10a06f98064739a95546cf71d142c0d0a3de19f65f52'; +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const ISSUER = '0x5555555555555555555555555555555555555555'; +const DELEGATION_DIGEST = `0x${'66'.repeat(32)}`; +const EIP191_SIGNATURE = `0x${'77'.repeat(65)}`; + +const EMPTY_DIRECTORY_PAYLOAD_CANONICAL = + `{"catalogScopeDigest":"${SCOPE_DIGEST}","entries":[{"bucketDigest":"${ZERO_DIGEST32_V1}","bucketId":"0","byteLength":"0","rowCount":"0"}],"era":"0","firstBucketId":"0","level":"0"}`; +const EMPTY_DIRECTORY_UNSIGNED_CANONICAL = + `{"issuer":"${ISSUER}","objectType":"AuthorCatalogDirectoryNodeV1","payload":${EMPTY_DIRECTORY_PAYLOAD_CANONICAL},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}`; + +const EMPTY_SCOPE = validatedScope({ + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/catalog-fixture', + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', +}); +const EMPTY_NODE = validatedNode( + JSON.parse(EMPTY_DIRECTORY_PAYLOAD_CANONICAL), + EMPTY_SCOPE.bucketCount, +); +const EMPTY_UNSIGNED = JSON.parse( + EMPTY_DIRECTORY_UNSIGNED_CANONICAL, +) as UnsignedControlEnvelopeV1; +const EMPTY_SIGNED = { + ...EMPTY_UNSIGNED, + objectDigest: EMPTY_ROOT_DIGEST, + signature: EIP191_SIGNATURE, +} as SignedControlEnvelopeV1; +const EMPTY_HEAD = validatedHead({ + networkId: EMPTY_SCOPE.networkId, + contextGraphId: EMPTY_SCOPE.contextGraphId, + governanceChainId: EMPTY_SCOPE.governanceChainId, + governanceContractAddress: EMPTY_SCOPE.governanceContractAddress, + ownershipTransitionDigest: EMPTY_SCOPE.ownershipTransitionDigest, + subGraphName: EMPTY_SCOPE.subGraphName, + authorAddress: EMPTY_SCOPE.authorAddress, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + era: '0', + version: '0', + previousHeadDigest: null, + bucketCount: '1', + totalRows: '0', + directoryHeight: '0', + directoryRootDigest: EMPTY_ROOT_DIGEST, + issuedAt: '1700000000123', +}); + +describe('AuthorCatalogDirectoryNodeV1 structural codec', () => { + it('pins the normative empty-directory payload, envelope, and root digest', () => { + expect(canonicalizeAuthorCatalogDirectoryNodePayloadV1(EMPTY_NODE, '1')).toBe( + EMPTY_DIRECTORY_PAYLOAD_CANONICAL, + ); + expect(new TextEncoder().encode(EMPTY_DIRECTORY_PAYLOAD_CANONICAL).byteLength).toBe(278); + expect(new TextDecoder().decode( + canonicalizeUnsignedAuthorCatalogDirectoryNodeEnvelopeBytesV1(EMPTY_UNSIGNED, '1'), + )).toBe(EMPTY_DIRECTORY_UNSIGNED_CANONICAL); + expect(new TextEncoder().encode(EMPTY_DIRECTORY_UNSIGNED_CANONICAL).byteLength).toBe(474); + expect(computeAuthorCatalogDirectoryNodeObjectDigestV1(EMPTY_UNSIGNED, '1')).toBe( + EMPTY_ROOT_DIGEST, + ); + expect(parseCanonicalAuthorCatalogDirectoryNodePayloadV1( + EMPTY_DIRECTORY_PAYLOAD_CANONICAL, + '1', + )).toEqual(EMPTY_NODE); + expect(parseCanonicalUnsignedAuthorCatalogDirectoryNodeEnvelopeV1( + EMPTY_DIRECTORY_UNSIGNED_CANONICAL, + '1', + )).toEqual(EMPTY_UNSIGNED); + }); + + it('validates signed digest linkage and resolves the height-zero empty path', () => { + expect(() => assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(EMPTY_SIGNED, '1')) + .not.toThrow(); + const signedBytes = canonicalizeSignedAuthorCatalogDirectoryNodeEnvelopeBytesV1( + EMPTY_SIGNED, + '1', + ); + expect(parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1(signedBytes, '1')) + .toEqual(EMPTY_SIGNED); + expect(verifyAuthorCatalogDirectoryPathV1(EMPTY_HEAD, [EMPTY_SIGNED], '0')).toEqual({ + bucketDigest: ZERO_DIGEST32_V1, + bucketId: '0', + byteLength: '0', + rowCount: '0', + }); + expect(() => assertSignedAuthorCatalogDirectoryNodeEnvelopeV1({ + ...EMPTY_SIGNED, + objectDigest: digest(99), + }, '1')).toThrow(/digest mismatch/); + }); + + it('binds nodes to the exact contextual scope and era', () => { + expect(() => assertAuthorCatalogDirectoryNodeScopeBindingV1(EMPTY_NODE, EMPTY_SCOPE)) + .not.toThrow(); + expect(() => assertAuthorCatalogDirectoryNodeScopeBindingV1( + { ...EMPTY_NODE, catalogScopeDigest: digest(12) }, + EMPTY_SCOPE, + )).toThrow(/scope-mismatch/); + expect(() => assertAuthorCatalogDirectoryNodeScopeBindingV1( + { ...EMPTY_NODE, era: '1' }, + EMPTY_SCOPE, + )).toThrow(/scope-mismatch/); + }); + + it('enforces canonical empty and non-empty leaf descriptor branches', () => { + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: [{ ...EMPTY_NODE.entries[0], bucketDigest: digest(1) }], + }, '1')).toThrow(/canonical empty bucket descriptor/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: [{ ...EMPTY_NODE.entries[0], rowCount: '1' }], + }, '1')).toThrow(/non-empty bucket bounds/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: [{ + bucketDigest: digest(1), + bucketId: '0', + byteLength: '1', + rowCount: '1024', + }], + }, '1')).not.toThrow(); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: [{ + bucketDigest: digest(1), + bucketId: '0', + byteLength: '1048577', + rowCount: '1', + }], + }, '1')).toThrow(/non-empty bucket bounds/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: [{ + bucketDigest: digest(1), + bucketId: '0', + byteLength: '1', + rowCount: '1025', + }], + }, '1')).toThrow(/non-empty bucket bounds/); + }); + + it('requires exact level-zero count, consecutive IDs, and canonical node alignment', () => { + const entries = emptyBucketDescriptors(256, 0n); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries, + }, '256')).not.toThrow(); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: entries.slice(0, 255), + }, '256')).toThrow(/exactly 256/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: entries.map((entry, index) => index === 4 ? { ...entry, bucketId: '5' } : entry), + }, '256')).toThrow(/not consecutive/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + firstBucketId: '1', + entries, + }, '512')).toThrow(/canonical level-0 node/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + level: '1', + entries: [{ + firstBucketId: '0', + bucketSpan: '1', + rowCount: '0', + byteLength: '1', + childDigest: digest(1), + }], + }, '1')).toThrow(/level must be in 0..0/); + }); + + it('enforces exact higher-level ranges, child bounds, and checked row sums', () => { + const branch = branchNode(512n, 1n, 0n, SCOPE_DIGEST); + expect(() => assertAuthorCatalogDirectoryNodeV1(branch, '512')).not.toThrow(); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...branch, + entries: branch.entries.map((entry, index) => index === 1 + ? { ...(entry as AuthorCatalogChildDescriptorV1), firstBucketId: '255' } + : entry), + }, '512')).toThrow(/non-canonical child range/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...branch, + entries: branch.entries.map((entry, index) => index === 0 + ? { ...(entry as AuthorCatalogChildDescriptorV1), bucketSpan: '255' } + : entry), + }, '512')).toThrow(/non-canonical child range/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...branch, + entries: branch.entries.map((entry, index) => index === 0 + ? { ...(entry as AuthorCatalogChildDescriptorV1), childDigest: ZERO_DIGEST32_V1 } + : entry), + }, '512')).toThrow(/childDigest must be nonzero/); + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...branch, + entries: branch.entries.map((entry, index) => index === 0 + ? { ...(entry as AuthorCatalogChildDescriptorV1), byteLength: '262145' } + : entry), + }, '512')).toThrow(/directory payload bounds/); + + const overflowing = branchNode(1n << 63n, 7n, 0n, SCOPE_DIGEST); + const maximum = '18446744073709551615'; + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...overflowing, + entries: overflowing.entries.map((entry) => ({ + ...(entry as AuthorCatalogChildDescriptorV1), + rowCount: maximum, + })), + }, (1n << 63n).toString() as CountV1)).toThrow(/rowCount sum exceeds u64/); + }); + + it('rejects sparse, subclassed, accessor, symbol, and custom-property entry arrays', () => { + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: new Array(1), + }, '1')).toThrow(/catalog-directory-array/); + + class Entries extends Array {} + expect(() => assertAuthorCatalogDirectoryNodeV1({ + ...EMPTY_NODE, + entries: new Entries(EMPTY_NODE.entries[0] as AuthorCatalogBucketDescriptorV1), + }, '1')).toThrow(/catalog-directory-array/); + + const accessor = [EMPTY_NODE.entries[0]]; + Object.defineProperty(accessor, '0', { + enumerable: true, + get: () => EMPTY_NODE.entries[0], + }); + expect(() => assertAuthorCatalogDirectoryNodeV1({ ...EMPTY_NODE, entries: accessor }, '1')) + .toThrow(/catalog-directory-array/); + + const withSymbol = [EMPTY_NODE.entries[0]] as unknown[] & Record; + withSymbol[Symbol('hidden')] = true; + expect(() => assertAuthorCatalogDirectoryNodeV1({ ...EMPTY_NODE, entries: withSymbol }, '1')) + .toThrow(/catalog-directory-array/); + + const withCustom = [EMPTY_NODE.entries[0]] as unknown[] & { extra?: boolean }; + withCustom.extra = true; + expect(() => assertAuthorCatalogDirectoryNodeV1({ ...EMPTY_NODE, entries: withCustom }, '1')) + .toThrow(/catalog-directory-array/); + }); + + it('rejects hostile node/entry shapes, lower-cap wire input, and the wrong object type', () => { + expect(() => assertAuthorCatalogDirectoryNodeV1({ ...EMPTY_NODE, bucketCount: '1' }, '1')) + .toThrow(/catalog-directory-schema/); + const accessor = { ...EMPTY_NODE }; + Object.defineProperty(accessor, 'level', { enumerable: true, get: () => '0' }); + expect(() => assertAuthorCatalogDirectoryNodeV1(accessor, '1')) + .toThrow(/catalog-directory-schema/); + const symbol = { ...EMPTY_NODE } as Record; + symbol[Symbol('hidden')] = true; + expect(() => assertAuthorCatalogDirectoryNodeV1(symbol, '1')) + .toThrow(/catalog-directory-schema/); + expect(() => parseCanonicalAuthorCatalogDirectoryNodePayloadV1( + '{'.padEnd(MAX_AUTHOR_CATALOG_DIRECTORY_PAYLOAD_BYTES_V1 + 1, 'x'), + '1', + )).toThrow(/payload-too-large/); + expect(() => assertUnsignedAuthorCatalogDirectoryNodeEnvelopeV1({ + ...EMPTY_UNSIGNED, + objectType: 'AuthorCatalogHeadV1', + }, '1')).toThrow(/catalog-directory-type/); + }); +}); + +describe('author catalog selected directory paths', () => { + it('derives both branch and leaf indexes and verifies child bytes/counts/digests', () => { + const fixture = twoLevelFixture(300n); + expect(verifyAuthorCatalogDirectoryPathV1( + fixture.head, + fixture.path, + '300', + )).toEqual(fixture.selectedDescriptor); + expect(() => verifyAuthorCatalogDirectoryPathV1(fixture.head, fixture.path, '10')) + .toThrow(/selected childDigest/); + + const wrongDigestRoot = signedEnvelope({ + ...fixture.root.payload, + entries: fixture.root.payload.entries.map((entry, index) => index === 1 + ? { ...(entry as AuthorCatalogChildDescriptorV1), childDigest: digest(999) } + : entry), + }, '512'); + expect(() => verifyAuthorCatalogDirectoryPathV1( + { ...fixture.head, directoryRootDigest: wrongDigestRoot.objectDigest }, + [wrongDigestRoot, fixture.leaf], + '300', + )).toThrow(/selected childDigest/); + + const wrongLengthRoot = signedEnvelope({ + ...fixture.root.payload, + entries: fixture.root.payload.entries.map((entry, index) => index === 1 + ? { ...(entry as AuthorCatalogChildDescriptorV1), byteLength: '1' } + : entry), + }, '512'); + expect(() => verifyAuthorCatalogDirectoryPathV1( + { ...fixture.head, directoryRootDigest: wrongLengthRoot.objectDigest }, + [wrongLengthRoot, fixture.leaf], + '300', + )).toThrow(/child byteLength/); + + const wrongCountRoot = signedEnvelope({ + ...fixture.root.payload, + entries: fixture.root.payload.entries.map((entry, index) => index === 1 + ? { ...(entry as AuthorCatalogChildDescriptorV1), rowCount: '2' } + : entry), + }, '512'); + expect(() => verifyAuthorCatalogDirectoryPathV1( + { ...fixture.head, totalRows: '2', directoryRootDigest: wrongCountRoot.objectDigest }, + [wrongCountRoot, fixture.leaf], + '300', + )).toThrow(/child rowCount/); + }); + + it('accepts the full eight-node path at the maximum v1 bucket count', () => { + const fixture = maximumHeightFixture(); + expect(fixture.path).toHaveLength(MAX_AUTHOR_CATALOG_DIRECTORY_PATH_NODES_V1); + expect(verifyAuthorCatalogDirectoryPathV1(fixture.head, fixture.path, '0')).toEqual( + fixture.selectedDescriptor, + ); + }); + + it('rejects omitted, reversed, repeated, sparse, subclassed, accessor, symbol, and custom paths', () => { + const fixture = twoLevelFixture(300n); + expect(() => verifyAuthorCatalogDirectoryPathV1( + fixture.head, + [fixture.root], + '300', + )).toThrow(/exactly 2/); + expect(() => verifyAuthorCatalogDirectoryPathV1( + fixture.head, + [fixture.leaf, fixture.root], + '300', + )).toThrow(/level 1/); + expect(() => verifyAuthorCatalogDirectoryPathV1( + fixture.head, + [fixture.root, fixture.root], + '300', + )).toThrow(); + expect(() => verifyAuthorCatalogDirectoryPathV1( + fixture.head, + new Array(2), + '300', + )).toThrow(/catalog-directory-path/); + + class Path extends Array {} + expect(() => verifyAuthorCatalogDirectoryPathV1( + fixture.head, + new Path(fixture.root, fixture.leaf), + '300', + )).toThrow(/catalog-directory-path/); + + const accessor = [fixture.root, fixture.leaf]; + Object.defineProperty(accessor, '1', { enumerable: true, get: () => fixture.leaf }); + expect(() => verifyAuthorCatalogDirectoryPathV1(fixture.head, accessor, '300')) + .toThrow(/catalog-directory-path/); + + const withSymbol = [fixture.root, fixture.leaf] as unknown[] & Record; + withSymbol[Symbol('hidden')] = true; + expect(() => verifyAuthorCatalogDirectoryPathV1(fixture.head, withSymbol, '300')) + .toThrow(/catalog-directory-path/); + + const withCustom = [fixture.root, fixture.leaf] as unknown[] & { extra?: boolean }; + withCustom.extra = true; + expect(() => verifyAuthorCatalogDirectoryPathV1(fixture.head, withCustom, '300')) + .toThrow(/catalog-directory-path/); + }); + + it('never consumes poisoned inherited iterators on entries or paths', () => { + const entries = [...EMPTY_NODE.entries]; + const node = { ...EMPTY_NODE, entries }; + const signed = signedEnvelope(node, '1'); + const path = [signed]; + const head = { ...EMPTY_HEAD, directoryRootDigest: signed.objectDigest }; + const originalIterator = Array.prototype[Symbol.iterator]; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value(this: unknown[]) { + if (this === entries || this === path) { + throw new Error('poisoned directory iterator was consumed'); + } + return originalIterator.call(this); + }, + }); + try { + expect(() => assertAuthorCatalogDirectoryNodeV1(node, '1')).not.toThrow(); + expect(() => verifyAuthorCatalogDirectoryPathV1(head, path, '0')).not.toThrow(); + } finally { + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: originalIterator, + }); + } + }); +}); + +function twoLevelFixture(selectedBucketId: bigint): { + readonly head: AuthorCatalogHeadV1; + readonly root: SignedAuthorCatalogDirectoryNodeEnvelopeV1; + readonly leaf: SignedAuthorCatalogDirectoryNodeEnvelopeV1; + readonly path: SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; + readonly selectedDescriptor: AuthorCatalogBucketDescriptorV1; +} { + const scope = validatedScope({ ...EMPTY_SCOPE, bucketCount: '512' }); + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const leafFirst = 256n; + const selectedDescriptor = nonemptyBucketDescriptor(selectedBucketId); + const leafEntries = emptyBucketDescriptors(256, leafFirst); + leafEntries[Number(selectedBucketId - leafFirst)] = selectedDescriptor; + const leaf = signedEnvelope({ + catalogScopeDigest: scopeDigest, + entries: leafEntries, + era: '0', + firstBucketId: leafFirst.toString(), + level: '0', + }, scope.bucketCount); + const leafBytes = canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1( + leaf.payload, + scope.bucketCount, + ).byteLength; + const root = signedEnvelope({ + catalogScopeDigest: scopeDigest, + entries: [ + { + firstBucketId: '0', + bucketSpan: '256', + rowCount: '0', + byteLength: '1', + childDigest: digest(100), + }, + { + firstBucketId: '256', + bucketSpan: '256', + rowCount: '1', + byteLength: leafBytes.toString(), + childDigest: leaf.objectDigest, + }, + ], + era: '0', + firstBucketId: '0', + level: '1', + }, scope.bucketCount); + const head = validatedHead({ + ...EMPTY_HEAD, + bucketCount: '512', + totalRows: '1', + directoryHeight: '1', + directoryRootDigest: root.objectDigest, + }); + return { head, root, leaf, path: [root, leaf], selectedDescriptor }; +} + +function maximumHeightFixture(): { + readonly head: AuthorCatalogHeadV1; + readonly path: SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; + readonly selectedDescriptor: AuthorCatalogBucketDescriptorV1; +} { + const bucketCount = 1n << 63n; + const bucketCountWire = bucketCount.toString() as CountV1; + const scope = validatedScope({ ...EMPTY_SCOPE, bucketCount: bucketCountWire }); + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const selectedDescriptor = nonemptyBucketDescriptor(0n); + const leafEntries = emptyBucketDescriptors(256, 0n); + leafEntries[0] = selectedDescriptor; + let child = signedEnvelope({ + catalogScopeDigest: scopeDigest, + entries: leafEntries, + era: '0', + firstBucketId: '0', + level: '0', + }, bucketCountWire); + const leafToRoot: SignedAuthorCatalogDirectoryNodeEnvelopeV1[] = [child]; + + for (let level = 1n; level <= 7n; level += 1n) { + const width = 256n ** level; + const parentCoverage = minimum(256n ** (level + 1n), bucketCount); + const entryCount = Number((parentCoverage + width - 1n) / width); + const entries: AuthorCatalogChildDescriptorV1[] = new Array(entryCount); + const childBytes = canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1( + child.payload, + bucketCountWire, + ).byteLength; + for (let index = 0; index < entryCount; index += 1) { + entries[index] = { + firstBucketId: (BigInt(index) * width).toString(), + bucketSpan: minimum(width, bucketCount - BigInt(index) * width).toString(), + rowCount: index === 0 ? '1' : '0', + byteLength: index === 0 ? childBytes.toString() : '1', + childDigest: index === 0 ? child.objectDigest : digest(Number(level) * 1000 + index + 1), + }; + } + child = signedEnvelope({ + catalogScopeDigest: scopeDigest, + entries, + era: '0', + firstBucketId: '0', + level: level.toString(), + }, bucketCountWire); + leafToRoot.push(child); + } + const path = leafToRoot.reverse(); + const head = validatedHead({ + ...EMPTY_HEAD, + bucketCount: bucketCountWire, + totalRows: '1', + directoryHeight: '7', + directoryRootDigest: path[0].objectDigest, + }); + return { head, path, selectedDescriptor }; +} + +function branchNode( + bucketCount: bigint, + level: bigint, + firstBucketId: bigint, + scopeDigest: Digest32V1, +): AuthorCatalogDirectoryNodeV1 { + const entryWidth = 256n ** level; + const nodeWidth = entryWidth * 256n; + const coverage = minimum(nodeWidth, bucketCount - firstBucketId); + const entryCount = Number((coverage + entryWidth - 1n) / entryWidth); + const entries: AuthorCatalogChildDescriptorV1[] = new Array(entryCount); + for (let index = 0; index < entryCount; index += 1) { + const childFirst = firstBucketId + BigInt(index) * entryWidth; + entries[index] = { + firstBucketId: childFirst.toString(), + bucketSpan: minimum(entryWidth, bucketCount - childFirst).toString(), + rowCount: '0', + byteLength: '1', + childDigest: digest(index + 1), + }; + } + return { + catalogScopeDigest: scopeDigest, + entries, + era: '0', + firstBucketId: firstBucketId.toString(), + level: level.toString(), + }; +} + +function emptyBucketDescriptors( + count: number, + firstBucketId: bigint, +): AuthorCatalogBucketDescriptorV1[] { + return Array.from({ length: count }, (_, index) => ({ + bucketDigest: ZERO_DIGEST32_V1, + bucketId: (firstBucketId + BigInt(index)).toString(), + byteLength: '0', + rowCount: '0', + })); +} + +function nonemptyBucketDescriptor(bucketId: bigint): AuthorCatalogBucketDescriptorV1 { + return { + bucketDigest: digest(500_000 + Number(bucketId % 100_000n)), + bucketId: bucketId.toString(), + byteLength: '868', + rowCount: '1', + }; +} + +function signedEnvelope( + node: AuthorCatalogDirectoryNodeV1, + bucketCount: CountV1, +): SignedAuthorCatalogDirectoryNodeEnvelopeV1 { + const unsigned = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + payload: node, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; + const signed = { + ...unsigned, + objectDigest: computeAuthorCatalogDirectoryNodeObjectDigestV1(unsigned, bucketCount), + signature: EIP191_SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(signed, bucketCount); + return signed; +} + +function validatedScope(value: unknown): AuthorCatalogScopeV1 { + assertAuthorCatalogScopeV1(value); + return value; +} + +function validatedNode( + value: unknown, + bucketCount: CountV1, +): AuthorCatalogDirectoryNodeV1 { + assertAuthorCatalogDirectoryNodeV1(value, bucketCount); + return value; +} + +function validatedHead(value: unknown): AuthorCatalogHeadV1 { + assertAuthorCatalogHeadV1(value); + return value; +} + +function digest(value: number): Digest32V1 { + return `0x${value.toString(16).padStart(64, '0')}` as Digest32V1; +} + +function minimum(left: bigint, right: bigint): bigint { + return left < right ? left : right; +} From 179119e41e604b4e4e986e2293243e89f5bf437a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 03:53:56 +0200 Subject: [PATCH 018/292] test(core): pin RFC-64 catalog payload maxima --- .../core/test/author-catalog-objects.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/core/test/author-catalog-objects.test.ts b/packages/core/test/author-catalog-objects.test.ts index 7d66f9be9c..943d002b7c 100644 --- a/packages/core/test/author-catalog-objects.test.ts +++ b/packages/core/test/author-catalog-objects.test.ts @@ -4,6 +4,8 @@ import { AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, + MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1, assertAuthorCatalogBucketScopeBindingV1, assertAuthorCatalogBucketV1, assertAuthorCatalogHeadScopeBindingV1, @@ -34,11 +36,16 @@ import { import { assertAuthorCatalogRowV1, assertAuthorCatalogScopeV1, + canonicalizeAuthorCatalogRowV1, computeAuthorCatalogKeyDigestV1, computeAuthorCatalogRowDigestV1, type AuthorCatalogRowV1, type AuthorCatalogScopeV1, } from '../src/author-catalog-codec.js'; +import { + MAX_DECIMAL_U64, + MAX_DECIMAL_U256, +} from '../src/sync-wire-scalars.js'; import { type SignedControlEnvelopeV1, type UnsignedControlEnvelopeV1, @@ -235,6 +242,51 @@ describe('AuthorCatalogBucketV1 structural codec', () => { })).toThrow(/catalog-object-type/); }); + it('keeps the structurally maximal 1024-row payload below the 1 MiB cap', () => { + const maximalRows = Array.from( + { length: MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 }, + (_, index) => ({ + ...VALID_ROW, + kaId: ( + MAX_DECIMAL_U256 + - BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 - 1 - index) + ).toString(), + assertionCoordinate: `${'x'.repeat(252)}${index.toString(16).padStart(4, '0')}`, + assertionVersion: MAX_DECIMAL_U64.toString(), + transfer: { + ...VALID_ROW.transfer, + byteLength: '1073741824', + chunkCount: '4096', + }, + }), + ); + const maximalCountOneBucket = { + ...VALID_BUCKET, + era: MAX_DECIMAL_U64.toString(), + rows: maximalRows, + }; + + expect(() => assertAuthorCatalogBucketV1(maximalCountOneBucket)).not.toThrow(); + expect(new TextEncoder().encode( + canonicalizeAuthorCatalogRowV1(maximalRows[0]), + ).byteLength).toBe(1_004); + const canonical = canonicalizeAuthorCatalogBucketPayloadV1(maximalCountOneBucket); + expect(new TextEncoder().encode(canonical).byteLength).toBe(1_029_282); + + // Each row is at its exact field-width maximum (1,004 bytes). Replacing + // the one-digit bucketCount/bucketId with their widest permitted 19-digit + // forms adds at most 36 bytes, so no structurally valid v1 bucket can reach + // the 1 MiB cap even before the bucket-mapping invariant is considered. + const absolutePayloadUpperBound = canonical.length + (2 * (19 - 1)); + expect(absolutePayloadUpperBound).toBe(1_029_318); + expect(absolutePayloadUpperBound).toBeLessThan( + MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + ); + expect(parseCanonicalAuthorCatalogBucketPayloadV1(canonical).rows).toHaveLength( + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, + ); + }); + it('rejects assertion version zero through bucket and envelope admission', () => { expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, @@ -342,6 +394,37 @@ describe('AuthorCatalogHeadV1 structural codec', () => { })).toThrow(/catalog-object-type/); }); + it('pins the maximum valid head below 4 KiB and rejects over-cap wire input', () => { + const maximalHead = { + ...VALID_HEAD, + networkId: 'n'.repeat(128), + contextGraphId: 'c'.repeat(256), + governanceChainId: MAX_DECIMAL_U256.toString(), + governanceContractAddress: `0x${'f'.repeat(40)}`, + ownershipTransitionDigest: `0x${'f'.repeat(64)}`, + subGraphName: 's'.repeat(256), + authorAddress: `0x${'f'.repeat(40)}`, + catalogIssuerDelegationDigest: `0x${'f'.repeat(64)}`, + era: MAX_DECIMAL_U64.toString(), + version: MAX_DECIMAL_U64.toString(), + previousHeadDigest: `0x${'f'.repeat(64)}`, + bucketCount: '9223372036854775808', + totalRows: MAX_DECIMAL_U64.toString(), + directoryHeight: '7', + directoryRootDigest: `0x${'f'.repeat(64)}`, + issuedAt: MAX_DECIMAL_U64.toString(), + }; + + expect(() => assertAuthorCatalogHeadV1(maximalHead)).not.toThrow(); + const canonical = canonicalizeAuthorCatalogHeadPayloadV1(maximalHead); + expect(new TextEncoder().encode(canonical).byteLength).toBe(1_497); + expect(canonical.length).toBeLessThan(MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1); + expect(parseCanonicalAuthorCatalogHeadPayloadV1(canonical)).toEqual(maximalHead); + expect(() => parseCanonicalAuthorCatalogHeadPayloadV1( + '{'.padEnd(MAX_AUTHOR_CATALOG_HEAD_PAYLOAD_BYTES_V1 + 1, 'x'), + )).toThrow(/catalog-object-payload-too-large/); + }); + it('wraps and strictly parses the generic signed envelope without authority checks', () => { expect(() => assertSignedAuthorCatalogHeadEnvelopeV1(HEAD_SIGNED)).not.toThrow(); const bytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(HEAD_SIGNED); From 29aa7ce69bc45a599005b92312000021c1a46530 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 03:58:53 +0200 Subject: [PATCH 019/292] feat(agent): add RFC-64 inventory SQL foundation --- .../agent/src/rfc64/inventory-v1/index.ts | 3 + packages/agent/src/rfc64/inventory-v1/open.ts | 741 ++++++++++++++++++ .../agent/src/rfc64/inventory-v1/scalars.ts | 152 ++++ packages/agent/src/rfc64/inventory-v1/sql.ts | 270 +++++++ .../test/rfc64-inventory-v1-lifecycle.test.ts | 419 ++++++++++ .../test/rfc64-inventory-v1-scalars.test.ts | 131 ++++ packages/agent/vitest.unit.config.ts | 2 + 7 files changed, 1718 insertions(+) create mode 100644 packages/agent/src/rfc64/inventory-v1/index.ts create mode 100644 packages/agent/src/rfc64/inventory-v1/open.ts create mode 100644 packages/agent/src/rfc64/inventory-v1/scalars.ts create mode 100644 packages/agent/src/rfc64/inventory-v1/sql.ts create mode 100644 packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts create mode 100644 packages/agent/test/rfc64-inventory-v1-scalars.test.ts diff --git a/packages/agent/src/rfc64/inventory-v1/index.ts b/packages/agent/src/rfc64/inventory-v1/index.ts new file mode 100644 index 0000000000..0ccfe1677c --- /dev/null +++ b/packages/agent/src/rfc64/inventory-v1/index.ts @@ -0,0 +1,3 @@ +export * from './open.js'; +export * from './scalars.js'; +export * from './sql.js'; diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts new file mode 100644 index 0000000000..2dd94be485 --- /dev/null +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -0,0 +1,741 @@ +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; + +import { + INVENTORY_V1_APPLICATION_ID, + INVENTORY_V1_DDL, + INVENTORY_V1_DIRECTORY_MODE, + INVENTORY_V1_FILE_MODE, + INVENTORY_V1_RELATIVE_PATH, + INVENTORY_V1_USER_OBJECTS, + INVENTORY_V1_USER_VERSION, + normalizeInventoryV1SchemaSql, +} from './sql.js'; + +type SqliteModuleV1 = typeof import('node:sqlite'); +type DatabaseSyncV1 = InstanceType; + +const RECOVERY_MARKER_SUFFIX = '.rebuild-required'; +const QUARANTINE_DIRECTORY = 'quarantine'; +const OWNED_FILE_SUFFIXES = ['', '-wal', '-shm'] as const; + +export type InventoryV1OpenErrorCode = + | 'sqlite-unavailable' + | 'foreign-database' + | 'newer-schema' + | 'ambiguous-database' + | 'unsafe-path' + | 'pragma-mismatch' + | 'database-busy' + | 'database-closed' + | 'database-io'; + +export class InventoryV1OpenError extends Error { + constructor( + readonly code: InventoryV1OpenErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'InventoryV1OpenError'; + } +} + +export interface Rfc64InventoryV1Foundation { + readonly databasePath: string; + readonly closed: boolean; + quarantineAndRebuild(): void; + close(): void; +} + +export async function openInventoryV1(dataDir: string): Promise { + const sqlite = await loadSqliteModule(); + const databasePath = resolve(dataDir, INVENTORY_V1_RELATIVE_PATH); + try { + prepareSecureDirectory(dirname(databasePath)); + finishPendingQuarantine(databasePath); + const database = openOrRebuildOwnedDatabase(sqlite, databasePath); + return new InventoryV1Foundation(sqlite, databasePath, database); + } catch (cause) { + if (cause instanceof InventoryV1OpenError) throw cause; + throw new InventoryV1OpenError( + 'database-io', + 'failed to prepare or open the RFC-64 inventory database', + { cause }, + ); + } +} + +class InventoryV1Foundation implements Rfc64InventoryV1Foundation { + #database: DatabaseSyncV1 | null; + + constructor( + private readonly sqlite: SqliteModuleV1, + readonly databasePath: string, + database: DatabaseSyncV1, + ) { + this.#database = database; + } + + get closed(): boolean { + return this.#database === null; + } + + quarantineAndRebuild(): void { + const database = this.requireOpen(); + assertDatabaseQuiescent(database); + database.close(); + this.#database = null; + try { + beginQuarantine(this.databasePath); + finishPendingQuarantine(this.databasePath); + this.#database = openOrRebuildOwnedDatabase(this.sqlite, this.databasePath); + } catch (error) { + throw new InventoryV1OpenError( + 'database-io', + 'failed to quarantine and rebuild the RFC-64 inventory database', + { cause: error }, + ); + } + } + + close(): void { + this.#database?.close(); + this.#database = null; + } + + private requireOpen(): DatabaseSyncV1 { + if (this.#database === null) { + throw new InventoryV1OpenError('database-closed', 'inventory database is closed'); + } + return this.#database; + } +} + +async function loadSqliteModule(): Promise { + try { + const moduleName = 'node:sqlite'; + return await import(moduleName); + } catch (cause) { + throw new InventoryV1OpenError( + 'sqlite-unavailable', + 'RFC-64 SQL-1 requires Node runtime support for node:sqlite', + { cause }, + ); + } +} + +function openOrRebuildOwnedDatabase( + sqlite: SqliteModuleV1, + databasePath: string, +): DatabaseSyncV1 { + rejectOwnedFileSymlinks(databasePath); + rejectOrphanedSidecars(databasePath); + const existed = existsSync(databasePath); + if (existed) { + refuseValidForeignSqliteHeader(databasePath); + assertOwnedUnitOwners(databasePath); + } + if (!existed) createSecureEmptyFile(databasePath); + + let database: DatabaseSyncV1 | null = null; + try { + // Keep SQL-1 on the original Node 22.5 node:sqlite surface. Loading + // extensions is disabled by default; every statement below is fixed SQL, + // so double-quoted-string compatibility cannot affect parsing. + database = new sqlite.DatabaseSync(databasePath); + database.exec(` + PRAGMA foreign_keys = ON; + PRAGMA trusted_schema = OFF; + PRAGMA busy_timeout = 5000; + `); + const identity = readIdentity(database); + + if (isFreshIdentity(identity)) { + if (identity.userObjects.length !== 0) { + database.close(); + database = null; + throw new InventoryV1OpenError( + 'ambiguous-database', + 'application_id=0/user_version=0 database contains user objects and will not be modified', + ); + } + tightenOwnedFileMode(databasePath); + applyAndVerifyPragmas(database); + initializeFreshDatabase(database); + verifyOwnedSchema(database); + tightenOwnedFileMode(databasePath); + return database; + } + + if (identity.applicationId !== INVENTORY_V1_APPLICATION_ID) { + database.close(); + database = null; + throw new InventoryV1OpenError( + identity.applicationId === 0 ? 'ambiguous-database' : 'foreign-database', + 'database application_id does not identify RFC-64 SQL-1 and will not be modified', + ); + } + if (identity.userVersion > INVENTORY_V1_USER_VERSION) { + database.close(); + database = null; + throw new InventoryV1OpenError( + 'newer-schema', + `inventory user_version ${identity.userVersion} is newer than supported version 1`, + ); + } + + tightenOwnedFileMode(databasePath); + if (identity.userVersion !== INVENTORY_V1_USER_VERSION || !schemaMatches(identity.userObjects)) { + assertDatabaseQuiescent(database); + database.close(); + database = null; + beginQuarantine(databasePath); + finishPendingQuarantine(databasePath); + return openOrRebuildOwnedDatabase(sqlite, databasePath); + } + + applyAndVerifyPragmas(database); + try { + verifyOwnedSchema(database); + } catch (error) { + if (!(error instanceof OwnedInventoryV1SchemaError)) throw error; + assertDatabaseQuiescent(database); + database.close(); + database = null; + beginQuarantine(databasePath); + finishPendingQuarantine(databasePath); + return openOrRebuildOwnedDatabase(sqlite, databasePath); + } + tightenOwnedFileMode(databasePath); + return database; + } catch (error) { + if (database !== null) { + try { database.close(); } catch { /* retain the original failure */ } + } + if (error instanceof InventoryV1OpenError) throw error; + if (isCorruptSqliteError(error)) { + const ownership = classifyCorruptDatabaseOwnership(databasePath); + if (ownership === 'owned') { + beginQuarantine(databasePath); + finishPendingQuarantine(databasePath); + return openOrRebuildOwnedDatabase(sqlite, databasePath); + } + throw new InventoryV1OpenError( + 'foreign-database', + 'corrupt SQLite database has a foreign application_id and will not be modified', + { cause: error }, + ); + } + if (isBusySqliteError(error)) { + throw new InventoryV1OpenError( + 'database-busy', + 'inventory database is busy or locked and will not be quarantined', + { cause: error }, + ); + } + throw new InventoryV1OpenError( + 'database-io', + existed ? 'failed to open RFC-64 inventory database' : 'failed to initialize RFC-64 inventory database', + { cause: error }, + ); + } +} + +interface DatabaseIdentityV1 { + applicationId: number; + userVersion: number; + userObjects: Array<{ name: string; sql: string | null }>; +} + +function readIdentity(database: DatabaseSyncV1): DatabaseIdentityV1 { + return { + applicationId: readPragmaInteger(database, 'application_id'), + userVersion: readPragmaInteger(database, 'user_version'), + userObjects: database.prepare( + `SELECT name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name`, + ).all().map((row) => ({ + name: assertString(row.name, 'sqlite_schema.name'), + sql: row.sql === null ? null : assertString(row.sql, 'sqlite_schema.sql'), + })), + }; +} + +function isFreshIdentity(identity: DatabaseIdentityV1): boolean { + return identity.applicationId === 0 && identity.userVersion === 0; +} + +function schemaMatches(objects: DatabaseIdentityV1['userObjects']): boolean { + if (objects.length !== Object.keys(INVENTORY_V1_USER_OBJECTS).length) return false; + return objects.every((object) => { + const expected = INVENTORY_V1_USER_OBJECTS[object.name]; + return expected !== undefined + && object.sql !== null + && normalizeInventoryV1SchemaSql(object.sql) === expected; + }); +} + +function initializeFreshDatabase(database: DatabaseSyncV1): void { + database.exec('BEGIN IMMEDIATE'); + try { + database.exec(INVENTORY_V1_DDL); + database.exec(`PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}`); + database.exec(`PRAGMA user_version = ${INVENTORY_V1_USER_VERSION}`); + database.exec('COMMIT'); + } catch (error) { + try { database.exec('ROLLBACK'); } catch { /* retain the initialization error */ } + throw error; + } +} + +class OwnedInventoryV1SchemaError extends Error { + constructor(message: string) { + super(message); + this.name = 'OwnedInventoryV1SchemaError'; + } +} + +function verifyOwnedSchema(database: DatabaseSyncV1): void { + const identity = readIdentity(database); + if ( + identity.applicationId !== INVENTORY_V1_APPLICATION_ID + || identity.userVersion !== INVENTORY_V1_USER_VERSION + || !schemaMatches(identity.userObjects) + ) { + throw new OwnedInventoryV1SchemaError('RFC-64 inventory schema verification failed'); + } + const foreignKeyRows = database.prepare('PRAGMA foreign_key_check').all(); + if (foreignKeyRows.length !== 0) { + throw new OwnedInventoryV1SchemaError('RFC-64 inventory foreign-key check failed'); + } +} + +function assertDatabaseQuiescent(database: DatabaseSyncV1): void { + let transactionOpen = false; + try { + database.exec('PRAGMA busy_timeout = 0'); + const checkpoint = database.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get(); + const busy = checkpoint?.busy; + if (typeof busy !== 'number' || busy !== 0) { + throw new InventoryV1OpenError( + busy === 1 ? 'database-busy' : 'database-io', + busy === 1 + ? 'inventory database has active WAL readers and will not be quarantined' + : 'SQLite returned an invalid WAL checkpoint result', + ); + } + database.exec('BEGIN EXCLUSIVE'); + transactionOpen = true; + database.exec('ROLLBACK'); + transactionOpen = false; + } catch (cause) { + if (transactionOpen) { + try { database.exec('ROLLBACK'); } catch { /* retain the quiescence failure */ } + } + if (cause instanceof InventoryV1OpenError) throw cause; + if (isBusySqliteError(cause)) { + throw new InventoryV1OpenError( + 'database-busy', + 'inventory database is busy and will not be quarantined', + { cause }, + ); + } + throw new InventoryV1OpenError( + 'database-io', + 'could not prove exclusive access; inventory database will not be quarantined', + { cause }, + ); + } finally { + try { database.exec('PRAGMA busy_timeout = 5000'); } catch { /* best-effort connection restore */ } + } +} + +function applyAndVerifyPragmas(database: DatabaseSyncV1): void { + database.exec(` + PRAGMA foreign_keys = ON; + PRAGMA trusted_schema = OFF; + PRAGMA synchronous = FULL; + PRAGMA busy_timeout = 5000; + PRAGMA journal_size_limit = 67108864; + `); + const journalMode = database.prepare('PRAGMA journal_mode = WAL').get(); + if (journalMode === undefined || String(journalMode.journal_mode).toLowerCase() !== 'wal') { + throw new InventoryV1OpenError('pragma-mismatch', 'SQLite refused journal_mode=WAL'); + } + const expected = new Map([ + ['foreign_keys', 1], + ['trusted_schema', 0], + ['synchronous', 2], + ['busy_timeout', 5000], + ['journal_size_limit', 67_108_864], + ]); + for (const [pragma, expectedValue] of expected) { + if (readPragmaInteger(database, pragma) !== expectedValue) { + throw new InventoryV1OpenError('pragma-mismatch', `SQLite refused PRAGMA ${pragma}=${expectedValue}`); + } + } +} + +function readPragmaInteger(database: DatabaseSyncV1, pragma: string): number { + const row = database.prepare(`PRAGMA ${pragma}`).get(); + if (row === undefined) throw new InventoryV1OpenError('database-io', `PRAGMA ${pragma} returned no row`); + const value = Object.values(row)[0]; + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + throw new InventoryV1OpenError('database-io', `PRAGMA ${pragma} returned a non-integer value`); + } + return value; +} + +function prepareSecureDirectory(directoryPath: string): void { + if (pathEntryExists(directoryPath)) rejectSymlink(directoryPath, 'inventory directory'); + mkdirSync(directoryPath, { recursive: true, mode: INVENTORY_V1_DIRECTORY_MODE }); + rejectSymlink(directoryPath, 'inventory directory'); + applySecurePermissions(directoryPath, INVENTORY_V1_DIRECTORY_MODE, true); +} + +function createSecureEmptyFile(databasePath: string): void { + const descriptor = openSync(databasePath, 'wx', INVENTORY_V1_FILE_MODE); + try { fsyncSync(descriptor); } finally { closeSync(descriptor); } + applySecurePermissions(databasePath, INVENTORY_V1_FILE_MODE, false); +} + +function tightenOwnedFileMode(databasePath: string): void { + applySecurePermissions(databasePath, INVENTORY_V1_FILE_MODE, false); +} + +function assertFilesystemOwner(path: string): void { + if (process.platform === 'win32') { + const script = String.raw` +$ErrorActionPreference = 'Stop' +$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$owner = (Get-Acl -LiteralPath $env:DKG_RFC64_ACL_PATH).GetOwner([System.Security.Principal.SecurityIdentifier]) +if ($owner.Value -ne $sid.Value) { exit 40 } +`; + const result = spawnSync( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { + encoding: 'utf8', + windowsHide: true, + env: { ...process.env, DKG_RFC64_ACL_PATH: path }, + }, + ); + if (result.error !== undefined || result.status !== 0) { + throw new InventoryV1OpenError( + 'database-io', + `inventory path is not owned by the current Windows identity: ${path}`, + { cause: result.error ?? new Error(result.stderr.trim() || `PowerShell exited ${result.status}`) }, + ); + } + return; + } + try { + const processUid = process.getuid?.(); + if (processUid !== undefined && statSync(path).uid !== processUid) { + throw new Error('filesystem entry is not owned by the current process uid'); + } + } catch (cause) { + throw new InventoryV1OpenError( + 'database-io', + `inventory path is not owned by the current process: ${path}`, + { cause }, + ); + } +} + +function assertOwnedUnitOwners(databasePath: string): void { + for (const suffix of OWNED_FILE_SUFFIXES) { + const path = `${databasePath}${suffix}`; + if (pathEntryExists(path)) assertFilesystemOwner(path); + } +} + +function applySecurePermissions(path: string, mode: number, directory: boolean): void { + if (process.platform === 'win32') { + applyWindowsOwnerOnlyAcl(path, directory); + return; + } + try { + chmodSync(path, mode); + const stat = statSync(path); + const processUid = process.getuid?.(); + if (processUid !== undefined && stat.uid !== processUid) { + throw new Error(`path owner uid ${stat.uid} does not match process uid ${processUid}`); + } + if ((stat.mode & 0o777) !== mode) { + throw new Error(`path mode ${(stat.mode & 0o777).toString(8)} does not match ${mode.toString(8)}`); + } + } catch (cause) { + throw new InventoryV1OpenError('database-io', `failed to set secure permissions on ${path}`, { cause }); + } +} + +function applyWindowsOwnerOnlyAcl(path: string, directory: boolean): void { + const script = String.raw` +$ErrorActionPreference = 'Stop' +$target = $env:DKG_RFC64_ACL_PATH +$isDirectory = [System.Convert]::ToBoolean($env:DKG_RFC64_ACL_DIRECTORY) +$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$acl = if ($isDirectory) { + [System.Security.AccessControl.DirectorySecurity]::new() +} else { + [System.Security.AccessControl.FileSecurity]::new() +} +$acl.SetOwner($sid) +$acl.SetAccessRuleProtection($true, $false) +$inheritance = if ($isDirectory) { + [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' +} else { + [System.Security.AccessControl.InheritanceFlags]::None +} +$rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow +) +$acl.AddAccessRule($rule) +Set-Acl -LiteralPath $target -AclObject $acl +$verified = Get-Acl -LiteralPath $target +$rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) +if (-not $verified.AreAccessRulesProtected -or $rules.Count -ne 1) { exit 41 } +if ($rules[0].IdentityReference.Value -ne $sid.Value) { exit 42 } +if ($rules[0].AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { exit 43 } +if (($rules[0].FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { exit 44 } +`; + const result = spawnSync( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { + encoding: 'utf8', + windowsHide: true, + env: { + ...process.env, + DKG_RFC64_ACL_PATH: path, + DKG_RFC64_ACL_DIRECTORY: String(directory), + }, + }, + ); + if (result.error !== undefined || result.status !== 0) { + throw new InventoryV1OpenError( + 'database-io', + `failed to establish owner-only Windows ACL on ${path}`, + { cause: result.error ?? new Error(result.stderr.trim() || `PowerShell exited ${result.status}`) }, + ); + } +} + +function rejectOwnedFileSymlinks(databasePath: string): void { + for (const suffix of OWNED_FILE_SUFFIXES) { + const path = `${databasePath}${suffix}`; + if (pathEntryExists(path)) rejectSymlink(path, `inventory database${suffix}`); + } +} + +function rejectOrphanedSidecars(databasePath: string): void { + if (existsSync(databasePath)) return; + if (pathEntryExists(`${databasePath}-wal`) || pathEntryExists(`${databasePath}-shm`)) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'orphaned inventory sidecars exist without a database and will not be modified', + ); + } +} + +function rejectSymlink(path: string, label: string): void { + if (lstatSync(path).isSymbolicLink()) { + throw new InventoryV1OpenError('unsafe-path', `${label} must not be a symbolic link`); + } +} + +function pathEntryExists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw new InventoryV1OpenError('database-io', `failed to inspect filesystem entry ${path}`, { cause }); + } +} + +interface RecoveryMarkerV1 { + version: 1; + quarantineDirectory: string; +} + +function beginQuarantine(databasePath: string): void { + const markerPath = recoveryMarkerPath(databasePath); + if (pathEntryExists(markerPath)) { + rejectSymlink(markerPath, 'inventory recovery marker'); + return; + } + const quarantineRoot = join(dirname(databasePath), QUARANTINE_DIRECTORY); + if (pathEntryExists(quarantineRoot)) rejectSymlink(quarantineRoot, 'inventory quarantine directory'); + mkdirSync(quarantineRoot, { recursive: true, mode: INVENTORY_V1_DIRECTORY_MODE }); + rejectSymlink(quarantineRoot, 'inventory quarantine directory'); + applySecurePermissions(quarantineRoot, INVENTORY_V1_DIRECTORY_MODE, true); + const suffix = `${Date.now()}-${randomBytes(8).toString('hex')}`; + const quarantineDirectory = join(quarantineRoot, `inventory-v1-${suffix}`); + mkdirSync(quarantineDirectory, { mode: INVENTORY_V1_DIRECTORY_MODE }); + rejectSymlink(quarantineDirectory, 'inventory quarantine generation'); + applySecurePermissions(quarantineDirectory, INVENTORY_V1_DIRECTORY_MODE, true); + const marker: RecoveryMarkerV1 = { version: 1, quarantineDirectory }; + writeFileSync(markerPath, JSON.stringify(marker), { encoding: 'utf8', flag: 'wx', mode: INVENTORY_V1_FILE_MODE }); + applySecurePermissions(markerPath, INVENTORY_V1_FILE_MODE, false); + const descriptor = openSync(markerPath, 'r'); + try { fsyncSync(descriptor); } finally { closeSync(descriptor); } + fsyncDirectory(dirname(databasePath)); +} + +function finishPendingQuarantine(databasePath: string): void { + const markerPath = recoveryMarkerPath(databasePath); + if (!pathEntryExists(markerPath)) return; + rejectSymlink(markerPath, 'inventory recovery marker'); + assertFilesystemOwner(markerPath); + const inventoryDirectory = dirname(databasePath); + const quarantineRoot = join(inventoryDirectory, QUARANTINE_DIRECTORY); + if (pathEntryExists(quarantineRoot)) { + rejectSymlink(quarantineRoot, 'inventory quarantine directory'); + assertFilesystemOwner(quarantineRoot); + } + const marker = parseRecoveryMarker(readFileSync(markerPath, 'utf8'), inventoryDirectory); + mkdirSync(marker.quarantineDirectory, { recursive: true, mode: INVENTORY_V1_DIRECTORY_MODE }); + rejectSymlink(quarantineRoot, 'inventory quarantine directory'); + applySecurePermissions(quarantineRoot, INVENTORY_V1_DIRECTORY_MODE, true); + rejectSymlink(marker.quarantineDirectory, 'inventory quarantine generation'); + assertFilesystemOwner(marker.quarantineDirectory); + applySecurePermissions(marker.quarantineDirectory, INVENTORY_V1_DIRECTORY_MODE, true); + for (const suffix of OWNED_FILE_SUFFIXES) { + const source = `${databasePath}${suffix}`; + if (!pathEntryExists(source)) continue; + rejectSymlink(source, `inventory database${suffix}`); + assertFilesystemOwner(source); + const target = join(marker.quarantineDirectory, `inventory-v1.sqlite3${suffix}`); + if (pathEntryExists(target)) { + throw new InventoryV1OpenError('database-io', `quarantine target already exists: ${target}`); + } + renameSync(source, target); + } + fsyncDirectory(marker.quarantineDirectory); + unlinkSync(markerPath); + fsyncDirectory(dirname(databasePath)); +} + +function parseRecoveryMarker(value: string, inventoryDirectory: string): RecoveryMarkerV1 { + let parsed: unknown; + try { parsed = JSON.parse(value); } catch (cause) { + throw new InventoryV1OpenError('database-io', 'inventory recovery marker is malformed', { cause }); + } + if ( + typeof parsed !== 'object' + || parsed === null + || (parsed as { version?: unknown }).version !== 1 + || typeof (parsed as { quarantineDirectory?: unknown }).quarantineDirectory !== 'string' + ) { + throw new InventoryV1OpenError('database-io', 'inventory recovery marker has an invalid shape'); + } + const quarantineDirectory = resolve((parsed as RecoveryMarkerV1).quarantineDirectory); + const quarantineRoot = resolve(inventoryDirectory, QUARANTINE_DIRECTORY); + const relativePath = relative(quarantineRoot, quarantineDirectory); + if ( + relativePath.length === 0 + || relativePath.startsWith('..') + || isAbsolute(relativePath) + || basename(relativePath) !== relativePath + || !/^inventory-v1-[0-9]+-[0-9a-f]{16}$/.test(relativePath) + ) { + throw new InventoryV1OpenError('unsafe-path', 'inventory recovery marker escapes the quarantine directory'); + } + return { version: 1, quarantineDirectory }; +} + +function recoveryMarkerPath(databasePath: string): string { + return `${databasePath}${RECOVERY_MARKER_SUFFIX}`; +} + +function isCorruptSqliteError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const errcode = (error as { errcode?: unknown }).errcode; + return errcode === 11 || errcode === 26; +} + +function isBusySqliteError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const errcode = (error as { errcode?: unknown }).errcode; + return errcode === 5 || errcode === 6; +} + +type CorruptDatabaseOwnershipV1 = 'owned' | 'foreign'; + +function classifyCorruptDatabaseOwnership(databasePath: string): CorruptDatabaseOwnershipV1 { + const applicationId = readValidSqliteHeaderApplicationId(databasePath); + if (applicationId === null || applicationId === INVENTORY_V1_APPLICATION_ID || applicationId === 0) { + return 'owned'; + } + return 'foreign'; +} + +function refuseValidForeignSqliteHeader(databasePath: string): void { + const applicationId = readValidSqliteHeaderApplicationId(databasePath); + if ( + applicationId !== null + && applicationId !== 0 + && applicationId !== INVENTORY_V1_APPLICATION_ID + ) { + throw new InventoryV1OpenError( + 'foreign-database', + 'database header has a foreign application_id and will not be opened or modified', + ); + } +} + +function readValidSqliteHeaderApplicationId(databasePath: string): number | null { + let descriptor: number | undefined; + try { + descriptor = openSync(databasePath, 'r'); + const header = Buffer.alloc(100); + const bytesRead = readSync(descriptor, header, 0, header.byteLength, 0); + if (bytesRead < header.byteLength || header.subarray(0, 16).toString('binary') !== 'SQLite format 3\u0000') { + return null; + } + return header.readUInt32BE(68); + } catch (cause) { + throw new InventoryV1OpenError( + 'database-io', + 'failed to read the inventory database header; it will not be quarantined', + { cause }, + ); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function fsyncDirectory(path: string): void { + if (process.platform === 'win32') return; + const descriptor = openSync(path, 'r'); + try { fsyncSync(descriptor); } finally { closeSync(descriptor); } +} + +function assertString(value: unknown, label: string): string { + if (typeof value !== 'string') { + throw new InventoryV1OpenError('database-io', `${label} is not text`); + } + return value; +} diff --git a/packages/agent/src/rfc64/inventory-v1/scalars.ts b/packages/agent/src/rfc64/inventory-v1/scalars.ts new file mode 100644 index 0000000000..cdb6c44680 --- /dev/null +++ b/packages/agent/src/rfc64/inventory-v1/scalars.ts @@ -0,0 +1,152 @@ +import { + MAX_DECIMAL_U64, + MAX_DECIMAL_U256, + assertCanonicalDecimalU64, + assertCanonicalDecimalU256, + assertCanonicalDigest, + type DecimalU64V1, + type DecimalU256V1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +const EVM_ADDRESS_V1 = /^0x[0-9a-f]{40}$/; +const ZERO_EVM_ADDRESS_V1 = `0x${'00'.repeat(20)}`; + +export class InventoryV1ScalarError extends Error { + constructor(message: string) { + super(message); + this.name = 'InventoryV1ScalarError'; + } +} + +export function decimalU64ToSqlBlobV1(value: DecimalU64V1): Uint8Array { + assertCanonicalDecimalU64(value, 'u64'); + return unsignedBigIntToFixedWidth(BigInt(value), 8, MAX_DECIMAL_U64, 'u64'); +} + +export function decimalU256ToSqlBlobV1(value: DecimalU256V1): Uint8Array { + assertCanonicalDecimalU256(value, 'u256'); + return unsignedBigIntToFixedWidth(BigInt(value), 32, MAX_DECIMAL_U256, 'u256'); +} + +export function sqlBlobToDecimalU64V1(value: unknown): DecimalU64V1 { + const decoded = fixedWidthToUnsignedBigInt(value, 8, 'u64').toString(); + assertCanonicalDecimalU64(decoded, 'u64'); + return decoded as DecimalU64V1; +} + +export function sqlBlobToDecimalU256V1(value: unknown): DecimalU256V1 { + const decoded = fixedWidthToUnsignedBigInt(value, 32, 'u256').toString(); + assertCanonicalDecimalU256(decoded, 'u256'); + return decoded as DecimalU256V1; +} + +export function digest32ToSqlBlobV1(value: Digest32V1): Uint8Array { + assertCanonicalDigest(value, 'digest'); + return lowerHexToBytes(value.slice(2), 32, 'digest'); +} + +export function sqlBlobToDigest32V1(value: unknown): Digest32V1 { + const digest = `0x${bytesToLowerHex(assertSqlBlobWidthV1(value, 32, 'digest'))}`; + assertCanonicalDigest(digest, 'digest'); + return digest; +} + +export function evmAddressToSqlBlobV1(value: EvmAddressV1): Uint8Array { + assertEvmAddressV1(value); + return lowerHexToBytes(value.slice(2), 20, 'address'); +} + +export function sqlBlobToEvmAddressV1(value: unknown): EvmAddressV1 { + const address = `0x${bytesToLowerHex(assertSqlBlobWidthV1(value, 20, 'address'))}`; + assertEvmAddressV1(address); + return address; +} + +export function nullableIdentifierToSqlTextV1( + value: string | null, + assertIdentifier: (candidate: unknown) => void, +): string | null { + if (value === null) return null; + assertIdentifier(value); + if (value.normalize('NFC') !== value) { + throw new InventoryV1ScalarError('identifier must already be NFC normalized'); + } + return value; +} + +export function assertSqlBlobWidthV1( + value: unknown, + width: number, + label: string, +): Uint8Array { + if (!(value instanceof Uint8Array) || value.byteLength !== width) { + throw new InventoryV1ScalarError(`${label} must be an exact ${width}-byte SQL BLOB`); + } + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); +} + +export function sqlBlobsEqualV1(left: unknown, right: unknown): boolean { + if (!(left instanceof Uint8Array)) return false; + if (!(right instanceof Uint8Array)) return false; + if (left.byteLength !== right.byteLength) return false; + const leftBytes = new Uint8Array(left.buffer, left.byteOffset, left.byteLength); + const rightBytes = new Uint8Array(right.buffer, right.byteOffset, right.byteLength); + for (let index = 0; index < leftBytes.byteLength; index += 1) { + if (leftBytes[index] !== rightBytes[index]) return false; + } + return true; +} + +function unsignedBigIntToFixedWidth( + value: bigint, + width: number, + maximum: bigint, + label: string, +): Uint8Array { + if (value < 0n || value > maximum) { + throw new InventoryV1ScalarError(`${label} is outside its unsigned range`); + } + const result = new Uint8Array(width); + let remaining = value; + for (let index = width - 1; index >= 0; index -= 1) { + result[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return result; +} + +function fixedWidthToUnsignedBigInt(value: unknown, width: number, label: string): bigint { + const bytes = assertSqlBlobWidthV1(value, width, label); + let result = 0n; + for (const byte of bytes) result = (result << 8n) | BigInt(byte); + return result; +} + +function lowerHexToBytes(value: string, width: number, label: string): Uint8Array { + if (value.length !== width * 2 || !/^[0-9a-f]+$/.test(value)) { + throw new InventoryV1ScalarError(`${label} must be ${width} lowercase hex bytes`); + } + const result = new Uint8Array(width); + for (let index = 0; index < width; index += 1) { + result[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return result; +} + +function bytesToLowerHex(value: Uint8Array): string { + let result = ''; + for (const byte of value) result += byte.toString(16).padStart(2, '0'); + return result; +} + +function assertEvmAddressV1(value: unknown): asserts value is EvmAddressV1 { + if ( + typeof value !== 'string' + || !EVM_ADDRESS_V1.test(value) + || value === ZERO_EVM_ADDRESS_V1 + ) { + throw new InventoryV1ScalarError('address must be a lowercase nonzero 20-byte EVM address'); + } +} diff --git a/packages/agent/src/rfc64/inventory-v1/sql.ts b/packages/agent/src/rfc64/inventory-v1/sql.ts new file mode 100644 index 0000000000..b4ca910059 --- /dev/null +++ b/packages/agent/src/rfc64/inventory-v1/sql.ts @@ -0,0 +1,270 @@ +export const INVENTORY_V1_APPLICATION_ID = 0x444b3634; +export const INVENTORY_V1_USER_VERSION = 1; +export const INVENTORY_V1_RELATIVE_PATH = 'rfc64-sync/inventory-v1.sqlite3'; +export const INVENTORY_V1_DIRECTORY_MODE = 0o700; +export const INVENTORY_V1_FILE_MODE = 0o600; + +export const INVENTORY_V1_LOADS_TABLE_SQL = ` +CREATE TABLE rfc64_candidate_bucket_loads_v1 ( + session_id BLOB NOT NULL + CHECK ( + typeof(session_id) = 'blob' + AND length(session_id) = 32 + AND session_id <> zeroblob(32) + ), + + catalog_scope_digest BLOB NOT NULL + CHECK ( + typeof(catalog_scope_digest) = 'blob' + AND length(catalog_scope_digest) = 32 + ), + + author_address BLOB NOT NULL + CHECK ( + typeof(author_address) = 'blob' + AND length(author_address) = 20 + AND author_address <> zeroblob(20) + ), + + target_catalog_head_digest BLOB NOT NULL + CHECK ( + typeof(target_catalog_head_digest) = 'blob' + AND length(target_catalog_head_digest) = 32 + ), + + subgraph_name TEXT + CHECK ( + subgraph_name IS NULL + OR (typeof(subgraph_name) = 'text' AND length(subgraph_name) > 0) + ), + + catalog_era_u64be BLOB NOT NULL + CHECK ( + typeof(catalog_era_u64be) = 'blob' + AND length(catalog_era_u64be) = 8 + ), + + bucket_count_u64be BLOB NOT NULL CHECK ( + typeof(bucket_count_u64be) = 'blob' AND length(bucket_count_u64be) = 8 + AND bucket_count_u64be >= x'0000000000000001' AND bucket_count_u64be <= x'8000000000000000' + ), + + bucket_id_u64be BLOB NOT NULL CHECK ( + typeof(bucket_id_u64be) = 'blob' AND length(bucket_id_u64be) = 8 AND bucket_id_u64be < bucket_count_u64be + ), + + bucket_object_digest BLOB NOT NULL + CHECK ( + typeof(bucket_object_digest) = 'blob' + AND length(bucket_object_digest) = 32 + ), + + row_count_u64be BLOB NOT NULL + CHECK ( + typeof(row_count_u64be) = 'blob' + AND length(row_count_u64be) = 8 + ), + + payload_byte_length_u64be BLOB NOT NULL CHECK ( + typeof(payload_byte_length_u64be) = 'blob' AND length(payload_byte_length_u64be) = 8 + ), + + CHECK ( + ( + bucket_object_digest = zeroblob(32) + AND row_count_u64be = zeroblob(8) + AND payload_byte_length_u64be = zeroblob(8) + ) + OR + ( + bucket_object_digest <> zeroblob(32) + AND row_count_u64be >= x'0000000000000001' + AND row_count_u64be <= x'0000000000000400' + AND payload_byte_length_u64be >= x'0000000000000001' + AND payload_byte_length_u64be <= x'0000000000100000' + ) + ), + + PRIMARY KEY ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be + ) +) WITHOUT ROWID, STRICT`; + +export const INVENTORY_V1_ROWS_TABLE_SQL = ` +CREATE TABLE rfc64_candidate_bucket_rows_v1 ( + session_id BLOB NOT NULL + CHECK ( + typeof(session_id) = 'blob' + AND length(session_id) = 32 + AND session_id <> zeroblob(32) + ), + + catalog_scope_digest BLOB NOT NULL + CHECK ( + typeof(catalog_scope_digest) = 'blob' + AND length(catalog_scope_digest) = 32 + ), + + author_address BLOB NOT NULL + CHECK ( + typeof(author_address) = 'blob' + AND length(author_address) = 20 + AND author_address <> zeroblob(20) + ), + + target_catalog_head_digest BLOB NOT NULL + CHECK ( + typeof(target_catalog_head_digest) = 'blob' + AND length(target_catalog_head_digest) = 32 + ), + + bucket_id_u64be BLOB NOT NULL + CHECK ( + typeof(bucket_id_u64be) = 'blob' + AND length(bucket_id_u64be) = 8 + ), + + ka_id_u256be BLOB NOT NULL + CHECK ( + typeof(ka_id_u256be) = 'blob' + AND length(ka_id_u256be) = 32 + ), + + catalog_key_digest BLOB NOT NULL + CHECK ( + typeof(catalog_key_digest) = 'blob' + AND length(catalog_key_digest) = 32 + ), + + assertion_coordinate TEXT NOT NULL COLLATE BINARY + CHECK ( + typeof(assertion_coordinate) = 'text' + AND length(assertion_coordinate) > 0 + ), + + assertion_version_u64be BLOB NOT NULL + CHECK ( + typeof(assertion_version_u64be) = 'blob' + AND length(assertion_version_u64be) = 8 + ), + + projection_id TEXT NOT NULL + CHECK (projection_id = 'cg-shared-v1'), + + projection_digest BLOB NOT NULL + CHECK ( + typeof(projection_digest) = 'blob' + AND length(projection_digest) = 32 + ), + + seal_digest BLOB NOT NULL + CHECK ( + typeof(seal_digest) = 'blob' + AND length(seal_digest) = 32 + ), + + transfer_codec TEXT NOT NULL + CHECK (transfer_codec = 'dkg-ka-bundle-v1'), + + transfer_byte_length_u64be BLOB NOT NULL CHECK ( + typeof(transfer_byte_length_u64be) = 'blob' AND length(transfer_byte_length_u64be) = 8 + AND transfer_byte_length_u64be >= x'0000000000000010' AND transfer_byte_length_u64be <= x'0000000040000000' + ), + + transfer_chunk_size_u64be BLOB NOT NULL + CHECK ( + transfer_chunk_size_u64be = x'0000000000040000' + ), + + transfer_chunk_count_u64be BLOB NOT NULL CHECK ( + typeof(transfer_chunk_count_u64be) = 'blob' AND length(transfer_chunk_count_u64be) = 8 + AND transfer_chunk_count_u64be >= x'0000000000000001' AND transfer_chunk_count_u64be <= x'0000000000001000' + ), + + transfer_blob_digest BLOB NOT NULL + CHECK ( + typeof(transfer_blob_digest) = 'blob' + AND length(transfer_blob_digest) = 32 + ), + + transfer_chunk_tree_root BLOB NOT NULL + CHECK ( + typeof(transfer_chunk_tree_root) = 'blob' + AND length(transfer_chunk_tree_root) = 32 + ), + + expected_catalog_row_digest BLOB NOT NULL CHECK ( + typeof(expected_catalog_row_digest) = 'blob' AND length(expected_catalog_row_digest) = 32 + ), + + PRIMARY KEY ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + ka_id_u256be + ), + + UNIQUE ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + catalog_key_digest + ), + + UNIQUE ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + assertion_coordinate + ), + + FOREIGN KEY ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be + ) + REFERENCES rfc64_candidate_bucket_loads_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be + ) + ON DELETE CASCADE +) WITHOUT ROWID, STRICT`; + +export const INVENTORY_V1_BUCKET_INDEX_SQL = ` +CREATE INDEX rfc64_candidate_bucket_rows_by_bucket_v1 +ON rfc64_candidate_bucket_rows_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + ka_id_u256be +)`; + +export const INVENTORY_V1_DDL = [ + INVENTORY_V1_LOADS_TABLE_SQL, + INVENTORY_V1_ROWS_TABLE_SQL, + INVENTORY_V1_BUCKET_INDEX_SQL, +].join(';\n\n').concat(';'); + +export const INVENTORY_V1_USER_OBJECTS: Readonly> = Object.freeze({ + rfc64_candidate_bucket_loads_v1: normalizeInventoryV1SchemaSql(INVENTORY_V1_LOADS_TABLE_SQL), + rfc64_candidate_bucket_rows_v1: normalizeInventoryV1SchemaSql(INVENTORY_V1_ROWS_TABLE_SQL), + rfc64_candidate_bucket_rows_by_bucket_v1: normalizeInventoryV1SchemaSql(INVENTORY_V1_BUCKET_INDEX_SQL), +}); + +export function normalizeInventoryV1SchemaSql(sql: string): string { + return sql.replace(/\s+/g, ' ').trim().replace(/;$/, ''); +} diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts new file mode 100644 index 0000000000..79ee2ed048 --- /dev/null +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -0,0 +1,419 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + truncateSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + INVENTORY_V1_APPLICATION_ID, + INVENTORY_V1_DDL, + INVENTORY_V1_RELATIVE_PATH, + INVENTORY_V1_USER_OBJECTS, + InventoryV1OpenError, + normalizeInventoryV1SchemaSql, + openInventoryV1, +} from '../src/rfc64/inventory-v1/index.js'; + +const temporaryDirectories: string[] = []; + +function temporaryDataDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'dkg-rfc64-sql1-')); + temporaryDirectories.push(directory); + return directory; +} + +function databasePath(dataDirectory: string): string { + return join(dataDirectory, INVENTORY_V1_RELATIVE_PATH); +} + +function pragmaInteger(database: DatabaseSync, pragma: string): number { + const row = database.prepare(`PRAGMA ${pragma}`).get(); + if (row === undefined) throw new Error(`missing PRAGMA ${pragma}`); + const value = Object.values(row)[0]; + if (typeof value !== 'number') throw new Error(`invalid PRAGMA ${pragma}`); + return value; +} + +function expectOpenErrorCode(error: unknown, code: InventoryV1OpenError['code']): boolean { + expect(error).toBeInstanceOf(InventoryV1OpenError); + expect((error as InventoryV1OpenError).code).toBe(code); + return true; +} + +function expectNoQuarantine(path: string): void { + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + expect(existsSync(join(dirname(path), 'quarantine'))).toBe(false); +} + +function quarantineGenerations(path: string): string[] { + const root = join(dirname(path), 'quarantine'); + if (!existsSync(root)) return []; + return readdirSync(root).map((name) => join(root, name)); +} + +function assertInitializedInventory(path: string): void { + const database = new DatabaseSync(path, { readOnly: true }); + try { + expect(pragmaInteger(database, 'application_id')).toBe(INVENTORY_V1_APPLICATION_ID); + expect(pragmaInteger(database, 'user_version')).toBe(1); + const rows = database.prepare( + `SELECT name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name`, + ).all(); + expect(rows).toHaveLength(Object.keys(INVENTORY_V1_USER_OBJECTS).length); + for (const row of rows) { + expect(typeof row.name).toBe('string'); + expect(typeof row.sql).toBe('string'); + expect(normalizeInventoryV1SchemaSql(String(row.sql))).toBe( + INVENTORY_V1_USER_OBJECTS[String(row.name)], + ); + } + expect(database.prepare('PRAGMA journal_mode').get()?.journal_mode).toBe('wal'); + } finally { + database.close(); + } +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('RFC-64 inventory v1 SQLite lifecycle', () => { + it('initializes the exact owned schema, identity, WAL mode, and private POSIX paths', async () => { + const dataDirectory = temporaryDataDirectory(); + const foundation = await openInventoryV1(dataDirectory); + const path = databasePath(dataDirectory); + + expect(foundation.databasePath).toBe(path); + expect(foundation.closed).toBe(false); + assertInitializedInventory(path); + if (process.platform !== 'win32') { + expect(statSync(dirname(path)).mode & 0o777).toBe(0o700); + expect(statSync(path).mode & 0o777).toBe(0o600); + } + + foundation.close(); + foundation.close(); + expect(foundation.closed).toBe(true); + if (process.platform !== 'win32') chmodSync(path, 0o666); + + const reopened = await openInventoryV1(dataDirectory); + assertInitializedInventory(path); + if (process.platform !== 'win32') expect(statSync(path).mode & 0o777).toBe(0o600); + reopened.close(); + try { + reopened.quarantineAndRebuild(); + expect.unreachable('closed foundation unexpectedly rebuilt'); + } catch (error) { + expectOpenErrorCode(error, 'database-closed'); + } + }); + + it('executes the frozen DDL as STRICT tables with the named bucket index', () => { + const database = new DatabaseSync(':memory:'); + try { + database.exec('PRAGMA foreign_keys = ON'); + database.exec(INVENTORY_V1_DDL); + const objects = database.prepare( + `SELECT name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name`, + ).all(); + expect(objects).toHaveLength(3); + for (const object of objects) { + expect(normalizeInventoryV1SchemaSql(String(object.sql))).toBe( + INVENTORY_V1_USER_OBJECTS[String(object.name)], + ); + } + expect(database.prepare( + `SELECT strict FROM pragma_table_list WHERE name = 'rfc64_candidate_bucket_loads_v1'`, + ).get()?.strict).toBe(1); + expect(database.prepare( + `SELECT strict FROM pragma_table_list WHERE name = 'rfc64_candidate_bucket_rows_v1'`, + ).get()?.strict).toBe(1); + } finally { + database.close(); + } + }); + + it('refuses a valid foreign application_id without modifying or quarantining it', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const foreign = new DatabaseSync(path); + foreign.exec('CREATE TABLE foreign_data (value TEXT); PRAGMA application_id = 305419896;'); + foreign.close(); + if (process.platform !== 'win32') chmodSync(path, 0o640); + const before = readFileSync(path); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'foreign-database')); + + expect(readFileSync(path)).toEqual(before); + if (process.platform !== 'win32') expect(statSync(path).mode & 0o777).toBe(0o640); + expectNoQuarantine(path); + const check = new DatabaseSync(path, { readOnly: true }); + expect(check.prepare( + `SELECT count(*) AS count FROM sqlite_schema WHERE name = 'foreign_data'`, + ).get()?.count).toBe(1); + check.close(); + }); + + it('refuses an application_id=0 database with user objects as ambiguous', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const ambiguous = new DatabaseSync(path); + ambiguous.exec('CREATE TABLE existing_data (value TEXT)'); + ambiguous.close(); + if (process.platform !== 'win32') chmodSync(path, 0o640); + const before = readFileSync(path); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + expect(readFileSync(path)).toEqual(before); + if (process.platform !== 'win32') expect(statSync(path).mode & 0o777).toBe(0o640); + expectNoQuarantine(path); + }); + + it('refuses a newer owned user_version without quarantine', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const newer = new DatabaseSync(path); + newer.exec(` + CREATE TABLE future_schema (value TEXT); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 2; + `); + newer.close(); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'newer-schema')); + expectNoQuarantine(path); + }); + + it('quarantines an incompatible owned v1 schema and rebuilds exact v1', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const incompatible = new DatabaseSync(path); + incompatible.exec(` + CREATE TABLE wrong_v1 (value TEXT); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 1; + `); + incompatible.close(); + + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + const generations = quarantineGenerations(path); + expect(generations).toHaveLength(1); + const oldPath = join(generations[0]!, 'inventory-v1.sqlite3'); + const old = new DatabaseSync(oldPath, { readOnly: true }); + expect(old.prepare( + `SELECT count(*) AS count FROM sqlite_schema WHERE name = 'wrong_v1'`, + ).get()?.count).toBe(1); + old.close(); + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + } finally { + foundation.close(); + } + }); + + it('quarantines NOTADB bytes at the dedicated path and preserves them as evidence', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const corruptBytes = Buffer.from('not-a-sqlite-database\n'); + writeFileSync(path, corruptBytes); + + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + const generations = quarantineGenerations(path); + expect(generations).toHaveLength(1); + expect(readFileSync(join(generations[0]!, 'inventory-v1.sqlite3'))).toEqual(corruptBytes); + } finally { + foundation.close(); + } + }); + + it('does not quarantine a corrupt SQLite file whose readable header has a foreign app id', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const foreign = new DatabaseSync(path); + foreign.exec('CREATE TABLE foreign_data (value TEXT); PRAGMA application_id = 305419896;'); + foreign.close(); + truncateSync(path, 100); + const before = readFileSync(path); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'foreign-database')); + expect(readFileSync(path)).toEqual(before); + expectNoQuarantine(path); + }); + + it.runIf(process.platform !== 'win32')( + 'refuses database, sidecar, recovery-marker, and parent symlinks without quarantine', + async () => { + const databaseLinkData = temporaryDataDirectory(); + const databaseLinkPath = databasePath(databaseLinkData); + mkdirSync(dirname(databaseLinkPath), { recursive: true }); + const databaseTarget = join(databaseLinkData, 'target.sqlite3'); + writeFileSync(databaseTarget, 'target'); + symlinkSync(databaseTarget, databaseLinkPath); + await expect(openInventoryV1(databaseLinkData)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'unsafe-path')); + expect(readFileSync(databaseTarget, 'utf8')).toBe('target'); + expectNoQuarantine(databaseLinkPath); + + const danglingData = temporaryDataDirectory(); + const danglingPath = databasePath(danglingData); + mkdirSync(dirname(danglingPath), { recursive: true }); + symlinkSync(join(danglingData, 'missing-target'), danglingPath); + await expect(openInventoryV1(danglingData)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'unsafe-path')); + + const sidecarData = temporaryDataDirectory(); + const sidecarPath = databasePath(sidecarData); + mkdirSync(dirname(sidecarPath), { recursive: true }); + const database = new DatabaseSync(sidecarPath); + database.close(); + const sidecarTarget = join(sidecarData, 'sidecar-target'); + writeFileSync(sidecarTarget, 'sidecar'); + symlinkSync(sidecarTarget, `${sidecarPath}-wal`); + await expect(openInventoryV1(sidecarData)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'unsafe-path')); + expectNoQuarantine(sidecarPath); + + const directoryLinkData = temporaryDataDirectory(); + const directoryTarget = join(directoryLinkData, 'target-directory'); + mkdirSync(directoryTarget); + symlinkSync(directoryTarget, join(directoryLinkData, 'rfc64-sync')); + await expect(openInventoryV1(directoryLinkData)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'unsafe-path')); + expect(readdirSync(directoryTarget)).toEqual([]); + + const markerLinkData = temporaryDataDirectory(); + const markerLinkPath = databasePath(markerLinkData); + mkdirSync(dirname(markerLinkPath), { recursive: true }); + symlinkSync(join(markerLinkData, 'missing-marker'), `${markerLinkPath}.rebuild-required`); + await expect(openInventoryV1(markerLinkData)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'unsafe-path')); + }, + ); + + it('refuses orphaned sidecars without deleting them', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(`${path}-wal`, 'orphan'); + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + expect(readFileSync(`${path}-wal`, 'utf8')).toBe('orphan'); + expectNoQuarantine(path); + }); + + it.runIf(process.platform !== 'win32' && process.getuid?.() !== 0)( + 'fails closed on file-permission errors without quarantine', + async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const database = new DatabaseSync(path); + database.exec(`PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; PRAGMA user_version = 1;`); + database.close(); + chmodSync(path, 0o000); + try { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + expectNoQuarantine(path); + } finally { + chmodSync(path, 0o600); + } + }, + ); + + it('refuses a busy incompatible owned database without quarantine', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const holder = new DatabaseSync(path); + holder.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE wrong_v1 (value TEXT); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 1; + BEGIN IMMEDIATE; + `); + try { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-busy')); + expectNoQuarantine(path); + } finally { + holder.exec('ROLLBACK'); + holder.close(); + } + }); + + it('resumes a partial recovery-marker move before rebuilding', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const inventoryDirectory = dirname(path); + const generation = join( + inventoryDirectory, + 'quarantine', + 'inventory-v1-1234567890-aaaaaaaaaaaaaaaa', + ); + mkdirSync(generation, { recursive: true }); + writeFileSync(join(generation, 'inventory-v1.sqlite3'), 'main-before-crash'); + writeFileSync(`${path}-wal`, 'wal-before-crash'); + writeFileSync(`${path}-shm`, 'shm-before-crash'); + writeFileSync( + `${path}.rebuild-required`, + JSON.stringify({ version: 1, quarantineDirectory: generation }), + ); + + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe('main-before-crash'); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'utf8')).toBe('wal-before-crash'); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3-shm'), 'utf8')).toBe('shm-before-crash'); + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + } finally { + foundation.close(); + } + }); + + it('explicitly quarantines and rebuilds an open owned database', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const foundation = await openInventoryV1(dataDirectory); + foundation.quarantineAndRebuild(); + try { + expect(foundation.closed).toBe(false); + assertInitializedInventory(path); + expect(quarantineGenerations(path)).toHaveLength(1); + expect(lstatSync(join(quarantineGenerations(path)[0]!, 'inventory-v1.sqlite3')).isFile()).toBe(true); + } finally { + foundation.close(); + } + }); +}); diff --git a/packages/agent/test/rfc64-inventory-v1-scalars.test.ts b/packages/agent/test/rfc64-inventory-v1-scalars.test.ts new file mode 100644 index 0000000000..68cdfe1d41 --- /dev/null +++ b/packages/agent/test/rfc64-inventory-v1-scalars.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; + +import type { + DecimalU64V1, + DecimalU256V1, + Digest32V1, + EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +import { + InventoryV1ScalarError, + assertSqlBlobWidthV1, + decimalU64ToSqlBlobV1, + decimalU256ToSqlBlobV1, + digest32ToSqlBlobV1, + evmAddressToSqlBlobV1, + nullableIdentifierToSqlTextV1, + sqlBlobToDecimalU64V1, + sqlBlobToDecimalU256V1, + sqlBlobToDigest32V1, + sqlBlobToEvmAddressV1, + sqlBlobsEqualV1, +} from '../src/rfc64/inventory-v1/index.js'; + +const u64 = (value: string) => value as DecimalU64V1; +const u256 = (value: string) => value as DecimalU256V1; +const digest = (value: string) => value as Digest32V1; +const address = (value: string) => value as EvmAddressV1; +const hex = (value: Uint8Array) => Buffer.from(value).toString('hex'); + +describe('RFC-64 inventory v1 SQL scalar codecs', () => { + it.each([ + ['0', '0000000000000000'], + ['1', '0000000000000001'], + ['262144', '0000000000040000'], + ['4096', '0000000000001000'], + ['18446744073709551615', 'ffffffffffffffff'], + ])('encodes u64 %s as the exact eight-byte big-endian BLOB', (value, expected) => { + const encoded = decimalU64ToSqlBlobV1(u64(value)); + expect(hex(encoded)).toBe(expected); + expect(sqlBlobToDecimalU64V1(encoded)).toBe(value); + }); + + it.each([ + ['1', `${'00'.repeat(31)}01`], + ['18446744073709551616', '0000000000000000000000000000000000000000000000010000000000000000'], + [ + '57896044618658097711785492504343953926634992332820282019728792003956564819968', + `80${'00'.repeat(31)}`, + ], + [ + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + 'ff'.repeat(32), + ], + ])('encodes u256 %s as the exact 32-byte big-endian BLOB', (value, expected) => { + const encoded = decimalU256ToSqlBlobV1(u256(value)); + expect(hex(encoded)).toBe(expected); + expect(sqlBlobToDecimalU256V1(encoded)).toBe(value); + }); + + it('sorts fixed-width u256 BLOBs in SQLite unsigned mathematical order', () => { + const values = [ + '0', + '1', + '9', + '10', + '18446744073709551615', + '18446744073709551616', + '57896044618658097711785492504343953926634992332820282019728792003956564819968', + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + ]; + const database = new DatabaseSync(':memory:'); + try { + database.exec(`CREATE TABLE values_v1 (value BLOB PRIMARY KEY) WITHOUT ROWID, STRICT`); + const insert = database.prepare('INSERT INTO values_v1 (value) VALUES (?)'); + for (const value of [...values].reverse()) insert.run(decimalU256ToSqlBlobV1(u256(value))); + const rows = database.prepare('SELECT value FROM values_v1 ORDER BY value').all(); + expect(rows.map((row) => sqlBlobToDecimalU256V1(row.value))).toEqual(values); + } finally { + database.close(); + } + }); + + it('round-trips exact digest and nonzero EVM-address bytes', () => { + const root = digest(`0x${'ab'.repeat(32)}`); + const author = address(`0x${'33'.repeat(20)}`); + expect(hex(digest32ToSqlBlobV1(root))).toBe('ab'.repeat(32)); + expect(sqlBlobToDigest32V1(Buffer.from('ab'.repeat(32), 'hex'))).toBe(root); + expect(hex(evmAddressToSqlBlobV1(author))).toBe('33'.repeat(20)); + expect(sqlBlobToEvmAddressV1(Buffer.from('33'.repeat(20), 'hex'))).toBe(author); + }); + + it('rejects noncanonical decimals, widths, typed views, digests, and addresses', () => { + expect(() => decimalU64ToSqlBlobV1(u64('01'))).toThrow(); + expect(() => decimalU64ToSqlBlobV1(u64('18446744073709551616'))).toThrow(); + expect(() => decimalU256ToSqlBlobV1(u256('-1'))).toThrow(); + expect(() => sqlBlobToDecimalU64V1(new Uint8Array(7))).toThrow(InventoryV1ScalarError); + expect(() => sqlBlobToDecimalU256V1(new Uint16Array(16))).toThrow(InventoryV1ScalarError); + expect(() => assertSqlBlobWidthV1(new DataView(new ArrayBuffer(8)), 8, 'u64')).toThrow( + InventoryV1ScalarError, + ); + expect(() => digest32ToSqlBlobV1(digest(`0x${'AB'.repeat(32)}`))).toThrow(); + expect(() => evmAddressToSqlBlobV1(address(`0x${'00'.repeat(20)}`))).toThrow( + InventoryV1ScalarError, + ); + expect(() => sqlBlobToEvmAddressV1(new Uint8Array(20))).toThrow(InventoryV1ScalarError); + }); + + it('copies accepted BLOB views and compares byte values without coercion', () => { + const source = Buffer.from('01020304', 'hex'); + const copy = assertSqlBlobWidthV1(source, 4, 'fixture'); + source[0] = 0xff; + expect(hex(copy)).toBe('01020304'); + expect(sqlBlobsEqualV1(copy, Buffer.from('01020304', 'hex'))).toBe(true); + expect(sqlBlobsEqualV1(copy, Buffer.from('01020305', 'hex'))).toBe(false); + expect(sqlBlobsEqualV1(copy, new Uint16Array(2))).toBe(false); + }); + + it('maps root identifiers only to SQL NULL and requires pre-normalized text', () => { + const assertIdentifier = (candidate: unknown) => { + if (typeof candidate !== 'string' || candidate.length === 0) throw new Error('invalid identifier'); + }; + expect(nullableIdentifierToSqlTextV1(null, assertIdentifier)).toBeNull(); + expect(nullableIdentifierToSqlTextV1('rootless', assertIdentifier)).toBe('rootless'); + expect(() => nullableIdentifierToSqlTextV1('', assertIdentifier)).toThrow('invalid identifier'); + expect(() => nullableIdentifierToSqlTextV1('e\u0301', assertIdentifier)).toThrow( + InventoryV1ScalarError, + ); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index f89c5a1511..bd0fc1fe05 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -103,6 +103,8 @@ export default defineConfig({ "test/cg-resolve-refresh.test.ts", "test/private-cg-membership-bootstrap.test.ts", "test/workspace-crypto-delegatee-filter.test.ts", + "test/rfc64-inventory-v1-scalars.test.ts", + "test/rfc64-inventory-v1-lifecycle.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 6aa75267f695a7949bf85e264d3ff40e4cb9621d Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 04:21:45 +0200 Subject: [PATCH 020/292] feat(core): add canonical RFC-64 author seal codec --- .../src/canonical-graph-scoped-author-seal.ts | 951 ++++++++++++++++++ packages/core/src/index.ts | 31 + ...canonical-graph-scoped-author-seal.test.ts | 418 ++++++++ 3 files changed, 1400 insertions(+) create mode 100644 packages/core/src/canonical-graph-scoped-author-seal.ts create mode 100644 packages/core/test/canonical-graph-scoped-author-seal.test.ts diff --git a/packages/core/src/canonical-graph-scoped-author-seal.ts b/packages/core/src/canonical-graph-scoped-author-seal.ts new file mode 100644 index 0000000000..8c945a3b41 --- /dev/null +++ b/packages/core/src/canonical-graph-scoped-author-seal.ts @@ -0,0 +1,951 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + +import { + ASSERTION_SEAL_PREDICATES, + buildAssertionSealQuads, + parseGraphScopedAssertionSealCandidate, + type GraphScopedAssertionSealCandidate, +} from './assertion-seal.js'; +import { + assertAssertionCoordinateV1, + assertContextGraphIdV1, + assertSubGraphNameV1, + buildCatalogAssertionScopeV1, + buildCatalogAssertionSubjectV1, + type AssertionCoordinateV1, + type CatalogLaneV1, + type ContextGraphIdV1, + type SubGraphNameV1, +} from './author-catalog-codec.js'; +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + GRAPH_KA_CONTENT_SCOPE_VERSION, + parseDeterministicKnowledgeAssetUal, +} from './ka-content-scope.js'; +import { isSafeIri } from './sparql-safe.js'; +import { + assertCanonicalChainId, + assertCanonicalDigest, + assertCanonicalEvmAddress, + parseCanonicalDecimalU64, + parseCanonicalDecimalU256, + type ChainIdV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type KaIdV1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +declare const HEX_32_V1_BRAND: unique symbol; +declare const POSITIVE_DECIMAL_U64_V1_BRAND: unique symbol; +declare const SEAL_TRIPLE_COUNT_V1_BRAND: unique symbol; +declare const CANONICAL_ISO_UTC_MILLIS_V1_BRAND: unique symbol; +declare const CANONICAL_DETERMINISTIC_UAL_V1_BRAND: unique symbol; + +export type Hex32V1 = string & { readonly [HEX_32_V1_BRAND]: true }; +export type PositiveDecimalU64V1 = DecimalU64V1 & { + readonly [POSITIVE_DECIMAL_U64_V1_BRAND]: true; +}; +export type SealTripleCountV1 = DecimalU64V1 & { + readonly [SEAL_TRIPLE_COUNT_V1_BRAND]: true; +}; +export type CanonicalIsoUtcMillisV1 = string & { + readonly [CANONICAL_ISO_UTC_MILLIS_V1_BRAND]: true; +}; +export type CanonicalDeterministicUalV1 = string & { + readonly [CANONICAL_DETERMINISTIC_UAL_V1_BRAND]: true; +}; + +export const CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_DIGEST_DOMAIN_V1 = + 'dkg-ka-author-seal-v1\n' as const; +export const MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1 = 16 * 1024; +export const MAX_SEAL_TRIPLE_COUNT_V1 = 9_007_199_254_740_991n; + +const XSD_HEX_BINARY = ''; +const XSD_INTEGER = ''; +const XSD_DATE_TIME = ''; +const UTF8 = new TextEncoder(); +const SEAL_DIGEST_DOMAIN_BYTES = UTF8.encode( + CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_DIGEST_DOMAIN_V1, +); +const CANONICAL_UTC_MILLIS = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})Z$/; + +const REQUIRED_PREDICATES = [ + ASSERTION_SEAL_PREDICATES.ASSERTION_MERKLE_ROOT, + ASSERTION_SEAL_PREDICATES.AUTHOR_ADDRESS, + ASSERTION_SEAL_PREDICATES.AUTHOR_ATTESTATION_R, + ASSERTION_SEAL_PREDICATES.AUTHOR_ATTESTATION_VS, + ASSERTION_SEAL_PREDICATES.AUTHOR_SCHEME_VERSION, + ASSERTION_SEAL_PREDICATES.ASSERTED_AT_CHAIN_ID, + ASSERTION_SEAL_PREDICATES.ASSERTED_AT_KAV10_ADDRESS, + ASSERTION_SEAL_PREDICATES.RESERVED_KA_ID, + ASSERTION_SEAL_PREDICATES.ASSERTION_FINALIZED_AT, + ASSERTION_SEAL_PREDICATES.CONTENT_SCOPE_VERSION, + ASSERTION_SEAL_PREDICATES.KA_UAL, + ASSERTION_SEAL_PREDICATES.ASSERTION_VERSION, + ASSERTION_SEAL_PREDICATES.PUBLIC_TRIPLE_COUNT, + ASSERTION_SEAL_PREDICATES.PRIVATE_TRIPLE_COUNT, +] as const; +const PRIVATE_ROOT_PREDICATE = ASSERTION_SEAL_PREDICATES.PRIVATE_MERKLE_ROOT; +const ALLOWED_PREDICATES = new Set([ + ...REQUIRED_PREDICATES, + PRIVATE_ROOT_PREDICATE, +]); + +export interface CanonicalGraphScopedAuthorSealV1 { + readonly assertionMerkleRoot: Digest32V1; + readonly authorAddress: EvmAddressV1; + readonly authorAttestationR: Hex32V1; + readonly authorAttestationVS: Hex32V1; + readonly authorSchemeVersion: '1'; + readonly assertedAtChainId: ChainIdV1; + readonly assertedAtKav10Address: EvmAddressV1; + readonly reservedKaId: KaIdV1; + readonly assertionFinalizedAt: CanonicalIsoUtcMillisV1; + readonly contentScopeVersion: '2'; + readonly kaUal: CanonicalDeterministicUalV1; + readonly assertionVersion: PositiveDecimalU64V1; + readonly publicTripleCount: SealTripleCountV1; + readonly privateTripleCount: SealTripleCountV1; + readonly privateMerkleRoot: Digest32V1 | null; +} + +/** Authenticated catalog coordinate used to derive subject and physical metadata graph. */ +export interface CanonicalGraphScopedAuthorSealCoordinateV1 extends CatalogLaneV1 { + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly assertionCoordinate: AssertionCoordinateV1; +} + +export interface CanonicalGraphScopedAuthorSealRowV1 { + readonly subject: string; + readonly predicate: string; + readonly object: string; + readonly graph: string; +} + +export type CanonicalAuthorSealStoreObjectV1 = + | { + readonly kind: 'named-node'; + /** Bare RDF IRI value; adapters must not preserve angle-bracket rendering. */ + readonly value: string; + } + | { + readonly kind: 'literal'; + readonly value: string; + readonly datatypeIri: string; + }; + +/** Backend-neutral typed adapter row accepted by the strict inverse decoder. */ +export interface CanonicalAuthorSealStoreRowV1 { + readonly subjectIri: string; + readonly predicateIri: string; + readonly graphIri: string; + readonly object: CanonicalAuthorSealStoreObjectV1; +} + +export interface CanonicalGraphScopedAuthorSealPlacementV1 { + readonly subject: string; + readonly metaGraph: string; +} + +export interface DecodedCanonicalGraphScopedAuthorSealRowsV1 { + readonly payload: CanonicalGraphScopedAuthorSealV1; + readonly canonicalSealBytes: Uint8Array; + readonly sealDigest: Digest32V1; + readonly placement: CanonicalGraphScopedAuthorSealPlacementV1; + readonly rows: readonly CanonicalGraphScopedAuthorSealRowV1[]; +} + +export interface ClassifiedCanonicalGraphScopedAuthorSealRowsV1 + extends DecodedCanonicalGraphScopedAuthorSealRowsV1 { + readonly candidate: GraphScopedAssertionSealCandidate; +} + +export type CanonicalGraphScopedAuthorSealErrorCode = + | 'canonical-seal-schema' + | 'canonical-seal-scalar' + | 'canonical-seal-timestamp' + | 'canonical-seal-ual' + | 'canonical-seal-binding' + | 'canonical-seal-count' + | 'canonical-seal-private-root' + | 'canonical-seal-too-large' + | 'canonical-seal-coordinate' + | 'canonical-seal-row-schema' + | 'canonical-seal-row-cardinality' + | 'canonical-seal-row-term' + | 'canonical-seal-projection' + | 'canonical-seal-candidate-rejected'; + +export class CanonicalGraphScopedAuthorSealError extends Error { + constructor( + readonly code: CanonicalGraphScopedAuthorSealErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'CanonicalGraphScopedAuthorSealError'; + } +} + +/** Validate the closed 15-key subjectless and graphless author-seal payload. */ +export function assertCanonicalGraphScopedAuthorSealV1( + payload: unknown, +): asserts payload is CanonicalGraphScopedAuthorSealV1 { + if (!isPlainRecord(payload)) { + fail('canonical-seal-schema', 'canonical author seal must be a plain JSON object'); + } + assertClosedKeys(payload, [ + 'assertedAtChainId', + 'assertedAtKav10Address', + 'assertionFinalizedAt', + 'assertionMerkleRoot', + 'assertionVersion', + 'authorAddress', + 'authorAttestationR', + 'authorAttestationVS', + 'authorSchemeVersion', + 'contentScopeVersion', + 'kaUal', + 'privateMerkleRoot', + 'privateTripleCount', + 'publicTripleCount', + 'reservedKaId', + ], 'canonical graph-scoped author seal'); + + assertSealScalar(() => assertCanonicalDigest( + payload.assertionMerkleRoot, + 'assertionMerkleRoot', + )); + assertSealScalar(() => assertCanonicalEvmAddress(payload.authorAddress, 'authorAddress')); + assertHex32V1(payload.authorAttestationR, 'authorAttestationR'); + assertHex32V1(payload.authorAttestationVS, 'authorAttestationVS'); + if (payload.authorSchemeVersion !== '1') { + fail('canonical-seal-schema', 'authorSchemeVersion must be the exact string "1"'); + } + assertSealScalar(() => assertCanonicalChainId(payload.assertedAtChainId, 'assertedAtChainId')); + assertSealScalar(() => assertCanonicalEvmAddress( + payload.assertedAtKav10Address, + 'assertedAtKav10Address', + )); + const reservedKaId = assertSealU256(payload.reservedKaId, 'reservedKaId'); + assertCanonicalIsoUtcMillisV1(payload.assertionFinalizedAt); + if (payload.contentScopeVersion !== '2') { + fail('canonical-seal-schema', 'contentScopeVersion must be the exact string "2"'); + } + const ual = assertCanonicalDeterministicUalV1(payload.kaUal); + const assertionVersion = assertSealU64(payload.assertionVersion, 'assertionVersion'); + if (assertionVersion < 1n) { + fail('canonical-seal-scalar', 'assertionVersion must be in 1..2^64-1'); + } + const publicCount = assertSealTripleCountV1(payload.publicTripleCount, 'publicTripleCount'); + const privateCount = assertSealTripleCountV1(payload.privateTripleCount, 'privateTripleCount'); + if (publicCount + privateCount < 1n) { + fail('canonical-seal-count', 'publicTripleCount + privateTripleCount must be positive'); + } + if (payload.privateMerkleRoot === null) { + if (privateCount !== 0n) { + fail( + 'canonical-seal-private-root', + 'positive privateTripleCount requires privateMerkleRoot', + ); + } + } else { + assertSealScalar(() => assertCanonicalDigest(payload.privateMerkleRoot, 'privateMerkleRoot')); + if (privateCount === 0n) { + fail( + 'canonical-seal-private-root', + 'privateMerkleRoot requires positive privateTripleCount', + ); + } + } + + if (ual.agentAddress !== payload.authorAddress) { + fail('canonical-seal-binding', 'kaUal author must equal authorAddress'); + } + const packedKaId = (BigInt(ual.agentAddress) << 96n) | BigInt(ual.kaNumber); + if (reservedKaId !== packedKaId) { + fail('canonical-seal-binding', 'reservedKaId must equal the packed kaUal identity'); + } + + // The structural domain is intentionally capped even though the frozen field + // widths currently keep every valid payload well below it. + canonicalizePayloadAfterValidation(payload as unknown as CanonicalGraphScopedAuthorSealV1); +} + +export function canonicalizeCanonicalGraphScopedAuthorSealV1( + payload: CanonicalGraphScopedAuthorSealV1, +): string { + assertCanonicalGraphScopedAuthorSealV1(payload); + return canonicalizePayloadAfterValidation(payload); +} + +export function canonicalizeCanonicalGraphScopedAuthorSealBytesV1( + payload: CanonicalGraphScopedAuthorSealV1, +): Uint8Array { + return UTF8.encode(canonicalizeCanonicalGraphScopedAuthorSealV1(payload)); +} + +export function parseCanonicalGraphScopedAuthorSealV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): CanonicalGraphScopedAuthorSealV1 { + rejectOversizedInput(input); + let parsed: CanonicalJsonValue; + try { + parsed = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1, + MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 1, 1), + }); + } catch (cause) { + fail('canonical-seal-schema', 'author-seal bytes are not strict canonical JCS', cause); + } + assertCanonicalGraphScopedAuthorSealV1(parsed); + return parsed; +} + +export function computeCanonicalGraphScopedAuthorSealDigestV1( + payload: CanonicalGraphScopedAuthorSealV1, +): Digest32V1 { + const bytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(payload); + const hasher = sha256.create(); + hasher.update(SEAL_DIGEST_DOMAIN_BYTES); + hasher.update(bytes); + return bytesToDigest(hasher.digest()); +} + +export function assertCanonicalGraphScopedAuthorSealCoordinateV1( + coordinate: unknown, +): asserts coordinate is CanonicalGraphScopedAuthorSealCoordinateV1 { + if (!isPlainRecord(coordinate)) { + fail('canonical-seal-coordinate', 'catalog assertion coordinate must be a plain object'); + } + assertClosedCoordinateKeys(coordinate); + try { + assertContextGraphIdV1(coordinate.contextGraphId); + if (coordinate.subGraphName !== null) assertSubGraphNameV1(coordinate.subGraphName); + assertCanonicalEvmAddress(coordinate.authorAddress, 'coordinate authorAddress'); + assertAssertionCoordinateV1(coordinate.assertionCoordinate); + } catch (cause) { + fail('canonical-seal-coordinate', 'catalog assertion coordinate is not canonical', cause); + } +} + +/** Derive the only subject and tagged physical metadata graph accepted for this seal. */ +export function deriveCanonicalGraphScopedAuthorSealPlacementV1( + coordinate: CanonicalGraphScopedAuthorSealCoordinateV1, +): CanonicalGraphScopedAuthorSealPlacementV1 { + assertCanonicalGraphScopedAuthorSealCoordinateV1(coordinate); + const catalogAssertionScope = buildCatalogAssertionScopeV1(coordinate); + return { + subject: buildCatalogAssertionSubjectV1( + coordinate, + coordinate.authorAddress, + coordinate.assertionCoordinate, + ), + metaGraph: `did:dkg:context-graph:${catalogAssertionScope}/_meta`, + }; +} + +/** Project the typed payload to the exact ordered 14/15-row graph-scoped v2 seal. */ +export function projectCanonicalGraphScopedAuthorSealRowsV1( + payload: CanonicalGraphScopedAuthorSealV1, + coordinate: CanonicalGraphScopedAuthorSealCoordinateV1, +): readonly CanonicalGraphScopedAuthorSealRowV1[] { + assertCanonicalGraphScopedAuthorSealV1(payload); + assertCanonicalGraphScopedAuthorSealCoordinateV1(coordinate); + if (payload.authorAddress !== coordinate.authorAddress) { + fail('canonical-seal-binding', 'payload authorAddress must equal coordinate authorAddress'); + } + const placement = deriveCanonicalGraphScopedAuthorSealPlacementV1(coordinate); + const row = (predicate: string, object: string): CanonicalGraphScopedAuthorSealRowV1 => ({ + subject: placement.subject, + predicate, + object, + graph: placement.metaGraph, + }); + const exactRows = [ + row( + ASSERTION_SEAL_PREDICATES.ASSERTION_MERKLE_ROOT, + hexBinaryObject(payload.assertionMerkleRoot), + ), + row(ASSERTION_SEAL_PREDICATES.AUTHOR_ADDRESS, JSON.stringify(payload.authorAddress)), + row( + ASSERTION_SEAL_PREDICATES.AUTHOR_ATTESTATION_R, + hexBinaryObject(payload.authorAttestationR), + ), + row( + ASSERTION_SEAL_PREDICATES.AUTHOR_ATTESTATION_VS, + hexBinaryObject(payload.authorAttestationVS), + ), + row(ASSERTION_SEAL_PREDICATES.AUTHOR_SCHEME_VERSION, integerObject('1')), + row(ASSERTION_SEAL_PREDICATES.ASSERTED_AT_CHAIN_ID, integerObject(payload.assertedAtChainId)), + row( + ASSERTION_SEAL_PREDICATES.ASSERTED_AT_KAV10_ADDRESS, + JSON.stringify(payload.assertedAtKav10Address), + ), + row(ASSERTION_SEAL_PREDICATES.RESERVED_KA_ID, integerObject(payload.reservedKaId)), + row( + ASSERTION_SEAL_PREDICATES.ASSERTION_FINALIZED_AT, + `${JSON.stringify(payload.assertionFinalizedAt)}^^${XSD_DATE_TIME}`, + ), + row(ASSERTION_SEAL_PREDICATES.CONTENT_SCOPE_VERSION, integerObject('2')), + row(ASSERTION_SEAL_PREDICATES.KA_UAL, `<${payload.kaUal}>`), + row(ASSERTION_SEAL_PREDICATES.ASSERTION_VERSION, integerObject(payload.assertionVersion)), + row(ASSERTION_SEAL_PREDICATES.PUBLIC_TRIPLE_COUNT, integerObject(payload.publicTripleCount)), + row(ASSERTION_SEAL_PREDICATES.PRIVATE_TRIPLE_COUNT, integerObject(payload.privateTripleCount)), + ...(payload.privateMerkleRoot === null ? [] : [row( + ASSERTION_SEAL_PREDICATES.PRIVATE_MERKLE_ROOT, + hexBinaryObject(payload.privateMerkleRoot), + )]), + ]; + + // Freeze equivalence with the mandatory deployed builder while returning the + // RFC's independently specified exact lexical projection. + const builderRows = buildAssertionSealQuads({ + assertionUri: placement.subject, + metaGraph: placement.metaGraph, + merkleRoot: hex32ToBytes(payload.assertionMerkleRoot), + authorAddress: payload.authorAddress, + authorAttestationR: hex32ToBytes(payload.authorAttestationR), + authorAttestationVS: hex32ToBytes(payload.authorAttestationVS), + authorSchemeVersion: 1, + chainId: BigInt(payload.assertedAtChainId), + kav10Address: payload.assertedAtKav10Address, + reservedKaId: BigInt(payload.reservedKaId), + finalizedAtIso: payload.assertionFinalizedAt, + contentScopeVersion: GRAPH_KA_CONTENT_SCOPE_VERSION, + kaUal: payload.kaUal, + assertionVersion: payload.assertionVersion, + publicTripleCount: Number(payload.publicTripleCount), + privateTripleCount: Number(payload.privateTripleCount), + ...(payload.privateMerkleRoot === null + ? {} + : { privateMerkleRoot: hex32ToBytes(payload.privateMerkleRoot) }), + }); + if (!sameOrderedRows(exactRows, builderRows)) { + fail( + 'canonical-seal-projection', + 'canonical author-seal projection disagrees with buildAssertionSealQuads', + ); + } + return exactRows; +} + +/** Render one typed store row to the exact canonical parser-row representation. */ +export function renderCanonicalAuthorSealStoreRowV1( + row: CanonicalAuthorSealStoreRowV1, +): CanonicalGraphScopedAuthorSealRowV1 { + assertCanonicalAuthorSealStoreRowShape(row); + for (const [label, value] of [ + ['subjectIri', row.subjectIri], + ['predicateIri', row.predicateIri], + ['graphIri', row.graphIri], + ] as const) { + if (!isSafeIri(value)) { + fail('canonical-seal-row-term', `${label} must contain one bare safe IRI`); + } + } + let object: string; + if (row.object.kind === 'named-node') { + if (!isSafeIri(row.object.value)) { + fail('canonical-seal-row-term', 'named-node object must contain one bare safe IRI'); + } + object = `<${row.object.value}>`; + } else { + const literal = JSON.stringify(row.object.value); + switch (row.object.datatypeIri) { + case 'http://www.w3.org/2001/XMLSchema#string': + object = literal; + break; + case 'http://www.w3.org/2001/XMLSchema#integer': + case 'http://www.w3.org/2001/XMLSchema#hexBinary': + case 'http://www.w3.org/2001/XMLSchema#dateTime': + object = `${literal}^^<${row.object.datatypeIri}>`; + break; + default: + fail( + 'canonical-seal-row-term', + `unsupported author-seal literal datatype ${row.object.datatypeIri}`, + ); + } + } + return { + subject: row.subjectIri, + predicate: row.predicateIri, + object, + graph: row.graphIri, + }; +} + +/** + * Strict order-independent inverse over one fixed subject and graph. Every + * predicate is cardinality-checked before the typed payload is materialized. + * The store adapter must enforce RFC-64's 64 KiB query-response cap before it + * constructs these typed rows; this codec intentionally does not invent a + * backend-independent byte serialization for already-materialized bindings. + */ +export function decodeCanonicalGraphScopedAuthorSealRowsV1( + rows: readonly CanonicalAuthorSealStoreRowV1[], + coordinate: CanonicalGraphScopedAuthorSealCoordinateV1, +): DecodedCanonicalGraphScopedAuthorSealRowsV1 { + assertCanonicalGraphScopedAuthorSealCoordinateV1(coordinate); + assertDenseOrdinaryStoreRowsArray(rows); + const placement = deriveCanonicalGraphScopedAuthorSealPlacementV1(coordinate); + const byPredicate = new Map(); + for (let index = 0; index < rows.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(rows, index)) { + fail('canonical-seal-row-schema', 'canonical author-seal row array must be dense'); + } + const current = renderCanonicalAuthorSealStoreRowV1(rows[index]); + if (current.subject !== placement.subject || current.graph !== placement.metaGraph) { + fail('canonical-seal-row-term', 'author-seal row has the wrong subject or graph'); + } + if (!ALLOWED_PREDICATES.has(current.predicate)) { + fail('canonical-seal-row-cardinality', `unknown author-seal predicate ${current.predicate}`); + } + if (byPredicate.has(current.predicate)) { + fail( + 'canonical-seal-row-cardinality', + `duplicate author-seal predicate ${current.predicate}`, + ); + } + byPredicate.set(current.predicate, current); + } + for (const predicate of REQUIRED_PREDICATES) { + if (!byPredicate.has(predicate)) { + fail('canonical-seal-row-cardinality', `missing author-seal predicate ${predicate}`); + } + } + + const objectFor = (predicate: string): string => byPredicate.get(predicate)!.object; + const payload = { + assertionMerkleRoot: parseHexBinaryObject( + objectFor(ASSERTION_SEAL_PREDICATES.ASSERTION_MERKLE_ROOT), + 'assertionMerkleRoot', + ), + authorAddress: parsePlainAddressObject( + objectFor(ASSERTION_SEAL_PREDICATES.AUTHOR_ADDRESS), + 'authorAddress', + ), + authorAttestationR: parseHexBinaryObject( + objectFor(ASSERTION_SEAL_PREDICATES.AUTHOR_ATTESTATION_R), + 'authorAttestationR', + ), + authorAttestationVS: parseHexBinaryObject( + objectFor(ASSERTION_SEAL_PREDICATES.AUTHOR_ATTESTATION_VS), + 'authorAttestationVS', + ), + authorSchemeVersion: parseIntegerObject( + objectFor(ASSERTION_SEAL_PREDICATES.AUTHOR_SCHEME_VERSION), + 'authorSchemeVersion', + ), + assertedAtChainId: parseIntegerObject( + objectFor(ASSERTION_SEAL_PREDICATES.ASSERTED_AT_CHAIN_ID), + 'assertedAtChainId', + ), + assertedAtKav10Address: parsePlainAddressObject( + objectFor(ASSERTION_SEAL_PREDICATES.ASSERTED_AT_KAV10_ADDRESS), + 'assertedAtKav10Address', + ), + reservedKaId: parseIntegerObject( + objectFor(ASSERTION_SEAL_PREDICATES.RESERVED_KA_ID), + 'reservedKaId', + ), + assertionFinalizedAt: parseDateTimeObject( + objectFor(ASSERTION_SEAL_PREDICATES.ASSERTION_FINALIZED_AT), + ), + contentScopeVersion: parseIntegerObject( + objectFor(ASSERTION_SEAL_PREDICATES.CONTENT_SCOPE_VERSION), + 'contentScopeVersion', + ), + kaUal: parseIriObject(objectFor(ASSERTION_SEAL_PREDICATES.KA_UAL)), + assertionVersion: parseIntegerObject( + objectFor(ASSERTION_SEAL_PREDICATES.ASSERTION_VERSION), + 'assertionVersion', + ), + publicTripleCount: parseIntegerObject( + objectFor(ASSERTION_SEAL_PREDICATES.PUBLIC_TRIPLE_COUNT), + 'publicTripleCount', + ), + privateTripleCount: parseIntegerObject( + objectFor(ASSERTION_SEAL_PREDICATES.PRIVATE_TRIPLE_COUNT), + 'privateTripleCount', + ), + privateMerkleRoot: byPredicate.has(PRIVATE_ROOT_PREDICATE) + ? parseHexBinaryObject(objectFor(PRIVATE_ROOT_PREDICATE), 'privateMerkleRoot') + : null, + }; + assertCanonicalGraphScopedAuthorSealV1(payload); + if (payload.authorAddress !== coordinate.authorAddress) { + fail('canonical-seal-binding', 'decoded payload author differs from catalog coordinate'); + } + + const projectedRows = projectCanonicalGraphScopedAuthorSealRowsV1(payload, coordinate); + if (projectedRows.length !== rows.length) { + fail('canonical-seal-row-cardinality', 'private-root row cardinality is inconsistent'); + } + for (const expected of projectedRows) { + const actual = byPredicate.get(expected.predicate); + if (!actual || !sameRow(expected, actual)) { + fail( + 'canonical-seal-row-term', + `noncanonical RDF term for predicate ${expected.predicate}`, + ); + } + } + const canonicalSealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(payload); + return { + payload, + canonicalSealBytes, + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(payload), + placement, + rows: projectedRows, + }; +} + +/** + * Strictly decode/reconstruct, then invoke the PR #1780 classifier exactly + * once. This returns only a structural publishable-seal candidate; it performs + * no catalog-row, signature/root, deployment, or chain-authority admission. + */ +export function classifyCanonicalGraphScopedAuthorSealRowsV1( + rows: readonly CanonicalAuthorSealStoreRowV1[], + coordinate: CanonicalGraphScopedAuthorSealCoordinateV1, +): ClassifiedCanonicalGraphScopedAuthorSealRowsV1 { + const decoded = decodeCanonicalGraphScopedAuthorSealRowsV1(rows, coordinate); + const classifierRows = decoded.rows.map(({ subject, predicate, object }) => ({ + subject, + predicate, + object, + })); + const candidate = parseGraphScopedAssertionSealCandidate( + classifierRows, + decoded.placement.subject, + ); + if (!candidate) { + fail( + 'canonical-seal-candidate-rejected', + 'PR #1780 rejected the strictly reconstructed graph-scoped author seal', + ); + } + return { ...decoded, candidate }; +} + +function canonicalizePayloadAfterValidation(payload: CanonicalGraphScopedAuthorSealV1): string { + try { + return canonicalizeJson(payload as unknown as CanonicalJsonValue, { + maxBytes: MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1, + maxDepth: 1, + }); + } catch (cause) { + fail('canonical-seal-too-large', 'canonical author-seal payload exceeds its v1 bounds', cause); + } +} + +function assertCanonicalIsoUtcMillisV1( + value: unknown, +): asserts value is CanonicalIsoUtcMillisV1 { + if (typeof value !== 'string' || !CANONICAL_UTC_MILLIS.test(value)) { + fail( + 'canonical-seal-timestamp', + 'assertionFinalizedAt must use exact YYYY-MM-DDTHH:mm:ss.sssZ form', + ); + } + const instant = Date.parse(value); + if (!Number.isFinite(instant) || new Date(instant).toISOString() !== value) { + fail('canonical-seal-timestamp', 'assertionFinalizedAt is not a real UTC instant'); + } +} + +function assertCanonicalDeterministicUalV1(value: unknown): { + ual: CanonicalDeterministicUalV1; + agentAddress: EvmAddressV1; + kaNumber: string; +} { + if (typeof value !== 'string') { + fail('canonical-seal-ual', 'kaUal must be a string'); + } + try { + const parsed = parseDeterministicKnowledgeAssetUal(value); + assertCanonicalEvmAddress(parsed.agentAddress, 'kaUal author'); + if (parsed.ual !== value) { + fail('canonical-seal-ual', 'kaUal must already be in canonical form'); + } + return { + ual: parsed.ual as CanonicalDeterministicUalV1, + agentAddress: parsed.agentAddress as EvmAddressV1, + kaNumber: parsed.kaNumber, + }; + } catch (cause) { + if (cause instanceof CanonicalGraphScopedAuthorSealError) throw cause; + fail('canonical-seal-ual', 'kaUal is not a canonical deterministic v10 UAL', cause); + } +} + +function assertSealTripleCountV1(value: unknown, label: string): bigint { + const count = assertSealU64(value, label); + if (count > MAX_SEAL_TRIPLE_COUNT_V1) { + fail('canonical-seal-count', `${label} exceeds 2^53-1`); + } + return count; +} + +function assertHex32V1(value: unknown, label: string): asserts value is Hex32V1 { + assertSealScalar(() => assertCanonicalDigest(value, label)); +} + +function assertSealU64(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU64(value, label); + } catch (cause) { + fail('canonical-seal-scalar', `${label} is not a canonical DecimalU64V1`, cause); + } +} + +function assertSealU256(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU256(value, label); + } catch (cause) { + fail('canonical-seal-scalar', `${label} is not a canonical DecimalU256V1`, cause); + } +} + +function assertSealScalar(operation: () => void): void { + try { + operation(); + } catch (cause) { + fail('canonical-seal-scalar', 'author-seal scalar is not canonical', cause); + } +} + +function assertClosedKeys( + record: Record, + keys: readonly string[], + label: string, +): void { + try { + assertExactKeys(record, keys, label); + } catch (cause) { + fail('canonical-seal-schema', `${label} has an invalid field set`, cause); + } +} + +function assertClosedCoordinateKeys(record: Record): void { + try { + assertExactKeys(record, [ + 'assertionCoordinate', + 'authorAddress', + 'contextGraphId', + 'subGraphName', + ], 'catalog assertion coordinate'); + } catch (cause) { + fail('canonical-seal-coordinate', 'catalog assertion coordinate has invalid fields', cause); + } +} + +function assertCanonicalAuthorSealStoreRowShape( + row: unknown, +): asserts row is CanonicalAuthorSealStoreRowV1 { + if (!isPlainRecord(row)) { + fail('canonical-seal-row-schema', 'author-seal store row must be a plain object'); + } + try { + assertExactKeys( + row, + ['graphIri', 'object', 'predicateIri', 'subjectIri'], + 'author-seal store row', + ); + } catch (cause) { + fail('canonical-seal-row-schema', 'author-seal store row has invalid fields', cause); + } + for (const key of ['subjectIri', 'predicateIri', 'graphIri']) { + if (typeof row[key] !== 'string') { + fail('canonical-seal-row-schema', `author-seal store row ${key} must be a string`); + } + } + if (!isPlainRecord(row.object)) { + fail('canonical-seal-row-schema', 'author-seal store object must be a plain object'); + } + const kindDescriptor = Object.getOwnPropertyDescriptor(row.object, 'kind'); + if ( + !kindDescriptor?.enumerable + || !Object.prototype.hasOwnProperty.call(kindDescriptor, 'value') + ) { + fail('canonical-seal-row-schema', 'author-seal store object kind must be a data property'); + } + if (kindDescriptor.value === 'named-node') { + try { + assertExactKeys(row.object, ['kind', 'value'], 'named-node object'); + } catch (cause) { + fail('canonical-seal-row-schema', 'named-node object has invalid fields', cause); + } + if (typeof row.object.value !== 'string') { + fail('canonical-seal-row-schema', 'named-node value must be a string'); + } + return; + } + if (kindDescriptor.value === 'literal') { + try { + assertExactKeys(row.object, ['datatypeIri', 'kind', 'value'], 'literal object'); + } catch (cause) { + fail('canonical-seal-row-schema', 'literal object has invalid fields', cause); + } + if (typeof row.object.value !== 'string' || typeof row.object.datatypeIri !== 'string') { + fail('canonical-seal-row-schema', 'literal value and datatypeIri must be strings'); + } + return; + } + fail('canonical-seal-row-schema', 'author-seal store object has an unsupported kind'); +} + +function assertDenseOrdinaryStoreRowsArray( + rows: unknown, +): asserts rows is CanonicalAuthorSealStoreRowV1[] { + if (!Array.isArray(rows) || Object.getPrototypeOf(rows) !== Array.prototype) { + fail('canonical-seal-row-schema', 'author-seal rows must be an ordinary Array'); + } + if (rows.length !== 14 && rows.length !== 15) { + fail('canonical-seal-row-cardinality', 'canonical author seal must contain 14 or 15 rows'); + } + const ownKeys = Reflect.ownKeys(rows); + if ( + ownKeys.some((key) => typeof key !== 'string') + || ownKeys.length !== rows.length + 1 + || !ownKeys.includes('length') + ) { + fail( + 'canonical-seal-row-schema', + 'author-seal rows must be dense and contain no custom properties', + ); + } + for (let index = 0; index < rows.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(rows, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail( + 'canonical-seal-row-schema', + 'author-seal rows must contain enumerable data properties', + ); + } + } +} + +function rejectOversizedInput(input: string | Uint8Array): void { + if (typeof input !== 'string') { + if (input.byteLength > MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1) { + fail('canonical-seal-too-large', 'canonical author-seal input exceeds 16384 bytes'); + } + return; + } + if ( + input.length > MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1 + || UTF8.encode(input).byteLength > MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1 + ) { + fail('canonical-seal-too-large', 'canonical author-seal input exceeds 16384 bytes'); + } +} + +function parseHexBinaryObject(object: string, label: string): string { + const match = /^"([0-9a-f]{64})"\^\^$/.exec(object); + if (!match) fail('canonical-seal-row-term', `${label} is not the exact xsd:hexBinary term`); + return `0x${match[1]}`; +} + +function parsePlainAddressObject(object: string, label: string): string { + const match = /^"(0x[0-9a-f]{40})"$/.exec(object); + if (!match) fail('canonical-seal-row-term', `${label} is not the exact plain address term`); + return match[1]; +} + +function parseIntegerObject(object: string, label: string): string { + const match = /^"(0|[1-9][0-9]*)"\^\^$/.exec(object); + if (!match) fail('canonical-seal-row-term', `${label} is not the exact xsd:integer term`); + return match[1]; +} + +function parseDateTimeObject(object: string): string { + const suffix = `^^${XSD_DATE_TIME}`; + if (!object.endsWith(suffix)) { + fail('canonical-seal-row-term', 'assertionFinalizedAt is not the exact xsd:dateTime term'); + } + const literal = object.slice(0, -suffix.length); + if (!/^"[^"\\]*"$/.test(literal)) { + fail('canonical-seal-row-term', 'assertionFinalizedAt has a noncanonical string literal'); + } + return literal.slice(1, -1); +} + +function parseIriObject(object: string): string { + const match = /^<([^<>"{}|\\^`\u0000-\u0020]+)>$/.exec(object); + if (!match) fail('canonical-seal-row-term', 'kaUal is not the exact IRI object form'); + return match[1]; +} + +function integerObject(value: string): string { + return `${JSON.stringify(value)}^^${XSD_INTEGER}`; +} + +function hexBinaryObject(value: string): string { + return `${JSON.stringify(value.slice(2))}^^${XSD_HEX_BINARY}`; +} + +function hex32ToBytes(value: string): Uint8Array { + const bytes = new Uint8Array(32); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(2 + (index * 2), 4 + (index * 2)), 16); + } + return bytes; +} + +function bytesToDigest(bytes: Uint8Array): Digest32V1 { + let result = '0x'; + for (const byte of bytes) result += byte.toString(16).padStart(2, '0'); + assertCanonicalDigest(result, 'computed canonical author-seal digest'); + return result; +} + +function sameOrderedRows( + left: readonly CanonicalGraphScopedAuthorSealRowV1[], + right: readonly CanonicalGraphScopedAuthorSealRowV1[], +): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (!sameRow(left[index], right[index])) return false; + } + return true; +} + +function sameRow( + left: CanonicalGraphScopedAuthorSealRowV1, + right: CanonicalGraphScopedAuthorSealRowV1, +): boolean { + return left.subject === right.subject + && left.predicate === right.predicate + && left.object === right.object + && left.graph === right.graph; +} + +function fail( + code: CanonicalGraphScopedAuthorSealErrorCode, + message: string, + cause?: unknown, +): never { + throw new CanonicalGraphScopedAuthorSealError( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5ae60088d..f5846bf2b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -339,3 +339,34 @@ export { type AssertionSealBuildArgs, type AssertionSeal, } from './assertion-seal.js'; +export { + CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_DIGEST_DOMAIN_V1, + MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1, + MAX_SEAL_TRIPLE_COUNT_V1, + CanonicalGraphScopedAuthorSealError, + assertCanonicalGraphScopedAuthorSealV1, + canonicalizeCanonicalGraphScopedAuthorSealV1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + parseCanonicalGraphScopedAuthorSealV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + assertCanonicalGraphScopedAuthorSealCoordinateV1, + deriveCanonicalGraphScopedAuthorSealPlacementV1, + projectCanonicalGraphScopedAuthorSealRowsV1, + renderCanonicalAuthorSealStoreRowV1, + decodeCanonicalGraphScopedAuthorSealRowsV1, + classifyCanonicalGraphScopedAuthorSealRowsV1, + type Hex32V1, + type PositiveDecimalU64V1, + type SealTripleCountV1, + type CanonicalIsoUtcMillisV1, + type CanonicalDeterministicUalV1, + type CanonicalGraphScopedAuthorSealV1, + type CanonicalGraphScopedAuthorSealCoordinateV1, + type CanonicalGraphScopedAuthorSealRowV1, + type CanonicalAuthorSealStoreObjectV1, + type CanonicalAuthorSealStoreRowV1, + type CanonicalGraphScopedAuthorSealPlacementV1, + type DecodedCanonicalGraphScopedAuthorSealRowsV1, + type ClassifiedCanonicalGraphScopedAuthorSealRowsV1, + type CanonicalGraphScopedAuthorSealErrorCode, +} from './canonical-graph-scoped-author-seal.js'; diff --git a/packages/core/test/canonical-graph-scoped-author-seal.test.ts b/packages/core/test/canonical-graph-scoped-author-seal.test.ts new file mode 100644 index 0000000000..3bfe903d77 --- /dev/null +++ b/packages/core/test/canonical-graph-scoped-author-seal.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it } from 'vitest'; + +import { ASSERTION_SEAL_PREDICATES } from '../src/assertion-seal.js'; +import { + MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1, + assertCanonicalGraphScopedAuthorSealCoordinateV1, + assertCanonicalGraphScopedAuthorSealV1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + canonicalizeCanonicalGraphScopedAuthorSealV1, + classifyCanonicalGraphScopedAuthorSealRowsV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + decodeCanonicalGraphScopedAuthorSealRowsV1, + deriveCanonicalGraphScopedAuthorSealPlacementV1, + parseCanonicalGraphScopedAuthorSealV1, + projectCanonicalGraphScopedAuthorSealRowsV1, + renderCanonicalAuthorSealStoreRowV1, + type CanonicalAuthorSealStoreRowV1, + type CanonicalGraphScopedAuthorSealCoordinateV1, + type CanonicalGraphScopedAuthorSealRowV1, + type CanonicalGraphScopedAuthorSealV1, +} from '../src/canonical-graph-scoped-author-seal.js'; + +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const RESERVED_KA_ID = + '23158417847463239084714197001737581570653996933112267175388663934063917137927'; +const SUBJECT = + `did:dkg:context-graph:v1/root/a%2Fb/assertion/${AUTHOR}/name%20%CE%BB`; +const META_GRAPH = 'did:dkg:context-graph:v1/root/a%2Fb/_meta'; +const SEAL_DIGEST = + '0x8fc37c7f66831aea9b2a0ed35aac26bb6eec2eb3042ed0dcdd2e023d3087632a'; +const XSD = 'http://www.w3.org/2001/XMLSchema#'; + +const CANONICAL = + '{"assertedAtChainId":"20430","assertedAtKav10Address":"0x4444444444444444444444444444444444444444","assertionFinalizedAt":"2026-07-19T12:34:56.789Z","assertionMerkleRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","assertionVersion":"2","authorAddress":"0x3333333333333333333333333333333333333333","authorAttestationR":"0x1111111111111111111111111111111111111111111111111111111111111111","authorAttestationVS":"0x2222222222222222222222222222222222222222222222222222222222222222","authorSchemeVersion":"1","contentScopeVersion":"2","kaUal":"did:dkg:otp:20430/0x3333333333333333333333333333333333333333/7","privateMerkleRoot":null,"privateTripleCount":"0","publicTripleCount":"12977","reservedKaId":"23158417847463239084714197001737581570653996933112267175388663934063917137927"}'; + +const EXPECTED_PREDICATES = [ + ASSERTION_SEAL_PREDICATES.ASSERTION_MERKLE_ROOT, + ASSERTION_SEAL_PREDICATES.AUTHOR_ADDRESS, + ASSERTION_SEAL_PREDICATES.AUTHOR_ATTESTATION_R, + ASSERTION_SEAL_PREDICATES.AUTHOR_ATTESTATION_VS, + ASSERTION_SEAL_PREDICATES.AUTHOR_SCHEME_VERSION, + ASSERTION_SEAL_PREDICATES.ASSERTED_AT_CHAIN_ID, + ASSERTION_SEAL_PREDICATES.ASSERTED_AT_KAV10_ADDRESS, + ASSERTION_SEAL_PREDICATES.RESERVED_KA_ID, + ASSERTION_SEAL_PREDICATES.ASSERTION_FINALIZED_AT, + ASSERTION_SEAL_PREDICATES.CONTENT_SCOPE_VERSION, + ASSERTION_SEAL_PREDICATES.KA_UAL, + ASSERTION_SEAL_PREDICATES.ASSERTION_VERSION, + ASSERTION_SEAL_PREDICATES.PUBLIC_TRIPLE_COUNT, + ASSERTION_SEAL_PREDICATES.PRIVATE_TRIPLE_COUNT, +]; +const EXPECTED_OBJECTS = [ + `"${'aa'.repeat(32)}"^^<${XSD}hexBinary>`, + `"${AUTHOR}"`, + `"${'11'.repeat(32)}"^^<${XSD}hexBinary>`, + `"${'22'.repeat(32)}"^^<${XSD}hexBinary>`, + `"1"^^<${XSD}integer>`, + `"20430"^^<${XSD}integer>`, + '"0x4444444444444444444444444444444444444444"', + `"${RESERVED_KA_ID}"^^<${XSD}integer>`, + `"2026-07-19T12:34:56.789Z"^^<${XSD}dateTime>`, + `"2"^^<${XSD}integer>`, + ``, + `"2"^^<${XSD}integer>`, + `"12977"^^<${XSD}integer>`, + `"0"^^<${XSD}integer>`, +]; + +const PAYLOAD = validatedPayload(JSON.parse(CANONICAL)); +const COORDINATE = validatedCoordinate({ + contextGraphId: 'a/b', + subGraphName: null, + authorAddress: AUTHOR, + assertionCoordinate: 'name λ', +}); + +describe('CanonicalGraphScopedAuthorSealV1 bytes and projection', () => { + it('pins the normative 803-byte payload, digest, placement, and ordered rows', () => { + expect(canonicalizeCanonicalGraphScopedAuthorSealV1(PAYLOAD)).toBe(CANONICAL); + expect(canonicalizeCanonicalGraphScopedAuthorSealBytesV1(PAYLOAD).byteLength).toBe(803); + expect(computeCanonicalGraphScopedAuthorSealDigestV1(PAYLOAD)).toBe(SEAL_DIGEST); + expect(parseCanonicalGraphScopedAuthorSealV1(CANONICAL)).toEqual(PAYLOAD); + expect(deriveCanonicalGraphScopedAuthorSealPlacementV1(COORDINATE)).toEqual({ + subject: SUBJECT, + metaGraph: META_GRAPH, + }); + + const rows = projectCanonicalGraphScopedAuthorSealRowsV1(PAYLOAD, COORDINATE); + expect(rows).toHaveLength(14); + expect(rows.map((row) => row.predicate)).toEqual(EXPECTED_PREDICATES); + expect(rows.map((row) => row.object)).toEqual(EXPECTED_OBJECTS); + expect(rows.every((row) => row.subject === SUBJECT && row.graph === META_GRAPH)).toBe(true); + }); + + it('emits the optional private-root row only for positive private content', () => { + const privatePayload = validatedPayload({ + ...PAYLOAD, + publicTripleCount: '0', + privateTripleCount: '1', + privateMerkleRoot: `0x${'bb'.repeat(32)}`, + }); + const rows = projectCanonicalGraphScopedAuthorSealRowsV1(privatePayload, COORDINATE); + expect(rows).toHaveLength(15); + expect(rows[14]).toEqual({ + subject: SUBJECT, + predicate: ASSERTION_SEAL_PREDICATES.PRIVATE_MERKLE_ROOT, + object: `"${'bb'.repeat(32)}"^^<${XSD}hexBinary>`, + graph: META_GRAPH, + }); + expect(decodeCanonicalGraphScopedAuthorSealRowsV1( + toStoreRows(rows), + COORDINATE, + ).payload).toEqual(privatePayload); + }); + + it('derives the prefix-free tagged subgraph subject and metadata graph', () => { + const subgraphCoordinate = validatedCoordinate({ + ...COORDINATE, + contextGraphId: 'a', + subGraphName: 'b', + }); + expect(deriveCanonicalGraphScopedAuthorSealPlacementV1(subgraphCoordinate)).toEqual({ + subject: `did:dkg:context-graph:v1/subgraph/a/b/assertion/${AUTHOR}/name%20%CE%BB`, + metaGraph: 'did:dkg:context-graph:v1/subgraph/a/b/_meta', + }); + }); +}); + +describe('CanonicalGraphScopedAuthorSealV1 typed store inverse', () => { + it('normalizes backend-neutral terms, decodes in any order, and calls #1780 once', () => { + const projected = projectCanonicalGraphScopedAuthorSealRowsV1(PAYLOAD, COORDINATE); + const storeRows = toStoreRows(projected).reverse(); + const decoded = decodeCanonicalGraphScopedAuthorSealRowsV1(storeRows, COORDINATE); + expect(decoded.payload).toEqual(PAYLOAD); + expect(new TextDecoder().decode(decoded.canonicalSealBytes)).toBe(CANONICAL); + expect(decoded.sealDigest).toBe(SEAL_DIGEST); + expect(decoded.rows).toEqual(projected); + + const classified = classifyCanonicalGraphScopedAuthorSealRowsV1(storeRows, COORDINATE); + expect(classified.candidate.coordinate).toEqual({ + scope: 'v1/root/a%2Fb', + agentAddress: AUTHOR, + name: 'name%20%CE%BB', + }); + expect(classified.candidate.seal).toMatchObject({ + authorAddress: AUTHOR, + reservedKaId: BigInt(RESERVED_KA_ID), + kaUal: `did:dkg:otp:20430/${AUTHOR}/7`, + assertionVersion: '2', + publicTripleCount: 12977, + privateTripleCount: 0, + }); + }); + + it('pins Oxigraph/Blazegraph typed-term parity rendering', () => { + const base = { subjectIri: SUBJECT, predicateIri: 'urn:predicate', graphIri: META_GRAPH }; + expect(renderCanonicalAuthorSealStoreRowV1({ + ...base, + object: { + kind: 'named-node', + value: `did:dkg:otp:20430/${AUTHOR}/7`, + }, + }).object).toBe(``); + expect(renderCanonicalAuthorSealStoreRowV1({ + ...base, + object: { kind: 'literal', value: '20430', datatypeIri: `${XSD}integer` }, + }).object).toBe(`"20430"^^<${XSD}integer>`); + expect(renderCanonicalAuthorSealStoreRowV1({ + ...base, + object: { kind: 'literal', value: AUTHOR, datatypeIri: `${XSD}string` }, + }).object).toBe(`"${AUTHOR}"`); + }); + + it('rejects missing, duplicate, unknown, legacy, misplaced, and noncanonical rows', () => { + const valid = toStoreRows(projectCanonicalGraphScopedAuthorSealRowsV1(PAYLOAD, COORDINATE)); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1(valid.slice(1), COORDINATE)) + .toThrow(/canonical-seal-row-cardinality/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1( + [...valid, valid[0]], + COORDINATE, + )).toThrow(/duplicate author-seal predicate/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1( + [ + ...valid, + { + ...valid[0], + object: { kind: 'literal', value: 'tampered', datatypeIri: `${XSD}string` }, + }, + ], + COORDINATE, + )).toThrow(/duplicate author-seal predicate/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + ...valid.slice(0, -1), + { ...valid.at(-1)!, predicateIri: 'http://dkg.io/ontology/unknown' }, + ], COORDINATE)).toThrow(/unknown author-seal predicate/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + ...valid.slice(0, -1), + { + ...valid.at(-1)!, + predicateIri: ASSERTION_SEAL_PREDICATES.ASSERTION_ROOT_ENTITY, + }, + ], COORDINATE)).toThrow(/unknown author-seal predicate/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + { ...valid[0], subjectIri: `${SUBJECT}/other` }, + ...valid.slice(1), + ], COORDINATE)).toThrow(/wrong subject or graph/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + { ...valid[0], graphIri: `${META_GRAPH}/other` }, + ...valid.slice(1), + ], COORDINATE)).toThrow(/wrong subject or graph/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + ...valid.slice(0, 5), + { + ...valid[5], + object: { kind: 'literal', value: '020430', datatypeIri: `${XSD}integer` }, + }, + ...valid.slice(6), + ], COORDINATE)).toThrow(/exact xsd:integer term/); + }); + + it('rejects malformed typed adapter terms before canonical comparison', () => { + const valid = toStoreRows(projectCanonicalGraphScopedAuthorSealRowsV1(PAYLOAD, COORDINATE)); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + ...valid.slice(0, 10), + { + ...valid[10], + object: { kind: 'named-node', value: `` }, + }, + ...valid.slice(11), + ], COORDINATE)).toThrow(/bare safe IRI/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + ...valid.slice(0, 1), + { + ...valid[1], + object: { kind: 'literal', value: AUTHOR, datatypeIri: `${XSD}decimal` }, + }, + ...valid.slice(2), + ], COORDINATE)).toThrow(/unsupported author-seal literal datatype/); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + ...valid.slice(0, 1), + { + ...valid[1], + object: { + kind: 'literal', + value: AUTHOR, + datatypeIri: `${XSD}string`, + language: 'en', + }, + } as unknown as CanonicalAuthorSealStoreRowV1, + ...valid.slice(2), + ], COORDINATE)).toThrow(/literal object has invalid fields/); + const accessorObject = {} as Record; + Object.defineProperties(accessorObject, { + kind: { enumerable: true, get: () => 'literal' }, + value: { enumerable: true, value: AUTHOR }, + datatypeIri: { enumerable: true, value: `${XSD}string` }, + }); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1([ + ...valid.slice(0, 1), + { ...valid[1], object: accessorObject } as unknown as CanonicalAuthorSealStoreRowV1, + ...valid.slice(2), + ], COORDINATE)).toThrow(/kind must be a data property/); + }); + + it('rejects wrapped, whitespace, and control characters in every typed row IRI', () => { + const base: CanonicalAuthorSealStoreRowV1 = { + subjectIri: SUBJECT, + predicateIri: 'urn:predicate', + graphIri: META_GRAPH, + object: { kind: 'literal', value: AUTHOR, datatypeIri: `${XSD}string` }, + }; + for (const field of ['subjectIri', 'predicateIri', 'graphIri'] as const) { + for (const value of ['', 'urn:has space', 'urn:has\u0000control']) { + expect(() => renderCanonicalAuthorSealStoreRowV1({ ...base, [field]: value })) + .toThrow(new RegExp(`${field} must contain one bare safe IRI`)); + } + } + }); + + it('rejects non-ordinary, accessor-backed store-row containers', () => { + const valid = toStoreRows(projectCanonicalGraphScopedAuthorSealRowsV1(PAYLOAD, COORDINATE)); + const accessor = [...valid]; + Object.defineProperty(accessor, '0', { enumerable: true, get: () => valid[0] }); + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1(accessor, COORDINATE)) + .toThrow(/enumerable data properties/); + class RowArray extends Array {} + expect(() => decodeCanonicalGraphScopedAuthorSealRowsV1( + new RowArray(...valid), + COORDINATE, + )).toThrow(/ordinary Array/); + }); +}); + +describe('CanonicalGraphScopedAuthorSealV1 hostile payload rejection', () => { + it('rejects missing, extra, duplicate, non-string, and noncanonical wire fields', () => { + const missing = { ...PAYLOAD } as Record; + delete missing.reservedKaId; + expect(() => assertCanonicalGraphScopedAuthorSealV1(missing)).toThrow( + /canonical-seal-schema/, + ); + expect(() => assertCanonicalGraphScopedAuthorSealV1({ ...PAYLOAD, legacy: true })) + .toThrow(/canonical-seal-schema/); + expect(() => parseCanonicalGraphScopedAuthorSealV1( + CANONICAL.replace( + '"assertionVersion":"2"', + '"assertionVersion":"2","assertionVersion":"2"', + ), + )).toThrow(/canonical-seal-schema/); + expect(() => parseCanonicalGraphScopedAuthorSealV1( + CANONICAL.replace('"assertionVersion":"2"', '"assertionVersion":2'), + )).toThrow(/canonical-seal-scalar/); + expect(() => parseCanonicalGraphScopedAuthorSealV1(` ${CANONICAL}`)) + .toThrow(/canonical-seal-schema/); + expect(() => parseCanonicalGraphScopedAuthorSealV1( + new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(CANONICAL)]), + )).toThrow(/canonical-seal-schema/); + expect(() => parseCanonicalGraphScopedAuthorSealV1(new Uint8Array([0xff]))) + .toThrow(/canonical-seal-schema/); + expect(() => parseCanonicalGraphScopedAuthorSealV1( + CANONICAL.replace('"privateMerkleRoot":null', '"privateMerkleRoot":{"x":null}'), + )).toThrow(/canonical-seal-schema/); + expect(() => parseCanonicalGraphScopedAuthorSealV1( + '{'.padEnd(MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1 + 1, 'x'), + )).toThrow(/canonical-seal-too-large/); + expectInvalid({ + kaUal: `did:dkg:${'n'.repeat(MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1)}/${AUTHOR}/7`, + }, /canonical-seal-too-large/); + }); + + it('rejects uppercase, invalid versions/counts, empty content, and bad private branches', () => { + expectInvalid({ authorAddress: `0x${'A'.repeat(40)}` }, /canonical-seal-scalar/); + expectInvalid({ authorAttestationR: `0x${'AA'.repeat(32)}` }, /canonical-seal-scalar/); + expectInvalid({ authorSchemeVersion: 1 }, /canonical-seal-schema/); + expectInvalid({ contentScopeVersion: '1' }, /canonical-seal-schema/); + expectInvalid({ assertionVersion: '0' }, /canonical-seal-scalar/); + expectInvalid({ assertionVersion: '18446744073709551616' }, /canonical-seal-scalar/); + expectInvalid({ publicTripleCount: '9007199254740992' }, /canonical-seal-count/); + expectInvalid({ publicTripleCount: '-1' }, /canonical-seal-scalar/); + expectInvalid({ publicTripleCount: '0' }, /canonical-seal-count/); + expectInvalid({ privateTripleCount: '1' }, /canonical-seal-private-root/); + expectInvalid({ privateMerkleRoot: `0x${'bb'.repeat(32)}` }, /canonical-seal-private-root/); + }); + + it('rejects noncanonical or internally inconsistent UAL identity', () => { + expectInvalid({ kaUal: `did:dkg:otp:20430/${AUTHOR}/07` }, /canonical-seal-ual/); + expectInvalid({ + kaUal: `did:dkg:otp:20430/0x5555555555555555555555555555555555555555/7`, + }, /canonical-seal-binding/); + expectInvalid({ reservedKaId: (BigInt(RESERVED_KA_ID) + 1n).toString() }, /canonical-seal-binding/); + expectInvalid({ + kaUal: `did:dkg:otp:20430/${AUTHOR}/${1n << 96n}`, + }, /canonical-seal-ual/); + }); + + it('rejects non-round-tripping UTC timestamps', () => { + for (const assertionFinalizedAt of [ + '2026-02-30T12:34:56.789Z', + '2026-07-19T12:34:60.789Z', + '2026-07-19T12:34:56.78Z', + '2026-07-19T12:34:56.789+00:00', + ]) { + expectInvalid({ assertionFinalizedAt }, /canonical-seal-timestamp/); + } + }); + + it('rejects a payload/coordinate author disagreement', () => { + const other = validatedCoordinate({ + ...COORDINATE, + authorAddress: '0x5555555555555555555555555555555555555555', + }); + expect(() => projectCanonicalGraphScopedAuthorSealRowsV1(PAYLOAD, other)) + .toThrow(/canonical-seal-binding/); + }); +}); + +function expectInvalid( + patch: Record, + expected: RegExp, +): void { + expect(() => assertCanonicalGraphScopedAuthorSealV1({ ...PAYLOAD, ...patch })) + .toThrow(expected); +} + +function validatedPayload(value: unknown): CanonicalGraphScopedAuthorSealV1 { + assertCanonicalGraphScopedAuthorSealV1(value); + return value; +} + +function validatedCoordinate(value: unknown): CanonicalGraphScopedAuthorSealCoordinateV1 { + assertCanonicalGraphScopedAuthorSealCoordinateV1(value); + return value; +} + +function toStoreRows( + rows: readonly CanonicalGraphScopedAuthorSealRowV1[], +): CanonicalAuthorSealStoreRowV1[] { + return rows.map((row) => ({ + subjectIri: row.subject, + predicateIri: row.predicate, + graphIri: row.graph, + object: toStoreObject(row.object), + })); +} + +function toStoreObject(object: string): CanonicalAuthorSealStoreRowV1['object'] { + if (object.startsWith('<')) { + return { kind: 'named-node', value: object.slice(1, -1) }; + } + const typed = /^"([^"]*)"\^\^<([^>]+)>$/.exec(object); + if (typed) { + return { kind: 'literal', value: typed[1], datatypeIri: typed[2] }; + } + return { + kind: 'literal', + value: JSON.parse(object) as string, + datatypeIri: `${XSD}string`, + }; +} From 06a80ac037ea6bc7e64a69a1240ecc0e09a0a122 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 04:22:38 +0200 Subject: [PATCH 021/292] fix(agent): harden inventory recovery safety --- packages/agent/src/rfc64/inventory-v1/open.ts | 232 +++++++++++++++--- .../test/rfc64-inventory-v1-lifecycle.test.ts | 211 +++++++++++++++- 2 files changed, 397 insertions(+), 46 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 2dd94be485..974c0397d1 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -13,7 +13,15 @@ import { unlinkSync, writeFileSync, } from 'node:fs'; -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; import { randomBytes } from 'node:crypto'; import { spawnSync } from 'node:child_process'; @@ -66,9 +74,10 @@ export interface Rfc64InventoryV1Foundation { export async function openInventoryV1(dataDir: string): Promise { const sqlite = await loadSqliteModule(); - const databasePath = resolve(dataDir, INVENTORY_V1_RELATIVE_PATH); + const resolvedDataDir = resolve(dataDir); + const databasePath = resolve(resolvedDataDir, INVENTORY_V1_RELATIVE_PATH); try { - prepareSecureDirectory(dirname(databasePath)); + prepareSecureDirectory(resolvedDataDir, dirname(databasePath)); finishPendingQuarantine(databasePath); const database = openOrRebuildOwnedDatabase(sqlite, databasePath); return new InventoryV1Foundation(sqlite, databasePath, database); @@ -192,6 +201,14 @@ function openOrRebuildOwnedDatabase( 'database application_id does not identify RFC-64 SQL-1 and will not be modified', ); } + if (identity.userVersion < INVENTORY_V1_USER_VERSION) { + database.close(); + database = null; + throw new InventoryV1OpenError( + 'ambiguous-database', + `inventory application_id is DK64 but user_version ${identity.userVersion} is not a committed v1 database`, + ); + } if (identity.userVersion > INVENTORY_V1_USER_VERSION) { database.close(); database = null; @@ -226,23 +243,81 @@ function openOrRebuildOwnedDatabase( tightenOwnedFileMode(databasePath); return database; } catch (error) { - if (database !== null) { + const closeProbe = (): void => { + if (database === null) return; try { database.close(); } catch { /* retain the original failure */ } + database = null; + }; + if (error instanceof InventoryV1OpenError) { + closeProbe(); + throw error; } - if (error instanceof InventoryV1OpenError) throw error; if (isCorruptSqliteError(error)) { - const ownership = classifyCorruptDatabaseOwnership(databasePath); - if (ownership === 'owned') { - beginQuarantine(databasePath); - finishPendingQuarantine(databasePath); - return openOrRebuildOwnedDatabase(sqlite, databasePath); + let ownership: CorruptDatabaseOwnershipV1; + try { + ownership = classifyCorruptDatabaseOwnership(databasePath); + } catch (cause) { + closeProbe(); + throw cause; } - throw new InventoryV1OpenError( - 'foreign-database', - 'corrupt SQLite database has a foreign application_id and will not be modified', - { cause: error }, - ); + if (ownership === 'ambiguous') { + closeProbe(); + throw new InventoryV1OpenError( + 'ambiguous-database', + 'corrupt database does not have a readable DK64 ownership identity and will not be modified', + { cause: error }, + ); + } + if (ownership === 'newer') { + closeProbe(); + throw new InventoryV1OpenError( + 'newer-schema', + 'corrupt DK64 database has a newer user_version and will not be modified', + { cause: error }, + ); + } + if (ownership === 'foreign') { + closeProbe(); + throw new InventoryV1OpenError( + 'foreign-database', + 'corrupt SQLite database has a foreign application_id and will not be modified', + { cause: error }, + ); + } + if (database === null) { + throw new InventoryV1OpenError( + 'database-io', + 'cannot prove exclusive access to the corrupt DK64 database; it will not be quarantined', + { cause: error }, + ); + } + try { + assertCorruptDatabaseQuiescent(database); + } catch (cause) { + closeProbe(); + if (cause instanceof InventoryV1OpenError) throw cause; + throw new InventoryV1OpenError( + 'database-io', + 'cannot prove exclusive access to the corrupt DK64 database; it will not be quarantined', + { cause }, + ); + } + try { + database.close(); + } catch (cause) { + database = null; + throw new InventoryV1OpenError( + 'database-io', + 'failed to close the corrupt DK64 database after proving quiescence; it will not be quarantined', + { cause }, + ); + } + database = null; + beginQuarantine(databasePath); + finishPendingQuarantine(databasePath); + return openOrRebuildOwnedDatabase(sqlite, databasePath); } + closeProbe(); if (isBusySqliteError(error)) { throw new InventoryV1OpenError( 'database-busy', @@ -366,6 +441,39 @@ function assertDatabaseQuiescent(database: DatabaseSyncV1): void { } } +function assertCorruptDatabaseQuiescent(database: DatabaseSyncV1): void { + let transactionOpen = false; + try { + // Probe the writer lock before corruption-sensitive schema/checkpoint work + // so a live WAL writer is reported as busy and is never split from the + // database file during quarantine. + database.exec('PRAGMA busy_timeout = 0'); + database.exec('BEGIN EXCLUSIVE'); + transactionOpen = true; + database.exec('ROLLBACK'); + transactionOpen = false; + } catch (cause) { + if (transactionOpen) { + try { database.exec('ROLLBACK'); } catch { /* retain the probe failure */ } + } + if (isBusySqliteError(cause)) { + throw new InventoryV1OpenError( + 'database-busy', + 'corrupt inventory database has an active writer and will not be quarantined', + { cause }, + ); + } + throw new InventoryV1OpenError( + 'database-io', + 'cannot prove exclusive access to the corrupt inventory database; it will not be quarantined', + { cause }, + ); + } finally { + try { database.exec('PRAGMA busy_timeout = 5000'); } catch { /* best-effort connection restore */ } + } + assertDatabaseQuiescent(database); +} + function applyAndVerifyPragmas(database: DatabaseSyncV1): void { database.exec(` PRAGMA foreign_keys = ON; @@ -402,10 +510,44 @@ function readPragmaInteger(database: DatabaseSyncV1, pragma: string): number { return value; } -function prepareSecureDirectory(directoryPath: string): void { - if (pathEntryExists(directoryPath)) rejectSymlink(directoryPath, 'inventory directory'); - mkdirSync(directoryPath, { recursive: true, mode: INVENTORY_V1_DIRECTORY_MODE }); - rejectSymlink(directoryPath, 'inventory directory'); +function prepareSecureDirectory(dataDirectory: string, directoryPath: string): void { + const relativeDirectory = relative(dataDirectory, directoryPath); + if ( + relativeDirectory === '..' + || relativeDirectory.startsWith(`..${sep}`) + || isAbsolute(relativeDirectory) + ) { + throw new InventoryV1OpenError( + 'unsafe-path', + 'inventory database directory must remain within the declared DKG data directory', + ); + } + + if (pathEntryExists(dataDirectory)) { + rejectSymlink(dataDirectory, 'DKG data directory'); + assertFilesystemOwner(dataDirectory); + } else { + // The declared data-directory boundary may itself be new. Components above + // that caller-selected boundary are intentionally outside adapter policy. + mkdirSync(dataDirectory, { recursive: true, mode: INVENTORY_V1_DIRECTORY_MODE }); + rejectSymlink(dataDirectory, 'DKG data directory'); + assertFilesystemOwner(dataDirectory); + } + + let currentDirectory = dataDirectory; + for (const component of relativeDirectory.split(sep).filter((value) => value.length !== 0)) { + // The current parent was owner-checked before this content mutation. + const nextDirectory = join(currentDirectory, component); + if (pathEntryExists(nextDirectory)) { + rejectSymlink(nextDirectory, 'inventory directory path component'); + assertFilesystemOwner(nextDirectory); + } else { + mkdirSync(nextDirectory, { mode: INVENTORY_V1_DIRECTORY_MODE }); + rejectSymlink(nextDirectory, 'inventory directory path component'); + assertFilesystemOwner(nextDirectory); + } + currentDirectory = nextDirectory; + } applySecurePermissions(directoryPath, INVENTORY_V1_DIRECTORY_MODE, true); } @@ -467,6 +609,10 @@ function assertOwnedUnitOwners(databasePath: string): void { } function applySecurePermissions(path: string, mode: number, directory: boolean): void { + // Never let chmod or Set-Acl turn a foreign existing entry into an owned one. + // Newly created entries are also checked because they must already be owned + // by this process before their permissions are tightened. + assertFilesystemOwner(path); if (process.platform === 'win32') { applyWindowsOwnerOnlyAcl(path, directory); return; @@ -586,22 +732,25 @@ function beginQuarantine(databasePath: string): void { rejectSymlink(markerPath, 'inventory recovery marker'); return; } - const quarantineRoot = join(dirname(databasePath), QUARANTINE_DIRECTORY); + const inventoryDirectory = dirname(databasePath); + const quarantineRoot = join(inventoryDirectory, QUARANTINE_DIRECTORY); if (pathEntryExists(quarantineRoot)) rejectSymlink(quarantineRoot, 'inventory quarantine directory'); mkdirSync(quarantineRoot, { recursive: true, mode: INVENTORY_V1_DIRECTORY_MODE }); rejectSymlink(quarantineRoot, 'inventory quarantine directory'); applySecurePermissions(quarantineRoot, INVENTORY_V1_DIRECTORY_MODE, true); + fsyncDirectory(inventoryDirectory); const suffix = `${Date.now()}-${randomBytes(8).toString('hex')}`; const quarantineDirectory = join(quarantineRoot, `inventory-v1-${suffix}`); mkdirSync(quarantineDirectory, { mode: INVENTORY_V1_DIRECTORY_MODE }); rejectSymlink(quarantineDirectory, 'inventory quarantine generation'); applySecurePermissions(quarantineDirectory, INVENTORY_V1_DIRECTORY_MODE, true); + fsyncDirectory(quarantineRoot); const marker: RecoveryMarkerV1 = { version: 1, quarantineDirectory }; writeFileSync(markerPath, JSON.stringify(marker), { encoding: 'utf8', flag: 'wx', mode: INVENTORY_V1_FILE_MODE }); applySecurePermissions(markerPath, INVENTORY_V1_FILE_MODE, false); const descriptor = openSync(markerPath, 'r'); try { fsyncSync(descriptor); } finally { closeSync(descriptor); } - fsyncDirectory(dirname(databasePath)); + fsyncDirectory(inventoryDirectory); } function finishPendingQuarantine(databasePath: string): void { @@ -622,6 +771,10 @@ function finishPendingQuarantine(databasePath: string): void { rejectSymlink(marker.quarantineDirectory, 'inventory quarantine generation'); assertFilesystemOwner(marker.quarantineDirectory); applySecurePermissions(marker.quarantineDirectory, INVENTORY_V1_DIRECTORY_MODE, true); + // Make both levels of the quarantine destination durable before evidence is + // moved under the recovery marker. + fsyncDirectory(inventoryDirectory); + fsyncDirectory(quarantineRoot); for (const suffix of OWNED_FILE_SUFFIXES) { const source = `${databasePath}${suffix}`; if (!pathEntryExists(source)) continue; @@ -633,9 +786,12 @@ function finishPendingQuarantine(databasePath: string): void { } renameSync(source, target); } + // Persist destination names and source removals before deleting the marker. + // A crash before either fsync leaves the durable marker to resume safely. fsyncDirectory(marker.quarantineDirectory); + fsyncDirectory(inventoryDirectory); unlinkSync(markerPath); - fsyncDirectory(dirname(databasePath)); + fsyncDirectory(inventoryDirectory); } function parseRecoveryMarker(value: string, inventoryDirectory: string): RecoveryMarkerV1 { @@ -682,22 +838,27 @@ function isBusySqliteError(error: unknown): boolean { return errcode === 5 || errcode === 6; } -type CorruptDatabaseOwnershipV1 = 'owned' | 'foreign'; +type CorruptDatabaseOwnershipV1 = 'owned' | 'ambiguous' | 'foreign' | 'newer'; + +interface SqliteHeaderIdentityV1 { + applicationId: number; + userVersion: number; +} function classifyCorruptDatabaseOwnership(databasePath: string): CorruptDatabaseOwnershipV1 { - const applicationId = readValidSqliteHeaderApplicationId(databasePath); - if (applicationId === null || applicationId === INVENTORY_V1_APPLICATION_ID || applicationId === 0) { - return 'owned'; - } - return 'foreign'; + const identity = readValidSqliteHeaderIdentity(databasePath); + if (identity === null || identity.applicationId === 0) return 'ambiguous'; + if (identity.applicationId !== INVENTORY_V1_APPLICATION_ID) return 'foreign'; + if (identity.userVersion > INVENTORY_V1_USER_VERSION) return 'newer'; + return identity.userVersion === INVENTORY_V1_USER_VERSION ? 'owned' : 'ambiguous'; } function refuseValidForeignSqliteHeader(databasePath: string): void { - const applicationId = readValidSqliteHeaderApplicationId(databasePath); + const identity = readValidSqliteHeaderIdentity(databasePath); if ( - applicationId !== null - && applicationId !== 0 - && applicationId !== INVENTORY_V1_APPLICATION_ID + identity !== null + && identity.applicationId !== 0 + && identity.applicationId !== INVENTORY_V1_APPLICATION_ID ) { throw new InventoryV1OpenError( 'foreign-database', @@ -706,7 +867,7 @@ function refuseValidForeignSqliteHeader(databasePath: string): void { } } -function readValidSqliteHeaderApplicationId(databasePath: string): number | null { +function readValidSqliteHeaderIdentity(databasePath: string): SqliteHeaderIdentityV1 | null { let descriptor: number | undefined; try { descriptor = openSync(databasePath, 'r'); @@ -715,7 +876,10 @@ function readValidSqliteHeaderApplicationId(databasePath: string): number | null if (bytesRead < header.byteLength || header.subarray(0, 16).toString('binary') !== 'SQLite format 3\u0000') { return null; } - return header.readUInt32BE(68); + return { + applicationId: header.readUInt32BE(68), + userVersion: header.readUInt32BE(60), + }; } catch (cause) { throw new InventoryV1OpenError( 'database-io', diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index 79ee2ed048..c8b432723f 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, readdirSync, + realpathSync, rmSync, statSync, symlinkSync, @@ -16,7 +17,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { INVENTORY_V1_APPLICATION_ID, @@ -31,7 +32,10 @@ import { const temporaryDirectories: string[] = []; function temporaryDataDirectory(): string { - const directory = mkdtempSync(join(tmpdir(), 'dkg-rfc64-sql1-')); + // macOS exposes /var as a symlink to /private/var. Use the canonical test + // root so the production component-wise no-symlink rule is exercised only + // by symlinks intentionally created by each test. + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-sql1-'))); temporaryDirectories.push(directory); return directory; } @@ -124,6 +128,17 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { } }); + it('initializes beneath a previously nonexistent declared dataDir suffix', async () => { + const container = temporaryDataDirectory(); + const dataDirectory = join(container, 'new-data', 'nested'); + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(databasePath(dataDirectory)); + } finally { + foundation.close(); + } + }); + it('executes the frozen DDL as STRICT tables with the named bucket index', () => { const database = new DatabaseSync(':memory:'); try { @@ -189,6 +204,24 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { expectNoQuarantine(path); }); + it('refuses an uncommitted DK64 user_version=0 database as ambiguous', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const partial = new DatabaseSync(path); + partial.exec(` + CREATE TABLE partial_data (value TEXT); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + `); + partial.close(); + const before = readFileSync(path); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + expect(readFileSync(path)).toEqual(before); + expectNoQuarantine(path); + }); + it('refuses a newer owned user_version without quarantine', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -235,22 +268,72 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { } }); - it('quarantines NOTADB bytes at the dedicated path and preserves them as evidence', async () => { + it('refuses NOTADB bytes without manufacturing DK64 ownership or quarantine', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); mkdirSync(dirname(path), { recursive: true }); const corruptBytes = Buffer.from('not-a-sqlite-database\n'); writeFileSync(path, corruptBytes); - const foundation = await openInventoryV1(dataDirectory); - try { - assertInitializedInventory(path); - const generations = quarantineGenerations(path); - expect(generations).toHaveLength(1); - expect(readFileSync(join(generations[0]!, 'inventory-v1.sqlite3'))).toEqual(corruptBytes); - } finally { - foundation.close(); - } + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + expect(readFileSync(path)).toEqual(corruptBytes); + expectNoQuarantine(path); + }); + + it('refuses a corrupt application_id=0 database with user data as ambiguous', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const ambiguous = new DatabaseSync(path); + ambiguous.exec('CREATE TABLE existing_data (value TEXT)'); + ambiguous.close(); + truncateSync(path, 100); + const before = readFileSync(path); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + expect(readFileSync(path)).toEqual(before); + expectNoQuarantine(path); + }); + + it('refuses a corrupt owned database with a newer header user_version', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const newer = new DatabaseSync(path); + newer.exec(` + CREATE TABLE future_schema (value TEXT); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 2; + `); + newer.close(); + truncateSync(path, 100); + const before = readFileSync(path); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'newer-schema')); + expect(readFileSync(path)).toEqual(before); + expectNoQuarantine(path); + }); + + it('refuses a corrupt uncommitted DK64 user_version=0 database as ambiguous', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const partial = new DatabaseSync(path); + partial.exec(` + CREATE TABLE partial_data (value TEXT); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + `); + partial.close(); + truncateSync(path, 100); + const before = readFileSync(path); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + expect(readFileSync(path)).toEqual(before); + expectNoQuarantine(path); }); it('does not quarantine a corrupt SQLite file whose readable header has a foreign app id', async () => { @@ -319,6 +402,42 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { }, ); + it.runIf(process.platform !== 'win32')( + 'refuses a symlinked dataDir before creating anything through it', + async () => { + const realDataDirectory = temporaryDataDirectory(); + const linkContainer = temporaryDataDirectory(); + const linkedDataDirectory = join(linkContainer, 'data-dir-link'); + symlinkSync(realDataDirectory, linkedDataDirectory); + + await expect(openInventoryV1(linkedDataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'unsafe-path')); + expect(readdirSync(realDataDirectory)).toEqual([]); + expect(existsSync(databasePath(realDataDirectory))).toBe(false); + expectNoQuarantine(databasePath(linkedDataDirectory)); + }, + ); + + it.runIf(process.platform !== 'win32')( + 'refuses a foreign-owned dataDir before creating its inventory child', + async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const originalMode = statSync(dataDirectory).mode & 0o777; + const actualUid = process.getuid(); + const uid = vi.spyOn(process, 'getuid').mockReturnValue(actualUid + 1); + try { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + } finally { + uid.mockRestore(); + } + expect(existsSync(dirname(path))).toBe(false); + expect(statSync(dataDirectory).mode & 0o777).toBe(originalMode); + expectNoQuarantine(path); + }, + ); + it('refuses orphaned sidecars without deleting them', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -372,6 +491,38 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { } }); + it('does not split a corrupt owned database from a live WAL writer', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const seed = new DatabaseSync(path); + seed.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE owned_data (value TEXT); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 1; + `); + seed.close(); + const holder = new DatabaseSync(path); + holder.exec(` + PRAGMA journal_mode = WAL; + BEGIN IMMEDIATE; + INSERT INTO owned_data VALUES ('live-writer'); + `); + truncateSync(path, 100); + const before = readFileSync(path); + try { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + expect(readFileSync(path)).toEqual(before); + expect(existsSync(`${path}-wal`)).toBe(true); + expectNoQuarantine(path); + } finally { + try { holder.exec('ROLLBACK'); } catch { /* the main file is intentionally corrupt */ } + try { holder.close(); } catch { /* the main file is intentionally corrupt */ } + } + }); + it('resumes a partial recovery-marker move before rebuilding', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -402,6 +553,42 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { } }); + it('retains the recovery marker across an interrupted evidence move and resumes', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const inventoryDirectory = dirname(path); + const generation = join( + inventoryDirectory, + 'quarantine', + 'inventory-v1-1234567890-bbbbbbbbbbbbbbbb', + ); + mkdirSync(generation, { recursive: true }); + writeFileSync(path, 'main-before-crash'); + writeFileSync(`${path}-wal`, 'wal-before-crash'); + writeFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'conflicting-target'); + writeFileSync( + `${path}.rebuild-required`, + JSON.stringify({ version: 1, quarantineDirectory: generation }), + ); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + expect(existsSync(`${path}.rebuild-required`)).toBe(true); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe('main-before-crash'); + expect(readFileSync(`${path}-wal`, 'utf8')).toBe('wal-before-crash'); + + rmSync(join(generation, 'inventory-v1.sqlite3-wal')); + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe('main-before-crash'); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'utf8')).toBe('wal-before-crash'); + } finally { + foundation.close(); + } + }); + it('explicitly quarantines and rebuilds an open owned database', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); From 45f263582a944275096f4690ef478d5a356b7afb Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 05:21:01 +0200 Subject: [PATCH 022/292] fix(core): freeze RFC-64 signature suites --- packages/core/src/sync-control-object.ts | 11 +++++++---- packages/core/test/sync-control-object.test.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index ccee6317c9..e1c6e9fb05 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -25,10 +25,10 @@ export const EIP191_SIGNATURE_BYTES = 65; export const MAX_EIP1271_SIGNATURE_BYTES = 4096; export const MAX_CONTROL_SIGNATURE_VARIANT_BYTES = 16 * 1024; -export const CONTROL_OBJECT_SIGNATURE_SUITES = [ +export const CONTROL_OBJECT_SIGNATURE_SUITES = Object.freeze([ 'eip191-personal-sign-digest-v1', 'eip1271-current-finalized-v1', -] as const; +] as const); export type ControlObjectSignatureSuite = (typeof CONTROL_OBJECT_SIGNATURE_SUITES)[number]; @@ -318,8 +318,11 @@ function assertUnsignedControlEnvelopeFields( if (/[-]/u.test(envelope.objectType)) { throw new Error('Control-object type is outside the canonical string bounds'); } - if (!CONTROL_OBJECT_SIGNATURE_SUITES.includes(envelope.signatureSuite)) { - throw new Error(`Unsupported control-object signature suite: ${String(envelope.signatureSuite)}`); + if ( + envelope.signatureSuite !== 'eip191-personal-sign-digest-v1' + && envelope.signatureSuite !== 'eip1271-current-finalized-v1' + ) { + throw new Error('Unsupported control-object signature suite'); } assertCanonicalEvmAddress(envelope.issuer, 'issuer'); diff --git a/packages/core/test/sync-control-object.test.ts b/packages/core/test/sync-control-object.test.ts index d959a4afd7..43e384ba87 100644 --- a/packages/core/test/sync-control-object.test.ts +++ b/packages/core/test/sync-control-object.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + CONTROL_OBJECT_SIGNATURE_SUITES, MAX_CONTROL_OBJECT_BYTES, MAX_EIP1271_SIGNATURE_BYTES, assertControlObjectDigest, @@ -178,6 +179,18 @@ describe('Track-2 control-object envelopes', () => { })).toThrow(/EIP-1271 signature evidence|unknown or missing fields/); }); + it('keeps the exact signature-suite registry immutable and non-authoritative', () => { + expect(Object.isFrozen(CONTROL_OBJECT_SIGNATURE_SUITES)).toBe(true); + expect(() => (CONTROL_OBJECT_SIGNATURE_SUITES as unknown as string[]).push('evil-suite')) + .toThrow(); + expect(() => assertUnsignedControlEnvelope({ + ...SAFE_VECTOR, + signatureSuite: 'evil-suite', + } as unknown as UnsignedControlEnvelopeV1)).toThrow( + /Unsupported control-object signature suite/, + ); + }); + it.each(['01', '+1', '-1', '1.0', '1e3', ''])('rejects non-canonical chainId %j', (chainId) => { expect(() => assertUnsignedControlEnvelope({ ...SAFE_VECTOR, From a6644d00a5fa13988ac115bb78d97f344075c31b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 05:31:28 +0200 Subject: [PATCH 023/292] feat(core): add RFC-64 policy and roster codecs --- packages/core/src/cg-policy-objects.ts | 779 +++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/test/cg-policy-objects.test.ts | 445 +++++++++++ 3 files changed, 1225 insertions(+) create mode 100644 packages/core/src/cg-policy-objects.ts create mode 100644 packages/core/test/cg-policy-objects.test.ts diff --git a/packages/core/src/cg-policy-objects.ts b/packages/core/src/cg-policy-objects.ts new file mode 100644 index 0000000000..14e283fdcd --- /dev/null +++ b/packages/core/src/cg-policy-objects.ts @@ -0,0 +1,779 @@ +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + assertContextGraphIdV1, + assertNetworkIdV1, + type ContextGraphIdV1, + type NetworkIdV1, +} from './author-catalog-codec.js'; +import { + assertSignedControlEnvelope, + assertUnsignedControlEnvelope, + canonicalizeSignedControlEnvelopeBytes, + canonicalizeUnsignedControlEnvelopeBytes, + computeControlObjectDigestHex, + parseCanonicalSignedControlEnvelope, + parseCanonicalUnsignedControlEnvelope, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from './sync-control-object.js'; +import { + assertCanonicalChainId, + assertCanonicalDecimalU256, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalTimestampMs, + parseCanonicalDecimalU64, + type ChainIdV1, + type DecimalU256V1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type TimestampMsV1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +export const CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1 = 'ContextGraphPolicyV1' as const; +export const MEMBER_ROSTER_OBJECT_TYPE_V1 = 'MemberRosterV1' as const; +export const CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1 = 'cg-shared-v1' as const; +export const MAX_CONTEXT_GRAPH_POLICY_PAYLOAD_BYTES_V1 = 8 * 1024; +export const MAX_MEMBER_ROSTER_PAYLOAD_BYTES_V1 = 4 * 1024 * 1024; +export const MAX_MEMBER_ROSTER_ENTRIES_V1 = 16_384; +export const MEMBER_ROSTER_ROLES_V1 = Object.freeze([ + 'holder', + 'ingress-host', + 'provider', +] as const); + +export type ContextGraphAccessPolicyV1 = 0 | 1; +export type ContextGraphPublishPolicyV1 = 0 | 1; +export type MemberRosterRoleV1 = (typeof MEMBER_ROSTER_ROLES_V1)[number]; + +export interface FinalizedChainPolicySourceV1 { + readonly kind: 'finalized-chain'; + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly blockNumber: DecimalU64V1; + readonly blockHash: Digest32V1; +} + +export interface OwnerSignedUnregisteredPolicySourceV1 { + readonly kind: 'owner-signed-unregistered'; + readonly ownerAddress: EvmAddressV1; + readonly ownerAuthorityEra: DecimalU64V1; +} + +export type ContextGraphPolicySourceV1 = + | FinalizedChainPolicySourceV1 + | OwnerSignedUnregisteredPolicySourceV1; + +export interface ContextGraphPolicyV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly governanceChainId: ChainIdV1 | null; + readonly governanceContractAddress: EvmAddressV1 | null; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly era: DecimalU64V1; + readonly version: DecimalU64V1; + readonly previousPolicyDigest: Digest32V1 | null; + readonly accessPolicy: ContextGraphAccessPolicyV1; + readonly publishPolicy: ContextGraphPublishPolicyV1; + readonly publishAuthority: EvmAddressV1 | null; + readonly publishAuthorityAccountId: DecimalU256V1; + readonly projectionId: typeof CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1; + readonly administrativeDelegationDigest: Digest32V1 | null; + readonly source: ContextGraphPolicySourceV1; + readonly effectiveAt: TimestampMsV1; + readonly issuedAt: TimestampMsV1; +} + +export interface MemberRosterEntryV1 { + readonly agentAddress: EvmAddressV1; + readonly roles: readonly MemberRosterRoleV1[]; +} + +export interface MemberRosterV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly era: DecimalU64V1; + readonly version: DecimalU64V1; + readonly previousRosterDigest: Digest32V1 | null; + readonly policyDigest: Digest32V1; + readonly administrativeDelegationDigest: Digest32V1 | null; + readonly members: readonly MemberRosterEntryV1[]; + readonly issuedAt: TimestampMsV1; +} + +export type UnsignedContextGraphPolicyEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1; + readonly payload: ContextGraphPolicyV1; +}; +export type SignedContextGraphPolicyEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1; + readonly payload: ContextGraphPolicyV1; +}; +export type UnsignedMemberRosterEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof MEMBER_ROSTER_OBJECT_TYPE_V1; + readonly payload: MemberRosterV1; +}; +export type SignedMemberRosterEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof MEMBER_ROSTER_OBJECT_TYPE_V1; + readonly payload: MemberRosterV1; +}; + +export type CgPolicyObjectCodecErrorCode = + | 'cg-policy-schema' + | 'cg-policy-scalar' + | 'cg-policy-type' + | 'cg-policy-governance' + | 'cg-policy-publish-domain' + | 'cg-policy-array' + | 'cg-policy-order' + | 'cg-policy-duplicate' + | 'cg-policy-role' + | 'cg-policy-payload-too-large'; + +export class CgPolicyObjectCodecError extends Error { + constructor( + readonly code: CgPolicyObjectCodecErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'CgPolicyObjectCodecError'; + } +} + +export function assertContextGraphPolicyV1( + value: unknown, +): asserts value is ContextGraphPolicyV1 { + validateContextGraphPolicySnapshotV1(value); +} + +export function canonicalizeContextGraphPolicyPayloadV1( + policy: ContextGraphPolicyV1, +): string { + return validateContextGraphPolicySnapshotV1(policy).canonical; +} + +export function canonicalizeContextGraphPolicyPayloadBytesV1( + policy: ContextGraphPolicyV1, +): Uint8Array { + return new TextEncoder().encode(canonicalizeContextGraphPolicyPayloadV1(policy)); +} + +export function parseCanonicalContextGraphPolicyPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): ContextGraphPolicyV1 { + rejectOversizedInput(input, MAX_CONTEXT_GRAPH_POLICY_PAYLOAD_BYTES_V1, 'CG policy'); + const value = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_CONTEXT_GRAPH_POLICY_PAYLOAD_BYTES_V1, + MAX_CONTEXT_GRAPH_POLICY_PAYLOAD_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 2, 2), + }); + return validateContextGraphPolicySnapshotV1(value).snapshot; +} + +export function assertMemberRosterV1(value: unknown): asserts value is MemberRosterV1 { + validateMemberRosterSnapshotV1(value); +} + +export function canonicalizeMemberRosterPayloadV1(roster: MemberRosterV1): string { + return validateMemberRosterSnapshotV1(roster).canonical; +} + +export function canonicalizeMemberRosterPayloadBytesV1(roster: MemberRosterV1): Uint8Array { + return new TextEncoder().encode(canonicalizeMemberRosterPayloadV1(roster)); +} + +export function parseCanonicalMemberRosterPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): MemberRosterV1 { + rejectOversizedInput(input, MAX_MEMBER_ROSTER_PAYLOAD_BYTES_V1, 'member roster'); + const value = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_MEMBER_ROSTER_PAYLOAD_BYTES_V1, + MAX_MEMBER_ROSTER_PAYLOAD_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 4, 4), + }); + return validateMemberRosterSnapshotV1(value).snapshot; +} + +export function assertUnsignedContextGraphPolicyEnvelopeV1( + envelope: UnsignedControlEnvelopeV1, +): asserts envelope is UnsignedContextGraphPolicyEnvelopeV1 { + validateUnsignedContextGraphPolicyEnvelopeSnapshotV1(envelope); +} + +export function assertSignedContextGraphPolicyEnvelopeV1( + envelope: SignedControlEnvelopeV1, +): asserts envelope is SignedContextGraphPolicyEnvelopeV1 { + validateSignedContextGraphPolicyEnvelopeSnapshotV1(envelope); +} + +export function assertUnsignedMemberRosterEnvelopeV1( + envelope: UnsignedControlEnvelopeV1, +): asserts envelope is UnsignedMemberRosterEnvelopeV1 { + validateUnsignedMemberRosterEnvelopeSnapshotV1(envelope); +} + +export function assertSignedMemberRosterEnvelopeV1( + envelope: SignedControlEnvelopeV1, +): asserts envelope is SignedMemberRosterEnvelopeV1 { + validateSignedMemberRosterEnvelopeSnapshotV1(envelope); +} + +export function canonicalizeUnsignedContextGraphPolicyEnvelopeBytesV1( + envelope: UnsignedControlEnvelopeV1, +): Uint8Array { + const snapshot = validateUnsignedContextGraphPolicyEnvelopeSnapshotV1(envelope); + return canonicalizeUnsignedControlEnvelopeBytes(snapshot); +} + +export function canonicalizeSignedContextGraphPolicyEnvelopeBytesV1( + envelope: SignedControlEnvelopeV1, +): Uint8Array { + const snapshot = validateSignedContextGraphPolicyEnvelopeSnapshotV1(envelope); + return canonicalizeSignedControlEnvelopeBytes(snapshot); +} + +export function canonicalizeUnsignedMemberRosterEnvelopeBytesV1( + envelope: UnsignedControlEnvelopeV1, +): Uint8Array { + const snapshot = validateUnsignedMemberRosterEnvelopeSnapshotV1(envelope); + return canonicalizeUnsignedControlEnvelopeBytes(snapshot); +} + +export function canonicalizeSignedMemberRosterEnvelopeBytesV1( + envelope: SignedControlEnvelopeV1, +): Uint8Array { + const snapshot = validateSignedMemberRosterEnvelopeSnapshotV1(envelope); + return canonicalizeSignedControlEnvelopeBytes(snapshot); +} + +export function computeContextGraphPolicyObjectDigestV1( + envelope: UnsignedControlEnvelopeV1, +): Digest32V1 { + const snapshot = validateUnsignedContextGraphPolicyEnvelopeSnapshotV1(envelope); + return asDigest(computeControlObjectDigestHex(snapshot)); +} + +export function computeMemberRosterObjectDigestV1( + envelope: UnsignedControlEnvelopeV1, +): Digest32V1 { + const snapshot = validateUnsignedMemberRosterEnvelopeSnapshotV1(envelope); + return asDigest(computeControlObjectDigestHex(snapshot)); +} + +export function parseCanonicalUnsignedContextGraphPolicyEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedContextGraphPolicyEnvelopeV1 { + const envelope = parseCanonicalUnsignedControlEnvelope(input, options); + return validateUnsignedContextGraphPolicyEnvelopeSnapshotV1(envelope); +} + +export function parseCanonicalSignedContextGraphPolicyEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedContextGraphPolicyEnvelopeV1 { + const envelope = parseCanonicalSignedControlEnvelope(input, options); + return validateSignedContextGraphPolicyEnvelopeSnapshotV1(envelope); +} + +export function parseCanonicalUnsignedMemberRosterEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedMemberRosterEnvelopeV1 { + const envelope = parseCanonicalUnsignedControlEnvelope(input, options); + return validateUnsignedMemberRosterEnvelopeSnapshotV1(envelope); +} + +export function parseCanonicalSignedMemberRosterEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedMemberRosterEnvelopeV1 { + const envelope = parseCanonicalSignedControlEnvelope(input, options); + return validateSignedMemberRosterEnvelopeSnapshotV1(envelope); +} + +function validateUnsignedContextGraphPolicyEnvelopeSnapshotV1( + value: unknown, +): UnsignedContextGraphPolicyEnvelopeV1 { + assertUnsignedContextGraphPolicyEnvelopePlainV1(value); + const snapshot = clonePlainData(value, 'unsigned CG policy envelope'); + assertUnsignedContextGraphPolicyEnvelopePlainV1(snapshot); + return snapshot; +} + +function validateSignedContextGraphPolicyEnvelopeSnapshotV1( + value: unknown, +): SignedContextGraphPolicyEnvelopeV1 { + assertSignedContextGraphPolicyEnvelopePlainV1(value); + const snapshot = clonePlainData(value, 'signed CG policy envelope'); + assertSignedContextGraphPolicyEnvelopePlainV1(snapshot); + return snapshot; +} + +function validateUnsignedMemberRosterEnvelopeSnapshotV1( + value: unknown, +): UnsignedMemberRosterEnvelopeV1 { + assertUnsignedMemberRosterEnvelopePlainV1(value); + const snapshot = clonePlainData(value, 'unsigned member roster envelope'); + assertUnsignedMemberRosterEnvelopePlainV1(snapshot); + return snapshot; +} + +function validateSignedMemberRosterEnvelopeSnapshotV1( + value: unknown, +): SignedMemberRosterEnvelopeV1 { + assertSignedMemberRosterEnvelopePlainV1(value); + const snapshot = clonePlainData(value, 'signed member roster envelope'); + assertSignedMemberRosterEnvelopePlainV1(snapshot); + return snapshot; +} + +function assertUnsignedContextGraphPolicyEnvelopePlainV1( + value: unknown, +): asserts value is UnsignedContextGraphPolicyEnvelopeV1 { + const envelope = value as UnsignedControlEnvelopeV1; + assertUnsignedControlEnvelope(envelope); + assertObjectType(envelope.objectType, CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1); + validateContextGraphPolicyPlainV1(envelope.payload); +} + +function assertSignedContextGraphPolicyEnvelopePlainV1( + value: unknown, +): asserts value is SignedContextGraphPolicyEnvelopeV1 { + const envelope = value as SignedControlEnvelopeV1; + assertSignedControlEnvelope(envelope); + assertObjectType(envelope.objectType, CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1); + validateContextGraphPolicyPlainV1(envelope.payload); +} + +function assertUnsignedMemberRosterEnvelopePlainV1( + value: unknown, +): asserts value is UnsignedMemberRosterEnvelopeV1 { + const envelope = value as UnsignedControlEnvelopeV1; + assertUnsignedControlEnvelope(envelope); + assertObjectType(envelope.objectType, MEMBER_ROSTER_OBJECT_TYPE_V1); + validateMemberRosterPlainV1(envelope.payload); +} + +function assertSignedMemberRosterEnvelopePlainV1( + value: unknown, +): asserts value is SignedMemberRosterEnvelopeV1 { + const envelope = value as SignedControlEnvelopeV1; + assertSignedControlEnvelope(envelope); + assertObjectType(envelope.objectType, MEMBER_ROSTER_OBJECT_TYPE_V1); + validateMemberRosterPlainV1(envelope.payload); +} + +function assertContextGraphPolicyStructureV1( + value: unknown, +): asserts value is ContextGraphPolicyV1 { + if (!isPlainRecord(value)) fail('cg-policy-schema', 'CG policy must be a plain object'); + closed(value, [ + 'accessPolicy', + 'administrativeDelegationDigest', + 'contextGraphId', + 'effectiveAt', + 'era', + 'governanceChainId', + 'governanceContractAddress', + 'issuedAt', + 'networkId', + 'ownershipTransitionDigest', + 'previousPolicyDigest', + 'projectionId', + 'publishAuthority', + 'publishAuthorityAccountId', + 'publishPolicy', + 'source', + 'version', + ], 'CG policy'); + scalar(() => assertNetworkIdV1(value.networkId)); + scalar(() => assertContextGraphIdV1(value.contextGraphId)); + assertGovernancePair(value.governanceChainId, value.governanceContractAddress); + optionalDigest(value.ownershipTransitionDigest, 'ownershipTransitionDigest'); + u64(value.era, 'era'); + u64(value.version, 'version'); + optionalDigest(value.previousPolicyDigest, 'previousPolicyDigest'); + assertPolicyEnum(value.accessPolicy, 'accessPolicy'); + assertPolicyEnum(value.publishPolicy, 'publishPolicy'); + if (value.publishAuthority !== null) { + scalar(() => assertCanonicalEvmAddress(value.publishAuthority, 'publishAuthority')); + } + scalar(() => assertCanonicalDecimalU256( + value.publishAuthorityAccountId, + 'publishAuthorityAccountId', + )); + if (value.publishPolicy === 1) { + if (value.publishAuthority !== null || value.publishAuthorityAccountId !== '0') { + fail( + 'cg-policy-publish-domain', + 'open contribution requires null publishAuthority and account ID zero', + ); + } + } else if (value.publishAuthority === null) { + fail('cg-policy-publish-domain', 'curated contribution requires publishAuthority'); + } + if (value.projectionId !== CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1) { + fail('cg-policy-scalar', 'projectionId must be cg-shared-v1'); + } + optionalDigest(value.administrativeDelegationDigest, 'administrativeDelegationDigest'); + assertPolicySource( + value.source, + value.governanceChainId, + value.governanceContractAddress, + value.ownershipTransitionDigest, + ); + scalar(() => assertCanonicalTimestampMs(value.effectiveAt, 'effectiveAt')); + scalar(() => assertCanonicalTimestampMs(value.issuedAt, 'issuedAt')); +} + +function validateContextGraphPolicySnapshotV1(value: unknown): { + readonly snapshot: ContextGraphPolicyV1; + readonly canonical: string; +} { + const bounded = validateContextGraphPolicyPlainV1(value); + const snapshot = clonePlainData(bounded.snapshot, 'CG policy'); + return validateContextGraphPolicyPlainV1(snapshot); +} + +function validateContextGraphPolicyPlainV1(value: unknown): { + readonly snapshot: ContextGraphPolicyV1; + readonly canonical: string; +} { + assertContextGraphPolicyStructureV1(value); + return { snapshot: value, canonical: canonicalizePolicyAfterStructure(value) }; +} + +function assertPolicySource( + source: unknown, + governanceChainId: unknown, + governanceContractAddress: unknown, + ownershipTransitionDigest: unknown, +): void { + if (!isPlainRecord(source)) { + fail('cg-policy-schema', 'policy source must be a closed object'); + } + const kindDescriptor = Object.getOwnPropertyDescriptor(source, 'kind'); + if ( + !kindDescriptor?.enumerable + || !Object.prototype.hasOwnProperty.call(kindDescriptor, 'value') + || typeof kindDescriptor.value !== 'string' + ) { + fail('cg-policy-schema', 'policy source kind must be an enumerable data field'); + } + const kind = kindDescriptor.value; + if (kind === 'finalized-chain') { + closed(source, [ + 'blockHash', + 'blockNumber', + 'chainId', + 'contractAddress', + 'kind', + ], 'finalized-chain policy source'); + scalar(() => assertCanonicalChainId(source.chainId)); + scalar(() => assertCanonicalEvmAddress(source.contractAddress, 'contractAddress')); + u64(source.blockNumber, 'blockNumber'); + scalar(() => assertCanonicalDigest(source.blockHash, 'blockHash')); + if ( + governanceChainId === null + || governanceContractAddress === null + || governanceChainId !== source.chainId + || governanceContractAddress !== source.contractAddress + ) { + fail('cg-policy-governance', 'finalized source must equal the governance tuple'); + } + return; + } + if (kind === 'owner-signed-unregistered') { + closed(source, ['kind', 'ownerAddress', 'ownerAuthorityEra'], 'owner policy source'); + scalar(() => assertCanonicalEvmAddress(source.ownerAddress, 'ownerAddress')); + u64(source.ownerAuthorityEra, 'ownerAuthorityEra'); + if (governanceChainId !== null || governanceContractAddress !== null) { + fail('cg-policy-governance', 'unregistered source requires a null governance tuple'); + } + if (ownershipTransitionDigest !== null) { + fail('cg-policy-governance', 'unregistered source requires a null ownership transition'); + } + return; + } + fail('cg-policy-schema', 'policy source kind is not supported by v1'); +} + +function assertMemberRosterStructureV1(value: unknown): asserts value is MemberRosterV1 { + if (!isPlainRecord(value)) fail('cg-policy-schema', 'member roster must be a plain object'); + closed(value, [ + 'administrativeDelegationDigest', + 'contextGraphId', + 'era', + 'issuedAt', + 'members', + 'networkId', + 'ownershipTransitionDigest', + 'policyDigest', + 'previousRosterDigest', + 'version', + ], 'member roster'); + scalar(() => assertNetworkIdV1(value.networkId)); + scalar(() => assertContextGraphIdV1(value.contextGraphId)); + optionalDigest(value.ownershipTransitionDigest, 'ownershipTransitionDigest'); + u64(value.era, 'era'); + u64(value.version, 'version'); + optionalDigest(value.previousRosterDigest, 'previousRosterDigest'); + scalar(() => assertCanonicalDigest(value.policyDigest, 'policyDigest')); + optionalDigest(value.administrativeDelegationDigest, 'administrativeDelegationDigest'); + assertDenseArray(value.members, 'members', MAX_MEMBER_ROSTER_ENTRIES_V1); + let previousAddress: string | undefined; + for (let index = 0; index < value.members.length; index += 1) { + const member = value.members[index]; + if (!isPlainRecord(member)) fail('cg-policy-schema', `member ${index} must be an object`); + closed(member, ['agentAddress', 'roles'], `member ${index}`); + const agentAddressValue = member.agentAddress; + scalar(() => assertCanonicalEvmAddress(agentAddressValue, `members[${index}].agentAddress`)); + const agentAddress = agentAddressValue as EvmAddressV1; + if (previousAddress !== undefined && previousAddress >= agentAddress) { + fail( + previousAddress === agentAddress ? 'cg-policy-duplicate' : 'cg-policy-order', + 'members must be strictly sorted by agentAddress', + ); + } + previousAddress = agentAddress; + assertDenseArray(member.roles, `members[${index}].roles`, MEMBER_ROSTER_ROLES_V1.length); + let previousRole: string | undefined; + for (let roleIndex = 0; roleIndex < member.roles.length; roleIndex += 1) { + const roleValue = member.roles[roleIndex]; + if (typeof roleValue !== 'string') { + fail('cg-policy-role', 'member role must be a string'); + } + if (!isMemberRosterRoleV1(roleValue)) { + fail('cg-policy-role', `unknown member role ${roleValue}`); + } + const role = roleValue as MemberRosterRoleV1; + if (previousRole !== undefined && previousRole >= role) { + fail( + previousRole === role ? 'cg-policy-duplicate' : 'cg-policy-order', + 'member roles must be strictly sorted', + ); + } + previousRole = role; + } + } + scalar(() => assertCanonicalTimestampMs(value.issuedAt, 'issuedAt')); +} + +function validateMemberRosterSnapshotV1(value: unknown): { + readonly snapshot: MemberRosterV1; + readonly canonical: string; +} { + const bounded = validateMemberRosterPlainV1(value); + const snapshot = clonePlainData(bounded.snapshot, 'member roster'); + return validateMemberRosterPlainV1(snapshot); +} + +function validateMemberRosterPlainV1(value: unknown): { + readonly snapshot: MemberRosterV1; + readonly canonical: string; +} { + assertMemberRosterStructureV1(value); + return { snapshot: value, canonical: canonicalizeRosterAfterStructure(value) }; +} + +function clonePlainData(value: T, label: string): T { + assertStablePlainDataShape(value, label, 0, new Set()); + try { + return structuredClone(value); + } catch (cause) { + fail('cg-policy-schema', `${label} must be stable structured-cloneable JSON data`, cause); + } +} + +function assertStablePlainDataShape( + value: unknown, + label: string, + depth: number, + ancestors: Set, +): void { + if (depth > 64) fail('cg-policy-schema', `${label} exceeds the generic nesting cap`); + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (!Number.isFinite(value)) fail('cg-policy-schema', `${label} contains a non-JSON number`); + return; + } + if (typeof value !== 'object') { + fail('cg-policy-schema', `${label} contains a non-JSON implementation value`); + } + if (ancestors.has(value)) fail('cg-policy-schema', `${label} contains a cycle`); + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + fail('cg-policy-schema', `${label} contains a non-ordinary array`); + } + const keys = Reflect.ownKeys(value); + if ( + keys.some((key) => typeof key !== 'string') + || keys.length !== value.length + 1 + ) { + fail('cg-policy-schema', `${label} contains a sparse or custom array`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('cg-policy-schema', `${label} contains an array accessor`); + } + assertStablePlainDataShape( + descriptor.value, + `${label}[${index}]`, + depth + 1, + ancestors, + ); + } + return; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + fail('cg-policy-schema', `${label} contains a non-plain object`); + } + const keys = Reflect.ownKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== 'string') { + fail('cg-policy-schema', `${label} contains a symbol property`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('cg-policy-schema', `${label} contains an object accessor`); + } + assertStablePlainDataShape( + descriptor.value, + `${label}.${key}`, + depth + 1, + ancestors, + ); + } + } finally { + ancestors.delete(value); + } +} + +function isMemberRosterRoleV1(value: string): value is MemberRosterRoleV1 { + return value === 'holder' || value === 'ingress-host' || value === 'provider'; +} + +function assertGovernancePair(chainId: unknown, contractAddress: unknown): void { + if ((chainId === null) !== (contractAddress === null)) { + fail('cg-policy-governance', 'governance chain and contract must be jointly null/non-null'); + } + if (chainId !== null) scalar(() => assertCanonicalChainId(chainId)); + if (contractAddress !== null) { + scalar(() => assertCanonicalEvmAddress(contractAddress, 'governanceContractAddress')); + } +} + +function assertPolicyEnum(value: unknown, label: string): asserts value is 0 | 1 { + if (value !== 0 && value !== 1) fail('cg-policy-scalar', `${label} must be JSON number 0 or 1`); +} + +function optionalDigest(value: unknown, label: string): void { + if (value !== null) scalar(() => assertCanonicalDigest(value, label)); +} + +function u64(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU64(value, label); + } catch (cause) { + fail('cg-policy-scalar', `${label} must be canonical DecimalU64V1`, cause); + } +} + +function scalar(operation: () => void): void { + try { + operation(); + } catch (cause) { + fail('cg-policy-scalar', 'policy/roster scalar is not canonical', cause); + } +} + +function closed(record: Record, keys: readonly string[], label: string): void { + try { + assertExactKeys(record, keys, label); + } catch (cause) { + fail('cg-policy-schema', `${label} has an invalid field set`, cause); + } +} + +function assertDenseArray(value: unknown, label: string, maxLength: number): asserts value is unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + fail('cg-policy-array', `${label} must be an ordinary array`); + } + if (value.length > maxLength) { + fail('cg-policy-array', `${label} exceeds ${maxLength} entries`); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string') || keys.length !== value.length + 1) { + fail('cg-policy-array', `${label} must be dense and contain no custom properties`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('cg-policy-array', `${label} must contain ordinary enumerable values`); + } + } +} + +function canonicalizePolicyAfterStructure(policy: ContextGraphPolicyV1): string { + try { + return canonicalizeJson(policy as unknown as CanonicalJsonValue, { + maxBytes: MAX_CONTEXT_GRAPH_POLICY_PAYLOAD_BYTES_V1, + maxDepth: 2, + }); + } catch (cause) { + fail('cg-policy-payload-too-large', 'CG policy exceeds its canonical cap', cause); + } +} + +function canonicalizeRosterAfterStructure(roster: MemberRosterV1): string { + try { + return canonicalizeJson(roster as unknown as CanonicalJsonValue, { + maxBytes: MAX_MEMBER_ROSTER_PAYLOAD_BYTES_V1, + maxDepth: 4, + }); + } catch (cause) { + fail('cg-policy-payload-too-large', 'member roster exceeds its canonical cap', cause); + } +} + +function assertObjectType(actual: string, expected: string): void { + if (actual !== expected) fail('cg-policy-type', `objectType must be exactly ${expected}`); +} + +function asDigest(value: string): Digest32V1 { + assertCanonicalDigest(value, 'objectDigest'); + return value; +} + +function rejectOversizedInput(input: string | Uint8Array, maxBytes: number, label: string): void { + const bytes = typeof input === 'string' ? new TextEncoder().encode(input).byteLength : input.byteLength; + if (bytes > maxBytes) fail('cg-policy-payload-too-large', `${label} exceeds ${maxBytes} bytes`); +} + +function fail(code: CgPolicyObjectCodecErrorCode, message: string, cause?: unknown): never { + throw new CgPolicyObjectCodecError(code, message, cause === undefined ? {} : { cause }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f5846bf2b0..a04e391377 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export * from './publisher-extension.js'; export * from './imported-artifact-bytes.js'; export * from './imported-artifact-metadata.js'; export * from './sync-control-object.js'; +export * from './cg-policy-objects.js'; export { MAX_DECIMAL_U64, MAX_DECIMAL_U256, diff --git a/packages/core/test/cg-policy-objects.test.ts b/packages/core/test/cg-policy-objects.test.ts new file mode 100644 index 0000000000..ba9c40d651 --- /dev/null +++ b/packages/core/test/cg-policy-objects.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, it } from 'vitest'; + +import { + CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + MAX_CONTEXT_GRAPH_POLICY_PAYLOAD_BYTES_V1, + MAX_MEMBER_ROSTER_PAYLOAD_BYTES_V1, + MAX_MEMBER_ROSTER_ENTRIES_V1, + MEMBER_ROSTER_ROLES_V1, + MEMBER_ROSTER_OBJECT_TYPE_V1, + assertContextGraphPolicyV1, + assertMemberRosterV1, + assertSignedContextGraphPolicyEnvelopeV1, + assertSignedMemberRosterEnvelopeV1, + assertUnsignedContextGraphPolicyEnvelopeV1, + assertUnsignedMemberRosterEnvelopeV1, + canonicalizeContextGraphPolicyPayloadV1, + canonicalizeMemberRosterPayloadV1, + canonicalizeSignedContextGraphPolicyEnvelopeBytesV1, + canonicalizeSignedMemberRosterEnvelopeBytesV1, + canonicalizeUnsignedContextGraphPolicyEnvelopeBytesV1, + canonicalizeUnsignedMemberRosterEnvelopeBytesV1, + computeContextGraphPolicyObjectDigestV1, + computeMemberRosterObjectDigestV1, + parseCanonicalContextGraphPolicyPayloadV1, + parseCanonicalMemberRosterPayloadV1, + parseCanonicalSignedContextGraphPolicyEnvelopeV1, + parseCanonicalSignedMemberRosterEnvelopeV1, + parseCanonicalUnsignedContextGraphPolicyEnvelopeV1, + parseCanonicalUnsignedMemberRosterEnvelopeV1, + type ContextGraphPolicyV1, + type MemberRosterV1, +} from '../src/cg-policy-objects.js'; +import { + CONTROL_OBJECT_SIGNATURE_SUITES, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '../src/sync-control-object.js'; + +const ISSUER = '0x5555555555555555555555555555555555555555'; +const SIGNATURE = `0x${'77'.repeat(65)}`; +const POLICY_DIGEST = '0x757a6c30157d416fc7f15e20a2ea4b4ccb57fb4d06000861d5c2bd47102a6db6'; +const ROSTER_DIGEST = '0x38bcd20907a5df67651bee6df47d774e4345f907a8e23ddf34a3b32c0933b02d'; + +const POLICY = validatedPolicy({ + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/policy-fixture', + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: 'cg-shared-v1', + administrativeDelegationDigest: null, + source: { + kind: 'finalized-chain', + chainId: '20430', + contractAddress: '0x2222222222222222222222222222222222222222', + blockNumber: '123', + blockHash: `0x${'33'.repeat(32)}`, + }, + effectiveAt: '1700000000000', + issuedAt: '1700000000123', +}); + +const ROSTER = validatedRoster({ + networkId: POLICY.networkId, + contextGraphId: POLICY.contextGraphId, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousRosterDigest: null, + policyDigest: POLICY_DIGEST, + administrativeDelegationDigest: null, + members: [ + { + agentAddress: '0x1111111111111111111111111111111111111111', + roles: ['holder', 'provider'], + }, + { + agentAddress: '0x2222222222222222222222222222222222222222', + roles: [], + }, + ], + issuedAt: '1700000000123', +}); + +const POLICY_CANONICAL = '{"accessPolicy":0,"administrativeDelegationDigest":null,"contextGraphId":"0x1111111111111111111111111111111111111111/policy-fixture","effectiveAt":"1700000000000","era":"0","governanceChainId":"20430","governanceContractAddress":"0x2222222222222222222222222222222222222222","issuedAt":"1700000000123","networkId":"otp:20430","ownershipTransitionDigest":null,"previousPolicyDigest":null,"projectionId":"cg-shared-v1","publishAuthority":null,"publishAuthorityAccountId":"0","publishPolicy":1,"source":{"blockHash":"0x3333333333333333333333333333333333333333333333333333333333333333","blockNumber":"123","chainId":"20430","contractAddress":"0x2222222222222222222222222222222222222222","kind":"finalized-chain"},"version":"0"}'; +const ROSTER_CANONICAL = '{"administrativeDelegationDigest":null,"contextGraphId":"0x1111111111111111111111111111111111111111/policy-fixture","era":"0","issuedAt":"1700000000123","members":[{"agentAddress":"0x1111111111111111111111111111111111111111","roles":["holder","provider"]},{"agentAddress":"0x2222222222222222222222222222222222222222","roles":[]}],"networkId":"otp:20430","ownershipTransitionDigest":null,"policyDigest":"0x757a6c30157d416fc7f15e20a2ea4b4ccb57fb4d06000861d5c2bd47102a6db6","previousRosterDigest":null,"version":"0"}'; +const POLICY_UNSIGNED = unsigned(CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, POLICY); +const ROSTER_UNSIGNED = unsigned(MEMBER_ROSTER_OBJECT_TYPE_V1, ROSTER); +const POLICY_UNSIGNED_CANONICAL = '{"issuer":"0x5555555555555555555555555555555555555555","objectType":"ContextGraphPolicyV1","payload":{"accessPolicy":0,"administrativeDelegationDigest":null,"contextGraphId":"0x1111111111111111111111111111111111111111/policy-fixture","effectiveAt":"1700000000000","era":"0","governanceChainId":"20430","governanceContractAddress":"0x2222222222222222222222222222222222222222","issuedAt":"1700000000123","networkId":"otp:20430","ownershipTransitionDigest":null,"previousPolicyDigest":null,"projectionId":"cg-shared-v1","publishAuthority":null,"publishAuthorityAccountId":"0","publishPolicy":1,"source":{"blockHash":"0x3333333333333333333333333333333333333333333333333333333333333333","blockNumber":"123","chainId":"20430","contractAddress":"0x2222222222222222222222222222222222222222","kind":"finalized-chain"},"version":"0"},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}'; +const ROSTER_UNSIGNED_CANONICAL = '{"issuer":"0x5555555555555555555555555555555555555555","objectType":"MemberRosterV1","payload":{"administrativeDelegationDigest":null,"contextGraphId":"0x1111111111111111111111111111111111111111/policy-fixture","era":"0","issuedAt":"1700000000123","members":[{"agentAddress":"0x1111111111111111111111111111111111111111","roles":["holder","provider"]},{"agentAddress":"0x2222222222222222222222222222222222222222","roles":[]}],"networkId":"otp:20430","ownershipTransitionDigest":null,"policyDigest":"0x757a6c30157d416fc7f15e20a2ea4b4ccb57fb4d06000861d5c2bd47102a6db6","previousRosterDigest":null,"version":"0"},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}'; +const POLICY_SIGNED = signed(POLICY_UNSIGNED, POLICY_DIGEST); +const ROSTER_SIGNED = signed(ROSTER_UNSIGNED, ROSTER_DIGEST); + +describe('ContextGraphPolicyV1 codec', () => { + it('pins canonical payload/envelope bytes and digest', () => { + const canonical = canonicalizeContextGraphPolicyPayloadV1(POLICY); + expect(canonical).toBe(POLICY_CANONICAL); + expect(new TextEncoder().encode(canonical)).toHaveLength(722); + expect(parseCanonicalContextGraphPolicyPayloadV1(canonical)).toEqual(POLICY); + const unsignedCanonical = new TextDecoder().decode( + canonicalizeUnsignedContextGraphPolicyEnvelopeBytesV1(POLICY_UNSIGNED), + ); + expect(unsignedCanonical).toBe(POLICY_UNSIGNED_CANONICAL); + expect(new TextEncoder().encode(unsignedCanonical)).toHaveLength(910); + expect(computeContextGraphPolicyObjectDigestV1(POLICY_UNSIGNED)).toBe(POLICY_DIGEST); + expect(parseCanonicalUnsignedContextGraphPolicyEnvelopeV1( + canonicalUnsigned(POLICY_UNSIGNED), + )).toEqual(POLICY_UNSIGNED); + expect(parseCanonicalSignedContextGraphPolicyEnvelopeV1( + new TextDecoder().decode(canonicalizeSignedContextGraphPolicyEnvelopeBytesV1(POLICY_SIGNED)), + )).toEqual(POLICY_SIGNED); + }); + + it('enforces finalized and unregistered governance source branches', () => { + expect(() => assertContextGraphPolicyV1({ + ...POLICY, + source: { ...POLICY.source, chainId: '1' }, + })).toThrow(/cg-policy-governance/); + expect(() => assertContextGraphPolicyV1({ + ...POLICY, + governanceChainId: null, + governanceContractAddress: null, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: ISSUER, + ownerAuthorityEra: '0', + }, + })).not.toThrow(); + expect(() => assertContextGraphPolicyV1({ + ...POLICY, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: `0x${'44'.repeat(32)}`, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: ISSUER, + ownerAuthorityEra: '0', + }, + })).toThrow(/cg-policy-governance/); + expect(() => assertContextGraphPolicyV1({ + ...POLICY, + governanceContractAddress: null, + })).toThrow(/cg-policy-governance/); + }); + + it('keeps contribution policy independent and closes its authority branch', () => { + for (const cell of [ + { accessPolicy: 0, publishPolicy: 1, publishAuthority: null, publishAuthorityAccountId: '0' }, + { accessPolicy: 1, publishPolicy: 1, publishAuthority: null, publishAuthorityAccountId: '0' }, + { accessPolicy: 0, publishPolicy: 0, publishAuthority: ISSUER, publishAuthorityAccountId: '0' }, + { accessPolicy: 1, publishPolicy: 0, publishAuthority: ISSUER, publishAuthorityAccountId: '0' }, + { accessPolicy: 1, publishPolicy: 0, publishAuthority: ISSUER, publishAuthorityAccountId: '7' }, + ]) { + expect(() => assertContextGraphPolicyV1({ ...POLICY, ...cell })).not.toThrow(); + } + expect(() => assertContextGraphPolicyV1({ ...POLICY, publishAuthority: ISSUER })) + .toThrow(/cg-policy-publish-domain/); + expect(() => assertContextGraphPolicyV1({ ...POLICY, publishPolicy: 0 })) + .toThrow(/cg-policy-publish-domain/); + for (const value of [2, '0', null]) { + expect(() => assertContextGraphPolicyV1({ ...POLICY, accessPolicy: value })) + .toThrow(/cg-policy-scalar/); + } + expect(() => assertContextGraphPolicyV1({ ...POLICY, publishPolicy: 2 })) + .toThrow(/cg-policy-scalar/); + }); + + it('rejects unknown fields, wrong projection, malformed scalars, and noncanonical wire', () => { + expect(() => assertContextGraphPolicyV1({ ...POLICY, subGraphName: null })) + .toThrow(/cg-policy-schema/); + expect(() => assertContextGraphPolicyV1({ ...POLICY, projectionId: 'other' })) + .toThrow(/cg-policy-scalar/); + expect(() => assertContextGraphPolicyV1({ ...POLICY, version: 0 })) + .toThrow(/cg-policy-scalar/); + expect(() => parseCanonicalContextGraphPolicyPayloadV1( + canonicalizeContextGraphPolicyPayloadV1(POLICY).replace('"accessPolicy":0', '"accessPolicy": 0'), + )).toThrow(); + }); + + it('rejects source accessors without invoking them', () => { + let getterCalls = 0; + const source = { ...POLICY.source } as Record; + Object.defineProperty(source, 'kind', { + enumerable: true, + get() { + getterCalls += 1; + return 'finalized-chain'; + }, + }); + expect(() => assertContextGraphPolicyV1({ ...POLICY, source })) + .toThrow(/cg-policy-schema/); + expect(getterCalls).toBe(0); + }); + + it('enforces the object-specific policy byte and nesting caps before schema use', () => { + const oversized = `{"x":"${'a'.repeat(MAX_CONTEXT_GRAPH_POLICY_PAYLOAD_BYTES_V1)}"}`; + expect(() => parseCanonicalContextGraphPolicyPayloadV1(oversized)) + .toThrow(/cg-policy-payload-too-large/); + expect(() => parseCanonicalContextGraphPolicyPayloadV1('{"a":{"b":{}}}')) + .toThrow(/nesting exceeds 2/); + }); + + it('enforces exact envelope type and validates signed and unsigned variants', () => { + expect(Object.isFrozen(CONTROL_OBJECT_SIGNATURE_SUITES)).toBe(true); + expect(() => (CONTROL_OBJECT_SIGNATURE_SUITES as unknown as string[]).push('evil-suite')) + .toThrow(); + expect(() => assertUnsignedContextGraphPolicyEnvelopeV1(POLICY_UNSIGNED)).not.toThrow(); + expect(() => assertSignedContextGraphPolicyEnvelopeV1(POLICY_SIGNED)).not.toThrow(); + expect(() => assertUnsignedContextGraphPolicyEnvelopeV1({ + ...POLICY_UNSIGNED, + objectType: MEMBER_ROSTER_OBJECT_TYPE_V1, + })).toThrow(/cg-policy-type/); + expect(() => assertUnsignedContextGraphPolicyEnvelopeV1({ + ...POLICY_UNSIGNED, + signatureSuite: 'evil-suite', + signatureEvidence: { + kind: 'eip1271-current-finalized', + chainId: '20430', + contractAddress: ISSUER, + }, + } as unknown as UnsignedControlEnvelopeV1)).toThrow(/Unsupported control-object signature suite/); + }); + + it('snapshots the complete envelope once and rejects stateful proxies', () => { + let payloadReads = 0; + const envelopeProxy = new Proxy(POLICY_UNSIGNED, { + get(target, property, receiver) { + if (property === 'payload') { + payloadReads += 1; + return payloadReads <= 2 ? POLICY : { evil: true }; + } + return Reflect.get(target, property, receiver); + }, + }); + expect(() => canonicalizeUnsignedContextGraphPolicyEnvelopeBytesV1(envelopeProxy)) + .toThrow(/cg-policy-schema/); + expect(payloadReads).toBe(2); + }); +}); + +describe('MemberRosterV1 codec', () => { + it('pins canonical payload/envelope bytes and digest', () => { + expect(canonicalizeMemberRosterPayloadV1(ROSTER)).toBe(ROSTER_CANONICAL); + expect(new TextEncoder().encode(ROSTER_CANONICAL)).toHaveLength(513); + expect(parseCanonicalMemberRosterPayloadV1(ROSTER_CANONICAL)).toEqual(ROSTER); + const unsignedCanonical = new TextDecoder().decode( + canonicalizeUnsignedMemberRosterEnvelopeBytesV1(ROSTER_UNSIGNED), + ); + expect(unsignedCanonical).toBe(ROSTER_UNSIGNED_CANONICAL); + expect(new TextEncoder().encode(unsignedCanonical)).toHaveLength(695); + expect(computeMemberRosterObjectDigestV1(ROSTER_UNSIGNED)).toBe(ROSTER_DIGEST); + expect(parseCanonicalUnsignedMemberRosterEnvelopeV1(canonicalUnsigned(ROSTER_UNSIGNED))) + .toEqual(ROSTER_UNSIGNED); + expect(parseCanonicalSignedMemberRosterEnvelopeV1( + new TextDecoder().decode(canonicalizeSignedMemberRosterEnvelopeBytesV1(ROSTER_SIGNED)), + )).toEqual(ROSTER_SIGNED); + }); + + it('requires strictly sorted unique members and the closed sorted role registry', () => { + expect(Object.isFrozen(MEMBER_ROSTER_ROLES_V1)).toBe(true); + expect(() => (MEMBER_ROSTER_ROLES_V1 as unknown as string[]).push('administrator')) + .toThrow(); + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [...ROSTER.members].reverse(), + })).toThrow(/cg-policy-order/); + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [ROSTER.members[0], ROSTER.members[0]], + })).toThrow(/cg-policy-duplicate/); + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [{ ...ROSTER.members[0], roles: ['provider', 'holder'] }], + })).toThrow(/cg-policy-order/); + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [{ ...ROSTER.members[0], roles: ['holder', 'holder'] }], + })).toThrow(/cg-policy-duplicate/); + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [{ ...ROSTER.members[0], roles: ['administrator'] }], + })).toThrow(/cg-policy-role/); + }); + + it('never consumes a poisoned inherited roles iterator', () => { + const roles = ['administrator']; + const originalIterator = Array.prototype[Symbol.iterator]; + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value(this: unknown[]) { + if (this === roles) return [][Symbol.iterator](); + return originalIterator.call(this); + }, + }); + try { + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [{ ...ROSTER.members[0], roles }], + })).toThrow(/cg-policy-role/); + } finally { + Object.defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + writable: true, + value: originalIterator, + }); + } + }); + + it('rejects stateful proxies and non-string roles without coercing them', () => { + let proxyReads = 0; + const roles = new Proxy(['holder'], { + get(target, property, receiver) { + if (property === '0') { + proxyReads += 1; + return proxyReads === 1 ? 'holder' : 'administrator'; + } + return Reflect.get(target, property, receiver); + }, + }); + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [{ ...ROSTER.members[0], roles }], + })).toThrow(/cg-policy-schema/); + + let coercions = 0; + const hostileRole = { + [Symbol.toPrimitive]() { + coercions += 1; + return 'provider'; + }, + }; + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [{ ...ROSTER.members[0], roles: [hostileRole] }], + })).toThrow(/cg-policy-role/); + expect(coercions).toBe(0); + }); + + it('rejects subgraph scope and hostile array shapes', () => { + expect(() => assertMemberRosterV1({ ...ROSTER, subGraphName: 'forbidden' })) + .toThrow(/cg-policy-schema/); + expect(() => assertMemberRosterV1({ ...ROSTER, members: new Array(1) })) + .toThrow(/cg-policy-array/); + const custom = [...ROSTER.members]; + Object.defineProperty(custom, 'extra', { value: true, enumerable: true }); + expect(() => assertMemberRosterV1({ ...ROSTER, members: custom })) + .toThrow(/cg-policy-array/); + const symbol = [...ROSTER.members]; + Object.defineProperty(symbol, Symbol('x'), { value: true, enumerable: true }); + expect(() => assertMemberRosterV1({ ...ROSTER, members: symbol })) + .toThrow(/cg-policy-array/); + + let getterCalls = 0; + const roles = [...ROSTER.members[0].roles]; + Object.defineProperty(roles, '0', { + enumerable: true, + get() { + getterCalls += 1; + return 'holder'; + }, + }); + expect(() => assertMemberRosterV1({ + ...ROSTER, + members: [{ ...ROSTER.members[0], roles }], + })).toThrow(/cg-policy-array/); + expect(getterCalls).toBe(0); + }); + + it('accepts an empty roster but enforces the 16,384-entry hard cap', () => { + expect(() => assertMemberRosterV1({ ...ROSTER, members: [] })).not.toThrow(); + const atCap = Array.from({ length: MAX_MEMBER_ROSTER_ENTRIES_V1 }, (_, index) => ({ + agentAddress: `0x${(index + 1).toString(16).padStart(40, '0')}`, + roles: ['holder', 'ingress-host', 'provider'] as const, + })); + const atCapCanonical = canonicalizeMemberRosterPayloadV1({ ...ROSTER, members: atCap }); + expect(new TextEncoder().encode(atCapCanonical).byteLength) + .toBeLessThanOrEqual(MAX_MEMBER_ROSTER_PAYLOAD_BYTES_V1); + const tooMany = [ + ...atCap, + { + agentAddress: `0x${(MAX_MEMBER_ROSTER_ENTRIES_V1 + 1).toString(16).padStart(40, '0')}`, + roles: [], + }, + ]; + expect(() => assertMemberRosterV1({ ...ROSTER, members: tooMany })) + .toThrow(/cg-policy-array/); + }); + + it('enforces the object-specific roster byte and nesting caps before schema use', () => { + const oversized = `{"x":"${'a'.repeat(MAX_MEMBER_ROSTER_PAYLOAD_BYTES_V1)}"}`; + expect(() => parseCanonicalMemberRosterPayloadV1(oversized)) + .toThrow(/cg-policy-payload-too-large/); + expect(() => parseCanonicalMemberRosterPayloadV1('{"a":[{"b":[{}]}]}')) + .toThrow(/nesting exceeds 4/); + }); + + it('enforces exact envelope type and signed digest', () => { + expect(() => assertUnsignedMemberRosterEnvelopeV1(ROSTER_UNSIGNED)).not.toThrow(); + expect(() => assertSignedMemberRosterEnvelopeV1(ROSTER_SIGNED)).not.toThrow(); + expect(() => assertSignedMemberRosterEnvelopeV1({ + ...ROSTER_SIGNED, + objectDigest: `0x${'00'.repeat(32)}`, + })).toThrow(/digest mismatch/i); + }); +}); + +function validatedPolicy(value: unknown): ContextGraphPolicyV1 { + assertContextGraphPolicyV1(value); + return value; +} + +function validatedRoster(value: unknown): MemberRosterV1 { + assertMemberRosterV1(value); + return value; +} + +function unsigned(objectType: string, payload: ContextGraphPolicyV1 | MemberRosterV1) { + return { + issuer: ISSUER, + objectType, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; +} + +function signed(envelope: UnsignedControlEnvelopeV1, objectDigest: string) { + return { ...envelope, objectDigest, signature: SIGNATURE } as SignedControlEnvelopeV1; +} + +function canonicalUnsigned(envelope: UnsignedControlEnvelopeV1): string { + return new TextDecoder().decode( + envelope.objectType === CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1 + ? canonicalizeUnsignedContextGraphPolicyEnvelopeBytesV1(envelope) + : canonicalizeUnsignedMemberRosterEnvelopeBytesV1(envelope), + ); +} From 2f132bac640e44b1fdb5cbcb0e50afa98121e0cf Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 05:48:50 +0200 Subject: [PATCH 024/292] fix(core): keep signature registry authoritative --- packages/core/src/sync-control-object.ts | 12 ++++++++---- packages/core/test/sync-control-object.test.ts | 11 ++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index e1c6e9fb05..85436e8db5 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -318,10 +318,7 @@ function assertUnsignedControlEnvelopeFields( if (/[-]/u.test(envelope.objectType)) { throw new Error('Control-object type is outside the canonical string bounds'); } - if ( - envelope.signatureSuite !== 'eip191-personal-sign-digest-v1' - && envelope.signatureSuite !== 'eip1271-current-finalized-v1' - ) { + if (!isControlObjectSignatureSuite(envelope.signatureSuite)) { throw new Error('Unsupported control-object signature suite'); } assertCanonicalEvmAddress(envelope.issuer, 'issuer'); @@ -359,6 +356,13 @@ function assertUnsignedControlEnvelopeFields( } } +function isControlObjectSignatureSuite( + value: unknown, +): value is ControlObjectSignatureSuite { + return typeof value === 'string' + && CONTROL_OBJECT_SIGNATURE_SUITES.includes(value as ControlObjectSignatureSuite); +} + function assertSignatureForSuite( signature: string, suite: ControlObjectSignatureSuite, diff --git a/packages/core/test/sync-control-object.test.ts b/packages/core/test/sync-control-object.test.ts index 43e384ba87..ebb938c643 100644 --- a/packages/core/test/sync-control-object.test.ts +++ b/packages/core/test/sync-control-object.test.ts @@ -14,6 +14,7 @@ import { parseCanonicalControlSignatureVariant, parseCanonicalSignedControlEnvelope, parseCanonicalUnsignedControlEnvelope, + type ControlObjectSignatureSuite, type SignedControlEnvelopeV1, type UnsignedControlEnvelopeV1, } from '../src/sync-control-object.js'; @@ -179,10 +180,18 @@ describe('Track-2 control-object envelopes', () => { })).toThrow(/EIP-1271 signature evidence|unknown or missing fields/); }); - it('keeps the exact signature-suite registry immutable and non-authoritative', () => { + it('keeps the immutable signature-suite registry authoritative', () => { expect(Object.isFrozen(CONTROL_OBJECT_SIGNATURE_SUITES)).toBe(true); expect(() => (CONTROL_OBJECT_SIGNATURE_SUITES as unknown as string[]).push('evil-suite')) .toThrow(); + const validEnvelopeBySuite = { + 'eip191-personal-sign-digest-v1': EOA_VECTOR, + 'eip1271-current-finalized-v1': SAFE_VECTOR, + } satisfies Record; + for (const suite of CONTROL_OBJECT_SIGNATURE_SUITES) { + expect(validEnvelopeBySuite[suite].signatureSuite).toBe(suite); + expect(() => assertUnsignedControlEnvelope(validEnvelopeBySuite[suite])).not.toThrow(); + } expect(() => assertUnsignedControlEnvelope({ ...SAFE_VECTOR, signatureSuite: 'evil-suite', From f6857ae1b95692bdc0bd836e32ea16b53916d1f2 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 05:49:21 +0200 Subject: [PATCH 025/292] fix(core): harden RFC-64 policy wire bounds --- packages/core/src/cg-policy-objects.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/core/src/cg-policy-objects.ts b/packages/core/src/cg-policy-objects.ts index 14e283fdcd..a91efcb130 100644 --- a/packages/core/src/cg-policy-objects.ts +++ b/packages/core/src/cg-policy-objects.ts @@ -555,23 +555,26 @@ function assertMemberRosterStructureV1(value: unknown): asserts value is MemberR } previousAddress = agentAddress; assertDenseArray(member.roles, `members[${index}].roles`, MEMBER_ROSTER_ROLES_V1.length); - let previousRole: string | undefined; + let previousRole: MemberRosterRoleV1 | undefined; + let previousRoleIndex = -1; for (let roleIndex = 0; roleIndex < member.roles.length; roleIndex += 1) { const roleValue = member.roles[roleIndex]; if (typeof roleValue !== 'string') { fail('cg-policy-role', 'member role must be a string'); } - if (!isMemberRosterRoleV1(roleValue)) { + const canonicalRoleIndex = memberRosterRoleIndexV1(roleValue); + if (canonicalRoleIndex < 0) { fail('cg-policy-role', `unknown member role ${roleValue}`); } const role = roleValue as MemberRosterRoleV1; - if (previousRole !== undefined && previousRole >= role) { + if (previousRole !== undefined && previousRoleIndex >= canonicalRoleIndex) { fail( previousRole === role ? 'cg-policy-duplicate' : 'cg-policy-order', 'member roles must be strictly sorted', ); } previousRole = role; + previousRoleIndex = canonicalRoleIndex; } } scalar(() => assertCanonicalTimestampMs(value.issuedAt, 'issuedAt')); @@ -673,8 +676,11 @@ function assertStablePlainDataShape( } } -function isMemberRosterRoleV1(value: string): value is MemberRosterRoleV1 { - return value === 'holder' || value === 'ingress-host' || value === 'provider'; +function memberRosterRoleIndexV1(value: string): number { + for (let index = 0; index < MEMBER_ROSTER_ROLES_V1.length; index += 1) { + if (MEMBER_ROSTER_ROLES_V1[index] === value) return index; + } + return -1; } function assertGovernancePair(chainId: unknown, contractAddress: unknown): void { @@ -770,7 +776,12 @@ function asDigest(value: string): Digest32V1 { } function rejectOversizedInput(input: string | Uint8Array, maxBytes: number, label: string): void { - const bytes = typeof input === 'string' ? new TextEncoder().encode(input).byteLength : input.byteLength; + if (typeof input === 'string' && input.length > maxBytes) { + fail('cg-policy-payload-too-large', `${label} exceeds ${maxBytes} bytes`); + } + const bytes = typeof input === 'string' + ? new TextEncoder().encode(input).byteLength + : input.byteLength; if (bytes > maxBytes) fail('cg-policy-payload-too-large', `${label} exceeds ${maxBytes} bytes`); } From 2a4d3e4042bb05bc9e6380541a2dc35c7ad0b096 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 05:51:00 +0200 Subject: [PATCH 026/292] test(core): prove numeric catalog row ordering --- .../core/test/author-catalog-objects.test.ts | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/core/test/author-catalog-objects.test.ts b/packages/core/test/author-catalog-objects.test.ts index 943d002b7c..f48aa99adf 100644 --- a/packages/core/test/author-catalog-objects.test.ts +++ b/packages/core/test/author-catalog-objects.test.ts @@ -136,13 +136,25 @@ describe('AuthorCatalogBucketV1 structural codec', () => { }); it('requires strictly numeric row order and rejects duplicate KA/key/coordinate', () => { - const second = rowForNumber(2n, 'fixture-2'); - const ordered = { ...VALID_BUCKET, rows: [VALID_ROW, second] }; - expect(() => assertAuthorCatalogBucketV1(ordered)).not.toThrow(); + const numericTwo = validatedRow({ + ...VALID_ROW, + kaId: '2', + assertionCoordinate: 'numeric-2', + }); + const numericTen = validatedRow({ + ...VALID_ROW, + kaId: '10', + assertionCoordinate: 'numeric-10', + }); + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + rows: [numericTwo, numericTen], + })).not.toThrow(); expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, - rows: [second, VALID_ROW], + rows: [numericTen, numericTwo], })).toThrow(/catalog-object-row-order/); + const second = rowForNumber(2n, 'fixture-2'); expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, rows: [VALID_ROW, VALID_ROW], @@ -154,6 +166,26 @@ describe('AuthorCatalogBucketV1 structural codec', () => { }); it('requires every row to map to the signed bucket and bucketId to be in range', () => { + const bucketZeroRows = [ + validatedRow({ ...VALID_ROW, kaId: '2', assertionCoordinate: 'bucket-0-2' }), + validatedRow({ ...VALID_ROW, kaId: '10', assertionCoordinate: 'bucket-0-10' }), + ]; + const bucketOneRows = [ + validatedRow({ ...VALID_ROW, kaId: '4', assertionCoordinate: 'bucket-1-4' }), + validatedRow({ ...VALID_ROW, kaId: '6', assertionCoordinate: 'bucket-1-6' }), + ]; + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + bucketCount: '2', + bucketId: '0', + rows: bucketZeroRows, + })).not.toThrow(); + expect(() => assertAuthorCatalogBucketV1({ + ...VALID_BUCKET, + bucketCount: '2', + bucketId: '1', + rows: bucketOneRows, + })).not.toThrow(); expect(() => assertAuthorCatalogBucketV1({ ...VALID_BUCKET, bucketCount: '2', From 624dd27e5d4be4d44454e2f6955171d3ba0951bb Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 05:54:34 +0200 Subject: [PATCH 027/292] fix(core): accept canonical empty hex bytes --- packages/core/src/sync-wire-scalars.ts | 2 +- packages/core/test/sync-wire-scalars.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/core/src/sync-wire-scalars.ts b/packages/core/src/sync-wire-scalars.ts index 78d1221099..0de4a754a6 100644 --- a/packages/core/src/sync-wire-scalars.ts +++ b/packages/core/src/sync-wire-scalars.ts @@ -42,7 +42,7 @@ export const MAX_DECIMAL_U256 = const CANONICAL_UNSIGNED_DECIMAL = /^(?:0|[1-9][0-9]*)$/; const EVM_ADDRESS = /^0x[0-9a-f]{40}$/; const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; -const LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})+$/; +const LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; const ZERO_EVM_ADDRESS = `0x${'00'.repeat(20)}`; export function parseCanonicalDecimalU64( diff --git a/packages/core/test/sync-wire-scalars.test.ts b/packages/core/test/sync-wire-scalars.test.ts index 6b59c32c59..640c3a67ef 100644 --- a/packages/core/test/sync-wire-scalars.test.ts +++ b/packages/core/test/sync-wire-scalars.test.ts @@ -7,6 +7,7 @@ import { assertCanonicalDecimalU64, assertCanonicalDigest, assertCanonicalEvmAddress, + assertCanonicalHexBytes, assertCanonicalKaId, assertCanonicalTimestampMs, parseCanonicalDecimalU256, @@ -65,4 +66,11 @@ describe('RFC-64 sync wire scalar profile', () => { ); expect(() => assertCanonicalEvmAddress(`0x${'00'.repeat(20)}`)).toThrow(/zero address/); }); + + it('accepts canonical empty bytes only when the declared range permits zero', () => { + expect(() => assertCanonicalHexBytes('0x', 'payload', 0, 0)).not.toThrow(); + expect(() => assertCanonicalHexBytes('0x', 'payload', 1, 1)).toThrow(/1 lowercase bytes/); + expect(() => assertCanonicalHexBytes('0x0', 'payload', 0, 1)).toThrow(/lowercase bytes/); + expect(() => assertCanonicalHexBytes('0xAA', 'payload', 0, 1)).toThrow(/lowercase bytes/); + }); }); From 4c89d41960f18433f3d13d535a9361c18f36f86b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 05:58:00 +0200 Subject: [PATCH 028/292] test(core): cover opaque bundle backing stores --- packages/core/test/ka-bundle-v1.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/test/ka-bundle-v1.test.ts b/packages/core/test/ka-bundle-v1.test.ts index 236b043c68..3aa6289ac1 100644 --- a/packages/core/test/ka-bundle-v1.test.ts +++ b/packages/core/test/ka-bundle-v1.test.ts @@ -60,10 +60,14 @@ describe('RFC-64 dormant opaque KA bundle framing', () => { it('rejects shared backing memory before copying, parsing, or hashing', () => { const sharedProjection = new Uint8Array(new SharedArrayBuffer(1)); sharedProjection[0] = 0x61; + const sharedSeal = new Uint8Array(new SharedArrayBuffer(1)); + sharedSeal[0] = 0x62; const sharedBundle = new Uint8Array(new SharedArrayBuffer(16)); expect(() => encodeOpaqueKaBundleV1(sharedProjection, new Uint8Array())) .toThrow(/shared backing memory/); + expect(() => encodeOpaqueKaBundleV1(new Uint8Array(), sharedSeal)) + .toThrow(/shared backing memory/); expect(() => decodeOpaqueKaBundleV1(sharedBundle)).toThrow(/shared backing memory/); }); @@ -79,6 +83,10 @@ describe('RFC-64 dormant opaque KA bundle framing', () => { return; } if ((backing as ArrayBuffer & { readonly resizable?: boolean }).resizable !== true) return; + expect(() => encodeOpaqueKaBundleV1(new Uint8Array(backing), new Uint8Array())) + .toThrow(/resizable backing memory/); + expect(() => encodeOpaqueKaBundleV1(new Uint8Array(), new Uint8Array(backing))) + .toThrow(/resizable backing memory/); expect(() => decodeOpaqueKaBundleV1(new Uint8Array(backing))) .toThrow(/resizable backing memory/); }); From 0e4bda5daa55703d448a2d4a79b9dd89f30469f4 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 05:58:00 +0200 Subject: [PATCH 029/292] test(core): cover single-chunk proof encoding --- packages/core/test/ka-chunk-proof.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/test/ka-chunk-proof.test.ts b/packages/core/test/ka-chunk-proof.test.ts index 43eadafbae..3afaadcae2 100644 --- a/packages/core/test/ka-chunk-proof.test.ts +++ b/packages/core/test/ka-chunk-proof.test.ts @@ -21,6 +21,17 @@ const THREE_CHUNK_PROOF = '{"chunkIndex":"2","steps":[{"kind":"odd"},{"digest":"0x12b230a0573621cb375b62f8f3f46cc6e8c05d3d0a84a50cd5d097132f86f58e","kind":"left"}]}'; describe('RFC-64 dormant KA chunk proof codec', () => { + it('builds and verifies the canonical single-chunk empty proof', () => { + const bundle = new Uint8Array(16); + const root = computeKaChunkTreeRootV1(bundle); + const proof = buildKaChunkProofV1(bundle, 0n); + expect(canonicalizeKaChunkProofV1(proof, 1n)) + .toBe('{"chunkIndex":"0","steps":[]}'); + expect(proof).toEqual({ chunkIndex: '0', steps: [] }); + const parsed = parseCanonicalKaChunkProofV1('{"chunkIndex":"0","steps":[]}', 1n); + expect(() => assertValidKaChunkProofV1(parsed, bundle, 16n, root)).not.toThrow(); + }); + it('builds, canonicalizes, parses, and verifies the exact three-chunk vector', () => { const bundle = fixtureBundle(3); const proof = buildKaChunkProofV1(bundle, 2n); From 51335fadfd2063d47b5d7b42dbe341410b7e3f71 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:03:32 +0200 Subject: [PATCH 030/292] feat(core): add RFC-64 administrative authority codecs --- .../src/administrative-authority-objects.ts | 1170 +++++++++++++++++ packages/core/src/index.ts | 1 + .../administrative-authority-objects.test.ts | 670 ++++++++++ 3 files changed, 1841 insertions(+) create mode 100644 packages/core/src/administrative-authority-objects.ts create mode 100644 packages/core/test/administrative-authority-objects.test.ts diff --git a/packages/core/src/administrative-authority-objects.ts b/packages/core/src/administrative-authority-objects.ts new file mode 100644 index 0000000000..da78b04b23 --- /dev/null +++ b/packages/core/src/administrative-authority-objects.ts @@ -0,0 +1,1170 @@ +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + assertContextGraphIdV1, + assertNetworkIdV1, + type ContextGraphIdV1, + type NetworkIdV1, +} from './author-catalog-codec.js'; +import { + assertSignedControlEnvelope, + assertUnsignedControlEnvelope, + canonicalizeSignedControlEnvelopeBytes, + canonicalizeUnsignedControlEnvelopeBytes, + computeControlObjectDigestHex, + parseCanonicalSignedControlEnvelope, + parseCanonicalUnsignedControlEnvelope, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from './sync-control-object.js'; +import { + assertCanonicalChainId, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalTimestampMs, + parseCanonicalDecimalU64, + type ChainIdV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type TimestampMsV1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +export const CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1 = 'CgOwnershipTransitionV1' as const; +export const CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1 = + 'CgAdministrativeDelegationV1' as const; +export const CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1 = + 'CheckpointAuthorityDelegationV1' as const; +export const CG_OWNERSHIP_TRANSITION_MODE_V1 = 'chain-rebaseline-v1' as const; +export const MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1 = 16 * 1024; +export const MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_DEPTH_V1 = 3; +export const CG_ADMINISTRATIVE_DELEGATION_ROLES_V1 = Object.freeze([ + 'checkpoint-delegation', + 'policy', + 'retention', + 'roster', +] as const); + +export type CgAdministrativeDelegationRoleV1 = + (typeof CG_ADMINISTRATIVE_DELEGATION_ROLES_V1)[number]; + +export interface FinalizedTransferPositionV1 { + readonly blockNumber: DecimalU64V1; + readonly blockHash: Digest32V1; + readonly transactionHash: Digest32V1; + readonly transactionIndex: DecimalU64V1; + readonly logIndex: DecimalU64V1; +} + +export interface FinalizedOwnershipTransferV1 extends FinalizedTransferPositionV1 { + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly previousOwnerAddress: EvmAddressV1; + readonly newOwnerAddress: EvmAddressV1; +} + +export interface CgOwnershipTransitionV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly ownershipEpoch: DecimalU64V1; + readonly previousFinalizedTransfer: FinalizedTransferPositionV1 | null; + readonly finalizedTransfer: FinalizedOwnershipTransferV1; + readonly mode: typeof CG_OWNERSHIP_TRANSITION_MODE_V1; + readonly issuedAt: TimestampMsV1; +} + +export interface FinalizedChainOwnerSourceV1 { + readonly kind: 'finalized-chain-owner'; + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly blockNumber: DecimalU64V1; + readonly blockHash: Digest32V1; +} + +export interface OwnerSignedUnregisteredAdministrativeSourceV1 { + readonly kind: 'owner-signed-unregistered'; + readonly ownerAuthorityEra: DecimalU64V1; +} + +export type CgAdministrativeDelegationSourceV1 = + | FinalizedChainOwnerSourceV1 + | OwnerSignedUnregisteredAdministrativeSourceV1; + +export interface CgAdministrativeDelegationV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly adminEra: DecimalU64V1; + readonly version: DecimalU64V1; + readonly previousDelegationDigest: Digest32V1 | null; + readonly ownerAddress: EvmAddressV1; + readonly delegateAddress: EvmAddressV1; + readonly roles: readonly CgAdministrativeDelegationRoleV1[]; + readonly source: CgAdministrativeDelegationSourceV1; + readonly effectiveAt: TimestampMsV1; + readonly expiresAt: TimestampMsV1; +} + +export interface CheckpointAuthorityDelegationV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly authorityEpoch: DecimalU64V1; + readonly previousDelegationDigest: Digest32V1 | null; + readonly predecessorCheckpointDigest: Digest32V1 | null; + readonly predecessorCheckpointVersion: DecimalU64V1 | null; + readonly rebaselineTransitionDigest: Digest32V1 | null; + readonly checkpointAuthorityAddress: EvmAddressV1; + readonly standbyCheckpointAuthorityAddress: EvmAddressV1 | null; + readonly administrativeDelegationDigest: Digest32V1 | null; + readonly activatedAt: TimestampMsV1; + readonly expiresAt: TimestampMsV1; +} + +export type UnsignedCgOwnershipTransitionEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1; + readonly payload: CgOwnershipTransitionV1; +}; +export type SignedCgOwnershipTransitionEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1; + readonly payload: CgOwnershipTransitionV1; +}; +export type UnsignedCgAdministrativeDelegationEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1; + readonly payload: CgAdministrativeDelegationV1; +}; +export type SignedCgAdministrativeDelegationEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1; + readonly payload: CgAdministrativeDelegationV1; +}; +export type UnsignedCheckpointAuthorityDelegationEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1; + readonly payload: CheckpointAuthorityDelegationV1; +}; +export type SignedCheckpointAuthorityDelegationEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1; + readonly payload: CheckpointAuthorityDelegationV1; +}; + +export type AdministrativeAuthorityCodecErrorCode = + | 'admin-authority-schema' + | 'admin-authority-scalar' + | 'admin-authority-type' + | 'admin-authority-order' + | 'admin-authority-role' + | 'admin-authority-branch' + | 'admin-authority-payload-too-large'; + +export class AdministrativeAuthorityCodecError extends Error { + constructor( + readonly code: AdministrativeAuthorityCodecErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'AdministrativeAuthorityCodecError'; + } +} + +export function assertCgOwnershipTransitionV1( + value: unknown, +): asserts value is CgOwnershipTransitionV1 { + validateCgOwnershipTransitionSnapshotV1(value); +} + +export function canonicalizeCgOwnershipTransitionPayloadV1( + value: CgOwnershipTransitionV1, +): string { + return validateCgOwnershipTransitionSnapshotV1(value).canonical; +} + +export function canonicalizeCgOwnershipTransitionPayloadBytesV1( + value: CgOwnershipTransitionV1, +): Uint8Array { + return new TextEncoder().encode(canonicalizeCgOwnershipTransitionPayloadV1(value)); +} + +export function parseCanonicalCgOwnershipTransitionPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): CgOwnershipTransitionV1 { + return parsePayload( + input, + options, + 'CG ownership transition', + validateCgOwnershipTransitionSnapshotV1, + ); +} + +export function assertCgAdministrativeDelegationV1( + value: unknown, +): asserts value is CgAdministrativeDelegationV1 { + validateCgAdministrativeDelegationSnapshotV1(value); +} + +export function canonicalizeCgAdministrativeDelegationPayloadV1( + value: CgAdministrativeDelegationV1, +): string { + return validateCgAdministrativeDelegationSnapshotV1(value).canonical; +} + +export function canonicalizeCgAdministrativeDelegationPayloadBytesV1( + value: CgAdministrativeDelegationV1, +): Uint8Array { + return new TextEncoder().encode(canonicalizeCgAdministrativeDelegationPayloadV1(value)); +} + +export function parseCanonicalCgAdministrativeDelegationPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): CgAdministrativeDelegationV1 { + return parsePayload( + input, + options, + 'CG administrative delegation', + validateCgAdministrativeDelegationSnapshotV1, + ); +} + +export function assertCheckpointAuthorityDelegationV1( + value: unknown, +): asserts value is CheckpointAuthorityDelegationV1 { + validateCheckpointAuthorityDelegationSnapshotV1(value); +} + +export function canonicalizeCheckpointAuthorityDelegationPayloadV1( + value: CheckpointAuthorityDelegationV1, +): string { + return validateCheckpointAuthorityDelegationSnapshotV1(value).canonical; +} + +export function canonicalizeCheckpointAuthorityDelegationPayloadBytesV1( + value: CheckpointAuthorityDelegationV1, +): Uint8Array { + return new TextEncoder().encode(canonicalizeCheckpointAuthorityDelegationPayloadV1(value)); +} + +export function parseCanonicalCheckpointAuthorityDelegationPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): CheckpointAuthorityDelegationV1 { + return parsePayload( + input, + options, + 'checkpoint-authority delegation', + validateCheckpointAuthorityDelegationSnapshotV1, + ); +} + +export function assertUnsignedCgOwnershipTransitionEnvelopeV1( + value: unknown, +): asserts value is UnsignedCgOwnershipTransitionEnvelopeV1 { + validateUnsignedEnvelopeSnapshot( + value, + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + validateCgOwnershipTransitionPlainV1, + ); +} + +export function assertSignedCgOwnershipTransitionEnvelopeV1( + value: unknown, +): asserts value is SignedCgOwnershipTransitionEnvelopeV1 { + validateSignedEnvelopeSnapshot( + value, + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + validateCgOwnershipTransitionPlainV1, + ); +} + +export function assertUnsignedCgAdministrativeDelegationEnvelopeV1( + value: unknown, +): asserts value is UnsignedCgAdministrativeDelegationEnvelopeV1 { + validateUnsignedEnvelopeSnapshot( + value, + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + validateCgAdministrativeDelegationPlainV1, + ); +} + +export function assertSignedCgAdministrativeDelegationEnvelopeV1( + value: unknown, +): asserts value is SignedCgAdministrativeDelegationEnvelopeV1 { + validateSignedEnvelopeSnapshot( + value, + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + validateCgAdministrativeDelegationPlainV1, + ); +} + +export function assertUnsignedCheckpointAuthorityDelegationEnvelopeV1( + value: unknown, +): asserts value is UnsignedCheckpointAuthorityDelegationEnvelopeV1 { + validateUnsignedEnvelopeSnapshot( + value, + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + validateCheckpointAuthorityDelegationPlainV1, + ); +} + +export function assertSignedCheckpointAuthorityDelegationEnvelopeV1( + value: unknown, +): asserts value is SignedCheckpointAuthorityDelegationEnvelopeV1 { + validateSignedEnvelopeSnapshot( + value, + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + validateCheckpointAuthorityDelegationPlainV1, + ); +} + +export function canonicalizeUnsignedCgOwnershipTransitionEnvelopeBytesV1( + value: unknown, +): Uint8Array { + return canonicalizeUnsignedControlEnvelopeBytes(validateUnsignedEnvelopeSnapshot( + value, + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + validateCgOwnershipTransitionPlainV1, + )); +} + +export function canonicalizeSignedCgOwnershipTransitionEnvelopeBytesV1( + value: unknown, +): Uint8Array { + return canonicalizeSignedControlEnvelopeBytes(validateSignedEnvelopeSnapshot( + value, + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + validateCgOwnershipTransitionPlainV1, + )); +} + +export function canonicalizeUnsignedCgAdministrativeDelegationEnvelopeBytesV1( + value: unknown, +): Uint8Array { + return canonicalizeUnsignedControlEnvelopeBytes(validateUnsignedEnvelopeSnapshot( + value, + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + validateCgAdministrativeDelegationPlainV1, + )); +} + +export function canonicalizeSignedCgAdministrativeDelegationEnvelopeBytesV1( + value: unknown, +): Uint8Array { + return canonicalizeSignedControlEnvelopeBytes(validateSignedEnvelopeSnapshot( + value, + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + validateCgAdministrativeDelegationPlainV1, + )); +} + +export function canonicalizeUnsignedCheckpointAuthorityDelegationEnvelopeBytesV1( + value: unknown, +): Uint8Array { + return canonicalizeUnsignedControlEnvelopeBytes(validateUnsignedEnvelopeSnapshot( + value, + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + validateCheckpointAuthorityDelegationPlainV1, + )); +} + +export function canonicalizeSignedCheckpointAuthorityDelegationEnvelopeBytesV1( + value: unknown, +): Uint8Array { + return canonicalizeSignedControlEnvelopeBytes(validateSignedEnvelopeSnapshot( + value, + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + validateCheckpointAuthorityDelegationPlainV1, + )); +} + +export function computeCgOwnershipTransitionObjectDigestV1(value: unknown): Digest32V1 { + return computeTypedObjectDigest( + value, + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + validateCgOwnershipTransitionPlainV1, + ); +} + +export function computeCgAdministrativeDelegationObjectDigestV1(value: unknown): Digest32V1 { + return computeTypedObjectDigest( + value, + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + validateCgAdministrativeDelegationPlainV1, + ); +} + +export function computeCheckpointAuthorityDelegationObjectDigestV1(value: unknown): Digest32V1 { + return computeTypedObjectDigest( + value, + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + validateCheckpointAuthorityDelegationPlainV1, + ); +} + +export function parseCanonicalUnsignedCgOwnershipTransitionEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedCgOwnershipTransitionEnvelopeV1 { + return validateUnsignedEnvelopeSnapshot( + parseCanonicalUnsignedControlEnvelope(input, options), + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + validateCgOwnershipTransitionPlainV1, + ) as UnsignedCgOwnershipTransitionEnvelopeV1; +} + +export function parseCanonicalSignedCgOwnershipTransitionEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedCgOwnershipTransitionEnvelopeV1 { + return validateSignedEnvelopeSnapshot( + parseCanonicalSignedControlEnvelope(input, options), + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + validateCgOwnershipTransitionPlainV1, + ) as SignedCgOwnershipTransitionEnvelopeV1; +} + +export function parseCanonicalUnsignedCgAdministrativeDelegationEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedCgAdministrativeDelegationEnvelopeV1 { + return validateUnsignedEnvelopeSnapshot( + parseCanonicalUnsignedControlEnvelope(input, options), + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + validateCgAdministrativeDelegationPlainV1, + ) as UnsignedCgAdministrativeDelegationEnvelopeV1; +} + +export function parseCanonicalSignedCgAdministrativeDelegationEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedCgAdministrativeDelegationEnvelopeV1 { + return validateSignedEnvelopeSnapshot( + parseCanonicalSignedControlEnvelope(input, options), + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + validateCgAdministrativeDelegationPlainV1, + ) as SignedCgAdministrativeDelegationEnvelopeV1; +} + +export function parseCanonicalUnsignedCheckpointAuthorityDelegationEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedCheckpointAuthorityDelegationEnvelopeV1 { + return validateUnsignedEnvelopeSnapshot( + parseCanonicalUnsignedControlEnvelope(input, options), + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + validateCheckpointAuthorityDelegationPlainV1, + ) as UnsignedCheckpointAuthorityDelegationEnvelopeV1; +} + +export function parseCanonicalSignedCheckpointAuthorityDelegationEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedCheckpointAuthorityDelegationEnvelopeV1 { + return validateSignedEnvelopeSnapshot( + parseCanonicalSignedControlEnvelope(input, options), + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + validateCheckpointAuthorityDelegationPlainV1, + ) as SignedCheckpointAuthorityDelegationEnvelopeV1; +} + +type PayloadValidator = (value: unknown) => { + readonly snapshot: unknown; + readonly canonical: string; +}; + +function validateUnsignedEnvelopeSnapshot( + value: unknown, + objectType: string, + payloadValidator: PayloadValidator, +): UnsignedControlEnvelopeV1 { + assertUnsignedEnvelopePlain(value, objectType, payloadValidator); + const snapshot = clonePlainData(value, `unsigned ${objectType} envelope`); + assertUnsignedEnvelopePlain(snapshot, objectType, payloadValidator); + assertAdministrativeAuthorityIssuerBinding(snapshot, objectType); + return snapshot; +} + +function validateSignedEnvelopeSnapshot( + value: unknown, + objectType: string, + payloadValidator: PayloadValidator, +): SignedControlEnvelopeV1 { + assertSignedEnvelopePlain(value, objectType, payloadValidator); + const snapshot = clonePlainData(value, `signed ${objectType} envelope`); + assertSignedEnvelopePlain(snapshot, objectType, payloadValidator); + assertAdministrativeAuthorityIssuerBinding(snapshot, objectType); + return snapshot; +} + +/** + * Bind the issuer only after the whole envelope has been cloned and validated + * again. This prevents a stateful in-memory caller from presenting one + * issuer/payload relationship during structural validation and another while + * the authority relationship is checked. + */ +function assertAdministrativeAuthorityIssuerBinding( + envelope: UnsignedControlEnvelopeV1 | SignedControlEnvelopeV1, + objectType: string, +): void { + if (objectType === CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1) { + const transition = envelope.payload as unknown as CgOwnershipTransitionV1; + if (envelope.issuer !== transition.finalizedTransfer.newOwnerAddress) { + fail( + 'admin-authority-branch', + 'ownership-transition issuer must equal finalizedTransfer.newOwnerAddress', + ); + } + return; + } + + if (objectType === CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1) { + const delegation = envelope.payload as unknown as CgAdministrativeDelegationV1; + if (envelope.issuer !== delegation.ownerAddress) { + fail( + 'admin-authority-branch', + 'administrative-delegation issuer must equal ownerAddress', + ); + } + } +} + +function assertUnsignedEnvelopePlain( + value: unknown, + objectType: string, + payloadValidator: PayloadValidator, +): asserts value is UnsignedControlEnvelopeV1 { + try { + assertUnsignedControlEnvelope(value as UnsignedControlEnvelopeV1); + } catch (cause) { + fail('admin-authority-schema', 'unsigned control envelope is invalid', cause); + } + const envelope = value as UnsignedControlEnvelopeV1; + assertObjectType(envelope.objectType, objectType); + payloadValidator(envelope.payload); +} + +function assertSignedEnvelopePlain( + value: unknown, + objectType: string, + payloadValidator: PayloadValidator, +): asserts value is SignedControlEnvelopeV1 { + try { + assertSignedControlEnvelope(value as SignedControlEnvelopeV1); + } catch (cause) { + fail('admin-authority-schema', 'signed control envelope is invalid', cause); + } + const envelope = value as SignedControlEnvelopeV1; + assertObjectType(envelope.objectType, objectType); + payloadValidator(envelope.payload); +} + +function computeTypedObjectDigest( + value: unknown, + objectType: string, + payloadValidator: PayloadValidator, +): Digest32V1 { + const snapshot = validateUnsignedEnvelopeSnapshot(value, objectType, payloadValidator); + const digest = computeControlObjectDigestHex(snapshot); + assertCanonicalDigest(digest, 'objectDigest'); + return digest; +} + +function validateCgOwnershipTransitionSnapshotV1(value: unknown): { + readonly snapshot: CgOwnershipTransitionV1; + readonly canonical: string; +} { + const bounded = validateCgOwnershipTransitionPlainV1(value); + return validateCgOwnershipTransitionPlainV1( + clonePlainData(bounded.snapshot, 'CG ownership transition'), + ); +} + +function validateCgOwnershipTransitionPlainV1(value: unknown): { + readonly snapshot: CgOwnershipTransitionV1; + readonly canonical: string; +} { + assertCgOwnershipTransitionStructureV1(value); + return { snapshot: value, canonical: canonicalizePayload(value, 'CG ownership transition') }; +} + +function assertCgOwnershipTransitionStructureV1( + value: unknown, +): asserts value is CgOwnershipTransitionV1 { + if (!isPlainRecord(value)) fail('admin-authority-schema', 'ownership transition must be an object'); + closed(value, [ + 'contextGraphId', + 'finalizedTransfer', + 'issuedAt', + 'mode', + 'networkId', + 'ownershipEpoch', + 'previousFinalizedTransfer', + ], 'ownership transition'); + scalar(() => assertNetworkIdV1(value.networkId)); + scalar(() => assertContextGraphIdV1(value.contextGraphId)); + const ownershipEpoch = u64(value.ownershipEpoch, 'ownershipEpoch'); + if (ownershipEpoch === 0n) { + fail('admin-authority-branch', 'ownershipEpoch is a one-based finalized transfer index'); + } + if (value.previousFinalizedTransfer !== null) { + assertFinalizedTransferPosition(value.previousFinalizedTransfer, 'previousFinalizedTransfer'); + } + assertFinalizedOwnershipTransfer(value.finalizedTransfer); + if ((ownershipEpoch === 1n) !== (value.previousFinalizedTransfer === null)) { + fail( + 'admin-authority-branch', + 'only ownership epoch one may have a null previous finalized transfer', + ); + } + if ( + value.previousFinalizedTransfer !== null + && compareTransferPositions(value.previousFinalizedTransfer, value.finalizedTransfer) >= 0 + ) { + fail('admin-authority-order', 'previous finalized transfer must precede finalized transfer'); + } + if (value.mode !== CG_OWNERSHIP_TRANSITION_MODE_V1) { + fail('admin-authority-branch', 'ownership transition mode must be chain-rebaseline-v1'); + } + timestamp(value.issuedAt, 'issuedAt'); +} + +function assertFinalizedTransferPosition( + value: unknown, + label: string, +): asserts value is FinalizedTransferPositionV1 { + if (!isPlainRecord(value)) fail('admin-authority-schema', `${label} must be an object`); + closed(value, [ + 'blockHash', + 'blockNumber', + 'logIndex', + 'transactionHash', + 'transactionIndex', + ], label); + u64(value.blockNumber, `${label}.blockNumber`); + digest(value.blockHash, `${label}.blockHash`); + digest(value.transactionHash, `${label}.transactionHash`); + u64(value.transactionIndex, `${label}.transactionIndex`); + u64(value.logIndex, `${label}.logIndex`); +} + +function assertFinalizedOwnershipTransfer( + value: unknown, +): asserts value is FinalizedOwnershipTransferV1 { + if (!isPlainRecord(value)) { + fail('admin-authority-schema', 'finalizedTransfer must be an object'); + } + closed(value, [ + 'blockHash', + 'blockNumber', + 'chainId', + 'contractAddress', + 'logIndex', + 'newOwnerAddress', + 'previousOwnerAddress', + 'transactionHash', + 'transactionIndex', + ], 'finalizedTransfer'); + scalar(() => assertCanonicalChainId(value.chainId)); + scalar(() => assertCanonicalEvmAddress(value.contractAddress, 'finalizedTransfer.contractAddress')); + u64(value.blockNumber, 'finalizedTransfer.blockNumber'); + digest(value.blockHash, 'finalizedTransfer.blockHash'); + digest(value.transactionHash, 'finalizedTransfer.transactionHash'); + u64(value.transactionIndex, 'finalizedTransfer.transactionIndex'); + u64(value.logIndex, 'finalizedTransfer.logIndex'); + scalar(() => assertCanonicalEvmAddress( + value.previousOwnerAddress, + 'finalizedTransfer.previousOwnerAddress', + )); + scalar(() => assertCanonicalEvmAddress(value.newOwnerAddress, 'finalizedTransfer.newOwnerAddress')); +} + +function validateCgAdministrativeDelegationSnapshotV1(value: unknown): { + readonly snapshot: CgAdministrativeDelegationV1; + readonly canonical: string; +} { + const bounded = validateCgAdministrativeDelegationPlainV1(value); + return validateCgAdministrativeDelegationPlainV1( + clonePlainData(bounded.snapshot, 'CG administrative delegation'), + ); +} + +function validateCgAdministrativeDelegationPlainV1(value: unknown): { + readonly snapshot: CgAdministrativeDelegationV1; + readonly canonical: string; +} { + assertCgAdministrativeDelegationStructureV1(value); + return { + snapshot: value, + canonical: canonicalizePayload(value, 'CG administrative delegation'), + }; +} + +function assertCgAdministrativeDelegationStructureV1( + value: unknown, +): asserts value is CgAdministrativeDelegationV1 { + if (!isPlainRecord(value)) { + fail('admin-authority-schema', 'administrative delegation must be an object'); + } + closed(value, [ + 'adminEra', + 'contextGraphId', + 'delegateAddress', + 'effectiveAt', + 'expiresAt', + 'networkId', + 'ownerAddress', + 'ownershipTransitionDigest', + 'previousDelegationDigest', + 'roles', + 'source', + 'version', + ], 'administrative delegation'); + scalar(() => assertNetworkIdV1(value.networkId)); + scalar(() => assertContextGraphIdV1(value.contextGraphId)); + optionalDigest(value.ownershipTransitionDigest, 'ownershipTransitionDigest'); + const adminEra = u64(value.adminEra, 'adminEra'); + const version = u64(value.version, 'version'); + optionalDigest(value.previousDelegationDigest, 'previousDelegationDigest'); + if ( + (value.previousDelegationDigest === null) + !== (adminEra === 0n && version === 0n) + ) { + fail( + 'admin-authority-branch', + 'previousDelegationDigest must be null exactly for admin era/version zero', + ); + } + scalar(() => assertCanonicalEvmAddress(value.ownerAddress, 'ownerAddress')); + scalar(() => assertCanonicalEvmAddress(value.delegateAddress, 'delegateAddress')); + assertAdministrativeRoles(value.roles); + assertAdministrativeSource(value.source, value.ownershipTransitionDigest); + const effectiveAt = timestamp(value.effectiveAt, 'effectiveAt'); + const expiresAt = timestamp(value.expiresAt, 'expiresAt'); + if (effectiveAt >= expiresAt) { + fail('admin-authority-order', 'effectiveAt must be earlier than expiresAt'); + } +} + +function assertAdministrativeRoles( + value: unknown, +): asserts value is CgAdministrativeDelegationRoleV1[] { + assertDenseArray(value, 'roles', CG_ADMINISTRATIVE_DELEGATION_ROLES_V1.length); + let previous: string | undefined; + for (let index = 0; index < value.length; index += 1) { + const roleValue = value[index]; + if (typeof roleValue !== 'string' || !isAdministrativeRole(roleValue)) { + fail('admin-authority-role', 'administrative role is not recognized by v1'); + } + if (previous !== undefined && previous >= roleValue) { + fail( + previous === roleValue ? 'admin-authority-role' : 'admin-authority-order', + 'administrative roles must be strictly sorted and unique', + ); + } + previous = roleValue; + } +} + +function assertAdministrativeSource( + value: unknown, + ownershipTransitionDigest: unknown, +): asserts value is CgAdministrativeDelegationSourceV1 { + if (!isPlainRecord(value)) { + fail('admin-authority-schema', 'administrative source must be an object'); + } + const kind = ownDataField(value, 'kind', 'administrative source'); + if (kind === 'finalized-chain-owner') { + closed(value, [ + 'blockHash', + 'blockNumber', + 'chainId', + 'contractAddress', + 'kind', + ], 'finalized-chain-owner source'); + scalar(() => assertCanonicalChainId(value.chainId)); + scalar(() => assertCanonicalEvmAddress(value.contractAddress, 'source.contractAddress')); + u64(value.blockNumber, 'source.blockNumber'); + digest(value.blockHash, 'source.blockHash'); + return; + } + if (kind === 'owner-signed-unregistered') { + closed(value, ['kind', 'ownerAuthorityEra'], 'owner-signed-unregistered source'); + u64(value.ownerAuthorityEra, 'source.ownerAuthorityEra'); + if (ownershipTransitionDigest !== null) { + fail( + 'admin-authority-branch', + 'owner-signed-unregistered source requires a null ownership transition', + ); + } + return; + } + fail('admin-authority-branch', 'administrative source kind is not supported by v1'); +} + +function validateCheckpointAuthorityDelegationSnapshotV1(value: unknown): { + readonly snapshot: CheckpointAuthorityDelegationV1; + readonly canonical: string; +} { + const bounded = validateCheckpointAuthorityDelegationPlainV1(value); + return validateCheckpointAuthorityDelegationPlainV1( + clonePlainData(bounded.snapshot, 'checkpoint-authority delegation'), + ); +} + +function validateCheckpointAuthorityDelegationPlainV1(value: unknown): { + readonly snapshot: CheckpointAuthorityDelegationV1; + readonly canonical: string; +} { + assertCheckpointAuthorityDelegationStructureV1(value); + return { + snapshot: value, + canonical: canonicalizePayload(value, 'checkpoint-authority delegation'), + }; +} + +function assertCheckpointAuthorityDelegationStructureV1( + value: unknown, +): asserts value is CheckpointAuthorityDelegationV1 { + if (!isPlainRecord(value)) { + fail('admin-authority-schema', 'checkpoint-authority delegation must be an object'); + } + closed(value, [ + 'activatedAt', + 'administrativeDelegationDigest', + 'authorityEpoch', + 'checkpointAuthorityAddress', + 'contextGraphId', + 'expiresAt', + 'networkId', + 'ownershipTransitionDigest', + 'predecessorCheckpointDigest', + 'predecessorCheckpointVersion', + 'previousDelegationDigest', + 'rebaselineTransitionDigest', + 'standbyCheckpointAuthorityAddress', + ], 'checkpoint-authority delegation'); + scalar(() => assertNetworkIdV1(value.networkId)); + scalar(() => assertContextGraphIdV1(value.contextGraphId)); + optionalDigest(value.ownershipTransitionDigest, 'ownershipTransitionDigest'); + const authorityEpoch = u64(value.authorityEpoch, 'authorityEpoch'); + optionalDigest(value.previousDelegationDigest, 'previousDelegationDigest'); + optionalDigest(value.predecessorCheckpointDigest, 'predecessorCheckpointDigest'); + const predecessorVersion = value.predecessorCheckpointVersion === null + ? null + : u64(value.predecessorCheckpointVersion, 'predecessorCheckpointVersion'); + optionalDigest(value.rebaselineTransitionDigest, 'rebaselineTransitionDigest'); + const predecessorIsNull = value.predecessorCheckpointDigest === null; + if (predecessorIsNull !== (predecessorVersion === null)) { + fail( + 'admin-authority-branch', + 'predecessor checkpoint digest and version must be jointly null or non-null', + ); + } + assertCheckpointLineageBranch( + value as unknown as CheckpointAuthorityDelegationV1, + authorityEpoch, + predecessorIsNull, + ); + scalar(() => assertCanonicalEvmAddress( + value.checkpointAuthorityAddress, + 'checkpointAuthorityAddress', + )); + if (value.standbyCheckpointAuthorityAddress !== null) { + scalar(() => assertCanonicalEvmAddress( + value.standbyCheckpointAuthorityAddress, + 'standbyCheckpointAuthorityAddress', + )); + if (value.standbyCheckpointAuthorityAddress === value.checkpointAuthorityAddress) { + fail('admin-authority-branch', 'standby authority must differ from active authority'); + } + } + optionalDigest( + value.administrativeDelegationDigest, + 'administrativeDelegationDigest', + ); + const activatedAt = timestamp(value.activatedAt, 'activatedAt'); + const expiresAt = timestamp(value.expiresAt, 'expiresAt'); + if (activatedAt >= expiresAt) { + fail('admin-authority-order', 'activatedAt must be earlier than expiresAt'); + } +} + +function assertCheckpointLineageBranch( + value: CheckpointAuthorityDelegationV1, + authorityEpoch: bigint, + predecessorIsNull: boolean, +): void { + if (value.rebaselineTransitionDigest !== null) { + if ( + value.ownershipTransitionDigest === null + || value.rebaselineTransitionDigest !== value.ownershipTransitionDigest + || !predecessorIsNull + || value.previousDelegationDigest !== null + || authorityEpoch !== 0n + ) { + fail( + 'admin-authority-branch', + 'rebaseline must bind its ownership transition and start a zero/null lineage', + ); + } + return; + } + + if (predecessorIsNull) { + if ( + value.ownershipTransitionDigest !== null + || value.previousDelegationDigest !== null + || authorityEpoch !== 0n + ) { + fail( + 'admin-authority-branch', + 'initial delegation must use the zero/null initial-owner lineage', + ); + } + return; + } + + if (value.previousDelegationDigest === null || authorityEpoch === 0n) { + fail( + 'admin-authority-branch', + 'ordinary rotation requires a predecessor delegation and nonzero authority epoch', + ); + } +} + +function compareTransferPositions( + left: FinalizedTransferPositionV1, + right: FinalizedTransferPositionV1, +): number { + const leftParts = [left.blockNumber, left.transactionIndex, left.logIndex]; + const rightParts = [right.blockNumber, right.transactionIndex, right.logIndex]; + for (let index = 0; index < leftParts.length; index += 1) { + const leftPart = u64(leftParts[index], `left transfer position ${index}`); + const rightPart = u64(rightParts[index], `right transfer position ${index}`); + if (leftPart < rightPart) return -1; + if (leftPart > rightPart) return 1; + } + return 0; +} + +function parsePayload( + input: string | Uint8Array, + options: StrictJsonParseOptions, + label: string, + validator: (value: unknown) => { readonly snapshot: T; readonly canonical: string }, +): T { + rejectOversizedInput(input, label); + const value = parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1, + MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1, + ), + maxDepth: Math.min( + options.maxDepth ?? MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_DEPTH_V1, + MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_DEPTH_V1, + ), + }); + return validator(value).snapshot; +} + +function canonicalizePayload(value: object, label: string): string { + try { + return canonicalizeJson(value as unknown as CanonicalJsonValue, { + maxBytes: MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1, + maxDepth: MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_DEPTH_V1, + }); + } catch (cause) { + fail( + 'admin-authority-payload-too-large', + `${label} exceeds the canonical payload cap`, + cause, + ); + } +} + +function clonePlainData(value: T, label: string): T { + assertStablePlainDataShape(value, label, 0, new Set()); + try { + return structuredClone(value); + } catch (cause) { + fail( + 'admin-authority-schema', + `${label} must be stable structured-cloneable JSON data`, + cause, + ); + } +} + +function assertStablePlainDataShape( + value: unknown, + label: string, + depth: number, + ancestors: Set, +): void { + if (depth > 64) fail('admin-authority-schema', `${label} exceeds the generic nesting cap`); + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (!Number.isFinite(value)) fail('admin-authority-schema', `${label} contains a non-JSON number`); + return; + } + if (typeof value !== 'object') { + fail('admin-authority-schema', `${label} contains a non-JSON implementation value`); + } + if (ancestors.has(value)) fail('admin-authority-schema', `${label} contains a cycle`); + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + fail('admin-authority-schema', `${label} contains a non-ordinary array`); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string') || keys.length !== value.length + 1) { + fail('admin-authority-schema', `${label} contains a sparse or custom array`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('admin-authority-schema', `${label} contains an array accessor`); + } + assertStablePlainDataShape(descriptor.value, `${label}[${index}]`, depth + 1, ancestors); + } + return; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + fail('admin-authority-schema', `${label} contains a non-plain object`); + } + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') { + fail('admin-authority-schema', `${label} contains a symbol property`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('admin-authority-schema', `${label} contains an object accessor`); + } + assertStablePlainDataShape(descriptor.value, `${label}.${key}`, depth + 1, ancestors); + } + } finally { + ancestors.delete(value); + } +} + +function ownDataField(record: Record, key: string, label: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('admin-authority-schema', `${label}.${key} must be an enumerable data field`); + } + return descriptor.value; +} + +function assertDenseArray( + value: unknown, + label: string, + maxLength: number, +): asserts value is unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + fail('admin-authority-schema', `${label} must be an ordinary array`); + } + if (value.length > maxLength) { + fail('admin-authority-role', `${label} exceeds the closed role registry`); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string') || keys.length !== value.length + 1) { + fail('admin-authority-schema', `${label} must be dense and contain no custom properties`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('admin-authority-schema', `${label} must contain ordinary enumerable values`); + } + } +} + +function isAdministrativeRole(value: string): value is CgAdministrativeDelegationRoleV1 { + return value === 'checkpoint-delegation' + || value === 'policy' + || value === 'retention' + || value === 'roster'; +} + +function u64(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU64(value, label); + } catch (cause) { + fail('admin-authority-scalar', `${label} must be canonical DecimalU64V1`, cause); + } +} + +function timestamp(value: unknown, label: string): bigint { + scalar(() => assertCanonicalTimestampMs(value, label)); + return u64(value, label); +} + +function optionalDigest(value: unknown, label: string): void { + if (value !== null) digest(value, label); +} + +function digest(value: unknown, label: string): void { + scalar(() => assertCanonicalDigest(value, label)); +} + +function scalar(operation: () => void): void { + try { + operation(); + } catch (cause) { + fail('admin-authority-scalar', 'administrative authority scalar is not canonical', cause); + } +} + +function closed(record: Record, keys: readonly string[], label: string): void { + try { + assertExactKeys(record, keys, label); + } catch (cause) { + fail('admin-authority-schema', `${label} has an invalid field set`, cause); + } +} + +function assertObjectType(actual: unknown, expected: string): void { + if (actual !== expected) { + fail('admin-authority-type', `objectType must be exactly ${expected}`); + } +} + +function rejectOversizedInput(input: string | Uint8Array, label: string): void { + if ( + typeof input === 'string' + && input.length > MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1 + ) { + fail( + 'admin-authority-payload-too-large', + `${label} exceeds ${MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1} bytes`, + ); + } + const byteLength = typeof input === 'string' + ? new TextEncoder().encode(input).byteLength + : input.byteLength; + if (byteLength > MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1) { + fail( + 'admin-authority-payload-too-large', + `${label} exceeds ${MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1} bytes`, + ); + } +} + +function fail( + code: AdministrativeAuthorityCodecErrorCode, + message: string, + cause?: unknown, +): never { + throw new AdministrativeAuthorityCodecError( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a04e391377..ac369fe8fb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export * from './imported-artifact-bytes.js'; export * from './imported-artifact-metadata.js'; export * from './sync-control-object.js'; export * from './cg-policy-objects.js'; +export * from './administrative-authority-objects.js'; export { MAX_DECIMAL_U64, MAX_DECIMAL_U256, diff --git a/packages/core/test/administrative-authority-objects.test.ts b/packages/core/test/administrative-authority-objects.test.ts new file mode 100644 index 0000000000..f4ebca929a --- /dev/null +++ b/packages/core/test/administrative-authority-objects.test.ts @@ -0,0 +1,670 @@ +import { describe, expect, it } from 'vitest'; + +import { + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + CG_ADMINISTRATIVE_DELEGATION_ROLES_V1, + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1, + assertCgAdministrativeDelegationV1, + assertCgOwnershipTransitionV1, + assertCheckpointAuthorityDelegationV1, + assertSignedCgAdministrativeDelegationEnvelopeV1, + assertSignedCgOwnershipTransitionEnvelopeV1, + assertSignedCheckpointAuthorityDelegationEnvelopeV1, + assertUnsignedCgAdministrativeDelegationEnvelopeV1, + assertUnsignedCgOwnershipTransitionEnvelopeV1, + assertUnsignedCheckpointAuthorityDelegationEnvelopeV1, + canonicalizeCgAdministrativeDelegationPayloadV1, + canonicalizeCgOwnershipTransitionPayloadV1, + canonicalizeCheckpointAuthorityDelegationPayloadV1, + canonicalizeSignedCgAdministrativeDelegationEnvelopeBytesV1, + canonicalizeSignedCgOwnershipTransitionEnvelopeBytesV1, + canonicalizeSignedCheckpointAuthorityDelegationEnvelopeBytesV1, + canonicalizeUnsignedCgAdministrativeDelegationEnvelopeBytesV1, + canonicalizeUnsignedCgOwnershipTransitionEnvelopeBytesV1, + canonicalizeUnsignedCheckpointAuthorityDelegationEnvelopeBytesV1, + computeCgAdministrativeDelegationObjectDigestV1, + computeCgOwnershipTransitionObjectDigestV1, + computeCheckpointAuthorityDelegationObjectDigestV1, + parseCanonicalCgAdministrativeDelegationPayloadV1, + parseCanonicalCgOwnershipTransitionPayloadV1, + parseCanonicalCheckpointAuthorityDelegationPayloadV1, + parseCanonicalSignedCgAdministrativeDelegationEnvelopeV1, + parseCanonicalSignedCgOwnershipTransitionEnvelopeV1, + parseCanonicalSignedCheckpointAuthorityDelegationEnvelopeV1, + parseCanonicalUnsignedCgAdministrativeDelegationEnvelopeV1, + parseCanonicalUnsignedCgOwnershipTransitionEnvelopeV1, + parseCanonicalUnsignedCheckpointAuthorityDelegationEnvelopeV1, + type CgAdministrativeDelegationV1, + type CgOwnershipTransitionV1, + type CheckpointAuthorityDelegationV1, +} from '../src/administrative-authority-objects.js'; +import { + computeControlObjectDigestHex, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '../src/sync-control-object.js'; + +const SIGNATURE = `0x${'77'.repeat(65)}`; +const ZERO_DIGEST = `0x${'00'.repeat(32)}`; +const TRANSITION_DIGEST = '0x0b54bd4b92d5520c8fe70585ded23a6af0867fc954c0224dbb1e783b95e41a4a'; +const ADMIN_DIGEST = '0x050e947b2c06bb5278c8b9b1bb5abd230c82fd97f53b86eb0363f12e966a22b4'; +const CHECKPOINT_DIGEST = '0xf46e611726d61ca4da35f031fb407623dcf96cb61a4b3433fef0634395c073de'; + +const TRANSITION = validatedTransition({ + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/admin-fixture', + ownershipEpoch: '2', + previousFinalizedTransfer: { + blockNumber: '100', + blockHash: `0x${'11'.repeat(32)}`, + transactionHash: `0x${'12'.repeat(32)}`, + transactionIndex: '1', + logIndex: '2', + }, + finalizedTransfer: { + chainId: '20430', + contractAddress: '0x2222222222222222222222222222222222222222', + blockNumber: '101', + blockHash: `0x${'21'.repeat(32)}`, + transactionHash: `0x${'22'.repeat(32)}`, + transactionIndex: '0', + logIndex: '3', + previousOwnerAddress: '0x3333333333333333333333333333333333333333', + newOwnerAddress: '0x4444444444444444444444444444444444444444', + }, + mode: 'chain-rebaseline-v1', + issuedAt: '1700000000123', +}); + +const ADMIN = validatedAdmin({ + networkId: TRANSITION.networkId, + contextGraphId: TRANSITION.contextGraphId, + ownershipTransitionDigest: TRANSITION_DIGEST, + adminEra: '0', + version: '0', + previousDelegationDigest: null, + ownerAddress: TRANSITION.finalizedTransfer.newOwnerAddress, + delegateAddress: '0x6666666666666666666666666666666666666666', + roles: ['checkpoint-delegation', 'policy', 'retention', 'roster'], + source: { + kind: 'finalized-chain-owner', + chainId: '20430', + contractAddress: TRANSITION.finalizedTransfer.contractAddress, + blockNumber: '101', + blockHash: TRANSITION.finalizedTransfer.blockHash, + }, + effectiveAt: '1700000001000', + expiresAt: '1700003601000', +}); + +const CHECKPOINT = validatedCheckpoint({ + networkId: TRANSITION.networkId, + contextGraphId: TRANSITION.contextGraphId, + ownershipTransitionDigest: TRANSITION_DIGEST, + authorityEpoch: '0', + previousDelegationDigest: null, + predecessorCheckpointDigest: null, + predecessorCheckpointVersion: null, + rebaselineTransitionDigest: TRANSITION_DIGEST, + checkpointAuthorityAddress: '0x7777777777777777777777777777777777777777', + standbyCheckpointAuthorityAddress: '0x8888888888888888888888888888888888888888', + administrativeDelegationDigest: ADMIN_DIGEST, + activatedAt: '1700000002000', + expiresAt: '1700003600000', +}); + +const TRANSITION_CANONICAL = '{"contextGraphId":"0x1111111111111111111111111111111111111111/admin-fixture","finalizedTransfer":{"blockHash":"0x2121212121212121212121212121212121212121212121212121212121212121","blockNumber":"101","chainId":"20430","contractAddress":"0x2222222222222222222222222222222222222222","logIndex":"3","newOwnerAddress":"0x4444444444444444444444444444444444444444","previousOwnerAddress":"0x3333333333333333333333333333333333333333","transactionHash":"0x2222222222222222222222222222222222222222222222222222222222222222","transactionIndex":"0"},"issuedAt":"1700000000123","mode":"chain-rebaseline-v1","networkId":"otp:20430","ownershipEpoch":"2","previousFinalizedTransfer":{"blockHash":"0x1111111111111111111111111111111111111111111111111111111111111111","blockNumber":"100","logIndex":"2","transactionHash":"0x1212121212121212121212121212121212121212121212121212121212121212","transactionIndex":"1"}}'; +const ADMIN_CANONICAL = '{"adminEra":"0","contextGraphId":"0x1111111111111111111111111111111111111111/admin-fixture","delegateAddress":"0x6666666666666666666666666666666666666666","effectiveAt":"1700000001000","expiresAt":"1700003601000","networkId":"otp:20430","ownerAddress":"0x4444444444444444444444444444444444444444","ownershipTransitionDigest":"0x0b54bd4b92d5520c8fe70585ded23a6af0867fc954c0224dbb1e783b95e41a4a","previousDelegationDigest":null,"roles":["checkpoint-delegation","policy","retention","roster"],"source":{"blockHash":"0x2121212121212121212121212121212121212121212121212121212121212121","blockNumber":"101","chainId":"20430","contractAddress":"0x2222222222222222222222222222222222222222","kind":"finalized-chain-owner"},"version":"0"}'; +const CHECKPOINT_CANONICAL = '{"activatedAt":"1700000002000","administrativeDelegationDigest":"0x050e947b2c06bb5278c8b9b1bb5abd230c82fd97f53b86eb0363f12e966a22b4","authorityEpoch":"0","checkpointAuthorityAddress":"0x7777777777777777777777777777777777777777","contextGraphId":"0x1111111111111111111111111111111111111111/admin-fixture","expiresAt":"1700003600000","networkId":"otp:20430","ownershipTransitionDigest":"0x0b54bd4b92d5520c8fe70585ded23a6af0867fc954c0224dbb1e783b95e41a4a","predecessorCheckpointDigest":null,"predecessorCheckpointVersion":null,"previousDelegationDigest":null,"rebaselineTransitionDigest":"0x0b54bd4b92d5520c8fe70585ded23a6af0867fc954c0224dbb1e783b95e41a4a","standbyCheckpointAuthorityAddress":"0x8888888888888888888888888888888888888888"}'; + +const TRANSITION_UNSIGNED = unsigned( + CG_OWNERSHIP_TRANSITION_OBJECT_TYPE_V1, + TRANSITION, + TRANSITION.finalizedTransfer.newOwnerAddress, +); +const ADMIN_UNSIGNED = unsigned( + CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + ADMIN, + ADMIN.ownerAddress, +); +const CHECKPOINT_UNSIGNED = unsigned( + CHECKPOINT_AUTHORITY_DELEGATION_OBJECT_TYPE_V1, + CHECKPOINT, + ADMIN.delegateAddress, +); + +const TRANSITION_UNSIGNED_CANONICAL = `{"issuer":"0x4444444444444444444444444444444444444444","objectType":"CgOwnershipTransitionV1","payload":${TRANSITION_CANONICAL},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}`; +const ADMIN_UNSIGNED_CANONICAL = `{"issuer":"0x4444444444444444444444444444444444444444","objectType":"CgAdministrativeDelegationV1","payload":${ADMIN_CANONICAL},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}`; +const CHECKPOINT_UNSIGNED_CANONICAL = `{"issuer":"0x6666666666666666666666666666666666666666","objectType":"CheckpointAuthorityDelegationV1","payload":${CHECKPOINT_CANONICAL},"signatureEvidence":{"kind":"none"},"signatureSuite":"eip191-personal-sign-digest-v1"}`; + +describe('CgOwnershipTransitionV1 codec', () => { + it('pins canonical payload/envelope bytes and object digest', () => { + expect(canonicalizeCgOwnershipTransitionPayloadV1(TRANSITION)).toBe(TRANSITION_CANONICAL); + expect(new TextEncoder().encode(TRANSITION_CANONICAL)).toHaveLength(894); + expect(parseCanonicalCgOwnershipTransitionPayloadV1(TRANSITION_CANONICAL)) + .toEqual(TRANSITION); + + const canonicalEnvelope = decode( + canonicalizeUnsignedCgOwnershipTransitionEnvelopeBytesV1(TRANSITION_UNSIGNED), + ); + expect(canonicalEnvelope).toBe(TRANSITION_UNSIGNED_CANONICAL); + expect(new TextEncoder().encode(canonicalEnvelope)).toHaveLength(1085); + expect(computeCgOwnershipTransitionObjectDigestV1(TRANSITION_UNSIGNED)) + .toBe(TRANSITION_DIGEST); + expect(parseCanonicalUnsignedCgOwnershipTransitionEnvelopeV1(canonicalEnvelope)) + .toEqual(TRANSITION_UNSIGNED); + const signedEnvelope = signed(TRANSITION_UNSIGNED, TRANSITION_DIGEST); + expect(parseCanonicalSignedCgOwnershipTransitionEnvelopeV1( + decode(canonicalizeSignedCgOwnershipTransitionEnvelopeBytesV1(signedEnvelope)), + )).toEqual(signedEnvelope); + }); + + it('closes the one-based finalized transfer branch and orders chain positions', () => { + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + ownershipEpoch: '1', + previousFinalizedTransfer: null, + })).not.toThrow(); + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + ownershipEpoch: '0', + })).toThrow(/one-based/); + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + ownershipEpoch: '1', + })).toThrow(/only ownership epoch one/); + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + previousFinalizedTransfer: null, + })).toThrow(/only ownership epoch one/); + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + previousFinalizedTransfer: { + ...TRANSITION.previousFinalizedTransfer, + blockNumber: '102', + }, + })).toThrow(/must precede/); + + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + previousFinalizedTransfer: { + ...TRANSITION.previousFinalizedTransfer, + blockNumber: TRANSITION.finalizedTransfer.blockNumber, + transactionIndex: TRANSITION.finalizedTransfer.transactionIndex, + logIndex: '2', + }, + })).not.toThrow(); + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + previousFinalizedTransfer: { + ...TRANSITION.previousFinalizedTransfer, + blockNumber: TRANSITION.finalizedTransfer.blockNumber, + transactionIndex: '1', + logIndex: '99', + }, + finalizedTransfer: { + ...TRANSITION.finalizedTransfer, + transactionIndex: '2', + logIndex: '0', + }, + })).not.toThrow(); + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + previousFinalizedTransfer: { + ...TRANSITION.previousFinalizedTransfer, + blockNumber: TRANSITION.finalizedTransfer.blockNumber, + transactionIndex: TRANSITION.finalizedTransfer.transactionIndex, + logIndex: TRANSITION.finalizedTransfer.logIndex, + }, + })).toThrow(/must precede/); + }); + + it('rejects unknown modes, fields, and noncanonical scalars', () => { + expect(() => assertCgOwnershipTransitionV1({ ...TRANSITION, mode: 'linked-v1' })) + .toThrow(/chain-rebaseline-v1/); + expect(() => assertCgOwnershipTransitionV1({ ...TRANSITION, subGraphName: 'forbidden' })) + .toThrow(/admin-authority-schema/); + expect(() => assertCgOwnershipTransitionV1({ ...TRANSITION, ownershipEpoch: 2 })) + .toThrow(/admin-authority-scalar/); + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + finalizedTransfer: { + ...TRANSITION.finalizedTransfer, + newOwnerAddress: '0x0000000000000000000000000000000000000000', + }, + })).toThrow(/admin-authority-scalar/); + }); + + it('accepts the structurally legal all-zero digest in every required hash field', () => { + expect(() => assertCgOwnershipTransitionV1({ + ...TRANSITION, + previousFinalizedTransfer: { + ...TRANSITION.previousFinalizedTransfer, + blockHash: ZERO_DIGEST, + transactionHash: ZERO_DIGEST, + }, + finalizedTransfer: { + ...TRANSITION.finalizedTransfer, + blockHash: ZERO_DIGEST, + transactionHash: ZERO_DIGEST, + }, + })).not.toThrow(); + }); +}); + +describe('CgAdministrativeDelegationV1 codec', () => { + it('pins canonical payload/envelope bytes and object digest', () => { + expect(canonicalizeCgAdministrativeDelegationPayloadV1(ADMIN)).toBe(ADMIN_CANONICAL); + expect(new TextEncoder().encode(ADMIN_CANONICAL)).toHaveLength(728); + expect(parseCanonicalCgAdministrativeDelegationPayloadV1(ADMIN_CANONICAL)).toEqual(ADMIN); + + const canonicalEnvelope = decode( + canonicalizeUnsignedCgAdministrativeDelegationEnvelopeBytesV1(ADMIN_UNSIGNED), + ); + expect(canonicalEnvelope).toBe(ADMIN_UNSIGNED_CANONICAL); + expect(new TextEncoder().encode(canonicalEnvelope)).toHaveLength(924); + expect(computeCgAdministrativeDelegationObjectDigestV1(ADMIN_UNSIGNED)).toBe(ADMIN_DIGEST); + expect(parseCanonicalUnsignedCgAdministrativeDelegationEnvelopeV1(canonicalEnvelope)) + .toEqual(ADMIN_UNSIGNED); + const signedEnvelope = signed(ADMIN_UNSIGNED, ADMIN_DIGEST); + expect(parseCanonicalSignedCgAdministrativeDelegationEnvelopeV1( + decode(canonicalizeSignedCgAdministrativeDelegationEnvelopeBytesV1(signedEnvelope)), + )).toEqual(signedEnvelope); + }); + + it('freezes the exact role registry and requires sorted unique literal roles', () => { + expect(Object.isFrozen(CG_ADMINISTRATIVE_DELEGATION_ROLES_V1)).toBe(true); + expect(CG_ADMINISTRATIVE_DELEGATION_ROLES_V1).toEqual([ + 'checkpoint-delegation', + 'policy', + 'retention', + 'roster', + ]); + expect(() => (CG_ADMINISTRATIVE_DELEGATION_ROLES_V1 as unknown as string[]).push('owner')) + .toThrow(); + expect(() => assertCgAdministrativeDelegationV1({ ...ADMIN, roles: [] })).not.toThrow(); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + roles: ['policy', 'checkpoint-delegation'], + })).toThrow(/strictly sorted/); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + roles: ['policy', 'policy'], + })).toThrow(/strictly sorted/); + expect(() => assertCgAdministrativeDelegationV1({ ...ADMIN, roles: ['vm-publisher'] })) + .toThrow(/admin-authority-role/); + + let coercions = 0; + const hostile = { toString() { coercions += 1; return 'policy'; } }; + expect(() => assertCgAdministrativeDelegationV1({ ...ADMIN, roles: [hostile] })) + .toThrow(/admin-authority-role/); + expect(coercions).toBe(0); + }); + + it('closes finalized-chain and owner-signed-unregistered source branches', () => { + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + ownershipTransitionDigest: null, + })).not.toThrow(); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + ownershipTransitionDigest: null, + source: { kind: 'owner-signed-unregistered', ownerAuthorityEra: '0' }, + })).not.toThrow(); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + source: { kind: 'owner-signed-unregistered', ownerAuthorityEra: '0' }, + })).toThrow(/requires a null ownership transition/); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + source: { ...ADMIN.source, kind: 'claimed-owner' }, + })).toThrow(/source kind/); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + source: { ...ADMIN.source, proof: 'extra' }, + })).toThrow(/admin-authority-schema/); + }); + + it('enforces the local genesis/successor predecessor branch in both directions', () => { + expect(() => assertCgAdministrativeDelegationV1(ADMIN)).not.toThrow(); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + version: '1', + previousDelegationDigest: ZERO_DIGEST, + })).not.toThrow(); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + adminEra: '1', + previousDelegationDigest: ZERO_DIGEST, + })).not.toThrow(); + + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + version: '1', + previousDelegationDigest: null, + })).toThrow(/null exactly for admin era\/version zero/); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + adminEra: '1', + previousDelegationDigest: null, + })).toThrow(/null exactly for admin era\/version zero/); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + previousDelegationDigest: ZERO_DIGEST, + })).toThrow(/null exactly for admin era\/version zero/); + }); + + it('accepts all-zero values in required and optional administrative digest fields', () => { + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + ownershipTransitionDigest: ZERO_DIGEST, + source: { ...ADMIN.source, blockHash: ZERO_DIGEST }, + })).not.toThrow(); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + version: '1', + previousDelegationDigest: ZERO_DIGEST, + source: { ...ADMIN.source, blockHash: ZERO_DIGEST }, + })).not.toThrow(); + }); + + it('requires a strictly increasing validity interval', () => { + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + expiresAt: ADMIN.effectiveAt, + })).toThrow(/effectiveAt must be earlier/); + expect(() => assertCgAdministrativeDelegationV1({ + ...ADMIN, + effectiveAt: '1700003601001', + })).toThrow(/effectiveAt must be earlier/); + }); +}); + +describe('CheckpointAuthorityDelegationV1 codec', () => { + it('pins canonical payload/envelope bytes and object digest', () => { + expect(canonicalizeCheckpointAuthorityDelegationPayloadV1(CHECKPOINT)) + .toBe(CHECKPOINT_CANONICAL); + expect(new TextEncoder().encode(CHECKPOINT_CANONICAL)).toHaveLength(735); + expect(parseCanonicalCheckpointAuthorityDelegationPayloadV1(CHECKPOINT_CANONICAL)) + .toEqual(CHECKPOINT); + + const canonicalEnvelope = decode( + canonicalizeUnsignedCheckpointAuthorityDelegationEnvelopeBytesV1(CHECKPOINT_UNSIGNED), + ); + expect(canonicalEnvelope).toBe(CHECKPOINT_UNSIGNED_CANONICAL); + expect(new TextEncoder().encode(canonicalEnvelope)).toHaveLength(934); + expect(computeCheckpointAuthorityDelegationObjectDigestV1(CHECKPOINT_UNSIGNED)) + .toBe(CHECKPOINT_DIGEST); + expect(parseCanonicalUnsignedCheckpointAuthorityDelegationEnvelopeV1(canonicalEnvelope)) + .toEqual(CHECKPOINT_UNSIGNED); + const signedEnvelope = signed(CHECKPOINT_UNSIGNED, CHECKPOINT_DIGEST); + expect(parseCanonicalSignedCheckpointAuthorityDelegationEnvelopeV1( + decode(canonicalizeSignedCheckpointAuthorityDelegationEnvelopeBytesV1(signedEnvelope)), + )).toEqual(signedEnvelope); + }); + + it('requires predecessor digest/version to be jointly null or non-null', () => { + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + predecessorCheckpointDigest: `0x${'91'.repeat(32)}`, + })).toThrow(/jointly null/); + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + predecessorCheckpointVersion: '0', + })).toThrow(/jointly null/); + }); + + it('accepts the local initial branch shape', () => { + const initial = { + ...CHECKPOINT, + ownershipTransitionDigest: null, + rebaselineTransitionDigest: null, + administrativeDelegationDigest: null, + }; + expect(() => assertCheckpointAuthorityDelegationV1(initial)).not.toThrow(); + }); + + it('accepts the local ownership-rebaseline branch shape', () => { + expect(() => assertCheckpointAuthorityDelegationV1(CHECKPOINT)).not.toThrow(); + }); + + it('accepts the local ordinary-rotation branch shape', () => { + const rotation = { + ...CHECKPOINT, + authorityEpoch: '1', + previousDelegationDigest: `0x${'90'.repeat(32)}`, + predecessorCheckpointDigest: `0x${'91'.repeat(32)}`, + predecessorCheckpointVersion: '8', + rebaselineTransitionDigest: null, + }; + expect(() => assertCheckpointAuthorityDelegationV1(rotation)).not.toThrow(); + }); + + it('rejects values that mix initial, rebaseline, and ordinary-rotation branches', () => { + const rotation = { + ...CHECKPOINT, + authorityEpoch: '1', + previousDelegationDigest: `0x${'90'.repeat(32)}`, + predecessorCheckpointDigest: `0x${'91'.repeat(32)}`, + predecessorCheckpointVersion: '8', + rebaselineTransitionDigest: null, + }; + expect(() => assertCheckpointAuthorityDelegationV1({ + ...rotation, + previousDelegationDigest: null, + })).toThrow(/ordinary rotation requires/); + expect(() => assertCheckpointAuthorityDelegationV1({ + ...rotation, + authorityEpoch: '0', + })).toThrow(/ordinary rotation requires/); + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + rebaselineTransitionDigest: `0x${'99'.repeat(32)}`, + })).toThrow(/rebaseline must bind/); + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + previousDelegationDigest: `0x${'90'.repeat(32)}`, + })).toThrow(/rebaseline must bind/); + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + rebaselineTransitionDigest: null, + })).toThrow(/initial delegation/); + }); + + it('accepts all-zero values in every optional checkpoint digest field', () => { + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + ownershipTransitionDigest: ZERO_DIGEST, + rebaselineTransitionDigest: ZERO_DIGEST, + administrativeDelegationDigest: ZERO_DIGEST, + })).not.toThrow(); + + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + ownershipTransitionDigest: ZERO_DIGEST, + authorityEpoch: '1', + previousDelegationDigest: ZERO_DIGEST, + predecessorCheckpointDigest: ZERO_DIGEST, + predecessorCheckpointVersion: '0', + rebaselineTransitionDigest: null, + administrativeDelegationDigest: ZERO_DIGEST, + })).not.toThrow(); + }); + + it('rejects equal standby authority and non-increasing validity', () => { + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + standbyCheckpointAuthorityAddress: CHECKPOINT.checkpointAuthorityAddress, + })).toThrow(/standby authority must differ/); + expect(() => assertCheckpointAuthorityDelegationV1({ + ...CHECKPOINT, + expiresAt: CHECKPOINT.activatedAt, + })).toThrow(/activatedAt must be earlier/); + }); +}); + +describe('administrative authority hostile-input and envelope boundaries', () => { + it('enforces the 16 KiB payload cap and depth three before typed use', () => { + const exactlyAtCap = `"${'a'.repeat( + MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1 - 2, + )}"`; + expect(new TextEncoder().encode(exactlyAtCap)).toHaveLength( + MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1, + ); + expect(() => parseCanonicalCgOwnershipTransitionPayloadV1(exactlyAtCap)) + .toThrow(/admin-authority-schema/); + + const oneByteOver = `"${'a'.repeat( + MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1 - 1, + )}"`; + expect(() => parseCanonicalCgOwnershipTransitionPayloadV1(oneByteOver)) + .toThrow(/admin-authority-payload-too-large/); + const multibyteOver = `"${'é'.repeat( + MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1 / 2, + )}"`; + expect(multibyteOver.length).toBeLessThan(MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1); + expect(() => parseCanonicalCgOwnershipTransitionPayloadV1(multibyteOver)) + .toThrow(/admin-authority-payload-too-large/); + expect(() => parseCanonicalCgOwnershipTransitionPayloadV1( + new Uint8Array(MAX_ADMINISTRATIVE_AUTHORITY_PAYLOAD_BYTES_V1 + 1), + )).toThrow(/admin-authority-payload-too-large/); + expect(() => parseCanonicalCgAdministrativeDelegationPayloadV1('{"a":{"b":{"c":{}}}}')) + .toThrow(/nesting exceeds 3/); + expect(() => parseCanonicalCheckpointAuthorityDelegationPayloadV1( + CHECKPOINT_CANONICAL.replace('"authorityEpoch":"0"', '"authorityEpoch": "0"'), + )).toThrow(); + }); + + it('rejects accessors and stateful payload or whole-envelope proxies', () => { + let getterCalls = 0; + const source = { ...ADMIN.source } as Record; + Object.defineProperty(source, 'kind', { + enumerable: true, + get() { + getterCalls += 1; + return 'finalized-chain-owner'; + }, + }); + expect(() => assertCgAdministrativeDelegationV1({ ...ADMIN, source })) + .toThrow(/admin-authority-schema/); + expect(getterCalls).toBe(0); + + let roleReads = 0; + const roles = new Proxy(['policy'], { + get(target, property, receiver) { + if (property === '0') { + roleReads += 1; + return roleReads === 1 ? 'policy' : 'owner'; + } + return Reflect.get(target, property, receiver); + }, + }); + expect(() => assertCgAdministrativeDelegationV1({ ...ADMIN, roles })) + .toThrow(/admin-authority-schema/); + + let payloadReads = 0; + const envelope = new Proxy(ADMIN_UNSIGNED, { + get(target, property, receiver) { + if (property === 'payload') { + payloadReads += 1; + return payloadReads <= 2 ? ADMIN : { granted: true }; + } + return Reflect.get(target, property, receiver); + }, + }); + expect(() => canonicalizeUnsignedCgAdministrativeDelegationEnvelopeBytesV1(envelope)) + .toThrow(/admin-authority-schema/); + expect(payloadReads).toBe(2); + }); + + it('enforces exact object types and validates signed digests', () => { + expect(() => assertUnsignedCgOwnershipTransitionEnvelopeV1(TRANSITION_UNSIGNED)) + .not.toThrow(); + expect(() => assertUnsignedCgAdministrativeDelegationEnvelopeV1(ADMIN_UNSIGNED)) + .not.toThrow(); + expect(() => assertUnsignedCheckpointAuthorityDelegationEnvelopeV1(CHECKPOINT_UNSIGNED)) + .not.toThrow(); + expect(() => assertSignedCgOwnershipTransitionEnvelopeV1( + signed(TRANSITION_UNSIGNED, TRANSITION_DIGEST), + )).not.toThrow(); + expect(() => assertSignedCgAdministrativeDelegationEnvelopeV1( + signed(ADMIN_UNSIGNED, ADMIN_DIGEST), + )).not.toThrow(); + expect(() => assertSignedCheckpointAuthorityDelegationEnvelopeV1( + signed(CHECKPOINT_UNSIGNED, CHECKPOINT_DIGEST), + )).not.toThrow(); + expect(() => assertUnsignedCgOwnershipTransitionEnvelopeV1({ + ...TRANSITION_UNSIGNED, + objectType: CG_ADMINISTRATIVE_DELEGATION_OBJECT_TYPE_V1, + })).toThrow(/admin-authority-type/); + expect(() => assertSignedCheckpointAuthorityDelegationEnvelopeV1({ + ...signed(CHECKPOINT_UNSIGNED, CHECKPOINT_DIGEST), + objectDigest: TRANSITION_DIGEST, + })).toThrow(/admin-authority-schema/); + }); + + it('binds ownership and administrative issuers on unsigned and redigested signed envelopes', () => { + const wrongTransitionIssuer = { + ...TRANSITION_UNSIGNED, + issuer: '0x5555555555555555555555555555555555555555', + } as UnsignedControlEnvelopeV1; + expect(() => assertUnsignedCgOwnershipTransitionEnvelopeV1(wrongTransitionIssuer)) + .toThrow(/issuer must equal finalizedTransfer\.newOwnerAddress/); + const redigestedTransition = computeControlObjectDigestHex(wrongTransitionIssuer); + expect(redigestedTransition).not.toBe(TRANSITION_DIGEST); + expect(() => assertSignedCgOwnershipTransitionEnvelopeV1( + signed(wrongTransitionIssuer, redigestedTransition), + )).toThrow(/issuer must equal finalizedTransfer\.newOwnerAddress/); + + const wrongAdministrativeIssuer = { + ...ADMIN_UNSIGNED, + issuer: ADMIN.delegateAddress, + } as UnsignedControlEnvelopeV1; + expect(() => assertUnsignedCgAdministrativeDelegationEnvelopeV1( + wrongAdministrativeIssuer, + )).toThrow(/issuer must equal ownerAddress/); + const redigestedAdministrative = computeControlObjectDigestHex(wrongAdministrativeIssuer); + expect(redigestedAdministrative).not.toBe(ADMIN_DIGEST); + expect(() => assertSignedCgAdministrativeDelegationEnvelopeV1( + signed(wrongAdministrativeIssuer, redigestedAdministrative), + )).toThrow(/issuer must equal ownerAddress/); + }); +}); + +function validatedTransition(value: unknown): CgOwnershipTransitionV1 { + assertCgOwnershipTransitionV1(value); + return value; +} + +function validatedAdmin(value: unknown): CgAdministrativeDelegationV1 { + assertCgAdministrativeDelegationV1(value); + return value; +} + +function validatedCheckpoint(value: unknown): CheckpointAuthorityDelegationV1 { + assertCheckpointAuthorityDelegationV1(value); + return value; +} + +function unsigned(objectType: string, payload: object, issuer: string): UnsignedControlEnvelopeV1 { + return { + issuer, + objectType, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; +} + +function signed( + envelope: UnsignedControlEnvelopeV1, + objectDigest: string, +): SignedControlEnvelopeV1 { + return { ...envelope, objectDigest, signature: SIGNATURE } as SignedControlEnvelopeV1; +} + +function decode(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} From 72347947cb6bc1a0b60d037138d99ba350998b53 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:03:32 +0200 Subject: [PATCH 031/292] feat(core): add RFC-64 catalog authority codecs --- .../src/author-catalog-authority-objects.ts | 629 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../author-catalog-authority-objects.test.ts | 325 +++++++++ 3 files changed, 955 insertions(+) create mode 100644 packages/core/src/author-catalog-authority-objects.ts create mode 100644 packages/core/test/author-catalog-authority-objects.test.ts diff --git a/packages/core/src/author-catalog-authority-objects.ts b/packages/core/src/author-catalog-authority-objects.ts new file mode 100644 index 0000000000..eb859b07b3 --- /dev/null +++ b/packages/core/src/author-catalog-authority-objects.ts @@ -0,0 +1,629 @@ +import { + canonicalizeJson, + parseCanonicalJson, + type CanonicalJsonValue, + type StrictJsonParseOptions, +} from './canonical-json.js'; +import { + assertContextGraphIdV1, + assertNetworkIdV1, + assertSubGraphNameV1, + type ContextGraphIdV1, + type NetworkIdV1, + type SubGraphNameV1, +} from './author-catalog-codec.js'; +import { + assertSignedControlEnvelope, + assertUnsignedControlEnvelope, + canonicalizeSignedControlEnvelopeBytes, + canonicalizeUnsignedControlEnvelopeBytes, + computeControlObjectDigestHex, + parseCanonicalSignedControlEnvelope, + parseCanonicalUnsignedControlEnvelope, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from './sync-control-object.js'; +import { + assertCanonicalChainId, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalTimestampMs, + parseCanonicalDecimalU64, + type ChainIdV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type TimestampMsV1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +export const AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1 = + 'AuthorCatalogIssuerDelegationV1' as const; +export const CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1 = + 'CatalogHeadTimelinessReceiptV1' as const; +export const MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1 = 16 * 1024; + +const UTF8 = new TextEncoder(); + +export interface AuthorCatalogIssuerDelegationV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly governanceChainId: ChainIdV1 | null; + readonly governanceContractAddress: EvmAddressV1 | null; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogEra: DecimalU64V1; + readonly previousDelegationDigest: Digest32V1 | null; + readonly catalogIssuerKey: EvmAddressV1; + readonly authorAuthorityEvidenceDigest: Digest32V1 | null; + readonly effectiveAt: TimestampMsV1; + readonly expiresAt: TimestampMsV1; +} + +export interface CatalogHeadTimelinessReceiptV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly governanceChainId: ChainIdV1 | null; + readonly governanceContractAddress: EvmAddressV1 | null; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly subGraphName: SubGraphNameV1 | null; + readonly checkpointAuthorityDelegationDigest: Digest32V1; + readonly authorAddress: EvmAddressV1; + readonly catalogIssuerDelegationDigest: Digest32V1; + readonly catalogHeadDigest: Digest32V1; + readonly observedAt: TimestampMsV1; +} + +export type UnsignedAuthorCatalogIssuerDelegationEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1; + readonly payload: AuthorCatalogIssuerDelegationV1; +}; +export type SignedAuthorCatalogIssuerDelegationEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1; + readonly payload: AuthorCatalogIssuerDelegationV1; +}; +export type UnsignedCatalogHeadTimelinessReceiptEnvelopeV1 = UnsignedControlEnvelopeV1 & { + readonly objectType: typeof CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1; + readonly payload: CatalogHeadTimelinessReceiptV1; +}; +export type SignedCatalogHeadTimelinessReceiptEnvelopeV1 = SignedControlEnvelopeV1 & { + readonly objectType: typeof CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1; + readonly payload: CatalogHeadTimelinessReceiptV1; +}; + +export type AuthorCatalogAuthorityCodecErrorCode = + | 'catalog-authority-schema' + | 'catalog-authority-scalar' + | 'catalog-authority-type' + | 'catalog-authority-governance' + | 'catalog-authority-history' + | 'catalog-authority-authority' + | 'catalog-authority-time' + | 'catalog-authority-payload-too-large'; + +export class AuthorCatalogAuthorityCodecError extends Error { + constructor( + readonly code: AuthorCatalogAuthorityCodecErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'AuthorCatalogAuthorityCodecError'; + } +} + +export function assertAuthorCatalogIssuerDelegationV1( + value: unknown, +): asserts value is AuthorCatalogIssuerDelegationV1 { + validateDelegationSnapshot(value); +} + +export function assertCatalogHeadTimelinessReceiptV1( + value: unknown, +): asserts value is CatalogHeadTimelinessReceiptV1 { + validateReceiptSnapshot(value); +} + +export function canonicalizeAuthorCatalogIssuerDelegationPayloadV1( + value: AuthorCatalogIssuerDelegationV1, +): string { + return validateDelegationSnapshot(value).canonical; +} + +export function canonicalizeAuthorCatalogIssuerDelegationPayloadBytesV1( + value: AuthorCatalogIssuerDelegationV1, +): Uint8Array { + return UTF8.encode(canonicalizeAuthorCatalogIssuerDelegationPayloadV1(value)); +} + +export function canonicalizeCatalogHeadTimelinessReceiptPayloadV1( + value: CatalogHeadTimelinessReceiptV1, +): string { + return validateReceiptSnapshot(value).canonical; +} + +export function canonicalizeCatalogHeadTimelinessReceiptPayloadBytesV1( + value: CatalogHeadTimelinessReceiptV1, +): Uint8Array { + return UTF8.encode(canonicalizeCatalogHeadTimelinessReceiptPayloadV1(value)); +} + +export function parseCanonicalAuthorCatalogIssuerDelegationPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): AuthorCatalogIssuerDelegationV1 { + rejectOversizedInput(input, 'author catalog issuer delegation'); + return validateDelegationSnapshot(parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 1, 1), + })).snapshot; +} + +export function parseCanonicalCatalogHeadTimelinessReceiptPayloadV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): CatalogHeadTimelinessReceiptV1 { + rejectOversizedInput(input, 'catalog head timeliness receipt'); + return validateReceiptSnapshot(parseCanonicalJson(input, { + ...options, + maxBytes: Math.min( + options.maxBytes ?? MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, + ), + maxDepth: Math.min(options.maxDepth ?? 1, 1), + })).snapshot; +} + +export function assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1( + value: UnsignedControlEnvelopeV1, +): asserts value is UnsignedAuthorCatalogIssuerDelegationEnvelopeV1 { + validateUnsignedDelegationEnvelopeSnapshot(value); +} + +export function assertSignedAuthorCatalogIssuerDelegationEnvelopeV1( + value: SignedControlEnvelopeV1, +): asserts value is SignedAuthorCatalogIssuerDelegationEnvelopeV1 { + validateSignedDelegationEnvelopeSnapshot(value); +} + +export function assertUnsignedCatalogHeadTimelinessReceiptEnvelopeV1( + value: UnsignedControlEnvelopeV1, +): asserts value is UnsignedCatalogHeadTimelinessReceiptEnvelopeV1 { + validateUnsignedReceiptEnvelopeSnapshot(value); +} + +export function assertSignedCatalogHeadTimelinessReceiptEnvelopeV1( + value: SignedControlEnvelopeV1, +): asserts value is SignedCatalogHeadTimelinessReceiptEnvelopeV1 { + validateSignedReceiptEnvelopeSnapshot(value); +} + +export function canonicalizeUnsignedAuthorCatalogIssuerDelegationEnvelopeBytesV1( + value: UnsignedControlEnvelopeV1, +): Uint8Array { + return canonicalizeUnsignedControlEnvelopeBytes(validateUnsignedDelegationEnvelopeSnapshot(value)); +} + +export function canonicalizeSignedAuthorCatalogIssuerDelegationEnvelopeBytesV1( + value: SignedControlEnvelopeV1, +): Uint8Array { + return canonicalizeSignedControlEnvelopeBytes(validateSignedDelegationEnvelopeSnapshot(value)); +} + +export function canonicalizeUnsignedCatalogHeadTimelinessReceiptEnvelopeBytesV1( + value: UnsignedControlEnvelopeV1, +): Uint8Array { + return canonicalizeUnsignedControlEnvelopeBytes(validateUnsignedReceiptEnvelopeSnapshot(value)); +} + +export function canonicalizeSignedCatalogHeadTimelinessReceiptEnvelopeBytesV1( + value: SignedControlEnvelopeV1, +): Uint8Array { + return canonicalizeSignedControlEnvelopeBytes(validateSignedReceiptEnvelopeSnapshot(value)); +} + +export function computeAuthorCatalogIssuerDelegationObjectDigestV1( + value: UnsignedControlEnvelopeV1, +): Digest32V1 { + return asDigest(computeControlObjectDigestHex(validateUnsignedDelegationEnvelopeSnapshot(value))); +} + +export function computeCatalogHeadTimelinessReceiptObjectDigestV1( + value: UnsignedControlEnvelopeV1, +): Digest32V1 { + return asDigest(computeControlObjectDigestHex(validateUnsignedReceiptEnvelopeSnapshot(value))); +} + +export function parseCanonicalUnsignedAuthorCatalogIssuerDelegationEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedAuthorCatalogIssuerDelegationEnvelopeV1 { + return validateUnsignedDelegationEnvelopeSnapshot( + parseCanonicalUnsignedControlEnvelope(input, options), + ); +} + +export function parseCanonicalSignedAuthorCatalogIssuerDelegationEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedAuthorCatalogIssuerDelegationEnvelopeV1 { + return validateSignedDelegationEnvelopeSnapshot( + parseCanonicalSignedControlEnvelope(input, options), + ); +} + +export function parseCanonicalUnsignedCatalogHeadTimelinessReceiptEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): UnsignedCatalogHeadTimelinessReceiptEnvelopeV1 { + return validateUnsignedReceiptEnvelopeSnapshot( + parseCanonicalUnsignedControlEnvelope(input, options), + ); +} + +export function parseCanonicalSignedCatalogHeadTimelinessReceiptEnvelopeV1( + input: string | Uint8Array, + options: StrictJsonParseOptions = {}, +): SignedCatalogHeadTimelinessReceiptEnvelopeV1 { + return validateSignedReceiptEnvelopeSnapshot( + parseCanonicalSignedControlEnvelope(input, options), + ); +} + +function validateUnsignedDelegationEnvelopeSnapshot( + value: unknown, +): UnsignedAuthorCatalogIssuerDelegationEnvelopeV1 { + assertUnsignedControlEnvelope(value as UnsignedControlEnvelopeV1); + const envelope = value as UnsignedControlEnvelopeV1; + assertObjectType(envelope.objectType, AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1); + validateDelegationPlain(envelope.payload); + const snapshot = clonePlainData(envelope, 'unsigned catalog issuer delegation envelope'); + assertUnsignedControlEnvelope(snapshot); + assertObjectType(snapshot.objectType, AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1); + validateDelegationPlain(snapshot.payload); + const typedSnapshot = snapshot as UnsignedAuthorCatalogIssuerDelegationEnvelopeV1; + assertDelegationAuthorPairing(typedSnapshot); + return typedSnapshot; +} + +function validateSignedDelegationEnvelopeSnapshot( + value: unknown, +): SignedAuthorCatalogIssuerDelegationEnvelopeV1 { + assertSignedControlEnvelope(value as SignedControlEnvelopeV1); + const envelope = value as SignedControlEnvelopeV1; + assertObjectType(envelope.objectType, AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1); + validateDelegationPlain(envelope.payload); + const snapshot = clonePlainData(envelope, 'signed catalog issuer delegation envelope'); + assertSignedControlEnvelope(snapshot); + assertObjectType(snapshot.objectType, AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1); + validateDelegationPlain(snapshot.payload); + const typedSnapshot = snapshot as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + assertDelegationAuthorPairing(typedSnapshot); + return typedSnapshot; +} + +/** + * Close the structural direct/delegated issuer branch on the stable envelope + * snapshot. A non-null evidence digest is only a reference here: resolving and + * verifying the referenced author/agent delegation remains a runtime authority + * check, outside this wire codec. + */ +function assertDelegationAuthorPairing( + envelope: + | UnsignedAuthorCatalogIssuerDelegationEnvelopeV1 + | SignedAuthorCatalogIssuerDelegationEnvelopeV1, +): void { + const issuerIsAuthor = envelope.issuer === envelope.payload.authorAddress; + const evidenceIsNull = envelope.payload.authorAuthorityEvidenceDigest === null; + if (issuerIsAuthor !== evidenceIsNull) { + fail( + 'catalog-authority-authority', + 'the author must issue directly without authority evidence, and every other issuer must name authority evidence', + ); + } +} + +function validateUnsignedReceiptEnvelopeSnapshot( + value: unknown, +): UnsignedCatalogHeadTimelinessReceiptEnvelopeV1 { + assertUnsignedControlEnvelope(value as UnsignedControlEnvelopeV1); + const envelope = value as UnsignedControlEnvelopeV1; + assertObjectType(envelope.objectType, CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1); + validateReceiptPlain(envelope.payload); + const snapshot = clonePlainData(envelope, 'unsigned catalog timeliness receipt envelope'); + assertUnsignedControlEnvelope(snapshot); + assertObjectType(snapshot.objectType, CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1); + validateReceiptPlain(snapshot.payload); + return snapshot as UnsignedCatalogHeadTimelinessReceiptEnvelopeV1; +} + +function validateSignedReceiptEnvelopeSnapshot( + value: unknown, +): SignedCatalogHeadTimelinessReceiptEnvelopeV1 { + assertSignedControlEnvelope(value as SignedControlEnvelopeV1); + const envelope = value as SignedControlEnvelopeV1; + assertObjectType(envelope.objectType, CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1); + validateReceiptPlain(envelope.payload); + const snapshot = clonePlainData(envelope, 'signed catalog timeliness receipt envelope'); + assertSignedControlEnvelope(snapshot); + assertObjectType(snapshot.objectType, CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1); + validateReceiptPlain(snapshot.payload); + return snapshot as SignedCatalogHeadTimelinessReceiptEnvelopeV1; +} + +function validateDelegationSnapshot(value: unknown): { + readonly snapshot: AuthorCatalogIssuerDelegationV1; + readonly canonical: string; +} { + const bounded = validateDelegationPlain(value); + return validateDelegationPlain(clonePlainData(bounded.snapshot, 'catalog issuer delegation')); +} + +function validateDelegationPlain(value: unknown): { + readonly snapshot: AuthorCatalogIssuerDelegationV1; + readonly canonical: string; +} { + if (!isPlainRecord(value)) fail('catalog-authority-schema', 'delegation must be a plain object'); + closed(value, [ + 'authorAddress', + 'authorAuthorityEvidenceDigest', + 'catalogEra', + 'catalogIssuerKey', + 'contextGraphId', + 'effectiveAt', + 'expiresAt', + 'governanceChainId', + 'governanceContractAddress', + 'networkId', + 'ownershipTransitionDigest', + 'previousDelegationDigest', + 'subGraphName', + ], 'catalog issuer delegation'); + scalar(() => assertNetworkIdV1(value.networkId)); + scalar(() => assertContextGraphIdV1(value.contextGraphId)); + assertGovernanceScope( + value.governanceChainId, + value.governanceContractAddress, + value.ownershipTransitionDigest, + ); + if (value.subGraphName !== null) scalar(() => assertSubGraphNameV1(value.subGraphName)); + scalar(() => assertCanonicalEvmAddress(value.authorAddress, 'authorAddress')); + const era = u64(value.catalogEra, 'catalogEra'); + optionalDigest(value.previousDelegationDigest, 'previousDelegationDigest'); + if ((era === 0n) !== (value.previousDelegationDigest === null)) { + fail( + 'catalog-authority-history', + 'catalog era zero requires a null predecessor and later eras require one', + ); + } + scalar(() => assertCanonicalEvmAddress(value.catalogIssuerKey, 'catalogIssuerKey')); + optionalDigest(value.authorAuthorityEvidenceDigest, 'authorAuthorityEvidenceDigest'); + const effectiveAt = timestamp(value.effectiveAt, 'effectiveAt'); + const expiresAt = timestamp(value.expiresAt, 'expiresAt'); + if (effectiveAt >= expiresAt) { + fail('catalog-authority-time', 'effectiveAt must be strictly earlier than expiresAt'); + } + const snapshot = value as unknown as AuthorCatalogIssuerDelegationV1; + return { snapshot, canonical: canonicalizeBounded(snapshot, 'catalog issuer delegation') }; +} + +function validateReceiptSnapshot(value: unknown): { + readonly snapshot: CatalogHeadTimelinessReceiptV1; + readonly canonical: string; +} { + const bounded = validateReceiptPlain(value); + return validateReceiptPlain(clonePlainData(bounded.snapshot, 'catalog head timeliness receipt')); +} + +function validateReceiptPlain(value: unknown): { + readonly snapshot: CatalogHeadTimelinessReceiptV1; + readonly canonical: string; +} { + if (!isPlainRecord(value)) fail('catalog-authority-schema', 'receipt must be a plain object'); + closed(value, [ + 'authorAddress', + 'catalogHeadDigest', + 'catalogIssuerDelegationDigest', + 'checkpointAuthorityDelegationDigest', + 'contextGraphId', + 'governanceChainId', + 'governanceContractAddress', + 'networkId', + 'observedAt', + 'ownershipTransitionDigest', + 'subGraphName', + ], 'catalog head timeliness receipt'); + scalar(() => assertNetworkIdV1(value.networkId)); + scalar(() => assertContextGraphIdV1(value.contextGraphId)); + assertGovernanceScope( + value.governanceChainId, + value.governanceContractAddress, + value.ownershipTransitionDigest, + ); + if (value.subGraphName !== null) scalar(() => assertSubGraphNameV1(value.subGraphName)); + scalar(() => assertCanonicalDigest( + value.checkpointAuthorityDelegationDigest, + 'checkpointAuthorityDelegationDigest', + )); + scalar(() => assertCanonicalEvmAddress(value.authorAddress, 'authorAddress')); + scalar(() => assertCanonicalDigest( + value.catalogIssuerDelegationDigest, + 'catalogIssuerDelegationDigest', + )); + scalar(() => assertCanonicalDigest(value.catalogHeadDigest, 'catalogHeadDigest')); + timestamp(value.observedAt, 'observedAt'); + const snapshot = value as unknown as CatalogHeadTimelinessReceiptV1; + return { snapshot, canonical: canonicalizeBounded(snapshot, 'catalog head timeliness receipt') }; +} + +function assertGovernanceScope( + chainId: unknown, + contractAddress: unknown, + ownershipTransitionDigest: unknown, +): void { + if ((chainId === null) !== (contractAddress === null)) { + fail('catalog-authority-governance', 'governance tuple must be jointly null/non-null'); + } + if (chainId === null) { + if (ownershipTransitionDigest !== null) { + fail('catalog-authority-governance', 'unregistered scope cannot name a transition'); + } + return; + } + scalar(() => assertCanonicalChainId(chainId)); + scalar(() => assertCanonicalEvmAddress(contractAddress, 'governanceContractAddress')); + optionalDigest(ownershipTransitionDigest, 'ownershipTransitionDigest'); +} + +function canonicalizeBounded( + value: AuthorCatalogIssuerDelegationV1 | CatalogHeadTimelinessReceiptV1, + label: string, +): string { + try { + return canonicalizeJson(value as unknown as CanonicalJsonValue, { + maxBytes: MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, + maxDepth: 1, + }); + } catch (cause) { + fail('catalog-authority-payload-too-large', `${label} exceeds its canonical cap`, cause); + } +} + +function clonePlainData(value: T, label: string): T { + assertStablePlainData(value, label, 0, new Set()); + try { + return structuredClone(value); + } catch (cause) { + fail('catalog-authority-schema', `${label} must be structured-cloneable JSON data`, cause); + } +} + +function assertStablePlainData( + value: unknown, + label: string, + depth: number, + ancestors: Set, +): void { + if (depth > 64) fail('catalog-authority-schema', `${label} exceeds nesting safety cap`); + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (!Number.isFinite(value)) fail('catalog-authority-schema', `${label} has a non-JSON number`); + return; + } + if (typeof value !== 'object') { + fail('catalog-authority-schema', `${label} has a non-JSON implementation value`); + } + if (ancestors.has(value)) fail('catalog-authority-schema', `${label} contains a cycle`); + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + fail('catalog-authority-schema', `${label} contains a non-ordinary array`); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string') || keys.length !== value.length + 1) { + fail('catalog-authority-schema', `${label} contains a sparse or custom array`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('catalog-authority-schema', `${label} contains an array accessor`); + } + assertStablePlainData(descriptor.value, `${label}[${index}]`, depth + 1, ancestors); + } + return; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + fail('catalog-authority-schema', `${label} contains a non-plain object`); + } + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') fail('catalog-authority-schema', `${label} contains a symbol`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('catalog-authority-schema', `${label} contains an object accessor`); + } + assertStablePlainData(descriptor.value, `${label}.${key}`, depth + 1, ancestors); + } + } finally { + ancestors.delete(value); + } +} + +function optionalDigest(value: unknown, label: string): void { + if (value !== null) scalar(() => assertCanonicalDigest(value, label)); +} + +function u64(value: unknown, label: string): bigint { + try { + return parseCanonicalDecimalU64(value, label); + } catch (cause) { + fail('catalog-authority-scalar', `${label} must be canonical DecimalU64V1`, cause); + } +} + +function timestamp(value: unknown, label: string): bigint { + scalar(() => assertCanonicalTimestampMs(value, label)); + return BigInt(value as string); +} + +function scalar(operation: () => void): void { + try { + operation(); + } catch (cause) { + fail('catalog-authority-scalar', 'catalog authority scalar is not canonical', cause); + } +} + +function closed(record: Record, keys: readonly string[], label: string): void { + try { + assertExactKeys(record, keys, label); + } catch (cause) { + fail('catalog-authority-schema', `${label} has an invalid field set`, cause); + } +} + +function assertObjectType(actual: string, expected: string): void { + if (actual !== expected) fail('catalog-authority-type', `objectType must be exactly ${expected}`); +} + +function asDigest(value: string): Digest32V1 { + assertCanonicalDigest(value, 'objectDigest'); + return value; +} + +function rejectOversizedInput(input: string | Uint8Array, label: string): void { + if ( + typeof input === 'string' + && input.length > MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1 + ) { + fail( + 'catalog-authority-payload-too-large', + `${label} exceeds ${MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1} bytes`, + ); + } + const bytes = typeof input === 'string' ? UTF8.encode(input).byteLength : input.byteLength; + if (bytes > MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1) { + fail( + 'catalog-authority-payload-too-large', + `${label} exceeds ${MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1} bytes`, + ); + } +} + +function fail( + code: AuthorCatalogAuthorityCodecErrorCode, + message: string, + cause?: unknown, +): never { + throw new AuthorCatalogAuthorityCodecError( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a04e391377..ec72a81c59 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export * from './imported-artifact-bytes.js'; export * from './imported-artifact-metadata.js'; export * from './sync-control-object.js'; export * from './cg-policy-objects.js'; +export * from './author-catalog-authority-objects.js'; export { MAX_DECIMAL_U64, MAX_DECIMAL_U256, diff --git a/packages/core/test/author-catalog-authority-objects.test.ts b/packages/core/test/author-catalog-authority-objects.test.ts new file mode 100644 index 0000000000..01cd4c41d9 --- /dev/null +++ b/packages/core/test/author-catalog-authority-objects.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest'; + +import { + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1, + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, + assertAuthorCatalogIssuerDelegationV1, + assertCatalogHeadTimelinessReceiptV1, + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, + assertSignedCatalogHeadTimelinessReceiptEnvelopeV1, + assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1, + assertUnsignedCatalogHeadTimelinessReceiptEnvelopeV1, + canonicalizeAuthorCatalogIssuerDelegationPayloadV1, + canonicalizeCatalogHeadTimelinessReceiptPayloadV1, + canonicalizeSignedAuthorCatalogIssuerDelegationEnvelopeBytesV1, + canonicalizeSignedCatalogHeadTimelinessReceiptEnvelopeBytesV1, + canonicalizeUnsignedAuthorCatalogIssuerDelegationEnvelopeBytesV1, + canonicalizeUnsignedCatalogHeadTimelinessReceiptEnvelopeBytesV1, + computeAuthorCatalogIssuerDelegationObjectDigestV1, + computeCatalogHeadTimelinessReceiptObjectDigestV1, + parseCanonicalAuthorCatalogIssuerDelegationPayloadV1, + parseCanonicalCatalogHeadTimelinessReceiptPayloadV1, + parseCanonicalSignedAuthorCatalogIssuerDelegationEnvelopeV1, + parseCanonicalSignedCatalogHeadTimelinessReceiptEnvelopeV1, + parseCanonicalUnsignedAuthorCatalogIssuerDelegationEnvelopeV1, + parseCanonicalUnsignedCatalogHeadTimelinessReceiptEnvelopeV1, + type AuthorCatalogIssuerDelegationV1, + type CatalogHeadTimelinessReceiptV1, +} from '../src/author-catalog-authority-objects.js'; +import type { + SignedControlEnvelopeV1, + UnsignedControlEnvelopeV1, +} from '../src/sync-control-object.js'; + +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const ISSUER = '0x5555555555555555555555555555555555555555'; +const CHECKPOINT = '0x7777777777777777777777777777777777777777'; +const SIGNATURE = `0x${'99'.repeat(65)}`; +const AUTHOR_AUTHORITY_EVIDENCE_DIGEST = `0x${'44'.repeat(32)}`; +const DELEGATION_DIGEST = '0x00884f491e9e1725cc41b7b74dfc00fca65f7fdd247d75345f99e55a8546541b'; +const DELEGATED_DELEGATION_DIGEST = '0x4a5a93a82cc7fa3aec78b082d3423d21c3e355ef9052ce41420d4c458014af85'; +const RECEIPT_DIGEST = '0x0ecaa8f7835d1006f7a1f16c0682e8172fa634da7ea7fe7521ef8dd8ddb7b8d8'; + +const DELEGATION = validatedDelegation({ + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/catalog-fixture', + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + previousDelegationDigest: null, + catalogIssuerKey: ISSUER, + authorAuthorityEvidenceDigest: null, + effectiveAt: '1700000000000', + expiresAt: '1700000120000', +}); + +const RECEIPT = validatedReceipt({ + networkId: DELEGATION.networkId, + contextGraphId: DELEGATION.contextGraphId, + governanceChainId: DELEGATION.governanceChainId, + governanceContractAddress: DELEGATION.governanceContractAddress, + ownershipTransitionDigest: DELEGATION.ownershipTransitionDigest, + subGraphName: DELEGATION.subGraphName, + checkpointAuthorityDelegationDigest: `0x${'77'.repeat(32)}`, + authorAddress: AUTHOR, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + catalogHeadDigest: `0x${'66'.repeat(32)}`, + observedAt: '1700000060000', +}); + +const DELEGATION_CANONICAL = '{"authorAddress":"0x3333333333333333333333333333333333333333","authorAuthorityEvidenceDigest":null,"catalogEra":"0","catalogIssuerKey":"0x5555555555555555555555555555555555555555","contextGraphId":"0x1111111111111111111111111111111111111111/catalog-fixture","effectiveAt":"1700000000000","expiresAt":"1700000120000","governanceChainId":"20430","governanceContractAddress":"0x2222222222222222222222222222222222222222","networkId":"otp:20430","ownershipTransitionDigest":null,"previousDelegationDigest":null,"subGraphName":null}'; +const RECEIPT_CANONICAL = '{"authorAddress":"0x3333333333333333333333333333333333333333","catalogHeadDigest":"0x6666666666666666666666666666666666666666666666666666666666666666","catalogIssuerDelegationDigest":"0x00884f491e9e1725cc41b7b74dfc00fca65f7fdd247d75345f99e55a8546541b","checkpointAuthorityDelegationDigest":"0x7777777777777777777777777777777777777777777777777777777777777777","contextGraphId":"0x1111111111111111111111111111111111111111/catalog-fixture","governanceChainId":"20430","governanceContractAddress":"0x2222222222222222222222222222222222222222","networkId":"otp:20430","observedAt":"1700000060000","ownershipTransitionDigest":null,"subGraphName":null}'; + +const DELEGATION_UNSIGNED = unsigned( + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + AUTHOR, + DELEGATION, +); +const DELEGATED_DELEGATION = validatedDelegation({ + ...DELEGATION, + authorAuthorityEvidenceDigest: AUTHOR_AUTHORITY_EVIDENCE_DIGEST, +}); +const DELEGATED_DELEGATION_UNSIGNED = unsigned( + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + ISSUER, + DELEGATED_DELEGATION, +); +const RECEIPT_UNSIGNED = unsigned( + CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1, + CHECKPOINT, + RECEIPT, +); +const DELEGATION_SIGNED = signed(DELEGATION_UNSIGNED, DELEGATION_DIGEST); +const DELEGATED_DELEGATION_SIGNED = signed( + DELEGATED_DELEGATION_UNSIGNED, + DELEGATED_DELEGATION_DIGEST, +); +const RECEIPT_SIGNED = signed(RECEIPT_UNSIGNED, RECEIPT_DIGEST); + +describe('AuthorCatalogIssuerDelegationV1 codec', () => { + it('pins canonical payload bytes, envelope digest, and signed round trips', () => { + expect(canonicalizeAuthorCatalogIssuerDelegationPayloadV1(DELEGATION)) + .toBe(DELEGATION_CANONICAL); + expect(new TextEncoder().encode(DELEGATION_CANONICAL)).toHaveLength(526); + expect(parseCanonicalAuthorCatalogIssuerDelegationPayloadV1(DELEGATION_CANONICAL)) + .toEqual(DELEGATION); + expect(computeAuthorCatalogIssuerDelegationObjectDigestV1(DELEGATION_UNSIGNED)) + .toBe(DELEGATION_DIGEST); + + const unsignedBytes = canonicalizeUnsignedAuthorCatalogIssuerDelegationEnvelopeBytesV1( + DELEGATION_UNSIGNED, + ); + expect(unsignedBytes).toHaveLength(725); + expect(parseCanonicalUnsignedAuthorCatalogIssuerDelegationEnvelopeV1(unsignedBytes)) + .toEqual(DELEGATION_UNSIGNED); + expect(parseCanonicalSignedAuthorCatalogIssuerDelegationEnvelopeV1( + canonicalizeSignedAuthorCatalogIssuerDelegationEnvelopeBytesV1(DELEGATION_SIGNED), + )).toEqual(DELEGATION_SIGNED); + expect(() => assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1(DELEGATION_UNSIGNED)) + .not.toThrow(); + expect(() => assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(DELEGATION_SIGNED)) + .not.toThrow(); + }); + + it('closes governance, era/predecessor, validity, and scalar branches', () => { + expect(() => assertAuthorCatalogIssuerDelegationV1({ + ...DELEGATION, + governanceContractAddress: null, + })).toThrow(/catalog-authority-governance/); + expect(() => assertAuthorCatalogIssuerDelegationV1({ + ...DELEGATION, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: `0x${'44'.repeat(32)}`, + })).toThrow(/catalog-authority-governance/); + expect(() => assertAuthorCatalogIssuerDelegationV1({ + ...DELEGATION, + catalogEra: '1', + })).toThrow(/catalog-authority-history/); + expect(() => assertAuthorCatalogIssuerDelegationV1({ + ...DELEGATION, + previousDelegationDigest: `0x${'11'.repeat(32)}`, + })).toThrow(/catalog-authority-history/); + expect(() => assertAuthorCatalogIssuerDelegationV1({ + ...DELEGATION, + expiresAt: DELEGATION.effectiveAt, + })).toThrow(/catalog-authority-time/); + expect(() => assertAuthorCatalogIssuerDelegationV1({ ...DELEGATION, catalogEra: 0 })) + .toThrow(/catalog-authority-scalar/); + expect(() => assertAuthorCatalogIssuerDelegationV1({ ...DELEGATION, extra: true })) + .toThrow(/catalog-authority-schema/); + }); + + it('enforces exact direct/delegated author pairing on the cloned envelope', () => { + // Direct-author positive: direct issuance carries no delegation evidence. + expect(() => assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1(DELEGATION_UNSIGNED)) + .not.toThrow(); + + // Delegated positive: the structural codec retains, but deliberately does not + // resolve, the exact evidence digest; the authority resolver verifies it later. + expect(() => assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1( + DELEGATED_DELEGATION_UNSIGNED, + )).not.toThrow(); + expect(() => assertSignedAuthorCatalogIssuerDelegationEnvelopeV1( + DELEGATED_DELEGATION_SIGNED, + )).not.toThrow(); + expect(computeAuthorCatalogIssuerDelegationObjectDigestV1(DELEGATED_DELEGATION_UNSIGNED)) + .toBe(DELEGATED_DELEGATION_DIGEST); + + // An arbitrary non-author issuer cannot masquerade as direct issuance. + expect(() => assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1(unsigned( + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + ISSUER, + DELEGATION, + ))).toThrow(/catalog-authority-authority/); + + // Conversely, the author cannot attach spurious delegated-authority evidence. + expect(() => assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1(unsigned( + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + AUTHOR, + DELEGATED_DELEGATION, + ))).toThrow(/catalog-authority-authority/); + }); + + it('accepts registered pre-transfer and unregistered scope but rejects replay between them', () => { + expect(() => assertAuthorCatalogIssuerDelegationV1(DELEGATION)).not.toThrow(); + expect(() => assertAuthorCatalogIssuerDelegationV1({ + ...DELEGATION, + governanceChainId: null, + governanceContractAddress: null, + })).not.toThrow(); + expect(() => assertAuthorCatalogIssuerDelegationV1({ + ...DELEGATION, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: `0x${'88'.repeat(32)}`, + })).toThrow(/catalog-authority-governance/); + }); + + it('rejects noncanonical wire, oversize input, accessors, and stateful values', () => { + expect(() => parseCanonicalAuthorCatalogIssuerDelegationPayloadV1( + DELEGATION_CANONICAL.replace('"catalogEra":"0"', '"catalogEra": "0"'), + )).toThrow(); + const oversized = `{"x":"${'a'.repeat(MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1)}"}`; + expect(() => parseCanonicalAuthorCatalogIssuerDelegationPayloadV1(oversized)) + .toThrow(/payload-too-large/); + + let getterCalls = 0; + const hostile = { ...DELEGATION } as Record; + Object.defineProperty(hostile, 'catalogIssuerKey', { + enumerable: true, + get() { + getterCalls += 1; + return ISSUER; + }, + }); + expect(() => assertAuthorCatalogIssuerDelegationV1(hostile)) + .toThrow(/catalog-authority-schema/); + expect(getterCalls).toBe(0); + }); + + it('rejects wrong object types and signed digest substitution', () => { + expect(() => assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1({ + ...DELEGATION_UNSIGNED, + objectType: CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1, + })).toThrow(/catalog-authority-type/); + expect(() => assertSignedAuthorCatalogIssuerDelegationEnvelopeV1({ + ...DELEGATION_SIGNED, + objectDigest: `0x${'00'.repeat(32)}`, + })).toThrow(/digest mismatch/i); + }); +}); + +describe('CatalogHeadTimelinessReceiptV1 codec', () => { + it('pins canonical payload bytes, envelope digest, and signed round trips', () => { + expect(canonicalizeCatalogHeadTimelinessReceiptPayloadV1(RECEIPT)) + .toBe(RECEIPT_CANONICAL); + expect(new TextEncoder().encode(RECEIPT_CANONICAL)).toHaveLength(644); + expect(parseCanonicalCatalogHeadTimelinessReceiptPayloadV1(RECEIPT_CANONICAL)) + .toEqual(RECEIPT); + expect(computeCatalogHeadTimelinessReceiptObjectDigestV1(RECEIPT_UNSIGNED)) + .toBe(RECEIPT_DIGEST); + + const unsignedBytes = canonicalizeUnsignedCatalogHeadTimelinessReceiptEnvelopeBytesV1( + RECEIPT_UNSIGNED, + ); + expect(unsignedBytes).toHaveLength(842); + expect(parseCanonicalUnsignedCatalogHeadTimelinessReceiptEnvelopeV1(unsignedBytes)) + .toEqual(RECEIPT_UNSIGNED); + expect(parseCanonicalSignedCatalogHeadTimelinessReceiptEnvelopeV1( + canonicalizeSignedCatalogHeadTimelinessReceiptEnvelopeBytesV1(RECEIPT_SIGNED), + )).toEqual(RECEIPT_SIGNED); + expect(() => assertUnsignedCatalogHeadTimelinessReceiptEnvelopeV1(RECEIPT_UNSIGNED)) + .not.toThrow(); + expect(() => assertSignedCatalogHeadTimelinessReceiptEnvelopeV1(RECEIPT_SIGNED)) + .not.toThrow(); + }); + + it('closes governance, field set, digest, address, and timestamp encodings', () => { + expect(() => assertCatalogHeadTimelinessReceiptV1({ + ...RECEIPT, + governanceChainId: null, + governanceContractAddress: null, + })).not.toThrow(); + expect(() => assertCatalogHeadTimelinessReceiptV1({ + ...RECEIPT, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: `0x${'88'.repeat(32)}`, + })).toThrow(/catalog-authority-governance/); + expect(() => assertCatalogHeadTimelinessReceiptV1({ ...RECEIPT, observedAt: 0 })) + .toThrow(/catalog-authority-scalar/); + expect(() => assertCatalogHeadTimelinessReceiptV1({ ...RECEIPT, authorAddress: AUTHOR.toUpperCase() })) + .toThrow(/catalog-authority-scalar/); + expect(() => assertCatalogHeadTimelinessReceiptV1({ ...RECEIPT, catalogHeadDigest: '0x00' })) + .toThrow(/catalog-authority-scalar/); + expect(() => assertCatalogHeadTimelinessReceiptV1({ ...RECEIPT, retryAfter: null })) + .toThrow(/catalog-authority-schema/); + }); + + it('rejects wrong object types and signed digest substitution', () => { + expect(() => assertUnsignedCatalogHeadTimelinessReceiptEnvelopeV1({ + ...RECEIPT_UNSIGNED, + objectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + })).toThrow(/catalog-authority-type/); + expect(() => assertSignedCatalogHeadTimelinessReceiptEnvelopeV1({ + ...RECEIPT_SIGNED, + objectDigest: `0x${'00'.repeat(32)}`, + })).toThrow(/digest mismatch/i); + }); +}); + +function validatedDelegation(value: unknown): AuthorCatalogIssuerDelegationV1 { + assertAuthorCatalogIssuerDelegationV1(value); + return value; +} + +function validatedReceipt(value: unknown): CatalogHeadTimelinessReceiptV1 { + assertCatalogHeadTimelinessReceiptV1(value); + return value; +} + +function unsigned( + objectType: string, + issuer: string, + payload: AuthorCatalogIssuerDelegationV1 | CatalogHeadTimelinessReceiptV1, +): UnsignedControlEnvelopeV1 { + return { + issuer, + objectType, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; +} + +function signed( + envelope: UnsignedControlEnvelopeV1, + objectDigest: string, +): SignedControlEnvelopeV1 { + return { ...envelope, objectDigest, signature: SIGNATURE } as SignedControlEnvelopeV1; +} From f42f3934bd5148f428083638786f061b4986e365 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:06:18 +0200 Subject: [PATCH 032/292] test(core): reject oversized chunk trees before allocation --- packages/core/src/ka-chunk-tree.ts | 25 ++++++++++++++++++------ packages/core/test/ka-chunk-tree.test.ts | 5 +++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/core/src/ka-chunk-tree.ts b/packages/core/src/ka-chunk-tree.ts index 00d9b12830..64f1891b64 100644 --- a/packages/core/src/ka-chunk-tree.ts +++ b/packages/core/src/ka-chunk-tree.ts @@ -39,6 +39,24 @@ export class KaChunkTreeV1Error extends Error { } } +/** + * Validate a complete transfer length before any transfer-sized buffer is + * allocated or hashed. Callers that learn the declared length from a + * descriptor can therefore reject an over-limit transfer up front. + */ +export function assertKaChunkTreeByteLengthV1(byteLength: bigint): void { + if ( + typeof byteLength !== 'bigint' + || byteLength < MIN_KA_TRANSFER_BYTES_V1 + || byteLength > MAX_KA_TRANSFER_BYTES_V1 + ) { + fail( + 'chunk-tree-byte-length', + `bundle length must be ${MIN_KA_TRANSFER_BYTES_V1}-${MAX_KA_TRANSFER_BYTES_V1} bytes`, + ); + } +} + /** * Compute the exact index- and length-bound RFC-64 leaf digest for one non-empty * transfer chunk. This is a dormant structural primitive; it makes no RDF or seal claim. @@ -84,12 +102,7 @@ function computeLeafDigestBytes(chunkIndex: bigint, chunkBytes: Uint8Array): Uin export function computeKaChunkTreeRootV1(bundleBytes: Uint8Array): Digest32V1 { assertOwnedFixedUint8Array(bundleBytes, 'bundleBytes'); const byteLength = BigInt(bundleBytes.byteLength); - if (byteLength < MIN_KA_TRANSFER_BYTES_V1 || byteLength > MAX_KA_TRANSFER_BYTES_V1) { - fail( - 'chunk-tree-byte-length', - `bundle length must be ${MIN_KA_TRANSFER_BYTES_V1}-${MAX_KA_TRANSFER_BYTES_V1} bytes`, - ); - } + assertKaChunkTreeByteLengthV1(byteLength); const chunkCount = ((byteLength - 1n) / KA_TRANSFER_CHUNK_SIZE_BYTES_V1) + 1n; if (chunkCount < 1n || chunkCount > MAX_KA_TRANSFER_CHUNKS_V1) { diff --git a/packages/core/test/ka-chunk-tree.test.ts b/packages/core/test/ka-chunk-tree.test.ts index 7f9a6947ec..2096722fe6 100644 --- a/packages/core/test/ka-chunk-tree.test.ts +++ b/packages/core/test/ka-chunk-tree.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + assertKaChunkTreeByteLengthV1, computeEmptyKaChunkTreeRootV1, computeKaChunkLeafDigestV1, computeKaChunkTreeRootV1, @@ -62,6 +63,10 @@ describe('RFC-64 dormant KA chunk tree', () => { }); it('rejects transfer lengths outside 16..1 GiB and empty leaves', () => { + expectFailureCode( + () => assertKaChunkTreeByteLengthV1(1_073_741_825n), + 'chunk-tree-byte-length', + ); expectFailureCode( () => computeKaChunkTreeRootV1(new Uint8Array(15)), 'chunk-tree-byte-length', From 804b6daac995336a330640ec727ebef219634a6a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:16:16 +0200 Subject: [PATCH 033/292] fix(core): derive administrative role validation from registry --- packages/core/src/administrative-authority-objects.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/administrative-authority-objects.ts b/packages/core/src/administrative-authority-objects.ts index da78b04b23..3eb1db78fc 100644 --- a/packages/core/src/administrative-authority-objects.ts +++ b/packages/core/src/administrative-authority-objects.ts @@ -1087,10 +1087,9 @@ function assertDenseArray( } function isAdministrativeRole(value: string): value is CgAdministrativeDelegationRoleV1 { - return value === 'checkpoint-delegation' - || value === 'policy' - || value === 'retention' - || value === 'roster'; + return CG_ADMINISTRATIVE_DELEGATION_ROLES_V1.includes( + value as CgAdministrativeDelegationRoleV1, + ); } function u64(value: unknown, label: string): bigint { From 5404fb2d417d018b072819066cbeff3108861a73 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:16:22 +0200 Subject: [PATCH 034/292] test(core): complete catalog authority boundary coverage --- .../src/author-catalog-authority-objects.ts | 13 ++- .../author-catalog-authority-objects.test.ts | 84 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/core/src/author-catalog-authority-objects.ts b/packages/core/src/author-catalog-authority-objects.ts index eb859b07b3..7d48f17aa6 100644 --- a/packages/core/src/author-catalog-authority-objects.ts +++ b/packages/core/src/author-catalog-authority-objects.ts @@ -42,6 +42,7 @@ export const AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1 = export const CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1 = 'CatalogHeadTimelinessReceiptV1' as const; export const MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1 = 16 * 1024; +export const MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_DEPTH_V1 = 1; const UTF8 = new TextEncoder(); @@ -160,7 +161,10 @@ export function parseCanonicalAuthorCatalogIssuerDelegationPayloadV1( options.maxBytes ?? MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, ), - maxDepth: Math.min(options.maxDepth ?? 1, 1), + maxDepth: Math.min( + options.maxDepth ?? MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_DEPTH_V1, + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_DEPTH_V1, + ), })).snapshot; } @@ -175,7 +179,10 @@ export function parseCanonicalCatalogHeadTimelinessReceiptPayloadV1( options.maxBytes ?? MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, ), - maxDepth: Math.min(options.maxDepth ?? 1, 1), + maxDepth: Math.min( + options.maxDepth ?? MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_DEPTH_V1, + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_DEPTH_V1, + ), })).snapshot; } @@ -487,7 +494,7 @@ function canonicalizeBounded( try { return canonicalizeJson(value as unknown as CanonicalJsonValue, { maxBytes: MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, - maxDepth: 1, + maxDepth: MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_DEPTH_V1, }); } catch (cause) { fail('catalog-authority-payload-too-large', `${label} exceeds its canonical cap`, cause); diff --git a/packages/core/test/author-catalog-authority-objects.test.ts b/packages/core/test/author-catalog-authority-objects.test.ts index 01cd4c41d9..c98578035f 100644 --- a/packages/core/test/author-catalog-authority-objects.test.ts +++ b/packages/core/test/author-catalog-authority-objects.test.ts @@ -4,6 +4,7 @@ import { AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, CATALOG_HEAD_TIMELINESS_RECEIPT_OBJECT_TYPE_V1, MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_DEPTH_V1, assertAuthorCatalogIssuerDelegationV1, assertCatalogHeadTimelinessReceiptV1, assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, @@ -31,6 +32,7 @@ import type { SignedControlEnvelopeV1, UnsignedControlEnvelopeV1, } from '../src/sync-control-object.js'; +import { computeControlObjectDigestHex } from '../src/sync-control-object.js'; const AUTHOR = '0x3333333333333333333333333333333333333333'; const ISSUER = '0x5555555555555555555555555555555555555555'; @@ -177,6 +179,16 @@ describe('AuthorCatalogIssuerDelegationV1 codec', () => { DELEGATION, ))).toThrow(/catalog-authority-authority/); + const redigestedWrongIssuer = unsigned( + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + ISSUER, + DELEGATION, + ); + expect(() => assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(signed( + redigestedWrongIssuer, + computeControlObjectDigestHex(redigestedWrongIssuer), + ))).toThrow(/catalog-authority-authority/); + // Conversely, the author cannot attach spurious delegated-authority evidence. expect(() => assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1(unsigned( AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, @@ -222,6 +234,41 @@ describe('AuthorCatalogIssuerDelegationV1 codec', () => { expect(getterCalls).toBe(0); }); + it('freezes the depth/byte caps and accepts structurally legal zero digests', () => { + expect(MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_DEPTH_V1).toBe(1); + expect(() => assertAuthorCatalogIssuerDelegationV1({ + ...DELEGATION, + ownershipTransitionDigest: `0x${'00'.repeat(32)}`, + authorAuthorityEvidenceDigest: `0x${'00'.repeat(32)}`, + })).not.toThrow(); + + const exactCapUnknownPayload = `{"x":"${'a'.repeat( + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1 - 8, + )}"}`; + expect(new TextEncoder().encode(exactCapUnknownPayload)).toHaveLength( + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1, + ); + expect(() => parseCanonicalAuthorCatalogIssuerDelegationPayloadV1(exactCapUnknownPayload)) + .toThrow(/catalog-authority-schema/); + + const capPlusOne = `{"x":"${'a'.repeat( + MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1 - 7, + )}"}`; + expect(() => parseCanonicalAuthorCatalogIssuerDelegationPayloadV1(capPlusOne)) + .toThrow(/payload-too-large/); + const multibyteOverCap = `{"x":"${'é'.repeat( + Math.ceil(MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1 / 2), + )}"}`; + expect(multibyteOverCap.length).toBeLessThan(MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1); + expect(() => parseCanonicalAuthorCatalogIssuerDelegationPayloadV1(multibyteOverCap)) + .toThrow(/payload-too-large/); + expect(() => parseCanonicalAuthorCatalogIssuerDelegationPayloadV1( + new Uint8Array(MAX_AUTHOR_CATALOG_AUTHORITY_PAYLOAD_BYTES_V1 + 1), + )).toThrow(/payload-too-large/); + expect(() => parseCanonicalAuthorCatalogIssuerDelegationPayloadV1('{"x":{"y":{}}}')) + .toThrow(/nesting/i); + }); + it('rejects wrong object types and signed digest substitution', () => { expect(() => assertUnsignedAuthorCatalogIssuerDelegationEnvelopeV1({ ...DELEGATION_UNSIGNED, @@ -279,6 +326,43 @@ describe('CatalogHeadTimelinessReceiptV1 codec', () => { .toThrow(/catalog-authority-scalar/); expect(() => assertCatalogHeadTimelinessReceiptV1({ ...RECEIPT, retryAfter: null })) .toThrow(/catalog-authority-schema/); + expect(() => assertCatalogHeadTimelinessReceiptV1({ + ...RECEIPT, + ownershipTransitionDigest: `0x${'00'.repeat(32)}`, + checkpointAuthorityDelegationDigest: `0x${'00'.repeat(32)}`, + catalogIssuerDelegationDigest: `0x${'00'.repeat(32)}`, + catalogHeadDigest: `0x${'00'.repeat(32)}`, + })).not.toThrow(); + }); + + it('rejects stateful receipt payloads and whole envelopes before reading accessors', () => { + let payloadGetterCalls = 0; + const hostileReceipt = { ...RECEIPT } as Record; + Object.defineProperty(hostileReceipt, 'observedAt', { + enumerable: true, + get() { + payloadGetterCalls += 1; + return RECEIPT.observedAt; + }, + }); + expect(() => assertCatalogHeadTimelinessReceiptV1(hostileReceipt)) + .toThrow(/catalog-authority-schema/); + expect(payloadGetterCalls).toBe(0); + + let envelopeGetterCalls = 0; + const hostileEnvelope = { ...RECEIPT_UNSIGNED } as Record; + Object.defineProperty(hostileEnvelope, 'issuer', { + enumerable: true, + get() { + envelopeGetterCalls += 1; + return CHECKPOINT; + }, + }); + expect(() => assertUnsignedCatalogHeadTimelinessReceiptEnvelopeV1(hostileEnvelope)) + .toThrow(); + expect(envelopeGetterCalls).toBe(0); + expect(() => assertCatalogHeadTimelinessReceiptV1(new Proxy({ ...RECEIPT }, {}))) + .toThrow(/catalog-authority-schema/); }); it('rejects wrong object types and signed digest substitution', () => { From 323946427909756f4a8d9efd3d1036a2b4288646 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:17:39 +0200 Subject: [PATCH 035/292] fix(core): make directory proof results unforgeable --- packages/core/src/author-catalog-directory.ts | 119 ++++++++++- .../test/author-catalog-directory.test.ts | 202 ++++++++++++++++-- 2 files changed, 297 insertions(+), 24 deletions(-) diff --git a/packages/core/src/author-catalog-directory.ts b/packages/core/src/author-catalog-directory.ts index 3e0eb132e4..6ce745fac9 100644 --- a/packages/core/src/author-catalog-directory.ts +++ b/packages/core/src/author-catalog-directory.ts @@ -16,10 +16,10 @@ import { MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, MAX_AUTHOR_CATALOG_DIRECTORY_HEIGHT_V1, ZERO_DIGEST32_V1, - assertAuthorCatalogHeadV1, + assertSignedAuthorCatalogHeadEnvelopeV1, computeAuthorCatalogDirectoryHeightV1, deriveAuthorCatalogScopeFromHeadV1, - type AuthorCatalogHeadV1, + type SignedAuthorCatalogHeadEnvelopeV1, } from './author-catalog-objects.js'; import { assertSignedControlEnvelope, @@ -51,7 +51,11 @@ export const MAX_AUTHOR_CATALOG_DIRECTORY_PATH_NODES_V1 = 8; const UTF8 = new TextEncoder(); -/** Canonical level-zero descriptor. Zero values denote the absence of a bucket object. */ +/** + * Canonical level-zero descriptor. Zero values denote the absence of a bucket + * object. This wire shape is not itself proof of directory membership; consumers + * that require membership trust must accept VerifiedAuthorCatalogDirectoryPathV1. + */ export interface AuthorCatalogBucketDescriptorV1 { readonly bucketId: DecimalU64V1; readonly rowCount: CountV1; @@ -81,6 +85,21 @@ export interface AuthorCatalogDirectoryNodeV1 { readonly entries: readonly AuthorCatalogDirectoryEntryV1[]; } +declare const VERIFIED_AUTHOR_CATALOG_DIRECTORY_PATH_BRAND_V1: unique symbol; + +/** + * Process-local proof that one exact bucket descriptor was selected by a + * canonical root-to-leaf path under one exact signed author-catalog head. + * + * This capability deliberately exposes no fields and has no wire encoding. + * Only {@link verifyAuthorCatalogDirectoryPathV1} can mint a value accepted by + * the module-private runtime registry; structural casts and serialized clones + * are rejected by the consumer assertion. + */ +export interface VerifiedAuthorCatalogDirectoryPathV1 { + readonly [VERIFIED_AUTHOR_CATALOG_DIRECTORY_PATH_BRAND_V1]: true; +} + export type UnsignedAuthorCatalogDirectoryNodeEnvelopeV1 = UnsignedControlEnvelopeV1 & { readonly objectType: typeof AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1; readonly payload: AuthorCatalogDirectoryNodeV1; @@ -113,6 +132,20 @@ export class AuthorCatalogDirectoryError extends Error { } } +interface VerifiedAuthorCatalogDirectoryPathStateV1 { + readonly headObjectDigest: Digest32V1; + readonly catalogScopeDigest: Digest32V1; + readonly era: DecimalU64V1; + readonly bucketCount: CountV1; + readonly directoryRootDigest: Digest32V1; + readonly descriptor: Readonly; +} + +const VERIFIED_AUTHOR_CATALOG_DIRECTORY_PATHS_V1 = new WeakMap< + object, + VerifiedAuthorCatalogDirectoryPathStateV1 +>(); + /** Validate one canonical directory-node layout in the enclosing catalog's bucket domain. */ export function assertAuthorCatalogDirectoryNodeV1( node: unknown, @@ -247,19 +280,23 @@ export function parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1( } /** - * Verify one selected root-to-leaf path under a structurally valid catalog head. + * Verify one selected root-to-leaf path under an exact signed catalog head. * * This checks canonical object digests, topology, selected child indexes, immediate * child byte lengths, and selected-subtree row counts. It deliberately performs no * signature authority, history, full-directory closure, persistence, or RDF work. + * The returned process-local capability must be consumed together with the same + * exact signed head; it is not a serializable bucket descriptor. */ export function verifyAuthorCatalogDirectoryPathV1( - head: AuthorCatalogHeadV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, path: unknown, selectedBucketId: DecimalU64V1, -): AuthorCatalogBucketDescriptorV1 { - assertAuthorCatalogHeadV1(head); +): VerifiedAuthorCatalogDirectoryPathV1 { + assertSignedAuthorCatalogHeadEnvelopeV1(signedHead); + const head = signedHead.payload; const scope = deriveAuthorCatalogScopeFromHeadV1(head); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); const bucketCount = BigInt(scope.bucketCount); const bucketId = assertDirectoryU64(selectedBucketId, 'selectedBucketId'); if (bucketId >= bucketCount) { @@ -361,7 +398,73 @@ export function verifyAuthorCatalogDirectoryPathV1( if (descriptor.bucketId !== selectedBucketId) { fail('catalog-directory-path', 'selected leaf descriptor does not match selectedBucketId'); } - return descriptor; + + const descriptorSnapshot = Object.freeze({ + bucketDigest: descriptor.bucketDigest, + bucketId: descriptor.bucketId, + byteLength: descriptor.byteLength, + rowCount: descriptor.rowCount, + }); + const state = Object.freeze({ + headObjectDigest: signedHead.objectDigest as Digest32V1, + catalogScopeDigest, + era: scope.era, + bucketCount: scope.bucketCount, + directoryRootDigest: head.directoryRootDigest, + descriptor: descriptorSnapshot, + }); + const capability = Object.freeze(Object.create(null)) as + VerifiedAuthorCatalogDirectoryPathV1; + VERIFIED_AUTHOR_CATALOG_DIRECTORY_PATHS_V1.set(capability as object, state); + return capability; +} + +/** + * Require a verifier-minted path capability for this exact signed head. + * + * Signature authority remains a separate admission step. This assertion binds + * only canonical envelope bytes, the head-derived catalog scope/era, directory + * root, bucket domain, and the selected path proof. + */ +export function assertVerifiedAuthorCatalogDirectoryPathForHeadV1( + value: unknown, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, +): asserts value is VerifiedAuthorCatalogDirectoryPathV1 { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + fail('catalog-directory-path', 'directory path capability was not minted by this verifier'); + } + const state = VERIFIED_AUTHOR_CATALOG_DIRECTORY_PATHS_V1.get(value as object); + if (state === undefined) { + fail('catalog-directory-path', 'directory path capability was not minted by this verifier'); + } + + assertSignedAuthorCatalogHeadEnvelopeV1(signedHead); + const scope = deriveAuthorCatalogScopeFromHeadV1(signedHead.payload); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); + if ( + state.headObjectDigest !== signedHead.objectDigest + || state.catalogScopeDigest !== catalogScopeDigest + || state.era !== scope.era + || state.bucketCount !== scope.bucketCount + || state.directoryRootDigest !== signedHead.payload.directoryRootDigest + ) { + fail( + 'catalog-directory-path', + 'directory path capability is not bound to the supplied signed catalog head', + ); + } +} + +/** + * Read the immutable selected descriptor after proving that the opaque path + * capability belongs to the supplied exact signed head. + */ +export function readVerifiedAuthorCatalogBucketDescriptorV1( + value: unknown, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, +): Readonly { + assertVerifiedAuthorCatalogDirectoryPathForHeadV1(value, signedHead); + return VERIFIED_AUTHOR_CATALOG_DIRECTORY_PATHS_V1.get(value as object)!.descriptor; } function assertAuthorCatalogDirectoryNodeStructureV1( diff --git a/packages/core/test/author-catalog-directory.test.ts b/packages/core/test/author-catalog-directory.test.ts index ce10b5aa9e..f120e0416c 100644 --- a/packages/core/test/author-catalog-directory.test.ts +++ b/packages/core/test/author-catalog-directory.test.ts @@ -7,6 +7,7 @@ import { assertAuthorCatalogDirectoryNodeScopeBindingV1, assertAuthorCatalogDirectoryNodeV1, assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + assertVerifiedAuthorCatalogDirectoryPathForHeadV1, assertUnsignedAuthorCatalogDirectoryNodeEnvelopeV1, canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1, canonicalizeAuthorCatalogDirectoryNodePayloadV1, @@ -16,6 +17,7 @@ import { parseCanonicalAuthorCatalogDirectoryNodePayloadV1, parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1, parseCanonicalUnsignedAuthorCatalogDirectoryNodeEnvelopeV1, + readVerifiedAuthorCatalogBucketDescriptorV1, verifyAuthorCatalogDirectoryPathV1, type AuthorCatalogBucketDescriptorV1, type AuthorCatalogChildDescriptorV1, @@ -28,9 +30,13 @@ import { type AuthorCatalogScopeV1, } from '../src/author-catalog-codec.js'; import { + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, ZERO_DIGEST32_V1, + assertSignedAuthorCatalogHeadEnvelopeV1, assertAuthorCatalogHeadV1, + computeAuthorCatalogHeadObjectDigestV1, type AuthorCatalogHeadV1, + type SignedAuthorCatalogHeadEnvelopeV1, } from '../src/author-catalog-objects.js'; import type { SignedControlEnvelopeV1, @@ -75,7 +81,7 @@ const EMPTY_SIGNED = { objectDigest: EMPTY_ROOT_DIGEST, signature: EIP191_SIGNATURE, } as SignedControlEnvelopeV1; -const EMPTY_HEAD = validatedHead({ +const EMPTY_HEAD_PAYLOAD = validatedHead({ networkId: EMPTY_SCOPE.networkId, contextGraphId: EMPTY_SCOPE.contextGraphId, governanceChainId: EMPTY_SCOPE.governanceChainId, @@ -93,6 +99,7 @@ const EMPTY_HEAD = validatedHead({ directoryRootDigest: EMPTY_ROOT_DIGEST, issuedAt: '1700000000123', }); +const EMPTY_HEAD = signedHeadEnvelope(EMPTY_HEAD_PAYLOAD); describe('AuthorCatalogDirectoryNodeV1 structural codec', () => { it('pins the normative empty-directory payload, envelope, and root digest', () => { @@ -126,7 +133,12 @@ describe('AuthorCatalogDirectoryNodeV1 structural codec', () => { ); expect(parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1(signedBytes, '1')) .toEqual(EMPTY_SIGNED); - expect(verifyAuthorCatalogDirectoryPathV1(EMPTY_HEAD, [EMPTY_SIGNED], '0')).toEqual({ + const verifiedPath = verifyAuthorCatalogDirectoryPathV1(EMPTY_HEAD, [EMPTY_SIGNED], '0'); + expect(() => assertVerifiedAuthorCatalogDirectoryPathForHeadV1( + verifiedPath, + EMPTY_HEAD, + )).not.toThrow(); + expect(readVerifiedAuthorCatalogBucketDescriptorV1(verifiedPath, EMPTY_HEAD)).toEqual({ bucketDigest: ZERO_DIGEST32_V1, bucketId: '0', byteLength: '0', @@ -316,10 +328,14 @@ describe('AuthorCatalogDirectoryNodeV1 structural codec', () => { describe('author catalog selected directory paths', () => { it('derives both branch and leaf indexes and verifies child bytes/counts/digests', () => { const fixture = twoLevelFixture(300n); - expect(verifyAuthorCatalogDirectoryPathV1( + const verifiedPath = verifyAuthorCatalogDirectoryPathV1( fixture.head, fixture.path, '300', + ); + expect(readVerifiedAuthorCatalogBucketDescriptorV1( + verifiedPath, + fixture.head, )).toEqual(fixture.selectedDescriptor); expect(() => verifyAuthorCatalogDirectoryPathV1(fixture.head, fixture.path, '10')) .toThrow(/selected childDigest/); @@ -331,7 +347,10 @@ describe('author catalog selected directory paths', () => { : entry), }, '512'); expect(() => verifyAuthorCatalogDirectoryPathV1( - { ...fixture.head, directoryRootDigest: wrongDigestRoot.objectDigest }, + signedHeadEnvelope({ + ...fixture.head.payload, + directoryRootDigest: wrongDigestRoot.objectDigest, + }), [wrongDigestRoot, fixture.leaf], '300', )).toThrow(/selected childDigest/); @@ -343,7 +362,10 @@ describe('author catalog selected directory paths', () => { : entry), }, '512'); expect(() => verifyAuthorCatalogDirectoryPathV1( - { ...fixture.head, directoryRootDigest: wrongLengthRoot.objectDigest }, + signedHeadEnvelope({ + ...fixture.head.payload, + directoryRootDigest: wrongLengthRoot.objectDigest, + }), [wrongLengthRoot, fixture.leaf], '300', )).toThrow(/child byteLength/); @@ -355,18 +377,143 @@ describe('author catalog selected directory paths', () => { : entry), }, '512'); expect(() => verifyAuthorCatalogDirectoryPathV1( - { ...fixture.head, totalRows: '2', directoryRootDigest: wrongCountRoot.objectDigest }, + signedHeadEnvelope({ + ...fixture.head.payload, + totalRows: '2', + directoryRootDigest: wrongCountRoot.objectDigest, + }), [wrongCountRoot, fixture.leaf], '300', )).toThrow(/child rowCount/); }); + it('mints an opaque exact-head capability and rejects every structural forgery', () => { + const fixture = twoLevelFixture(300n); + const verifiedPath = verifyAuthorCatalogDirectoryPathV1( + fixture.head, + fixture.path, + '300', + ); + const descriptor = readVerifiedAuthorCatalogBucketDescriptorV1( + verifiedPath, + fixture.head, + ); + + expect(Object.getPrototypeOf(verifiedPath)).toBeNull(); + expect(Object.isFrozen(verifiedPath)).toBe(true); + expect(Reflect.ownKeys(verifiedPath as object)).toEqual([]); + expect(Object.isFrozen(descriptor)).toBe(true); + expect(descriptor).toEqual(fixture.selectedDescriptor); + + const structuralDescriptor = Object.freeze({ ...descriptor }); + expect(() => assertVerifiedAuthorCatalogDirectoryPathForHeadV1( + structuralDescriptor, + fixture.head, + )).toThrow(/not minted/); + expect(() => readVerifiedAuthorCatalogBucketDescriptorV1( + structuralDescriptor, + fixture.head, + )).toThrow(/not minted/); + + const frozenClone = Object.freeze({ ...verifiedPath as object }); + expect(() => readVerifiedAuthorCatalogBucketDescriptorV1( + frozenClone, + fixture.head, + )).toThrow(/not minted/); + + expect(JSON.stringify(verifiedPath)).toBe('{}'); + expect(() => readVerifiedAuthorCatalogBucketDescriptorV1( + JSON.parse(JSON.stringify(verifiedPath)), + fixture.head, + )).toThrow(/not minted/); + + const otherHead = signedHeadEnvelope({ + ...fixture.head.payload, + issuedAt: '1700000000124', + }); + const otherHeadToken = verifyAuthorCatalogDirectoryPathV1( + otherHead, + fixture.path, + '300', + ); + expect(() => readVerifiedAuthorCatalogBucketDescriptorV1( + otherHeadToken, + fixture.head, + )).toThrow(/not bound to the supplied signed catalog head/); + expect(() => readVerifiedAuthorCatalogBucketDescriptorV1( + verifiedPath, + otherHead, + )).toThrow(/not bound to the supplied signed catalog head/); + }); + + it('rejects mutated roots, paths, and leaf descriptors before minting trust', () => { + const fixture = twoLevelFixture(300n); + const wrongRootHead = signedHeadEnvelope({ + ...fixture.head.payload, + directoryRootDigest: digest(900_001), + }); + expect(() => verifyAuthorCatalogDirectoryPathV1( + wrongRootHead, + fixture.path, + '300', + )).toThrow(/root objectDigest/); + + const selectedIndex = 300 - 256; + const mutatedLeaf = { + ...fixture.leaf, + payload: { + ...fixture.leaf.payload, + entries: fixture.leaf.payload.entries.map((entry, index) => index === selectedIndex + ? { ...(entry as AuthorCatalogBucketDescriptorV1), rowCount: '2' } + : entry), + }, + } as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + expect(() => verifyAuthorCatalogDirectoryPathV1( + fixture.head, + [fixture.root, mutatedLeaf], + '300', + )).toThrow(/digest mismatch/); + + expect(() => verifyAuthorCatalogDirectoryPathV1( + fixture.head, + [fixture.leaf, fixture.root], + '300', + )).toThrow(/level 1/); + }); + + it('keeps the verified descriptor stable after untrusted proof input is mutated', () => { + const fixture = twoLevelFixture(300n); + const verifiedPath = verifyAuthorCatalogDirectoryPathV1( + fixture.head, + fixture.path, + '300', + ); + const expected = { ...fixture.selectedDescriptor }; + const selectedIndex = 300 - 256; + (fixture.leaf.payload.entries as AuthorCatalogBucketDescriptorV1[])[selectedIndex] = { + ...fixture.selectedDescriptor, + bucketDigest: digest(900_002), + rowCount: '2', + }; + + const firstRead = readVerifiedAuthorCatalogBucketDescriptorV1( + verifiedPath, + fixture.head, + ); + const secondRead = readVerifiedAuthorCatalogBucketDescriptorV1( + verifiedPath, + fixture.head, + ); + expect(firstRead).toBe(secondRead); + expect(firstRead).toEqual(expected); + }); + it('accepts the full eight-node path at the maximum v1 bucket count', () => { const fixture = maximumHeightFixture(); expect(fixture.path).toHaveLength(MAX_AUTHOR_CATALOG_DIRECTORY_PATH_NODES_V1); - expect(verifyAuthorCatalogDirectoryPathV1(fixture.head, fixture.path, '0')).toEqual( - fixture.selectedDescriptor, - ); + const verifiedPath = verifyAuthorCatalogDirectoryPathV1(fixture.head, fixture.path, '0'); + expect(readVerifiedAuthorCatalogBucketDescriptorV1(verifiedPath, fixture.head)) + .toEqual(fixture.selectedDescriptor); }); it('rejects omitted, reversed, repeated, sparse, subclassed, accessor, symbol, and custom paths', () => { @@ -420,7 +567,10 @@ describe('author catalog selected directory paths', () => { const node = { ...EMPTY_NODE, entries }; const signed = signedEnvelope(node, '1'); const path = [signed]; - const head = { ...EMPTY_HEAD, directoryRootDigest: signed.objectDigest }; + const head = signedHeadEnvelope({ + ...EMPTY_HEAD.payload, + directoryRootDigest: signed.objectDigest, + }); const originalIterator = Array.prototype[Symbol.iterator]; Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, @@ -446,7 +596,7 @@ describe('author catalog selected directory paths', () => { }); function twoLevelFixture(selectedBucketId: bigint): { - readonly head: AuthorCatalogHeadV1; + readonly head: SignedAuthorCatalogHeadEnvelopeV1; readonly root: SignedAuthorCatalogDirectoryNodeEnvelopeV1; readonly leaf: SignedAuthorCatalogDirectoryNodeEnvelopeV1; readonly path: SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; @@ -491,8 +641,8 @@ function twoLevelFixture(selectedBucketId: bigint): { firstBucketId: '0', level: '1', }, scope.bucketCount); - const head = validatedHead({ - ...EMPTY_HEAD, + const head = signedHeadEnvelope({ + ...EMPTY_HEAD.payload, bucketCount: '512', totalRows: '1', directoryHeight: '1', @@ -502,7 +652,7 @@ function twoLevelFixture(selectedBucketId: bigint): { } function maximumHeightFixture(): { - readonly head: AuthorCatalogHeadV1; + readonly head: SignedAuthorCatalogHeadEnvelopeV1; readonly path: SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; readonly selectedDescriptor: AuthorCatalogBucketDescriptorV1; } { @@ -550,8 +700,8 @@ function maximumHeightFixture(): { leafToRoot.push(child); } const path = leafToRoot.reverse(); - const head = validatedHead({ - ...EMPTY_HEAD, + const head = signedHeadEnvelope({ + ...EMPTY_HEAD.payload, bucketCount: bucketCountWire, totalRows: '1', directoryHeight: '7', @@ -631,6 +781,26 @@ function signedEnvelope( return signed; } +function signedHeadEnvelope( + head: AuthorCatalogHeadV1, +): SignedAuthorCatalogHeadEnvelopeV1 { + const payload = validatedHead(head); + const unsigned = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; + const signed = { + ...unsigned, + objectDigest: computeAuthorCatalogHeadObjectDigestV1(unsigned), + signature: EIP191_SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogHeadEnvelopeV1(signed); + return signed; +} + function validatedScope(value: unknown): AuthorCatalogScopeV1 { assertAuthorCatalogScopeV1(value); return value; From f8dc06f16439944af6510c0e3e0be0226d6a907e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:31:56 +0200 Subject: [PATCH 036/292] fix(agent): harden RFC-64 inventory lifecycle boundaries --- .github/workflows/rfc64-inventory-windows.yml | 61 + .../agent/src/rfc64/inventory-v1/index.ts | 12 +- .../rfc64/inventory-v1/lifecycle-adapter.ts | 61 + packages/agent/src/rfc64/inventory-v1/open.ts | 1313 ++++++++++++-- .../test/fixtures/rfc64-inventory-v1-child.ts | 168 ++ .../test/rfc64-inventory-v1-lifecycle.test.ts | 1563 ++++++++++++++++- 6 files changed, 2989 insertions(+), 189 deletions(-) create mode 100644 .github/workflows/rfc64-inventory-windows.yml create mode 100644 packages/agent/src/rfc64/inventory-v1/lifecycle-adapter.ts create mode 100644 packages/agent/test/fixtures/rfc64-inventory-v1-child.ts diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml new file mode 100644 index 0000000000..cda662bfec --- /dev/null +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -0,0 +1,61 @@ +name: RFC-64 inventory Windows gate + +on: + pull_request: + paths: + - '.github/workflows/rfc64-inventory-windows.yml' + - 'packages/agent/src/rfc64/inventory-v1/**' + - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' + - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' + - 'packages/agent/vitest.unit.config.ts' + - 'packages/agent/package.json' + - 'packages/core/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + workflow_dispatch: + +concurrency: + group: rfc64-inventory-windows-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + inventory-lifecycle: + name: SQLite lifecycle (Windows) + runs-on: windows-latest + timeout-minutes: 25 + steps: + - name: Checkout candidate + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build agent dependency closure + run: pnpm --filter @origintrail-official/dkg-agent... run build + + - name: Typecheck the lifecycle crash harness + run: >- + pnpm exec tsc --noEmit --target ES2022 --module NodeNext + --moduleResolution NodeNext --types node,vitest/globals --skipLibCheck + packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts + packages/agent/test/fixtures/rfc64-inventory-v1-child.ts + + - name: Run RFC-64 inventory tests + run: >- + pnpm --filter @origintrail-official/dkg-agent exec vitest run + --config vitest.unit.config.ts + test/rfc64-inventory-v1 diff --git a/packages/agent/src/rfc64/inventory-v1/index.ts b/packages/agent/src/rfc64/inventory-v1/index.ts index 0ccfe1677c..05d6d34012 100644 --- a/packages/agent/src/rfc64/inventory-v1/index.ts +++ b/packages/agent/src/rfc64/inventory-v1/index.ts @@ -1,3 +1,13 @@ -export * from './open.js'; +export { + INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + InventoryV1OpenError, + openInventoryV1, +} from './open.js'; +export type { + InventoryV1OpenErrorCode, + InventoryV1OpenOptions, + InventoryV1QuarantineCapability, + Rfc64InventoryV1Foundation, +} from './open.js'; export * from './scalars.js'; export * from './sql.js'; diff --git a/packages/agent/src/rfc64/inventory-v1/lifecycle-adapter.ts b/packages/agent/src/rfc64/inventory-v1/lifecycle-adapter.ts new file mode 100644 index 0000000000..1df2f3abee --- /dev/null +++ b/packages/agent/src/rfc64/inventory-v1/lifecycle-adapter.ts @@ -0,0 +1,61 @@ +/** + * Internal lifecycle adapter for the RFC-64 SQL-A namespace protocol. + * + * Production callers receive a hook-free frozen adapter. Tests may construct + * a distinct frozen adapter per opener; there is deliberately no mutable + * process-global registry that shipped code could activate at runtime. + */ + +export const INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY = + 'posix-atomic-rename-directory-fsync-v1' as const; + +export type InventoryV1QuarantineCapability = + typeof INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY; + +export type InventoryV1QuarantineBoundary = + | 'target-exclusivity-proven' + | `begin.source.${'wal' | 'shm' | 'main'}.file-fsync` + | 'begin.inventory-directory.fsync-after-quarantine-root' + | 'begin.quarantine-root.fsync-after-generation' + | 'begin.marker.write' + | 'begin.marker.file-fsync' + | 'begin.inventory-directory.fsync-after-marker' + | `resume.prefix.${'wal' | 'shm' | 'main'}.file-fsync` + | `resume.prefix.${'wal' | 'shm' | 'main'}.generation-directory-fsync` + | `resume.prefix.${'wal' | 'shm' | 'main'}.inventory-directory-fsync` + | `resume.source.${'wal' | 'shm' | 'main'}.file-fsync-after-quiescence` + | `resume.member.${'wal' | 'shm' | 'main'}.rename` + | `resume.member.${'wal' | 'shm' | 'main'}.file-fsync` + | `resume.member.${'wal' | 'shm' | 'main'}.generation-directory-fsync` + | `resume.member.${'wal' | 'shm' | 'main'}.inventory-directory-fsync` + | 'resume.marker.unlink' + | 'resume.inventory-directory.fsync-after-marker-unlink'; + +export type InventoryV1TargetCloseReason = + | 'automatic-schema-quarantine' + | 'automatic-corrupt-quarantine' + | 'failed-open-cleanup' + | 'foundation-close' + | 'explicit-quarantine' + | 'pending-quarantine-probe'; + +export interface InventoryV1LifecycleAdapter { + readonly quarantineCapability: InventoryV1QuarantineCapability | null; + readonly boundary: (boundary: InventoryV1QuarantineBoundary) => void; + readonly closeTarget: ( + close: () => void, + reason: InventoryV1TargetCloseReason, + ) => void; +} + +export function createProductionInventoryV1LifecycleAdapter( + quarantineCapability: InventoryV1QuarantineCapability | null, +): InventoryV1LifecycleAdapter { + return Object.freeze({ + quarantineCapability, + boundary: (_boundary: InventoryV1QuarantineBoundary): void => {}, + closeTarget: (close: () => void, _reason: InventoryV1TargetCloseReason): void => { + close(); + }, + }); +} diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 974c0397d1..b37ef52b04 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -2,11 +2,12 @@ import { chmodSync, closeSync, existsSync, + fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, - readFileSync, + readdirSync, readSync, renameSync, statSync, @@ -24,6 +25,8 @@ import { } from 'node:path'; import { randomBytes } from 'node:crypto'; import { spawnSync } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { TextDecoder } from 'node:util'; import { INVENTORY_V1_APPLICATION_ID, @@ -35,6 +38,14 @@ import { INVENTORY_V1_USER_VERSION, normalizeInventoryV1SchemaSql, } from './sql.js'; +import { + createProductionInventoryV1LifecycleAdapter, + INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + type InventoryV1LifecycleAdapter, + type InventoryV1QuarantineBoundary, + type InventoryV1QuarantineCapability, + type InventoryV1TargetCloseReason, +} from './lifecycle-adapter.js'; type SqliteModuleV1 = typeof import('node:sqlite'); type DatabaseSyncV1 = InstanceType; @@ -42,6 +53,13 @@ type DatabaseSyncV1 = InstanceType; const RECOVERY_MARKER_SUFFIX = '.rebuild-required'; const QUARANTINE_DIRECTORY = 'quarantine'; const OWNED_FILE_SUFFIXES = ['', '-wal', '-shm'] as const; +const LEASE_DATABASE_NAME = 'inventory-v1.lease.sqlite3'; +const LEASE_FILE_SUFFIXES = ['', '-journal'] as const; +const LEASE_APPLICATION_ID = 0x444b364c; +const LEASE_USER_VERSION = 1; +const LEASE_CREATION_RACE_TIMEOUT_MS = 1_000; +const LEASE_CREATION_RACE_RETRY_MS = 10; +const RECOVERY_MARKER_MAX_BYTES = 4_096; export type InventoryV1OpenErrorCode = | 'sqlite-unavailable' @@ -51,6 +69,7 @@ export type InventoryV1OpenErrorCode = | 'unsafe-path' | 'pragma-mismatch' | 'database-busy' + | 'durability-unavailable' | 'database-closed' | 'database-io'; @@ -65,6 +84,21 @@ export class InventoryV1OpenError extends Error { } } +class InventoryV1TargetCloseError extends InventoryV1OpenError { + constructor( + readonly target: DatabaseSyncV1, + readonly reason: InventoryV1TargetCloseReason, + cause: unknown, + ) { + super( + 'database-io', + `failed to close the RFC-64 inventory target during ${reason}; the DK6L lease remains held in fail-stop`, + { cause }, + ); + this.name = 'InventoryV1TargetCloseError'; + } +} + export interface Rfc64InventoryV1Foundation { readonly databasePath: string; readonly closed: boolean; @@ -72,16 +106,130 @@ export interface Rfc64InventoryV1Foundation { close(): void; } -export async function openInventoryV1(dataDir: string): Promise { +export interface InventoryV1OpenOptions { + /** + * Explicit deployment attestation for namespace quarantine. Omit this for + * the fail-closed default. Fresh and valid databases do not need it. + */ + readonly quarantineCapability?: InventoryV1QuarantineCapability; +} + +export { INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY }; +export type { InventoryV1QuarantineCapability }; + +export async function openInventoryV1( + dataDir: string, + options: InventoryV1OpenOptions = {}, +): Promise { + return openInventoryV1WithLifecycleAdapter( + dataDir, + createProductionInventoryV1LifecycleAdapter( + options.quarantineCapability ?? null, + ), + ); +} + +/** @internal Source-test utility; not exported from the package entry point. */ +export interface InventoryV1TestOpenerIo { + readonly quarantineCapability?: InventoryV1QuarantineCapability | null; + readonly boundary?: (boundary: InventoryV1QuarantineBoundary) => void; + readonly closeTarget?: ( + close: () => void, + reason: InventoryV1TargetCloseReason, + ) => void; +} + +/** @internal Source-test utility; not exported from the package entry point. */ +export type InventoryV1TestOpener = ( + dataDir: string, +) => Promise; + +/** + * Test-only lifecycle seam. This symbol is deliberately omitted from the + * package entry point; direct source tests must also remain in NODE_ENV=test + * for factory creation, every open, and every callback invocation. + * @internal + */ +export function createInventoryV1TestOpener( + io: InventoryV1TestOpenerIo = {}, +): InventoryV1TestOpener { + assertInventoryV1TestEnvironment(); + const quarantineCapability = io.quarantineCapability ?? null; + const boundaryCallback = io.boundary; + const closeTargetCallback = io.closeTarget; + const lifecycle = Object.freeze({ + get quarantineCapability(): InventoryV1QuarantineCapability | null { + return process.env.NODE_ENV === 'test' + ? quarantineCapability + : null; + }, + boundary(boundary: InventoryV1QuarantineBoundary): void { + if (process.env.NODE_ENV === 'test') boundaryCallback?.(boundary); + }, + closeTarget(close: () => void, reason: InventoryV1TargetCloseReason): void { + if (process.env.NODE_ENV === 'test' && closeTargetCallback !== undefined) { + closeTargetCallback(close, reason); + return; + } + close(); + }, + }) satisfies InventoryV1LifecycleAdapter; + return async (dataDir: string): Promise => { + assertInventoryV1TestEnvironment(); + return openInventoryV1WithLifecycleAdapter(dataDir, lifecycle); + }; +} + +function assertInventoryV1TestEnvironment(): void { + if (process.env.NODE_ENV !== 'test') { + throw new InventoryV1OpenError( + 'database-io', + 'RFC-64 inventory lifecycle test injection is unavailable outside NODE_ENV=test', + ); + } +} + +async function openInventoryV1WithLifecycleAdapter( + dataDir: string, + lifecycle: InventoryV1LifecycleAdapter, +): Promise { + if (!Object.isFrozen(lifecycle)) { + throw new InventoryV1OpenError( + 'database-io', + 'RFC-64 inventory lifecycle adapter must be immutable', + ); + } const sqlite = await loadSqliteModule(); const resolvedDataDir = resolve(dataDir); const databasePath = resolve(resolvedDataDir, INVENTORY_V1_RELATIVE_PATH); + let lease: InventoryV1Lease | null = null; try { + if (pathEntryExists(recoveryMarkerPath(databasePath))) { + preflightExistingRecoveryMarker(resolvedDataDir, databasePath); + } prepareSecureDirectory(resolvedDataDir, dirname(databasePath)); - finishPendingQuarantine(databasePath); - const database = openOrRebuildOwnedDatabase(sqlite, databasePath); - return new InventoryV1Foundation(sqlite, databasePath, database); + rejectOrphanedSqliteSidecars( + inventoryLeasePath(databasePath), + 'inventory lifetime lease', + ); + // A pending recovery marker owns target-side evidence and must be parsed + // before classifying its source topology. Without such a marker, reject + // every orphan sidecar before creating even the independent lease unit so + // opening cannot mutate the directory around ambiguous evidence. + if (!pathEntryExists(recoveryMarkerPath(databasePath))) { + rejectOrphanedSqliteSidecars(databasePath, 'inventory database'); + } + lease = await acquireInventoryLease(sqlite, databasePath); + finishPendingQuarantine(sqlite, databasePath, lifecycle); + const database = openOrRebuildOwnedDatabase(sqlite, databasePath, lifecycle); + return new InventoryV1Foundation(sqlite, databasePath, database, lease, lifecycle); } catch (cause) { + if (cause instanceof InventoryV1TargetCloseError && lease !== null) { + retainInventoryFailStop(lease, cause); + lease = null; + throw cause; + } + releaseInventoryLease(lease); if (cause instanceof InventoryV1OpenError) throw cause; throw new InventoryV1OpenError( 'database-io', @@ -93,13 +241,17 @@ export async function openInventoryV1(dataDir: string): Promise { @@ -150,104 +336,346 @@ async function loadSqliteModule(): Promise { } } -function openOrRebuildOwnedDatabase( +interface InventoryV1Lease { + readonly database: DatabaseSyncV1; +} + +interface InventoryV1FailStopResources { + readonly lease: InventoryV1Lease; + readonly targetCloseError: InventoryV1TargetCloseError; +} + +// A target close failure is an in-process fail-stop. Keeping both handles +// strongly reachable prevents GC from silently releasing either side of the +// ownership pair and admitting a second conforming adapter after an ambiguous +// target teardown. +const RETAINED_INVENTORY_FAIL_STOPS = new Set(); + +function retainInventoryFailStop( + lease: InventoryV1Lease, + targetCloseError: InventoryV1TargetCloseError, +): void { + RETAINED_INVENTORY_FAIL_STOPS.add({ lease, targetCloseError }); +} + +function closeInventoryTarget( + target: DatabaseSyncV1, + reason: InventoryV1TargetCloseReason, + lifecycle: InventoryV1LifecycleAdapter, +): void { + try { + lifecycle.closeTarget(() => target.close(), reason); + } catch (cause) { + if (cause instanceof InventoryV1TargetCloseError) throw cause; + throw new InventoryV1TargetCloseError(target, reason, cause); + } +} + +function inventoryLeasePath(databasePath: string): string { + return join(dirname(databasePath), LEASE_DATABASE_NAME); +} + +async function acquireInventoryLease( sqlite: SqliteModuleV1, databasePath: string, -): DatabaseSyncV1 { - rejectOwnedFileSymlinks(databasePath); - rejectOrphanedSidecars(databasePath); - const existed = existsSync(databasePath); - if (existed) { - refuseValidForeignSqliteHeader(databasePath); - assertOwnedUnitOwners(databasePath); +): Promise { + const path = inventoryLeasePath(databasePath); + const existedAtEntry = pathEntryExists(path); + if (!existedAtEntry) { + rejectOrphanedSqliteSidecars(path, 'inventory lifetime lease'); } - if (!existed) createSecureEmptyFile(databasePath); + let createdByThisOpen = false; + if (existedAtEntry) { + assertLeaseUnitOwnersAndTypes(path); + assertCommittedLeaseHeaderBeforeOpen(path); + } else { + try { + createSecureEmptyFile(path); + createdByThisOpen = true; + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== 'EEXIST') throw cause; + // Only an opener that lost this exact wx race may briefly wait for the + // winner to publish a committed DK6L/v1 header. A lease that existed at + // entry never gets this grace period: headerless and zero/zero remnants + // fail closed for offline removal. + await waitForCommittedRacingLease(path); + } + } + applySecurePermissions(path, INVENTORY_V1_FILE_MODE, false); let database: DatabaseSyncV1 | null = null; + let transactionOpen = false; try { - // Keep SQL-1 on the original Node 22.5 node:sqlite surface. Loading - // extensions is disabled by default; every statement below is fixed SQL, - // so double-quoted-string compatibility cannot affect parsing. - database = new sqlite.DatabaseSync(databasePath); + database = new sqlite.DatabaseSync(path); database.exec(` - PRAGMA foreign_keys = ON; - PRAGMA trusted_schema = OFF; - PRAGMA busy_timeout = 5000; + PRAGMA busy_timeout = 0; + PRAGMA synchronous = FULL; + PRAGMA locking_mode = EXCLUSIVE; `); + const journalMode = database.prepare( + createdByThisOpen ? 'PRAGMA journal_mode = DELETE' : 'PRAGMA journal_mode', + ).get(); + if ( + journalMode === undefined + || String(journalMode.journal_mode).toLowerCase() !== 'delete' + ) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'inventory lifetime lease must use SQLite rollback-journal mode', + ); + } + database.exec('BEGIN EXCLUSIVE'); + transactionOpen = true; const identity = readIdentity(database); - - if (isFreshIdentity(identity)) { - if (identity.userObjects.length !== 0) { - database.close(); - database = null; + if (createdByThisOpen) { + if (!isFreshIdentity(identity) || identity.userObjects.length !== 0) { throw new InventoryV1OpenError( 'ambiguous-database', - 'application_id=0/user_version=0 database contains user objects and will not be modified', + 'new inventory lease lost its pristine identity before initialization', ); } - tightenOwnedFileMode(databasePath); - applyAndVerifyPragmas(database); - initializeFreshDatabase(database); - verifyOwnedSchema(database); - tightenOwnedFileMode(databasePath); - return database; + database.exec(` + PRAGMA application_id = ${LEASE_APPLICATION_ID}; + PRAGMA user_version = ${LEASE_USER_VERSION}; + COMMIT; + BEGIN EXCLUSIVE; + `); + } else { + assertLeaseIdentity(identity); } - - if (identity.applicationId !== INVENTORY_V1_APPLICATION_ID) { - database.close(); - database = null; + assertLeaseIdentity(readIdentity(database)); + verifyLeasePragmas(database); + if (!database.isTransaction) { throw new InventoryV1OpenError( - identity.applicationId === 0 ? 'ambiguous-database' : 'foreign-database', - 'database application_id does not identify RFC-64 SQL-1 and will not be modified', + 'database-io', + 'inventory lifetime lease lost its exclusive transaction before publication', ); } - if (identity.userVersion < INVENTORY_V1_USER_VERSION) { - database.close(); - database = null; + tightenOwnedFileMode(path); + return { database }; + } catch (cause) { + if (transactionOpen) { + try { database?.exec('ROLLBACK'); } catch { /* retain the lease acquisition failure */ } + } + try { database?.close(); } catch { /* retain the lease acquisition failure */ } + if (cause instanceof InventoryV1OpenError) throw cause; + if (isBusySqliteError(cause)) { + throw new InventoryV1OpenError( + 'database-busy', + 'another process holds the RFC-64 inventory lifetime lease', + { cause }, + ); + } + if (isCorruptSqliteError(cause)) { + const identity = readValidSqliteHeaderIdentity(path); + if (identity?.applicationId === LEASE_APPLICATION_ID && identity.userVersion > LEASE_USER_VERSION) { + throw new InventoryV1OpenError( + 'newer-schema', + 'corrupt inventory lease has a newer user_version and will not be modified', + { cause }, + ); + } + if (identity !== null && identity.applicationId !== 0 && identity.applicationId !== LEASE_APPLICATION_ID) { + throw new InventoryV1OpenError( + 'foreign-database', + 'corrupt inventory lease has a foreign application_id and will not be modified', + { cause }, + ); + } throw new InventoryV1OpenError( 'ambiguous-database', - `inventory application_id is DK64 but user_version ${identity.userVersion} is not a committed v1 database`, + 'corrupt inventory lifetime lease will not be modified or quarantined', + { cause }, ); } - if (identity.userVersion > INVENTORY_V1_USER_VERSION) { - database.close(); - database = null; + throw new InventoryV1OpenError( + 'database-io', + 'failed to acquire the RFC-64 inventory lifetime lease', + { cause }, + ); + } +} + +function assertLeaseUnitOwnersAndTypes(path: string): void { + for (const suffix of LEASE_FILE_SUFFIXES) { + const member = `${path}${suffix}`; + if (!pathEntryExists(member)) continue; + assertOwnedRegularFile(member, `inventory lifetime lease${suffix}`); + } + for (const suffix of ['-wal', '-shm'] as const) { + const member = `${path}${suffix}`; + if (!pathEntryExists(member)) continue; + assertOwnedRegularFile(member, `inventory lifetime lease${suffix}`); + throw new InventoryV1OpenError( + 'ambiguous-database', + 'inventory lifetime lease has unexpected WAL sidecars and will not be modified', + ); + } +} + +function assertCommittedLeaseHeaderBeforeOpen(path: string): void { + const identity = readValidSqliteHeaderIdentity(path); + if (identity === null) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'pre-existing headerless inventory lifetime lease will not be initialized or modified', + ); + } + if (identity.applicationId !== LEASE_APPLICATION_ID) { + throw new InventoryV1OpenError( + identity.applicationId === 0 ? 'ambiguous-database' : 'foreign-database', + 'inventory lifetime lease application_id is not the frozen DK6L identity', + ); + } + if (identity.userVersion > LEASE_USER_VERSION) { + throw new InventoryV1OpenError( + 'newer-schema', + 'inventory lifetime lease user_version is newer than supported version 1', + ); + } + if (identity.userVersion !== LEASE_USER_VERSION) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'inventory lifetime lease is not a committed v1 lease', + ); + } +} + +async function waitForCommittedRacingLease(path: string): Promise { + const deadline = Date.now() + LEASE_CREATION_RACE_TIMEOUT_MS; + while (true) { + assertLeaseUnitOwnersAndTypes(path); + const identity = readValidSqliteHeaderIdentity(path); + if ( + identity?.applicationId === LEASE_APPLICATION_ID + && identity.userVersion === LEASE_USER_VERSION + ) { + return; + } + if ( + identity !== null + && ( + identity.applicationId !== 0 + || identity.userVersion !== 0 + ) + ) { + assertCommittedLeaseHeaderBeforeOpen(path); + } + if (Date.now() >= deadline) { throw new InventoryV1OpenError( - 'newer-schema', - `inventory user_version ${identity.userVersion} is newer than supported version 1`, + 'ambiguous-database', + 'racing lease creator did not publish a committed DK6L/v1 identity', ); } + await delay(LEASE_CREATION_RACE_RETRY_MS); + } +} - tightenOwnedFileMode(databasePath); - if (identity.userVersion !== INVENTORY_V1_USER_VERSION || !schemaMatches(identity.userObjects)) { - assertDatabaseQuiescent(database); - database.close(); - database = null; - beginQuarantine(databasePath); - finishPendingQuarantine(databasePath); - return openOrRebuildOwnedDatabase(sqlite, databasePath); +function assertLeaseIdentity(identity: DatabaseIdentityV1): void { + if (identity.applicationId !== LEASE_APPLICATION_ID) { + throw new InventoryV1OpenError( + identity.applicationId === 0 ? 'ambiguous-database' : 'foreign-database', + 'inventory lifetime lease application_id is not the frozen DK6L identity', + ); + } + if (identity.userVersion > LEASE_USER_VERSION) { + throw new InventoryV1OpenError( + 'newer-schema', + 'inventory lifetime lease user_version is newer than supported version 1', + ); + } + if (identity.userVersion !== LEASE_USER_VERSION || identity.userObjects.length !== 0) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'inventory lifetime lease schema is not the frozen empty v1 schema', + ); + } +} + +function verifyLeasePragmas(database: DatabaseSyncV1): void { + const journalMode = database.prepare('PRAGMA journal_mode').get(); + const lockingMode = database.prepare('PRAGMA locking_mode').get(); + if (String(journalMode?.journal_mode).toLowerCase() !== 'delete') { + throw new InventoryV1OpenError('pragma-mismatch', 'inventory lease refused journal_mode=DELETE'); + } + if (String(lockingMode?.locking_mode).toLowerCase() !== 'exclusive') { + throw new InventoryV1OpenError('pragma-mismatch', 'inventory lease refused locking_mode=EXCLUSIVE'); + } + if (readPragmaInteger(database, 'synchronous') !== 2) { + throw new InventoryV1OpenError('pragma-mismatch', 'inventory lease refused synchronous=FULL'); + } + if (readPragmaInteger(database, 'busy_timeout') !== 0) { + throw new InventoryV1OpenError('pragma-mismatch', 'inventory lease refused busy_timeout=0'); + } +} + +function releaseInventoryLease(lease: InventoryV1Lease | null): void { + if (lease === null) return; + try { + lease.database.exec('ROLLBACK'); + } finally { + lease.database.close(); + } +} + +function openOrRebuildOwnedDatabase( + sqlite: SqliteModuleV1, + databasePath: string, + lifecycle: InventoryV1LifecycleAdapter, +): DatabaseSyncV1 { + rejectOwnedFileSymlinks(databasePath); + rejectOrphanedSqliteSidecars(databasePath, 'inventory database'); + const created = !existsSync(databasePath); + if (created) { + createSecureEmptyFile(databasePath); + } else { + assertOwnedUnitOwners(databasePath); + assertExistingTargetHeader(databasePath); + } + + let database: DatabaseSyncV1 | null = null; + try { + // Keep SQL-1 on the original Node 22.5 node:sqlite surface. Loading + // extensions is disabled by default; every statement below is fixed SQL, + // so double-quoted-string compatibility cannot affect parsing. + database = new sqlite.DatabaseSync(databasePath); + + if (created) { + tightenOwnedFileMode(databasePath); + initializeFreshDatabase(database, databasePath); + verifyOwnedSchema(database); + applyAndVerifyPragmas(database); + tightenOwnedFileMode(databasePath); + return database; } - applyAndVerifyPragmas(database); + // Existing exact-owned targets receive identity, schema, and FK reads + // before any target PRAGMA assignment. This no-create classifier must not + // convert or "repair" a pre-existing unit merely by opening it. + const identity = readIdentity(database); + assertCommittedTargetIdentity(identity); try { verifyOwnedSchema(database); } catch (error) { if (!(error instanceof OwnedInventoryV1SchemaError)) throw error; - assertDatabaseQuiescent(database); - database.close(); + assertDatabaseQuiescent(database, lifecycle); + closeInventoryTarget(database, 'automatic-schema-quarantine', lifecycle); database = null; - beginQuarantine(databasePath); - finishPendingQuarantine(databasePath); - return openOrRebuildOwnedDatabase(sqlite, databasePath); + assertQuarantineDurabilityAvailable(lifecycle); + beginQuarantine(databasePath, lifecycle); + finishPendingQuarantine(sqlite, databasePath, lifecycle); + return openOrRebuildOwnedDatabase(sqlite, databasePath, lifecycle); } + applyAndVerifyPragmas(database); tightenOwnedFileMode(databasePath); return database; } catch (error) { const closeProbe = (): void => { if (database === null) return; - try { database.close(); } catch { /* retain the original failure */ } + closeInventoryTarget(database, 'failed-open-cleanup', lifecycle); database = null; }; + if (error instanceof InventoryV1TargetCloseError) throw error; if (error instanceof InventoryV1OpenError) { closeProbe(); throw error; @@ -292,7 +720,7 @@ function openOrRebuildOwnedDatabase( ); } try { - assertCorruptDatabaseQuiescent(database); + assertCorruptDatabaseQuiescent(database, lifecycle); } catch (cause) { closeProbe(); if (cause instanceof InventoryV1OpenError) throw cause; @@ -302,20 +730,15 @@ function openOrRebuildOwnedDatabase( { cause }, ); } - try { - database.close(); - } catch (cause) { - database = null; - throw new InventoryV1OpenError( - 'database-io', - 'failed to close the corrupt DK64 database after proving quiescence; it will not be quarantined', - { cause }, - ); - } + closeInventoryTarget(database, 'automatic-corrupt-quarantine', lifecycle); database = null; - beginQuarantine(databasePath); - finishPendingQuarantine(databasePath); - return openOrRebuildOwnedDatabase(sqlite, databasePath); + // The corrupt target handle is deliberately closed before this platform + // gate can reject quarantine. The caller may release DK6L only after that + // target close has succeeded. + assertQuarantineDurabilityAvailable(lifecycle); + beginQuarantine(databasePath, lifecycle); + finishPendingQuarantine(sqlite, databasePath, lifecycle); + return openOrRebuildOwnedDatabase(sqlite, databasePath, lifecycle); } closeProbe(); if (isBusySqliteError(error)) { @@ -327,12 +750,52 @@ function openOrRebuildOwnedDatabase( } throw new InventoryV1OpenError( 'database-io', - existed ? 'failed to open RFC-64 inventory database' : 'failed to initialize RFC-64 inventory database', + created ? 'failed to initialize RFC-64 inventory database' : 'failed to open RFC-64 inventory database', { cause: error }, ); } } +function assertExistingTargetHeader(databasePath: string): void { + const identity = readValidSqliteHeaderIdentity(databasePath); + if (identity === null || identity.applicationId === 0) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'pre-existing inventory database lacks the committed DK64 identity and will not be opened', + ); + } + if (identity.applicationId !== INVENTORY_V1_APPLICATION_ID) { + throw new InventoryV1OpenError( + 'foreign-database', + 'database header has a foreign application_id and will not be opened or modified', + ); + } + if (identity.userVersion > INVENTORY_V1_USER_VERSION) { + throw new InventoryV1OpenError( + 'newer-schema', + `inventory user_version ${identity.userVersion} is newer than supported version 1`, + ); + } + if (identity.userVersion !== INVENTORY_V1_USER_VERSION) { + throw new InventoryV1OpenError( + 'ambiguous-database', + `inventory application_id is DK64 but user_version ${identity.userVersion} is not committed v1`, + ); + } +} + +function assertCommittedTargetIdentity(identity: DatabaseIdentityV1): void { + if ( + identity.applicationId !== INVENTORY_V1_APPLICATION_ID + || identity.userVersion !== INVENTORY_V1_USER_VERSION + ) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'inventory identity changed after the no-create header preflight', + ); + } +} + interface DatabaseIdentityV1 { applicationId: number; userVersion: number; @@ -366,17 +829,63 @@ function schemaMatches(objects: DatabaseIdentityV1['userObjects']): boolean { }); } -function initializeFreshDatabase(database: DatabaseSyncV1): void { - database.exec('BEGIN IMMEDIATE'); +function initializeFreshDatabase(database: DatabaseSyncV1, databasePath: string): void { + const journalMode = database.prepare('PRAGMA journal_mode = DELETE').get(); + database.exec(` + PRAGMA synchronous = FULL; + PRAGMA busy_timeout = 5000; + `); + if (String(journalMode?.journal_mode).toLowerCase() !== 'delete') { + throw new InventoryV1OpenError( + 'pragma-mismatch', + 'new inventory database refused rollback-journal bootstrap mode', + ); + } + if (readPragmaInteger(database, 'synchronous') !== 2) { + throw new InventoryV1OpenError('pragma-mismatch', 'new inventory database refused synchronous=FULL'); + } + if (readPragmaInteger(database, 'busy_timeout') !== 5000) { + throw new InventoryV1OpenError('pragma-mismatch', 'new inventory database refused busy_timeout=5000'); + } + + let transactionOpen = false; try { + database.exec('BEGIN IMMEDIATE'); + transactionOpen = true; + const identity = readIdentity(database); + if (!isFreshIdentity(identity) || identity.userObjects.length !== 0) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'new inventory file lost its pristine identity before initialization', + ); + } database.exec(INVENTORY_V1_DDL); database.exec(`PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}`); database.exec(`PRAGMA user_version = ${INVENTORY_V1_USER_VERSION}`); database.exec('COMMIT'); + transactionOpen = false; } catch (error) { - try { database.exec('ROLLBACK'); } catch { /* retain the initialization error */ } + if (transactionOpen) { + try { database.exec('ROLLBACK'); } catch { /* retain the initialization error */ } + } throw error; } + + // Publish the committed DK64/v1 identity through the rollback-journal main + // file before WAL is ever enabled. A crash therefore cannot leave a valid + // identity that is visible only through an uncheckpointed WAL. + fsyncRegularFile(databasePath, 'new inventory database'); + fsyncDirectory(dirname(databasePath)); + const committed = readValidSqliteHeaderIdentity(databasePath); + if ( + committed?.applicationId !== INVENTORY_V1_APPLICATION_ID + || committed.userVersion !== INVENTORY_V1_USER_VERSION + ) { + throw new InventoryV1OpenError( + 'database-io', + 'committed inventory database identity was not durable in the raw main header', + ); + } } class OwnedInventoryV1SchemaError extends Error { @@ -401,7 +910,10 @@ function verifyOwnedSchema(database: DatabaseSyncV1): void { } } -function assertDatabaseQuiescent(database: DatabaseSyncV1): void { +function assertDatabaseQuiescent( + database: DatabaseSyncV1, + lifecycle: InventoryV1LifecycleAdapter, +): void { let transactionOpen = false; try { database.exec('PRAGMA busy_timeout = 0'); @@ -419,6 +931,10 @@ function assertDatabaseQuiescent(database: DatabaseSyncV1): void { transactionOpen = true; database.exec('ROLLBACK'); transactionOpen = false; + // A conforming contender must still be fenced by the independently held + // DK6L lease in the interval after this target proof releases its target + // lock and before quarantine namespace mutation starts. + lifecycle.boundary('target-exclusivity-proven'); } catch (cause) { if (transactionOpen) { try { database.exec('ROLLBACK'); } catch { /* retain the quiescence failure */ } @@ -441,7 +957,10 @@ function assertDatabaseQuiescent(database: DatabaseSyncV1): void { } } -function assertCorruptDatabaseQuiescent(database: DatabaseSyncV1): void { +function assertCorruptDatabaseQuiescent( + database: DatabaseSyncV1, + lifecycle: InventoryV1LifecycleAdapter, +): void { let transactionOpen = false; try { // Probe the writer lock before corruption-sensitive schema/checkpoint work @@ -471,7 +990,7 @@ function assertCorruptDatabaseQuiescent(database: DatabaseSyncV1): void { } finally { try { database.exec('PRAGMA busy_timeout = 5000'); } catch { /* best-effort connection restore */ } } - assertDatabaseQuiescent(database); + assertDatabaseQuiescent(database, lifecycle); } function applyAndVerifyPragmas(database: DatabaseSyncV1): void { @@ -551,6 +1070,41 @@ function prepareSecureDirectory(dataDirectory: string, directoryPath: string): v applySecurePermissions(directoryPath, INVENTORY_V1_DIRECTORY_MODE, true); } +function preflightExistingRecoveryMarker( + dataDirectory: string, + databasePath: string, +): void { + const inventoryDirectory = dirname(databasePath); + const relativeDirectory = relative(dataDirectory, inventoryDirectory); + if ( + relativeDirectory === '..' + || relativeDirectory.startsWith(`..${sep}`) + || isAbsolute(relativeDirectory) + ) { + throw new InventoryV1OpenError( + 'unsafe-path', + 'inventory recovery marker is outside the declared DKG data directory', + ); + } + + assertOwnedDirectory(dataDirectory, 'DKG data directory'); + let currentDirectory = dataDirectory; + for (const component of relativeDirectory.split(sep).filter((value) => value.length !== 0)) { + currentDirectory = join(currentDirectory, component); + assertOwnedDirectory(currentDirectory, 'inventory directory path component'); + } + + const markerPath = recoveryMarkerPath(databasePath); + assertOwnedRegularFile(markerPath, 'inventory recovery marker'); + // Parse before chmod, lease creation, SQLite open, lifecycle boundaries, or + // any recovery namespace mutation. The marker is parsed again under DK6L in + // finishPendingQuarantine to close the read/lock TOCTOU window. + parseRecoveryMarker( + readBoundedRecoveryMarker(markerPath), + inventoryDirectory, + ); +} + function createSecureEmptyFile(databasePath: string): void { const descriptor = openSync(databasePath, 'wx', INVENTORY_V1_FILE_MODE); try { fsyncSync(descriptor); } finally { closeSync(descriptor); } @@ -695,12 +1249,13 @@ function rejectOwnedFileSymlinks(databasePath: string): void { } } -function rejectOrphanedSidecars(databasePath: string): void { - if (existsSync(databasePath)) return; - if (pathEntryExists(`${databasePath}-wal`) || pathEntryExists(`${databasePath}-shm`)) { +function rejectOrphanedSqliteSidecars(mainPath: string, label: string): void { + if (pathEntryExists(mainPath)) return; + for (const suffix of ['-journal', '-wal', '-shm'] as const) { + if (!pathEntryExists(`${mainPath}${suffix}`)) continue; throw new InventoryV1OpenError( 'ambiguous-database', - 'orphaned inventory sidecars exist without a database and will not be modified', + `orphaned ${label} sidecars exist without a main database and will not be modified`, ); } } @@ -711,6 +1266,53 @@ function rejectSymlink(path: string, label: string): void { } } +function assertOwnedRegularFile(path: string, label: string): void { + rejectSymlink(path, label); + if (!lstatSync(path).isFile()) { + throw new InventoryV1OpenError('unsafe-path', `${label} must be a regular file`); + } + assertFilesystemOwner(path); +} + +function assertOwnedDirectory(path: string, label: string): void { + rejectSymlink(path, label); + if (!lstatSync(path).isDirectory()) { + throw new InventoryV1OpenError('unsafe-path', `${label} must be a directory`); + } + assertFilesystemOwner(path); +} + +function assertSameFilesystem(path: string, directoryPath: string, label: string): void { + try { + if (statSync(path).dev !== statSync(directoryPath).dev) { + throw new InventoryV1OpenError( + 'unsafe-path', + `${label} is not on the quarantine generation filesystem`, + ); + } + } catch (cause) { + if (cause instanceof InventoryV1OpenError) throw cause; + throw new InventoryV1OpenError( + 'database-io', + `failed to verify same-filesystem recovery for ${label}`, + { cause }, + ); + } +} + +function fsyncRegularFile(path: string, label: string): void { + assertOwnedRegularFile(path, label); + let descriptor: number | undefined; + try { + descriptor = openSync(path, 'r'); + fsyncSync(descriptor); + } catch (cause) { + throw new InventoryV1OpenError('database-io', `failed to fsync ${label}`, { cause }); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + function pathEntryExists(path: string): boolean { try { lstatSync(path); @@ -721,77 +1323,432 @@ function pathEntryExists(path: string): boolean { } } +type RecoveryMemberV1 = 'wal' | 'shm' | 'main'; +type RecoveryMembersV1 = + | readonly ['main'] + | readonly ['wal', 'main'] + | readonly ['shm', 'main'] + | readonly ['wal', 'shm', 'main']; + +const ALL_RECOVERY_MEMBERS = ['wal', 'shm', 'main'] as const satisfies readonly RecoveryMemberV1[]; +const RECOVERY_MEMBER_ARRAYS = new Set([ + '["main"]', + '["wal","main"]', + '["shm","main"]', + '["wal","shm","main"]', +]); + interface RecoveryMarkerV1 { - version: 1; - quarantineDirectory: string; + readonly version: 1; + readonly quarantineDirectory: string; + readonly members: RecoveryMembersV1; +} + +type RecoveryMemberLocationV1 = 'source' | 'destination'; + +interface RecoveryTopologyV1 { + readonly locations: ReadonlyMap; + readonly movedCount: number; +} + +function assertQuarantineDurabilityAvailable( + lifecycle: InventoryV1LifecycleAdapter, +): void { + if ( + process.platform === 'win32' + || lifecycle.quarantineCapability + !== INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY + ) { + throw new InventoryV1OpenError( + 'durability-unavailable', + 'RFC-64 inventory quarantine requires the explicit certified POSIX namespace capability', + ); + } } -function beginQuarantine(databasePath: string): void { +function beginQuarantine( + databasePath: string, + lifecycle: InventoryV1LifecycleAdapter, +): void { + assertQuarantineDurabilityAvailable(lifecycle); const markerPath = recoveryMarkerPath(databasePath); if (pathEntryExists(markerPath)) { rejectSymlink(markerPath, 'inventory recovery marker'); return; } + const members = inspectSourceMembersForNewMarker(databasePath); + for (const member of members) { + fsyncRegularFile(recoverySourcePath(databasePath, member), `inventory ${member} evidence`); + lifecycle.boundary(`begin.source.${member}.file-fsync`); + } + const inventoryDirectory = dirname(databasePath); const quarantineRoot = join(inventoryDirectory, QUARANTINE_DIRECTORY); - if (pathEntryExists(quarantineRoot)) rejectSymlink(quarantineRoot, 'inventory quarantine directory'); - mkdirSync(quarantineRoot, { recursive: true, mode: INVENTORY_V1_DIRECTORY_MODE }); - rejectSymlink(quarantineRoot, 'inventory quarantine directory'); + if (pathEntryExists(quarantineRoot)) { + assertOwnedDirectory(quarantineRoot, 'inventory quarantine directory'); + } else { + mkdirSync(quarantineRoot, { mode: INVENTORY_V1_DIRECTORY_MODE }); + } + assertOwnedDirectory(quarantineRoot, 'inventory quarantine directory'); applySecurePermissions(quarantineRoot, INVENTORY_V1_DIRECTORY_MODE, true); fsyncDirectory(inventoryDirectory); + lifecycle.boundary('begin.inventory-directory.fsync-after-quarantine-root'); const suffix = `${Date.now()}-${randomBytes(8).toString('hex')}`; const quarantineDirectory = join(quarantineRoot, `inventory-v1-${suffix}`); mkdirSync(quarantineDirectory, { mode: INVENTORY_V1_DIRECTORY_MODE }); - rejectSymlink(quarantineDirectory, 'inventory quarantine generation'); + assertOwnedDirectory(quarantineDirectory, 'inventory quarantine generation'); applySecurePermissions(quarantineDirectory, INVENTORY_V1_DIRECTORY_MODE, true); + assertSameFilesystem(inventoryDirectory, quarantineDirectory, 'inventory quarantine generation'); + for (const member of members) { + const source = recoverySourcePath(databasePath, member); + assertOwnedRegularFile(source, `inventory ${member} evidence`); + assertSameFilesystem(source, quarantineDirectory, `inventory ${member} evidence`); + const destination = recoveryDestinationPath(quarantineDirectory, member); + if (pathEntryExists(destination)) { + throw new InventoryV1OpenError( + 'database-io', + `new quarantine generation already contains ${member} evidence`, + ); + } + } fsyncDirectory(quarantineRoot); - const marker: RecoveryMarkerV1 = { version: 1, quarantineDirectory }; + lifecycle.boundary('begin.quarantine-root.fsync-after-generation'); + const marker: RecoveryMarkerV1 = { version: 1, quarantineDirectory, members }; writeFileSync(markerPath, JSON.stringify(marker), { encoding: 'utf8', flag: 'wx', mode: INVENTORY_V1_FILE_MODE }); + lifecycle.boundary('begin.marker.write'); applySecurePermissions(markerPath, INVENTORY_V1_FILE_MODE, false); - const descriptor = openSync(markerPath, 'r'); - try { fsyncSync(descriptor); } finally { closeSync(descriptor); } + fsyncRegularFile(markerPath, 'inventory recovery marker'); + lifecycle.boundary('begin.marker.file-fsync'); fsyncDirectory(inventoryDirectory); + lifecycle.boundary('begin.inventory-directory.fsync-after-marker'); } -function finishPendingQuarantine(databasePath: string): void { +function finishPendingQuarantine( + sqlite: SqliteModuleV1, + databasePath: string, + lifecycle: InventoryV1LifecycleAdapter, +): void { const markerPath = recoveryMarkerPath(databasePath); if (!pathEntryExists(markerPath)) return; - rejectSymlink(markerPath, 'inventory recovery marker'); - assertFilesystemOwner(markerPath); + // This check precedes marker parsing, directory creation, permission changes, + // SQLite probes, and evidence moves. Unsupported platforms leave the whole + // pending unit byte-for-byte untouched for an offline operator procedure. + assertQuarantineDurabilityAvailable(lifecycle); + assertOwnedRegularFile(markerPath, 'inventory recovery marker'); const inventoryDirectory = dirname(databasePath); const quarantineRoot = join(inventoryDirectory, QUARANTINE_DIRECTORY); - if (pathEntryExists(quarantineRoot)) { - rejectSymlink(quarantineRoot, 'inventory quarantine directory'); - assertFilesystemOwner(quarantineRoot); + if (!pathEntryExists(quarantineRoot)) { + throw new InventoryV1OpenError( + 'database-io', + 'pending inventory recovery marker names a missing quarantine root', + ); } - const marker = parseRecoveryMarker(readFileSync(markerPath, 'utf8'), inventoryDirectory); - mkdirSync(marker.quarantineDirectory, { recursive: true, mode: INVENTORY_V1_DIRECTORY_MODE }); - rejectSymlink(quarantineRoot, 'inventory quarantine directory'); - applySecurePermissions(quarantineRoot, INVENTORY_V1_DIRECTORY_MODE, true); - rejectSymlink(marker.quarantineDirectory, 'inventory quarantine generation'); - assertFilesystemOwner(marker.quarantineDirectory); - applySecurePermissions(marker.quarantineDirectory, INVENTORY_V1_DIRECTORY_MODE, true); - // Make both levels of the quarantine destination durable before evidence is - // moved under the recovery marker. - fsyncDirectory(inventoryDirectory); - fsyncDirectory(quarantineRoot); - for (const suffix of OWNED_FILE_SUFFIXES) { - const source = `${databasePath}${suffix}`; - if (!pathEntryExists(source)) continue; - rejectSymlink(source, `inventory database${suffix}`); - assertFilesystemOwner(source); - const target = join(marker.quarantineDirectory, `inventory-v1.sqlite3${suffix}`); - if (pathEntryExists(target)) { - throw new InventoryV1OpenError('database-io', `quarantine target already exists: ${target}`); + assertOwnedDirectory(quarantineRoot, 'inventory quarantine directory'); + const marker = parseRecoveryMarker( + readBoundedRecoveryMarker(markerPath), + inventoryDirectory, + ); + if (!pathEntryExists(marker.quarantineDirectory)) { + throw new InventoryV1OpenError( + 'database-io', + 'pending inventory recovery marker names a missing quarantine generation', + ); + } + assertOwnedDirectory(marker.quarantineDirectory, 'inventory quarantine generation'); + assertSameFilesystem(inventoryDirectory, marker.quarantineDirectory, 'inventory quarantine generation'); + + let topology = inspectRecoveryTopology(marker, databasePath); + // A crash may have occurred after rename but before that move's file or + // directory barriers. Re-establish durability for the observed moved prefix + // before either continuing or deleting the marker. + fsyncMovedRecoveryPrefix(marker, topology, inventoryDirectory, lifecycle); + if (topology.movedCount === 0) { + // Only a complete source unit may be reopened. Once even one member has + // moved, SQLite must never see the partial source topology. + assertPendingUnitQuiescent(sqlite, databasePath, lifecycle); + topology = inspectRecoveryTopology(marker, databasePath); + if (topology.movedCount !== 0) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery topology changed while re-proving source quiescence', + ); + } + // The re-probe may checkpoint or perform ordinary exact-owned recovery. + // Re-fsync the complete manifest after closing that handle so the pending + // marker never vouches for bytes dirtied by the restart proof. + for (const member of marker.members) { + fsyncRegularFile(recoverySourcePath(databasePath, member), `inventory ${member} evidence`); + lifecycle.boundary(`resume.source.${member}.file-fsync-after-quiescence`); } - renameSync(source, target); } - // Persist destination names and source removals before deleting the marker. - // A crash before either fsync leaves the durable marker to resume safely. - fsyncDirectory(marker.quarantineDirectory); - fsyncDirectory(inventoryDirectory); + + for (const member of marker.members) { + if (topology.locations.get(member) === 'destination') continue; + const source = recoverySourcePath(databasePath, member); + const destination = recoveryDestinationPath(marker.quarantineDirectory, member); + assertOwnedRegularFile(source, `inventory ${member} evidence`); + assertSameFilesystem(source, marker.quarantineDirectory, `inventory ${member} evidence`); + renameNoOverwriteUnderLease(source, destination); + lifecycle.boundary(`resume.member.${member}.rename`); + fsyncRegularFile(destination, `moved inventory ${member} evidence`); + lifecycle.boundary(`resume.member.${member}.file-fsync`); + fsyncDirectory(marker.quarantineDirectory); + lifecycle.boundary(`resume.member.${member}.generation-directory-fsync`); + fsyncDirectory(inventoryDirectory); + lifecycle.boundary(`resume.member.${member}.inventory-directory-fsync`); + } + + topology = inspectRecoveryTopology(marker, databasePath); + if (topology.movedCount !== marker.members.length) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery did not durably move every manifest member', + ); + } unlinkSync(markerPath); + lifecycle.boundary('resume.marker.unlink'); fsyncDirectory(inventoryDirectory); + lifecycle.boundary('resume.inventory-directory.fsync-after-marker-unlink'); +} + +function fsyncMovedRecoveryPrefix( + marker: RecoveryMarkerV1, + topology: RecoveryTopologyV1, + inventoryDirectory: string, + lifecycle: InventoryV1LifecycleAdapter, +): void { + for (const member of marker.members) { + if (topology.locations.get(member) !== 'destination') break; + fsyncRegularFile( + recoveryDestinationPath(marker.quarantineDirectory, member), + `moved inventory ${member} evidence`, + ); + lifecycle.boundary(`resume.prefix.${member}.file-fsync`); + fsyncDirectory(marker.quarantineDirectory); + lifecycle.boundary(`resume.prefix.${member}.generation-directory-fsync`); + fsyncDirectory(inventoryDirectory); + lifecycle.boundary(`resume.prefix.${member}.inventory-directory-fsync`); + } +} + +function inspectSourceMembersForNewMarker(databasePath: string): RecoveryMembersV1 { + const present: RecoveryMemberV1[] = []; + for (const member of ALL_RECOVERY_MEMBERS) { + const source = recoverySourcePath(databasePath, member); + if (!pathEntryExists(source)) continue; + assertOwnedRegularFile(source, `inventory ${member} evidence`); + present.push(member); + } + return assertRecoveryMembers(present); +} + +function inspectRecoveryTopology( + marker: RecoveryMarkerV1, + databasePath: string, +): RecoveryTopologyV1 { + const listed = new Set(marker.members); + const knownDestinationNames = new Set( + ALL_RECOVERY_MEMBERS.map((member) => basename(recoveryDestinationPath(marker.quarantineDirectory, member))), + ); + for (const name of readdirSync(marker.quarantineDirectory)) { + if (!knownDestinationNames.has(name)) { + throw new InventoryV1OpenError( + 'database-io', + `quarantine generation contains an unknown entry: ${name}`, + ); + } + } + + const locations = new Map(); + for (const member of ALL_RECOVERY_MEMBERS) { + const source = recoverySourcePath(databasePath, member); + const destination = recoveryDestinationPath(marker.quarantineDirectory, member); + const sourceExists = pathEntryExists(source); + const destinationExists = pathEntryExists(destination); + if (!listed.has(member)) { + if (sourceExists || destinationExists) { + throw new InventoryV1OpenError( + 'database-io', + `unlisted inventory recovery member exists: ${member}`, + ); + } + continue; + } + if (sourceExists === destinationExists) { + throw new InventoryV1OpenError( + 'database-io', + sourceExists + ? `inventory recovery member exists at both source and destination: ${member}` + : `inventory recovery member is missing from both source and destination: ${member}`, + ); + } + const path = sourceExists ? source : destination; + assertOwnedRegularFile(path, `inventory ${member} recovery member`); + assertSameFilesystem(path, marker.quarantineDirectory, `inventory ${member} recovery member`); + locations.set(member, sourceExists ? 'source' : 'destination'); + } + + let movedCount = 0; + let sawSource = false; + for (const member of marker.members) { + const location = locations.get(member); + if (location === 'source') { + sawSource = true; + } else { + if (sawSource) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery members are not in a prefix-moved topology', + ); + } + movedCount += 1; + } + } + return { locations, movedCount }; +} + +function recoverySourcePath(databasePath: string, member: RecoveryMemberV1): string { + if (member === 'main') return databasePath; + return `${databasePath}-${member}`; +} + +function recoveryDestinationPath( + quarantineDirectory: string, + member: RecoveryMemberV1, +): string { + return join( + quarantineDirectory, + member === 'main' ? 'inventory-v1.sqlite3' : `inventory-v1.sqlite3-${member}`, + ); +} + +function renameNoOverwriteUnderLease(source: string, destination: string): void { + // Node 22.5 exposes no renameat2/RENAME_NOREPLACE binding. Under the frozen + // dedicated-directory plus all-conforming-adapter invariant, the DK6L lease + // excludes every permitted namespace writer between this check and rename. + // A deployment with any nonconforming path writer must disable quarantine. + if (pathEntryExists(destination)) { + throw new InventoryV1OpenError( + 'database-io', + `quarantine destination already exists: ${destination}`, + ); + } + renameSync(source, destination); +} + +function assertPendingUnitQuiescent( + sqlite: SqliteModuleV1, + databasePath: string, + lifecycle: InventoryV1LifecycleAdapter, +): void { + if (!pathEntryExists(databasePath)) { + throw new InventoryV1OpenError( + 'database-io', + 'pending quarantine has no complete source main and will not create a database', + ); + } + rejectOwnedFileSymlinks(databasePath); + assertOwnedUnitOwners(databasePath); + let database: DatabaseSyncV1 | null = null; + try { + database = new sqlite.DatabaseSync(databasePath); + database.exec('PRAGMA busy_timeout = 0'); + assertCorruptDatabaseQuiescent(database, lifecycle); + } catch (cause) { + if (cause instanceof InventoryV1OpenError) throw cause; + if (isBusySqliteError(cause)) { + throw new InventoryV1OpenError( + 'database-busy', + 'pending inventory quarantine still has an active SQLite holder', + { cause }, + ); + } + throw new InventoryV1OpenError( + 'database-io', + 'cannot re-prove exclusive access before resuming inventory quarantine', + { cause }, + ); + } finally { + if (database !== null) { + closeInventoryTarget(database, 'pending-quarantine-probe', lifecycle); + } + } +} + +function readBoundedRecoveryMarker(markerPath: string): string { + let descriptor: number | undefined; + let bytes: Buffer; + try { + descriptor = openSync(markerPath, 'r'); + const descriptorStat = fstatSync(descriptor); + const pathStat = lstatSync(markerPath); + if ( + !descriptorStat.isFile() + || !pathStat.isFile() + || descriptorStat.dev !== pathStat.dev + || descriptorStat.ino !== pathStat.ino + ) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery marker changed before it was read', + ); + } + if (descriptorStat.size > RECOVERY_MARKER_MAX_BYTES) { + throw new InventoryV1OpenError( + 'database-io', + `inventory recovery marker exceeds ${RECOVERY_MARKER_MAX_BYTES} bytes`, + ); + } + const boundedBuffer = Buffer.allocUnsafe(RECOVERY_MARKER_MAX_BYTES + 1); + const bytesRead = readSync( + descriptor, + boundedBuffer, + 0, + boundedBuffer.byteLength, + 0, + ); + const finalStat = fstatSync(descriptor); + if ( + bytesRead > RECOVERY_MARKER_MAX_BYTES + || bytesRead !== descriptorStat.size + || finalStat.size !== descriptorStat.size + || finalStat.mtimeMs !== descriptorStat.mtimeMs + ) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery marker changed while it was read', + ); + } + bytes = boundedBuffer.subarray(0, bytesRead); + } catch (cause) { + if (cause instanceof InventoryV1OpenError) throw cause; + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery marker could not be read safely', + { cause }, + ); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + if ( + (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) + || (bytes[0] === 0xff && bytes[1] === 0xfe) + || (bytes[0] === 0xfe && bytes[1] === 0xff) + ) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery marker must not contain a Unicode byte-order mark', + ); + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (cause) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery marker is not valid UTF-8', + { cause }, + ); + } } function parseRecoveryMarker(value: string, inventoryDirectory: string): RecoveryMarkerV1 { @@ -802,24 +1759,76 @@ function parseRecoveryMarker(value: string, inventoryDirectory: string): Recover if ( typeof parsed !== 'object' || parsed === null + || Array.isArray(parsed) + || Object.keys(parsed).length !== 3 + || !['members', 'quarantineDirectory', 'version'].every((key) => Object.hasOwn(parsed, key)) || (parsed as { version?: unknown }).version !== 1 || typeof (parsed as { quarantineDirectory?: unknown }).quarantineDirectory !== 'string' + || !Array.isArray((parsed as { members?: unknown }).members) ) { throw new InventoryV1OpenError('database-io', 'inventory recovery marker has an invalid shape'); } - const quarantineDirectory = resolve((parsed as RecoveryMarkerV1).quarantineDirectory); - const quarantineRoot = resolve(inventoryDirectory, QUARANTINE_DIRECTORY); - const relativePath = relative(quarantineRoot, quarantineDirectory); + const members = assertRecoveryMembers((parsed as { members: unknown[] }).members); + const encodedDirectory = (parsed as RecoveryMarkerV1).quarantineDirectory; + if (hasLoneUtf16Surrogate(encodedDirectory)) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery marker path contains an unpaired UTF-16 surrogate', + ); + } + const serializedMarker = JSON.stringify({ + version: 1, + quarantineDirectory: encodedDirectory, + members, + }); + if (serializedMarker !== value) { + // Markers are private crash-recovery state emitted only by beginQuarantine. + // Requiring its exact local encoding rejects duplicate textual JSON keys, + // alternate key order, insignificant whitespace, and escape aliases that + // JSON.parse alone would otherwise collapse before shape validation. + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery marker is not in the frozen local serialization', + ); + } + const quarantineRoot = join(inventoryDirectory, QUARANTINE_DIRECTORY); + const generationName = basename(encodedDirectory); + const canonicalDirectory = join(quarantineRoot, generationName); if ( - relativePath.length === 0 - || relativePath.startsWith('..') - || isAbsolute(relativePath) - || basename(relativePath) !== relativePath - || !/^inventory-v1-[0-9]+-[0-9a-f]{16}$/.test(relativePath) + !isAbsolute(encodedDirectory) + || encodedDirectory !== canonicalDirectory + || !/^inventory-v1-[0-9]+-[0-9a-f]{16}$/.test(generationName) ) { - throw new InventoryV1OpenError('unsafe-path', 'inventory recovery marker escapes the quarantine directory'); + throw new InventoryV1OpenError( + 'unsafe-path', + 'inventory recovery marker path is not the exact canonical quarantine generation path', + ); + } + return { version: 1, quarantineDirectory: canonicalDirectory, members }; +} + +function hasLoneUtf16Surrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + return true; + } } - return { version: 1, quarantineDirectory }; + return false; +} + +function assertRecoveryMembers(value: readonly unknown[]): RecoveryMembersV1 { + if (!RECOVERY_MEMBER_ARRAYS.has(JSON.stringify(value))) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory recovery marker has an invalid or reordered members manifest', + ); + } + return [...value] as unknown as RecoveryMembersV1; } function recoveryMarkerPath(databasePath: string): string { @@ -853,20 +1862,6 @@ function classifyCorruptDatabaseOwnership(databasePath: string): CorruptDatabase return identity.userVersion === INVENTORY_V1_USER_VERSION ? 'owned' : 'ambiguous'; } -function refuseValidForeignSqliteHeader(databasePath: string): void { - const identity = readValidSqliteHeaderIdentity(databasePath); - if ( - identity !== null - && identity.applicationId !== 0 - && identity.applicationId !== INVENTORY_V1_APPLICATION_ID - ) { - throw new InventoryV1OpenError( - 'foreign-database', - 'database header has a foreign application_id and will not be opened or modified', - ); - } -} - function readValidSqliteHeaderIdentity(databasePath: string): SqliteHeaderIdentityV1 | null { let descriptor: number | undefined; try { diff --git a/packages/agent/test/fixtures/rfc64-inventory-v1-child.ts b/packages/agent/test/fixtures/rfc64-inventory-v1-child.ts new file mode 100644 index 0000000000..d93df3314e --- /dev/null +++ b/packages/agent/test/fixtures/rfc64-inventory-v1-child.ts @@ -0,0 +1,168 @@ +import { + appendFileSync, + chmodSync, + existsSync, + lstatSync, + readFileSync, + readdirSync, + writeFileSync, + writeSync, +} from 'node:fs'; +import { createHash } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; +import { dirname, join, relative, resolve } from 'node:path'; + +import { + INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + InventoryV1OpenError, + createInventoryV1TestOpener, + openInventoryV1, +} from '../../src/rfc64/inventory-v1/open.js'; +import { + type InventoryV1QuarantineBoundary, +} from '../../src/rfc64/inventory-v1/lifecycle-adapter.js'; +import { INVENTORY_V1_RELATIVE_PATH } from '../../src/rfc64/inventory-v1/sql.js'; + +const mode = requiredEnvironment('DKG_RFC64_CHILD_MODE'); +const dataDirectory = requiredEnvironment('DKG_RFC64_CHILD_DATA_DIR'); + +if (mode === 'fault') { + const targetBoundary = requiredEnvironment( + 'DKG_RFC64_CHILD_BOUNDARY', + ) as InventoryV1QuarantineBoundary; + const tracePath = requiredEnvironment('DKG_RFC64_CHILD_TRACE'); + const targetPath = resolve(dataDirectory, INVENTORY_V1_RELATIVE_PATH); + const openForFault = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + boundary: (boundary) => { + appendFileSync( + tracePath, + `${JSON.stringify({ boundary, topology: snapshotTopology(dataDirectory) })}\n`, + ); + if (boundary !== targetBoundary) return; + if (process.env.DKG_RFC64_CHILD_PROTECT_EVIDENCE === '1') { + // Synthetic empty WAL/SHM fixtures are not owned by a live SQLite + // connection. Make their names stable across forced process teardown; + // the parent restores the production directory mode before recovery. + chmodSync(dirname(targetPath), 0o500); + } + writeSync(process.stdout.fd, `BOUNDARY:${boundary}\n`); + // Deliberately pause inside the real lifecycle adapter. The parent must + // terminate this process with SIGKILL; no JavaScript cleanup runs. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); + }, + closeTarget: (close, reason) => { + close(); + if ( + process.env.DKG_RFC64_CHILD_SYNTHETIC_FULL_MANIFEST !== undefined + && reason === 'automatic-schema-quarantine' + ) { + const hostile = process.env.DKG_RFC64_CHILD_SYNTHETIC_FULL_MANIFEST === 'hostile'; + writeFileSync( + `${targetPath}-wal`, + hostile + ? Buffer.from('hostile-rfc64-wal-evidence\0\u0001\u0002', 'utf8') + : Buffer.alloc(0), + ); + writeFileSync( + `${targetPath}-shm`, + hostile + ? Buffer.from('hostile-rfc64-shm-evidence\0\u0003\u0004', 'utf8') + : Buffer.alloc(0), + ); + } + }, + }); + await openForFault(dataDirectory); + throw new Error(`fault boundary was not reached: ${targetBoundary}`); +} else if (mode === 'reopen') { + const foundation = await openInventoryV1(dataDirectory, { + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + }); + foundation.close(); + process.stdout.write('OPEN\n'); +} else if (mode === 'contender') { + const targetPath = resolve(dataDirectory, INVENTORY_V1_RELATIVE_PATH); + const leasePath = join(dirname(targetPath), 'inventory-v1.lease.sqlite3'); + let directLease = 'OPEN'; + const directLeaseDatabase = new DatabaseSync(leasePath); + try { + directLeaseDatabase.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE; ROLLBACK'); + } catch (error) { + if ((error as { errcode?: unknown }).errcode === 5) directLease = 'BUSY'; + else throw error; + } finally { + directLeaseDatabase.close(); + } + const target = new DatabaseSync(targetPath); + let targetTransaction = 'BUSY'; + try { + target.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE; ROLLBACK'); + targetTransaction = 'FREE'; + } finally { + target.close(); + } + + let lease = 'OPEN'; + try { + const foundation = await openInventoryV1(dataDirectory, { + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + }); + foundation.close(); + } catch (error) { + if (error instanceof InventoryV1OpenError && error.code === 'database-busy') { + lease = 'BUSY'; + } else { + throw error; + } + } + process.stdout.write(`${JSON.stringify({ directLease, targetTransaction, lease })}\n`); +} else { + throw new Error(`unsupported RFC-64 inventory child mode: ${mode}`); +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`missing required environment variable ${name}`); + } + return value; +} + +interface TopologyEntry { + readonly path: string; + readonly kind: 'file' | 'directory' | 'other'; + readonly size: number; + readonly dev: number; + readonly ino: number; + readonly sha256?: string; +} + +function snapshotTopology(dataDir: string): TopologyEntry[] { + const database = resolve(dataDir, INVENTORY_V1_RELATIVE_PATH); + const root = dirname(database); + if (!existsSync(root)) return []; + const entries: TopologyEntry[] = []; + const visit = (path: string): void => { + const stat = lstatSync(path); + const kind = stat.isFile() ? 'file' : stat.isDirectory() ? 'directory' : 'other'; + entries.push({ + path: relative(root, path) || '.', + kind, + size: stat.size, + dev: stat.dev, + ino: stat.ino, + // POSIX advisory locks are process-associated and closing any extra FD + // for the same inode can release this process's SQLite lease locks. + // Never open/hash the live DK6L unit from the observation adapter. + ...(kind === 'file' && !path.includes('inventory-v1.lease.sqlite3') + ? { sha256: createHash('sha256').update(readFileSync(path)).digest('hex') } + : {}), + }); + if (kind === 'directory') { + for (const name of readdirSync(path).sort()) visit(join(path, name)); + } + }; + visit(root); + return entries.sort((left, right) => left.path.localeCompare(right.path)); +} diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index c8b432723f..f8fa4d8796 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -7,14 +7,20 @@ import { readFileSync, readdirSync, realpathSync, + renameSync, rmSync, statSync, symlinkSync, truncateSync, writeFileSync, } from 'node:fs'; +import { + spawn, + type ChildProcessWithoutNullStreams, +} from 'node:child_process'; +import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -24,12 +30,98 @@ import { INVENTORY_V1_DDL, INVENTORY_V1_RELATIVE_PATH, INVENTORY_V1_USER_OBJECTS, + INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, InventoryV1OpenError, normalizeInventoryV1SchemaSql, - openInventoryV1, + openInventoryV1 as openProductionInventoryV1, } from '../src/rfc64/inventory-v1/index.js'; +import { + type InventoryV1QuarantineBoundary, +} from '../src/rfc64/inventory-v1/lifecycle-adapter.js'; +import { createInventoryV1TestOpener } from '../src/rfc64/inventory-v1/open.js'; const temporaryDirectories: string[] = []; +const CHILD_FIXTURE = resolve( + import.meta.dirname, + 'fixtures/rfc64-inventory-v1-child.ts', +); +const HOSTILE_SIDECAR_BYTES = Object.freeze({ + wal: Buffer.from('hostile-rfc64-wal-evidence\0\u0001\u0002', 'utf8'), + shm: Buffer.from('hostile-rfc64-shm-evidence\0\u0003\u0004', 'utf8'), +}); + +const openInventoryV1 = (dataDirectory: string) => openProductionInventoryV1( + dataDirectory, + { quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY }, +); + +type RecoveryMember = 'wal' | 'shm' | 'main'; +type RecoveryManifest = + | readonly ['main'] + | readonly ['wal', 'main'] + | readonly ['shm', 'main'] + | readonly ['wal', 'shm', 'main']; +type EvidenceHashes = Readonly>>; +type SeededRecoveryTopology = Readonly<{ + generation: string; + hashes: EvidenceHashes; +}>; + +const FULL_RECOVERY_MANIFEST = ['wal', 'shm', 'main'] as const; +const FULL_MANIFEST_FAULT_BOUNDARIES = [ + 'begin.source.wal.file-fsync', + 'begin.source.shm.file-fsync', + 'begin.source.main.file-fsync', + 'begin.inventory-directory.fsync-after-quarantine-root', + 'begin.quarantine-root.fsync-after-generation', + 'begin.marker.write', + 'begin.marker.file-fsync', + 'begin.inventory-directory.fsync-after-marker', + 'resume.source.wal.file-fsync-after-quiescence', + 'resume.source.shm.file-fsync-after-quiescence', + 'resume.source.main.file-fsync-after-quiescence', + 'resume.member.wal.rename', + 'resume.member.wal.file-fsync', + 'resume.member.wal.generation-directory-fsync', + 'resume.member.wal.inventory-directory-fsync', + 'resume.member.shm.rename', + 'resume.member.shm.file-fsync', + 'resume.member.shm.generation-directory-fsync', + 'resume.member.shm.inventory-directory-fsync', + 'resume.member.main.rename', + 'resume.member.main.file-fsync', + 'resume.member.main.generation-directory-fsync', + 'resume.member.main.inventory-directory-fsync', + 'resume.marker.unlink', + 'resume.inventory-directory.fsync-after-marker-unlink', +] as const satisfies readonly InventoryV1QuarantineBoundary[]; + +const MOVED_PREFIX_FAULT_BOUNDARIES = FULL_RECOVERY_MANIFEST.flatMap((member) => [ + `resume.prefix.${member}.file-fsync`, + `resume.prefix.${member}.generation-directory-fsync`, + `resume.prefix.${member}.inventory-directory-fsync`, +] as const) satisfies readonly InventoryV1QuarantineBoundary[]; + +function expectedFullManifestTrace( + boundary: (typeof FULL_MANIFEST_FAULT_BOUNDARIES)[number], +): InventoryV1QuarantineBoundary[] { + const prefix = FULL_MANIFEST_FAULT_BOUNDARIES.slice( + 0, + FULL_MANIFEST_FAULT_BOUNDARIES.indexOf(boundary) + 1, + ); + const resumeStart = FULL_MANIFEST_FAULT_BOUNDARIES.indexOf( + 'resume.source.wal.file-fsync-after-quiescence', + ); + if (FULL_MANIFEST_FAULT_BOUNDARIES.indexOf(boundary) < resumeStart) { + return ['target-exclusivity-proven', ...prefix]; + } + return [ + 'target-exclusivity-proven', + ...prefix.slice(0, resumeStart), + 'target-exclusivity-proven', + ...prefix.slice(resumeStart), + ]; +} function temporaryDataDirectory(): string { // macOS exposes /var as a symlink to /private/var. Use the canonical test @@ -44,6 +136,10 @@ function databasePath(dataDirectory: string): string { return join(dataDirectory, INVENTORY_V1_RELATIVE_PATH); } +function leasePath(dataDirectory: string): string { + return join(dirname(databasePath(dataDirectory)), 'inventory-v1.lease.sqlite3'); +} + function pragmaInteger(database: DatabaseSync, pragma: string): number { const row = database.prepare(`PRAGMA ${pragma}`).get(); if (row === undefined) throw new Error(`missing PRAGMA ${pragma}`); @@ -69,6 +165,120 @@ function quarantineGenerations(path: string): string[] { return readdirSync(root).map((name) => join(root, name)); } +function recoverySourcePath(path: string, member: RecoveryMember): string { + return member === 'main' ? path : `${path}-${member}`; +} + +function recoveryDestinationPath(generation: string, member: RecoveryMember): string { + return join( + generation, + member === 'main' ? 'inventory-v1.sqlite3' : `inventory-v1.sqlite3-${member}`, + ); +} + +function createIncompatibleOwnedInventory(path: string, label: string): void { + mkdirSync(dirname(path), { recursive: true }); + const database = new DatabaseSync(path); + try { + database.exec(` + CREATE TABLE wrong_v1 (value TEXT NOT NULL); + INSERT INTO wrong_v1 VALUES ('${label}'); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 1; + `); + } finally { + database.close(); + } +} + +function sha256File(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +function sourceEvidenceHashes( + path: string, + members: RecoveryManifest, +): EvidenceHashes { + return Object.fromEntries(members.map((member) => [ + member, + sha256File(recoverySourcePath(path, member)), + ])) as EvidenceHashes; +} + +function seedPendingRecoveryTopology( + dataDirectory: string, + members: RecoveryManifest, + movedPrefix: number, + label: string, + hostileSidecars = false, +): SeededRecoveryTopology { + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + `inventory-v1-1234567890-${String(movedPrefix).padStart(16, '0')}`, + ); + mkdirSync(generation, { recursive: true }); + createIncompatibleOwnedInventory(path, label); + for (const member of members) { + if (member !== 'main') { + writeFileSync( + recoverySourcePath(path, member), + hostileSidecars ? HOSTILE_SIDECAR_BYTES[member] : Buffer.alloc(0), + ); + } + } + const hashes = sourceEvidenceHashes(path, members); + for (const [index, member] of members.entries()) { + if (index >= movedPrefix) continue; + renameSync(recoverySourcePath(path, member), recoveryDestinationPath(generation, member)); + } + writeFileSync( + `${path}.rebuild-required`, + JSON.stringify({ version: 1, quarantineDirectory: generation, members }), + ); + return { generation, hashes }; +} + +function evidenceGeneration(path: string): string { + const generations = quarantineGenerations(path).filter((generation) => + existsSync(join(generation, 'inventory-v1.sqlite3'))); + expect(generations).toHaveLength(1); + return generations[0]!; +} + +function assertRecoveredManifestEvidence( + path: string, + members: RecoveryManifest, + label: string, + expectedGeneration?: string, + expectedHashes?: EvidenceHashes, +): void { + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + const generation = evidenceGeneration(path); + if (expectedGeneration !== undefined) expect(generation).toBe(expectedGeneration); + for (const member of members) { + const destination = recoveryDestinationPath(generation, member); + expect(lstatSync(destination).isFile()).toBe(true); + if (expectedHashes?.[member] !== undefined) { + expect(sha256File(destination)).toBe(expectedHashes[member]); + } else if (member !== 'main') { + expect(readFileSync(destination)).toEqual(Buffer.alloc(0)); + } + } + if (expectedHashes === undefined) { + const evidence = new DatabaseSync( + recoveryDestinationPath(generation, 'main'), + { readOnly: true }, + ); + try { + expect(evidence.prepare('SELECT value FROM wrong_v1').get()?.value).toBe(label); + } finally { + evidence.close(); + } + } +} + function assertInitializedInventory(path: string): void { const database = new DatabaseSync(path, { readOnly: true }); try { @@ -91,13 +301,206 @@ function assertInitializedInventory(path: string): void { } } +async function startLeaseHolder(path: string): Promise { + const script = String.raw` +const { DatabaseSync } = require('node:sqlite'); +const database = new DatabaseSync(process.env.DKG_RFC64_LEASE_PATH); +database.exec('PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE'); +process.stdout.write('READY\n'); +setInterval(() => {}, 60_000); +`; + const child = spawn(process.execPath, ['--experimental-sqlite', '-e', script], { + env: { ...process.env, DKG_RFC64_LEASE_PATH: path }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + await new Promise((resolveReady, rejectReady) => { + let stdout = ''; + let stderr = ''; + const timeout = setTimeout(() => { + rejectReady(new Error(`lease holder did not become ready: ${stderr}`)); + }, 10_000); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + if (!stdout.includes('READY\n')) return; + clearTimeout(timeout); + resolveReady(); + }); + child.once('error', (error) => { + clearTimeout(timeout); + rejectReady(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + rejectReady(new Error(`lease holder exited before ready: code=${code} signal=${signal} stderr=${stderr}`)); + }); + }); + return child; +} + +async function killLeaseHolder(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGKILL'); + await new Promise((resolveExit) => child.once('exit', () => resolveExit())); +} + +interface ChildResult { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + readonly stdout: string; + readonly stderr: string; +} + +function spawnInventoryChild( + mode: 'fault' | 'reopen' | 'contender', + dataDirectory: string, + extraEnvironment: Readonly> = {}, +): ChildProcessWithoutNullStreams { + return spawn( + process.execPath, + ['--experimental-sqlite', '--import', 'tsx', CHILD_FIXTURE], + { + cwd: resolve(import.meta.dirname, '../../..'), + env: { + ...process.env, + NODE_ENV: 'test', + DKG_RFC64_CHILD_MODE: mode, + DKG_RFC64_CHILD_DATA_DIR: dataDirectory, + ...extraEnvironment, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); +} + +async function collectChild(child: ChildProcessWithoutNullStreams): Promise { + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + return await new Promise((resolveResult, rejectResult) => { + child.once('error', rejectResult); + child.once('exit', (code, signal) => resolveResult({ code, signal, stdout, stderr })); + }); +} + +async function waitForChildBoundary( + child: ChildProcessWithoutNullStreams, + boundary: InventoryV1QuarantineBoundary, +): Promise { + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + let stdout = ''; + let stderr = ''; + await new Promise((resolveBoundary, rejectBoundary) => { + const timeout = setTimeout(() => { + rejectBoundary(new Error( + `child did not reach ${boundary}; stdout=${stdout} stderr=${stderr}`, + )); + }, 15_000); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + if (!stdout.includes(`BOUNDARY:${boundary}\n`)) return; + clearTimeout(timeout); + resolveBoundary(); + }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + child.once('error', (error) => { + clearTimeout(timeout); + rejectBoundary(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + rejectBoundary(new Error( + `child exited before ${boundary}: code=${code} signal=${signal} stderr=${stderr}`, + )); + }); + }); +} + +async function killAtInventoryBoundary( + dataDirectory: string, + boundary: InventoryV1QuarantineBoundary, + tracePath: string, + extraEnvironment: Readonly> = {}, +): Promise> { + const child = spawnInventoryChild('fault', dataDirectory, { + DKG_RFC64_CHILD_BOUNDARY: boundary, + DKG_RFC64_CHILD_TRACE: tracePath, + ...extraEnvironment, + }); + await waitForChildBoundary(child, boundary); + const exitResult = new Promise((resolveResult, rejectResult) => { + let stderr = ''; + child.stderr.on('data', (chunk: Buffer | string) => { stderr += String(chunk); }); + child.once('error', rejectResult); + child.once('exit', (code, signal) => resolveResult({ code, signal, stdout: '', stderr })); + }); + child.kill('SIGKILL'); + const result = await exitResult; + expect(result.code).toBeNull(); + expect(result.signal).toBe('SIGKILL'); + if (extraEnvironment.DKG_RFC64_CHILD_PROTECT_EVIDENCE === '1') { + chmodSync(dirname(databasePath(dataDirectory)), 0o700); + } + const trace = readFileSync(tracePath, 'utf8').trim().split('\n').filter(Boolean) + .map((line) => JSON.parse(line) as { + boundary: InventoryV1QuarantineBoundary; + topology: unknown[]; + }); + expect(trace.at(-1)?.boundary).toBe(boundary); + expect(trace.every((entry) => entry.topology.length > 0)).toBe(true); + return trace; +} + +async function reopenInventoryInFreshProcess(dataDirectory: string): Promise { + const result = await collectChild(spawnInventoryChild('reopen', dataDirectory)); + expect(result, result.stderr).toMatchObject({ code: 0, signal: null }); + expect(result.stdout).toContain('OPEN\n'); +} + +async function runContender(dataDirectory: string): Promise<{ + directLease: 'OPEN' | 'BUSY'; + targetTransaction: 'FREE' | 'BUSY'; + lease: 'OPEN' | 'BUSY'; +}> { + const result = await collectChild(spawnInventoryChild('contender', dataDirectory)); + expect(result, result.stderr).toMatchObject({ code: 0, signal: null }); + return JSON.parse(result.stdout.trim()) as { + directLease: 'OPEN' | 'BUSY'; + targetTransaction: 'FREE' | 'BUSY'; + lease: 'OPEN' | 'BUSY'; + }; +} + +interface FileOracle { + readonly dev: number; + readonly ino: number; + readonly size: number; + readonly sha256: string; +} + +function fileOracle(path: string): FileOracle { + const stat = lstatSync(path); + return { + dev: stat.dev, + ino: stat.ino, + size: stat.size, + sha256: createHash('sha256').update(readFileSync(path)).digest('hex'), + }; +} + afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); } }); -describe('RFC-64 inventory v1 SQLite lifecycle', () => { +describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecycle', () => { it('initializes the exact owned schema, identity, WAL mode, and private POSIX paths', async () => { const dataDirectory = temporaryDataDirectory(); const foundation = await openInventoryV1(dataDirectory); @@ -106,9 +509,14 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { expect(foundation.databasePath).toBe(path); expect(foundation.closed).toBe(false); assertInitializedInventory(path); + const committedHeader = readFileSync(path).subarray(0, 100); + expect(committedHeader.subarray(0, 16).toString('binary')).toBe('SQLite format 3\u0000'); + expect(committedHeader.readUInt32BE(68)).toBe(INVENTORY_V1_APPLICATION_ID); + expect(committedHeader.readUInt32BE(60)).toBe(1); if (process.platform !== 'win32') { expect(statSync(dirname(path)).mode & 0o777).toBe(0o700); expect(statSync(path).mode & 0o777).toBe(0o600); + expect(statSync(leasePath(dataDirectory)).mode & 0o777).toBe(0o600); } foundation.close(); @@ -128,6 +536,221 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { } }); + it('initializes the exact DK6L lease identity and holds it for the foundation lifetime', async () => { + const dataDirectory = temporaryDataDirectory(); + const foundation = await openInventoryV1(dataDirectory); + try { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-busy')); + } finally { + foundation.close(); + } + + const lease = new DatabaseSync(leasePath(dataDirectory)); + try { + expect(pragmaInteger(lease, 'application_id')).toBe(0x444b364c); + expect(pragmaInteger(lease, 'user_version')).toBe(1); + expect(String(lease.prepare('PRAGMA journal_mode').get()?.journal_mode).toLowerCase()).toBe('delete'); + expect(lease.prepare( + `SELECT count(*) AS count FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'`, + ).get()?.count).toBe(0); + } finally { + lease.close(); + } + + const reopened = await openInventoryV1(dataDirectory); + reopened.close(); + }); + + it('retains DK6L in fail-stop when the owned target cannot close', async () => { + const dataDirectory = temporaryDataDirectory(); + const openWithCloseFailure = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + closeTarget: (_close, reason) => { + if (reason === 'foundation-close') throw new Error('injected target close failure'); + _close(); + }, + }); + const foundation = await openWithCloseFailure(dataDirectory); + + expect(() => foundation.close()).toThrowError( + expect.objectContaining({ code: 'database-io' }), + ); + expect(foundation.closed).toBe(false); + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-busy')); + }); + + it('closes a corrupt owned target before reporting unavailable durability', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const initialized = await openInventoryV1(dataDirectory); + initialized.close(); + const seed = new DatabaseSync(path); + const zeroU64 = Buffer.alloc(8); + const oneU64 = Buffer.from([0, 0, 0, 0, 0, 0, 0, 1]); + const sixteenU64 = Buffer.from([0, 0, 0, 0, 0, 0, 0, 16]); + const chunkSizeU64 = Buffer.from([0, 0, 0, 0, 0, 4, 0, 0]); + const session = Buffer.alloc(32, 1); + const scope = Buffer.alloc(32, 2); + const author = Buffer.alloc(20, 3); + const head = Buffer.alloc(32, 4); + seed.prepare('INSERT INTO rfc64_candidate_bucket_loads_v1 VALUES (?,?,?,?,?,?,?,?,?,?,?)') + .run( + session, scope, author, head, null, zeroU64, oneU64, zeroU64, + Buffer.alloc(32), zeroU64, zeroU64, + ); + seed.prepare('INSERT INTO rfc64_candidate_bucket_rows_v1 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + .run( + session, scope, author, head, zeroU64, Buffer.alloc(32, 5), + Buffer.alloc(32, 6), 'coordinate', zeroU64, 'cg-shared-v1', + Buffer.alloc(32, 7), Buffer.alloc(32, 8), 'dkg-ka-bundle-v1', + sixteenU64, chunkSizeU64, oneU64, Buffer.alloc(32, 9), + Buffer.alloc(32, 10), Buffer.alloc(32, 11), + ); + const pageSize = pragmaInteger(seed, 'page_size'); + const rowRootPage = seed.prepare( + "SELECT rootpage FROM sqlite_schema WHERE name = 'rfc64_candidate_bucket_rows_v1'", + ).get()?.rootpage; + expect(typeof rowRootPage).toBe('number'); + seed.close(); + const corruptBytes = readFileSync(path); + corruptBytes[(Number(rowRootPage) - 1) * pageSize] = 0xff; + writeFileSync(path, corruptBytes); + const closeReasons: string[] = []; + const openWithoutDurability = createInventoryV1TestOpener({ + quarantineCapability: null, + closeTarget: (close, reason) => { + close(); + closeReasons.push(reason); + }, + }); + + await expect(openWithoutDurability(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + expect(closeReasons).toContain('automatic-corrupt-quarantine'); + await expect(openWithoutDurability(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + }); + + it('keeps the test seam and legacy durability override inert outside current test mode', async () => { + const previousNodeEnvironment = process.env.NODE_ENV; + const overrideName = 'DKG_RFC64_TEST_DISABLE_QUARANTINE_DURABILITY'; + const previousOverride = process.env[overrideName]; + const unopenedDataDirectory = temporaryDataDirectory(); + let closeHookCalls = 0; + const guardedOpen = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + closeTarget: () => { closeHookCalls += 1; }, + }); + const openForClose = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + closeTarget: (_close) => { + closeHookCalls += 1; + throw new Error('test close hook must be inert'); + }, + }); + const closeFoundation = await openForClose(temporaryDataDirectory()); + try { + process.env.NODE_ENV = 'production'; + process.env[overrideName] = '1'; + expect(() => createInventoryV1TestOpener()).toThrowError( + expect.objectContaining({ code: 'database-io' }), + ); + await expect(guardedOpen(unopenedDataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'database-io'), + ); + expect(existsSync(dirname(databasePath(unopenedDataDirectory)))).toBe(false); + + closeFoundation.close(); + expect(closeHookCalls).toBe(0); + + const productionDataDirectory = temporaryDataDirectory(); + const productionPath = databasePath(productionDataDirectory); + createIncompatibleOwnedInventory(productionPath, 'production-override-inert'); + const productionFoundation = await openProductionInventoryV1( + productionDataDirectory, + { quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY }, + ); + productionFoundation.close(); + assertInitializedInventory(productionPath); + } finally { + if (previousNodeEnvironment === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnvironment; + if (previousOverride === undefined) delete process.env[overrideName]; + else process.env[overrideName] = previousOverride; + } + }); + + it('releases the OS-backed lease when a holder process is killed', async () => { + const dataDirectory = temporaryDataDirectory(); + const initialized = await openInventoryV1(dataDirectory); + initialized.close(); + const child = await startLeaseHolder(leasePath(dataDirectory)); + try { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-busy')); + } finally { + await killLeaseHolder(child); + } + + const reopened = await openInventoryV1(dataDirectory); + reopened.close(); + }); + + it('fails closed on foreign, newer, and corrupt lease identities without quarantine', async () => { + for (const kind of ['foreign', 'newer', 'corrupt'] as const) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const leaseFile = leasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const lease = new DatabaseSync(leaseFile); + lease.exec(kind === 'foreign' + ? 'PRAGMA application_id = 305419896; PRAGMA user_version = 1;' + : `PRAGMA application_id = ${0x444b364c}; PRAGMA user_version = ${kind === 'newer' ? 2 : 1};`); + lease.close(); + if (kind === 'corrupt') truncateSync(leaseFile, 100); + const before = readFileSync(leaseFile); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode( + error, + kind === 'foreign' ? 'foreign-database' : kind === 'newer' ? 'newer-schema' : 'ambiguous-database', + )); + expect(readFileSync(leaseFile)).toEqual(before); + expect(existsSync(path)).toBe(false); + expectNoQuarantine(path); + } + }); + + it('never initializes a pre-existing headerless or zero-zero lease remnant', async () => { + for (const kind of ['empty', 'zero-zero'] as const) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const leaseFile = leasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + if (kind === 'empty') { + writeFileSync(leaseFile, Buffer.alloc(0)); + } else { + const lease = new DatabaseSync(leaseFile); + lease.exec('VACUUM'); + lease.close(); + } + const before = readFileSync(leaseFile); + const beforeMode = statSync(leaseFile).mode & 0o777; + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + + expect(readFileSync(leaseFile)).toEqual(before); + if (process.platform !== 'win32') expect(statSync(leaseFile).mode & 0o777).toBe(beforeMode); + expect(existsSync(path)).toBe(false); + expectNoQuarantine(path); + } + }); + it('initializes beneath a previously nonexistent declared dataDir suffix', async () => { const container = temporaryDataDirectory(); const dataDirectory = join(container, 'new-data', 'nested'); @@ -268,6 +891,31 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { } }); + it('fails closed before automatic quarantine when namespace durability is unavailable', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + const incompatible = new DatabaseSync(path); + incompatible.exec(` + CREATE TABLE wrong_v1 (value TEXT); + INSERT INTO wrong_v1 VALUES ('must-survive'); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 1; + `); + incompatible.close(); + const before = readFileSync(path); + + await expect(openProductionInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + + expect(readFileSync(path)).toEqual(before); + expectNoQuarantine(path); + const check = new DatabaseSync(path, { readOnly: true }); + expect(check.prepare('SELECT value FROM wrong_v1').get()?.value).toBe('must-survive'); + check.close(); + }); + it('refuses NOTADB bytes without manufacturing DK64 ownership or quarantine', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -281,6 +929,28 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { expectNoQuarantine(path); }); + it('never initializes a pre-existing empty or zero-zero target remnant', async () => { + for (const kind of ['empty', 'zero-zero'] as const) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true }); + if (kind === 'empty') { + writeFileSync(path, Buffer.alloc(0)); + } else { + const target = new DatabaseSync(path); + target.exec('VACUUM'); + target.close(); + } + const before = readFileSync(path); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + + expect(readFileSync(path)).toEqual(before); + expectNoQuarantine(path); + } + }); + it('refuses a corrupt application_id=0 database with user data as ambiguous', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -438,15 +1108,32 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { }, ); - it('refuses orphaned sidecars without deleting them', async () => { - const dataDirectory = temporaryDataDirectory(); - const path = databasePath(dataDirectory); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(`${path}-wal`, 'orphan'); - await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => - expectOpenErrorCode(error, 'ambiguous-database')); - expect(readFileSync(`${path}-wal`, 'utf8')).toBe('orphan'); - expectNoQuarantine(path); + it('refuses every orphan target and lease sidecar without mutating the unit', async () => { + for (const unit of ['target', 'lease'] as const) { + for (const suffix of ['-journal', '-wal', '-shm'] as const) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const mainPath = unit === 'target' ? path : leasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') chmodSync(dirname(path), 0o700); + const sidecarPath = `${mainPath}${suffix}`; + const evidence = Buffer.from(`orphan-${unit}${suffix}-evidence`); + writeFileSync(sidecarPath, evidence); + const beforeMode = statSync(sidecarPath).mode & 0o777; + const beforeEntries = readdirSync(dirname(path)).sort(); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'ambiguous-database')); + + expect(existsSync(mainPath)).toBe(false); + expect(readFileSync(sidecarPath)).toEqual(evidence); + expect(statSync(sidecarPath).mode & 0o777).toBe(beforeMode); + expect(readdirSync(dirname(path)).sort()).toEqual(beforeEntries); + expect(existsSync(path)).toBe(false); + expect(existsSync(leasePath(dataDirectory))).toBe(false); + expectNoQuarantine(path); + } + } }); it.runIf(process.platform !== 'win32' && process.getuid?.() !== 0)( @@ -473,12 +1160,16 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); mkdirSync(dirname(path), { recursive: true }); - const holder = new DatabaseSync(path); - holder.exec(` - PRAGMA journal_mode = WAL; + const seed = new DatabaseSync(path); + seed.exec(` CREATE TABLE wrong_v1 (value TEXT); PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; PRAGMA user_version = 1; + `); + seed.close(); + const holder = new DatabaseSync(path); + holder.exec(` + PRAGMA journal_mode = WAL; BEGIN IMMEDIATE; `); try { @@ -523,6 +1214,431 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { } }); + it('rejects recovery markers with unknown keys or a non-frozen members manifest', async () => { + const invalidMarkers: unknown[] = [ + { version: 1, quarantineDirectory: 'placeholder' }, + { version: 1, quarantineDirectory: 'placeholder', members: ['main'], extra: true }, + { version: 1, quarantineDirectory: 'placeholder', members: ['main', 'wal'] }, + { version: 1, quarantineDirectory: 'placeholder', members: ['wal', 'wal', 'main'] }, + { version: 1, quarantineDirectory: 'placeholder', members: ['unknown', 'main'] }, + ]; + + for (let index = 0; index < invalidMarkers.length; index += 1) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, `invalid-shape-${index}`); + const sourceBefore = fileOracle(path); + const generation = join( + dirname(path), + 'quarantine', + `inventory-v1-1234567890-${String(index).padStart(16, 'a')}`, + ); + mkdirSync(generation, { recursive: true }); + const markerObject = invalidMarkers[index] as Record; + markerObject.quarantineDirectory = generation; + const marker = JSON.stringify(markerObject); + writeFileSync(`${path}.rebuild-required`, marker); + const inventoryDirectory = dirname(path); + const directoryModeBefore = statSync(inventoryDirectory).mode; + const entriesBefore = readdirSync(inventoryDirectory).sort(); + const reached: InventoryV1QuarantineBoundary[] = []; + const openMalformedMarker = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + boundary: (boundary) => reached.push(boundary), + }); + + await expect(openMalformedMarker(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + + expect(reached).toEqual([]); + expect(readFileSync(`${path}.rebuild-required`, 'utf8')).toBe(marker); + expect(fileOracle(path)).toEqual(sourceBefore); + expect(readdirSync(generation)).toEqual([]); + expect(statSync(inventoryDirectory).mode).toBe(directoryModeBefore); + expect(readdirSync(inventoryDirectory).sort()).toEqual(entriesBefore); + expect(existsSync(leasePath(dataDirectory))).toBe(false); + } + }); + + it('rejects duplicate-key and non-canonical recovery marker encodings without mutation', async () => { + for (const kind of ['duplicate-version', 'duplicate-directory', 'duplicate-members', 'whitespace', 'reordered'] as const) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, `invalid-encoding-${kind}`); + const sourceBefore = fileOracle(path); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-cacacacacacacaca', + ); + mkdirSync(generation, { recursive: true }); + const encodedGeneration = JSON.stringify(generation); + const marker = kind === 'duplicate-version' + ? `{"version":1,"version":1,"quarantineDirectory":${encodedGeneration},"members":["main"]}` + : kind === 'duplicate-directory' + ? `{"version":1,"quarantineDirectory":${encodedGeneration},"quarantineDirectory":${encodedGeneration},"members":["main"]}` + : kind === 'duplicate-members' + ? `{"version":1,"quarantineDirectory":${encodedGeneration},"members":["main"],"members":["main"]}` + : kind === 'whitespace' + ? `{"version":1, "quarantineDirectory":${encodedGeneration},"members":["main"]}` + : `{"members":["main"],"quarantineDirectory":${encodedGeneration},"version":1}`; + writeFileSync(`${path}.rebuild-required`, marker); + const inventoryDirectory = dirname(path); + const directoryModeBefore = statSync(inventoryDirectory).mode; + const entriesBefore = readdirSync(inventoryDirectory).sort(); + const reached: InventoryV1QuarantineBoundary[] = []; + const openMalformedMarker = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + boundary: (boundary) => reached.push(boundary), + }); + + await expect(openMalformedMarker(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + + expect(reached).toEqual([]); + expect(readFileSync(`${path}.rebuild-required`, 'utf8')).toBe(marker); + expect(fileOracle(path)).toEqual(sourceBefore); + expect(readdirSync(generation)).toEqual([]); + expect(statSync(inventoryDirectory).mode).toBe(directoryModeBefore); + expect(readdirSync(inventoryDirectory).sort()).toEqual(entriesBefore); + expect(existsSync(leasePath(dataDirectory))).toBe(false); + } + }); + + it('bounds and strictly decodes recovery markers and rejects every path alias before exclusivity or movement', async () => { + const variants = [ + 'cap-plus-one', + 'invalid-utf8', + 'utf8-bom', + 'relative', + 'normalized-alias', + 'trailing-separator', + 'escape', + 'lone-surrogate', + ] as const; + for (const [index, variant] of variants.entries()) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, `marker-${variant}`); + const sourceBefore = fileOracle(path); + const generationName = `inventory-v1-1234567890-${String(index).padStart(16, 'a')}`; + const quarantineRoot = join(dirname(path), 'quarantine'); + const generation = join(quarantineRoot, generationName); + mkdirSync(generation, { recursive: true }); + const canonical = JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['main'], + }); + const markerBytes = variant === 'cap-plus-one' + ? Buffer.alloc(4_097, 0x20) + : variant === 'invalid-utf8' + ? Buffer.from([0x7b, 0x22, 0xff, 0x22, 0x7d]) + : variant === 'utf8-bom' + ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(canonical)]) + : Buffer.from(variant === 'lone-surrogate' + ? `{"version":1,"quarantineDirectory":"\\ud800","members":["main"]}` + : JSON.stringify({ + version: 1, + quarantineDirectory: variant === 'relative' + ? join('quarantine', generationName) + : variant === 'normalized-alias' + ? `${quarantineRoot}/nested/../${generationName}` + : variant === 'trailing-separator' + ? `${generation}/` + : join(quarantineRoot, '..', generationName), + members: ['main'], + })); + writeFileSync(`${path}.rebuild-required`, markerBytes); + const inventoryDirectory = dirname(path); + const directoryModeBefore = statSync(inventoryDirectory).mode; + const entriesBefore = readdirSync(inventoryDirectory).sort(); + let exclusivityBoundaries = 0; + const openForMarkerTest = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + boundary: (boundary) => { + if (boundary === 'target-exclusivity-proven') exclusivityBoundaries += 1; + }, + }); + + await expect( + openForMarkerTest(dataDirectory), + ).rejects.toBeInstanceOf(InventoryV1OpenError); + expect(exclusivityBoundaries).toBe(0); + expect(fileOracle(path)).toEqual(sourceBefore); + expect(readFileSync(`${path}.rebuild-required`)).toEqual(markerBytes); + expect(readdirSync(generation)).toEqual([]); + expect(statSync(inventoryDirectory).mode).toBe(directoryModeBefore); + expect(readdirSync(inventoryDirectory).sort()).toEqual(entriesBefore); + expect(existsSync(leasePath(dataDirectory))).toBe(false); + } + }); + + it('round-trips a canonical marker beneath a hostile but valid data-directory name', async () => { + const container = temporaryDataDirectory(); + const dataDirectory = join(container, 'hostile [] ž\n☃'); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, 'hostile-data-directory'); + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + assertRecoveredManifestEvidence(path, ['main'], 'hostile-data-directory'); + } finally { + foundation.close(); + } + }); + + it('resumes a prefix-moved manifest without reopening the partial SQLite source', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-eeeeeeeeeeeeeeee', + ); + mkdirSync(generation, { recursive: true }); + writeFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'wal-moved-before-crash'); + writeFileSync(`${path}-shm`, 'shm-still-at-source'); + // This is intentionally not SQLite. Because WAL already moved, reopening + // or checkpointing the partial source would fail; manifest resume must + // treat the remaining bytes as evidence and continue under the lease. + writeFileSync(path, 'not-a-sqlite-main-but-marker-owned'); + writeFileSync( + `${path}.rebuild-required`, + JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['wal', 'shm', 'main'], + }), + ); + + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'utf8')).toBe( + 'wal-moved-before-crash', + ); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3-shm'), 'utf8')).toBe( + 'shm-still-at-source', + ); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe( + 'not-a-sqlite-main-but-marker-owned', + ); + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + } finally { + foundation.close(); + } + }); + + it('finishes an all-destination crash topology without recreating or reopening the source', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-abababababababab', + ); + mkdirSync(generation, { recursive: true }); + writeFileSync(join(generation, 'inventory-v1.sqlite3'), 'main-moved-before-crash'); + writeFileSync( + `${path}.rebuild-required`, + JSON.stringify({ version: 1, quarantineDirectory: generation, members: ['main'] }), + ); + + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe( + 'main-moved-before-crash', + ); + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + } finally { + foundation.close(); + } + }); + + it('fails closed on a reordered partial recovery topology without moving evidence', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-ffffffffffffffff', + ); + mkdirSync(generation, { recursive: true }); + writeFileSync(`${path}-wal`, 'wal-source'); + writeFileSync(join(generation, 'inventory-v1.sqlite3-shm'), 'shm-destination'); + writeFileSync(path, 'main-source'); + const marker = JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['wal', 'shm', 'main'], + }); + writeFileSync(`${path}.rebuild-required`, marker); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + + expect(readFileSync(`${path}-wal`, 'utf8')).toBe('wal-source'); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3-shm'), 'utf8')).toBe( + 'shm-destination', + ); + expect(readFileSync(path, 'utf8')).toBe('main-source'); + expect(readFileSync(`${path}.rebuild-required`, 'utf8')).toBe(marker); + }); + + it('fails closed on duplicate, missing, unlisted, or unknown recovery evidence', async () => { + for (const kind of ['duplicate', 'missing', 'unlisted', 'unknown-entry'] as const) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + `inventory-v1-1234567890-${kind === 'duplicate' ? '1111111111111111' + : kind === 'missing' ? '2222222222222222' + : kind === 'unlisted' ? '3333333333333333' : '4444444444444444'}`, + ); + mkdirSync(generation, { recursive: true }); + if (kind !== 'missing') writeFileSync(path, 'source-main-evidence'); + if (kind === 'duplicate') { + writeFileSync(join(generation, 'inventory-v1.sqlite3'), 'destination-main-evidence'); + } + if (kind === 'unlisted') writeFileSync(`${path}-wal`, 'unlisted-wal-evidence'); + if (kind === 'unknown-entry') writeFileSync(join(generation, 'unexpected-file'), 'unknown'); + const marker = JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['main'], + }); + writeFileSync(`${path}.rebuild-required`, marker); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + + expect(readFileSync(`${path}.rebuild-required`, 'utf8')).toBe(marker); + if (kind !== 'missing') expect(readFileSync(path, 'utf8')).toBe('source-main-evidence'); + if (kind === 'duplicate') { + expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe( + 'destination-main-evidence', + ); + } + if (kind === 'unlisted') { + expect(readFileSync(`${path}-wal`, 'utf8')).toBe('unlisted-wal-evidence'); + } + if (kind === 'unknown-entry') { + expect(readFileSync(join(generation, 'unexpected-file'), 'utf8')).toBe('unknown'); + } + } + }); + + it('exhaustively rejects every non-prefix source/destination/missing/duplicate manifest topology without mutation', async () => { + const manifests = [ + ['main'], + ['wal', 'main'], + ['shm', 'main'], + ['wal', 'shm', 'main'], + ] as const satisfies readonly RecoveryManifest[]; + const locations = ['source', 'destination', 'missing', 'both'] as const; + for (const members of manifests) { + let combinations: Array> = [[]]; + for (let index = 0; index < members.length; index += 1) { + combinations = combinations.flatMap((row) => + locations.map((location) => [...row, location])); + } + for (const [caseIndex, topology] of combinations.entries()) { + const isValidPrefix = topology.every((location) => + location === 'source' || location === 'destination') + && !topology.some((location, index) => + location === 'destination' && topology.slice(0, index).includes('source')); + if (isValidPrefix) continue; + + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + `inventory-v1-1234567890-${caseIndex.toString(16).padStart(16, '0')}`, + ); + mkdirSync(generation, { recursive: true }); + const evidencePaths: string[] = []; + for (const [index, member] of members.entries()) { + const location = topology[index]!; + const source = recoverySourcePath(path, member); + const destination = recoveryDestinationPath(generation, member); + if (location === 'source' || location === 'both') { + writeFileSync(source, `source-${member}-${caseIndex}`); + evidencePaths.push(source); + } + if (location === 'destination' || location === 'both') { + writeFileSync(destination, `destination-${member}-${caseIndex}`); + evidencePaths.push(destination); + } + } + const marker = Buffer.from(JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members, + })); + writeFileSync(`${path}.rebuild-required`, marker); + const before = new Map(evidencePaths.map((evidence) => [evidence, fileOracle(evidence)])); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'database-io'), + ); + + expect(readFileSync(`${path}.rebuild-required`)).toEqual(marker); + for (const evidence of evidencePaths) { + expect(fileOracle(evidence)).toEqual(before.get(evidence)); + } + } + } + }, 60_000); + + it('re-probes a zero-move marker and refuses an active raw SQLite holder', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-9999999999999999', + ); + mkdirSync(generation, { recursive: true }); + const seed = new DatabaseSync(path); + seed.exec(` + CREATE TABLE wrong_v1 (value TEXT); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 1; + `); + seed.close(); + const marker = JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['main'], + }); + writeFileSync(`${path}.rebuild-required`, marker); + const holder = new DatabaseSync(path); + holder.exec('BEGIN IMMEDIATE'); + try { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-busy')); + expect(existsSync(path)).toBe(true); + expect(readdirSync(generation)).toEqual([]); + expect(readFileSync(`${path}.rebuild-required`, 'utf8')).toBe(marker); + } finally { + holder.exec('ROLLBACK'); + holder.close(); + } + + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + expect(existsSync(join(generation, 'inventory-v1.sqlite3'))).toBe(true); + } finally { + foundation.close(); + } + }); + it('resumes a partial recovery-marker move before rebuilding', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -533,26 +1649,108 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { 'inventory-v1-1234567890-aaaaaaaaaaaaaaaa', ); mkdirSync(generation, { recursive: true }); - writeFileSync(join(generation, 'inventory-v1.sqlite3'), 'main-before-crash'); - writeFileSync(`${path}-wal`, 'wal-before-crash'); - writeFileSync(`${path}-shm`, 'shm-before-crash'); + const incompatible = new DatabaseSync(path); + incompatible.exec(` + CREATE TABLE wrong_v1 (value TEXT); + INSERT INTO wrong_v1 VALUES ('main-before-crash'); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 1; + `); + incompatible.close(); + // A supported crash after both sidecars moved leaves the valid source + // main in place. Resume must prove that main quiescent, move it last, and + // then rebuild a fresh inventory at the source pathname. + writeFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'wal-before-crash'); + writeFileSync(join(generation, 'inventory-v1.sqlite3-shm'), 'shm-before-crash'); writeFileSync( `${path}.rebuild-required`, - JSON.stringify({ version: 1, quarantineDirectory: generation }), + JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['wal', 'shm', 'main'], + }), ); const foundation = await openInventoryV1(dataDirectory); try { assertInitializedInventory(path); - expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe('main-before-crash'); expect(readFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'utf8')).toBe('wal-before-crash'); expect(readFileSync(join(generation, 'inventory-v1.sqlite3-shm'), 'utf8')).toBe('shm-before-crash'); expect(existsSync(`${path}.rebuild-required`)).toBe(false); + // Remove the synthetic sidecar evidence only after verifying it, so the + // quarantined main can be inspected independently. + rmSync(join(generation, 'inventory-v1.sqlite3-wal')); + rmSync(join(generation, 'inventory-v1.sqlite3-shm')); + const old = new DatabaseSync(join(generation, 'inventory-v1.sqlite3'), { readOnly: true }); + expect(old.prepare('SELECT value FROM wrong_v1').get()?.value).toBe('main-before-crash'); + old.close(); } finally { foundation.close(); } }); + it('refuses a pending recovery unit with sidecars but no main without creating a source database', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-dddddddddddddddd', + ); + mkdirSync(generation, { recursive: true }); + writeFileSync(`${path}-wal`, 'orphaned-wal-evidence'); + const marker = JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['wal', 'main'], + }); + writeFileSync(`${path}.rebuild-required`, marker); + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => + expectOpenErrorCode(error, 'database-io')); + + expect(existsSync(path)).toBe(false); + expect(readFileSync(`${path}-wal`, 'utf8')).toBe('orphaned-wal-evidence'); + expect(readFileSync(`${path}.rebuild-required`, 'utf8')).toBe(marker); + expect(readdirSync(generation)).toEqual([]); + }); + + it('leaves a pending marker and every evidence byte untouched when durability is unavailable', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const inventoryDirectory = dirname(path); + const generation = join( + inventoryDirectory, + 'quarantine', + 'inventory-v1-1234567890-cccccccccccccccc', + ); + mkdirSync(generation, { recursive: true }); + writeFileSync(path, 'main-before-resume'); + writeFileSync(`${path}-wal`, 'wal-before-resume'); + writeFileSync(join(generation, 'inventory-v1.sqlite3-shm'), 'destination-before-resume'); + const marker = JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['wal', 'shm', 'main'], + }); + writeFileSync(`${path}.rebuild-required`, marker); + const before = { + main: readFileSync(path), + wal: readFileSync(`${path}-wal`), + destination: readFileSync(join(generation, 'inventory-v1.sqlite3-shm')), + marker: readFileSync(`${path}.rebuild-required`), + }; + + await expect(openProductionInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + + expect(readFileSync(path)).toEqual(before.main); + expect(readFileSync(`${path}-wal`)).toEqual(before.wal); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3-shm'))).toEqual(before.destination); + expect(readFileSync(`${path}.rebuild-required`)).toEqual(before.marker); + }); + it('retains the recovery marker across an interrupted evidence move and resumes', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -563,32 +1761,254 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { 'inventory-v1-1234567890-bbbbbbbbbbbbbbbb', ); mkdirSync(generation, { recursive: true }); - writeFileSync(path, 'main-before-crash'); - writeFileSync(`${path}-wal`, 'wal-before-crash'); - writeFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'conflicting-target'); + const incompatible = new DatabaseSync(path); + incompatible.exec(` + CREATE TABLE wrong_v1 (value TEXT); + INSERT INTO wrong_v1 VALUES ('main-before-crash'); + PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; + PRAGMA user_version = 1; + `); + incompatible.close(); + writeFileSync(join(generation, 'inventory-v1.sqlite3'), 'conflicting-target'); writeFileSync( `${path}.rebuild-required`, - JSON.stringify({ version: 1, quarantineDirectory: generation }), + JSON.stringify({ version: 1, quarantineDirectory: generation, members: ['main'] }), ); await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => expectOpenErrorCode(error, 'database-io')); expect(existsSync(`${path}.rebuild-required`)).toBe(true); - expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe('main-before-crash'); - expect(readFileSync(`${path}-wal`, 'utf8')).toBe('wal-before-crash'); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe('conflicting-target'); + const source = new DatabaseSync(path, { readOnly: true }); + expect(source.prepare('SELECT value FROM wrong_v1').get()?.value).toBe('main-before-crash'); + source.close(); - rmSync(join(generation, 'inventory-v1.sqlite3-wal')); + rmSync(join(generation, 'inventory-v1.sqlite3')); const foundation = await openInventoryV1(dataDirectory); try { assertInitializedInventory(path); expect(existsSync(`${path}.rebuild-required`)).toBe(false); - expect(readFileSync(join(generation, 'inventory-v1.sqlite3'), 'utf8')).toBe('main-before-crash'); - expect(readFileSync(join(generation, 'inventory-v1.sqlite3-wal'), 'utf8')).toBe('wal-before-crash'); + const old = new DatabaseSync(join(generation, 'inventory-v1.sqlite3'), { readOnly: true }); + expect(old.prepare('SELECT value FROM wrong_v1').get()?.value).toBe('main-before-crash'); + old.close(); } finally { foundation.close(); } }); + it('converges every frozen recovery manifest and valid moved-prefix topology', async () => { + const manifests = [ + ['main'], + ['wal', 'main'], + ['shm', 'main'], + ['wal', 'shm', 'main'], + ] as const satisfies readonly RecoveryManifest[]; + + for (const members of manifests) { + for (let movedPrefix = 0; movedPrefix <= members.length; movedPrefix += 1) { + const dataDirectory = temporaryDataDirectory(); + const label = `topology-${members.join('-')}-${movedPrefix}`; + const seeded = seedPendingRecoveryTopology( + dataDirectory, + members, + movedPrefix, + label, + ); + const path = databasePath(dataDirectory); + + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + assertRecoveredManifestEvidence( + path, + members, + label, + seeded.generation, + seeded.hashes, + ); + } finally { + foundation.close(); + } + } + } + }); + + it('recovers in a fresh process after SIGKILL at every full-manifest durability boundary', async () => { + for (const boundary of FULL_MANIFEST_FAULT_BOUNDARIES) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const label = `fault-${boundary}`; + createIncompatibleOwnedInventory(path, label); + // Before marker creation the source member set is not committed crash + // state. A fresh adapter may normalize empty SQLite auxiliaries and then + // start a main-only generation; once the marker exists, every listed + // member must retain its exact inode/device/size/digest through recovery. + const markerExistsAtBoundary = FULL_MANIFEST_FAULT_BOUNDARIES.indexOf(boundary) + >= FULL_MANIFEST_FAULT_BOUNDARIES.indexOf('begin.marker.write'); + const walEvidence = Buffer.alloc(0); + const shmEvidence = Buffer.alloc(0); + const expectedHashes: EvidenceHashes = { + main: sha256File(path), + wal: createHash('sha256').update(walEvidence).digest('hex'), + shm: createHash('sha256').update(shmEvidence).digest('hex'), + }; + const tracePath = join(dataDirectory, `trace-${boundary}.jsonl`); + const trace = await killAtInventoryBoundary(dataDirectory, boundary, tracePath, { + DKG_RFC64_CHILD_SYNTHETIC_FULL_MANIFEST: 'empty', + DKG_RFC64_CHILD_PROTECT_EVIDENCE: '1', + }); + expect(trace.map((entry) => entry.boundary)).toEqual( + expectedFullManifestTrace(boundary), + ); + const killedTopology = trace.at(-1)!.topology as Array<{ + path: string; + kind: string; + dev: number; + ino: number; + size: number; + sha256?: string; + }>; + const killedEvidence = new Map(); + for (const member of FULL_RECOVERY_MANIFEST) { + const sourceName = member === 'main' + ? 'inventory-v1.sqlite3' + : `inventory-v1.sqlite3-${member}`; + const destinationSuffix = `/inventory-v1.sqlite3${member === 'main' ? '' : `-${member}`}`; + const entry = killedTopology.find((candidate) => + candidate.path === sourceName || candidate.path.endsWith(destinationSuffix)); + expect(entry, `missing ${member} oracle at ${boundary}`).toBeDefined(); + expect(entry!.kind).toBe('file'); + expect(entry!.sha256).toBe(expectedHashes[member]); + killedEvidence.set(member, { + dev: entry!.dev, + ino: entry!.ino, + size: entry!.size, + sha256: entry!.sha256!, + }); + } + expect(killedEvidence.get('wal')!.ino).not.toBe(killedEvidence.get('shm')!.ino); + + try { + await reopenInventoryInFreshProcess(dataDirectory); + } catch (cause) { + throw new Error(`fresh-process recovery failed after ${boundary}`, { cause }); + } + assertInitializedInventory(path); + try { + assertRecoveredManifestEvidence( + path, + markerExistsAtBoundary ? FULL_RECOVERY_MANIFEST : ['main'], + label, + undefined, + markerExistsAtBoundary ? expectedHashes : { main: expectedHashes.main }, + ); + } catch (cause) { + throw new Error(`evidence verification failed after ${boundary}`, { cause }); + } + const generation = evidenceGeneration(path); + for (const member of markerExistsAtBoundary ? FULL_RECOVERY_MANIFEST : ['main'] as const) { + expect(fileOracle(recoveryDestinationPath(generation, member))).toEqual( + killedEvidence.get(member), + ); + } + } + }, 180_000); + + it('recovers in a fresh process after SIGKILL at every moved-prefix re-fsync boundary', async () => { + for (const boundary of MOVED_PREFIX_FAULT_BOUNDARIES) { + const member = boundary.split('.')[2] as RecoveryMember; + const movedPrefix = FULL_RECOVERY_MANIFEST.indexOf(member) + 1; + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const label = `prefix-fault-${boundary}`; + const seeded = seedPendingRecoveryTopology( + dataDirectory, + FULL_RECOVERY_MANIFEST, + movedPrefix, + label, + true, + ); + expect(Object.values(seeded.hashes)).toHaveLength(FULL_RECOVERY_MANIFEST.length); + expect(new Set(Object.values(seeded.hashes)).size).toBe(FULL_RECOVERY_MANIFEST.length); + for (const evidenceMember of FULL_RECOVERY_MANIFEST) { + const evidencePath = FULL_RECOVERY_MANIFEST.indexOf(evidenceMember) < movedPrefix + ? recoveryDestinationPath(seeded.generation, evidenceMember) + : recoverySourcePath(path, evidenceMember); + expect(lstatSync(evidencePath).size).toBeGreaterThan(0); + } + const { generation } = seeded; + expect(seeded.hashes.wal).not.toBe(seeded.hashes.shm); + expect(fileOracle(recoveryDestinationPath(generation, member)).size).toBeGreaterThan(0); + const tracePath = join(dataDirectory, `trace-${boundary}.jsonl`); + const trace = await killAtInventoryBoundary(dataDirectory, boundary, tracePath); + expect(trace.map((entry) => entry.boundary)).toEqual( + MOVED_PREFIX_FAULT_BOUNDARIES.slice( + 0, + MOVED_PREFIX_FAULT_BOUNDARIES.indexOf(boundary) + 1, + ), + ); + const before = new Map( + FULL_RECOVERY_MANIFEST.map((evidenceMember) => [ + evidenceMember, + fileOracle( + FULL_RECOVERY_MANIFEST.indexOf(evidenceMember) < movedPrefix + ? recoveryDestinationPath(generation, evidenceMember) + : recoverySourcePath(path, evidenceMember), + ), + ]), + ); + + await reopenInventoryInFreshProcess(dataDirectory); + assertInitializedInventory(path); + assertRecoveredManifestEvidence( + path, + FULL_RECOVERY_MANIFEST, + label, + generation, + seeded.hashes, + ); + for (const evidenceMember of FULL_RECOVERY_MANIFEST) { + expect(fileOracle(recoveryDestinationPath(generation, evidenceMember))).toEqual( + before.get(evidenceMember), + ); + } + } + }, 120_000); + + it('fences a separate-process conforming contender after target exclusivity until holder SIGKILL', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, 'separate-process-contender'); + const tracePath = join(dataDirectory, 'contender-holder-trace.jsonl'); + const holder = spawnInventoryChild('fault', dataDirectory, { + DKG_RFC64_CHILD_BOUNDARY: 'target-exclusivity-proven', + DKG_RFC64_CHILD_TRACE: tracePath, + }); + await waitForChildBoundary(holder, 'target-exclusivity-proven'); + const targetBefore = fileOracle(path); + const namespaceBefore = readdirSync(dirname(path)).sort(); + expect(await runContender(dataDirectory)).toEqual({ + directLease: 'BUSY', + targetTransaction: 'FREE', + lease: 'BUSY', + }); + expect(fileOracle(path)).toEqual(targetBefore); + expect(readdirSync(dirname(path)).sort()).toEqual(namespaceBefore); + + const holderExitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveExit) => holder.once('exit', (code, signal) => resolveExit({ code, signal })), + ); + holder.kill('SIGKILL'); + const holderExit = await holderExitPromise; + expect(holderExit).toEqual({ code: null, signal: 'SIGKILL' }); + expect(await runContender(dataDirectory)).toEqual({ + directLease: 'OPEN', + targetTransaction: 'FREE', + lease: 'OPEN', + }); + assertInitializedInventory(path); + }, 30_000); + it('explicitly quarantines and rebuilds an open owned database', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -603,4 +2023,89 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { foundation.close(); } }); + + it('rejects explicit quarantine before closing a valid foundation when durability is unavailable', async () => { + const dataDirectory = temporaryDataDirectory(); + const foundation = await openProductionInventoryV1(dataDirectory); + try { + expect(() => foundation.quarantineAndRebuild()).toThrowError( + expect.objectContaining({ code: 'durability-unavailable' }), + ); + expect(foundation.closed).toBe(false); + expectNoQuarantine(databasePath(dataDirectory)); + } finally { + foundation.close(); + } + + const reopened = await openInventoryV1(dataDirectory); + reopened.close(); + }); +}); + +describe.runIf(process.platform === 'win32')('RFC-64 inventory v1 native Windows gate', () => { + it('opens a fresh inventory and reopens the exact valid database without quarantine capability', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const foundation = await openProductionInventoryV1(dataDirectory); + foundation.close(); + const before = fileOracle(path); + const reopened = await openProductionInventoryV1(dataDirectory); + reopened.close(); + expect(fileOracle(path)).toEqual(before); + assertInitializedInventory(path); + }); + + it('leaves an automatic-quarantine candidate byte-for-byte untouched', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, 'windows-automatic'); + const before = fileOracle(path); + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + expect(fileOracle(path)).toEqual(before); + expectNoQuarantine(path); + }); + + it('leaves an explicitly quarantined valid target open and mutation-free', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const foundation = await openProductionInventoryV1(dataDirectory); + const before = fileOracle(path); + try { + expect(() => foundation.quarantineAndRebuild()).toThrowError( + expect.objectContaining({ code: 'durability-unavailable' }), + ); + expect(foundation.closed).toBe(false); + expect(fileOracle(path)).toEqual(before); + expectNoQuarantine(path); + } finally { + foundation.close(); + } + }); + + it('leaves pending marker-resume evidence and namespace byte-for-byte untouched', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, 'windows-resume'); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-9999999999999999', + ); + mkdirSync(generation, { recursive: true }); + const marker = Buffer.from(JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['main'], + })); + writeFileSync(`${path}.rebuild-required`, marker); + const before = fileOracle(path); + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + expect(fileOracle(path)).toEqual(before); + expect(readFileSync(`${path}.rebuild-required`)).toEqual(marker); + expect(readdirSync(generation)).toEqual([]); + }); }); From 30cdc45e8a59f222751c34b29c5ad51a6281cae0 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:32:18 +0200 Subject: [PATCH 037/292] feat(core): bind catalog rows to canonical author seals --- .../src/canonical-graph-scoped-author-seal.ts | 61 ++- packages/core/src/catalog-seal-binding.ts | 357 ++++++++++++++++++ packages/core/src/index.ts | 2 + ...canonical-graph-scoped-author-seal.test.ts | 8 + .../core/test/catalog-seal-binding.test.ts | 305 +++++++++++++++ 5 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/catalog-seal-binding.ts create mode 100644 packages/core/test/catalog-seal-binding.test.ts diff --git a/packages/core/src/canonical-graph-scoped-author-seal.ts b/packages/core/src/canonical-graph-scoped-author-seal.ts index 8c945a3b41..8481bd3cdd 100644 --- a/packages/core/src/canonical-graph-scoped-author-seal.ts +++ b/packages/core/src/canonical-graph-scoped-author-seal.ts @@ -67,9 +67,13 @@ export const CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_DIGEST_DOMAIN_V1 = export const MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1 = 16 * 1024; export const MAX_SEAL_TRIPLE_COUNT_V1 = 9_007_199_254_740_991n; -const XSD_HEX_BINARY = ''; -const XSD_INTEGER = ''; -const XSD_DATE_TIME = ''; +const XSD_STRING_IRI = 'http://www.w3.org/2001/XMLSchema#string'; +const XSD_HEX_BINARY_IRI = 'http://www.w3.org/2001/XMLSchema#hexBinary'; +const XSD_INTEGER_IRI = 'http://www.w3.org/2001/XMLSchema#integer'; +const XSD_DATE_TIME_IRI = 'http://www.w3.org/2001/XMLSchema#dateTime'; +const XSD_HEX_BINARY = `<${XSD_HEX_BINARY_IRI}>`; +const XSD_INTEGER = `<${XSD_INTEGER_IRI}>`; +const XSD_DATE_TIME = `<${XSD_DATE_TIME_IRI}>`; const UTF8 = new TextEncoder(); const SEAL_DIGEST_DOMAIN_BYTES = UTF8.encode( CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_DIGEST_DOMAIN_V1, @@ -445,6 +449,57 @@ export function projectCanonicalGraphScopedAuthorSealRowsV1( return exactRows; } +/** + * Project transferred canonical seal bytes into the backend-neutral typed RDF + * rows consumed by the strict inverse decoder and the PR #1780 classifier. + * + * This is an in-memory projection only. It performs no triple-store read or + * write and gives callers no authority to treat the seal as semantically + * admitted. + */ +export function projectCanonicalGraphScopedAuthorSealStoreRowsV1( + payload: CanonicalGraphScopedAuthorSealV1, + coordinate: CanonicalGraphScopedAuthorSealCoordinateV1, +): readonly CanonicalAuthorSealStoreRowV1[] { + const rows = projectCanonicalGraphScopedAuthorSealRowsV1(payload, coordinate); + const literal = ( + value: string, + datatypeIri: string, + ): CanonicalAuthorSealStoreObjectV1 => Object.freeze({ + kind: 'literal' as const, + value, + datatypeIri, + }); + const objects: readonly CanonicalAuthorSealStoreObjectV1[] = [ + literal(payload.assertionMerkleRoot.slice(2), XSD_HEX_BINARY_IRI), + literal(payload.authorAddress, XSD_STRING_IRI), + literal(payload.authorAttestationR.slice(2), XSD_HEX_BINARY_IRI), + literal(payload.authorAttestationVS.slice(2), XSD_HEX_BINARY_IRI), + literal('1', XSD_INTEGER_IRI), + literal(payload.assertedAtChainId, XSD_INTEGER_IRI), + literal(payload.assertedAtKav10Address, XSD_STRING_IRI), + literal(payload.reservedKaId, XSD_INTEGER_IRI), + literal(payload.assertionFinalizedAt, XSD_DATE_TIME_IRI), + literal('2', XSD_INTEGER_IRI), + Object.freeze({ kind: 'named-node' as const, value: payload.kaUal }), + literal(payload.assertionVersion, XSD_INTEGER_IRI), + literal(payload.publicTripleCount, XSD_INTEGER_IRI), + literal(payload.privateTripleCount, XSD_INTEGER_IRI), + ...(payload.privateMerkleRoot === null + ? [] + : [literal(payload.privateMerkleRoot.slice(2), XSD_HEX_BINARY_IRI)]), + ]; + if (objects.length !== rows.length) { + fail('canonical-seal-row-cardinality', 'typed projection cardinality is inconsistent'); + } + return Object.freeze(rows.map((row, index) => Object.freeze({ + subjectIri: row.subject, + predicateIri: row.predicate, + graphIri: row.graph, + object: objects[index], + }))); +} + /** Render one typed store row to the exact canonical parser-row representation. */ export function renderCanonicalAuthorSealStoreRowV1( row: CanonicalAuthorSealStoreRowV1, diff --git a/packages/core/src/catalog-seal-binding.ts b/packages/core/src/catalog-seal-binding.ts new file mode 100644 index 0000000000..eac9e6e0cc --- /dev/null +++ b/packages/core/src/catalog-seal-binding.ts @@ -0,0 +1,357 @@ +import { + assertAuthorCatalogRowScopeBindingV1, + assertAuthorCatalogRowV1, + assertAuthorCatalogScopeV1, + assertNetworkIdV1, + buildCatalogAssertionScopeV1, + canonicalizeAuthorCatalogRowV1, + canonicalizeAuthorCatalogScopeV1, + computeAuthorCatalogRowDigestV1, + computeAuthorCatalogScopeDigestV1, + iriComponentV1, + parseCanonicalAuthorCatalogRowV1, + parseCanonicalAuthorCatalogScopeV1, + type AssertionCoordinateV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type NetworkIdV1, +} from './author-catalog-codec.js'; +import { + MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1, + classifyCanonicalGraphScopedAuthorSealRowsV1, + parseCanonicalGraphScopedAuthorSealV1, + projectCanonicalGraphScopedAuthorSealStoreRowsV1, + type CanonicalGraphScopedAuthorSealPlacementV1, + type CanonicalGraphScopedAuthorSealRowV1, + type CanonicalGraphScopedAuthorSealV1, +} from './canonical-graph-scoped-author-seal.js'; +import { parseDeterministicKnowledgeAssetUal } from './ka-content-scope.js'; +import { + assertCanonicalChainId, + assertCanonicalEvmAddress, + type ChainIdV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type KaIdV1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +declare const VERIFIED_CATALOG_SEAL_BINDING_BRAND_V1: unique symbol; + +/** Pinned v10 lane mapping supplied by local subscription configuration. */ +export interface CatalogSealDeploymentProfileV1 { + readonly networkId: NetworkIdV1; + readonly assertedAtChainId: ChainIdV1; + readonly assertedAtKav10Address: EvmAddressV1; +} + +/** + * Unforgeable process-local proof that transferred canonical seal bytes match + * one exact catalog row and locally pinned deployment profile. + * + * This is intentionally not `SemanticallyAdmittedCatalogRowV1`: KA projection + * RDFC/structured-root verification, author-signature recovery, finalized VM + * evidence, semantic-store commit, and post-read verification remain mandatory. + */ +export interface VerifiedCatalogSealBindingV1 { + readonly [VERIFIED_CATALOG_SEAL_BINDING_BRAND_V1]: true; +} + +export interface VerifiedCatalogSealBindingSnapshotV1 { + readonly catalogScopeDigest: Digest32V1; + readonly catalogRowDigest: Digest32V1; + readonly networkId: NetworkIdV1; + readonly authorAddress: EvmAddressV1; + readonly kaId: KaIdV1; + readonly assertionCoordinate: AssertionCoordinateV1; + readonly assertionVersion: DecimalU64V1; + readonly sealDigest: Digest32V1; + readonly placement: Readonly; + readonly seal: Readonly; + readonly sealRows: readonly Readonly[]; + /** Caller-owned copy; mutating it cannot alter the verifier's retained state. */ + readonly canonicalSealBytes: Uint8Array; +} + +export type CatalogSealBindingErrorCode = + | 'catalog-seal-input' + | 'catalog-seal-profile' + | 'catalog-seal-classifier' + | 'catalog-seal-coordinate' + | 'catalog-seal-ual' + | 'catalog-seal-ka-id' + | 'catalog-seal-version' + | 'catalog-seal-digest' + | 'catalog-seal-deployment' + | 'catalog-seal-capability'; + +export class CatalogSealBindingError extends Error { + constructor( + readonly code: CatalogSealBindingErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'CatalogSealBindingError'; + } +} + +interface CatalogSealBindingStateV1 { + readonly snapshot: Omit; + readonly canonicalSealBytes: Uint8Array; +} + +const VERIFIED_CATALOG_SEAL_BINDINGS_V1 = new WeakMap< + object, + CatalogSealBindingStateV1 +>(); +const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(Uint8Array.prototype) as object; +const GET_TYPED_ARRAY_BUFFER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'buffer', +)!.get!; +const GET_TYPED_ARRAY_BYTE_LENGTH = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteLength', +)!.get!; +const GET_TYPED_ARRAY_TAG = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + Symbol.toStringTag, +)!.get!; + +/** + * Bind one transferred canonical author seal to a staged catalog row. + * + * The strict typed-row reconstruction invokes PR #1780's canonical classifier + * exactly once. This function performs no RDF payload parsing or persistence. + */ +export function verifyCatalogSealBindingV1( + catalogScope: AuthorCatalogScopeV1, + row: AuthorCatalogRowV1, + canonicalSealBytes: Uint8Array, + deployment: CatalogSealDeploymentProfileV1, +): VerifiedCatalogSealBindingV1 { + let scope: AuthorCatalogScopeV1; + let catalogRow: AuthorCatalogRowV1; + try { + assertAuthorCatalogScopeV1(catalogScope); + assertAuthorCatalogRowV1(row); + scope = parseCanonicalAuthorCatalogScopeV1( + canonicalizeAuthorCatalogScopeV1(catalogScope), + ); + catalogRow = parseCanonicalAuthorCatalogRowV1( + canonicalizeAuthorCatalogRowV1(row), + ); + assertAuthorCatalogRowScopeBindingV1(catalogRow, scope); + } catch (cause) { + fail('catalog-seal-input', 'catalog scope or row is not structurally bound', cause); + } + assertDeploymentProfile(deployment); + const pinnedDeployment = Object.freeze({ + networkId: deployment.networkId, + assertedAtChainId: deployment.assertedAtChainId, + assertedAtKav10Address: deployment.assertedAtKav10Address, + }); + if (pinnedDeployment.networkId !== scope.networkId) { + fail('catalog-seal-deployment', 'deployment networkId differs from catalog scope'); + } + const receivedSealBytes = snapshotCanonicalBytes(canonicalSealBytes); + let seal: CanonicalGraphScopedAuthorSealV1; + try { + seal = parseCanonicalGraphScopedAuthorSealV1(receivedSealBytes); + } catch (cause) { + fail('catalog-seal-input', 'canonicalSealBytes are not a canonical graph-scoped seal', cause); + } + + const coordinate = { + contextGraphId: scope.contextGraphId, + subGraphName: scope.subGraphName, + authorAddress: scope.authorAddress, + assertionCoordinate: catalogRow.assertionCoordinate, + }; + let classified: ReturnType; + try { + const storeRows = projectCanonicalGraphScopedAuthorSealStoreRowsV1(seal, coordinate); + classified = classifyCanonicalGraphScopedAuthorSealRowsV1(storeRows, coordinate); + } catch (cause) { + fail( + 'catalog-seal-classifier', + 'strict typed-row reconstruction or PR #1780 classification failed', + cause, + ); + } + + if (!equalBytes(receivedSealBytes, classified.canonicalSealBytes)) { + fail('catalog-seal-digest', 'classifier reconstruction changed canonical seal bytes'); + } + const expectedScope = buildCatalogAssertionScopeV1(scope); + if ( + classified.candidate.coordinate.scope !== expectedScope + || classified.candidate.coordinate.agentAddress !== scope.authorAddress + || classified.candidate.coordinate.name !== iriComponentV1(catalogRow.assertionCoordinate) + ) { + fail('catalog-seal-coordinate', 'PR #1780 coordinate differs from the catalog row'); + } + + let ual: ReturnType; + try { + ual = parseDeterministicKnowledgeAssetUal(seal.kaUal); + } catch (cause) { + fail('catalog-seal-ual', 'seal UAL is not a canonical packed graph identity', cause); + } + if ( + ual.chainId !== pinnedDeployment.networkId + || ual.agentAddress !== scope.authorAddress + || classified.candidate.seal.kaUal !== seal.kaUal + ) { + fail('catalog-seal-ual', 'seal UAL namespace or author differs from the pinned catalog lane'); + } + + const expectedKaId = (BigInt(ual.agentAddress) << 96n) | BigInt(ual.kaNumber); + if ( + BigInt(catalogRow.kaId) !== expectedKaId + || BigInt(seal.reservedKaId) !== expectedKaId + || classified.candidate.seal.reservedKaId !== expectedKaId + ) { + fail('catalog-seal-ka-id', 'UAL, row kaId, and reservedKaId do not identify one KA'); + } + if ( + seal.assertionVersion !== catalogRow.assertionVersion + || classified.candidate.seal.assertionVersion !== catalogRow.assertionVersion + ) { + fail('catalog-seal-version', 'seal assertionVersion differs from the catalog row'); + } + if (classified.sealDigest !== catalogRow.sealDigest) { + fail('catalog-seal-digest', 'canonical seal digest differs from the catalog row'); + } + if ( + seal.assertedAtChainId !== pinnedDeployment.assertedAtChainId + || seal.assertedAtKav10Address !== pinnedDeployment.assertedAtKav10Address + || classified.candidate.seal.chainId !== BigInt(pinnedDeployment.assertedAtChainId) + || classified.candidate.seal.kav10Address !== pinnedDeployment.assertedAtKav10Address + ) { + fail('catalog-seal-deployment', 'seal chain or KAv10 deployment differs from local pins'); + } + + const placement = Object.freeze({ ...classified.placement }); + const sealSnapshot = Object.freeze({ ...classified.payload }); + const sealRows = Object.freeze(classified.rows.map((sealRow) => Object.freeze({ ...sealRow }))); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const snapshot = Object.freeze({ + catalogScopeDigest, + catalogRowDigest: computeAuthorCatalogRowDigestV1( + catalogScopeDigest, + catalogRow, + ), + networkId: pinnedDeployment.networkId, + authorAddress: scope.authorAddress, + kaId: catalogRow.kaId, + assertionCoordinate: catalogRow.assertionCoordinate, + assertionVersion: catalogRow.assertionVersion, + sealDigest: classified.sealDigest, + placement, + seal: sealSnapshot, + sealRows, + }); + const capability = Object.freeze(Object.create(null)) as VerifiedCatalogSealBindingV1; + VERIFIED_CATALOG_SEAL_BINDINGS_V1.set(capability as object, Object.freeze({ + snapshot, + canonicalSealBytes: receivedSealBytes, + })); + return capability; +} + +export function assertVerifiedCatalogSealBindingV1( + value: unknown, +): asserts value is VerifiedCatalogSealBindingV1 { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + fail('catalog-seal-capability', 'catalog seal binding was not minted by this verifier'); + } + if (!VERIFIED_CATALOG_SEAL_BINDINGS_V1.has(value as object)) { + fail('catalog-seal-capability', 'catalog seal binding was not minted by this verifier'); + } +} + +/** Return immutable scalar state plus a fresh caller-owned canonical byte copy. */ +export function readVerifiedCatalogSealBindingV1( + value: unknown, +): VerifiedCatalogSealBindingSnapshotV1 { + assertVerifiedCatalogSealBindingV1(value); + const state = VERIFIED_CATALOG_SEAL_BINDINGS_V1.get(value as object)!; + return Object.freeze({ + ...state.snapshot, + canonicalSealBytes: new Uint8Array(state.canonicalSealBytes), + }); +} + +function assertDeploymentProfile( + deployment: unknown, +): asserts deployment is CatalogSealDeploymentProfileV1 { + try { + if (!isPlainRecord(deployment)) throw new Error('profile must be a plain object'); + assertExactKeys( + deployment, + ['assertedAtChainId', 'assertedAtKav10Address', 'networkId'], + 'catalog seal deployment profile', + ); + assertNetworkIdV1(deployment.networkId); + assertCanonicalChainId(deployment.assertedAtChainId, 'assertedAtChainId'); + assertCanonicalEvmAddress(deployment.assertedAtKav10Address, 'assertedAtKav10Address'); + } catch (cause) { + fail('catalog-seal-profile', 'deployment profile is not canonical', cause); + } +} + +function snapshotCanonicalBytes(value: unknown): Uint8Array { + let backingBuffer: ArrayBufferLike; + let byteLength: number; + let typedArrayTag: string | undefined; + try { + backingBuffer = Reflect.apply(GET_TYPED_ARRAY_BUFFER, value, []); + byteLength = Reflect.apply(GET_TYPED_ARRAY_BYTE_LENGTH, value, []); + typedArrayTag = Reflect.apply(GET_TYPED_ARRAY_TAG, value, []); + } catch (cause) { + fail('catalog-seal-input', 'canonicalSealBytes must be a genuine Uint8Array', cause); + } + if (typedArrayTag !== 'Uint8Array') { + fail('catalog-seal-input', 'canonicalSealBytes must be a Uint8Array'); + } + if (!(backingBuffer instanceof ArrayBuffer)) { + fail('catalog-seal-input', 'canonicalSealBytes must not use shared backing memory'); + } + if ((backingBuffer as ArrayBuffer & { readonly resizable?: boolean }).resizable === true) { + fail('catalog-seal-input', 'canonicalSealBytes must not use resizable backing memory'); + } + if (byteLength > MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1) { + fail( + 'catalog-seal-input', + `canonicalSealBytes exceed ${MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1} bytes`, + ); + } + try { + // Always normalize Buffer and typed-array subclasses to an ordinary, + // independently backed Uint8Array. Buffer.prototype.slice() returns a + // shared view, so retaining a Buffer here would make later reads mutable. + return new Uint8Array(value as Uint8Array); + } catch (cause) { + fail('catalog-seal-input', 'canonicalSealBytes could not be snapshotted safely', cause); + } +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + let difference = 0; + for (let index = 0; index < left.byteLength; index += 1) { + difference |= left[index] ^ right[index]; + } + return difference === 0; +} + +function fail( + code: CatalogSealBindingErrorCode, + message: string, + cause?: unknown, +): never { + throw new CatalogSealBindingError(code, message, cause === undefined ? {} : { cause }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fcd2b5b150..bef4083125 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -355,6 +355,7 @@ export { assertCanonicalGraphScopedAuthorSealCoordinateV1, deriveCanonicalGraphScopedAuthorSealPlacementV1, projectCanonicalGraphScopedAuthorSealRowsV1, + projectCanonicalGraphScopedAuthorSealStoreRowsV1, renderCanonicalAuthorSealStoreRowV1, decodeCanonicalGraphScopedAuthorSealRowsV1, classifyCanonicalGraphScopedAuthorSealRowsV1, @@ -373,3 +374,4 @@ export { type ClassifiedCanonicalGraphScopedAuthorSealRowsV1, type CanonicalGraphScopedAuthorSealErrorCode, } from './canonical-graph-scoped-author-seal.js'; +export * from './catalog-seal-binding.js'; diff --git a/packages/core/test/canonical-graph-scoped-author-seal.test.ts b/packages/core/test/canonical-graph-scoped-author-seal.test.ts index 3bfe903d77..dc8ae30591 100644 --- a/packages/core/test/canonical-graph-scoped-author-seal.test.ts +++ b/packages/core/test/canonical-graph-scoped-author-seal.test.ts @@ -13,6 +13,7 @@ import { deriveCanonicalGraphScopedAuthorSealPlacementV1, parseCanonicalGraphScopedAuthorSealV1, projectCanonicalGraphScopedAuthorSealRowsV1, + projectCanonicalGraphScopedAuthorSealStoreRowsV1, renderCanonicalAuthorSealStoreRowV1, type CanonicalAuthorSealStoreRowV1, type CanonicalGraphScopedAuthorSealCoordinateV1, @@ -90,6 +91,13 @@ describe('CanonicalGraphScopedAuthorSealV1 bytes and projection', () => { expect(rows.map((row) => row.predicate)).toEqual(EXPECTED_PREDICATES); expect(rows.map((row) => row.object)).toEqual(EXPECTED_OBJECTS); expect(rows.every((row) => row.subject === SUBJECT && row.graph === META_GRAPH)).toBe(true); + + const typedRows = projectCanonicalGraphScopedAuthorSealStoreRowsV1(PAYLOAD, COORDINATE); + expect(Object.isFrozen(typedRows)).toBe(true); + expect(typedRows).toHaveLength(rows.length); + expect(typedRows.map(renderCanonicalAuthorSealStoreRowV1)).toEqual(rows); + expect(typedRows.every((row) => Object.isFrozen(row) && Object.isFrozen(row.object))) + .toBe(true); }); it('emits the optional private-root row only for positive private content', () => { diff --git a/packages/core/test/catalog-seal-binding.test.ts b/packages/core/test/catalog-seal-binding.test.ts new file mode 100644 index 0000000000..edb59eceba --- /dev/null +++ b/packages/core/test/catalog-seal-binding.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertAuthorCatalogRowV1, + assertAuthorCatalogScopeV1, + computeAuthorCatalogRowDigestV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, +} from '../src/author-catalog-codec.js'; +import { + CatalogSealBindingError, + assertVerifiedCatalogSealBindingV1, + readVerifiedCatalogSealBindingV1, + verifyCatalogSealBindingV1, + type CatalogSealBindingErrorCode, + type CatalogSealDeploymentProfileV1, +} from '../src/catalog-seal-binding.js'; +import { + MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1, + assertCanonicalGraphScopedAuthorSealV1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + type CanonicalGraphScopedAuthorSealV1, +} from '../src/canonical-graph-scoped-author-seal.js'; + +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const KAV10 = '0x4444444444444444444444444444444444444444'; +const KA_ID = + '23158417847463239084714197001737581570653996933112267175388663934063917137927'; +const NEXT_KA_ID = + '23158417847463239084714197001737581570653996933112267175388663934063917137928'; +const SEAL_DIGEST = + '0x8fc37c7f66831aea9b2a0ed35aac26bb6eec2eb3042ed0dcdd2e023d3087632a'; +const ZERO_DIGEST = `0x${'00'.repeat(32)}`; + +const SCOPE = validScope({ + networkId: 'otp:20430', + contextGraphId: 'a/b', + governanceChainId: '20430', + governanceContractAddress: '0x5555555555555555555555555555555555555555', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', +}); +const ROW = validRow({ + kaId: KA_ID, + assertionCoordinate: 'name λ', + assertionVersion: '2', + projectionId: 'cg-shared-v1', + projectionDigest: ZERO_DIGEST, + sealDigest: SEAL_DIGEST, + transfer: { + codec: 'dkg-ka-bundle-v1', + projectionId: 'cg-shared-v1', + projectionDigest: ZERO_DIGEST, + byteLength: '16', + chunkSize: '262144', + chunkCount: '1', + blobDigest: `0x${'11'.repeat(32)}`, + chunkTreeRoot: `0x${'22'.repeat(32)}`, + }, +}); +const PROFILE = { + networkId: 'otp:20430', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +} as CatalogSealDeploymentProfileV1; +const SEAL = validSeal({ + assertionMerkleRoot: `0x${'aa'.repeat(32)}`, + authorAddress: AUTHOR, + authorAttestationR: `0x${'11'.repeat(32)}`, + authorAttestationVS: `0x${'22'.repeat(32)}`, + authorSchemeVersion: '1', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: `did:dkg:otp:20430/${AUTHOR}/7`, + assertionVersion: '2', + publicTripleCount: '12977', + privateTripleCount: '0', + privateMerkleRoot: null, +}); +const SEAL_BYTES = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(SEAL); + +describe('RFC-64 transferred catalog seal binding', () => { + it('binds one exact row through typed reconstruction and the #1780 classifier', () => { + const verified = verifyCatalogSealBindingV1(SCOPE, ROW, SEAL_BYTES, PROFILE); + expect(Object.getPrototypeOf(verified)).toBeNull(); + expect(Object.isFrozen(verified)).toBe(true); + expect(Reflect.ownKeys(verified as object)).toEqual([]); + expect(() => assertVerifiedCatalogSealBindingV1(verified)).not.toThrow(); + + const snapshot = readVerifiedCatalogSealBindingV1(verified); + expect(snapshot).toMatchObject({ + networkId: 'otp:20430', + authorAddress: AUTHOR, + kaId: KA_ID, + assertionCoordinate: 'name λ', + assertionVersion: '2', + sealDigest: SEAL_DIGEST, + placement: { + subject: `did:dkg:context-graph:v1/root/a%2Fb/assertion/${AUTHOR}/name%20%CE%BB`, + metaGraph: 'did:dkg:context-graph:v1/root/a%2Fb/_meta', + }, + seal: SEAL, + }); + expect(snapshot.catalogRowDigest).toBe( + computeAuthorCatalogRowDigestV1(snapshot.catalogScopeDigest, ROW), + ); + expect(snapshot.sealRows).toHaveLength(14); + expect(new TextDecoder().decode(snapshot.canonicalSealBytes)).toBe( + new TextDecoder().decode(SEAL_BYTES), + ); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.seal)).toBe(true); + expect(Object.isFrozen(snapshot.placement)).toBe(true); + expect(Object.isFrozen(snapshot.sealRows)).toBe(true); + }); + + it('retains immutable verifier state while returning caller-owned byte copies', () => { + const source = SEAL_BYTES.slice(); + const verified = verifyCatalogSealBindingV1(SCOPE, ROW, source, PROFILE); + source.fill(0); + const first = readVerifiedCatalogSealBindingV1(verified); + first.canonicalSealBytes.fill(0); + const second = readVerifiedCatalogSealBindingV1(verified); + expect(new TextDecoder().decode(second.canonicalSealBytes)).toBe( + new TextDecoder().decode(SEAL_BYTES), + ); + }); + + it('normalizes Buffer and typed-array subclasses without exposing retained bytes', () => { + class SealBytes extends Uint8Array {} + for (const source of [ + Buffer.from(SEAL_BYTES), + new SealBytes(SEAL_BYTES), + ]) { + const verified = verifyCatalogSealBindingV1(SCOPE, ROW, source, PROFILE); + source.fill(0); + const first = readVerifiedCatalogSealBindingV1(verified); + expect(first.canonicalSealBytes.constructor).toBe(Uint8Array); + expect(Buffer.isBuffer(first.canonicalSealBytes)).toBe(false); + first.canonicalSealBytes.fill(0); + const second = readVerifiedCatalogSealBindingV1(verified); + expect(second.canonicalSealBytes.constructor).toBe(Uint8Array); + expect(Buffer.isBuffer(second.canonicalSealBytes)).toBe(false); + expect(new TextDecoder().decode(second.canonicalSealBytes)).toBe( + new TextDecoder().decode(SEAL_BYTES), + ); + } + }); + + it('rejects over-cap byte inputs before attempting semantic parsing', () => { + const spoofedOversized = Buffer.alloc( + MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1 + 1, + ); + Object.defineProperties(spoofedOversized, { + buffer: { value: new ArrayBuffer(1) }, + byteLength: { value: 1 }, + }); + expectFailure( + () => verifyCatalogSealBindingV1( + SCOPE, + ROW, + Buffer.alloc(MAX_CANONICAL_GRAPH_SCOPED_AUTHOR_SEAL_BYTES_V1 + 1), + PROFILE, + ), + 'catalog-seal-input', + ); + expectFailure( + () => verifyCatalogSealBindingV1(SCOPE, ROW, spoofedOversized, PROFILE), + 'catalog-seal-input', + ); + }); + + it('rejects structural casts, frozen clones, and JSON round trips', () => { + const verified = verifyCatalogSealBindingV1(SCOPE, ROW, SEAL_BYTES, PROFILE); + for (const forged of [ + Object.freeze({}), + Object.freeze({ ...verified as object }), + JSON.parse(JSON.stringify(verified)), + ]) { + expectFailure( + () => assertVerifiedCatalogSealBindingV1(forged), + 'catalog-seal-capability', + ); + expectFailure( + () => readVerifiedCatalogSealBindingV1(forged), + 'catalog-seal-capability', + ); + } + }); + + it('fails closed on row identity, version, digest, UAL, and deployment mismatches', () => { + expectFailure( + () => verifyCatalogSealBindingV1( + SCOPE, + validRow({ ...ROW, kaId: NEXT_KA_ID }), + SEAL_BYTES, + PROFILE, + ), + 'catalog-seal-ka-id', + ); + expectFailure( + () => verifyCatalogSealBindingV1( + SCOPE, + validRow({ ...ROW, assertionVersion: '3' }), + SEAL_BYTES, + PROFILE, + ), + 'catalog-seal-version', + ); + expectFailure( + () => verifyCatalogSealBindingV1( + SCOPE, + validRow({ ...ROW, sealDigest: ZERO_DIGEST }), + SEAL_BYTES, + PROFILE, + ), + 'catalog-seal-digest', + ); + const otherLaneSeal = validSeal({ + ...SEAL, + kaUal: `did:dkg:base:8453/${AUTHOR}/7`, + }); + expectFailure( + () => verifyCatalogSealBindingV1( + SCOPE, + ROW, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1(otherLaneSeal), + PROFILE, + ), + 'catalog-seal-ual', + ); + expectFailure( + () => verifyCatalogSealBindingV1(SCOPE, ROW, SEAL_BYTES, { + ...PROFILE, + assertedAtChainId: '20431', + }), + 'catalog-seal-deployment', + ); + expectFailure( + () => verifyCatalogSealBindingV1(SCOPE, ROW, SEAL_BYTES, { + ...PROFILE, + assertedAtKav10Address: '0x6666666666666666666666666666666666666666', + }), + 'catalog-seal-deployment', + ); + expectFailure( + () => verifyCatalogSealBindingV1(SCOPE, ROW, SEAL_BYTES, { + ...PROFILE, + networkId: 'base:8453', + }), + 'catalog-seal-deployment', + ); + }); + + it('rejects malformed profile records and mutable-memory classes before classification', () => { + expectFailure( + () => verifyCatalogSealBindingV1(SCOPE, ROW, SEAL_BYTES, { + ...PROFILE, + extra: true, + } as unknown as CatalogSealDeploymentProfileV1), + 'catalog-seal-profile', + ); + expectFailure( + () => verifyCatalogSealBindingV1( + SCOPE, + ROW, + new Uint8Array(new SharedArrayBuffer(SEAL_BYTES.byteLength)), + PROFILE, + ), + 'catalog-seal-input', + ); + }); +}); + +function validScope(value: unknown): AuthorCatalogScopeV1 { + assertAuthorCatalogScopeV1(value); + return value; +} + +function validRow(value: unknown): AuthorCatalogRowV1 { + assertAuthorCatalogRowV1(value); + return value; +} + +function validSeal(value: unknown): CanonicalGraphScopedAuthorSealV1 { + assertCanonicalGraphScopedAuthorSealV1(value); + return value; +} + +function expectFailure(operation: () => unknown, code: CatalogSealBindingErrorCode): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(CatalogSealBindingError); + expect((error as CatalogSealBindingError).code).toBe(code); + return; + } + throw new Error(`expected ${code}`); +} From 2fab9a6536966509dd1d041a8ad7bae5b9094ab2 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:34:04 +0200 Subject: [PATCH 038/292] feat(agent): stage RFC-64 catalog candidates --- .../agent/src/rfc64/inventory-v1/candidate.ts | 2052 +++++++++++++++++ .../agent/src/rfc64/inventory-v1/index.ts | 19 + packages/agent/src/rfc64/inventory-v1/open.ts | 210 +- packages/agent/src/rfc64/inventory-v1/sql.ts | 22 +- .../src/rfc64/inventory-v1/statements.ts | 276 +++ ...fc64-inventory-v1-candidate-faults.test.ts | 245 ++ ...c64-inventory-v1-candidate-latency.test.ts | 608 +++++ ...rfc64-inventory-v1-candidate-plans.test.ts | 350 +++ .../rfc64-inventory-v1-candidates.test.ts | 1342 +++++++++++ .../test/rfc64-inventory-v1-lifecycle.test.ts | 29 +- packages/agent/vitest.unit.config.ts | 4 + 11 files changed, 5141 insertions(+), 16 deletions(-) create mode 100644 packages/agent/src/rfc64/inventory-v1/candidate.ts create mode 100644 packages/agent/src/rfc64/inventory-v1/statements.ts create mode 100644 packages/agent/test/rfc64-inventory-v1-candidate-faults.test.ts create mode 100644 packages/agent/test/rfc64-inventory-v1-candidate-latency.test.ts create mode 100644 packages/agent/test/rfc64-inventory-v1-candidate-plans.test.ts create mode 100644 packages/agent/test/rfc64-inventory-v1-candidates.test.ts diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts new file mode 100644 index 0000000000..57aba02b29 --- /dev/null +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -0,0 +1,2052 @@ +import { randomBytes } from 'node:crypto'; + +import type { DatabaseSync, SQLInputValue, StatementSync } from 'node:sqlite'; + +import { + KA_TRANSFER_CHUNK_SIZE_V1, + KA_TRANSFER_CODEC_V1, + KA_TRANSFER_PROJECTION_V1, + MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, + ZERO_DIGEST32_V1, + assertAssertionCoordinateV1, + assertAuthorCatalogBucketScopeBindingV1, + assertAuthorCatalogBucketV1, + assertAuthorCatalogBucketCountV1, + assertAuthorCatalogRowV1, + assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + assertSubGraphNameV1, + canonicalizeAuthorCatalogBucketPayloadBytesV1, + computeAuthorCatalogKeyDigestV1, + computeAuthorCatalogRowDigestV1, + computeAuthorCatalogScopeDigestV1, + deriveAuthorCatalogScopeFromHeadV1, + parseCanonicalDecimalU64, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type ByteLengthV1, + type CountV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type KaIdV1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SubGraphNameV1, +} from '@origintrail-official/dkg-core'; + +import { + assertSqlBlobWidthV1, + decimalU64ToSqlBlobV1, + decimalU256ToSqlBlobV1, + digest32ToSqlBlobV1, + evmAddressToSqlBlobV1, + sqlBlobToDecimalU64V1, + sqlBlobToDecimalU256V1, + sqlBlobToDigest32V1, + sqlBlobToEvmAddressV1, + sqlBlobsEqualV1, +} from './scalars.js'; +import { INVENTORY_V1_STATEMENT_SQL } from './statements.js'; + +const MAX_PAGE_SIZE = 256; +const STATEMENT_LATENCY_BUDGET_MS = 10_000; +const WRITE_TRANSACTION_LATENCY_BUDGET_MS = 30_000; + +declare const CANDIDATE_SESSION_BRAND: unique symbol; +declare const CANDIDATE_LOAD_KEY_BRAND: unique symbol; +declare const CANDIDATE_ROWS_TRAVERSAL_BRAND: unique symbol; +declare const CANDIDATE_DIFF_TRAVERSAL_BRAND: unique symbol; +declare const VERIFIED_CANDIDATE_BUCKET_DESCRIPTOR_BRAND: unique symbol; + +/** Opaque, adapter-local session. No raw session bytes are exposed or accepted. */ +export interface CandidateSessionV1 { + readonly [CANDIDATE_SESSION_BRAND]: true; +} + +/** Opaque key returned only after a verified load has been accepted. */ +export interface CandidateBucketLoadKeyV1 { + readonly [CANDIDATE_LOAD_KEY_BRAND]: true; +} + +export interface CandidateBucketRowsTraversalV1 { + readonly [CANDIDATE_ROWS_TRAVERSAL_BRAND]: true; +} + +export interface CandidateBucketDiffTraversalV1 { + readonly [CANDIDATE_DIFF_TRAVERSAL_BRAND]: true; +} + +/** + * Exact selected leaf descriptor returned by the core directory-path verifier. + * The network layer remains responsible for signature authority and path proof; + * this adapter redundantly rechecks every deterministic descriptor/bucket bind. + */ +export interface VerifiedCandidateBucketDescriptorV1 { + /** + * This capability is minted only by the canonical directory-path verifier. + * Candidate SQL deliberately has no structural-only factory for it. + */ + readonly [VERIFIED_CANDIDATE_BUCKET_DESCRIPTOR_BRAND]: true; + readonly bucketId: DecimalU64V1; + readonly rowCount: CountV1; + readonly byteLength: ByteLengthV1; + readonly bucketDigest: Digest32V1; +} + +export interface VerifiedCandidateBucketLoadV1 { + readonly session: CandidateSessionV1; + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly descriptor: VerifiedCandidateBucketDescriptorV1; + readonly bucket: SignedAuthorCatalogBucketEnvelopeV1 | null; +} + +export interface CandidateBucketHeaderV1 { + readonly catalogScopeDigest: Digest32V1; + readonly authorAddress: EvmAddressV1; + readonly targetCatalogHeadDigest: Digest32V1; + readonly subGraphName: SubGraphNameV1 | null; + readonly catalogEra: DecimalU64V1; + readonly bucketCount: CountV1; + readonly bucketId: DecimalU64V1; + readonly bucketObjectDigest: Digest32V1; + readonly rowCount: CountV1; + readonly payloadByteLength: ByteLengthV1; +} + +export interface CandidateBucketRowV1 { + readonly row: AuthorCatalogRowV1; + readonly catalogKeyDigest: Digest32V1; + readonly expectedCatalogRowDigest: Digest32V1; +} + +export interface CandidateBucketPutResultV1 { + readonly status: 'inserted' | 'existing'; + readonly loadKey: CandidateBucketLoadKeyV1; + readonly header: CandidateBucketHeaderV1; +} + +export interface CandidateBucketPageV1 { + readonly rows: readonly CandidateBucketRowV1[]; + readonly resumeAfter: KaIdV1 | null; +} + +export interface CandidateSessionGcBatchResultV1 { + readonly deletedLoads: number; + readonly done: boolean; +} + +export type InventoryV1CandidateErrorCode = + | 'candidate-invalid-session' + | 'candidate-startup-purge-required' + | 'candidate-session-poisoned' + | 'candidate-session-not-poisoned' + | 'candidate-invalid-load' + | 'candidate-conflict' + | 'candidate-not-loaded' + | 'candidate-invalid-load-key' + | 'candidate-invalid-traversal' + | 'candidate-traversal-closed' + | 'candidate-cursor-mismatch' + | 'candidate-stream-complete' + | 'candidate-in-use' + | 'candidate-database-corrupt' + | 'latency-budget-exceeded' + | 'candidate-database-error'; + +export class InventoryV1CandidateError extends Error { + constructor( + readonly code: InventoryV1CandidateErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'InventoryV1CandidateError'; + } +} + +export interface Rfc64InventoryV1CandidateApi { + purgeNextStartupStaleCandidateBatch(): CandidateSessionGcBatchResultV1; + createCandidateSession(): CandidateSessionV1; + putVerifiedCandidateBucket(load: VerifiedCandidateBucketLoadV1): CandidateBucketPutResultV1; + getCandidateBucket(loadKey: CandidateBucketLoadKeyV1): CandidateBucketHeaderV1; + beginCandidateBucketRows(loadKey: CandidateBucketLoadKeyV1): CandidateBucketRowsTraversalV1; + beginCandidateBucketDiff( + oldLoadKey: CandidateBucketLoadKeyV1, + newLoadKey: CandidateBucketLoadKeyV1, + ): CandidateBucketDiffTraversalV1; + pageCandidateBucketRows( + traversal: CandidateBucketRowsTraversalV1, + cursor: KaIdV1 | null | undefined, + limit: number, + ): CandidateBucketPageV1; + pageCandidateBucketAddedOrChanged( + traversal: CandidateBucketDiffTraversalV1, + cursor: KaIdV1 | null | undefined, + limit: number, + ): CandidateBucketPageV1; + pageCandidateBucketRemoved( + traversal: CandidateBucketDiffTraversalV1, + cursor: KaIdV1 | null | undefined, + limit: number, + ): CandidateBucketPageV1; + closeCandidateTraversal( + traversal: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, + ): void; + discardCandidateSessionBatch(session: CandidateSessionV1): CandidateSessionGcBatchResultV1; + deleteCandidateBucket(loadKey: CandidateBucketLoadKeyV1): void; +} + +interface SessionContextV1 { + readonly scopeHex: string; + readonly authorHex: string; + readonly subGraphName: string | null; + readonly eraHex: string; + readonly bucketCountHex: string; +} + +interface SessionRecordV1 { + readonly id: Uint8Array; + readonly headContexts: Map; + readonly traversals: Set; + poisoned: boolean; +} + +interface EncodedLoadKeyV1 { + readonly session: Uint8Array; + readonly scope: Uint8Array; + readonly author: Uint8Array; + readonly head: Uint8Array; + readonly bucket: Uint8Array; +} + +interface LoadKeyRecordV1 { + readonly sessionHandle: CandidateSessionV1; + readonly session: SessionRecordV1; + readonly encoded: EncodedLoadKeyV1; + readonly expectedHeader: EncodedHeaderV1; + readonly scope: AuthorCatalogScopeV1; + readonly pinKey: string; +} + +interface EncodedHeaderV1 extends EncodedLoadKeyV1 { + readonly subgraphName: string | null; + readonly era: Uint8Array; + readonly bucketCount: Uint8Array; + readonly bucketObjectDigest: Uint8Array; + readonly rowCount: Uint8Array; + readonly payloadByteLength: Uint8Array; +} + +interface EncodedRowV1 extends EncodedLoadKeyV1 { + readonly kaId: Uint8Array; + readonly catalogKeyDigest: Uint8Array; + readonly assertionCoordinate: string; + readonly assertionVersion: Uint8Array; + readonly projectionId: typeof KA_TRANSFER_PROJECTION_V1; + readonly projectionDigest: Uint8Array; + readonly sealDigest: Uint8Array; + readonly transferCodec: typeof KA_TRANSFER_CODEC_V1; + readonly transferByteLength: Uint8Array; + readonly transferChunkSize: Uint8Array; + readonly transferChunkCount: Uint8Array; + readonly transferBlobDigest: Uint8Array; + readonly transferChunkTreeRoot: Uint8Array; + readonly expectedCatalogRowDigest: Uint8Array; +} + +interface EncodedCandidateLoadV1 { + readonly sessionHandle: CandidateSessionV1; + readonly session: SessionRecordV1; + readonly header: EncodedHeaderV1; + readonly rows: readonly EncodedRowV1[]; + readonly publicHeader: CandidateBucketHeaderV1; + readonly scope: AuthorCatalogScopeV1; + readonly context: SessionContextV1; +} + +interface DeleteTargetV1 { + readonly key: EncodedLoadKeyV1; + readonly header: EncodedHeaderV1; + readonly childCount: number; +} + +type IndeterminateCommitResolutionV1 = 'committed' | 'not-committed'; + +interface IndeterminateCommitStrategyV1 { + readonly resolve: (result: T) => IndeterminateCommitResolutionV1; + readonly retry: (result: T) => T; + readonly resolvedCommittedResult?: (result: T) => T; +} + +interface RollbackResultV1 { + readonly failed: boolean; + readonly overran: boolean; +} + +interface TraversalBaseV1 { + readonly sessions: readonly SessionRecordV1[]; + readonly pins: readonly LoadKeyRecordV1[]; + closed: boolean; + closeReason: 'normal' | 'poisoned' | 'reopen' | null; +} + +interface RowsTraversalRecordV1 extends TraversalBaseV1 { + readonly kind: 'rows'; + readonly load: LoadKeyRecordV1; + expectedCursor: KaIdV1 | null; + terminal: boolean; +} + +interface DiffTraversalRecordV1 extends TraversalBaseV1 { + readonly kind: 'diff'; + readonly oldLoad: LoadKeyRecordV1; + readonly newLoad: LoadKeyRecordV1; + addedExpectedCursor: KaIdV1 | null; + removedExpectedCursor: KaIdV1 | null; + addedTerminal: boolean; + removedTerminal: boolean; +} + +type TraversalRecordV1 = RowsTraversalRecordV1 | DiffTraversalRecordV1; +type SqlRowV1 = Record; +type SqlParametersV1 = Record; + +/** @internal Wired into the owned SQLite foundation; not a standalone database API. */ +export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { + readonly #sessions = new WeakMap(); + readonly #sessionRecords = new Set(); + readonly #loadKeys = new WeakMap(); + readonly #traversals = new WeakMap(); + readonly #pins = new Map(); + #startupPurgeDone = false; + #committedOverruns = 0; + #available = true; + #closed = false; + #activeWriteDeadline: number | null = null; + + constructor( + private database: DatabaseSync, + private readonly onRequireReopen: (database: DatabaseSync) => DatabaseSync, + private readonly monotonicNow: () => number = () => performance.now(), + private readonly onRejectReopened: (database: DatabaseSync) => void = closeRejectedDatabase, + ) {} + + /** Number of committed write operations whose successful COMMIT crossed a hard budget. */ + get committedOverruns(): number { + return this.#committedOverruns; + } + + purgeNextStartupStaleCandidateBatch(): CandidateSessionGcBatchResultV1 { + this.assertOpen(); + if (this.#startupPurgeDone) return Object.freeze({ deletedLoads: 0, done: true }); + const transaction = this.writeTransaction('startup candidate purge', () => { + const select = this.prepare(INVENTORY_V1_STATEMENT_SQL.startupGcNext); + const selected = this.statement(() => select.all() as SqlRowV1[]); + const targets = selected.map((row) => this.captureDeleteTarget(row)); + this.deleteExactTargets(targets); + return Object.freeze({ targets }); + }, { + resolve: ({ targets }) => this.resolveDeletedTargets(targets), + retry: ({ targets }) => { + this.deleteExactTargets(targets); + return Object.freeze({ targets }); + }, + }); + const result = Object.freeze({ + deletedLoads: transaction.targets.length, + done: transaction.targets.length === 0, + }); + if (result.done) this.#startupPurgeDone = true; + return result; + } + + createCandidateSession(): CandidateSessionV1 { + this.assertOpen(); + if (!this.#startupPurgeDone) { + throw new InventoryV1CandidateError( + 'candidate-startup-purge-required', + 'startup stale-candidate purge must reach a terminal empty batch first', + ); + } + let id = randomBytes(32); + while (isAllZero(id)) id = randomBytes(32); + const handle = Object.freeze({}) as CandidateSessionV1; + const session: SessionRecordV1 = { + id: new Uint8Array(id), + headContexts: new Map(), + traversals: new Set(), + poisoned: false, + }; + this.#sessions.set(handle as object, session); + this.#sessionRecords.add(session); + return handle; + } + + putVerifiedCandidateBucket(load: VerifiedCandidateBucketLoadV1): CandidateBucketPutResultV1 { + this.assertOpen(); + // A poisoned session is terminal. Inspect only the opaque local capability + // before touching any attacker-controlled head, path, bucket, or row bytes + // so malformed follow-up input cannot mask the poison outcome. + const earlySessionHandle = extractCandidateLoadSession(load); + const earlySession = this.requireSession(earlySessionHandle); + this.assertSessionUsable(earlySessionHandle, earlySession); + const encoded = this.encodeAndVerifyLoad(load); + this.assertSessionUsable(encoded.sessionHandle, encoded.session); + const headKey = bytesToHex(encoded.header.head); + const existingContext = encoded.session.headContexts.get(headKey); + if (existingContext !== undefined && !sameContext(existingContext, encoded.context)) { + this.poison(encoded.session); + throw new InventoryV1CandidateError( + 'candidate-conflict', + 'one candidate session/head cannot change scope, author, lane, era, or bucketCount', + ); + } + + let status: CandidateBucketPutResultV1['status']; + try { + status = this.writeTransaction( + 'stage verified candidate bucket', + () => this.stageExactCandidateLoad(encoded), + { + resolve: () => this.resolvePutOutcome(encoded), + retry: () => this.stageExactCandidateLoad(encoded), + resolvedCommittedResult: () => 'existing', + }, + ); + } catch (cause) { + if (cause instanceof CandidateConflictSignal || isSqliteUniqueOrPrimaryKeyConstraint(cause)) { + this.poison(encoded.session); + throw new InventoryV1CandidateError( + 'candidate-conflict', + 'candidate bytes conflict with an immutable load or head-wide uniqueness constraint', + { cause }, + ); + } + if (cause instanceof InventoryV1CandidateError) throw cause; + throw databaseError('failed to atomically stage verified candidate bucket', cause); + } + + encoded.session.headContexts.set(headKey, encoded.context); + return { + status, + loadKey: this.createLoadKey(encoded), + header: encoded.publicHeader, + }; + } + + getCandidateBucket(loadKey: CandidateBucketLoadKeyV1): CandidateBucketHeaderV1 { + this.assertOpen(); + const key = this.requireLoadKey(loadKey); + this.assertSessionUsable(key.sessionHandle, key.session); + return this.readTransaction(() => this.requireHeader(key)); + } + + beginCandidateBucketRows(loadKey: CandidateBucketLoadKeyV1): CandidateBucketRowsTraversalV1 { + this.assertOpen(); + const load = this.requireLoadKey(loadKey); + this.assertSessionUsable(load.sessionHandle, load.session); + this.readTransaction(() => this.requireHeaderAndExactChildCount(load)); + const record: RowsTraversalRecordV1 = { + kind: 'rows', + load, + sessions: [load.session], + pins: [load], + expectedCursor: null, + terminal: false, + closed: false, + closeReason: null, + }; + return this.registerTraversal(record) as CandidateBucketRowsTraversalV1; + } + + beginCandidateBucketDiff( + oldLoadKey: CandidateBucketLoadKeyV1, + newLoadKey: CandidateBucketLoadKeyV1, + ): CandidateBucketDiffTraversalV1 { + this.assertOpen(); + const oldLoad = this.requireLoadKey(oldLoadKey); + const newLoad = this.requireLoadKey(newLoadKey); + this.assertSessionUsable(oldLoad.sessionHandle, oldLoad.session); + this.assertSessionUsable(newLoad.sessionHandle, newLoad.session); + const [oldHeader, newHeader] = this.readTransaction(() => [ + this.requireHeaderAndExactChildCount(oldLoad), + this.requireHeaderAndExactChildCount(newLoad), + ] as const); + assertDiffCompatible(oldHeader, newHeader); + + const sessions = oldLoad.session === newLoad.session + ? [oldLoad.session] + : [oldLoad.session, newLoad.session]; + const pins = oldLoad.pinKey === newLoad.pinKey ? [oldLoad] : [oldLoad, newLoad]; + const record: DiffTraversalRecordV1 = { + kind: 'diff', + oldLoad, + newLoad, + sessions, + pins, + addedExpectedCursor: null, + removedExpectedCursor: null, + addedTerminal: false, + removedTerminal: false, + closed: false, + closeReason: null, + }; + return this.registerTraversal(record) as CandidateBucketDiffTraversalV1; + } + + pageCandidateBucketRows( + traversal: CandidateBucketRowsTraversalV1, + cursor: KaIdV1 | null | undefined, + limit: number, + ): CandidateBucketPageV1 { + const record = this.requireTraversal(traversal, 'rows'); + try { + this.assertTraversalSessions(record); + if (record.terminal) throw streamComplete(); + const canonicalCursor = assertCursor(cursor, record.expectedCursor); + const page = this.readTransaction(() => { + this.requireHeader(record.load); + return this.queryRowsPage(record.load, canonicalCursor, limit); + }); + if (page.rows.length === 0) { + record.terminal = true; + this.closeTraversalRecord(record); + } else { + record.expectedCursor = page.resumeAfter; + } + return page; + } catch (cause) { + this.closeTraversalRecord(record); + throw normalizeCandidateError('candidate row page failed', cause); + } + } + + pageCandidateBucketAddedOrChanged( + traversal: CandidateBucketDiffTraversalV1, + cursor: KaIdV1 | null | undefined, + limit: number, + ): CandidateBucketPageV1 { + return this.pageDiff(traversal, cursor, limit, 'added'); + } + + pageCandidateBucketRemoved( + traversal: CandidateBucketDiffTraversalV1, + cursor: KaIdV1 | null | undefined, + limit: number, + ): CandidateBucketPageV1 { + return this.pageDiff(traversal, cursor, limit, 'removed'); + } + + closeCandidateTraversal( + traversal: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, + ): void { + this.assertOpen(); + if (typeof traversal !== 'object' || traversal === null) { + throw new InventoryV1CandidateError( + 'candidate-invalid-traversal', + 'candidate traversal is not an adapter-local opaque handle', + ); + } + const record = this.#traversals.get(traversal as object); + if (record === undefined) { + throw new InventoryV1CandidateError( + 'candidate-invalid-traversal', + 'candidate traversal is not an adapter-local opaque handle', + ); + } + this.closeTraversalRecord(record); + } + + discardCandidateSessionBatch( + sessionHandle: CandidateSessionV1, + ): CandidateSessionGcBatchResultV1 { + this.assertOpen(); + const session = this.requireSession(sessionHandle); + if (!session.poisoned) { + throw new InventoryV1CandidateError( + 'candidate-session-not-poisoned', + 'only a poisoned candidate session may be discarded through this API', + ); + } + const transaction = this.writeTransaction('discard poisoned candidate session', () => { + const select = this.prepare(INVENTORY_V1_STATEMENT_SQL.discardSessionNext); + const selected = this.statement(() => select + .all({ session: session.id }) as SqlRowV1[]); + const targets = selected.map((row) => this.captureDeleteTarget(row)); + this.deleteExactTargets(targets); + return Object.freeze({ targets }); + }, { + resolve: ({ targets }) => this.resolveDeletedTargets(targets), + retry: ({ targets }) => { + this.deleteExactTargets(targets); + return Object.freeze({ targets }); + }, + }); + const result = Object.freeze({ + deletedLoads: transaction.targets.length, + done: transaction.targets.length === 0, + }); + if (result.done) { + this.#sessions.delete(sessionHandle as object); + this.#sessionRecords.delete(session); + } + return result; + } + + deleteCandidateBucket(loadKey: CandidateBucketLoadKeyV1): void { + this.assertOpen(); + const key = this.requireLoadKey(loadKey); + this.assertSessionUsable(key.sessionHandle, key.session); + if ((this.#pins.get(key.pinKey) ?? 0) !== 0) { + throw new InventoryV1CandidateError( + 'candidate-in-use', + 'candidate load is pinned by a live traversal', + ); + } + this.writeTransaction('delete candidate bucket', () => { + // Missing is a normal initial API outcome. The stricter missing/mismatch + // rules in deleteExactTargets apply only after these exact bytes have + // been captured for an indeterminate-COMMIT retry. + const storedHeader = this.getRawHeader(key.encoded); + if (storedHeader === undefined) { + throw new InventoryV1CandidateError( + 'candidate-not-loaded', + 'candidate bucket header is absent', + ); + } + if (!rawHeaderEquals(storedHeader, key.expectedHeader)) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'stored candidate header changed before delete capture', + ); + } + const target: DeleteTargetV1 = { + key: cloneKey(key.encoded), + header: snapshotEncodedHeader(storedHeader, key.encoded), + childCount: readStoredDescriptorCount(storedHeader.row_count_u64be), + }; + this.assertStoredChildCount(target.key, target.childCount); + this.deleteExactTargets([target]); + return target; + }, { + resolve: (captured) => this.resolveDeletedTargets([captured]), + retry: (captured) => { + this.deleteExactTargets([captured]); + return captured; + }, + }); + } + + /** Invalidate every process-local capability before the owning database closes. */ + close(): void { + if (this.#closed) return; + this.#closed = true; + this.#available = false; + this.invalidateTraversals(); + this.#sessionRecords.clear(); + this.#pins.clear(); + } + + private pageDiff( + traversal: CandidateBucketDiffTraversalV1, + cursor: KaIdV1 | null | undefined, + limit: number, + stream: 'added' | 'removed', + ): CandidateBucketPageV1 { + const record = this.requireTraversal(traversal, 'diff'); + try { + this.assertTraversalSessions(record); + const terminal = stream === 'added' ? record.addedTerminal : record.removedTerminal; + if (terminal) throw streamComplete(); + const expected = stream === 'added' + ? record.addedExpectedCursor + : record.removedExpectedCursor; + const canonicalCursor = assertCursor(cursor, expected); + const page = this.readTransaction(() => { + const oldHeader = this.requireHeader(record.oldLoad); + const newHeader = this.requireHeader(record.newLoad); + assertDiffCompatible(oldHeader, newHeader); + return this.queryDiffPage(record, canonicalCursor, limit, stream); + }); + if (page.rows.length === 0) { + if (stream === 'added') record.addedTerminal = true; + else record.removedTerminal = true; + if (record.addedTerminal && record.removedTerminal) this.closeTraversalRecord(record); + } else if (stream === 'added') { + record.addedExpectedCursor = page.resumeAfter; + } else { + record.removedExpectedCursor = page.resumeAfter; + } + return page; + } catch (cause) { + this.closeTraversalRecord(record); + throw normalizeCandidateError(`candidate ${stream} diff page failed`, cause); + } + } + + private queryRowsPage( + load: LoadKeyRecordV1, + cursor: KaIdV1 | null, + limit: number, + ): CandidateBucketPageV1 { + assertPageLimit(limit); + const sql = cursor === null + ? INVENTORY_V1_STATEMENT_SQL.getRowsFirst + : INVENTORY_V1_STATEMENT_SQL.getRowsNext; + const parameters: SqlParametersV1 = { + ...keyParameters(load.encoded), + limit, + }; + if (cursor !== null) parameters.afterKaIdU256be = decimalU256ToSqlBlobV1(cursor); + const query = this.prepare(sql); + const rows = this.statement(() => query.all(parameters) as SqlRowV1[]); + return decodePage(rows, load); + } + + private queryDiffPage( + traversal: DiffTraversalRecordV1, + cursor: KaIdV1 | null, + limit: number, + stream: 'added' | 'removed', + ): CandidateBucketPageV1 { + assertPageLimit(limit); + const sql = stream === 'added' + ? (cursor === null + ? INVENTORY_V1_STATEMENT_SQL.diffAddedOrChangedFirst + : INVENTORY_V1_STATEMENT_SQL.diffAddedOrChangedNext) + : (cursor === null + ? INVENTORY_V1_STATEMENT_SQL.diffRemovedFirst + : INVENTORY_V1_STATEMENT_SQL.diffRemovedNext); + const parameters: SqlParametersV1 = { + oldSession: traversal.oldLoad.encoded.session, + newSession: traversal.newLoad.encoded.session, + scope: traversal.oldLoad.encoded.scope, + author: traversal.oldLoad.encoded.author, + oldHead: traversal.oldLoad.encoded.head, + newHead: traversal.newLoad.encoded.head, + bucket: traversal.oldLoad.encoded.bucket, + limit, + }; + if (cursor !== null) parameters.afterKaIdU256be = decimalU256ToSqlBlobV1(cursor); + const query = this.prepare(sql); + const rows = this.statement(() => query.all(parameters) as SqlRowV1[]); + return decodePage(rows, stream === 'added' ? traversal.newLoad : traversal.oldLoad); + } + + private encodeAndVerifyLoad(load: VerifiedCandidateBucketLoadV1): EncodedCandidateLoadV1 { + try { + return this.encodeAndVerifyLoadUnchecked(load); + } catch (cause) { + if (cause instanceof InventoryV1CandidateError) throw cause; + throw new InventoryV1CandidateError( + 'candidate-invalid-load', + 'candidate load failed frozen head, descriptor, bucket, or row verification', + { cause }, + ); + } + } + + private encodeAndVerifyLoadUnchecked( + load: VerifiedCandidateBucketLoadV1, + ): EncodedCandidateLoadV1 { + const loadRecord = exactPlainRecord(load, ['bucket', 'descriptor', 'head', 'session'], 'load'); + const sessionHandle = loadRecord.session as CandidateSessionV1; + const session = this.requireSession(sessionHandle); + assertSignedAuthorCatalogHeadEnvelopeV1( + loadRecord.head as SignedAuthorCatalogHeadEnvelopeV1, + ); + const head = loadRecord.head as SignedAuthorCatalogHeadEnvelopeV1; + const scope = deriveAuthorCatalogScopeFromHeadV1(head.payload); + const headDigest = head.objectDigest as Digest32V1; + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const descriptor = verifyDescriptor(loadRecord.descriptor); + if (BigInt(descriptor.bucketId) >= BigInt(scope.bucketCount)) { + invalidLoad('descriptor bucketId is outside the head bucket domain'); + } + + const descriptorRows = Number(parseCanonicalDecimalU64(descriptor.rowCount, 'rowCount')); + const descriptorBytes = Number(parseCanonicalDecimalU64(descriptor.byteLength, 'byteLength')); + const empty = descriptor.bucketDigest === ZERO_DIGEST32_V1; + let rows: readonly AuthorCatalogRowV1[]; + if (empty) { + if (descriptorRows !== 0 || descriptorBytes !== 0 || loadRecord.bucket !== null) { + invalidLoad('canonical empty bucket requires zero descriptor fields and no bucket object'); + } + rows = []; + } else { + if (descriptorRows < 1 || descriptorRows > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) { + invalidLoad('non-empty descriptor rowCount must be in 1..1024'); + } + if (descriptorBytes < 1 || descriptorBytes > MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1) { + invalidLoad('non-empty descriptor byteLength must be in 1..1048576'); + } + if (loadRecord.bucket === null) invalidLoad('non-empty descriptor requires a bucket object'); + assertSignedAuthorCatalogBucketEnvelopeV1( + loadRecord.bucket as SignedAuthorCatalogBucketEnvelopeV1, + ); + const bucket = loadRecord.bucket as SignedAuthorCatalogBucketEnvelopeV1; + if (bucket.objectDigest !== descriptor.bucketDigest) { + invalidLoad('bucket objectDigest does not equal the verified directory descriptor'); + } + assertAuthorCatalogBucketV1(bucket.payload); + assertAuthorCatalogBucketScopeBindingV1(bucket.payload, scope); + if (bucket.payload.bucketId !== descriptor.bucketId) { + invalidLoad('bucket payload bucketId does not equal the verified directory descriptor'); + } + const canonicalLength = canonicalizeAuthorCatalogBucketPayloadBytesV1( + bucket.payload, + ).byteLength; + if (canonicalLength !== descriptorBytes || bucket.payload.rows.length !== descriptorRows) { + invalidLoad('bucket canonical byte length or child count does not equal its descriptor'); + } + rows = bucket.payload.rows; + } + + const encodedKey: EncodedLoadKeyV1 = { + session: session.id.slice(), + scope: digest32ToSqlBlobV1(scopeDigest), + author: evmAddressToSqlBlobV1(scope.authorAddress), + head: digest32ToSqlBlobV1(headDigest), + bucket: decimalU64ToSqlBlobV1(descriptor.bucketId), + }; + const header: EncodedHeaderV1 = { + ...encodedKey, + subgraphName: scope.subGraphName, + era: decimalU64ToSqlBlobV1(scope.era), + bucketCount: decimalU64ToSqlBlobV1(scope.bucketCount), + bucketObjectDigest: digest32ToSqlBlobV1(descriptor.bucketDigest), + rowCount: decimalU64ToSqlBlobV1(descriptor.rowCount), + payloadByteLength: decimalU64ToSqlBlobV1(descriptor.byteLength), + }; + const encodedRows = rows.map((row): EncodedRowV1 => ({ + ...encodedKey, + kaId: decimalU256ToSqlBlobV1(row.kaId), + catalogKeyDigest: digest32ToSqlBlobV1(computeAuthorCatalogKeyDigestV1(row.kaId)), + assertionCoordinate: row.assertionCoordinate, + assertionVersion: decimalU64ToSqlBlobV1(row.assertionVersion), + projectionId: row.projectionId, + projectionDigest: digest32ToSqlBlobV1(row.projectionDigest), + sealDigest: digest32ToSqlBlobV1(row.sealDigest), + transferCodec: row.transfer.codec, + transferByteLength: decimalU64ToSqlBlobV1(row.transfer.byteLength), + transferChunkSize: decimalU64ToSqlBlobV1( + row.transfer.chunkSize as DecimalU64V1, + ), + transferChunkCount: decimalU64ToSqlBlobV1(row.transfer.chunkCount), + transferBlobDigest: digest32ToSqlBlobV1(row.transfer.blobDigest), + transferChunkTreeRoot: digest32ToSqlBlobV1(row.transfer.chunkTreeRoot), + expectedCatalogRowDigest: digest32ToSqlBlobV1( + computeAuthorCatalogRowDigestV1(scopeDigest, row), + ), + })); + const publicHeader: CandidateBucketHeaderV1 = Object.freeze({ + catalogScopeDigest: scopeDigest, + authorAddress: scope.authorAddress, + targetCatalogHeadDigest: headDigest, + subGraphName: scope.subGraphName, + catalogEra: scope.era, + bucketCount: scope.bucketCount, + bucketId: descriptor.bucketId, + bucketObjectDigest: descriptor.bucketDigest, + rowCount: descriptor.rowCount, + payloadByteLength: descriptor.byteLength, + }); + return { + sessionHandle, + session, + header, + rows: encodedRows, + publicHeader, + scope: Object.freeze({ ...scope }), + context: { + scopeHex: bytesToHex(header.scope), + authorHex: bytesToHex(header.author), + subGraphName: header.subgraphName, + eraHex: bytesToHex(header.era), + bucketCountHex: bytesToHex(header.bucketCount), + }, + }; + } + + private createLoadKey(load: EncodedCandidateLoadV1): CandidateBucketLoadKeyV1 { + const handle = Object.freeze({}) as CandidateBucketLoadKeyV1; + const encoded = cloneKey(load.header); + this.#loadKeys.set(handle as object, { + sessionHandle: load.sessionHandle, + session: load.session, + encoded, + expectedHeader: cloneHeader(load.header), + scope: Object.freeze({ ...load.scope }), + pinKey: pinKey(encoded), + }); + return handle; + } + + private requireSession(handle: CandidateSessionV1): SessionRecordV1 { + if (typeof handle !== 'object' || handle === null) { + throw invalidSession(); + } + const session = this.#sessions.get(handle as object); + if (session === undefined) throw invalidSession(); + return session; + } + + private requireLoadKey(handle: CandidateBucketLoadKeyV1): LoadKeyRecordV1 { + if (typeof handle !== 'object' || handle === null) { + throw new InventoryV1CandidateError( + 'candidate-invalid-load-key', + 'candidate load key is not an adapter-local opaque handle', + ); + } + const key = this.#loadKeys.get(handle as object); + if (key === undefined) { + throw new InventoryV1CandidateError( + 'candidate-invalid-load-key', + 'candidate load key is not an adapter-local opaque handle', + ); + } + return key; + } + + private requireTraversal( + handle: object, + kind: TKind, + ): Extract { + this.assertOpen(); + if (typeof handle !== 'object' || handle === null) { + throw new InventoryV1CandidateError( + 'candidate-invalid-traversal', + 'candidate traversal is not an adapter-local opaque handle', + ); + } + const traversal = this.#traversals.get(handle); + if (traversal === undefined || traversal.kind !== kind) { + throw new InventoryV1CandidateError( + 'candidate-invalid-traversal', + `candidate traversal is not an adapter-local ${kind} handle`, + ); + } + if ( + traversal.closeReason === 'poisoned' + || traversal.sessions.some((session) => session.poisoned) + ) { + throw new InventoryV1CandidateError( + 'candidate-session-poisoned', + 'candidate traversal references a poisoned session', + ); + } + if (traversal.closed) { + throw new InventoryV1CandidateError( + 'candidate-traversal-closed', + 'candidate traversal is closed', + ); + } + return traversal as Extract; + } + + private requireHeader(key: LoadKeyRecordV1): CandidateBucketHeaderV1 { + const row = this.getRawHeader(key.encoded); + if (row === undefined) { + throw new InventoryV1CandidateError( + 'candidate-not-loaded', + 'candidate bucket header is absent', + ); + } + if (!rawHeaderEquals(row, key.expectedHeader)) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'stored candidate header no longer equals its verified directory-path load', + ); + } + const header = decodeHeader(row, key); + assertVerifiedHeaderScopeBinding(header, key); + return header; + } + + private requireHeaderAndExactChildCount( + key: LoadKeyRecordV1, + ): CandidateBucketHeaderV1 { + const header = this.requireHeader(key); + this.assertStoredChildCount(key.encoded, Number(BigInt(header.rowCount))); + return header; + } + + private getRawHeader(key: EncodedLoadKeyV1): SqlRowV1 | undefined { + const query = this.prepare(INVENTORY_V1_STATEMENT_SQL.getHeader); + return this.statement(() => query + .get({ ...keyParameters(key) }) as SqlRowV1 | undefined); + } + + private captureDeleteTarget(row: SqlRowV1): DeleteTargetV1 { + const key: EncodedLoadKeyV1 = { + session: assertSqlKeyBlob(row.session_id, 32, 'selected session_id'), + scope: assertSqlKeyBlob(row.catalog_scope_digest, 32, 'selected catalog_scope_digest'), + author: assertSqlKeyBlob(row.author_address, 20, 'selected author_address'), + head: assertSqlKeyBlob(row.target_catalog_head_digest, 32, 'selected target head'), + bucket: assertSqlKeyBlob(row.bucket_id_u64be, 8, 'selected bucket_id'), + }; + // Keep the bounded selector byte-for-byte aligned with the frozen RFC. + // Re-read the exact immutable header inside the same transaction before + // trusting its signed child-count projection for the cascade bound. + const storedHeader = this.getRawHeader(key); + if (storedHeader === undefined) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'bounded candidate GC selection lost its exact header', + ); + } + const header = snapshotEncodedHeader(storedHeader, key); + const childCount = readStoredDescriptorCount(header.rowCount); + this.assertStoredChildCount(key, childCount); + return Object.freeze({ key: cloneKey(key), header, childCount }); + } + + private deleteExactTargets(targets: readonly DeleteTargetV1[]): void { + const remove = this.prepare(INVENTORY_V1_STATEMENT_SQL.deleteHeader); + for (const target of targets) { + const storedHeader = this.getRawHeader(target.key); + if (storedHeader === undefined) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'exact candidate delete target disappeared before its bounded retry', + ); + } + if (!rawHeaderEquals(storedHeader, target.header)) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'exact candidate delete target changed before its bounded retry', + ); + } + this.assertStoredChildCount(target.key, target.childCount); + const result = this.statement(() => remove.run(keyParameters(target.key))); + if (Number(result.changes) !== 1) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'bounded candidate delete did not remove exactly one immutable header', + ); + } + } + } + + private resolveDeletedTargets( + targets: readonly DeleteTargetV1[], + ): IndeterminateCommitResolutionV1 { + if (targets.length === 0) return 'committed'; + let absent = 0; + let present = 0; + for (const target of targets) { + const storedHeader = this.getRawHeader(target.key); + if (storedHeader === undefined) { + absent += 1; + continue; + } + present += 1; + if (!rawHeaderEquals(storedHeader, target.header)) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'indeterminate candidate delete resolved to a mismatched exact-key header', + ); + } + this.assertStoredChildCount(target.key, target.childCount); + } + if (absent === targets.length) return 'committed'; + if (present === targets.length) return 'not-committed'; + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'indeterminate bounded candidate delete resolved to a mixed key set', + ); + } + + private stageExactCandidateLoad( + encoded: EncodedCandidateLoadV1, + ): CandidateBucketPutResultV1['status'] { + const storedHeader = this.getRawHeader(encoded.header); + if (storedHeader !== undefined) { + if (!this.exactCandidateLoadMatches(encoded, storedHeader)) { + throw new CandidateConflictSignal('same load key has different immutable bytes'); + } + return 'existing'; + } + const insertHeader = this.prepare(INVENTORY_V1_STATEMENT_SQL.insertHeader); + this.statement(() => insertHeader.run(headerParameters(encoded.header))); + const insertRow = this.prepare(INVENTORY_V1_STATEMENT_SQL.insertRow); + for (const row of encoded.rows) { + this.statement(() => insertRow.run(rowParameters(row))); + } + this.assertStoredChildCount(encoded.header, encoded.rows.length); + return 'inserted'; + } + + private exactCandidateLoadMatches( + encoded: EncodedCandidateLoadV1, + storedHeader = this.getRawHeader(encoded.header), + ): boolean { + if (storedHeader === undefined || !rawHeaderEquals(storedHeader, encoded.header)) return false; + const selectExactRows = this.prepare(INVENTORY_V1_STATEMENT_SQL.getRowsExactRetry); + const storedRows = this.statement(() => selectExactRows.all({ + ...keyParameters(encoded.header), + }) as SqlRowV1[]); + return storedRows.length === encoded.rows.length + && storedRows.every((row, index) => rawRowEquals(row, encoded.rows[index]!)); + } + + private resolvePutOutcome( + encoded: EncodedCandidateLoadV1, + ): IndeterminateCommitResolutionV1 { + const storedHeader = this.getRawHeader(encoded.header); + if (storedHeader === undefined) return 'not-committed'; + if (this.exactCandidateLoadMatches(encoded, storedHeader)) return 'committed'; + throw new CandidateConflictSignal( + 'indeterminate candidate put resolved to partial or mismatched immutable bytes', + ); + } + + private assertStoredChildCount(key: EncodedLoadKeyV1, expectedCount: number): void { + const countRows = this.prepare(INVENTORY_V1_STATEMENT_SQL.countBucketRows); + const countRow = this.statement(() => countRows + .get({ ...keyParameters(key) }) as SqlRowV1 | undefined); + const actualCount = readSafeCount(countRow?.row_count); + if ( + actualCount !== expectedCount + || actualCount > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 + ) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'candidate cascade child count is inconsistent or exceeds the 1024-row bound', + ); + } + } + + private readTransaction(operation: () => T): T { + this.assertOpen(); + const transactionStartedAt = this.monotonicNow(); + try { + this.statement(() => this.database.exec('BEGIN')); + } catch (cause) { + const rollback = rollbackIfOpen(this.database, this.monotonicNow); + const overBudget = cause instanceof LatencyBudgetSignal + || rollback.overran + || this.monotonicNow() - transactionStartedAt > WRITE_TRANSACTION_LATENCY_BUDGET_MS; + if (rollback.failed || overBudget) this.requireReopen(); + if (overBudget) throw latencyBudgetError(cause); + throw databaseError('candidate read transaction failed to begin', cause); + } + + let result: T; + try { + result = operation(); + } catch (cause) { + const rollback = rollbackIfOpen(this.database, this.monotonicNow); + const overBudget = cause instanceof LatencyBudgetSignal + || rollback.overran + || this.monotonicNow() - transactionStartedAt > WRITE_TRANSACTION_LATENCY_BUDGET_MS; + if (rollback.failed || overBudget) this.requireReopen(); + if (overBudget) throw latencyBudgetError(cause); + if (cause instanceof InventoryV1CandidateError) throw cause; + throw databaseError('candidate read transaction failed', cause); + } + + try { + this.statement(() => this.database.exec('COMMIT')); + } catch (cause) { + // Even though a read-only COMMIT has no durable write outcome, its + // low-level connection state is indeterminate. Discard the read result, + // invalidate traversals, and require the caller to retry after a verified + // reopen rather than continuing on that handle. + rollbackIfOpen(this.database, this.monotonicNow); + this.requireReopen(); + if (cause instanceof LatencyBudgetSignal) throw latencyBudgetError(cause); + throw databaseError('candidate read COMMIT failed after verified reopen', cause); + } + return result; + } + + private writeTransaction( + label: string, + operation: () => T, + indeterminate?: IndeterminateCommitStrategyV1, + attempt = 0, + writeDeadline = this.monotonicNow() + WRITE_TRANSACTION_LATENCY_BUDGET_MS, + ): T { + this.assertOpen(); + const ownsDeadline = this.#activeWriteDeadline === null; + if (ownsDeadline) this.#activeWriteDeadline = writeDeadline; + else if (this.#activeWriteDeadline !== writeDeadline) { + throw databaseError(`${label} attempted to replace the active write deadline`, undefined); + } + const transactionStartedAt = this.monotonicNow(); + try { + try { + this.statement(() => this.database.exec('BEGIN IMMEDIATE')); + } catch (cause) { + const rollback = rollbackIfOpen(this.database, this.monotonicNow, writeDeadline); + const overBudget = cause instanceof LatencyBudgetSignal || rollback.overran; + if (rollback.failed || overBudget) { + try { + this.requireReopen(writeDeadline); + } catch (reopenCause) { + if (overBudget) throw latencyBudgetError(cause); + throw reopenCause; + } + } + if (overBudget) throw latencyBudgetError(cause); + throw databaseError(`${label} failed to begin`, cause); + } + + let result: T; + try { + this.assertWriteDeadline('before write operation'); + result = operation(); + this.assertWriteDeadline('after write operation'); + } catch (cause) { + const rollback = rollbackIfOpen(this.database, this.monotonicNow, writeDeadline); + const overBudget = cause instanceof LatencyBudgetSignal || rollback.overran; + if (rollback.failed || overBudget) { + try { + this.requireReopen(writeDeadline); + } catch (reopenCause) { + if (overBudget) throw latencyBudgetError(cause); + throw reopenCause; + } + } + if (overBudget) throw latencyBudgetError(cause); + if ( + cause instanceof InventoryV1CandidateError + || cause instanceof CandidateConflictSignal + || isSqliteUniqueOrPrimaryKeyConstraint(cause) + ) { + throw cause; + } + throw databaseError(`${label} failed before commit`, cause); + } + + const commitStartedAt = this.monotonicNow(); + try { + this.statement(() => this.database.exec('COMMIT')); + } catch (cause) { + if (cause instanceof LatencyBudgetSignal && cause.callCompleted) { + // The synchronous COMMIT call returned successfully; only the + // post-call budget check failed. Its durable outcome is therefore + // known committed even if the mandatory verified reopen later fails. + this.#committedOverruns += 1; + this.reopenAfterKnownCommit(writeDeadline); + return result; + } + // Never issue ROLLBACK after an indeterminate COMMIT. Close the low-level + // handle, perform the foundation's full verified reopen, and inspect only + // the immutable key set captured by the failed attempt. + this.requireReopen(writeDeadline); + if (indeterminate === undefined) { + throw databaseError( + `${label} COMMIT failed without an exact-key outcome resolver`, + cause, + ); + } + let resolution: IndeterminateCommitResolutionV1; + try { + this.assertWriteDeadline('before indeterminate COMMIT resolution'); + resolution = indeterminate.resolve(result); + this.assertWriteDeadline('after indeterminate COMMIT resolution'); + } catch (resolutionCause) { + if (resolutionCause instanceof LatencyBudgetSignal) { + this.requireReopen(writeDeadline); + throw latencyBudgetError(resolutionCause); + } + throw resolutionCause; + } + if (resolution === 'committed') { + if (cause instanceof LatencyBudgetSignal) this.#committedOverruns += 1; + return indeterminate.resolvedCommittedResult?.(result) ?? result; + } + this.assertWriteDeadline('before exact-key write retry'); + if (attempt !== 0) { + throw databaseError( + `${label} COMMIT remained not-committed after one exact-key retry`, + cause, + ); + } + return this.writeTransaction( + label, + () => { + this.assertWriteDeadline('before exact-key retry callback'); + const retried = indeterminate.retry(result); + this.assertWriteDeadline('after exact-key retry callback'); + return retried; + }, + indeterminate, + attempt + 1, + writeDeadline, + ); + } + const commitElapsed = this.monotonicNow() - commitStartedAt; + const transactionElapsed = this.monotonicNow() - transactionStartedAt; + if ( + commitElapsed > STATEMENT_LATENCY_BUDGET_MS + || transactionElapsed >= WRITE_TRANSACTION_LATENCY_BUDGET_MS + ) { + this.#committedOverruns += 1; + this.reopenAfterKnownCommit(writeDeadline); + } + return result; + } finally { + if (ownsDeadline) this.#activeWriteDeadline = null; + } + } + + private statement(operation: () => T): T { + this.assertWriteDeadline('before SQLite call'); + const startedAt = this.monotonicNow(); + try { + const result = operation(); + const completedAt = this.monotonicNow(); + if (completedAt - startedAt > STATEMENT_LATENCY_BUDGET_MS) { + throw new LatencyBudgetSignal('SQLite statement exceeded 10000 ms', { + callCompleted: true, + }); + } + this.assertWriteDeadline('after SQLite call', completedAt, true); + return result; + } catch (cause) { + if (cause instanceof LatencyBudgetSignal) throw cause; + if (this.monotonicNow() - startedAt > STATEMENT_LATENCY_BUDGET_MS) { + throw new LatencyBudgetSignal('failed SQLite statement exceeded 10000 ms', { cause }); + } + this.assertWriteDeadline('after failed SQLite call'); + throw cause; + } + } + + private prepare(sql: string): StatementSync { + return this.statement(() => this.database.prepare(sql)); + } + + private requireReopen(writeDeadline = this.#activeWriteDeadline): void { + if (this.#closed || !this.#available) { + throw new InventoryV1CandidateError( + 'candidate-database-error', + 'candidate inventory is unavailable and cannot reopen', + ); + } + this.invalidateTraversals(); + const previous = this.database; + // Mark unavailable before invoking external lifecycle code. No exception, + // identity return, malformed handle, or failed verification can leave this + // adapter usable on the abandoned connection. + this.#available = false; + let reopened: DatabaseSync | null = null; + try { + const beforeReopen = this.monotonicNow(); + reopened = this.onRequireReopen(previous); + if (reopened === previous) { + throw new Error('verified reopen provider returned the abandoned low-level handle'); + } + const afterReopen = this.monotonicNow(); + assertReopenDeadline(beforeReopen, writeDeadline, 'before verified reopen'); + assertReopenDeadline(afterReopen, writeDeadline, 'after verified reopen'); + assertUsableReopenedDatabase(reopened, this.monotonicNow, writeDeadline); + this.database = reopened; + this.#available = true; + } catch (cause) { + // A lifecycle owner may attach the replacement before returning it. + // Detach first and close second so a rejected identity/probe/deadline + // result can never leave the foundation pointing at a stale handle. + if (reopened !== null) { + try { this.onRejectReopened(reopened); } catch { /* remain unavailable */ } + } + // Direct/internal providers are required to abandon the prior handle, + // but a malformed provider might return before doing so. + try { previous.close(); } catch { /* remain unavailable */ } + if (cause instanceof LatencyBudgetSignal) throw latencyBudgetError(cause); + if ( + cause instanceof InventoryV1CandidateError + && cause.code === 'latency-budget-exceeded' + ) { + throw cause; + } + throw new InventoryV1CandidateError( + 'candidate-database-error', + 'verified low-level inventory reopen failed; the foundation remains unavailable', + { cause }, + ); + } + } + + /** + * A successful COMMIT is authoritative even if the mandatory post-overrun + * reopen fails. Preserve its result, but make every subsequent API fail + * closed through #available/the owning foundation's closed connection. + */ + private reopenAfterKnownCommit(writeDeadline: number): void { + try { + this.requireReopen(writeDeadline); + } catch { + // requireReopen already marked the candidate adapter unavailable and the + // foundation callback closed/detached the low-level connection. + } + } + + private invalidateTraversals(): void { + for (const session of this.#sessionRecords) { + for (const traversal of [...session.traversals]) { + this.closeTraversalRecord(traversal, 'reopen'); + } + } + } + + private registerTraversal(record: TraversalRecordV1): object { + const handle = Object.freeze({}); + this.#traversals.set(handle, record); + for (const session of record.sessions) session.traversals.add(record); + for (const pin of record.pins) { + this.#pins.set(pin.pinKey, (this.#pins.get(pin.pinKey) ?? 0) + 1); + } + return handle; + } + + private closeTraversalRecord( + record: TraversalRecordV1, + reason: Exclude = 'normal', + ): void { + if (record.closed) return; + record.closed = true; + record.closeReason = reason; + for (const session of record.sessions) session.traversals.delete(record); + for (const pin of record.pins) { + const count = this.#pins.get(pin.pinKey) ?? 0; + if (count <= 1) this.#pins.delete(pin.pinKey); + else this.#pins.set(pin.pinKey, count - 1); + } + } + + private assertSessionUsable(handle: CandidateSessionV1, session: SessionRecordV1): void { + if (this.#sessions.get(handle as object) !== session) throw invalidSession(); + if (session.poisoned) { + throw new InventoryV1CandidateError( + 'candidate-session-poisoned', + 'candidate session is poisoned and must be abandoned', + ); + } + } + + private assertTraversalSessions(record: TraversalRecordV1): void { + for (const session of record.sessions) { + if (session.poisoned) { + throw new InventoryV1CandidateError( + 'candidate-session-poisoned', + 'candidate traversal references a poisoned session', + ); + } + } + } + + private poison(session: SessionRecordV1): void { + if (session.poisoned) return; + session.poisoned = true; + for (const traversal of [...session.traversals]) { + this.closeTraversalRecord(traversal, 'poisoned'); + } + } + + private assertOpen(): void { + if (this.#closed || !this.#available) { + throw new InventoryV1CandidateError( + 'candidate-database-error', + this.#closed + ? 'candidate inventory adapter is closed' + : 'candidate inventory adapter is unavailable after failed verified reopen', + ); + } + } + + private assertWriteDeadline( + label: string, + now = this.monotonicNow(), + callCompleted = false, + ): void { + if (this.#activeWriteDeadline !== null && now >= this.#activeWriteDeadline) { + throw new LatencyBudgetSignal(`${label} reached the absolute 30000 ms write deadline`, { + callCompleted, + }); + } + } +} + +class CandidateConflictSignal extends Error {} +class LatencyBudgetSignal extends Error { + readonly callCompleted: boolean; + + constructor( + message: string, + options: ErrorOptions & { readonly callCompleted?: boolean } = {}, + ) { + super(message, options); + this.name = 'LatencyBudgetSignal'; + this.callCompleted = options.callCompleted ?? false; + } +} + +function verifyDescriptor(value: unknown): VerifiedCandidateBucketDescriptorV1 { + const descriptor = exactPlainRecord( + value, + ['bucketDigest', 'bucketId', 'byteLength', 'rowCount'], + 'descriptor', + ); + const bucketId = canonicalU64(descriptor.bucketId, 'bucketId'); + const rowCount = canonicalU64(descriptor.rowCount, 'rowCount'); + const byteLength = canonicalU64(descriptor.byteLength, 'byteLength'); + const bucketDigest = descriptor.bucketDigest; + if (typeof bucketDigest !== 'string' || !/^0x[0-9a-f]{64}$/.test(bucketDigest)) { + invalidLoad('descriptor bucketDigest must be a canonical Digest32V1'); + } + return { + bucketId, + rowCount, + byteLength, + bucketDigest: bucketDigest as Digest32V1, + } as VerifiedCandidateBucketDescriptorV1; +} + +function extractCandidateLoadSession(load: unknown): CandidateSessionV1 { + if (typeof load !== 'object' || load === null || Object.getPrototypeOf(load) !== Object.prototype) { + invalidLoad('load must be a plain object'); + } + const descriptor = Object.getOwnPropertyDescriptor(load, 'session'); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + invalidLoad('load.session must be an enumerable data property'); + } + return descriptor.value as CandidateSessionV1; +} + +function canonicalU64(value: unknown, label: string): DecimalU64V1 { + try { + return parseCanonicalDecimalU64(value, label).toString() as DecimalU64V1; + } catch (cause) { + throw new InventoryV1CandidateError( + 'candidate-invalid-load', + `${label} is not a canonical DecimalU64V1`, + { cause }, + ); + } +} + +function exactPlainRecord( + value: unknown, + expectedKeys: readonly string[], + label: string, +): Record { + if (typeof value !== 'object' || value === null || Object.getPrototypeOf(value) !== Object.prototype) { + invalidLoad(`${label} must be a plain object`); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length + || keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + invalidLoad(`${label} has an invalid field set`); + } + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + invalidLoad(`${label}.${key} must be an enumerable data property`); + } + } + return value as Record; +} + +function cloneKey(key: EncodedLoadKeyV1): EncodedLoadKeyV1 { + return { + session: key.session.slice(), + scope: key.scope.slice(), + author: key.author.slice(), + head: key.head.slice(), + bucket: key.bucket.slice(), + }; +} + +function cloneHeader(header: EncodedHeaderV1): EncodedHeaderV1 { + return { + ...cloneKey(header), + subgraphName: header.subgraphName, + era: header.era.slice(), + bucketCount: header.bucketCount.slice(), + bucketObjectDigest: header.bucketObjectDigest.slice(), + rowCount: header.rowCount.slice(), + payloadByteLength: header.payloadByteLength.slice(), + }; +} + +function snapshotEncodedHeader( + row: SqlRowV1, + key: EncodedLoadKeyV1, +): EncodedHeaderV1 { + const subgraphName = row.subgraph_name; + if (subgraphName !== null && typeof subgraphName !== 'string') { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'stored candidate subgraph_name is neither text nor NULL', + ); + } + return { + ...cloneKey(key), + subgraphName, + era: assertSqlKeyBlob(row.catalog_era_u64be, 8, 'stored catalog era'), + bucketCount: assertSqlKeyBlob(row.bucket_count_u64be, 8, 'stored bucket count'), + bucketObjectDigest: assertSqlKeyBlob( + row.bucket_object_digest, + 32, + 'stored bucket object digest', + ), + rowCount: assertSqlKeyBlob(row.row_count_u64be, 8, 'stored row count'), + payloadByteLength: assertSqlKeyBlob( + row.payload_byte_length_u64be, + 8, + 'stored payload byte length', + ), + }; +} + +function keyParameters(key: EncodedLoadKeyV1): SqlParametersV1 { + return { + session: key.session, + scope: key.scope, + author: key.author, + head: key.head, + bucket: key.bucket, + }; +} + +function headerParameters(header: EncodedHeaderV1): SqlParametersV1 { + return { + ...keyParameters(header), + subgraphName: header.subgraphName, + era: header.era, + bucketCount: header.bucketCount, + bucketObjectDigest: header.bucketObjectDigest, + rowCount: header.rowCount, + payloadByteLength: header.payloadByteLength, + }; +} + +function rowParameters(row: EncodedRowV1): SqlParametersV1 { + return { + ...keyParameters(row), + kaId: row.kaId, + catalogKeyDigest: row.catalogKeyDigest, + assertionCoordinate: row.assertionCoordinate, + assertionVersion: row.assertionVersion, + projectionId: row.projectionId, + projectionDigest: row.projectionDigest, + sealDigest: row.sealDigest, + transferCodec: row.transferCodec, + transferByteLength: row.transferByteLength, + transferChunkSize: row.transferChunkSize, + transferChunkCount: row.transferChunkCount, + transferBlobDigest: row.transferBlobDigest, + transferChunkTreeRoot: row.transferChunkTreeRoot, + expectedCatalogRowDigest: row.expectedCatalogRowDigest, + }; +} + +function rawHeaderEquals(row: SqlRowV1, header: EncodedHeaderV1): boolean { + return sqlBlobsEqualV1(row.session_id, header.session) + && sqlBlobsEqualV1(row.catalog_scope_digest, header.scope) + && sqlBlobsEqualV1(row.author_address, header.author) + && sqlBlobsEqualV1(row.target_catalog_head_digest, header.head) + && row.subgraph_name === header.subgraphName + && sqlBlobsEqualV1(row.catalog_era_u64be, header.era) + && sqlBlobsEqualV1(row.bucket_count_u64be, header.bucketCount) + && sqlBlobsEqualV1(row.bucket_id_u64be, header.bucket) + && sqlBlobsEqualV1(row.bucket_object_digest, header.bucketObjectDigest) + && sqlBlobsEqualV1(row.row_count_u64be, header.rowCount) + && sqlBlobsEqualV1(row.payload_byte_length_u64be, header.payloadByteLength); +} + +function rawRowEquals(row: SqlRowV1, expected: EncodedRowV1): boolean { + return sqlBlobsEqualV1(row.session_id, expected.session) + && sqlBlobsEqualV1(row.catalog_scope_digest, expected.scope) + && sqlBlobsEqualV1(row.author_address, expected.author) + && sqlBlobsEqualV1(row.target_catalog_head_digest, expected.head) + && sqlBlobsEqualV1(row.bucket_id_u64be, expected.bucket) + && sqlBlobsEqualV1(row.ka_id_u256be, expected.kaId) + && sqlBlobsEqualV1(row.catalog_key_digest, expected.catalogKeyDigest) + && row.assertion_coordinate === expected.assertionCoordinate + && sqlBlobsEqualV1(row.assertion_version_u64be, expected.assertionVersion) + && row.projection_id === expected.projectionId + && sqlBlobsEqualV1(row.projection_digest, expected.projectionDigest) + && sqlBlobsEqualV1(row.seal_digest, expected.sealDigest) + && row.transfer_codec === expected.transferCodec + && sqlBlobsEqualV1(row.transfer_byte_length_u64be, expected.transferByteLength) + && sqlBlobsEqualV1(row.transfer_chunk_size_u64be, expected.transferChunkSize) + && sqlBlobsEqualV1(row.transfer_chunk_count_u64be, expected.transferChunkCount) + && sqlBlobsEqualV1(row.transfer_blob_digest, expected.transferBlobDigest) + && sqlBlobsEqualV1(row.transfer_chunk_tree_root, expected.transferChunkTreeRoot) + && sqlBlobsEqualV1(row.expected_catalog_row_digest, expected.expectedCatalogRowDigest); +} + +function decodeHeader(row: SqlRowV1, key: LoadKeyRecordV1): CandidateBucketHeaderV1 { + try { + if ( + !sqlBlobsEqualV1(row.session_id, key.encoded.session) + || !sqlBlobsEqualV1(row.catalog_scope_digest, key.encoded.scope) + || !sqlBlobsEqualV1(row.author_address, key.encoded.author) + || !sqlBlobsEqualV1(row.target_catalog_head_digest, key.encoded.head) + || !sqlBlobsEqualV1(row.bucket_id_u64be, key.encoded.bucket) + ) { + throw new Error('header primary-key bytes do not match the requested load key'); + } + const subGraphName = row.subgraph_name; + if (subGraphName !== null) { + assertSubGraphNameV1(subGraphName, 'stored subGraphName'); + } + const bucketCount = sqlBlobToDecimalU64V1(row.bucket_count_u64be) as CountV1; + assertAuthorCatalogBucketCountV1(bucketCount); + const bucketId = sqlBlobToDecimalU64V1(row.bucket_id_u64be); + if (BigInt(bucketId) >= BigInt(bucketCount)) throw new Error('stored bucketId is out of range'); + const rowCount = sqlBlobToDecimalU64V1(row.row_count_u64be) as CountV1; + const payloadByteLength = sqlBlobToDecimalU64V1(row.payload_byte_length_u64be) as ByteLengthV1; + const bucketObjectDigest = sqlBlobToDigest32V1(row.bucket_object_digest); + const empty = bucketObjectDigest === ZERO_DIGEST32_V1; + if ( + empty !== (rowCount === '0' && payloadByteLength === '0') + || BigInt(rowCount) > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) + || BigInt(payloadByteLength) > BigInt(MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1) + ) { + throw new Error('stored bucket descriptor is not canonical'); + } + return Object.freeze({ + catalogScopeDigest: sqlBlobToDigest32V1(row.catalog_scope_digest), + authorAddress: sqlBlobToEvmAddressV1(row.author_address), + targetCatalogHeadDigest: sqlBlobToDigest32V1(row.target_catalog_head_digest), + subGraphName: subGraphName as SubGraphNameV1 | null, + catalogEra: sqlBlobToDecimalU64V1(row.catalog_era_u64be), + bucketCount, + bucketId, + bucketObjectDigest, + rowCount, + payloadByteLength, + }); + } catch (cause) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'stored candidate header violates the frozen SQL-1 codec', + { cause }, + ); + } +} + +function assertVerifiedHeaderScopeBinding( + header: CandidateBucketHeaderV1, + key: LoadKeyRecordV1, +): void { + const recomputedScopeDigest = computeAuthorCatalogScopeDigestV1(key.scope); + const expectedHeadDigest = sqlBlobToDigest32V1(key.expectedHeader.head); + if ( + header.catalogScopeDigest !== recomputedScopeDigest + || header.authorAddress !== key.scope.authorAddress + || header.subGraphName !== key.scope.subGraphName + || header.catalogEra !== key.scope.era + || header.bucketCount !== key.scope.bucketCount + || header.targetCatalogHeadDigest !== expectedHeadDigest + ) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'stored candidate header is not bound to the verified head-derived catalog scope', + ); + } +} + +function decodePage(rows: readonly SqlRowV1[], key: LoadKeyRecordV1): CandidateBucketPageV1 { + const decoded = rows.map((row) => decodeRow(row, key)); + return Object.freeze({ + rows: Object.freeze(decoded), + resumeAfter: decoded.length === 0 ? null : decoded[decoded.length - 1]!.row.kaId, + }); +} + +function decodeRow(row: SqlRowV1, key: LoadKeyRecordV1): CandidateBucketRowV1 { + try { + if ( + !sqlBlobsEqualV1(row.session_id, key.encoded.session) + || !sqlBlobsEqualV1(row.catalog_scope_digest, key.encoded.scope) + || !sqlBlobsEqualV1(row.author_address, key.encoded.author) + || !sqlBlobsEqualV1(row.target_catalog_head_digest, key.encoded.head) + || !sqlBlobsEqualV1(row.bucket_id_u64be, key.encoded.bucket) + ) { + throw new Error('candidate row key bytes do not match the pinned load'); + } + if (row.projection_id !== KA_TRANSFER_PROJECTION_V1) { + throw new Error('stored projectionId is not cg-shared-v1'); + } + if (row.transfer_codec !== KA_TRANSFER_CODEC_V1) { + throw new Error('stored transfer codec is not dkg-ka-bundle-v1'); + } + if (typeof row.assertion_coordinate !== 'string') { + throw new Error('stored assertionCoordinate is not text'); + } + assertAssertionCoordinateV1(row.assertion_coordinate); + const candidate: AuthorCatalogRowV1 = { + kaId: sqlBlobToDecimalU256V1(row.ka_id_u256be) as KaIdV1, + assertionCoordinate: row.assertion_coordinate, + assertionVersion: sqlBlobToDecimalU64V1(row.assertion_version_u64be), + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: sqlBlobToDigest32V1(row.projection_digest), + sealDigest: sqlBlobToDigest32V1(row.seal_digest), + transfer: { + codec: KA_TRANSFER_CODEC_V1, + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: sqlBlobToDigest32V1(row.projection_digest), + byteLength: sqlBlobToDecimalU64V1(row.transfer_byte_length_u64be) as ByteLengthV1, + chunkSize: sqlBlobToDecimalU64V1(row.transfer_chunk_size_u64be) as typeof KA_TRANSFER_CHUNK_SIZE_V1, + chunkCount: sqlBlobToDecimalU64V1(row.transfer_chunk_count_u64be) as CountV1, + blobDigest: sqlBlobToDigest32V1(row.transfer_blob_digest), + chunkTreeRoot: sqlBlobToDigest32V1(row.transfer_chunk_tree_root), + }, + }; + assertAuthorCatalogRowV1(candidate); + const catalogScopeDigest = sqlBlobToDigest32V1(row.catalog_scope_digest); + const catalogKeyDigest = sqlBlobToDigest32V1(row.catalog_key_digest); + const expectedCatalogRowDigest = sqlBlobToDigest32V1(row.expected_catalog_row_digest); + if (catalogKeyDigest !== computeAuthorCatalogKeyDigestV1(candidate.kaId)) { + throw new Error('stored catalogKeyDigest does not match kaId'); + } + if (expectedCatalogRowDigest !== computeAuthorCatalogRowDigestV1( + catalogScopeDigest, + candidate, + )) { + throw new Error('stored expectedCatalogRowDigest does not match the row'); + } + return Object.freeze({ + row: Object.freeze(candidate), + catalogKeyDigest, + expectedCatalogRowDigest, + }); + } catch (cause) { + if (cause instanceof InventoryV1CandidateError) throw cause; + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'stored candidate row violates the frozen SQL-1 codec', + { cause }, + ); + } +} + +function assertDiffCompatible( + oldHeader: CandidateBucketHeaderV1, + newHeader: CandidateBucketHeaderV1, +): void { + if ( + oldHeader.catalogScopeDigest !== newHeader.catalogScopeDigest + || oldHeader.authorAddress !== newHeader.authorAddress + || oldHeader.catalogEra !== newHeader.catalogEra + || oldHeader.bucketCount !== newHeader.bucketCount + || oldHeader.bucketId !== newHeader.bucketId + ) { + throw new InventoryV1CandidateError( + 'candidate-invalid-load', + 'candidate diff requires equal scope, author, era, bucketCount, and bucketId', + ); + } +} + +function assertCursor( + cursor: KaIdV1 | null | undefined, + expected: KaIdV1 | null, +): KaIdV1 | null { + const canonical = cursor ?? null; + if (canonical !== null) decimalU256ToSqlBlobV1(canonical); + if (canonical !== expected) { + throw new InventoryV1CandidateError( + 'candidate-cursor-mismatch', + 'candidate traversal cursor must equal the preceding page resumeAfter value', + ); + } + return canonical; +} + +function assertPageLimit(limit: number): void { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) { + throw new InventoryV1CandidateError( + 'candidate-invalid-load', + `candidate page limit must be an integer in 1..${MAX_PAGE_SIZE}`, + ); + } +} + +function sameContext(left: SessionContextV1, right: SessionContextV1): boolean { + return left.scopeHex === right.scopeHex + && left.authorHex === right.authorHex + && left.subGraphName === right.subGraphName + && left.eraHex === right.eraHex + && left.bucketCountHex === right.bucketCountHex; +} + +function pinKey(key: EncodedLoadKeyV1): string { + return [key.session, key.scope, key.author, key.head, key.bucket] + .map(bytesToHex) + .join(':'); +} + +function bytesToHex(value: Uint8Array): string { + return Buffer.from(value).toString('hex'); +} + +function isAllZero(value: Uint8Array): boolean { + for (const byte of value) if (byte !== 0) return false; + return true; +} + +function readSafeCount(value: unknown): number { + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value; + if (typeof value === 'bigint' && value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER)) { + return Number(value); + } + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'SQLite count result is not a nonnegative safe integer', + ); +} + +function readStoredDescriptorCount(value: unknown): number { + try { + const count = Number(BigInt(sqlBlobToDecimalU64V1(value))); + if (!Number.isSafeInteger(count) || count > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) { + throw new Error('stored row_count is outside the SQL-1 bucket bound'); + } + return count; + } catch (cause) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'stored candidate row_count is not a bounded canonical u64', + { cause }, + ); + } +} + +function assertSqlKeyBlob(value: unknown, width: number, label: string): Uint8Array { + try { + return assertSqlBlobWidthV1(value, width, label); + } catch (cause) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + `${label} is not a canonical SQL key BLOB`, + { cause }, + ); + } +} + +function rollbackIfOpen( + database: DatabaseSync, + monotonicNow: () => number, + deadline: number | null = null, +): RollbackResultV1 { + const startedAt = monotonicNow(); + let failed = false; + try { + database.exec('ROLLBACK'); + } catch { + failed = true; + } + return Object.freeze({ + failed, + overran: (() => { + const completedAt = monotonicNow(); + return completedAt - startedAt > STATEMENT_LATENCY_BUDGET_MS + || (deadline !== null && (startedAt >= deadline || completedAt >= deadline)); + })(), + }); +} + +function isSqliteUniqueOrPrimaryKeyConstraint(cause: unknown): boolean { + if (!(cause instanceof Error)) return false; + const candidate = cause as Error & { code?: string; errcode?: number }; + // SQLITE_CONSTRAINT_PRIMARYKEY = 1555, SQLITE_CONSTRAINT_UNIQUE = 2067. + // Never collapse CHECK/FK/NOT NULL constraint classes into immutable-key + // conflict: those indicate invalid data, schema drift, or database failure. + return candidate.errcode === 1555 + || candidate.errcode === 2067 + || candidate.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' + || candidate.code === 'SQLITE_CONSTRAINT_UNIQUE' + || /^(?:UNIQUE|PRIMARY KEY) constraint failed:/i.test(candidate.message); +} + +function assertUsableReopenedDatabase( + value: unknown, + monotonicNow: () => number, + writeDeadline: number | null, +): asserts value is DatabaseSync { + if ( + typeof value !== 'object' + || value === null + || typeof (value as { prepare?: unknown }).prepare !== 'function' + || typeof (value as { exec?: unknown }).exec !== 'function' + || typeof (value as { close?: unknown }).close !== 'function' + ) { + throw new Error('verified reopen provider returned an invalid low-level handle'); + } + const startedAt = monotonicNow(); + assertReopenDeadline(startedAt, writeDeadline, 'before verified reopen SQL probe'); + const probe = (value as DatabaseSync).prepare( + 'SELECT 1 AS rfc64_verified_reopen_probe', + ).get() as Record | undefined; + const completedAt = monotonicNow(); + if (completedAt - startedAt > STATEMENT_LATENCY_BUDGET_MS) { + throw new LatencyBudgetSignal('verified reopen SQL probe exceeded 10000 ms', { + callCompleted: true, + }); + } + assertReopenDeadline(completedAt, writeDeadline, 'after verified reopen SQL probe'); + if (probe?.rfc64_verified_reopen_probe !== 1) { + throw new Error('verified reopen provider returned a nonfunctional low-level handle'); + } +} + +function assertReopenDeadline( + now: number, + writeDeadline: number | null, + label: string, +): void { + if (writeDeadline !== null && now >= writeDeadline) { + throw new LatencyBudgetSignal(`${label} reached the absolute 30000 ms write deadline`); + } +} + +function closeRejectedDatabase(database: DatabaseSync): void { + database.close(); +} + +function normalizeCandidateError(message: string, cause: unknown): InventoryV1CandidateError { + if (cause instanceof InventoryV1CandidateError) return cause; + return databaseError(message, cause); +} + +function databaseError(message: string, cause: unknown): InventoryV1CandidateError { + return new InventoryV1CandidateError('candidate-database-error', message, { cause }); +} + +function latencyBudgetError(cause: unknown): InventoryV1CandidateError { + return new InventoryV1CandidateError( + 'latency-budget-exceeded', + 'SQL-1 latency budget was exceeded; the result was discarded and verified reopen is required', + { cause }, + ); +} + +function invalidLoad(message: string): never { + throw new InventoryV1CandidateError('candidate-invalid-load', message); +} + +function invalidSession(): InventoryV1CandidateError { + return new InventoryV1CandidateError( + 'candidate-invalid-session', + 'candidate session is not an adapter-local opaque handle', + ); +} + +function streamComplete(): InventoryV1CandidateError { + return new InventoryV1CandidateError( + 'candidate-stream-complete', + 'candidate traversal stream already reached its terminal empty page', + ); +} diff --git a/packages/agent/src/rfc64/inventory-v1/index.ts b/packages/agent/src/rfc64/inventory-v1/index.ts index 0ccfe1677c..742f681c63 100644 --- a/packages/agent/src/rfc64/inventory-v1/index.ts +++ b/packages/agent/src/rfc64/inventory-v1/index.ts @@ -1,3 +1,22 @@ +// CandidateInventoryV1 is an internal connection-bound adapter. Public callers +// receive only the foundation API from openInventoryV1 and cannot supply an +// unverified low-level reopen callback. +export { + InventoryV1CandidateError, + type CandidateBucketDiffTraversalV1, + type CandidateBucketHeaderV1, + type CandidateBucketLoadKeyV1, + type CandidateBucketPageV1, + type CandidateBucketPutResultV1, + type CandidateBucketRowV1, + type CandidateBucketRowsTraversalV1, + type CandidateSessionGcBatchResultV1, + type CandidateSessionV1, + type InventoryV1CandidateErrorCode, + type Rfc64InventoryV1CandidateApi, + type VerifiedCandidateBucketLoadV1, +} from './candidate.js'; export * from './open.js'; export * from './scalars.js'; export * from './sql.js'; +export * from './statements.js'; diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 974c0397d1..f521309ed2 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -35,6 +35,20 @@ import { INVENTORY_V1_USER_VERSION, normalizeInventoryV1SchemaSql, } from './sql.js'; +import { + CandidateInventoryV1, + type CandidateBucketDiffTraversalV1, + type CandidateBucketHeaderV1, + type CandidateBucketLoadKeyV1, + type CandidateBucketPageV1, + type CandidateBucketPutResultV1, + type CandidateBucketRowsTraversalV1, + type CandidateSessionV1, + type CandidateSessionGcBatchResultV1, + type Rfc64InventoryV1CandidateApi, + type VerifiedCandidateBucketLoadV1, +} from './candidate.js'; +import type { KaIdV1 } from '@origintrail-official/dkg-core'; type SqliteModuleV1 = typeof import('node:sqlite'); type DatabaseSyncV1 = InstanceType; @@ -65,7 +79,7 @@ export class InventoryV1OpenError extends Error { } } -export interface Rfc64InventoryV1Foundation { +export interface Rfc64InventoryV1Foundation extends Rfc64InventoryV1CandidateApi { readonly databasePath: string; readonly closed: boolean; quarantineAndRebuild(): void; @@ -93,6 +107,7 @@ export async function openInventoryV1(dataDir: string): Promise { + if (this.#database !== currentDatabase) { + throw new InventoryV1OpenError( + 'database-closed', + 'candidate inventory low-level handle no longer owns the live foundation connection', + ); + } + try { + currentDatabase.close(); + } catch (cause) { + this.#database = null; + throw new InventoryV1OpenError( + 'database-io', + 'failed to close the over-budget inventory connection before verified reopen', + { cause }, + ); + } + this.#database = null; + try { + const reopened = reopenVerifiedOwnedDatabase(this.sqlite, this.databasePath); + this.#database = reopened; + return reopened; + } catch (cause) { + // Never quarantine, rebuild, or manufacture a candidate outcome on the + // latency/indeterminate-COMMIT path. The live foundation stays closed. + throw new InventoryV1OpenError( + 'database-io', + 'failed to verify and reopen the existing RFC-64 inventory database', + { cause }, + ); + } + }, + undefined, + (rejectedDatabase) => { + // Candidate validation happens after the provider returns. Detach the + // adopted replacement before closing it so `closed` and requireOpen() + // cannot observe a stale closed DatabaseSync handle. + if (this.#database === rejectedDatabase) this.#database = null; + rejectedDatabase.close(); + }, + ); + } +} + +function reopenVerifiedOwnedDatabase( + sqlite: SqliteModuleV1, + databasePath: string, +): DatabaseSyncV1 { + rejectOwnedFileSymlinks(databasePath); + rejectOrphanedSidecars(databasePath); + if (!existsSync(databasePath)) { + throw new InventoryV1OpenError( + 'database-io', + 'inventory database disappeared before verified low-level reopen', + ); + } + refuseValidForeignSqliteHeader(databasePath); + assertOwnedUnitOwners(databasePath); + + let database: DatabaseSyncV1 | null = null; + try { + database = new sqlite.DatabaseSync(databasePath); + database.exec(` + PRAGMA foreign_keys = ON; + PRAGMA trusted_schema = OFF; + PRAGMA busy_timeout = 5000; + `); + const identity = readIdentity(database); + if (identity.applicationId !== INVENTORY_V1_APPLICATION_ID) { + throw new InventoryV1OpenError( + identity.applicationId === 0 ? 'ambiguous-database' : 'foreign-database', + 'verified reopen found a non-DK64 inventory database', + ); + } + if (identity.userVersion !== INVENTORY_V1_USER_VERSION) { + throw new InventoryV1OpenError( + identity.userVersion > INVENTORY_V1_USER_VERSION ? 'newer-schema' : 'ambiguous-database', + `verified reopen requires exact inventory user_version ${INVENTORY_V1_USER_VERSION}`, + ); + } + if (!schemaMatches(identity.userObjects)) { + throw new InventoryV1OpenError( + 'database-io', + 'verified reopen found an incompatible owned v1 schema', + ); + } + applyAndVerifyPragmas(database); + verifyOwnedSchema(database); + tightenOwnedFileMode(databasePath); + return database; + } catch (cause) { + try { database?.close(); } catch { /* preserve verified-reopen failure */ } + if (cause instanceof InventoryV1OpenError) throw cause; + if (isBusySqliteError(cause)) { + throw new InventoryV1OpenError( + 'database-busy', + 'inventory database is busy during verified low-level reopen', + { cause }, + ); + } + throw new InventoryV1OpenError( + 'database-io', + 'failed to verify the existing inventory database during low-level reopen', + { cause }, + ); + } } async function loadSqliteModule(): Promise { diff --git a/packages/agent/src/rfc64/inventory-v1/sql.ts b/packages/agent/src/rfc64/inventory-v1/sql.ts index b4ca910059..fe1dcf4b78 100644 --- a/packages/agent/src/rfc64/inventory-v1/sql.ts +++ b/packages/agent/src/rfc64/inventory-v1/sql.ts @@ -202,6 +202,15 @@ CREATE TABLE rfc64_candidate_bucket_rows_v1 ( ), PRIMARY KEY ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + ka_id_u256be + ), + + UNIQUE ( session_id, catalog_scope_digest, author_address, @@ -242,27 +251,14 @@ CREATE TABLE rfc64_candidate_bucket_rows_v1 ( ON DELETE CASCADE ) WITHOUT ROWID, STRICT`; -export const INVENTORY_V1_BUCKET_INDEX_SQL = ` -CREATE INDEX rfc64_candidate_bucket_rows_by_bucket_v1 -ON rfc64_candidate_bucket_rows_v1 ( - session_id, - catalog_scope_digest, - author_address, - target_catalog_head_digest, - bucket_id_u64be, - ka_id_u256be -)`; - export const INVENTORY_V1_DDL = [ INVENTORY_V1_LOADS_TABLE_SQL, INVENTORY_V1_ROWS_TABLE_SQL, - INVENTORY_V1_BUCKET_INDEX_SQL, ].join(';\n\n').concat(';'); export const INVENTORY_V1_USER_OBJECTS: Readonly> = Object.freeze({ rfc64_candidate_bucket_loads_v1: normalizeInventoryV1SchemaSql(INVENTORY_V1_LOADS_TABLE_SQL), rfc64_candidate_bucket_rows_v1: normalizeInventoryV1SchemaSql(INVENTORY_V1_ROWS_TABLE_SQL), - rfc64_candidate_bucket_rows_by_bucket_v1: normalizeInventoryV1SchemaSql(INVENTORY_V1_BUCKET_INDEX_SQL), }); export function normalizeInventoryV1SchemaSql(sql: string): string { diff --git a/packages/agent/src/rfc64/inventory-v1/statements.ts b/packages/agent/src/rfc64/inventory-v1/statements.ts new file mode 100644 index 0000000000..48dbe93710 --- /dev/null +++ b/packages/agent/src/rfc64/inventory-v1/statements.ts @@ -0,0 +1,276 @@ +/** + * Fixed SQL-1 statement manifest. Every statement that can touch persistent + * candidate rows is named here so plan gates can inspect the exact production + * template. SQL-1 deliberately has no OFFSET-based statement. + */ +export const INVENTORY_V1_STATEMENT_SQL = Object.freeze({ + insertHeader: ` +INSERT INTO rfc64_candidate_bucket_loads_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + subgraph_name, + catalog_era_u64be, + bucket_count_u64be, + bucket_id_u64be, + bucket_object_digest, + row_count_u64be, + payload_byte_length_u64be +) VALUES ( + :session, + :scope, + :author, + :head, + :subgraphName, + :era, + :bucketCount, + :bucket, + :bucketObjectDigest, + :rowCount, + :payloadByteLength +);`, + + insertRow: ` +INSERT INTO rfc64_candidate_bucket_rows_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + ka_id_u256be, + catalog_key_digest, + assertion_coordinate, + assertion_version_u64be, + projection_id, + projection_digest, + seal_digest, + transfer_codec, + transfer_byte_length_u64be, + transfer_chunk_size_u64be, + transfer_chunk_count_u64be, + transfer_blob_digest, + transfer_chunk_tree_root, + expected_catalog_row_digest +) VALUES ( + :session, + :scope, + :author, + :head, + :bucket, + :kaId, + :catalogKeyDigest, + :assertionCoordinate, + :assertionVersion, + :projectionId, + :projectionDigest, + :sealDigest, + :transferCodec, + :transferByteLength, + :transferChunkSize, + :transferChunkCount, + :transferBlobDigest, + :transferChunkTreeRoot, + :expectedCatalogRowDigest +);`, + + startupGcNext: ` +SELECT session_id, catalog_scope_digest, author_address, + target_catalog_head_digest, bucket_id_u64be +FROM rfc64_candidate_bucket_loads_v1 +WHERE session_id > zeroblob(32) +ORDER BY session_id, catalog_scope_digest, author_address, + target_catalog_head_digest, bucket_id_u64be +LIMIT 8;`, + + discardSessionNext: ` +SELECT session_id, catalog_scope_digest, author_address, + target_catalog_head_digest, bucket_id_u64be +FROM rfc64_candidate_bucket_loads_v1 +WHERE session_id = :session +ORDER BY catalog_scope_digest, author_address, + target_catalog_head_digest, bucket_id_u64be +LIMIT 8;`, + + getHeader: ` +SELECT * +FROM rfc64_candidate_bucket_loads_v1 +WHERE session_id = :session + AND catalog_scope_digest = :scope + AND author_address = :author + AND target_catalog_head_digest = :head + AND bucket_id_u64be = :bucket;`, + + getRowsFirst: ` +SELECT * +FROM rfc64_candidate_bucket_rows_v1 +WHERE session_id = :session + AND catalog_scope_digest = :scope + AND author_address = :author + AND target_catalog_head_digest = :head + AND bucket_id_u64be = :bucket +ORDER BY ka_id_u256be +LIMIT :limit;`, + + getRowsNext: ` +SELECT * +FROM rfc64_candidate_bucket_rows_v1 +WHERE session_id = :session + AND catalog_scope_digest = :scope + AND author_address = :author + AND target_catalog_head_digest = :head + AND bucket_id_u64be = :bucket + AND ka_id_u256be > :afterKaIdU256be +ORDER BY ka_id_u256be +LIMIT :limit;`, + + getRowsExactRetry: ` +SELECT * +FROM rfc64_candidate_bucket_rows_v1 +WHERE session_id = :session + AND catalog_scope_digest = :scope + AND author_address = :author + AND target_catalog_head_digest = :head + AND bucket_id_u64be = :bucket +ORDER BY ka_id_u256be +LIMIT 1025;`, + + diffAddedOrChangedFirst: ` +SELECT n.* +FROM rfc64_candidate_bucket_rows_v1 AS n +LEFT JOIN rfc64_candidate_bucket_rows_v1 AS o + ON o.session_id = :oldSession + AND o.catalog_scope_digest = :scope + AND o.author_address = :author + AND o.target_catalog_head_digest = :oldHead + AND o.ka_id_u256be = n.ka_id_u256be +WHERE n.session_id = :newSession + AND n.catalog_scope_digest = :scope + AND n.author_address = :author + AND n.target_catalog_head_digest = :newHead + AND n.bucket_id_u64be = :bucket + AND ( + o.ka_id_u256be IS NULL + OR o.expected_catalog_row_digest <> n.expected_catalog_row_digest + ) +ORDER BY n.ka_id_u256be +LIMIT :limit;`, + + diffAddedOrChangedNext: ` +SELECT n.* +FROM rfc64_candidate_bucket_rows_v1 AS n +LEFT JOIN rfc64_candidate_bucket_rows_v1 AS o + ON o.session_id = :oldSession + AND o.catalog_scope_digest = :scope + AND o.author_address = :author + AND o.target_catalog_head_digest = :oldHead + AND o.ka_id_u256be = n.ka_id_u256be +WHERE n.session_id = :newSession + AND n.catalog_scope_digest = :scope + AND n.author_address = :author + AND n.target_catalog_head_digest = :newHead + AND n.bucket_id_u64be = :bucket + AND n.ka_id_u256be > :afterKaIdU256be + AND ( + o.ka_id_u256be IS NULL + OR o.expected_catalog_row_digest <> n.expected_catalog_row_digest + ) +ORDER BY n.ka_id_u256be +LIMIT :limit;`, + + diffRemovedFirst: ` +SELECT o.* +FROM rfc64_candidate_bucket_rows_v1 AS o +LEFT JOIN rfc64_candidate_bucket_rows_v1 AS n + ON n.session_id = :newSession + AND n.catalog_scope_digest = :scope + AND n.author_address = :author + AND n.target_catalog_head_digest = :newHead + AND n.ka_id_u256be = o.ka_id_u256be +WHERE o.session_id = :oldSession + AND o.catalog_scope_digest = :scope + AND o.author_address = :author + AND o.target_catalog_head_digest = :oldHead + AND o.bucket_id_u64be = :bucket + AND n.ka_id_u256be IS NULL +ORDER BY o.ka_id_u256be +LIMIT :limit;`, + + diffRemovedNext: ` +SELECT o.* +FROM rfc64_candidate_bucket_rows_v1 AS o +LEFT JOIN rfc64_candidate_bucket_rows_v1 AS n + ON n.session_id = :newSession + AND n.catalog_scope_digest = :scope + AND n.author_address = :author + AND n.target_catalog_head_digest = :newHead + AND n.ka_id_u256be = o.ka_id_u256be +WHERE o.session_id = :oldSession + AND o.catalog_scope_digest = :scope + AND o.author_address = :author + AND o.target_catalog_head_digest = :oldHead + AND o.bucket_id_u64be = :bucket + AND o.ka_id_u256be > :afterKaIdU256be + AND n.ka_id_u256be IS NULL +ORDER BY o.ka_id_u256be +LIMIT :limit;`, + + countBucketRows: ` +SELECT count(*) AS row_count +FROM rfc64_candidate_bucket_rows_v1 +WHERE session_id = :session + AND catalog_scope_digest = :scope + AND author_address = :author + AND target_catalog_head_digest = :head + AND bucket_id_u64be = :bucket;`, + + deleteHeader: ` +DELETE FROM rfc64_candidate_bucket_loads_v1 +WHERE session_id = :session + AND catalog_scope_digest = :scope + AND author_address = :author + AND target_catalog_head_digest = :head + AND bucket_id_u64be = :bucket;`, +}); + +export type InventoryV1StatementKey = keyof typeof INVENTORY_V1_STATEMENT_SQL; + +/** Stable production query IDs; these are telemetry/plan-contract identities. */ +export const INVENTORY_V1_STATEMENT_IDS = Object.freeze({ + insertHeader: 'rfc64.candidate-bucket.header.insert.v1', + insertRow: 'rfc64.candidate-bucket.row.insert.v1', + startupGcNext: 'rfc64.candidate-session.startup-gc.next.v1', + discardSessionNext: 'rfc64.candidate-session.discard.next.v1', + getHeader: 'rfc64.candidate-bucket.header.get.v1', + getRowsFirst: 'rfc64.candidate-bucket.rows.first.v1', + getRowsNext: 'rfc64.candidate-bucket.rows.next.v1', + getRowsExactRetry: 'rfc64.candidate-bucket.rows.exact-retry.v1', + diffAddedOrChangedFirst: 'rfc64.candidate-bucket.diff-added-or-changed.first.v1', + diffAddedOrChangedNext: 'rfc64.candidate-bucket.diff-added-or-changed.next.v1', + diffRemovedFirst: 'rfc64.candidate-bucket.diff-removed.first.v1', + diffRemovedNext: 'rfc64.candidate-bucket.diff-removed.next.v1', + countBucketRows: 'rfc64.candidate-bucket.rows.count.v1', + deleteHeader: 'rfc64.candidate-bucket.delete.v1', +} as const satisfies Readonly>); + +export type InventoryV1StatementId = + typeof INVENTORY_V1_STATEMENT_IDS[InventoryV1StatementKey]; + +export const INVENTORY_V1_PERSISTENT_READ_STATEMENT_KEYS = Object.freeze([ + 'startupGcNext', + 'discardSessionNext', + 'getHeader', + 'getRowsFirst', + 'getRowsNext', + 'getRowsExactRetry', + 'diffAddedOrChangedFirst', + 'diffAddedOrChangedNext', + 'diffRemovedFirst', + 'diffRemovedNext', + 'countBucketRows', +] as const satisfies readonly InventoryV1StatementKey[]); + +export const INVENTORY_V1_PLAN_STATEMENT_KEYS = Object.freeze([ + ...INVENTORY_V1_PERSISTENT_READ_STATEMENT_KEYS, + 'deleteHeader', +] as const satisfies readonly InventoryV1StatementKey[]); diff --git a/packages/agent/test/rfc64-inventory-v1-candidate-faults.test.ts b/packages/agent/test/rfc64-inventory-v1-candidate-faults.test.ts new file mode 100644 index 0000000000..010b15e1ba --- /dev/null +++ b/packages/agent/test/rfc64-inventory-v1-candidate-faults.test.ts @@ -0,0 +1,245 @@ +import { spawn } from 'node:child_process'; +import { + mkdtempSync, + readFileSync, + realpathSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { describe, expect, it } from 'vitest'; + +import { + INVENTORY_V1_DDL, + INVENTORY_V1_ROWS_TABLE_SQL, +} from '../src/rfc64/inventory-v1/index.js'; +import { CandidateInventoryV1 } from '../src/rfc64/inventory-v1/candidate.js'; + +const SESSION_HEX = '11'.repeat(32); +const SCOPE_HEX = '22'.repeat(32); +const AUTHOR_HEX = '33'.repeat(20); +const HEAD_HEX = '44'.repeat(32); + +describe('RFC-64 SQL-1 candidate crash and static fault matrix', () => { + it.skipIf(process.platform === 'win32')( + 'rolls back a real child SIGKILL after the header and every child insert boundary', + async () => { + const insertRows = [ + rowInsertSql('0000000000000000', `${AUTHOR_HEX}${'00'.repeat(11)}01`, '51', 'one'), + rowInsertSql('0000000000000000', `${AUTHOR_HEX}${'00'.repeat(11)}02`, '52', 'two'), + rowInsertSql('0000000000000000', `${AUTHOR_HEX}${'00'.repeat(11)}03`, '53', 'three'), + ]; + for (let boundary = 1; boundary <= insertRows.length + 1; boundary += 1) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-sigkill-'))); + const path = join(directory, 'inventory.sqlite3'); + initializeDatabase(path); + try { + const result = await runInsertChild(path, boundary, insertRows); + expect(result).toEqual({ code: null, signal: 'SIGKILL' }); + expect(expectStoredCounts(path)).toEqual({ headers: 0, rows: 0 }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + } + + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-commit-'))); + const path = join(directory, 'inventory.sqlite3'); + initializeDatabase(path); + try { + const result = await runInsertChild(path, 0, insertRows); + expect(result).toEqual({ code: 0, signal: null }); + expect(expectStoredCounts(path)).toEqual({ headers: 1, rows: 3 }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + 30_000, + ); + + it('enforces head-wide duplicate KA and catalog-key rejection across buckets', () => { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON;'); + database.exec(INVENTORY_V1_DDL); + database.exec(headerInsertSql('0000000000000000', 1)); + database.exec(headerInsertSql('0000000000000001', 1)); + const kaOne = `${AUTHOR_HEX}${'00'.repeat(11)}01`; + database.exec(rowInsertSql('0000000000000000', kaOne, '61', 'first')); + try { + expect(() => database.exec( + rowInsertSql('0000000000000001', kaOne, '62', 'duplicate-ka'), + )).toThrowError(/UNIQUE constraint failed/i); + expect(() => database.exec( + rowInsertSql( + '0000000000000001', + `${AUTHOR_HEX}${'00'.repeat(11)}02`, + '61', + 'duplicate-key', + ), + )).toThrowError(/UNIQUE constraint failed/i); + } finally { + database.close(); + } + }); + + it('keeps SQL-1 D26-neutral and free of seal admission or completion APIs', () => { + const candidateSource = readFileSync( + new URL('../src/rfc64/inventory-v1/candidate.ts', import.meta.url), + 'utf8', + ); + const statementSource = readFileSync( + new URL('../src/rfc64/inventory-v1/statements.ts', import.meta.url), + 'utf8', + ); + const publicIndexSource = readFileSync( + new URL('../src/rfc64/inventory-v1/index.ts', import.meta.url), + 'utf8', + ); + expect(candidateSource).not.toMatch(/(?:PR\s*#?\s*1780|#1780)/i); + expect(candidateSource).not.toMatch(/\b(?:isComplete|promoteToApplied|markApplied)\b/); + expect(Object.getOwnPropertyNames(CandidateInventoryV1.prototype)).not.toEqual( + expect.arrayContaining(['isComplete', 'promoteToApplied', 'markApplied']), + ); + expect(`${INVENTORY_V1_ROWS_TABLE_SQL}\n${statementSource}`).not.toMatch( + /\b(?:accessPolicy|publishPolicy|memberRoster|curator|provider|vmOrdinal|vmState|tier)\b/i, + ); + expect(publicIndexSource).not.toContain("export * from './candidate.js'"); + expect(publicIndexSource).not.toMatch(/\bVerifiedCandidateBucketDescriptorV1\b/); + expect(publicIndexSource).not.toMatch(/\bCandidateInventoryV1\s*,/); + }); +}); + +function initializeDatabase(path: string): void { + const database = new DatabaseSync(path); + try { + database.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;'); + database.exec(INVENTORY_V1_DDL); + } finally { + database.close(); + } +} + +function expectStoredCounts(path: string): { headers: number; rows: number } { + const database = new DatabaseSync(path); + try { + return { + headers: Number(database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get()?.count), + rows: Number(database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_rows_v1', + ).get()?.count), + }; + } finally { + database.close(); + } +} + +function runInsertChild( + path: string, + pauseBoundary: number, + insertRows: readonly string[], +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + const childSource = String.raw` + import { DatabaseSync } from 'node:sqlite'; + const database = new DatabaseSync(process.env.RFC64_DATABASE_PATH); + const pauseBoundary = Number(process.env.RFC64_PAUSE_BOUNDARY); + const rows = JSON.parse(process.env.RFC64_ROW_INSERTS); + const pause = async (boundary) => { + if (boundary !== pauseBoundary) return; + await new Promise(() => process.stdout.write('BOUNDARY ' + boundary + '\n')); + }; + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; BEGIN IMMEDIATE;'); + database.exec(process.env.RFC64_HEADER_INSERT); + await pause(1); + for (let index = 0; index < rows.length; index += 1) { + database.exec(rows[index]); + await pause(index + 2); + } + database.exec('COMMIT'); + database.close(); + `; + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', childSource], { + env: { + ...process.env, + RFC64_DATABASE_PATH: path, + RFC64_PAUSE_BOUNDARY: String(pauseBoundary), + RFC64_HEADER_INSERT: headerInsertSql('0000000000000000', insertRows.length), + RFC64_ROW_INSERTS: JSON.stringify(insertRows), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let killed = false; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`child insert boundary timed out; stdout=${stdout}; stderr=${stderr}`)); + }, 5_000); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + if (!killed && pauseBoundary > 0 && stdout.includes(`BOUNDARY ${pauseBoundary}\n`)) { + killed = child.kill('SIGKILL'); + } + }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + child.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + if (pauseBoundary === 0 && code !== 0) { + reject(new Error(`child commit failed; code=${code}; signal=${signal}; stderr=${stderr}`)); + return; + } + resolve({ code, signal }); + }); + }); +} + +function headerInsertSql(bucketHex: string, rowCount: number): string { + return ` + INSERT INTO rfc64_candidate_bucket_loads_v1 ( + session_id, catalog_scope_digest, author_address, + target_catalog_head_digest, subgraph_name, catalog_era_u64be, + bucket_count_u64be, bucket_id_u64be, bucket_object_digest, + row_count_u64be, payload_byte_length_u64be + ) VALUES ( + x'${SESSION_HEX}', x'${SCOPE_HEX}', x'${AUTHOR_HEX}', x'${HEAD_HEX}', + NULL, zeroblob(8), x'0000000000000002', x'${bucketHex}', + x'${'55'.repeat(32)}', unhex(printf('%016x', ${rowCount})), + x'0000000000000001' + ); + `; +} + +function rowInsertSql( + bucketHex: string, + kaHex: string, + keyByte: string, + coordinate: string, +): string { + return ` + INSERT INTO rfc64_candidate_bucket_rows_v1 ( + session_id, catalog_scope_digest, author_address, + target_catalog_head_digest, bucket_id_u64be, ka_id_u256be, + catalog_key_digest, assertion_coordinate, assertion_version_u64be, + projection_id, projection_digest, seal_digest, transfer_codec, + transfer_byte_length_u64be, transfer_chunk_size_u64be, + transfer_chunk_count_u64be, transfer_blob_digest, + transfer_chunk_tree_root, expected_catalog_row_digest + ) VALUES ( + x'${SESSION_HEX}', x'${SCOPE_HEX}', x'${AUTHOR_HEX}', x'${HEAD_HEX}', + x'${bucketHex}', x'${kaHex}', x'${keyByte.repeat(32)}', '${coordinate}', + x'0000000000000001', 'cg-shared-v1', x'${'66'.repeat(32)}', + x'${'77'.repeat(32)}', 'dkg-ka-bundle-v1', x'0000000000000010', + x'0000000000040000', x'0000000000000001', x'${'88'.repeat(32)}', + x'${'99'.repeat(32)}', x'${'aa'.repeat(32)}' + ); + `; +} diff --git a/packages/agent/test/rfc64-inventory-v1-candidate-latency.test.ts b/packages/agent/test/rfc64-inventory-v1-candidate-latency.test.ts new file mode 100644 index 0000000000..9f16f86795 --- /dev/null +++ b/packages/agent/test/rfc64-inventory-v1-candidate-latency.test.ts @@ -0,0 +1,608 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync, type StatementSync } from 'node:sqlite'; + +import { describe, expect, it, vi } from 'vitest'; + +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + KA_TRANSFER_CHUNK_SIZE_V1, + KA_TRANSFER_CODEC_V1, + KA_TRANSFER_PROJECTION_V1, + assertAuthorCatalogRowV1, + assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + canonicalizeAuthorCatalogBucketPayloadBytesV1, + computeAuthorCatalogBucketObjectDigestV1, + computeAuthorCatalogHeadObjectDigestV1, + computeAuthorCatalogScopeDigestV1, + deriveAuthorCatalogScopeFromHeadV1, + type AuthorCatalogBucketV1, + type AuthorCatalogHeadV1, + type AuthorCatalogRowV1, + type ByteLengthV1, + type CountV1, + type Digest32V1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; + +import { + INVENTORY_V1_DDL, + openInventoryV1, + type CandidateSessionV1, + type VerifiedCandidateBucketLoadV1, +} from '../src/rfc64/inventory-v1/index.js'; +import { CandidateInventoryV1 } from '../src/rfc64/inventory-v1/candidate.js'; + +describe('RFC-64 SQL-1 candidate latency and indeterminate-commit boundaries', () => { + it('performs the real verified low-level reopen inside one live foundation', async () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-reopen-'))); + const foundation = await openInventoryV1(directory); + let clockCalls = 0; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => { + clockCalls += 1; + return clockCalls >= 13 ? 10_001 : 0; + }); + try { + expect(foundation.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(foundation.closed).toBe(false); + clock.mockRestore(); + expect(() => foundation.createCandidateSession()).not.toThrow(); + } finally { + clock.mockRestore(); + foundation.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('atomically detaches a foundation replacement rejected by the candidate probe', async () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-reject-reopen-'))); + const foundation = await openInventoryV1(directory); + const originalPrepare = DatabaseSync.prototype.prepare; + let time = 0; + let rejectProbe = false; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => time); + const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare').mockImplementation(function ( + this: DatabaseSync, + sql: string, + ): StatementSync { + if (rejectProbe && sql.includes('rfc64_verified_reopen_probe')) { + throw new Error('injected reopened-handle probe rejection'); + } + const statement = originalPrepare.call(this, sql); + if (!sql.includes('FROM rfc64_candidate_bucket_loads_v1')) return statement; + return new Proxy(statement, { + get(target, property) { + if (property !== 'all') return Reflect.get(target, property, target); + const all = target.all.bind(target); + return (...parameters: Parameters) => { + const result = all(...parameters); + time = 10_001; + rejectProbe = true; + return result; + }; + }, + }); + }); + try { + expect(() => foundation.purgeNextStartupStaleCandidateBatch()).toThrowError( + expect.objectContaining({ code: 'latency-budget-exceeded' }), + ); + expect(foundation.closed).toBe(true); + expect(() => foundation.createCandidateSession()).toThrowError( + expect.objectContaining({ code: 'database-closed' }), + ); + } finally { + prepare.mockRestore(); + clock.mockRestore(); + foundation.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('fails closed and rolls back an over-budget statement', () => { + const clock = manualClock(); + let overrunOnce = true; + const fixture = createReopenableLatencyDatabase({ + prepare(sql, prepare) { + const statement = prepare(sql); + if (overrunOnce && sql.includes('FROM rfc64_candidate_bucket_loads_v1')) { + overrunOnce = false; + clock.advance(10_001); + } + return statement; + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen, clock.now); + try { + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + expect.objectContaining({ code: 'latency-budget-exceeded' }), + ); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(() => inventory.createCandidateSession()).not.toThrow(); + } finally { + fixture.close(); + } + }); + + it('rolls back and closes before COMMIT when the write transaction exceeds 30 seconds', () => { + const fixture = createReopenableLatencyDatabase(); + insertEmptyHeader(fixture.database()); + // Every individual statement is measured at 4 seconds, but the complete + // select+delete transaction crosses 30 seconds before COMMIT. + let time = 0; + let advanceTime = true; + const now = (): number => { + const value = time; + if (advanceTime) time += 4_000; + return value; + }; + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen, now); + try { + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + expect.objectContaining({ code: 'latency-budget-exceeded' }), + ); + expect(fixture.reopen).toHaveBeenCalledOnce(); + advanceTime = false; + const inspector = new DatabaseSync(fixture.path); + expect(inspector.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get()?.count).toBe(1); + inspector.close(); + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + /unavailable after failed verified reopen/, + ); + } finally { + fixture.close(); + } + }); + + it('stops a multi-row stage at the absolute deadline and executes no later insert', () => { + let time = 0; + let rowInsertCalls = 0; + const fixture = createReopenableLatencyDatabase({ + prepare(sql, prepare) { + const statement = prepare(sql); + if (!/^\s*INSERT INTO rfc64_candidate_bucket_rows_v1/i.test(sql)) return statement; + return new Proxy(statement, { + get(target, property) { + if (property !== 'run') return Reflect.get(target, property, target); + const run = target.run.bind(target); + return (...parameters: Parameters) => { + rowInsertCalls += 1; + const result = run(...parameters); + time += 6_000; + return result; + }; + }, + }); + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen, () => time); + try { + inventory.purgeNextStartupStaleCandidateBatch(); + const session = inventory.createCandidateSession(); + expect(() => inventory.putVerifiedCandidateBucket( + latencyCandidateLoad(session, 10), + )).toThrowError(expect.objectContaining({ code: 'latency-budget-exceeded' })); + expect(rowInsertCalls).toBe(5); + const inspector = new DatabaseSync(fixture.path); + try { + expect(inspector.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get()?.count).toBe(0); + expect(inspector.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_rows_v1', + ).get()?.count).toBe(0); + } finally { + inspector.close(); + } + } finally { + inventory.close(); + fixture.close(); + } + }); + + it('returns a successfully committed result, records the overrun, then requires reopen', () => { + const clock = manualClock(); + const fixture = createReopenableLatencyDatabase({ + exec(sql, exec) { + exec(sql); + if (sql.trim().toUpperCase() === 'COMMIT') clock.advance(10_001); + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen, clock.now); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(inventory.committedOverruns).toBe(1); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(() => inventory.createCandidateSession()).not.toThrow(); + } finally { + fixture.close(); + } + }); + + it('resolves an empty-batch COMMIT failure to the identical committed outcome', () => { + let failOnce = true; + const fixture = createReopenableLatencyDatabase({ + exec(sql, exec) { + if (failOnce && sql.trim().toUpperCase() === 'COMMIT') { + failOnce = false; + // Throw before COMMIT. Closing the abandoned file-backed handle is + // the rollback boundary used by the verified reopen provider. + throw new Error('injected COMMIT failure'); + } + exec(sql); + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(inventory.committedOverruns).toBe(0); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(() => inventory.createCandidateSession()).not.toThrow(); + } finally { + fixture.close(); + } + }); + + it('preserves a known committed result while making a failed-reopen foundation unavailable', () => { + const clock = manualClock(); + const fixture = createReopenableLatencyDatabase({ + exec(sql, exec) { + exec(sql); + if (sql.trim().toUpperCase() === 'COMMIT') clock.advance(10_001); + }, + failReopen: true, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen, clock.now); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(inventory.committedOverruns).toBe(1); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + /unavailable after failed verified reopen/, + ); + } finally { + fixture.close(); + } + }); + + for (const invalidReopen of [ + { + label: 'identity', + result: (abandoned: DatabaseSync): DatabaseSync => abandoned, + }, + { + label: 'malformed', + result: (_abandoned: DatabaseSync): DatabaseSync => ({}) as DatabaseSync, + }, + { + label: 'closed', + result: (_abandoned: DatabaseSync): DatabaseSync => { + const closed = new DatabaseSync(':memory:'); + closed.close(); + return closed; + }, + }, + ] as const) { + it(`rejects an ${invalidReopen.label} reopen result and remains unavailable`, () => { + let failCommit = true; + const fixture = createReopenableLatencyDatabase({ + exec(sql, exec) { + if (failCommit && sql.trim().toUpperCase() === 'COMMIT') { + failCommit = false; + throw new Error('injected indeterminate COMMIT'); + } + exec(sql); + }, + }); + const provider = vi.fn(invalidReopen.result); + const inventory = new CandidateInventoryV1(fixture.facade, provider); + try { + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + expect.objectContaining({ code: 'candidate-database-error' }), + ); + expect(provider).toHaveBeenCalledOnce(); + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + /unavailable after failed verified reopen/, + ); + } finally { + inventory.close(); + fixture.close(); + } + }); + } + + for (const reopenBudget of [ + { + label: '10-second SQL-probe budget', + providerAdvance: 0, + probeAdvance: 10_001, + }, + { + label: 'original 30-second absolute deadline', + providerAdvance: 25_000, + probeAdvance: 6_000, + }, + ] as const) { + it(`detaches and closes a replacement that crosses the ${reopenBudget.label}`, () => { + let time = 0; + let failCommit = true; + const fixture = createReopenableLatencyDatabase({ + exec(sql, exec) { + if (failCommit && sql.trim().toUpperCase() === 'COMMIT') { + failCommit = false; + throw new Error('injected indeterminate COMMIT'); + } + exec(sql); + }, + prepare(sql, prepare) { + const statement = prepare(sql); + if (!sql.includes('rfc64_verified_reopen_probe')) return statement; + return new Proxy(statement, { + get(target, property) { + if (property !== 'get') return Reflect.get(target, property, target); + const get = target.get.bind(target); + return (...parameters: Parameters) => { + const result = get(...parameters); + time += reopenBudget.probeAdvance; + return result; + }; + }, + }); + }, + }); + let attached: DatabaseSync | null = null; + const provider = vi.fn((abandoned: DatabaseSync): DatabaseSync => { + const replacement = fixture.reopen(abandoned); + time += reopenBudget.providerAdvance; + attached = replacement; + return replacement; + }); + const reject = vi.fn((replacement: DatabaseSync): void => { + if (attached === replacement) attached = null; + replacement.close(); + }); + const inventory = new CandidateInventoryV1( + fixture.facade, + provider, + () => time, + reject, + ); + try { + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + expect.objectContaining({ code: 'latency-budget-exceeded' }), + ); + expect(provider).toHaveBeenCalledOnce(); + expect(reject).toHaveBeenCalledOnce(); + expect(attached).toBeNull(); + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + /unavailable after failed verified reopen/, + ); + } finally { + inventory.close(); + fixture.close(); + } + }); + } +}); + +interface DatabaseHooks { + readonly exec?: (sql: string, exec: (sql: string) => void) => void; + readonly prepare?: (sql: string, prepare: (sql: string) => StatementSync) => StatementSync; + readonly failReopen?: boolean; +} + +function proxyDatabase(database: DatabaseSync, hooks: DatabaseHooks): DatabaseSync { + return new Proxy(database, { + get(target, property) { + if (property === 'exec') { + const exec = target.exec.bind(target); + return (sql: string): void => { + if (hooks.exec !== undefined) hooks.exec(sql, exec); + else exec(sql); + }; + } + if (property === 'prepare') { + const prepare = target.prepare.bind(target); + return (sql: string): StatementSync => hooks.prepare?.(sql, prepare) ?? prepare(sql); + } + const value = Reflect.get(target, property, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +function createReopenableLatencyDatabase(hooks: DatabaseHooks = {}): { + readonly facade: DatabaseSync; + readonly reopen: ReturnType DatabaseSync>>; + readonly database: () => DatabaseSync; + readonly path: string; + readonly close: () => void; +} { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-latency-db-'))); + const path = join(directory, 'inventory.sqlite3'); + let database = new DatabaseSync(path); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + database.exec(INVENTORY_V1_DDL); + let facade = proxyDatabase(database, hooks); + const reopen = vi.fn((abandoned: DatabaseSync): DatabaseSync => { + if (abandoned !== facade) throw new Error('reopen received a non-current low-level handle'); + database.close(); + if (hooks.failReopen === true) throw new Error('injected reopen verification failure'); + database = new DatabaseSync(path); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + facade = proxyDatabase(database, hooks); + return facade; + }); + return { + facade, + reopen, + database: () => database, + path, + close: () => { + try { database.close(); } catch { /* a failed reopen already closed it */ } + rmSync(directory, { recursive: true, force: true }); + }, + }; +} + +function manualClock(): { + readonly now: () => number; + readonly advance: (milliseconds: number) => void; +} { + let value = 0; + return { + now: () => value, + advance: (milliseconds) => { value += milliseconds; }, + }; +} + +function insertEmptyHeader(database: DatabaseSync): void { + database.exec(` + INSERT INTO rfc64_candidate_bucket_loads_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + subgraph_name, + catalog_era_u64be, + bucket_count_u64be, + bucket_id_u64be, + bucket_object_digest, + row_count_u64be, + payload_byte_length_u64be + ) VALUES ( + x'${'11'.repeat(32)}', + x'${'22'.repeat(32)}', + x'${'33'.repeat(20)}', + x'${'44'.repeat(32)}', + NULL, + zeroblob(8), + x'0000000000000001', + zeroblob(8), + zeroblob(32), + zeroblob(8), + zeroblob(8) + ); + `); +} + +const LATENCY_AUTHOR = '0x3333333333333333333333333333333333333333'; +const LATENCY_ISSUER = '0x5555555555555555555555555555555555555555'; +const LATENCY_SIGNATURE = `0x${'77'.repeat(65)}`; + +function latencyCandidateLoad( + session: CandidateSessionV1, + rowCount: number, +): VerifiedCandidateBucketLoadV1 { + const headPayload = { + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/latency-fixture', + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: LATENCY_AUTHOR, + catalogIssuerDelegationDigest: `0x${'66'.repeat(32)}`, + era: '0', + version: '50', + previousHeadDigest: null, + bucketCount: '1', + totalRows: String(rowCount), + directoryHeight: '0', + directoryRootDigest: `0x${'50'.repeat(32)}`, + issuedAt: '1700000000050', + } as AuthorCatalogHeadV1; + const unsignedHead = { + issuer: LATENCY_ISSUER, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload: headPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; + const head = { + ...unsignedHead, + objectDigest: computeAuthorCatalogHeadObjectDigestV1(unsignedHead), + signature: LATENCY_SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogHeadEnvelopeV1(head); + const signedHead = head as SignedAuthorCatalogHeadEnvelopeV1; + const scope = deriveAuthorCatalogScopeFromHeadV1(signedHead.payload); + const rows = Array.from({ length: rowCount }, (_, index): AuthorCatalogRowV1 => { + const row = { + kaId: ((BigInt(LATENCY_AUTHOR) << 96n) | BigInt(index + 1)).toString(), + assertionCoordinate: `latency-row-${index + 1}`, + assertionVersion: '1', + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: `0x${String(index + 1).padStart(2, '0').slice(-2).repeat(32)}`, + sealDigest: `0x${'44'.repeat(32)}`, + transfer: { + codec: KA_TRANSFER_CODEC_V1, + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: `0x${String(index + 1).padStart(2, '0').slice(-2).repeat(32)}`, + byteLength: '16', + chunkSize: KA_TRANSFER_CHUNK_SIZE_V1, + chunkCount: '1', + blobDigest: `0x${'11'.repeat(32)}`, + chunkTreeRoot: `0x${'22'.repeat(32)}`, + }, + } as unknown as AuthorCatalogRowV1; + assertAuthorCatalogRowV1(row); + return row; + }); + const bucketPayload = { + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + era: scope.era, + bucketCount: scope.bucketCount, + bucketId: '0', + rows, + } as AuthorCatalogBucketV1; + const unsignedBucket = { + issuer: LATENCY_ISSUER, + objectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + payload: bucketPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; + const bucket = { + ...unsignedBucket, + objectDigest: computeAuthorCatalogBucketObjectDigestV1(unsignedBucket), + signature: LATENCY_SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogBucketEnvelopeV1(bucket); + return { + session, + head: signedHead, + descriptor: { + bucketId: '0', + rowCount: String(rowCount) as CountV1, + byteLength: String( + canonicalizeAuthorCatalogBucketPayloadBytesV1(bucketPayload).byteLength, + ) as ByteLengthV1, + bucketDigest: bucket.objectDigest as Digest32V1, + }, + bucket, + }; +} diff --git a/packages/agent/test/rfc64-inventory-v1-candidate-plans.test.ts b/packages/agent/test/rfc64-inventory-v1-candidate-plans.test.ts new file mode 100644 index 0000000000..a9ebcdd8b5 --- /dev/null +++ b/packages/agent/test/rfc64-inventory-v1-candidate-plans.test.ts @@ -0,0 +1,350 @@ +import { DatabaseSync } from 'node:sqlite'; + +import { describe, expect, it } from 'vitest'; + +import { + INVENTORY_V1_DDL, + INVENTORY_V1_PLAN_STATEMENT_KEYS, + INVENTORY_V1_STATEMENT_IDS, + INVENTORY_V1_STATEMENT_SQL, + type InventoryV1StatementKey, +} from '../src/rfc64/inventory-v1/index.js'; + +const NEW_SESSION_HEX = '11'.repeat(32); +const OLD_SESSION_HEX = '22'.repeat(32); +const SCOPE_HEX = '33'.repeat(32); +const AUTHOR_HEX = '44'.repeat(20); +const NEW_HEAD_HEX = '55'.repeat(32); +const OLD_HEAD_HEX = '66'.repeat(32); +const BUCKET_COUNT_HEX = '0000000000000200'; +const SELECTED_BUCKET_HEX = '0000000000000001'; + +type PlanStatementKey = typeof INVENTORY_V1_PLAN_STATEMENT_KEYS[number]; +type PlanClass = Readonly>; + +describe('RFC-64 SQL-1 candidate fixed-statement plan gate', () => { + it('assigns one unique stable ID to every production SQL template', () => { + expect(Object.keys(INVENTORY_V1_STATEMENT_IDS).sort()) + .toEqual(Object.keys(INVENTORY_V1_STATEMENT_SQL).sort()); + expect(new Set(Object.values(INVENTORY_V1_STATEMENT_IDS)).size) + .toBe(Object.keys(INVENTORY_V1_STATEMENT_IDS).length); + }); + + it('keeps every persistent statement indexed on a fresh empty schema without statistics', () => { + const database = createFixtureDatabase(INVENTORY_V1_DDL, false); + try { + expect(() => database.prepare( + 'SELECT count(*) AS count FROM sqlite_stat1', + ).get()).toThrowError(); + expectPlanGate(collectPlanClass(database)); + } finally { + database.close(); + } + }); + + it('keeps every persistent statement indexed and plan-stable at 50k and 500k rows', () => { + const database = createFixtureDatabase(); + try { + seedRows(database, 1, 49_999, NEW_SESSION_HEX, NEW_HEAD_HEX); + seedRows(database, 50_000, 50_000, OLD_SESSION_HEX, OLD_HEAD_HEX); + const withoutStats = collectPlanClass(database); + expectPlanGate(withoutStats); + + database.exec('ANALYZE; PRAGMA optimize;'); + const at50k = collectPlanClass(database); + expectPlanGate(at50k); + + seedRows(database, 50_001, 500_000, NEW_SESSION_HEX, NEW_HEAD_HEX); + database.exec('ANALYZE; PRAGMA optimize;'); + const at500k = collectPlanClass(database); + expectPlanGate(at500k); + + // Estimated cardinalities may change, but the selected indexes and join + // order must not change with one decimal order of magnitude more rows. + expect(at500k).toEqual(at50k); + expect(withoutStats).toEqual(at50k); + } finally { + database.close(); + } + }, 60_000); + + it('fails the gate for the old head-wide-KA physical primary key', () => { + const database = createFixtureDatabase(oldHeadWidePrimaryKeyDdl()); + try { + seedRows(database, 1, 50_000, NEW_SESSION_HEX, NEW_HEAD_HEX); + const oldPhysicalKey = collectPlanClass(database); + expect(() => expectPlanGate(oldPhysicalKey)).toThrowError(); + } finally { + database.close(); + } + }, 60_000); + + it('fails the gate when the bucket-first primary key lacks head-wide KA uniqueness', () => { + const database = createFixtureDatabase(withoutHeadWideKaUniqueDdl()); + try { + seedRows(database, 1, 50_000, NEW_SESSION_HEX, NEW_HEAD_HEX); + const missingHeadWideLookup = collectPlanClass(database); + expect(() => expectPlanGate(missingHeadWideLookup)).toThrowError(); + } finally { + database.close(); + } + }, 60_000); +}); + +function createFixtureDatabase( + ddl = INVENTORY_V1_DDL, + withHeaders = true, +): DatabaseSync { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = MEMORY;'); + database.exec(ddl); + if (withHeaders) { + insertHeaders(database, NEW_SESSION_HEX, NEW_HEAD_HEX); + insertHeaders(database, OLD_SESSION_HEX, OLD_HEAD_HEX); + } + return database; +} + +function insertHeaders(database: DatabaseSync, sessionHex: string, headHex: string): void { + database.exec(` + WITH RECURSIVE buckets(bucket_id) AS ( + VALUES(0) + UNION ALL + SELECT bucket_id + 1 FROM buckets WHERE bucket_id < 511 + ) + INSERT INTO rfc64_candidate_bucket_loads_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + subgraph_name, + catalog_era_u64be, + bucket_count_u64be, + bucket_id_u64be, + bucket_object_digest, + row_count_u64be, + payload_byte_length_u64be + ) + SELECT + x'${sessionHex}', + x'${SCOPE_HEX}', + x'${AUTHOR_HEX}', + x'${headHex}', + NULL, + zeroblob(8), + x'${BUCKET_COUNT_HEX}', + unhex(printf('%016x', bucket_id)), + x'${'77'.repeat(32)}', + x'0000000000000001', + x'0000000000000001' + FROM buckets; + `); +} + +function seedRows( + database: DatabaseSync, + first: number, + last: number, + sessionHex: string, + headHex: string, +): void { + database.exec(` + WITH RECURSIVE numbers(n) AS ( + VALUES(${first}) + UNION ALL + SELECT n + 1 FROM numbers WHERE n < ${last} + ) + INSERT INTO rfc64_candidate_bucket_rows_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + ka_id_u256be, + catalog_key_digest, + assertion_coordinate, + assertion_version_u64be, + projection_id, + projection_digest, + seal_digest, + transfer_codec, + transfer_byte_length_u64be, + transfer_chunk_size_u64be, + transfer_chunk_count_u64be, + transfer_blob_digest, + transfer_chunk_tree_root, + expected_catalog_row_digest + ) + SELECT + x'${sessionHex}', + x'${SCOPE_HEX}', + x'${AUTHOR_HEX}', + x'${headHex}', + unhex(printf('%016x', n % 512)), + unhex('${AUTHOR_HEX}' || printf('%024x', n)), + unhex(printf('%064x', n)), + 'fixture-row-' || n, + x'0000000000000001', + 'cg-shared-v1', + x'${'88'.repeat(32)}', + x'${'99'.repeat(32)}', + 'dkg-ka-bundle-v1', + x'0000000000000010', + x'0000000000040000', + x'0000000000000001', + x'${'aa'.repeat(32)}', + x'${'bb'.repeat(32)}', + unhex(printf('%064x', n)) + FROM numbers; + `); +} + +function collectPlanClass(database: DatabaseSync): PlanClass { + const plans = {} as Record; + for (const statementId of INVENTORY_V1_PLAN_STATEMENT_KEYS) { + const sql = INVENTORY_V1_STATEMENT_SQL[statementId]; + const rows = database.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(parametersFor(sql)); + plans[statementId] = rows.map((row) => normalizePlanDetail(row.detail)); + } + return Object.freeze(plans); +} + +function parametersFor(sql: string): Record { + const all: Readonly> = { + session: hexBytes(NEW_SESSION_HEX), + oldSession: hexBytes(OLD_SESSION_HEX), + newSession: hexBytes(NEW_SESSION_HEX), + scope: hexBytes(SCOPE_HEX), + author: hexBytes(AUTHOR_HEX), + head: hexBytes(NEW_HEAD_HEX), + oldHead: hexBytes(OLD_HEAD_HEX), + newHead: hexBytes(NEW_HEAD_HEX), + bucket: hexBytes(SELECTED_BUCKET_HEX), + afterKaIdU256be: hexBytes(`${AUTHOR_HEX}${'00'.repeat(11)}01`), + limit: 256, + }; + const names = [...sql.matchAll(/:([A-Za-z][A-Za-z0-9]*)/g)].map((match) => match[1]!); + return Object.fromEntries([...new Set(names)].map((name) => [name, all[name]!])); +} + +function normalizePlanDetail(value: unknown): string { + if (typeof value !== 'string') throw new Error('SQLite returned a non-text plan detail'); + return value.replace(/\s+/g, ' ').trim(); +} + +function expectPlanGate(plans: PlanClass): void { + const allDetails = Object.entries(plans).flatMap(([statementId, details]) => + details.map((detail) => + `${INVENTORY_V1_STATEMENT_IDS[statementId as InventoryV1StatementKey]}: ${detail}`)); + expect(allDetails, 'persistent statements must never scan owned tables') + .not.toEqual(expect.arrayContaining([ + expect.stringMatching(/\bSCAN rfc64_candidate_(?:bucket_loads|bucket_rows)_v1\b/i), + ])); + expect(allDetails, 'persistent statements must never materialize a sort') + .not.toEqual(expect.arrayContaining([expect.stringMatching(/USE TEMP B-TREE/i)])); + + expect( + plans.getHeader.some((detail) => + detail.includes('USING PRIMARY KEY') && detail.includes('bucket_id_u64be=?')), + `${INVENTORY_V1_STATEMENT_IDS.getHeader} must use the exact load primary key`, + ).toBe(true); + + for (const statementId of [ + 'getRowsFirst', + 'getRowsNext', + 'getRowsExactRetry', + 'countBucketRows', + ] as const) { + expect( + plans[statementId].some((detail) => + detail.includes('USING PRIMARY KEY') && detail.includes('bucket_id_u64be=?')), + `${INVENTORY_V1_STATEMENT_IDS[statementId]} must use the bucket-first physical key`, + ).toBe(true); + } + + for (const statementId of [ + 'diffAddedOrChangedFirst', + 'diffAddedOrChangedNext', + 'diffRemovedFirst', + 'diffRemovedNext', + ] as const) { + expect( + plans[statementId].some((detail) => + detail.includes('USING PRIMARY KEY') && detail.includes('bucket_id_u64be=?')), + `${INVENTORY_V1_STATEMENT_IDS[statementId]} must stream the selected bucket by physical key`, + ).toBe(true); + expect( + plans[statementId].some((detail) => + detail.includes('sqlite_autoindex_rfc64_candidate_bucket_rows_v1_2') + && detail.includes('ka_id_u256be=?')), + `${INVENTORY_V1_STATEMENT_IDS[statementId]} must point-join through head-wide KA uniqueness`, + ).toBe(true); + } + + expect( + plans.deleteHeader.some((detail) => + detail.includes('rfc64_candidate_bucket_rows_v1') + && detail.includes('USING PRIMARY KEY') + && detail.includes('bucket_id_u64be=?')), + 'candidate cascade must use the bucket-first child primary key', + ).toBe(true); +} + +function oldHeadWidePrimaryKeyDdl(): string { + const bucketFirst = `PRIMARY KEY ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + ka_id_u256be + ), + + UNIQUE ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + ka_id_u256be + ),`; + const oldShape = `PRIMARY KEY ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + ka_id_u256be + ), + + UNIQUE ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + ka_id_u256be + ),`; + const mutated = INVENTORY_V1_DDL.replace(bucketFirst, oldShape); + if (mutated === INVENTORY_V1_DDL) throw new Error('failed to mutate frozen candidate rows DDL'); + return mutated; +} + +function withoutHeadWideKaUniqueDdl(): string { + const headWideUnique = `UNIQUE ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + ka_id_u256be + ), + + `; + const mutated = INVENTORY_V1_DDL.replace(headWideUnique, ''); + if (mutated === INVENTORY_V1_DDL) { + throw new Error('failed to remove the frozen head-wide KA unique constraint'); + } + return mutated; +} + +function hexBytes(hex: string): Uint8Array { + return Uint8Array.from(Buffer.from(hex, 'hex')); +} diff --git a/packages/agent/test/rfc64-inventory-v1-candidates.test.ts b/packages/agent/test/rfc64-inventory-v1-candidates.test.ts new file mode 100644 index 0000000000..d000293c20 --- /dev/null +++ b/packages/agent/test/rfc64-inventory-v1-candidates.test.ts @@ -0,0 +1,1342 @@ +import { + mkdtempSync, + realpathSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync, type StatementSync } from 'node:sqlite'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + KA_TRANSFER_CHUNK_SIZE_V1, + KA_TRANSFER_CODEC_V1, + KA_TRANSFER_PROJECTION_V1, + ZERO_DIGEST32_V1, + assertAuthorCatalogRowV1, + assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + canonicalizeAuthorCatalogBucketPayloadBytesV1, + catalogKeyToBucketIdV1, + computeAuthorCatalogBucketObjectDigestV1, + computeAuthorCatalogHeadObjectDigestV1, + computeAuthorCatalogScopeDigestV1, + deriveAuthorCatalogScopeFromHeadV1, + type AuthorCatalogBucketV1, + type AuthorCatalogHeadV1, + type AuthorCatalogRowV1, + type ByteLengthV1, + type CountV1, + type DecimalU64V1, + type Digest32V1, + type KaIdV1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; + +import { + INVENTORY_V1_DDL, + InventoryV1CandidateError, + openInventoryV1, + type CandidateSessionV1, + type Rfc64InventoryV1Foundation, + type VerifiedCandidateBucketLoadV1, +} from '../src/rfc64/inventory-v1/index.js'; +import { CandidateInventoryV1 } from '../src/rfc64/inventory-v1/candidate.js'; + +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const ISSUER = '0x5555555555555555555555555555555555555555'; +const SIGNATURE = `0x${'77'.repeat(65)}`; +const temporaryDirectories: string[] = []; +const foundations: Rfc64InventoryV1Foundation[] = []; + +afterEach(() => { + for (const foundation of foundations.splice(0)) foundation.close(); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('RFC-64 SQL-1 verified candidate buckets', () => { + it('round-trips the positive author-bound vector, exact retry, and terminal paging', async () => { + const inventory = await openFixtureInventory(); + expect(() => inventory.createCandidateSession()).toThrowError( + expect.objectContaining({ code: 'candidate-startup-purge-required' }), + ); + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + const session = inventory.createCandidateSession(); + const head = makeHead('1', '1'); + const row = makeRow(1n, 'fixture'); + const load = makeNonEmptyLoad(session, head, [row]); + expect(load.bucket?.objectDigest).toBe( + '0xddedcd25a1fd2afb797f146b04fec735fd3b341d2f10293a1db9fd915e701866', + ); + + const inserted = inventory.putVerifiedCandidateBucket(load); + expect(inserted.status).toBe('inserted'); + expect(inserted.header).toMatchObject({ + bucketId: '0', + bucketCount: '1', + rowCount: '1', + payloadByteLength: '868', + }); + expect(Object.keys(inventory.getCandidateBucket(inserted.loadKey))).not.toContain('rows'); + + const retried = inventory.putVerifiedCandidateBucket(load); + expect(retried.status).toBe('existing'); + expect(retried.header).toEqual(inserted.header); + + const traversal = inventory.beginCandidateBucketRows(inserted.loadKey); + const first = inventory.pageCandidateBucketRows(traversal, null, 1); + expect(first.rows.map((entry) => entry.row.kaId)).toEqual([row.kaId]); + expect(first.rows[0]).toMatchObject({ + catalogKeyDigest: '0x5f49f03c5a2480a80ee4b7dadff8b7c8e18a69358bfdf64a1420dddf513de2e5', + expectedCatalogRowDigest: '0x893392ecdfbf47fac3eb3290bc6f69b9472952439b9233555c523ed8e28f3179', + }); + expect(first.resumeAfter).toBe(row.kaId); + expect(inventory.pageCandidateBucketRows(traversal, first.resumeAfter, 1)).toEqual({ + rows: [], + resumeAfter: null, + }); + expect(() => inventory.pageCandidateBucketRows(traversal, first.resumeAfter, 1)) + .toThrowError(expect.objectContaining({ code: 'candidate-traversal-closed' })); + }); + + it('reopens the low-level database, retains the opaque session, and exact-key retries', () => { + const directory = temporaryDataDirectory(); + const path = join(directory, 'commit-retry.sqlite3'); + let database = new DatabaseSync(path); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + database.exec(INVENTORY_V1_DDL); + let failAfterCommittedOnce = false; + const makeFacade = (): DatabaseSync => proxyDatabase(database, { + exec(sql, exec) { + exec(sql); + if (failAfterCommittedOnce && sql.trim().toUpperCase() === 'COMMIT') { + failAfterCommittedOnce = false; + throw new Error('injected post-COMMIT transport failure'); + } + }, + }); + let facade = makeFacade(); + const reopen = vi.fn((abandoned: DatabaseSync) => { + expect(abandoned).toBe(facade); + database.close(); + database = new DatabaseSync(path); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + facade = makeFacade(); + return facade; + }); + const inventory = new CandidateInventoryV1(facade, reopen); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + const session = inventory.createCandidateSession(); + const firstLoad = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '1'), [makeRow(1n, 'fixture')]), + ); + const invalidatedTraversal = inventory.beginCandidateBucketRows(firstLoad.loadKey); + + failAfterCommittedOnce = true; + const retried = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '2'), [makeRow(1n, 'fixture')]), + ); + expect(retried.status).toBe('existing'); + expect(reopen).toHaveBeenCalledOnce(); + expect(inventory.getCandidateBucket(retried.loadKey).targetCatalogHeadDigest) + .toBe(retried.header.targetCatalogHeadDigest); + expect(() => inventory.pageCandidateBucketRows(invalidatedTraversal, null, 1)) + .toThrowError(expect.objectContaining({ code: 'candidate-traversal-closed' })); + expect(inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '1'), [makeRow(1n, 'fixture')]), + ).status).toBe('existing'); + } finally { + inventory.close(); + database.close(); + } + }); + + it('retries the exact candidate put when the failed COMMIT provably rolled back', () => { + let failNextCommit = false; + const fixture = createReopenableCandidateDatabase({ + exec(sql, exec) { + if (failNextCommit && sql.trim().toUpperCase() === 'COMMIT') { + failNextCommit = false; + // Throw before COMMIT. Closing the abandoned file-backed connection + // during verified reopen performs SQLite's rollback. + throw new Error('injected pre-COMMIT transport failure'); + } + exec(sql); + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + const session = inventory.createCandidateSession(); + failNextCommit = true; + const loaded = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '30'), [makeRow(1n, 'retry')]), + ); + expect(loaded.status).toBe('inserted'); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(fixture.database().prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get()?.count).toBe(1); + expect(fixture.database().prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_rows_v1', + ).get()?.count).toBe(1); + } finally { + inventory.close(); + fixture.close(); + } + }); + + for (const landed of [false, true]) { + it(`resolves an indeterminate exact bucket delete when COMMIT ${landed ? 'landed' : 'rolled back'}`, () => { + let failNextCommit = false; + const fixture = createReopenableCandidateDatabase({ + exec(sql, exec) { + if (failNextCommit && sql.trim().toUpperCase() === 'COMMIT') { + failNextCommit = false; + if (landed) exec(sql); + throw new Error(`injected ${landed ? 'post' : 'pre'}-COMMIT failure`); + } + exec(sql); + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen); + try { + inventory.purgeNextStartupStaleCandidateBatch(); + const session = inventory.createCandidateSession(); + const loaded = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', landed ? '31' : '32'), [makeRow(1n, 'delete')]), + ); + failNextCommit = true; + expect(() => inventory.deleteCandidateBucket(loaded.loadKey)).not.toThrow(); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(fixture.database().prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get()?.count).toBe(0); + expect(fixture.database().prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_rows_v1', + ).get()?.count).toBe(0); + } finally { + inventory.close(); + fixture.close(); + } + }); + } + + it('retries only the original startup purge keys after an indeterminate COMMIT', () => { + let failNextCommit = true; + const fixture = createReopenableCandidateDatabase({ + exec(sql, exec) { + if (failNextCommit && sql.trim().toUpperCase() === 'COMMIT') { + failNextCommit = false; + throw new Error('injected purge COMMIT failure'); + } + exec(sql); + }, + afterReopen(database) { + insertRawEmptyHeader(database, 0x01); + }, + }); + for (let sessionByte = 0x10; sessionByte <= 0x19; sessionByte += 1) { + insertRawEmptyHeader(fixture.database(), sessionByte); + } + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 8, + done: false, + }); + expect(fixture.reopen).toHaveBeenCalledOnce(); + const remaining = fixture.database().prepare(` + SELECT hex(session_id) AS session_hex + FROM rfc64_candidate_bucket_loads_v1 + ORDER BY session_id + `).all().map((row) => row.session_hex); + expect(remaining).toEqual([ + rawSessionHex(0x01), + rawSessionHex(0x18), + rawSessionHex(0x19), + ]); + } finally { + inventory.close(); + fixture.close(); + } + }); + + it('fails closed when an indeterminate purge resolves to a mixed original key set', () => { + let failNextCommit = true; + const fixture = createReopenableCandidateDatabase({ + exec(sql, exec) { + exec(sql); + if (failNextCommit && sql.trim().toUpperCase() === 'COMMIT') { + failNextCommit = false; + throw new Error('injected post-COMMIT purge failure'); + } + }, + afterReopen(database) { + insertRawEmptyHeader(database, 0x10); + }, + }); + insertRawEmptyHeader(fixture.database(), 0x10); + insertRawEmptyHeader(fixture.database(), 0x11); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen); + try { + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + expect.objectContaining({ code: 'candidate-database-corrupt' }), + ); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(() => inventory.createCandidateSession()).toThrowError( + expect.objectContaining({ code: 'candidate-startup-purge-required' }), + ); + } finally { + inventory.close(); + fixture.close(); + } + }); + + it('forces verified reopen whenever rollback itself reports failure', () => { + let failPrepare = true; + let abandonedRollbackAttempted = false; + const fixture = createReopenableCandidateDatabase({ + exec(sql, exec) { + if (sql.trim().toUpperCase() === 'ROLLBACK') { + abandonedRollbackAttempted = true; + // Throw before executing ROLLBACK. Verified reopen must close this + // transaction and return a genuinely new file-backed handle. + throw new Error('injected rollback failure before execution'); + } + exec(sql); + }, + prepare(sql, prepare) { + if (failPrepare && sql.includes('FROM rfc64_candidate_bucket_loads_v1')) { + failPrepare = false; + throw new Error('injected operation failure'); + } + return prepare(sql); + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen); + try { + expect(() => inventory.purgeNextStartupStaleCandidateBatch()).toThrowError( + expect.objectContaining({ code: 'candidate-database-error' }), + ); + expect(abandonedRollbackAttempted).toBe(true); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(fixture.didReturnDistinctHandle()).toBe(true); + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + } finally { + inventory.close(); + fixture.close(); + } + }); + + it('resolves poisoned-session discard against the original exact keys before invalidation', () => { + let failNextCommit = false; + const fixture = createReopenableCandidateDatabase({ + exec(sql, exec) { + if (failNextCommit && sql.trim().toUpperCase() === 'COMMIT') { + failNextCommit = false; + throw new Error('injected discard COMMIT failure'); + } + exec(sql); + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen); + try { + inventory.purgeNextStartupStaleCandidateBatch(); + const session = inventory.createCandidateSession(); + const head = makeHead('1', '34'); + inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, head, [makeRow(1n, 'original')]), + ); + expect(() => inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, head, [makeRow(2n, 'conflict')]), + )).toThrowError(expect.objectContaining({ code: 'candidate-conflict' })); + + failNextCommit = true; + expect(inventory.discardCandidateSessionBatch(session)).toEqual({ + deletedLoads: 1, + done: false, + }); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(inventory.discardCandidateSessionBatch(session)).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(() => inventory.discardCandidateSessionBatch(session)).toThrowError( + expect.objectContaining({ code: 'candidate-invalid-session' }), + ); + } finally { + inventory.close(); + fixture.close(); + } + }); + + it('returns a known committed put even when its mandatory post-overrun reopen fails', () => { + let now = 0; + let overrunNextCommit = false; + let failReopen = false; + const fixture = createReopenableCandidateDatabase({ + exec(sql, exec) { + exec(sql); + if (overrunNextCommit && sql.trim().toUpperCase() === 'COMMIT') { + overrunNextCommit = false; + now += 10_001; + } + }, + failReopen() { + return failReopen; + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen, () => now); + try { + inventory.purgeNextStartupStaleCandidateBatch(); + const session = inventory.createCandidateSession(); + overrunNextCommit = true; + failReopen = true; + const result = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '35'), [makeRow(1n, 'known-commit')]), + ); + expect(result.status).toBe('inserted'); + expect(inventory.committedOverruns).toBe(1); + expect(fixture.reopen).toHaveBeenCalledOnce(); + const inspector = openFileDatabase(fixture.path, false); + try { + expect(inspector.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get()?.count).toBe(1); + } finally { + inspector.close(); + } + expect(() => inventory.getCandidateBucket(result.loadKey)).toThrowError( + /unavailable after failed verified reopen/, + ); + } finally { + inventory.close(); + fixture.close(); + } + }); + + it('discards a read on COMMIT failure and reopens before accepting its retry', () => { + let failNextCommit = false; + const fixture = createReopenableCandidateDatabase({ + exec(sql, exec) { + if (failNextCommit && sql.trim().toUpperCase() === 'COMMIT') { + failNextCommit = false; + throw new Error('injected read COMMIT failure'); + } + exec(sql); + }, + }); + const inventory = new CandidateInventoryV1(fixture.facade, fixture.reopen); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + const session = inventory.createCandidateSession(); + const inserted = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '1'), [makeRow(1n, 'fixture')]), + ); + const invalidatedTraversal = inventory.beginCandidateBucketRows(inserted.loadKey); + + failNextCommit = true; + expect(() => inventory.getCandidateBucket(inserted.loadKey)).toThrowError( + /read COMMIT failed after verified reopen/, + ); + expect(fixture.reopen).toHaveBeenCalledOnce(); + expect(() => inventory.pageCandidateBucketRows(invalidatedTraversal, null, 1)) + .toThrowError(expect.objectContaining({ code: 'candidate-traversal-closed' })); + expect(inventory.getCandidateBucket(inserted.loadKey)).toEqual(inserted.header); + } finally { + inventory.close(); + fixture.close(); + } + }); + + for (const failAfterInsert of [1, 2, 3]) { + it(`rolls back atomically after injected candidate insert ${failAfterInsert}`, () => { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + database.exec(INVENTORY_V1_DDL); + let insertions = 0; + let armed = false; + const facade = { + exec: database.exec.bind(database), + prepare(sql: string): StatementSync { + const statement = database.prepare(sql); + if (!/^\s*INSERT INTO rfc64_candidate_bucket_(?:loads|rows)_v1/i.test(sql)) { + return statement; + } + return new Proxy(statement, { + get(target, property) { + if (property === 'run') { + return (...args: unknown[]) => { + const result = Reflect.apply(target.run, target, args); + insertions += 1; + if (armed && insertions === failAfterInsert) { + throw new Error(`injected death after insert ${failAfterInsert}`); + } + return result; + }; + } + const value = Reflect.get(target, property, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }, + } as unknown as DatabaseSync; + const inventory = new CandidateInventoryV1(facade, unexpectedReopen); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + const session = inventory.createCandidateSession(); + const load = makeNonEmptyLoad( + session, + makeHead('2', String(failAfterInsert + 2)), + [makeRow(1n, 'fixture-1'), makeRow(2n, 'fixture-2')], + ); + armed = true; + expect(() => inventory.putVerifiedCandidateBucket(load)).toThrowError( + expect.objectContaining({ code: 'candidate-database-error' }), + ); + expect(database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get()?.count).toBe(0); + expect(database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_rows_v1', + ).get()?.count).toBe(0); + + armed = false; + insertions = 0; + expect(inventory.putVerifiedCandidateBucket(load).status).toBe('inserted'); + expect(database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_rows_v1', + ).get()?.count).toBe(2); + } finally { + inventory.close(); + database.close(); + } + }); + } + + for (const constraint of [ + { label: 'CHECK', errcode: 275, code: 'SQLITE_CONSTRAINT_CHECK' }, + { label: 'FOREIGN KEY', errcode: 787, code: 'SQLITE_CONSTRAINT_FOREIGNKEY' }, + ] as const) { + it(`does not poison a session for a ${constraint.label} constraint failure`, () => { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + database.exec(INVENTORY_V1_DDL); + let injectConstraint = false; + const facade = proxyDatabase(database, { + prepare(sql, prepare) { + const statement = prepare(sql); + if (!/^\s*INSERT INTO rfc64_candidate_bucket_loads_v1/i.test(sql)) return statement; + return new Proxy(statement, { + get(target, property) { + if (property !== 'run') return Reflect.get(target, property, target); + const run = target.run.bind(target); + return (...parameters: Parameters) => { + if (injectConstraint) { + injectConstraint = false; + const error = new Error(`${constraint.label} constraint failed: injected`) as Error & { + code: string; + errcode: number; + }; + error.code = constraint.code; + error.errcode = constraint.errcode; + throw error; + } + return run(...parameters); + }; + }, + }); + }, + }); + const inventory = new CandidateInventoryV1(facade, unexpectedReopen); + try { + inventory.purgeNextStartupStaleCandidateBatch(); + const session = inventory.createCandidateSession(); + const load = makeNonEmptyLoad( + session, + makeHead('1', constraint.errcode === 275 ? '42' : '43'), + [makeRow(1n, constraint.label)], + ); + injectConstraint = true; + expect(() => inventory.putVerifiedCandidateBucket(load)).toThrowError( + expect.objectContaining({ code: 'candidate-database-error' }), + ); + expect(inventory.putVerifiedCandidateBucket(load).status).toBe('inserted'); + } finally { + inventory.close(); + database.close(); + } + }); + } + + it('refuses a cascade when stored children exceed the committed header count', () => { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + database.exec(INVENTORY_V1_DDL); + const inventory = new CandidateInventoryV1(database, unexpectedReopen); + try { + inventory.purgeNextStartupStaleCandidateBatch(); + const session = inventory.createCandidateSession(); + const loaded = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '20'), [makeRow(1n, 'fixture')]), + ); + database.exec(` + INSERT INTO rfc64_candidate_bucket_rows_v1 + SELECT + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + x'${AUTHOR.slice(2)}000000000000000000000002', + x'${'cc'.repeat(32)}', + 'tampered-extra-child', + assertion_version_u64be, + projection_id, + projection_digest, + seal_digest, + transfer_codec, + transfer_byte_length_u64be, + transfer_chunk_size_u64be, + transfer_chunk_count_u64be, + transfer_blob_digest, + transfer_chunk_tree_root, + x'${'dd'.repeat(32)}' + FROM rfc64_candidate_bucket_rows_v1; + `); + expect(() => inventory.deleteCandidateBucket(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-database-corrupt' }), + ); + expect(database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get()?.count).toBe(1); + expect(database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_rows_v1', + ).get()?.count).toBe(2); + } finally { + inventory.close(); + database.close(); + } + }); + + it('rejects a traversal before pinning when actual children exceed the verified header count', () => { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + database.exec(INVENTORY_V1_DDL); + const inventory = new CandidateInventoryV1(database, unexpectedReopen); + try { + inventory.purgeNextStartupStaleCandidateBatch(); + const session = inventory.createCandidateSession(); + const loaded = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '33'), [makeRow(1n, 'fixture')]), + ); + insertTamperedExtraChild(database); + expect(() => inventory.beginCandidateBucketRows(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-database-corrupt' }), + ); + // No traversal was registered or pinned before the exact count check. + expect(() => inventory.deleteCandidateBucket(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-database-corrupt' }), + ); + } finally { + inventory.close(); + database.close(); + } + }); + + it('distinguishes a canonical empty bucket from an absent header and cascades deletion', async () => { + const inventory = await readyInventory(); + const session = inventory.createCandidateSession(); + const head = makeHead('0', '2'); + const loaded = inventory.putVerifiedCandidateBucket(makeEmptyLoad(session, head, '0')); + expect(loaded.header).toMatchObject({ + bucketObjectDigest: ZERO_DIGEST32_V1, + rowCount: '0', + payloadByteLength: '0', + }); + const traversal = inventory.beginCandidateBucketRows(loaded.loadKey); + expect(inventory.pageCandidateBucketRows(traversal, undefined, 256)).toEqual({ + rows: [], + resumeAfter: null, + }); + inventory.deleteCandidateBucket(loaded.loadKey); + expect(() => inventory.getCandidateBucket(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-not-loaded' }), + ); + expect(() => inventory.deleteCandidateBucket(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-not-loaded' }), + ); + }); + + it('poisons a conflicting session and permits only bounded discard until terminal empty', async () => { + const inventory = await readyInventory(); + const session = inventory.createCandidateSession(); + const head = makeHead('1', '3'); + const original = makeNonEmptyLoad(session, head, [makeRow(1n, 'original')]); + const loaded = inventory.putVerifiedCandidateBucket(original); + const mutation = makeNonEmptyLoad(session, head, [makeRow(2n, 'mutation')]); + + expect(() => inventory.putVerifiedCandidateBucket(mutation)).toThrowError( + expect.objectContaining({ code: 'candidate-conflict' }), + ); + expect(() => inventory.getCandidateBucket(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-session-poisoned' }), + ); + expect(() => inventory.deleteCandidateBucket(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-session-poisoned' }), + ); + expect(inventory.discardCandidateSessionBatch(session)).toEqual({ + deletedLoads: 1, + done: false, + }); + expect(inventory.discardCandidateSessionBatch(session)).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(() => inventory.discardCandidateSessionBatch(session)).toThrowError( + expect.objectContaining({ code: 'candidate-invalid-session' }), + ); + }); + + it('poisons cross-bucket assertion-coordinate uniqueness conflicts atomically', async () => { + const inventory = await readyInventory(); + const session = inventory.createCandidateSession(); + const head = makeHead('2', '4', '2'); + const bucket0Row = rowForBucket('0', '2', 1n, 'same-coordinate'); + const bucket1Row = rowForBucket('1', '2', 2n, 'same-coordinate'); + const first = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, head, [bucket0Row], '0'), + ); + expect(() => inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, head, [bucket1Row], '1'), + )).toThrowError(expect.objectContaining({ code: 'candidate-conflict' })); + expect(() => inventory.getCandidateBucket(first.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-session-poisoned' }), + ); + }); + + it('reports poison before malformed puts and before closed rows or either diff stream', async () => { + const inventory = await readyInventory(); + const oldSession = inventory.createCandidateSession(); + const poisonedSession = inventory.createCandidateSession(); + const oldHead = makeHead('1', '40'); + const newHead = makeHead('1', '41'); + const oldLoad = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(oldSession, oldHead, [makeRow(1n, 'old')]), + ); + const newLoadInput = makeNonEmptyLoad( + poisonedSession, + newHead, + [makeRow(2n, 'new')], + ); + const newLoad = inventory.putVerifiedCandidateBucket(newLoadInput); + const rows = inventory.beginCandidateBucketRows(newLoad.loadKey); + const diff = inventory.beginCandidateBucketDiff(oldLoad.loadKey, newLoad.loadKey); + + expect(() => inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(poisonedSession, newHead, [makeRow(3n, 'conflict')]), + )).toThrowError(expect.objectContaining({ code: 'candidate-conflict' })); + + expect(() => inventory.putVerifiedCandidateBucket({ + session: poisonedSession, + head: null, + descriptor: null, + bucket: null, + } as unknown as VerifiedCandidateBucketLoadV1)).toThrowError( + expect.objectContaining({ code: 'candidate-session-poisoned' }), + ); + expect(() => inventory.pageCandidateBucketRows(rows, null, 1)).toThrowError( + expect.objectContaining({ code: 'candidate-session-poisoned' }), + ); + expect(() => inventory.pageCandidateBucketAddedOrChanged(diff, null, 1)).toThrowError( + expect.objectContaining({ code: 'candidate-session-poisoned' }), + ); + expect(() => inventory.pageCandidateBucketRemoved(diff, null, 1)).toThrowError( + expect.objectContaining({ code: 'candidate-session-poisoned' }), + ); + }); + + it('pages added/changed and removed streams independently to terminal empty pages', async () => { + const inventory = await readyInventory(); + const oldSession = inventory.createCandidateSession(); + const newSession = inventory.createCandidateSession(); + const oldHead = makeHead('3', '5'); + const newHead = makeHead('3', '6'); + const one = makeRow(1n, 'one'); + const two = makeRow(2n, 'two'); + const three = makeRow(3n, 'three'); + const changedTwo = makeRow(2n, 'two', '99'); + const four = makeRow(4n, 'four'); + const oldLoad = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(oldSession, oldHead, [one, two, three]), + ); + const newLoad = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(newSession, newHead, [one, changedTwo, four]), + ); + const traversal = inventory.beginCandidateBucketDiff(oldLoad.loadKey, newLoad.loadKey); + + const added1 = inventory.pageCandidateBucketAddedOrChanged(traversal, null, 1); + expect(added1.rows.map((entry) => entry.row.kaId)).toEqual([two.kaId]); + const added2 = inventory.pageCandidateBucketAddedOrChanged( + traversal, + added1.resumeAfter, + 1, + ); + expect(added2.rows.map((entry) => entry.row.kaId)).toEqual([four.kaId]); + expect(inventory.pageCandidateBucketAddedOrChanged( + traversal, + added2.resumeAfter, + 1, + )).toEqual({ rows: [], resumeAfter: null }); + + const removed = inventory.pageCandidateBucketRemoved(traversal, null, 1); + expect(removed.rows.map((entry) => entry.row.kaId)).toEqual([three.kaId]); + expect(inventory.pageCandidateBucketRemoved(traversal, removed.resumeAfter, 1)).toEqual({ + rows: [], + resumeAfter: null, + }); + }); + + it('pins loads, rejects cursor skipping, and releases pins on explicit early close', async () => { + const inventory = await readyInventory(); + const session = inventory.createCandidateSession(); + const head = makeHead('2', '7'); + const loaded = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, head, [makeRow(1n, 'one'), makeRow(2n, 'two')]), + ); + const traversal = inventory.beginCandidateBucketRows(loaded.loadKey); + const first = inventory.pageCandidateBucketRows(traversal, null, 1); + expect(() => inventory.deleteCandidateBucket(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-in-use' }), + ); + expect(() => inventory.pageCandidateBucketRows(traversal, makeRow(2n, 'x').kaId, 1)) + .toThrowError(expect.objectContaining({ code: 'candidate-cursor-mismatch' })); + expect(() => inventory.pageCandidateBucketRows(traversal, first.resumeAfter, 1)) + .toThrowError(expect.objectContaining({ code: 'candidate-traversal-closed' })); + + const secondTraversal = inventory.beginCandidateBucketRows(loaded.loadKey); + inventory.closeCandidateTraversal(secondTraversal); + inventory.closeCandidateTraversal(secondTraversal); + inventory.deleteCandidateBucket(loaded.loadKey); + }); + + it('purges startup-stale loads in fixed batches of eight plus a terminal empty call', async () => { + const dataDirectory = temporaryDataDirectory(); + const first = await openInventoryV1(dataDirectory); + expect(first.purgeNextStartupStaleCandidateBatch()).toEqual({ deletedLoads: 0, done: true }); + const session = first.createCandidateSession(); + const head = makeHead('0', '8', '16'); + for (let bucket = 0; bucket < 9; bucket += 1) { + first.putVerifiedCandidateBucket(makeEmptyLoad(session, head, String(bucket))); + } + first.close(); + + const reopened = await openInventoryV1(dataDirectory); + foundations.push(reopened); + expect(() => reopened.createCandidateSession()).toThrowError( + expect.objectContaining({ code: 'candidate-startup-purge-required' }), + ); + expect(reopened.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 8, + done: false, + }); + expect(reopened.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 1, + done: false, + }); + expect(reopened.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(() => reopened.createCandidateSession()).not.toThrow(); + }); + + it('rebuilds after database deletion and rejects every old process-local capability', async () => { + const dataDirectory = temporaryDataDirectory(); + const first = await readyInventoryAt(dataDirectory); + const oldSession = first.createCandidateSession(); + const loaded = first.putVerifiedCandidateBucket( + makeNonEmptyLoad(oldSession, makeHead('1', '41'), [makeRow(41n, 'deleted-db')]), + ); + const oldTraversal = first.beginCandidateBucketRows(loaded.loadKey); + const databasePath = first.databasePath; + first.close(); + rmSync(databasePath, { force: true }); + rmSync(`${databasePath}-wal`, { force: true }); + rmSync(`${databasePath}-shm`, { force: true }); + + const rebuilt = await openFixtureInventory(dataDirectory); + expect(rebuilt.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + expect(() => rebuilt.getCandidateBucket(loaded.loadKey)).toThrowError( + expect.objectContaining({ code: 'candidate-invalid-load-key' }), + ); + expect(() => rebuilt.pageCandidateBucketRows(oldTraversal, null, 1)).toThrowError( + expect.objectContaining({ code: 'candidate-invalid-traversal' }), + ); + expect(() => rebuilt.putVerifiedCandidateBucket( + makeNonEmptyLoad(oldSession, makeHead('1', '42'), [makeRow(42n, 'old-session')]), + )).toThrowError(expect.objectContaining({ code: 'candidate-invalid-session' })); + expect(() => first.purgeNextStartupStaleCandidateBatch()).toThrowError( + expect.objectContaining({ code: 'database-closed' }), + ); + + const freshSession = rebuilt.createCandidateSession(); + expect(rebuilt.putVerifiedCandidateBucket( + makeNonEmptyLoad(freshSession, makeHead('1', '43'), [makeRow(43n, 'fresh-session')]), + ).status).toBe('inserted'); + }); + + it('rejects duplicate KA/key input and a row mapped to the wrong bucket', async () => { + const inventory = await readyInventory(); + const session = inventory.createCandidateSession(); + const duplicate = rowForBucket('0', '2', 1n, 'duplicate-ka'); + expect(() => inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('2', '44', '2'), [duplicate, duplicate], '0', false), + )).toThrowError(expect.objectContaining({ code: 'candidate-invalid-load' })); + + const wrongBucket = rowForBucket('0', '2', 10_000n, 'wrong-bucket'); + expect(() => inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '45', '2'), [wrongBucket], '1', false), + )).toThrowError(expect.objectContaining({ code: 'candidate-invalid-load' })); + }); + + for (const malformed of [ + { label: 'type', sql: "UPDATE rfc64_candidate_bucket_rows_v1 SET ka_id_u256be = 'text'" }, + { label: 'width', sql: 'UPDATE rfc64_candidate_bucket_rows_v1 SET ka_id_u256be = zeroblob(31)' }, + { label: 'null', sql: 'UPDATE rfc64_candidate_bucket_rows_v1 SET ka_id_u256be = NULL' }, + ] as const) { + it(`fails closed on a malformed stored ${malformed.label}`, () => { + const database = createLaxCandidateDatabase(); + const inventory = new CandidateInventoryV1(database, unexpectedReopen); + try { + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + const session = inventory.createCandidateSession(); + const loaded = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, makeHead('1', '46'), [makeRow(46n, 'malformed')]), + ); + database.exec(malformed.sql); + const traversal = inventory.beginCandidateBucketRows(loaded.loadKey); + expect(() => inventory.pageCandidateBucketRows(traversal, null, 1)).toThrowError( + expect.objectContaining({ code: 'candidate-database-corrupt' }), + ); + } finally { + inventory.close(); + database.close(); + } + }); + } + + it('rejects raw capabilities, clean-session discard, invalid pages, and descriptor mismatch', async () => { + const inventory = await readyInventory(); + const session = inventory.createCandidateSession(); + expect(() => inventory.discardCandidateSessionBatch(session)).toThrowError( + expect.objectContaining({ code: 'candidate-session-not-poisoned' }), + ); + expect(() => inventory.putVerifiedCandidateBucket({ + ...makeEmptyLoad(session, makeHead('0', '9'), '0'), + session: Object.freeze({}) as CandidateSessionV1, + })).toThrowError(expect.objectContaining({ code: 'candidate-invalid-session' })); + + const head = makeHead('1', '10'); + const invalid = makeNonEmptyLoad(session, head, [makeRow(1n, 'fixture')]); + expect(() => inventory.putVerifiedCandidateBucket({ + ...invalid, + descriptor: { ...invalid.descriptor, byteLength: '1' }, + } as VerifiedCandidateBucketLoadV1)).toThrowError( + expect.objectContaining({ code: 'candidate-invalid-load' }), + ); + const unboundRow = { + ...makeRow(1n, 'fixture'), + kaId: '1', + } as AuthorCatalogRowV1; + expect(() => inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, head, [unboundRow]), + )).toThrowError(expect.objectContaining({ code: 'candidate-invalid-load' })); + const loaded = inventory.putVerifiedCandidateBucket(invalid); + const traversal = inventory.beginCandidateBucketRows(loaded.loadKey); + expect(() => inventory.pageCandidateBucketRows(traversal, null, 0)).toThrowError( + expect.objectContaining({ code: 'candidate-invalid-load' }), + ); + }); +}); + +async function openFixtureInventory(dataDirectory = temporaryDataDirectory()) { + const inventory = await openInventoryV1(dataDirectory); + foundations.push(inventory); + return inventory; +} + +async function readyInventory() { + return readyInventoryAt(temporaryDataDirectory()); +} + +async function readyInventoryAt(dataDirectory: string) { + const inventory = await openFixtureInventory(dataDirectory); + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + return inventory; +} + +function temporaryDataDirectory(): string { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-candidate-'))); + temporaryDirectories.push(directory); + return directory; +} + +function makeHead( + totalRows: CountV1 | string, + version: DecimalU64V1 | string, + bucketCount: CountV1 | string = '1', +): SignedAuthorCatalogHeadEnvelopeV1 { + const payload: AuthorCatalogHeadV1 = { + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/catalog-fixture', + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + catalogIssuerDelegationDigest: `0x${'66'.repeat(32)}`, + era: '0', + version, + previousHeadDigest: null, + bucketCount, + totalRows, + directoryHeight: '0', + directoryRootDigest: `0x${String(version).padStart(2, '0').slice(-2).repeat(32)}`, + issuedAt: String(1_700_000_000_000n + BigInt(version)), + } as AuthorCatalogHeadV1; + const unsigned = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; + const signed = { + ...unsigned, + objectDigest: computeAuthorCatalogHeadObjectDigestV1(unsigned), + signature: SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogHeadEnvelopeV1(signed); + return signed; +} + +function makeRow( + number: bigint, + assertionCoordinate: string, + projectionByte = '00', +): AuthorCatalogRowV1 { + const row = { + kaId: ((BigInt(AUTHOR) << 96n) | number).toString(), + assertionCoordinate, + assertionVersion: '1', + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: `0x${projectionByte.repeat(32)}`, + sealDigest: `0x${'44'.repeat(32)}`, + transfer: { + codec: KA_TRANSFER_CODEC_V1, + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: `0x${projectionByte.repeat(32)}`, + byteLength: '16', + chunkSize: KA_TRANSFER_CHUNK_SIZE_V1, + chunkCount: '1', + blobDigest: `0x${'11'.repeat(32)}`, + chunkTreeRoot: `0x${'22'.repeat(32)}`, + }, + } as unknown as AuthorCatalogRowV1; + assertAuthorCatalogRowV1(row); + return row; +} + +function rowForBucket( + bucketId: string, + bucketCount: string, + start: bigint, + coordinate: string, +): AuthorCatalogRowV1 { + for (let number = start; number < start + 100_000n; number += 1n) { + const row = makeRow(number, coordinate); + if (catalogKeyToBucketIdV1(row.kaId, bucketCount as CountV1) === bucketId) return row; + } + throw new Error(`unable to find fixture row for bucket ${bucketId}`); +} + +function makeNonEmptyLoad( + session: CandidateSessionV1, + head: SignedAuthorCatalogHeadEnvelopeV1, + rows: readonly AuthorCatalogRowV1[], + bucketId = '0', + validate = true, +): VerifiedCandidateBucketLoadV1 { + const scope = deriveAuthorCatalogScopeFromHeadV1(head.payload); + const payload: AuthorCatalogBucketV1 = { + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + era: scope.era, + bucketCount: scope.bucketCount, + bucketId, + rows, + } as AuthorCatalogBucketV1; + const unsigned = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; + const bucket = { + ...unsigned, + objectDigest: validate + ? computeAuthorCatalogBucketObjectDigestV1(unsigned) + : `0x${'ab'.repeat(32)}`, + signature: SIGNATURE, + } as SignedControlEnvelopeV1; + if (validate) assertSignedAuthorCatalogBucketEnvelopeV1(bucket); + return { + session, + head, + descriptor: { + bucketId: bucketId as DecimalU64V1, + rowCount: String(rows.length) as CountV1, + byteLength: (validate + ? String(canonicalizeAuthorCatalogBucketPayloadBytesV1(payload).byteLength) + : '1') as ByteLengthV1, + bucketDigest: bucket.objectDigest as Digest32V1, + }, + bucket, + }; +} + +function makeEmptyLoad( + session: CandidateSessionV1, + head: SignedAuthorCatalogHeadEnvelopeV1, + bucketId: string, +): VerifiedCandidateBucketLoadV1 { + return { + session, + head, + descriptor: { + bucketId: bucketId as DecimalU64V1, + rowCount: '0' as CountV1, + byteLength: '0' as ByteLengthV1, + bucketDigest: ZERO_DIGEST32_V1, + }, + bucket: null, + }; +} + +function rawSessionHex(sessionByte: number): string { + return sessionByte.toString(16).padStart(2, '0').repeat(32).toUpperCase(); +} + +function insertRawEmptyHeader(database: DatabaseSync, sessionByte: number): void { + database.exec(` + INSERT INTO rfc64_candidate_bucket_loads_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + subgraph_name, + catalog_era_u64be, + bucket_count_u64be, + bucket_id_u64be, + bucket_object_digest, + row_count_u64be, + payload_byte_length_u64be + ) VALUES ( + x'${rawSessionHex(sessionByte)}', + x'${'22'.repeat(32)}', + x'${'33'.repeat(20)}', + x'${sessionByte.toString(16).padStart(2, '0').repeat(32)}', + NULL, + zeroblob(8), + x'0000000000000001', + zeroblob(8), + zeroblob(32), + zeroblob(8), + zeroblob(8) + ); + `); +} + +function insertTamperedExtraChild(database: DatabaseSync): void { + database.exec(` + INSERT INTO rfc64_candidate_bucket_rows_v1 + SELECT + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + x'${AUTHOR.slice(2)}000000000000000000000002', + x'${'cc'.repeat(32)}', + 'tampered-extra-child', + assertion_version_u64be, + projection_id, + projection_digest, + seal_digest, + transfer_codec, + transfer_byte_length_u64be, + transfer_chunk_size_u64be, + transfer_chunk_count_u64be, + transfer_blob_digest, + transfer_chunk_tree_root, + x'${'dd'.repeat(32)}' + FROM rfc64_candidate_bucket_rows_v1 + LIMIT 1; + `); +} + +interface CandidateDatabaseHooks { + readonly exec?: (sql: string, exec: (sql: string) => void) => void; + readonly prepare?: (sql: string, prepare: (sql: string) => StatementSync) => StatementSync; + readonly afterReopen?: (database: DatabaseSync) => void; + readonly failReopen?: () => boolean; +} + +function proxyDatabase(database: DatabaseSync, hooks: CandidateDatabaseHooks): DatabaseSync { + return new Proxy(database, { + get(target, property) { + if (property === 'exec') { + const exec = target.exec.bind(target); + return (sql: string): void => { + if (hooks.exec !== undefined) hooks.exec(sql, exec); + else exec(sql); + }; + } + if (property === 'prepare') { + const prepare = target.prepare.bind(target); + return (sql: string): StatementSync => hooks.prepare?.(sql, prepare) ?? prepare(sql); + } + const value = Reflect.get(target, property, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +function createReopenableCandidateDatabase(hooks: CandidateDatabaseHooks = {}): { + readonly facade: DatabaseSync; + readonly reopen: ReturnType DatabaseSync>>; + readonly database: () => DatabaseSync; + readonly currentFacade: () => DatabaseSync; + readonly didReturnDistinctHandle: () => boolean; + readonly path: string; + readonly close: () => void; +} { + const path = join(temporaryDataDirectory(), 'candidate-reopen.sqlite3'); + let database = openFileDatabase(path, true); + let facade = proxyDatabase(database, hooks); + let returnedDistinctHandle = false; + const reopen = vi.fn((abandoned: DatabaseSync): DatabaseSync => { + if (abandoned !== facade) throw new Error('reopen received a non-current low-level handle'); + database.close(); + if (hooks.failReopen?.() === true) { + throw new Error('injected verified-reopen failure'); + } + database = openFileDatabase(path, false); + hooks.afterReopen?.(database); + facade = proxyDatabase(database, hooks); + returnedDistinctHandle = facade !== abandoned; + return facade; + }); + return { + facade, + reopen, + database: () => database, + currentFacade: () => facade, + didReturnDistinctHandle: () => returnedDistinctHandle, + path, + close: () => { + try { database.close(); } catch { /* a failed reopen already closed it */ } + }, + }; +} + +function openFileDatabase(path: string, initialize: boolean): DatabaseSync { + const database = new DatabaseSync(path); + database.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;'); + if (initialize) database.exec(INVENTORY_V1_DDL); + return database; +} + +/** Fault-only schema: lets the reader codec observe bytes SQLite v1 DDL forbids. */ +function createLaxCandidateDatabase(): DatabaseSync { + const database = new DatabaseSync(':memory:'); + database.exec(` + CREATE TABLE rfc64_candidate_bucket_loads_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + subgraph_name, + catalog_era_u64be, + bucket_count_u64be, + bucket_id_u64be, + bucket_object_digest, + row_count_u64be, + payload_byte_length_u64be + ); + CREATE TABLE rfc64_candidate_bucket_rows_v1 ( + session_id, + catalog_scope_digest, + author_address, + target_catalog_head_digest, + bucket_id_u64be, + ka_id_u256be, + catalog_key_digest, + assertion_coordinate, + assertion_version_u64be, + projection_id, + projection_digest, + seal_digest, + transfer_codec, + transfer_byte_length_u64be, + transfer_chunk_size_u64be, + transfer_chunk_count_u64be, + transfer_blob_digest, + transfer_chunk_tree_root, + expected_catalog_row_digest + ); + `); + return database; +} + +function unexpectedReopen(): never { + throw new Error('test did not expect a verified low-level reopen'); +} diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index c8b432723f..51dfcbbd2a 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -139,7 +139,7 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { } }); - it('executes the frozen DDL as STRICT tables with the named bucket index', () => { + it('executes the frozen DDL with bucket-first storage and head-wide uniqueness', () => { const database = new DatabaseSync(':memory:'); try { database.exec('PRAGMA foreign_keys = ON'); @@ -147,7 +147,7 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { const objects = database.prepare( `SELECT name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name`, ).all(); - expect(objects).toHaveLength(3); + expect(objects).toHaveLength(Object.keys(INVENTORY_V1_USER_OBJECTS).length); for (const object of objects) { expect(normalizeInventoryV1SchemaSql(String(object.sql))).toBe( INVENTORY_V1_USER_OBJECTS[String(object.name)], @@ -159,6 +159,31 @@ describe('RFC-64 inventory v1 SQLite lifecycle', () => { expect(database.prepare( `SELECT strict FROM pragma_table_list WHERE name = 'rfc64_candidate_bucket_rows_v1'`, ).get()?.strict).toBe(1); + const indexes = database.prepare( + `PRAGMA index_list('rfc64_candidate_bucket_rows_v1')`, + ).all(); + const indexColumns = (name: unknown): string[] => database.prepare( + `PRAGMA index_info('${String(name)}')`, + ).all().map((row) => String(row.name)); + expect(indexes.some((index) => index.origin === 'pk' && indexColumns(index.name) + .join(',') === [ + 'session_id', + 'catalog_scope_digest', + 'author_address', + 'target_catalog_head_digest', + 'bucket_id_u64be', + 'ka_id_u256be', + ].join(','))).toBe(true); + expect(indexes.some((index) => index.origin === 'u' && indexColumns(index.name) + .join(',') === [ + 'session_id', + 'catalog_scope_digest', + 'author_address', + 'target_catalog_head_digest', + 'ka_id_u256be', + ].join(','))).toBe(true); + expect(indexes.some((index) => String(index.name) + .includes('rfc64_candidate_bucket_rows_by_bucket_v1'))).toBe(false); } finally { database.close(); } diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index bd0fc1fe05..441b2d8c1a 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -105,6 +105,10 @@ export default defineConfig({ "test/workspace-crypto-delegatee-filter.test.ts", "test/rfc64-inventory-v1-scalars.test.ts", "test/rfc64-inventory-v1-lifecycle.test.ts", + "test/rfc64-inventory-v1-candidates.test.ts", + "test/rfc64-inventory-v1-candidate-plans.test.ts", + "test/rfc64-inventory-v1-candidate-latency.test.ts", + "test/rfc64-inventory-v1-candidate-faults.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From f073ee4ee6f37f804a332f03e0e5b5ca48891d73 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:45:40 +0200 Subject: [PATCH 039/292] feat(chain): verify RFC-64 control signatures --- .../src/control-object-signature-verifier.ts | 549 ++++++++++++++++++ packages/chain/src/index.ts | 26 + ...rol-object-signature-verifier.unit.test.ts | 460 +++++++++++++++ 3 files changed, 1035 insertions(+) create mode 100644 packages/chain/src/control-object-signature-verifier.ts create mode 100644 packages/chain/test/control-object-signature-verifier.unit.test.ts diff --git a/packages/chain/src/control-object-signature-verifier.ts b/packages/chain/src/control-object-signature-verifier.ts new file mode 100644 index 0000000000..cfa4ff8c82 --- /dev/null +++ b/packages/chain/src/control-object-signature-verifier.ts @@ -0,0 +1,549 @@ +import { + assertCanonicalChainId, + assertCanonicalDecimalU64, + assertCanonicalDigest, + canonicalizeSignedControlEnvelopeBytes, + computeControlSignatureVariantDigestHex, + parseCanonicalSignedControlEnvelope, + type BlockNumberV1, + type ChainIdV1, + type ControlObjectSignatureSuite, + type Digest32V1, + type EvmAddressV1, + type SignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +export const EIP1271_MAGIC_VALUE_V1 = '0x1626ba7e' as const; +export const EIP1271_CANONICAL_ABI_RETURN_V1 = + `0x${EIP1271_MAGIC_VALUE_V1.slice(2)}${'00'.repeat(28)}` as const; +export const CONTROL_EIP1271_GAS_LIMIT_V1 = 1_000_000n; +export const CONTROL_EIP1271_MAX_RETURN_BYTES_V1 = 32; +export const CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 = 4_000; +export const CONTROL_EIP1271_MAX_ATTEMPTS_V1 = 2; +export const CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1 = 10_000; +export const CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 = 4; +export const CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1 = + 'distinct-configured-endpoints-no-same-endpoint-retry' as const; +export const CONTROL_EIP1271_CALL_FROM_V1 = + '0x0000000000000000000000000000000000000000' as const; + +const SECP256K1_N = BigInt( + '0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', +); +const SECP256K1_HALF_N = BigInt( + '0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0', +); +const EIP1271_INTERFACE = new ethers.Interface([ + 'function isValidSignature(bytes32,bytes) view returns (bytes4)', +]); + +export const CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1 = Object.freeze([ + 'unsupported-chain', + 'chain-mismatch', + 'finalized-state-unavailable', + 'rpc-unavailable', + 'rpc-timeout', + 'resource-limit', + 'revert', + 'no-code', + 'malformed-return', +] as const); + +export type CurrentFinalizedEvmCallErrorCodeV1 = + (typeof CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1)[number]; + +/** Closed failure vocabulary implemented by the finalized-state RPC gateway. */ +export class CurrentFinalizedEvmCallErrorV1 extends Error { + readonly code: CurrentFinalizedEvmCallErrorCodeV1; + + constructor( + code: CurrentFinalizedEvmCallErrorCodeV1, + message: string, + options: { readonly cause?: unknown } = {}, + ) { + if (!CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1.includes(code)) { + throw new TypeError(`Unsupported current-finalized EVM call error code: ${String(code)}`); + } + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'CurrentFinalizedEvmCallErrorV1'; + this.code = code; + Object.freeze(this); + } +} + +export interface CurrentFinalizedEvmCallRequestV1 { + readonly chainId: ChainIdV1; + readonly to: EvmAddressV1; + readonly from: typeof CONTROL_EIP1271_CALL_FROM_V1; + readonly data: string; + readonly gasLimit: bigint; + readonly maxReturnBytes: number; + readonly attemptTimeoutMs: number; + readonly maxAttempts: number; + readonly endpointAttemptPolicy: typeof CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1; + readonly maxConcurrentCallsPerChain: number; + readonly totalDeadlineMs: number; + readonly ccipReadEnabled: false; + readonly signal: AbortSignal; +} + +export interface CurrentFinalizedEvmCallResultV1 { + readonly chainId: ChainIdV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly returnData: string; +} + +/** + * Trusted local adapter boundary. Implementations select the receiver's current + * finalized block and execute the exact request at that block; peer data never + * supplies an RPC URL or block selector. + */ +export interface CurrentFinalizedEvmCallV1 { + (request: CurrentFinalizedEvmCallRequestV1): Promise; +} + +export const CONTROL_SIGNATURE_VERIFICATION_ERROR_CODES_V1 = Object.freeze([ + 'CONTROL_SIGNATURE_ENVELOPE_INVALID', + 'CONTROL_SIGNATURE_EIP191_NON_CANONICAL', + 'CONTROL_SIGNATURE_EIP191_INVALID', + 'CONTROL_SIGNATURE_ISSUER_MISMATCH', + 'CONTROL_SIGNATURE_EIP1271_INVALID', + 'CONTROL_SIGNATURE_EIP1271_RESOURCE_LIMIT', + 'CONTROL_SIGNATURE_CHAIN_UNSUPPORTED', + 'CONTROL_SIGNATURE_CHAIN_MISMATCH', + 'CONTROL_SIGNATURE_FINALIZED_STATE_UNAVAILABLE', + 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + 'CONTROL_SIGNATURE_RPC_TIMEOUT', + 'CONTROL_SIGNATURE_ABORTED', +] as const); + +export type ControlSignatureVerificationErrorCodeV1 = + (typeof CONTROL_SIGNATURE_VERIFICATION_ERROR_CODES_V1)[number]; +export type ControlSignatureVerificationDispositionV1 = + | 'invalid' + | 'unsupported' + | 'retryable-unavailable' + | 'cancelled'; +export type ControlSignatureVerificationReasonV1 = + | 'no-code' + | 'revert' + | 'malformed-return' + | 'wrong-magic'; + +export class ControlSignatureVerificationErrorV1 extends Error { + readonly code: ControlSignatureVerificationErrorCodeV1; + readonly disposition: ControlSignatureVerificationDispositionV1; + readonly reason?: ControlSignatureVerificationReasonV1; + + constructor( + code: ControlSignatureVerificationErrorCodeV1, + disposition: ControlSignatureVerificationDispositionV1, + message: string, + options: { + readonly cause?: unknown; + readonly reason?: ControlSignatureVerificationReasonV1; + } = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'ControlSignatureVerificationErrorV1'; + this.code = code; + this.disposition = disposition; + this.reason = options.reason; + } +} + +export interface VerifyControlEnvelopeIssuerSignatureOptionsV1 { + readonly callEvmAtCurrentFinalized?: CurrentFinalizedEvmCallV1; + readonly signal?: AbortSignal; +} + +export interface VerifiedControlEnvelopeIssuerSignatureV1 { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + readonly issuer: EvmAddressV1; + readonly signatureSuite: ControlObjectSignatureSuite; + readonly verificationEvidence: + | { readonly kind: 'eip191' } + | { + readonly kind: 'eip1271-current-finalized'; + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + }; +} + +/** + * Verify only generic envelope cryptography. A successful result does not prove + * that the issuer has object-specific owner/admin/catalog/checkpoint authority. + */ +export async function verifyControlEnvelopeIssuerSignatureV1( + input: SignedControlEnvelopeV1, + options: VerifyControlEnvelopeIssuerSignatureOptionsV1 = {}, +): Promise { + const envelope = snapshotEnvelope(input); + const objectDigest = asDigest32(envelope.objectDigest); + const signatureVariantDigest = asDigest32( + computeControlSignatureVariantDigestHex(envelope.objectDigest, envelope.signature), + ); + const issuer = envelope.issuer as EvmAddressV1; + + if (envelope.signatureSuite === 'eip191-personal-sign-digest-v1') { + verifyCanonicalEip191(envelope); + return Object.freeze({ + objectDigest, + signatureVariantDigest, + issuer, + signatureSuite: envelope.signatureSuite, + verificationEvidence: Object.freeze({ kind: 'eip191' as const }), + }); + } + + const call = options.callEvmAtCurrentFinalized; + if (call === undefined) { + fail( + 'CONTROL_SIGNATURE_CHAIN_UNSUPPORTED', + 'unsupported', + 'No current-finalized EVM call gateway is configured for EIP-1271 verification', + ); + } + if (options.signal?.aborted) { + fail('CONTROL_SIGNATURE_ABORTED', 'cancelled', 'EIP-1271 verification was aborted'); + } + + const controller = new AbortController(); + let rejectCallerAbort: ((reason: ControlSignatureVerificationErrorV1) => void) | undefined; + const callerAbort = new Promise((_resolve, reject) => { + rejectCallerAbort = reject; + }); + const abortFromCaller = (): void => { + const error = new ControlSignatureVerificationErrorV1( + 'CONTROL_SIGNATURE_ABORTED', + 'cancelled', + 'EIP-1271 verification was aborted', + ); + rejectCallerAbort?.(error); + controller.abort(options.signal?.reason ?? error); + }; + options.signal?.addEventListener('abort', abortFromCaller, { once: true }); + + let timeout: ReturnType | undefined; + try { + const request = Object.freeze({ + chainId: envelope.signatureEvidence.chainId as ChainIdV1, + to: envelope.signatureEvidence.contractAddress as EvmAddressV1, + from: CONTROL_EIP1271_CALL_FROM_V1, + data: EIP1271_INTERFACE.encodeFunctionData('isValidSignature', [ + envelope.objectDigest, + envelope.signature, + ]).toLowerCase(), + gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, + maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, + endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + ccipReadEnabled: false, + signal: controller.signal, + } satisfies CurrentFinalizedEvmCallRequestV1); + + // Classify the injected gateway on its own promise branch. In particular, + // a gateway must not be able to throw this verifier's public error class and + // thereby impersonate a locally-created timeout/cancellation or downgrade a + // transport failure into cryptographic invalidity. + const callPromise = Promise.resolve() + .then(() => call(request)) + .catch((cause: unknown) => mapFinalizedCallFailure(cause, options.signal)); + void callPromise.catch(() => undefined); + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = new ControlSignatureVerificationErrorV1( + 'CONTROL_SIGNATURE_RPC_TIMEOUT', + 'retryable-unavailable', + `EIP-1271 verification exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`, + ); + reject(error); + controller.abort(error); + }, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1); + }); + + let result: CurrentFinalizedEvmCallResultV1; + try { + result = await Promise.race([callPromise, deadline, callerAbort]); + } catch (cause) { + if (cause instanceof ControlSignatureVerificationErrorV1) throw cause; + mapFinalizedCallFailure(cause, options.signal); + } + + const evidence = validateFinalizedCallResult(result, request.chainId); + if (evidence.returnData !== EIP1271_CANONICAL_ABI_RETURN_V1) { + fail( + 'CONTROL_SIGNATURE_EIP1271_INVALID', + 'invalid', + 'Contract wallet returned the wrong EIP-1271 magic value', + { reason: 'wrong-magic' }, + ); + } + + return Object.freeze({ + objectDigest, + signatureVariantDigest, + issuer, + signatureSuite: envelope.signatureSuite, + verificationEvidence: Object.freeze({ + kind: 'eip1271-current-finalized' as const, + chainId: request.chainId, + contractAddress: request.to, + blockNumber: evidence.blockNumber, + blockHash: evidence.blockHash, + }), + }); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + options.signal?.removeEventListener('abort', abortFromCaller); + } +} + +function snapshotEnvelope(input: SignedControlEnvelopeV1): SignedControlEnvelopeV1 { + try { + const bytes = canonicalizeSignedControlEnvelopeBytes(input); + return deepFreezeJson(parseCanonicalSignedControlEnvelope(bytes)) as SignedControlEnvelopeV1; + } catch (cause) { + fail( + 'CONTROL_SIGNATURE_ENVELOPE_INVALID', + 'invalid', + 'Control-object envelope failed canonical structural validation', + { cause }, + ); + } +} + +function verifyCanonicalEip191(envelope: SignedControlEnvelopeV1): void { + const r = BigInt(`0x${envelope.signature.slice(2, 66)}`); + const s = BigInt(`0x${envelope.signature.slice(66, 130)}`); + const v = envelope.signature.slice(130, 132); + if ( + (v !== '1b' && v !== '1c') + || r < 1n + || r >= SECP256K1_N + || s < 1n + || s > SECP256K1_HALF_N + ) { + fail( + 'CONTROL_SIGNATURE_EIP191_NON_CANONICAL', + 'invalid', + 'EIP-191 signature must use canonical r, low-s, and v=27/28 encoding', + ); + } + + let recovered: string; + try { + const signature = ethers.Signature.from(envelope.signature); + if (signature.serialized !== envelope.signature) { + fail( + 'CONTROL_SIGNATURE_EIP191_NON_CANONICAL', + 'invalid', + 'EIP-191 signature encoding is not canonical', + ); + } + recovered = ethers.verifyMessage( + ethers.getBytes(envelope.objectDigest), + signature, + ).toLowerCase(); + } catch (cause) { + if (cause instanceof ControlSignatureVerificationErrorV1) throw cause; + fail( + 'CONTROL_SIGNATURE_EIP191_INVALID', + 'invalid', + 'EIP-191 signature is unrecoverable', + { cause }, + ); + } + if (recovered !== envelope.issuer) { + fail( + 'CONTROL_SIGNATURE_ISSUER_MISMATCH', + 'invalid', + 'EIP-191 recovered signer does not equal the signed envelope issuer', + ); + } +} + +function validateFinalizedCallResult( + input: unknown, + expectedChainId: ChainIdV1, +): CurrentFinalizedEvmCallResultV1 { + let evidence: CurrentFinalizedEvmCallResultV1; + try { + if (!isPlainRecord(input)) throw new Error('result is not a plain record'); + assertExactDataKeys(input, ['blockHash', 'blockNumber', 'chainId', 'returnData']); + const snapshot = { + chainId: input.chainId, + blockNumber: input.blockNumber, + blockHash: input.blockHash, + returnData: input.returnData, + }; + assertCanonicalChainId(snapshot.chainId, 'current-finalized result chainId'); + assertCanonicalDecimalU64(snapshot.blockNumber, 'current-finalized blockNumber'); + assertCanonicalDigest(snapshot.blockHash, 'current-finalized blockHash'); + if ( + typeof snapshot.returnData !== 'string' + || !/^0x[0-9a-f]{64}$/.test(snapshot.returnData) + ) { + throw new Error('returnData is not exactly 32 canonical bytes'); + } + evidence = Object.freeze(snapshot) as CurrentFinalizedEvmCallResultV1; + } catch (cause) { + fail( + 'CONTROL_SIGNATURE_EIP1271_INVALID', + 'invalid', + 'EIP-1271 current-finalized call returned malformed evidence or ABI data', + { cause, reason: 'malformed-return' }, + ); + } + // This verifier-owned semantic check intentionally occurs after the hostile + // gateway result has been fully snapshotted and structurally classified. + if (evidence.chainId !== expectedChainId) { + fail( + 'CONTROL_SIGNATURE_CHAIN_MISMATCH', + 'invalid', + 'Current-finalized gateway returned a different chain than the signed evidence', + ); + } + return evidence; +} + +function mapFinalizedCallFailure(cause: unknown, signal: AbortSignal | undefined): never { + if (signal?.aborted) { + fail('CONTROL_SIGNATURE_ABORTED', 'cancelled', 'EIP-1271 verification was aborted', { + cause, + }); + } + const gatewayFailure = snapshotFinalizedCallFailure(cause); + if (gatewayFailure === undefined) { + fail( + 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + 'retryable-unavailable', + 'Current-finalized EIP-1271 RPC failed closed', + ); + } + switch (gatewayFailure.code) { + case 'unsupported-chain': + fail('CONTROL_SIGNATURE_CHAIN_UNSUPPORTED', 'unsupported', gatewayFailure.message, { cause }); + case 'chain-mismatch': + fail('CONTROL_SIGNATURE_CHAIN_MISMATCH', 'invalid', gatewayFailure.message, { cause }); + case 'finalized-state-unavailable': + fail( + 'CONTROL_SIGNATURE_FINALIZED_STATE_UNAVAILABLE', + 'retryable-unavailable', + gatewayFailure.message, + { cause }, + ); + case 'rpc-unavailable': + fail('CONTROL_SIGNATURE_RPC_UNAVAILABLE', 'retryable-unavailable', gatewayFailure.message, { + cause, + }); + case 'rpc-timeout': + fail('CONTROL_SIGNATURE_RPC_TIMEOUT', 'retryable-unavailable', gatewayFailure.message, { cause }); + case 'resource-limit': + fail('CONTROL_SIGNATURE_EIP1271_RESOURCE_LIMIT', 'unsupported', gatewayFailure.message, { + cause, + }); + case 'revert': + fail('CONTROL_SIGNATURE_EIP1271_INVALID', 'invalid', gatewayFailure.message, { + cause, + reason: 'revert', + }); + case 'no-code': + fail('CONTROL_SIGNATURE_EIP1271_INVALID', 'invalid', gatewayFailure.message, { + cause, + reason: 'no-code', + }); + case 'malformed-return': + fail('CONTROL_SIGNATURE_EIP1271_INVALID', 'invalid', gatewayFailure.message, { + cause, + reason: 'malformed-return', + }); + default: + fail( + 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + 'retryable-unavailable', + 'Current-finalized EVM gateway returned an unknown failure code', + { cause }, + ); + } +} + +function snapshotFinalizedCallFailure( + cause: unknown, +): Readonly<{ code: CurrentFinalizedEvmCallErrorCodeV1; message: string }> | undefined { + try { + if (!(cause instanceof CurrentFinalizedEvmCallErrorV1)) return undefined; + const code = cause.code; + const message = cause.message; + if ( + !CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1.includes(code) + || typeof message !== 'string' + ) { + return undefined; + } + return Object.freeze({ code, message }); + } catch { + // A rejected thenable may supply a Proxy whose prototype/property traps + // throw. Such foreign data is transport-unavailable, never verifier-owned + // invalidity or cancellation. + return undefined; + } +} + +function assertExactDataKeys(record: Record, expected: readonly string[]): void { + const actual = Reflect.ownKeys(record); + if ( + actual.some((key) => typeof key !== 'string') + || actual.length !== expected.length + || (actual as string[]).sort().some((key, index) => key !== expected[index]) + ) { + throw new Error('current-finalized result has unknown or missing fields'); + } + for (const key of expected) { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('current-finalized result fields must be enumerable data properties'); + } + } +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function deepFreezeJson(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) { + for (const item of value) deepFreezeJson(item); + return Object.freeze(value); + } + for (const item of Object.values(value)) deepFreezeJson(item); + return Object.freeze(value); +} + +function asDigest32(value: string): Digest32V1 { + assertCanonicalDigest(value); + return value; +} + +function fail( + code: ControlSignatureVerificationErrorCodeV1, + disposition: ControlSignatureVerificationDispositionV1, + message: string, + options: { + readonly cause?: unknown; + readonly reason?: ControlSignatureVerificationReasonV1; + } = {}, +): never { + throw new ControlSignatureVerificationErrorV1(code, disposition, message, options); +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index a80dd8025b..cae6e23fcf 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -1,4 +1,30 @@ export * from './chain-adapter.js'; +export { + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, + CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + CONTROL_EIP1271_GAS_LIMIT_V1, + CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + CONTROL_SIGNATURE_VERIFICATION_ERROR_CODES_V1, + CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, + EIP1271_CANONICAL_ABI_RETURN_V1, + EIP1271_MAGIC_VALUE_V1, + ControlSignatureVerificationErrorV1, + CurrentFinalizedEvmCallErrorV1, + verifyControlEnvelopeIssuerSignatureV1, + type ControlSignatureVerificationDispositionV1, + type ControlSignatureVerificationErrorCodeV1, + type ControlSignatureVerificationReasonV1, + type CurrentFinalizedEvmCallErrorCodeV1, + type CurrentFinalizedEvmCallRequestV1, + type CurrentFinalizedEvmCallResultV1, + type CurrentFinalizedEvmCallV1, + type VerifiedControlEnvelopeIssuerSignatureV1, + type VerifyControlEnvelopeIssuerSignatureOptionsV1, +} from './control-object-signature-verifier.js'; export { resolveQuotedPublisherCandidatePricing, resolveLegacyPublisherCandidatePricing, diff --git a/packages/chain/test/control-object-signature-verifier.unit.test.ts b/packages/chain/test/control-object-signature-verifier.unit.test.ts new file mode 100644 index 0000000000..d3edb52d36 --- /dev/null +++ b/packages/chain/test/control-object-signature-verifier.unit.test.ts @@ -0,0 +1,460 @@ +import { + computeControlObjectDigestHex, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, + CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + CONTROL_EIP1271_GAS_LIMIT_V1, + CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + EIP1271_CANONICAL_ABI_RETURN_V1, + ControlSignatureVerificationErrorV1, + CurrentFinalizedEvmCallErrorV1, + verifyControlEnvelopeIssuerSignatureV1, + type CurrentFinalizedEvmCallResultV1, + type CurrentFinalizedEvmCallV1, +} from '../src/control-object-signature-verifier.js'; + +const PRIVATE_KEY = `0x${'11'.repeat(32)}`; +const OTHER_PRIVATE_KEY = `0x${'22'.repeat(32)}`; +const SAFE = '0x3333333333333333333333333333333333333333'; +const BLOCK_HASH = `0x${'44'.repeat(32)}`; +const SECP256K1_N = BigInt( + '0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', +); +const SECP256K1_HALF_N = BigInt( + '0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0', +); +const EIP1271_INTERFACE = new ethers.Interface([ + 'function isValidSignature(bytes32,bytes) view returns (bytes4)', +]); +const EIP191_VECTOR_DIGEST = + '0x2e5b81a340e15ae386e3319a642fe2bc431dff13b568ec3c887fbaebdc151b73'; +const EIP191_VECTOR_SIGNATURE = + '0xc528dd3ae35507cee21806cd55eb02e2398fd9c70c662888d59c02a725ebe9c244442765007b40bc331a093139c4713396a6b9c688836f1064d03ad6eb75a49d1c'; +const EIP191_VECTOR_VARIANT_DIGEST = + '0xdef8e7105256a04293e404d7001db9816aa786ffceb390872135ac6a9dbf4908'; + +const FINALIZED_OK = Object.freeze({ + chainId: '20430', + blockNumber: '123', + blockHash: BLOCK_HASH, + returnData: EIP1271_CANONICAL_ABI_RETURN_V1, +} satisfies CurrentFinalizedEvmCallResultV1); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('RFC-64 control-object issuer signature verifier', () => { + it('verifies EIP-191 over raw digest bytes and returns immutable variant identity', async () => { + const wallet = new ethers.Wallet(PRIVATE_KEY); + const envelope = await eoaEnvelope(wallet); + const verified = await verifyControlEnvelopeIssuerSignatureV1(envelope); + + expect(envelope.objectDigest).toBe(EIP191_VECTOR_DIGEST); + expect(envelope.signature).toBe(EIP191_VECTOR_SIGNATURE); + expect(verified).toMatchObject({ + objectDigest: envelope.objectDigest, + signatureVariantDigest: EIP191_VECTOR_VARIANT_DIGEST, + issuer: wallet.address.toLowerCase(), + signatureSuite: 'eip191-personal-sign-digest-v1', + verificationEvidence: { kind: 'eip191' }, + }); + expect(verified.signatureVariantDigest).toMatch(/^0x[0-9a-f]{64}$/); + expect(Object.isFrozen(verified)).toBe(true); + expect(Object.isFrozen(verified.verificationEvidence)).toBe(true); + }); + + it('rejects signing UTF-8 hex text and a signature from another issuer', async () => { + const wallet = new ethers.Wallet(PRIVATE_KEY); + const other = new ethers.Wallet(OTHER_PRIVATE_KEY); + const envelope = await eoaEnvelope(wallet); + + await expect(verifyControlEnvelopeIssuerSignatureV1({ + ...envelope, + signature: (await wallet.signMessage(envelope.objectDigest)).toLowerCase(), + })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_ISSUER_MISMATCH' }); + await expect(verifyControlEnvelopeIssuerSignatureV1({ + ...envelope, + signature: (await other.signMessage(ethers.getBytes(envelope.objectDigest))).toLowerCase(), + })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_ISSUER_MISMATCH' }); + }); + + it.each(['00', '01', '23', '25'])('rejects non-canonical wire v=0x%s', async (v) => { + const envelope = await eoaEnvelope(new ethers.Wallet(PRIVATE_KEY)); + await expect(verifyControlEnvelopeIssuerSignatureV1({ + ...envelope, + signature: `${envelope.signature.slice(0, -2)}${v}`, + })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_EIP191_NON_CANONICAL' }); + }); + + it.each([ + ['r=0', 0n, 1n], + ['r=n', SECP256K1_N, 1n], + ['s=0', 1n, 0n], + ['s=halfN+1', 1n, SECP256K1_HALF_N + 1n], + ])('rejects non-canonical %s before recovery', async (_label, r, s) => { + const envelope = await eoaEnvelope(new ethers.Wallet(PRIVATE_KEY)); + await expect(verifyControlEnvelopeIssuerSignatureV1({ + ...envelope, + signature: serializedSignature(r, s, '1b'), + })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_EIP191_NON_CANONICAL' }); + }); + + it('admits the inclusive halfN boundary to recovery rather than canonicality rejection', async () => { + const envelope = await eoaEnvelope(new ethers.Wallet(PRIVATE_KEY)); + let caught: unknown; + try { + await verifyControlEnvelopeIssuerSignatureV1({ + ...envelope, + signature: serializedSignature(1n, SECP256K1_HALF_N, '1b'), + }); + } catch (error) { + caught = error; + } + expect(caught).toBeDefined(); + expect(caught).not.toMatchObject({ code: 'CONTROL_SIGNATURE_EIP191_NON_CANONICAL' }); + }); + + it('rejects envelope digest corruption before invoking the finalized gateway', async () => { + const call = vi.fn(async () => FINALIZED_OK); + await expect(verifyControlEnvelopeIssuerSignatureV1({ + ...safeEnvelope(), + objectDigest: `0x${'00'.repeat(32)}`, + }, { callEvmAtCurrentFinalized: call })) + .rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_ENVELOPE_INVALID' }); + expect(call).not.toHaveBeenCalled(); + }); + + it('issues one exact bounded raw EIP-1271 call at current-finalized state', async () => { + const call = vi.fn(async () => FINALIZED_OK); + const envelope = safeEnvelope(); + const verified = await verifyControlEnvelopeIssuerSignatureV1(envelope, { + callEvmAtCurrentFinalized: call, + }); + + expect(call).toHaveBeenCalledTimes(1); + const request = call.mock.calls[0]![0]; + expect(request).toMatchObject({ + chainId: '20430', + to: SAFE, + from: CONTROL_EIP1271_CALL_FROM_V1, + gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, + maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, + endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + ccipReadEnabled: false, + }); + const decoded = EIP1271_INTERFACE.decodeFunctionData('isValidSignature', request.data); + expect(decoded[0]).toBe(envelope.objectDigest); + expect(decoded[1]).toBe(envelope.signature); + expect(verified.verificationEvidence).toEqual({ + kind: 'eip1271-current-finalized', + chainId: '20430', + contractAddress: SAFE, + blockNumber: '123', + blockHash: BLOCK_HASH, + }); + }); + + it.each([ + ['empty', '0x', 'malformed-return'], + ['bare bytes4', '0x1626ba7e', 'malformed-return'], + ['oversized', `0x1626ba7e${'00'.repeat(29)}`, 'malformed-return'], + ['nonzero padding', `0x1626ba7e01${'00'.repeat(27)}`, 'wrong-magic'], + ['wrong magic', `0xffffffff${'00'.repeat(28)}`, 'wrong-magic'], + ])('rejects %s EIP-1271 return data', async (_label, returnData, reason) => { + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: callReturning({ ...FINALIZED_OK, returnData }), + })).rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_EIP1271_INVALID', + disposition: 'invalid', + reason, + }); + }); + + it('fails closed on chain mismatch and hostile finalized-call results', async () => { + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: callReturning({ ...FINALIZED_OK, chainId: '1' }), + })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_CHAIN_MISMATCH' }); + + const hostile = new Proxy({}, { + ownKeys() { + throw new Error('hostile ownKeys'); + }, + }) as CurrentFinalizedEvmCallResultV1; + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: callReturning(hostile), + })).rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_EIP1271_INVALID', + reason: 'malformed-return', + }); + + const injected = new ControlSignatureVerificationErrorV1( + 'CONTROL_SIGNATURE_ABORTED', + 'cancelled', + 'gateway-result trap tried to impersonate caller cancellation', + ); + const hostileVerifierError = new Proxy({}, { + ownKeys() { + throw injected; + }, + }) as CurrentFinalizedEvmCallResultV1; + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: callReturning(hostileVerifierError), + })).rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_EIP1271_INVALID', + disposition: 'invalid', + reason: 'malformed-return', + }); + }); + + it('snapshots a stateful finalized result once and never re-reads the hostile object', async () => { + let blockNumberReads = 0; + const stateful = new Proxy({ ...FINALIZED_OK }, { + get(target, property, receiver) { + if (property === 'blockNumber') { + blockNumberReads += 1; + return blockNumberReads === 1 ? '123' : 'NOT-U64'; + } + return Reflect.get(target, property, receiver); + }, + }) as CurrentFinalizedEvmCallResultV1; + const verified = await verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: callReturning(stateful), + }); + expect(verified.verificationEvidence).toMatchObject({ blockNumber: '123' }); + expect(blockNumberReads).toBe(1); + }); + + it('closes and freezes the gateway error vocabulary, with forged codes retryable', async () => { + const typed = new CurrentFinalizedEvmCallErrorV1('rpc-unavailable', 'offline'); + expect(Object.isFrozen(typed)).toBe(true); + expect(() => new CurrentFinalizedEvmCallErrorV1( + 'evil' as never, + 'evil', + )).toThrow(/Unsupported current-finalized EVM call error code/); + + const forged = Object.create(CurrentFinalizedEvmCallErrorV1.prototype) as { + code: string; + message: string; + }; + forged.code = 'evil'; + forged.message = 'forged gateway failure'; + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: vi.fn(async () => { + throw forged; + }), + })).rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + disposition: 'retryable-unavailable', + }); + }); + + it('does not let an injected gateway impersonate a verifier-owned invalid result', async () => { + const injected = new ControlSignatureVerificationErrorV1( + 'CONTROL_SIGNATURE_EIP1271_INVALID', + 'invalid', + 'gateway-forged cryptographic failure', + { reason: 'wrong-magic' }, + ); + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: vi.fn(async () => { + throw injected; + }), + })).rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + disposition: 'retryable-unavailable', + }); + + const trapError = new ControlSignatureVerificationErrorV1( + 'CONTROL_SIGNATURE_ABORTED', + 'cancelled', + 'gateway rejection trap tried to impersonate caller cancellation', + ); + const hostileRejection = new Proxy({}, { + getPrototypeOf() { + throw trapError; + }, + }); + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: vi.fn(async () => { + throw hostileRejection; + }), + })).rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + disposition: 'retryable-unavailable', + }); + }); + + it.each([ + ['unsupported-chain', 'CONTROL_SIGNATURE_CHAIN_UNSUPPORTED', 'unsupported', undefined], + ['chain-mismatch', 'CONTROL_SIGNATURE_CHAIN_MISMATCH', 'invalid', undefined], + ['finalized-state-unavailable', 'CONTROL_SIGNATURE_FINALIZED_STATE_UNAVAILABLE', 'retryable-unavailable', undefined], + ['rpc-unavailable', 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', 'retryable-unavailable', undefined], + ['rpc-timeout', 'CONTROL_SIGNATURE_RPC_TIMEOUT', 'retryable-unavailable', undefined], + ['resource-limit', 'CONTROL_SIGNATURE_EIP1271_RESOURCE_LIMIT', 'unsupported', undefined], + ['revert', 'CONTROL_SIGNATURE_EIP1271_INVALID', 'invalid', 'revert'], + ['no-code', 'CONTROL_SIGNATURE_EIP1271_INVALID', 'invalid', 'no-code'], + ['malformed-return', 'CONTROL_SIGNATURE_EIP1271_INVALID', 'invalid', 'malformed-return'], + ] as const)('maps gateway %s without conflating validity and availability', async ( + gatewayCode, + code, + disposition, + reason, + ) => { + const call = vi.fn(async () => { + throw new CurrentFinalizedEvmCallErrorV1(gatewayCode, gatewayCode); + }); + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: call, + })).rejects.toMatchObject({ code, disposition, ...(reason ? { reason } : {}) }); + expect(call).toHaveBeenCalledTimes(1); + }); + + it('treats a missing gateway as unsupported rather than signature-invalid', async () => { + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope())) + .rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_CHAIN_UNSUPPORTED', + disposition: 'unsupported', + }); + }); + + it('enforces the total deadline even if a gateway ignores abort', async () => { + vi.useFakeTimers(); + const call: CurrentFinalizedEvmCallV1 = vi.fn(async () => new Promise(() => undefined)); + const verification = verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + callEvmAtCurrentFinalized: call, + }); + const assertion = expect(verification).rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_RPC_TIMEOUT', + disposition: 'retryable-unavailable', + }); + await vi.advanceTimersByTimeAsync(CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1); + await assertion; + }); + + it('honors caller abort before and during a finalized call', async () => { + const before = new AbortController(); + before.abort(); + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + signal: before.signal, + callEvmAtCurrentFinalized: callReturning(FINALIZED_OK), + })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_ABORTED' }); + + const during = new AbortController(); + const call: CurrentFinalizedEvmCallV1 = vi.fn(async ({ signal }) => new Promise( + (_resolve, reject) => { + if (signal.aborted) { + reject(new Error('aborted')); + return; + } + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }, + )); + const verification = verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + signal: during.signal, + callEvmAtCurrentFinalized: call, + }); + during.abort(); + await expect(verification).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_ABORTED' }); + }); + + it('keeps object identity stable while EIP-1271 re-signing changes variant identity', async () => { + const first = safeEnvelope('0x12'); + const second = safeEnvelope('0x1234'); + const call = callReturning(FINALIZED_OK); + + const verifiedFirst = await verifyControlEnvelopeIssuerSignatureV1(first, { + callEvmAtCurrentFinalized: call, + }); + const verifiedSecond = await verifyControlEnvelopeIssuerSignatureV1(second, { + callEvmAtCurrentFinalized: call, + }); + expect(verifiedFirst.objectDigest).toBe(verifiedSecond.objectDigest); + expect(verifiedFirst.signatureVariantDigest).not.toBe(verifiedSecond.signatureVariantDigest); + }); + + it('accepts 1-byte and 4096-byte EIP-1271 signatures and rejects 0/4097 structurally', async () => { + const call = callReturning(FINALIZED_OK); + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope('0xaa'), { + callEvmAtCurrentFinalized: call, + })).resolves.toMatchObject({ signatureSuite: 'eip1271-current-finalized-v1' }); + await expect(verifyControlEnvelopeIssuerSignatureV1( + safeEnvelope(`0x${'aa'.repeat(4096)}`), + { callEvmAtCurrentFinalized: call }, + )).resolves.toMatchObject({ signatureSuite: 'eip1271-current-finalized-v1' }); + await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope('0x'), { + callEvmAtCurrentFinalized: call, + })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_ENVELOPE_INVALID' }); + await expect(verifyControlEnvelopeIssuerSignatureV1( + safeEnvelope(`0x${'aa'.repeat(4097)}`), + { callEvmAtCurrentFinalized: call }, + )).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_ENVELOPE_INVALID' }); + }); +}); + +async function eoaEnvelope(wallet: ethers.Wallet): Promise { + const unsigned: UnsignedControlEnvelopeV1 = { + objectType: 'ContextGraphPolicyV1', + payload: { + contextGraphId: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/verification', + networkId: 'otp:20430', + version: '1', + }, + signatureSuite: 'eip191-personal-sign-digest-v1', + issuer: wallet.address.toLowerCase(), + signatureEvidence: { kind: 'none' }, + }; + const objectDigest = computeControlObjectDigestHex(unsigned); + return { + ...unsigned, + objectDigest, + signature: (await wallet.signMessage(ethers.getBytes(objectDigest))).toLowerCase(), + }; +} + +function safeEnvelope(signature = '0x1234'): SignedControlEnvelopeV1 { + const unsigned: UnsignedControlEnvelopeV1 = { + objectType: 'ContextGraphCheckpointV1', + payload: { + contextGraphId: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/verification', + networkId: 'otp:20430', + version: '1', + }, + signatureSuite: 'eip1271-current-finalized-v1', + issuer: SAFE, + signatureEvidence: { + kind: 'eip1271-current-finalized', + chainId: '20430', + contractAddress: SAFE, + }, + }; + return { + ...unsigned, + objectDigest: computeControlObjectDigestHex(unsigned), + signature, + }; +} + +function callReturning(result: CurrentFinalizedEvmCallResultV1): CurrentFinalizedEvmCallV1 { + return vi.fn(async () => result); +} + +function serializedSignature(r: bigint, s: bigint, v: '1b' | '1c'): string { + return `0x${r.toString(16).padStart(64, '0')}${s + .toString(16) + .padStart(64, '0')}${v}`; +} From 21950fd7f441effe81ad753fc796a8595815c268 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:45:45 +0200 Subject: [PATCH 040/292] fix(agent): gate quarantine before recovery mutation --- packages/agent/src/rfc64/inventory-v1/open.ts | 41 +++++++-- .../test/rfc64-inventory-v1-lifecycle.test.ts | 86 ++++++++++++++++++- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index b37ef52b04..5a72fbb1af 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -658,10 +658,13 @@ function openOrRebuildOwnedDatabase( verifyOwnedSchema(database); } catch (error) { if (!(error instanceof OwnedInventoryV1SchemaError)) throw error; + // Eligibility is a precondition for even the zero-timeout/checkpoint + // quarantine proof. In particular, do not checkpoint or truncate a + // crashed WAL on a deployment that cannot durably rename the namespace. + assertQuarantineDurabilityAvailable(lifecycle); assertDatabaseQuiescent(database, lifecycle); closeInventoryTarget(database, 'automatic-schema-quarantine', lifecycle); database = null; - assertQuarantineDurabilityAvailable(lifecycle); beginQuarantine(databasePath, lifecycle); finishPendingQuarantine(sqlite, databasePath, lifecycle); return openOrRebuildOwnedDatabase(sqlite, databasePath, lifecycle); @@ -719,6 +722,15 @@ function openOrRebuildOwnedDatabase( { cause: error }, ); } + // Refuse before BEGIN EXCLUSIVE or wal_checkpoint(TRUNCATE) can alter a + // corrupt unit on an uncertified deployment. The ordinary cleanup close + // may still perform SQLite's normal recovery, which the RFC permits. + try { + assertQuarantineDurabilityAvailable(lifecycle); + } catch (cause) { + closeProbe(); + throw cause; + } try { assertCorruptDatabaseQuiescent(database, lifecycle); } catch (cause) { @@ -732,10 +744,6 @@ function openOrRebuildOwnedDatabase( } closeInventoryTarget(database, 'automatic-corrupt-quarantine', lifecycle); database = null; - // The corrupt target handle is deliberately closed before this platform - // gate can reject quarantine. The caller may release DK6L only after that - // target close has succeeded. - assertQuarantineDurabilityAvailable(lifecycle); beginQuarantine(databasePath, lifecycle); finishPendingQuarantine(sqlite, databasePath, lifecycle); return openOrRebuildOwnedDatabase(sqlite, databasePath, lifecycle); @@ -1120,7 +1128,19 @@ function assertFilesystemOwner(path: string): void { const script = String.raw` $ErrorActionPreference = 'Stop' $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User -$owner = (Get-Acl -LiteralPath $env:DKG_RFC64_ACL_PATH).GetOwner([System.Security.Principal.SecurityIdentifier]) +$target = [System.IO.Path]::GetFullPath($env:DKG_RFC64_ACL_PATH) +$acl = if ([System.IO.Directory]::Exists($target)) { + [System.IO.Directory]::GetAccessControl( + $target, + [System.Security.AccessControl.AccessControlSections]::Owner + ) +} else { + [System.IO.File]::GetAccessControl( + $target, + [System.Security.AccessControl.AccessControlSections]::Owner + ) +} +$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) if ($owner.Value -ne $sid.Value) { exit 40 } `; const result = spawnSync( @@ -1212,8 +1232,13 @@ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( [System.Security.AccessControl.AccessControlType]::Allow ) $acl.AddAccessRule($rule) -Set-Acl -LiteralPath $target -AclObject $acl -$verified = Get-Acl -LiteralPath $target +if ($isDirectory) { + [System.IO.Directory]::SetAccessControl($target, $acl) + $verified = [System.IO.Directory]::GetAccessControl($target) +} else { + [System.IO.File]::SetAccessControl($target, $acl) + $verified = [System.IO.File]::GetAccessControl($target) +} $rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) if (-not $verified.AreAccessRulesProtected -or $rules.Count -ne 1) { exit 41 } if ($rules[0].IdentityReference.Value -ne $sid.Value) { exit 42 } diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index f8fa4d8796..737e4eef51 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -191,6 +191,57 @@ function createIncompatibleOwnedInventory(path: string, label: string): void { } } +async function createCrashedWalIncompatibleOwnedInventory( + path: string, + label: string, +): Promise { + createIncompatibleOwnedInventory(path, label); + const script = String.raw` +const { DatabaseSync } = require('node:sqlite'); +const database = new DatabaseSync(process.env.DKG_RFC64_CRASHED_WAL_PATH); +database.exec('PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; BEGIN; INSERT INTO wrong_v1 VALUES (\'wal-row\'); COMMIT'); +process.stdout.write('READY\n'); +setInterval(() => {}, 60_000); +`; + const child = spawn(process.execPath, ['--experimental-sqlite', '-e', script], { + env: { ...process.env, DKG_RFC64_CRASHED_WAL_PATH: path }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + await new Promise((resolveReady, rejectReady) => { + let stdout = ''; + const timeout = setTimeout(() => { + rejectReady(new Error(`crashed-WAL child did not become ready: ${stderr}`)); + }, 10_000); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + if (!stdout.includes('READY\n')) return; + clearTimeout(timeout); + resolveReady(); + }); + child.once('error', (error) => { + clearTimeout(timeout); + rejectReady(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + rejectReady(new Error( + `crashed-WAL child exited before ready: code=${code} signal=${signal} stderr=${stderr}`, + )); + }); + }); + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveExit) => child.once('exit', (code, signal) => resolveExit({ code, signal })), + ); + child.kill('SIGKILL'); + expect(await exit).toEqual({ code: null, signal: 'SIGKILL' }); + expect(existsSync(`${path}-wal`)).toBe(true); + expect(existsSync(`${path}-shm`)).toBe(true); +} + function sha256File(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } @@ -581,7 +632,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc expectOpenErrorCode(error, 'database-busy')); }); - it('closes a corrupt owned target before reporting unavailable durability', async () => { + it('rejects corrupt quarantine before its exclusivity proof and closes as ordinary cleanup', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); const initialized = await openInventoryV1(dataDirectory); @@ -629,7 +680,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc await expect(openWithoutDurability(dataDirectory)).rejects.toSatisfy( (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), ); - expect(closeReasons).toContain('automatic-corrupt-quarantine'); + expect(closeReasons).toEqual(['failed-open-cleanup']); await expect(openWithoutDurability(dataDirectory)).rejects.toSatisfy( (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), ); @@ -916,6 +967,37 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc check.close(); }); + it('does not enter the quarantine proof for an unsupported crashed-WAL candidate', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + await createCrashedWalIncompatibleOwnedInventory(path, 'main-row'); + const boundaries: InventoryV1QuarantineBoundary[] = []; + const closeReasons: string[] = []; + const openWithoutDurability = createInventoryV1TestOpener({ + quarantineCapability: null, + boundary: (boundary) => { boundaries.push(boundary); }, + closeTarget: (close, reason) => { + close(); + closeReasons.push(reason); + }, + }); + + await expect(openWithoutDurability(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + + expect(boundaries).not.toContain('target-exclusivity-proven'); + expect(closeReasons).toEqual(['failed-open-cleanup']); + expectNoQuarantine(path); + const recovered = new DatabaseSync(path, { readOnly: true }); + try { + expect(recovered.prepare('SELECT value FROM wrong_v1 ORDER BY rowid').all()) + .toEqual([{ value: 'main-row' }, { value: 'wal-row' }]); + } finally { + recovered.close(); + } + }); + it('refuses NOTADB bytes without manufacturing DK64 ownership or quarantine', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); From de921af5f43fcdc90654f61bb6a98c0c1d69c581 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:52:48 +0200 Subject: [PATCH 041/292] fix(agent): accept active Windows owner identities --- packages/agent/src/rfc64/inventory-v1/open.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 5a72fbb1af..dec0c996f9 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -1127,7 +1127,14 @@ function assertFilesystemOwner(path: string): void { if (process.platform === 'win32') { const script = String.raw` $ErrorActionPreference = 'Stop' -$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User +$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$userSid = $identity.User +$defaultOwnerSid = $identity.Owner +$administratorsSid = [System.Security.Principal.SecurityIdentifier]::new( + [System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, + $null +) +$principal = [System.Security.Principal.WindowsPrincipal]::new($identity) $target = [System.IO.Path]::GetFullPath($env:DKG_RFC64_ACL_PATH) $acl = if ([System.IO.Directory]::Exists($target)) { [System.IO.Directory]::GetAccessControl( @@ -1141,7 +1148,26 @@ $acl = if ([System.IO.Directory]::Exists($target)) { ) } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) -if ($owner.Value -ne $sid.Value) { exit 40 } +# An elevated Windows token may create entries with its distinct default-owner +# SID (normally BUILTIN\Administrators) even though the process account SID is +# the user. GitHub-hosted and service runners may also pre-create the caller's +# temp root with BUILTIN\Administrators as owner while the active token is an +# administrator. Accept that one exact well-known group only when it is active +# in this token; arbitrary token groups are deliberately not accepted. +$isActiveAdministratorsOwner = ( + $owner.Value -eq $administratorsSid.Value -and + $principal.IsInRole($administratorsSid) +) +if ( + $owner.Value -ne $userSid.Value -and + $owner.Value -ne $defaultOwnerSid.Value -and + -not $isActiveAdministratorsOwner +) { + [Console]::Error.WriteLine( + "owner SID $($owner.Value) is neither token user $($userSid.Value) nor token default owner $($defaultOwnerSid.Value)" + ) + exit 40 +} `; const result = spawnSync( 'powershell.exe', From b3cc09b6b770aafe3a8ce6004b601d966f5a106a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:57:17 +0200 Subject: [PATCH 042/292] fix(agent): require opaque catalog directory proofs --- .../agent/src/rfc64/inventory-v1/candidate.ts | 55 +-- ...fc64-inventory-v1-candidate-faults.test.ts | 3 + ...c64-inventory-v1-candidate-latency.test.ts | 66 +++- .../rfc64-inventory-v1-candidates.test.ts | 353 +++++++++++++++--- 4 files changed, 372 insertions(+), 105 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index 57aba02b29..3164e2c1c2 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -23,6 +23,7 @@ import { computeAuthorCatalogScopeDigestV1, deriveAuthorCatalogScopeFromHeadV1, parseCanonicalDecimalU64, + readVerifiedAuthorCatalogBucketDescriptorV1, type AuthorCatalogRowV1, type AuthorCatalogScopeV1, type ByteLengthV1, @@ -34,6 +35,7 @@ import { type SignedAuthorCatalogBucketEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, type SubGraphNameV1, + type VerifiedAuthorCatalogDirectoryPathV1, } from '@origintrail-official/dkg-core'; import { @@ -58,7 +60,6 @@ declare const CANDIDATE_SESSION_BRAND: unique symbol; declare const CANDIDATE_LOAD_KEY_BRAND: unique symbol; declare const CANDIDATE_ROWS_TRAVERSAL_BRAND: unique symbol; declare const CANDIDATE_DIFF_TRAVERSAL_BRAND: unique symbol; -declare const VERIFIED_CANDIDATE_BUCKET_DESCRIPTOR_BRAND: unique symbol; /** Opaque, adapter-local session. No raw session bytes are exposed or accepted. */ export interface CandidateSessionV1 { @@ -78,27 +79,11 @@ export interface CandidateBucketDiffTraversalV1 { readonly [CANDIDATE_DIFF_TRAVERSAL_BRAND]: true; } -/** - * Exact selected leaf descriptor returned by the core directory-path verifier. - * The network layer remains responsible for signature authority and path proof; - * this adapter redundantly rechecks every deterministic descriptor/bucket bind. - */ -export interface VerifiedCandidateBucketDescriptorV1 { - /** - * This capability is minted only by the canonical directory-path verifier. - * Candidate SQL deliberately has no structural-only factory for it. - */ - readonly [VERIFIED_CANDIDATE_BUCKET_DESCRIPTOR_BRAND]: true; - readonly bucketId: DecimalU64V1; - readonly rowCount: CountV1; - readonly byteLength: ByteLengthV1; - readonly bucketDigest: Digest32V1; -} - export interface VerifiedCandidateBucketLoadV1 { readonly session: CandidateSessionV1; readonly head: SignedAuthorCatalogHeadEnvelopeV1; - readonly descriptor: VerifiedCandidateBucketDescriptorV1; + /** Exact process-local core proof for the selected descriptor under `head`. */ + readonly directoryPath: VerifiedAuthorCatalogDirectoryPathV1; readonly bucket: SignedAuthorCatalogBucketEnvelopeV1 | null; } @@ -751,7 +736,11 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { private encodeAndVerifyLoadUnchecked( load: VerifiedCandidateBucketLoadV1, ): EncodedCandidateLoadV1 { - const loadRecord = exactPlainRecord(load, ['bucket', 'descriptor', 'head', 'session'], 'load'); + const loadRecord = exactPlainRecord( + load, + ['bucket', 'directoryPath', 'head', 'session'], + 'load', + ); const sessionHandle = loadRecord.session as CandidateSessionV1; const session = this.requireSession(sessionHandle); assertSignedAuthorCatalogHeadEnvelopeV1( @@ -761,7 +750,10 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { const scope = deriveAuthorCatalogScopeFromHeadV1(head.payload); const headDigest = head.objectDigest as Digest32V1; const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); - const descriptor = verifyDescriptor(loadRecord.descriptor); + const descriptor = readVerifiedAuthorCatalogBucketDescriptorV1( + loadRecord.directoryPath, + head, + ); if (BigInt(descriptor.bucketId) >= BigInt(scope.bucketCount)) { invalidLoad('descriptor bucketId is outside the head bucket domain'); } @@ -1487,27 +1479,6 @@ class LatencyBudgetSignal extends Error { } } -function verifyDescriptor(value: unknown): VerifiedCandidateBucketDescriptorV1 { - const descriptor = exactPlainRecord( - value, - ['bucketDigest', 'bucketId', 'byteLength', 'rowCount'], - 'descriptor', - ); - const bucketId = canonicalU64(descriptor.bucketId, 'bucketId'); - const rowCount = canonicalU64(descriptor.rowCount, 'rowCount'); - const byteLength = canonicalU64(descriptor.byteLength, 'byteLength'); - const bucketDigest = descriptor.bucketDigest; - if (typeof bucketDigest !== 'string' || !/^0x[0-9a-f]{64}$/.test(bucketDigest)) { - invalidLoad('descriptor bucketDigest must be a canonical Digest32V1'); - } - return { - bucketId, - rowCount, - byteLength, - bucketDigest: bucketDigest as Digest32V1, - } as VerifiedCandidateBucketDescriptorV1; -} - function extractCandidateLoadSession(load: unknown): CandidateSessionV1 { if (typeof load !== 'object' || load === null || Object.getPrototypeOf(load) !== Object.prototype) { invalidLoad('load must be a plain object'); diff --git a/packages/agent/test/rfc64-inventory-v1-candidate-faults.test.ts b/packages/agent/test/rfc64-inventory-v1-candidate-faults.test.ts index 010b15e1ba..85e82f3381 100644 --- a/packages/agent/test/rfc64-inventory-v1-candidate-faults.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-candidate-faults.test.ts @@ -98,6 +98,9 @@ describe('RFC-64 SQL-1 candidate crash and static fault matrix', () => { ); expect(candidateSource).not.toMatch(/(?:PR\s*#?\s*1780|#1780)/i); expect(candidateSource).not.toMatch(/\b(?:isComplete|promoteToApplied|markApplied)\b/); + expect(candidateSource).not.toMatch(/\bVerifiedCandidateBucketDescriptorV1\b/); + expect(candidateSource).not.toMatch(/\bfunction\s+verifyDescriptor\b/); + expect(candidateSource).toMatch(/\breadVerifiedAuthorCatalogBucketDescriptorV1\b/); expect(Object.getOwnPropertyNames(CandidateInventoryV1.prototype)).not.toEqual( expect.arrayContaining(['isComplete', 'promoteToApplied', 'markApplied']), ); diff --git a/packages/agent/test/rfc64-inventory-v1-candidate-latency.test.ts b/packages/agent/test/rfc64-inventory-v1-candidate-latency.test.ts index 9f16f86795..394bc9db44 100644 --- a/packages/agent/test/rfc64-inventory-v1-candidate-latency.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-candidate-latency.test.ts @@ -7,24 +7,31 @@ import { describe, expect, it, vi } from 'vitest'; import { AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, KA_TRANSFER_CHUNK_SIZE_V1, KA_TRANSFER_CODEC_V1, KA_TRANSFER_PROJECTION_V1, assertAuthorCatalogRowV1, assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, assertSignedAuthorCatalogHeadEnvelopeV1, canonicalizeAuthorCatalogBucketPayloadBytesV1, computeAuthorCatalogBucketObjectDigestV1, + computeAuthorCatalogDirectoryNodeObjectDigestV1, computeAuthorCatalogHeadObjectDigestV1, computeAuthorCatalogScopeDigestV1, deriveAuthorCatalogScopeFromHeadV1, + verifyAuthorCatalogDirectoryPathV1, + type AuthorCatalogBucketDescriptorV1, type AuthorCatalogBucketV1, + type AuthorCatalogDirectoryNodeV1, type AuthorCatalogHeadV1, type AuthorCatalogRowV1, type ByteLengthV1, type CountV1, type Digest32V1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, type SignedControlEnvelopeV1, type UnsignedControlEnvelopeV1, @@ -548,8 +555,8 @@ function latencyCandidateLoad( signature: LATENCY_SIGNATURE, } as SignedControlEnvelopeV1; assertSignedAuthorCatalogHeadEnvelopeV1(head); - const signedHead = head as SignedAuthorCatalogHeadEnvelopeV1; - const scope = deriveAuthorCatalogScopeFromHeadV1(signedHead.payload); + const headTemplate = head as SignedAuthorCatalogHeadEnvelopeV1; + const scope = deriveAuthorCatalogScopeFromHeadV1(headTemplate.payload); const rows = Array.from({ length: rowCount }, (_, index): AuthorCatalogRowV1 => { const row = { kaId: ((BigInt(LATENCY_AUTHOR) << 96n) | BigInt(index + 1)).toString(), @@ -592,17 +599,56 @@ function latencyCandidateLoad( signature: LATENCY_SIGNATURE, } as SignedControlEnvelopeV1; assertSignedAuthorCatalogBucketEnvelopeV1(bucket); + const descriptor = { + bucketId: '0', + rowCount: String(rowCount) as CountV1, + byteLength: String( + canonicalizeAuthorCatalogBucketPayloadBytesV1(bucketPayload).byteLength, + ) as ByteLengthV1, + bucketDigest: bucket.objectDigest as Digest32V1, + } satisfies AuthorCatalogBucketDescriptorV1; + const directoryPayload: AuthorCatalogDirectoryNodeV1 = { + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + entries: [descriptor], + era: scope.era, + firstBucketId: '0', + level: '0', + }; + const unsignedDirectory = { + issuer: LATENCY_ISSUER, + objectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + payload: directoryPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; + const signedDirectory = { + ...unsignedDirectory, + objectDigest: computeAuthorCatalogDirectoryNodeObjectDigestV1(unsignedDirectory, '1'), + signature: LATENCY_SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(signedDirectory, '1'); + const directory = signedDirectory as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + const unsignedBoundHead = { + issuer: headTemplate.issuer, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload: { + ...headTemplate.payload, + directoryRootDigest: directory.objectDigest, + }, + signatureEvidence: headTemplate.signatureEvidence, + signatureSuite: headTemplate.signatureSuite, + } as UnsignedControlEnvelopeV1; + const boundHead = { + ...unsignedBoundHead, + objectDigest: computeAuthorCatalogHeadObjectDigestV1(unsignedBoundHead), + signature: headTemplate.signature, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogHeadEnvelopeV1(boundHead); + const signedHead = boundHead as SignedAuthorCatalogHeadEnvelopeV1; return { session, head: signedHead, - descriptor: { - bucketId: '0', - rowCount: String(rowCount) as CountV1, - byteLength: String( - canonicalizeAuthorCatalogBucketPayloadBytesV1(bucketPayload).byteLength, - ) as ByteLengthV1, - bucketDigest: bucket.objectDigest as Digest32V1, - }, + directoryPath: verifyAuthorCatalogDirectoryPathV1(signedHead, [directory], '0'), bucket, }; } diff --git a/packages/agent/test/rfc64-inventory-v1-candidates.test.ts b/packages/agent/test/rfc64-inventory-v1-candidates.test.ts index d000293c20..8b142b8652 100644 --- a/packages/agent/test/rfc64-inventory-v1-candidates.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-candidates.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, KA_TRANSFER_CHUNK_SIZE_V1, KA_TRANSFER_CODEC_V1, @@ -18,14 +19,19 @@ import { ZERO_DIGEST32_V1, assertAuthorCatalogRowV1, assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, assertSignedAuthorCatalogHeadEnvelopeV1, canonicalizeAuthorCatalogBucketPayloadBytesV1, catalogKeyToBucketIdV1, computeAuthorCatalogBucketObjectDigestV1, + computeAuthorCatalogDirectoryNodeObjectDigestV1, computeAuthorCatalogHeadObjectDigestV1, computeAuthorCatalogScopeDigestV1, deriveAuthorCatalogScopeFromHeadV1, + verifyAuthorCatalogDirectoryPathV1, + type AuthorCatalogBucketDescriptorV1, type AuthorCatalogBucketV1, + type AuthorCatalogDirectoryNodeV1, type AuthorCatalogHeadV1, type AuthorCatalogRowV1, type ByteLengthV1, @@ -34,9 +40,11 @@ import { type Digest32V1, type KaIdV1, type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, type SignedControlEnvelopeV1, type UnsignedControlEnvelopeV1, + type VerifiedAuthorCatalogDirectoryPathV1, } from '@origintrail-official/dkg-core'; import { @@ -365,13 +373,21 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { try { inventory.purgeNextStartupStaleCandidateBatch(); const session = inventory.createCandidateSession(); - const head = makeHead('1', '34'); - inventory.putVerifiedCandidateBucket( - makeNonEmptyLoad(session, head, [makeRow(1n, 'original')]), + const head = makeHead('2', '34', '2'); + const [original, conflict] = makeBoundNonEmptyLoads(session, head, [ + { + bucketId: '0', + rows: [rowForBucket('0', '2', 1n, 'same-coordinate')], + }, + { + bucketId: '1', + rows: [rowForBucket('1', '2', 2n, 'same-coordinate')], + }, + ]); + inventory.putVerifiedCandidateBucket(original); + expect(() => inventory.putVerifiedCandidateBucket(conflict)).toThrowError( + expect.objectContaining({ code: 'candidate-conflict' }), ); - expect(() => inventory.putVerifiedCandidateBucket( - makeNonEmptyLoad(session, head, [makeRow(2n, 'conflict')]), - )).toThrowError(expect.objectContaining({ code: 'candidate-conflict' })); failNextCommit = true; expect(inventory.discardCandidateSessionBatch(session)).toEqual({ @@ -699,10 +715,18 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { it('poisons a conflicting session and permits only bounded discard until terminal empty', async () => { const inventory = await readyInventory(); const session = inventory.createCandidateSession(); - const head = makeHead('1', '3'); - const original = makeNonEmptyLoad(session, head, [makeRow(1n, 'original')]); + const head = makeHead('2', '3', '2'); + const [original, mutation] = makeBoundNonEmptyLoads(session, head, [ + { + bucketId: '0', + rows: [rowForBucket('0', '2', 1n, 'same-coordinate')], + }, + { + bucketId: '1', + rows: [rowForBucket('1', '2', 2n, 'same-coordinate')], + }, + ]); const loaded = inventory.putVerifiedCandidateBucket(original); - const mutation = makeNonEmptyLoad(session, head, [makeRow(2n, 'mutation')]); expect(() => inventory.putVerifiedCandidateBucket(mutation)).toThrowError( expect.objectContaining({ code: 'candidate-conflict' }), @@ -732,12 +756,14 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { const head = makeHead('2', '4', '2'); const bucket0Row = rowForBucket('0', '2', 1n, 'same-coordinate'); const bucket1Row = rowForBucket('1', '2', 2n, 'same-coordinate'); - const first = inventory.putVerifiedCandidateBucket( - makeNonEmptyLoad(session, head, [bucket0Row], '0'), + const [bucket0, bucket1] = makeBoundNonEmptyLoads(session, head, [ + { bucketId: '0', rows: [bucket0Row] }, + { bucketId: '1', rows: [bucket1Row] }, + ]); + const first = inventory.putVerifiedCandidateBucket(bucket0); + expect(() => inventory.putVerifiedCandidateBucket(bucket1)).toThrowError( + expect.objectContaining({ code: 'candidate-conflict' }), ); - expect(() => inventory.putVerifiedCandidateBucket( - makeNonEmptyLoad(session, head, [bucket1Row], '1'), - )).toThrowError(expect.objectContaining({ code: 'candidate-conflict' })); expect(() => inventory.getCandidateBucket(first.loadKey)).toThrowError( expect.objectContaining({ code: 'candidate-session-poisoned' }), ); @@ -747,28 +773,42 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { const inventory = await readyInventory(); const oldSession = inventory.createCandidateSession(); const poisonedSession = inventory.createCandidateSession(); - const oldHead = makeHead('1', '40'); - const newHead = makeHead('1', '41'); + const oldHead = makeHead('1', '40', '2'); + const newHead = makeHead('2', '41', '2'); const oldLoad = inventory.putVerifiedCandidateBucket( - makeNonEmptyLoad(oldSession, oldHead, [makeRow(1n, 'old')]), + makeNonEmptyLoad( + oldSession, + oldHead, + [rowForBucket('0', '2', 1n, 'old')], + '0', + ), ); - const newLoadInput = makeNonEmptyLoad( + const [newLoadInput, conflictingLoadInput] = makeBoundNonEmptyLoads( poisonedSession, newHead, - [makeRow(2n, 'new')], + [ + { + bucketId: '0', + rows: [rowForBucket('0', '2', 2n, 'same-coordinate')], + }, + { + bucketId: '1', + rows: [rowForBucket('1', '2', 3n, 'same-coordinate')], + }, + ], ); const newLoad = inventory.putVerifiedCandidateBucket(newLoadInput); const rows = inventory.beginCandidateBucketRows(newLoad.loadKey); const diff = inventory.beginCandidateBucketDiff(oldLoad.loadKey, newLoad.loadKey); - expect(() => inventory.putVerifiedCandidateBucket( - makeNonEmptyLoad(poisonedSession, newHead, [makeRow(3n, 'conflict')]), - )).toThrowError(expect.objectContaining({ code: 'candidate-conflict' })); + expect(() => inventory.putVerifiedCandidateBucket(conflictingLoadInput)).toThrowError( + expect.objectContaining({ code: 'candidate-conflict' }), + ); expect(() => inventory.putVerifiedCandidateBucket({ session: poisonedSession, head: null, - descriptor: null, + directoryPath: null, bucket: null, } as unknown as VerifiedCandidateBucketLoadV1)).toThrowError( expect.objectContaining({ code: 'candidate-session-poisoned' }), @@ -960,7 +1000,7 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { }); } - it('rejects raw capabilities, clean-session discard, invalid pages, and descriptor mismatch', async () => { + it('rejects forged, cloned, serialized, wrong-head, and wrong-bucket directory proofs', async () => { const inventory = await readyInventory(); const session = inventory.createCandidateSession(); expect(() => inventory.discardCandidateSessionBatch(session)).toThrowError( @@ -971,22 +1011,49 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { session: Object.freeze({}) as CandidateSessionV1, })).toThrowError(expect.objectContaining({ code: 'candidate-invalid-session' })); - const head = makeHead('1', '10'); - const invalid = makeNonEmptyLoad(session, head, [makeRow(1n, 'fixture')]); - expect(() => inventory.putVerifiedCandidateBucket({ - ...invalid, - descriptor: { ...invalid.descriptor, byteLength: '1' }, - } as VerifiedCandidateBucketLoadV1)).toThrowError( - expect.objectContaining({ code: 'candidate-invalid-load' }), + const head = makeHead('2', '10', '2'); + const [bucket0, bucket1] = makeBoundNonEmptyLoads(session, head, [ + { bucketId: '0', rows: [rowForBucket('0', '2', 1n, 'bucket-zero')] }, + { bucketId: '1', rows: [rowForBucket('1', '2', 2n, 'bucket-one')] }, + ]); + const invalidDirectoryProofs: readonly [string, unknown][] = [ + ['forged/cast object', Object.freeze(Object.create(null))], + ['spread clone', { ...bucket0.directoryPath }], + ['JSON roundtrip', JSON.parse(JSON.stringify(bucket0.directoryPath))], + ['serialized token', JSON.stringify(bucket0.directoryPath)], + ]; + for (const [label, directoryPath] of invalidDirectoryProofs) { + expect( + () => inventory.putVerifiedCandidateBucket({ + ...bucket0, + directoryPath, + } as VerifiedCandidateBucketLoadV1), + label, + ).toThrowError(expect.objectContaining({ code: 'candidate-invalid-load' })); + } + + const otherHeadLoad = makeNonEmptyLoad( + session, + makeHead('1', '11'), + [makeRow(3n, 'other-head')], ); + expect(() => inventory.putVerifiedCandidateBucket({ + ...bucket0, + head: otherHeadLoad.head, + })).toThrowError(expect.objectContaining({ code: 'candidate-invalid-load' })); + expect(() => inventory.putVerifiedCandidateBucket({ + ...bucket0, + directoryPath: bucket1.directoryPath, + })).toThrowError(expect.objectContaining({ code: 'candidate-invalid-load' })); + const unboundRow = { ...makeRow(1n, 'fixture'), kaId: '1', } as AuthorCatalogRowV1; expect(() => inventory.putVerifiedCandidateBucket( - makeNonEmptyLoad(session, head, [unboundRow]), + makeNonEmptyLoad(session, makeHead('1', '12'), [unboundRow]), )).toThrowError(expect.objectContaining({ code: 'candidate-invalid-load' })); - const loaded = inventory.putVerifiedCandidateBucket(invalid); + const loaded = inventory.putVerifiedCandidateBucket(bucket0); const traversal = inventory.beginCandidateBucketRows(loaded.loadKey); expect(() => inventory.pageCandidateBucketRows(traversal, null, 0)).toThrowError( expect.objectContaining({ code: 'candidate-invalid-load' }), @@ -1100,12 +1167,81 @@ function rowForBucket( function makeNonEmptyLoad( session: CandidateSessionV1, - head: SignedAuthorCatalogHeadEnvelopeV1, + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, rows: readonly AuthorCatalogRowV1[], bucketId = '0', validate = true, ): VerifiedCandidateBucketLoadV1 { - const scope = deriveAuthorCatalogScopeFromHeadV1(head.payload); + const fixture = makeSignedBucketFixture(headTemplate, rows, bucketId, validate); + const { head, directoryPath } = bindHeadToSelectedDescriptor( + headTemplate, + fixture.descriptor, + ); + return { + session, + head, + directoryPath, + bucket: fixture.bucket, + }; +} + +function makeBoundNonEmptyLoads( + session: CandidateSessionV1, + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, + specifications: readonly { + readonly bucketId: string; + readonly rows: readonly AuthorCatalogRowV1[]; + readonly validate?: boolean; + }[], +): readonly VerifiedCandidateBucketLoadV1[] { + const bucketCount = Number(BigInt(headTemplate.payload.bucketCount)); + const descriptors = Array.from( + { length: bucketCount }, + (_, bucketId) => emptyDescriptor(bucketId), + ); + const fixtures = specifications.map((specification) => makeSignedBucketFixture( + headTemplate, + specification.rows, + specification.bucketId, + specification.validate ?? true, + )); + const occupied = new Set(); + let totalRows = 0n; + for (const fixture of fixtures) { + const bucketId = Number(BigInt(fixture.descriptor.bucketId)); + if (bucketId < 0 || bucketId >= bucketCount || occupied.has(bucketId)) { + throw new Error('invalid or duplicate bound bucket fixture'); + } + occupied.add(bucketId); + descriptors[bucketId] = fixture.descriptor; + totalRows += BigInt(fixture.descriptor.rowCount); + } + if (totalRows !== BigInt(headTemplate.payload.totalRows)) { + throw new Error('bound bucket fixture rows do not equal head totalRows'); + } + const bindings = fixtures.map((fixture) => bindHeadToDirectory( + headTemplate, + descriptors, + fixture.descriptor.bucketId, + )); + return fixtures.map((fixture, index) => ({ + session, + head: bindings[0].head, + directoryPath: bindings[index].directoryPath, + bucket: fixture.bucket, + })); +} + +function makeSignedBucketFixture( + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, + rows: readonly AuthorCatalogRowV1[], + bucketId: string, + validate: boolean, +): { + readonly bucket: SignedAuthorCatalogBucketEnvelopeV1; + readonly descriptor: AuthorCatalogBucketDescriptorV1; +} { + const scope = deriveAuthorCatalogScopeFromHeadV1(headTemplate.payload); const payload: AuthorCatalogBucketV1 = { catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), era: scope.era, @@ -1128,39 +1264,150 @@ function makeNonEmptyLoad( signature: SIGNATURE, } as SignedControlEnvelopeV1; if (validate) assertSignedAuthorCatalogBucketEnvelopeV1(bucket); + const descriptor = { + bucketId: bucketId as DecimalU64V1, + rowCount: String(rows.length) as CountV1, + byteLength: (validate + ? String(canonicalizeAuthorCatalogBucketPayloadBytesV1(payload).byteLength) + : '1') as ByteLengthV1, + bucketDigest: bucket.objectDigest as Digest32V1, + } satisfies AuthorCatalogBucketDescriptorV1; return { - session, - head, - descriptor: { - bucketId: bucketId as DecimalU64V1, - rowCount: String(rows.length) as CountV1, - byteLength: (validate - ? String(canonicalizeAuthorCatalogBucketPayloadBytesV1(payload).byteLength) - : '1') as ByteLengthV1, - bucketDigest: bucket.objectDigest as Digest32V1, - }, - bucket, + bucket: bucket as SignedAuthorCatalogBucketEnvelopeV1, + descriptor, }; } function makeEmptyLoad( session: CandidateSessionV1, - head: SignedAuthorCatalogHeadEnvelopeV1, + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, bucketId: string, ): VerifiedCandidateBucketLoadV1 { + const { head, directoryPath } = bindHeadToSelectedDescriptor(headTemplate, { + bucketId: bucketId as DecimalU64V1, + rowCount: '0' as CountV1, + byteLength: '0' as ByteLengthV1, + bucketDigest: ZERO_DIGEST32_V1, + }); return { session, head, - descriptor: { - bucketId: bucketId as DecimalU64V1, - rowCount: '0' as CountV1, - byteLength: '0' as ByteLengthV1, - bucketDigest: ZERO_DIGEST32_V1, - }, + directoryPath, bucket: null, }; } +function bindHeadToSelectedDescriptor( + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, + selected: AuthorCatalogBucketDescriptorV1, +): { + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly directoryPath: VerifiedAuthorCatalogDirectoryPathV1; +} { + const bucketCount = Number(BigInt(headTemplate.payload.bucketCount)); + const selectedBucket = Number(BigInt(selected.bucketId)); + const totalRows = BigInt(headTemplate.payload.totalRows); + const selectedRows = BigInt(selected.rowCount); + if (selectedBucket < 0 || selectedBucket >= bucketCount || selectedRows > totalRows) { + throw new Error('invalid selected descriptor fixture'); + } + let remainingRows = totalRows - selectedRows; + const descriptors = Array.from({ length: bucketCount }, (_, index) => { + if (index === selectedBucket) return selected; + if (remainingRows > 0n) { + const rowCount = remainingRows; + remainingRows = 0n; + return placeholderDescriptor(index, rowCount); + } + return emptyDescriptor(index); + }); + if (remainingRows !== 0n) throw new Error('unable to allocate fixture directory rows'); + return bindHeadToDirectory(headTemplate, descriptors, selected.bucketId); +} + +function bindHeadToDirectory( + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, + descriptors: readonly AuthorCatalogBucketDescriptorV1[], + selectedBucketId: DecimalU64V1, +): { + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly directoryPath: VerifiedAuthorCatalogDirectoryPathV1; +} { + const scope = deriveAuthorCatalogScopeFromHeadV1(headTemplate.payload); + const node: AuthorCatalogDirectoryNodeV1 = { + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + entries: descriptors, + era: scope.era, + firstBucketId: '0', + level: '0', + }; + const unsignedDirectory = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + payload: node, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; + const signedDirectory = { + ...unsignedDirectory, + objectDigest: computeAuthorCatalogDirectoryNodeObjectDigestV1( + unsignedDirectory, + scope.bucketCount, + ), + signature: SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(signedDirectory, scope.bucketCount); + const directory = signedDirectory as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + const unsignedHead = { + issuer: headTemplate.issuer, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload: { + ...headTemplate.payload, + directoryRootDigest: directory.objectDigest, + }, + signatureEvidence: headTemplate.signatureEvidence, + signatureSuite: headTemplate.signatureSuite, + } as UnsignedControlEnvelopeV1; + const signedHead = { + ...unsignedHead, + objectDigest: computeAuthorCatalogHeadObjectDigestV1(unsignedHead), + signature: headTemplate.signature, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogHeadEnvelopeV1(signedHead); + const head = signedHead as SignedAuthorCatalogHeadEnvelopeV1; + return { + head, + directoryPath: verifyAuthorCatalogDirectoryPathV1( + head, + [directory], + selectedBucketId, + ), + }; +} + +function emptyDescriptor(bucketId: number): AuthorCatalogBucketDescriptorV1 { + return { + bucketDigest: ZERO_DIGEST32_V1, + bucketId: String(bucketId) as DecimalU64V1, + byteLength: '0', + rowCount: '0', + }; +} + +function placeholderDescriptor( + bucketId: number, + rowCount: bigint, +): AuthorCatalogBucketDescriptorV1 { + if (rowCount < 1n || rowCount > 1024n) throw new Error('invalid placeholder row count'); + const byte = (0x80 + bucketId).toString(16).padStart(2, '0'); + return { + bucketDigest: `0x${byte.repeat(32)}` as Digest32V1, + bucketId: String(bucketId) as DecimalU64V1, + byteLength: '1', + rowCount: rowCount.toString() as CountV1, + }; +} + function rawSessionHex(sessionByte: number): string { return sessionByte.toString(16).padStart(2, '0').repeat(32).toUpperCase(); } From b01ebf6377e08d03c792b3b050a89239e0f20b9e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:57:27 +0200 Subject: [PATCH 043/292] fix(chain): make RFC-64 signature proofs unforgeable --- .../src/control-object-signature-verifier.ts | 51 ++++++++++++++- packages/chain/src/index.ts | 3 + ...rol-object-signature-verifier.unit.test.ts | 63 +++++++++++++++---- 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/packages/chain/src/control-object-signature-verifier.ts b/packages/chain/src/control-object-signature-verifier.ts index cfa4ff8c82..709146e25d 100644 --- a/packages/chain/src/control-object-signature-verifier.ts +++ b/packages/chain/src/control-object-signature-verifier.ts @@ -37,6 +37,7 @@ const SECP256K1_HALF_N = BigInt( const EIP1271_INTERFACE = new ethers.Interface([ 'function isValidSignature(bytes32,bytes) view returns (bytes4)', ]); +declare const VERIFIED_CONTROL_ENVELOPE_ISSUER_SIGNATURE_BRAND_V1: unique symbol; export const CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1 = Object.freeze([ 'unsupported-chain', @@ -160,6 +161,11 @@ export interface VerifyControlEnvelopeIssuerSignatureOptionsV1 { } export interface VerifiedControlEnvelopeIssuerSignatureV1 { + readonly [VERIFIED_CONTROL_ENVELOPE_ISSUER_SIGNATURE_BRAND_V1]: true; +} + +/** Immutable evidence retained behind the process-local verification token. */ +export interface VerifiedControlEnvelopeIssuerSignatureSnapshotV1 { readonly objectDigest: Digest32V1; readonly signatureVariantDigest: Digest32V1; readonly issuer: EvmAddressV1; @@ -175,6 +181,11 @@ export interface VerifiedControlEnvelopeIssuerSignatureV1 { }; } +const VERIFIED_CONTROL_ENVELOPE_ISSUER_SIGNATURES_V1 = new WeakMap< + object, + VerifiedControlEnvelopeIssuerSignatureSnapshotV1 +>(); + /** * Verify only generic envelope cryptography. A successful result does not prove * that the issuer has object-specific owner/admin/catalog/checkpoint authority. @@ -192,7 +203,7 @@ export async function verifyControlEnvelopeIssuerSignatureV1( if (envelope.signatureSuite === 'eip191-personal-sign-digest-v1') { verifyCanonicalEip191(envelope); - return Object.freeze({ + return mintVerifiedControlEnvelopeIssuerSignatureV1({ objectDigest, signatureVariantDigest, issuer, @@ -288,7 +299,7 @@ export async function verifyControlEnvelopeIssuerSignatureV1( ); } - return Object.freeze({ + return mintVerifiedControlEnvelopeIssuerSignatureV1({ objectDigest, signatureVariantDigest, issuer, @@ -307,6 +318,42 @@ export async function verifyControlEnvelopeIssuerSignatureV1( } } +/** Reject lookalikes, casts, clones, and serialized copies of verification tokens. */ +export function assertVerifiedControlEnvelopeIssuerSignatureV1( + value: unknown, +): asserts value is VerifiedControlEnvelopeIssuerSignatureV1 { + if ( + (typeof value !== 'object' && typeof value !== 'function') + || value === null + || !VERIFIED_CONTROL_ENVELOPE_ISSUER_SIGNATURES_V1.has(value as object) + ) { + throw new TypeError( + 'control-envelope issuer signature token was not minted by this verifier', + ); + } +} + +export function readVerifiedControlEnvelopeIssuerSignatureV1( + value: unknown, +): VerifiedControlEnvelopeIssuerSignatureSnapshotV1 { + assertVerifiedControlEnvelopeIssuerSignatureV1(value); + return VERIFIED_CONTROL_ENVELOPE_ISSUER_SIGNATURES_V1.get(value as object)!; +} + +function mintVerifiedControlEnvelopeIssuerSignatureV1( + snapshot: VerifiedControlEnvelopeIssuerSignatureSnapshotV1, +): VerifiedControlEnvelopeIssuerSignatureV1 { + const immutable = Object.freeze({ + ...snapshot, + verificationEvidence: Object.freeze({ ...snapshot.verificationEvidence }), + }) as VerifiedControlEnvelopeIssuerSignatureSnapshotV1; + const capability = Object.freeze( + Object.create(null), + ) as VerifiedControlEnvelopeIssuerSignatureV1; + VERIFIED_CONTROL_ENVELOPE_ISSUER_SIGNATURES_V1.set(capability as object, immutable); + return capability; +} + function snapshotEnvelope(input: SignedControlEnvelopeV1): SignedControlEnvelopeV1 { try { const bytes = canonicalizeSignedControlEnvelopeBytes(input); diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index cae6e23fcf..312183bbc5 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -14,6 +14,8 @@ export { EIP1271_MAGIC_VALUE_V1, ControlSignatureVerificationErrorV1, CurrentFinalizedEvmCallErrorV1, + assertVerifiedControlEnvelopeIssuerSignatureV1, + readVerifiedControlEnvelopeIssuerSignatureV1, verifyControlEnvelopeIssuerSignatureV1, type ControlSignatureVerificationDispositionV1, type ControlSignatureVerificationErrorCodeV1, @@ -23,6 +25,7 @@ export { type CurrentFinalizedEvmCallResultV1, type CurrentFinalizedEvmCallV1, type VerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureSnapshotV1, type VerifyControlEnvelopeIssuerSignatureOptionsV1, } from './control-object-signature-verifier.js'; export { diff --git a/packages/chain/test/control-object-signature-verifier.unit.test.ts b/packages/chain/test/control-object-signature-verifier.unit.test.ts index d3edb52d36..572490db1e 100644 --- a/packages/chain/test/control-object-signature-verifier.unit.test.ts +++ b/packages/chain/test/control-object-signature-verifier.unit.test.ts @@ -18,6 +18,8 @@ import { EIP1271_CANONICAL_ABI_RETURN_V1, ControlSignatureVerificationErrorV1, CurrentFinalizedEvmCallErrorV1, + assertVerifiedControlEnvelopeIssuerSignatureV1, + readVerifiedControlEnvelopeIssuerSignatureV1, verifyControlEnvelopeIssuerSignatureV1, type CurrentFinalizedEvmCallResultV1, type CurrentFinalizedEvmCallV1, @@ -58,7 +60,8 @@ describe('RFC-64 control-object issuer signature verifier', () => { it('verifies EIP-191 over raw digest bytes and returns immutable variant identity', async () => { const wallet = new ethers.Wallet(PRIVATE_KEY); const envelope = await eoaEnvelope(wallet); - const verified = await verifyControlEnvelopeIssuerSignatureV1(envelope); + const capability = await verifyControlEnvelopeIssuerSignatureV1(envelope); + const verified = readVerifiedControlEnvelopeIssuerSignatureV1(capability); expect(envelope.objectDigest).toBe(EIP191_VECTOR_DIGEST); expect(envelope.signature).toBe(EIP191_VECTOR_SIGNATURE); @@ -70,10 +73,32 @@ describe('RFC-64 control-object issuer signature verifier', () => { verificationEvidence: { kind: 'eip191' }, }); expect(verified.signatureVariantDigest).toMatch(/^0x[0-9a-f]{64}$/); + expect(Object.isFrozen(capability)).toBe(true); + expect(Object.getPrototypeOf(capability)).toBeNull(); expect(Object.isFrozen(verified)).toBe(true); expect(Object.isFrozen(verified.verificationEvidence)).toBe(true); }); + it('rejects forged, cloned, and serialized verification-token lookalikes', async () => { + const capability = await verifyControlEnvelopeIssuerSignatureV1( + await eoaEnvelope(new ethers.Wallet(PRIVATE_KEY)), + ); + expect(() => assertVerifiedControlEnvelopeIssuerSignatureV1(capability)).not.toThrow(); + for (const lookalike of [ + {}, + { ...capability }, + Object.create(null), + JSON.parse(JSON.stringify(capability)) as unknown, + ]) { + expect(() => assertVerifiedControlEnvelopeIssuerSignatureV1(lookalike)).toThrow( + /was not minted by this verifier/, + ); + expect(() => readVerifiedControlEnvelopeIssuerSignatureV1(lookalike)).toThrow( + /was not minted by this verifier/, + ); + } + }); + it('rejects signing UTF-8 hex text and a signature from another issuer', async () => { const wallet = new ethers.Wallet(PRIVATE_KEY); const other = new ethers.Wallet(OTHER_PRIVATE_KEY); @@ -138,9 +163,10 @@ describe('RFC-64 control-object issuer signature verifier', () => { it('issues one exact bounded raw EIP-1271 call at current-finalized state', async () => { const call = vi.fn(async () => FINALIZED_OK); const envelope = safeEnvelope(); - const verified = await verifyControlEnvelopeIssuerSignatureV1(envelope, { + const capability = await verifyControlEnvelopeIssuerSignatureV1(envelope, { callEvmAtCurrentFinalized: call, }); + const verified = readVerifiedControlEnvelopeIssuerSignatureV1(capability); expect(call).toHaveBeenCalledTimes(1); const request = call.mock.calls[0]![0]; @@ -232,9 +258,10 @@ describe('RFC-64 control-object issuer signature verifier', () => { return Reflect.get(target, property, receiver); }, }) as CurrentFinalizedEvmCallResultV1; - const verified = await verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { + const capability = await verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { callEvmAtCurrentFinalized: callReturning(stateful), }); + const verified = readVerifiedControlEnvelopeIssuerSignatureV1(capability); expect(verified.verificationEvidence).toMatchObject({ blockNumber: '123' }); expect(blockNumberReads).toBe(1); }); @@ -377,25 +404,35 @@ describe('RFC-64 control-object issuer signature verifier', () => { const second = safeEnvelope('0x1234'); const call = callReturning(FINALIZED_OK); - const verifiedFirst = await verifyControlEnvelopeIssuerSignatureV1(first, { - callEvmAtCurrentFinalized: call, - }); - const verifiedSecond = await verifyControlEnvelopeIssuerSignatureV1(second, { - callEvmAtCurrentFinalized: call, - }); + const verifiedFirst = readVerifiedControlEnvelopeIssuerSignatureV1( + await verifyControlEnvelopeIssuerSignatureV1(first, { + callEvmAtCurrentFinalized: call, + }), + ); + const verifiedSecond = readVerifiedControlEnvelopeIssuerSignatureV1( + await verifyControlEnvelopeIssuerSignatureV1(second, { + callEvmAtCurrentFinalized: call, + }), + ); expect(verifiedFirst.objectDigest).toBe(verifiedSecond.objectDigest); expect(verifiedFirst.signatureVariantDigest).not.toBe(verifiedSecond.signatureVariantDigest); }); it('accepts 1-byte and 4096-byte EIP-1271 signatures and rejects 0/4097 structurally', async () => { const call = callReturning(FINALIZED_OK); - await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope('0xaa'), { + const oneByte = await verifyControlEnvelopeIssuerSignatureV1(safeEnvelope('0xaa'), { callEvmAtCurrentFinalized: call, - })).resolves.toMatchObject({ signatureSuite: 'eip1271-current-finalized-v1' }); - await expect(verifyControlEnvelopeIssuerSignatureV1( + }); + expect(readVerifiedControlEnvelopeIssuerSignatureV1(oneByte)).toMatchObject({ + signatureSuite: 'eip1271-current-finalized-v1', + }); + const maxBytes = await verifyControlEnvelopeIssuerSignatureV1( safeEnvelope(`0x${'aa'.repeat(4096)}`), { callEvmAtCurrentFinalized: call }, - )).resolves.toMatchObject({ signatureSuite: 'eip1271-current-finalized-v1' }); + ); + expect(readVerifiedControlEnvelopeIssuerSignatureV1(maxBytes)).toMatchObject({ + signatureSuite: 'eip1271-current-finalized-v1', + }); await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope('0x'), { callEvmAtCurrentFinalized: call, })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_ENVELOPE_INVALID' }); From 0c4358752516ed0f640208d1371861743d2477d5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 06:58:44 +0200 Subject: [PATCH 044/292] feat(core): bind complete transferred catalog bundles --- packages/core/src/index.ts | 1 + .../core/src/transferred-catalog-bundle.ts | 461 ++++++++++++++++++ .../test/transferred-catalog-bundle.test.ts | 409 ++++++++++++++++ 3 files changed, 871 insertions(+) create mode 100644 packages/core/src/transferred-catalog-bundle.ts create mode 100644 packages/core/test/transferred-catalog-bundle.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bef4083125..24da6cc552 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -375,3 +375,4 @@ export { type CanonicalGraphScopedAuthorSealErrorCode, } from './canonical-graph-scoped-author-seal.js'; export * from './catalog-seal-binding.js'; +export * from './transferred-catalog-bundle.js'; diff --git a/packages/core/src/transferred-catalog-bundle.ts b/packages/core/src/transferred-catalog-bundle.ts new file mode 100644 index 0000000000..182208a68c --- /dev/null +++ b/packages/core/src/transferred-catalog-bundle.ts @@ -0,0 +1,461 @@ +import { + assertAuthorCatalogRowScopeBindingV1, + assertAuthorCatalogRowV1, + assertNetworkIdV1, + canonicalizeAuthorCatalogRowV1, + computeAuthorCatalogRowDigestV1, + computeAuthorCatalogScopeDigestV1, + parseCanonicalAuthorCatalogRowV1, + type AuthorCatalogRowV1, + type NetworkIdV1, +} from './author-catalog-codec.js'; +import { + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, + deriveAuthorCatalogScopeFromHeadV1, + parseCanonicalSignedAuthorCatalogHeadEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, +} from './author-catalog-objects.js'; +import { + readVerifiedCatalogSealBindingV1, + verifyCatalogSealBindingV1, + type CatalogSealDeploymentProfileV1, + type VerifiedCatalogSealBindingV1, +} from './catalog-seal-binding.js'; +import { decodeOpaqueKaBundleV1 } from './ka-bundle-v1.js'; +import { computeKaChunkTreeRootV1 } from './ka-chunk-tree.js'; +import { + MAX_KA_TRANSFER_BYTES_V1, + MIN_KA_TRANSFER_BYTES_V1, + assertKaTransferDescriptorV1, + computeKaTransferIdentityDigestV1, + type KaTransferDescriptorV1, +} from './ka-transfer-descriptor.js'; +import { + assertCanonicalChainId, + assertCanonicalEvmAddress, + type ByteLengthV1, + type Digest32V1, + type EvmAddressV1, +} from './sync-wire-scalars.js'; +import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; + +declare const VERIFIED_TRANSFERRED_CATALOG_BUNDLE_BRAND_V1: unique symbol; + +/** + * Unforgeable process-local proof that one complete received byte string matches + * one exact catalog row, signed head, and locally pinned deployment profile. + * + * This is a structural pre-admission capability only. It grants no RDF semantic, + * structured-root, author-signature, VM-finality, triple-store, catalog-authority, + * or activation authority. + */ +export interface VerifiedTransferredCatalogBundleV1 { + readonly [VERIFIED_TRANSFERRED_CATALOG_BUNDLE_BRAND_V1]: true; +} + +export interface VerifiedTransferredCatalogBundleSnapshotV1 { + readonly headObjectDigest: Digest32V1; + readonly headIssuer: EvmAddressV1; + readonly catalogScopeDigest: Digest32V1; + readonly catalogRowDigest: Digest32V1; + readonly transferIdentityDigest: Digest32V1; + readonly deployment: Readonly; + readonly transfer: Readonly; + readonly projectionByteLength: ByteLengthV1; + readonly sealByteLength: ByteLengthV1; + readonly projectionDigest: Digest32V1; + readonly blobDigest: Digest32V1; + readonly chunkTreeRoot: Digest32V1; + /** Structural seal/row binding only; it is not semantic admission. */ + readonly catalogSealBinding: VerifiedCatalogSealBindingV1; + /** Fresh caller-owned copy of the complete received bundle. */ + readonly bundleBytes: Uint8Array; +} + +export type TransferredCatalogBundleErrorCode = + | 'transferred-bundle-head' + | 'transferred-bundle-row' + | 'transferred-bundle-profile' + | 'transferred-bundle-input' + | 'transferred-bundle-length' + | 'transferred-bundle-codec' + | 'transferred-bundle-projection-digest' + | 'transferred-bundle-blob-digest' + | 'transferred-bundle-chunk-tree' + | 'transferred-bundle-seal' + | 'transferred-bundle-capability' + | 'transferred-bundle-binding'; + +export class TransferredCatalogBundleError extends Error { + constructor( + readonly code: TransferredCatalogBundleErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'TransferredCatalogBundleError'; + } +} + +interface TransferredCatalogBundleInputsV1 { + readonly signedHead: SignedAuthorCatalogHeadEnvelopeV1; + readonly row: AuthorCatalogRowV1; + readonly deployment: Readonly; + readonly signedHeadBytes: Uint8Array; + readonly rowBytes: Uint8Array; +} + +interface TransferredCatalogBundleStateV1 { + readonly signedHeadBytes: Uint8Array; + readonly rowBytes: Uint8Array; + readonly deployment: Readonly; + readonly snapshot: Omit; + readonly bundleBytes: Uint8Array; +} + +const VERIFIED_TRANSFERRED_CATALOG_BUNDLES_V1 = new WeakMap< + object, + TransferredCatalogBundleStateV1 +>(); +const UTF8 = new TextEncoder(); +const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(Uint8Array.prototype) as object; +const GET_TYPED_ARRAY_BUFFER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'buffer', +)!.get!; +const GET_TYPED_ARRAY_BYTE_LENGTH = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteLength', +)!.get!; +const GET_TYPED_ARRAY_TAG = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + Symbol.toStringTag, +)!.get!; + +/** + * Verify the complete structural transfer boundary for one catalog row. + * + * The received byte view is copied in full before the decoder returns any + * borrowed projection/seal views. The seal view is consumed only by + * {@link verifyCatalogSealBindingV1}, which is the sole PR #1780 path. + */ +export function verifyTransferredCatalogBundleV1( + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + receivedBundleBytes: Uint8Array, + deployment: CatalogSealDeploymentProfileV1, +): VerifiedTransferredCatalogBundleV1 { + const inputs = snapshotTransferredBundleInputs( + signedHead, + row, + deployment, + ); + const transfer = inputs.row.transfer; + // Snapshot before decode: no borrowed projection or seal view can outlive the + // complete independently owned received-byte snapshot. + const bundleBytes = snapshotCompleteBundleBytes(receivedBundleBytes); + const receivedByteLength = BigInt(bundleBytes.byteLength); + if (receivedByteLength !== BigInt(transfer.byteLength)) { + fail( + 'transferred-bundle-length', + 'complete received byte length differs from row.transfer.byteLength', + ); + } + + let decoded: ReturnType; + try { + decoded = decodeOpaqueKaBundleV1(bundleBytes); + } catch (cause) { + fail('transferred-bundle-codec', 'received bytes are not one strict opaque KA bundle', cause); + } + if ( + decoded.projectionDigest !== transfer.projectionDigest + || decoded.projectionDigest !== inputs.row.projectionDigest + ) { + fail( + 'transferred-bundle-projection-digest', + 'decoded projection digest differs from the descriptor or catalog row', + ); + } + if (decoded.blobDigest !== transfer.blobDigest) { + fail( + 'transferred-bundle-blob-digest', + 'complete bundle digest differs from row.transfer.blobDigest', + ); + } + + let chunkTreeRoot: Digest32V1; + try { + chunkTreeRoot = computeKaChunkTreeRootV1(bundleBytes); + } catch (cause) { + fail( + 'transferred-bundle-chunk-tree', + 'complete received bytes could not produce the canonical chunk tree', + cause, + ); + } + if (chunkTreeRoot !== transfer.chunkTreeRoot) { + fail( + 'transferred-bundle-chunk-tree', + 'recomputed complete chunk-tree root differs from the descriptor', + ); + } + + const scope = deriveAuthorCatalogScopeFromHeadV1(inputs.signedHead.payload); + let catalogSealBinding: VerifiedCatalogSealBindingV1; + try { + catalogSealBinding = verifyCatalogSealBindingV1( + scope, + inputs.row, + decoded.sealBytes, + inputs.deployment, + ); + } catch (cause) { + fail( + 'transferred-bundle-seal', + 'decoded seal bytes do not bind to the exact catalog row and deployment', + cause, + ); + } + const sealBindingSnapshot = readVerifiedCatalogSealBindingV1(catalogSealBinding); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const transferSnapshot = Object.freeze({ ...transfer }); + const deploymentSnapshot = inputs.deployment; + const snapshot = Object.freeze({ + headObjectDigest: inputs.signedHead.objectDigest as Digest32V1, + headIssuer: inputs.signedHead.issuer as EvmAddressV1, + catalogScopeDigest, + catalogRowDigest: computeAuthorCatalogRowDigestV1(catalogScopeDigest, inputs.row), + transferIdentityDigest: computeKaTransferIdentityDigestV1(transfer), + deployment: deploymentSnapshot, + transfer: transferSnapshot, + projectionByteLength: String(decoded.projectionBytes.byteLength) as ByteLengthV1, + sealByteLength: String(decoded.sealBytes.byteLength) as ByteLengthV1, + projectionDigest: decoded.projectionDigest, + blobDigest: decoded.blobDigest, + chunkTreeRoot, + catalogSealBinding, + }); + + // Defensive internal consistency check: the nested structural proof must refer + // to the same canonical scope/row values bound above. + if ( + sealBindingSnapshot.catalogScopeDigest !== snapshot.catalogScopeDigest + || sealBindingSnapshot.catalogRowDigest !== snapshot.catalogRowDigest + ) { + fail('transferred-bundle-seal', 'nested catalog seal binding changed row identity'); + } + + const capability = Object.freeze(Object.create(null)) as VerifiedTransferredCatalogBundleV1; + VERIFIED_TRANSFERRED_CATALOG_BUNDLES_V1.set(capability as object, Object.freeze({ + signedHeadBytes: inputs.signedHeadBytes, + rowBytes: inputs.rowBytes, + deployment: deploymentSnapshot, + snapshot, + bundleBytes, + })); + return capability; +} + +export function assertVerifiedTransferredCatalogBundleV1( + value: unknown, +): asserts value is VerifiedTransferredCatalogBundleV1 { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + fail( + 'transferred-bundle-capability', + 'transferred bundle proof was not minted by this verifier', + ); + } + if (!VERIFIED_TRANSFERRED_CATALOG_BUNDLES_V1.has(value as object)) { + fail( + 'transferred-bundle-capability', + 'transferred bundle proof was not minted by this verifier', + ); + } +} + +/** Require this capability to be consumed with the exact canonical inputs it bound. */ +export function assertVerifiedTransferredCatalogBundleForInputsV1( + value: unknown, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): asserts value is VerifiedTransferredCatalogBundleV1 { + assertVerifiedTransferredCatalogBundleV1(value); + const state = VERIFIED_TRANSFERRED_CATALOG_BUNDLES_V1.get(value as object)!; + let inputs: TransferredCatalogBundleInputsV1; + try { + inputs = snapshotTransferredBundleInputs(signedHead, row, deployment); + } catch (cause) { + fail( + 'transferred-bundle-binding', + 'supplied bundle-binding context is not canonical', + cause, + ); + } + if ( + !equalBytes(inputs.signedHeadBytes, state.signedHeadBytes) + || !equalBytes(inputs.rowBytes, state.rowBytes) + || !equalDeployment(inputs.deployment, state.deployment) + ) { + fail( + 'transferred-bundle-binding', + 'transferred bundle capability is not bound to the supplied head, row, and deployment', + ); + } +} + +/** + * Return immutable scalar state plus one fresh caller-owned copy of the complete + * bundle. Decode that copy if projection/seal byte views are needed. + */ +export function readVerifiedTransferredCatalogBundleV1( + value: unknown, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): VerifiedTransferredCatalogBundleSnapshotV1 { + assertVerifiedTransferredCatalogBundleForInputsV1( + value, + signedHead, + row, + deployment, + ); + const state = VERIFIED_TRANSFERRED_CATALOG_BUNDLES_V1.get(value as object)!; + return Object.freeze({ + ...state.snapshot, + bundleBytes: new Uint8Array(state.bundleBytes), + }); +} + +function snapshotTransferredBundleInputs( + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): TransferredCatalogBundleInputsV1 { + let signedHeadBytes: Uint8Array; + let headSnapshot: SignedAuthorCatalogHeadEnvelopeV1; + try { + signedHeadBytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(signedHead); + headSnapshot = parseCanonicalSignedAuthorCatalogHeadEnvelopeV1(signedHeadBytes); + } catch (cause) { + fail('transferred-bundle-head', 'signed catalog head is not canonical', cause); + } + let rowBytes: Uint8Array; + let rowSnapshot: AuthorCatalogRowV1; + try { + assertAuthorCatalogRowV1(row); + assertKaTransferDescriptorV1(row.transfer); + rowBytes = UTF8.encode(canonicalizeAuthorCatalogRowV1(row)); + rowSnapshot = parseCanonicalAuthorCatalogRowV1(rowBytes); + assertAuthorCatalogRowScopeBindingV1( + rowSnapshot, + deriveAuthorCatalogScopeFromHeadV1(headSnapshot.payload), + ); + } catch (cause) { + fail( + 'transferred-bundle-row', + 'catalog row or row.transfer is not structurally bound to the signed head', + cause, + ); + } + const deploymentSnapshot = snapshotDeploymentProfile(deployment); + return Object.freeze({ + signedHead: headSnapshot, + row: rowSnapshot, + deployment: deploymentSnapshot, + signedHeadBytes, + rowBytes, + }); +} + +function snapshotDeploymentProfile( + deployment: unknown, +): Readonly { + try { + if (!isPlainRecord(deployment)) throw new Error('profile must be a plain object'); + assertExactKeys( + deployment, + ['assertedAtChainId', 'assertedAtKav10Address', 'networkId'], + 'transferred bundle deployment profile', + ); + assertNetworkIdV1(deployment.networkId); + assertCanonicalChainId(deployment.assertedAtChainId, 'assertedAtChainId'); + assertCanonicalEvmAddress(deployment.assertedAtKav10Address, 'assertedAtKav10Address'); + return Object.freeze({ + networkId: deployment.networkId as NetworkIdV1, + assertedAtChainId: deployment.assertedAtChainId, + assertedAtKav10Address: deployment.assertedAtKav10Address, + }); + } catch (cause) { + fail('transferred-bundle-profile', 'deployment profile is not canonical', cause); + } +} + +function snapshotCompleteBundleBytes(value: unknown): Uint8Array { + let backingBuffer: ArrayBufferLike; + let byteLength: number; + let typedArrayTag: string | undefined; + try { + backingBuffer = Reflect.apply(GET_TYPED_ARRAY_BUFFER, value, []); + byteLength = Reflect.apply(GET_TYPED_ARRAY_BYTE_LENGTH, value, []); + typedArrayTag = Reflect.apply(GET_TYPED_ARRAY_TAG, value, []); + } catch (cause) { + fail('transferred-bundle-input', 'receivedBundleBytes must be a genuine Uint8Array', cause); + } + if (typedArrayTag !== 'Uint8Array') { + fail('transferred-bundle-input', 'receivedBundleBytes must be a Uint8Array'); + } + if (!(backingBuffer instanceof ArrayBuffer)) { + fail('transferred-bundle-input', 'receivedBundleBytes must not use shared backing memory'); + } + if ((backingBuffer as ArrayBuffer & { readonly resizable?: boolean }).resizable === true) { + fail('transferred-bundle-input', 'receivedBundleBytes must not use resizable backing memory'); + } + const boundedLength = BigInt(byteLength); + if ( + boundedLength < MIN_KA_TRANSFER_BYTES_V1 + || boundedLength > MAX_KA_TRANSFER_BYTES_V1 + ) { + fail( + 'transferred-bundle-input', + `receivedBundleBytes must contain ${MIN_KA_TRANSFER_BYTES_V1}-${MAX_KA_TRANSFER_BYTES_V1} bytes`, + ); + } + try { + // Normalize Buffer and subclasses to an ordinary, independently backed view. + return new Uint8Array(value as Uint8Array); + } catch (cause) { + fail('transferred-bundle-input', 'receivedBundleBytes could not be snapshotted safely', cause); + } +} + +function equalDeployment( + left: Readonly, + right: Readonly, +): boolean { + return left.networkId === right.networkId + && left.assertedAtChainId === right.assertedAtChainId + && left.assertedAtKav10Address === right.assertedAtKav10Address; +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + let difference = 0; + for (let index = 0; index < left.byteLength; index += 1) { + difference |= left[index] ^ right[index]; + } + return difference === 0; +} + +function fail( + code: TransferredCatalogBundleErrorCode, + message: string, + cause?: unknown, +): never { + throw new TransferredCatalogBundleError( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/core/test/transferred-catalog-bundle.test.ts b/packages/core/test/transferred-catalog-bundle.test.ts new file mode 100644 index 0000000000..87f260fad4 --- /dev/null +++ b/packages/core/test/transferred-catalog-bundle.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertAuthorCatalogRowV1, + type AuthorCatalogRowV1, +} from '../src/author-catalog-codec.js'; +import { + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + assertSignedAuthorCatalogHeadEnvelopeV1, + computeAuthorCatalogHeadObjectDigestV1, + type AuthorCatalogHeadV1, + type SignedAuthorCatalogHeadEnvelopeV1, +} from '../src/author-catalog-objects.js'; +import { + assertVerifiedCatalogSealBindingV1, + type CatalogSealDeploymentProfileV1, +} from '../src/catalog-seal-binding.js'; +import { + assertCanonicalGraphScopedAuthorSealV1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + type CanonicalGraphScopedAuthorSealV1, +} from '../src/canonical-graph-scoped-author-seal.js'; +import { encodeOpaqueKaBundleV1 } from '../src/ka-bundle-v1.js'; +import { computeKaChunkTreeRootV1 } from '../src/ka-chunk-tree.js'; +import type { UnsignedControlEnvelopeV1 } from '../src/sync-control-object.js'; +import { + TransferredCatalogBundleError, + assertVerifiedTransferredCatalogBundleForInputsV1, + assertVerifiedTransferredCatalogBundleV1, + readVerifiedTransferredCatalogBundleV1, + verifyTransferredCatalogBundleV1, + type TransferredCatalogBundleErrorCode, +} from '../src/transferred-catalog-bundle.js'; + +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const ISSUER = '0x5555555555555555555555555555555555555555'; +const KAV10 = '0x4444444444444444444444444444444444444444'; +const KA_ID = + '23158417847463239084714197001737581570653996933112267175388663934063917137927'; +const ZERO_DIGEST = `0x${'00'.repeat(32)}`; +const EIP191_SIGNATURE = `0x${'77'.repeat(65)}`; +const PROJECTION_BYTES = new TextEncoder().encode( + ' "opaque structural fixture" .\n', +); + +const PROFILE = { + networkId: 'otp:20430', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +} as CatalogSealDeploymentProfileV1; +const SEAL = validSeal({ + assertionMerkleRoot: `0x${'aa'.repeat(32)}`, + authorAddress: AUTHOR, + authorAttestationR: `0x${'11'.repeat(32)}`, + authorAttestationVS: `0x${'22'.repeat(32)}`, + authorSchemeVersion: '1', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: `did:dkg:otp:20430/${AUTHOR}/7`, + assertionVersion: '2', + publicTripleCount: '12977', + privateTripleCount: '0', + privateMerkleRoot: null, +}); +const SEAL_BYTES = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(SEAL); +const ENCODED = encodeOpaqueKaBundleV1(PROJECTION_BYTES, SEAL_BYTES); +const ROW = rowForBundle( + ENCODED.bundleBytes, + ENCODED.projectionDigest, + ENCODED.blobDigest, + computeCanonicalGraphScopedAuthorSealDigestV1(SEAL), +); +const HEAD = signedHead({ + networkId: 'otp:20430', + contextGraphId: 'a/b', + governanceChainId: '20430', + governanceContractAddress: '0x6666666666666666666666666666666666666666', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + catalogIssuerDelegationDigest: `0x${'77'.repeat(32)}`, + era: '0', + version: '1', + previousHeadDigest: null, + bucketCount: '1', + totalRows: '1', + directoryHeight: '0', + directoryRootDigest: `0x${'88'.repeat(32)}`, + issuedAt: '1700000000123', +}); + +describe('RFC-64 complete transferred catalog bundle binding', () => { + it('binds complete bytes to the exact row, signed head, deployment, tree, and sole seal path', () => { + const verified = verifyTransferredCatalogBundleV1( + HEAD, + ROW, + ENCODED.bundleBytes, + PROFILE, + ); + expect(Object.getPrototypeOf(verified)).toBeNull(); + expect(Object.isFrozen(verified)).toBe(true); + expect(Reflect.ownKeys(verified as object)).toEqual([]); + expect(() => assertVerifiedTransferredCatalogBundleV1(verified)).not.toThrow(); + expect(() => assertVerifiedTransferredCatalogBundleForInputsV1( + verified, + clone(HEAD), + clone(ROW), + clone(PROFILE), + )).not.toThrow(); + + const snapshot = readVerifiedTransferredCatalogBundleV1( + verified, + HEAD, + ROW, + PROFILE, + ); + expect(snapshot).toMatchObject({ + headObjectDigest: HEAD.objectDigest, + headIssuer: ISSUER, + transfer: ROW.transfer, + projectionByteLength: String(PROJECTION_BYTES.byteLength), + sealByteLength: String(SEAL_BYTES.byteLength), + projectionDigest: ENCODED.projectionDigest, + blobDigest: ENCODED.blobDigest, + chunkTreeRoot: ROW.transfer.chunkTreeRoot, + }); + expect(snapshot.bundleBytes).toEqual(ENCODED.bundleBytes); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.transfer)).toBe(true); + expect(Object.isFrozen(snapshot.deployment)).toBe(true); + expect(() => assertVerifiedCatalogSealBindingV1(snapshot.catalogSealBinding)) + .not.toThrow(); + expect('semanticAdmission' in snapshot).toBe(false); + expect('finality' in snapshot).toBe(false); + expect('storeAuthority' in snapshot).toBe(false); + }); + + it('snapshots the complete visible view before retaining decoder views', () => { + const original = ENCODED.bundleBytes.slice(); + const padded = Buffer.concat([ + Buffer.from([0xff]), + Buffer.from(original), + Buffer.from([0xee]), + ]); + const visible = padded.subarray(1, padded.byteLength - 1); + const verified = verifyTransferredCatalogBundleV1(HEAD, ROW, visible, PROFILE); + visible.fill(0); + + const first = readVerifiedTransferredCatalogBundleV1(verified, HEAD, ROW, PROFILE); + expect(first.bundleBytes.constructor).toBe(Uint8Array); + expect(Buffer.isBuffer(first.bundleBytes)).toBe(false); + expect(first.bundleBytes).toEqual(original); + first.bundleBytes.fill(0); + const second = readVerifiedTransferredCatalogBundleV1(verified, HEAD, ROW, PROFILE); + expect(second.bundleBytes).toEqual(original); + }); + + it('rejects forged capabilities, frozen clones, and JSON round trips', () => { + const verified = verifyTransferredCatalogBundleV1(HEAD, ROW, ENCODED.bundleBytes, PROFILE); + for (const forged of [ + Object.freeze({}), + Object.freeze({ ...verified as object }), + JSON.parse(JSON.stringify(verified)), + ]) { + expectFailure( + () => assertVerifiedTransferredCatalogBundleV1(forged), + 'transferred-bundle-capability', + ); + expectFailure( + () => readVerifiedTransferredCatalogBundleV1(forged, HEAD, ROW, PROFILE), + 'transferred-bundle-capability', + ); + } + }); + + it('rejects cross-head, cross-row, and cross-deployment consumption', () => { + const verified = verifyTransferredCatalogBundleV1(HEAD, ROW, ENCODED.bundleBytes, PROFILE); + const otherSignature = validatedSignedHead({ + ...HEAD, + signature: `0x${'99'.repeat(65)}`, + }); + const otherRow = validRow({ ...ROW, assertionCoordinate: 'other' }); + for (const [head, row, deployment] of [ + [otherSignature, ROW, PROFILE], + [HEAD, otherRow, PROFILE], + [HEAD, ROW, { ...PROFILE, assertedAtChainId: '20431' }], + ] as const) { + expectFailure( + () => assertVerifiedTransferredCatalogBundleForInputsV1( + verified, + head as SignedAuthorCatalogHeadEnvelopeV1, + row as AuthorCatalogRowV1, + deployment as CatalogSealDeploymentProfileV1, + ), + 'transferred-bundle-binding', + ); + } + }); + + it('requires a valid row.transfer and exact advertised/received length', () => { + expectFailure( + () => verifyTransferredCatalogBundleV1(HEAD, { + ...ROW, + transfer: { ...ROW.transfer, codec: 'other' }, + } as unknown as AuthorCatalogRowV1, ENCODED.bundleBytes, PROFILE), + 'transferred-bundle-row', + ); + const advertisedLonger = validRow({ + ...ROW, + transfer: { + ...ROW.transfer, + byteLength: String(ENCODED.bundleBytes.byteLength + 1), + }, + }); + expectFailure( + () => verifyTransferredCatalogBundleV1( + HEAD, + advertisedLonger, + ENCODED.bundleBytes, + PROFILE, + ), + 'transferred-bundle-length', + ); + }); + + it('strictly decodes the complete opaque frame before digest work', () => { + const malformed = ENCODED.bundleBytes.slice(); + malformed.fill(0xff, 0, 8); + const malformedRow = rowForBundle( + malformed, + ROW.projectionDigest, + ROW.transfer.blobDigest, + ROW.sealDigest, + ); + expectFailure( + () => verifyTransferredCatalogBundleV1(HEAD, malformedRow, malformed, PROFILE), + 'transferred-bundle-codec', + ); + }); + + it('rejects projection and whole-blob digest mismatches independently', () => { + const wrongProjection = validRow({ + ...ROW, + projectionDigest: ZERO_DIGEST, + transfer: { ...ROW.transfer, projectionDigest: ZERO_DIGEST }, + }); + expectFailure( + () => verifyTransferredCatalogBundleV1( + HEAD, + wrongProjection, + ENCODED.bundleBytes, + PROFILE, + ), + 'transferred-bundle-projection-digest', + ); + const wrongBlob = validRow({ + ...ROW, + transfer: { ...ROW.transfer, blobDigest: ZERO_DIGEST }, + }); + expectFailure( + () => verifyTransferredCatalogBundleV1( + HEAD, + wrongBlob, + ENCODED.bundleBytes, + PROFILE, + ), + 'transferred-bundle-blob-digest', + ); + }); + + it('recomputes the complete chunk-tree root and rejects a descriptor mismatch', () => { + const wrongTree = validRow({ + ...ROW, + transfer: { ...ROW.transfer, chunkTreeRoot: ZERO_DIGEST }, + }); + expectFailure( + () => verifyTransferredCatalogBundleV1( + HEAD, + wrongTree, + ENCODED.bundleBytes, + PROFILE, + ), + 'transferred-bundle-chunk-tree', + ); + }); + + it('routes decoded seal bytes through the catalog seal binding and fails closed', () => { + const otherSeal = validSeal({ ...SEAL, assertionVersion: '3' }); + const otherEncoded = encodeOpaqueKaBundleV1( + PROJECTION_BYTES, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1(otherSeal), + ); + const mismatchedSealRow = rowForBundle( + otherEncoded.bundleBytes, + otherEncoded.projectionDigest, + otherEncoded.blobDigest, + ROW.sealDigest, + ); + expectFailure( + () => verifyTransferredCatalogBundleV1( + HEAD, + mismatchedSealRow, + otherEncoded.bundleBytes, + PROFILE, + ), + 'transferred-bundle-seal', + ); + }); + + it('rejects shared backing memory and spoofed Uint8Array properties', () => { + expectFailure( + () => verifyTransferredCatalogBundleV1( + HEAD, + ROW, + new Uint8Array(new SharedArrayBuffer(ENCODED.bundleBytes.byteLength)), + PROFILE, + ), + 'transferred-bundle-input', + ); + const short = Buffer.alloc(15); + Object.defineProperties(short, { + buffer: { value: new ArrayBuffer(ENCODED.bundleBytes.byteLength) }, + byteLength: { value: ENCODED.bundleBytes.byteLength }, + }); + expectFailure( + () => verifyTransferredCatalogBundleV1(HEAD, ROW, short, PROFILE), + 'transferred-bundle-input', + ); + }); +}); + +function rowForBundle( + bundleBytes: Uint8Array, + projectionDigest: string, + blobDigest: string, + sealDigest: string, +): AuthorCatalogRowV1 { + const byteLength = BigInt(bundleBytes.byteLength); + return validRow({ + kaId: KA_ID, + assertionCoordinate: 'name λ', + assertionVersion: '2', + projectionId: 'cg-shared-v1', + projectionDigest, + sealDigest, + transfer: { + codec: 'dkg-ka-bundle-v1', + projectionId: 'cg-shared-v1', + projectionDigest, + byteLength: byteLength.toString(), + chunkSize: '262144', + chunkCount: (((byteLength - 1n) / 262_144n) + 1n).toString(), + blobDigest, + chunkTreeRoot: computeKaChunkTreeRootV1(bundleBytes), + }, + }); +} + +function signedHead(head: AuthorCatalogHeadV1): SignedAuthorCatalogHeadEnvelopeV1 { + const unsigned = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload: head, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; + return validatedSignedHead({ + ...unsigned, + objectDigest: computeAuthorCatalogHeadObjectDigestV1(unsigned), + signature: EIP191_SIGNATURE, + }); +} + +function validRow(value: unknown): AuthorCatalogRowV1 { + assertAuthorCatalogRowV1(value); + return value; +} + +function validSeal(value: unknown): CanonicalGraphScopedAuthorSealV1 { + assertCanonicalGraphScopedAuthorSealV1(value); + return value; +} + +function validatedSignedHead(value: unknown): SignedAuthorCatalogHeadEnvelopeV1 { + assertSignedAuthorCatalogHeadEnvelopeV1(value as SignedAuthorCatalogHeadEnvelopeV1); + return value as SignedAuthorCatalogHeadEnvelopeV1; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function expectFailure( + operation: () => unknown, + code: TransferredCatalogBundleErrorCode, +): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(TransferredCatalogBundleError); + expect((error as TransferredCatalogBundleError).code).toBe(code); + return; + } + throw new Error(`expected ${code}`); +} From 01eeb1a57195a5e8cae683076a06ec8d69acda97 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:03:33 +0200 Subject: [PATCH 045/292] fix(chain): separate signature validity from gateway availability --- .../src/control-object-signature-verifier.ts | 66 ++++++++++++++----- packages/chain/src/index.ts | 1 + ...rol-object-signature-verifier.unit.test.ts | 47 ++++++++++--- 3 files changed, 88 insertions(+), 26 deletions(-) diff --git a/packages/chain/src/control-object-signature-verifier.ts b/packages/chain/src/control-object-signature-verifier.ts index 709146e25d..895f99111a 100644 --- a/packages/chain/src/control-object-signature-verifier.ts +++ b/packages/chain/src/control-object-signature-verifier.ts @@ -19,6 +19,7 @@ export const EIP1271_CANONICAL_ABI_RETURN_V1 = `0x${EIP1271_MAGIC_VALUE_V1.slice(2)}${'00'.repeat(28)}` as const; export const CONTROL_EIP1271_GAS_LIMIT_V1 = 1_000_000n; export const CONTROL_EIP1271_MAX_RETURN_BYTES_V1 = 32; +export const CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1 = 64 * 1024; export const CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 = 4_000; export const CONTROL_EIP1271_MAX_ATTEMPTS_V1 = 2; export const CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1 = 10_000; @@ -45,6 +46,7 @@ export const CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1 = Object.freeze([ 'finalized-state-unavailable', 'rpc-unavailable', 'rpc-timeout', + 'concurrency-saturated', 'resource-limit', 'revert', 'no-code', @@ -80,6 +82,7 @@ export interface CurrentFinalizedEvmCallRequestV1 { readonly data: string; readonly gasLimit: bigint; readonly maxReturnBytes: number; + readonly maxRpcResponseBytes: number; readonly attemptTimeoutMs: number; readonly maxAttempts: number; readonly endpointAttemptPolicy: typeof CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1; @@ -117,6 +120,7 @@ export const CONTROL_SIGNATURE_VERIFICATION_ERROR_CODES_V1 = Object.freeze([ 'CONTROL_SIGNATURE_FINALIZED_STATE_UNAVAILABLE', 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', 'CONTROL_SIGNATURE_RPC_TIMEOUT', + 'CONTROL_SIGNATURE_CONCURRENCY_SATURATED', 'CONTROL_SIGNATURE_ABORTED', ] as const); @@ -252,6 +256,7 @@ export async function verifyControlEnvelopeIssuerSignatureV1( ]).toLowerCase(), gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, @@ -422,7 +427,12 @@ function validateFinalizedCallResult( input: unknown, expectedChainId: ChainIdV1, ): CurrentFinalizedEvmCallResultV1 { - let evidence: CurrentFinalizedEvmCallResultV1; + let anchor: Readonly<{ + chainId: ChainIdV1; + blockNumber: BlockNumberV1; + blockHash: Digest32V1; + returnData: unknown; + }>; try { if (!isPlainRecord(input)) throw new Error('result is not a plain record'); assertExactDataKeys(input, ['blockHash', 'blockNumber', 'chainId', 'returnData']); @@ -435,31 +445,39 @@ function validateFinalizedCallResult( assertCanonicalChainId(snapshot.chainId, 'current-finalized result chainId'); assertCanonicalDecimalU64(snapshot.blockNumber, 'current-finalized blockNumber'); assertCanonicalDigest(snapshot.blockHash, 'current-finalized blockHash'); - if ( - typeof snapshot.returnData !== 'string' - || !/^0x[0-9a-f]{64}$/.test(snapshot.returnData) - ) { - throw new Error('returnData is not exactly 32 canonical bytes'); - } - evidence = Object.freeze(snapshot) as CurrentFinalizedEvmCallResultV1; + anchor = Object.freeze(snapshot) as typeof anchor; } catch (cause) { fail( - 'CONTROL_SIGNATURE_EIP1271_INVALID', - 'invalid', - 'EIP-1271 current-finalized call returned malformed evidence or ABI data', - { cause, reason: 'malformed-return' }, + 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + 'retryable-unavailable', + 'Current-finalized gateway returned malformed anchor evidence', + { cause }, ); } - // This verifier-owned semantic check intentionally occurs after the hostile - // gateway result has been fully snapshotted and structurally classified. - if (evidence.chainId !== expectedChainId) { + if (anchor.chainId !== expectedChainId) { fail( 'CONTROL_SIGNATURE_CHAIN_MISMATCH', - 'invalid', + 'retryable-unavailable', 'Current-finalized gateway returned a different chain than the signed evidence', ); } - return evidence; + if ( + typeof anchor.returnData !== 'string' + || !/^0x[0-9a-f]{64}$/.test(anchor.returnData) + ) { + fail( + 'CONTROL_SIGNATURE_EIP1271_INVALID', + 'invalid', + 'EIP-1271 current-finalized call returned malformed raw ABI data', + { reason: 'malformed-return' }, + ); + } + return Object.freeze({ + chainId: anchor.chainId, + blockNumber: anchor.blockNumber, + blockHash: anchor.blockHash, + returnData: anchor.returnData, + }); } function mapFinalizedCallFailure(cause: unknown, signal: AbortSignal | undefined): never { @@ -480,7 +498,12 @@ function mapFinalizedCallFailure(cause: unknown, signal: AbortSignal | undefined case 'unsupported-chain': fail('CONTROL_SIGNATURE_CHAIN_UNSUPPORTED', 'unsupported', gatewayFailure.message, { cause }); case 'chain-mismatch': - fail('CONTROL_SIGNATURE_CHAIN_MISMATCH', 'invalid', gatewayFailure.message, { cause }); + fail( + 'CONTROL_SIGNATURE_CHAIN_MISMATCH', + 'retryable-unavailable', + gatewayFailure.message, + { cause }, + ); case 'finalized-state-unavailable': fail( 'CONTROL_SIGNATURE_FINALIZED_STATE_UNAVAILABLE', @@ -494,6 +517,13 @@ function mapFinalizedCallFailure(cause: unknown, signal: AbortSignal | undefined }); case 'rpc-timeout': fail('CONTROL_SIGNATURE_RPC_TIMEOUT', 'retryable-unavailable', gatewayFailure.message, { cause }); + case 'concurrency-saturated': + fail( + 'CONTROL_SIGNATURE_CONCURRENCY_SATURATED', + 'retryable-unavailable', + gatewayFailure.message, + { cause }, + ); case 'resource-limit': fail('CONTROL_SIGNATURE_EIP1271_RESOURCE_LIMIT', 'unsupported', gatewayFailure.message, { cause, diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 312183bbc5..2267e096b0 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -6,6 +6,7 @@ export { CONTROL_EIP1271_GAS_LIMIT_V1, CONTROL_EIP1271_MAX_ATTEMPTS_V1, CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, CONTROL_SIGNATURE_VERIFICATION_ERROR_CODES_V1, diff --git a/packages/chain/test/control-object-signature-verifier.unit.test.ts b/packages/chain/test/control-object-signature-verifier.unit.test.ts index 572490db1e..d3e297dd42 100644 --- a/packages/chain/test/control-object-signature-verifier.unit.test.ts +++ b/packages/chain/test/control-object-signature-verifier.unit.test.ts @@ -13,6 +13,7 @@ import { CONTROL_EIP1271_GAS_LIMIT_V1, CONTROL_EIP1271_MAX_ATTEMPTS_V1, CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, EIP1271_CANONICAL_ABI_RETURN_V1, @@ -160,6 +161,32 @@ describe('RFC-64 control-object issuer signature verifier', () => { expect(call).not.toHaveBeenCalled(); }); + it('rejects EIP-1271 evidence for a contract other than the issuer before RPC', async () => { + const call = vi.fn(async () => FINALIZED_OK); + const base = safeEnvelope(); + const unsigned: UnsignedControlEnvelopeV1 = { + objectType: base.objectType, + payload: base.payload, + signatureSuite: 'eip1271-current-finalized-v1', + issuer: base.issuer, + signatureEvidence: { + kind: 'eip1271-current-finalized', + chainId: '20430', + contractAddress: '0x5555555555555555555555555555555555555555', + }, + }; + await expect(verifyControlEnvelopeIssuerSignatureV1({ + ...unsigned, + objectDigest: base.objectDigest, + signature: base.signature, + }, { callEvmAtCurrentFinalized: call })) + .rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_ENVELOPE_INVALID', + disposition: 'invalid', + }); + expect(call).not.toHaveBeenCalled(); + }); + it('issues one exact bounded raw EIP-1271 call at current-finalized state', async () => { const call = vi.fn(async () => FINALIZED_OK); const envelope = safeEnvelope(); @@ -176,6 +203,7 @@ describe('RFC-64 control-object issuer signature verifier', () => { from: CONTROL_EIP1271_CALL_FROM_V1, gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, @@ -211,10 +239,13 @@ describe('RFC-64 control-object issuer signature verifier', () => { }); }); - it('fails closed on chain mismatch and hostile finalized-call results', async () => { + it('keeps local chain mismatch and malformed gateway anchors retryable', async () => { await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { callEvmAtCurrentFinalized: callReturning({ ...FINALIZED_OK, chainId: '1' }), - })).rejects.toMatchObject({ code: 'CONTROL_SIGNATURE_CHAIN_MISMATCH' }); + })).rejects.toMatchObject({ + code: 'CONTROL_SIGNATURE_CHAIN_MISMATCH', + disposition: 'retryable-unavailable', + }); const hostile = new Proxy({}, { ownKeys() { @@ -224,8 +255,8 @@ describe('RFC-64 control-object issuer signature verifier', () => { await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { callEvmAtCurrentFinalized: callReturning(hostile), })).rejects.toMatchObject({ - code: 'CONTROL_SIGNATURE_EIP1271_INVALID', - reason: 'malformed-return', + code: 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + disposition: 'retryable-unavailable', }); const injected = new ControlSignatureVerificationErrorV1( @@ -241,9 +272,8 @@ describe('RFC-64 control-object issuer signature verifier', () => { await expect(verifyControlEnvelopeIssuerSignatureV1(safeEnvelope(), { callEvmAtCurrentFinalized: callReturning(hostileVerifierError), })).rejects.toMatchObject({ - code: 'CONTROL_SIGNATURE_EIP1271_INVALID', - disposition: 'invalid', - reason: 'malformed-return', + code: 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', + disposition: 'retryable-unavailable', }); }); @@ -328,10 +358,11 @@ describe('RFC-64 control-object issuer signature verifier', () => { it.each([ ['unsupported-chain', 'CONTROL_SIGNATURE_CHAIN_UNSUPPORTED', 'unsupported', undefined], - ['chain-mismatch', 'CONTROL_SIGNATURE_CHAIN_MISMATCH', 'invalid', undefined], + ['chain-mismatch', 'CONTROL_SIGNATURE_CHAIN_MISMATCH', 'retryable-unavailable', undefined], ['finalized-state-unavailable', 'CONTROL_SIGNATURE_FINALIZED_STATE_UNAVAILABLE', 'retryable-unavailable', undefined], ['rpc-unavailable', 'CONTROL_SIGNATURE_RPC_UNAVAILABLE', 'retryable-unavailable', undefined], ['rpc-timeout', 'CONTROL_SIGNATURE_RPC_TIMEOUT', 'retryable-unavailable', undefined], + ['concurrency-saturated', 'CONTROL_SIGNATURE_CONCURRENCY_SATURATED', 'retryable-unavailable', undefined], ['resource-limit', 'CONTROL_SIGNATURE_EIP1271_RESOURCE_LIMIT', 'unsupported', undefined], ['revert', 'CONTROL_SIGNATURE_EIP1271_INVALID', 'invalid', 'revert'], ['no-code', 'CONTROL_SIGNATURE_EIP1271_INVALID', 'invalid', 'no-code'], From 725517de7b634aa3b3248c584e03388f952f3dcc Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:04:00 +0200 Subject: [PATCH 046/292] feat(chain): route current-finalized EVM calls --- .../chain/src/current-finalized-evm-call.ts | 326 ++++++++++++++++++ packages/chain/src/index.ts | 5 + .../current-finalized-evm-call.unit.test.ts | 290 ++++++++++++++++ 3 files changed, 621 insertions(+) create mode 100644 packages/chain/src/current-finalized-evm-call.ts create mode 100644 packages/chain/test/current-finalized-evm-call.unit.test.ts diff --git a/packages/chain/src/current-finalized-evm-call.ts b/packages/chain/src/current-finalized-evm-call.ts new file mode 100644 index 0000000000..e2c790b4fb --- /dev/null +++ b/packages/chain/src/current-finalized-evm-call.ts @@ -0,0 +1,326 @@ +import { + assertCanonicalChainId, + type ChainIdV1, +} from '@origintrail-official/dkg-core'; + +import { + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, + CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + CONTROL_EIP1271_GAS_LIMIT_V1, + CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, + CurrentFinalizedEvmCallErrorV1, + type CurrentFinalizedEvmCallRequestV1, + type CurrentFinalizedEvmCallResultV1, + type CurrentFinalizedEvmCallV1, +} from './control-object-signature-verifier.js'; + +const REQUEST_KEYS = Object.freeze([ + 'attemptTimeoutMs', + 'ccipReadEnabled', + 'chainId', + 'data', + 'endpointAttemptPolicy', + 'from', + 'gasLimit', + 'maxAttempts', + 'maxConcurrentCallsPerChain', + 'maxReturnBytes', + 'signal', + 'to', + 'totalDeadlineMs', +] as const); +const CANONICAL_NONZERO_EVM_ADDRESS = /^0x(?!0{40}$)[0-9a-f]{40}$/; + +/** + * Per-chain trusted-local adapter seam. A later transport implementation owns + * configured endpoint selection and the exact current-finalized block lookup. + */ +export interface CurrentFinalizedEvmChainAdapterV1 { + (request: CurrentFinalizedEvmCallRequestV1): Promise; +} + +export interface CurrentFinalizedEvmChainAdapterRegistrationV1 { + readonly chainId: ChainIdV1; + readonly adapter: CurrentFinalizedEvmChainAdapterV1; +} + +/** + * Build an immutable, non-queueing current-finalized call router. + * + * Registrations are snapshotted once. Peer-controlled data can select only a + * canonical chain ID already present in this local registry; it can never + * supply an RPC endpoint or block selector. + */ +export function createCurrentFinalizedEvmCallRouterV1( + registrations: readonly CurrentFinalizedEvmChainAdapterRegistrationV1[], +): CurrentFinalizedEvmCallV1 { + const adapters = snapshotAdapterRegistry(registrations); + const inFlightByChain = new Map(); + for (const chainId of adapters.keys()) inFlightByChain.set(chainId, 0); + + const route: CurrentFinalizedEvmCallV1 = async (input) => { + const request = snapshotAndValidateRequest(input); + const adapter = adapters.get(request.chainId); + if (adapter === undefined) { + throw new CurrentFinalizedEvmCallErrorV1( + 'unsupported-chain', + `No current-finalized EVM adapter is configured for chain ${request.chainId}`, + ); + } + + const active = inFlightByChain.get(request.chainId) ?? 0; + if (active >= CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1) { + throw new CurrentFinalizedEvmCallErrorV1( + 'resource-limit', + `Chain ${request.chainId} already has ${active} current-finalized calls in flight`, + ); + } + inFlightByChain.set(request.chainId, active + 1); + + // Do not race this operation with request.signal. The verifier may stop + // awaiting after caller cancellation, but this permit remains held until + // the actual adapter operation settles (including adapters that ignore + // abort), so abandoned work cannot evade the four-call ceiling. + const operation = Promise.resolve() + .then(() => adapter(request)) + .catch((cause: unknown) => { + throw snapshotAdapterFailure(cause); + }); + + try { + return await operation; + } finally { + const remaining = (inFlightByChain.get(request.chainId) ?? 1) - 1; + inFlightByChain.set(request.chainId, Math.max(0, remaining)); + } + }; + + return Object.freeze(route); +} + +function snapshotAdapterRegistry( + input: unknown, +): ReadonlyMap { + const registrations = snapshotDenseArray(input); + const adapters = new Map(); + + for (const registration of registrations) { + const snapshot = snapshotRegistration(registration); + if (adapters.has(snapshot.chainId)) { + throw new TypeError(`Duplicate current-finalized adapter for chain ${snapshot.chainId}`); + } + adapters.set(snapshot.chainId, snapshot.adapter); + } + return adapters; +} + +function snapshotDenseArray(input: unknown): readonly unknown[] { + try { + if (!Array.isArray(input)) throw new Error('not an array'); + const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); + if ( + lengthDescriptor === undefined + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + ) { + throw new Error('invalid array length'); + } + + const length = lengthDescriptor.value; + const ownKeys = Reflect.ownKeys(input); + if ( + ownKeys.length !== length + 1 + || ownKeys.some((key) => ( + typeof key !== 'string' + || (key !== 'length' && !isCanonicalArrayIndex(key, length)) + )) + ) { + throw new Error('registrations must be a dense ordinary array'); + } + + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new Error('registration entry must be an enumerable data property'); + } + snapshot.push(descriptor.value); + } + return Object.freeze(snapshot); + } catch { + throw new TypeError('Current-finalized adapter registrations must be a dense data-only array'); + } +} + +function isCanonicalArrayIndex(key: string, length: number): boolean { + if (!/^(?:0|[1-9][0-9]*)$/.test(key)) return false; + const index = Number(key); + return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key; +} + +function snapshotRegistration(input: unknown): Readonly { + let chainId: unknown; + let adapter: unknown; + try { + const record = snapshotExactDataRecord(input, ['adapter', 'chainId']); + chainId = record.chainId; + adapter = record.adapter; + } catch { + throw new TypeError('Current-finalized adapter registration must be a plain data-only record'); + } + + try { + assertCanonicalChainId(chainId, 'current-finalized adapter chainId'); + } catch { + throw new TypeError('Current-finalized adapter chainId must be canonical decimal u256'); + } + if (typeof adapter !== 'function') { + throw new TypeError(`Current-finalized adapter for chain ${chainId} must be callable`); + } + return Object.freeze({ + chainId, + adapter: adapter as CurrentFinalizedEvmChainAdapterV1, + }); +} + +function snapshotAndValidateRequest(input: unknown): CurrentFinalizedEvmCallRequestV1 { + try { + const record = snapshotExactDataRecord(input, REQUEST_KEYS); + assertCanonicalChainId(record.chainId, 'current-finalized request chainId'); + if ( + typeof record.to !== 'string' + || !CANONICAL_NONZERO_EVM_ADDRESS.test(record.to) + ) { + throw new Error('to is not a canonical nonzero EVM address'); + } + if (record.from !== CONTROL_EIP1271_CALL_FROM_V1) throw new Error('wrong from'); + if ( + typeof record.data !== 'string' + || !/^0x(?:[0-9a-f]{2})+$/.test(record.data) + ) { + throw new Error('call data is not canonical non-empty lowercase bytes'); + } + if (record.gasLimit !== CONTROL_EIP1271_GAS_LIMIT_V1) throw new Error('wrong gas limit'); + if (record.maxReturnBytes !== CONTROL_EIP1271_MAX_RETURN_BYTES_V1) { + throw new Error('wrong return cap'); + } + if (record.attemptTimeoutMs !== CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1) { + throw new Error('wrong attempt timeout'); + } + if (record.maxAttempts !== CONTROL_EIP1271_MAX_ATTEMPTS_V1) { + throw new Error('wrong attempt count'); + } + if (record.endpointAttemptPolicy !== CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1) { + throw new Error('wrong endpoint policy'); + } + if ( + record.maxConcurrentCallsPerChain + !== CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 + ) { + throw new Error('wrong concurrency ceiling'); + } + if (record.totalDeadlineMs !== CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1) { + throw new Error('wrong total deadline'); + } + if (record.ccipReadEnabled !== false) throw new Error('CCIP Read must be disabled'); + if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); + + return Object.freeze({ + chainId: record.chainId, + to: record.to, + from: record.from, + data: record.data, + gasLimit: record.gasLimit, + maxReturnBytes: record.maxReturnBytes, + attemptTimeoutMs: record.attemptTimeoutMs, + maxAttempts: record.maxAttempts, + endpointAttemptPolicy: record.endpointAttemptPolicy, + maxConcurrentCallsPerChain: record.maxConcurrentCallsPerChain, + totalDeadlineMs: record.totalDeadlineMs, + ccipReadEnabled: record.ccipReadEnabled, + signal: record.signal, + }) as CurrentFinalizedEvmCallRequestV1; + } catch { + throw new CurrentFinalizedEvmCallErrorV1( + 'rpc-unavailable', + 'Current-finalized EVM call request failed the fixed local profile', + ); + } +} + +function snapshotExactDataRecord( + input: unknown, + expectedKeys: readonly string[], +): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('not a record'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) throw new Error('not plain'); + const actualKeys = Reflect.ownKeys(input); + if ( + actualKeys.some((key) => typeof key !== 'string') + || actualKeys.length !== expectedKeys.length + || (actualKeys as string[]).sort().some((key, index) => key !== expectedKeys[index]) + ) { + throw new Error('unknown or missing fields'); + } + + const snapshot: Record = Object.create(null) as Record; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new Error('fields must be enumerable data properties'); + } + snapshot[key] = descriptor.value; + } + return snapshot; +} + +function isAbortSignal(value: unknown): value is AbortSignal { + if (value === null || typeof value !== 'object') return false; + try { + const abortedGetter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + if (abortedGetter === undefined) return false; + abortedGetter.call(value); + return true; + } catch { + return false; + } +} + +function snapshotAdapterFailure(cause: unknown): CurrentFinalizedEvmCallErrorV1 { + try { + if (cause instanceof CurrentFinalizedEvmCallErrorV1) { + const code = cause.code; + const message = cause.message; + if ( + CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1.includes(code) + && typeof message === 'string' + ) { + return new CurrentFinalizedEvmCallErrorV1(code, message); + } + } + } catch { + // Hostile error objects cannot select a verifier disposition. + } + return new CurrentFinalizedEvmCallErrorV1( + 'rpc-unavailable', + 'Current-finalized EVM chain adapter failed closed', + ); +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index cae6e23fcf..be5ee11871 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -25,6 +25,11 @@ export { type VerifiedControlEnvelopeIssuerSignatureV1, type VerifyControlEnvelopeIssuerSignatureOptionsV1, } from './control-object-signature-verifier.js'; +export { + createCurrentFinalizedEvmCallRouterV1, + type CurrentFinalizedEvmChainAdapterRegistrationV1, + type CurrentFinalizedEvmChainAdapterV1, +} from './current-finalized-evm-call.js'; export { resolveQuotedPublisherCandidatePricing, resolveLegacyPublisherCandidatePricing, diff --git a/packages/chain/test/current-finalized-evm-call.unit.test.ts b/packages/chain/test/current-finalized-evm-call.unit.test.ts new file mode 100644 index 0000000000..51ff656b97 --- /dev/null +++ b/packages/chain/test/current-finalized-evm-call.unit.test.ts @@ -0,0 +1,290 @@ +import { + type ChainIdV1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, + CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + CONTROL_EIP1271_GAS_LIMIT_V1, + CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + CurrentFinalizedEvmCallErrorV1, + type CurrentFinalizedEvmCallRequestV1, + type CurrentFinalizedEvmCallResultV1, +} from '../src/control-object-signature-verifier.js'; +import { + createCurrentFinalizedEvmCallRouterV1, + type CurrentFinalizedEvmChainAdapterRegistrationV1, + type CurrentFinalizedEvmChainAdapterV1, +} from '../src/current-finalized-evm-call.js'; + +const CHAIN_A = '20430' as ChainIdV1; +const CHAIN_B = '100' as ChainIdV1; +const TO = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const BLOCK_HASH = `0x${'22'.repeat(32)}`; +const RESULT_A = Object.freeze({ + chainId: CHAIN_A, + blockNumber: '123', + blockHash: BLOCK_HASH, + returnData: `0x${'00'.repeat(32)}`, +} as CurrentFinalizedEvmCallResultV1); + +describe('RFC-64 current-finalized EVM call router', () => { + it('snapshots an immutable local registry and routes a frozen exact request', async () => { + const firstAdapter = vi.fn(async () => RESULT_A); + const replacementAdapter = vi.fn(async () => RESULT_A); + const registration = { + chainId: CHAIN_A, + adapter: firstAdapter, + } satisfies CurrentFinalizedEvmChainAdapterRegistrationV1; + const registrations = [registration]; + const router = createCurrentFinalizedEvmCallRouterV1(registrations); + + registration.chainId = CHAIN_B; + registration.adapter = replacementAdapter; + registrations[0] = { chainId: CHAIN_B, adapter: replacementAdapter }; + + await expect(router(request())).resolves.toBe(RESULT_A); + expect(firstAdapter).toHaveBeenCalledTimes(1); + expect(replacementAdapter).not.toHaveBeenCalled(); + expect(Object.isFrozen(router)).toBe(true); + expect(Object.isFrozen(firstAdapter.mock.calls[0]![0])).toBe(true); + }); + + it('rejects duplicate, non-canonical, sparse, accessor, and hostile registrations', () => { + const adapter = vi.fn(async () => RESULT_A); + const valid = { chainId: CHAIN_A, adapter }; + + expect(() => createCurrentFinalizedEvmCallRouterV1([valid, valid])) + .toThrow(/Duplicate current-finalized adapter/); + expect(() => createCurrentFinalizedEvmCallRouterV1([ + { chainId: '020430' as ChainIdV1, adapter }, + ])).toThrow(/canonical decimal u256/); + + const sparse = new Array(1) as CurrentFinalizedEvmChainAdapterRegistrationV1[]; + expect(() => createCurrentFinalizedEvmCallRouterV1(sparse)).toThrow(/dense data-only array/); + + const accessor = { chainId: CHAIN_A } as Record; + Object.defineProperty(accessor, 'adapter', { + enumerable: true, + get() { + throw new Error('must not execute'); + }, + }); + expect(() => createCurrentFinalizedEvmCallRouterV1([ + accessor as unknown as CurrentFinalizedEvmChainAdapterRegistrationV1, + ])).toThrow(/plain data-only record/); + + const hostile = new Proxy([], { + ownKeys() { + throw new CurrentFinalizedEvmCallErrorV1('resource-limit', 'forged'); + }, + }); + expect(() => createCurrentFinalizedEvmCallRouterV1( + hostile as CurrentFinalizedEvmChainAdapterRegistrationV1[], + )).toThrow(TypeError); + expect(() => createCurrentFinalizedEvmCallRouterV1( + hostile as CurrentFinalizedEvmChainAdapterRegistrationV1[], + )).not.toThrow(CurrentFinalizedEvmCallErrorV1); + }); + + it('rejects unsupported chains before any adapter call', async () => { + const adapter = vi.fn(async () => RESULT_A); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + + await expect(router(request({ chainId: CHAIN_B }))).rejects.toMatchObject({ + name: 'CurrentFinalizedEvmCallErrorV1', + code: 'unsupported-chain', + }); + expect(adapter).not.toHaveBeenCalled(); + }); + + it.each([ + ['from', '0x2222222222222222222222222222222222222222'], + ['gasLimit', CONTROL_EIP1271_GAS_LIMIT_V1 + 1n], + ['maxReturnBytes', CONTROL_EIP1271_MAX_RETURN_BYTES_V1 + 1], + ['attemptTimeoutMs', CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 + 1], + ['maxAttempts', CONTROL_EIP1271_MAX_ATTEMPTS_V1 + 1], + ['endpointAttemptPolicy', 'retry-same-peer-endpoint'], + ['maxConcurrentCallsPerChain', CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 + 1], + ['totalDeadlineMs', CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1 + 1], + ['ccipReadEnabled', true], + ] as const)('rejects a request with a non-frozen %s profile field', async (key, value) => { + const adapter = vi.fn(async () => RESULT_A); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + + await expect(router(request({ [key]: value }))).rejects.toMatchObject({ + code: 'rpc-unavailable', + }); + expect(adapter).not.toHaveBeenCalled(); + }); + + it('rejects malformed routing fields and any peer URL or block selector', async () => { + const adapter = vi.fn(async () => RESULT_A); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + + for (const malformed of [ + request({ chainId: '020430' as ChainIdV1 }), + request({ to: '0xABC' as EvmAddressV1 }), + request({ data: '0xABC' }), + request({ signal: {} as AbortSignal }), + { ...request(), rpcUrl: 'https://peer.invalid' }, + { ...request(), blockTag: 'latest' }, + ]) { + await expect(router(malformed as CurrentFinalizedEvmCallRequestV1)) + .rejects.toMatchObject({ code: 'rpc-unavailable' }); + } + expect(adapter).not.toHaveBeenCalled(); + }); + + it('admits four calls per chain and rejects the fifth immediately without queueing', async () => { + const pending = Array.from({ length: 5 }, () => deferred()); + let callIndex = 0; + const adapter = vi.fn(async () => pending[callIndex++]!.promise); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + + const active = Array.from({ length: 4 }, () => router(request())); + await Promise.resolve(); + expect(adapter).toHaveBeenCalledTimes(4); + await expect(router(request())).rejects.toMatchObject({ code: 'resource-limit' }); + expect(adapter).toHaveBeenCalledTimes(4); + + pending[0]!.resolve(RESULT_A); + await active[0]; + const admitted = router(request()); + await Promise.resolve(); + expect(adapter).toHaveBeenCalledTimes(5); + + for (let index = 1; index < 5; index += 1) pending[index]!.resolve(RESULT_A); + await Promise.all([...active.slice(1), admitted]); + }); + + it('holds a permit after caller abort until the underlying adapter promise settles', async () => { + const pending = Array.from({ length: 5 }, () => deferred()); + let callIndex = 0; + const adapter = vi.fn(async () => pending[callIndex++]!.promise); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + const controller = new AbortController(); + const active = [ + router(request({ signal: controller.signal })), + router(request()), + router(request()), + router(request()), + ]; + await Promise.resolve(); + + controller.abort(); + await expect(router(request())).rejects.toMatchObject({ code: 'resource-limit' }); + expect(adapter).toHaveBeenCalledTimes(4); + + pending[0]!.resolve(RESULT_A); + await active[0]; + const admitted = router(request()); + await Promise.resolve(); + expect(adapter).toHaveBeenCalledTimes(5); + + for (let index = 1; index < 5; index += 1) pending[index]!.resolve(RESULT_A); + await Promise.all([...active.slice(1), admitted]); + }); + + it('maintains independent four-call ceilings for independent chains', async () => { + const pendingA = Array.from({ length: 4 }, () => deferred()); + const pendingB = Array.from({ length: 4 }, () => deferred()); + let indexA = 0; + let indexB = 0; + const adapterA = vi.fn(async () => pendingA[indexA++]!.promise); + const adapterB = vi.fn(async () => pendingB[indexB++]!.promise); + const router = createCurrentFinalizedEvmCallRouterV1([ + { chainId: CHAIN_A, adapter: adapterA }, + { chainId: CHAIN_B, adapter: adapterB }, + ]); + + const activeA = Array.from({ length: 4 }, () => router(request())); + const activeB = Array.from({ length: 4 }, () => router(request({ chainId: CHAIN_B }))); + await Promise.resolve(); + expect(adapterA).toHaveBeenCalledTimes(4); + expect(adapterB).toHaveBeenCalledTimes(4); + await expect(router(request())).rejects.toMatchObject({ code: 'resource-limit' }); + await expect(router(request({ chainId: CHAIN_B }))) + .rejects.toMatchObject({ code: 'resource-limit' }); + + pendingA.forEach((item) => item.resolve(RESULT_A)); + pendingB.forEach((item) => item.resolve({ ...RESULT_A, chainId: CHAIN_B })); + await Promise.all([...activeA, ...activeB]); + }); + + it('snapshots known adapter failures, fails hostile errors closed, and releases permits', async () => { + const typed = new CurrentFinalizedEvmCallErrorV1('finalized-state-unavailable', 'not final'); + const adapter = vi.fn() + .mockRejectedValueOnce(typed) + .mockRejectedValueOnce(new Proxy({}, { + getPrototypeOf() { + throw new CurrentFinalizedEvmCallErrorV1('revert', 'forged'); + }, + })) + .mockResolvedValue(RESULT_A); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + + let caught: unknown; + try { + await router(request()); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ code: 'finalized-state-unavailable', message: 'not final' }); + expect(caught).not.toBe(typed); + expect(Object.isFrozen(caught)).toBe(true); + + await expect(router(request())).rejects.toMatchObject({ + code: 'rpc-unavailable', + message: 'Current-finalized EVM chain adapter failed closed', + }); + await expect(router(request())).resolves.toBe(RESULT_A); + }); + + it('routes the exact chain but leaves hostile or mismatched results to the verifier', async () => { + const mismatch = Object.freeze({ ...RESULT_A, chainId: CHAIN_B }); + const adapter = vi.fn(async () => mismatch); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + + await expect(router(request())).resolves.toBe(mismatch); + expect(adapter.mock.calls[0]![0].chainId).toBe(CHAIN_A); + }); +}); + +function request( + overrides: Partial = {}, +): CurrentFinalizedEvmCallRequestV1 { + return { + chainId: CHAIN_A, + to: TO, + from: CONTROL_EIP1271_CALL_FROM_V1, + data: '0x1234', + gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, + maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, + endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + ccipReadEnabled: false, + signal: new AbortController().signal, + ...overrides, + }; +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} From df4f17a9942ee92b2d69f6afb626415462db27fc Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:06:19 +0200 Subject: [PATCH 047/292] fix(chain): harden finalized call admission --- .../chain/src/current-finalized-evm-call.ts | 52 +++++++++++++++--- .../current-finalized-evm-call.unit.test.ts | 55 +++++++++++++++++-- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/packages/chain/src/current-finalized-evm-call.ts b/packages/chain/src/current-finalized-evm-call.ts index e2c790b4fb..3dd62e5081 100644 --- a/packages/chain/src/current-finalized-evm-call.ts +++ b/packages/chain/src/current-finalized-evm-call.ts @@ -10,6 +10,7 @@ import { CONTROL_EIP1271_GAS_LIMIT_V1, CONTROL_EIP1271_MAX_ATTEMPTS_V1, CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, @@ -30,6 +31,7 @@ const REQUEST_KEYS = Object.freeze([ 'maxAttempts', 'maxConcurrentCallsPerChain', 'maxReturnBytes', + 'maxRpcResponseBytes', 'signal', 'to', 'totalDeadlineMs', @@ -76,7 +78,7 @@ export function createCurrentFinalizedEvmCallRouterV1( const active = inFlightByChain.get(request.chainId) ?? 0; if (active >= CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1) { throw new CurrentFinalizedEvmCallErrorV1( - 'resource-limit', + 'concurrency-saturated', `Chain ${request.chainId} already has ${active} current-finalized calls in flight`, ); } @@ -205,16 +207,15 @@ function snapshotAndValidateRequest(input: unknown): CurrentFinalizedEvmCallRequ throw new Error('to is not a canonical nonzero EVM address'); } if (record.from !== CONTROL_EIP1271_CALL_FROM_V1) throw new Error('wrong from'); - if ( - typeof record.data !== 'string' - || !/^0x(?:[0-9a-f]{2})+$/.test(record.data) - ) { - throw new Error('call data is not canonical non-empty lowercase bytes'); - } + if (typeof record.data !== 'string') throw new Error('call data is not a string'); + assertCanonicalEip1271CallData(record.data); if (record.gasLimit !== CONTROL_EIP1271_GAS_LIMIT_V1) throw new Error('wrong gas limit'); if (record.maxReturnBytes !== CONTROL_EIP1271_MAX_RETURN_BYTES_V1) { throw new Error('wrong return cap'); } + if (record.maxRpcResponseBytes !== CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1) { + throw new Error('wrong RPC response cap'); + } if (record.attemptTimeoutMs !== CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1) { throw new Error('wrong attempt timeout'); } @@ -243,6 +244,7 @@ function snapshotAndValidateRequest(input: unknown): CurrentFinalizedEvmCallRequ data: record.data, gasLimit: record.gasLimit, maxReturnBytes: record.maxReturnBytes, + maxRpcResponseBytes: record.maxRpcResponseBytes, attemptTimeoutMs: record.attemptTimeoutMs, maxAttempts: record.maxAttempts, endpointAttemptPolicy: record.endpointAttemptPolicy, @@ -259,6 +261,42 @@ function snapshotAndValidateRequest(input: unknown): CurrentFinalizedEvmCallRequ } } +function assertCanonicalEip1271CallData(data: string): void { + if (!/^0x[0-9a-f]+$/.test(data)) { + throw new Error('call data is not canonical lowercase hex'); + } + const bytes = data.slice(2); + const selectorLength = 8; + const wordLength = 64; + const fixedLength = selectorLength + (wordLength * 3); + if ( + bytes.slice(0, selectorLength) !== '1626ba7e' + || bytes.length < fixedLength + || bytes.slice(selectorLength + wordLength, selectorLength + (wordLength * 2)) + !== `${'0'.repeat(62)}40` + ) { + throw new Error('call data is not canonical isValidSignature(bytes32,bytes) ABI'); + } + + const signatureLengthWord = bytes.slice( + selectorLength + (wordLength * 2), + fixedLength, + ); + const signatureLength = BigInt(`0x${signatureLengthWord}`); + if (signatureLength < 1n || signatureLength > 4096n) { + throw new Error('EIP-1271 signature length is outside 1..4096 bytes'); + } + const paddedSignatureHexLength = Math.ceil(Number(signatureLength) / 32) * wordLength; + const tail = bytes.slice(fixedLength); + if (tail.length !== paddedSignatureHexLength) { + throw new Error('EIP-1271 signature tail has noncanonical length'); + } + const signatureHexLength = Number(signatureLength) * 2; + if (!/^0*$/.test(tail.slice(signatureHexLength))) { + throw new Error('EIP-1271 signature tail has nonzero ABI padding'); + } +} + function snapshotExactDataRecord( input: unknown, expectedKeys: readonly string[], diff --git a/packages/chain/test/current-finalized-evm-call.unit.test.ts b/packages/chain/test/current-finalized-evm-call.unit.test.ts index 51ff656b97..5da0a0e10b 100644 --- a/packages/chain/test/current-finalized-evm-call.unit.test.ts +++ b/packages/chain/test/current-finalized-evm-call.unit.test.ts @@ -11,6 +11,7 @@ import { CONTROL_EIP1271_GAS_LIMIT_V1, CONTROL_EIP1271_MAX_ATTEMPTS_V1, CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, CurrentFinalizedEvmCallErrorV1, @@ -27,6 +28,8 @@ const CHAIN_A = '20430' as ChainIdV1; const CHAIN_B = '100' as ChainIdV1; const TO = '0x1111111111111111111111111111111111111111' as EvmAddressV1; const BLOCK_HASH = `0x${'22'.repeat(32)}`; +const OBJECT_DIGEST = `${'33'.repeat(32)}`; +const CANONICAL_CALL_DATA = `0x1626ba7e${OBJECT_DIGEST}${'0'.repeat(62)}40${'0'.repeat(63)}1aa${'0'.repeat(62)}`; const RESULT_A = Object.freeze({ chainId: CHAIN_A, blockNumber: '123', @@ -56,6 +59,21 @@ describe('RFC-64 current-finalized EVM call router', () => { expect(Object.isFrozen(firstAdapter.mock.calls[0]![0])).toBe(true); }); + it('snapshots request data before the deferred adapter invocation', async () => { + const adapter = vi.fn(async () => RESULT_A); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + const mutable = request() as { -readonly [K in keyof CurrentFinalizedEvmCallRequestV1]: CurrentFinalizedEvmCallRequestV1[K] }; + + const result = router(mutable); + mutable.chainId = CHAIN_B; + mutable.data = `0x${'00'.repeat(100)}`; + await expect(result).resolves.toBe(RESULT_A); + expect(adapter.mock.calls[0]![0]).toMatchObject({ + chainId: CHAIN_A, + data: CANONICAL_CALL_DATA, + }); + }); + it('rejects duplicate, non-canonical, sparse, accessor, and hostile registrations', () => { const adapter = vi.fn(async () => RESULT_A); const valid = { chainId: CHAIN_A, adapter }; @@ -108,6 +126,7 @@ describe('RFC-64 current-finalized EVM call router', () => { ['from', '0x2222222222222222222222222222222222222222'], ['gasLimit', CONTROL_EIP1271_GAS_LIMIT_V1 + 1n], ['maxReturnBytes', CONTROL_EIP1271_MAX_RETURN_BYTES_V1 + 1], + ['maxRpcResponseBytes', CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1 + 1], ['attemptTimeoutMs', CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 + 1], ['maxAttempts', CONTROL_EIP1271_MAX_ATTEMPTS_V1 + 1], ['endpointAttemptPolicy', 'retry-same-peer-endpoint'], @@ -132,6 +151,10 @@ describe('RFC-64 current-finalized EVM call router', () => { request({ chainId: '020430' as ChainIdV1 }), request({ to: '0xABC' as EvmAddressV1 }), request({ data: '0xABC' }), + request({ data: '0x1234' }), + request({ data: CANONICAL_CALL_DATA.replace('1626ba7e', 'ffffffff') }), + request({ data: `${CANONICAL_CALL_DATA}00` }), + request({ data: `${CANONICAL_CALL_DATA.slice(0, -2)}01` }), request({ signal: {} as AbortSignal }), { ...request(), rpcUrl: 'https://peer.invalid' }, { ...request(), blockTag: 'latest' }, @@ -139,6 +162,27 @@ describe('RFC-64 current-finalized EVM call router', () => { await expect(router(malformed as CurrentFinalizedEvmCallRequestV1)) .rejects.toMatchObject({ code: 'rpc-unavailable' }); } + + const accessor = { ...request() } as Record; + Object.defineProperty(accessor, 'data', { + enumerable: true, + get() { + throw new Error('must not execute'); + }, + }); + await expect(router(accessor as unknown as CurrentFinalizedEvmCallRequestV1)) + .rejects.toMatchObject({ code: 'rpc-unavailable' }); + + const symbol = { ...request(), [Symbol('peer-url')]: 'https://peer.invalid' }; + await expect(router(symbol as CurrentFinalizedEvmCallRequestV1)) + .rejects.toMatchObject({ code: 'rpc-unavailable' }); + + const hostile = new Proxy(request(), { + ownKeys() { + throw new CurrentFinalizedEvmCallErrorV1('revert', 'forged'); + }, + }); + await expect(router(hostile)).rejects.toMatchObject({ code: 'rpc-unavailable' }); expect(adapter).not.toHaveBeenCalled(); }); @@ -151,7 +195,7 @@ describe('RFC-64 current-finalized EVM call router', () => { const active = Array.from({ length: 4 }, () => router(request())); await Promise.resolve(); expect(adapter).toHaveBeenCalledTimes(4); - await expect(router(request())).rejects.toMatchObject({ code: 'resource-limit' }); + await expect(router(request())).rejects.toMatchObject({ code: 'concurrency-saturated' }); expect(adapter).toHaveBeenCalledTimes(4); pending[0]!.resolve(RESULT_A); @@ -179,7 +223,7 @@ describe('RFC-64 current-finalized EVM call router', () => { await Promise.resolve(); controller.abort(); - await expect(router(request())).rejects.toMatchObject({ code: 'resource-limit' }); + await expect(router(request())).rejects.toMatchObject({ code: 'concurrency-saturated' }); expect(adapter).toHaveBeenCalledTimes(4); pending[0]!.resolve(RESULT_A); @@ -209,9 +253,9 @@ describe('RFC-64 current-finalized EVM call router', () => { await Promise.resolve(); expect(adapterA).toHaveBeenCalledTimes(4); expect(adapterB).toHaveBeenCalledTimes(4); - await expect(router(request())).rejects.toMatchObject({ code: 'resource-limit' }); + await expect(router(request())).rejects.toMatchObject({ code: 'concurrency-saturated' }); await expect(router(request({ chainId: CHAIN_B }))) - .rejects.toMatchObject({ code: 'resource-limit' }); + .rejects.toMatchObject({ code: 'concurrency-saturated' }); pendingA.forEach((item) => item.resolve(RESULT_A)); pendingB.forEach((item) => item.resolve({ ...RESULT_A, chainId: CHAIN_B })); @@ -264,9 +308,10 @@ function request( chainId: CHAIN_A, to: TO, from: CONTROL_EIP1271_CALL_FROM_V1, - data: '0x1234', + data: CANONICAL_CALL_DATA, gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, From 21a88c91be1a2e1d04af0f9da74f8ff59abbd3cf Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:22:33 +0200 Subject: [PATCH 048/292] feat(agent): mint verified candidate row capabilities --- .../agent/src/rfc64/inventory-v1/candidate.ts | 167 +++++++++++++++--- .../agent/src/rfc64/inventory-v1/index.ts | 2 + packages/agent/src/rfc64/inventory-v1/open.ts | 9 + .../rfc64-inventory-v1-candidates.test.ts | 100 +++++++++++ 4 files changed, 258 insertions(+), 20 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index 3164e2c1c2..e3a9dd78cc 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -60,6 +60,7 @@ declare const CANDIDATE_SESSION_BRAND: unique symbol; declare const CANDIDATE_LOAD_KEY_BRAND: unique symbol; declare const CANDIDATE_ROWS_TRAVERSAL_BRAND: unique symbol; declare const CANDIDATE_DIFF_TRAVERSAL_BRAND: unique symbol; +declare const VERIFIED_CANDIDATE_CATALOG_ROW_BRAND: unique symbol; /** Opaque, adapter-local session. No raw session bytes are exposed or accepted. */ export interface CandidateSessionV1 { @@ -79,6 +80,15 @@ export interface CandidateBucketDiffTraversalV1 { readonly [CANDIDATE_DIFF_TRAVERSAL_BRAND]: true; } +/** + * Process-local proof that a row was decoded through a live, pinned traversal + * rooted in an opaque verified head/directory/bucket load. SQLite bytes alone + * can never construct this capability. + */ +export interface VerifiedCandidateCatalogRowV1 { + readonly [VERIFIED_CANDIDATE_CATALOG_ROW_BRAND]: true; +} + export interface VerifiedCandidateBucketLoadV1 { readonly session: CandidateSessionV1; readonly head: SignedAuthorCatalogHeadEnvelopeV1; @@ -100,12 +110,22 @@ export interface CandidateBucketHeaderV1 { readonly payloadByteLength: ByteLengthV1; } -export interface CandidateBucketRowV1 { +export interface CandidateBucketRowSnapshotV1 { + /** Full canonical catalog scope needed by every later digest/admission check. */ + readonly catalogScope: AuthorCatalogScopeV1; + /** The exact verified bucket/head scope from which this row was traversed. */ + readonly header: CandidateBucketHeaderV1; + /** `removed` rows are deletion candidates, never transfer/admission candidates. */ + readonly disposition: 'present' | 'removed'; readonly row: AuthorCatalogRowV1; readonly catalogKeyDigest: Digest32V1; readonly expectedCatalogRowDigest: Digest32V1; } +export interface CandidateBucketRowV1 extends CandidateBucketRowSnapshotV1 { + readonly verifiedRow: VerifiedCandidateCatalogRowV1; +} + export interface CandidateBucketPutResultV1 { readonly status: 'inserted' | 'existing'; readonly loadKey: CandidateBucketLoadKeyV1; @@ -133,6 +153,8 @@ export type InventoryV1CandidateErrorCode = | 'candidate-invalid-load-key' | 'candidate-invalid-traversal' | 'candidate-traversal-closed' + | 'candidate-invalid-row-proof' + | 'candidate-stale-row-proof' | 'candidate-cursor-mismatch' | 'candidate-stream-complete' | 'candidate-in-use' @@ -176,6 +198,9 @@ export interface Rfc64InventoryV1CandidateApi { cursor: KaIdV1 | null | undefined, limit: number, ): CandidateBucketPageV1; + readVerifiedCandidateCatalogRow( + verifiedRow: VerifiedCandidateCatalogRowV1, + ): CandidateBucketRowSnapshotV1; closeCandidateTraversal( traversal: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, ): void; @@ -211,6 +236,7 @@ interface LoadKeyRecordV1 { readonly session: SessionRecordV1; readonly encoded: EncodedLoadKeyV1; readonly expectedHeader: EncodedHeaderV1; + readonly publicHeader: CandidateBucketHeaderV1; readonly scope: AuthorCatalogScopeV1; readonly pinKey: string; } @@ -277,6 +303,24 @@ interface TraversalBaseV1 { closeReason: 'normal' | 'poisoned' | 'reopen' | null; } +interface VerifiedCandidateCatalogRowRecordV1 { + readonly traversalHandle: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1; + readonly traversal: TraversalRecordV1; + readonly load: LoadKeyRecordV1; + readonly snapshot: CandidateBucketRowSnapshotV1; +} + +interface DecodedCandidateBucketRowV1 { + readonly row: AuthorCatalogRowV1; + readonly catalogKeyDigest: Digest32V1; + readonly expectedCatalogRowDigest: Digest32V1; +} + +interface CandidateBucketDecodedPageV1 { + readonly rows: readonly DecodedCandidateBucketRowV1[]; + readonly resumeAfter: KaIdV1 | null; +} + interface RowsTraversalRecordV1 extends TraversalBaseV1 { readonly kind: 'rows'; readonly load: LoadKeyRecordV1; @@ -304,6 +348,7 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { readonly #sessionRecords = new Set(); readonly #loadKeys = new WeakMap(); readonly #traversals = new WeakMap(); + readonly #verifiedRows = new WeakMap(); readonly #pins = new Map(); #startupPurgeDone = false; #committedOverruns = 0; @@ -495,13 +540,14 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { this.requireHeader(record.load); return this.queryRowsPage(record.load, canonicalCursor, limit); }); + const verifiedPage = this.mintVerifiedPage(page, traversal, record, record.load); if (page.rows.length === 0) { record.terminal = true; this.closeTraversalRecord(record); } else { record.expectedCursor = page.resumeAfter; } - return page; + return verifiedPage; } catch (cause) { this.closeTraversalRecord(record); throw normalizeCandidateError('candidate row page failed', cause); @@ -524,6 +570,31 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { return this.pageDiff(traversal, cursor, limit, 'removed'); } + readVerifiedCandidateCatalogRow( + verifiedRow: VerifiedCandidateCatalogRowV1, + ): CandidateBucketRowSnapshotV1 { + this.assertOpen(); + if (typeof verifiedRow !== 'object' || verifiedRow === null) { + throw invalidRowProof(); + } + const proof = this.#verifiedRows.get(verifiedRow as object); + if (proof === undefined) throw invalidRowProof(); + const current = this.#traversals.get(proof.traversalHandle as object); + if ( + current !== proof.traversal + || current.closed + || current.closeReason !== null + || current.sessions.some((session) => session.poisoned) + || !current.pins.some((pin) => pin.pinKey === proof.load.pinKey) + ) { + throw new InventoryV1CandidateError( + 'candidate-stale-row-proof', + 'verified candidate row proof no longer belongs to a live pinned traversal', + ); + } + return proof.snapshot; + } + closeCandidateTraversal( traversal: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, ): void { @@ -655,6 +726,14 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { assertDiffCompatible(oldHeader, newHeader); return this.queryDiffPage(record, canonicalCursor, limit, stream); }); + const load = stream === 'added' ? record.newLoad : record.oldLoad; + const verifiedPage = this.mintVerifiedPage( + page, + traversal, + record, + load, + stream === 'removed' ? 'removed' : 'present', + ); if (page.rows.length === 0) { if (stream === 'added') record.addedTerminal = true; else record.removedTerminal = true; @@ -664,7 +743,7 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { } else { record.removedExpectedCursor = page.resumeAfter; } - return page; + return verifiedPage; } catch (cause) { this.closeTraversalRecord(record); throw normalizeCandidateError(`candidate ${stream} diff page failed`, cause); @@ -675,7 +754,7 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { load: LoadKeyRecordV1, cursor: KaIdV1 | null, limit: number, - ): CandidateBucketPageV1 { + ): CandidateBucketDecodedPageV1 { assertPageLimit(limit); const sql = cursor === null ? INVENTORY_V1_STATEMENT_SQL.getRowsFirst @@ -695,7 +774,7 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { cursor: KaIdV1 | null, limit: number, stream: 'added' | 'removed', - ): CandidateBucketPageV1 { + ): CandidateBucketDecodedPageV1 { assertPageLimit(limit); const sql = stream === 'added' ? (cursor === null @@ -720,6 +799,40 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { return decodePage(rows, stream === 'added' ? traversal.newLoad : traversal.oldLoad); } + private mintVerifiedPage( + page: CandidateBucketDecodedPageV1, + traversalHandle: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, + traversal: TraversalRecordV1, + load: LoadKeyRecordV1, + disposition: CandidateBucketRowSnapshotV1['disposition'] = 'present', + ): CandidateBucketPageV1 { + const rows = page.rows.map((decoded): CandidateBucketRowV1 => { + const snapshot: CandidateBucketRowSnapshotV1 = Object.freeze({ + catalogScope: load.scope, + header: load.publicHeader, + disposition, + ...decoded, + }); + const verifiedRow = Object.freeze( + Object.create(null), + ) as VerifiedCandidateCatalogRowV1; + this.#verifiedRows.set(verifiedRow as object, { + traversalHandle, + traversal, + load, + snapshot, + }); + return Object.freeze({ + ...snapshot, + verifiedRow, + }); + }); + return Object.freeze({ + rows: Object.freeze(rows), + resumeAfter: page.resumeAfter, + }); + } + private encodeAndVerifyLoad(load: VerifiedCandidateBucketLoadV1): EncodedCandidateLoadV1 { try { return this.encodeAndVerifyLoadUnchecked(load); @@ -870,6 +983,7 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { session: load.session, encoded, expectedHeader: cloneHeader(load.header), + publicHeader: load.publicHeader, scope: Object.freeze({ ...load.scope }), pinKey: pinKey(encoded), }); @@ -1728,7 +1842,10 @@ function assertVerifiedHeaderScopeBinding( } } -function decodePage(rows: readonly SqlRowV1[], key: LoadKeyRecordV1): CandidateBucketPageV1 { +function decodePage( + rows: readonly SqlRowV1[], + key: LoadKeyRecordV1, +): CandidateBucketDecodedPageV1 { const decoded = rows.map((row) => decodeRow(row, key)); return Object.freeze({ rows: Object.freeze(decoded), @@ -1736,7 +1853,7 @@ function decodePage(rows: readonly SqlRowV1[], key: LoadKeyRecordV1): CandidateB }); } -function decodeRow(row: SqlRowV1, key: LoadKeyRecordV1): CandidateBucketRowV1 { +function decodeRow(row: SqlRowV1, key: LoadKeyRecordV1): DecodedCandidateBucketRowV1 { try { if ( !sqlBlobsEqualV1(row.session_id, key.encoded.session) @@ -1757,24 +1874,27 @@ function decodeRow(row: SqlRowV1, key: LoadKeyRecordV1): CandidateBucketRowV1 { throw new Error('stored assertionCoordinate is not text'); } assertAssertionCoordinateV1(row.assertion_coordinate); - const candidate: AuthorCatalogRowV1 = { + const transfer = Object.freeze({ + codec: KA_TRANSFER_CODEC_V1, + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: sqlBlobToDigest32V1(row.projection_digest), + byteLength: sqlBlobToDecimalU64V1(row.transfer_byte_length_u64be) as ByteLengthV1, + chunkSize: sqlBlobToDecimalU64V1( + row.transfer_chunk_size_u64be, + ) as typeof KA_TRANSFER_CHUNK_SIZE_V1, + chunkCount: sqlBlobToDecimalU64V1(row.transfer_chunk_count_u64be) as CountV1, + blobDigest: sqlBlobToDigest32V1(row.transfer_blob_digest), + chunkTreeRoot: sqlBlobToDigest32V1(row.transfer_chunk_tree_root), + }); + const candidate: AuthorCatalogRowV1 = Object.freeze({ kaId: sqlBlobToDecimalU256V1(row.ka_id_u256be) as KaIdV1, assertionCoordinate: row.assertion_coordinate, assertionVersion: sqlBlobToDecimalU64V1(row.assertion_version_u64be), projectionId: KA_TRANSFER_PROJECTION_V1, projectionDigest: sqlBlobToDigest32V1(row.projection_digest), sealDigest: sqlBlobToDigest32V1(row.seal_digest), - transfer: { - codec: KA_TRANSFER_CODEC_V1, - projectionId: KA_TRANSFER_PROJECTION_V1, - projectionDigest: sqlBlobToDigest32V1(row.projection_digest), - byteLength: sqlBlobToDecimalU64V1(row.transfer_byte_length_u64be) as ByteLengthV1, - chunkSize: sqlBlobToDecimalU64V1(row.transfer_chunk_size_u64be) as typeof KA_TRANSFER_CHUNK_SIZE_V1, - chunkCount: sqlBlobToDecimalU64V1(row.transfer_chunk_count_u64be) as CountV1, - blobDigest: sqlBlobToDigest32V1(row.transfer_blob_digest), - chunkTreeRoot: sqlBlobToDigest32V1(row.transfer_chunk_tree_root), - }, - }; + transfer, + }); assertAuthorCatalogRowV1(candidate); const catalogScopeDigest = sqlBlobToDigest32V1(row.catalog_scope_digest); const catalogKeyDigest = sqlBlobToDigest32V1(row.catalog_key_digest); @@ -1789,7 +1909,7 @@ function decodeRow(row: SqlRowV1, key: LoadKeyRecordV1): CandidateBucketRowV1 { throw new Error('stored expectedCatalogRowDigest does not match the row'); } return Object.freeze({ - row: Object.freeze(candidate), + row: candidate, catalogKeyDigest, expectedCatalogRowDigest, }); @@ -2015,6 +2135,13 @@ function invalidSession(): InventoryV1CandidateError { ); } +function invalidRowProof(): InventoryV1CandidateError { + return new InventoryV1CandidateError( + 'candidate-invalid-row-proof', + 'verified candidate row proof is not an adapter-local opaque handle', + ); +} + function streamComplete(): InventoryV1CandidateError { return new InventoryV1CandidateError( 'candidate-stream-complete', diff --git a/packages/agent/src/rfc64/inventory-v1/index.ts b/packages/agent/src/rfc64/inventory-v1/index.ts index f5d23920b4..cce8ad2169 100644 --- a/packages/agent/src/rfc64/inventory-v1/index.ts +++ b/packages/agent/src/rfc64/inventory-v1/index.ts @@ -8,6 +8,7 @@ export { type CandidateBucketLoadKeyV1, type CandidateBucketPageV1, type CandidateBucketPutResultV1, + type CandidateBucketRowSnapshotV1, type CandidateBucketRowV1, type CandidateBucketRowsTraversalV1, type CandidateSessionGcBatchResultV1, @@ -15,6 +16,7 @@ export { type InventoryV1CandidateErrorCode, type Rfc64InventoryV1CandidateApi, type VerifiedCandidateBucketLoadV1, + type VerifiedCandidateCatalogRowV1, } from './candidate.js'; export { INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 53e0d7a8e4..ab263145e1 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -45,11 +45,13 @@ import { type CandidateBucketLoadKeyV1, type CandidateBucketPageV1, type CandidateBucketPutResultV1, + type CandidateBucketRowSnapshotV1, type CandidateBucketRowsTraversalV1, type CandidateSessionV1, type CandidateSessionGcBatchResultV1, type Rfc64InventoryV1CandidateApi, type VerifiedCandidateBucketLoadV1, + type VerifiedCandidateCatalogRowV1, } from './candidate.js'; import type { KaIdV1 } from '@origintrail-official/dkg-core'; import { @@ -380,6 +382,13 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { return this.#candidate.pageCandidateBucketRemoved(traversal, cursor, limit); } + readVerifiedCandidateCatalogRow( + verifiedRow: VerifiedCandidateCatalogRowV1, + ): CandidateBucketRowSnapshotV1 { + this.requireOpen(); + return this.#candidate.readVerifiedCandidateCatalogRow(verifiedRow); + } + closeCandidateTraversal( traversal: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, ): void { diff --git a/packages/agent/test/rfc64-inventory-v1-candidates.test.ts b/packages/agent/test/rfc64-inventory-v1-candidates.test.ts index 8b142b8652..a4957418f8 100644 --- a/packages/agent/test/rfc64-inventory-v1-candidates.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-candidates.test.ts @@ -118,6 +118,74 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { .toThrowError(expect.objectContaining({ code: 'candidate-traversal-closed' })); }); + it('mints unforgeable row capabilities only for live pinned traversals', async () => { + const inventory = await readyInventory(); + const otherInventory = await readyInventory(); + const session = inventory.createCandidateSession(); + const sourceRow = makeRow(1n, 'verified-row-capability'); + const head = makeHead('1', '60'); + const loaded = inventory.putVerifiedCandidateBucket( + makeNonEmptyLoad(session, head, [sourceRow]), + ); + + const traversal = inventory.beginCandidateBucketRows(loaded.loadKey); + const page = inventory.pageCandidateBucketRows(traversal, null, 1); + const candidate = page.rows[0]; + expect(candidate).toBeDefined(); + expect(Object.getPrototypeOf(candidate.verifiedRow)).toBeNull(); + expect(Object.isFrozen(candidate.verifiedRow)).toBe(true); + expect(Object.keys(candidate.verifiedRow)).toEqual([]); + expect(Object.isFrozen(candidate)).toBe(true); + expect(Object.isFrozen(candidate.row)).toBe(true); + expect(Object.isFrozen(candidate.row.transfer)).toBe(true); + expect(Object.isFrozen(candidate.catalogScope)).toBe(true); + expect(candidate.catalogScope).toEqual(deriveAuthorCatalogScopeFromHeadV1(head.payload)); + expect(computeAuthorCatalogScopeDigestV1(candidate.catalogScope)) + .toBe(candidate.header.catalogScopeDigest); + + const snapshot = inventory.readVerifiedCandidateCatalogRow(candidate.verifiedRow); + expect(snapshot).toEqual({ + catalogScope: candidate.catalogScope, + header: candidate.header, + disposition: 'present', + row: candidate.row, + catalogKeyDigest: candidate.catalogKeyDigest, + expectedCatalogRowDigest: candidate.expectedCatalogRowDigest, + }); + expect(Object.isFrozen(snapshot)).toBe(true); + + const forged = Object.freeze(Object.create(null)) as typeof candidate.verifiedRow; + const serializedClone = JSON.parse( + JSON.stringify(candidate.verifiedRow), + ) as typeof candidate.verifiedRow; + expect(() => inventory.readVerifiedCandidateCatalogRow(forged)).toThrowError( + expect.objectContaining({ code: 'candidate-invalid-row-proof' }), + ); + expect(() => inventory.readVerifiedCandidateCatalogRow(serializedClone)).toThrowError( + expect.objectContaining({ code: 'candidate-invalid-row-proof' }), + ); + expect(() => inventory.readVerifiedCandidateCatalogRow( + candidate.row as unknown as typeof candidate.verifiedRow, + )).toThrowError(expect.objectContaining({ code: 'candidate-invalid-row-proof' })); + expect(() => otherInventory.readVerifiedCandidateCatalogRow(candidate.verifiedRow)) + .toThrowError(expect.objectContaining({ code: 'candidate-invalid-row-proof' })); + + expect(inventory.pageCandidateBucketRows(traversal, page.resumeAfter, 1)).toEqual({ + rows: [], + resumeAfter: null, + }); + expect(() => inventory.readVerifiedCandidateCatalogRow(candidate.verifiedRow)).toThrowError( + expect.objectContaining({ code: 'candidate-stale-row-proof' }), + ); + + const explicitlyClosed = inventory.beginCandidateBucketRows(loaded.loadKey); + const explicitlyClosedPage = inventory.pageCandidateBucketRows(explicitlyClosed, null, 1); + inventory.closeCandidateTraversal(explicitlyClosed); + expect(() => inventory.readVerifiedCandidateCatalogRow( + explicitlyClosedPage.rows[0].verifiedRow, + )).toThrowError(expect.objectContaining({ code: 'candidate-stale-row-proof' })); + }); + it('reopens the low-level database, retains the opaque session, and exact-key retries', () => { const directory = temporaryDataDirectory(); const path = join(directory, 'commit-retry.sqlite3'); @@ -154,6 +222,11 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { makeNonEmptyLoad(session, makeHead('1', '1'), [makeRow(1n, 'fixture')]), ); const invalidatedTraversal = inventory.beginCandidateBucketRows(firstLoad.loadKey); + const invalidatedPage = inventory.pageCandidateBucketRows( + invalidatedTraversal, + null, + 1, + ); failAfterCommittedOnce = true; const retried = inventory.putVerifiedCandidateBucket( @@ -165,6 +238,9 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { .toBe(retried.header.targetCatalogHeadDigest); expect(() => inventory.pageCandidateBucketRows(invalidatedTraversal, null, 1)) .toThrowError(expect.objectContaining({ code: 'candidate-traversal-closed' })); + expect(() => inventory.readVerifiedCandidateCatalogRow( + invalidatedPage.rows[0].verifiedRow, + )).toThrowError(expect.objectContaining({ code: 'candidate-stale-row-proof' })); expect(inventory.putVerifiedCandidateBucket( makeNonEmptyLoad(session, makeHead('1', '1'), [makeRow(1n, 'fixture')]), ).status).toBe('existing'); @@ -727,10 +803,15 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { }, ]); const loaded = inventory.putVerifiedCandidateBucket(original); + const traversal = inventory.beginCandidateBucketRows(loaded.loadKey); + const verifiedPage = inventory.pageCandidateBucketRows(traversal, null, 1); expect(() => inventory.putVerifiedCandidateBucket(mutation)).toThrowError( expect.objectContaining({ code: 'candidate-conflict' }), ); + expect(() => inventory.readVerifiedCandidateCatalogRow( + verifiedPage.rows[0].verifiedRow, + )).toThrowError(expect.objectContaining({ code: 'candidate-stale-row-proof' })); expect(() => inventory.getCandidateBucket(loaded.loadKey)).toThrowError( expect.objectContaining({ code: 'candidate-session-poisoned' }), ); @@ -856,13 +937,30 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { added2.resumeAfter, 1, )).toEqual({ rows: [], resumeAfter: null }); + expect(inventory.readVerifiedCandidateCatalogRow(added1.rows[0].verifiedRow)) + .toMatchObject({ + disposition: 'present', + header: { targetCatalogHeadDigest: newLoad.header.targetCatalogHeadDigest }, + }); const removed = inventory.pageCandidateBucketRemoved(traversal, null, 1); expect(removed.rows.map((entry) => entry.row.kaId)).toEqual([three.kaId]); + expect(removed.rows[0]).toMatchObject({ + disposition: 'removed', + header: { targetCatalogHeadDigest: oldLoad.header.targetCatalogHeadDigest }, + }); + expect(added1.rows[0]).toMatchObject({ + disposition: 'present', + header: { targetCatalogHeadDigest: newLoad.header.targetCatalogHeadDigest }, + }); expect(inventory.pageCandidateBucketRemoved(traversal, removed.resumeAfter, 1)).toEqual({ rows: [], resumeAfter: null, }); + expect(() => inventory.readVerifiedCandidateCatalogRow(added1.rows[0].verifiedRow)) + .toThrowError(expect.objectContaining({ code: 'candidate-stale-row-proof' })); + expect(() => inventory.readVerifiedCandidateCatalogRow(removed.rows[0].verifiedRow)) + .toThrowError(expect.objectContaining({ code: 'candidate-stale-row-proof' })); }); it('pins loads, rejects cursor skipping, and releases pins on explicit early close', async () => { @@ -879,6 +977,8 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { ); expect(() => inventory.pageCandidateBucketRows(traversal, makeRow(2n, 'x').kaId, 1)) .toThrowError(expect.objectContaining({ code: 'candidate-cursor-mismatch' })); + expect(() => inventory.readVerifiedCandidateCatalogRow(first.rows[0].verifiedRow)) + .toThrowError(expect.objectContaining({ code: 'candidate-stale-row-proof' })); expect(() => inventory.pageCandidateBucketRows(traversal, first.resumeAfter, 1)) .toThrowError(expect.objectContaining({ code: 'candidate-traversal-closed' })); From 812e9e82c1834f03bfe0df01acbc96c9b2f9933b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:23:32 +0200 Subject: [PATCH 049/292] test(agent): harden candidate row capability checks --- .../test/rfc64-inventory-v1-candidates.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/agent/test/rfc64-inventory-v1-candidates.test.ts b/packages/agent/test/rfc64-inventory-v1-candidates.test.ts index a4957418f8..b4fdeb0fd3 100644 --- a/packages/agent/test/rfc64-inventory-v1-candidates.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-candidates.test.ts @@ -158,6 +158,11 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { const serializedClone = JSON.parse( JSON.stringify(candidate.verifiedRow), ) as typeof candidate.verifiedRow; + for (const primitive of [null, undefined, false, 0, 'proof']) { + expect(() => inventory.readVerifiedCandidateCatalogRow( + primitive as unknown as typeof candidate.verifiedRow, + )).toThrowError(expect.objectContaining({ code: 'candidate-invalid-row-proof' })); + } expect(() => inventory.readVerifiedCandidateCatalogRow(forged)).toThrowError( expect.objectContaining({ code: 'candidate-invalid-row-proof' }), ); @@ -170,6 +175,15 @@ describe('RFC-64 SQL-1 verified candidate buckets', () => { expect(() => otherInventory.readVerifiedCandidateCatalogRow(candidate.verifiedRow)) .toThrowError(expect.objectContaining({ code: 'candidate-invalid-row-proof' })); + const tamperedWrapper = { + ...candidate, + disposition: 'removed', + catalogKeyDigest: ZERO_DIGEST32_V1, + } as const; + expect(inventory.readVerifiedCandidateCatalogRow(tamperedWrapper.verifiedRow)).toEqual( + snapshot, + ); + expect(inventory.pageCandidateBucketRows(traversal, page.resumeAfter, 1)).toEqual({ rows: [], resumeAfter: null, From 3dde2d6e9613d0fe921e40ed508d1b276e64245f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:29:34 +0200 Subject: [PATCH 050/292] fix(agent): harden inventory recovery evidence --- .../rfc64/inventory-v1/lifecycle-adapter.ts | 18 +- packages/agent/src/rfc64/inventory-v1/open.ts | 217 ++++++-- packages/agent/src/rfc64/inventory-v1/sql.ts | 26 + .../test/rfc64-inventory-v1-lifecycle.test.ts | 491 +++++++++++++++++- 4 files changed, 702 insertions(+), 50 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/lifecycle-adapter.ts b/packages/agent/src/rfc64/inventory-v1/lifecycle-adapter.ts index 1df2f3abee..6cf6c64de8 100644 --- a/packages/agent/src/rfc64/inventory-v1/lifecycle-adapter.ts +++ b/packages/agent/src/rfc64/inventory-v1/lifecycle-adapter.ts @@ -14,20 +14,20 @@ export type InventoryV1QuarantineCapability = export type InventoryV1QuarantineBoundary = | 'target-exclusivity-proven' - | `begin.source.${'wal' | 'shm' | 'main'}.file-fsync` + | `begin.source.${'journal' | 'wal' | 'shm' | 'main'}.file-fsync` | 'begin.inventory-directory.fsync-after-quarantine-root' | 'begin.quarantine-root.fsync-after-generation' | 'begin.marker.write' | 'begin.marker.file-fsync' | 'begin.inventory-directory.fsync-after-marker' - | `resume.prefix.${'wal' | 'shm' | 'main'}.file-fsync` - | `resume.prefix.${'wal' | 'shm' | 'main'}.generation-directory-fsync` - | `resume.prefix.${'wal' | 'shm' | 'main'}.inventory-directory-fsync` - | `resume.source.${'wal' | 'shm' | 'main'}.file-fsync-after-quiescence` - | `resume.member.${'wal' | 'shm' | 'main'}.rename` - | `resume.member.${'wal' | 'shm' | 'main'}.file-fsync` - | `resume.member.${'wal' | 'shm' | 'main'}.generation-directory-fsync` - | `resume.member.${'wal' | 'shm' | 'main'}.inventory-directory-fsync` + | `resume.prefix.${'journal' | 'wal' | 'shm' | 'main'}.file-fsync` + | `resume.prefix.${'journal' | 'wal' | 'shm' | 'main'}.generation-directory-fsync` + | `resume.prefix.${'journal' | 'wal' | 'shm' | 'main'}.inventory-directory-fsync` + | `resume.source.${'journal' | 'wal' | 'shm' | 'main'}.file-fsync-after-quiescence` + | `resume.member.${'journal' | 'wal' | 'shm' | 'main'}.rename` + | `resume.member.${'journal' | 'wal' | 'shm' | 'main'}.file-fsync` + | `resume.member.${'journal' | 'wal' | 'shm' | 'main'}.generation-directory-fsync` + | `resume.member.${'journal' | 'wal' | 'shm' | 'main'}.inventory-directory-fsync` | 'resume.marker.unlink' | 'resume.inventory-directory.fsync-after-marker-unlink'; diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index dec0c996f9..0675e08e2e 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -52,7 +52,7 @@ type DatabaseSyncV1 = InstanceType; const RECOVERY_MARKER_SUFFIX = '.rebuild-required'; const QUARANTINE_DIRECTORY = 'quarantine'; -const OWNED_FILE_SUFFIXES = ['', '-wal', '-shm'] as const; +const OWNED_FILE_SUFFIXES = ['', '-journal', '-wal', '-shm'] as const; const LEASE_DATABASE_NAME = 'inventory-v1.lease.sqlite3'; const LEASE_FILE_SUFFIXES = ['', '-journal'] as const; const LEASE_APPLICATION_ID = 0x444b364c; @@ -630,6 +630,7 @@ function openOrRebuildOwnedDatabase( createSecureEmptyFile(databasePath); } else { assertOwnedUnitOwners(databasePath); + assertTargetJournalModeCoherence(databasePath); assertExistingTargetHeader(databasePath); } @@ -764,6 +765,21 @@ function openOrRebuildOwnedDatabase( } } +function assertTargetJournalModeCoherence(databasePath: string): void { + if ( + pathEntryExists(`${databasePath}-journal`) + && ( + pathEntryExists(`${databasePath}-wal`) + || pathEntryExists(`${databasePath}-shm`) + ) + ) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'inventory target mixes rollback-journal and WAL evidence and will not be opened or modified', + ); + } +} + function assertExistingTargetHeader(databasePath: string): void { const identity = readValidSqliteHeaderIdentity(databasePath); if (identity === null || identity.applicationId === 0) { @@ -1130,11 +1146,6 @@ $ErrorActionPreference = 'Stop' $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $userSid = $identity.User $defaultOwnerSid = $identity.Owner -$administratorsSid = [System.Security.Principal.SecurityIdentifier]::new( - [System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, - $null -) -$principal = [System.Security.Principal.WindowsPrincipal]::new($identity) $target = [System.IO.Path]::GetFullPath($env:DKG_RFC64_ACL_PATH) $acl = if ([System.IO.Directory]::Exists($target)) { [System.IO.Directory]::GetAccessControl( @@ -1148,20 +1159,9 @@ $acl = if ([System.IO.Directory]::Exists($target)) { ) } $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) -# An elevated Windows token may create entries with its distinct default-owner -# SID (normally BUILTIN\Administrators) even though the process account SID is -# the user. GitHub-hosted and service runners may also pre-create the caller's -# temp root with BUILTIN\Administrators as owner while the active token is an -# administrator. Accept that one exact well-known group only when it is active -# in this token; arbitrary token groups are deliberately not accepted. -$isActiveAdministratorsOwner = ( - $owner.Value -eq $administratorsSid.Value -and - $principal.IsInRole($administratorsSid) -) if ( $owner.Value -ne $userSid.Value -and - $owner.Value -ne $defaultOwnerSid.Value -and - -not $isActiveAdministratorsOwner + $owner.Value -ne $defaultOwnerSid.Value ) { [Console]::Error.WriteLine( "owner SID $($owner.Value) is neither token user $($userSid.Value) nor token default owner $($defaultOwnerSid.Value)" @@ -1204,7 +1204,9 @@ if ( function assertOwnedUnitOwners(databasePath: string): void { for (const suffix of OWNED_FILE_SUFFIXES) { const path = `${databasePath}${suffix}`; - if (pathEntryExists(path)) assertFilesystemOwner(path); + if (pathEntryExists(path)) { + assertOwnedRegularFile(path, `inventory database${suffix}`); + } } } @@ -1355,7 +1357,11 @@ function fsyncRegularFile(path: string, label: string): void { assertOwnedRegularFile(path, label); let descriptor: number | undefined; try { - descriptor = openSync(path, 'r'); + // Windows implements fsync with FlushFileBuffers, which rejects a handle + // opened for read-only access with EPERM. The entry is already an owned + // regular file; opening r+ grants the required handle access without + // changing bytes, while POSIX retains its narrower read-only descriptor. + descriptor = openSync(path, process.platform === 'win32' ? 'r+' : 'r'); fsyncSync(descriptor); } catch (cause) { throw new InventoryV1OpenError('database-io', `failed to fsync ${label}`, { cause }); @@ -1374,16 +1380,18 @@ function pathEntryExists(path: string): boolean { } } -type RecoveryMemberV1 = 'wal' | 'shm' | 'main'; +type RecoveryMemberV1 = 'journal' | 'wal' | 'shm' | 'main'; type RecoveryMembersV1 = | readonly ['main'] + | readonly ['journal', 'main'] | readonly ['wal', 'main'] | readonly ['shm', 'main'] | readonly ['wal', 'shm', 'main']; -const ALL_RECOVERY_MEMBERS = ['wal', 'shm', 'main'] as const satisfies readonly RecoveryMemberV1[]; +const ALL_RECOVERY_MEMBERS = ['journal', 'wal', 'shm', 'main'] as const satisfies readonly RecoveryMemberV1[]; const RECOVERY_MEMBER_ARRAYS = new Set([ '["main"]', + '["journal","main"]', '["wal","main"]', '["shm","main"]', '["wal","shm","main"]', @@ -1514,9 +1522,17 @@ function finishPendingQuarantine( // before either continuing or deleting the marker. fsyncMovedRecoveryPrefix(marker, topology, inventoryDirectory, lifecycle); if (topology.movedCount === 0) { - // Only a complete source unit may be reopened. Once even one member has - // moved, SQLite must never see the partial source topology. - assertPendingUnitQuiescent(sqlite, databasePath, lifecycle); + // A zero-move marker never authorizes namespace mutation by itself. Prove + // zero-timeout exclusivity while the complete source unit, including any + // rollback journal, remains at its original pathname. A live raw SQLite + // holder therefore leaves every source member, destination, and marker + // untouched. Once the first prefix member has moved, that durable prefix + // is the restart witness that this proof completed before any rename. + if (marker.members[0] === 'journal') { + assertPendingRollbackJournalUnitQuiescent(sqlite, databasePath, lifecycle); + } else { + assertPendingUnitQuiescent(sqlite, databasePath, lifecycle); + } topology = inspectRecoveryTopology(marker, databasePath); if (topology.movedCount !== 0) { throw new InventoryV1OpenError( @@ -1535,18 +1551,13 @@ function finishPendingQuarantine( for (const member of marker.members) { if (topology.locations.get(member) === 'destination') continue; - const source = recoverySourcePath(databasePath, member); - const destination = recoveryDestinationPath(marker.quarantineDirectory, member); - assertOwnedRegularFile(source, `inventory ${member} evidence`); - assertSameFilesystem(source, marker.quarantineDirectory, `inventory ${member} evidence`); - renameNoOverwriteUnderLease(source, destination); - lifecycle.boundary(`resume.member.${member}.rename`); - fsyncRegularFile(destination, `moved inventory ${member} evidence`); - lifecycle.boundary(`resume.member.${member}.file-fsync`); - fsyncDirectory(marker.quarantineDirectory); - lifecycle.boundary(`resume.member.${member}.generation-directory-fsync`); - fsyncDirectory(inventoryDirectory); - lifecycle.boundary(`resume.member.${member}.inventory-directory-fsync`); + moveRecoveryMember( + marker, + databasePath, + member, + inventoryDirectory, + lifecycle, + ); } topology = inspectRecoveryTopology(marker, databasePath); @@ -1562,6 +1573,27 @@ function finishPendingQuarantine( lifecycle.boundary('resume.inventory-directory.fsync-after-marker-unlink'); } +function moveRecoveryMember( + marker: RecoveryMarkerV1, + databasePath: string, + member: RecoveryMemberV1, + inventoryDirectory: string, + lifecycle: InventoryV1LifecycleAdapter, +): void { + const source = recoverySourcePath(databasePath, member); + const destination = recoveryDestinationPath(marker.quarantineDirectory, member); + assertOwnedRegularFile(source, `inventory ${member} evidence`); + assertSameFilesystem(source, marker.quarantineDirectory, `inventory ${member} evidence`); + renameNoOverwriteUnderLease(source, destination); + lifecycle.boundary(`resume.member.${member}.rename`); + fsyncRegularFile(destination, `moved inventory ${member} evidence`); + lifecycle.boundary(`resume.member.${member}.file-fsync`); + fsyncDirectory(marker.quarantineDirectory); + lifecycle.boundary(`resume.member.${member}.generation-directory-fsync`); + fsyncDirectory(inventoryDirectory); + lifecycle.boundary(`resume.member.${member}.inventory-directory-fsync`); +} + function fsyncMovedRecoveryPrefix( marker: RecoveryMarkerV1, topology: RecoveryTopologyV1, @@ -1726,6 +1758,117 @@ function assertPendingUnitQuiescent( } } +function assertPendingRollbackJournalUnitQuiescent( + sqlite: SqliteModuleV1, + databasePath: string, + lifecycle: InventoryV1LifecycleAdapter, +): void { + if (!pathEntryExists(databasePath)) { + throw new InventoryV1OpenError( + 'database-io', + 'pending rollback-journal quarantine has no source main', + ); + } + rejectOwnedFileSymlinks(databasePath); + assertOwnedUnitOwners(databasePath); + + // Opening the main database by its ordinary pathname can recover or delete + // its rollback journal before SQLite reports whether another process owns a + // writer lock. Instead, open the already-validated main inode through this + // process's descriptor namespace. SQLite locks the same inode, while its + // derived journal pathname is the descriptor alias and therefore cannot + // consume or rename the source `-journal` evidence. Quarantine is available + // only under the certified POSIX capability, where /dev/fd (or Linux's + // equivalent /proc/self/fd) provides this same-inode alias. + let descriptor: number | undefined; + let database: DatabaseSyncV1 | null = null; + let transactionOpen = false; + try { + descriptor = openSync(databasePath, 'r+'); + const sourceIdentity = fstatSync(descriptor); + if (!sourceIdentity.isFile()) { + throw new InventoryV1OpenError( + 'database-io', + 'pending rollback-journal source main is not a regular file', + ); + } + const aliasCandidates = [ + `/dev/fd/${descriptor}`, + ...(process.platform === 'linux' ? [`/proc/self/fd/${descriptor}`] : []), + ]; + let descriptorAlias: string | undefined; + for (const candidate of aliasCandidates) { + let aliasDescriptor: number | undefined; + try { + aliasDescriptor = openSync(candidate, 'r+'); + const aliasIdentity = fstatSync(aliasDescriptor); + if ( + aliasIdentity.isFile() + && aliasIdentity.dev === sourceIdentity.dev + && aliasIdentity.ino === sourceIdentity.ino + ) { + descriptorAlias = candidate; + break; + } + } catch { + // Try the next fixed descriptor namespace alias. + } finally { + if (aliasDescriptor !== undefined) closeSync(aliasDescriptor); + } + } + if (descriptorAlias === undefined) { + throw new InventoryV1OpenError( + 'durability-unavailable', + 'certified POSIX quarantine cannot address the source main through a same-inode descriptor alias', + ); + } + + database = new sqlite.DatabaseSync(descriptorAlias); + database.exec('PRAGMA busy_timeout = 0'); + database.exec('BEGIN EXCLUSIVE'); + transactionOpen = true; + database.exec('ROLLBACK'); + transactionOpen = false; + database.close(); + database = null; + + const finalIdentity = fstatSync(descriptor); + if ( + !finalIdentity.isFile() + || finalIdentity.dev !== sourceIdentity.dev + || finalIdentity.ino !== sourceIdentity.ino + ) { + throw new InventoryV1OpenError( + 'database-io', + 'pending rollback-journal source main changed during the exclusivity proof', + ); + } + lifecycle.boundary('target-exclusivity-proven'); + } catch (cause) { + if (transactionOpen && database !== null) { + try { database.exec('ROLLBACK'); } catch { /* retain the proof failure */ } + } + if (cause instanceof InventoryV1OpenError) throw cause; + if (isBusySqliteError(cause)) { + throw new InventoryV1OpenError( + 'database-busy', + 'pending rollback-journal quarantine still has an active SQLite holder', + { cause }, + ); + } + throw new InventoryV1OpenError( + 'database-io', + 'cannot prove exclusive access to the pending rollback-journal unit', + { cause }, + ); + } finally { + if (database !== null) { + try { database.close(); } catch { /* retain any primary proof failure */ } + } + if (descriptor !== undefined) closeSync(descriptor); + } +} + function readBoundedRecoveryMarker(markerPath: string): string { let descriptor: number | undefined; let bytes: Buffer; diff --git a/packages/agent/src/rfc64/inventory-v1/sql.ts b/packages/agent/src/rfc64/inventory-v1/sql.ts index b4ca910059..b13b86196d 100644 --- a/packages/agent/src/rfc64/inventory-v1/sql.ts +++ b/packages/agent/src/rfc64/inventory-v1/sql.ts @@ -4,6 +4,31 @@ export const INVENTORY_V1_RELATIVE_PATH = 'rfc64-sync/inventory-v1.sqlite3'; export const INVENTORY_V1_DIRECTORY_MODE = 0o700; export const INVENTORY_V1_FILE_MODE = 0o600; +// SQL-1 stores protocol integers only as canonical fixed-width big-endian +// BLOBs. Decode the bounded low hexadecimal suffix with SQLite core built-ins +// solely inside the relational CHECK below; no redundant numeric authority, +// extension function, trigger, or connection-local UDF is introduced. +function unsignedHexSuffixIntegerSql(column: string, nibbles: number): string { + const firstPosition = 17 - nibbles; + return Array.from({ length: nibbles }, (_unused, index) => { + const position = firstPosition + index; + const multiplier = 16 ** (nibbles - index - 1); + return `((instr('0123456789ABCDEF', substr(hex(${column}), ${position}, 1)) - 1) * ${multiplier})`; + }).join(' + '); +} + +const TRANSFER_BYTE_LENGTH_INTEGER_SQL = unsignedHexSuffixIntegerSql( + 'transfer_byte_length_u64be', + 8, +); +const TRANSFER_CHUNK_COUNT_INTEGER_SQL = unsignedHexSuffixIntegerSql( + 'transfer_chunk_count_u64be', + 4, +); +const TRANSFER_CHUNK_GEOMETRY_CHECK_SQL = + `((${TRANSFER_BYTE_LENGTH_INTEGER_SQL}) + 262143) / 262144` + + ` = (${TRANSFER_CHUNK_COUNT_INTEGER_SQL})`; + export const INVENTORY_V1_LOADS_TABLE_SQL = ` CREATE TABLE rfc64_candidate_bucket_loads_v1 ( session_id BLOB NOT NULL @@ -183,6 +208,7 @@ CREATE TABLE rfc64_candidate_bucket_rows_v1 ( transfer_chunk_count_u64be BLOB NOT NULL CHECK ( typeof(transfer_chunk_count_u64be) = 'blob' AND length(transfer_chunk_count_u64be) = 8 AND transfer_chunk_count_u64be >= x'0000000000000001' AND transfer_chunk_count_u64be <= x'0000000000001000' + AND ${TRANSFER_CHUNK_GEOMETRY_CHECK_SQL} ), transfer_blob_digest BLOB NOT NULL diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index 737e4eef51..5763ae5306 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -46,6 +46,7 @@ const CHILD_FIXTURE = resolve( 'fixtures/rfc64-inventory-v1-child.ts', ); const HOSTILE_SIDECAR_BYTES = Object.freeze({ + journal: Buffer.from('hostile-rfc64-journal-evidence\0\u0000\u0001', 'utf8'), wal: Buffer.from('hostile-rfc64-wal-evidence\0\u0001\u0002', 'utf8'), shm: Buffer.from('hostile-rfc64-shm-evidence\0\u0003\u0004', 'utf8'), }); @@ -55,9 +56,10 @@ const openInventoryV1 = (dataDirectory: string) => openProductionInventoryV1( { quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY }, ); -type RecoveryMember = 'wal' | 'shm' | 'main'; +type RecoveryMember = 'journal' | 'wal' | 'shm' | 'main'; type RecoveryManifest = | readonly ['main'] + | readonly ['journal', 'main'] | readonly ['wal', 'main'] | readonly ['shm', 'main'] | readonly ['wal', 'shm', 'main']; @@ -68,6 +70,8 @@ type SeededRecoveryTopology = Readonly<{ }>; const FULL_RECOVERY_MANIFEST = ['wal', 'shm', 'main'] as const; +type FullRecoveryMember = (typeof FULL_RECOVERY_MANIFEST)[number]; +const JOURNAL_RECOVERY_MANIFEST = ['journal', 'main'] as const; const FULL_MANIFEST_FAULT_BOUNDARIES = [ 'begin.source.wal.file-fsync', 'begin.source.shm.file-fsync', @@ -102,6 +106,28 @@ const MOVED_PREFIX_FAULT_BOUNDARIES = FULL_RECOVERY_MANIFEST.flatMap((member) => `resume.prefix.${member}.inventory-directory-fsync`, ] as const) satisfies readonly InventoryV1QuarantineBoundary[]; +const JOURNAL_RESTART_FAULT_BOUNDARIES = [ + 'target-exclusivity-proven', + 'resume.source.journal.file-fsync-after-quiescence', + 'resume.source.main.file-fsync-after-quiescence', + 'resume.member.journal.rename', + 'resume.member.journal.file-fsync', + 'resume.member.journal.generation-directory-fsync', + 'resume.member.journal.inventory-directory-fsync', + 'resume.member.main.rename', + 'resume.member.main.file-fsync', + 'resume.member.main.generation-directory-fsync', + 'resume.member.main.inventory-directory-fsync', + 'resume.marker.unlink', + 'resume.inventory-directory.fsync-after-marker-unlink', +] as const satisfies readonly InventoryV1QuarantineBoundary[]; + +const JOURNAL_PREFIX_FAULT_BOUNDARIES = [ + 'resume.prefix.journal.file-fsync', + 'resume.prefix.journal.generation-directory-fsync', + 'resume.prefix.journal.inventory-directory-fsync', +] as const satisfies readonly InventoryV1QuarantineBoundary[]; + function expectedFullManifestTrace( boundary: (typeof FULL_MANIFEST_FAULT_BOUNDARIES)[number], ): InventoryV1QuarantineBoundary[] { @@ -148,6 +174,12 @@ function pragmaInteger(database: DatabaseSync, pragma: string): number { return value; } +function u64be(value: bigint): Buffer { + const encoded = Buffer.alloc(8); + encoded.writeBigUInt64BE(value); + return encoded; +} + function expectOpenErrorCode(error: unknown, code: InventoryV1OpenError['code']): boolean { expect(error).toBeInstanceOf(InventoryV1OpenError); expect((error as InventoryV1OpenError).code).toBe(code); @@ -242,6 +274,61 @@ setInterval(() => {}, 60_000); expect(existsSync(`${path}-shm`)).toBe(true); } +async function leaveHotRollbackJournal( + path: string, + transactionSql: string, +): Promise { + const script = String.raw` +const { DatabaseSync } = require('node:sqlite'); +const database = new DatabaseSync(process.env.DKG_RFC64_HOT_JOURNAL_PATH); +database.exec('PRAGMA journal_mode=DELETE; BEGIN IMMEDIATE'); +database.exec(process.env.DKG_RFC64_HOT_JOURNAL_SQL); +process.stdout.write('READY\n'); +setInterval(() => {}, 60_000); +`; + const child = spawn(process.execPath, ['--experimental-sqlite', '-e', script], { + env: { + ...process.env, + DKG_RFC64_HOT_JOURNAL_PATH: path, + DKG_RFC64_HOT_JOURNAL_SQL: transactionSql, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + await new Promise((resolveReady, rejectReady) => { + let stdout = ''; + const timeout = setTimeout(() => { + rejectReady(new Error(`hot-journal child did not become ready: ${stderr}`)); + }, 10_000); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + if (!stdout.includes('READY\n')) return; + clearTimeout(timeout); + resolveReady(); + }); + child.once('error', (error) => { + clearTimeout(timeout); + rejectReady(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + rejectReady(new Error( + `hot-journal child exited before ready: code=${code} signal=${signal} stderr=${stderr}`, + )); + }); + }); + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveExit) => child.once('exit', (code, signal) => resolveExit({ code, signal })), + ); + child.kill('SIGKILL'); + expect(await exit).toEqual({ code: null, signal: 'SIGKILL' }); + expect(lstatSync(`${path}-journal`).isFile()).toBe(true); + expect(lstatSync(`${path}-journal`).size).toBeGreaterThan(512); +} + function sha256File(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } @@ -838,6 +925,113 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }); + it('rejects hostile transfer geometry while accepting every exact chunk boundary', () => { + const database = new DatabaseSync(':memory:'); + try { + database.exec('PRAGMA foreign_keys = ON'); + database.exec(INVENTORY_V1_DDL); + const session = Buffer.alloc(32, 1); + const scope = Buffer.alloc(32, 2); + const author = Buffer.alloc(20, 3); + const head = Buffer.alloc(32, 4); + database.prepare( + 'INSERT INTO rfc64_candidate_bucket_loads_v1 VALUES (?,?,?,?,?,?,?,?,?,?,?)', + ).run( + session, + scope, + author, + head, + null, + u64be(0n), + u64be(1n), + u64be(0n), + Buffer.alloc(32, 5), + u64be(4n), + u64be(512n), + ); + const insert = database.prepare( + 'INSERT INTO rfc64_candidate_bucket_rows_v1 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', + ); + let nonce = 1; + const insertGeometry = (byteLength: bigint, chunkCount: bigint): void => { + const unique = nonce; + nonce += 1; + const kaId = Buffer.alloc(32); + kaId.writeUInt32BE(unique, 28); + insert.run( + session, + scope, + author, + head, + u64be(0n), + kaId, + Buffer.from(kaId), + `coordinate-${unique}`, + u64be(1n), + 'cg-shared-v1', + Buffer.alloc(32, 6), + Buffer.alloc(32, 7), + 'dkg-ka-bundle-v1', + u64be(byteLength), + u64be(262_144n), + u64be(chunkCount), + Buffer.alloc(32, 8), + Buffer.alloc(32, 9), + Buffer.alloc(32, 10), + ); + }; + + for (let chunkCount = 1n; chunkCount <= 4_096n; chunkCount += 1n) { + const lowerBoundary = chunkCount === 1n + ? 16n + : ((chunkCount - 1n) * 262_144n) + 1n; + const upperBoundary = chunkCount * 262_144n; + expect(() => insertGeometry(lowerBoundary, chunkCount)).not.toThrow(); + expect(() => insertGeometry(upperBoundary, chunkCount)).not.toThrow(); + } + for (const [byteLength, chunkCount] of [ + [0n, 0n], + [16n, 0n], + [16n, 4_096n], + [262_144n, 2n], + [262_145n, 1n], + [262_145n, 3n], + [1_073_741_824n, 4_095n], + ] as const) { + expect(() => insertGeometry(byteLength, chunkCount)).toThrow(/constraint/i); + } + + expect(database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_rows_v1', + ).get()?.count).toBe(8_192); + const plan = database.prepare(` + EXPLAIN QUERY PLAN + SELECT ka_id_u256be + FROM rfc64_candidate_bucket_rows_v1 + WHERE session_id = ? + AND catalog_scope_digest = ? + AND author_address = ? + AND target_catalog_head_digest = ? + AND bucket_id_u64be = ? + AND ka_id_u256be > ? + ORDER BY ka_id_u256be + LIMIT 256 + `).all( + session, + scope, + author, + head, + u64be(0n), + Buffer.alloc(32), + ); + expect(plan.map((row) => String(row.detail)).join('\n')).toMatch( + /USING COVERING INDEX rfc64_candidate_bucket_rows_by_bucket_v1 \(session_id=\? AND catalog_scope_digest=\? AND author_address=\? AND target_catalog_head_digest=\? AND bucket_id_u64be=\? AND ka_id_u256be>\?\)/, + ); + } finally { + database.close(); + } + }); + it('refuses a valid foreign application_id without modifying or quarantining it', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -942,6 +1136,141 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }); + it('classifies and safely recovers a main-plus-hot-journal unit before enabling WAL', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const initialized = await openInventoryV1(dataDirectory); + initialized.close(); + await leaveHotRollbackJournal( + path, + "CREATE TABLE uncommitted_intruder (value TEXT); INSERT INTO uncommitted_intruder VALUES ('lost')", + ); + const journalBefore = fileOracle(`${path}-journal`); + + const reopened = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + const database = new DatabaseSync(path, { readOnly: true }); + try { + expect(database.prepare( + "SELECT count(*) AS count FROM sqlite_schema WHERE name = 'uncommitted_intruder'", + ).get()?.count).toBe(0); + } finally { + database.close(); + } + expect(journalBefore.size).toBeGreaterThan(512); + expect(existsSync(`${path}-journal`)).toBe(false); + } finally { + reopened.close(); + } + }); + + it('recovers a hot journal before automatically quarantining its incompatible main', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, 'committed-before-hot-journal'); + await leaveHotRollbackJournal( + path, + "INSERT INTO wrong_v1 VALUES ('uncommitted-before-crash')", + ); + + const foundation = await openInventoryV1(dataDirectory); + try { + assertInitializedInventory(path); + const generation = evidenceGeneration(path); + expect(lstatSync(join(generation, 'inventory-v1.sqlite3-journal')).isFile()).toBe(true); + const quarantined = new DatabaseSync( + join(generation, 'inventory-v1.sqlite3'), + { readOnly: true }, + ); + try { + expect(quarantined.prepare('SELECT value FROM wrong_v1 ORDER BY rowid').all()).toEqual([ + { value: 'committed-before-hot-journal' }, + ]); + } finally { + quarantined.close(); + } + } finally { + foundation.close(); + } + }); + + it('moves residual rollback-journal evidence in the automatic quarantine manifest', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, 'residual-journal'); + const evidence = HOSTILE_SIDECAR_BYTES.journal; + const openWithResidualJournal = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + closeTarget: (close, reason) => { + close(); + if (reason === 'automatic-schema-quarantine') { + writeFileSync(`${path}-journal`, evidence); + } + }, + }); + + const foundation = await openWithResidualJournal(dataDirectory); + try { + assertInitializedInventory(path); + const generation = evidenceGeneration(path); + expect(readFileSync(join(generation, 'inventory-v1.sqlite3-journal'))).toEqual(evidence); + expect(existsSync(`${path}-journal`)).toBe(false); + } finally { + foundation.close(); + } + }); + + it('rejects pre-existing mixed journal modes before SQLite can mutate either sidecar', async () => { + for (const mixedMember of ['wal', 'shm'] as const) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const initialized = await openInventoryV1(dataDirectory); + initialized.close(); + writeFileSync(`${path}-journal`, HOSTILE_SIDECAR_BYTES.journal); + writeFileSync(`${path}-${mixedMember}`, HOSTILE_SIDECAR_BYTES[mixedMember]); + const before = { + main: fileOracle(path), + journal: fileOracle(`${path}-journal`), + mixed: fileOracle(`${path}-${mixedMember}`), + }; + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'ambiguous-database'), + ); + expect(fileOracle(path)).toEqual(before.main); + expect(fileOracle(`${path}-journal`)).toEqual(before.journal); + expect(fileOracle(`${path}-${mixedMember}`)).toEqual(before.mixed); + expectNoQuarantine(path); + } + }); + + it('fails closed on mixed rollback-journal and WAL evidence without starting quarantine', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, 'mixed-journal-mode-evidence'); + const journalEvidence = HOSTILE_SIDECAR_BYTES.journal; + const walEvidence = HOSTILE_SIDECAR_BYTES.wal; + const openWithMixedEvidence = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + closeTarget: (close, reason) => { + close(); + if (reason === 'automatic-schema-quarantine') { + writeFileSync(`${path}-journal`, journalEvidence); + writeFileSync(`${path}-wal`, walEvidence); + } + }, + }); + + await expect(openWithMixedEvidence(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'database-io'), + ); + expect(readFileSync(`${path}-journal`)).toEqual(journalEvidence); + expect(readFileSync(`${path}-wal`)).toEqual(walEvidence); + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + expect(quarantineGenerations(path)).toEqual([]); + }); + it('fails closed before automatic quarantine when namespace durability is unavailable', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -1132,7 +1461,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc database.close(); const sidecarTarget = join(sidecarData, 'sidecar-target'); writeFileSync(sidecarTarget, 'sidecar'); - symlinkSync(sidecarTarget, `${sidecarPath}-wal`); + symlinkSync(sidecarTarget, `${sidecarPath}-journal`); await expect(openInventoryV1(sidecarData)).rejects.toSatisfy((error: unknown) => expectOpenErrorCode(error, 'unsafe-path')); expectNoQuarantine(sidecarPath); @@ -1176,7 +1505,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); const originalMode = statSync(dataDirectory).mode & 0o777; - const actualUid = process.getuid(); + const actualUid = process.getuid!(); const uid = vi.spyOn(process, 'getuid').mockReturnValue(actualUid + 1); try { await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy((error: unknown) => @@ -1301,6 +1630,11 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc { version: 1, quarantineDirectory: 'placeholder' }, { version: 1, quarantineDirectory: 'placeholder', members: ['main'], extra: true }, { version: 1, quarantineDirectory: 'placeholder', members: ['main', 'wal'] }, + { version: 1, quarantineDirectory: 'placeholder', members: ['main', 'journal'] }, + { version: 1, quarantineDirectory: 'placeholder', members: ['journal', 'journal', 'main'] }, + { version: 1, quarantineDirectory: 'placeholder', members: ['journal', 'wal', 'main'] }, + { version: 1, quarantineDirectory: 'placeholder', members: ['journal', 'shm', 'main'] }, + { version: 1, quarantineDirectory: 'placeholder', members: ['journal', 'wal', 'shm', 'main'] }, { version: 1, quarantineDirectory: 'placeholder', members: ['wal', 'wal', 'main'] }, { version: 1, quarantineDirectory: 'placeholder', members: ['unknown', 'main'] }, ]; @@ -1616,6 +1950,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc it('exhaustively rejects every non-prefix source/destination/missing/duplicate manifest topology without mutation', async () => { const manifests = [ ['main'], + ['journal', 'main'], ['wal', 'main'], ['shm', 'main'], ['wal', 'shm', 'main'], @@ -1721,6 +2056,58 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }); + it('does not move a zero-prefix journal manifest before proving a raw SQLite holder is gone', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-9898989898989898', + ); + mkdirSync(generation, { recursive: true }); + createIncompatibleOwnedInventory(path, 'journal-zero-prefix-holder'); + const holder = new DatabaseSync(path); + holder.exec(` + PRAGMA journal_mode = DELETE; + BEGIN IMMEDIATE; + INSERT INTO wrong_v1 VALUES ('uncommitted-live-holder'); + `); + expect(lstatSync(`${path}-journal`).size).toBeGreaterThan(512); + const marker = Buffer.from(JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['journal', 'main'], + })); + writeFileSync(`${path}.rebuild-required`, marker); + const before = { + main: fileOracle(path), + journal: fileOracle(`${path}-journal`), + marker: fileOracle(`${path}.rebuild-required`), + sourceNames: readdirSync(dirname(path)) + .filter((name) => name.startsWith('inventory-v1.sqlite3')) + .sort(), + destinationNames: readdirSync(generation).sort(), + }; + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'database-busy'), + ); + expect(fileOracle(path)).toEqual(before.main); + expect(fileOracle(`${path}-journal`)).toEqual(before.journal); + expect(fileOracle(`${path}.rebuild-required`)).toEqual(before.marker); + expect(readdirSync(dirname(path)) + .filter((name) => name.startsWith('inventory-v1.sqlite3')) + .sort()).toEqual(before.sourceNames); + expect(readdirSync(generation).sort()).toEqual(before.destinationNames); + expect(readFileSync(`${path}.rebuild-required`)).toEqual(marker); + } + } finally { + holder.exec('ROLLBACK'); + holder.close(); + } + }); + it('resumes a partial recovery-marker move before rebuilding', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -1881,6 +2268,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc it('converges every frozen recovery manifest and valid moved-prefix topology', async () => { const manifests = [ ['main'], + ['journal', 'main'], ['wal', 'main'], ['shm', 'main'], ['wal', 'shm', 'main'], @@ -1996,9 +2384,104 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }, 180_000); + it('recovers journal evidence in a fresh process after SIGKILL at every restart-proof and move boundary', async () => { + for (const boundary of JOURNAL_RESTART_FAULT_BOUNDARIES) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const label = `journal-fault-${boundary}`; + const seeded = seedPendingRecoveryTopology( + dataDirectory, + JOURNAL_RECOVERY_MANIFEST, + 0, + label, + ); + const tracePath = join(dataDirectory, `trace-${boundary}.jsonl`); + const trace = await killAtInventoryBoundary(dataDirectory, boundary, tracePath, { + DKG_RFC64_CHILD_PROTECT_EVIDENCE: '1', + }); + expect(trace.map((entry) => entry.boundary)).toEqual( + JOURNAL_RESTART_FAULT_BOUNDARIES.slice( + 0, + JOURNAL_RESTART_FAULT_BOUNDARIES.indexOf(boundary) + 1, + ), + ); + const killedEvidence = new Map(); + for (const member of JOURNAL_RECOVERY_MANIFEST) { + const source = recoverySourcePath(path, member); + const destination = recoveryDestinationPath(seeded.generation, member); + expect(existsSync(source) || existsSync(destination)).toBe(true); + killedEvidence.set(member, fileOracle(existsSync(source) ? source : destination)); + } + + try { + await reopenInventoryInFreshProcess(dataDirectory); + } catch (cause) { + throw new Error(`fresh-process journal recovery failed after ${boundary}`, { cause }); + } + assertInitializedInventory(path); + assertRecoveredManifestEvidence( + path, + JOURNAL_RECOVERY_MANIFEST, + label, + seeded.generation, + seeded.hashes, + ); + for (const member of JOURNAL_RECOVERY_MANIFEST) { + expect(fileOracle(recoveryDestinationPath(seeded.generation, member))).toEqual( + killedEvidence.get(member), + ); + } + } + }, 120_000); + + it('recovers a journal moved-prefix in a fresh process after SIGKILL at every prefix durability boundary', async () => { + for (const boundary of JOURNAL_PREFIX_FAULT_BOUNDARIES) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const label = `journal-prefix-fault-${boundary}`; + const seeded = seedPendingRecoveryTopology( + dataDirectory, + JOURNAL_RECOVERY_MANIFEST, + 1, + label, + true, + ); + const journalBefore = fileOracle( + recoveryDestinationPath(seeded.generation, 'journal'), + ); + const mainBefore = fileOracle(path); + const tracePath = join(dataDirectory, `trace-${boundary}.jsonl`); + const trace = await killAtInventoryBoundary(dataDirectory, boundary, tracePath, { + DKG_RFC64_CHILD_PROTECT_EVIDENCE: '1', + }); + expect(trace.map((entry) => entry.boundary)).toEqual( + JOURNAL_PREFIX_FAULT_BOUNDARIES.slice( + 0, + JOURNAL_PREFIX_FAULT_BOUNDARIES.indexOf(boundary) + 1, + ), + ); + + await reopenInventoryInFreshProcess(dataDirectory); + assertInitializedInventory(path); + assertRecoveredManifestEvidence( + path, + JOURNAL_RECOVERY_MANIFEST, + label, + seeded.generation, + seeded.hashes, + ); + expect(fileOracle(recoveryDestinationPath(seeded.generation, 'journal'))).toEqual( + journalBefore, + ); + expect(fileOracle(recoveryDestinationPath(seeded.generation, 'main'))).toEqual( + mainBefore, + ); + } + }, 60_000); + it('recovers in a fresh process after SIGKILL at every moved-prefix re-fsync boundary', async () => { for (const boundary of MOVED_PREFIX_FAULT_BOUNDARIES) { - const member = boundary.split('.')[2] as RecoveryMember; + const member = boundary.split('.')[2] as FullRecoveryMember; const movedPrefix = FULL_RECOVERY_MANIFEST.indexOf(member) + 1; const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); From 85cc5c809d45f42354a97e4f5ed7eec590976a39 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:41:13 +0200 Subject: [PATCH 051/292] test(agent): accept primary-key candidate plan --- packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index 5e2d10c712..375d675739 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -1049,9 +1049,11 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc u64be(0n), Buffer.alloc(32), ); - expect(plan.map((row) => String(row.detail)).join('\n')).toMatch( - /USING COVERING INDEX rfc64_candidate_bucket_rows_by_bucket_v1 \(session_id=\? AND catalog_scope_digest=\? AND author_address=\? AND target_catalog_head_digest=\? AND bucket_id_u64be=\? AND ka_id_u256be>\?\)/, + const planDetail = plan.map((row) => String(row.detail)).join('\n'); + expect(planDetail).toMatch( + /USING PRIMARY KEY \(session_id=\? AND catalog_scope_digest=\? AND author_address=\? AND target_catalog_head_digest=\? AND bucket_id_u64be=\? AND ka_id_u256be>\?\)/, ); + expect(planDetail).not.toMatch(/SCAN|USE TEMP B-TREE/); } finally { database.close(); } From 03c83b85429ed1dd9701626ea3dbcbc89e786632 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:48:33 +0200 Subject: [PATCH 052/292] feat(chain): add strict finalized RPC transport --- .../chain/src/current-finalized-evm-call.ts | 7 +- packages/chain/src/index.ts | 6 + .../src/strict-current-finalized-evm-rpc.ts | 720 ++++++++++++++++++ ...ict-current-finalized-evm-rpc.unit.test.ts | 545 +++++++++++++ 4 files changed, 1276 insertions(+), 2 deletions(-) create mode 100644 packages/chain/src/strict-current-finalized-evm-rpc.ts create mode 100644 packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts diff --git a/packages/chain/src/current-finalized-evm-call.ts b/packages/chain/src/current-finalized-evm-call.ts index 3dd62e5081..3a68047859 100644 --- a/packages/chain/src/current-finalized-evm-call.ts +++ b/packages/chain/src/current-finalized-evm-call.ts @@ -66,7 +66,7 @@ export function createCurrentFinalizedEvmCallRouterV1( for (const chainId of adapters.keys()) inFlightByChain.set(chainId, 0); const route: CurrentFinalizedEvmCallV1 = async (input) => { - const request = snapshotAndValidateRequest(input); + const request = snapshotCurrentFinalizedEvmCallRequestV1(input); const adapter = adapters.get(request.chainId); if (adapter === undefined) { throw new CurrentFinalizedEvmCallErrorV1( @@ -196,7 +196,10 @@ function snapshotRegistration(input: unknown): Readonly boolean; + readonly close: () => void; +} + +interface RpcErrorEnvelopeV1 { + readonly code: number; + readonly message: string; +} + +const CONFIG_REQUIRED_KEYS = Object.freeze(['chainId', 'endpoints'] as const); +const CONFIG_OPTIONAL_KEYS = Object.freeze(['blockReferenceProfile'] as const); +const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; +const CANONICAL_LOWER_QUANTITY = /^0x(?:0|[1-9a-f][0-9a-f]*)$/; +const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; +const MAX_U64 = 18_446_744_073_709_551_615n; +const MAX_U256 = + 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; +const RPC_CALL_GAS_QUANTITY = `0x${CONTROL_EIP1271_GAS_LIMIT_V1.toString(16)}`; +const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); + +/** + * Build one strict raw-JSON-RPC adapter from trusted local chain configuration. + * + * This transport intentionally bypasses JsonRpcProvider, RpcFailoverClient, + * and generic timeout wrappers: native fetch gives this lane a streamed + * pre-parse body cap and an AbortSignal that cancels the actual HTTP I/O. POST + * requests are never retried, redirected, reordered, or made sticky. + */ +export function createStrictCurrentFinalizedEvmChainAdapterV1( + input: StrictCurrentFinalizedEvmRpcConfigV1, +): CurrentFinalizedEvmChainAdapterV1 { + const config = snapshotConfig(input); + + const adapter: CurrentFinalizedEvmChainAdapterV1 = async (inputRequest) => { + const request = snapshotCurrentFinalizedEvmCallRequestV1(inputRequest); + if (request.chainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Adapter is configured for chain ${config.chainId}, not ${request.chainId}`, + ); + } + if (request.signal.aborted) { + throw cancelled('Current-finalized EVM call was cancelled before transport admission'); + } + + const totalDeadline = createDeadlineScope( + request.signal, + request.totalDeadlineMs, + 'current-finalized total deadline', + ); + let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + + try { + for (let index = 0; index < config.endpoints.length; index += 1) { + if (request.signal.aborted) { + throw cancelled('Current-finalized EVM call was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut(`Current-finalized total deadline exceeded ${request.totalDeadlineMs}ms`); + } + + const attemptDeadline = createDeadlineScope( + totalDeadline.signal, + request.attemptTimeoutMs, + `current-finalized endpoint attempt ${index + 1}`, + ); + try { + const result = await executeEndpointAttempt( + config, + config.endpoints[index]!, + request, + attemptDeadline.signal, + ); + // Close races where transport completion and abort/deadline become + // observable in the same turn. A late response never escapes merely + // because its promise callback ran before the timer callback. + if (request.signal.aborted) { + throw cancelled('Current-finalized EVM call was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized total deadline exceeded ${request.totalDeadlineMs}ms`, + ); + } + if (attemptDeadline.timedOut()) { + throw timedOut( + `Current-finalized endpoint attempt exceeded ${request.attemptTimeoutMs}ms`, + ); + } + return result; + } catch (cause) { + const failure = classifyAttemptFailure( + cause, + request.signal, + totalDeadline, + attemptDeadline, + ); + if (isTerminalAttemptFailure(failure)) throw failure; + lastRetryableFailure = failure; + } finally { + attemptDeadline.close(); + } + } + + if (request.signal.aborted) { + throw cancelled('Current-finalized EVM call was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut(`Current-finalized total deadline exceeded ${request.totalDeadlineMs}ms`); + } + throw lastRetryableFailure ?? unavailable('No configured current-finalized endpoint succeeded'); + } finally { + totalDeadline.close(); + } + }; + + return Object.freeze(adapter); +} + +async function executeEndpointAttempt( + config: StrictRpcConfigSnapshotV1, + endpoint: string, + request: CurrentFinalizedEvmCallRequestV1, + signal: AbortSignal, +): Promise { + let requestId = 0; + const rpc = async (method: string, params: readonly unknown[]): Promise => { + requestId += 1; + return postJsonRpc(endpoint, requestId, method, params, request.maxRpcResponseBytes, signal); + }; + + const remoteChainId = parseChainId(await rpc('eth_chainId', Object.freeze([]))); + if (remoteChainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Configured endpoint attempt reported chain ${remoteChainId}, expected ${config.chainId}`, + ); + } + + const anchor = parseFinalizedAnchor( + await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), + 'current finalized header', + ); + const callObject = Object.freeze({ + from: request.from, + to: request.to, + data: request.data, + gas: RPC_CALL_GAS_QUANTITY, + }); + + let returnData: string; + if (config.blockReferenceProfile === 'eip1898') { + const blockReference = Object.freeze({ + blockHash: anchor.blockHash, + requireCanonical: true as const, + }); + const code = await rpc('eth_getCode', Object.freeze([request.to, blockReference])); + assertDeployedCode(code); + returnData = parseContractReturn( + await rpc('eth_call', Object.freeze([callObject, blockReference])), + request.maxReturnBytes, + ); + } else { + // Number-selected code/call evidence is not deterministic until the + // same-endpoint post-read closes the hash sandwich. Hold anchor-dependent + // outcomes until then, so a reorg cannot manufacture no-code/revert, a + // malformed return, or an execution-cap failure and poison admission. + let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + let provisionalReturnData: string | undefined; + try { + const code = await rpc( + 'eth_getCode', + Object.freeze([request.to, anchor.blockNumberQuantity]), + ); + assertDeployedCode(code); + provisionalReturnData = parseContractReturn( + await rpc('eth_call', Object.freeze([callObject, anchor.blockNumberQuantity])), + request.maxReturnBytes, + ); + } catch (cause) { + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && ( + cause.code === 'no-code' + || cause.code === 'revert' + || cause.code === 'malformed-return' + || isAnchorDependentResourceLimit(cause) + ) + ) { + anchorDependentFailure = cause; + } else { + throw cause; + } + } + + const postAnchor = parseFinalizedAnchor( + await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + 'post-call numbered header', + ); + if ( + postAnchor.blockNumber !== anchor.blockNumber + || postAnchor.blockHash !== anchor.blockHash + ) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + 'Block-number fallback hash sandwich did not preserve the resolved finalized anchor', + ); + } + if (anchorDependentFailure !== undefined) throw anchorDependentFailure; + if (provisionalReturnData === undefined) { + throw unavailable('Block-number fallback produced no contract result'); + } + returnData = provisionalReturnData; + } + + return Object.freeze({ + chainId: config.chainId, + blockNumber: anchor.blockNumber, + blockHash: anchor.blockHash, + returnData, + }); +} + +async function postJsonRpc( + endpoint: string, + id: number, + method: string, + params: readonly unknown[], + maxResponseBytes: number, + signal: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers: Object.freeze({ + accept: 'application/json', + 'content-type': 'application/json', + }), + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + redirect: 'error', + signal, + }); + } catch (cause) { + if (signal.aborted) throw cause; + throw unavailable(`JSON-RPC ${method} transport failed`, cause); + } + + const body = await readResponseBodyBounded(response, maxResponseBytes); + if (!response.ok) { + // An HTTP intermediary/provider failure is transport availability, even if + // its untrusted body happens to mimic a deterministic JSON-RPC revert. Only + // a successful JSON-RPC transport response may select an invalidity code. + throw unavailable(`JSON-RPC ${method} returned HTTP ${response.status}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch (cause) { + throw unavailable(`JSON-RPC ${method} returned malformed JSON`, cause); + } + if (!isPlainRecord(parsed) || parsed.jsonrpc !== '2.0' || parsed.id !== id) { + throw unavailable(`JSON-RPC ${method} returned a mismatched response envelope`); + } + const hasResult = Object.prototype.hasOwnProperty.call(parsed, 'result'); + const hasError = Object.prototype.hasOwnProperty.call(parsed, 'error'); + if (hasResult === hasError) { + throw unavailable(`JSON-RPC ${method} response must contain exactly one of result or error`); + } + if (hasError) { + const error = parseRpcError(parsed.error); + if (error === undefined) throw unavailable(`JSON-RPC ${method} returned a malformed error`); + throw classifyJsonRpcError(method, error); + } + return parsed.result; +} + +async function readResponseBodyBounded(response: Response, maxBytes: number): Promise { + const contentLength = response.headers.get('content-length'); + if (contentLength !== null && /^\d+$/.test(contentLength)) { + const declared = BigInt(contentLength); + if (declared > BigInt(maxBytes)) { + await response.body?.cancel().catch(() => undefined); + throw resourceLimited( + `Raw JSON-RPC response declared ${declared.toString()} bytes, limit ${maxBytes}`, + ); + } + } + + if (response.body === null) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw resourceLimited(`Raw JSON-RPC response exceeded ${maxBytes} bytes before parsing`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (cause) { + throw unavailable('JSON-RPC response body is not valid UTF-8', cause); + } +} + +function parseChainId(input: unknown): ChainIdV1 { + let parsed: bigint; + try { + parsed = parseCanonicalQuantity(input, MAX_U256); + } catch (cause) { + throw unavailable('eth_chainId returned a malformed chain ID', cause); + } + return parsed.toString(10) as ChainIdV1; +} + +function parseFinalizedAnchor(input: unknown, label: string): FinalizedAnchorV1 { + if (input === null) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} is unavailable`, + ); + } + if (!isPlainRecord(input)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} is malformed`, + ); + } + + let blockNumber: bigint; + try { + blockNumber = parseCanonicalQuantity(input.number, MAX_U64); + } catch (cause) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} has a malformed block number`, + { cause }, + ); + } + if (typeof input.hash !== 'string' || !CANONICAL_DIGEST_32.test(input.hash)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} has a malformed block hash`, + ); + } + return Object.freeze({ + blockNumber: blockNumber.toString(10) as BlockNumberV1, + blockNumberQuantity: input.number as string, + blockHash: input.hash as Digest32V1, + }); +} + +function assertDeployedCode(input: unknown): void { + if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { + throw unavailable('eth_getCode returned malformed code bytes'); + } + if (input === '0x') { + throw new CurrentFinalizedEvmCallErrorV1( + 'no-code', + 'EIP-1271 issuer has no deployed code at the resolved finalized anchor', + ); + } +} + +function parseContractReturn(input: unknown, maxBytes: number): string { + if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + 'EIP-1271 eth_call returned malformed bytes', + ); + } + const byteLength = (input.length - 2) / 2; + if (byteLength > maxBytes || byteLength !== 32) { + throw new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + `EIP-1271 eth_call returned ${byteLength} bytes; exactly 32 are required`, + ); + } + return input; +} + +function parseCanonicalQuantity(input: unknown, maximum: bigint): bigint { + if (typeof input !== 'string' || !CANONICAL_LOWER_QUANTITY.test(input)) { + throw new Error('not a canonical lowercase JSON-RPC quantity'); + } + const parsed = BigInt(input); + if (parsed > maximum) throw new Error('JSON-RPC quantity is out of range'); + return parsed; +} + +function parseRpcError(input: unknown): RpcErrorEnvelopeV1 | undefined { + if ( + !isPlainRecord(input) + || typeof input.code !== 'number' + || !Number.isSafeInteger(input.code) + || typeof input.message !== 'string' + ) { + return undefined; + } + return Object.freeze({ code: input.code, message: input.message }); +} + +function classifyJsonRpcError( + method: string, + error: RpcErrorEnvelopeV1, +): CurrentFinalizedEvmCallErrorV1 { + const message = error.message.toLowerCase(); + if ( + method === 'eth_call' + && ( + message.includes('out of gas') + || message.includes('gas limit') + || message.includes('gas required') + || message.includes('exceeds allowance') + || message.includes('intrinsic gas') + ) + ) { + return anchorDependentResourceLimited( + 'EIP-1271 execution could not complete within the fixed gas cap', + ); + } + if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { + return new CurrentFinalizedEvmCallErrorV1( + 'revert', + 'EIP-1271 contract call reverted at the resolved finalized anchor', + ); + } + if (message.includes('timeout') || message.includes('timed out')) { + return timedOut(`JSON-RPC ${method} timed out`); + } + if ( + method === 'eth_getBlockByNumber' + || message.includes('header not found') + || message.includes('unknown block') + || message.includes('block not found') + || message.includes('canonical') + ) { + return new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `JSON-RPC ${method} could not prove the required finalized anchor`, + ); + } + return unavailable(`JSON-RPC ${method} failed with code ${error.code}`); +} + +function classifyAttemptFailure( + cause: unknown, + callerSignal: AbortSignal, + totalDeadline: DeadlineScope, + attemptDeadline: DeadlineScope, +): CurrentFinalizedEvmCallErrorV1 { + if (callerSignal.aborted) return cancelled('Current-finalized EVM call was cancelled'); + if (totalDeadline.timedOut()) { + return timedOut(`Current-finalized total deadline exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`); + } + if (attemptDeadline.timedOut()) { + return timedOut(`Current-finalized endpoint attempt exceeded ${CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1}ms`); + } + if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; + return unavailable('Current-finalized endpoint attempt failed closed', cause); +} + +function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { + return error.code === 'unsupported-chain' + || error.code === 'resource-limit' + || error.code === 'revert' + || error.code === 'no-code' + || error.code === 'malformed-return'; +} + +function createDeadlineScope( + parent: AbortSignal, + timeoutMs: number, + label: string, +): DeadlineScope { + const controller = new AbortController(); + let didTimeOut = false; + const expiresAt = performance.now() + timeoutMs; + const parentAbort = (): void => controller.abort(parent.reason); + if (parent.aborted) parentAbort(); + else parent.addEventListener('abort', parentAbort, { once: true }); + const timer = setTimeout(() => { + didTimeOut = true; + controller.abort(new Error(`${label} exceeded ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + return Object.freeze({ + signal: controller.signal, + timedOut: () => didTimeOut || performance.now() >= expiresAt, + close: () => { + clearTimeout(timer); + parent.removeEventListener('abort', parentAbort); + }, + }); +} + +function snapshotConfig(input: StrictCurrentFinalizedEvmRpcConfigV1): StrictRpcConfigSnapshotV1 { + if (!isPlainRecord(input)) { + throw new TypeError('Strict current-finalized RPC config must be a plain data record'); + } + assertConfigDataProperties(input); + try { + assertCanonicalChainId(input.chainId, 'strict current-finalized chainId'); + } catch { + throw new TypeError('Strict current-finalized chainId must be canonical decimal u256'); + } + + const endpoints = snapshotNormalizedEndpoints(input.endpoints); + const blockReferenceProfile = input.blockReferenceProfile ?? 'eip1898'; + if (!CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1.includes(blockReferenceProfile)) { + throw new TypeError('Unsupported strict current-finalized block reference profile'); + } + return Object.freeze({ + chainId: input.chainId, + endpoints, + blockReferenceProfile, + }); +} + +function assertConfigDataProperties(input: Record): void { + const keys = Reflect.ownKeys(input); + if (keys.some((key) => typeof key !== 'string')) { + throw new TypeError('Strict current-finalized RPC config cannot contain symbol keys'); + } + const allowed = new Set([...CONFIG_REQUIRED_KEYS, ...CONFIG_OPTIONAL_KEYS]); + if ( + !CONFIG_REQUIRED_KEYS.every((key) => keys.includes(key)) + || (keys as string[]).some((key) => !allowed.has(key)) + ) { + throw new TypeError('Strict current-finalized RPC config has unknown or missing fields'); + } + for (const key of keys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new TypeError('Strict current-finalized RPC config fields must be enumerable data properties'); + } + } +} + +function snapshotNormalizedEndpoints(input: unknown): readonly string[] { + const normalized: string[] = []; + try { + if (!Array.isArray(input)) throw new Error('not an array'); + const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); + if ( + lengthDescriptor === undefined + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + ) { + throw new Error('invalid length'); + } + const length = lengthDescriptor.value; + const ownKeys = Reflect.ownKeys(input); + if ( + ownKeys.length !== length + 1 + || ownKeys.some((key) => ( + typeof key !== 'string' + || (key !== 'length' && !isCanonicalArrayIndex(key, length)) + )) + ) { + throw new Error('not a dense ordinary array'); + } + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('entry is not a data property'); + } + const endpoint = normalizeEndpoint(descriptor.value); + if (!normalized.includes(endpoint)) normalized.push(endpoint); + } + } catch (cause) { + if (cause instanceof TypeError) throw cause; + throw new TypeError('Strict current-finalized endpoints must be a dense data-only array'); + } + if (normalized.length === 0 || normalized.length > CONTROL_EIP1271_MAX_ATTEMPTS_V1) { + throw new TypeError( + `Strict current-finalized RPC requires 1..${CONTROL_EIP1271_MAX_ATTEMPTS_V1} distinct endpoints`, + ); + } + return Object.freeze(normalized); +} + +function isCanonicalArrayIndex(key: string, length: number): boolean { + if (!/^(?:0|[1-9][0-9]*)$/.test(key)) return false; + const index = Number(key); + return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key; +} + +function normalizeEndpoint(input: unknown): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new TypeError('Strict current-finalized RPC endpoint must be a nonempty URL string'); + } + let url: URL; + try { + url = new URL(input.trim()); + } catch { + throw new TypeError('Strict current-finalized RPC endpoint must be an absolute URL'); + } + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.hash !== '') { + throw new TypeError('Strict current-finalized RPC endpoint must use HTTP(S) without a fragment'); + } + return url.href; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function unavailable(message: string, cause?: unknown): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1( + 'rpc-unavailable', + message, + cause === undefined ? undefined : { cause }, + ); +} + +function timedOut(message: string): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1('rpc-timeout', message); +} + +function resourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1('resource-limit', message); +} + +function anchorDependentResourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { + const error = resourceLimited(message); + ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.add(error); + return error; +} + +function isAnchorDependentResourceLimit( + error: CurrentFinalizedEvmCallErrorV1, +): boolean { + return error.code === 'resource-limit' + && ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.has(error); +} + +function cancelled(message: string): CurrentFinalizedEvmCallErrorV1 { + // Caller intent is authenticated by the verifier-owned AbortSignal. Keep + // the adapter error retryable so a foreign gateway cannot forge a cancelled + // disposition merely by throwing a public error code; the verifier observes + // its caller signal first and maps this exact path to `cancelled`. + return new CurrentFinalizedEvmCallErrorV1('rpc-unavailable', message); +} diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts new file mode 100644 index 0000000000..9b91f37edc --- /dev/null +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -0,0 +1,545 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { + type ChainIdV1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, + CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + CONTROL_EIP1271_GAS_LIMIT_V1, + CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + type CurrentFinalizedEvmCallRequestV1, +} from '../src/control-object-signature-verifier.js'; +import { + createStrictCurrentFinalizedEvmChainAdapterV1, + type StrictCurrentFinalizedEvmRpcConfigV1, +} from '../src/strict-current-finalized-evm-rpc.js'; + +const CHAIN_ID = '20430' as ChainIdV1; +const CHAIN_QUANTITY = '0x4fce'; +const TO = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const BLOCK_HASH = `0x${'22'.repeat(32)}`; +const OTHER_BLOCK_HASH = `0x${'23'.repeat(32)}`; +const OBJECT_DIGEST = `${'33'.repeat(32)}`; +const CANONICAL_CALL_DATA = `0x1626ba7e${OBJECT_DIGEST}${'0'.repeat(62)}40${'0'.repeat(63)}1aa${'0'.repeat(62)}`; +const MAGIC_RETURN = `0x1626ba7e${'00'.repeat(28)}`; +const WRONG_MAGIC_RETURN = `0xffffffff${'00'.repeat(28)}`; + +interface JsonRpcRequest { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly method: string; + readonly params: readonly unknown[]; +} + +interface LoopbackRpcServer { + readonly url: string; + readonly calls: JsonRpcRequest[]; + readonly stop: () => Promise; +} + +type LoopbackHandler = ( + call: JsonRpcRequest, + response: ServerResponse, + request: IncomingMessage, +) => void | Promise; + +const activeServers: LoopbackRpcServer[] = []; + +afterEach(async () => { + await Promise.all(activeServers.splice(0).map((server) => server.stop())); +}); + +describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { + it('uses the configured endpoint once, in exact EIP-1898 request order and shape', async () => { + const server = await startRpcServer(successfulHandler()); + const configuredEndpoints = [server.url]; + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: configuredEndpoints, + }); + configuredEndpoints[0] = 'http://peer-controlled.invalid'; + + await expect(adapter(fixedRequest())).resolves.toEqual({ + chainId: CHAIN_ID, + blockNumber: '123', + blockHash: BLOCK_HASH, + returnData: MAGIC_RETURN, + }); + expect(Object.isFrozen(adapter)).toBe(true); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + ]); + expect(server.calls.map(({ id }) => id)).toEqual([1, 2, 3, 4]); + expect(server.calls[0]!.params).toEqual([]); + expect(server.calls[1]!.params).toEqual(['finalized', false]); + const hashReference = { blockHash: BLOCK_HASH, requireCanonical: true }; + expect(server.calls[2]!.params).toEqual([TO, hashReference]); + expect(server.calls[3]!.params).toEqual([{ + from: CONTROL_EIP1271_CALL_FROM_V1, + to: TO, + data: CANONICAL_CALL_DATA, + gas: '0xf4240', + }, hashReference]); + }); + + it('uses the explicit trusted number profile with a same-endpoint post-hash sandwich', async () => { + const server = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(adapter(fixedRequest())).resolves.toMatchObject({ + blockNumber: '123', + blockHash: BLOCK_HASH, + }); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getBlockByNumber', + ]); + expect(server.calls[2]!.params).toEqual([TO, '0x7b']); + expect(server.calls[3]!.params[1]).toBe('0x7b'); + expect(server.calls[4]!.params).toEqual(['0x7b', false]); + }); + + it('fails over in canonical order after wrong-chain evidence without endpoint stickiness', async () => { + const first = await startRpcServer((call, response) => { + sendResult(response, call, '0x1'); + }); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, `${second.url}/`], + }); + + await expect(adapter(fixedRequest())).resolves.toMatchObject({ chainId: CHAIN_ID }); + await expect(adapter(fixedRequest())).resolves.toMatchObject({ chainId: CHAIN_ID }); + expect(first.calls.map(({ method }) => method)).toEqual(['eth_chainId', 'eth_chainId']); + expect(second.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + ]); + }); + + it('deduplicates normalized endpoints and performs no hidden same-endpoint retry', async () => { + const server = await startRpcServer((call, response) => { + response.writeHead(503, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + jsonrpc: '2.0', + id: call.id, + error: { code: -32000, message: 'temporarily unavailable' }, + })); + }); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [server.url, `${server.url}/`], + }); + + await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'rpc-unavailable' }); + expect(server.calls).toHaveLength(1); + }); + + it('never follows a redirect to an endpoint outside trusted local configuration', async () => { + const unconfigured = await startRpcServer(successfulHandler()); + const configured = await startRpcServer((_call, response) => { + response.writeHead(307, { location: unconfigured.url }); + response.end(); + }); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [configured.url], + }); + + await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'rpc-unavailable' }); + expect(configured.calls).toHaveLength(1); + expect(unconfigured.calls).toHaveLength(0); + }); + + it('advances after a fallback hash mismatch and never accepts mixed-anchor evidence', async () => { + let finalizedReads = 0; + const first = await startRpcServer(successfulHandler({ + blockHash: () => (++finalizedReads === 1 ? BLOCK_HASH : OTHER_BLOCK_HASH), + })); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(adapter(fixedRequest())).resolves.toMatchObject({ blockHash: BLOCK_HASH }); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getBlockByNumber', + ]); + expect(second.calls).toHaveLength(5); + }); + + it('does not turn reorg-produced fallback no-code into deterministic invalidity', async () => { + let finalizedReads = 0; + const first = await startRpcServer(successfulHandler({ + blockHash: () => (++finalizedReads === 1 ? BLOCK_HASH : OTHER_BLOCK_HASH), + code: '0x', + })); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(adapter(fixedRequest())).resolves.toMatchObject({ blockHash: BLOCK_HASH }); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_getBlockByNumber', + ]); + expect(second.calls).toHaveLength(5); + }); + + it('does not turn reorg-produced fallback out-of-gas into a terminal resource limit', async () => { + let finalizedReads = 0; + const first = await startRpcServer(successfulHandler({ + blockHash: () => (++finalizedReads === 1 ? BLOCK_HASH : OTHER_BLOCK_HASH), + outOfGas: true, + })); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(adapter(fixedRequest())).resolves.toMatchObject({ blockHash: BLOCK_HASH }); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getBlockByNumber', + ]); + expect(second.calls).toHaveLength(5); + }); + + it('keeps fallback out-of-gas terminal after the same-anchor post-read succeeds', async () => { + const first = await startRpcServer(successfulHandler({ outOfGas: true })); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'resource-limit' }); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getBlockByNumber', + ]); + expect(second.calls).toHaveLength(0); + }); + + it('stops on terminal no-code evidence without contacting a later endpoint', async () => { + const first = await startRpcServer(successfulHandler({ code: '0x' })); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + + await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'no-code' }); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + ]); + expect(second.calls).toHaveLength(0); + }); + + it('stops on a deterministic contract revert without contacting a later endpoint', async () => { + const first = await startRpcServer(successfulHandler({ revert: true })); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + + await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'revert' }); + expect(second.calls).toHaveLength(0); + }); + + it('rejects malformed contract returns but preserves exact wrong magic for the verifier', async () => { + const malformed = await startRpcServer(successfulHandler({ returnData: '0x1626ba7e' })); + const malformedAdapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [malformed.url], + }); + await expect(malformedAdapter(fixedRequest())) + .rejects.toMatchObject({ code: 'malformed-return' }); + + const wrong = await startRpcServer(successfulHandler({ returnData: WRONG_MAGIC_RETURN })); + const wrongAdapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [wrong.url], + }); + await expect(wrongAdapter(fixedRequest())).resolves.toMatchObject({ + returnData: WRONG_MAGIC_RETURN, + }); + }); + + it('enforces the 65,536-byte cap while streaming, before JSON parsing or failover', async () => { + const first = await startRpcServer((_call, response) => { + response.writeHead(200, { + 'content-type': 'application/json', + 'transfer-encoding': 'chunked', + }); + response.write(' '.repeat(40_000)); + response.end(' '.repeat(30_000)); + }); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + + await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'resource-limit' }); + expect(first.calls).toHaveLength(1); + expect(second.calls).toHaveLength(0); + }); + + it('cancels the actual loopback HTTP operation and does not fail over', async () => { + const received = deferred(); + const connectionClosed = deferred(); + const first = await startRpcServer((_call, response) => { + response.on('close', () => connectionClosed.resolve()); + received.resolve(); + // Deliberately leave the response open until AbortSignal cancels fetch. + }); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + const controller = new AbortController(); + const operation = adapter(fixedRequest({ signal: controller.signal })); + + await received.promise; + controller.abort(new Error('caller stopped')); + // The transport closes as rpc-unavailable; the verifier-owned caller + // signal maps this path to the public `cancelled` disposition. + await expect(operation).rejects.toMatchObject({ code: 'rpc-unavailable' }); + await connectionClosed.promise; + expect(second.calls).toHaveLength(0); + }); + + it('enforces one four-second whole-attempt budget, cancels it, then advances', async () => { + const firstClosed = deferred(); + const delayedSuccess = successfulHandler(); + const first = await startRpcServer(async (call, response, request) => { + if (call.method === 'eth_call') response.on('close', () => firstClosed.resolve()); + // Each RPC is individually under four seconds, but the chain/header/code + // work consumes most of the one shared attempt budget. The call is then + // cancelled by that original timer (a per-RPC timer would wrongly pass). + await new Promise((resolve) => setTimeout(resolve, 1_100)); + if (!response.destroyed) await delayedSuccess(call, response, request); + }); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + const started = Date.now(); + + await expect(adapter(fixedRequest())).resolves.toMatchObject({ chainId: CHAIN_ID }); + const elapsed = Date.now() - started; + expect(elapsed).toBeGreaterThanOrEqual(CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 - 200); + expect(elapsed).toBeLessThan(CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1); + await firstClosed.promise; + expect(first.calls).toHaveLength(4); + expect(second.calls).toHaveLength(4); + }, 12_000); + + it('rejects unsafe configuration and more than two distinct normalized endpoints', () => { + const third = 'http://127.0.0.1:3'; + for (const config of [ + { chainId: '020430', endpoints: ['http://127.0.0.1:1'] }, + { chainId: CHAIN_ID, endpoints: [] }, + { chainId: CHAIN_ID, endpoints: ['ftp://127.0.0.1/a'] }, + { chainId: CHAIN_ID, endpoints: ['http://127.0.0.1/a#fragment'] }, + { chainId: CHAIN_ID, endpoints: [ + 'http://127.0.0.1:1', + 'http://127.0.0.1:2', + third, + ] }, + { chainId: CHAIN_ID, endpoints: ['http://127.0.0.1:1'], peerEndpoint: third }, + ]) { + expect(() => createStrictCurrentFinalizedEvmChainAdapterV1( + config as StrictCurrentFinalizedEvmRpcConfigV1, + )).toThrow(TypeError); + } + }); +}); + +interface SuccessfulHandlerOptions { + readonly blockHash?: string | (() => string); + readonly code?: string; + readonly outOfGas?: boolean; + readonly returnData?: string; + readonly revert?: boolean; +} + +function successfulHandler(options: SuccessfulHandlerOptions = {}): LoopbackHandler { + return (call, response) => { + switch (call.method) { + case 'eth_chainId': + sendResult(response, call, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': { + const selectedHash = typeof options.blockHash === 'function' + ? options.blockHash() + : (options.blockHash ?? BLOCK_HASH); + sendResult(response, call, { number: '0x7b', hash: selectedHash }); + return; + } + case 'eth_getCode': + sendResult(response, call, options.code ?? '0x6000'); + return; + case 'eth_call': + if (options.outOfGas) { + sendError(response, call, -32000, 'out of gas'); + } else if (options.revert) { + sendError(response, call, 3, 'execution reverted'); + } else { + sendResult(response, call, options.returnData ?? MAGIC_RETURN); + } + return; + default: + sendError(response, call, -32601, 'method not found'); + } + }; +} + +async function startRpcServer(handler: LoopbackHandler): Promise { + const calls: JsonRpcRequest[] = []; + const server = createServer(async (request, response) => { + try { + const body = await readRequestBody(request); + const parsed = JSON.parse(body) as JsonRpcRequest; + calls.push(parsed); + await handler(parsed, response, request); + } catch (cause) { + if (!response.headersSent) response.writeHead(500, { 'content-type': 'text/plain' }); + if (!response.writableEnded) response.end(cause instanceof Error ? cause.message : 'failure'); + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + let stopped = false; + const loopback = Object.freeze({ + url: `http://127.0.0.1:${address.port}`, + calls, + stop: async () => { + if (stopped) return; + stopped = true; + await closeServer(server); + }, + }); + activeServers.push(loopback); + return loopback; +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +function sendResult(response: ServerResponse, request: JsonRpcRequest, result: unknown): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); +} + +function sendError( + response: ServerResponse, + request: JsonRpcRequest, + code: number, + message: string, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code, message } })); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); +} + +function fixedRequest( + overrides: Partial = {}, +): CurrentFinalizedEvmCallRequestV1 { + return { + chainId: CHAIN_ID, + to: TO, + from: CONTROL_EIP1271_CALL_FROM_V1, + data: CANONICAL_CALL_DATA, + gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, + maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, + endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + ccipReadEnabled: false, + signal: new AbortController().signal, + ...overrides, + }; +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T | PromiseLike) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} From 4dd845bbb45ffc6023194a0aad4c86107deeee63 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:49:12 +0200 Subject: [PATCH 053/292] feat(core): verify CG shared semantic projections --- packages/core/src/absolute-rfc3987-iri.ts | 279 ++++++ packages/core/src/cg-shared-projection.ts | 805 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../core/src/transferred-catalog-bundle.ts | 51 +- .../core/test/absolute-rfc3987-iri.test.ts | 54 ++ .../core/test/cg-shared-projection.test.ts | 726 ++++++++++++++++ 6 files changed, 1914 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/absolute-rfc3987-iri.ts create mode 100644 packages/core/src/cg-shared-projection.ts create mode 100644 packages/core/test/absolute-rfc3987-iri.test.ts create mode 100644 packages/core/test/cg-shared-projection.test.ts diff --git a/packages/core/src/absolute-rfc3987-iri.ts b/packages/core/src/absolute-rfc3987-iri.ts new file mode 100644 index 0000000000..7f2ce9386a --- /dev/null +++ b/packages/core/src/absolute-rfc3987-iri.ts @@ -0,0 +1,279 @@ +/** + * Validate the RFC 3987 `IRI` production without normalizing its bytes. + * + * This deliberately validates only syntax. It does not apply scheme-specific + * equivalence, case folding, Unicode normalization, percent normalization, or + * network resolution. + */ +export function isAbsoluteRfc3987IriV1(value: string): boolean { + const schemeEnd = value.indexOf(':'); + if (schemeEnd <= 0 || !isScheme(value.slice(0, schemeEnd))) return false; + + const remainder = value.slice(schemeEnd + 1); + const fragmentAt = remainder.indexOf('#'); + const beforeFragment = fragmentAt < 0 + ? remainder + : remainder.slice(0, fragmentAt); + const fragment = fragmentAt < 0 + ? undefined + : remainder.slice(fragmentAt + 1); + if (fragment !== undefined && !isIFragment(fragment)) return false; + + const queryAt = beforeFragment.indexOf('?'); + const hierarchy = queryAt < 0 + ? beforeFragment + : beforeFragment.slice(0, queryAt); + const query = queryAt < 0 + ? undefined + : beforeFragment.slice(queryAt + 1); + if (query !== undefined && !isIQuery(query)) return false; + + return isIHierPart(hierarchy); +} + +function isScheme(value: string): boolean { + if (value.length === 0 || !isAsciiAlpha(value.charCodeAt(0))) return false; + for (let index = 1; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if ( + !isAsciiAlpha(code) + && !isAsciiDigit(code) + && code !== 0x2b + && code !== 0x2d + && code !== 0x2e + ) return false; + } + return true; +} + +function isIHierPart(value: string): boolean { + if (value.startsWith('//')) { + const slashAt = value.indexOf('/', 2); + const authority = slashAt < 0 + ? value.slice(2) + : value.slice(2, slashAt); + const path = slashAt < 0 ? '' : value.slice(slashAt); + return isIAuthority(authority) && scanIChars(path, '/'); + } + if (value.length === 0) return true; + if (value[0] === '/') { + // `//` is consumed by the authority branch. An authority-free absolute + // path cannot start with an empty first segment. + return value.length === 1 + || (value[1] !== '/' && scanIChars(value, '/')); + } + return scanIChars(value, '/'); +} + +function isIAuthority(value: string): boolean { + const at = value.lastIndexOf('@'); + if (at >= 0) { + if ( + value.indexOf('@') !== at + || !scanIChars(value.slice(0, at), '', false, true, false) + ) { + return false; + } + } + const hostAndPort = value.slice(at + 1); + if (hostAndPort.startsWith('[')) { + const close = hostAndPort.indexOf(']'); + if (close < 0) return false; + const literal = hostAndPort.slice(1, close); + const suffix = hostAndPort.slice(close + 1); + if (suffix.length > 0 && (suffix[0] !== ':' || !isPort(suffix.slice(1)))) { + return false; + } + return isIpv6Address(literal) || isIpvFuture(literal); + } + + const colon = hostAndPort.lastIndexOf(':'); + const host = colon < 0 ? hostAndPort : hostAndPort.slice(0, colon); + const port = colon < 0 ? undefined : hostAndPort.slice(colon + 1); + if (host.includes(':') || host.includes('[') || host.includes(']')) return false; + return scanIChars(host, '', false, false, false) + && (port === undefined || isPort(port)); +} + +function isPort(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (!isAsciiDigit(value.charCodeAt(index))) return false; + } + return true; +} + +function isIpvFuture(value: string): boolean { + if (value.length < 4 || (value[0] !== 'v' && value[0] !== 'V')) return false; + const dot = value.indexOf('.', 1); + if (dot <= 1 || dot === value.length - 1) return false; + for (let index = 1; index < dot; index += 1) { + if (!isAsciiHex(value.charCodeAt(index))) return false; + } + for (let index = dot + 1; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (!isAsciiUnreserved(code) && !isSubDelimiter(code) && code !== 0x3a) { + return false; + } + } + return true; +} + +function isIpv6Address(value: string): boolean { + if (value.length === 0 || value.includes('%')) return false; + const compression = value.indexOf('::'); + if (compression !== value.lastIndexOf('::')) return false; + + const leftText = compression < 0 ? value : value.slice(0, compression); + const rightText = compression < 0 ? '' : value.slice(compression + 2); + const left = leftText.length === 0 ? [] : leftText.split(':'); + const right = rightText.length === 0 ? [] : rightText.split(':'); + if (left.some((part) => part.length === 0) || right.some((part) => part.length === 0)) { + return false; + } + + const all = [...left, ...right]; + const dotted = all.findIndex((part) => part.includes('.')); + if ( + dotted >= 0 + && ( + dotted !== all.length - 1 + // An embedded IPv4 address is the final `ls32`; `::` cannot follow it. + || (compression >= 0 && right.length === 0) + ) + ) return false; + let units = 0; + for (let index = 0; index < all.length; index += 1) { + const part = all[index]; + if (part.includes('.')) { + if (!isIpv4Address(part)) return false; + units += 2; + continue; + } + if (part.length < 1 || part.length > 4) return false; + for (let digit = 0; digit < part.length; digit += 1) { + if (!isAsciiHex(part.charCodeAt(digit))) return false; + } + units += 1; + } + return compression < 0 ? units === 8 : units < 8; +} + +function isIpv4Address(value: string): boolean { + const parts = value.split('.'); + if (parts.length !== 4) return false; + return parts.every((part) => { + if (part.length === 0 || part.length > 3) return false; + if (part.length > 1 && part[0] === '0') return false; + for (let index = 0; index < part.length; index += 1) { + if (!isAsciiDigit(part.charCodeAt(index))) return false; + } + return Number(part) <= 255; + }); +} + +function isIQuery(value: string): boolean { + return scanIChars(value, '/?', true); +} + +function isIFragment(value: string): boolean { + return scanIChars(value, '/?'); +} + +function scanIChars( + value: string, + asciiExtra: string, + allowPrivate = false, + allowColon = true, + allowAt = true, +): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x25) { + if ( + index + 2 >= value.length + || !isAsciiHex(value.charCodeAt(index + 1)) + || !isAsciiHex(value.charCodeAt(index + 2)) + ) return false; + index += 2; + continue; + } + const codePoint = value.codePointAt(index)!; + if ( + isAsciiUnreserved(codePoint) + || isSubDelimiter(codePoint) + || (allowColon && codePoint === 0x3a) + || (allowAt && codePoint === 0x40) + || asciiExtra.includes(String.fromCodePoint(codePoint)) + || isUcsChar(codePoint) + || (allowPrivate && isIPrivate(codePoint)) + ) { + if (codePoint > 0xffff) index += 1; + continue; + } + return false; + } + return true; +} + +function isAsciiUnreserved(code: number): boolean { + return isAsciiAlpha(code) + || isAsciiDigit(code) + || code === 0x2d + || code === 0x2e + || code === 0x5f + || code === 0x7e; +} + +function isSubDelimiter(code: number): boolean { + return code === 0x21 + || code === 0x24 + || code === 0x26 + || code === 0x27 + || code === 0x28 + || code === 0x29 + || code === 0x2a + || code === 0x2b + || code === 0x2c + || code === 0x3b + || code === 0x3d; +} + +function isUcsChar(code: number): boolean { + return (code >= 0x00a0 && code <= 0xd7ff) + || (code >= 0xf900 && code <= 0xfdcf) + || (code >= 0xfdf0 && code <= 0xffef) + || (code >= 0x10000 && code <= 0x1fffd) + || (code >= 0x20000 && code <= 0x2fffd) + || (code >= 0x30000 && code <= 0x3fffd) + || (code >= 0x40000 && code <= 0x4fffd) + || (code >= 0x50000 && code <= 0x5fffd) + || (code >= 0x60000 && code <= 0x6fffd) + || (code >= 0x70000 && code <= 0x7fffd) + || (code >= 0x80000 && code <= 0x8fffd) + || (code >= 0x90000 && code <= 0x9fffd) + || (code >= 0xa0000 && code <= 0xafffd) + || (code >= 0xb0000 && code <= 0xbfffd) + || (code >= 0xc0000 && code <= 0xcfffd) + || (code >= 0xd0000 && code <= 0xdfffd) + || (code >= 0xe1000 && code <= 0xefffd); +} + +function isIPrivate(code: number): boolean { + return (code >= 0xe000 && code <= 0xf8ff) + || (code >= 0xf0000 && code <= 0xffffd) + || (code >= 0x100000 && code <= 0x10fffd); +} + +function isAsciiAlpha(code: number): boolean { + return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); +} + +function isAsciiDigit(code: number): boolean { + return code >= 0x30 && code <= 0x39; +} + +function isAsciiHex(code: number): boolean { + return isAsciiDigit(code) + || (code >= 0x41 && code <= 0x46) + || (code >= 0x61 && code <= 0x66); +} diff --git a/packages/core/src/cg-shared-projection.ts b/packages/core/src/cg-shared-projection.ts new file mode 100644 index 0000000000..b94616d4bf --- /dev/null +++ b/packages/core/src/cg-shared-projection.ts @@ -0,0 +1,805 @@ +import { + parseRdfLiteralLexicalTerm, +} from '@origintrail-official/dkg-rdf-utils'; +import { sha256 } from '@noble/hashes/sha2.js'; + +import type { AuthorCatalogRowV1 } from './author-catalog-codec.js'; +import type { SignedAuthorCatalogHeadEnvelopeV1 } from './author-catalog-objects.js'; +import { + readVerifiedCatalogSealBindingV1, + type CatalogSealDeploymentProfileV1, +} from './catalog-seal-binding.js'; +import { + type CanonicalGraphScopedAuthorSealV1, + type SealTripleCountV1, +} from './canonical-graph-scoped-author-seal.js'; +import { + KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1, +} from './ka-bundle-v1.js'; +import { + V10MerkleTree, +} from './crypto/v10-merkle.js'; +import { + canonicalizeObjectTermForHash, +} from './crypto/term-canon.js'; +import { + hashTripleV10, + tripleContentV10, +} from './crypto/canonicalize.js'; +import { isAbsoluteRfc3987IriV1 } from './absolute-rfc3987-iri.js'; +import type { + ByteLengthV1, + Digest32V1, +} from './sync-wire-scalars.js'; +import { + assertVerifiedTransferredCatalogBundleForInputsV1, + readVerifiedTransferredCatalogBundleMetadataV1, + readVerifiedTransferredCatalogProjectionBytesV1, + type VerifiedTransferredCatalogBundleV1, +} from './transferred-catalog-bundle.js'; + +declare const VERIFIED_CG_SHARED_PROJECTION_BRAND_V1: unique symbol; + +export const CG_SHARED_PROJECTION_ID_V1 = 'cg-shared-v1' as const; +export const CG_SHARED_PRIVATE_COMMITMENT_SUFFIX_V1 = '/_cg-shared-v1' as const; +export const CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1 = + 'http://dkg.io/ontology/privateDataAnchor' as const; +export const CG_SHARED_PRIVATE_HASH_PREDICATE_V1 = + 'http://dkg.io/ontology/privateDataHash' as const; + +const PRIVATE_COMMITMENT_PREDICATE_PREFIX = + 'http://dkg.io/ontology/privateData'; +const XSD_HEX_BINARY_IRI = 'http://www.w3.org/2001/XMLSchema#hexBinary'; +const UTF8 = new TextEncoder(); +const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); +const PROJECTION_DIGEST_DOMAIN = UTF8.encode( + KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1, +); +const SENTINEL_NO_PRIVATE_DIGEST_V10 = + '0xdfba2a3576c2aa2d73ecd8c55d1c27cfb15691ca9d3237b86434a06592f160ee' as Digest32V1; +const LOWER_LANGUAGE_TAG = /^[a-z]{1,8}(?:-[a-z0-9]{1,8})*$/; +const HEX_ESCAPE = /^[0-9A-Fa-f]{2}$/; + +/** + * Safe defaults for this in-memory verifier. Exceeding them is a local + * resource refusal, not proof that otherwise canonical projection bytes are + * invalid. A streaming/external-sort verifier is required for larger inputs. + */ +export const DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1 = Object.freeze({ + maxProjectionBytes: 64 * 1024 * 1024, + maxPublicTriples: 262_144, + maxLineBytes: 64 * 1024 * 1024, +}); + +export interface CgSharedProjectionVerificationLimitsV1 { + readonly maxProjectionBytes: number; + readonly maxPublicTriples: number; + readonly maxLineBytes: number; +} + +/** + * Process-local proof that one structurally verified transferred bundle carries + * the exact canonical `cg-shared-v1` projection committed by its author seal. + * + * This is precommit evidence only. It grants no catalog authority, policy, + * author-attestation, VM-finality, semantic-store, or activation authority. + */ +export interface VerifiedCgSharedProjectionV1 { + readonly [VERIFIED_CG_SHARED_PROJECTION_BRAND_V1]: true; +} + +export interface VerifiedCgSharedProjectionSnapshotV1 { + readonly headObjectDigest: Digest32V1; + readonly catalogScopeDigest: Digest32V1; + readonly catalogRowDigest: Digest32V1; + readonly transferIdentityDigest: Digest32V1; + readonly projectionId: typeof CG_SHARED_PROJECTION_ID_V1; + readonly projectionDigest: Digest32V1; + readonly projectionByteLength: ByteLengthV1; + readonly publicTripleCount: SealTripleCountV1; + readonly privateTripleCount: SealTripleCountV1; + readonly publicRoot: Digest32V1; + readonly privateDataHash: Digest32V1; + readonly assertionMerkleRoot: Digest32V1; + readonly privateMerkleRoot: Digest32V1 | null; + readonly kaUal: string; + /** Local in-memory verifier limits used for this successful proof. */ + readonly verificationLimits: Readonly; + /** Fresh caller-owned copy of the exact canonical projection bytes. */ + readonly projectionBytes: Uint8Array; +} + +export type VerifiedCgSharedProjectionMetadataV1 = Omit< + VerifiedCgSharedProjectionSnapshotV1, + 'projectionBytes' +>; + +export type CgSharedProjectionErrorCode = + | 'projection-input' + | 'projection-empty' + | 'projection-utf8' + | 'projection-line-ending' + | 'projection-line' + | 'projection-order' + | 'projection-duplicate' + | 'projection-iri' + | 'projection-literal' + | 'projection-private-predicate' + | 'projection-private-cardinality' + | 'projection-private-subject' + | 'projection-private-root-mismatch' + | 'projection-public-count' + | 'projection-digest' + | 'projection-structured-root' + | 'projection-resource-refused' + | 'projection-capability' + | 'projection-binding'; + +export class CgSharedProjectionError extends Error { + constructor( + readonly code: CgSharedProjectionErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'CgSharedProjectionError'; + } +} + +interface CanonicalProjectionTripleV1 { + readonly subject: string; + readonly predicate: string; + readonly object: string; + readonly leaf: Uint8Array; +} + +interface VerifiedCgSharedProjectionStateV1 { + readonly transferredBundle: VerifiedTransferredCatalogBundleV1; + readonly snapshot: Omit; +} + +const VERIFIED_CG_SHARED_PROJECTIONS_V1 = new WeakMap< + object, + VerifiedCgSharedProjectionStateV1 +>(); + +/** Verify canonical bytes, private commitment, counts, and structured V10 root. */ +export function verifyCgSharedProjectionV1( + transferredBundle: VerifiedTransferredCatalogBundleV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, + limits: CgSharedProjectionVerificationLimitsV1 = + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, +): VerifiedCgSharedProjectionV1 { + let metadata: ReturnType; + try { + metadata = readVerifiedTransferredCatalogBundleMetadataV1( + transferredBundle, + signedHead, + row, + deployment, + ); + } catch (cause) { + fail( + 'projection-input', + 'projection input is not an exact verified transferred catalog bundle', + cause, + ); + } + + const sealBinding = readVerifiedCatalogSealBindingV1( + metadata.catalogSealBinding, + ); + if ( + sealBinding.catalogScopeDigest !== metadata.catalogScopeDigest + || sealBinding.catalogRowDigest !== metadata.catalogRowDigest + ) { + fail('projection-binding', 'seal binding and transferred bundle identify different rows'); + } + if (metadata.transfer.projectionId !== CG_SHARED_PROJECTION_ID_V1) { + fail('projection-binding', 'transferred row does not advertise cg-shared-v1'); + } + const verificationLimits = normalizeVerificationLimits(limits); + assertProjectionWithinLocalLimits( + metadata.projectionByteLength, + sealBinding.seal.publicTripleCount, + verificationLimits, + ); + + let projectionBytes: Uint8Array; + try { + projectionBytes = readVerifiedTransferredCatalogProjectionBytesV1( + transferredBundle, + signedHead, + row, + deployment, + ); + } catch (cause) { + fail( + 'projection-input', + 'verified transferred projection bytes could not be read', + cause, + ); + } + if (projectionBytes.byteLength > verificationLimits.maxProjectionBytes) { + fail( + 'projection-resource-refused', + 'projection exceeds the local in-memory byte limit', + ); + } + + const projectionDigest = computeProjectionDigest(projectionBytes); + if ( + projectionDigest !== metadata.projectionDigest + || projectionDigest !== metadata.transfer.projectionDigest + ) { + fail('projection-digest', 'canonical projection bytes differ from the transferred digest'); + } + + const semantic = verifyCanonicalProjectionBytes( + projectionBytes, + sealBinding.seal, + verificationLimits, + ); + const snapshot = Object.freeze({ + headObjectDigest: metadata.headObjectDigest, + catalogScopeDigest: metadata.catalogScopeDigest, + catalogRowDigest: metadata.catalogRowDigest, + transferIdentityDigest: metadata.transferIdentityDigest, + projectionId: CG_SHARED_PROJECTION_ID_V1, + projectionDigest, + projectionByteLength: String(projectionBytes.byteLength) as ByteLengthV1, + publicTripleCount: sealBinding.seal.publicTripleCount, + privateTripleCount: sealBinding.seal.privateTripleCount, + publicRoot: semantic.publicRoot, + privateDataHash: semantic.privateDataHash, + assertionMerkleRoot: semantic.assertionMerkleRoot, + privateMerkleRoot: sealBinding.seal.privateMerkleRoot, + kaUal: sealBinding.seal.kaUal, + verificationLimits, + }); + const capability = Object.freeze( + Object.create(null), + ) as VerifiedCgSharedProjectionV1; + VERIFIED_CG_SHARED_PROJECTIONS_V1.set(capability as object, Object.freeze({ + transferredBundle, + snapshot, + })); + return capability; +} + +export function assertVerifiedCgSharedProjectionV1( + value: unknown, +): asserts value is VerifiedCgSharedProjectionV1 { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + fail('projection-capability', 'projection proof was not minted by this verifier'); + } + if (!VERIFIED_CG_SHARED_PROJECTIONS_V1.has(value as object)) { + fail('projection-capability', 'projection proof was not minted by this verifier'); + } +} + +export function assertVerifiedCgSharedProjectionForTransferV1( + value: unknown, + transferredBundle: VerifiedTransferredCatalogBundleV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): asserts value is VerifiedCgSharedProjectionV1 { + assertVerifiedCgSharedProjectionV1(value); + try { + assertVerifiedTransferredCatalogBundleForInputsV1( + transferredBundle, + signedHead, + row, + deployment, + ); + } catch (cause) { + fail('projection-binding', 'transferred bundle context is not canonical', cause); + } + const state = VERIFIED_CG_SHARED_PROJECTIONS_V1.get(value as object)!; + if (state.transferredBundle !== transferredBundle) { + fail('projection-binding', 'projection proof belongs to another transferred bundle'); + } +} + +export function readVerifiedCgSharedProjectionV1( + value: unknown, + transferredBundle: VerifiedTransferredCatalogBundleV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): VerifiedCgSharedProjectionSnapshotV1 { + const metadata = readVerifiedCgSharedProjectionMetadataV1( + value, + transferredBundle, + signedHead, + row, + deployment, + ); + return Object.freeze({ + ...metadata, + projectionBytes: readVerifiedCgSharedProjectionBytesV1( + value, + transferredBundle, + signedHead, + row, + deployment, + ), + }); +} + +/** Read verified scalar/root metadata without allocating projection bytes. */ +export function readVerifiedCgSharedProjectionMetadataV1( + value: unknown, + transferredBundle: VerifiedTransferredCatalogBundleV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): VerifiedCgSharedProjectionMetadataV1 { + assertVerifiedCgSharedProjectionForTransferV1( + value, + transferredBundle, + signedHead, + row, + deployment, + ); + const state = VERIFIED_CG_SHARED_PROJECTIONS_V1.get(value as object)!; + return Object.freeze({ ...state.snapshot }); +} + +/** Read one fresh caller-owned copy of the verified canonical projection. */ +export function readVerifiedCgSharedProjectionBytesV1( + value: unknown, + transferredBundle: VerifiedTransferredCatalogBundleV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): Uint8Array { + assertVerifiedCgSharedProjectionForTransferV1( + value, + transferredBundle, + signedHead, + row, + deployment, + ); + return readVerifiedTransferredCatalogProjectionBytesV1( + transferredBundle, + signedHead, + row, + deployment, + ); +} + +function verifyCanonicalProjectionBytes( + projectionBytes: Uint8Array, + seal: Readonly, + limits: Readonly, +): { + readonly publicRoot: Digest32V1; + readonly privateDataHash: Digest32V1; + readonly assertionMerkleRoot: Digest32V1; +} { + if (projectionBytes.byteLength === 0) { + fail('projection-empty', 'cg-shared-v1 projection must not be empty'); + } + if ( + projectionBytes.byteLength >= 3 + && projectionBytes[0] === 0xef + && projectionBytes[1] === 0xbb + && projectionBytes[2] === 0xbf + ) { + fail('projection-utf8', 'cg-shared-v1 projection must not start with a UTF-8 BOM'); + } + if (projectionBytes[projectionBytes.byteLength - 1] !== 0x0a) { + fail('projection-line-ending', 'cg-shared-v1 projection must end with one LF'); + } + + const leaves: Uint8Array[] = []; + let anchor: CanonicalProjectionTripleV1 | undefined; + let privateHash: CanonicalProjectionTripleV1 | undefined; + let previousLine: Uint8Array | undefined; + let lineStart = 0; + let lineNumber = 0; + const signedPublicTripleCount = BigInt(seal.publicTripleCount); + const reservedCommitmentSubject = + `${seal.kaUal}${CG_SHARED_PRIVATE_COMMITMENT_SUFFIX_V1}`; + for (let cursor = 0; cursor < projectionBytes.byteLength; cursor += 1) { + const byte = projectionBytes[cursor]; + if (byte === 0x0d) { + fail('projection-line-ending', 'raw CR is forbidden in cg-shared-v1 bytes'); + } + if (byte !== 0x0a) continue; + const line = projectionBytes.subarray(lineStart, cursor); + lineNumber += 1; + if (BigInt(lineNumber) > signedPublicTripleCount) { + fail( + 'projection-public-count', + 'projection contains more lines than the signed publicTripleCount', + ); + } + if (line.byteLength === 0) { + fail('projection-line', `projection line ${lineNumber} is empty`); + } + if (line.byteLength > limits.maxLineBytes) { + fail( + 'projection-resource-refused', + `projection line ${lineNumber} exceeds the local in-memory line limit`, + ); + } + if (previousLine !== undefined) { + const ordering = compareBytes(previousLine, line); + if (ordering === 0) { + fail('projection-duplicate', `projection line ${lineNumber} duplicates its predecessor`); + } + if (ordering > 0) { + fail('projection-order', `projection line ${lineNumber} is not in raw UTF-8 order`); + } + } + const triple = parseCanonicalProjectionLine(line, lineNumber); + if ( + triple.subject === reservedCommitmentSubject + && triple.predicate !== CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1 + && triple.predicate !== CG_SHARED_PRIVATE_HASH_PREDICATE_V1 + ) { + fail( + 'projection-private-subject', + `projection line ${lineNumber} reuses the reserved commitment subject`, + ); + } + if (triple.predicate === CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1) { + if (anchor !== undefined) { + fail('projection-private-cardinality', 'projection contains duplicate private anchors'); + } + anchor = triple; + } else if (triple.predicate === CG_SHARED_PRIVATE_HASH_PREDICATE_V1) { + if (privateHash !== undefined) { + fail('projection-private-cardinality', 'projection contains duplicate private hashes'); + } + privateHash = triple; + } else if (triple.predicate.startsWith(PRIVATE_COMMITMENT_PREDICATE_PREFIX)) { + fail( + 'projection-private-predicate', + `projection line ${lineNumber} uses an unknown reserved private-data predicate`, + ); + } + leaves.push(triple.leaf); + previousLine = line; + lineStart = cursor + 1; + } + if (lineStart !== projectionBytes.byteLength) { + fail('projection-line-ending', 'projection contains bytes after its final complete line'); + } + + if (BigInt(lineNumber) !== BigInt(seal.publicTripleCount)) { + fail( + 'projection-public-count', + 'canonical projection line count differs from seal publicTripleCount', + ); + } + assertPrivateCommitment(anchor, privateHash, seal); + + const publicTree = new V10MerkleTree(leaves); + const publicRoot = bytesToDigest(publicTree.root); + const privateDataHash = seal.privateMerkleRoot ?? SENTINEL_NO_PRIVATE_DIGEST_V10; + const assertionMerkleRoot = bytesToDigest(V10MerkleTree.computeKARoot( + publicTree.root, + digestToBytes(privateDataHash), + )); + if (assertionMerkleRoot !== seal.assertionMerkleRoot) { + fail( + 'projection-structured-root', + 'recomputed structured assertion root differs from the author seal', + ); + } + return Object.freeze({ publicRoot, privateDataHash, assertionMerkleRoot }); +} + +function assertPrivateCommitment( + anchor: CanonicalProjectionTripleV1 | undefined, + privateHash: CanonicalProjectionTripleV1 | undefined, + seal: Readonly, +): void { + const privateCount = BigInt(seal.privateTripleCount); + if (privateCount === 0n) { + if (seal.privateMerkleRoot !== null || anchor !== undefined || privateHash !== undefined) { + fail( + 'projection-private-cardinality', + 'public-only projection must not contain a private commitment', + ); + } + return; + } + if ( + seal.privateMerkleRoot === null + || anchor === undefined + || privateHash === undefined + ) { + fail( + 'projection-private-cardinality', + 'private-bearing projection requires exactly one anchor and one hash statement', + ); + } + const expectedSubject = `${seal.kaUal}${CG_SHARED_PRIVATE_COMMITMENT_SUFFIX_V1}`; + if (anchor.subject !== expectedSubject || privateHash.subject !== expectedSubject) { + fail( + 'projection-private-subject', + 'private commitment subject is not derived from the canonical author-seal UAL', + ); + } + if (anchor.object !== '"true"') { + fail('projection-private-cardinality', 'privateDataAnchor must be the plain literal "true"'); + } + const expectedHash = + `"${seal.privateMerkleRoot.slice(2)}"^^<${XSD_HEX_BINARY_IRI}>`; + if (privateHash.object !== expectedHash) { + fail( + 'projection-private-root-mismatch', + 'privateDataHash does not reproduce the seal privateMerkleRoot', + ); + } +} + +function parseCanonicalProjectionLine( + lineBytes: Uint8Array, + lineNumber: number, +): CanonicalProjectionTripleV1 { + let line: string; + try { + line = STRICT_UTF8.decode(lineBytes); + } catch (cause) { + fail('projection-utf8', `projection line ${lineNumber} is not valid UTF-8`, cause); + } + if (line.codePointAt(0) === 0xfeff) { + fail('projection-utf8', `projection line ${lineNumber} starts with a BOM`); + } + if (!line.endsWith(' .')) { + fail('projection-line', `projection line ${lineNumber} must end with exactly " ."`); + } + + const objectEnd = line.length - 2; + const subject = parseCanonicalIriTerm(line, 0, lineNumber, 'subject'); + if (line[subject.end] !== ' ') { + fail('projection-line', `projection line ${lineNumber} must contain one subject separator`); + } + const predicate = parseCanonicalIriTerm( + line, + subject.end + 1, + lineNumber, + 'predicate', + ); + if (line[predicate.end] !== ' ') { + fail('projection-line', `projection line ${lineNumber} must contain one predicate separator`); + } + const objectStart = predicate.end + 1; + if (objectStart >= objectEnd) { + fail('projection-line', `projection line ${lineNumber} has no object`); + } + const object = line.slice(objectStart, objectEnd); + assertCanonicalObjectTerm(object, lineNumber); + + const reconstructed = tripleContentV10( + subject.value, + predicate.value, + object, + ); + if (!equalBytes(reconstructed, lineBytes)) { + fail( + object.startsWith('"') ? 'projection-literal' : 'projection-line', + `projection line ${lineNumber} is not a canonical V10 fixed point`, + ); + } + return Object.freeze({ + subject: subject.value, + predicate: predicate.value, + object, + leaf: hashTripleV10(subject.value, predicate.value, object), + }); +} + +function parseCanonicalIriTerm( + input: string, + start: number, + lineNumber: number, + label: 'subject' | 'predicate' | 'object', +): { readonly value: string; readonly end: number } { + if (input[start] !== '<') { + fail('projection-iri', `projection line ${lineNumber} ${label} must be an IRI`); + } + const endBracket = input.indexOf('>', start + 1); + if (endBracket < 0) { + fail('projection-iri', `projection line ${lineNumber} ${label} IRI is unterminated`); + } + const value = input.slice(start + 1, endBracket); + assertCanonicalIriValue(value, lineNumber, label); + return Object.freeze({ value, end: endBracket + 1 }); +} + +function assertCanonicalObjectTerm(object: string, lineNumber: number): void { + if (object.startsWith('<')) { + const parsed = parseCanonicalIriTerm(object, 0, lineNumber, 'object'); + if (parsed.end !== object.length) { + fail('projection-iri', `projection line ${lineNumber} object has trailing bytes`); + } + return; + } + if (!object.startsWith('"')) { + fail( + 'projection-line', + `projection line ${lineNumber} object must be a named node or literal`, + ); + } + const literal = parseRdfLiteralLexicalTerm(object); + if (!literal) { + fail('projection-literal', `projection line ${lineNumber} literal is malformed`); + } + assertCanonicalLiteralBody(literal.body, lineNumber); + if (literal.suffix.kind === 'language') { + if (!LOWER_LANGUAGE_TAG.test(literal.suffix.language)) { + fail( + 'projection-literal', + `projection line ${lineNumber} language tag is not canonical lowercase BCP47`, + ); + } + } else if (literal.suffix.kind === 'datatype') { + if (literal.suffix.syntax !== 'bracketed') { + fail('projection-literal', `projection line ${lineNumber} datatype IRI is not bracketed`); + } + assertCanonicalIriValue(literal.suffix.datatype, lineNumber, 'object'); + } + if (canonicalizeObjectTermForHash(object) !== object) { + fail( + 'projection-literal', + `projection line ${lineNumber} literal is not the canonical V10 fixed point`, + ); + } +} + +function assertCanonicalLiteralBody(body: string, lineNumber: number): void { + for (let index = 0; index < body.length; index += 1) { + const code = body.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) { + fail('projection-literal', `projection line ${lineNumber} literal contains a raw control`); + } + if (body[index] !== '\\') continue; + const escaped = body[index + 1]; + if (escaped !== '\\' && escaped !== '"' && escaped !== 'n' && escaped !== 'r') { + fail( + 'projection-literal', + `projection line ${lineNumber} literal uses a noncanonical escape`, + ); + } + index += 1; + } +} + +function assertCanonicalIriValue( + value: string, + lineNumber: number, + label: string, +): void { + if (!isAbsoluteRfc3987IriV1(value)) { + fail('projection-iri', `projection line ${lineNumber} ${label} IRI is not absolute and safe`); + } + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '%') { + if (!HEX_ESCAPE.test(value.slice(index + 1, index + 3))) { + fail('projection-iri', `projection line ${lineNumber} ${label} IRI has a bad percent escape`); + } + index += 2; + continue; + } + const codePoint = value.codePointAt(index)!; + if ( + codePoint <= 0x20 + || (codePoint >= 0x7f && codePoint <= 0x9f) + || (codePoint >= 0xd800 && codePoint <= 0xdfff) + ) { + fail('projection-iri', `projection line ${lineNumber} ${label} IRI has a forbidden code point`); + } + if (codePoint > 0xffff) index += 1; + } +} + +function normalizeVerificationLimits( + value: CgSharedProjectionVerificationLimitsV1, +): Readonly { + const hard = DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1; + for (const [name, member, ceiling] of [ + ['maxProjectionBytes', value?.maxProjectionBytes, hard.maxProjectionBytes], + ['maxPublicTriples', value?.maxPublicTriples, hard.maxPublicTriples], + ['maxLineBytes', value?.maxLineBytes, hard.maxLineBytes], + ] as const) { + if ( + !Number.isSafeInteger(member) + || member < 1 + || member > ceiling + ) { + fail( + 'projection-resource-refused', + `${name} is outside this in-memory verifier's supported range`, + ); + } + } + if (value.maxLineBytes > value.maxProjectionBytes) { + fail( + 'projection-resource-refused', + 'maxLineBytes cannot exceed maxProjectionBytes', + ); + } + return Object.freeze({ + maxProjectionBytes: value.maxProjectionBytes, + maxPublicTriples: value.maxPublicTriples, + maxLineBytes: value.maxLineBytes, + }); +} + +function assertProjectionWithinLocalLimits( + projectionByteLength: ByteLengthV1, + publicTripleCount: SealTripleCountV1, + limits: Readonly, +): void { + if (BigInt(projectionByteLength) > BigInt(limits.maxProjectionBytes)) { + fail( + 'projection-resource-refused', + 'projection exceeds the local in-memory byte limit', + ); + } + if (BigInt(publicTripleCount) > BigInt(limits.maxPublicTriples)) { + fail( + 'projection-resource-refused', + 'signed publicTripleCount exceeds the local in-memory leaf limit', + ); + } +} + +function computeProjectionDigest(projectionBytes: Uint8Array): Digest32V1 { + const hasher = sha256.create(); + hasher.update(PROJECTION_DIGEST_DOMAIN); + hasher.update(projectionBytes); + return bytesToDigest(hasher.digest()); +} + +function digestToBytes(value: Digest32V1): Uint8Array { + const bytes = new Uint8Array(32); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(2 + index * 2, 4 + index * 2), 16); + } + return bytes; +} + +function bytesToDigest(bytes: Uint8Array): Digest32V1 { + let value = '0x'; + for (const byte of bytes) value += byte.toString(16).padStart(2, '0'); + return value as Digest32V1; +} + +function compareBytes(left: Uint8Array, right: Uint8Array): number { + const length = Math.min(left.byteLength, right.byteLength); + for (let index = 0; index < length; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return left.byteLength - right.byteLength; +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + let difference = 0; + for (let index = 0; index < left.byteLength; index += 1) { + difference |= left[index] ^ right[index]; + } + return difference === 0; +} + +function fail( + code: CgSharedProjectionErrorCode, + message: string, + cause?: unknown, +): never { + throw new CgSharedProjectionError( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 24da6cc552..b0fec84e3f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -51,6 +51,7 @@ export * from './ka-transfer-descriptor.js'; export * from './ka-bundle-v1.js'; export * from './ka-chunk-tree.js'; export * from './ka-chunk-proof.js'; +export * from './cg-shared-projection.js'; export * from './author-catalog-codec.js'; export * from './author-catalog-objects.js'; export * from './author-catalog-directory.js'; diff --git a/packages/core/src/transferred-catalog-bundle.ts b/packages/core/src/transferred-catalog-bundle.ts index 182208a68c..5efa67c6bd 100644 --- a/packages/core/src/transferred-catalog-bundle.ts +++ b/packages/core/src/transferred-catalog-bundle.ts @@ -72,6 +72,11 @@ export interface VerifiedTransferredCatalogBundleSnapshotV1 { readonly bundleBytes: Uint8Array; } +export type VerifiedTransferredCatalogBundleMetadataV1 = Omit< + VerifiedTransferredCatalogBundleSnapshotV1, + 'bundleBytes' +>; + export type TransferredCatalogBundleErrorCode = | 'transferred-bundle-head' | 'transferred-bundle-row' @@ -315,7 +320,7 @@ export function readVerifiedTransferredCatalogBundleV1( row: AuthorCatalogRowV1, deployment: CatalogSealDeploymentProfileV1, ): VerifiedTransferredCatalogBundleSnapshotV1 { - assertVerifiedTransferredCatalogBundleForInputsV1( + const metadata = readVerifiedTransferredCatalogBundleMetadataV1( value, signedHead, row, @@ -323,11 +328,53 @@ export function readVerifiedTransferredCatalogBundleV1( ); const state = VERIFIED_TRANSFERRED_CATALOG_BUNDLES_V1.get(value as object)!; return Object.freeze({ - ...state.snapshot, + ...metadata, bundleBytes: new Uint8Array(state.bundleBytes), }); } +/** + * Read only immutable scalar/capability metadata without copying a potentially + * one-gibibyte bundle. Exact input rebinding remains mandatory. + */ +export function readVerifiedTransferredCatalogBundleMetadataV1( + value: unknown, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): VerifiedTransferredCatalogBundleMetadataV1 { + assertVerifiedTransferredCatalogBundleForInputsV1( + value, + signedHead, + row, + deployment, + ); + const state = VERIFIED_TRANSFERRED_CATALOG_BUNDLES_V1.get(value as object)!; + return Object.freeze({ ...state.snapshot }); +} + +/** + * Return only the canonical projection component as a fresh caller-owned copy. + * This avoids copying the seal and framing bytes when the semantic projection + * verifier consumes a large, already-verified transfer. + */ +export function readVerifiedTransferredCatalogProjectionBytesV1( + value: unknown, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + deployment: CatalogSealDeploymentProfileV1, +): Uint8Array { + assertVerifiedTransferredCatalogBundleForInputsV1( + value, + signedHead, + row, + deployment, + ); + const state = VERIFIED_TRANSFERRED_CATALOG_BUNDLES_V1.get(value as object)!; + const decoded = decodeOpaqueKaBundleV1(state.bundleBytes); + return new Uint8Array(decoded.projectionBytes); +} + function snapshotTransferredBundleInputs( signedHead: SignedAuthorCatalogHeadEnvelopeV1, row: AuthorCatalogRowV1, diff --git a/packages/core/test/absolute-rfc3987-iri.test.ts b/packages/core/test/absolute-rfc3987-iri.test.ts new file mode 100644 index 0000000000..c36b3fd651 --- /dev/null +++ b/packages/core/test/absolute-rfc3987-iri.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { isAbsoluteRfc3987IriV1 } from '../src/absolute-rfc3987-iri.js'; + +describe('RFC 3987 absolute IRI syntax', () => { + it.each([ + 'a:', + 'https://example.org/a/b?x=1#part', + 'https://例え.テスト/路径', + 'urn:example:animal:ferret:nose', + 'did:dkg:otp:20430/0x3333333333333333333333333333333333333333/7', + 'tag:example.org,2026:fixture', + 'mailto:user@example.org', + 'file:///tmp/example', + 'http://[2001:db8::1]/a', + 'http://[::192.0.2.1]/a', + 'http://[v1.fe80]/a', + 'urn:test:%E2%82%AC', + 'urn:test:\u00A0', + ])('accepts %s', (value) => { + expect(isAbsoluteRfc3987IriV1(value)).toBe(true); + }); + + it.each([ + '', + 'relative/path', + '1bad:scheme', + 'http://[', + 'http://[]/', + 'http://[2001:::1]/', + 'http://[2001:db8::1', + 'http://[192.0.2.1::]/', + 'http://[1:192.0.2.1::]/', + 'http://example.org/[x', + 'http://example.org/a\\b', + 'http://example.org/a b', + 'http://example.org/%zz', + 'http://example.org/%0', + 'urn:test:\uFFFF', + 'urn:test:\u{1FFFF}', + 'urn:test:{x}', + 'urn:test:#fragment#again', + 'http://user@@example.org/', + 'http://example.org:bad/', + ])('rejects %s', (value) => { + expect(isAbsoluteRfc3987IriV1(value)).toBe(false); + }); + + it('permits RFC3987 private-use code points only in the query component', () => { + expect(isAbsoluteRfc3987IriV1('urn:test:value?x=\uE000')).toBe(true); + expect(isAbsoluteRfc3987IriV1('urn:test:\uE000')).toBe(false); + expect(isAbsoluteRfc3987IriV1('urn:test:value#\uE000')).toBe(false); + }); +}); diff --git a/packages/core/test/cg-shared-projection.test.ts b/packages/core/test/cg-shared-projection.test.ts new file mode 100644 index 0000000000..d9a34e84ab --- /dev/null +++ b/packages/core/test/cg-shared-projection.test.ts @@ -0,0 +1,726 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertAuthorCatalogRowV1, + type AuthorCatalogRowV1, +} from '../src/author-catalog-codec.js'; +import { + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + assertSignedAuthorCatalogHeadEnvelopeV1, + computeAuthorCatalogHeadObjectDigestV1, + type AuthorCatalogHeadV1, + type SignedAuthorCatalogHeadEnvelopeV1, +} from '../src/author-catalog-objects.js'; +import { + CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1, + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, + CgSharedProjectionError, + assertVerifiedCgSharedProjectionForTransferV1, + assertVerifiedCgSharedProjectionV1, + readVerifiedCgSharedProjectionBytesV1, + readVerifiedCgSharedProjectionMetadataV1, + readVerifiedCgSharedProjectionV1, + verifyCgSharedProjectionV1, + type CgSharedProjectionErrorCode, + type CgSharedProjectionVerificationLimitsV1, +} from '../src/cg-shared-projection.js'; +import type { CatalogSealDeploymentProfileV1 } from '../src/catalog-seal-binding.js'; +import { + assertCanonicalGraphScopedAuthorSealV1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + type CanonicalGraphScopedAuthorSealV1, +} from '../src/canonical-graph-scoped-author-seal.js'; +import { hashTripleV10 } from '../src/crypto/canonicalize.js'; +import { + SENTINEL_NO_PRIVATE_V10, + V10MerkleTree, +} from '../src/crypto/v10-merkle.js'; +import { encodeOpaqueKaBundleV1 } from '../src/ka-bundle-v1.js'; +import { computeKaChunkTreeRootV1 } from '../src/ka-chunk-tree.js'; +import type { UnsignedControlEnvelopeV1 } from '../src/sync-control-object.js'; +import { + readVerifiedTransferredCatalogBundleMetadataV1, + readVerifiedTransferredCatalogProjectionBytesV1, + verifyTransferredCatalogBundleV1, + type VerifiedTransferredCatalogBundleV1, +} from '../src/transferred-catalog-bundle.js'; + +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const ISSUER = '0x5555555555555555555555555555555555555555'; +const KAV10 = '0x4444444444444444444444444444444444444444'; +const KA_ID = + '23158417847463239084714197001737581570653996933112267175388663934063917137927'; +const UAL = `did:dkg:otp:20430/${AUTHOR}/7`; +const COMMITMENT = `${UAL}/_cg-shared-v1`; +const EIP191_SIGNATURE = `0x${'77'.repeat(65)}`; +const ZERO_DIGEST = `0x${'00'.repeat(32)}`; +const UTF8 = new TextEncoder(); + +const PROFILE = { + networkId: 'otp:20430', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +} as CatalogSealDeploymentProfileV1; + +const PUBLIC = + ' "42"^^ .\n' + + ' "Alice" .\n'; +const MIXED = + `<${COMMITMENT}> "true" .\n` + + `<${COMMITMENT}> "95f31b6b9c7ac80554b3808b2cef2f6542c89a28947ede43d194bb8022398e2c"^^ .\n` + + ' "Alice" .\n'; +const FULLY_WITHHELD = + `<${COMMITMENT}> "true" .\n` + + `<${COMMITMENT}> "034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c"^^ .\n`; + +describe('RFC-64 canonical cg-shared-v1 projection verification', () => { + it.each([ + { + name: 'public', + projection: PUBLIC, + seal: { + assertionMerkleRoot: '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + }, + projectionDigest: '0x11e4d5cf843a836e3500a4a8d9bdc4495708056976247ad996f3497f5ff82e3d', + publicRoot: '0x0ff81162102568cf16401e4beebc03561950e6bb5b19ac686b9fa0e4132637ba', + privateDataHash: '0xdfba2a3576c2aa2d73ecd8c55d1c27cfb15691ca9d3237b86434a06592f160ee', + }, + { + name: 'mixed', + projection: MIXED, + seal: { + assertionMerkleRoot: '0xb775676d78d72f9038ba726111b5f64c40f1cad159e7cada86dd7de16dbb6fa1', + publicTripleCount: '3', + privateTripleCount: '1', + privateMerkleRoot: '0x95f31b6b9c7ac80554b3808b2cef2f6542c89a28947ede43d194bb8022398e2c', + }, + projectionDigest: '0xc45c59668c441426519220914ebeb59694ed60b2e502c7bff2548f3a8a3bcc4f', + publicRoot: '0xe3181c31fc57bed6862d86136c3f50b7c854ecfd602196a3862877bac2469e23', + privateDataHash: '0x95f31b6b9c7ac80554b3808b2cef2f6542c89a28947ede43d194bb8022398e2c', + }, + { + name: 'fully withheld', + projection: FULLY_WITHHELD, + seal: { + assertionMerkleRoot: '0xe0f1eb9ec6ed3efe806b900cf738b81277c28e68a669b345318bf825e51f4378', + publicTripleCount: '2', + privateTripleCount: '2', + privateMerkleRoot: '0x034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + }, + projectionDigest: '0x508144f5a405975107fb0977fe7efb0554b63615b293ef8472b805fcded525fa', + publicRoot: '0x7758136bc9fbf2e668b694c0b4dae7188d559eaad1ccd7d1fbc062de16360e92', + privateDataHash: '0x034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + }, + ])('accepts the normative $name vector', (vector) => { + const fixture = makeFixture(vector.projection, vector.seal); + const verified = verifyProjection(fixture); + expect(Object.getPrototypeOf(verified)).toBeNull(); + expect(Object.isFrozen(verified)).toBe(true); + expect(Reflect.ownKeys(verified as object)).toEqual([]); + const snapshot = readProjection(verified, fixture); + expect(snapshot).toMatchObject({ + projectionDigest: vector.projectionDigest, + projectionByteLength: String(UTF8.encode(vector.projection).byteLength), + publicRoot: vector.publicRoot, + privateDataHash: vector.privateDataHash, + assertionMerkleRoot: vector.seal.assertionMerkleRoot, + publicTripleCount: vector.seal.publicTripleCount, + privateTripleCount: vector.seal.privateTripleCount, + privateMerkleRoot: vector.seal.privateMerkleRoot, + kaUal: UAL, + verificationLimits: DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, + }); + expect(snapshot.projectionBytes).toEqual(UTF8.encode(vector.projection)); + }); + + it('sorts by raw UTF-8 bytes rather than JavaScript UTF-16 code units', () => { + const first = ' "bmp" .'; + const second = ' "astral" .'; + expect([first, second].sort()).toEqual([second, first]); + const canonical = `${first}\n${second}\n`; + const fixture = makeFixture(canonical, sealForProjection(canonical, '0', null)); + expect(() => verifyProjection(fixture)).not.toThrow(); + + const wrongOrder = `${second}\n${first}\n`; + const wrong = makeFixture(wrongOrder, sealForProjection(wrongOrder, '0', null)); + expectFailure(() => verifyProjection(wrong), 'projection-order'); + }); + + it.each([ + ' "empty hierarchy" .\n', + ' "RFC3987 non-ASCII space" .\n', + ])('accepts valid RFC3987 boundary IRIs without legacy regex narrowing', (projection) => { + const fixture = makeFixture(projection, sealForProjection(projection, '0', null)); + expect(() => verifyProjection(fixture)).not.toThrow(); + }); + + it('returns only fresh projection copies from both verification boundaries', () => { + const fixture = makeFixture(PUBLIC, { + assertionMerkleRoot: '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + }); + const metadata = readVerifiedTransferredCatalogBundleMetadataV1( + fixture.transferred, + fixture.head, + fixture.row, + PROFILE, + ); + expect('bundleBytes' in metadata).toBe(false); + const firstTransferRead = readVerifiedTransferredCatalogProjectionBytesV1( + fixture.transferred, + fixture.head, + fixture.row, + PROFILE, + ); + firstTransferRead.fill(0); + expect(readVerifiedTransferredCatalogProjectionBytesV1( + fixture.transferred, + fixture.head, + fixture.row, + PROFILE, + )).toEqual(UTF8.encode(PUBLIC)); + + const verified = verifyProjection(fixture); + const projectionMetadata = readVerifiedCgSharedProjectionMetadataV1( + verified, + fixture.transferred, + fixture.head, + fixture.row, + PROFILE, + ); + expect('projectionBytes' in projectionMetadata).toBe(false); + expect(projectionMetadata.projectionByteLength).toBe(String(UTF8.encode(PUBLIC).byteLength)); + const explicitBytes = readVerifiedCgSharedProjectionBytesV1( + verified, + fixture.transferred, + fixture.head, + fixture.row, + PROFILE, + ); + explicitBytes.fill(0); + expect(readVerifiedCgSharedProjectionBytesV1( + verified, + fixture.transferred, + fixture.head, + fixture.row, + PROFILE, + )).toEqual(UTF8.encode(PUBLIC)); + const first = readProjection(verified, fixture); + first.projectionBytes.fill(0); + expect(readProjection(verified, fixture).projectionBytes).toEqual(UTF8.encode(PUBLIC)); + }); + + it('rejects forged capabilities and cross-transfer replay', () => { + const fixture = publicFixture(); + const verified = verifyProjection(fixture); + for (const forged of [ + Object.freeze({}), + Object.freeze({ ...verified as object }), + JSON.parse(JSON.stringify(verified)), + ]) { + expectFailure(() => assertVerifiedCgSharedProjectionV1(forged), 'projection-capability'); + } + const secondTransfer = verifyTransferredCatalogBundleV1( + fixture.head, + fixture.row, + fixture.encoded.bundleBytes, + PROFILE, + ); + expectFailure( + () => assertVerifiedCgSharedProjectionForTransferV1( + verified, + secondTransfer, + fixture.head, + fixture.row, + PROFILE, + ), + 'projection-binding', + ); + }); + + it.each([ + { + name: 'random private anchor', + projection: FULLY_WITHHELD.replaceAll(COMMITMENT, 'urn:dkg:private:00000000-0000-4000-8000-000000000000'), + seal: { + assertionMerkleRoot: '0x05eea89e80cb5eb80720e2ae005ab8a7df9baa4e3a3abdd5cd8af0c9185dc096', + publicTripleCount: '2', privateTripleCount: '2', + privateMerkleRoot: '0x034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + }, + code: 'projection-private-subject', + }, + { + name: 'missing private hash', + projection: FULLY_WITHHELD.split('\n')[0] + '\n', + seal: { + assertionMerkleRoot: '0x68c726f6225b11c09ee3d6b591a2a6c1fae140c976b73f285fd47d62d629370b', + publicTripleCount: '1', privateTripleCount: '2', + privateMerkleRoot: '0x034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + }, + code: 'projection-private-cardinality', + }, + { + name: 'wrong private hash', + projection: FULLY_WITHHELD.replace( + '034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + 'f'.repeat(64), + ), + seal: { + assertionMerkleRoot: '0x4b521d726668cc2d6cdd4427690c3e47e92cf6733d850e3303feb0fca0b68e31', + publicTripleCount: '2', privateTripleCount: '2', + privateMerkleRoot: '0x034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + }, + code: 'projection-private-root-mismatch', + }, + { + name: 'unsorted public lines', + projection: PUBLIC.split('\n').filter(Boolean).reverse().join('\n') + '\n', + seal: { + assertionMerkleRoot: '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + publicTripleCount: '2', privateTripleCount: '0', privateMerkleRoot: null, + }, + code: 'projection-order', + }, + { + name: 'duplicate public line', + projection: PUBLIC.split('\n')[0] + '\n' + PUBLIC, + seal: { + assertionMerkleRoot: '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + publicTripleCount: '3', privateTripleCount: '0', privateMerkleRoot: null, + }, + code: 'projection-duplicate', + }, + { + name: 'CRLF', + projection: PUBLIC.replaceAll('\n', '\r\n'), + seal: { + assertionMerkleRoot: '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + publicTripleCount: '2', privateTripleCount: '0', privateMerkleRoot: null, + }, + code: 'projection-line-ending', + }, + ] as const)('rejects the normative $name vector', (vector) => { + const fixture = makeFixture(vector.projection, vector.seal); + expectFailure(() => verifyProjection(fixture), vector.code); + }); + + it.each([ + ['missing final LF', PUBLIC.slice(0, -1), 'projection-line-ending'], + ['leading BOM', `\uFEFF${PUBLIC}`, 'projection-utf8'], + ['leading space', ` ${PUBLIC}`, 'projection-iri'], + ['blank-node subject', '_:b0 "Alice" .\n', 'projection-iri'], + ['relative IRI', ' "Alice" .\n', 'projection-iri'], + ['bad percent escape', ' "Alice" .\n', 'projection-iri'], + ['malformed IP literal', ' "Alice" .\n', 'projection-iri'], + ['forbidden bracket in path', ' "Alice" .\n', 'projection-iri'], + ['RFC3987 noncharacter', ' "Alice" .\n', 'projection-iri'], + ['fourth graph term', ' "x" .\n', 'projection-literal'], + ['blank-node object', ' _:b0 .\n', 'projection-line'], + ['RDF-star object', ' << "x" >> .\n', 'projection-iri'], + ['doubled separator', ' "x" .\n', 'projection-iri'], + ['trailing space', ' "x" . \n', 'projection-line'], + ['explicit xsd:string', ' "x"^^ .\n', 'projection-literal'], + ['uppercase language', ' "x"@EN .\n', 'projection-literal'], + ['noncanonical integer', ' "+042"^^ .\n', 'projection-literal'], + ['unknown reserved predicate', ' "x" .\n', 'projection-private-predicate'], + ] as const)('rejects %s', (_name, projection, code) => { + const fixture = makeFixture( + projection, + sealForProjection(projection, '0', null), + ); + expectFailure(() => verifyProjection(fixture), code); + }); + + it('rejects an empty projection before root work', () => { + const fixture = makeFixture('', { + assertionMerkleRoot: ZERO_DIGEST, + publicTripleCount: '1', + privateTripleCount: '0', + privateMerkleRoot: null, + }); + expectFailure(() => verifyProjection(fixture), 'projection-empty'); + }); + + it('rejects invalid UTF-8 before term work', () => { + const bytes = UTF8.encode(PUBLIC); + bytes[1] = 0xff; + const fixture = makeFixtureBytes(bytes, { + assertionMerkleRoot: '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + publicTripleCount: '2', privateTripleCount: '0', privateMerkleRoot: null, + }); + expectFailure(() => verifyProjection(fixture), 'projection-utf8'); + }); + + it('rejects public count and structured-root mismatches independently', () => { + const wrongCount = makeFixture(PUBLIC, { + assertionMerkleRoot: '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + publicTripleCount: '3', privateTripleCount: '0', privateMerkleRoot: null, + }); + expectFailure(() => verifyProjection(wrongCount), 'projection-public-count'); + + const wrongRoot = makeFixture(PUBLIC, { + assertionMerkleRoot: ZERO_DIGEST, + publicTripleCount: '2', privateTripleCount: '0', privateMerkleRoot: null, + }); + expectFailure(() => verifyProjection(wrongRoot), 'projection-structured-root'); + }); + + it('rejects private commitment predicates in a public-only projection', () => { + const projection = + `<${COMMITMENT}> <${CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1}> "true" .\n`; + const fixture = makeFixture(projection, sealForProjection(projection, '0', null)); + expectFailure(() => verifyProjection(fixture), 'projection-private-cardinality'); + }); + + it('rejects an xsd:boolean private anchor even when the alternate root is signed', () => { + const projection = FULLY_WITHHELD.replace( + '"true" .', + '"true"^^ .', + ); + const fixture = makeFixture( + projection, + sealForProjection( + projection, + '2', + '0x034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + ), + ); + expectFailure(() => verifyProjection(fixture), 'projection-private-cardinality'); + }); + + it('does not inherit mutation of the exported V10 no-private sentinel', () => { + const fixture = publicFixture(); + const original = new Uint8Array(SENTINEL_NO_PRIVATE_V10); + try { + SENTINEL_NO_PRIVATE_V10.fill(0); + const verified = verifyProjection(fixture); + expect(readProjection(verified, fixture).assertionMerkleRoot).toBe( + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + ); + } finally { + SENTINEL_NO_PRIVATE_V10.set(original); + } + }); + + it('reserves the deterministic commitment subject exclusively for codec rows', () => { + const projection = + `<${COMMITMENT}> "x" .\n`; + const fixture = makeFixture(projection, sealForProjection(projection, '0', null)); + expectFailure(() => verifyProjection(fixture), 'projection-private-subject'); + }); + + it.each([ + { + name: 'uppercase private hash', + projection: FULLY_WITHHELD.replace('034349e1', 'A34349e1'), + }, + { + name: '0x-prefixed private hash', + projection: FULLY_WITHHELD.replace('"034349e1', '"0x034349e1'), + }, + ])('rejects $name even when the alternate bytes are sealed', ({ projection }) => { + const fixture = makeFixture( + projection, + sealForProjection( + projection, + '2', + '0x034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + ), + ); + expectFailure(() => verifyProjection(fixture), 'projection-private-root-mismatch'); + }); + + it('rejects distinct duplicate commitment predicates', () => { + const duplicate = + `<${COMMITMENT}> <${CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1}> "false" .\n`; + const projection = [ + ...FULLY_WITHHELD.trimEnd().split('\n'), + duplicate.trimEnd(), + ].sort((left, right) => Buffer.from(left).compare(Buffer.from(right))).join('\n') + '\n'; + const fixture = makeFixture( + projection, + sealForProjection( + projection, + '2', + '0x034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c', + ), + ); + expectFailure(() => verifyProjection(fixture), 'projection-private-cardinality'); + }); + + it('distinguishes local resource refusal from malformed projection bytes', () => { + const fixture = publicFixture(); + expectFailure( + () => verifyProjection(fixture, { + maxProjectionBytes: UTF8.encode(PUBLIC).byteLength - 1, + maxPublicTriples: 2, + maxLineBytes: UTF8.encode(PUBLIC).byteLength - 1, + }), + 'projection-resource-refused', + ); + expectFailure( + () => verifyProjection(fixture, { + maxProjectionBytes: UTF8.encode(PUBLIC).byteLength, + maxPublicTriples: 1, + maxLineBytes: UTF8.encode(PUBLIC).byteLength, + }), + 'projection-resource-refused', + ); + expectFailure( + () => verifyProjection(fixture, { + maxProjectionBytes: UTF8.encode(PUBLIC).byteLength, + maxPublicTriples: 2, + maxLineBytes: 32, + }), + 'projection-resource-refused', + ); + }); + + it('rejects unsupported or internally inconsistent verifier limits', () => { + const fixture = publicFixture(); + for (const limits of [ + { maxProjectionBytes: 0, maxPublicTriples: 2, maxLineBytes: 1 }, + { + maxProjectionBytes: + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1.maxProjectionBytes + 1, + maxPublicTriples: 2, + maxLineBytes: 1, + }, + { maxProjectionBytes: 64, maxPublicTriples: 2, maxLineBytes: 65 }, + ]) { + expectFailure( + () => verifyProjection(fixture, limits), + 'projection-resource-refused', + ); + } + }); +}); + +interface Fixture { + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly row: AuthorCatalogRowV1; + readonly encoded: ReturnType; + readonly transferred: VerifiedTransferredCatalogBundleV1; +} + +function publicFixture(): Fixture { + return makeFixture(PUBLIC, { + assertionMerkleRoot: '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + publicTripleCount: '2', privateTripleCount: '0', privateMerkleRoot: null, + }); +} + +function makeFixture( + projection: string, + sealFields: Pick< + CanonicalGraphScopedAuthorSealV1, + 'assertionMerkleRoot' | 'publicTripleCount' | 'privateTripleCount' | 'privateMerkleRoot' + >, +): Fixture { + return makeFixtureBytes(UTF8.encode(projection), sealFields); +} + +function makeFixtureBytes( + projectionBytes: Uint8Array, + sealFields: Pick< + CanonicalGraphScopedAuthorSealV1, + 'assertionMerkleRoot' | 'publicTripleCount' | 'privateTripleCount' | 'privateMerkleRoot' + >, +): Fixture { + const seal = validSeal({ + assertionMerkleRoot: sealFields.assertionMerkleRoot, + authorAddress: AUTHOR, + authorAttestationR: `0x${'11'.repeat(32)}`, + authorAttestationVS: `0x${'22'.repeat(32)}`, + authorSchemeVersion: '1', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: UAL, + assertionVersion: '2', + publicTripleCount: sealFields.publicTripleCount, + privateTripleCount: sealFields.privateTripleCount, + privateMerkleRoot: sealFields.privateMerkleRoot, + }); + const sealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(seal); + const encoded = encodeOpaqueKaBundleV1(projectionBytes, sealBytes); + const row = rowForBundle( + encoded.bundleBytes, + encoded.projectionDigest, + encoded.blobDigest, + computeCanonicalGraphScopedAuthorSealDigestV1(seal), + ); + const head = signedHead({ + networkId: 'otp:20430', + contextGraphId: 'a/b', + governanceChainId: '20430', + governanceContractAddress: '0x6666666666666666666666666666666666666666', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + catalogIssuerDelegationDigest: `0x${'77'.repeat(32)}`, + era: '0', + version: '1', + previousHeadDigest: null, + bucketCount: '1', + totalRows: '1', + directoryHeight: '0', + directoryRootDigest: `0x${'88'.repeat(32)}`, + issuedAt: '1700000000123', + }); + const transferred = verifyTransferredCatalogBundleV1( + head, + row, + encoded.bundleBytes, + PROFILE, + ); + return { head, row, encoded, transferred }; +} + +function sealForProjection( + projection: string, + privateTripleCount: string, + privateMerkleRoot: string | null, +): Pick< + CanonicalGraphScopedAuthorSealV1, + 'assertionMerkleRoot' | 'publicTripleCount' | 'privateTripleCount' | 'privateMerkleRoot' +> { + const lines = projection.endsWith('\n') + ? projection.slice(0, -1).split('\n') + : projection.split('\n'); + const leaves = lines.filter(Boolean).map((line) => { + const match = /^(<[^>]+>) (<[^>]+>) (.+) \.$/.exec(line); + if (!match) return hashTripleV10('https://invalid.example/s', 'https://invalid.example/p', '"x"'); + return hashTripleV10(match[1].slice(1, -1), match[2].slice(1, -1), match[3]); + }); + const publicRoot = leaves.length === 0 ? new Uint8Array(32) : new V10MerkleTree(leaves).root; + const privateDataHash = privateMerkleRoot === null + ? SENTINEL_NO_PRIVATE_V10 + : hexToBytes(privateMerkleRoot); + return { + assertionMerkleRoot: bytesToHex(V10MerkleTree.computeKARoot(publicRoot, privateDataHash)), + publicTripleCount: String(lines.filter(Boolean).length), + privateTripleCount, + privateMerkleRoot, + } as Pick< + CanonicalGraphScopedAuthorSealV1, + 'assertionMerkleRoot' | 'publicTripleCount' | 'privateTripleCount' | 'privateMerkleRoot' + >; +} + +function verifyProjection( + fixture: Fixture, + limits?: CgSharedProjectionVerificationLimitsV1, +) { + return verifyCgSharedProjectionV1( + fixture.transferred, + fixture.head, + fixture.row, + PROFILE, + limits, + ); +} + +function readProjection( + verified: ReturnType, + fixture: Fixture, +) { + return readVerifiedCgSharedProjectionV1( + verified, + fixture.transferred, + fixture.head, + fixture.row, + PROFILE, + ); +} + +function rowForBundle( + bundleBytes: Uint8Array, + projectionDigest: string, + blobDigest: string, + sealDigest: string, +): AuthorCatalogRowV1 { + const byteLength = BigInt(bundleBytes.byteLength); + return validRow({ + kaId: KA_ID, + assertionCoordinate: 'name λ', + assertionVersion: '2', + projectionId: 'cg-shared-v1', + projectionDigest, + sealDigest, + transfer: { + codec: 'dkg-ka-bundle-v1', + projectionId: 'cg-shared-v1', + projectionDigest, + byteLength: byteLength.toString(), + chunkSize: '262144', + chunkCount: (((byteLength - 1n) / 262_144n) + 1n).toString(), + blobDigest, + chunkTreeRoot: computeKaChunkTreeRootV1(bundleBytes), + }, + }); +} + +function signedHead(head: AuthorCatalogHeadV1): SignedAuthorCatalogHeadEnvelopeV1 { + const unsigned = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload: head, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; + return validatedSignedHead({ + ...unsigned, + objectDigest: computeAuthorCatalogHeadObjectDigestV1(unsigned), + signature: EIP191_SIGNATURE, + }); +} + +function validRow(value: unknown): AuthorCatalogRowV1 { + assertAuthorCatalogRowV1(value); + return value; +} + +function validSeal(value: unknown): CanonicalGraphScopedAuthorSealV1 { + assertCanonicalGraphScopedAuthorSealV1(value); + return value; +} + +function validatedSignedHead(value: unknown): SignedAuthorCatalogHeadEnvelopeV1 { + assertSignedAuthorCatalogHeadEnvelopeV1(value as SignedAuthorCatalogHeadEnvelopeV1); + return value as SignedAuthorCatalogHeadEnvelopeV1; +} + +function hexToBytes(value: string): Uint8Array { + const bytes = new Uint8Array(32); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(2 + index * 2, 4 + index * 2), 16); + } + return bytes; +} + +function bytesToHex(value: Uint8Array): string { + return `0x${[...value].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`; +} + +function expectFailure( + operation: () => unknown, + code: CgSharedProjectionErrorCode, +): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(CgSharedProjectionError); + expect((error as CgSharedProjectionError).code).toBe(code); + return; + } + throw new Error(`expected ${code}`); +} From 6f97d9d356655b7c303efc03f5ae9a9f67aca5e2 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:53:45 +0200 Subject: [PATCH 054/292] fix(agent): fail closed on journal restart ambiguity --- packages/agent/src/rfc64/inventory-v1/open.ts | 196 ++++++--------- .../test/fixtures/rfc64-inventory-v1-child.ts | 9 + .../test/rfc64-inventory-v1-lifecycle.test.ts | 226 +++++++++++++----- 3 files changed, 251 insertions(+), 180 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 0675e08e2e..2ee3bf4fbb 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -269,8 +269,13 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { } this.#database = null; try { - beginQuarantine(this.databasePath, this.lifecycle); - finishPendingQuarantine(this.sqlite, this.databasePath, this.lifecycle); + const freshMarker = beginQuarantine(this.databasePath, this.lifecycle); + finishPendingQuarantine( + this.sqlite, + this.databasePath, + this.lifecycle, + freshMarker, + ); this.#database = openOrRebuildOwnedDatabase( this.sqlite, this.databasePath, @@ -666,8 +671,8 @@ function openOrRebuildOwnedDatabase( assertDatabaseQuiescent(database, lifecycle); closeInventoryTarget(database, 'automatic-schema-quarantine', lifecycle); database = null; - beginQuarantine(databasePath, lifecycle); - finishPendingQuarantine(sqlite, databasePath, lifecycle); + const freshMarker = beginQuarantine(databasePath, lifecycle); + finishPendingQuarantine(sqlite, databasePath, lifecycle, freshMarker); return openOrRebuildOwnedDatabase(sqlite, databasePath, lifecycle); } applyAndVerifyPragmas(database); @@ -745,8 +750,8 @@ function openOrRebuildOwnedDatabase( } closeInventoryTarget(database, 'automatic-corrupt-quarantine', lifecycle); database = null; - beginQuarantine(databasePath, lifecycle); - finishPendingQuarantine(sqlite, databasePath, lifecycle); + const freshMarker = beginQuarantine(databasePath, lifecycle); + finishPendingQuarantine(sqlite, databasePath, lifecycle, freshMarker); return openOrRebuildOwnedDatabase(sqlite, databasePath, lifecycle); } closeProbe(); @@ -1410,6 +1415,13 @@ interface RecoveryTopologyV1 { readonly movedCount: number; } +interface FreshRecoveryMarkerAuthorizationV1 { + readonly databasePath: string; + readonly markerPath: string; + readonly quarantineDirectory: string; + readonly members: RecoveryMembersV1; +} + function assertQuarantineDurabilityAvailable( lifecycle: InventoryV1LifecycleAdapter, ): void { @@ -1428,12 +1440,12 @@ function assertQuarantineDurabilityAvailable( function beginQuarantine( databasePath: string, lifecycle: InventoryV1LifecycleAdapter, -): void { +): FreshRecoveryMarkerAuthorizationV1 | null { assertQuarantineDurabilityAvailable(lifecycle); const markerPath = recoveryMarkerPath(databasePath); if (pathEntryExists(markerPath)) { rejectSymlink(markerPath, 'inventory recovery marker'); - return; + return null; } const members = inspectSourceMembersForNewMarker(databasePath); for (const member of members) { @@ -1480,12 +1492,19 @@ function beginQuarantine( lifecycle.boundary('begin.marker.file-fsync'); fsyncDirectory(inventoryDirectory); lifecycle.boundary('begin.inventory-directory.fsync-after-marker'); + return Object.freeze({ + databasePath, + markerPath, + quarantineDirectory, + members, + }); } function finishPendingQuarantine( sqlite: SqliteModuleV1, databasePath: string, lifecycle: InventoryV1LifecycleAdapter, + freshMarkerAuthorization: FreshRecoveryMarkerAuthorizationV1 | null = null, ): void { const markerPath = recoveryMarkerPath(databasePath); if (!pathEntryExists(markerPath)) return; @@ -1507,6 +1526,14 @@ function finishPendingQuarantine( readBoundedRecoveryMarker(markerPath), inventoryDirectory, ); + const sourceQuiescenceAlreadyProven = freshMarkerAuthorization !== null + && freshMarkerAuthorization.databasePath === databasePath + && freshMarkerAuthorization.markerPath === markerPath + && freshMarkerAuthorization.quarantineDirectory === marker.quarantineDirectory + && freshMarkerAuthorization.members.length === marker.members.length + && freshMarkerAuthorization.members.every( + (member, index) => marker.members[index] === member, + ); if (!pathEntryExists(marker.quarantineDirectory)) { throw new InventoryV1OpenError( 'database-io', @@ -1517,6 +1544,12 @@ function finishPendingQuarantine( assertSameFilesystem(inventoryDirectory, marker.quarantineDirectory, 'inventory quarantine generation'); let topology = inspectRecoveryTopology(marker, databasePath); + if ( + marker.members[0] === 'journal' + && topology.movedCount < marker.members.length + ) { + assertRollbackJournalMainHeader(databasePath); + } // A crash may have occurred after rename but before that move's file or // directory barriers. Re-establish durability for the observed moved prefix // before either continuing or deleting the marker. @@ -1529,7 +1562,20 @@ function finishPendingQuarantine( // untouched. Once the first prefix member has moved, that durable prefix // is the restart witness that this proof completed before any rename. if (marker.members[0] === 'journal') { - assertPendingRollbackJournalUnitQuiescent(sqlite, databasePath, lifecycle); + // SQLite 3.39+ canonicalizes /dev/fd and /proc/self/fd aliases before + // opening the database. An alias probe can therefore derive the real + // source journal pathname and recover, zero, or delete the evidence it + // is meant to protect. A freshly created marker may continue because + // its caller proved target exclusivity before close and still holds the + // lifetime DK6L lease. A zero-move marker reached after restart has no + // equally non-mutating proof primitive in Node 22.5, so preserve the + // complete unit and require offline recovery. + if (!sourceQuiescenceAlreadyProven) { + throw new InventoryV1OpenError( + 'durability-unavailable', + 'zero-move rollback-journal recovery requires offline exclusivity proof', + ); + } } else { assertPendingUnitQuiescent(sqlite, databasePath, lifecycle); } @@ -1622,9 +1668,26 @@ function inspectSourceMembersForNewMarker(databasePath: string): RecoveryMembers assertOwnedRegularFile(source, `inventory ${member} evidence`); present.push(member); } + if (present[0] === 'journal') { + assertRollbackJournalMainHeader(databasePath); + } return assertRecoveryMembers(present); } +function assertRollbackJournalMainHeader(databasePath: string): void { + const identity = readValidSqliteHeaderIdentity(databasePath); + if ( + identity === null + || identity.writeVersion !== 1 + || identity.readVersion !== 1 + ) { + throw new InventoryV1OpenError( + 'ambiguous-database', + 'rollback-journal evidence with a non-rollback main header requires offline recovery', + ); + } +} + function inspectRecoveryTopology( marker: RecoveryMarkerV1, databasePath: string, @@ -1758,117 +1821,6 @@ function assertPendingUnitQuiescent( } } -function assertPendingRollbackJournalUnitQuiescent( - sqlite: SqliteModuleV1, - databasePath: string, - lifecycle: InventoryV1LifecycleAdapter, -): void { - if (!pathEntryExists(databasePath)) { - throw new InventoryV1OpenError( - 'database-io', - 'pending rollback-journal quarantine has no source main', - ); - } - rejectOwnedFileSymlinks(databasePath); - assertOwnedUnitOwners(databasePath); - - // Opening the main database by its ordinary pathname can recover or delete - // its rollback journal before SQLite reports whether another process owns a - // writer lock. Instead, open the already-validated main inode through this - // process's descriptor namespace. SQLite locks the same inode, while its - // derived journal pathname is the descriptor alias and therefore cannot - // consume or rename the source `-journal` evidence. Quarantine is available - // only under the certified POSIX capability, where /dev/fd (or Linux's - // equivalent /proc/self/fd) provides this same-inode alias. - let descriptor: number | undefined; - let database: DatabaseSyncV1 | null = null; - let transactionOpen = false; - try { - descriptor = openSync(databasePath, 'r+'); - const sourceIdentity = fstatSync(descriptor); - if (!sourceIdentity.isFile()) { - throw new InventoryV1OpenError( - 'database-io', - 'pending rollback-journal source main is not a regular file', - ); - } - const aliasCandidates = [ - `/dev/fd/${descriptor}`, - ...(process.platform === 'linux' ? [`/proc/self/fd/${descriptor}`] : []), - ]; - let descriptorAlias: string | undefined; - for (const candidate of aliasCandidates) { - let aliasDescriptor: number | undefined; - try { - aliasDescriptor = openSync(candidate, 'r+'); - const aliasIdentity = fstatSync(aliasDescriptor); - if ( - aliasIdentity.isFile() - && aliasIdentity.dev === sourceIdentity.dev - && aliasIdentity.ino === sourceIdentity.ino - ) { - descriptorAlias = candidate; - break; - } - } catch { - // Try the next fixed descriptor namespace alias. - } finally { - if (aliasDescriptor !== undefined) closeSync(aliasDescriptor); - } - } - if (descriptorAlias === undefined) { - throw new InventoryV1OpenError( - 'durability-unavailable', - 'certified POSIX quarantine cannot address the source main through a same-inode descriptor alias', - ); - } - - database = new sqlite.DatabaseSync(descriptorAlias); - database.exec('PRAGMA busy_timeout = 0'); - database.exec('BEGIN EXCLUSIVE'); - transactionOpen = true; - database.exec('ROLLBACK'); - transactionOpen = false; - database.close(); - database = null; - - const finalIdentity = fstatSync(descriptor); - if ( - !finalIdentity.isFile() - || finalIdentity.dev !== sourceIdentity.dev - || finalIdentity.ino !== sourceIdentity.ino - ) { - throw new InventoryV1OpenError( - 'database-io', - 'pending rollback-journal source main changed during the exclusivity proof', - ); - } - lifecycle.boundary('target-exclusivity-proven'); - } catch (cause) { - if (transactionOpen && database !== null) { - try { database.exec('ROLLBACK'); } catch { /* retain the proof failure */ } - } - if (cause instanceof InventoryV1OpenError) throw cause; - if (isBusySqliteError(cause)) { - throw new InventoryV1OpenError( - 'database-busy', - 'pending rollback-journal quarantine still has an active SQLite holder', - { cause }, - ); - } - throw new InventoryV1OpenError( - 'database-io', - 'cannot prove exclusive access to the pending rollback-journal unit', - { cause }, - ); - } finally { - if (database !== null) { - try { database.close(); } catch { /* retain any primary proof failure */ } - } - if (descriptor !== undefined) closeSync(descriptor); - } -} - function readBoundedRecoveryMarker(markerPath: string): string { let descriptor: number | undefined; let bytes: Buffer; @@ -2046,6 +1998,8 @@ type CorruptDatabaseOwnershipV1 = 'owned' | 'ambiguous' | 'foreign' | 'newer'; interface SqliteHeaderIdentityV1 { applicationId: number; userVersion: number; + writeVersion: number; + readVersion: number; } function classifyCorruptDatabaseOwnership(databasePath: string): CorruptDatabaseOwnershipV1 { @@ -2068,6 +2022,8 @@ function readValidSqliteHeaderIdentity(databasePath: string): SqliteHeaderIdenti return { applicationId: header.readUInt32BE(68), userVersion: header.readUInt32BE(60), + writeVersion: header[18], + readVersion: header[19], }; } catch (cause) { throw new InventoryV1OpenError( diff --git a/packages/agent/test/fixtures/rfc64-inventory-v1-child.ts b/packages/agent/test/fixtures/rfc64-inventory-v1-child.ts index d93df3314e..860ef359d5 100644 --- a/packages/agent/test/fixtures/rfc64-inventory-v1-child.ts +++ b/packages/agent/test/fixtures/rfc64-inventory-v1-child.ts @@ -71,6 +71,15 @@ if (mode === 'fault') { : Buffer.alloc(0), ); } + if ( + process.env.DKG_RFC64_CHILD_SYNTHETIC_JOURNAL === '1' + && reason === 'automatic-schema-quarantine' + ) { + writeFileSync( + `${targetPath}-journal`, + Buffer.from('hostile-rfc64-journal-evidence\0\u0000\u0001', 'utf8'), + ); + } }, }); await openForFault(dataDirectory); diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index 5763ae5306..1dcac3f771 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -106,8 +106,20 @@ const MOVED_PREFIX_FAULT_BOUNDARIES = FULL_RECOVERY_MANIFEST.flatMap((member) => `resume.prefix.${member}.inventory-directory-fsync`, ] as const) satisfies readonly InventoryV1QuarantineBoundary[]; -const JOURNAL_RESTART_FAULT_BOUNDARIES = [ - 'target-exclusivity-proven', +const JOURNAL_PREFIX_FAULT_BOUNDARIES = [ + 'resume.prefix.journal.file-fsync', + 'resume.prefix.journal.generation-directory-fsync', + 'resume.prefix.journal.inventory-directory-fsync', +] as const satisfies readonly InventoryV1QuarantineBoundary[]; + +const JOURNAL_AUTOMATIC_FAULT_BOUNDARIES = [ + 'begin.source.journal.file-fsync', + 'begin.source.main.file-fsync', + 'begin.inventory-directory.fsync-after-quarantine-root', + 'begin.quarantine-root.fsync-after-generation', + 'begin.marker.write', + 'begin.marker.file-fsync', + 'begin.inventory-directory.fsync-after-marker', 'resume.source.journal.file-fsync-after-quiescence', 'resume.source.main.file-fsync-after-quiescence', 'resume.member.journal.rename', @@ -122,12 +134,6 @@ const JOURNAL_RESTART_FAULT_BOUNDARIES = [ 'resume.inventory-directory.fsync-after-marker-unlink', ] as const satisfies readonly InventoryV1QuarantineBoundary[]; -const JOURNAL_PREFIX_FAULT_BOUNDARIES = [ - 'resume.prefix.journal.file-fsync', - 'resume.prefix.journal.generation-directory-fsync', - 'resume.prefix.journal.inventory-directory-fsync', -] as const satisfies readonly InventoryV1QuarantineBoundary[]; - function expectedFullManifestTrace( boundary: (typeof FULL_MANIFEST_FAULT_BOUNDARIES)[number], ): InventoryV1QuarantineBoundary[] { @@ -1221,6 +1227,33 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }); + it('fails closed on rollback-journal evidence beside a WAL-mode main header', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + createIncompatibleOwnedInventory(path, 'wal-header-journal'); + const wal = new DatabaseSync(path); + wal.exec('PRAGMA journal_mode = WAL; PRAGMA wal_checkpoint(TRUNCATE);'); + wal.close(); + expect(readFileSync(path).subarray(18, 20)).toEqual(Buffer.from([2, 2])); + + const journalEvidence = HOSTILE_SIDECAR_BYTES.journal; + const opener = createInventoryV1TestOpener({ + quarantineCapability: INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, + closeTarget: (close, reason) => { + close(); + if (reason === 'automatic-schema-quarantine') { + writeFileSync(`${path}-journal`, journalEvidence); + } + }, + }); + await expect(opener(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'ambiguous-database'), + ); + expect(readFileSync(`${path}-journal`)).toEqual(journalEvidence); + expect(existsSync(`${path}.rebuild-required`)).toBe(false); + expect(quarantineGenerations(path)).toEqual([]); + }); + it('rejects pre-existing mixed journal modes before SQLite can mutate either sidecar', async () => { for (const mixedMember of ['wal', 'shm'] as const) { const dataDirectory = temporaryDataDirectory(); @@ -2056,7 +2089,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }); - it('does not move a zero-prefix journal manifest before proving a raw SQLite holder is gone', async () => { + it('fails closed without opening or moving a restarted zero-prefix journal manifest', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); const generation = join( @@ -2091,7 +2124,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc try { for (let attempt = 0; attempt < 2; attempt += 1) { await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( - (error: unknown) => expectOpenErrorCode(error, 'database-busy'), + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), ); expect(fileOracle(path)).toEqual(before.main); expect(fileOracle(`${path}-journal`)).toEqual(before.journal); @@ -2108,6 +2141,42 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }); + it('leaves a zero-prefix journal marker with a WAL-mode main untouched', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const generation = join( + dirname(path), + 'quarantine', + 'inventory-v1-1234567890-9797979797979797', + ); + mkdirSync(generation, { recursive: true }); + createIncompatibleOwnedInventory(path, 'wal-marker-journal'); + const wal = new DatabaseSync(path); + wal.exec('PRAGMA journal_mode = WAL; PRAGMA wal_checkpoint(TRUNCATE);'); + wal.close(); + writeFileSync(`${path}-journal`, HOSTILE_SIDECAR_BYTES.journal); + const marker = Buffer.from(JSON.stringify({ + version: 1, + quarantineDirectory: generation, + members: ['journal', 'main'], + })); + writeFileSync(`${path}.rebuild-required`, marker); + const before = { + main: fileOracle(path), + journal: fileOracle(`${path}-journal`), + marker: fileOracle(`${path}.rebuild-required`), + destinationNames: readdirSync(generation).sort(), + }; + + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'ambiguous-database'), + ); + expect(fileOracle(path)).toEqual(before.main); + expect(fileOracle(`${path}-journal`)).toEqual(before.journal); + expect(fileOracle(`${path}.rebuild-required`)).toEqual(before.marker); + expect(readdirSync(generation).sort()).toEqual(before.destinationNames); + }); + it('resumes a partial recovery-marker move before rebuilding', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); @@ -2286,6 +2355,22 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc ); const path = databasePath(dataDirectory); + if (members[0] === 'journal' && movedPrefix === 0) { + const before = new Map(members.map((member) => { + const evidence = recoverySourcePath(path, member); + return [evidence, fileOracle(evidence)] as const; + })); + const markerBefore = fileOracle(`${path}.rebuild-required`); + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + expect(fileOracle(`${path}.rebuild-required`)).toEqual(markerBefore); + for (const [evidence, oracle] of before) { + expect(fileOracle(evidence)).toEqual(oracle); + } + continue; + } + const foundation = await openInventoryV1(dataDirectory); try { assertInitializedInventory(path); @@ -2384,56 +2469,6 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }, 180_000); - it('recovers journal evidence in a fresh process after SIGKILL at every restart-proof and move boundary', async () => { - for (const boundary of JOURNAL_RESTART_FAULT_BOUNDARIES) { - const dataDirectory = temporaryDataDirectory(); - const path = databasePath(dataDirectory); - const label = `journal-fault-${boundary}`; - const seeded = seedPendingRecoveryTopology( - dataDirectory, - JOURNAL_RECOVERY_MANIFEST, - 0, - label, - ); - const tracePath = join(dataDirectory, `trace-${boundary}.jsonl`); - const trace = await killAtInventoryBoundary(dataDirectory, boundary, tracePath, { - DKG_RFC64_CHILD_PROTECT_EVIDENCE: '1', - }); - expect(trace.map((entry) => entry.boundary)).toEqual( - JOURNAL_RESTART_FAULT_BOUNDARIES.slice( - 0, - JOURNAL_RESTART_FAULT_BOUNDARIES.indexOf(boundary) + 1, - ), - ); - const killedEvidence = new Map(); - for (const member of JOURNAL_RECOVERY_MANIFEST) { - const source = recoverySourcePath(path, member); - const destination = recoveryDestinationPath(seeded.generation, member); - expect(existsSync(source) || existsSync(destination)).toBe(true); - killedEvidence.set(member, fileOracle(existsSync(source) ? source : destination)); - } - - try { - await reopenInventoryInFreshProcess(dataDirectory); - } catch (cause) { - throw new Error(`fresh-process journal recovery failed after ${boundary}`, { cause }); - } - assertInitializedInventory(path); - assertRecoveredManifestEvidence( - path, - JOURNAL_RECOVERY_MANIFEST, - label, - seeded.generation, - seeded.hashes, - ); - for (const member of JOURNAL_RECOVERY_MANIFEST) { - expect(fileOracle(recoveryDestinationPath(seeded.generation, member))).toEqual( - killedEvidence.get(member), - ); - } - } - }, 120_000); - it('recovers a journal moved-prefix in a fresh process after SIGKILL at every prefix durability boundary', async () => { for (const boundary of JOURNAL_PREFIX_FAULT_BOUNDARIES) { const dataDirectory = temporaryDataDirectory(); @@ -2479,6 +2514,77 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }, 60_000); + it('preserves or resumes automatic journal quarantine at every crash boundary', async () => { + for (const boundary of JOURNAL_AUTOMATIC_FAULT_BOUNDARIES) { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + const label = `automatic-journal-fault-${boundary}`; + createIncompatibleOwnedInventory(path, label); + const tracePath = join(dataDirectory, `trace-${boundary}.jsonl`); + const trace = await killAtInventoryBoundary(dataDirectory, boundary, tracePath, { + DKG_RFC64_CHILD_SYNTHETIC_JOURNAL: '1', + }); + expect(trace.map((entry) => entry.boundary)).toEqual([ + 'target-exclusivity-proven', + ...JOURNAL_AUTOMATIC_FAULT_BOUNDARIES.slice( + 0, + JOURNAL_AUTOMATIC_FAULT_BOUNDARIES.indexOf(boundary) + 1, + ), + ]); + + const markerPath = `${path}.rebuild-required`; + const generation = quarantineGenerations(path).find((candidate) => + existsSync(recoveryDestinationPath(candidate, 'journal')) + || existsSync(recoveryDestinationPath(candidate, 'main')) + || existsSync(markerPath)); + const journalAtDestination = generation !== undefined + && existsSync(recoveryDestinationPath(generation, 'journal')); + + if (existsSync(markerPath) && !journalAtDestination) { + const before = { + main: fileOracle(path), + journal: fileOracle(`${path}-journal`), + marker: fileOracle(markerPath), + destinationNames: generation === undefined ? [] : readdirSync(generation).sort(), + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(openInventoryV1(dataDirectory)).rejects.toSatisfy( + (error: unknown) => expectOpenErrorCode(error, 'durability-unavailable'), + ); + expect(fileOracle(path)).toEqual(before.main); + expect(fileOracle(`${path}-journal`)).toEqual(before.journal); + expect(fileOracle(markerPath)).toEqual(before.marker); + if (generation !== undefined) { + expect(readdirSync(generation).sort()).toEqual(before.destinationNames); + } + } + continue; + } + + const killedEvidence = new Map(); + const committedMembers = journalAtDestination + ? JOURNAL_RECOVERY_MANIFEST + : (['main'] as const); + for (const member of committedMembers) { + const source = recoverySourcePath(path, member); + const destination = generation === undefined + ? undefined + : recoveryDestinationPath(generation, member); + const evidence = existsSync(source) ? source : destination; + if (evidence !== undefined && existsSync(evidence)) { + killedEvidence.set(member, fileOracle(evidence)); + } + } + + await reopenInventoryInFreshProcess(dataDirectory); + assertInitializedInventory(path); + const recoveredGeneration = evidenceGeneration(path); + for (const [member, oracle] of killedEvidence) { + expect(fileOracle(recoveryDestinationPath(recoveredGeneration, member))).toEqual(oracle); + } + } + }, 120_000); + it('recovers in a fresh process after SIGKILL at every moved-prefix re-fsync boundary', async () => { for (const boundary of MOVED_PREFIX_FAULT_BOUNDARIES) { const member = boundary.split('.')[2] as FullRecoveryMember; From 023c25c4dddb2604f60bedce818f9a6c4ebae742 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 07:57:16 +0200 Subject: [PATCH 055/292] fix(agent): narrow candidate subgraph snapshots --- packages/agent/src/rfc64/inventory-v1/candidate.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index e3a9dd78cc..0b3948496e 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -1666,8 +1666,13 @@ function snapshotEncodedHeader( row: SqlRowV1, key: EncodedLoadKeyV1, ): EncodedHeaderV1 { - const subgraphName = row.subgraph_name; - if (subgraphName !== null && typeof subgraphName !== 'string') { + const rawSubgraphName = row.subgraph_name; + let subgraphName: string | null; + if (rawSubgraphName === null) { + subgraphName = null; + } else if (typeof rawSubgraphName === 'string') { + subgraphName = rawSubgraphName; + } else { throw new InventoryV1CandidateError( 'candidate-database-corrupt', 'stored candidate subgraph_name is neither text nor NULL', From a67607db73894397aa654cd70696191810834ca5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 08:02:25 +0200 Subject: [PATCH 056/292] fix(chain): prioritize explicit finalized-call reverts --- .../src/strict-current-finalized-evm-rpc.ts | 18 ++++--- ...ict-current-finalized-evm-rpc.unit.test.ts | 47 ++++++++++++++++++- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 48a7bfdaee..7ca127b35d 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -477,6 +477,18 @@ function classifyJsonRpcError( error: RpcErrorEnvelopeV1, ): CurrentFinalizedEvmCallErrorV1 { const message = error.message.toLowerCase(); + // Explicit revert evidence is deterministic even when a gateway decorates + // the message with gas-related text (for example, code 3 plus + // "execution reverted: out of gas"). A fixed-cap exhaustion that did not + // execute a REVERT remains a resource refusal below, but a proven REVERT + // must win so callers cannot misclassify invalid EIP-1271 evidence as merely + // unsupported. + if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { + return new CurrentFinalizedEvmCallErrorV1( + 'revert', + 'EIP-1271 contract call reverted at the resolved finalized anchor', + ); + } if ( method === 'eth_call' && ( @@ -491,12 +503,6 @@ function classifyJsonRpcError( 'EIP-1271 execution could not complete within the fixed gas cap', ); } - if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { - return new CurrentFinalizedEvmCallErrorV1( - 'revert', - 'EIP-1271 contract call reverted at the resolved finalized anchor', - ); - } if (message.includes('timeout') || message.includes('timed out')) { return timedOut(`JSON-RPC ${method} timed out`); } diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 9b91f37edc..5f7255c6cd 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -297,6 +297,48 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { expect(second.calls).toHaveLength(0); }); + it('treats explicit revert evidence as terminal when its message also mentions gas', async () => { + const first = await startRpcServer(successfulHandler({ + callError: { code: 3, message: 'execution reverted: out of gas' }, + })); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + + await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'revert' }); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + ]); + expect(second.calls).toHaveLength(0); + }); + + it('keeps mixed revert-and-gas evidence terminal after a stable fallback post-read', async () => { + const first = await startRpcServer(successfulHandler({ + callError: { code: 3, message: 'execution reverted: gas required exceeds allowance' }, + })); + const second = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'revert' }); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getBlockByNumber', + ]); + expect(second.calls).toHaveLength(0); + }); + it('rejects malformed contract returns but preserves exact wrong magic for the verifier', async () => { const malformed = await startRpcServer(successfulHandler({ returnData: '0x1626ba7e' })); const malformedAdapter = createStrictCurrentFinalizedEvmChainAdapterV1({ @@ -411,6 +453,7 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { interface SuccessfulHandlerOptions { readonly blockHash?: string | (() => string); + readonly callError?: { readonly code: number; readonly message: string }; readonly code?: string; readonly outOfGas?: boolean; readonly returnData?: string; @@ -434,7 +477,9 @@ function successfulHandler(options: SuccessfulHandlerOptions = {}): LoopbackHand sendResult(response, call, options.code ?? '0x6000'); return; case 'eth_call': - if (options.outOfGas) { + if (options.callError) { + sendError(response, call, options.callError.code, options.callError.message); + } else if (options.outOfGas) { sendError(response, call, -32000, 'out of gas'); } else if (options.revert) { sendError(response, call, 3, 'execution reverted'); From 502599b73ae191e603c22fea93edb84cefe40a3f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 08:04:15 +0200 Subject: [PATCH 057/292] fix(agent): narrow candidate subgraph snapshots --- packages/agent/src/rfc64/inventory-v1/candidate.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index 3164e2c1c2..887854f216 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -1552,8 +1552,13 @@ function snapshotEncodedHeader( row: SqlRowV1, key: EncodedLoadKeyV1, ): EncodedHeaderV1 { - const subgraphName = row.subgraph_name; - if (subgraphName !== null && typeof subgraphName !== 'string') { + const rawSubgraphName = row.subgraph_name; + let subgraphName: string | null; + if (rawSubgraphName === null) { + subgraphName = null; + } else if (typeof rawSubgraphName === 'string') { + subgraphName = rawSubgraphName; + } else { throw new InventoryV1CandidateError( 'candidate-database-corrupt', 'stored candidate subgraph_name is neither text nor NULL', From a456de2de7b057cd93970a32727f4af6d15e76a1 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 08:19:04 +0200 Subject: [PATCH 058/292] feat(agent): verify RFC-64 catalog row authorship --- packages/agent/src/index.ts | 1 + .../agent/src/rfc64/catalog-row-authorship.ts | 1145 +++++++++++++++++ .../test/rfc64-catalog-row-authorship.test.ts | 846 ++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 4 files changed, 1993 insertions(+) create mode 100644 packages/agent/src/rfc64/catalog-row-authorship.ts create mode 100644 packages/agent/test/rfc64-catalog-row-authorship.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index a8496bf5ea..f6f3ce8336 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -34,6 +34,7 @@ export { type SignAgentDelegationParams, type VerifyAgentDelegationOptions, } from './auth/agent-delegation.js'; +export * from './rfc64/catalog-row-authorship.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/catalog-row-authorship.ts b/packages/agent/src/rfc64/catalog-row-authorship.ts new file mode 100644 index 0000000000..99245fae93 --- /dev/null +++ b/packages/agent/src/rfc64/catalog-row-authorship.ts @@ -0,0 +1,1145 @@ +import { sha256 } from '@noble/hashes/sha2.js'; +import { + AuthorCatalogAuthorityCodecError, + AuthorCatalogObjectCodecError, + assertAuthorCatalogBucketScopeBindingV1, + assertCanonicalChainId, + assertCanonicalDigest, + assertCanonicalKaId, + assertContextGraphIdV1, + assertNetworkIdV1, + assertSubGraphNameV1, + canonicalizeAuthorCatalogBucketPayloadBytesV1, + canonicalizeAuthorCatalogRowV1, + canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1, + canonicalizeSignedAuthorCatalogDirectoryNodeEnvelopeBytesV1, + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, + canonicalizeSignedAuthorCatalogIssuerDelegationEnvelopeBytesV1, + catalogKeyToBucketIdV1, + computeAuthorCatalogRowDigestV1, + computeAuthorCatalogScopeDigestV1, + computeControlSignatureVariantDigestHex, + computeKaTransferIdentityDigestV1, + deriveAuthorCatalogScopeFromHeadV1, + parseCanonicalAuthorCatalogRowV1, + parseCanonicalDecimalU64, + parseCanonicalSignedAuthorCatalogBucketEnvelopeV1, + parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1, + parseCanonicalSignedAuthorCatalogHeadEnvelopeV1, + parseCanonicalSignedAuthorCatalogIssuerDelegationEnvelopeV1, + readVerifiedAuthorCatalogBucketDescriptorV1, + verifyAuthorCatalogDirectoryPathV1, + type AuthorCatalogRowV1, + type ChainIdV1, + type ContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, + type KaIdV1, + type NetworkIdV1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, + type SubGraphNameV1, + type TimestampMsV1, + type VerifiedAuthorCatalogDirectoryPathV1, +} from '@origintrail-official/dkg-core'; +import { + readVerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureSnapshotV1, +} from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; + +import { computeDelegationDigest } from '../auth/agent-delegation.js'; + +export const AUTHOR_CATALOG_AGENT_SCOPE_DIGEST_DOMAIN_V1 = + 'dkg-author-catalog-agent-scope-v1\n' as const; +export const AUTHOR_AGENT_DELEGATION_EVIDENCE_DIGEST_DOMAIN_V1 = + 'dkg-author-agent-delegation-evidence-v1\n' as const; +export const AUTHOR_CATALOG_AGENT_SCOPE_PREFIX_V1 = + 'dkg:rfc64:author-catalog-issuer-v1:' as const; +export const MAX_AUTHOR_AGENT_DELEGATION_EVIDENCE_BYTES_V1 = 4_096; +export const MAX_AUTHOR_AGENT_DELEGATEE_PEER_ID_BYTES_V1 = 256; +export const MAX_AUTHOR_AGENT_DELEGATION_TIMESTAMP_V1 = 9_007_199_254_740_991n; + +const UTF8 = new TextEncoder(); +const AUTHOR_CATALOG_AGENT_SCOPE_DOMAIN_BYTES_V1 = UTF8.encode( + AUTHOR_CATALOG_AGENT_SCOPE_DIGEST_DOMAIN_V1, +); +const AUTHOR_AGENT_EVIDENCE_DOMAIN_BYTES_V1 = UTF8.encode( + AUTHOR_AGENT_DELEGATION_EVIDENCE_DIGEST_DOMAIN_V1, +); +const SECP256K1_N = BigInt( + '0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', +); +const SECP256K1_HALF_N = BigInt( + '0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0', +); + +export interface AuthorCatalogAgentScopeV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly governanceChainId: ChainIdV1 | null; + readonly governanceContractAddress: EvmAddressV1 | null; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; +} + +export interface AuthorAgentDelegationEvidenceV1 { + readonly authorAddress: EvmAddressV1; + readonly delegateeOpKey: EvmAddressV1; + readonly delegateePeerId: string | null; + readonly expiresAt: TimestampMsV1; + readonly issuedAt: TimestampMsV1; + readonly scope: string; + readonly signature: string; +} + +export interface VerifyAuthorCatalogRowAuthorshipInputV1 { + readonly catalogIssuerDelegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1; + readonly catalogIssuerDelegationSignature: VerifiedControlEnvelopeIssuerSignatureV1; + readonly parentAuthorAgentEvidence: AuthorAgentDelegationEvidenceV1 | null; + readonly catalogHead: SignedAuthorCatalogHeadEnvelopeV1; + readonly catalogHeadSignature: VerifiedControlEnvelopeIssuerSignatureV1; + readonly directoryPathEnvelopes: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; + readonly directoryPathSignatures: readonly VerifiedControlEnvelopeIssuerSignatureV1[]; + readonly directoryPathProof: VerifiedAuthorCatalogDirectoryPathV1; + readonly catalogBucket: SignedAuthorCatalogBucketEnvelopeV1; + readonly catalogBucketSignature: VerifiedControlEnvelopeIssuerSignatureV1; + readonly targetKaId: KaIdV1; +} + +declare const VERIFIED_AUTHOR_CATALOG_ROW_AUTHORSHIP_BRAND_V1: unique symbol; + +/** + * Process-local proof of only the exact author/catalog-key/signed-row closure. + * The phantom type brand is not present on the runtime token. + */ +export interface VerifiedAuthorCatalogRowAuthorshipV1 { + readonly [VERIFIED_AUTHOR_CATALOG_ROW_AUTHORSHIP_BRAND_V1]: true; +} + +export interface VerifiedAuthorCatalogRowAuthorshipSnapshotV1 { + readonly authorCatalogAgentScopeDigest: Digest32V1; + readonly authorAuthorityEvidenceDigest: Digest32V1 | null; + readonly catalogIssuerDelegationObjectDigest: Digest32V1; + readonly catalogIssuerDelegationSignatureVariantDigest: Digest32V1; + readonly catalogHeadObjectDigest: Digest32V1; + readonly catalogHeadSignatureVariantDigest: Digest32V1; + readonly directoryPathObjectDigests: readonly Digest32V1[]; + readonly directoryPathSignatureVariantDigests: readonly Digest32V1[]; + readonly bucketObjectDigest: Digest32V1; + readonly bucketSignatureVariantDigest: Digest32V1; + readonly catalogScopeDigest: Digest32V1; + readonly catalogRowDigest: Digest32V1; + readonly transferIdentityDigest: Digest32V1; + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly governanceChainId: ChainIdV1 | null; + readonly governanceContractAddress: EvmAddressV1 | null; + readonly ownershipTransitionDigest: Digest32V1 | null; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogIssuerKey: EvmAddressV1; + readonly era: SignedAuthorCatalogHeadEnvelopeV1['payload']['era']; + readonly version: SignedAuthorCatalogHeadEnvelopeV1['payload']['version']; + readonly bucketId: SignedAuthorCatalogBucketEnvelopeV1['payload']['bucketId']; + readonly effectiveAt: TimestampMsV1; + readonly expiresAt: TimestampMsV1; + readonly headIssuedAt: TimestampMsV1; + readonly row: Readonly; +} + +export const AUTHOR_CATALOG_ROW_AUTHORSHIP_ERROR_CODES_V1 = Object.freeze([ + 'AUTHORSHIP_INPUT_INVALID', + 'AUTHORSHIP_DEPENDENCY_MISSING', + 'AUTHORSHIP_SIGNATURE_PROOF_INVALID', + 'AUTHORSHIP_PARENT_EVIDENCE_INVALID', + 'AUTHORSHIP_PARENT_DIGEST_MISMATCH', + 'AUTHORSHIP_PARENT_SCOPE_MISMATCH', + 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH', + 'AUTHORSHIP_INTERVAL_MISMATCH', + 'AUTHORSHIP_HEAD_BINDING_MISMATCH', + 'AUTHORSHIP_PATH_BINDING_MISMATCH', + 'AUTHORSHIP_BUCKET_BINDING_MISMATCH', + 'AUTHORSHIP_ROW_BINDING_MISMATCH', + 'AUTHORSHIP_CAPABILITY_INVALID', +] as const); + +export type AuthorCatalogRowAuthorshipErrorCodeV1 = + (typeof AUTHOR_CATALOG_ROW_AUTHORSHIP_ERROR_CODES_V1)[number]; + +export class AuthorCatalogRowAuthorshipErrorV1 extends Error { + readonly code: AuthorCatalogRowAuthorshipErrorCodeV1; + + constructor( + code: AuthorCatalogRowAuthorshipErrorCodeV1, + message: string, + options: { readonly cause?: unknown } = {}, + ) { + if (!AUTHOR_CATALOG_ROW_AUTHORSHIP_ERROR_CODES_V1.includes(code)) { + throw new TypeError(`Unsupported catalog-row authorship error code: ${String(code)}`); + } + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'AuthorCatalogRowAuthorshipErrorV1'; + this.code = code; + Object.freeze(this); + } +} + +const VERIFIED_AUTHOR_CATALOG_ROW_AUTHORSHIPS_V1 = new WeakMap< + object, + VerifiedAuthorCatalogRowAuthorshipSnapshotV1 +>(); + +/** Derive the exact seven-key logical lane scoped by a parent author delegation. */ +export function deriveAuthorCatalogAgentScopeV1( + delegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1, +): Readonly { + const snapshot = snapshotCatalogIssuerDelegation(delegation); + const payload = snapshot.payload; + return Object.freeze({ + authorAddress: payload.authorAddress, + contextGraphId: payload.contextGraphId, + governanceChainId: payload.governanceChainId, + governanceContractAddress: payload.governanceContractAddress, + networkId: payload.networkId, + ownershipTransitionDigest: payload.ownershipTransitionDigest, + subGraphName: payload.subGraphName, + }); +} + +export function computeAuthorCatalogAgentScopeDigestV1( + scope: AuthorCatalogAgentScopeV1, +): Digest32V1 { + const snapshot = snapshotAuthorCatalogAgentScope(scope); + return digestWithDomain( + AUTHOR_CATALOG_AGENT_SCOPE_DOMAIN_BYTES_V1, + UTF8.encode(canonicalizeStringRecord(snapshot)), + ); +} + +export function buildAuthorCatalogAgentScopeV1( + scope: AuthorCatalogAgentScopeV1, +): string { + return `${AUTHOR_CATALOG_AGENT_SCOPE_PREFIX_V1}${computeAuthorCatalogAgentScopeDigestV1(scope).slice(2)}`; +} + +export function computeAuthorAgentDelegationEvidenceDigestV1( + evidence: AuthorAgentDelegationEvidenceV1, +): Digest32V1 { + const snapshot = snapshotParentEvidence(evidence); + return computeParentEvidenceDigestAfterSnapshot(snapshot); +} + +/** + * Close one exact signed catalog row under its author-authorized catalog key. + * No policy, timeliness, history, finality, persistence, RDF, or activation work + * is performed here. + */ +export function verifyAuthorCatalogRowAuthorshipV1( + untrustedInput: VerifyAuthorCatalogRowAuthorshipInputV1, +): VerifiedAuthorCatalogRowAuthorshipV1 { + const input = snapshotTopLevelInput(untrustedInput); + const delegation = snapshotCatalogIssuerDelegation(input.catalogIssuerDelegation); + const head = snapshotCatalogHead(input.catalogHead); + const bucket = snapshotCatalogBucket(input.catalogBucket); + const targetKaId = snapshotTargetKaId(input.targetKaId); + + // A canonical head caps directoryHeight at seven, so its exact root-to-leaf + // path contains 1..8 nodes. Preflight BOTH attacker-controlled arrays before + // canonicalizing a single envelope or opening a signature capability. This + // keeps an oversized dense array from turning a structurally invalid proof + // into unbounded parser/signature work before the core path verifier rejects + // it. + const expectedDirectoryPathLength = Number(BigInt(head.payload.directoryHeight) + 1n); + assertOrdinaryArrayExactLength( + input.directoryPathEnvelopes, + 'directoryPathEnvelopes', + 'AUTHORSHIP_PATH_BINDING_MISMATCH', + expectedDirectoryPathLength, + ); + assertOrdinaryArrayExactLength( + input.directoryPathSignatures, + 'directoryPathSignatures', + 'AUTHORSHIP_PATH_BINDING_MISMATCH', + expectedDirectoryPathLength, + ); + + const directoryEnvelopes = snapshotDirectoryPath( + input.directoryPathEnvelopes, + head.payload.bucketCount, + expectedDirectoryPathLength, + ); + const directorySignatureProofs = snapshotProofArray( + input.directoryPathSignatures, + 'directoryPathSignatures', + expectedDirectoryPathLength, + ); + + const delegationSignature = bindExactSignatureProof( + input.catalogIssuerDelegationSignature, + delegation, + ); + const headSignature = bindExactSignatureProof(input.catalogHeadSignature, head); + const bucketSignature = bindExactSignatureProof(input.catalogBucketSignature, bucket); + const directorySignatures = directoryEnvelopes.map((envelope, index) => { + try { + return bindExactSignatureProof(directorySignatureProofs[index], envelope); + } catch (cause) { + if ( + cause instanceof AuthorCatalogRowAuthorshipErrorV1 + && cause.code === 'AUTHORSHIP_SIGNATURE_PROOF_INVALID' + ) { + fail( + 'AUTHORSHIP_PATH_BINDING_MISMATCH', + `directory path signature proof ${index} is not bound to its exact node`, + cause, + ); + } + throw cause; + } + }); + + const delegationPayload = delegation.payload; + const agentScope = snapshotAuthorCatalogAgentScope({ + authorAddress: delegationPayload.authorAddress, + contextGraphId: delegationPayload.contextGraphId, + governanceChainId: delegationPayload.governanceChainId, + governanceContractAddress: delegationPayload.governanceContractAddress, + networkId: delegationPayload.networkId, + ownershipTransitionDigest: delegationPayload.ownershipTransitionDigest, + subGraphName: delegationPayload.subGraphName, + }); + const authorCatalogAgentScopeDigest = computeAuthorCatalogAgentScopeDigestV1(agentScope); + const expectedAgentScope = + `${AUTHOR_CATALOG_AGENT_SCOPE_PREFIX_V1}${authorCatalogAgentScopeDigest.slice(2)}`; + + const issuerIsAuthor = delegation.issuer === delegationPayload.authorAddress; + const evidenceDigest = delegationPayload.authorAuthorityEvidenceDigest; + if (issuerIsAuthor) { + if (evidenceDigest !== null || input.parentAuthorAgentEvidence !== null) { + fail( + 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH', + 'direct author issuance requires null evidence and no parent evidence object', + ); + } + } else { + if (evidenceDigest === null) { + fail( + 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH', + 'delegated issuance must name exact parent author-agent evidence', + ); + } + if (input.parentAuthorAgentEvidence === null) { + fail( + 'AUTHORSHIP_DEPENDENCY_MISSING', + 'the referenced parent author-agent evidence object is unavailable', + ); + } + const parent = snapshotParentEvidence(input.parentAuthorAgentEvidence); + if (computeParentEvidenceDigestAfterSnapshot(parent) !== evidenceDigest) { + fail( + 'AUTHORSHIP_PARENT_DIGEST_MISMATCH', + 'parent author-agent evidence does not hash to the delegated reference', + ); + } + verifyParentEvidenceSignature(parent); + if ( + parent.authorAddress !== delegationPayload.authorAddress + || parent.scope !== expectedAgentScope + ) { + fail( + 'AUTHORSHIP_PARENT_SCOPE_MISMATCH', + 'parent evidence is not scoped to the exact author, CG, governance tuple, and lane', + ); + } + if ( + parent.delegateeOpKey !== delegation.issuer + || parent.delegateeOpKey !== delegationPayload.catalogIssuerKey + ) { + fail( + 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH', + 'parent operational key, delegation issuer, and selected catalog key must match', + ); + } + if ( + BigInt(parent.issuedAt) > BigInt(delegationPayload.effectiveAt) + || BigInt(delegationPayload.effectiveAt) >= BigInt(delegationPayload.expiresAt) + || BigInt(delegationPayload.expiresAt) > BigInt(parent.expiresAt) + ) { + fail( + 'AUTHORSHIP_INTERVAL_MISMATCH', + 'catalog issuer interval is not contained in the parent half-open interval', + ); + } + } + + if (!issuerIsAuthor && delegation.issuer !== delegationPayload.catalogIssuerKey) { + fail( + 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH', + 'a delegated issuer may nominate only its own operational key as catalogIssuerKey', + ); + } + + assertHeadBinding(delegation, head); + const catalogIssuerKey = delegationPayload.catalogIssuerKey; + if ( + head.issuer !== catalogIssuerKey + || bucket.issuer !== catalogIssuerKey + || directoryEnvelopes.some((envelope) => envelope.issuer !== catalogIssuerKey) + ) { + fail( + 'AUTHORSHIP_HEAD_BINDING_MISMATCH', + 'head, directory path, and bucket must all be signed by the exact catalog issuer key', + ); + } + + const headIssuedAt = BigInt(head.payload.issuedAt); + if ( + headIssuedAt < BigInt(delegationPayload.effectiveAt) + || headIssuedAt >= BigInt(delegationPayload.expiresAt) + ) { + fail( + 'AUTHORSHIP_INTERVAL_MISMATCH', + 'catalog head issuedAt is outside the child half-open interval', + ); + } + + const scope = deriveAuthorCatalogScopeFromHeadV1(head.payload); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const selectedBucketId = catalogKeyToBucketIdV1(targetKaId, scope.bucketCount); + const descriptor = readBoundPathDescriptor( + input.directoryPathProof, + head, + directoryEnvelopes, + selectedBucketId, + ); + if (descriptor.rowCount === '0' || descriptor.byteLength === '0') { + fail( + 'AUTHORSHIP_BUCKET_BINDING_MISMATCH', + 'an authorship proof requires a non-empty selected bucket descriptor', + ); + } + + const bucketPayloadBytes = canonicalizeAuthorCatalogBucketPayloadBytesV1(bucket.payload); + if ( + descriptor.bucketDigest !== bucket.objectDigest + || descriptor.bucketId !== bucket.payload.bucketId + || descriptor.bucketId !== selectedBucketId + || BigInt(descriptor.byteLength) !== BigInt(bucketPayloadBytes.byteLength) + || BigInt(descriptor.rowCount) !== BigInt(bucket.payload.rows.length) + ) { + fail( + 'AUTHORSHIP_BUCKET_BINDING_MISMATCH', + 'signed leaf descriptor does not exactly describe the supplied signed bucket', + ); + } + try { + assertAuthorCatalogBucketScopeBindingV1(bucket.payload, scope); + } catch (cause) { + fail( + isPackedAuthorFailure(cause) + ? 'AUTHORSHIP_ROW_BINDING_MISMATCH' + : 'AUTHORSHIP_BUCKET_BINDING_MISMATCH', + 'signed bucket does not match the head-derived scope or packed author lane', + cause, + ); + } + + const matchingRows = bucket.payload.rows.filter((row) => row.kaId === targetKaId); + if (matchingRows.length !== 1) { + fail( + 'AUTHORSHIP_ROW_BINDING_MISMATCH', + 'target kaId must occur exactly once in its mathematically derived bucket', + ); + } + const row = snapshotCatalogRow(matchingRows[0]); + if ((BigInt(row.kaId) >> 96n) !== BigInt(head.payload.authorAddress)) { + fail( + 'AUTHORSHIP_ROW_BINDING_MISMATCH', + 'target kaId high 160 bits do not equal the signed head author', + ); + } + const catalogRowDigest = computeAuthorCatalogRowDigestV1(catalogScopeDigest, row); + const transferIdentityDigest = computeKaTransferIdentityDigestV1(row.transfer); + + return mintVerifiedAuthorship({ + authorCatalogAgentScopeDigest, + authorAuthorityEvidenceDigest: evidenceDigest, + catalogIssuerDelegationObjectDigest: delegation.objectDigest as Digest32V1, + catalogIssuerDelegationSignatureVariantDigest: delegationSignature.signatureVariantDigest, + catalogHeadObjectDigest: head.objectDigest as Digest32V1, + catalogHeadSignatureVariantDigest: headSignature.signatureVariantDigest, + directoryPathObjectDigests: Object.freeze( + directoryEnvelopes.map((envelope) => envelope.objectDigest as Digest32V1), + ), + directoryPathSignatureVariantDigests: Object.freeze( + directorySignatures.map((signature) => signature.signatureVariantDigest), + ), + bucketObjectDigest: bucket.objectDigest as Digest32V1, + bucketSignatureVariantDigest: bucketSignature.signatureVariantDigest, + catalogScopeDigest, + catalogRowDigest, + transferIdentityDigest, + networkId: head.payload.networkId, + contextGraphId: head.payload.contextGraphId, + governanceChainId: head.payload.governanceChainId, + governanceContractAddress: head.payload.governanceContractAddress, + ownershipTransitionDigest: head.payload.ownershipTransitionDigest, + subGraphName: head.payload.subGraphName, + authorAddress: head.payload.authorAddress, + catalogIssuerKey, + era: head.payload.era, + version: head.payload.version, + bucketId: bucket.payload.bucketId, + effectiveAt: delegationPayload.effectiveAt, + expiresAt: delegationPayload.expiresAt, + headIssuedAt: head.payload.issuedAt, + row, + }); +} + +export function assertVerifiedAuthorCatalogRowAuthorshipV1( + value: unknown, +): asserts value is VerifiedAuthorCatalogRowAuthorshipV1 { + if ( + (typeof value !== 'object' && typeof value !== 'function') + || value === null + || !VERIFIED_AUTHOR_CATALOG_ROW_AUTHORSHIPS_V1.has(value as object) + ) { + fail( + 'AUTHORSHIP_CAPABILITY_INVALID', + 'catalog-row authorship capability was not minted by this verifier', + ); + } +} + +export function assertVerifiedAuthorCatalogRowAuthorshipForTargetV1( + value: unknown, + catalogRowDigest: Digest32V1, + transferIdentityDigest: Digest32V1, +): asserts value is VerifiedAuthorCatalogRowAuthorshipV1 { + assertVerifiedAuthorCatalogRowAuthorshipV1(value); + try { + assertCanonicalDigest(catalogRowDigest, 'catalogRowDigest'); + assertCanonicalDigest(transferIdentityDigest, 'transferIdentityDigest'); + } catch (cause) { + fail('AUTHORSHIP_CAPABILITY_INVALID', 'authorship target digests are not canonical', cause); + } + const snapshot = VERIFIED_AUTHOR_CATALOG_ROW_AUTHORSHIPS_V1.get(value as object)!; + if ( + snapshot.catalogRowDigest !== catalogRowDigest + || snapshot.transferIdentityDigest !== transferIdentityDigest + ) { + fail( + 'AUTHORSHIP_CAPABILITY_INVALID', + 'catalog-row authorship capability is bound to different target digests', + ); + } +} + +export function readVerifiedAuthorCatalogRowAuthorshipV1( + value: unknown, +): VerifiedAuthorCatalogRowAuthorshipSnapshotV1 { + assertVerifiedAuthorCatalogRowAuthorshipV1(value); + return VERIFIED_AUTHOR_CATALOG_ROW_AUTHORSHIPS_V1.get(value as object)!; +} + +function snapshotTopLevelInput( + input: VerifyAuthorCatalogRowAuthorshipInputV1, +): VerifyAuthorCatalogRowAuthorshipInputV1 { + if (!isPlainRecord(input)) { + fail('AUTHORSHIP_INPUT_INVALID', 'authorship input must be a plain object'); + } + const expected = [ + 'catalogBucket', + 'catalogBucketSignature', + 'catalogHead', + 'catalogHeadSignature', + 'catalogIssuerDelegation', + 'catalogIssuerDelegationSignature', + 'directoryPathEnvelopes', + 'directoryPathProof', + 'directoryPathSignatures', + 'parentAuthorAgentEvidence', + 'targetKaId', + ]; + const actual = Reflect.ownKeys(input); + if (actual.some((key) => typeof key !== 'string')) { + fail('AUTHORSHIP_INPUT_INVALID', 'authorship input must not contain symbol fields'); + } + const actualStrings = actual as string[]; + if (actualStrings.some((key) => !expected.includes(key))) { + fail('AUTHORSHIP_INPUT_INVALID', 'authorship input contains unknown fields'); + } + const missing = expected.filter((key) => !actualStrings.includes(key)); + if (missing.length > 0) { + const code = missing.every((key) => key !== 'targetKaId') + ? 'AUTHORSHIP_DEPENDENCY_MISSING' + : 'AUTHORSHIP_INPUT_INVALID'; + fail(code, `authorship input is missing ${missing.join(', ')}`); + } + const snapshot: Record = Object.create(null); + for (const key of expected) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('AUTHORSHIP_INPUT_INVALID', `authorship input ${key} must be an enumerable data field`); + } + snapshot[key] = descriptor.value; + } + for (const key of expected) { + if (key !== 'parentAuthorAgentEvidence' && key !== 'targetKaId' && snapshot[key] == null) { + fail('AUTHORSHIP_DEPENDENCY_MISSING', `authorship dependency ${key} is unavailable`); + } + } + return Object.freeze(snapshot) as unknown as VerifyAuthorCatalogRowAuthorshipInputV1; +} + +function snapshotCatalogIssuerDelegation( + input: SignedAuthorCatalogIssuerDelegationEnvelopeV1, +): SignedAuthorCatalogIssuerDelegationEnvelopeV1 { + try { + return deepFreezeJson(parseCanonicalSignedAuthorCatalogIssuerDelegationEnvelopeV1( + canonicalizeSignedAuthorCatalogIssuerDelegationEnvelopeBytesV1(input), + )) as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + } catch (cause) { + if ( + cause instanceof AuthorCatalogAuthorityCodecError + && cause.code === 'catalog-authority-authority' + ) { + fail( + 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH', + 'catalog issuer delegation has an invalid direct/delegated evidence branch', + cause, + ); + } + if ( + cause instanceof AuthorCatalogAuthorityCodecError + && cause.code === 'catalog-authority-time' + ) { + fail('AUTHORSHIP_INTERVAL_MISMATCH', 'catalog issuer interval is invalid', cause); + } + fail('AUTHORSHIP_INPUT_INVALID', 'catalog issuer delegation is invalid', cause); + } +} + +function snapshotCatalogHead( + input: SignedAuthorCatalogHeadEnvelopeV1, +): SignedAuthorCatalogHeadEnvelopeV1 { + try { + return deepFreezeJson(parseCanonicalSignedAuthorCatalogHeadEnvelopeV1( + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(input), + )) as SignedAuthorCatalogHeadEnvelopeV1; + } catch (cause) { + fail('AUTHORSHIP_INPUT_INVALID', 'catalog head is invalid', cause); + } +} + +function snapshotCatalogBucket( + input: SignedAuthorCatalogBucketEnvelopeV1, +): SignedAuthorCatalogBucketEnvelopeV1 { + try { + return deepFreezeJson(parseCanonicalSignedAuthorCatalogBucketEnvelopeV1( + canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1(input), + )) as SignedAuthorCatalogBucketEnvelopeV1; + } catch (cause) { + if ( + cause instanceof AuthorCatalogObjectCodecError + && [ + 'catalog-object-bucket-mapping', + 'catalog-object-duplicate', + 'catalog-object-row-order', + ].includes(cause.code) + ) { + fail('AUTHORSHIP_ROW_BINDING_MISMATCH', 'catalog bucket row closure is invalid', cause); + } + fail('AUTHORSHIP_INPUT_INVALID', 'catalog bucket is invalid', cause); + } +} + +function snapshotDirectoryPath( + input: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[], + bucketCount: SignedAuthorCatalogHeadEnvelopeV1['payload']['bucketCount'], + expectedLength: number, +): readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[] { + const values = snapshotDenseOrdinaryArray( + input, + 'directoryPathEnvelopes', + 'AUTHORSHIP_PATH_BINDING_MISMATCH', + expectedLength, + ); + const snapshots = values.map((envelope) => { + try { + return deepFreezeJson(parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1( + canonicalizeSignedAuthorCatalogDirectoryNodeEnvelopeBytesV1( + envelope as SignedAuthorCatalogDirectoryNodeEnvelopeV1, + bucketCount, + ), + bucketCount, + )) as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + } catch (cause) { + fail('AUTHORSHIP_PATH_BINDING_MISMATCH', 'directory path envelope is invalid', cause); + } + }); + return Object.freeze(snapshots); +} + +function snapshotProofArray( + input: readonly VerifiedControlEnvelopeIssuerSignatureV1[], + label: string, + expectedLength: number, +): readonly VerifiedControlEnvelopeIssuerSignatureV1[] { + return Object.freeze(snapshotDenseOrdinaryArray( + input, + label, + 'AUTHORSHIP_PATH_BINDING_MISMATCH', + expectedLength, + ) as VerifiedControlEnvelopeIssuerSignatureV1[]); +} + +function snapshotTargetKaId(input: KaIdV1): KaIdV1 { + try { + assertCanonicalKaId(input, 'targetKaId'); + return input; + } catch (cause) { + fail('AUTHORSHIP_INPUT_INVALID', 'targetKaId is not canonical', cause); + } +} + +function snapshotCatalogRow(row: AuthorCatalogRowV1): Readonly { + try { + return deepFreezeJson( + parseCanonicalAuthorCatalogRowV1(canonicalizeAuthorCatalogRowV1(row)), + ) as Readonly; + } catch (cause) { + fail('AUTHORSHIP_ROW_BINDING_MISMATCH', 'target row is invalid', cause); + } +} + +function snapshotAuthorCatalogAgentScope( + input: AuthorCatalogAgentScopeV1, +): Readonly { + if (!isPlainRecord(input)) { + fail('AUTHORSHIP_INPUT_INVALID', 'author catalog agent scope must be a plain object'); + } + const values = exactPrimitiveDataSnapshot(input, [ + 'authorAddress', + 'contextGraphId', + 'governanceChainId', + 'governanceContractAddress', + 'networkId', + 'ownershipTransitionDigest', + 'subGraphName', + ], 'author catalog agent scope', 'AUTHORSHIP_INPUT_INVALID'); + try { + assertEvmAddress(values.authorAddress, 'authorAddress'); + assertNetworkIdV1(values.networkId, 'networkId'); + assertContextGraphIdV1(values.contextGraphId, 'contextGraphId'); + const chainIsNull = values.governanceChainId === null; + const contractIsNull = values.governanceContractAddress === null; + if (chainIsNull !== contractIsNull) throw new Error('governance tuple must be jointly null'); + if (!contractIsNull) { + assertCanonicalChainId(values.governanceChainId, 'governanceChainId'); + assertEvmAddress(values.governanceContractAddress, 'governanceContractAddress'); + } + if (values.ownershipTransitionDigest !== null) { + assertCanonicalDigest(values.ownershipTransitionDigest, 'ownershipTransitionDigest'); + } + if (values.subGraphName !== null) assertSubGraphNameV1(values.subGraphName, 'subGraphName'); + } catch (cause) { + fail('AUTHORSHIP_INPUT_INVALID', 'author catalog agent scope is invalid', cause); + } + return Object.freeze(values) as unknown as Readonly; +} + +function snapshotParentEvidence( + input: AuthorAgentDelegationEvidenceV1, +): Readonly { + if (!isPlainRecord(input)) { + fail('AUTHORSHIP_PARENT_EVIDENCE_INVALID', 'parent evidence must be a plain object'); + } + const values = exactPrimitiveDataSnapshot(input, [ + 'authorAddress', + 'delegateeOpKey', + 'delegateePeerId', + 'expiresAt', + 'issuedAt', + 'scope', + 'signature', + ], 'parent author-agent evidence', 'AUTHORSHIP_PARENT_EVIDENCE_INVALID'); + try { + assertEvmAddress(values.authorAddress, 'authorAddress'); + assertEvmAddress(values.delegateeOpKey, 'delegateeOpKey'); + if (values.delegateePeerId !== null) { + assertPeerId(values.delegateePeerId); + } + if (typeof values.scope !== 'string' || values.scope.length === 0) { + throw new Error('scope must be a non-empty string'); + } + if (values.scope.length > MAX_AUTHOR_AGENT_DELEGATION_EVIDENCE_BYTES_V1) { + throw new Error('scope exceeds the parent evidence byte ceiling'); + } + assertWellFormedUnicode(values.scope, 'scope'); + if (typeof values.signature !== 'string' || !/^0x[0-9a-f]{130}$/.test(values.signature)) { + throw new Error('signature must be a canonical lowercase 65-byte hex string'); + } + const issuedAt = assertFinitePositiveTimestamp(values.issuedAt, 'issuedAt'); + const expiresAt = assertFinitePositiveTimestamp(values.expiresAt, 'expiresAt'); + if (issuedAt >= expiresAt) throw new Error('issuedAt must be strictly earlier than expiresAt'); + } catch (cause) { + fail('AUTHORSHIP_PARENT_EVIDENCE_INVALID', 'parent evidence is invalid', cause); + } + const snapshot = Object.freeze(values) as unknown as Readonly; + if (UTF8.encode(canonicalizeStringRecord(snapshot)).byteLength > MAX_AUTHOR_AGENT_DELEGATION_EVIDENCE_BYTES_V1) { + fail( + 'AUTHORSHIP_PARENT_EVIDENCE_INVALID', + `parent evidence exceeds ${MAX_AUTHOR_AGENT_DELEGATION_EVIDENCE_BYTES_V1} canonical bytes`, + ); + } + return snapshot; +} + +function computeParentEvidenceDigestAfterSnapshot( + evidence: Readonly, +): Digest32V1 { + return digestWithDomain( + AUTHOR_AGENT_EVIDENCE_DOMAIN_BYTES_V1, + UTF8.encode(canonicalizeStringRecord(evidence)), + ); +} + +function verifyParentEvidenceSignature( + evidence: Readonly, +): void { + try { + const r = BigInt(`0x${evidence.signature.slice(2, 66)}`); + const s = BigInt(`0x${evidence.signature.slice(66, 130)}`); + const v = evidence.signature.slice(130, 132); + if ( + (v !== '1b' && v !== '1c') + || r < 1n + || r >= SECP256K1_N + || s < 1n + || s > SECP256K1_HALF_N + ) { + throw new Error('parent signature must use canonical r, low-s, and v=27/28'); + } + const digest = computeDelegationDigest({ + agentAddress: evidence.authorAddress, + scope: evidence.scope, + issuedAtMs: Number(evidence.issuedAt), + expiresAtMs: Number(evidence.expiresAt), + delegateePeerId: evidence.delegateePeerId ?? undefined, + delegateeOpKey: evidence.delegateeOpKey, + }); + const recovered = ethers.verifyMessage(digest, evidence.signature).toLowerCase(); + if (recovered !== evidence.authorAddress) { + throw new Error('parent signature does not recover the canonical author'); + } + } catch (cause) { + fail( + 'AUTHORSHIP_PARENT_EVIDENCE_INVALID', + 'parent author-agent evidence signature is invalid', + cause, + ); + } +} + +function bindExactSignatureProof( + proof: VerifiedControlEnvelopeIssuerSignatureV1, + envelope: { + readonly objectDigest: string; + readonly issuer: string; + readonly signatureSuite: string; + readonly signature: string; + }, +): VerifiedControlEnvelopeIssuerSignatureSnapshotV1 { + let snapshot: VerifiedControlEnvelopeIssuerSignatureSnapshotV1; + try { + snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + } catch (cause) { + fail( + 'AUTHORSHIP_SIGNATURE_PROOF_INVALID', + 'signature proof was not minted by the generic verifier', + cause, + ); + } + if ( + snapshot.objectDigest !== envelope.objectDigest + || snapshot.issuer !== envelope.issuer + || snapshot.signatureSuite !== envelope.signatureSuite + || snapshot.signatureVariantDigest + !== computeControlSignatureVariantDigestHex(envelope.objectDigest, envelope.signature) + ) { + fail( + 'AUTHORSHIP_SIGNATURE_PROOF_INVALID', + 'signature proof is bound to another control envelope', + ); + } + return snapshot; +} + +function assertHeadBinding( + delegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1, + head: SignedAuthorCatalogHeadEnvelopeV1, +): void { + const left = delegation.payload; + const right = head.payload; + if ( + right.networkId !== left.networkId + || right.contextGraphId !== left.contextGraphId + || right.governanceChainId !== left.governanceChainId + || right.governanceContractAddress !== left.governanceContractAddress + || right.ownershipTransitionDigest !== left.ownershipTransitionDigest + || right.subGraphName !== left.subGraphName + || right.authorAddress !== left.authorAddress + || right.era !== left.catalogEra + || right.catalogIssuerDelegationDigest !== delegation.objectDigest + ) { + fail( + 'AUTHORSHIP_HEAD_BINDING_MISMATCH', + 'catalog head does not bind the exact issuer delegation scope, era, and digest', + ); + } +} + +function readBoundPathDescriptor( + proof: VerifiedAuthorCatalogDirectoryPathV1, + head: SignedAuthorCatalogHeadEnvelopeV1, + path: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[], + selectedBucketId: SignedAuthorCatalogBucketEnvelopeV1['payload']['bucketId'], +) { + try { + const supplied = readVerifiedAuthorCatalogBucketDescriptorV1(proof, head); + const recomputedProof = verifyAuthorCatalogDirectoryPathV1(head, path, selectedBucketId); + const recomputed = readVerifiedAuthorCatalogBucketDescriptorV1(recomputedProof, head); + if ( + supplied.bucketDigest !== recomputed.bucketDigest + || supplied.bucketId !== recomputed.bucketId + || supplied.byteLength !== recomputed.byteLength + || supplied.rowCount !== recomputed.rowCount + || supplied.bucketId !== selectedBucketId + ) { + fail( + 'AUTHORSHIP_PATH_BINDING_MISMATCH', + 'structural path proof is not bound to the supplied head, path, and selected bucket', + ); + } + return supplied; + } catch (cause) { + if (cause instanceof AuthorCatalogRowAuthorshipErrorV1) throw cause; + fail( + 'AUTHORSHIP_PATH_BINDING_MISMATCH', + 'directory path proof or root-to-leaf closure is invalid', + cause, + ); + } +} + +function mintVerifiedAuthorship( + snapshot: VerifiedAuthorCatalogRowAuthorshipSnapshotV1, +): VerifiedAuthorCatalogRowAuthorshipV1 { + const immutable = deepFreezeJson({ ...snapshot }) as VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + const capability = Object.freeze( + Object.create(null), + ) as VerifiedAuthorCatalogRowAuthorshipV1; + VERIFIED_AUTHOR_CATALOG_ROW_AUTHORSHIPS_V1.set(capability as object, immutable); + return capability; +} + +function exactPrimitiveDataSnapshot( + input: Record, + expectedKeys: readonly string[], + label: string, + code: AuthorCatalogRowAuthorshipErrorCodeV1, +): Readonly> { + const keys = Reflect.ownKeys(input); + if ( + keys.some((key) => typeof key !== 'string') + || keys.length !== expectedKeys.length + || (keys as string[]).sort().some((key, index) => key !== [...expectedKeys].sort()[index]) + ) { + fail(code, `${label} has unknown or missing fields`); + } + const snapshot: Record = Object.create(null); + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail(code, `${label} fields must be enumerable data properties`); + } + const value = descriptor.value; + if (value !== null && typeof value !== 'string') { + fail(code, `${label}.${key} must be a string or null`); + } + snapshot[key] = value; + } + return Object.freeze(snapshot); +} + +function canonicalizeStringRecord(value: Readonly>): string { + const ordered: Record = {}; + for (const key of Object.keys(value).sort()) { + const item = value[key]; + if (item !== null && typeof item !== 'string') { + throw new TypeError('canonical authorship records contain only strings and null'); + } + ordered[key] = item; + } + return JSON.stringify(ordered); +} + +function snapshotDenseOrdinaryArray( + value: unknown, + label: string, + code: AuthorCatalogRowAuthorshipErrorCodeV1, + expectedLength?: number, +): unknown[] { + assertOrdinaryArrayExactLength(value, label, code, expectedLength); + const length = (Object.getOwnPropertyDescriptor(value, 'length')!.value as number); + const keys = Reflect.ownKeys(value); + if ( + keys.some((key) => typeof key !== 'string') + || keys.length !== length + 1 + ) { + fail(code, `${label} must be dense and contain no custom fields`); + } + const snapshot: unknown[] = new Array(length); + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail(code, `${label}[${index}] must be an enumerable data field`); + } + snapshot[index] = descriptor.value; + } + return snapshot; +} + +function assertOrdinaryArrayExactLength( + value: unknown, + label: string, + code: AuthorCatalogRowAuthorshipErrorCodeV1, + expectedLength?: number, +): asserts value is unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + fail(code, `${label} must be an ordinary dense array`); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + !lengthDescriptor + || lengthDescriptor.enumerable + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + ) { + fail(code, `${label}.length must be an ordinary array data field`); + } + const length = lengthDescriptor.value; + if (expectedLength !== undefined && length !== expectedLength) { + fail( + code, + `${label} must contain exactly ${expectedLength} values for the signed directory height`, + ); + } +} + +function assertFinitePositiveTimestamp(value: unknown, label: string): bigint { + const parsed = parseCanonicalDecimalU64(value, label); + if (parsed < 1n || parsed > MAX_AUTHOR_AGENT_DELEGATION_TIMESTAMP_V1) { + throw new Error(`${label} must be in 1..${MAX_AUTHOR_AGENT_DELEGATION_TIMESTAMP_V1}`); + } + return parsed; +} + +function assertPeerId(value: unknown): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error('delegateePeerId must be null or a non-empty string'); + } + assertWellFormedUnicode(value, 'delegateePeerId'); + const bytes = UTF8.encode(value).byteLength; + if (bytes < 1 || bytes > MAX_AUTHOR_AGENT_DELEGATEE_PEER_ID_BYTES_V1) { + throw new Error('delegateePeerId exceeds its UTF-8 byte limit'); + } + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if (codePoint <= 0x1f || codePoint === 0x7f) { + throw new Error('delegateePeerId contains C0/DEL control data'); + } + } +} + +function assertEvmAddress(value: unknown, label: string): asserts value is EvmAddressV1 { + if ( + typeof value !== 'string' + || !/^0x[0-9a-f]{40}$/.test(value) + || value === '0x0000000000000000000000000000000000000000' + ) { + throw new Error(`${label} is not a canonical nonzero EVM address`); + } +} + +function assertWellFormedUnicode(value: string, label: string): void { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + if (index + 1 >= value.length) throw new Error(`${label} contains an unpaired surrogate`); + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) { + throw new Error(`${label} contains an unpaired surrogate`); + } + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new Error(`${label} contains an unpaired surrogate`); + } + } +} + +function digestWithDomain(domain: Uint8Array, payload: Uint8Array): Digest32V1 { + const hasher = sha256.create(); + hasher.update(domain); + hasher.update(payload); + const digest = `0x${Array.from(hasher.digest(), (byte) => + byte.toString(16).padStart(2, '0')).join('')}`; + assertCanonicalDigest(digest, 'authorship digest'); + return digest; +} + +function deepFreezeJson(value: T): T { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value; + if (Array.isArray(value)) { + for (const item of value) deepFreezeJson(item); + } else { + for (const item of Object.values(value)) deepFreezeJson(item); + } + return Object.freeze(value); +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isPackedAuthorFailure(cause: unknown): boolean { + return ( + cause !== null + && typeof cause === 'object' + && 'code' in cause + && cause.code === 'catalog-packed-author-mismatch' + ); +} + +function fail( + code: AuthorCatalogRowAuthorshipErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new AuthorCatalogRowAuthorshipErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-catalog-row-authorship.test.ts b/packages/agent/test/rfc64-catalog-row-authorship.test.ts new file mode 100644 index 0000000000..33d8b54f77 --- /dev/null +++ b/packages/agent/test/rfc64-catalog-row-authorship.test.ts @@ -0,0 +1,846 @@ +import { describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; + +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + canonicalizeAuthorCatalogBucketPayloadBytesV1, + canonicalizeAuthorCatalogRowV1, + computeAuthorCatalogScopeDigestV1, + computeControlObjectDigestHex, + verifyAuthorCatalogDirectoryPathV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type KaIdV1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + verifyControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; + +import { computeDelegationDigest } from '../src/auth/agent-delegation.js'; +import { + AUTHOR_CATALOG_ROW_AUTHORSHIP_ERROR_CODES_V1, + AuthorCatalogRowAuthorshipErrorV1, + assertVerifiedAuthorCatalogRowAuthorshipForTargetV1, + assertVerifiedAuthorCatalogRowAuthorshipV1, + buildAuthorCatalogAgentScopeV1, + computeAuthorAgentDelegationEvidenceDigestV1, + computeAuthorCatalogAgentScopeDigestV1, + readVerifiedAuthorCatalogRowAuthorshipV1, + verifyAuthorCatalogRowAuthorshipV1, + type AuthorAgentDelegationEvidenceV1, + type AuthorCatalogAgentScopeV1, + type VerifyAuthorCatalogRowAuthorshipInputV1, +} from '../src/rfc64/catalog-row-authorship.js'; + +const AUTHOR_PRIVATE_KEY = `0x${'11'.repeat(32)}`; +const CATALOG_PRIVATE_KEY = `0x${'22'.repeat(32)}`; +const THIRD_PRIVATE_KEY = `0x${'33'.repeat(32)}`; +const AUTHOR = '0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a'; +const CATALOG_ISSUER = '0x1563915e194d8cfba1943570603f7606a3115508'; +const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/catalog-fixture'; +const GOVERNANCE_CONTRACT = '0x2222222222222222222222222222222222222222'; +const ZERO_DIGEST = `0x${'00'.repeat(32)}`; +const BLOB_DIGEST = `0x${'11'.repeat(32)}`; +const CHUNK_ROOT = `0x${'22'.repeat(32)}`; +const SEAL_DIGEST = `0x${'44'.repeat(32)}`; +const TARGET_KA_ID = + '11717532788646872703907650691873846707067843023881409494733446432351458426881'; +const TARGET_KA_ID_2 = + '11717532788646872703907650691873846707067843023881409494733446432351458426882'; +const WRONG_AUTHOR_KA_ID = + ((BigInt(`0x${'44'.repeat(20)}`) << 96n) | 1n).toString(); +const PARENT_SIGNATURE = + '0xa2d72565e1a9dabdbac0c1311d8d65fe74d377bb965ea8fd9d0f0df522e995400bea5baaae6fe2288035a5af5e5c93dc7d176bcadabc99dadf4d0f41394334231b'; +const EXPECTED_PARENT_DIGEST = + '0xb50ae153356ec5fa420a4db1ad78a92c8a1445e975c30f6fd64df9faba043dd5'; +const EXPECTED_AGENT_SCOPE_DIGEST = + '0x962893b5aeb2981d0d3cbbd1b95a7ec8ace1ee468d57de635e4bada99e1c9a83'; +const EXPECTED_DELEGATED_DELEGATION_DIGEST = + '0xeab310c15a84618f0eaf9a6eb2e094a64789c407d1f984a0aea918b284b64143'; +const EXPECTED_DIRECT_DELEGATION_DIGEST = + '0x7b53f3fcf0d5b9c4d49da6ae8bdc9d0b99e893c588ebacbf6d8267d55458bacf'; +const EXPECTED_SCOPE_DIGEST = + '0x8bea420829cdde5cf1e5258895619ac1d484784d2b3998cd8a45e6c8eabbf832'; +const EXPECTED_BUCKET_DIGEST = + '0x82db48bc0cadf93a3803300348464d04d2d91b335426ba86b6fca83623bd25f5'; +const EXPECTED_DIRECTORY_DIGEST = + '0x5774cf8df10e1fe3421b87fa907bd403323c6ac11d71728e2e52dc6319d3f35d'; +const EXPECTED_DELEGATED_HEAD_DIGEST = + '0xa3964f6671e1c015276c692d75d6dd6250f77e4ec32aaf22c2481cf05bd111e1'; +const EXPECTED_DIRECT_HEAD_DIGEST = + '0x808232803f3d8f928c88287dff20ffdc2ad9488e2dc12e08b1bf633ccdc0e57b'; +const EXPECTED_ROW_DIGEST = + '0x0a8cb39f5593176e2eedf8a31b0b96465cc371115340f4f037e58610f04265bd'; +const EXPECTED_TRANSFER_DIGEST = + '0x007c995fd7d001736d39af9f2d3c79177c99b55682a1ff4d002fbc3e0345db25'; + +const authorWallet = new ethers.Wallet(AUTHOR_PRIVATE_KEY); +const catalogWallet = new ethers.Wallet(CATALOG_PRIVATE_KEY); +const thirdWallet = new ethers.Wallet(THIRD_PRIVATE_KEY); + +interface FixtureOptions { + readonly mode?: 'direct' | 'delegated'; + readonly contextGraphId?: string; + readonly subGraphName?: string | null; + readonly effectiveAt?: string; + readonly delegationExpiresAt?: string; + readonly headIssuedAt?: string; + readonly parentIssuedAt?: string; + readonly parentExpiresAt?: string; + readonly parentEvidence?: AuthorAgentDelegationEvidenceV1; + readonly evidenceDigestOverride?: string | null; + readonly delegationSigner?: ethers.Wallet; + readonly catalogIssuerKey?: string; + readonly headSigner?: ethers.Wallet; + readonly directorySigner?: ethers.Wallet; + readonly bucketSigner?: ethers.Wallet; + readonly kaId?: string; + readonly targetKaId?: string; + readonly assertionCoordinate?: string; + readonly duplicateTarget?: boolean; + readonly bucketIdOverride?: string; + readonly delegationPayloadOverride?: Record; + readonly headPayloadOverride?: Record; +} + +interface Fixture { + readonly input: VerifyAuthorCatalogRowAuthorshipInputV1; + readonly parentEvidence: AuthorAgentDelegationEvidenceV1 | null; + readonly delegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1; + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly directory: SignedAuthorCatalogDirectoryNodeEnvelopeV1; + readonly bucket: SignedAuthorCatalogBucketEnvelopeV1; +} + +describe('RFC-64 catalog-row authorship normative closure', () => { + it('pins the delegated parent, object, path, row, and transfer vectors', async () => { + const fixture = await buildFixture(); + const token = verifyAuthorCatalogRowAuthorshipV1(fixture.input); + const snapshot = readVerifiedAuthorCatalogRowAuthorshipV1(token); + + expect(computeAuthorAgentDelegationEvidenceDigestV1(fixture.parentEvidence!)).toBe( + EXPECTED_PARENT_DIGEST, + ); + expect(snapshot.authorCatalogAgentScopeDigest).toBe(EXPECTED_AGENT_SCOPE_DIGEST); + expect(snapshot.catalogIssuerDelegationObjectDigest).toBe( + EXPECTED_DELEGATED_DELEGATION_DIGEST, + ); + expect(snapshot.catalogScopeDigest).toBe(EXPECTED_SCOPE_DIGEST); + expect(snapshot.bucketObjectDigest).toBe(EXPECTED_BUCKET_DIGEST); + expect(snapshot.directoryPathObjectDigests).toEqual([EXPECTED_DIRECTORY_DIGEST]); + expect(snapshot.catalogHeadObjectDigest).toBe(EXPECTED_DELEGATED_HEAD_DIGEST); + expect(snapshot.catalogRowDigest).toBe(EXPECTED_ROW_DIGEST); + expect(snapshot.transferIdentityDigest).toBe(EXPECTED_TRANSFER_DIGEST); + expect(snapshot.row.kaId).toBe(TARGET_KA_ID); + expect(canonicalizeAuthorCatalogBucketPayloadBytesV1(fixture.bucket.payload).byteLength) + .toBe(868); + expect(() => assertVerifiedAuthorCatalogRowAuthorshipForTargetV1( + token, + EXPECTED_ROW_DIGEST, + EXPECTED_TRANSFER_DIGEST, + )).not.toThrow(); + }); + + it('pins the direct-author delegation/head variant while retaining the same row target', async () => { + const fixture = await buildFixture({ mode: 'direct' }); + const snapshot = readVerifiedAuthorCatalogRowAuthorshipV1( + verifyAuthorCatalogRowAuthorshipV1(fixture.input), + ); + expect(snapshot.authorAuthorityEvidenceDigest).toBeNull(); + expect(snapshot.catalogIssuerDelegationObjectDigest).toBe( + EXPECTED_DIRECT_DELEGATION_DIGEST, + ); + expect(snapshot.catalogHeadObjectDigest).toBe(EXPECTED_DIRECT_HEAD_DIGEST); + expect(snapshot.catalogRowDigest).toBe(EXPECTED_ROW_DIGEST); + expect(snapshot.transferIdentityDigest).toBe(EXPECTED_TRANSFER_DIGEST); + }); + + it('derives the exact seven-key scope and deployed v10 legacy digest', async () => { + const fixture = await buildFixture(); + const payload = fixture.delegation.payload; + const scope: AuthorCatalogAgentScopeV1 = { + authorAddress: payload.authorAddress, + contextGraphId: payload.contextGraphId, + governanceChainId: payload.governanceChainId, + governanceContractAddress: payload.governanceContractAddress, + networkId: payload.networkId, + ownershipTransitionDigest: payload.ownershipTransitionDigest, + subGraphName: payload.subGraphName, + }; + expect(computeAuthorCatalogAgentScopeDigestV1(scope)).toBe(EXPECTED_AGENT_SCOPE_DIGEST); + expect(new TextEncoder().encode(canonicalizeFixtureJson(scope)).byteLength).toBe(318); + expect(buildAuthorCatalogAgentScopeV1(scope)).toBe( + `dkg:rfc64:author-catalog-issuer-v1:${EXPECTED_AGENT_SCOPE_DIGEST.slice(2)}`, + ); + expect(new TextEncoder().encode( + canonicalizeFixtureJson(fixture.parentEvidence), + ).byteLength).toBe(459); + expect(ethers.hexlify(computeDelegationDigest({ + agentAddress: AUTHOR, + scope: buildAuthorCatalogAgentScopeV1(scope), + issuedAtMs: 1_700_000_000_000, + expiresAtMs: 1_700_000_121_000, + delegateeOpKey: CATALOG_ISSUER, + }))).toBe('0x341e615362648661f46ba3adbe3d9e748d54e147a5e22212025d4d210d4bb43e'); + }); + + it('is D26-neutral and mints fresh process-local tokens in all four policy cells', async () => { + const fixture = await buildFixture(); + const policyCells = [ + ['open', 'open'], + ['open', 'curated'], + ['invite-only', 'open'], + ['invite-only', 'curated'], + ] as const; + const tokens = policyCells.map(() => verifyAuthorCatalogRowAuthorshipV1(fixture.input)); + expect(new Set(tokens).size).toBe(4); + for (const token of tokens) { + const snapshot = readVerifiedAuthorCatalogRowAuthorshipV1(token); + expect(snapshot.catalogRowDigest).toBe(EXPECTED_ROW_DIGEST); + expect('accessPolicy' in snapshot).toBe(false); + expect('publishPolicy' in snapshot).toBe(false); + expect('membership' in snapshot).toBe(false); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.row)).toBe(true); + expect(Object.isFrozen(snapshot.row.transfer)).toBe(true); + } + }); +}); + +describe('RFC-64 direct/delegated parent closure failures', () => { + it('rejects direct issuance with supplied parent evidence', async () => { + const direct = await buildFixture({ mode: 'direct' }); + const delegated = await buildFixture(); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...direct.input, + parentAuthorAgentEvidence: delegated.parentEvidence, + } as VerifyAuthorCatalogRowAuthorshipInputV1), 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH'); + }); + + it('rejects structurally inconsistent direct/delegated evidence branches', async () => { + const fixture = await buildFixture({ + evidenceDigestOverride: null, + }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(fixture.input), + 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH', + ); + }); + + it('rejects a catalog key that differs from the parent operational signer', async () => { + const scope = defaultAgentScope(); + const thirdEvidence = await signParentEvidence({ + authorAddress: AUTHOR, + delegateeOpKey: thirdWallet.address.toLowerCase(), + delegateePeerId: null, + expiresAt: '1700000121000', + issuedAt: '1700000000000', + scope: buildAuthorCatalogAgentScopeV1(scope), + }, authorWallet); + const fixture = await buildFixture({ + parentEvidence: thirdEvidence, + delegationSigner: thirdWallet, + catalogIssuerKey: CATALOG_ISSUER, + }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(fixture.input), + 'AUTHORSHIP_ISSUER_CLOSURE_MISMATCH', + ); + }); + + it('rejects a one-byte parent mutation before treating its signature as authority', async () => { + const fixture = await buildFixture(); + const mutated = { + ...fixture.parentEvidence!, + delegateePeerId: 'x', + }; + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...fixture.input, + parentAuthorAgentEvidence: mutated, + }), 'AUTHORSHIP_PARENT_DIGEST_MISMATCH'); + }); + + it('rejects a valid root-lane parent replayed into a named lane', async () => { + const root = await buildFixture(); + const named = await buildFixture({ + subGraphName: 'named', + parentEvidence: root.parentEvidence!, + }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(named.input), + 'AUTHORSHIP_PARENT_SCOPE_MISMATCH', + ); + }); + + it.each([ + ['missing operational key', (parent: AuthorAgentDelegationEvidenceV1) => ({ + ...parent, + delegateeOpKey: undefined, + })], + ['zero expiry', (parent: AuthorAgentDelegationEvidenceV1) => ({ + ...parent, + expiresAt: '0', + })], + ])('rejects invalid parent evidence: %s', async (_label, mutate) => { + const fixture = await buildFixture(); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...fixture.input, + parentAuthorAgentEvidence: mutate(fixture.parentEvidence!) as AuthorAgentDelegationEvidenceV1, + }), 'AUTHORSHIP_PARENT_EVIDENCE_INVALID'); + }); + + it('rejects high-s and wrong-recovered-author parent signatures', async () => { + const base = await buildFixture(); + const highS = makeHighS(base.parentEvidence!); + const highSFixture = await buildFixture({ parentEvidence: highS }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(highSFixture.input), + 'AUTHORSHIP_PARENT_EVIDENCE_INVALID', + ); + + const unsigned = withoutSignature(base.parentEvidence!); + const wrongSignature = await thirdWallet.signMessage(computeDelegationDigest({ + agentAddress: unsigned.authorAddress, + scope: unsigned.scope, + issuedAtMs: Number(unsigned.issuedAt), + expiresAtMs: Number(unsigned.expiresAt), + delegateePeerId: undefined, + delegateeOpKey: unsigned.delegateeOpKey, + })); + const wrong = { ...unsigned, signature: wrongSignature } as AuthorAgentDelegationEvidenceV1; + const wrongFixture = await buildFixture({ parentEvidence: wrong }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(wrongFixture.input), + 'AUTHORSHIP_PARENT_EVIDENCE_INVALID', + ); + }); +}); + +describe('RFC-64 half-open interval closure', () => { + it('accepts equality at parent containment boundaries', async () => { + const fixture = await buildFixture({ + effectiveAt: '1700000000000', + delegationExpiresAt: '1700000121000', + headIssuedAt: '1700000000000', + }); + expect(() => verifyAuthorCatalogRowAuthorshipV1(fixture.input)).not.toThrow(); + }); + + it.each([ + ['child starts before parent', { + effectiveAt: '1699999999999', + headIssuedAt: '1700000000000', + }], + ['child ends after parent', { + delegationExpiresAt: '1700000121001', + }], + ['empty child interval', { + effectiveAt: '1700000120000', + delegationExpiresAt: '1700000120000', + }], + ['head is exactly at child expiry', { + headIssuedAt: '1700000120000', + }], + ])('rejects %s', async (_label, options) => { + const fixture = await buildFixture(options); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(fixture.input), + 'AUTHORSHIP_INTERVAL_MISMATCH', + ); + }); +}); + +describe('RFC-64 signed object and path closure failures', () => { + it('rejects a head that names another exact delegation', async () => { + const delegated = await buildFixture(); + const direct = await buildFixture({ mode: 'direct' }); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...direct.input, + catalogHead: delegated.input.catalogHead, + catalogHeadSignature: delegated.input.catalogHeadSignature, + directoryPathEnvelopes: delegated.input.directoryPathEnvelopes, + directoryPathSignatures: delegated.input.directoryPathSignatures, + directoryPathProof: delegated.input.directoryPathProof, + catalogBucket: delegated.input.catalogBucket, + catalogBucketSignature: delegated.input.catalogBucketSignature, + }), 'AUTHORSHIP_HEAD_BINDING_MISMATCH'); + }); + + it('rejects a catalog object signed by another issuer', async () => { + const fixture = await buildFixture({ bucketSigner: thirdWallet }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(fixture.input), + 'AUTHORSHIP_HEAD_BINDING_MISMATCH', + ); + }); + + it('rejects omitted, cloned, foreign-head, and wrong-signature path dependencies', async () => { + const delegated = await buildFixture(); + const direct = await buildFixture({ mode: 'direct' }); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...delegated.input, + directoryPathEnvelopes: [], + directoryPathSignatures: [], + }), 'AUTHORSHIP_PATH_BINDING_MISMATCH'); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...delegated.input, + directoryPathProof: { ...delegated.input.directoryPathProof }, + } as VerifyAuthorCatalogRowAuthorshipInputV1), 'AUTHORSHIP_PATH_BINDING_MISMATCH'); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...delegated.input, + directoryPathProof: direct.input.directoryPathProof, + }), 'AUTHORSHIP_PATH_BINDING_MISMATCH'); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...delegated.input, + directoryPathSignatures: [delegated.input.catalogBucketSignature], + }), 'AUTHORSHIP_PATH_BINDING_MISMATCH'); + }); + + it('bounds both directory arrays before touching attacker-controlled elements', async () => { + const fixture = await buildFixture(); + + let oversizedEnvelopeTailTouched = false; + const oversizedEnvelopes = new Array(2); + oversizedEnvelopes[0] = fixture.input.directoryPathEnvelopes[0]; + Object.defineProperty(oversizedEnvelopes, '1', { + enumerable: true, + configurable: true, + get() { + oversizedEnvelopeTailTouched = true; + throw new Error('oversized envelope tail must not be inspected'); + }, + }); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...fixture.input, + directoryPathEnvelopes: oversizedEnvelopes, + }), 'AUTHORSHIP_PATH_BINDING_MISMATCH'); + expect(oversizedEnvelopeTailTouched).toBe(false); + + let validLengthEnvelopeTouched = false; + const trappedValidLengthEnvelopes = new Array(1); + Object.defineProperty(trappedValidLengthEnvelopes, '0', { + enumerable: true, + configurable: true, + get() { + validLengthEnvelopeTouched = true; + throw new Error('envelope must not be inspected before signature-array preflight'); + }, + }); + let oversizedSignatureTailTouched = false; + const oversizedSignatures = new Array(2); + oversizedSignatures[0] = fixture.input.directoryPathSignatures[0]; + Object.defineProperty(oversizedSignatures, '1', { + enumerable: true, + configurable: true, + get() { + oversizedSignatureTailTouched = true; + throw new Error('oversized signature tail must not be inspected'); + }, + }); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...fixture.input, + directoryPathEnvelopes: trappedValidLengthEnvelopes, + directoryPathSignatures: oversizedSignatures, + }), 'AUTHORSHIP_PATH_BINDING_MISMATCH'); + expect(validLengthEnvelopeTouched).toBe(false); + expect(oversizedSignatureTailTouched).toBe(false); + }); + + it('rejects a signed bucket that differs from the selected leaf descriptor', async () => { + const first = await buildFixture(); + const other = await buildFixture({ assertionCoordinate: 'other' }); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...first.input, + catalogBucket: other.input.catalogBucket, + catalogBucketSignature: other.input.catalogBucketSignature, + }), 'AUTHORSHIP_BUCKET_BINDING_MISMATCH'); + }); + + it('rejects wrong-object generic signature capabilities', async () => { + const fixture = await buildFixture(); + expectCode(() => verifyAuthorCatalogRowAuthorshipV1({ + ...fixture.input, + catalogHeadSignature: fixture.input.catalogBucketSignature, + }), 'AUTHORSHIP_SIGNATURE_PROOF_INVALID'); + }); +}); + +describe('RFC-64 exact target row and capability closure', () => { + it('rejects an absent target row', async () => { + const fixture = await buildFixture({ targetKaId: TARGET_KA_ID_2 }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(fixture.input), + 'AUTHORSHIP_ROW_BINDING_MISMATCH', + ); + }); + + it('rejects duplicate and wrong-bucket target rows', async () => { + const duplicate = await buildFixture({ duplicateTarget: true }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(duplicate.input), + 'AUTHORSHIP_ROW_BINDING_MISMATCH', + ); + const wrongBucket = await buildFixture({ bucketIdOverride: '1' }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(wrongBucket.input), + 'AUTHORSHIP_ROW_BINDING_MISMATCH', + ); + }); + + it('rejects a fully re-signed self-consistent row whose high 160 bits name another author', async () => { + const fixture = await buildFixture({ + kaId: WRONG_AUTHOR_KA_ID, + targetKaId: WRONG_AUTHOR_KA_ID, + }); + expectCode( + () => verifyAuthorCatalogRowAuthorshipV1(fixture.input), + 'AUTHORSHIP_ROW_BINDING_MISMATCH', + ); + }); + + it('snapshots source objects and cannot be retargeted after verification', async () => { + const fixture = await buildFixture(); + const token = verifyAuthorCatalogRowAuthorshipV1(fixture.input); + const snapshot = readVerifiedAuthorCatalogRowAuthorshipV1(token); + (fixture.input.catalogBucket.payload.rows[0] as { assertionCoordinate: string }) + .assertionCoordinate = 'mutated'; + expect(snapshot.row.assertionCoordinate).toBe('fixture'); + expect(snapshot.catalogRowDigest).toBe(EXPECTED_ROW_DIGEST); + expectCode( + () => assertVerifiedAuthorCatalogRowAuthorshipForTargetV1( + token, + `0x${'99'.repeat(32)}`, + snapshot.transferIdentityDigest, + ), + 'AUTHORSHIP_CAPABILITY_INVALID', + ); + }); + + it('rejects cast, spread, JSON, and structured clones of the opaque token', async () => { + const fixture = await buildFixture(); + const token = verifyAuthorCatalogRowAuthorshipV1(fixture.input); + const clones: unknown[] = [ + {}, + { ...token }, + JSON.parse(JSON.stringify(token)), + structuredClone(token), + ]; + for (const clone of clones) { + expectCode( + () => assertVerifiedAuthorCatalogRowAuthorshipV1(clone), + 'AUTHORSHIP_CAPABILITY_INVALID', + ); + } + }); + + it('preserves Unicode scalar sequence as raw UTF-8 bytes without UTF-16 reordering', async () => { + const bmpThenAstral = await buildFixture({ assertionCoordinate: '\ue000\u{10000}' }); + const astralThenBmp = await buildFixture({ assertionCoordinate: '\u{10000}\ue000' }); + const first = readVerifiedAuthorCatalogRowAuthorshipV1( + verifyAuthorCatalogRowAuthorshipV1(bmpThenAstral.input), + ); + const second = readVerifiedAuthorCatalogRowAuthorshipV1( + verifyAuthorCatalogRowAuthorshipV1(astralThenBmp.input), + ); + expect(first.row.assertionCoordinate).toBe('\ue000\u{10000}'); + expect(second.row.assertionCoordinate).toBe('\u{10000}\ue000'); + expect(first.catalogRowDigest).not.toBe(second.catalogRowDigest); + const raw = Buffer.from(canonicalizeAuthorCatalogRowV1(first.row)).toString('hex'); + expect(raw.indexOf('ee8080')).toBeLessThan(raw.indexOf('f0908080')); + }); + + it('exposes only the frozen closed error-code registry', () => { + expect(AUTHOR_CATALOG_ROW_AUTHORSHIP_ERROR_CODES_V1).toHaveLength(13); + expect(Object.isFrozen(AUTHOR_CATALOG_ROW_AUTHORSHIP_ERROR_CODES_V1)).toBe(true); + expect(() => new AuthorCatalogRowAuthorshipErrorV1( + 'NOT_CLOSED' as never, + 'bad', + )).toThrow(/Unsupported catalog-row authorship error code/); + }); +}); + +async function buildFixture(options: FixtureOptions = {}): Promise { + const mode = options.mode ?? 'delegated'; + const contextGraphId = options.contextGraphId ?? CONTEXT_GRAPH_ID; + const subGraphName = options.subGraphName ?? null; + const effectiveAt = options.effectiveAt ?? '1700000000000'; + const delegationExpiresAt = options.delegationExpiresAt ?? '1700000120000'; + const headIssuedAt = options.headIssuedAt ?? '1700000000123'; + const delegationSigner = options.delegationSigner + ?? (mode === 'direct' ? authorWallet : catalogWallet); + const catalogIssuerKey = (options.catalogIssuerKey ?? CATALOG_ISSUER).toLowerCase(); + + const agentScope = defaultAgentScope({ contextGraphId, subGraphName }); + let parentEvidence: AuthorAgentDelegationEvidenceV1 | null = null; + if (mode === 'delegated') { + parentEvidence = options.parentEvidence ?? await signParentEvidence({ + authorAddress: AUTHOR, + delegateeOpKey: delegationSigner.address.toLowerCase(), + delegateePeerId: null, + expiresAt: options.parentExpiresAt ?? '1700000121000', + issuedAt: options.parentIssuedAt ?? '1700000000000', + scope: buildAuthorCatalogAgentScopeV1(agentScope), + }, authorWallet); + } + const evidenceDigest = options.evidenceDigestOverride !== undefined + ? options.evidenceDigestOverride + : parentEvidence === null + ? null + : computeAuthorAgentDelegationEvidenceDigestV1(parentEvidence); + + const delegationPayload = { + authorAddress: AUTHOR, + authorAuthorityEvidenceDigest: evidenceDigest, + catalogEra: '0', + catalogIssuerKey, + contextGraphId, + effectiveAt, + expiresAt: delegationExpiresAt, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + networkId: 'otp:20430', + ownershipTransitionDigest: null, + previousDelegationDigest: null, + subGraphName, + ...options.delegationPayloadOverride, + }; + const delegation = await signControlEnvelope({ + issuer: delegationSigner.address.toLowerCase(), + objectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + payload: delegationPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, delegationSigner) as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + + const scope = { + authorAddress: AUTHOR, + bucketCount: '1', + contextGraphId, + era: '0', + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + networkId: 'otp:20430', + ownershipTransitionDigest: null, + subGraphName, + } as AuthorCatalogScopeV1; + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const kaId = (options.kaId ?? TARGET_KA_ID) as KaIdV1; + const row: AuthorCatalogRowV1 = { + assertionCoordinate: (options.assertionCoordinate ?? 'fixture') as AuthorCatalogRowV1['assertionCoordinate'], + assertionVersion: '1', + kaId, + projectionDigest: ZERO_DIGEST, + projectionId: 'cg-shared-v1', + sealDigest: SEAL_DIGEST, + transfer: { + blobDigest: BLOB_DIGEST, + byteLength: '16', + chunkCount: '1', + chunkSize: '262144', + chunkTreeRoot: CHUNK_ROOT, + codec: 'dkg-ka-bundle-v1', + projectionDigest: ZERO_DIGEST, + projectionId: 'cg-shared-v1', + }, + }; + const rows = options.duplicateTarget ? [row, structuredClone(row)] : [row]; + const bucketPayload = { + bucketCount: '1', + bucketId: options.bucketIdOverride ?? '0', + catalogScopeDigest: scopeDigest, + era: '0', + rows, + }; + const bucketSigner = options.bucketSigner ?? catalogWallet; + const bucket = await signControlEnvelope({ + issuer: bucketSigner.address.toLowerCase(), + objectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + payload: bucketPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, bucketSigner) as SignedAuthorCatalogBucketEnvelopeV1; + + const bucketPayloadBytes = canonicalPayloadBytes(bucketPayload); + const directoryPayload = { + catalogScopeDigest: scopeDigest, + entries: [{ + bucketDigest: bucket.objectDigest, + bucketId: '0', + byteLength: String(bucketPayloadBytes.byteLength), + rowCount: String(rows.length), + }], + era: '0', + firstBucketId: '0', + level: '0', + }; + const directorySigner = options.directorySigner ?? catalogWallet; + const directory = await signControlEnvelope({ + issuer: directorySigner.address.toLowerCase(), + objectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + payload: directoryPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, directorySigner) as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + + const headPayload = { + authorAddress: AUTHOR, + bucketCount: '1', + catalogIssuerDelegationDigest: delegation.objectDigest, + contextGraphId, + directoryHeight: '0', + directoryRootDigest: directory.objectDigest, + era: '0', + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + issuedAt: headIssuedAt, + networkId: 'otp:20430', + ownershipTransitionDigest: null, + previousHeadDigest: null, + subGraphName, + totalRows: String(rows.length), + version: '0', + ...options.headPayloadOverride, + }; + const headSigner = options.headSigner ?? catalogWallet; + const head = await signControlEnvelope({ + issuer: headSigner.address.toLowerCase(), + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload: headPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, headSigner) as SignedAuthorCatalogHeadEnvelopeV1; + + const delegationProof = await verifyControlEnvelopeIssuerSignatureV1(delegation); + const headProof = await verifyControlEnvelopeIssuerSignatureV1(head); + const directoryProof = await verifyControlEnvelopeIssuerSignatureV1(directory); + const bucketProof = await verifyControlEnvelopeIssuerSignatureV1(bucket); + let pathProof: ReturnType; + try { + pathProof = verifyAuthorCatalogDirectoryPathV1(head, [directory], '0'); + } catch { + // Some negative fixtures are intentionally structurally invalid before the + // authorship verifier maps their closed error. Borrow a valid opaque proof; + // verification will fail before it can grant authority. + const valid = await buildFixture(); + pathProof = valid.input.directoryPathProof; + } + + return { + parentEvidence, + delegation, + head, + directory, + bucket, + input: { + catalogBucket: bucket, + catalogBucketSignature: bucketProof, + catalogHead: head, + catalogHeadSignature: headProof, + catalogIssuerDelegation: delegation, + catalogIssuerDelegationSignature: delegationProof, + directoryPathEnvelopes: [directory], + directoryPathProof: pathProof, + directoryPathSignatures: [directoryProof], + parentAuthorAgentEvidence: parentEvidence, + targetKaId: (options.targetKaId ?? kaId) as KaIdV1, + }, + }; +} + +async function signControlEnvelope( + unsigned: UnsignedControlEnvelopeV1, + wallet: ethers.Wallet, +): Promise { + const objectDigest = computeControlObjectDigestHex(unsigned); + return { + ...unsigned, + objectDigest, + signature: await wallet.signMessage(ethers.getBytes(objectDigest)), + }; +} + +async function signParentEvidence( + unsigned: Omit, + wallet: ethers.Wallet, +): Promise { + const signature = await wallet.signMessage(computeDelegationDigest({ + agentAddress: unsigned.authorAddress, + scope: unsigned.scope, + issuedAtMs: Number(unsigned.issuedAt), + expiresAtMs: Number(unsigned.expiresAt), + delegateePeerId: unsigned.delegateePeerId ?? undefined, + delegateeOpKey: unsigned.delegateeOpKey, + })); + return { ...unsigned, signature }; +} + +function defaultAgentScope( + options: { readonly contextGraphId?: string; readonly subGraphName?: string | null } = {}, +): AuthorCatalogAgentScopeV1 { + return { + authorAddress: AUTHOR, + contextGraphId: (options.contextGraphId ?? CONTEXT_GRAPH_ID) as AuthorCatalogAgentScopeV1['contextGraphId'], + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + networkId: 'otp:20430' as AuthorCatalogAgentScopeV1['networkId'], + ownershipTransitionDigest: null, + subGraphName: (options.subGraphName ?? null) as AuthorCatalogAgentScopeV1['subGraphName'], + }; +} + +function withoutSignature( + evidence: AuthorAgentDelegationEvidenceV1, +): Omit { + const { signature: _signature, ...unsigned } = evidence; + return unsigned; +} + +function makeHighS( + evidence: AuthorAgentDelegationEvidenceV1, +): AuthorAgentDelegationEvidenceV1 { + const n = BigInt('0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); + const r = evidence.signature.slice(2, 66); + const lowS = BigInt(`0x${evidence.signature.slice(66, 130)}`); + const highS = (n - lowS).toString(16).padStart(64, '0'); + return { + ...evidence, + signature: `0x${r}${highS}${evidence.signature.slice(130)}`, + }; +} + +function canonicalPayloadBytes(payload: unknown): Uint8Array { + // Every fixture payload contains only canonical JSON strings/null/arrays. + // The production codec independently revalidates the exact JCS byte length. + return new TextEncoder().encode(canonicalizeFixtureJson(payload)); +} + +function canonicalizeFixtureJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalizeFixtureJson).join(',')}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalizeFixtureJson(record[key])}`).join(',')}}`; +} + +function expectCode( + operation: () => unknown, + code: (typeof AUTHOR_CATALOG_ROW_AUTHORSHIP_ERROR_CODES_V1)[number], +): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(AuthorCatalogRowAuthorshipErrorV1); + expect((error as AuthorCatalogRowAuthorshipErrorV1).code).toBe(code); + return; + } + throw new Error(`Expected ${code}`); +} diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 5a7f454e29..1ac2ed540a 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -107,6 +107,7 @@ export default defineConfig({ "test/workspace-crypto-delegatee-filter.test.ts", "test/rfc64-inventory-v1-scalars.test.ts", "test/rfc64-inventory-v1-lifecycle.test.ts", + "test/rfc64-catalog-row-authorship.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 2734cb7fbe477ae32e90307cd41d98e61bcb973b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 10:52:55 +0200 Subject: [PATCH 059/292] fix(core): read cg-shared projection limits exactly once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeVerificationLimits() read each caller-controlled limit property (maxProjectionBytes, maxPublicTriples, maxLineBytes) during validation and again when building the frozen result and cross-checking maxLineBytes. A stateful getter or Proxy could return a safe value on the first read and an oversized value on later reads, so the validated value differed from the value that became the effective ceiling — bypassing the hard in-memory resource limits. Capture each property into a local scalar exactly once, then validate, cross-check, and return only those captured scalars. Add two regression tests driven through the public verifier limits param: one proving each property is consulted exactly once via a read-counting getter, and one proving a getter that turns oversized after validation is still refused by the byte ceiling. Both fail against the pre-fix code. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/cg-shared-projection.ts | 22 +++++++--- .../core/test/cg-shared-projection.test.ts | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/core/src/cg-shared-projection.ts b/packages/core/src/cg-shared-projection.ts index b94616d4bf..dbdde3cd77 100644 --- a/packages/core/src/cg-shared-projection.ts +++ b/packages/core/src/cg-shared-projection.ts @@ -706,10 +706,18 @@ function normalizeVerificationLimits( value: CgSharedProjectionVerificationLimitsV1, ): Readonly { const hard = DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1; + // Capture each caller-controlled limit exactly once. A stateful getter or + // Proxy could otherwise return a safe value during validation and an + // oversized value when the frozen result is built, smuggling a limit past + // the hard resource ceilings. Every check and the returned object below read + // only these captured scalars, never `value` a second time. + const maxProjectionBytes = value?.maxProjectionBytes; + const maxPublicTriples = value?.maxPublicTriples; + const maxLineBytes = value?.maxLineBytes; for (const [name, member, ceiling] of [ - ['maxProjectionBytes', value?.maxProjectionBytes, hard.maxProjectionBytes], - ['maxPublicTriples', value?.maxPublicTriples, hard.maxPublicTriples], - ['maxLineBytes', value?.maxLineBytes, hard.maxLineBytes], + ['maxProjectionBytes', maxProjectionBytes, hard.maxProjectionBytes], + ['maxPublicTriples', maxPublicTriples, hard.maxPublicTriples], + ['maxLineBytes', maxLineBytes, hard.maxLineBytes], ] as const) { if ( !Number.isSafeInteger(member) @@ -722,16 +730,16 @@ function normalizeVerificationLimits( ); } } - if (value.maxLineBytes > value.maxProjectionBytes) { + if (maxLineBytes > maxProjectionBytes) { fail( 'projection-resource-refused', 'maxLineBytes cannot exceed maxProjectionBytes', ); } return Object.freeze({ - maxProjectionBytes: value.maxProjectionBytes, - maxPublicTriples: value.maxPublicTriples, - maxLineBytes: value.maxLineBytes, + maxProjectionBytes, + maxPublicTriples, + maxLineBytes, }); } diff --git a/packages/core/test/cg-shared-projection.test.ts b/packages/core/test/cg-shared-projection.test.ts index d9a34e84ab..f2b4abae18 100644 --- a/packages/core/test/cg-shared-projection.test.ts +++ b/packages/core/test/cg-shared-projection.test.ts @@ -500,6 +500,49 @@ describe('RFC-64 canonical cg-shared-v1 projection verification', () => { ); } }); + + it('reads each caller-controlled limit exactly once (stateful-getter safe)', () => { + const fixture = publicFixture(); + const hard = DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1; + const reads = { maxProjectionBytes: 0, maxPublicTriples: 0, maxLineBytes: 0 }; + const limits: CgSharedProjectionVerificationLimitsV1 = { + get maxProjectionBytes() { + reads.maxProjectionBytes += 1; + return hard.maxProjectionBytes; + }, + get maxPublicTriples() { + reads.maxPublicTriples += 1; + return hard.maxPublicTriples; + }, + get maxLineBytes() { + reads.maxLineBytes += 1; + return hard.maxLineBytes; + }, + }; + const verified = verifyProjection(fixture, limits); + // A stateful getter must be consulted exactly once per property so a later + // read cannot diverge from the validated value. + expect(reads).toEqual({ maxProjectionBytes: 1, maxPublicTriples: 1, maxLineBytes: 1 }); + expect(readProjection(verified, fixture).verificationLimits).toEqual(hard); + }); + + it('cannot be tricked by a getter that turns oversized after validation', () => { + const fixture = publicFixture(); + const safeBelowProjection = UTF8.encode(PUBLIC).byteLength - 1; + let projectionByteReads = 0; + // First read returns a safe value that passes validation; every later read + // returns an oversized value. A second read leaking into the effective + // ceiling would accept this over-limit projection instead of refusing it. + const limits: CgSharedProjectionVerificationLimitsV1 = { + get maxProjectionBytes() { + projectionByteReads += 1; + return projectionByteReads === 1 ? safeBelowProjection : Number.MAX_SAFE_INTEGER; + }, + maxPublicTriples: 2, + maxLineBytes: safeBelowProjection, + }; + expectFailure(() => verifyProjection(fixture, limits), 'projection-resource-refused'); + }); }); interface Fixture { From c1e341ce41642d054982ffc0ba299a44dd43707f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 10:58:41 +0200 Subject: [PATCH 060/292] feat(agent): verify RFC-64 candidate transfer precommit --- .../agent/src/rfc64/inventory-v1/candidate.ts | 219 ++++++ .../agent/src/rfc64/inventory-v1/index.ts | 1 + packages/agent/src/rfc64/inventory-v1/open.ts | 25 +- ...rfc64-candidate-transfer-admission.test.ts | 646 ++++++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 5 files changed, 891 insertions(+), 1 deletion(-) create mode 100644 packages/agent/test/rfc64-candidate-transfer-admission.test.ts diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index 0b3948496e..9a68f15329 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -18,15 +18,23 @@ import { assertSignedAuthorCatalogHeadEnvelopeV1, assertSubGraphNameV1, canonicalizeAuthorCatalogBucketPayloadBytesV1, + canonicalizeAuthorCatalogScopeV1, + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, computeAuthorCatalogKeyDigestV1, computeAuthorCatalogRowDigestV1, computeAuthorCatalogScopeDigestV1, deriveAuthorCatalogScopeFromHeadV1, + parseCanonicalSignedAuthorCatalogHeadEnvelopeV1, parseCanonicalDecimalU64, readVerifiedAuthorCatalogBucketDescriptorV1, + readVerifiedTransferredCatalogBundleMetadataV1, + verifyCgSharedProjectionV1, + verifyTransferredCatalogBundleV1, type AuthorCatalogRowV1, type AuthorCatalogScopeV1, type ByteLengthV1, + type CatalogSealDeploymentProfileV1, + type CgSharedProjectionVerificationLimitsV1, type CountV1, type DecimalU64V1, type Digest32V1, @@ -36,6 +44,8 @@ import { type SignedAuthorCatalogHeadEnvelopeV1, type SubGraphNameV1, type VerifiedAuthorCatalogDirectoryPathV1, + type VerifiedCgSharedProjectionV1, + type VerifiedTransferredCatalogBundleV1, } from '@origintrail-official/dkg-core'; import { @@ -142,6 +152,16 @@ export interface CandidateSessionGcBatchResultV1 { readonly done: boolean; } +/** + * Precommit evidence for one exact live candidate row. Both fields are opaque, + * process-local capabilities. This container proves neither catalog-head authority + * nor policy/finality, and grants no semantic-store or activation authority. + */ +export interface CandidateCatalogPrecommitResultV1 { + readonly transferredBundle: VerifiedTransferredCatalogBundleV1; + readonly sharedProjection: VerifiedCgSharedProjectionV1; +} + export type InventoryV1CandidateErrorCode = | 'candidate-invalid-session' | 'candidate-startup-purge-required' @@ -155,6 +175,10 @@ export type InventoryV1CandidateErrorCode = | 'candidate-traversal-closed' | 'candidate-invalid-row-proof' | 'candidate-stale-row-proof' + | 'candidate-row-not-present' + | 'candidate-head-binding' + | 'candidate-deployment-profile' + | 'candidate-binding-changed' | 'candidate-cursor-mismatch' | 'candidate-stream-complete' | 'candidate-in-use' @@ -201,6 +225,18 @@ export interface Rfc64InventoryV1CandidateApi { readVerifiedCandidateCatalogRow( verifiedRow: VerifiedCandidateCatalogRowV1, ): CandidateBucketRowSnapshotV1; + /** + * Replace one live `present` row proof with structural transfer and semantic + * projection proofs. The caller must separately establish head authority, + * policy, placement/finality, atomic commit, and exact post-read success. + */ + verifyCandidateCatalogPrecommitV1( + verifiedRow: VerifiedCandidateCatalogRowV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + receivedBundleBytes: Uint8Array, + deployment: CatalogSealDeploymentProfileV1, + limits?: CgSharedProjectionVerificationLimitsV1, + ): CandidateCatalogPrecommitResultV1; closeCandidateTraversal( traversal: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, ): void; @@ -595,6 +631,189 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { return proof.snapshot; } + verifyCandidateCatalogPrecommitV1( + verifiedRow: VerifiedCandidateCatalogRowV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + receivedBundleBytes: Uint8Array, + deployment: CatalogSealDeploymentProfileV1, + limits?: CgSharedProjectionVerificationLimitsV1, + ): CandidateCatalogPrecommitResultV1 { + const candidate = this.readVerifiedCandidateCatalogRow(verifiedRow); + if (candidate.disposition !== 'present') { + throw new InventoryV1CandidateError( + 'candidate-row-not-present', + 'removed candidate rows cannot authorize payload transfer or semantic admission', + ); + } + + let transferredBundle: VerifiedTransferredCatalogBundleV1 | undefined; + let sharedProjection: VerifiedCgSharedProjectionV1 | undefined; + let verificationFailed = false; + let verificationFailure: unknown; + try { + // Snapshot every caller-controlled binding input once. The parsed head and + // deployment copy are the only forms passed to later verifiers, preventing + // a stateful Proxy from rebinding one candidate between verifier calls. + const headSnapshot = this.snapshotSignedHead(signedHead); + const deploymentSnapshot = this.snapshotDeploymentProfile(deployment); + this.assertCandidateHeadBinding(candidate, headSnapshot); + + transferredBundle = verifyTransferredCatalogBundleV1( + headSnapshot, + candidate.row, + receivedBundleBytes, + deploymentSnapshot, + ); + sharedProjection = verifyCgSharedProjectionV1( + transferredBundle, + headSnapshot, + candidate.row, + deploymentSnapshot, + limits, + ); + + const metadata = readVerifiedTransferredCatalogBundleMetadataV1( + transferredBundle, + headSnapshot, + candidate.row, + deploymentSnapshot, + ); + if ( + metadata.headObjectDigest !== candidate.header.targetCatalogHeadDigest + || metadata.catalogScopeDigest !== candidate.header.catalogScopeDigest + || metadata.catalogRowDigest !== candidate.expectedCatalogRowDigest + ) { + throw new InventoryV1CandidateError( + 'candidate-binding-changed', + 'verified transfer metadata does not match the live candidate binding', + ); + } + } catch (cause) { + verificationFailed = true; + verificationFailure = cause; + } + + // The wire objects above are untrusted JavaScript values. Accessors on a + // hostile value can re-enter the adapter and close/poison/reopen the + // originating traversal while core verification is running. Revalidate the + // exact opaque proof after all input consumption before returning authority. + let current: CandidateBucketRowSnapshotV1; + try { + current = this.readVerifiedCandidateCatalogRow(verifiedRow); + } catch (cause) { + throw new InventoryV1CandidateError( + 'candidate-binding-changed', + 'candidate row authority changed during transfer precommit verification', + { cause }, + ); + } + if (current !== candidate || current.disposition !== 'present') { + throw new InventoryV1CandidateError( + 'candidate-binding-changed', + 'candidate row binding changed during transfer precommit verification', + ); + } + if (verificationFailed) throw verificationFailure; + + return Object.freeze({ + transferredBundle: transferredBundle!, + sharedProjection: sharedProjection!, + }); + } + + private snapshotSignedHead( + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + ): SignedAuthorCatalogHeadEnvelopeV1 { + try { + const canonicalBytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(signedHead); + const snapshot = parseCanonicalSignedAuthorCatalogHeadEnvelopeV1(canonicalBytes); + Object.freeze(snapshot.payload); + Object.freeze(snapshot.signatureEvidence); + return Object.freeze(snapshot); + } catch (cause) { + throw new InventoryV1CandidateError( + 'candidate-head-binding', + 'signed catalog head could not be snapshotted canonically', + { cause }, + ); + } + } + + private snapshotDeploymentProfile( + deployment: CatalogSealDeploymentProfileV1, + ): Readonly { + try { + if (deployment === null || typeof deployment !== 'object' || Array.isArray(deployment)) { + throw new Error('deployment profile must be an object'); + } + const keys = Reflect.ownKeys(deployment); + const expected = ['assertedAtChainId', 'assertedAtKav10Address', 'networkId']; + const stringKeys = keys.filter((key): key is string => typeof key === 'string'); + if ( + stringKeys.length !== keys.length + || stringKeys.length !== expected.length + || [...stringKeys].sort().some((key, index) => key !== expected[index]) + ) { + throw new Error('deployment profile has unknown or missing fields'); + } + for (const key of stringKeys) { + const descriptor = Object.getOwnPropertyDescriptor(deployment, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('deployment profile fields must be enumerable data properties'); + } + } + + // Each scalar getter is consumed exactly once. Core verification validates + // the resulting plain, frozen profile before using it. + const networkId = deployment.networkId; + const assertedAtChainId = deployment.assertedAtChainId; + const assertedAtKav10Address = deployment.assertedAtKav10Address; + return Object.freeze({ + networkId, + assertedAtChainId, + assertedAtKav10Address, + }); + } catch (cause) { + throw new InventoryV1CandidateError( + 'candidate-deployment-profile', + 'deployment profile could not be snapshotted canonically', + { cause }, + ); + } + } + + private assertCandidateHeadBinding( + candidate: CandidateBucketRowSnapshotV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + ): void { + try { + const scope = deriveAuthorCatalogScopeFromHeadV1(signedHead.payload); + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + if ( + signedHead.objectDigest !== candidate.header.targetCatalogHeadDigest + || scopeDigest !== candidate.header.catalogScopeDigest + || canonicalizeAuthorCatalogScopeV1(scope) + !== canonicalizeAuthorCatalogScopeV1(candidate.catalogScope) + || computeAuthorCatalogRowDigestV1(scopeDigest, candidate.row) + !== candidate.expectedCatalogRowDigest + ) { + throw new Error('signed head, catalog scope, and candidate row do not share one binding'); + } + } catch (cause) { + if ( + cause instanceof InventoryV1CandidateError + && cause.code === 'candidate-head-binding' + ) { + throw cause; + } + throw new InventoryV1CandidateError( + 'candidate-head-binding', + 'signed catalog head does not bind the live candidate row', + { cause }, + ); + } + } + closeCandidateTraversal( traversal: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, ): void { diff --git a/packages/agent/src/rfc64/inventory-v1/index.ts b/packages/agent/src/rfc64/inventory-v1/index.ts index cce8ad2169..7a0eac383f 100644 --- a/packages/agent/src/rfc64/inventory-v1/index.ts +++ b/packages/agent/src/rfc64/inventory-v1/index.ts @@ -3,6 +3,7 @@ // unverified low-level reopen callback. export { InventoryV1CandidateError, + type CandidateCatalogPrecommitResultV1, type CandidateBucketDiffTraversalV1, type CandidateBucketHeaderV1, type CandidateBucketLoadKeyV1, diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 7c323066aa..b265952891 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -40,6 +40,7 @@ import { } from './sql.js'; import { CandidateInventoryV1, + type CandidateCatalogPrecommitResultV1, type CandidateBucketDiffTraversalV1, type CandidateBucketHeaderV1, type CandidateBucketLoadKeyV1, @@ -53,7 +54,12 @@ import { type VerifiedCandidateBucketLoadV1, type VerifiedCandidateCatalogRowV1, } from './candidate.js'; -import type { KaIdV1 } from '@origintrail-official/dkg-core'; +import type { + CatalogSealDeploymentProfileV1, + CgSharedProjectionVerificationLimitsV1, + KaIdV1, + SignedAuthorCatalogHeadEnvelopeV1, +} from '@origintrail-official/dkg-core'; import { createProductionInventoryV1LifecycleAdapter, INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, @@ -394,6 +400,23 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { return this.#candidate.readVerifiedCandidateCatalogRow(verifiedRow); } + verifyCandidateCatalogPrecommitV1( + verifiedRow: VerifiedCandidateCatalogRowV1, + signedHead: SignedAuthorCatalogHeadEnvelopeV1, + receivedBundleBytes: Uint8Array, + deployment: CatalogSealDeploymentProfileV1, + limits?: CgSharedProjectionVerificationLimitsV1, + ): CandidateCatalogPrecommitResultV1 { + this.requireOpen(); + return this.#candidate.verifyCandidateCatalogPrecommitV1( + verifiedRow, + signedHead, + receivedBundleBytes, + deployment, + limits, + ); + } + closeCandidateTraversal( traversal: CandidateBucketRowsTraversalV1 | CandidateBucketDiffTraversalV1, ): void { diff --git a/packages/agent/test/rfc64-candidate-transfer-admission.test.ts b/packages/agent/test/rfc64-candidate-transfer-admission.test.ts new file mode 100644 index 0000000000..80d247d33f --- /dev/null +++ b/packages/agent/test/rfc64-candidate-transfer-admission.test.ts @@ -0,0 +1,646 @@ +import { + mkdtempSync, + realpathSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + assertAuthorCatalogRowV1, + assertCanonicalGraphScopedAuthorSealV1, + assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + assertVerifiedCgSharedProjectionForTransferV1, + assertVerifiedTransferredCatalogBundleForInputsV1, + canonicalizeAuthorCatalogBucketPayloadBytesV1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + computeAuthorCatalogBucketObjectDigestV1, + computeAuthorCatalogDirectoryNodeObjectDigestV1, + computeAuthorCatalogHeadObjectDigestV1, + computeAuthorCatalogScopeDigestV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + computeKaChunkTreeRootV1, + deriveAuthorCatalogScopeFromHeadV1, + encodeOpaqueKaBundleV1, + readVerifiedCatalogSealBindingV1, + readVerifiedCgSharedProjectionMetadataV1, + readVerifiedTransferredCatalogBundleMetadataV1, + verifyAuthorCatalogDirectoryPathV1, + type AuthorCatalogBucketDescriptorV1, + type AuthorCatalogBucketV1, + type AuthorCatalogDirectoryNodeV1, + type AuthorCatalogHeadV1, + type AuthorCatalogRowV1, + type ByteLengthV1, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, + type CgSharedProjectionVerificationLimitsV1, + type CountV1, + type DecimalU64V1, + type Digest32V1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, + type VerifiedAuthorCatalogDirectoryPathV1, +} from '@origintrail-official/dkg-core'; + +import { + openInventoryV1, + type CandidateBucketRowV1, + type CandidateSessionV1, + type Rfc64InventoryV1Foundation, + type VerifiedCandidateBucketLoadV1, + type VerifiedCandidateCatalogRowV1, +} from '../src/rfc64/inventory-v1/index.js'; + +const AUTHOR = '0x3333333333333333333333333333333333333333'; +const ISSUER = '0x5555555555555555555555555555555555555555'; +const GOVERNANCE = '0x6666666666666666666666666666666666666666'; +const KAV10 = '0x4444444444444444444444444444444444444444'; +const SIGNATURE = `0x${'77'.repeat(65)}`; +const ZERO_DIGEST = `0x${'00'.repeat(32)}` as Digest32V1; +const KA_ID = ((BigInt(AUTHOR) << 96n) | 7n).toString(); +const UAL = `did:dkg:otp:20430/${AUTHOR}/7`; +const UTF8 = new TextEncoder(); +const PROJECTION = + ' "42"^^ .\n' + + ' "Alice" .\n'; +const ASSERTION_ROOT = + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f'; +const PROFILE = Object.freeze({ + networkId: 'otp:20430', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +}) as CatalogSealDeploymentProfileV1; + +const temporaryDirectories: string[] = []; +const foundations: Rfc64InventoryV1Foundation[] = []; + +afterEach(() => { + for (const foundation of foundations.splice(0)) foundation.close(); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('RFC-64 live candidate transfer admission boundary', () => { + it('replaces one live present row with exact transfer and cg-shared capabilities', async () => { + const fixture = await presentFixture(); + + const result = fixture.inventory.verifyCandidateCatalogPrecommitV1( + fixture.candidate.verifiedRow, + fixture.head, + fixture.bundleBytes, + PROFILE, + ); + + expect(Object.isFrozen(result)).toBe(true); + expect(Reflect.ownKeys(result)).toEqual(['transferredBundle', 'sharedProjection']); + expect(() => assertVerifiedTransferredCatalogBundleForInputsV1( + result.transferredBundle, + fixture.head, + fixture.row, + PROFILE, + )).not.toThrow(); + expect(() => assertVerifiedCgSharedProjectionForTransferV1( + result.sharedProjection, + result.transferredBundle, + fixture.head, + fixture.row, + PROFILE, + )).not.toThrow(); + + const transfer = readVerifiedTransferredCatalogBundleMetadataV1( + result.transferredBundle, + fixture.head, + fixture.row, + PROFILE, + ); + const seal = readVerifiedCatalogSealBindingV1(transfer.catalogSealBinding); + expect(seal).toMatchObject({ + authorAddress: AUTHOR, + kaId: KA_ID, + assertionCoordinate: 'name λ', + assertionVersion: '2', + seal: { kaUal: UAL, assertionMerkleRoot: ASSERTION_ROOT }, + }); + + const projection = readVerifiedCgSharedProjectionMetadataV1( + result.sharedProjection, + result.transferredBundle, + fixture.head, + fixture.row, + PROFILE, + ); + expect(projection).toMatchObject({ + projectionDigest: fixture.row.projectionDigest, + projectionByteLength: String(UTF8.encode(PROJECTION).byteLength), + assertionMerkleRoot: ASSERTION_ROOT, + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + kaUal: UAL, + }); + + // Successful replacement does not implicitly close the caller-owned page traversal. + expect(fixture.inventory.readVerifiedCandidateCatalogRow( + fixture.candidate.verifiedRow, + ).disposition).toBe('present'); + }); + + it('rejects plain views, structural lookalikes, and capabilities from another inventory', async () => { + const fixture = await presentFixture(); + const other = await readyInventory(); + const snapshot = fixture.inventory.readVerifiedCandidateCatalogRow( + fixture.candidate.verifiedRow, + ); + const lookalikes = [ + snapshot, + fixture.candidate.row, + Object.freeze(Object.create(null)), + JSON.parse(JSON.stringify(fixture.candidate.verifiedRow)), + ]; + + for (const lookalike of lookalikes) { + expectCandidateFailure( + () => fixture.inventory.verifyCandidateCatalogPrecommitV1( + lookalike as VerifiedCandidateCatalogRowV1, + fixture.head, + fixture.bundleBytes, + PROFILE, + ), + 'candidate-invalid-row-proof', + ); + } + expectCandidateFailure( + () => other.verifyCandidateCatalogPrecommitV1( + fixture.candidate.verifiedRow, + fixture.head, + fixture.bundleBytes, + PROFILE, + ), + 'candidate-invalid-row-proof', + ); + + // Tampering with the serializable page wrapper cannot change the privately retained proof. + const tampered = { + ...fixture.candidate, + disposition: 'removed', + row: { ...fixture.row, assertionCoordinate: 'attacker' }, + } as unknown as CandidateBucketRowV1; + expect(() => fixture.inventory.verifyCandidateCatalogPrecommitV1( + tampered.verifiedRow, + fixture.head, + fixture.bundleBytes, + PROFILE, + )).not.toThrow(); + }); + + it('rejects removed rows before inspecting untrusted bundle or deployment input', async () => { + const inventory = await readyInventory(); + const session = inventory.createCandidateSession(); + const transfer = transferFixture(); + const oldLoad = makeNonEmptyLoad(session, makeHead('1', '1'), [transfer.row]); + const newLoad = makeEmptyLoad(session, makeHead('0', '2')); + const oldStored = inventory.putVerifiedCandidateBucket(oldLoad); + const newStored = inventory.putVerifiedCandidateBucket(newLoad); + const traversal = inventory.beginCandidateBucketDiff(oldStored.loadKey, newStored.loadKey); + const removed = inventory.pageCandidateBucketRemoved(traversal, null, 1).rows[0]; + expect(removed.disposition).toBe('removed'); + + let deploymentRead = false; + const hostileDeployment = Object.create(null) as CatalogSealDeploymentProfileV1; + Object.defineProperty(hostileDeployment, 'networkId', { + get() { + deploymentRead = true; + throw new Error('deployment must not be inspected for a removed row'); + }, + }); + + expectCandidateFailure( + () => inventory.verifyCandidateCatalogPrecommitV1( + removed.verifiedRow, + oldLoad.head, + new Uint8Array([0xff]), + hostileDeployment, + ), + 'candidate-row-not-present', + ); + expect(deploymentRead).toBe(false); + }); + + it('rejects stale row capabilities and a different canonical head in the same scope', async () => { + const explicitlyClosed = await presentFixture(); + explicitlyClosed.inventory.closeCandidateTraversal(explicitlyClosed.traversal); + expectCandidateFailure( + () => explicitlyClosed.inventory.verifyCandidateCatalogPrecommitV1( + explicitlyClosed.candidate.verifiedRow, + explicitlyClosed.head, + explicitlyClosed.bundleBytes, + PROFILE, + ), + 'candidate-stale-row-proof', + ); + + const exhausted = await presentFixture(); + expect(exhausted.inventory.pageCandidateBucketRows( + exhausted.traversal, + exhausted.pageResumeAfter, + 1, + )).toEqual({ rows: [], resumeAfter: null }); + expectCandidateFailure( + () => exhausted.inventory.verifyCandidateCatalogPrecommitV1( + exhausted.candidate.verifiedRow, + exhausted.head, + exhausted.bundleBytes, + PROFILE, + ), + 'candidate-stale-row-proof', + ); + + const wrongHead = await presentFixture(); + const sameScopeDifferentHead = makeHead('1', '99'); + expect(deriveAuthorCatalogScopeFromHeadV1(sameScopeDifferentHead.payload)).toEqual( + deriveAuthorCatalogScopeFromHeadV1(wrongHead.head.payload), + ); + expect(sameScopeDifferentHead.objectDigest).not.toBe(wrongHead.head.objectDigest); + expectCandidateFailure( + () => wrongHead.inventory.verifyCandidateCatalogPrecommitV1( + wrongHead.candidate.verifiedRow, + sameScopeDifferentHead, + wrongHead.bundleBytes, + PROFILE, + ), + 'candidate-head-binding', + ); + }); + + it('cannot rebind a candidate through a switching signed-head Proxy', async () => { + const fixture = await presentFixture(); + const reboundHead = makeHead('1', '99'); + expect(deriveAuthorCatalogScopeFromHeadV1(reboundHead.payload)).toEqual( + deriveAuthorCatalogScopeFromHeadV1(fixture.head.payload), + ); + expect(reboundHead.objectDigest).not.toBe(fixture.head.objectDigest); + + // The old boundary consumed three digest reads while validating the head, + // then one candidate-binding read. It could therefore see the candidate head + // through that fourth read and expose another same-scope head to core verifiers. + let objectDigestReads = 0; + const switchingHead = new Proxy(fixture.head, { + get(_target, property) { + const source = objectDigestReads >= 4 ? reboundHead : fixture.head; + const value = Reflect.get(source, property); + if (property === 'objectDigest') objectDigestReads += 1; + return value; + }, + }) as SignedAuthorCatalogHeadEnvelopeV1; + + expectCandidateFailure( + () => fixture.inventory.verifyCandidateCatalogPrecommitV1( + fixture.candidate.verifiedRow, + switchingHead, + fixture.bundleBytes, + PROFILE, + ), + 'candidate-head-binding', + ); + expect(objectDigestReads).toBeGreaterThan(4); + }); + + it('snapshots each caller-controlled deployment scalar exactly once', async () => { + const fixture = await presentFixture(); + const scalarReads = new Map(); + const deployment = new Proxy(PROFILE, { + get(target, property, receiver) { + if ( + property === 'networkId' + || property === 'assertedAtChainId' + || property === 'assertedAtKav10Address' + ) { + const reads = (scalarReads.get(property) ?? 0) + 1; + scalarReads.set(property, reads); + if (reads > 1) throw new Error(`${String(property)} was reread`); + } + return Reflect.get(target, property, receiver); + }, + }) as CatalogSealDeploymentProfileV1; + + expect(() => fixture.inventory.verifyCandidateCatalogPrecommitV1( + fixture.candidate.verifiedRow, + fixture.head, + fixture.bundleBytes, + deployment, + )).not.toThrow(); + expect(Object.fromEntries(scalarReads)).toEqual({ + networkId: 1, + assertedAtChainId: 1, + assertedAtKav10Address: 1, + }); + }); + + it('fails closed if re-entrant input access invalidates the row during verification', async () => { + const fixture = await presentFixture(); + let closed = false; + const reentrantLimits = Object.defineProperties({}, { + maxProjectionBytes: { + enumerable: true, + get() { + if (!closed) { + closed = true; + fixture.inventory.closeCandidateTraversal(fixture.traversal); + } + return 64 * 1024 * 1024; + }, + }, + maxPublicTriples: { + enumerable: true, + get: () => 262_144, + }, + maxLineBytes: { + enumerable: true, + get: () => 64 * 1024 * 1024, + }, + }) as CgSharedProjectionVerificationLimitsV1; + + expectCandidateFailure( + () => fixture.inventory.verifyCandidateCatalogPrecommitV1( + fixture.candidate.verifiedRow, + fixture.head, + fixture.bundleBytes, + PROFILE, + reentrantLimits, + ), + 'candidate-binding-changed', + ); + expect(closed).toBe(true); + }); +}); + +interface TransferFixture { + readonly row: AuthorCatalogRowV1; + readonly bundleBytes: Uint8Array; +} + +interface PresentFixture extends TransferFixture { + readonly inventory: Rfc64InventoryV1Foundation; + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly traversal: ReturnType; + readonly candidate: CandidateBucketRowV1; + readonly pageResumeAfter: CandidateBucketRowV1['row']['kaId']; +} + +async function presentFixture(): Promise { + const inventory = await readyInventory(); + const session = inventory.createCandidateSession(); + const transfer = transferFixture(); + const load = makeNonEmptyLoad(session, makeHead('1', '1'), [transfer.row]); + const stored = inventory.putVerifiedCandidateBucket(load); + const traversal = inventory.beginCandidateBucketRows(stored.loadKey); + const page = inventory.pageCandidateBucketRows(traversal, null, 1); + const candidate = page.rows[0]; + if (candidate === undefined || page.resumeAfter === null) { + throw new Error('present transfer fixture did not produce one candidate row'); + } + return { + ...transfer, + inventory, + head: load.head, + traversal, + candidate, + pageResumeAfter: page.resumeAfter, + }; +} + +function transferFixture(): TransferFixture { + const seal = validSeal({ + assertionMerkleRoot: ASSERTION_ROOT, + authorAddress: AUTHOR, + authorAttestationR: `0x${'11'.repeat(32)}`, + authorAttestationVS: `0x${'22'.repeat(32)}`, + authorSchemeVersion: '1', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: UAL, + assertionVersion: '2', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + }); + const sealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(seal); + const encoded = encodeOpaqueKaBundleV1(UTF8.encode(PROJECTION), sealBytes); + const byteLength = BigInt(encoded.bundleBytes.byteLength); + const row = validRow({ + kaId: KA_ID, + assertionCoordinate: 'name λ', + assertionVersion: '2', + projectionId: 'cg-shared-v1', + projectionDigest: encoded.projectionDigest, + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), + transfer: { + codec: 'dkg-ka-bundle-v1', + projectionId: 'cg-shared-v1', + projectionDigest: encoded.projectionDigest, + byteLength: byteLength.toString(), + chunkSize: '262144', + chunkCount: (((byteLength - 1n) / 262_144n) + 1n).toString(), + blobDigest: encoded.blobDigest, + chunkTreeRoot: computeKaChunkTreeRootV1(encoded.bundleBytes), + }, + }); + return { row, bundleBytes: encoded.bundleBytes }; +} + +async function readyInventory(): Promise { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-transfer-'))); + temporaryDirectories.push(directory); + const inventory = await openInventoryV1(directory); + foundations.push(inventory); + expect(inventory.purgeNextStartupStaleCandidateBatch()).toEqual({ + deletedLoads: 0, + done: true, + }); + return inventory; +} + +function makeHead( + totalRows: CountV1 | string, + version: DecimalU64V1 | string, +): SignedAuthorCatalogHeadEnvelopeV1 { + const payload = { + networkId: 'otp:20430', + contextGraphId: 'a/b', + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + catalogIssuerDelegationDigest: `0x${'66'.repeat(32)}`, + era: '0', + version, + previousHeadDigest: null, + bucketCount: '1', + totalRows, + directoryHeight: '0', + directoryRootDigest: `0x${String(version).padStart(2, '0').slice(-2).repeat(32)}`, + issuedAt: String(1_700_000_000_000n + BigInt(version)), + } as AuthorCatalogHeadV1; + return signHead(payload); +} + +function signHead(payload: AuthorCatalogHeadV1): SignedAuthorCatalogHeadEnvelopeV1 { + const unsigned = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; + const signed = { + ...unsigned, + objectDigest: computeAuthorCatalogHeadObjectDigestV1(unsigned), + signature: SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogHeadEnvelopeV1(signed); + return signed as SignedAuthorCatalogHeadEnvelopeV1; +} + +function makeNonEmptyLoad( + session: CandidateSessionV1, + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, + rows: readonly AuthorCatalogRowV1[], +): VerifiedCandidateBucketLoadV1 { + const scope = deriveAuthorCatalogScopeFromHeadV1(headTemplate.payload); + const payload = { + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + era: scope.era, + bucketCount: scope.bucketCount, + bucketId: '0', + rows, + } as AuthorCatalogBucketV1; + const unsignedBucket = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; + const signedBucket = { + ...unsignedBucket, + objectDigest: computeAuthorCatalogBucketObjectDigestV1(unsignedBucket), + signature: SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogBucketEnvelopeV1(signedBucket); + const descriptor = { + bucketId: '0', + rowCount: String(rows.length), + byteLength: String(canonicalizeAuthorCatalogBucketPayloadBytesV1(payload).byteLength), + bucketDigest: signedBucket.objectDigest, + } as AuthorCatalogBucketDescriptorV1; + const bound = bindHeadToDescriptor(headTemplate, descriptor); + return { + session, + head: bound.head, + directoryPath: bound.directoryPath, + bucket: signedBucket as SignedAuthorCatalogBucketEnvelopeV1, + }; +} + +function makeEmptyLoad( + session: CandidateSessionV1, + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, +): VerifiedCandidateBucketLoadV1 { + const bound = bindHeadToDescriptor(headTemplate, { + bucketId: '0' as DecimalU64V1, + rowCount: '0' as CountV1, + byteLength: '0' as ByteLengthV1, + bucketDigest: ZERO_DIGEST, + }); + return { + session, + head: bound.head, + directoryPath: bound.directoryPath, + bucket: null, + }; +} + +function bindHeadToDescriptor( + headTemplate: SignedAuthorCatalogHeadEnvelopeV1, + descriptor: AuthorCatalogBucketDescriptorV1, +): { + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly directoryPath: VerifiedAuthorCatalogDirectoryPathV1; +} { + const scope = deriveAuthorCatalogScopeFromHeadV1(headTemplate.payload); + const directoryPayload = { + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + entries: [descriptor], + era: scope.era, + firstBucketId: '0' as DecimalU64V1, + level: '0' as DecimalU64V1, + } as unknown as AuthorCatalogDirectoryNodeV1; + const unsignedDirectory = { + issuer: ISSUER, + objectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + payload: directoryPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1; + const signedDirectory = { + ...unsignedDirectory, + objectDigest: computeAuthorCatalogDirectoryNodeObjectDigestV1( + unsignedDirectory, + '1' as DecimalU64V1, + ), + signature: SIGNATURE, + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1( + signedDirectory, + '1' as DecimalU64V1, + ); + const directory = signedDirectory as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + const head = signHead({ + ...(headTemplate.payload as AuthorCatalogHeadV1), + directoryRootDigest: directory.objectDigest as Digest32V1, + }); + return { + head, + directoryPath: verifyAuthorCatalogDirectoryPathV1( + head, + [directory], + '0' as DecimalU64V1, + ), + }; +} + +function validRow(value: unknown): AuthorCatalogRowV1 { + assertAuthorCatalogRowV1(value); + return value; +} + +function validSeal(value: unknown): CanonicalGraphScopedAuthorSealV1 { + assertCanonicalGraphScopedAuthorSealV1(value); + return value; +} + +function expectCandidateFailure(operation: () => unknown, code: string): void { + try { + operation(); + } catch (error) { + expect(error).toMatchObject({ code }); + return; + } + throw new Error(`expected ${code}`); +} diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index ba4a3741c6..37291bede8 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -111,6 +111,7 @@ export default defineConfig({ "test/rfc64-inventory-v1-candidate-plans.test.ts", "test/rfc64-inventory-v1-candidate-latency.test.ts", "test/rfc64-inventory-v1-candidate-faults.test.ts", + "test/rfc64-candidate-transfer-admission.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From f18e9b2c9142e828c5af1587f33986b3b51df242 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 11:24:43 +0200 Subject: [PATCH 061/292] feat(agent): own RFC-64 inventory lifecycle --- packages/agent/src/dkg-agent-base.ts | 53 +++ packages/agent/src/dkg-agent-lifecycle.ts | 40 ++- packages/agent/src/dkg-agent.ts | 14 + .../rfc64-agent-inventory-lifecycle-child.ts | 33 ++ .../rfc64-agent-inventory-lifecycle.test.ts | 314 ++++++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 6 files changed, 443 insertions(+), 12 deletions(-) create mode 100644 packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts create mode 100644 packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index 5dbc94d34d..f4016d0473 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -11,6 +11,10 @@ */ import { createHash, randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; +import { + openInventoryV1, + type Rfc64InventoryV1Foundation, +} from './rfc64/inventory-v1/index.js'; import { resolveVmReconcileStartupMaxDelayMs } from './startup-jitter.js'; import { DKGNode, ProtocolRouter, GossipSubManager, TypedEventBus, DKGEvent, @@ -983,6 +987,12 @@ export class DKGAgentBase { protected profileProvisioningInFlight = false; protected readonly config: ResolvedDKGAgentConfig; protected started = false; + /** + * OT-RFC-64 durable inventory ownership. Persistent agents hold exactly one + * production foundation for their data directory; agents without dataDir + * are deliberately dormant and never substitute an in-memory inventory. + */ + protected rfc64InventoryV1?: Rfc64InventoryV1Foundation; protected readonly subscribedContextGraphs = new Map(); protected contextGraphSubscriptionRehydrationStatus: ContextGraphSubscriptionRehydrationStatus | null = null; protected readonly contextGraphSubscriptionRehydrationAccountedIds = new Set(); @@ -1554,6 +1564,49 @@ export class DKGAgentBase { this.changelogCursors = config.changelogCursorStore ?? this.changelogCursors; } + /** + * Acquire the RFC-64 inventory and finish bounded stale-candidate cleanup + * before any network consumer can observe or mutate inventory state. + */ + protected async prepareRfc64InventoryV1(): Promise { + if (!this.config.dataDir || this.rfc64InventoryV1 !== undefined) return; + + const inventory = await openInventoryV1(this.config.dataDir); + try { + for (;;) { + const batch = inventory.purgeNextStartupStaleCandidateBatch(); + if (batch.done) break; + await this.yieldRfc64InventoryV1StartupBatch(); + } + } catch (cause) { + try { + inventory.close(); + } catch (closeCause) { + throw new AggregateError( + [cause, closeCause], + 'RFC-64 inventory startup purge and cleanup both failed', + ); + } + throw cause; + } + this.rfc64InventoryV1 = inventory; + } + + /** Yield between fixed-size adapter batches so startup cannot monopolize the event loop. */ + protected async yieldRfc64InventoryV1StartupBatch(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + } + + /** + * Relinquish the single inventory foundation. Clear the local reference + * before closing so a fail-stop close error cannot be retried accidentally. + */ + protected closeRfc64InventoryV1(): void { + const inventory = this.rfc64InventoryV1; + this.rfc64InventoryV1 = undefined; + inventory?.close(); + } + /** * RpcUsageDrainable: drain the chain adapter's raw JSON-RPC usage window * (delta since the previous drain — the provider-billing unit). The diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 373dc3e0b8..9523ec397e 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -932,19 +932,35 @@ export class LifecycleSyncMethods extends DKGAgentBase { const ctx = createOperationContext('connect'); this.log.info(ctx, `Starting DKG node`); - // One-shot resident-poison sweep (OT-RFC-56 §4.4) — BEFORE networking, so - // the local store is clean before this node serves or syncs anything. - // Marker-gated (runs once per data dir), never throws, no-op on stores - // that never accepted oversized literals (Blazegraph). - await runOversizeSweep({ - store: this.store, - dataDir: this.config.dataDir, - recordDrops: (drops, seam) => this.oversizeTombstoneLog.record(drops, seam), - logInfo: (message) => this.log.info(ctx, message), - logWarn: (message) => this.log.warn(ctx, message), - }); + // OT-RFC-64: persistent inventory ownership and the complete bounded + // startup purge precede node.start(), protocol registration, and every + // network consumer. No dataDir intentionally leaves the feature dormant. + await this.prepareRfc64InventoryV1(); + try { + // One-shot resident-poison sweep (OT-RFC-56 §4.4) — BEFORE networking, + // so the local store is clean before this node serves or syncs anything. + // Marker-gated (runs once per data dir), never throws, no-op on stores + // that never accepted oversized literals (Blazegraph). + await runOversizeSweep({ + store: this.store, + dataDir: this.config.dataDir, + recordDrops: (drops, seam) => this.oversizeTombstoneLog.record(drops, seam), + logInfo: (message) => this.log.info(ctx, message), + logWarn: (message) => this.log.warn(ctx, message), + }); - await this.node.start(); + await this.node.start(); + } catch (cause) { + try { + this.closeRfc64InventoryV1(); + } catch (closeCause) { + throw new AggregateError( + [cause, closeCause], + 'DKG node startup and RFC-64 inventory cleanup both failed', + ); + } + throw cause; + } this.started = true; this.log.info(ctx, `Node started, peer ID: ${this.node.peerId.toString()}`); diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index b97fb96f53..607de464c0 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1713,6 +1713,19 @@ export class DKGAgent extends DKGAgentBase { await this.syncVerifyWorker.close(); this.syncVerifyWorker = undefined; } + // OT-RFC-64 inventory consumers are now stopped. Release the exclusive + // inventory foundation before the triple store closes, but finish the + // remaining teardown even when close enters its deliberate fail-stop + // state. The original close failure is re-thrown after teardown so the + // operator receives a failed shutdown rather than a false success. + let inventoryCloseFailed = false; + let inventoryCloseFailure: unknown; + try { + this.closeRfc64InventoryV1(); + } catch (error) { + inventoryCloseFailed = true; + inventoryCloseFailure = error; + } // Flush WM to disk before exit so the debounced 50ms flush in the // Oxigraph adapter can't lose the latest inserts when the process // exits. See docs/bugs/wm-persistence-regression.md. @@ -1734,6 +1747,7 @@ export class DKGAgent extends DKGAgentBase { ); } this.started = false; + if (inventoryCloseFailed) throw inventoryCloseFailure; } /** diff --git a/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts b/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts new file mode 100644 index 0000000000..cfe4b5d0dc --- /dev/null +++ b/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts @@ -0,0 +1,33 @@ +import { DKGAgent } from '../../src/dkg-agent.js'; + +const dataDirectory = process.env.DKG_RFC64_AGENT_INVENTORY_DATA_DIR; +if (!dataDirectory) { + throw new Error('missing DKG_RFC64_AGENT_INVENTORY_DATA_DIR'); +} + +// Exercise the production DKGAgent-owned lifecycle without starting libp2p. +// The parent uses SIGTERM to invoke the real close path and SIGKILL to prove +// that the operating-system lease is recoverable without JavaScript cleanup. +const agent = Object.create(DKGAgent.prototype) as any; +Object.assign(agent, { + config: { dataDir: dataDirectory }, + rfc64InventoryV1: undefined, +}); + +await agent.prepareRfc64InventoryV1(); + +let terminating = false; +process.once('SIGTERM', () => { + if (terminating) return; + terminating = true; + try { + agent.closeRfc64InventoryV1(); + process.stdout.write('CLOSED\n', () => process.exit(0)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exit(1); + } +}); + +process.stdout.write('READY\n'); +setInterval(() => undefined, 60_000); diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts new file mode 100644 index 0000000000..9470844882 --- /dev/null +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -0,0 +1,314 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DKGAgent } from '../src/dkg-agent.js'; +import { + INVENTORY_V1_RELATIVE_PATH, + openInventoryV1, +} from '../src/rfc64/inventory-v1/index.js'; + +const temporaryDirectories: string[] = []; +const childProcesses = new Set(); +const CHILD_FIXTURE = resolve( + import.meta.dirname, + 'fixtures/rfc64-agent-inventory-lifecycle-child.ts', +); + +function temporaryDataDirectory(): string { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-agent-'))); + temporaryDirectories.push(directory); + return directory; +} + +function syntheticAgent(dataDirectory?: string): any { + const agent = Object.create(DKGAgent.prototype) as any; + Object.assign(agent, { + config: dataDirectory === undefined ? {} : { dataDir: dataDirectory }, + rfc64InventoryV1: undefined, + }); + return agent; +} + +function u64be(value: bigint): Buffer { + const encoded = Buffer.alloc(8); + encoded.writeBigUInt64BE(value); + return encoded; +} + +async function seedStaleCandidateLoads(dataDirectory: string, count: number): Promise { + const foundation = await openInventoryV1(dataDirectory); + foundation.close(); + + const database = new DatabaseSync(join(dataDirectory, INVENTORY_V1_RELATIVE_PATH)); + try { + const insert = database.prepare(` + INSERT INTO rfc64_candidate_bucket_loads_v1 ( + session_id, catalog_scope_digest, author_address, + target_catalog_head_digest, subgraph_name, catalog_era_u64be, + bucket_count_u64be, bucket_id_u64be, bucket_object_digest, + row_count_u64be, payload_byte_length_u64be + ) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?) + `); + for (let index = 0; index < count; index += 1) { + const session = Buffer.alloc(32); + session.writeUInt32BE(index + 1, 28); + insert.run( + session, + Buffer.alloc(32, 0x22), + Buffer.alloc(20, 0x33), + Buffer.alloc(32, 0x44), + u64be(0n), + u64be(1n), + u64be(0n), + Buffer.alloc(32), + u64be(0n), + u64be(0n), + ); + } + } finally { + database.close(); + } +} + +function candidateLoadCount(dataDirectory: string): number { + const database = new DatabaseSync(join(dataDirectory, INVENTORY_V1_RELATIVE_PATH)); + try { + const row = database.prepare( + 'SELECT count(*) AS count FROM rfc64_candidate_bucket_loads_v1', + ).get() as { count: number }; + return row.count; + } finally { + database.close(); + } +} + +function minimalStartedAgent( + order: string[], + inventoryClose: () => void, +): any { + const agent = syntheticAgent(); + Object.assign(agent, { + started: true, + chainPoller: null, + coreHostRecordingsClosed: false, + drainCoreHostRecordings: vi.fn(async () => {}), + messenger: { stopOutboxDrain: vi.fn(async () => {}) }, + clearRandomSamplingBindRetry: vi.fn(), + clearStorageACKRegistrationRetry: vi.fn(), + storageACKRegistrationRetryInFlight: false, + randomSamplingHandle: null, + inFlightSubstrateFanOutCount: () => 0, + router: { closePooling: vi.fn(async () => {}) }, + node: { stop: vi.fn(async () => { order.push('node'); }) }, + syncVerifyWorker: { close: vi.fn(async () => { order.push('sync-worker'); }) }, + rfc64InventoryV1: { close: inventoryClose }, + store: { close: vi.fn(async () => { order.push('store'); }) }, + log: { warn: vi.fn() }, + }); + return agent; +} + +function spawnLifecycleHolder(dataDirectory: string): ChildProcessWithoutNullStreams { + const child = spawn( + process.execPath, + ['--experimental-sqlite', '--import', 'tsx', CHILD_FIXTURE], + { + cwd: resolve(import.meta.dirname, '../../..'), + env: { + ...process.env, + NODE_ENV: 'test', + DKG_RFC64_AGENT_INVENTORY_DATA_DIR: dataDirectory, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + childProcesses.add(child); + child.once('exit', () => childProcesses.delete(child)); + return child; +} + +async function waitForReady(child: ChildProcessWithoutNullStreams): Promise { + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + await new Promise((resolveReady, rejectReady) => { + const timeout = setTimeout(() => { + rejectReady(new Error(`agent inventory holder did not become ready: ${stderr}`)); + }, 20_000); + const onData = (): void => { + if (!stdout.includes('READY\n')) return; + clearTimeout(timeout); + resolveReady(); + }; + child.stdout.on('data', onData); + child.once('error', (error) => { + clearTimeout(timeout); + rejectReady(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + rejectReady(new Error( + `agent inventory holder exited before ready: code=${code} signal=${signal} stderr=${stderr}`, + )); + }); + }); +} + +async function terminate( + child: ChildProcessWithoutNullStreams, + signal: 'SIGTERM' | 'SIGKILL', +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveExit) => child.once('exit', (code, exitSignal) => resolveExit({ + code, + signal: exitSignal, + })), + ); + child.kill(signal); + return await exit; +} + +afterEach(async () => { + await Promise.all([...childProcesses].map(async (child) => { + if (child.exitCode === null && child.signalCode === null) { + await terminate(child, 'SIGKILL'); + } + })); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('DKGAgent RFC-64 inventory lifecycle', () => { + it('stays dormant without dataDir and performs no in-memory fallback', async () => { + const agent = syntheticAgent(); + + await agent.prepareRfc64InventoryV1(); + await agent.prepareRfc64InventoryV1(); + + expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); + expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); + }); + + it('owns one persistent foundation and purges every stale candidate in bounded yielding batches', async () => { + const dataDirectory = temporaryDataDirectory(); + await seedStaleCandidateLoads(dataDirectory, 17); + const agent = syntheticAgent(dataDirectory); + const yieldBatch = vi.fn(async () => {}); + agent.yieldRfc64InventoryV1StartupBatch = yieldBatch; + + await agent.prepareRfc64InventoryV1(); + const ownedFoundation = agent.rfc64InventoryV1; + await agent.prepareRfc64InventoryV1(); + + expect(agent.rfc64InventoryV1).toBe(ownedFoundation); + expect(ownedFoundation.databasePath).toBe( + join(dataDirectory, INVENTORY_V1_RELATIVE_PATH), + ); + expect(yieldBatch).toHaveBeenCalledTimes(3); + expect(() => ownedFoundation.createCandidateSession()).not.toThrow(); + + agent.closeRfc64InventoryV1(); + expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(candidateLoadCount(dataDirectory)).toBe(0); + expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); + }); + + it('fails startup before node.start when inventory acquisition or recovery fails', async () => { + const failure = new Error('inventory unavailable'); + const nodeStart = vi.fn(async () => {}); + const agent = syntheticAgent(); + Object.assign(agent, { + started: false, + coreHostRecordingGeneration: 0, + prepareRfc64InventoryV1: vi.fn(async () => { throw failure; }), + node: { start: nodeStart }, + log: { info: vi.fn(), warn: vi.fn() }, + }); + + await expect(agent.start()).rejects.toBe(failure); + expect(nodeStart).not.toHaveBeenCalled(); + expect(agent.started).toBe(false); + }); + + it('releases an acquired inventory when node startup fails', async () => { + const failure = new Error('node start failed'); + const close = vi.fn(); + const agent = syntheticAgent(); + Object.assign(agent, { + started: false, + coreHostRecordingGeneration: 0, + prepareRfc64InventoryV1: vi.fn(async () => { + agent.rfc64InventoryV1 = { close }; + }), + node: { start: vi.fn(async () => { throw failure; }) }, + log: { info: vi.fn(), warn: vi.fn() }, + }); + + await expect(agent.start()).rejects.toBe(failure); + expect(close).toHaveBeenCalledOnce(); + expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.started).toBe(false); + }); + + it('closes after network consumers and before the triple store, with idempotent stop', async () => { + const order: string[] = []; + const inventoryClose = vi.fn(() => { order.push('inventory'); }); + const agent = minimalStartedAgent(order, inventoryClose); + + await agent.stop(); + await agent.stop(); + + expect(order).toEqual(['node', 'sync-worker', 'inventory', 'store']); + expect(inventoryClose).toHaveBeenCalledOnce(); + expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.started).toBe(false); + }); + + it('finishes store teardown but rejects shutdown when inventory close fails', async () => { + const order: string[] = []; + const failure = new Error('inventory close failed'); + const agent = minimalStartedAgent(order, () => { + order.push('inventory'); + throw failure; + }); + + await expect(agent.stop()).rejects.toBe(failure); + + expect(order).toEqual(['node', 'sync-worker', 'inventory', 'store']); + expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.started).toBe(false); + await expect(agent.stop()).resolves.toBeUndefined(); + }); + + it('restarts after graceful SIGTERM inventory release', async () => { + const dataDirectory = temporaryDataDirectory(); + const first = spawnLifecycleHolder(dataDirectory); + await waitForReady(first); + expect(await terminate(first, 'SIGTERM')).toEqual({ code: 0, signal: null }); + + const restarted = spawnLifecycleHolder(dataDirectory); + await waitForReady(restarted); + expect(await terminate(restarted, 'SIGTERM')).toEqual({ code: 0, signal: null }); + }); + + it('recovers the operating-system lease after SIGKILL without cleanup', async () => { + const dataDirectory = temporaryDataDirectory(); + const first = spawnLifecycleHolder(dataDirectory); + await waitForReady(first); + expect(await terminate(first, 'SIGKILL')).toEqual({ code: null, signal: 'SIGKILL' }); + + const restarted = spawnLifecycleHolder(dataDirectory); + await waitForReady(restarted); + expect(await terminate(restarted, 'SIGTERM')).toEqual({ code: 0, signal: null }); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index e1983e8a8f..f753fa4e4e 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -113,6 +113,7 @@ export default defineConfig({ "test/rfc64-inventory-v1-candidate-faults.test.ts", "test/rfc64-candidate-transfer-admission.test.ts", "test/rfc64-catalog-row-authorship.test.ts", + "test/rfc64-agent-inventory-lifecycle.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 7153e74ce94a1217bb1e0c8931ffa98a158da73b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 11:30:07 +0200 Subject: [PATCH 062/292] test(agent): cover lifecycle on Windows --- .github/workflows/rfc64-inventory-windows.yml | 3 ++ .../rfc64-agent-inventory-lifecycle.test.ts | 46 +++++++++++-------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index cda662bfec..5404da6c9b 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -6,7 +6,9 @@ on: - '.github/workflows/rfc64-inventory-windows.yml' - 'packages/agent/src/rfc64/inventory-v1/**' - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' + - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' + - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' - 'packages/agent/vitest.unit.config.ts' - 'packages/agent/package.json' - 'packages/core/**' @@ -59,3 +61,4 @@ jobs: pnpm --filter @origintrail-official/dkg-agent exec vitest run --config vitest.unit.config.ts test/rfc64-inventory-v1 + test/rfc64-agent-inventory-lifecycle.test.ts diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index 9470844882..887ae92ab1 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -290,25 +290,31 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await expect(agent.stop()).resolves.toBeUndefined(); }); - it('restarts after graceful SIGTERM inventory release', async () => { - const dataDirectory = temporaryDataDirectory(); - const first = spawnLifecycleHolder(dataDirectory); - await waitForReady(first); - expect(await terminate(first, 'SIGTERM')).toEqual({ code: 0, signal: null }); - - const restarted = spawnLifecycleHolder(dataDirectory); - await waitForReady(restarted); - expect(await terminate(restarted, 'SIGTERM')).toEqual({ code: 0, signal: null }); - }); - - it('recovers the operating-system lease after SIGKILL without cleanup', async () => { - const dataDirectory = temporaryDataDirectory(); - const first = spawnLifecycleHolder(dataDirectory); - await waitForReady(first); - expect(await terminate(first, 'SIGKILL')).toEqual({ code: null, signal: 'SIGKILL' }); + it.runIf(process.platform !== 'win32')( + 'restarts after graceful SIGTERM inventory release', + async () => { + const dataDirectory = temporaryDataDirectory(); + const first = spawnLifecycleHolder(dataDirectory); + await waitForReady(first); + expect(await terminate(first, 'SIGTERM')).toEqual({ code: 0, signal: null }); + + const restarted = spawnLifecycleHolder(dataDirectory); + await waitForReady(restarted); + expect(await terminate(restarted, 'SIGTERM')).toEqual({ code: 0, signal: null }); + }, + ); - const restarted = spawnLifecycleHolder(dataDirectory); - await waitForReady(restarted); - expect(await terminate(restarted, 'SIGTERM')).toEqual({ code: 0, signal: null }); - }); + it.runIf(process.platform !== 'win32')( + 'recovers the operating-system lease after SIGKILL without cleanup', + async () => { + const dataDirectory = temporaryDataDirectory(); + const first = spawnLifecycleHolder(dataDirectory); + await waitForReady(first); + expect(await terminate(first, 'SIGKILL')).toEqual({ code: null, signal: 'SIGKILL' }); + + const restarted = spawnLifecycleHolder(dataDirectory); + await waitForReady(restarted); + expect(await terminate(restarted, 'SIGTERM')).toEqual({ code: 0, signal: null }); + }, + ); }); From cc61c483088f0cce42b223de38232dac1a216cfa Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 11:44:38 +0200 Subject: [PATCH 063/292] refactor(core): make assertExactKeys sort both key sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertExactKeys sorted the record's actual keys but compared them against an unsorted `expected`, so it only worked when every call site pre-sorted its expected list — a brittle hidden precondition. Sort a copy of `expected` too so it is a true exact-key validator. Behavior is unchanged for the existing (already-sorted) call sites; full core suite green. Addresses otReviewAgent 🟡 (sync-control-object.ts:501 — hidden sorted-key precondition in assertExactKeys). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/sync-control-object.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index 89a6966723..ef61ea02bc 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -496,9 +496,12 @@ function assertExactKeys( throw new Error(`${label} must not contain symbol properties`); } const strings = actual as string[]; + // Sort both sides so the helper is a true exact-key validator and does not + // silently depend on each call site pre-sorting `expected`. + const sortedExpected = [...expected].sort(); if ( strings.length !== expected.length - || [...strings].sort().some((key, index) => key !== expected[index]) + || [...strings].sort().some((key, index) => key !== sortedExpected[index]) ) { throw new Error(`${label} has unknown or missing fields`); } From 2684c9eb3b823cb7ac66cec72cbcca5699ee37a5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 12:00:04 +0200 Subject: [PATCH 064/292] feat(agent): produce sparse RFC-64 author catalogs --- .github/workflows/rfc64-inventory-windows.yml | 3 + packages/agent/src/index.ts | 1 + .../src/rfc64/author-catalog-producer.ts | 912 ++++++++++++++++++ .../rfc64-author-catalog-producer.test.ts | 795 +++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 5 files changed, 1712 insertions(+) create mode 100644 packages/agent/src/rfc64/author-catalog-producer.ts create mode 100644 packages/agent/test/rfc64-author-catalog-producer.test.ts diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 5404da6c9b..ee6fcc4842 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -5,8 +5,10 @@ on: paths: - '.github/workflows/rfc64-inventory-windows.yml' - 'packages/agent/src/rfc64/inventory-v1/**' + - 'packages/agent/src/rfc64/author-catalog-producer.ts' - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' + - 'packages/agent/test/rfc64-author-catalog-producer.test.ts' - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' - 'packages/agent/vitest.unit.config.ts' @@ -62,3 +64,4 @@ jobs: --config vitest.unit.config.ts test/rfc64-inventory-v1 test/rfc64-agent-inventory-lifecycle.test.ts + test/rfc64-author-catalog-producer.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index f6f3ce8336..3edd885080 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -35,6 +35,7 @@ export { type VerifyAgentDelegationOptions, } from './auth/agent-delegation.js'; export * from './rfc64/catalog-row-authorship.js'; +export * from './rfc64/author-catalog-producer.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/author-catalog-producer.ts b/packages/agent/src/rfc64/author-catalog-producer.ts new file mode 100644 index 0000000000..2eef61e1dd --- /dev/null +++ b/packages/agent/src/rfc64/author-catalog-producer.ts @@ -0,0 +1,912 @@ +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_FANOUT_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + MAX_DECIMAL_U64, + ZERO_DIGEST32_V1, + assertAuthorCatalogBucketScopeBindingV1, + assertAuthorCatalogHeadScopeBindingV1, + assertAuthorCatalogScopeV1, + assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + canonicalizeAuthorCatalogBucketPayloadBytesV1, + canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1, + canonicalizeAuthorCatalogRowV1, + canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1, + canonicalizeSignedAuthorCatalogDirectoryNodeEnvelopeBytesV1, + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, + computeAuthorCatalogBucketObjectDigestV1, + computeAuthorCatalogDirectoryNodeObjectDigestV1, + computeAuthorCatalogHeadObjectDigestV1, + computeAuthorCatalogScopeDigestV1, + deriveAuthorCatalogScopeFromHeadV1, + parseCanonicalAuthorCatalogBucketPayloadV1, + parseCanonicalAuthorCatalogRowV1, + parseCanonicalSignedAuthorCatalogBucketEnvelopeV1, + parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1, + parseCanonicalSignedAuthorCatalogHeadEnvelopeV1, + readVerifiedAuthorCatalogBucketDescriptorV1, + verifyAuthorCatalogDirectoryPathV1, + type AuthorCatalogBucketDescriptorV1, + type AuthorCatalogBucketV1, + type AuthorCatalogChildDescriptorV1, + type AuthorCatalogDirectoryEntryV1, + type AuthorCatalogDirectoryNodeV1, + type AuthorCatalogHeadV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedControlEnvelopeV1, + type TimestampMsV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; + +export const RFC64_AUTHOR_CATALOG_PRODUCTION_ERROR_CODES_V1 = Object.freeze([ + 'catalog-production-input', + 'catalog-production-history', + 'catalog-production-path', + 'catalog-production-delta', + 'catalog-production-noop', + 'catalog-production-signer', +] as const); + +export type Rfc64AuthorCatalogProductionErrorCodeV1 = + (typeof RFC64_AUTHOR_CATALOG_PRODUCTION_ERROR_CODES_V1)[number]; + +export class Rfc64AuthorCatalogProductionErrorV1 extends Error { + constructor( + readonly code: Rfc64AuthorCatalogProductionErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + if (!RFC64_AUTHOR_CATALOG_PRODUCTION_ERROR_CODES_V1.includes(code)) { + throw new TypeError(`Unsupported RFC-64 author catalog production error code: ${code}`); + } + this.name = 'Rfc64AuthorCatalogProductionErrorV1'; + } +} + +/** + * Local EOA signer used for catalog production. The callback signs the raw + * 32-byte control-object digest with EIP-191 personal-sign framing. + */ +export interface Rfc64AuthorCatalogEip191SignerV1 { + readonly issuer: EvmAddressV1; + readonly signDigest: (objectDigest: Uint8Array) => Promise; +} + +export interface ProduceEmptyAuthorCatalogGenesisInputV1 { + readonly scope: AuthorCatalogScopeV1; + readonly catalogIssuerDelegationDigest: Digest32V1; + readonly issuedAt: TimestampMsV1; + readonly signer: Rfc64AuthorCatalogEip191SignerV1; +} + +export interface ProduceSparseAuthorCatalogSuccessorInputV1 { + readonly previousHead: SignedAuthorCatalogHeadEnvelopeV1; + readonly previousDirectoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; + /** Exact prior bucket object, or null when the selected descriptor is empty. */ + readonly previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null; + readonly selectedBucketId: DecimalU64V1; + /** Complete replacement live set for the selected bucket. */ + readonly nextRows: readonly AuthorCatalogRowV1[]; + readonly issuedAt: TimestampMsV1; + readonly signer: Rfc64AuthorCatalogEip191SignerV1; +} + +export interface ProducedAuthorCatalogPublicationV1 { + /** Signed candidate only; this grants no durable-staging or semantic-ref authority. */ + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + /** Root-to-leaf path under {@link head}. */ + readonly directoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; + /** Null denotes the selected bucket's canonical empty descriptor. */ + readonly bucket: SignedAuthorCatalogBucketEnvelopeV1 | null; + /** Immutable objects in required staging order: bucket, leaf-to-root path, head. */ + readonly stagedObjects: readonly SignedControlEnvelopeV1[]; +} + +interface SnapshotEip191SignerV1 { + readonly issuer: EvmAddressV1; + readonly signDigest: (objectDigest: Uint8Array) => Promise; +} + +/** Produce the canonical scoped empty catalog used to bootstrap later sparse writes. */ +export async function produceEmptyAuthorCatalogGenesisV1( + input: ProduceEmptyAuthorCatalogGenesisInputV1, +): Promise { + try { + return await produceEmptyAuthorCatalogGenesisUncheckedV1(input); + } catch (cause) { + normalizePublicError('empty author catalog genesis production failed', cause); + } +} + +async function produceEmptyAuthorCatalogGenesisUncheckedV1( + input: ProduceEmptyAuthorCatalogGenesisInputV1, +): Promise { + const scopeInput = input.scope; + const delegationDigest = input.catalogIssuerDelegationDigest; + const issuedAt = input.issuedAt; + const signer = snapshotSigner(input.signer); + + const scope = snapshotScope(scopeInput); + if (scope.era !== '0' || scope.bucketCount !== '1') { + fail( + 'catalog-production-history', + 'empty genesis requires era=0 and bucketCount=1', + ); + } + + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const rootPayload: AuthorCatalogDirectoryNodeV1 = { + catalogScopeDigest, + entries: [{ + bucketDigest: ZERO_DIGEST32_V1, + bucketId: '0' as DecimalU64V1, + byteLength: '0' as DecimalU64V1, + rowCount: '0' as DecimalU64V1, + }], + era: '0' as DecimalU64V1, + firstBucketId: '0' as DecimalU64V1, + level: '0' as DecimalU64V1, + }; + const root = await signDirectoryNode(rootPayload, scope.bucketCount, signer); + const headPayload: AuthorCatalogHeadV1 = { + networkId: scope.networkId, + contextGraphId: scope.contextGraphId, + governanceChainId: scope.governanceChainId, + governanceContractAddress: scope.governanceContractAddress, + ownershipTransitionDigest: scope.ownershipTransitionDigest, + subGraphName: scope.subGraphName, + authorAddress: scope.authorAddress, + catalogIssuerDelegationDigest: delegationDigest, + era: '0' as DecimalU64V1, + version: '0' as DecimalU64V1, + previousHeadDigest: null, + bucketCount: '1' as DecimalU64V1, + totalRows: '0' as DecimalU64V1, + directoryHeight: '0' as DecimalU64V1, + directoryRootDigest: root.objectDigest as Digest32V1, + issuedAt, + }; + const head = await signHead(headPayload, signer); + + try { + assertAuthorCatalogHeadScopeBindingV1(head.payload, scope); + const verifiedPath = verifyAuthorCatalogDirectoryPathV1( + head, + [root], + '0' as DecimalU64V1, + ); + const descriptor = readVerifiedAuthorCatalogBucketDescriptorV1(verifiedPath, head); + if (descriptor.bucketDigest !== ZERO_DIGEST32_V1 || descriptor.rowCount !== '0') { + throw new Error('empty genesis selected a non-empty descriptor'); + } + } catch (cause) { + fail('catalog-production-path', 'produced empty genesis is not self-consistent', cause); + } + + return freezePublication(head, [root], null, [root, head]); +} + +/** + * Change exactly one KA row, replace its complete bucket, and rebuild only the + * leaf-to-root path. + * This is the ordinary same-era update path; capacity/key/ownership transitions + * deliberately require a different producer. + */ +export async function produceSparseAuthorCatalogSuccessorV1( + input: ProduceSparseAuthorCatalogSuccessorInputV1, +): Promise { + try { + return await produceSparseAuthorCatalogSuccessorUncheckedV1(input); + } catch (cause) { + normalizePublicError('sparse author catalog successor production failed', cause); + } +} + +async function produceSparseAuthorCatalogSuccessorUncheckedV1( + input: ProduceSparseAuthorCatalogSuccessorInputV1, +): Promise { + const previousHeadInput = input.previousHead; + const previousPathInput = input.previousDirectoryPath; + const previousBucketInput = input.previousBucket; + const selectedBucketId = input.selectedBucketId; + const nextRowsInput = input.nextRows; + const issuedAt = input.issuedAt; + const signer = snapshotSigner(input.signer); + + const previousHead = snapshotHead(previousHeadInput); + assertEip191Issuer(previousHead, signer.issuer, 'previous head'); + await verifyExistingSignature(previousHead, 'previous head'); + const scope = deriveAuthorCatalogScopeFromHeadV1(previousHead.payload); + const previousPath = snapshotDirectoryPath(previousPathInput, scope.bucketCount); + for (let index = 0; index < previousPath.length; index += 1) { + assertEip191Issuer(previousPath[index], signer.issuer, `previous path node ${index}`); + await verifyExistingSignature(previousPath[index], `previous path node ${index}`); + } + + let previousDescriptor: Readonly; + try { + const verifiedPath = verifyAuthorCatalogDirectoryPathV1( + previousHead, + previousPath, + selectedBucketId, + ); + previousDescriptor = readVerifiedAuthorCatalogBucketDescriptorV1( + verifiedPath, + previousHead, + ); + } catch (cause) { + fail('catalog-production-path', 'previous directory path is not bound to the selected bucket', cause); + } + const previousBucket = await snapshotPreviousBucket( + previousBucketInput, + previousDescriptor, + scope, + signer.issuer, + ); + + const preparedBucket = prepareReplacementBucket( + scope, + selectedBucketId, + nextRowsInput, + signer.issuer, + ); + const nextDescriptor = preparedBucket.descriptor; + if (sameBucketDescriptor(previousDescriptor, nextDescriptor)) { + fail('catalog-production-noop', 'ordinary catalog successor must change its selected bucket'); + } + assertOneKaBucketDelta(previousBucket, preparedBucket); + + const previousTotal = BigInt(previousHead.payload.totalRows); + const removedRows = BigInt(previousDescriptor.rowCount); + const addedRows = BigInt(nextDescriptor.rowCount); + const nextTotal = previousTotal - removedRows + addedRows; + if (nextTotal < 0n || nextTotal > MAX_DECIMAL_U64) { + fail('catalog-production-history', 'successor totalRows is outside DecimalU64V1'); + } + const previousVersion = BigInt(previousHead.payload.version); + if (previousVersion >= MAX_DECIMAL_U64) { + fail('catalog-production-history', 'ordinary catalog version cannot overflow DecimalU64V1'); + } + const nextBucket = await signPreparedReplacementBucket(preparedBucket, signer); + + const nextPath = new Array( + previousPath.length, + ); + for (let pathIndex = previousPath.length - 1; pathIndex >= 0; pathIndex -= 1) { + const previousNode = previousPath[pathIndex].payload; + const entries = cloneEntries(previousNode.entries); + const entryIndex = selectedEntryIndex(previousNode, selectedBucketId); + if (BigInt(previousNode.level) === 0n) { + entries[entryIndex] = nextDescriptor; + } else { + const child = nextPath[pathIndex + 1]; + entries[entryIndex] = childDescriptor(child, scope.bucketCount); + } + nextPath[pathIndex] = await signDirectoryNode({ + catalogScopeDigest: previousNode.catalogScopeDigest, + entries, + era: previousNode.era, + firstBucketId: previousNode.firstBucketId, + level: previousNode.level, + }, scope.bucketCount, signer); + } + + const nextHead = await signHead({ + networkId: previousHead.payload.networkId, + contextGraphId: previousHead.payload.contextGraphId, + governanceChainId: previousHead.payload.governanceChainId, + governanceContractAddress: previousHead.payload.governanceContractAddress, + ownershipTransitionDigest: previousHead.payload.ownershipTransitionDigest, + subGraphName: previousHead.payload.subGraphName, + authorAddress: previousHead.payload.authorAddress, + catalogIssuerDelegationDigest: previousHead.payload.catalogIssuerDelegationDigest, + era: previousHead.payload.era, + version: (previousVersion + 1n).toString() as DecimalU64V1, + previousHeadDigest: previousHead.objectDigest as Digest32V1, + bucketCount: previousHead.payload.bucketCount, + totalRows: nextTotal.toString() as DecimalU64V1, + directoryHeight: previousHead.payload.directoryHeight, + directoryRootDigest: nextPath[0].objectDigest as Digest32V1, + issuedAt, + }, signer); + + try { + assertAuthorCatalogHeadScopeBindingV1(nextHead.payload, scope); + const verifiedPath = verifyAuthorCatalogDirectoryPathV1( + nextHead, + nextPath, + selectedBucketId, + ); + const producedDescriptor = readVerifiedAuthorCatalogBucketDescriptorV1( + verifiedPath, + nextHead, + ); + if (!sameBucketDescriptor(producedDescriptor, nextDescriptor)) { + throw new Error('produced directory descriptor changed during construction'); + } + if (nextBucket !== null) { + assertAuthorCatalogBucketScopeBindingV1(nextBucket.payload, scope); + } + } catch (cause) { + fail('catalog-production-path', 'produced sparse successor is not self-consistent', cause); + } + + const stagedObjects: SignedControlEnvelopeV1[] = []; + if (nextBucket !== null) stagedObjects.push(nextBucket); + for (let index = nextPath.length - 1; index >= 0; index -= 1) { + stagedObjects.push(nextPath[index]); + } + stagedObjects.push(nextHead); + return freezePublication(nextHead, nextPath, nextBucket, stagedObjects); +} + +type PreparedReplacementBucketV1 = + | { + readonly bucket: null; + readonly descriptor: AuthorCatalogBucketDescriptorV1; + readonly rows: readonly AuthorCatalogRowV1[]; + } + | { + readonly bucket: { + readonly unsigned: UnsignedControlEnvelopeV1; + readonly objectDigest: Digest32V1; + }; + readonly descriptor: AuthorCatalogBucketDescriptorV1; + readonly rows: readonly AuthorCatalogRowV1[]; + }; + +function prepareReplacementBucket( + scope: AuthorCatalogScopeV1, + selectedBucketId: DecimalU64V1, + rowsInput: readonly AuthorCatalogRowV1[], + issuer: EvmAddressV1, +): PreparedReplacementBucketV1 { + if (!Array.isArray(rowsInput) || Object.getPrototypeOf(rowsInput) !== Array.prototype) { + fail('catalog-production-input', 'nextRows must be an ordinary dense Array'); + } + const rows = snapshotRows(rowsInput); + if (rows.length === 0) { + return Object.freeze({ + bucket: null, + descriptor: emptyBucketDescriptor(selectedBucketId), + rows: Object.freeze([]), + }); + } + + let payload: AuthorCatalogBucketV1; + try { + const candidate: AuthorCatalogBucketV1 = { + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + era: scope.era, + bucketCount: scope.bucketCount, + bucketId: selectedBucketId, + rows, + }; + payload = parseCanonicalAuthorCatalogBucketPayloadV1( + canonicalizeAuthorCatalogBucketPayloadBytesV1(candidate), + ); + assertAuthorCatalogBucketScopeBindingV1(payload, scope); + } catch (cause) { + fail('catalog-production-input', 'replacement bucket is not canonical for this scope', cause); + } + + const unsigned = eip191Unsigned( + issuer, + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + freezePlainSnapshot(payload), + ); + const objectDigest = computeAuthorCatalogBucketObjectDigestV1(unsigned); + return Object.freeze({ + bucket: Object.freeze({ unsigned, objectDigest }), + descriptor: Object.freeze({ + bucketDigest: objectDigest, + bucketId: selectedBucketId, + byteLength: canonicalizeAuthorCatalogBucketPayloadBytesV1(payload) + .byteLength.toString() as DecimalU64V1, + rowCount: payload.rows.length.toString() as DecimalU64V1, + }), + rows: payload.rows, + }); +} + +async function snapshotPreviousBucket( + input: SignedAuthorCatalogBucketEnvelopeV1 | null, + descriptor: Readonly, + scope: AuthorCatalogScopeV1, + issuer: EvmAddressV1, +): Promise { + if (descriptor.bucketDigest === ZERO_DIGEST32_V1) { + if (input !== null) { + fail('catalog-production-path', 'empty previous descriptor must not carry a bucket object'); + } + return null; + } + if (input === null) { + fail('catalog-production-path', 'non-empty previous descriptor requires its exact bucket object'); + } + + let bucket: SignedAuthorCatalogBucketEnvelopeV1; + try { + bucket = freezePlainSnapshot(parseCanonicalSignedAuthorCatalogBucketEnvelopeV1( + canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1(input), + )) as SignedAuthorCatalogBucketEnvelopeV1; + assertAuthorCatalogBucketScopeBindingV1(bucket.payload, scope); + } catch (cause) { + fail('catalog-production-input', 'previous bucket is not one canonical scoped snapshot', cause); + } + assertEip191Issuer(bucket, issuer, 'previous bucket'); + await verifyExistingSignature(bucket, 'previous bucket'); + const byteLength = canonicalizeAuthorCatalogBucketPayloadBytesV1(bucket.payload).byteLength; + if ( + bucket.objectDigest !== descriptor.bucketDigest + || bucket.payload.bucketId !== descriptor.bucketId + || bucket.payload.rows.length.toString() !== descriptor.rowCount + || byteLength.toString() !== descriptor.byteLength + ) { + fail('catalog-production-path', 'previous bucket does not match its verified descriptor'); + } + return bucket; +} + +function assertOneKaBucketDelta( + previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null, + nextBucket: PreparedReplacementBucketV1, +): void { + const previousRows = previousBucket?.payload.rows ?? []; + const nextRows = nextBucket.rows; + const previousCanonical = canonicalRows(previousRows); + const nextCanonical = canonicalRows(nextRows); + const lengthDelta = nextRows.length - previousRows.length; + if (lengthDelta === 1) { + assertSingleInsertion(previousCanonical, nextCanonical); + return; + } + if (lengthDelta === -1) { + assertSingleRemoval(previousCanonical, nextCanonical); + return; + } + if (lengthDelta !== 0) { + fail('catalog-production-delta', 'ordinary successor must change exactly one KA row'); + } + + let changedIndex = -1; + for (let index = 0; index < previousCanonical.length; index += 1) { + if (previousCanonical[index] === nextCanonical[index]) continue; + if (changedIndex !== -1) { + fail('catalog-production-delta', 'ordinary successor changed more than one KA row'); + } + changedIndex = index; + } + if (changedIndex === -1) { + fail('catalog-production-noop', 'ordinary successor did not change a KA row'); + } + const previousRow = previousRows[changedIndex]; + const nextRow = nextRows[changedIndex]; + if ( + previousRow.kaId !== nextRow.kaId + || previousRow.assertionCoordinate !== nextRow.assertionCoordinate + || BigInt(nextRow.assertionVersion) !== BigInt(previousRow.assertionVersion) + 1n + ) { + fail( + 'catalog-production-delta', + 'one-row replacement must preserve KA/coordinate and increment assertionVersion by one', + ); + } +} + +function canonicalRows(rows: readonly AuthorCatalogRowV1[]): string[] { + const result = new Array(rows.length); + for (let index = 0; index < rows.length; index += 1) { + result[index] = canonicalizeAuthorCatalogRowV1(rows[index]); + } + return result; +} + +function assertSingleInsertion(previous: readonly string[], next: readonly string[]): void { + let insertionIndex = 0; + while ( + insertionIndex < previous.length + && previous[insertionIndex] === next[insertionIndex] + ) { + insertionIndex += 1; + } + for (let index = insertionIndex; index < previous.length; index += 1) { + if (previous[index] !== next[index + 1]) { + fail('catalog-production-delta', 'ordinary successor inserted more than one KA row'); + } + } +} + +function assertSingleRemoval(previous: readonly string[], next: readonly string[]): void { + let removalIndex = 0; + while ( + removalIndex < next.length + && previous[removalIndex] === next[removalIndex] + ) { + removalIndex += 1; + } + for (let index = removalIndex; index < next.length; index += 1) { + if (previous[index + 1] !== next[index]) { + fail('catalog-production-delta', 'ordinary successor removed more than one KA row'); + } + } +} + +async function signPreparedReplacementBucket( + prepared: PreparedReplacementBucketV1, + signer: SnapshotEip191SignerV1, +): Promise { + if (prepared.bucket === null) return null; + const signed = await signEnvelope( + prepared.bucket.unsigned, + prepared.bucket.objectDigest, + signer, + (value) => { + assertSignedAuthorCatalogBucketEnvelopeV1(value); + }, + ); + return freezePlainSnapshot(parseCanonicalSignedAuthorCatalogBucketEnvelopeV1( + canonicalizeSignedAuthorCatalogBucketEnvelopeBytesV1(signed), + )) as SignedAuthorCatalogBucketEnvelopeV1; +} + +async function signDirectoryNode( + payload: AuthorCatalogDirectoryNodeV1, + bucketCount: AuthorCatalogScopeV1['bucketCount'], + signer: SnapshotEip191SignerV1, +): Promise { + const unsigned = eip191Unsigned( + signer.issuer, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + freezePlainSnapshot(payload), + ); + const digest = computeAuthorCatalogDirectoryNodeObjectDigestV1(unsigned, bucketCount); + const signed = await signEnvelope(unsigned, digest, signer, (value) => { + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(value, bucketCount); + }); + return freezePlainSnapshot(parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1( + canonicalizeSignedAuthorCatalogDirectoryNodeEnvelopeBytesV1(signed, bucketCount), + bucketCount, + )) as SignedAuthorCatalogDirectoryNodeEnvelopeV1; +} + +async function signHead( + payload: AuthorCatalogHeadV1, + signer: SnapshotEip191SignerV1, +): Promise { + const unsigned = eip191Unsigned( + signer.issuer, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + freezePlainSnapshot(payload), + ); + const digest = computeAuthorCatalogHeadObjectDigestV1(unsigned); + const signed = await signEnvelope(unsigned, digest, signer, (value) => { + assertSignedAuthorCatalogHeadEnvelopeV1(value); + }); + return freezePlainSnapshot(parseCanonicalSignedAuthorCatalogHeadEnvelopeV1( + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(signed), + )) as SignedAuthorCatalogHeadEnvelopeV1; +} + +async function signEnvelope( + unsigned: UnsignedControlEnvelopeV1, + objectDigest: Digest32V1, + signer: SnapshotEip191SignerV1, + assertSpecific: (value: SignedControlEnvelopeV1) => void, +): Promise { + let signature: string; + try { + signature = await signer.signDigest(ethers.getBytes(objectDigest)); + } catch (cause) { + fail('catalog-production-signer', `signer failed for ${unsigned.objectType}`, cause); + } + const signed = { + ...unsigned, + objectDigest, + signature, + } as SignedControlEnvelopeV1; + try { + assertSpecific(signed); + await verifyControlEnvelopeIssuerSignatureV1(signed); + } catch (cause) { + fail( + 'catalog-production-signer', + `signer did not produce a canonical ${unsigned.objectType} signature for ${signer.issuer}`, + cause, + ); + } + return signed; +} + +function snapshotHead( + input: SignedAuthorCatalogHeadEnvelopeV1, +): SignedAuthorCatalogHeadEnvelopeV1 { + try { + return freezePlainSnapshot(parseCanonicalSignedAuthorCatalogHeadEnvelopeV1( + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(input), + )) as SignedAuthorCatalogHeadEnvelopeV1; + } catch (cause) { + fail('catalog-production-input', 'previous head is not one canonical snapshot', cause); + } +} + +function snapshotDirectoryPath( + input: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[], + bucketCount: AuthorCatalogScopeV1['bucketCount'], +): readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[] { + if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { + fail('catalog-production-input', 'previousDirectoryPath must be an ordinary dense Array'); + } + const length = input.length; + if (!Number.isSafeInteger(length) || length < 1 || length > 8) { + fail('catalog-production-input', 'previousDirectoryPath must contain 1..8 nodes'); + } + const snapshot = new Array(length); + try { + for (let index = 0; index < length; index += 1) { + snapshot[index] = freezePlainSnapshot( + parseCanonicalSignedAuthorCatalogDirectoryNodeEnvelopeV1( + canonicalizeSignedAuthorCatalogDirectoryNodeEnvelopeBytesV1( + input[index], + bucketCount, + ), + bucketCount, + ), + ) as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + } + } catch (cause) { + fail('catalog-production-input', 'previous directory path is not one canonical snapshot', cause); + } + return Object.freeze(snapshot); +} + +function snapshotRows(input: readonly AuthorCatalogRowV1[]): readonly AuthorCatalogRowV1[] { + const length = input.length; + if (!Number.isSafeInteger(length) || length < 0 || length > 1024) { + fail('catalog-production-input', 'nextRows is outside the 0..1024 bucket-row bound'); + } + const snapshot = new Array(length); + try { + for (let index = 0; index < length; index += 1) { + const rowInput = input[index]; + const transferInput = rowInput.transfer; + const row = { + kaId: rowInput.kaId, + assertionCoordinate: rowInput.assertionCoordinate, + assertionVersion: rowInput.assertionVersion, + projectionId: rowInput.projectionId, + projectionDigest: rowInput.projectionDigest, + sealDigest: rowInput.sealDigest, + transfer: { + codec: transferInput.codec, + projectionId: transferInput.projectionId, + projectionDigest: transferInput.projectionDigest, + byteLength: transferInput.byteLength, + chunkSize: transferInput.chunkSize, + chunkCount: transferInput.chunkCount, + blobDigest: transferInput.blobDigest, + chunkTreeRoot: transferInput.chunkTreeRoot, + }, + } as AuthorCatalogRowV1; + snapshot[index] = freezePlainSnapshot(parseCanonicalAuthorCatalogRowV1( + canonicalizeAuthorCatalogRowV1(row), + )); + } + } catch (cause) { + fail('catalog-production-input', 'nextRows is not one canonical row snapshot', cause); + } + return Object.freeze(snapshot); +} + +function snapshotSigner(input: Rfc64AuthorCatalogEip191SignerV1): SnapshotEip191SignerV1 { + const issuer = input.issuer; + const signDigest = input.signDigest; + if (typeof signDigest !== 'function') { + fail('catalog-production-input', 'catalog signer must expose signDigest'); + } + return Object.freeze({ issuer, signDigest }); +} + +function assertEip191Issuer( + envelope: SignedControlEnvelopeV1, + issuer: EvmAddressV1, + label: string, +): void { + if ( + envelope.issuer !== issuer + || envelope.signatureSuite !== 'eip191-personal-sign-digest-v1' + || envelope.signatureEvidence.kind !== 'none' + ) { + fail( + 'catalog-production-history', + `${label} must use the same EIP-191 catalog issuer as the successor signer`, + ); + } +} + +async function verifyExistingSignature( + envelope: SignedControlEnvelopeV1, + label: string, +): Promise { + try { + await verifyControlEnvelopeIssuerSignatureV1(envelope); + } catch (cause) { + fail('catalog-production-history', `${label} signature is not valid`, cause); + } +} + +function eip191Unsigned( + issuer: EvmAddressV1, + objectType: string, + payload: unknown, +): UnsignedControlEnvelopeV1 { + return freezePlainSnapshot({ + issuer, + objectType, + payload: payload as UnsignedControlEnvelopeV1['payload'], + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }) as UnsignedControlEnvelopeV1; +} + +function emptyBucketDescriptor( + bucketId: DecimalU64V1, +): AuthorCatalogBucketDescriptorV1 { + return Object.freeze({ + bucketDigest: ZERO_DIGEST32_V1, + bucketId, + byteLength: '0' as DecimalU64V1, + rowCount: '0' as DecimalU64V1, + }); +} + +function cloneEntries( + input: readonly AuthorCatalogDirectoryEntryV1[], +): AuthorCatalogDirectoryEntryV1[] { + const result = new Array(input.length); + for (let index = 0; index < input.length; index += 1) { + result[index] = { ...input[index] } as AuthorCatalogDirectoryEntryV1; + } + return result; +} + +function selectedEntryIndex( + node: AuthorCatalogDirectoryNodeV1, + bucketIdValue: DecimalU64V1, +): number { + const bucketId = BigInt(bucketIdValue); + const firstBucketId = BigInt(node.firstBucketId); + const entryWidth = directoryPower(BigInt(node.level)); + const relative = bucketId - firstBucketId; + if (relative < 0n) { + fail('catalog-production-path', 'selected bucket is outside the rebuilt directory node'); + } + const index = relative / entryWidth; + if (index < 0n || index >= BigInt(node.entries.length)) { + fail('catalog-production-path', 'selected bucket is outside the rebuilt directory node'); + } + return Number(index); +} + +function childDescriptor( + child: SignedAuthorCatalogDirectoryNodeEnvelopeV1, + bucketCount: AuthorCatalogScopeV1['bucketCount'], +): AuthorCatalogChildDescriptorV1 { + let rowCount = 0n; + for (let index = 0; index < child.payload.entries.length; index += 1) { + rowCount += BigInt(child.payload.entries[index].rowCount); + if (rowCount > MAX_DECIMAL_U64) { + fail('catalog-production-path', 'rebuilt child rowCount exceeds DecimalU64V1'); + } + } + const firstBucketId = BigInt(child.payload.firstBucketId); + const childCoverage = directoryPower(BigInt(child.payload.level) + 1n); + const remaining = BigInt(bucketCount) - firstBucketId; + const bucketSpan = remaining < childCoverage ? remaining : childCoverage; + return Object.freeze({ + firstBucketId: child.payload.firstBucketId, + bucketSpan: bucketSpan.toString() as DecimalU64V1, + rowCount: rowCount.toString() as DecimalU64V1, + byteLength: canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1( + child.payload, + bucketCount, + ).byteLength.toString() as DecimalU64V1, + childDigest: child.objectDigest as Digest32V1, + }); +} + +function directoryPower(exponent: bigint): bigint { + let result = 1n; + for (let index = 0n; index < exponent; index += 1n) { + result *= AUTHOR_CATALOG_DIRECTORY_FANOUT_V1; + } + return result; +} + +function sameBucketDescriptor( + left: Readonly, + right: Readonly, +): boolean { + return left.bucketId === right.bucketId + && left.rowCount === right.rowCount + && left.byteLength === right.byteLength + && left.bucketDigest === right.bucketDigest; +} + +function freezePublication( + head: SignedAuthorCatalogHeadEnvelopeV1, + directoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[], + bucket: SignedAuthorCatalogBucketEnvelopeV1 | null, + stagedObjects: readonly SignedControlEnvelopeV1[], +): ProducedAuthorCatalogPublicationV1 { + return Object.freeze({ + head, + directoryPath: Object.freeze(Array.from(directoryPath)), + bucket, + stagedObjects: Object.freeze(Array.from(stagedObjects)), + }); +} + +function snapshotScope(input: AuthorCatalogScopeV1): AuthorCatalogScopeV1 { + const candidate = { + networkId: input.networkId, + contextGraphId: input.contextGraphId, + governanceChainId: input.governanceChainId, + governanceContractAddress: input.governanceContractAddress, + ownershipTransitionDigest: input.ownershipTransitionDigest, + subGraphName: input.subGraphName, + authorAddress: input.authorAddress, + era: input.era, + bucketCount: input.bucketCount, + }; + try { + assertAuthorCatalogScopeV1(candidate); + } catch (cause) { + fail('catalog-production-input', 'empty genesis scope is not canonical', cause); + } + return freezePlainSnapshot(candidate) as AuthorCatalogScopeV1; +} + +function freezePlainSnapshot(value: T): T { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + freezePlainSnapshot(value[index]); + } + return Object.freeze(value); + } + for (const key of Object.keys(value)) { + freezePlainSnapshot((value as Record)[key]); + } + return Object.freeze(value); +} + +function normalizePublicError(message: string, cause: unknown): never { + if (cause instanceof Rfc64AuthorCatalogProductionErrorV1) throw cause; + fail('catalog-production-input', message, cause); +} + +function fail( + code: Rfc64AuthorCatalogProductionErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64AuthorCatalogProductionErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-author-catalog-producer.test.ts b/packages/agent/test/rfc64-author-catalog-producer.test.ts new file mode 100644 index 0000000000..296bc8fa66 --- /dev/null +++ b/packages/agent/test/rfc64-author-catalog-producer.test.ts @@ -0,0 +1,795 @@ +import { describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; + +import { + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + KA_TRANSFER_CHUNK_SIZE_V1, + KA_TRANSFER_CODEC_V1, + KA_TRANSFER_PROJECTION_V1, + MAX_DECIMAL_U64, + ZERO_DIGEST32_V1, + assertAuthorCatalogRowV1, + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1, + catalogKeyToBucketIdV1, + computeAuthorCatalogDirectoryNodeObjectDigestV1, + computeAuthorCatalogHeadObjectDigestV1, + computeAuthorCatalogScopeDigestV1, + readVerifiedAuthorCatalogBucketDescriptorV1, + verifyAuthorCatalogDirectoryPathV1, + type AuthorCatalogBucketDescriptorV1, + type AuthorCatalogChildDescriptorV1, + type AuthorCatalogDirectoryNodeV1, + type AuthorCatalogHeadV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type CountV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedControlEnvelopeV1, + type TimestampMsV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; + +import { + Rfc64AuthorCatalogProductionErrorV1, + produceEmptyAuthorCatalogGenesisV1, + produceSparseAuthorCatalogSuccessorV1, + type Rfc64AuthorCatalogEip191SignerV1, +} from '../src/rfc64/author-catalog-producer.js'; + +const CATALOG_PRIVATE_KEY = `0x${'22'.repeat(32)}`; +const OTHER_PRIVATE_KEY = `0x${'33'.repeat(32)}`; +const AUTHOR = '0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a' as EvmAddressV1; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/catalog-production'; +const GOVERNANCE_CONTRACT = + '0x2222222222222222222222222222222222222222' as EvmAddressV1; +const DELEGATION_DIGEST = `0x${'66'.repeat(32)}` as Digest32V1; +const SEAL_DIGEST = `0x${'44'.repeat(32)}` as Digest32V1; + +const catalogWallet = new ethers.Wallet(CATALOG_PRIVATE_KEY); +const otherWallet = new ethers.Wallet(OTHER_PRIVATE_KEY); + +describe('RFC-64 author catalog producer', () => { + it('produces a signed immutable empty genesis with canonical staging order', async () => { + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope: scope('1'), + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1700000000000' as TimestampMsV1, + signer: signer(catalogWallet), + }); + + expect(produced.bucket).toBeNull(); + expect(produced.head.payload).toMatchObject({ + era: '0', + version: '0', + previousHeadDigest: null, + bucketCount: '1', + totalRows: '0', + directoryHeight: '0', + }); + expect(produced.directoryPath).toHaveLength(1); + expect(produced.stagedObjects.map((object) => object.objectType)).toEqual([ + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + ]); + const proof = verifyAuthorCatalogDirectoryPathV1( + produced.head, + produced.directoryPath, + '0' as DecimalU64V1, + ); + expect(readVerifiedAuthorCatalogBucketDescriptorV1(proof, produced.head)).toEqual({ + bucketDigest: ZERO_DIGEST32_V1, + bucketId: '0', + byteLength: '0', + rowCount: '0', + }); + expect(Object.isFrozen(produced)).toBe(true); + expect(Object.isFrozen(produced.head.payload)).toBe(true); + expect(Object.isFrozen(produced.directoryPath[0].payload.entries)).toBe(true); + await expectAllSignatures(produced.stagedObjects); + }); + + it('adds one row by replacing one bucket, path, and constant-size head', async () => { + const genesis = await emptyGenesis(); + const row = makeRow(1n, 'first'); + const produced = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row], + issuedAt: '1700000000001' as TimestampMsV1, + signer: signer(catalogWallet), + }); + + expect(produced.bucket?.payload.rows).toEqual([row]); + expect(produced.head.payload.version).toBe('1'); + expect(produced.head.payload.previousHeadDigest).toBe(genesis.head.objectDigest); + expect(produced.head.payload.totalRows).toBe('1'); + expect(produced.stagedObjects.map((object) => object.objectType)).toEqual([ + 'AuthorCatalogBucketV1', + 'AuthorCatalogDirectoryNodeV1', + 'AuthorCatalogHeadV1', + ]); + const proof = verifyAuthorCatalogDirectoryPathV1( + produced.head, + produced.directoryPath, + '0' as DecimalU64V1, + ); + expect(readVerifiedAuthorCatalogBucketDescriptorV1(proof, produced.head)) + .toMatchObject({ bucketDigest: produced.bucket?.objectDigest, rowCount: '1' }); + await expectAllSignatures(produced.stagedObjects); + }); + + it('rebuilds only one leaf-to-root path in a two-level 512-bucket catalog', async () => { + const selectedBucketId = '300' as DecimalU64V1; + const previous = await twoLevelEmptyCatalog(selectedBucketId); + const unchangedSibling = previous.path[0].payload.entries[0]; + const row = rowForBucket(selectedBucketId, '512' as CountV1, 1n, 'bucket-300'); + + const produced = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: previous.head, + previousDirectoryPath: previous.path, + previousBucket: null, + selectedBucketId, + nextRows: [row], + issuedAt: '1700000000100' as TimestampMsV1, + signer: signer(catalogWallet), + }); + + expect(produced.directoryPath).toHaveLength(2); + expect(produced.stagedObjects.map((object) => object.objectType)).toEqual([ + 'AuthorCatalogBucketV1', + 'AuthorCatalogDirectoryNodeV1', + 'AuthorCatalogDirectoryNodeV1', + 'AuthorCatalogHeadV1', + ]); + expect(produced.directoryPath[0].payload.entries[0]).toEqual(unchangedSibling); + expect(produced.directoryPath[0].payload.entries[1]).toMatchObject({ rowCount: '1' }); + expect(produced.head.payload.totalRows).toBe('1'); + expect(produced.head.payload.version).toBe('8'); + await expectAllSignatures(produced.stagedObjects); + }); + + it('keeps a sparse update bounded at the maximum eight-node directory height', async () => { + const bucketCount = (1n << 63n).toString() as CountV1; + const row = makeRow(77n, 'maximum-height'); + const selectedBucketId = catalogKeyToBucketIdV1(row.kaId, bucketCount); + const previous = await maximumHeightEmptyCatalog(selectedBucketId); + + const produced = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: previous.head, + previousDirectoryPath: previous.path, + previousBucket: null, + selectedBucketId, + nextRows: [row], + issuedAt: '1700000000200' as TimestampMsV1, + signer: signer(catalogWallet), + }); + + expect(produced.directoryPath).toHaveLength(8); + expect(produced.stagedObjects).toHaveLength(10); + expect(produced.stagedObjects[0].objectType).toBe('AuthorCatalogBucketV1'); + expect(produced.stagedObjects.at(-1)?.objectType).toBe('AuthorCatalogHeadV1'); + expect(produced.head.payload).toMatchObject({ + bucketCount, + directoryHeight: '7', + totalRows: '1', + version: '1', + }); + await expectAllSignatures(produced.stagedObjects); + }); + + it('removes the last selected row through a canonical empty descriptor', async () => { + const selectedBucketId = '300' as DecimalU64V1; + const previous = await twoLevelEmptyCatalog(selectedBucketId); + const row = rowForBucket(selectedBucketId, '512' as CountV1, 1n, 'bucket-300'); + const populated = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: previous.head, + previousDirectoryPath: previous.path, + previousBucket: null, + selectedBucketId, + nextRows: [row], + issuedAt: '1700000000100' as TimestampMsV1, + signer: signer(catalogWallet), + }); + + const removed = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: populated.head, + previousDirectoryPath: populated.directoryPath, + previousBucket: populated.bucket, + selectedBucketId, + nextRows: [], + issuedAt: '1700000000101' as TimestampMsV1, + signer: signer(catalogWallet), + }); + + expect(removed.bucket).toBeNull(); + expect(removed.head.payload.totalRows).toBe('0'); + expect(removed.head.payload.version).toBe('9'); + expect(removed.stagedObjects.map((object) => object.objectType)).toEqual([ + 'AuthorCatalogDirectoryNodeV1', + 'AuthorCatalogDirectoryNodeV1', + 'AuthorCatalogHeadV1', + ]); + const proof = verifyAuthorCatalogDirectoryPathV1( + removed.head, + removed.directoryPath, + selectedBucketId, + ); + expect(readVerifiedAuthorCatalogBucketDescriptorV1(proof, removed.head)) + .toMatchObject({ bucketDigest: ZERO_DIGEST32_V1, rowCount: '0', byteLength: '0' }); + }); + + it('snapshots rows and the previous path before invoking the signer', async () => { + const genesis = await emptyGenesis(); + const originalRow = makeRow(1n, 'stable'); + const rows = [originalRow]; + const path = Array.from(genesis.directoryPath); + let calls = 0; + const mutatingSigner: Rfc64AuthorCatalogEip191SignerV1 = { + issuer: catalogWallet.address.toLowerCase() as EvmAddressV1, + signDigest: async (digest) => { + calls += 1; + if (calls === 1) { + rows[0] = makeRow(2n, 'switched'); + path.length = 0; + } + return catalogWallet.signMessage(digest); + }, + }; + + const produced = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: path, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: rows, + issuedAt: '1700000000001' as TimestampMsV1, + signer: mutatingSigner, + }); + + expect(produced.bucket?.payload.rows).toEqual([originalRow]); + expect(produced.directoryPath).toHaveLength(1); + }); + + it('reads adversarial path and row array lengths exactly once', async () => { + const genesis = await emptyGenesis(); + const row = makeRow(1n, 'stable'); + let pathLengthReads = 0; + let rowLengthReads = 0; + const path = new Proxy(Array.from(genesis.directoryPath), { + get(target, property, receiver) { + if (property === 'length') { + pathLengthReads += 1; + return pathLengthReads === 1 ? 1 : 0; + } + return Reflect.get(target, property, receiver); + }, + }); + const rows = new Proxy([row], { + get(target, property, receiver) { + if (property === 'length') { + rowLengthReads += 1; + return rowLengthReads === 1 ? 1 : 0; + } + return Reflect.get(target, property, receiver); + }, + }); + + const produced = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: path, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: rows, + issuedAt: '1700000000001' as TimestampMsV1, + signer: signer(catalogWallet), + }); + + expect(produced.bucket?.payload.rows).toEqual([row]); + expect(pathLengthReads).toBe(1); + expect(rowLengthReads).toBe(1); + }); + + it('rejects no-op successors and rows assigned to a different bucket', async () => { + const genesis = await emptyGenesis(); + const row = makeRow(1n, 'first'); + const populated = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row], + issuedAt: '1700000000001' as TimestampMsV1, + signer: signer(catalogWallet), + }); + + let noOpSignCalls = 0; + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: populated.head, + previousDirectoryPath: populated.directoryPath, + previousBucket: populated.bucket, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row], + issuedAt: '1700000000002' as TimestampMsV1, + signer: { + issuer: catalogWallet.address.toLowerCase() as EvmAddressV1, + signDigest: (digest) => { + noOpSignCalls += 1; + return catalogWallet.signMessage(digest); + }, + }, + })).rejects.toMatchObject({ code: 'catalog-production-noop' }); + expect(noOpSignCalls).toBe(0); + + const previous = await twoLevelEmptyCatalog('300' as DecimalU64V1); + const wrongRow = rowForBucket('301' as DecimalU64V1, '512' as CountV1, 1n, 'wrong'); + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: previous.head, + previousDirectoryPath: previous.path, + previousBucket: null, + selectedBucketId: '300' as DecimalU64V1, + nextRows: [wrongRow], + issuedAt: '1700000000100' as TimestampMsV1, + signer: signer(catalogWallet), + })).rejects.toMatchObject({ code: 'catalog-production-input' }); + }); + + it('requires the exact prior bucket and permits only one KA delta per head', async () => { + const genesis = await emptyGenesis(); + const row1 = makeRow(1n, 'first'); + const row2 = makeRow(2n, 'second'); + + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row1, row2], + issuedAt: '1700000000001' as TimestampMsV1, + signer: signer(catalogWallet), + })).rejects.toMatchObject({ code: 'catalog-production-delta' }); + + const oneRow = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row1], + issuedAt: '1700000000001' as TimestampMsV1, + signer: signer(catalogWallet), + }); + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: oneRow.head, + previousDirectoryPath: oneRow.directoryPath, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row1, row2], + issuedAt: '1700000000002' as TimestampMsV1, + signer: signer(catalogWallet), + })).rejects.toMatchObject({ code: 'catalog-production-path' }); + + const twoRows = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: oneRow.head, + previousDirectoryPath: oneRow.directoryPath, + previousBucket: oneRow.bucket, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row1, row2], + issuedAt: '1700000000002' as TimestampMsV1, + signer: signer(catalogWallet), + }); + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: twoRows.head, + previousDirectoryPath: twoRows.directoryPath, + previousBucket: twoRows.bucket, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [], + issuedAt: '1700000000003' as TimestampMsV1, + signer: signer(catalogWallet), + })).rejects.toMatchObject({ code: 'catalog-production-delta' }); + }); + + it('requires a one-step assertion version and stable coordinate for row replacement', async () => { + const genesis = await emptyGenesis(); + const rowV1 = makeRow(1n, 'stable', '1', 0); + const oneRow = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [rowV1], + issuedAt: '1700000000001' as TimestampMsV1, + signer: signer(catalogWallet), + }); + const rowV2 = makeRow(1n, 'stable', '2', 1); + const updated = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: oneRow.head, + previousDirectoryPath: oneRow.directoryPath, + previousBucket: oneRow.bucket, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [rowV2], + issuedAt: '1700000000002' as TimestampMsV1, + signer: signer(catalogWallet), + }); + expect(updated.bucket?.payload.rows).toEqual([rowV2]); + expect(updated.head.payload.totalRows).toBe('1'); + + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: updated.head, + previousDirectoryPath: updated.directoryPath, + previousBucket: updated.bucket, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [makeRow(1n, 'changed-coordinate', '3', 2)], + issuedAt: '1700000000003' as TimestampMsV1, + signer: signer(catalogWallet), + })).rejects.toMatchObject({ code: 'catalog-production-delta' }); + + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: updated.head, + previousDirectoryPath: updated.directoryPath, + previousBucket: updated.bucket, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [makeRow(1n, 'stable', '4', 2)], + issuedAt: '1700000000003' as TimestampMsV1, + signer: signer(catalogWallet), + })).rejects.toMatchObject({ code: 'catalog-production-delta' }); + }); + + it('fails closed on an invalid predecessor or a signer for another issuer', async () => { + const genesis = await emptyGenesis(); + const row = makeRow(1n, 'first'); + const forgedHead = { + ...genesis.head, + signature: await otherWallet.signMessage(ethers.getBytes(genesis.head.objectDigest)), + } as SignedAuthorCatalogHeadEnvelopeV1; + + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: forgedHead, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row], + issuedAt: '1700000000001' as TimestampMsV1, + signer: signer(catalogWallet), + })).rejects.toMatchObject({ code: 'catalog-production-history' }); + + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [row], + issuedAt: '1700000000001' as TimestampMsV1, + signer: { + issuer: catalogWallet.address.toLowerCase() as EvmAddressV1, + signDigest: (digest) => otherWallet.signMessage(digest), + }, + })).rejects.toMatchObject({ code: 'catalog-production-signer' }); + }); + + it('rejects ordinary history overflow before producing a successor head', async () => { + const previous = await twoLevelEmptyCatalog( + '300' as DecimalU64V1, + MAX_DECIMAL_U64.toString() as DecimalU64V1, + ); + const row = rowForBucket('300' as DecimalU64V1, '512' as CountV1, 1n, 'overflow'); + await expect(produceSparseAuthorCatalogSuccessorV1({ + previousHead: previous.head, + previousDirectoryPath: previous.path, + previousBucket: null, + selectedBucketId: '300' as DecimalU64V1, + nextRows: [row], + issuedAt: '1700000000100' as TimestampMsV1, + signer: signer(catalogWallet), + })).rejects.toMatchObject({ code: 'catalog-production-history' }); + }); + + it('uses a closed typed error registry', () => { + const error = new Rfc64AuthorCatalogProductionErrorV1( + 'catalog-production-input', + 'fixture', + ); + expect(error).toMatchObject({ + name: 'Rfc64AuthorCatalogProductionErrorV1', + code: 'catalog-production-input', + }); + expect(() => new Rfc64AuthorCatalogProductionErrorV1( + 'not-a-production-code' as never, + 'fixture', + )).toThrow(TypeError); + }); +}); + +async function emptyGenesis() { + return produceEmptyAuthorCatalogGenesisV1({ + scope: scope('1'), + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1700000000000' as TimestampMsV1, + signer: signer(catalogWallet), + }); +} + +function signer(wallet: ethers.Wallet): Rfc64AuthorCatalogEip191SignerV1 { + return { + issuer: wallet.address.toLowerCase() as EvmAddressV1, + signDigest: (digest) => wallet.signMessage(digest), + }; +} + +function scope(bucketCount: string): AuthorCatalogScopeV1 { + return { + networkId: 'otp:20430', + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount, + } as AuthorCatalogScopeV1; +} + +function makeRow( + number: bigint, + assertionCoordinate: string, + assertionVersion = '1', + variant = 0, +): AuthorCatalogRowV1 { + const projectionDigest = digest(Number(number % 1000n) + 1000 + variant * 10_000); + const row = { + kaId: ((BigInt(AUTHOR) << 96n) | number).toString(), + assertionCoordinate, + assertionVersion, + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest, + sealDigest: SEAL_DIGEST, + transfer: { + codec: KA_TRANSFER_CODEC_V1, + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest, + byteLength: '16', + chunkSize: KA_TRANSFER_CHUNK_SIZE_V1, + chunkCount: '1', + blobDigest: digest(Number(number % 1000n) + 2000 + variant * 10_000), + chunkTreeRoot: digest(Number(number % 1000n) + 3000 + variant * 10_000), + }, + } as unknown as AuthorCatalogRowV1; + assertAuthorCatalogRowV1(row); + return row; +} + +function rowForBucket( + bucketId: DecimalU64V1, + bucketCount: CountV1, + start: bigint, + coordinate: string, +): AuthorCatalogRowV1 { + for (let number = start; number < start + 1_000_000n; number += 1n) { + const row = makeRow(number, coordinate); + if (catalogKeyToBucketIdV1(row.kaId, bucketCount) === bucketId) return row; + } + throw new Error(`unable to find a row for bucket ${bucketId}`); +} + +async function twoLevelEmptyCatalog( + selectedBucketId: DecimalU64V1, + version: DecimalU64V1 = '7' as DecimalU64V1, +): Promise<{ + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly path: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; +}> { + const catalogScope = scope('512'); + const scopeDigest = computeAuthorCatalogScopeDigestV1(catalogScope); + const leafFirst = 256n; + const leafPayload: AuthorCatalogDirectoryNodeV1 = { + catalogScopeDigest: scopeDigest, + entries: emptyDescriptors(256, leafFirst), + era: '0' as DecimalU64V1, + firstBucketId: leafFirst.toString() as DecimalU64V1, + level: '0' as DecimalU64V1, + }; + const leaf = await signedDirectory(leafPayload, '512' as CountV1); + const leafBytes = canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1( + leaf.payload, + '512' as CountV1, + ).byteLength; + const rootPayload: AuthorCatalogDirectoryNodeV1 = { + catalogScopeDigest: scopeDigest, + entries: [ + { + firstBucketId: '0' as DecimalU64V1, + bucketSpan: '256' as CountV1, + rowCount: '0' as CountV1, + byteLength: '1' as CountV1, + childDigest: digest(99), + }, + { + firstBucketId: '256' as DecimalU64V1, + bucketSpan: '256' as CountV1, + rowCount: '0' as CountV1, + byteLength: leafBytes.toString() as CountV1, + childDigest: leaf.objectDigest as Digest32V1, + }, + ] satisfies readonly AuthorCatalogChildDescriptorV1[], + era: '0' as DecimalU64V1, + firstBucketId: '0' as DecimalU64V1, + level: '1' as DecimalU64V1, + }; + const root = await signedDirectory(rootPayload, '512' as CountV1); + const headPayload = { + networkId: catalogScope.networkId, + contextGraphId: catalogScope.contextGraphId, + governanceChainId: catalogScope.governanceChainId, + governanceContractAddress: catalogScope.governanceContractAddress, + ownershipTransitionDigest: catalogScope.ownershipTransitionDigest, + subGraphName: catalogScope.subGraphName, + authorAddress: catalogScope.authorAddress, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + era: catalogScope.era, + version, + previousHeadDigest: digest(98), + bucketCount: catalogScope.bucketCount, + totalRows: '0' as CountV1, + directoryHeight: '1' as DecimalU64V1, + directoryRootDigest: root.objectDigest as Digest32V1, + issuedAt: '1700000000000' as TimestampMsV1, + } satisfies AuthorCatalogHeadV1; + const head = await signedHead(headPayload); + verifyAuthorCatalogDirectoryPathV1(head, [root, leaf], selectedBucketId); + return { head, path: [root, leaf] }; +} + +async function maximumHeightEmptyCatalog( + selectedBucketId: DecimalU64V1, +): Promise<{ + readonly head: SignedAuthorCatalogHeadEnvelopeV1; + readonly path: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; +}> { + const bucketCount = 1n << 63n; + const bucketCountWire = bucketCount.toString() as CountV1; + const catalogScope = scope(bucketCountWire); + const scopeDigest = computeAuthorCatalogScopeDigestV1(catalogScope); + const selected = BigInt(selectedBucketId); + const leafFirst = (selected / 256n) * 256n; + const leafCoverage = minimum(256n, bucketCount - leafFirst); + let child = await signedDirectory({ + catalogScopeDigest: scopeDigest, + entries: emptyDescriptors(Number(leafCoverage), leafFirst), + era: '0' as DecimalU64V1, + firstBucketId: leafFirst.toString() as DecimalU64V1, + level: '0' as DecimalU64V1, + }, bucketCountWire); + const leafToRoot = [child]; + + for (let level = 1n; level <= 7n; level += 1n) { + const entryWidth = 256n ** level; + const nodeWidth = entryWidth * 256n; + const firstBucketId = (selected / nodeWidth) * nodeWidth; + const coverage = minimum(nodeWidth, bucketCount - firstBucketId); + const entryCount = Number((coverage + entryWidth - 1n) / entryWidth); + const selectedIndex = Number( + (BigInt(child.payload.firstBucketId) - firstBucketId) / entryWidth, + ); + const childBytes = canonicalizeAuthorCatalogDirectoryNodePayloadBytesV1( + child.payload, + bucketCountWire, + ).byteLength; + const entries = new Array(entryCount); + for (let index = 0; index < entryCount; index += 1) { + const entryFirst = firstBucketId + BigInt(index) * entryWidth; + entries[index] = { + firstBucketId: entryFirst.toString() as DecimalU64V1, + bucketSpan: minimum(entryWidth, bucketCount - entryFirst).toString() as CountV1, + rowCount: '0' as CountV1, + byteLength: (index === selectedIndex ? childBytes.toString() : '1') as CountV1, + childDigest: index === selectedIndex + ? child.objectDigest as Digest32V1 + : digest(Number(level) * 1000 + index + 10_000), + }; + } + child = await signedDirectory({ + catalogScopeDigest: scopeDigest, + entries, + era: '0' as DecimalU64V1, + firstBucketId: firstBucketId.toString() as DecimalU64V1, + level: level.toString() as DecimalU64V1, + }, bucketCountWire); + leafToRoot.push(child); + } + const path = leafToRoot.reverse(); + const head = await signedHead({ + networkId: catalogScope.networkId, + contextGraphId: catalogScope.contextGraphId, + governanceChainId: catalogScope.governanceChainId, + governanceContractAddress: catalogScope.governanceContractAddress, + ownershipTransitionDigest: catalogScope.ownershipTransitionDigest, + subGraphName: catalogScope.subGraphName, + authorAddress: catalogScope.authorAddress, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + era: catalogScope.era, + version: '0' as DecimalU64V1, + previousHeadDigest: null, + bucketCount: bucketCountWire, + totalRows: '0' as CountV1, + directoryHeight: '7' as DecimalU64V1, + directoryRootDigest: path[0].objectDigest as Digest32V1, + issuedAt: '1700000000000' as TimestampMsV1, + }); + verifyAuthorCatalogDirectoryPathV1(head, path, selectedBucketId); + return { head, path }; +} + +async function signedDirectory( + payload: AuthorCatalogDirectoryNodeV1, + bucketCount: CountV1, +): Promise { + const unsigned = { + issuer: catalogWallet.address.toLowerCase(), + objectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; + const objectDigest = computeAuthorCatalogDirectoryNodeObjectDigestV1(unsigned, bucketCount); + const signed = { + ...unsigned, + objectDigest, + signature: await catalogWallet.signMessage(ethers.getBytes(objectDigest)), + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1(signed, bucketCount); + return signed as SignedAuthorCatalogDirectoryNodeEnvelopeV1; +} + +async function signedHead(payload: AuthorCatalogHeadV1): Promise { + const unsigned = { + issuer: catalogWallet.address.toLowerCase(), + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedControlEnvelopeV1; + const objectDigest = computeAuthorCatalogHeadObjectDigestV1(unsigned); + const signed = { + ...unsigned, + objectDigest, + signature: await catalogWallet.signMessage(ethers.getBytes(objectDigest)), + } as SignedControlEnvelopeV1; + assertSignedAuthorCatalogHeadEnvelopeV1(signed); + return signed as SignedAuthorCatalogHeadEnvelopeV1; +} + +function emptyDescriptors( + count: number, + firstBucketId: bigint, +): AuthorCatalogBucketDescriptorV1[] { + return Array.from({ length: count }, (_, index) => ({ + bucketDigest: ZERO_DIGEST32_V1, + bucketId: (firstBucketId + BigInt(index)).toString() as DecimalU64V1, + byteLength: '0' as CountV1, + rowCount: '0' as CountV1, + })); +} + +async function expectAllSignatures(objects: readonly SignedControlEnvelopeV1[]): Promise { + for (const object of objects) { + await expect(verifyControlEnvelopeIssuerSignatureV1(object)).resolves.toBeDefined(); + } +} + +function digest(value: number): Digest32V1 { + return `0x${value.toString(16).padStart(64, '0')}` as Digest32V1; +} + +function minimum(left: bigint, right: bigint): bigint { + return left < right ? left : right; +} diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index f753fa4e4e..08ba69dc43 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -114,6 +114,7 @@ export default defineConfig({ "test/rfc64-candidate-transfer-admission.test.ts", "test/rfc64-catalog-row-authorship.test.ts", "test/rfc64-agent-inventory-lifecycle.test.ts", + "test/rfc64-author-catalog-producer.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 5a548408595cb003b60210b6ba307b6124804cf6 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 12:02:47 +0200 Subject: [PATCH 065/292] refactor(core): escape control-byte regexp + cover signature-variant limits Two contained cleanups on the control-object codec: - The objectType validator embedded literal C0/DEL bytes in its regexp, which made the source file classify as binary (invisible to grep/ripgrep). Respell the character class with unicode escape sequences (U+0000 through U+001F and U+007F) instead of literal control bytes -- identical behavior, and the source file now reports as ASCII text. - Add negative tests for parseCanonicalControlSignatureVariant: extra-field rejection via the exact-key contract (in canonical position), plus the maxDepth-1 and 16 KiB byte-cap parser limits. Full core suite green, tsc clean. Addresses otReviewAgent issues on sync-control-object.ts:324 (raw control bytes) and test:129 (detached-variant normalization coverage). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/sync-control-object.ts | 2 +- packages/core/test/sync-control-object.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index ef61ea02bc..68654a61fe 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -321,7 +321,7 @@ function assertUnsignedControlEnvelopeFields( throw new Error('Control-object type is outside the canonical string bounds'); } } - if (/[-]/u.test(envelope.objectType)) { + if (/[\u0000-\u001f\u007f]/u.test(envelope.objectType)) { throw new Error('Control-object type is outside the canonical string bounds'); } if (!CONTROL_OBJECT_SIGNATURE_SUITES.includes(envelope.signatureSuite)) { diff --git a/packages/core/test/sync-control-object.test.ts b/packages/core/test/sync-control-object.test.ts index b1fa5ede07..448d34635b 100644 --- a/packages/core/test/sync-control-object.test.ts +++ b/packages/core/test/sync-control-object.test.ts @@ -390,5 +390,16 @@ describe('Track-2 control-object envelopes', () => { expect(() => parseCanonicalControlSignatureVariant(uppercaseSignature)).toThrow( /lowercase bytes/, ); + // Exact-key contract: an extra field in canonical (sorted) position passes + // canonicalization but must be rejected by the exact-key check. + const withExtraField = `${EOA_CANONICAL_SIGNATURE_VARIANT.slice(0, -1)},"unknown":"metadata"}`; + expect(() => parseCanonicalControlSignatureVariant(withExtraField)).toThrow( + /unknown or missing fields/, + ); + // Variant parser limits: maxDepth 1 rejects nesting, and the 16 KiB byte cap + // rejects an over-cap canonical input before materialization. + expect(() => parseCanonicalControlSignatureVariant('{"a":{"b":1}}')).toThrow(/nesting/); + const oversizedVariant = `{"a":"${'x'.repeat(16 * 1024)}"}`; + expect(() => parseCanonicalControlSignatureVariant(oversizedVariant)).toThrow(); }); }); From d5a61c574b36921331c99673910c6720cfd12072 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 12:27:29 +0200 Subject: [PATCH 066/292] feat(agent): durably stage RFC-64 control objects --- .github/workflows/rfc64-inventory-windows.yml | 5 + packages/agent/src/dkg-agent-base.ts | 22 +- packages/agent/src/index.ts | 1 + .../src/rfc64/control-object-store-v1.ts | 744 ++++++++++++++++++ .../rfc64-agent-inventory-lifecycle.test.ts | 48 +- .../rfc64-control-object-store-v1.test.ts | 406 ++++++++++ packages/agent/vitest.unit.config.ts | 1 + packages/core/src/sync-control-object.ts | 41 +- .../core/test/sync-control-object.test.ts | 6 + 9 files changed, 1254 insertions(+), 20 deletions(-) create mode 100644 packages/agent/src/rfc64/control-object-store-v1.ts create mode 100644 packages/agent/test/rfc64-control-object-store-v1.test.ts diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index ee6fcc4842..27005d4561 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -6,9 +6,13 @@ on: - '.github/workflows/rfc64-inventory-windows.yml' - 'packages/agent/src/rfc64/inventory-v1/**' - 'packages/agent/src/rfc64/author-catalog-producer.ts' + - 'packages/agent/src/rfc64/control-object-store-v1.ts' + - 'packages/agent/src/dkg-agent-base.ts' + - 'packages/agent/src/index.ts' - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' - 'packages/agent/test/rfc64-author-catalog-producer.test.ts' + - 'packages/agent/test/rfc64-control-object-store-v1.test.ts' - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' - 'packages/agent/vitest.unit.config.ts' @@ -65,3 +69,4 @@ jobs: test/rfc64-inventory-v1 test/rfc64-agent-inventory-lifecycle.test.ts test/rfc64-author-catalog-producer.test.ts + test/rfc64-control-object-store-v1.test.ts diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index f4016d0473..9754a6af51 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -15,6 +15,10 @@ import { openInventoryV1, type Rfc64InventoryV1Foundation, } from './rfc64/inventory-v1/index.js'; +import { + openRfc64ControlObjectStoreV1, + type Rfc64ControlObjectStoreV1, +} from './rfc64/control-object-store-v1.js'; import { resolveVmReconcileStartupMaxDelayMs } from './startup-jitter.js'; import { DKGNode, ProtocolRouter, GossipSubManager, TypedEventBus, DKGEvent, @@ -993,6 +997,8 @@ export class DKGAgentBase { * are deliberately dormant and never substitute an in-memory inventory. */ protected rfc64InventoryV1?: Rfc64InventoryV1Foundation; + /** Durable immutable object cache opened only after inventory path ownership. */ + protected rfc64ControlObjectStoreV1?: Rfc64ControlObjectStoreV1; protected readonly subscribedContextGraphs = new Map(); protected contextGraphSubscriptionRehydrationStatus: ContextGraphSubscriptionRehydrationStatus | null = null; protected readonly contextGraphSubscriptionRehydrationAccountedIds = new Set(); @@ -1565,19 +1571,23 @@ export class DKGAgentBase { } /** - * Acquire the RFC-64 inventory and finish bounded stale-candidate cleanup - * before any network consumer can observe or mutate inventory state. + * Acquire the RFC-64 inventory, finish bounded stale-candidate cleanup, and + * open the inherited-owner control-object tree before network consumers. */ protected async prepareRfc64InventoryV1(): Promise { if (!this.config.dataDir || this.rfc64InventoryV1 !== undefined) return; const inventory = await openInventoryV1(this.config.dataDir); + let controlObjectStore: Rfc64ControlObjectStoreV1 | undefined; try { for (;;) { const batch = inventory.purgeNextStartupStaleCandidateBatch(); if (batch.done) break; await this.yieldRfc64InventoryV1StartupBatch(); } + // openInventoryV1 has already established single-process ownership and + // secured rfc64-sync; Windows children inherit that owner-only ACL. + controlObjectStore = await openRfc64ControlObjectStoreV1(this.config.dataDir); } catch (cause) { try { inventory.close(); @@ -1589,6 +1599,7 @@ export class DKGAgentBase { } throw cause; } + this.rfc64ControlObjectStoreV1 = controlObjectStore; this.rfc64InventoryV1 = inventory; } @@ -1598,12 +1609,15 @@ export class DKGAgentBase { } /** - * Relinquish the single inventory foundation. Clear the local reference - * before closing so a fail-stop close error cannot be retried accidentally. + * Relinquish the control store and single inventory foundation. Clear local + * references before closing so fail-stop cleanup cannot be retried. */ protected closeRfc64InventoryV1(): void { + const controlObjectStore = this.rfc64ControlObjectStoreV1; const inventory = this.rfc64InventoryV1; + this.rfc64ControlObjectStoreV1 = undefined; this.rfc64InventoryV1 = undefined; + controlObjectStore?.close(); inventory?.close(); } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3edd885080..8785f29a2f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -36,6 +36,7 @@ export { } from './auth/agent-delegation.js'; export * from './rfc64/catalog-row-authorship.js'; export * from './rfc64/author-catalog-producer.js'; +export * from './rfc64/control-object-store-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/control-object-store-v1.ts b/packages/agent/src/rfc64/control-object-store-v1.ts new file mode 100644 index 0000000000..097e9ed4cc --- /dev/null +++ b/packages/agent/src/rfc64/control-object-store-v1.ts @@ -0,0 +1,744 @@ +import { randomBytes } from 'node:crypto'; +import { constants } from 'node:fs'; +import { + chmod, + lstat, + mkdir, + open, + rename, + unlink, +} from 'node:fs/promises'; +import { + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; + +import { + MAX_CONTROL_OBJECT_BYTES, + MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + assertCanonicalDigest, + canonicalizeControlSignatureVariantBytes, + canonicalizeSignedControlEnvelopeBytes, + canonicalizeUnsignedControlEnvelopeBytes, + computeControlSignatureVariantDigestHex, + parseCanonicalControlSignatureVariant, + parseCanonicalSignedControlEnvelope, + parseCanonicalUnsignedControlEnvelope, + type ControlObjectSignatureVariantV1, + type Digest32V1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + readVerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; + +export const RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH = + 'rfc64-sync/control-objects-v1' as const; +export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = 0o700; +export const RFC64_CONTROL_OBJECT_STORE_FILE_MODE = 0o600; +export const RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS = 16; + +const OBJECTS_DIRECTORY = 'objects'; +const SIGNATURES_DIRECTORY = 'signatures'; +const CANONICAL_FILE_SUFFIX = '.jcs'; + +export const RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1 = Object.freeze([ + 'control-store-input', + 'control-store-verification', + 'control-store-unsafe-path', + 'control-store-corrupt', + 'control-store-io', + 'control-store-durability', + 'control-store-closed', +] as const); + +export type Rfc64ControlObjectStoreErrorCodeV1 = + (typeof RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1)[number]; + +export class Rfc64ControlObjectStoreErrorV1 extends Error { + constructor( + readonly code: Rfc64ControlObjectStoreErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + if (!RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1.includes(code)) { + throw new TypeError(`Unsupported RFC-64 control store error code: ${code}`); + } + this.name = 'Rfc64ControlObjectStoreErrorV1'; + } +} + +export type Rfc64ControlObjectStoreDurabilityBoundaryV1 = + | 'directory.created' + | 'directory.mode-secured' + | 'directory.self-fsynced' + | 'directory.parent-fsynced' + | 'object.temp-written' + | 'object.temp-mode-secured' + | 'object.temp-fsynced' + | 'object.renamed' + | 'object.parent-fsynced' + | 'signature.temp-written' + | 'signature.temp-mode-secured' + | 'signature.temp-fsynced' + | 'signature.renamed' + | 'signature.parent-fsynced'; + +interface Rfc64ControlObjectStoreIoV1 { + readonly boundary: (boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1) => void; + readonly randomSuffix: () => string; +} + +const PRODUCTION_IO = Object.freeze({ + boundary: (_boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1): void => {}, + randomSuffix: (): string => randomBytes(16).toString('hex'), +}) satisfies Rfc64ControlObjectStoreIoV1; + +export interface StageVerifiedControlObjectV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; +} + +export interface StagedVerifiedControlObjectV1 { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; +} + +export interface StageVerifiedControlObjectsResultV1 { + /** Every named file and containing directory has crossed its fsync boundary. */ + readonly durable: true; + readonly objects: readonly StagedVerifiedControlObjectV1[]; +} + +export interface GetVerifiedControlObjectInputV1 { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + /** Re-establishes current generic envelope cryptography after reading cache bytes. */ + readonly verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; +} + +export interface StoredVerifiedControlObjectV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; +} + +export interface Rfc64ControlObjectStoreV1 { + readonly rootPath: string; + readonly closed: boolean; + /** + * Durably stage immutable unsigned envelopes and detached signature variants. + * Success does not advance a semantic ref or make any catalog authoritative. + */ + stageVerifiedObjects( + input: readonly StageVerifiedControlObjectV1[], + ): Promise; + /** Read exact cache keys and reverify the reconstructed signed envelope. */ + getVerifiedObject( + input: GetVerifiedControlObjectInputV1, + ): Promise; + close(): void; +} + +export interface Rfc64ControlObjectStoreTestIoV1 { + readonly boundary?: (boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1) => void; + readonly randomSuffix?: () => string; +} + +export type Rfc64ControlObjectStoreTestOpenerV1 = ( + dataDir: string, +) => Promise; + +/** + * Open after the RFC-64 inventory foundation has secured rfc64-sync and owns + * its single-process lease. This cache deliberately does not mint that lease. + */ +export async function openRfc64ControlObjectStoreV1( + dataDir: string, +): Promise { + return openRfc64ControlObjectStoreWithIoV1(dataDir, PRODUCTION_IO); +} + +/** Test-only fault-boundary opener; shipped production code cannot install hooks. */ +export function createRfc64ControlObjectStoreTestOpenerV1( + testIo: Rfc64ControlObjectStoreTestIoV1 = {}, +): Rfc64ControlObjectStoreTestOpenerV1 { + if (process.env.NODE_ENV !== 'test') { + fail('control-store-input', 'control store test opener is available only under NODE_ENV=test'); + } + const boundary = testIo.boundary; + const randomSuffix = testIo.randomSuffix; + const io = Object.freeze({ + boundary: (value: Rfc64ControlObjectStoreDurabilityBoundaryV1): void => { + boundary?.(value); + }, + randomSuffix: (): string => randomSuffix?.() ?? randomBytes(16).toString('hex'), + }) satisfies Rfc64ControlObjectStoreIoV1; + return async (dataDir: string): Promise => + openRfc64ControlObjectStoreWithIoV1(dataDir, io); +} + +interface PreparedStoredControlObjectV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + readonly unsignedBytes: Uint8Array; + readonly signatureVariantBytes: Uint8Array; +} + +class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { + #closed = false; + #operationTail: Promise = Promise.resolve(); + + constructor( + readonly rootPath: string, + private readonly io: Rfc64ControlObjectStoreIoV1, + ) {} + + get closed(): boolean { + return this.#closed; + } + + stageVerifiedObjects( + input: readonly StageVerifiedControlObjectV1[], + ): Promise { + this.requireOpen(); + const prepared = prepareStageBatch(input); + return this.enqueue(async () => { + this.requireOpen(); + const result = new Array(prepared.length); + for (let index = 0; index < prepared.length; index += 1) { + const item = prepared[index]; + await this.stagePrepared(item); + result[index] = Object.freeze({ + objectDigest: item.objectDigest, + signatureVariantDigest: item.signatureVariantDigest, + }); + } + return Object.freeze({ + durable: true as const, + objects: Object.freeze(result), + }); + }); + } + + getVerifiedObject( + input: GetVerifiedControlObjectInputV1, + ): Promise { + this.requireOpen(); + const objectDigest = snapshotDigest(input.objectDigest, 'objectDigest'); + const signatureVariantDigest = snapshotDigest( + input.signatureVariantDigest, + 'signatureVariantDigest', + ); + const verifyIssuerSignature = input.verifyIssuerSignature; + if (typeof verifyIssuerSignature !== 'function') { + fail('control-store-input', 'verifyIssuerSignature must be a function'); + } + return this.enqueue(async () => { + this.requireOpen(); + const objectPath = this.objectPath(objectDigest); + const signaturePath = this.signaturePath(objectDigest, signatureVariantDigest); + const [unsignedBytes, variantBytes] = await Promise.all([ + readOptionalBoundedFile(objectPath, MAX_CONTROL_OBJECT_BYTES, 'control object'), + readOptionalBoundedFile( + signaturePath, + MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + 'control signature variant', + ), + ]); + if (unsignedBytes === null || variantBytes === null) return null; + + let unsigned: UnsignedControlEnvelopeV1; + let variant: ControlObjectSignatureVariantV1; + let envelope: SignedControlEnvelopeV1; + try { + unsigned = parseCanonicalUnsignedControlEnvelope(unsignedBytes); + variant = parseCanonicalControlSignatureVariant(variantBytes); + if ( + variant.objectDigest !== objectDigest + || variant.signatureVariantDigest !== signatureVariantDigest + ) { + throw new Error('stored cache keys do not match the canonical records'); + } + envelope = deepFreezePlain(parseCanonicalSignedControlEnvelope( + canonicalizeSignedControlEnvelopeBytes({ + ...unsigned, + objectDigest, + signature: variant.signature, + }), + )) as SignedControlEnvelopeV1; + } catch (cause) { + fail('control-store-corrupt', 'stored control object is not canonical for its exact keys', cause); + } + + let issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; + try { + issuerSignature = await verifyIssuerSignature(envelope); + assertProofMatchesEnvelope(envelope, issuerSignature); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-verification', 'stored control object signature verification failed', cause); + } + return Object.freeze({ envelope, issuerSignature }); + }); + } + + close(): void { + this.#closed = true; + } + + private async stagePrepared(item: PreparedStoredControlObjectV1): Promise { + const objectPath = this.objectPath(item.objectDigest); + await ensureSecureDirectory(dirname(objectPath), this.rootPath, this.io); + await stageExactFile(objectPath, item.unsignedBytes, 'object', this.io); + + const signaturePath = this.signaturePath( + item.objectDigest, + item.signatureVariantDigest, + ); + await ensureSecureDirectory(dirname(signaturePath), this.rootPath, this.io); + await stageExactFile(signaturePath, item.signatureVariantBytes, 'signature', this.io); + } + + private objectPath(objectDigest: Digest32V1): string { + const hex = objectDigest.slice(2); + return join( + this.rootPath, + OBJECTS_DIRECTORY, + hex.slice(0, 2), + `${hex}${CANONICAL_FILE_SUFFIX}`, + ); + } + + private signaturePath( + objectDigest: Digest32V1, + signatureVariantDigest: Digest32V1, + ): string { + const objectHex = objectDigest.slice(2); + const variantHex = signatureVariantDigest.slice(2); + return join( + this.rootPath, + SIGNATURES_DIRECTORY, + objectHex.slice(0, 2), + objectHex, + `${variantHex}${CANONICAL_FILE_SUFFIX}`, + ); + } + + private enqueue(operation: () => Promise): Promise { + const run = this.#operationTail.then(operation, operation); + this.#operationTail = run.then(() => undefined, () => undefined); + return run; + } + + private requireOpen(): void { + if (this.#closed) fail('control-store-closed', 'control object store is closed'); + } +} + +async function openRfc64ControlObjectStoreWithIoV1( + dataDirInput: string, + io: Rfc64ControlObjectStoreIoV1, +): Promise { + if (!Object.isFrozen(io)) { + fail('control-store-input', 'control object store I/O adapter must be immutable'); + } + if (typeof dataDirInput !== 'string' || dataDirInput.length === 0) { + fail('control-store-input', 'dataDir must be a non-empty path'); + } + const dataDir = resolve(dataDirInput); + const rootPath = resolve(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH); + const relativeRoot = relative(dataDir, rootPath); + if ( + relativeRoot === '..' + || relativeRoot.startsWith(`..${sep}`) + || isAbsolute(relativeRoot) + ) { + fail('control-store-unsafe-path', 'control object store must remain inside dataDir'); + } + await assertExistingDirectory(dataDir, 'DKG data directory', false); + await ensureSecureDirectory(rootPath, dataDir, io); + await ensureSecureDirectory(join(rootPath, OBJECTS_DIRECTORY), rootPath, io); + await ensureSecureDirectory(join(rootPath, SIGNATURES_DIRECTORY), rootPath, io); + return new FileRfc64ControlObjectStoreV1(rootPath, io); +} + +function prepareStageBatch( + input: readonly StageVerifiedControlObjectV1[], +): readonly PreparedStoredControlObjectV1[] { + if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { + fail('control-store-input', 'stage input must be an ordinary dense Array'); + } + const length = input.length; + if ( + !Number.isSafeInteger(length) + || length < 1 + || length > RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS + ) { + fail( + 'control-store-input', + `stage input must contain 1..${RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS} objects`, + ); + } + const result = new Array(length); + for (let index = 0; index < length; index += 1) { + if (!(index in input)) fail('control-store-input', 'stage input must not contain holes'); + const item = input[index]; + try { + const envelope = deepFreezePlain(parseCanonicalSignedControlEnvelope( + canonicalizeSignedControlEnvelopeBytes(item.envelope), + )) as SignedControlEnvelopeV1; + assertProofMatchesEnvelope(envelope, item.issuerSignature); + const unsigned = unsignedFromSigned(envelope); + const objectDigest = envelope.objectDigest as Digest32V1; + const signatureVariantDigest = computeControlSignatureVariantDigestHex( + objectDigest, + envelope.signature, + ) as Digest32V1; + const variant = Object.freeze({ + objectDigest, + signature: envelope.signature, + signatureVariantDigest, + }) satisfies ControlObjectSignatureVariantV1; + result[index] = Object.freeze({ + envelope, + objectDigest, + signatureVariantDigest, + unsignedBytes: canonicalizeUnsignedControlEnvelopeBytes(unsigned), + signatureVariantBytes: canonicalizeControlSignatureVariantBytes(variant), + }); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-input', `stage input ${index} is not one verified canonical snapshot`, cause); + } + } + return Object.freeze(result); +} + +function assertProofMatchesEnvelope( + envelope: SignedControlEnvelopeV1, + proof: VerifiedControlEnvelopeIssuerSignatureV1, +): void { + let snapshot; + try { + snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + } catch (cause) { + fail('control-store-verification', 'issuer signature proof was not minted by the verifier', cause); + } + const expectedVariant = computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ); + if ( + snapshot.objectDigest !== envelope.objectDigest + || snapshot.signatureVariantDigest !== expectedVariant + || snapshot.issuer !== envelope.issuer + || snapshot.signatureSuite !== envelope.signatureSuite + ) { + fail('control-store-verification', 'issuer signature proof is not bound to this envelope'); + } +} + +function unsignedFromSigned(envelope: SignedControlEnvelopeV1): UnsignedControlEnvelopeV1 { + return { + issuer: envelope.issuer, + objectType: envelope.objectType, + payload: envelope.payload, + signatureEvidence: envelope.signatureEvidence, + signatureSuite: envelope.signatureSuite, + } as UnsignedControlEnvelopeV1; +} + +async function ensureSecureDirectory( + target: string, + containmentRoot: string, + io: Rfc64ControlObjectStoreIoV1, +): Promise { + const resolvedTarget = resolve(target); + const resolvedRoot = resolve(containmentRoot); + const relativeTarget = relative(resolvedRoot, resolvedTarget); + if ( + relativeTarget === '..' + || relativeTarget.startsWith(`..${sep}`) + || isAbsolute(relativeTarget) + ) { + fail('control-store-unsafe-path', 'control store directory escaped its containment root'); + } + + if (relativeTarget.length === 0) { + await assertExistingDirectory(resolvedTarget, 'control store directory', true); + return; + } + await assertExistingDirectory(resolvedRoot, 'control store containment root', false); + let current = resolvedRoot; + for (const component of relativeTarget.split(sep).filter(Boolean)) { + current = join(current, component); + let created = false; + try { + await mkdir(current, { mode: RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE }); + created = true; + io.boundary('directory.created'); + } catch (cause) { + if (!isNodeError(cause, 'EEXIST')) { + fail('control-store-io', `failed to create control store directory ${current}`, cause); + } + } + if (created) { + await chmodSecure(current, RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, 'directory'); + io.boundary('directory.mode-secured'); + await fsyncDirectory(current); + io.boundary('directory.self-fsynced'); + await fsyncDirectory(dirname(current)); + io.boundary('directory.parent-fsynced'); + } + await assertExistingDirectory(current, 'control store directory', true); + } +} + +async function stageExactFile( + targetPath: string, + bytes: Uint8Array, + kind: 'object' | 'signature', + io: Rfc64ControlObjectStoreIoV1, +): Promise { + const existing = await readOptionalBoundedFile( + targetPath, + kind === 'object' ? MAX_CONTROL_OBJECT_BYTES : MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + `existing ${kind}`, + ); + if (existing !== null) { + if (!bytesEqual(existing, bytes)) { + fail('control-store-corrupt', `existing ${kind} bytes differ for the same digest key`); + } + return; + } + + const suffix = io.randomSuffix(); + if (!/^[0-9a-f]{32}$/u.test(suffix)) { + fail('control-store-input', 'control store random suffix must be 16 lowercase hex bytes'); + } + const tempPath = join(dirname(targetPath), `.${suffix}.tmp`); + let renamed = false; + let handle: Awaited> | null = null; + try { + handle = await open(tempPath, 'wx', RFC64_CONTROL_OBJECT_STORE_FILE_MODE); + await handle.writeFile(bytes); + io.boundary(`${kind}.temp-written`); + await chmodSecure(tempPath, RFC64_CONTROL_OBJECT_STORE_FILE_MODE, `${kind} temp file`); + io.boundary(`${kind}.temp-mode-secured`); + await handle.sync(); + io.boundary(`${kind}.temp-fsynced`); + await handle.close(); + handle = null; + await rename(tempPath, targetPath); + renamed = true; + io.boundary(`${kind}.renamed`); + await fsyncDirectory(dirname(targetPath)); + io.boundary(`${kind}.parent-fsynced`); + await assertExistingRegularFile(targetPath, `${kind} cache file`, true); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-durability', `failed to durably stage ${kind} bytes`, cause); + } finally { + if (handle !== null) await handle.close().catch(() => undefined); + if (!renamed) await unlink(tempPath).catch(() => undefined); + } +} + +async function readOptionalBoundedFile( + path: string, + maxBytes: number, + label: string, +): Promise { + try { + const entry = await lstat(path); + if (entry.isSymbolicLink() || !entry.isFile()) { + fail('control-store-unsafe-path', `${label} must be a regular non-symlink file`); + } + } catch (cause) { + if (isNodeError(cause, 'ENOENT')) return null; + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-io', `failed to inspect ${label}`, cause); + } + + let handle: Awaited> | null = null; + try { + const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; + handle = await open(path, constants.O_RDONLY | noFollow); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size < 1 || stat.size > maxBytes) { + fail('control-store-corrupt', `${label} is outside its bounded regular-file shape`); + } + const bytes = new Uint8Array(stat.size); + let offset = 0; + while (offset < bytes.length) { + const read = await handle.read(bytes, offset, bytes.length - offset, offset); + if (read.bytesRead === 0) { + fail('control-store-corrupt', `${label} was truncated during its bounded read`); + } + offset += read.bytesRead; + } + const extra = new Uint8Array(1); + const tail = await handle.read(extra, 0, 1, offset); + if (tail.bytesRead !== 0) { + fail('control-store-corrupt', `${label} grew during its bounded read`); + } + assertOwner(stat.uid, label); + await assertFileMode(stat.mode, label); + return bytes; + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-io', `failed to read ${label}`, cause); + } finally { + if (handle !== null) await handle.close().catch(() => undefined); + } + return fail('control-store-io', `failed to complete bounded read of ${label}`); +} + +async function assertExistingDirectory( + path: string, + label: string, + requireSecureMode: boolean, +): Promise { + try { + const entry = await lstat(path); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + fail('control-store-unsafe-path', `${label} must be a non-symlink directory`); + } + assertOwner(entry.uid, label); + if (requireSecureMode) await assertDirectoryMode(entry.mode, label); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-io', `failed to inspect ${label}`, cause); + } +} + +async function assertExistingRegularFile( + path: string, + label: string, + requireSecureMode: boolean, +): Promise { + try { + const entry = await lstat(path); + if (entry.isSymbolicLink() || !entry.isFile()) { + fail('control-store-unsafe-path', `${label} must be a regular non-symlink file`); + } + assertOwner(entry.uid, label); + if (requireSecureMode) await assertFileMode(entry.mode, label); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-io', `failed to inspect ${label}`, cause); + } +} + +async function chmodSecure(path: string, mode: number, label: string): Promise { + try { + if (process.platform !== 'win32') await chmod(path, mode); + const entry = await lstat(path); + assertOwner(entry.uid, label); + if (process.platform !== 'win32' && (entry.mode & 0o777) !== mode) { + throw new Error(`${label} mode is ${(entry.mode & 0o777).toString(8)}, expected ${mode.toString(8)}`); + } + } catch (cause) { + fail('control-store-unsafe-path', `failed to secure ${label}`, cause); + } +} + +async function assertDirectoryMode(mode: number, label: string): Promise { + if ( + process.platform !== 'win32' + && (mode & 0o777) !== RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE + ) { + fail('control-store-unsafe-path', `${label} must have owner-only mode 0700`); + } +} + +async function assertFileMode(mode: number, label: string): Promise { + if ( + process.platform !== 'win32' + && (mode & 0o777) !== RFC64_CONTROL_OBJECT_STORE_FILE_MODE + ) { + fail('control-store-unsafe-path', `${label} must have owner-only mode 0600`); + } +} + +function assertOwner(uid: number, label: string): void { + if (process.platform === 'win32') return; + const processUid = process.getuid?.(); + if (processUid !== undefined && uid !== processUid) { + fail('control-store-unsafe-path', `${label} is not owned by the current process user`); + } +} + +async function fsyncDirectory(path: string): Promise { + let handle: Awaited> | null = null; + try { + handle = await open(path, constants.O_RDONLY); + const stat = await handle.stat(); + if (!stat.isDirectory()) { + fail('control-store-unsafe-path', 'directory fsync target is not a directory'); + } + await handle.sync(); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-durability', `failed to fsync directory ${path}`, cause); + } finally { + if (handle !== null) await handle.close().catch(() => undefined); + } +} + +function snapshotDigest(value: string, label: string): Digest32V1 { + try { + assertCanonicalDigest(value, label); + } catch (cause) { + fail('control-store-input', `${label} is not a canonical digest`, cause); + } + return value as Digest32V1; +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + let different = 0; + for (let index = 0; index < left.byteLength; index += 1) { + different |= left[index] ^ right[index]; + } + return different === 0; +} + +function deepFreezePlain(value: T): T { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) { + for (const item of value) deepFreezePlain(item); + return Object.freeze(value); + } + for (const key of Object.keys(value)) { + deepFreezePlain((value as Record)[key]); + } + return Object.freeze(value); +} + +function isNodeError(cause: unknown, code: string): boolean { + return cause instanceof Error && 'code' in cause + && (cause as NodeJS.ErrnoException).code === code; +} + +function fail( + code: Rfc64ControlObjectStoreErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64ControlObjectStoreErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index 887ae92ab1..e3e46309c0 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; -import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; @@ -11,6 +11,10 @@ import { INVENTORY_V1_RELATIVE_PATH, openInventoryV1, } from '../src/rfc64/inventory-v1/index.js'; +import { + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + openRfc64ControlObjectStoreV1, +} from '../src/rfc64/control-object-store-v1.js'; const temporaryDirectories: string[] = []; const childProcesses = new Set(); @@ -30,6 +34,7 @@ function syntheticAgent(dataDirectory?: string): any { Object.assign(agent, { config: dataDirectory === undefined ? {} : { dataDir: dataDirectory }, rfc64InventoryV1: undefined, + rfc64ControlObjectStoreV1: undefined, }); return agent; } @@ -107,6 +112,7 @@ function minimalStartedAgent( node: { stop: vi.fn(async () => { order.push('node'); }) }, syncVerifyWorker: { close: vi.fn(async () => { order.push('sync-worker'); }) }, rfc64InventoryV1: { close: inventoryClose }, + rfc64ControlObjectStoreV1: { close: () => { order.push('control-store'); } }, store: { close: vi.fn(async () => { order.push('store'); }) }, log: { warn: vi.fn() }, }); @@ -195,6 +201,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await agent.prepareRfc64InventoryV1(); expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.rfc64ControlObjectStoreV1).toBeUndefined(); expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); }); @@ -208,17 +215,24 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await agent.prepareRfc64InventoryV1(); const ownedFoundation = agent.rfc64InventoryV1; + const ownedControlObjectStore = agent.rfc64ControlObjectStoreV1; await agent.prepareRfc64InventoryV1(); expect(agent.rfc64InventoryV1).toBe(ownedFoundation); + expect(agent.rfc64ControlObjectStoreV1).toBe(ownedControlObjectStore); expect(ownedFoundation.databasePath).toBe( join(dataDirectory, INVENTORY_V1_RELATIVE_PATH), ); expect(yieldBatch).toHaveBeenCalledTimes(3); expect(() => ownedFoundation.createCandidateSession()).not.toThrow(); + expect(ownedControlObjectStore.rootPath).toBe( + join(dataDirectory, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH), + ); agent.closeRfc64InventoryV1(); expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.rfc64ControlObjectStoreV1).toBeUndefined(); + expect(ownedControlObjectStore.closed).toBe(true); expect(candidateLoadCount(dataDirectory)).toBe(0); expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); }); @@ -240,6 +254,34 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { expect(agent.started).toBe(false); }); + it.runIf(process.platform !== 'win32')( + 'releases inventory ownership when control-store topology is unsafe', + async () => { + const dataDirectory = temporaryDataDirectory(); + const outside = temporaryDataDirectory(); + const inventory = await openInventoryV1(dataDirectory); + const initialStore = await openRfc64ControlObjectStoreV1(dataDirectory); + initialStore.close(); + inventory.close(); + const objectsPath = join( + dataDirectory, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + 'objects', + ); + rmSync(objectsPath, { recursive: true, force: true }); + symlinkSync(outside, objectsPath, 'dir'); + const agent = syntheticAgent(dataDirectory); + + await expect(agent.prepareRfc64InventoryV1()) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.rfc64ControlObjectStoreV1).toBeUndefined(); + + const replacement = await openInventoryV1(dataDirectory); + replacement.close(); + }, + ); + it('releases an acquired inventory when node startup fails', async () => { const failure = new Error('node start failed'); const close = vi.fn(); @@ -268,7 +310,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await agent.stop(); await agent.stop(); - expect(order).toEqual(['node', 'sync-worker', 'inventory', 'store']); + expect(order).toEqual(['node', 'sync-worker', 'control-store', 'inventory', 'store']); expect(inventoryClose).toHaveBeenCalledOnce(); expect(agent.rfc64InventoryV1).toBeUndefined(); expect(agent.started).toBe(false); @@ -284,7 +326,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await expect(agent.stop()).rejects.toBe(failure); - expect(order).toEqual(['node', 'sync-worker', 'inventory', 'store']); + expect(order).toEqual(['node', 'sync-worker', 'control-store', 'inventory', 'store']); expect(agent.rfc64InventoryV1).toBeUndefined(); expect(agent.started).toBe(false); await expect(agent.stop()).resolves.toBeUndefined(); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts new file mode 100644 index 0000000000..e9176c739a --- /dev/null +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -0,0 +1,406 @@ +import { mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + computeControlObjectDigestHex, + computeControlSignatureVariantDigestHex, + type AuthorCatalogScopeV1, + type Digest32V1, + type EvmAddressV1, + type SignedControlEnvelopeV1, + type TimestampMsV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + verifyControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, + RFC64_CONTROL_OBJECT_STORE_FILE_MODE, + RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + Rfc64ControlObjectStoreErrorV1, + createRfc64ControlObjectStoreTestOpenerV1, + openRfc64ControlObjectStoreV1, + type Rfc64ControlObjectStoreDurabilityBoundaryV1, + type StageVerifiedControlObjectV1, +} from '../src/rfc64/control-object-store-v1.js'; +import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; + +const PRIVATE_KEY = `0x${'42'.repeat(32)}`; +const wallet = new ethers.Wallet(PRIVATE_KEY); +const ISSUER = wallet.address.toLowerCase() as EvmAddressV1; + +const temporaryDirectories: string[] = []; + +async function temporaryDataDirectory(): Promise { + const path = await mkdtemp(join(tmpdir(), 'dkg-rfc64-control-store-')); + temporaryDirectories.push(path); + return path; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(async (path) => { + await rm(path, { recursive: true, force: true }); + })); +}); + +async function signedFixture( + sequence: string, +): Promise { + const unsigned = { + issuer: ISSUER, + objectType: 'dkg-rfc64-control-store-test-v1', + payload: { sequence }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } satisfies UnsignedControlEnvelopeV1; + const objectDigest = computeControlObjectDigestHex(unsigned); + const signature = await wallet.signMessage(ethers.getBytes(objectDigest)); + const envelope = { + ...unsigned, + objectDigest, + signature, + } as SignedControlEnvelopeV1; + return { + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }; +} + +function pathsFor( + dataDir: string, + envelope: SignedControlEnvelopeV1, +): { root: string; object: string; signature: string; signatureDigest: Digest32V1 } { + const root = join(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH); + const objectHex = envelope.objectDigest.slice(2); + const signatureDigest = computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ) as Digest32V1; + return { + root, + object: join(root, 'objects', objectHex.slice(0, 2), `${objectHex}.jcs`), + signature: join( + root, + 'signatures', + objectHex.slice(0, 2), + objectHex, + `${signatureDigest.slice(2)}.jcs`, + ), + signatureDigest, + }; +} + +describe('RFC-64 durable control-object store v1', () => { + it('durably splits unsigned object bytes from a detached signature and reverifies reads', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('1'); + const result = await store.stageVerifiedObjects([fixture]); + + expect(result).toEqual({ + durable: true, + objects: [{ + objectDigest: fixture.envelope.objectDigest, + signatureVariantDigest: pathsFor(dataDir, fixture.envelope).signatureDigest, + }], + }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.objects)).toBe(true); + + const paths = pathsFor(dataDir, fixture.envelope); + const unsigned = JSON.parse(await readFile(paths.object, 'utf8')) as Record; + const signature = JSON.parse(await readFile(paths.signature, 'utf8')) as Record; + expect(Object.keys(unsigned).sort()).toEqual([ + 'issuer', + 'objectType', + 'payload', + 'signatureEvidence', + 'signatureSuite', + ]); + expect(unsigned).not.toHaveProperty('objectDigest'); + expect(unsigned).not.toHaveProperty('signature'); + expect(signature).toEqual({ + objectDigest: fixture.envelope.objectDigest, + signature: fixture.envelope.signature, + signatureVariantDigest: paths.signatureDigest, + }); + + const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + const loaded = await store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature, + }); + expect(verifyIssuerSignature).toHaveBeenCalledTimes(1); + expect(loaded?.envelope).toEqual(fixture.envelope); + expect(Object.isFrozen(loaded)).toBe(true); + expect(Object.isFrozen(loaded?.envelope)).toBe(true); + + if (process.platform !== 'win32') { + expect((await stat(paths.root)).mode & 0o777) + .toBe(RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE); + expect((await stat(paths.object)).mode & 0o777) + .toBe(RFC64_CONTROL_OBJECT_STORE_FILE_MODE); + expect((await stat(paths.signature)).mode & 0o777) + .toBe(RFC64_CONTROL_OBJECT_STORE_FILE_MODE); + } + }); + + it('stages an ordered author-catalog candidate without granting head-ref authority', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope: { + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/store-integration', + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: ISSUER, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + catalogIssuerDelegationDigest: `0x${'66'.repeat(32)}` as Digest32V1, + issuedAt: '1700000000000' as TimestampMsV1, + signer: { + issuer: ISSUER, + signDigest: (digest) => wallet.signMessage(digest), + }, + }); + const stageInput = await Promise.all(produced.stagedObjects.map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }))); + + const result = await store.stageVerifiedObjects(stageInput); + expect(result.durable).toBe(true); + expect(result.objects.map((item) => item.objectDigest)).toEqual( + produced.stagedObjects.map((item) => item.objectDigest), + ); + expect(store).not.toHaveProperty('currentHead'); + expect(store).not.toHaveProperty('advanceHead'); + }); + + it('is idempotent and serializes concurrent staging for exact digest keys', async () => { + const dataDir = await temporaryDataDirectory(); + const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: (boundary) => boundaries.push(boundary), + randomSuffix: () => '01'.repeat(16), + })(dataDir); + const fixture = await signedFixture('2'); + boundaries.length = 0; + await Promise.all([ + store.stageVerifiedObjects([fixture]), + store.stageVerifiedObjects([fixture]), + ]); + expect(boundaries).toEqual([ + 'directory.created', + 'directory.mode-secured', + 'directory.self-fsynced', + 'directory.parent-fsynced', + 'object.temp-written', + 'object.temp-mode-secured', + 'object.temp-fsynced', + 'object.renamed', + 'object.parent-fsynced', + 'directory.created', + 'directory.mode-secured', + 'directory.self-fsynced', + 'directory.parent-fsynced', + 'directory.created', + 'directory.mode-secured', + 'directory.self-fsynced', + 'directory.parent-fsynced', + 'signature.temp-written', + 'signature.temp-mode-secured', + 'signature.temp-fsynced', + 'signature.renamed', + 'signature.parent-fsynced', + ]); + boundaries.length = 0; + await store.stageVerifiedObjects([fixture]); + expect(boundaries).toEqual([]); + }); + + it('requires an unforgeable signature proof bound to the exact envelope before I/O', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const first = await signedFixture('3'); + const second = await signedFixture('4'); + const firstPaths = pathsFor(dataDir, first.envelope); + + expect(() => store.stageVerifiedObjects([{ + envelope: first.envelope, + issuerSignature: Object.freeze({}) as VerifiedControlEnvelopeIssuerSignatureV1, + }])).toThrow(expect.objectContaining({ code: 'control-store-verification' })); + expect(() => store.stageVerifiedObjects([{ + envelope: first.envelope, + issuerSignature: second.issuerSignature, + }])).toThrow(expect.objectContaining({ code: 'control-store-verification' })); + await expect(stat(firstPaths.object)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('snapshots inputs before any durability callback can mutate caller-owned objects', async () => { + const dataDir = await temporaryDataDirectory(); + const fixture = await signedFixture('5'); + const originalEnvelope = structuredClone(fixture.envelope); + let mutated = false; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: (boundary) => { + if (boundary !== 'object.temp-written' || mutated) return; + mutated = true; + (fixture.envelope.payload as { sequence: string }).sequence = 'mutated'; + }, + })(dataDir); + + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, originalEnvelope); + const loaded = await store.getVerifiedObject({ + objectDigest: originalEnvelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + expect(loaded?.envelope).toEqual(originalEnvelope); + }); + + it('fails closed on canonical object or signature corruption and never overwrites it', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('6'); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + + await writeFile(paths.object, '{}', { mode: RFC64_CONTROL_OBJECT_STORE_FILE_MODE }); + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).rejects.toMatchObject({ code: 'control-store-corrupt' }); + await expect(store.stageVerifiedObjects([fixture])) + .rejects.toMatchObject({ code: 'control-store-corrupt' }); + expect(await readFile(paths.object, 'utf8')).toBe('{}'); + + await unlink(paths.object); + await store.stageVerifiedObjects([fixture]); + await writeFile(paths.signature, '{}', { mode: RFC64_CONTROL_OBJECT_STORE_FILE_MODE }); + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).rejects.toMatchObject({ code: 'control-store-corrupt' }); + }); + + it('cleans an unrenamed temp after a pre-visibility fault and converges on retry', async () => { + const dataDir = await temporaryDataDirectory(); + const fixture = await signedFixture('7'); + let injected = false; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: (boundary) => { + if (boundary === 'object.temp-fsynced' && !injected) { + injected = true; + throw new Error('injected pre-rename fault'); + } + }, + })(dataDir); + const paths = pathsFor(dataDir, fixture.envelope); + + await expect(store.stageVerifiedObjects([fixture])) + .rejects.toMatchObject({ code: 'control-store-durability' }); + await expect(stat(paths.object)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); + }); + + it('treats a post-rename fault as an unreachable orphan and safely completes on retry', async () => { + const dataDir = await temporaryDataDirectory(); + const fixture = await signedFixture('8'); + let injected = false; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: (boundary) => { + if (boundary === 'object.renamed' && !injected) { + injected = true; + throw new Error('injected post-rename fault'); + } + }, + })(dataDir); + const paths = pathsFor(dataDir, fixture.envelope); + + await expect(store.stageVerifiedObjects([fixture])) + .rejects.toMatchObject({ code: 'control-store-durability' }); + expect(await readFile(paths.object, 'utf8')).toContain('dkg-rfc64-control-store-test-v1'); + await expect(stat(paths.signature)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); + }); + + it('rejects symlinked store topology instead of following it', async () => { + const dataDir = await temporaryDataDirectory(); + const outside = await temporaryDataDirectory(); + const first = await openRfc64ControlObjectStoreV1(dataDir); + first.close(); + const objects = join(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, 'objects'); + await rm(objects, { recursive: true, force: true }); + await symlink(outside, objects, process.platform === 'win32' ? 'junction' : 'dir'); + + await expect(openRfc64ControlObjectStoreV1(dataDir)) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + }); + + it('returns null for an exact cache miss without calling the verifier', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + const missing = `0x${'12'.repeat(32)}` as Digest32V1; + const missingVariant = `0x${'34'.repeat(32)}` as Digest32V1; + await expect(store.getVerifiedObject({ + objectDigest: missing, + signatureVariantDigest: missingVariant, + verifyIssuerSignature, + })).resolves.toBeNull(); + expect(verifyIssuerSignature).not.toHaveBeenCalled(); + }); + + it('bounds dense batches and fences all operations after close', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('9'); + const oversized = Array.from( + { length: RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS + 1 }, + () => fixture, + ); + const holey = new Array(1); + expect(() => store.stageVerifiedObjects([])) + .toThrow(expect.objectContaining({ code: 'control-store-input' })); + expect(() => store.stageVerifiedObjects(oversized)) + .toThrow(expect.objectContaining({ code: 'control-store-input' })); + expect(() => store.stageVerifiedObjects(holey)) + .toThrow(expect.objectContaining({ code: 'control-store-input' })); + + store.close(); + expect(store.closed).toBe(true); + expect(() => store.stageVerifiedObjects([fixture])) + .toThrow(expect.objectContaining({ code: 'control-store-closed' })); + expect(() => store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: pathsFor(dataDir, fixture.envelope).signatureDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).toThrow(expect.objectContaining({ code: 'control-store-closed' })); + }); + + it('uses a closed typed error registry', () => { + expect(() => new Rfc64ControlObjectStoreErrorV1( + 'not-registered' as never, + 'bad', + )).toThrow(TypeError); + expect(new Set(RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1).size) + .toBe(RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1.length); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 08ba69dc43..e0c5c8434a 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -115,6 +115,7 @@ export default defineConfig({ "test/rfc64-catalog-row-authorship.test.ts", "test/rfc64-agent-inventory-lifecycle.test.ts", "test/rfc64-author-catalog-producer.test.ts", + "test/rfc64-control-object-store-v1.test.ts", ], testTimeout: 60_000, maxWorkers: 1, diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index 85436e8db5..02ae18a59d 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -224,6 +224,33 @@ export function assertControlSignatureVariantDigest( } } +/** Return the exact bounded canonical bytes of a detached signature variant. */ +export function canonicalizeControlSignatureVariantBytes( + variant: ControlObjectSignatureVariantV1, +): Uint8Array { + if (!isPlainRecord(variant)) { + throw new Error('Control signature variant must be a plain JSON object'); + } + assertExactKeys( + variant, + ['objectDigest', 'signature', 'signatureVariantDigest'], + 'control signature variant', + ); + assertControlSignatureVariantDigest( + variant.objectDigest, + variant.signature, + variant.signatureVariantDigest, + ); + return canonicalizeJsonBytes({ + objectDigest: variant.objectDigest, + signature: variant.signature, + signatureVariantDigest: variant.signatureVariantDigest, + }, { + maxBytes: MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + maxDepth: 1, + }); +} + /** Strictly decode the normalized detached-signature record defined by RFC-64. */ export function parseCanonicalControlSignatureVariant( input: string | Uint8Array, @@ -237,20 +264,8 @@ export function parseCanonicalControlSignatureVariant( ), maxDepth: Math.min(options.maxDepth ?? 1, 1), }); - if (!isPlainRecord(parsed)) { - throw new Error('Control signature variant must be a plain JSON object'); - } - assertExactKeys( - parsed, - ['objectDigest', 'signature', 'signatureVariantDigest'], - 'control signature variant', - ); const variant = parsed as unknown as ControlObjectSignatureVariantV1; - assertControlSignatureVariantDigest( - variant.objectDigest, - variant.signature, - variant.signatureVariantDigest, - ); + canonicalizeControlSignatureVariantBytes(variant); return variant; } diff --git a/packages/core/test/sync-control-object.test.ts b/packages/core/test/sync-control-object.test.ts index ebb938c643..44bb16a3c0 100644 --- a/packages/core/test/sync-control-object.test.ts +++ b/packages/core/test/sync-control-object.test.ts @@ -7,6 +7,7 @@ import { assertControlObjectDigest, assertSignedControlEnvelope, assertUnsignedControlEnvelope, + canonicalizeControlSignatureVariantBytes, canonicalizeSignedControlEnvelopeBytes, canonicalizeUnsignedControlEnvelopeBytes, computeControlObjectDigestHex, @@ -138,6 +139,11 @@ describe('Track-2 control-object envelopes', () => { signature: EOA_SIGNATURE, signatureVariantDigest: EOA_SIGNATURE_VARIANT_DIGEST, }); + expect(new TextDecoder().decode(canonicalizeControlSignatureVariantBytes({ + signatureVariantDigest: EOA_SIGNATURE_VARIANT_DIGEST, + signature: EOA_SIGNATURE, + objectDigest: EOA_DIGEST, + }))).toBe(EOA_CANONICAL_SIGNATURE_VARIANT); }); it('is independent of JavaScript insertion order and verifies exact claims', () => { From 6bcdc3170a2ecb18edc77452b000224abe25f541 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 12:37:00 +0200 Subject: [PATCH 067/292] fix(agent): fence Windows control store durability --- .../src/rfc64/control-object-store-v1.ts | 58 ++++++++++++++++++- .../rfc64-control-object-store-v1.test.ts | 22 ++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/rfc64/control-object-store-v1.ts b/packages/agent/src/rfc64/control-object-store-v1.ts index 097e9ed4cc..555924dd79 100644 --- a/packages/agent/src/rfc64/control-object-store-v1.ts +++ b/packages/agent/src/rfc64/control-object-store-v1.ts @@ -43,6 +43,14 @@ export const RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH = export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = 0o700; export const RFC64_CONTROL_OBJECT_STORE_FILE_MODE = 0o600; export const RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS = 16; +export const RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY = + 'posix-atomic-rename-directory-fsync-v1' as const; +export const RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY = + 'windows-file-flush-atomic-rename-v1' as const; + +export type Rfc64ControlObjectStoreNamespaceDurabilityV1 = + | typeof RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY + | typeof RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY; const OBJECTS_DIRECTORY = 'objects'; const SIGNATURES_DIRECTORY = 'signatures'; @@ -85,11 +93,15 @@ export type Rfc64ControlObjectStoreDurabilityBoundaryV1 = | 'object.temp-fsynced' | 'object.renamed' | 'object.parent-fsynced' + | 'object.existing-fsynced' + | 'object.existing-parent-fsynced' | 'signature.temp-written' | 'signature.temp-mode-secured' | 'signature.temp-fsynced' | 'signature.renamed' - | 'signature.parent-fsynced'; + | 'signature.parent-fsynced' + | 'signature.existing-fsynced' + | 'signature.existing-parent-fsynced'; interface Rfc64ControlObjectStoreIoV1 { readonly boundary: (boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1) => void; @@ -112,8 +124,10 @@ export interface StagedVerifiedControlObjectV1 { } export interface StageVerifiedControlObjectsResultV1 { - /** Every named file and containing directory has crossed its fsync boundary. */ + /** Every named file and platform-supported containing-directory barrier has completed. */ readonly durable: true; + /** Explicitly fences later semantic ref publication from weaker namespace barriers. */ + readonly namespaceDurability: Rfc64ControlObjectStoreNamespaceDurabilityV1; readonly objects: readonly StagedVerifiedControlObjectV1[]; } @@ -134,6 +148,7 @@ export interface StoredVerifiedControlObjectV1 { export interface Rfc64ControlObjectStoreV1 { readonly rootPath: string; readonly closed: boolean; + readonly namespaceDurability: Rfc64ControlObjectStoreNamespaceDurabilityV1; /** * Durably stage immutable unsigned envelopes and detached signature variants. * Success does not advance a semantic ref or make any catalog authoritative. @@ -197,6 +212,9 @@ interface PreparedStoredControlObjectV1 { class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { #closed = false; #operationTail: Promise = Promise.resolve(); + readonly namespaceDurability = process.platform === 'win32' + ? RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY + : RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY; constructor( readonly rootPath: string, @@ -225,6 +243,7 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { } return Object.freeze({ durable: true as const, + namespaceDurability: this.namespaceDurability, objects: Object.freeze(result), }); }); @@ -519,6 +538,13 @@ async function stageExactFile( if (!bytesEqual(existing, bytes)) { fail('control-store-corrupt', `existing ${kind} bytes differ for the same digest key`); } + // A prior attempt can fail after rename but before the parent-directory + // barrier. Re-establish both barriers before an idempotent retry reports + // success; merely observing the exact bytes is not a durability proof. + await fsyncRegularFile(targetPath, `existing ${kind}`); + io.boundary(`${kind}.existing-fsynced`); + await fsyncDirectory(dirname(targetPath)); + io.boundary(`${kind}.existing-parent-fsynced`); return; } @@ -680,6 +706,11 @@ function assertOwner(uid: number, label: string): void { } async function fsyncDirectory(path: string): Promise { + // Match the inventory-v1 durability primitive: Node maps fsync to + // FlushFileBuffers on Windows, which rejects directory handles with EPERM. + // Regular files are still flushed before rename (and again on idempotent + // recovery); the directory barrier is available only on POSIX backends. + if (process.platform === 'win32') return; let handle: Awaited> | null = null; try { handle = await open(path, constants.O_RDONLY); @@ -696,6 +727,29 @@ async function fsyncDirectory(path: string): Promise { } } +async function fsyncRegularFile(path: string, label: string): Promise { + let handle: Awaited> | null = null; + try { + const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; + // FlushFileBuffers requires a write-capable Windows handle. POSIX retains + // a read-only descriptor so an idempotent recovery never gains write access. + handle = await open( + path, + process.platform === 'win32' ? 'r+' : constants.O_RDONLY | noFollow, + ); + const stat = await handle.stat(); + if (!stat.isFile()) { + fail('control-store-unsafe-path', `${label} fsync target is not a regular file`); + } + await handle.sync(); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-durability', `failed to fsync ${label}`, cause); + } finally { + if (handle !== null) await handle.close().catch(() => undefined); + } +} + function snapshotDigest(value: string, label: string): Digest32V1 { try { assertCanonicalDigest(value, label); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index e9176c739a..765a50830b 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -24,7 +24,9 @@ import { RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, RFC64_CONTROL_OBJECT_STORE_FILE_MODE, RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, + RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, Rfc64ControlObjectStoreErrorV1, createRfc64ControlObjectStoreTestOpenerV1, openRfc64ControlObjectStoreV1, @@ -107,6 +109,9 @@ describe('RFC-64 durable control-object store v1', () => { expect(result).toEqual({ durable: true, + namespaceDurability: process.platform === 'win32' + ? RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY + : RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, objects: [{ objectDigest: fixture.envelope.objectDigest, signatureVariantDigest: pathsFor(dataDir, fixture.envelope).signatureDigest, @@ -114,6 +119,7 @@ describe('RFC-64 durable control-object store v1', () => { }); expect(Object.isFrozen(result)).toBe(true); expect(Object.isFrozen(result.objects)).toBe(true); + expect(store.namespaceDurability).toBe(result.namespaceDurability); const paths = pathsFor(dataDir, fixture.envelope); const unsigned = JSON.parse(await readFile(paths.object, 'utf8')) as Record; @@ -226,10 +232,19 @@ describe('RFC-64 durable control-object store v1', () => { 'signature.temp-fsynced', 'signature.renamed', 'signature.parent-fsynced', + 'object.existing-fsynced', + 'object.existing-parent-fsynced', + 'signature.existing-fsynced', + 'signature.existing-parent-fsynced', ]); boundaries.length = 0; await store.stageVerifiedObjects([fixture]); - expect(boundaries).toEqual([]); + expect(boundaries).toEqual([ + 'object.existing-fsynced', + 'object.existing-parent-fsynced', + 'signature.existing-fsynced', + 'signature.existing-parent-fsynced', + ]); }); it('requires an unforgeable signature proof bound to the exact envelope before I/O', async () => { @@ -324,8 +339,10 @@ describe('RFC-64 durable control-object store v1', () => { const dataDir = await temporaryDataDirectory(); const fixture = await signedFixture('8'); let injected = false; + const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; const store = await createRfc64ControlObjectStoreTestOpenerV1({ boundary: (boundary) => { + boundaries.push(boundary); if (boundary === 'object.renamed' && !injected) { injected = true; throw new Error('injected post-rename fault'); @@ -338,7 +355,10 @@ describe('RFC-64 durable control-object store v1', () => { .rejects.toMatchObject({ code: 'control-store-durability' }); expect(await readFile(paths.object, 'utf8')).toContain('dkg-rfc64-control-store-test-v1'); await expect(stat(paths.signature)).rejects.toMatchObject({ code: 'ENOENT' }); + boundaries.length = 0; await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); + expect(boundaries).toContain('object.existing-fsynced'); + expect(boundaries).toContain('object.existing-parent-fsynced'); }); it('rejects symlinked store topology instead of following it', async () => { From 251fd707e187e578802cfd13d3377b8c9ab43a04 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 12:49:44 +0200 Subject: [PATCH 068/292] fix(agent): harden RFC-64 control store lifecycle --- .github/workflows/rfc64-inventory-windows.yml | 1 + packages/agent/src/dkg-agent-base.ts | 58 +++--------- packages/agent/src/index.ts | 18 +++- .../src/rfc64/control-object-store-v1.ts | 44 ++++++--- packages/agent/src/rfc64/persistence-v1.ts | 90 +++++++++++++++++++ .../rfc64-agent-inventory-lifecycle.test.ts | 37 ++++---- .../rfc64-control-object-store-v1.test.ts | 75 +++++++++++++++- 7 files changed, 243 insertions(+), 80 deletions(-) create mode 100644 packages/agent/src/rfc64/persistence-v1.ts diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 27005d4561..516edbbeed 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -7,6 +7,7 @@ on: - 'packages/agent/src/rfc64/inventory-v1/**' - 'packages/agent/src/rfc64/author-catalog-producer.ts' - 'packages/agent/src/rfc64/control-object-store-v1.ts' + - 'packages/agent/src/rfc64/persistence-v1.ts' - 'packages/agent/src/dkg-agent-base.ts' - 'packages/agent/src/index.ts' - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index 9754a6af51..d4e40dc268 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -12,13 +12,9 @@ import { createHash, randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; import { - openInventoryV1, - type Rfc64InventoryV1Foundation, -} from './rfc64/inventory-v1/index.js'; -import { - openRfc64ControlObjectStoreV1, - type Rfc64ControlObjectStoreV1, -} from './rfc64/control-object-store-v1.js'; + openRfc64PersistenceV1, + type Rfc64PersistenceV1, +} from './rfc64/persistence-v1.js'; import { resolveVmReconcileStartupMaxDelayMs } from './startup-jitter.js'; import { DKGNode, ProtocolRouter, GossipSubManager, TypedEventBus, DKGEvent, @@ -992,13 +988,10 @@ export class DKGAgentBase { protected readonly config: ResolvedDKGAgentConfig; protected started = false; /** - * OT-RFC-64 durable inventory ownership. Persistent agents hold exactly one - * production foundation for their data directory; agents without dataDir - * are deliberately dormant and never substitute an in-memory inventory. + * One OT-RFC-64 persistence owner for the inventory lease and every resource + * protected by it. Agents without dataDir remain deliberately dormant. */ - protected rfc64InventoryV1?: Rfc64InventoryV1Foundation; - /** Durable immutable object cache opened only after inventory path ownership. */ - protected rfc64ControlObjectStoreV1?: Rfc64ControlObjectStoreV1; + protected rfc64PersistenceV1?: Rfc64PersistenceV1; protected readonly subscribedContextGraphs = new Map(); protected contextGraphSubscriptionRehydrationStatus: ContextGraphSubscriptionRehydrationStatus | null = null; protected readonly contextGraphSubscriptionRehydrationAccountedIds = new Set(); @@ -1575,32 +1568,10 @@ export class DKGAgentBase { * open the inherited-owner control-object tree before network consumers. */ protected async prepareRfc64InventoryV1(): Promise { - if (!this.config.dataDir || this.rfc64InventoryV1 !== undefined) return; - - const inventory = await openInventoryV1(this.config.dataDir); - let controlObjectStore: Rfc64ControlObjectStoreV1 | undefined; - try { - for (;;) { - const batch = inventory.purgeNextStartupStaleCandidateBatch(); - if (batch.done) break; - await this.yieldRfc64InventoryV1StartupBatch(); - } - // openInventoryV1 has already established single-process ownership and - // secured rfc64-sync; Windows children inherit that owner-only ACL. - controlObjectStore = await openRfc64ControlObjectStoreV1(this.config.dataDir); - } catch (cause) { - try { - inventory.close(); - } catch (closeCause) { - throw new AggregateError( - [cause, closeCause], - 'RFC-64 inventory startup purge and cleanup both failed', - ); - } - throw cause; - } - this.rfc64ControlObjectStoreV1 = controlObjectStore; - this.rfc64InventoryV1 = inventory; + if (!this.config.dataDir || this.rfc64PersistenceV1 !== undefined) return; + this.rfc64PersistenceV1 = await openRfc64PersistenceV1(this.config.dataDir, { + yieldAfterPurgeBatch: () => this.yieldRfc64InventoryV1StartupBatch(), + }); } /** Yield between fixed-size adapter batches so startup cannot monopolize the event loop. */ @@ -1613,12 +1584,9 @@ export class DKGAgentBase { * references before closing so fail-stop cleanup cannot be retried. */ protected closeRfc64InventoryV1(): void { - const controlObjectStore = this.rfc64ControlObjectStoreV1; - const inventory = this.rfc64InventoryV1; - this.rfc64ControlObjectStoreV1 = undefined; - this.rfc64InventoryV1 = undefined; - controlObjectStore?.close(); - inventory?.close(); + const persistence = this.rfc64PersistenceV1; + this.rfc64PersistenceV1 = undefined; + persistence?.close(); } /** diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 8785f29a2f..3b6954d0af 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -36,7 +36,23 @@ export { } from './auth/agent-delegation.js'; export * from './rfc64/catalog-row-authorship.js'; export * from './rfc64/author-catalog-producer.js'; -export * from './rfc64/control-object-store-v1.js'; +export { + RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, + RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, + RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, + Rfc64ControlObjectStoreErrorV1, + openRfc64ControlObjectStoreV1, + type GetVerifiedControlObjectInputV1, + type Rfc64ControlObjectStoreErrorCodeV1, + type Rfc64ControlObjectStoreNamespaceDurabilityV1, + type Rfc64ControlObjectStoreV1, + type StageVerifiedControlObjectV1, + type StageVerifiedControlObjectsResultV1, + type StagedVerifiedControlObjectV1, + type StoredVerifiedControlObjectV1, +} from './rfc64/control-object-store-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/control-object-store-v1.ts b/packages/agent/src/rfc64/control-object-store-v1.ts index 555924dd79..6a31448d8d 100644 --- a/packages/agent/src/rfc64/control-object-store-v1.ts +++ b/packages/agent/src/rfc64/control-object-store-v1.ts @@ -211,7 +211,7 @@ interface PreparedStoredControlObjectV1 { class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { #closed = false; - #operationTail: Promise = Promise.resolve(); + readonly #fileWriteTails = new Map>(); readonly namespaceDurability = process.platform === 'win32' ? RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY : RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY; @@ -230,7 +230,7 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { ): Promise { this.requireOpen(); const prepared = prepareStageBatch(input); - return this.enqueue(async () => { + return (async () => { this.requireOpen(); const result = new Array(prepared.length); for (let index = 0; index < prepared.length; index += 1) { @@ -246,7 +246,7 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { namespaceDurability: this.namespaceDurability, objects: Object.freeze(result), }); - }); + })(); } getVerifiedObject( @@ -262,8 +262,7 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { if (typeof verifyIssuerSignature !== 'function') { fail('control-store-input', 'verifyIssuerSignature must be a function'); } - return this.enqueue(async () => { - this.requireOpen(); + return (async () => { const objectPath = this.objectPath(objectDigest); const signaturePath = this.signaturePath(objectDigest, signatureVariantDigest); const [unsignedBytes, variantBytes] = await Promise.all([ @@ -299,6 +298,9 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { fail('control-store-corrupt', 'stored control object is not canonical for its exact keys', cause); } + // The caller callback intentionally runs outside every file-key tail. It + // may compose this cache with another exact read without deadlocking the + // write that produced the snapshot above. let issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; try { issuerSignature = await verifyIssuerSignature(envelope); @@ -308,7 +310,7 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { fail('control-store-verification', 'stored control object signature verification failed', cause); } return Object.freeze({ envelope, issuerSignature }); - }); + })(); } close(): void { @@ -317,15 +319,13 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { private async stagePrepared(item: PreparedStoredControlObjectV1): Promise { const objectPath = this.objectPath(item.objectDigest); - await ensureSecureDirectory(dirname(objectPath), this.rootPath, this.io); - await stageExactFile(objectPath, item.unsignedBytes, 'object', this.io); + await this.stageFileByKey(objectPath, item.unsignedBytes, 'object'); const signaturePath = this.signaturePath( item.objectDigest, item.signatureVariantDigest, ); - await ensureSecureDirectory(dirname(signaturePath), this.rootPath, this.io); - await stageExactFile(signaturePath, item.signatureVariantBytes, 'signature', this.io); + await this.stageFileByKey(signaturePath, item.signatureVariantBytes, 'signature'); } private objectPath(objectDigest: Digest32V1): string { @@ -353,9 +353,27 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { ); } - private enqueue(operation: () => Promise): Promise { - const run = this.#operationTail.then(operation, operation); - this.#operationTail = run.then(() => undefined, () => undefined); + private stageFileByKey( + targetPath: string, + bytes: Uint8Array, + kind: 'object' | 'signature', + ): Promise { + const previous = this.#fileWriteTails.get(targetPath) ?? Promise.resolve(); + const run = previous.then(async () => { + this.requireOpen(); + await ensureSecureDirectory(dirname(targetPath), this.rootPath, this.io); + await stageExactFile(targetPath, bytes, kind, this.io); + }, async () => { + this.requireOpen(); + await ensureSecureDirectory(dirname(targetPath), this.rootPath, this.io); + await stageExactFile(targetPath, bytes, kind, this.io); + }); + this.#fileWriteTails.set(targetPath, run); + void run.finally(() => { + if (this.#fileWriteTails.get(targetPath) === run) { + this.#fileWriteTails.delete(targetPath); + } + }).catch(() => undefined); return run; } diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts new file mode 100644 index 0000000000..31591309d9 --- /dev/null +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -0,0 +1,90 @@ +import { + openInventoryV1, + type Rfc64InventoryV1Foundation, +} from './inventory-v1/index.js'; +import { + openRfc64ControlObjectStoreV1, + type Rfc64ControlObjectStoreV1, +} from './control-object-store-v1.js'; + +export interface OpenRfc64PersistenceOptionsV1 { + /** Yield after each non-terminal fixed-size startup purge batch. */ + readonly yieldAfterPurgeBatch: () => Promise; +} + +/** One lifecycle owner for every RFC-64 resource protected by the inventory lease. */ +export interface Rfc64PersistenceV1 { + readonly inventory: Rfc64InventoryV1Foundation; + readonly controlObjectStore: Rfc64ControlObjectStoreV1; + readonly closed: boolean; + /** Close the control store before releasing inventory ownership. */ + close(): void; +} + +class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { + #closed = false; + + constructor( + readonly inventory: Rfc64InventoryV1Foundation, + readonly controlObjectStore: Rfc64ControlObjectStoreV1, + ) {} + + get closed(): boolean { + return this.#closed; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + const failures: unknown[] = []; + try { + this.controlObjectStore.close(); + } catch (cause) { + failures.push(cause); + } + try { + this.inventory.close(); + } catch (cause) { + failures.push(cause); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, 'RFC-64 persistence resources failed to close'); + } + } +} + +/** + * Acquire the inventory lease, finish bounded stale-candidate cleanup, then + * open the immutable control-object cache under that same ownership boundary. + */ +export async function openRfc64PersistenceV1( + dataDir: string, + options: OpenRfc64PersistenceOptionsV1, +): Promise { + const yieldAfterPurgeBatch = options?.yieldAfterPurgeBatch; + if (typeof yieldAfterPurgeBatch !== 'function') { + throw new TypeError('yieldAfterPurgeBatch must be a function'); + } + + const inventory = await openInventoryV1(dataDir); + try { + for (;;) { + const batch = inventory.purgeNextStartupStaleCandidateBatch(); + if (batch.done) break; + await yieldAfterPurgeBatch(); + } + const controlObjectStore = await openRfc64ControlObjectStoreV1(dataDir); + return new OwnedRfc64PersistenceV1(inventory, controlObjectStore); + } catch (cause) { + try { + inventory.close(); + } catch (closeCause) { + throw new AggregateError( + [cause, closeCause], + 'RFC-64 persistence startup and inventory cleanup both failed', + ); + } + throw cause; + } +} diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index e3e46309c0..f90036bb4e 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -33,8 +33,7 @@ function syntheticAgent(dataDirectory?: string): any { const agent = Object.create(DKGAgent.prototype) as any; Object.assign(agent, { config: dataDirectory === undefined ? {} : { dataDir: dataDirectory }, - rfc64InventoryV1: undefined, - rfc64ControlObjectStoreV1: undefined, + rfc64PersistenceV1: undefined, }); return agent; } @@ -111,8 +110,12 @@ function minimalStartedAgent( router: { closePooling: vi.fn(async () => {}) }, node: { stop: vi.fn(async () => { order.push('node'); }) }, syncVerifyWorker: { close: vi.fn(async () => { order.push('sync-worker'); }) }, - rfc64InventoryV1: { close: inventoryClose }, - rfc64ControlObjectStoreV1: { close: () => { order.push('control-store'); } }, + rfc64PersistenceV1: { + close: () => { + order.push('control-store'); + inventoryClose(); + }, + }, store: { close: vi.fn(async () => { order.push('store'); }) }, log: { warn: vi.fn() }, }); @@ -200,8 +203,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await agent.prepareRfc64InventoryV1(); await agent.prepareRfc64InventoryV1(); - expect(agent.rfc64InventoryV1).toBeUndefined(); - expect(agent.rfc64ControlObjectStoreV1).toBeUndefined(); + expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); }); @@ -214,12 +216,12 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { agent.yieldRfc64InventoryV1StartupBatch = yieldBatch; await agent.prepareRfc64InventoryV1(); - const ownedFoundation = agent.rfc64InventoryV1; - const ownedControlObjectStore = agent.rfc64ControlObjectStoreV1; + const ownedPersistence = agent.rfc64PersistenceV1; + const ownedFoundation = ownedPersistence.inventory; + const ownedControlObjectStore = ownedPersistence.controlObjectStore; await agent.prepareRfc64InventoryV1(); - expect(agent.rfc64InventoryV1).toBe(ownedFoundation); - expect(agent.rfc64ControlObjectStoreV1).toBe(ownedControlObjectStore); + expect(agent.rfc64PersistenceV1).toBe(ownedPersistence); expect(ownedFoundation.databasePath).toBe( join(dataDirectory, INVENTORY_V1_RELATIVE_PATH), ); @@ -230,8 +232,8 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { ); agent.closeRfc64InventoryV1(); - expect(agent.rfc64InventoryV1).toBeUndefined(); - expect(agent.rfc64ControlObjectStoreV1).toBeUndefined(); + expect(agent.rfc64PersistenceV1).toBeUndefined(); + expect(ownedPersistence.closed).toBe(true); expect(ownedControlObjectStore.closed).toBe(true); expect(candidateLoadCount(dataDirectory)).toBe(0); expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); @@ -274,8 +276,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await expect(agent.prepareRfc64InventoryV1()) .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); - expect(agent.rfc64InventoryV1).toBeUndefined(); - expect(agent.rfc64ControlObjectStoreV1).toBeUndefined(); + expect(agent.rfc64PersistenceV1).toBeUndefined(); const replacement = await openInventoryV1(dataDirectory); replacement.close(); @@ -290,7 +291,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { started: false, coreHostRecordingGeneration: 0, prepareRfc64InventoryV1: vi.fn(async () => { - agent.rfc64InventoryV1 = { close }; + agent.rfc64PersistenceV1 = { close }; }), node: { start: vi.fn(async () => { throw failure; }) }, log: { info: vi.fn(), warn: vi.fn() }, @@ -298,7 +299,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await expect(agent.start()).rejects.toBe(failure); expect(close).toHaveBeenCalledOnce(); - expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(agent.started).toBe(false); }); @@ -312,7 +313,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { expect(order).toEqual(['node', 'sync-worker', 'control-store', 'inventory', 'store']); expect(inventoryClose).toHaveBeenCalledOnce(); - expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(agent.started).toBe(false); }); @@ -327,7 +328,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await expect(agent.stop()).rejects.toBe(failure); expect(order).toEqual(['node', 'sync-worker', 'control-store', 'inventory', 'store']); - expect(agent.rfc64InventoryV1).toBeUndefined(); + expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(agent.started).toBe(false); await expect(agent.stop()).resolves.toBeUndefined(); }); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 765a50830b..066af9408e 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { computeControlObjectDigestHex, @@ -223,6 +223,8 @@ describe('RFC-64 durable control-object store v1', () => { 'directory.mode-secured', 'directory.self-fsynced', 'directory.parent-fsynced', + 'object.existing-fsynced', + 'object.existing-parent-fsynced', 'directory.created', 'directory.mode-secured', 'directory.self-fsynced', @@ -232,8 +234,6 @@ describe('RFC-64 durable control-object store v1', () => { 'signature.temp-fsynced', 'signature.renamed', 'signature.parent-fsynced', - 'object.existing-fsynced', - 'object.existing-parent-fsynced', 'signature.existing-fsynced', 'signature.existing-parent-fsynced', ]); @@ -247,6 +247,24 @@ describe('RFC-64 durable control-object store v1', () => { ]); }); + it('allows independent digest keys to stage concurrently without a store-wide queue', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const [first, second] = await Promise.all([signedFixture('2a'), signedFixture('2b')]); + + const [firstResult, secondResult] = await Promise.all([ + store.stageVerifiedObjects([first]), + store.stageVerifiedObjects([second]), + ]); + + expect(firstResult.objects[0].objectDigest).toBe(first.envelope.objectDigest); + expect(secondResult.objects[0].objectDigest).toBe(second.envelope.objectDigest); + await expect(Promise.all([ + readFile(pathsFor(dataDir, first.envelope).object), + readFile(pathsFor(dataDir, second.envelope).object), + ])).resolves.toHaveLength(2); + }); + it('requires an unforgeable signature proof bound to the exact envelope before I/O', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); @@ -313,6 +331,57 @@ describe('RFC-64 durable control-object store v1', () => { signatureVariantDigest: paths.signatureDigest, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, })).rejects.toMatchObject({ code: 'control-store-corrupt' }); + await expect(store.stageVerifiedObjects([fixture])) + .rejects.toMatchObject({ code: 'control-store-corrupt' }); + expect(await readFile(paths.signature, 'utf8')).toBe('{}'); + }); + + it('rejects a canonical signature variant stored under a different exact key', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('6b'); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + const wrongVariant = `0x${'34'.repeat(32)}` as Digest32V1; + const wrongPath = join(dirname(paths.signature), `${wrongVariant.slice(2)}.jcs`); + await writeFile(wrongPath, await readFile(paths.signature), { + mode: RFC64_CONTROL_OBJECT_STORE_FILE_MODE, + }); + const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: wrongVariant, + verifyIssuerSignature, + })).rejects.toMatchObject({ code: 'control-store-corrupt' }); + expect(verifyIssuerSignature).not.toHaveBeenCalled(); + }); + + it('lets an issuer verifier re-enter the same store without deadlocking', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('6c'); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + const missing = `0x${'12'.repeat(32)}` as Digest32V1; + const missingVariant = `0x${'56'.repeat(32)}` as Digest32V1; + const verifyIssuerSignature = vi.fn(async (envelope: SignedControlEnvelopeV1) => { + await expect(store.getVerifiedObject({ + objectDigest: missing, + signatureVariantDigest: missingVariant, + verifyIssuerSignature: async () => { + throw new Error('cache-miss verifier must not run'); + }, + })).resolves.toBeNull(); + return verifyControlEnvelopeIssuerSignatureV1(envelope); + }); + + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature, + })).resolves.toMatchObject({ envelope: fixture.envelope }); + expect(verifyIssuerSignature).toHaveBeenCalledOnce(); }); it('cleans an unrenamed temp after a pre-visibility fault and converges on retry', async () => { From f9df547a7961938784150912755c4d1e4a3fe30b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 13:18:16 +0200 Subject: [PATCH 069/292] fix(agent): isolate RFC-64 durable file policy --- .github/workflows/rfc64-inventory-windows.yml | 2 + packages/agent/src/index.ts | 1 - .../src/rfc64/control-object-store-v1.ts | 471 ++++-------------- .../agent/src/rfc64/durable-file-store-v1.ts | 360 +++++++++++++ packages/agent/src/rfc64/inventory-v1/open.ts | 35 +- packages/agent/src/rfc64/inventory-v1/sql.ts | 9 +- packages/agent/src/rfc64/persistence-v1.ts | 2 +- .../src/rfc64/secure-filesystem-policy-v1.ts | 76 +++ .../rfc64-agent-inventory-lifecycle.test.ts | 17 +- .../rfc64-control-object-store-v1.test.ts | 107 +++- ...fc64-control-store-public-api.typecheck.ts | 10 + packages/agent/tsconfig.type-tests.json | 2 +- 12 files changed, 710 insertions(+), 382 deletions(-) create mode 100644 packages/agent/src/rfc64/durable-file-store-v1.ts create mode 100644 packages/agent/src/rfc64/secure-filesystem-policy-v1.ts create mode 100644 packages/agent/test/rfc64-control-store-public-api.typecheck.ts diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 516edbbeed..cb7a9eba61 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -7,7 +7,9 @@ on: - 'packages/agent/src/rfc64/inventory-v1/**' - 'packages/agent/src/rfc64/author-catalog-producer.ts' - 'packages/agent/src/rfc64/control-object-store-v1.ts' + - 'packages/agent/src/rfc64/durable-file-store-v1.ts' - 'packages/agent/src/rfc64/persistence-v1.ts' + - 'packages/agent/src/rfc64/secure-filesystem-policy-v1.ts' - 'packages/agent/src/dkg-agent-base.ts' - 'packages/agent/src/index.ts' - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3b6954d0af..4460dd1a0c 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -43,7 +43,6 @@ export { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, Rfc64ControlObjectStoreErrorV1, - openRfc64ControlObjectStoreV1, type GetVerifiedControlObjectInputV1, type Rfc64ControlObjectStoreErrorCodeV1, type Rfc64ControlObjectStoreNamespaceDurabilityV1, diff --git a/packages/agent/src/rfc64/control-object-store-v1.ts b/packages/agent/src/rfc64/control-object-store-v1.ts index 6a31448d8d..1bb87d2fb7 100644 --- a/packages/agent/src/rfc64/control-object-store-v1.ts +++ b/packages/agent/src/rfc64/control-object-store-v1.ts @@ -1,21 +1,5 @@ import { randomBytes } from 'node:crypto'; -import { constants } from 'node:fs'; -import { - chmod, - lstat, - mkdir, - open, - rename, - unlink, -} from 'node:fs/promises'; -import { - dirname, - isAbsolute, - join, - relative, - resolve, - sep, -} from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { MAX_CONTROL_OBJECT_BYTES, @@ -37,20 +21,39 @@ import { readVerifiedControlEnvelopeIssuerSignatureV1, type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; +import { + inventoryV1OwnsDataDir, + type Rfc64InventoryV1Foundation, +} from './inventory-v1/open.js'; +import { + Rfc64DurableFileErrorV1, + assertRfc64ExistingDirectoryV1, + ensureRfc64SecureDirectoryTreeV1, + putRfc64ExactBytesV1, + readRfc64OptionalBoundedBytesV1, + type Rfc64DurableFileBoundaryV1, + type Rfc64DurableFileIoV1, +} from './durable-file-store-v1.js'; +import { + RFC64_POSIX_NAMESPACE_DURABILITY_V1, + RFC64_SECURE_DIRECTORY_MODE_V1, + RFC64_SECURE_FILE_MODE_V1, + RFC64_WINDOWS_NAMESPACE_DURABILITY_V1, + rfc64NamespaceDurabilityV1, + type Rfc64NamespaceDurabilityV1, +} from './secure-filesystem-policy-v1.js'; export const RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH = 'rfc64-sync/control-objects-v1' as const; -export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = 0o700; -export const RFC64_CONTROL_OBJECT_STORE_FILE_MODE = 0o600; +export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; +export const RFC64_CONTROL_OBJECT_STORE_FILE_MODE = RFC64_SECURE_FILE_MODE_V1; export const RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS = 16; export const RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY = - 'posix-atomic-rename-directory-fsync-v1' as const; + RFC64_POSIX_NAMESPACE_DURABILITY_V1; export const RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY = - 'windows-file-flush-atomic-rename-v1' as const; + RFC64_WINDOWS_NAMESPACE_DURABILITY_V1; -export type Rfc64ControlObjectStoreNamespaceDurabilityV1 = - | typeof RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY - | typeof RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY; +export type Rfc64ControlObjectStoreNamespaceDurabilityV1 = Rfc64NamespaceDurabilityV1; const OBJECTS_DIRECTORY = 'objects'; const SIGNATURES_DIRECTORY = 'signatures'; @@ -83,30 +86,13 @@ export class Rfc64ControlObjectStoreErrorV1 extends Error { } } +type Rfc64ControlObjectStoreFileKindV1 = 'object' | 'signature'; + export type Rfc64ControlObjectStoreDurabilityBoundaryV1 = - | 'directory.created' - | 'directory.mode-secured' - | 'directory.self-fsynced' - | 'directory.parent-fsynced' - | 'object.temp-written' - | 'object.temp-mode-secured' - | 'object.temp-fsynced' - | 'object.renamed' - | 'object.parent-fsynced' - | 'object.existing-fsynced' - | 'object.existing-parent-fsynced' - | 'signature.temp-written' - | 'signature.temp-mode-secured' - | 'signature.temp-fsynced' - | 'signature.renamed' - | 'signature.parent-fsynced' - | 'signature.existing-fsynced' - | 'signature.existing-parent-fsynced'; - -interface Rfc64ControlObjectStoreIoV1 { - readonly boundary: (boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1) => void; - readonly randomSuffix: () => string; -} + Rfc64DurableFileBoundaryV1; + +interface Rfc64ControlObjectStoreIoV1 + extends Rfc64DurableFileIoV1 {} const PRODUCTION_IO = Object.freeze({ boundary: (_boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1): void => {}, @@ -178,7 +164,14 @@ export type Rfc64ControlObjectStoreTestOpenerV1 = ( */ export async function openRfc64ControlObjectStoreV1( dataDir: string, + inventoryOwnership: Rfc64InventoryV1Foundation, ): Promise { + if (!inventoryV1OwnsDataDir(inventoryOwnership, dataDir)) { + fail( + 'control-store-input', + 'control object store requires the live inventory owner for the same dataDir', + ); + } return openRfc64ControlObjectStoreWithIoV1(dataDir, PRODUCTION_IO); } @@ -212,9 +205,7 @@ interface PreparedStoredControlObjectV1 { class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { #closed = false; readonly #fileWriteTails = new Map>(); - readonly namespaceDurability = process.platform === 'win32' - ? RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY - : RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY; + readonly namespaceDurability = rfc64NamespaceDurabilityV1(); constructor( readonly rootPath: string, @@ -232,15 +223,13 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { const prepared = prepareStageBatch(input); return (async () => { this.requireOpen(); - const result = new Array(prepared.length); - for (let index = 0; index < prepared.length; index += 1) { - const item = prepared[index]; + const result = await Promise.all(prepared.map(async (item) => { await this.stagePrepared(item); - result[index] = Object.freeze({ + return Object.freeze({ objectDigest: item.objectDigest, signatureVariantDigest: item.signatureVariantDigest, }); - } + })); return Object.freeze({ durable: true as const, namespaceDurability: this.namespaceDurability, @@ -265,14 +254,19 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { return (async () => { const objectPath = this.objectPath(objectDigest); const signaturePath = this.signaturePath(objectDigest, signatureVariantDigest); - const [unsignedBytes, variantBytes] = await Promise.all([ - readOptionalBoundedFile(objectPath, MAX_CONTROL_OBJECT_BYTES, 'control object'), - readOptionalBoundedFile( - signaturePath, - MAX_CONTROL_SIGNATURE_VARIANT_BYTES, - 'control signature variant', - ), - ]); + const [unsignedBytes, variantBytes] = await mapDurableFileErrors(async () => + Promise.all([ + readRfc64OptionalBoundedBytesV1( + objectPath, + MAX_CONTROL_OBJECT_BYTES, + 'control object', + ), + readRfc64OptionalBoundedBytesV1( + signaturePath, + MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + 'control signature variant', + ), + ])); if (unsignedBytes === null || variantBytes === null) return null; let unsigned: UnsignedControlEnvelopeV1; @@ -361,12 +355,10 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { const previous = this.#fileWriteTails.get(targetPath) ?? Promise.resolve(); const run = previous.then(async () => { this.requireOpen(); - await ensureSecureDirectory(dirname(targetPath), this.rootPath, this.io); - await stageExactFile(targetPath, bytes, kind, this.io); + await this.putExactFile(targetPath, bytes, kind); }, async () => { this.requireOpen(); - await ensureSecureDirectory(dirname(targetPath), this.rootPath, this.io); - await stageExactFile(targetPath, bytes, kind, this.io); + await this.putExactFile(targetPath, bytes, kind); }); this.#fileWriteTails.set(targetPath, run); void run.finally(() => { @@ -377,6 +369,30 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { return run; } + private async putExactFile( + targetPath: string, + bytes: Uint8Array, + kind: Rfc64ControlObjectStoreFileKindV1, + ): Promise { + await mapDurableFileErrors(async () => { + await ensureRfc64SecureDirectoryTreeV1( + dirname(targetPath), + this.rootPath, + this.io, + ); + await putRfc64ExactBytesV1({ + targetPath, + bytes, + maxBytes: kind === 'object' + ? MAX_CONTROL_OBJECT_BYTES + : MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + label: kind === 'object' ? 'control object' : 'control signature variant', + kind, + io: this.io, + }); + }); + } + private requireOpen(): void { if (this.#closed) fail('control-store-closed', 'control object store is closed'); } @@ -394,18 +410,20 @@ async function openRfc64ControlObjectStoreWithIoV1( } const dataDir = resolve(dataDirInput); const rootPath = resolve(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH); - const relativeRoot = relative(dataDir, rootPath); - if ( - relativeRoot === '..' - || relativeRoot.startsWith(`..${sep}`) - || isAbsolute(relativeRoot) - ) { - fail('control-store-unsafe-path', 'control object store must remain inside dataDir'); - } - await assertExistingDirectory(dataDir, 'DKG data directory', false); - await ensureSecureDirectory(rootPath, dataDir, io); - await ensureSecureDirectory(join(rootPath, OBJECTS_DIRECTORY), rootPath, io); - await ensureSecureDirectory(join(rootPath, SIGNATURES_DIRECTORY), rootPath, io); + await mapDurableFileErrors(async () => { + await assertRfc64ExistingDirectoryV1(dataDir, 'DKG data directory', false); + await ensureRfc64SecureDirectoryTreeV1(rootPath, dataDir, io); + await ensureRfc64SecureDirectoryTreeV1( + join(rootPath, OBJECTS_DIRECTORY), + rootPath, + io, + ); + await ensureRfc64SecureDirectoryTreeV1( + join(rootPath, SIGNATURES_DIRECTORY), + rootPath, + io, + ); + }); return new FileRfc64ControlObjectStoreV1(rootPath, io); } @@ -495,276 +513,21 @@ function unsignedFromSigned(envelope: SignedControlEnvelopeV1): UnsignedControlE } as UnsignedControlEnvelopeV1; } -async function ensureSecureDirectory( - target: string, - containmentRoot: string, - io: Rfc64ControlObjectStoreIoV1, -): Promise { - const resolvedTarget = resolve(target); - const resolvedRoot = resolve(containmentRoot); - const relativeTarget = relative(resolvedRoot, resolvedTarget); - if ( - relativeTarget === '..' - || relativeTarget.startsWith(`..${sep}`) - || isAbsolute(relativeTarget) - ) { - fail('control-store-unsafe-path', 'control store directory escaped its containment root'); - } - - if (relativeTarget.length === 0) { - await assertExistingDirectory(resolvedTarget, 'control store directory', true); - return; - } - await assertExistingDirectory(resolvedRoot, 'control store containment root', false); - let current = resolvedRoot; - for (const component of relativeTarget.split(sep).filter(Boolean)) { - current = join(current, component); - let created = false; - try { - await mkdir(current, { mode: RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE }); - created = true; - io.boundary('directory.created'); - } catch (cause) { - if (!isNodeError(cause, 'EEXIST')) { - fail('control-store-io', `failed to create control store directory ${current}`, cause); - } - } - if (created) { - await chmodSecure(current, RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, 'directory'); - io.boundary('directory.mode-secured'); - await fsyncDirectory(current); - io.boundary('directory.self-fsynced'); - await fsyncDirectory(dirname(current)); - io.boundary('directory.parent-fsynced'); - } - await assertExistingDirectory(current, 'control store directory', true); - } -} - -async function stageExactFile( - targetPath: string, - bytes: Uint8Array, - kind: 'object' | 'signature', - io: Rfc64ControlObjectStoreIoV1, -): Promise { - const existing = await readOptionalBoundedFile( - targetPath, - kind === 'object' ? MAX_CONTROL_OBJECT_BYTES : MAX_CONTROL_SIGNATURE_VARIANT_BYTES, - `existing ${kind}`, - ); - if (existing !== null) { - if (!bytesEqual(existing, bytes)) { - fail('control-store-corrupt', `existing ${kind} bytes differ for the same digest key`); - } - // A prior attempt can fail after rename but before the parent-directory - // barrier. Re-establish both barriers before an idempotent retry reports - // success; merely observing the exact bytes is not a durability proof. - await fsyncRegularFile(targetPath, `existing ${kind}`); - io.boundary(`${kind}.existing-fsynced`); - await fsyncDirectory(dirname(targetPath)); - io.boundary(`${kind}.existing-parent-fsynced`); - return; - } - - const suffix = io.randomSuffix(); - if (!/^[0-9a-f]{32}$/u.test(suffix)) { - fail('control-store-input', 'control store random suffix must be 16 lowercase hex bytes'); - } - const tempPath = join(dirname(targetPath), `.${suffix}.tmp`); - let renamed = false; - let handle: Awaited> | null = null; - try { - handle = await open(tempPath, 'wx', RFC64_CONTROL_OBJECT_STORE_FILE_MODE); - await handle.writeFile(bytes); - io.boundary(`${kind}.temp-written`); - await chmodSecure(tempPath, RFC64_CONTROL_OBJECT_STORE_FILE_MODE, `${kind} temp file`); - io.boundary(`${kind}.temp-mode-secured`); - await handle.sync(); - io.boundary(`${kind}.temp-fsynced`); - await handle.close(); - handle = null; - await rename(tempPath, targetPath); - renamed = true; - io.boundary(`${kind}.renamed`); - await fsyncDirectory(dirname(targetPath)); - io.boundary(`${kind}.parent-fsynced`); - await assertExistingRegularFile(targetPath, `${kind} cache file`, true); - } catch (cause) { - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-durability', `failed to durably stage ${kind} bytes`, cause); - } finally { - if (handle !== null) await handle.close().catch(() => undefined); - if (!renamed) await unlink(tempPath).catch(() => undefined); - } -} - -async function readOptionalBoundedFile( - path: string, - maxBytes: number, - label: string, -): Promise { - try { - const entry = await lstat(path); - if (entry.isSymbolicLink() || !entry.isFile()) { - fail('control-store-unsafe-path', `${label} must be a regular non-symlink file`); - } - } catch (cause) { - if (isNodeError(cause, 'ENOENT')) return null; - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-io', `failed to inspect ${label}`, cause); - } - - let handle: Awaited> | null = null; - try { - const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; - handle = await open(path, constants.O_RDONLY | noFollow); - const stat = await handle.stat(); - if (!stat.isFile() || stat.size < 1 || stat.size > maxBytes) { - fail('control-store-corrupt', `${label} is outside its bounded regular-file shape`); - } - const bytes = new Uint8Array(stat.size); - let offset = 0; - while (offset < bytes.length) { - const read = await handle.read(bytes, offset, bytes.length - offset, offset); - if (read.bytesRead === 0) { - fail('control-store-corrupt', `${label} was truncated during its bounded read`); - } - offset += read.bytesRead; - } - const extra = new Uint8Array(1); - const tail = await handle.read(extra, 0, 1, offset); - if (tail.bytesRead !== 0) { - fail('control-store-corrupt', `${label} grew during its bounded read`); - } - assertOwner(stat.uid, label); - await assertFileMode(stat.mode, label); - return bytes; - } catch (cause) { - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-io', `failed to read ${label}`, cause); - } finally { - if (handle !== null) await handle.close().catch(() => undefined); - } - return fail('control-store-io', `failed to complete bounded read of ${label}`); -} - -async function assertExistingDirectory( - path: string, - label: string, - requireSecureMode: boolean, -): Promise { - try { - const entry = await lstat(path); - if (entry.isSymbolicLink() || !entry.isDirectory()) { - fail('control-store-unsafe-path', `${label} must be a non-symlink directory`); - } - assertOwner(entry.uid, label); - if (requireSecureMode) await assertDirectoryMode(entry.mode, label); - } catch (cause) { - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-io', `failed to inspect ${label}`, cause); - } -} - -async function assertExistingRegularFile( - path: string, - label: string, - requireSecureMode: boolean, -): Promise { - try { - const entry = await lstat(path); - if (entry.isSymbolicLink() || !entry.isFile()) { - fail('control-store-unsafe-path', `${label} must be a regular non-symlink file`); - } - assertOwner(entry.uid, label); - if (requireSecureMode) await assertFileMode(entry.mode, label); - } catch (cause) { - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-io', `failed to inspect ${label}`, cause); - } -} - -async function chmodSecure(path: string, mode: number, label: string): Promise { - try { - if (process.platform !== 'win32') await chmod(path, mode); - const entry = await lstat(path); - assertOwner(entry.uid, label); - if (process.platform !== 'win32' && (entry.mode & 0o777) !== mode) { - throw new Error(`${label} mode is ${(entry.mode & 0o777).toString(8)}, expected ${mode.toString(8)}`); - } - } catch (cause) { - fail('control-store-unsafe-path', `failed to secure ${label}`, cause); - } -} - -async function assertDirectoryMode(mode: number, label: string): Promise { - if ( - process.platform !== 'win32' - && (mode & 0o777) !== RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE - ) { - fail('control-store-unsafe-path', `${label} must have owner-only mode 0700`); - } -} - -async function assertFileMode(mode: number, label: string): Promise { - if ( - process.platform !== 'win32' - && (mode & 0o777) !== RFC64_CONTROL_OBJECT_STORE_FILE_MODE - ) { - fail('control-store-unsafe-path', `${label} must have owner-only mode 0600`); - } -} - -function assertOwner(uid: number, label: string): void { - if (process.platform === 'win32') return; - const processUid = process.getuid?.(); - if (processUid !== undefined && uid !== processUid) { - fail('control-store-unsafe-path', `${label} is not owned by the current process user`); - } -} - -async function fsyncDirectory(path: string): Promise { - // Match the inventory-v1 durability primitive: Node maps fsync to - // FlushFileBuffers on Windows, which rejects directory handles with EPERM. - // Regular files are still flushed before rename (and again on idempotent - // recovery); the directory barrier is available only on POSIX backends. - if (process.platform === 'win32') return; - let handle: Awaited> | null = null; +async function mapDurableFileErrors(operation: () => Promise): Promise { try { - handle = await open(path, constants.O_RDONLY); - const stat = await handle.stat(); - if (!stat.isDirectory()) { - fail('control-store-unsafe-path', 'directory fsync target is not a directory'); - } - await handle.sync(); + return await operation(); } catch (cause) { - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-durability', `failed to fsync directory ${path}`, cause); - } finally { - if (handle !== null) await handle.close().catch(() => undefined); - } -} - -async function fsyncRegularFile(path: string, label: string): Promise { - let handle: Awaited> | null = null; - try { - const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; - // FlushFileBuffers requires a write-capable Windows handle. POSIX retains - // a read-only descriptor so an idempotent recovery never gains write access. - handle = await open( - path, - process.platform === 'win32' ? 'r+' : constants.O_RDONLY | noFollow, - ); - const stat = await handle.stat(); - if (!stat.isFile()) { - fail('control-store-unsafe-path', `${label} fsync target is not a regular file`); - } - await handle.sync(); - } catch (cause) { - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-durability', `failed to fsync ${label}`, cause); - } finally { - if (handle !== null) await handle.close().catch(() => undefined); + if (!(cause instanceof Rfc64DurableFileErrorV1)) throw cause; + const code: Rfc64ControlObjectStoreErrorCodeV1 = cause.code === 'input' + ? 'control-store-input' + : cause.code === 'unsafe-path' + ? 'control-store-unsafe-path' + : cause.code === 'corrupt' + ? 'control-store-corrupt' + : cause.code === 'io' + ? 'control-store-io' + : 'control-store-durability'; + return fail(code, cause.message, cause); } } @@ -777,15 +540,6 @@ function snapshotDigest(value: string, label: string): Digest32V1 { return value as Digest32V1; } -function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { - if (left.byteLength !== right.byteLength) return false; - let different = 0; - for (let index = 0; index < left.byteLength; index += 1) { - different |= left[index] ^ right[index]; - } - return different === 0; -} - function deepFreezePlain(value: T): T { if (value === null || typeof value !== 'object') return value; if (Array.isArray(value)) { @@ -798,11 +552,6 @@ function deepFreezePlain(value: T): T { return Object.freeze(value); } -function isNodeError(cause: unknown, code: string): boolean { - return cause instanceof Error && 'code' in cause - && (cause as NodeJS.ErrnoException).code === code; -} - function fail( code: Rfc64ControlObjectStoreErrorCodeV1, message: string, diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts new file mode 100644 index 0000000000..8f690d9f59 --- /dev/null +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -0,0 +1,360 @@ +import { chmod, lstat, mkdir, open, rename, unlink } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { + RFC64_SECURE_DIRECTORY_MODE_V1, + RFC64_SECURE_FILE_MODE_V1, + fsyncRfc64DirectoryV1, + rfc64CurrentUserOwnsUidV1, + rfc64PosixModeMatchesV1, + rfc64RegularFileFsyncOpenFlagsV1, + rfc64RegularFileReadOpenFlagsV1, + rfc64UsesWindowsFilesystemPolicyV1, +} from './secure-filesystem-policy-v1.js'; + +export type Rfc64DurableFileErrorCodeV1 = + | 'input' + | 'unsafe-path' + | 'corrupt' + | 'io' + | 'durability'; + +export class Rfc64DurableFileErrorV1 extends Error { + constructor( + readonly code: Rfc64DurableFileErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(message, options); + this.name = 'Rfc64DurableFileErrorV1'; + } +} + +type Rfc64DurableFilePublishBoundaryV1 = + | 'temp-written' + | 'temp-mode-secured' + | 'temp-fsynced' + | 'renamed' + | 'parent-fsynced' + | 'existing-fsynced' + | 'existing-parent-fsynced'; + +export type Rfc64DurableFileBoundaryV1 = + | 'directory.created' + | 'directory.mode-secured' + | 'directory.self-fsynced' + | 'directory.parent-fsynced' + | `${TKind}.${Rfc64DurableFilePublishBoundaryV1}`; + +export interface Rfc64DurableFileIoV1 { + readonly boundary: (boundary: Rfc64DurableFileBoundaryV1) => void; + readonly randomSuffix: () => string; +} + +export async function assertRfc64ExistingDirectoryV1( + path: string, + label: string, + requireSecureMode: boolean, +): Promise { + try { + const entry = await lstat(path); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + fail('unsafe-path', `${label} must be a non-symlink directory`); + } + assertOwner(entry.uid, label); + if ( + requireSecureMode + && !rfc64PosixModeMatchesV1(entry.mode, RFC64_SECURE_DIRECTORY_MODE_V1) + ) { + fail('unsafe-path', `${label} must have owner-only mode 0700`); + } + } catch (cause) { + if (cause instanceof Rfc64DurableFileErrorV1) throw cause; + fail('io', `failed to inspect ${label}`, cause); + } +} + +export async function ensureRfc64SecureDirectoryTreeV1( + target: string, + containmentRoot: string, + io: Rfc64DurableFileIoV1, +): Promise { + const resolvedTarget = resolve(target); + const resolvedRoot = resolve(containmentRoot); + const relativeTarget = relative(resolvedRoot, resolvedTarget); + if ( + relativeTarget === '..' + || relativeTarget.startsWith(`..${sep}`) + || isAbsolute(relativeTarget) + ) { + fail('unsafe-path', 'durable directory escaped its containment root'); + } + + if (relativeTarget.length === 0) { + await assertRfc64ExistingDirectoryV1( + resolvedTarget, + 'durable store directory', + true, + ); + return; + } + await assertRfc64ExistingDirectoryV1( + resolvedRoot, + 'durable store containment root', + false, + ); + let current = resolvedRoot; + for (const component of relativeTarget.split(sep).filter(Boolean)) { + current = join(current, component); + let created = false; + try { + await mkdir(current, { mode: RFC64_SECURE_DIRECTORY_MODE_V1 }); + created = true; + io.boundary('directory.created'); + } catch (cause) { + if (!isNodeError(cause, 'EEXIST')) { + fail('io', `failed to create durable store directory ${current}`, cause); + } + } + if (created) { + await chmodSecure(current, RFC64_SECURE_DIRECTORY_MODE_V1, 'directory'); + io.boundary('directory.mode-secured'); + await fsyncDirectory(current); + io.boundary('directory.self-fsynced'); + await fsyncDirectory(dirname(current)); + io.boundary('directory.parent-fsynced'); + } + await assertRfc64ExistingDirectoryV1(current, 'durable store directory', true); + } +} + +export interface PutRfc64ExactBytesInputV1 { + readonly targetPath: string; + readonly bytes: Uint8Array; + readonly maxBytes: number; + readonly label: string; + readonly kind: TKind; + readonly io: Rfc64DurableFileIoV1; +} + +export async function putRfc64ExactBytesV1( + input: PutRfc64ExactBytesInputV1, +): Promise { + const { targetPath, bytes, maxBytes, label, kind, io } = input; + assertByteBounds(bytes, maxBytes, label); + const existing = await readRfc64OptionalBoundedBytesV1(targetPath, maxBytes, label); + if (existing !== null) { + if (!bytesEqual(existing, bytes)) { + fail('corrupt', `${label} bytes differ for the same immutable key`); + } + // A prior attempt can fail after rename but before the parent-directory + // barrier. Re-establish both barriers before an idempotent retry succeeds. + await fsyncRegularFile(targetPath, label); + io.boundary(`${kind}.existing-fsynced`); + await fsyncDirectory(dirname(targetPath)); + io.boundary(`${kind}.existing-parent-fsynced`); + return; + } + + const suffix = io.randomSuffix(); + if (!/^[0-9a-f]{32}$/u.test(suffix)) { + fail('input', 'durable file random suffix must be 16 lowercase hex bytes'); + } + const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.${suffix}.tmp`); + let createdTemp = false; + let renamed = false; + let handle: Awaited> | null = null; + try { + handle = await open(tempPath, 'wx', RFC64_SECURE_FILE_MODE_V1); + createdTemp = true; + await handle.writeFile(bytes); + io.boundary(`${kind}.temp-written`); + await chmodSecure(tempPath, RFC64_SECURE_FILE_MODE_V1, `${label} temp file`); + io.boundary(`${kind}.temp-mode-secured`); + await handle.sync(); + io.boundary(`${kind}.temp-fsynced`); + await handle.close(); + handle = null; + await rename(tempPath, targetPath); + renamed = true; + io.boundary(`${kind}.renamed`); + await fsyncDirectory(dirname(targetPath)); + io.boundary(`${kind}.parent-fsynced`); + await assertExistingRegularFile(targetPath, `${label} cache file`, true); + } catch (cause) { + if (cause instanceof Rfc64DurableFileErrorV1) throw cause; + fail('durability', `failed to durably stage ${label} bytes`, cause); + } finally { + if (handle !== null) await handle.close().catch(() => undefined); + if (createdTemp && !renamed) await unlink(tempPath).catch(() => undefined); + } +} + +export async function readRfc64OptionalBoundedBytesV1( + path: string, + maxBytes: number, + label: string, +): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + fail('input', `${label} maximum byte length must be a positive safe integer`); + } + try { + const entry = await lstat(path); + if (entry.isSymbolicLink() || !entry.isFile()) { + fail('unsafe-path', `${label} must be a regular non-symlink file`); + } + } catch (cause) { + if (isNodeError(cause, 'ENOENT')) return null; + if (cause instanceof Rfc64DurableFileErrorV1) throw cause; + fail('io', `failed to inspect ${label}`, cause); + } + + let handle: Awaited> | null = null; + try { + handle = await open(path, rfc64RegularFileReadOpenFlagsV1()); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size < 1 || stat.size > maxBytes) { + fail('corrupt', `${label} is outside its bounded regular-file shape`); + } + const bytes = new Uint8Array(stat.size); + let offset = 0; + while (offset < bytes.length) { + const read = await handle.read(bytes, offset, bytes.length - offset, offset); + if (read.bytesRead === 0) { + fail('corrupt', `${label} was truncated during its bounded read`); + } + offset += read.bytesRead; + } + const extra = new Uint8Array(1); + const tail = await handle.read(extra, 0, 1, offset); + if (tail.bytesRead !== 0) { + fail('corrupt', `${label} grew during its bounded read`); + } + assertOwner(stat.uid, label); + if (!rfc64PosixModeMatchesV1(stat.mode, RFC64_SECURE_FILE_MODE_V1)) { + fail('unsafe-path', `${label} must have owner-only mode 0600`); + } + return bytes; + } catch (cause) { + if (cause instanceof Rfc64DurableFileErrorV1) throw cause; + fail('io', `failed to read ${label}`, cause); + } finally { + if (handle !== null) await handle.close().catch(() => undefined); + } + return fail('io', `failed to complete bounded read of ${label}`); +} + +async function assertExistingRegularFile( + path: string, + label: string, + requireSecureMode: boolean, +): Promise { + try { + const entry = await lstat(path); + if (entry.isSymbolicLink() || !entry.isFile()) { + fail('unsafe-path', `${label} must be a regular non-symlink file`); + } + assertOwner(entry.uid, label); + if ( + requireSecureMode + && !rfc64PosixModeMatchesV1(entry.mode, RFC64_SECURE_FILE_MODE_V1) + ) { + fail('unsafe-path', `${label} must have owner-only mode 0600`); + } + } catch (cause) { + if (cause instanceof Rfc64DurableFileErrorV1) throw cause; + fail('io', `failed to inspect ${label}`, cause); + } +} + +async function chmodSecure(path: string, mode: number, label: string): Promise { + try { + if (!rfc64UsesWindowsFilesystemPolicyV1()) await chmod(path, mode); + const entry = await lstat(path); + assertOwner(entry.uid, label); + if (!rfc64PosixModeMatchesV1(entry.mode, mode)) { + throw new Error( + `${label} mode is ${(entry.mode & 0o777).toString(8)}, expected ${mode.toString(8)}`, + ); + } + } catch (cause) { + if (cause instanceof Rfc64DurableFileErrorV1) throw cause; + fail('unsafe-path', `failed to secure ${label}`, cause); + } +} + +function assertOwner(uid: number, label: string): void { + if (!rfc64CurrentUserOwnsUidV1(uid)) { + fail('unsafe-path', `${label} is not owned by the current process user`); + } +} + +async function fsyncDirectory(path: string): Promise { + try { + await fsyncRfc64DirectoryV1(path); + } catch (cause) { + fail('durability', `failed to fsync directory ${path}`, cause); + } +} + +async function fsyncRegularFile(path: string, label: string): Promise { + let handle: Awaited> | null = null; + try { + handle = await open(path, rfc64RegularFileFsyncOpenFlagsV1()); + const stat = await handle.stat(); + if (!stat.isFile()) { + fail('unsafe-path', `${label} fsync target is not a regular file`); + } + assertOwner(stat.uid, label); + if (!rfc64PosixModeMatchesV1(stat.mode, RFC64_SECURE_FILE_MODE_V1)) { + fail('unsafe-path', `${label} fsync target must have owner-only mode 0600`); + } + await handle.sync(); + } catch (cause) { + if (cause instanceof Rfc64DurableFileErrorV1) throw cause; + fail('durability', `failed to fsync ${label}`, cause); + } finally { + if (handle !== null) await handle.close().catch(() => undefined); + } +} + +function assertByteBounds(bytes: Uint8Array, maxBytes: number, label: string): void { + if (!(bytes instanceof Uint8Array)) { + fail('input', `${label} bytes must be a Uint8Array`); + } + if ( + !Number.isSafeInteger(maxBytes) + || maxBytes < 1 + || bytes.byteLength < 1 + || bytes.byteLength > maxBytes + ) { + fail('input', `${label} bytes are outside the configured immutable bounds`); + } +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + let different = 0; + for (let index = 0; index < left.byteLength; index += 1) { + different |= left[index] ^ right[index]; + } + return different === 0; +} + +function isNodeError(cause: unknown, code: string): boolean { + return cause instanceof Error + && 'code' in cause + && (cause as NodeJS.ErrnoException).code === code; +} + +function fail( + code: Rfc64DurableFileErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64DurableFileErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index b265952891..c9a7d168bd 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -27,6 +27,12 @@ import { randomBytes } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { setTimeout as delay } from 'node:timers/promises'; import { TextDecoder } from 'node:util'; +import { + fsyncRfc64DirectorySyncV1, + rfc64CurrentUserOwnsUidV1, + rfc64PosixModeMatchesV1, + rfc64RegularFileFsyncOpenFlagsV1, +} from '../secure-filesystem-policy-v1.js'; import { INVENTORY_V1_APPLICATION_ID, @@ -556,6 +562,19 @@ function reopenVerifiedOwnedDatabase( } } +/** @internal Proves that the exact live foundation owns the lease for dataDir. */ +export function inventoryV1OwnsDataDir( + inventory: unknown, + dataDir: string, +): inventory is Rfc64InventoryV1Foundation { + return inventory instanceof InventoryV1Foundation + && !inventory.closed + && typeof dataDir === 'string' + && dataDir.length > 0 + && inventory.databasePath + === resolve(resolve(dataDir), INVENTORY_V1_RELATIVE_PATH); +} + async function loadSqliteModule(): Promise { try { const moduleName = 'node:sqlite'; @@ -1421,8 +1440,7 @@ if ( return; } try { - const processUid = process.getuid?.(); - if (processUid !== undefined && statSync(path).uid !== processUid) { + if (!rfc64CurrentUserOwnsUidV1(statSync(path).uid)) { throw new Error('filesystem entry is not owned by the current process uid'); } } catch (cause) { @@ -1455,11 +1473,10 @@ function applySecurePermissions(path: string, mode: number, directory: boolean): try { chmodSync(path, mode); const stat = statSync(path); - const processUid = process.getuid?.(); - if (processUid !== undefined && stat.uid !== processUid) { - throw new Error(`path owner uid ${stat.uid} does not match process uid ${processUid}`); + if (!rfc64CurrentUserOwnsUidV1(stat.uid)) { + throw new Error(`path owner uid ${stat.uid} does not match the current process uid`); } - if ((stat.mode & 0o777) !== mode) { + if (!rfc64PosixModeMatchesV1(stat.mode, mode)) { throw new Error(`path mode ${(stat.mode & 0o777).toString(8)} does not match ${mode.toString(8)}`); } } catch (cause) { @@ -1594,7 +1611,7 @@ function fsyncRegularFile(path: string, label: string): void { // opened for read-only access with EPERM. The entry is already an owned // regular file; opening r+ grants the required handle access without // changing bytes, while POSIX retains its narrower read-only descriptor. - descriptor = openSync(path, process.platform === 'win32' ? 'r+' : 'r'); + descriptor = openSync(path, rfc64RegularFileFsyncOpenFlagsV1()); fsyncSync(descriptor); } catch (cause) { throw new InventoryV1OpenError('database-io', `failed to fsync ${label}`, { cause }); @@ -2265,9 +2282,7 @@ function readValidSqliteHeaderIdentity(databasePath: string): SqliteHeaderIdenti } function fsyncDirectory(path: string): void { - if (process.platform === 'win32') return; - const descriptor = openSync(path, 'r'); - try { fsyncSync(descriptor); } finally { closeSync(descriptor); } + fsyncRfc64DirectorySyncV1(path); } function assertString(value: unknown, label: string): string { diff --git a/packages/agent/src/rfc64/inventory-v1/sql.ts b/packages/agent/src/rfc64/inventory-v1/sql.ts index 588bd6e570..c27f6b8f19 100644 --- a/packages/agent/src/rfc64/inventory-v1/sql.ts +++ b/packages/agent/src/rfc64/inventory-v1/sql.ts @@ -1,8 +1,13 @@ +import { + RFC64_SECURE_DIRECTORY_MODE_V1, + RFC64_SECURE_FILE_MODE_V1, +} from '../secure-filesystem-policy-v1.js'; + export const INVENTORY_V1_APPLICATION_ID = 0x444b3634; export const INVENTORY_V1_USER_VERSION = 1; export const INVENTORY_V1_RELATIVE_PATH = 'rfc64-sync/inventory-v1.sqlite3'; -export const INVENTORY_V1_DIRECTORY_MODE = 0o700; -export const INVENTORY_V1_FILE_MODE = 0o600; +export const INVENTORY_V1_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; +export const INVENTORY_V1_FILE_MODE = RFC64_SECURE_FILE_MODE_V1; // SQL-1 stores protocol integers only as canonical fixed-width big-endian // BLOBs. Decode the bounded low hexadecimal suffix with SQLite core built-ins diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index 31591309d9..4fcf63f2b4 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -74,7 +74,7 @@ export async function openRfc64PersistenceV1( if (batch.done) break; await yieldAfterPurgeBatch(); } - const controlObjectStore = await openRfc64ControlObjectStoreV1(dataDir); + const controlObjectStore = await openRfc64ControlObjectStoreV1(dataDir, inventory); return new OwnedRfc64PersistenceV1(inventory, controlObjectStore); } catch (cause) { try { diff --git a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts new file mode 100644 index 0000000000..e6fa0092f5 --- /dev/null +++ b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts @@ -0,0 +1,76 @@ +import { + closeSync, + constants, + fsyncSync, + openSync, +} from 'node:fs'; +import { open } from 'node:fs/promises'; + +export const RFC64_SECURE_DIRECTORY_MODE_V1 = 0o700; +export const RFC64_SECURE_FILE_MODE_V1 = 0o600; +export const RFC64_POSIX_NAMESPACE_DURABILITY_V1 = + 'posix-atomic-rename-directory-fsync-v1' as const; +export const RFC64_WINDOWS_NAMESPACE_DURABILITY_V1 = + 'windows-file-flush-atomic-rename-v1' as const; + +export type Rfc64NamespaceDurabilityV1 = + | typeof RFC64_POSIX_NAMESPACE_DURABILITY_V1 + | typeof RFC64_WINDOWS_NAMESPACE_DURABILITY_V1; + +export function rfc64UsesWindowsFilesystemPolicyV1(): boolean { + return process.platform === 'win32'; +} + +export function rfc64NamespaceDurabilityV1(): Rfc64NamespaceDurabilityV1 { + return rfc64UsesWindowsFilesystemPolicyV1() + ? RFC64_WINDOWS_NAMESPACE_DURABILITY_V1 + : RFC64_POSIX_NAMESPACE_DURABILITY_V1; +} + +export function rfc64CurrentUserOwnsUidV1(uid: number): boolean { + if (rfc64UsesWindowsFilesystemPolicyV1()) return true; + const processUid = process.getuid?.(); + return processUid === undefined || uid === processUid; +} + +export function rfc64PosixModeMatchesV1(mode: number, expected: number): boolean { + return rfc64UsesWindowsFilesystemPolicyV1() || (mode & 0o777) === expected; +} + +/** Node cannot FlushFileBuffers on a Windows directory handle. */ +export async function fsyncRfc64DirectoryV1(path: string): Promise { + if (rfc64UsesWindowsFilesystemPolicyV1()) return; + const handle = await open(path, constants.O_RDONLY); + try { + const stat = await handle.stat(); + if (!stat.isDirectory()) { + throw new Error('RFC-64 directory fsync target is not a directory'); + } + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** Synchronous twin used by the SQLite inventory lifecycle. */ +export function fsyncRfc64DirectorySyncV1(path: string): void { + if (rfc64UsesWindowsFilesystemPolicyV1()) return; + const descriptor = openSync(path, 'r'); + try { + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } +} + +/** FlushFileBuffers requires a write-capable Windows handle. */ +export function rfc64RegularFileFsyncOpenFlagsV1(): string | number { + return rfc64UsesWindowsFilesystemPolicyV1() + ? 'r+' + : constants.O_RDONLY | constants.O_NOFOLLOW; +} + +export function rfc64RegularFileReadOpenFlagsV1(): number { + return constants.O_RDONLY + | (rfc64UsesWindowsFilesystemPolicyV1() ? 0 : constants.O_NOFOLLOW); +} diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index f90036bb4e..898ae96ee0 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -262,7 +262,10 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { const dataDirectory = temporaryDataDirectory(); const outside = temporaryDataDirectory(); const inventory = await openInventoryV1(dataDirectory); - const initialStore = await openRfc64ControlObjectStoreV1(dataDirectory); + const initialStore = await openRfc64ControlObjectStoreV1( + dataDirectory, + inventory, + ); initialStore.close(); inventory.close(); const objectsPath = join( @@ -283,6 +286,18 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { }, ); + it('requires the live inventory owner for the exact control-store dataDir', async () => { + const dataDirectory = temporaryDataDirectory(); + const otherDataDirectory = temporaryDataDirectory(); + const inventory = await openInventoryV1(dataDirectory); + + await expect(openRfc64ControlObjectStoreV1(otherDataDirectory, inventory)) + .rejects.toMatchObject({ code: 'control-store-input' }); + inventory.close(); + await expect(openRfc64ControlObjectStoreV1(dataDirectory, inventory)) + .rejects.toMatchObject({ code: 'control-store-input' }); + }); + it('releases an acquired inventory when node startup fails', async () => { const failure = new Error('node start failed'); const close = vi.fn(); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 066af9408e..82dcb0e1ad 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -29,7 +29,6 @@ import { RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, Rfc64ControlObjectStoreErrorV1, createRfc64ControlObjectStoreTestOpenerV1, - openRfc64ControlObjectStoreV1, type Rfc64ControlObjectStoreDurabilityBoundaryV1, type StageVerifiedControlObjectV1, } from '../src/rfc64/control-object-store-v1.js'; @@ -38,9 +37,16 @@ import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog- const PRIVATE_KEY = `0x${'42'.repeat(32)}`; const wallet = new ethers.Wallet(PRIVATE_KEY); const ISSUER = wallet.address.toLowerCase() as EvmAddressV1; +const openRfc64ControlObjectStoreV1 = createRfc64ControlObjectStoreTestOpenerV1(); const temporaryDirectories: string[] = []; +function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { resolvePromise = resolve; }); + return { promise, resolve: () => resolvePromise?.() }; +} + async function temporaryDataDirectory(): Promise { const path = await mkdtemp(join(tmpdir(), 'dkg-rfc64-control-store-')); temporaryDirectories.push(path); @@ -209,7 +215,7 @@ describe('RFC-64 durable control-object store v1', () => { store.stageVerifiedObjects([fixture]), store.stageVerifiedObjects([fixture]), ]); - expect(boundaries).toEqual([ + const expectedBoundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = [ 'directory.created', 'directory.mode-secured', 'directory.self-fsynced', @@ -236,7 +242,16 @@ describe('RFC-64 durable control-object store v1', () => { 'signature.parent-fsynced', 'signature.existing-fsynced', 'signature.existing-parent-fsynced', - ]); + ]; + expect([...boundaries].sort()).toEqual([...expectedBoundaries].sort()); + expect(boundaries.indexOf('object.temp-written')) + .toBeLessThan(boundaries.indexOf('object.renamed')); + expect(boundaries.indexOf('object.renamed')) + .toBeLessThan(boundaries.indexOf('object.existing-fsynced')); + expect(boundaries.indexOf('signature.temp-written')) + .toBeLessThan(boundaries.indexOf('signature.renamed')); + expect(boundaries.indexOf('signature.renamed')) + .toBeLessThan(boundaries.indexOf('signature.existing-fsynced')); boundaries.length = 0; await store.stageVerifiedObjects([fixture]); expect(boundaries).toEqual([ @@ -336,10 +351,30 @@ describe('RFC-64 durable control-object store v1', () => { expect(await readFile(paths.signature, 'utf8')).toBe('{}'); }); + it('rejects canonical unsigned object bytes stored under another object digest key', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const [first, second] = await Promise.all([signedFixture('6a'), signedFixture('6b')]); + await store.stageVerifiedObjects([first, second]); + const firstPaths = pathsFor(dataDir, first.envelope); + const secondPaths = pathsFor(dataDir, second.envelope); + await writeFile(firstPaths.object, await readFile(secondPaths.object), { + mode: RFC64_CONTROL_OBJECT_STORE_FILE_MODE, + }); + const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + + await expect(store.getVerifiedObject({ + objectDigest: first.envelope.objectDigest as Digest32V1, + signatureVariantDigest: firstPaths.signatureDigest, + verifyIssuerSignature, + })).rejects.toMatchObject({ code: 'control-store-corrupt' }); + expect(verifyIssuerSignature).not.toHaveBeenCalled(); + }); + it('rejects a canonical signature variant stored under a different exact key', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); - const fixture = await signedFixture('6b'); + const fixture = await signedFixture('6c'); await store.stageVerifiedObjects([fixture]); const paths = pathsFor(dataDir, fixture.envelope); const wrongVariant = `0x${'34'.repeat(32)}` as Digest32V1; @@ -384,6 +419,44 @@ describe('RFC-64 durable control-object store v1', () => { expect(verifyIssuerSignature).toHaveBeenCalledOnce(); }); + it('rejects a read-side issuer proof bound to a different verified envelope', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const [first, second] = await Promise.all([signedFixture('6d'), signedFixture('6e')]); + await store.stageVerifiedObjects([first]); + const paths = pathsFor(dataDir, first.envelope); + + await expect(store.getVerifiedObject({ + objectDigest: first.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature: async () => second.issuerSignature, + })).rejects.toMatchObject({ code: 'control-store-verification' }); + }); + + it('does not let a slow verifier block an unrelated immutable stage', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const [first, second] = await Promise.all([signedFixture('6f'), signedFixture('6g')]); + await store.stageVerifiedObjects([first]); + const firstPaths = pathsFor(dataDir, first.envelope); + const entered = deferred(); + const release = deferred(); + const slowRead = store.getVerifiedObject({ + objectDigest: first.envelope.objectDigest as Digest32V1, + signatureVariantDigest: firstPaths.signatureDigest, + verifyIssuerSignature: async (envelope) => { + entered.resolve(); + await release.promise; + return verifyControlEnvelopeIssuerSignatureV1(envelope); + }, + }); + await entered.promise; + + await expect(store.stageVerifiedObjects([second])).resolves.toMatchObject({ durable: true }); + release.resolve(); + await expect(slowRead).resolves.toMatchObject({ envelope: first.envelope }); + }); + it('cleans an unrenamed temp after a pre-visibility fault and converges on retry', async () => { const dataDir = await temporaryDataDirectory(); const fixture = await signedFixture('7'); @@ -443,6 +516,30 @@ describe('RFC-64 durable control-object store v1', () => { .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); }); + it.runIf(process.platform !== 'win32')( + 'rejects permissive existing files and directories instead of trusting or tightening them', + async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('8b'); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + + await chmod(paths.object, 0o644); + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + + await chmod(paths.object, RFC64_CONTROL_OBJECT_STORE_FILE_MODE); + await chmod(dirname(dirname(paths.object)), 0o755); + store.close(); + await expect(openRfc64ControlObjectStoreV1(dataDir)) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + }, + ); + it('returns null for an exact cache miss without calling the verifier', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); diff --git a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts new file mode 100644 index 0000000000..bc93913aab --- /dev/null +++ b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts @@ -0,0 +1,10 @@ +import * as publicApi from '../src/index.js'; + +type PublicApiHasRawControlStoreOpener = + 'openRfc64ControlObjectStoreV1' extends keyof typeof publicApi ? true : false; + +// Package consumers cannot construct the cache without the aggregate +// persistence owner; direct-source tests retain a guarded internal seam. +const rawControlStoreOpenerIsNotPublic: PublicApiHasRawControlStoreOpener = false; + +void rawControlStoreOpenerIsNotPublic; diff --git a/packages/agent/tsconfig.type-tests.json b/packages/agent/tsconfig.type-tests.json index ff65ac02d6..90bd248955 100644 --- a/packages/agent/tsconfig.type-tests.json +++ b/packages/agent/tsconfig.type-tests.json @@ -8,5 +8,5 @@ "rootDir": ".", "sourceMap": false }, - "include": ["test/**/*.typecheck.ts"] + "include": ["test/**/*.typecheck.ts", "src/jsonld.d.ts"] } From 696e36b478811c4c1b0d3a00f54e1a41d4fa21df Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 13:50:40 +0200 Subject: [PATCH 070/292] fix(agent): harden RFC-64 staging boundaries --- .github/workflows/rfc64-inventory-windows.yml | 2 + .../src/rfc64/control-object-store-v1.ts | 187 ++++++++---------- .../agent/src/rfc64/durable-file-store-v1.ts | 167 ++++++++++++---- packages/agent/src/rfc64/inventory-v1/open.ts | 145 +------------- .../rfc64/persistence-owner-capability-v1.ts | 54 +++++ packages/agent/src/rfc64/persistence-v1.ts | 9 +- .../src/rfc64/secure-filesystem-policy-v1.ts | 168 ++++++++++++++++ .../rfc64-agent-inventory-lifecycle.test.ts | 29 ++- .../rfc64-control-object-store-v1.test.ts | 66 +++++++ ...fc64-control-store-public-api.typecheck.ts | 39 ++++ packages/core/src/sync-control-object.ts | 67 +++++++ .../core/test/sync-control-object.test.ts | 37 ++++ 12 files changed, 676 insertions(+), 294 deletions(-) create mode 100644 packages/agent/src/rfc64/persistence-owner-capability-v1.ts diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index cb7a9eba61..ef3bac3a27 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -9,6 +9,7 @@ on: - 'packages/agent/src/rfc64/control-object-store-v1.ts' - 'packages/agent/src/rfc64/durable-file-store-v1.ts' - 'packages/agent/src/rfc64/persistence-v1.ts' + - 'packages/agent/src/rfc64/persistence-owner-capability-v1.ts' - 'packages/agent/src/rfc64/secure-filesystem-policy-v1.ts' - 'packages/agent/src/dkg-agent-base.ts' - 'packages/agent/src/index.ts' @@ -16,6 +17,7 @@ on: - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' - 'packages/agent/test/rfc64-author-catalog-producer.test.ts' - 'packages/agent/test/rfc64-control-object-store-v1.test.ts' + - 'packages/agent/test/rfc64-control-store-public-api.typecheck.ts' - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' - 'packages/agent/vitest.unit.config.ts' diff --git a/packages/agent/src/rfc64/control-object-store-v1.ts b/packages/agent/src/rfc64/control-object-store-v1.ts index 1bb87d2fb7..dec88dc2eb 100644 --- a/packages/agent/src/rfc64/control-object-store-v1.ts +++ b/packages/agent/src/rfc64/control-object-store-v1.ts @@ -1,30 +1,20 @@ import { randomBytes } from 'node:crypto'; -import { dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { MAX_CONTROL_OBJECT_BYTES, MAX_CONTROL_SIGNATURE_VARIANT_BYTES, assertCanonicalDigest, - canonicalizeControlSignatureVariantBytes, - canonicalizeSignedControlEnvelopeBytes, - canonicalizeUnsignedControlEnvelopeBytes, computeControlSignatureVariantDigestHex, - parseCanonicalControlSignatureVariant, - parseCanonicalSignedControlEnvelope, - parseCanonicalUnsignedControlEnvelope, - type ControlObjectSignatureVariantV1, + recombineCanonicalSignedControlEnvelopeV1, + splitCanonicalSignedControlEnvelopeV1, type Digest32V1, type SignedControlEnvelopeV1, - type UnsignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { readVerifiedControlEnvelopeIssuerSignatureV1, type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; -import { - inventoryV1OwnsDataDir, - type Rfc64InventoryV1Foundation, -} from './inventory-v1/open.js'; import { Rfc64DurableFileErrorV1, assertRfc64ExistingDirectoryV1, @@ -34,6 +24,10 @@ import { type Rfc64DurableFileBoundaryV1, type Rfc64DurableFileIoV1, } from './durable-file-store-v1.js'; +import { + readLiveRfc64PersistenceOwnerRootV1, + type Rfc64PersistenceOwnerCapabilityV1, +} from './persistence-owner-capability-v1.js'; import { RFC64_POSIX_NAMESPACE_DURABILITY_V1, RFC64_SECURE_DIRECTORY_MODE_V1, @@ -57,6 +51,7 @@ export type Rfc64ControlObjectStoreNamespaceDurabilityV1 = Rfc64NamespaceDurabil const OBJECTS_DIRECTORY = 'objects'; const SIGNATURES_DIRECTORY = 'signatures'; +const CONTROL_OBJECT_STORE_DIRECTORY = 'control-objects-v1'; const CANONICAL_FILE_SUFFIX = '.jcs'; export const RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1 = Object.freeze([ @@ -163,16 +158,15 @@ export type Rfc64ControlObjectStoreTestOpenerV1 = ( * its single-process lease. This cache deliberately does not mint that lease. */ export async function openRfc64ControlObjectStoreV1( - dataDir: string, - inventoryOwnership: Rfc64InventoryV1Foundation, + ownership: Rfc64PersistenceOwnerCapabilityV1, ): Promise { - if (!inventoryV1OwnsDataDir(inventoryOwnership, dataDir)) { - fail( - 'control-store-input', - 'control object store requires the live inventory owner for the same dataDir', - ); + let rfc64RootPath: string; + try { + rfc64RootPath = readLiveRfc64PersistenceOwnerRootV1(ownership); + } catch (cause) { + fail('control-store-input', 'control object store requires a live persistence owner', cause); } - return openRfc64ControlObjectStoreWithIoV1(dataDir, PRODUCTION_IO); + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, PRODUCTION_IO); } /** Test-only fault-boundary opener; shipped production code cannot install hooks. */ @@ -191,11 +185,10 @@ export function createRfc64ControlObjectStoreTestOpenerV1( randomSuffix: (): string => randomSuffix?.() ?? randomBytes(16).toString('hex'), }) satisfies Rfc64ControlObjectStoreIoV1; return async (dataDir: string): Promise => - openRfc64ControlObjectStoreWithIoV1(dataDir, io); + openRfc64ControlObjectStoreForTestV1(dataDir, io); } interface PreparedStoredControlObjectV1 { - readonly envelope: SignedControlEnvelopeV1; readonly objectDigest: Digest32V1; readonly signatureVariantDigest: Digest32V1; readonly unsignedBytes: Uint8Array; @@ -252,42 +245,36 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { fail('control-store-input', 'verifyIssuerSignature must be a function'); } return (async () => { - const objectPath = this.objectPath(objectDigest); - const signaturePath = this.signaturePath(objectDigest, signatureVariantDigest); + const objectPath = this.objectRelativePath(objectDigest); + const signaturePath = this.signatureRelativePath( + objectDigest, + signatureVariantDigest, + ); const [unsignedBytes, variantBytes] = await mapDurableFileErrors(async () => Promise.all([ - readRfc64OptionalBoundedBytesV1( - objectPath, - MAX_CONTROL_OBJECT_BYTES, - 'control object', - ), - readRfc64OptionalBoundedBytesV1( - signaturePath, - MAX_CONTROL_SIGNATURE_VARIANT_BYTES, - 'control signature variant', - ), + readRfc64OptionalBoundedBytesV1({ + containmentRoot: this.rootPath, + relativePath: objectPath, + maxBytes: MAX_CONTROL_OBJECT_BYTES, + label: 'control object', + }), + readRfc64OptionalBoundedBytesV1({ + containmentRoot: this.rootPath, + relativePath: signaturePath, + maxBytes: MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + label: 'control signature variant', + }), ])); if (unsignedBytes === null || variantBytes === null) return null; - let unsigned: UnsignedControlEnvelopeV1; - let variant: ControlObjectSignatureVariantV1; let envelope: SignedControlEnvelopeV1; try { - unsigned = parseCanonicalUnsignedControlEnvelope(unsignedBytes); - variant = parseCanonicalControlSignatureVariant(variantBytes); - if ( - variant.objectDigest !== objectDigest - || variant.signatureVariantDigest !== signatureVariantDigest - ) { - throw new Error('stored cache keys do not match the canonical records'); - } - envelope = deepFreezePlain(parseCanonicalSignedControlEnvelope( - canonicalizeSignedControlEnvelopeBytes({ - ...unsigned, - objectDigest, - signature: variant.signature, - }), - )) as SignedControlEnvelopeV1; + envelope = deepFreezePlain(recombineCanonicalSignedControlEnvelopeV1( + unsignedBytes, + variantBytes, + objectDigest, + signatureVariantDigest, + )); } catch (cause) { fail('control-store-corrupt', 'stored control object is not canonical for its exact keys', cause); } @@ -312,34 +299,32 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { } private async stagePrepared(item: PreparedStoredControlObjectV1): Promise { - const objectPath = this.objectPath(item.objectDigest); + const objectPath = this.objectRelativePath(item.objectDigest); await this.stageFileByKey(objectPath, item.unsignedBytes, 'object'); - const signaturePath = this.signaturePath( + const signaturePath = this.signatureRelativePath( item.objectDigest, item.signatureVariantDigest, ); await this.stageFileByKey(signaturePath, item.signatureVariantBytes, 'signature'); } - private objectPath(objectDigest: Digest32V1): string { + private objectRelativePath(objectDigest: Digest32V1): string { const hex = objectDigest.slice(2); return join( - this.rootPath, OBJECTS_DIRECTORY, hex.slice(0, 2), `${hex}${CANONICAL_FILE_SUFFIX}`, ); } - private signaturePath( + private signatureRelativePath( objectDigest: Digest32V1, signatureVariantDigest: Digest32V1, ): string { const objectHex = objectDigest.slice(2); const variantHex = signatureVariantDigest.slice(2); return join( - this.rootPath, SIGNATURES_DIRECTORY, objectHex.slice(0, 2), objectHex, @@ -348,40 +333,36 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { } private stageFileByKey( - targetPath: string, + relativePath: string, bytes: Uint8Array, kind: 'object' | 'signature', ): Promise { - const previous = this.#fileWriteTails.get(targetPath) ?? Promise.resolve(); + const previous = this.#fileWriteTails.get(relativePath) ?? Promise.resolve(); const run = previous.then(async () => { this.requireOpen(); - await this.putExactFile(targetPath, bytes, kind); + await this.putExactFile(relativePath, bytes, kind); }, async () => { this.requireOpen(); - await this.putExactFile(targetPath, bytes, kind); + await this.putExactFile(relativePath, bytes, kind); }); - this.#fileWriteTails.set(targetPath, run); + this.#fileWriteTails.set(relativePath, run); void run.finally(() => { - if (this.#fileWriteTails.get(targetPath) === run) { - this.#fileWriteTails.delete(targetPath); + if (this.#fileWriteTails.get(relativePath) === run) { + this.#fileWriteTails.delete(relativePath); } }).catch(() => undefined); return run; } private async putExactFile( - targetPath: string, + relativePath: string, bytes: Uint8Array, kind: Rfc64ControlObjectStoreFileKindV1, ): Promise { await mapDurableFileErrors(async () => { - await ensureRfc64SecureDirectoryTreeV1( - dirname(targetPath), - this.rootPath, - this.io, - ); await putRfc64ExactBytesV1({ - targetPath, + containmentRoot: this.rootPath, + relativePath, bytes, maxBytes: kind === 'object' ? MAX_CONTROL_OBJECT_BYTES @@ -398,7 +379,7 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { } } -async function openRfc64ControlObjectStoreWithIoV1( +async function openRfc64ControlObjectStoreForTestV1( dataDirInput: string, io: Rfc64ControlObjectStoreIoV1, ): Promise { @@ -409,10 +390,30 @@ async function openRfc64ControlObjectStoreWithIoV1( fail('control-store-input', 'dataDir must be a non-empty path'); } const dataDir = resolve(dataDirInput); - const rootPath = resolve(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH); + const rfc64RootPath = resolve(dataDir, 'rfc64-sync'); await mapDurableFileErrors(async () => { await assertRfc64ExistingDirectoryV1(dataDir, 'DKG data directory', false); - await ensureRfc64SecureDirectoryTreeV1(rootPath, dataDir, io); + await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, io); + }); + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, io); +} + +async function openRfc64ControlObjectStoreAtRootV1( + rfc64RootPathInput: string, + io: Rfc64ControlObjectStoreIoV1, +): Promise { + if (!Object.isFrozen(io)) { + fail('control-store-input', 'control object store I/O adapter must be immutable'); + } + const rfc64RootPath = resolve(rfc64RootPathInput); + const rootPath = resolve(rfc64RootPath, CONTROL_OBJECT_STORE_DIRECTORY); + await mapDurableFileErrors(async () => { + await assertRfc64ExistingDirectoryV1( + rfc64RootPath, + 'RFC-64 persistence root', + true, + ); + await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath, io); await ensureRfc64SecureDirectoryTreeV1( join(rootPath, OBJECTS_DIRECTORY), rootPath, @@ -449,27 +450,19 @@ function prepareStageBatch( if (!(index in input)) fail('control-store-input', 'stage input must not contain holes'); const item = input[index]; try { - const envelope = deepFreezePlain(parseCanonicalSignedControlEnvelope( - canonicalizeSignedControlEnvelopeBytes(item.envelope), - )) as SignedControlEnvelopeV1; + const split = splitCanonicalSignedControlEnvelopeV1(item.envelope); + const envelope = deepFreezePlain(split.envelope); assertProofMatchesEnvelope(envelope, item.issuerSignature); - const unsigned = unsignedFromSigned(envelope); - const objectDigest = envelope.objectDigest as Digest32V1; - const signatureVariantDigest = computeControlSignatureVariantDigestHex( - objectDigest, - envelope.signature, - ) as Digest32V1; - const variant = Object.freeze({ - objectDigest, - signature: envelope.signature, - signatureVariantDigest, - }) satisfies ControlObjectSignatureVariantV1; + const objectDigest = snapshotDigest(envelope.objectDigest, 'objectDigest'); + const signatureVariantDigest = snapshotDigest( + split.signatureVariant.signatureVariantDigest, + 'signatureVariantDigest', + ); result[index] = Object.freeze({ - envelope, objectDigest, signatureVariantDigest, - unsignedBytes: canonicalizeUnsignedControlEnvelopeBytes(unsigned), - signatureVariantBytes: canonicalizeControlSignatureVariantBytes(variant), + unsignedBytes: split.unsignedEnvelopeBytes, + signatureVariantBytes: split.signatureVariantBytes, }); } catch (cause) { if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; @@ -503,16 +496,6 @@ function assertProofMatchesEnvelope( } } -function unsignedFromSigned(envelope: SignedControlEnvelopeV1): UnsignedControlEnvelopeV1 { - return { - issuer: envelope.issuer, - objectType: envelope.objectType, - payload: envelope.payload, - signatureEvidence: envelope.signatureEvidence, - signatureSuite: envelope.signatureSuite, - } as UnsignedControlEnvelopeV1; -} - async function mapDurableFileErrors(operation: () => Promise): Promise { try { return await operation(); diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index 8f690d9f59..68b90f1b05 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -1,15 +1,15 @@ -import { chmod, lstat, mkdir, open, rename, unlink } from 'node:fs/promises'; +import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { RFC64_SECURE_DIRECTORY_MODE_V1, RFC64_SECURE_FILE_MODE_V1, + applyRfc64OwnerOnlyPermissionsSyncV1, + assertRfc64FilesystemOwnerSyncV1, + assertRfc64OwnerOnlyPermissionsSyncV1, fsyncRfc64DirectoryV1, - rfc64CurrentUserOwnsUidV1, - rfc64PosixModeMatchesV1, rfc64RegularFileFsyncOpenFlagsV1, rfc64RegularFileReadOpenFlagsV1, - rfc64UsesWindowsFilesystemPolicyV1, } from './secure-filesystem-policy-v1.js'; export type Rfc64DurableFileErrorCodeV1 = @@ -61,13 +61,13 @@ export async function assertRfc64ExistingDirectoryV1( if (entry.isSymbolicLink() || !entry.isDirectory()) { fail('unsafe-path', `${label} must be a non-symlink directory`); } - assertOwner(entry.uid, label); - if ( - requireSecureMode - && !rfc64PosixModeMatchesV1(entry.mode, RFC64_SECURE_DIRECTORY_MODE_V1) - ) { - fail('unsafe-path', `${label} must have owner-only mode 0700`); - } + assertSecureAccess( + path, + RFC64_SECURE_DIRECTORY_MODE_V1, + true, + requireSecureMode, + label, + ); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; fail('io', `failed to inspect ${label}`, cause); @@ -129,7 +129,8 @@ export async function ensureRfc64SecureDirectoryTreeV1( } export interface PutRfc64ExactBytesInputV1 { - readonly targetPath: string; + readonly containmentRoot: string; + readonly relativePath: string; readonly bytes: Uint8Array; readonly maxBytes: number; readonly label: string; @@ -140,9 +141,20 @@ export interface PutRfc64ExactBytesInputV1 { export async function putRfc64ExactBytesV1( input: PutRfc64ExactBytesInputV1, ): Promise { - const { targetPath, bytes, maxBytes, label, kind, io } = input; + const { containmentRoot, relativePath, bytes, maxBytes, label, kind, io } = input; + const targetPath = resolveContainedFileTarget(containmentRoot, relativePath); assertByteBounds(bytes, maxBytes, label); - const existing = await readRfc64OptionalBoundedBytesV1(targetPath, maxBytes, label); + await ensureRfc64SecureDirectoryTreeV1( + dirname(targetPath), + containmentRoot, + io, + ); + const existing = await readRfc64OptionalBoundedBytesV1({ + containmentRoot, + relativePath, + maxBytes, + label, + }); if (existing !== null) { if (!bytesEqual(existing, bytes)) { fail('corrupt', `${label} bytes differ for the same immutable key`); @@ -190,11 +202,18 @@ export async function putRfc64ExactBytesV1( } } +export interface ReadRfc64OptionalBoundedBytesInputV1 { + readonly containmentRoot: string; + readonly relativePath: string; + readonly maxBytes: number; + readonly label: string; +} + export async function readRfc64OptionalBoundedBytesV1( - path: string, - maxBytes: number, - label: string, + input: ReadRfc64OptionalBoundedBytesInputV1, ): Promise { + const { containmentRoot, relativePath, maxBytes, label } = input; + const path = resolveContainedFileTarget(containmentRoot, relativePath); if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { fail('input', `${label} maximum byte length must be a positive safe integer`); } @@ -209,6 +228,8 @@ export async function readRfc64OptionalBoundedBytesV1( fail('io', `failed to inspect ${label}`, cause); } + await assertRfc64SecureDirectoryTreeV1(dirname(path), containmentRoot); + let handle: Awaited> | null = null; try { handle = await open(path, rfc64RegularFileReadOpenFlagsV1()); @@ -230,10 +251,13 @@ export async function readRfc64OptionalBoundedBytesV1( if (tail.bytesRead !== 0) { fail('corrupt', `${label} grew during its bounded read`); } - assertOwner(stat.uid, label); - if (!rfc64PosixModeMatchesV1(stat.mode, RFC64_SECURE_FILE_MODE_V1)) { - fail('unsafe-path', `${label} must have owner-only mode 0600`); - } + assertSecureAccess( + path, + RFC64_SECURE_FILE_MODE_V1, + false, + true, + label, + ); return bytes; } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; @@ -244,6 +268,32 @@ export async function readRfc64OptionalBoundedBytesV1( return fail('io', `failed to complete bounded read of ${label}`); } +async function assertRfc64SecureDirectoryTreeV1( + target: string, + containmentRoot: string, +): Promise { + const resolvedTarget = resolve(target); + const resolvedRoot = resolve(containmentRoot); + const relativeTarget = relative(resolvedRoot, resolvedTarget); + if ( + relativeTarget === '..' + || relativeTarget.startsWith(`..${sep}`) + || isAbsolute(relativeTarget) + ) { + fail('unsafe-path', 'durable directory escaped its containment root'); + } + await assertRfc64ExistingDirectoryV1( + resolvedRoot, + 'durable store containment root', + true, + ); + let current = resolvedRoot; + for (const component of relativeTarget.split(sep).filter(Boolean)) { + current = join(current, component); + await assertRfc64ExistingDirectoryV1(current, 'durable store directory', true); + } +} + async function assertExistingRegularFile( path: string, label: string, @@ -254,13 +304,13 @@ async function assertExistingRegularFile( if (entry.isSymbolicLink() || !entry.isFile()) { fail('unsafe-path', `${label} must be a regular non-symlink file`); } - assertOwner(entry.uid, label); - if ( - requireSecureMode - && !rfc64PosixModeMatchesV1(entry.mode, RFC64_SECURE_FILE_MODE_V1) - ) { - fail('unsafe-path', `${label} must have owner-only mode 0600`); - } + assertSecureAccess( + path, + RFC64_SECURE_FILE_MODE_V1, + false, + requireSecureMode, + label, + ); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; fail('io', `failed to inspect ${label}`, cause); @@ -269,23 +319,29 @@ async function assertExistingRegularFile( async function chmodSecure(path: string, mode: number, label: string): Promise { try { - if (!rfc64UsesWindowsFilesystemPolicyV1()) await chmod(path, mode); const entry = await lstat(path); - assertOwner(entry.uid, label); - if (!rfc64PosixModeMatchesV1(entry.mode, mode)) { - throw new Error( - `${label} mode is ${(entry.mode & 0o777).toString(8)}, expected ${mode.toString(8)}`, - ); - } + applyRfc64OwnerOnlyPermissionsSyncV1(path, mode, entry.isDirectory()); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; fail('unsafe-path', `failed to secure ${label}`, cause); } } -function assertOwner(uid: number, label: string): void { - if (!rfc64CurrentUserOwnsUidV1(uid)) { - fail('unsafe-path', `${label} is not owned by the current process user`); +function assertSecureAccess( + path: string, + mode: number, + directory: boolean, + requireOwnerOnly: boolean, + label: string, +): void { + try { + if (requireOwnerOnly) { + assertRfc64OwnerOnlyPermissionsSyncV1(path, mode, directory); + } else { + assertRfc64FilesystemOwnerSyncV1(path); + } + } catch (cause) { + fail('unsafe-path', `${label} does not satisfy the owner-only access policy`, cause); } } @@ -305,10 +361,13 @@ async function fsyncRegularFile(path: string, label: string): Promise { if (!stat.isFile()) { fail('unsafe-path', `${label} fsync target is not a regular file`); } - assertOwner(stat.uid, label); - if (!rfc64PosixModeMatchesV1(stat.mode, RFC64_SECURE_FILE_MODE_V1)) { - fail('unsafe-path', `${label} fsync target must have owner-only mode 0600`); - } + assertSecureAccess( + path, + RFC64_SECURE_FILE_MODE_V1, + false, + true, + label, + ); await handle.sync(); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; @@ -332,6 +391,30 @@ function assertByteBounds(bytes: Uint8Array, maxBytes: number, label: string): v } } +function resolveContainedFileTarget(containmentRoot: string, relativePath: string): string { + if ( + typeof containmentRoot !== 'string' + || containmentRoot.length === 0 + || typeof relativePath !== 'string' + || relativePath.length === 0 + || isAbsolute(relativePath) + ) { + fail('input', 'durable file target requires one root and a non-empty relative path'); + } + const resolvedRoot = resolve(containmentRoot); + const targetPath = resolve(resolvedRoot, relativePath); + const relativeTarget = relative(resolvedRoot, targetPath); + if ( + relativeTarget.length === 0 + || relativeTarget === '..' + || relativeTarget.startsWith(`..${sep}`) + || isAbsolute(relativeTarget) + ) { + fail('unsafe-path', 'durable file target escaped its containment root'); + } + return targetPath; +} + function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { if (left.byteLength !== right.byteLength) return false; let different = 0; diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index c9a7d168bd..c845a35ae6 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -1,5 +1,4 @@ import { - chmodSync, closeSync, existsSync, fstatSync, @@ -24,13 +23,12 @@ import { sep, } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { spawnSync } from 'node:child_process'; import { setTimeout as delay } from 'node:timers/promises'; import { TextDecoder } from 'node:util'; import { + applyRfc64OwnerOnlyPermissionsSyncV1, + assertRfc64FilesystemOwnerSyncV1, fsyncRfc64DirectorySyncV1, - rfc64CurrentUserOwnsUidV1, - rfc64PosixModeMatchesV1, rfc64RegularFileFsyncOpenFlagsV1, } from '../secure-filesystem-policy-v1.js'; @@ -562,19 +560,6 @@ function reopenVerifiedOwnedDatabase( } } -/** @internal Proves that the exact live foundation owns the lease for dataDir. */ -export function inventoryV1OwnsDataDir( - inventory: unknown, - dataDir: string, -): inventory is Rfc64InventoryV1Foundation { - return inventory instanceof InventoryV1Foundation - && !inventory.closed - && typeof dataDir === 'string' - && dataDir.length > 0 - && inventory.databasePath - === resolve(resolve(dataDir), INVENTORY_V1_RELATIVE_PATH); -} - async function loadSqliteModule(): Promise { try { const moduleName = 'node:sqlite'; @@ -1392,57 +1377,8 @@ function tightenOwnedFileMode(databasePath: string): void { } function assertFilesystemOwner(path: string): void { - if (process.platform === 'win32') { - const script = String.raw` -$ErrorActionPreference = 'Stop' -$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() -$userSid = $identity.User -$defaultOwnerSid = $identity.Owner -$target = [System.IO.Path]::GetFullPath($env:DKG_RFC64_ACL_PATH) -$acl = if ([System.IO.Directory]::Exists($target)) { - [System.IO.Directory]::GetAccessControl( - $target, - [System.Security.AccessControl.AccessControlSections]::Owner - ) -} else { - [System.IO.File]::GetAccessControl( - $target, - [System.Security.AccessControl.AccessControlSections]::Owner - ) -} -$owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) -if ( - $owner.Value -ne $userSid.Value -and - $owner.Value -ne $defaultOwnerSid.Value -) { - [Console]::Error.WriteLine( - "owner SID $($owner.Value) is neither token user $($userSid.Value) nor token default owner $($defaultOwnerSid.Value)" - ) - exit 40 -} -`; - const result = spawnSync( - 'powershell.exe', - ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], - { - encoding: 'utf8', - windowsHide: true, - env: { ...process.env, DKG_RFC64_ACL_PATH: path }, - }, - ); - if (result.error !== undefined || result.status !== 0) { - throw new InventoryV1OpenError( - 'database-io', - `inventory path is not owned by the current Windows identity: ${path}`, - { cause: result.error ?? new Error(result.stderr.trim() || `PowerShell exited ${result.status}`) }, - ); - } - return; - } try { - if (!rfc64CurrentUserOwnsUidV1(statSync(path).uid)) { - throw new Error('filesystem entry is not owned by the current process uid'); - } + assertRfc64FilesystemOwnerSyncV1(path); } catch (cause) { throw new InventoryV1OpenError( 'database-io', @@ -1465,86 +1401,13 @@ function applySecurePermissions(path: string, mode: number, directory: boolean): // Never let chmod or Set-Acl turn a foreign existing entry into an owned one. // Newly created entries are also checked because they must already be owned // by this process before their permissions are tightened. - assertFilesystemOwner(path); - if (process.platform === 'win32') { - applyWindowsOwnerOnlyAcl(path, directory); - return; - } try { - chmodSync(path, mode); - const stat = statSync(path); - if (!rfc64CurrentUserOwnsUidV1(stat.uid)) { - throw new Error(`path owner uid ${stat.uid} does not match the current process uid`); - } - if (!rfc64PosixModeMatchesV1(stat.mode, mode)) { - throw new Error(`path mode ${(stat.mode & 0o777).toString(8)} does not match ${mode.toString(8)}`); - } + applyRfc64OwnerOnlyPermissionsSyncV1(path, mode, directory); } catch (cause) { throw new InventoryV1OpenError('database-io', `failed to set secure permissions on ${path}`, { cause }); } } -function applyWindowsOwnerOnlyAcl(path: string, directory: boolean): void { - const script = String.raw` -$ErrorActionPreference = 'Stop' -$target = $env:DKG_RFC64_ACL_PATH -$isDirectory = [System.Convert]::ToBoolean($env:DKG_RFC64_ACL_DIRECTORY) -$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User -$acl = if ($isDirectory) { - [System.Security.AccessControl.DirectorySecurity]::new() -} else { - [System.Security.AccessControl.FileSecurity]::new() -} -$acl.SetOwner($sid) -$acl.SetAccessRuleProtection($true, $false) -$inheritance = if ($isDirectory) { - [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' -} else { - [System.Security.AccessControl.InheritanceFlags]::None -} -$rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $sid, - [System.Security.AccessControl.FileSystemRights]::FullControl, - $inheritance, - [System.Security.AccessControl.PropagationFlags]::None, - [System.Security.AccessControl.AccessControlType]::Allow -) -$acl.AddAccessRule($rule) -if ($isDirectory) { - [System.IO.Directory]::SetAccessControl($target, $acl) - $verified = [System.IO.Directory]::GetAccessControl($target) -} else { - [System.IO.File]::SetAccessControl($target, $acl) - $verified = [System.IO.File]::GetAccessControl($target) -} -$rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) -if (-not $verified.AreAccessRulesProtected -or $rules.Count -ne 1) { exit 41 } -if ($rules[0].IdentityReference.Value -ne $sid.Value) { exit 42 } -if ($rules[0].AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { exit 43 } -if (($rules[0].FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { exit 44 } -`; - const result = spawnSync( - 'powershell.exe', - ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], - { - encoding: 'utf8', - windowsHide: true, - env: { - ...process.env, - DKG_RFC64_ACL_PATH: path, - DKG_RFC64_ACL_DIRECTORY: String(directory), - }, - }, - ); - if (result.error !== undefined || result.status !== 0) { - throw new InventoryV1OpenError( - 'database-io', - `failed to establish owner-only Windows ACL on ${path}`, - { cause: result.error ?? new Error(result.stderr.trim() || `PowerShell exited ${result.status}`) }, - ); - } -} - function rejectOwnedFileSymlinks(databasePath: string): void { for (const suffix of OWNED_FILE_SUFFIXES) { const path = `${databasePath}${suffix}`; diff --git a/packages/agent/src/rfc64/persistence-owner-capability-v1.ts b/packages/agent/src/rfc64/persistence-owner-capability-v1.ts new file mode 100644 index 0000000000..6454810c93 --- /dev/null +++ b/packages/agent/src/rfc64/persistence-owner-capability-v1.ts @@ -0,0 +1,54 @@ +import { isAbsolute, resolve } from 'node:path'; + +interface Rfc64PersistenceOwnerCapabilityStateV1 { + readonly rfc64RootPath: string; + readonly isLive: () => boolean; +} + +const CAPABILITY_STATE = new WeakMap(); +declare const RFC64_PERSISTENCE_OWNER_CAPABILITY_BRAND: unique symbol; + +/** Opaque runtime proof owned by the aggregate RFC-64 persistence lifecycle. */ +export interface Rfc64PersistenceOwnerCapabilityV1 { + readonly [RFC64_PERSISTENCE_OWNER_CAPABILITY_BRAND]: true; +} + +/** @internal Mint only after the inventory foundation owns its DK6L lease. */ +export function createRfc64PersistenceOwnerCapabilityV1( + rfc64RootPathInput: string, + isLive: () => boolean, +): Rfc64PersistenceOwnerCapabilityV1 { + if ( + typeof rfc64RootPathInput !== 'string' + || rfc64RootPathInput.length === 0 + || !isAbsolute(rfc64RootPathInput) + ) { + throw new TypeError('RFC-64 persistence owner root must be an absolute path'); + } + if (typeof isLive !== 'function') { + throw new TypeError('RFC-64 persistence owner liveness probe must be a function'); + } + const capability = Object.freeze({}) as Rfc64PersistenceOwnerCapabilityV1; + CAPABILITY_STATE.set(capability, Object.freeze({ + rfc64RootPath: resolve(rfc64RootPathInput), + isLive, + })); + return capability; +} + +/** @internal Validate the unforgeable capability and return its canonical root. */ +export function readLiveRfc64PersistenceOwnerRootV1( + capability: Rfc64PersistenceOwnerCapabilityV1, +): string { + if (capability === null || typeof capability !== 'object') { + throw new TypeError('RFC-64 persistence owner capability is missing'); + } + const state = CAPABILITY_STATE.get(capability as object); + if (state === undefined) { + throw new TypeError('RFC-64 persistence owner capability was not minted locally'); + } + if (!state.isLive()) { + throw new TypeError('RFC-64 persistence owner capability is no longer live'); + } + return state.rfc64RootPath; +} diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index 4fcf63f2b4..669d9a7038 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -1,3 +1,5 @@ +import { dirname } from 'node:path'; + import { openInventoryV1, type Rfc64InventoryV1Foundation, @@ -6,6 +8,7 @@ import { openRfc64ControlObjectStoreV1, type Rfc64ControlObjectStoreV1, } from './control-object-store-v1.js'; +import { createRfc64PersistenceOwnerCapabilityV1 } from './persistence-owner-capability-v1.js'; export interface OpenRfc64PersistenceOptionsV1 { /** Yield after each non-terminal fixed-size startup purge batch. */ @@ -74,7 +77,11 @@ export async function openRfc64PersistenceV1( if (batch.done) break; await yieldAfterPurgeBatch(); } - const controlObjectStore = await openRfc64ControlObjectStoreV1(dataDir, inventory); + const ownership = createRfc64PersistenceOwnerCapabilityV1( + dirname(inventory.databasePath), + () => !inventory.closed, + ); + const controlObjectStore = await openRfc64ControlObjectStoreV1(ownership); return new OwnedRfc64PersistenceV1(inventory, controlObjectStore); } catch (cause) { try { diff --git a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts index e6fa0092f5..2d0b4e8ab0 100644 --- a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts +++ b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts @@ -1,8 +1,11 @@ +import { spawnSync } from 'node:child_process'; import { + chmodSync, closeSync, constants, fsyncSync, openSync, + statSync, } from 'node:fs'; import { open } from 'node:fs/promises'; @@ -37,6 +40,50 @@ export function rfc64PosixModeMatchesV1(mode: number, expected: number): boolean return rfc64UsesWindowsFilesystemPolicyV1() || (mode & 0o777) === expected; } +export function assertRfc64FilesystemOwnerSyncV1(path: string): void { + if (rfc64UsesWindowsFilesystemPolicyV1()) { + runWindowsAclPolicy(path, statSync(path).isDirectory(), 'owner'); + return; + } + if (!rfc64CurrentUserOwnsUidV1(statSync(path).uid)) { + throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); + } +} + +export function assertRfc64OwnerOnlyPermissionsSyncV1( + path: string, + expectedMode: number, + directory: boolean, +): void { + if (rfc64UsesWindowsFilesystemPolicyV1()) { + runWindowsAclPolicy(path, directory, 'assert-owner-only'); + return; + } + const stat = statSync(path); + if (!rfc64CurrentUserOwnsUidV1(stat.uid)) { + throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); + } + if (!rfc64PosixModeMatchesV1(stat.mode, expectedMode)) { + throw new Error( + `RFC-64 path mode ${(stat.mode & 0o777).toString(8)} does not match ${expectedMode.toString(8)}`, + ); + } +} + +export function applyRfc64OwnerOnlyPermissionsSyncV1( + path: string, + mode: number, + directory: boolean, +): void { + if (rfc64UsesWindowsFilesystemPolicyV1()) { + runWindowsAclPolicy(path, directory, 'apply-owner-only'); + return; + } + assertRfc64FilesystemOwnerSyncV1(path); + chmodSync(path, mode); + assertRfc64OwnerOnlyPermissionsSyncV1(path, mode, directory); +} + /** Node cannot FlushFileBuffers on a Windows directory handle. */ export async function fsyncRfc64DirectoryV1(path: string): Promise { if (rfc64UsesWindowsFilesystemPolicyV1()) return; @@ -74,3 +121,124 @@ export function rfc64RegularFileReadOpenFlagsV1(): number { return constants.O_RDONLY | (rfc64UsesWindowsFilesystemPolicyV1() ? 0 : constants.O_NOFOLLOW); } + +type WindowsAclPolicyActionV1 = 'owner' | 'assert-owner-only' | 'apply-owner-only'; + +function runWindowsAclPolicy( + path: string, + directory: boolean, + action: WindowsAclPolicyActionV1, +): void { + const script = String.raw` +$ErrorActionPreference = 'Stop' +$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$userSid = $identity.User +$defaultOwnerSid = $identity.Owner +$target = [System.IO.Path]::GetFullPath($env:DKG_RFC64_ACL_PATH) +$isDirectory = [System.Convert]::ToBoolean($env:DKG_RFC64_ACL_DIRECTORY) +$action = $env:DKG_RFC64_ACL_ACTION + +function Read-TargetAcl { + if ($isDirectory) { + return [System.IO.Directory]::GetAccessControl( + $target, + [System.Security.AccessControl.AccessControlSections]'Owner, Access' + ) + } + return [System.IO.File]::GetAccessControl( + $target, + [System.Security.AccessControl.AccessControlSections]'Owner, Access' + ) +} + +function Assert-CurrentOwner($acl) { + $owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) + if ( + $owner.Value -ne $userSid.Value -and + $owner.Value -ne $defaultOwnerSid.Value + ) { + throw "owner SID $($owner.Value) is not the current token owner" + } +} + +$acl = Read-TargetAcl +Assert-CurrentOwner $acl + +if ($action -eq 'apply-owner-only') { + $acl = if ($isDirectory) { + [System.Security.AccessControl.DirectorySecurity]::new() + } else { + [System.Security.AccessControl.FileSecurity]::new() + } + $acl.SetOwner($userSid) + $acl.SetAccessRuleProtection($true, $false) + $inheritance = if ($isDirectory) { + [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + } else { + [System.Security.AccessControl.InheritanceFlags]::None + } + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $userSid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $acl.AddAccessRule($rule) + if ($isDirectory) { + [System.IO.Directory]::SetAccessControl($target, $acl) + } else { + [System.IO.File]::SetAccessControl($target, $acl) + } + $acl = Read-TargetAcl + Assert-CurrentOwner $acl +} + +if ($action -ne 'owner') { + if (-not $acl.AreAccessRulesProtected) { + throw 'RFC-64 owner-only ACL must disable inherited access rules' + } + $allowedRights = 0 + foreach ($rule in $acl.GetAccessRules( + $true, + $true, + [System.Security.Principal.SecurityIdentifier] + )) { + if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { + continue + } + if ($rule.IdentityReference.Value -ne $userSid.Value) { + throw "allow ACL grants another SID: $($rule.IdentityReference.Value)" + } + $allowedRights = $allowedRights -bor [int]$rule.FileSystemRights + } + $fullControl = [int][System.Security.AccessControl.FileSystemRights]::FullControl + if (($allowedRights -band $fullControl) -ne $fullControl) { + throw 'current user does not hold FullControl on the RFC-64 path' + } +} +`; + const result = spawnSync( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { + encoding: 'utf8', + windowsHide: true, + env: { + ...process.env, + DKG_RFC64_ACL_ACTION: action, + DKG_RFC64_ACL_DIRECTORY: String(directory), + DKG_RFC64_ACL_PATH: path, + }, + }, + ); + if (result.error !== undefined || result.status !== 0) { + throw new Error( + `RFC-64 Windows owner-only ACL policy failed for ${path}: ${ + result.error?.message + ?? (result.stderr.trim() || `PowerShell exited ${result.status}`) + }`, + result.error === undefined ? {} : { cause: result.error }, + ); + } +} diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index 898ae96ee0..a35fb046a0 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -15,6 +15,10 @@ import { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, openRfc64ControlObjectStoreV1, } from '../src/rfc64/control-object-store-v1.js'; +import { + createRfc64PersistenceOwnerCapabilityV1, + type Rfc64PersistenceOwnerCapabilityV1, +} from '../src/rfc64/persistence-owner-capability-v1.js'; const temporaryDirectories: string[] = []; const childProcesses = new Set(); @@ -262,9 +266,12 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { const dataDirectory = temporaryDataDirectory(); const outside = temporaryDataDirectory(); const inventory = await openInventoryV1(dataDirectory); + const ownership = createRfc64PersistenceOwnerCapabilityV1( + dirname(inventory.databasePath), + () => !inventory.closed, + ); const initialStore = await openRfc64ControlObjectStoreV1( - dataDirectory, - inventory, + ownership, ); initialStore.close(); inventory.close(); @@ -286,15 +293,21 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { }, ); - it('requires the live inventory owner for the exact control-store dataDir', async () => { + it('requires an unforgeable live aggregate persistence owner capability', async () => { const dataDirectory = temporaryDataDirectory(); - const otherDataDirectory = temporaryDataDirectory(); const inventory = await openInventoryV1(dataDirectory); + const ownership = createRfc64PersistenceOwnerCapabilityV1( + dirname(inventory.databasePath), + () => !inventory.closed, + ); - await expect(openRfc64ControlObjectStoreV1(otherDataDirectory, inventory)) - .rejects.toMatchObject({ code: 'control-store-input' }); + const store = await openRfc64ControlObjectStoreV1(ownership); + store.close(); + await expect(openRfc64ControlObjectStoreV1( + {} as Rfc64PersistenceOwnerCapabilityV1, + )).rejects.toMatchObject({ code: 'control-store-input' }); inventory.close(); - await expect(openRfc64ControlObjectStoreV1(dataDirectory, inventory)) + await expect(openRfc64ControlObjectStoreV1(ownership)) .rejects.toMatchObject({ code: 'control-store-input' }); }); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 82dcb0e1ad..b48d01d933 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process'; import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -32,6 +33,7 @@ import { type Rfc64ControlObjectStoreDurabilityBoundaryV1, type StageVerifiedControlObjectV1, } from '../src/rfc64/control-object-store-v1.js'; +import { putRfc64ExactBytesV1 } from '../src/rfc64/durable-file-store-v1.js'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; const PRIVATE_KEY = `0x${'42'.repeat(32)}`; @@ -53,6 +55,25 @@ async function temporaryDataDirectory(): Promise { return path; } +function grantWindowsEveryoneRead(path: string, directory: boolean): void { + const permission = directory + ? '*S-1-1-0:(OI)(CI)(RX)' + : '*S-1-1-0:(R)'; + const result = spawnSync( + 'icacls.exe', + [path, '/grant', permission], + { encoding: 'utf8', windowsHide: true }, + ); + if (result.error !== undefined || result.status !== 0) { + throw new Error( + `failed to grant the Windows ACL test permission: ${ + result.error?.message ?? (result.stderr.trim() || `icacls exited ${result.status}`) + }`, + result.error === undefined ? {} : { cause: result.error }, + ); + } +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map(async (path) => { await rm(path, { recursive: true, force: true }); @@ -540,6 +561,35 @@ describe('RFC-64 durable control-object store v1', () => { }, ); + it.runIf(process.platform === 'win32')( + 'rejects permissive existing Windows file and directory ACLs', + async () => { + const directoryDataDir = await temporaryDataDirectory(); + const directoryStore = await openRfc64ControlObjectStoreV1(directoryDataDir); + const objectsDirectory = join( + directoryDataDir, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + 'objects', + ); + directoryStore.close(); + grantWindowsEveryoneRead(objectsDirectory, true); + await expect(openRfc64ControlObjectStoreV1(directoryDataDir)) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + + const fileDataDir = await temporaryDataDirectory(); + const fileStore = await openRfc64ControlObjectStoreV1(fileDataDir); + const fixture = await signedFixture('8c'); + await fileStore.stageVerifiedObjects([fixture]); + const paths = pathsFor(fileDataDir, fixture.envelope); + grantWindowsEveryoneRead(paths.object, false); + await expect(fileStore.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + }, + ); + it('returns null for an exact cache miss without calling the verifier', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); @@ -581,6 +631,22 @@ describe('RFC-64 durable control-object store v1', () => { })).toThrow(expect.objectContaining({ code: 'control-store-closed' })); }); + it('rejects an escaping durable-file relative key inside the write operation', async () => { + const containmentRoot = await temporaryDataDirectory(); + await expect(putRfc64ExactBytesV1({ + containmentRoot, + relativePath: join('..', 'escaped.jcs'), + bytes: new TextEncoder().encode('{}'), + maxBytes: 16, + label: 'test control object', + kind: 'object', + io: Object.freeze({ + boundary: () => {}, + randomSuffix: () => '00'.repeat(16), + }), + })).rejects.toMatchObject({ code: 'unsafe-path' }); + }); + it('uses a closed typed error registry', () => { expect(() => new Rfc64ControlObjectStoreErrorV1( 'not-registered' as never, diff --git a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts index bc93913aab..963a94e40d 100644 --- a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts +++ b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts @@ -1,4 +1,20 @@ import * as publicApi from '../src/index.js'; +import { + RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, + RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, + RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, + Rfc64ControlObjectStoreErrorV1, + type GetVerifiedControlObjectInputV1, + type Rfc64ControlObjectStoreErrorCodeV1, + type Rfc64ControlObjectStoreNamespaceDurabilityV1, + type Rfc64ControlObjectStoreV1, + type StageVerifiedControlObjectV1, + type StageVerifiedControlObjectsResultV1, + type StagedVerifiedControlObjectV1, + type StoredVerifiedControlObjectV1, +} from '../src/index.js'; type PublicApiHasRawControlStoreOpener = 'openRfc64ControlObjectStoreV1' extends keyof typeof publicApi ? true : false; @@ -7,4 +23,27 @@ type PublicApiHasRawControlStoreOpener = // persistence owner; direct-source tests retain a guarded internal seam. const rawControlStoreOpenerIsNotPublic: PublicApiHasRawControlStoreOpener = false; +const publicControlStoreRuntimeExports = [ + RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, + RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, + RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, + Rfc64ControlObjectStoreErrorV1, +] as const; + +type PublicControlStoreTypesAreUsable = readonly [ + GetVerifiedControlObjectInputV1, + Rfc64ControlObjectStoreErrorCodeV1, + Rfc64ControlObjectStoreNamespaceDurabilityV1, + Rfc64ControlObjectStoreV1, + StageVerifiedControlObjectV1, + StageVerifiedControlObjectsResultV1, + StagedVerifiedControlObjectV1, + StoredVerifiedControlObjectV1, +]; +const publicControlStoreTypesAreUsable: PublicControlStoreTypesAreUsable | undefined = undefined; + void rawControlStoreOpenerIsNotPublic; +void publicControlStoreRuntimeExports; +void publicControlStoreTypesAreUsable; diff --git a/packages/core/src/sync-control-object.ts b/packages/core/src/sync-control-object.ts index 02ae18a59d..40ee8a5038 100644 --- a/packages/core/src/sync-control-object.ts +++ b/packages/core/src/sync-control-object.ts @@ -269,6 +269,73 @@ export function parseCanonicalControlSignatureVariant( return variant; } +export interface SplitCanonicalSignedControlEnvelopeV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly unsignedEnvelope: UnsignedControlEnvelopeV1; + readonly signatureVariant: ControlObjectSignatureVariantV1; + readonly unsignedEnvelopeBytes: Uint8Array; + readonly signatureVariantBytes: Uint8Array; +} + +/** + * Snapshot one signed envelope into the exact immutable records used by the + * RFC-64 digest-addressed cache. This is the sole schema-aware split boundary. + */ +export function splitCanonicalSignedControlEnvelopeV1( + input: SignedControlEnvelopeV1, +): SplitCanonicalSignedControlEnvelopeV1 { + const envelope = parseCanonicalSignedControlEnvelope( + canonicalizeSignedControlEnvelopeBytes(input), + ); + const unsignedEnvelope = extractUnsignedEnvelope(envelope); + const signatureVariantDigest = computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ); + const signatureVariant: ControlObjectSignatureVariantV1 = { + objectDigest: envelope.objectDigest, + signature: envelope.signature, + signatureVariantDigest, + }; + return Object.freeze({ + envelope, + unsignedEnvelope, + signatureVariant, + unsignedEnvelopeBytes: canonicalizeUnsignedControlEnvelopeBytes(unsignedEnvelope), + signatureVariantBytes: canonicalizeControlSignatureVariantBytes(signatureVariant), + }); +} + +/** + * Recombine exact cache records only when both records are canonical for the + * requested digest keys. Signed-envelope validation re-computes objectDigest. + */ +export function recombineCanonicalSignedControlEnvelopeV1( + unsignedEnvelopeInput: string | Uint8Array, + signatureVariantInput: string | Uint8Array, + expectedObjectDigest: string, + expectedSignatureVariantDigest: string, +): SignedControlEnvelopeV1 { + assertCanonicalDigest(expectedObjectDigest, 'expectedObjectDigest'); + assertCanonicalDigest( + expectedSignatureVariantDigest, + 'expectedSignatureVariantDigest', + ); + const unsignedEnvelope = parseCanonicalUnsignedControlEnvelope(unsignedEnvelopeInput); + const signatureVariant = parseCanonicalControlSignatureVariant(signatureVariantInput); + if ( + signatureVariant.objectDigest !== expectedObjectDigest + || signatureVariant.signatureVariantDigest !== expectedSignatureVariantDigest + ) { + throw new Error('Control-object cache records do not match the requested digest keys'); + } + return parseCanonicalSignedControlEnvelope(canonicalizeSignedControlEnvelopeBytes({ + ...unsignedEnvelope, + objectDigest: expectedObjectDigest, + signature: signatureVariant.signature, + })); +} + function bytesToLowerHex(bytes: Uint8Array): string { let result = '0x'; for (const byte of bytes) result += byte.toString(16).padStart(2, '0'); diff --git a/packages/core/test/sync-control-object.test.ts b/packages/core/test/sync-control-object.test.ts index 44bb16a3c0..dea92c09c8 100644 --- a/packages/core/test/sync-control-object.test.ts +++ b/packages/core/test/sync-control-object.test.ts @@ -15,6 +15,8 @@ import { parseCanonicalControlSignatureVariant, parseCanonicalSignedControlEnvelope, parseCanonicalUnsignedControlEnvelope, + recombineCanonicalSignedControlEnvelopeV1, + splitCanonicalSignedControlEnvelopeV1, type ControlObjectSignatureSuite, type SignedControlEnvelopeV1, type UnsignedControlEnvelopeV1, @@ -146,6 +148,41 @@ describe('Track-2 control-object envelopes', () => { }))).toBe(EOA_CANONICAL_SIGNATURE_VARIANT); }); + it('owns the exact cache split and fail-closed recombination schema in core', () => { + const split = splitCanonicalSignedControlEnvelopeV1(EOA_SIGNED); + + expect(split.envelope).toEqual(EOA_SIGNED); + expect(split.unsignedEnvelope).toEqual(EOA_VECTOR); + expect(split.signatureVariant).toEqual({ + objectDigest: EOA_DIGEST, + signature: EOA_SIGNATURE, + signatureVariantDigest: EOA_SIGNATURE_VARIANT_DIGEST, + }); + expect(new TextDecoder().decode(split.unsignedEnvelopeBytes)) + .toBe(EOA_CANONICAL_UNSIGNED); + expect(new TextDecoder().decode(split.signatureVariantBytes)) + .toBe(EOA_CANONICAL_SIGNATURE_VARIANT); + expect(recombineCanonicalSignedControlEnvelopeV1( + split.unsignedEnvelopeBytes, + split.signatureVariantBytes, + EOA_DIGEST, + EOA_SIGNATURE_VARIANT_DIGEST, + )).toEqual(EOA_SIGNED); + + expect(() => recombineCanonicalSignedControlEnvelopeV1( + split.unsignedEnvelopeBytes, + split.signatureVariantBytes, + SAFE_DIGEST, + EOA_SIGNATURE_VARIANT_DIGEST, + )).toThrow(/requested digest keys/); + expect(() => recombineCanonicalSignedControlEnvelopeV1( + new TextEncoder().encode(SAFE_CANONICAL_UNSIGNED), + split.signatureVariantBytes, + EOA_DIGEST, + EOA_SIGNATURE_VARIANT_DIGEST, + )).toThrow(/digest mismatch/); + }); + it('is independent of JavaScript insertion order and verifies exact claims', () => { const reordered = { signatureEvidence: { kind: 'none' }, From bb5b363d3c321af7afe2e290ae74839fd3eadd09 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 14:00:35 +0200 Subject: [PATCH 071/292] test(agent): secure Windows corrupt fixture --- packages/agent/test/rfc64-control-object-store-v1.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index b48d01d933..dd6ceaf6ab 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -35,6 +35,7 @@ import { } from '../src/rfc64/control-object-store-v1.js'; import { putRfc64ExactBytesV1 } from '../src/rfc64/durable-file-store-v1.js'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { applyRfc64OwnerOnlyPermissionsSyncV1 } from '../src/rfc64/secure-filesystem-policy-v1.js'; const PRIVATE_KEY = `0x${'42'.repeat(32)}`; const wallet = new ethers.Wallet(PRIVATE_KEY); @@ -403,6 +404,11 @@ describe('RFC-64 durable control-object store v1', () => { await writeFile(wrongPath, await readFile(paths.signature), { mode: RFC64_CONTROL_OBJECT_STORE_FILE_MODE, }); + applyRfc64OwnerOnlyPermissionsSyncV1( + wrongPath, + RFC64_CONTROL_OBJECT_STORE_FILE_MODE, + false, + ); const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); await expect(store.getVerifiedObject({ From 1c90ead094cb6dec1a34891b0a94ec4fb4b39045 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 14:32:27 +0200 Subject: [PATCH 072/292] fix(agent): fence RFC-64 immutable persistence races --- .github/workflows/rfc64-inventory-windows.yml | 3 +- packages/agent/src/dkg-agent-base.ts | 4 +- packages/agent/src/dkg-agent-lifecycle.ts | 2 +- packages/agent/src/dkg-agent.ts | 2 +- packages/agent/src/index.ts | 16 - .../rfc64/control-object-store-v1-internal.ts | 531 ++++++++++++++++ .../src/rfc64/control-object-store-v1.ts | 566 +----------------- .../agent/src/rfc64/durable-file-store-v1.ts | 147 +++-- .../rfc64/persistence-owner-capability-v1.ts | 54 -- packages/agent/src/rfc64/persistence-v1.ts | 23 +- .../src/rfc64/secure-filesystem-policy-v1.ts | 4 +- .../rfc64-agent-inventory-lifecycle-child.ts | 16 +- .../rfc64-agent-inventory-lifecycle.test.ts | 83 +-- .../rfc64-control-object-store-v1.test.ts | 184 +++++- ...fc64-control-store-public-api.typecheck.ts | 74 +-- ...rfc64-control-object-store-test-support.ts | 42 ++ 16 files changed, 947 insertions(+), 804 deletions(-) create mode 100644 packages/agent/src/rfc64/control-object-store-v1-internal.ts delete mode 100644 packages/agent/src/rfc64/persistence-owner-capability-v1.ts create mode 100644 packages/agent/test/support/rfc64-control-object-store-test-support.ts diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index ef3bac3a27..0ce776b990 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -7,9 +7,9 @@ on: - 'packages/agent/src/rfc64/inventory-v1/**' - 'packages/agent/src/rfc64/author-catalog-producer.ts' - 'packages/agent/src/rfc64/control-object-store-v1.ts' + - 'packages/agent/src/rfc64/control-object-store-v1-internal.ts' - 'packages/agent/src/rfc64/durable-file-store-v1.ts' - 'packages/agent/src/rfc64/persistence-v1.ts' - - 'packages/agent/src/rfc64/persistence-owner-capability-v1.ts' - 'packages/agent/src/rfc64/secure-filesystem-policy-v1.ts' - 'packages/agent/src/dkg-agent-base.ts' - 'packages/agent/src/index.ts' @@ -17,6 +17,7 @@ on: - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' - 'packages/agent/test/rfc64-author-catalog-producer.test.ts' - 'packages/agent/test/rfc64-control-object-store-v1.test.ts' + - 'packages/agent/test/support/rfc64-control-object-store-test-support.ts' - 'packages/agent/test/rfc64-control-store-public-api.typecheck.ts' - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index d4e40dc268..3183d18327 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -1583,10 +1583,10 @@ export class DKGAgentBase { * Relinquish the control store and single inventory foundation. Clear local * references before closing so fail-stop cleanup cannot be retried. */ - protected closeRfc64InventoryV1(): void { + protected async closeRfc64InventoryV1(): Promise { const persistence = this.rfc64PersistenceV1; this.rfc64PersistenceV1 = undefined; - persistence?.close(); + await persistence?.close(); } /** diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 9523ec397e..691f0b4199 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -952,7 +952,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { await this.node.start(); } catch (cause) { try { - this.closeRfc64InventoryV1(); + await this.closeRfc64InventoryV1(); } catch (closeCause) { throw new AggregateError( [cause, closeCause], diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 607de464c0..47a021ffb8 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1721,7 +1721,7 @@ export class DKGAgent extends DKGAgentBase { let inventoryCloseFailed = false; let inventoryCloseFailure: unknown; try { - this.closeRfc64InventoryV1(); + await this.closeRfc64InventoryV1(); } catch (error) { inventoryCloseFailed = true; inventoryCloseFailure = error; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 4460dd1a0c..3edd885080 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -36,22 +36,6 @@ export { } from './auth/agent-delegation.js'; export * from './rfc64/catalog-row-authorship.js'; export * from './rfc64/author-catalog-producer.js'; -export { - RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, - RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, - RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, - RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, - RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, - Rfc64ControlObjectStoreErrorV1, - type GetVerifiedControlObjectInputV1, - type Rfc64ControlObjectStoreErrorCodeV1, - type Rfc64ControlObjectStoreNamespaceDurabilityV1, - type Rfc64ControlObjectStoreV1, - type StageVerifiedControlObjectV1, - type StageVerifiedControlObjectsResultV1, - type StagedVerifiedControlObjectV1, - type StoredVerifiedControlObjectV1, -} from './rfc64/control-object-store-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts new file mode 100644 index 0000000000..0be7b04991 --- /dev/null +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -0,0 +1,531 @@ +import { randomBytes } from 'node:crypto'; +import { join, resolve } from 'node:path'; + +import { + MAX_CONTROL_OBJECT_BYTES, + MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + assertCanonicalDigest, + computeControlSignatureVariantDigestHex, + recombineCanonicalSignedControlEnvelopeV1, + splitCanonicalSignedControlEnvelopeV1, + type Digest32V1, + type SignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + readVerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; +import { + Rfc64DurableFileErrorV1, + assertRfc64ExistingDirectoryV1, + ensureRfc64SecureDirectoryTreeV1, + putRfc64ExactBytesV1, + readRfc64OptionalBoundedBytesV1, + type Rfc64DurableFileBoundaryV1, + type Rfc64DurableFileIoV1, +} from './durable-file-store-v1.js'; +import { + RFC64_POSIX_NAMESPACE_DURABILITY_V1, + RFC64_SECURE_DIRECTORY_MODE_V1, + RFC64_SECURE_FILE_MODE_V1, + RFC64_WINDOWS_NAMESPACE_DURABILITY_V1, + rfc64NamespaceDurabilityV1, + type Rfc64NamespaceDurabilityV1, +} from './secure-filesystem-policy-v1.js'; + +export const RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH = + 'rfc64-sync/control-objects-v1' as const; +export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; +export const RFC64_CONTROL_OBJECT_STORE_FILE_MODE = RFC64_SECURE_FILE_MODE_V1; +export const RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS = 16; +export const RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY = + RFC64_POSIX_NAMESPACE_DURABILITY_V1; +export const RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY = + RFC64_WINDOWS_NAMESPACE_DURABILITY_V1; + +export type Rfc64ControlObjectStoreNamespaceDurabilityV1 = Rfc64NamespaceDurabilityV1; + +const OBJECTS_DIRECTORY = 'objects'; +const SIGNATURES_DIRECTORY = 'signatures'; +const CONTROL_OBJECT_STORE_DIRECTORY = 'control-objects-v1'; +const CANONICAL_FILE_SUFFIX = '.jcs'; + +export const RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1 = Object.freeze([ + 'control-store-input', + 'control-store-verification', + 'control-store-unsafe-path', + 'control-store-corrupt', + 'control-store-io', + 'control-store-durability', + 'control-store-closed', +] as const); + +export type Rfc64ControlObjectStoreErrorCodeV1 = + (typeof RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1)[number]; + +export class Rfc64ControlObjectStoreErrorV1 extends Error { + constructor( + readonly code: Rfc64ControlObjectStoreErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + if (!RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1.includes(code)) { + throw new TypeError(`Unsupported RFC-64 control store error code: ${code}`); + } + this.name = 'Rfc64ControlObjectStoreErrorV1'; + } +} + +type Rfc64ControlObjectStoreFileKindV1 = 'object' | 'signature'; + +export type Rfc64ControlObjectStoreDurabilityBoundaryV1 = + Rfc64DurableFileBoundaryV1; + +interface Rfc64ControlObjectStoreIoV1 + extends Rfc64DurableFileIoV1 {} + +const PRODUCTION_IO = Object.freeze({ + boundary: (_boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1): void => {}, + randomSuffix: (): string => randomBytes(16).toString('hex'), +}) satisfies Rfc64ControlObjectStoreIoV1; + +export interface StageVerifiedControlObjectV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; +} + +export interface StagedVerifiedControlObjectV1 { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; +} + +export interface StageVerifiedControlObjectsResultV1 { + /** Every named file and platform-supported containing-directory barrier has completed. */ + readonly durable: true; + /** Explicitly fences later semantic ref publication from weaker namespace barriers. */ + readonly namespaceDurability: Rfc64ControlObjectStoreNamespaceDurabilityV1; + readonly objects: readonly StagedVerifiedControlObjectV1[]; +} + +export interface GetVerifiedControlObjectInputV1 { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + /** Re-establishes current generic envelope cryptography after reading cache bytes. */ + readonly verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; +} + +export interface StoredVerifiedControlObjectV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; +} + +export interface Rfc64ControlObjectStoreV1 { + readonly rootPath: string; + readonly closed: boolean; + readonly namespaceDurability: Rfc64ControlObjectStoreNamespaceDurabilityV1; + /** + * Durably stage immutable unsigned envelopes and detached signature variants. + * Success does not advance a semantic ref or make any catalog authoritative. + */ + stageVerifiedObjects( + input: readonly StageVerifiedControlObjectV1[], + ): Promise; + /** Read exact cache keys and reverify the reconstructed signed envelope. */ + getVerifiedObject( + input: GetVerifiedControlObjectInputV1, + ): Promise; + /** Reject new operations, then settle every admitted read/write. */ + close(): Promise; +} + +/** + * Open after the RFC-64 inventory foundation has secured rfc64-sync and owns + * its single-process lease. This cache deliberately does not mint that lease. + */ +export async function openRfc64ControlObjectStoreAtOwnedRootV1( + rfc64RootPath: string, +): Promise { + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, PRODUCTION_IO); +} + +interface PreparedStoredControlObjectV1 { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + readonly unsignedBytes: Uint8Array; + readonly signatureVariantBytes: Uint8Array; +} + +class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { + #closed = false; + #closePromise: Promise | null = null; + readonly #inFlightOperations = new Set>(); + readonly #fileWriteTails = new Map>(); + readonly namespaceDurability = rfc64NamespaceDurabilityV1(); + + constructor( + readonly rootPath: string, + private readonly io: Rfc64ControlObjectStoreIoV1, + ) {} + + get closed(): boolean { + return this.#closed; + } + + stageVerifiedObjects( + input: readonly StageVerifiedControlObjectV1[], + ): Promise { + this.requireOpen(); + const prepared = prepareStageBatch(input); + const operation = (async () => { + this.requireOpen(); + const result = await Promise.all(prepared.map(async (item) => { + await this.stagePrepared(item); + return Object.freeze({ + objectDigest: item.objectDigest, + signatureVariantDigest: item.signatureVariantDigest, + }); + })); + return Object.freeze({ + durable: true as const, + namespaceDurability: this.namespaceDurability, + objects: Object.freeze(result), + }); + })(); + return this.trackOperation(operation); + } + + getVerifiedObject( + input: GetVerifiedControlObjectInputV1, + ): Promise { + this.requireOpen(); + const objectDigest = snapshotDigest(input.objectDigest, 'objectDigest'); + const signatureVariantDigest = snapshotDigest( + input.signatureVariantDigest, + 'signatureVariantDigest', + ); + const verifyIssuerSignature = input.verifyIssuerSignature; + if (typeof verifyIssuerSignature !== 'function') { + fail('control-store-input', 'verifyIssuerSignature must be a function'); + } + const operation = (async () => { + const objectPath = this.objectRelativePath(objectDigest); + const signaturePath = this.signatureRelativePath( + objectDigest, + signatureVariantDigest, + ); + const [unsignedBytes, variantBytes] = await mapDurableFileErrors(async () => + Promise.all([ + readRfc64OptionalBoundedBytesV1({ + containmentRoot: this.rootPath, + relativePath: objectPath, + maxBytes: MAX_CONTROL_OBJECT_BYTES, + label: 'control object', + }), + readRfc64OptionalBoundedBytesV1({ + containmentRoot: this.rootPath, + relativePath: signaturePath, + maxBytes: MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + label: 'control signature variant', + }), + ])); + if (unsignedBytes === null || variantBytes === null) return null; + + let envelope: SignedControlEnvelopeV1; + try { + envelope = deepFreezePlain(recombineCanonicalSignedControlEnvelopeV1( + unsignedBytes, + variantBytes, + objectDigest, + signatureVariantDigest, + )); + } catch (cause) { + fail('control-store-corrupt', 'stored control object is not canonical for its exact keys', cause); + } + + // The caller callback intentionally runs outside every file-key tail. It + // may compose this cache with another exact read without deadlocking the + // write that produced the snapshot above. + let issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; + try { + issuerSignature = await verifyIssuerSignature(envelope); + assertProofMatchesEnvelope(envelope, issuerSignature); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-verification', 'stored control object signature verification failed', cause); + } + return Object.freeze({ envelope, issuerSignature }); + })(); + return this.trackOperation(operation); + } + + close(): Promise { + if (this.#closePromise !== null) return this.#closePromise; + this.#closed = true; + this.#closePromise = (async () => { + while (this.#inFlightOperations.size > 0) { + await Promise.allSettled([...this.#inFlightOperations]); + } + })(); + return this.#closePromise; + } + + private async stagePrepared(item: PreparedStoredControlObjectV1): Promise { + const objectPath = this.objectRelativePath(item.objectDigest); + await this.stageFileByKey(objectPath, item.unsignedBytes, 'object'); + + const signaturePath = this.signatureRelativePath( + item.objectDigest, + item.signatureVariantDigest, + ); + await this.stageFileByKey(signaturePath, item.signatureVariantBytes, 'signature'); + } + + private objectRelativePath(objectDigest: Digest32V1): string { + const hex = objectDigest.slice(2); + return join( + OBJECTS_DIRECTORY, + hex.slice(0, 2), + `${hex}${CANONICAL_FILE_SUFFIX}`, + ); + } + + private signatureRelativePath( + objectDigest: Digest32V1, + signatureVariantDigest: Digest32V1, + ): string { + const objectHex = objectDigest.slice(2); + const variantHex = signatureVariantDigest.slice(2); + return join( + SIGNATURES_DIRECTORY, + objectHex.slice(0, 2), + objectHex, + `${variantHex}${CANONICAL_FILE_SUFFIX}`, + ); + } + + private stageFileByKey( + relativePath: string, + bytes: Uint8Array, + kind: 'object' | 'signature', + ): Promise { + const previous = this.#fileWriteTails.get(relativePath) ?? Promise.resolve(); + const run = previous.then(async () => { + this.requireOpen(); + await this.putExactFile(relativePath, bytes, kind); + }, async () => { + this.requireOpen(); + await this.putExactFile(relativePath, bytes, kind); + }); + this.#fileWriteTails.set(relativePath, run); + void run.finally(() => { + if (this.#fileWriteTails.get(relativePath) === run) { + this.#fileWriteTails.delete(relativePath); + } + }).catch(() => undefined); + return run; + } + + private async putExactFile( + relativePath: string, + bytes: Uint8Array, + kind: Rfc64ControlObjectStoreFileKindV1, + ): Promise { + await mapDurableFileErrors(async () => { + await putRfc64ExactBytesV1({ + containmentRoot: this.rootPath, + relativePath, + bytes, + maxBytes: kind === 'object' + ? MAX_CONTROL_OBJECT_BYTES + : MAX_CONTROL_SIGNATURE_VARIANT_BYTES, + label: kind === 'object' ? 'control object' : 'control signature variant', + kind, + io: this.io, + }); + }); + } + + private requireOpen(): void { + if (this.#closed) fail('control-store-closed', 'control object store is closed'); + } + + private trackOperation(operation: Promise): Promise { + this.#inFlightOperations.add(operation); + void operation.finally(() => { + this.#inFlightOperations.delete(operation); + }).catch(() => undefined); + return operation; + } +} + +/** @internal Used only by the package-local test support module. */ +export async function openRfc64ControlObjectStoreForTestV1( + dataDirInput: string, + io: Rfc64ControlObjectStoreIoV1, +): Promise { + if (!Object.isFrozen(io)) { + fail('control-store-input', 'control object store I/O adapter must be immutable'); + } + if (typeof dataDirInput !== 'string' || dataDirInput.length === 0) { + fail('control-store-input', 'dataDir must be a non-empty path'); + } + const dataDir = resolve(dataDirInput); + const rfc64RootPath = resolve(dataDir, 'rfc64-sync'); + await mapDurableFileErrors(async () => { + await assertRfc64ExistingDirectoryV1(dataDir, 'DKG data directory', false); + await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, io); + }); + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, io); +} + +async function openRfc64ControlObjectStoreAtRootV1( + rfc64RootPathInput: string, + io: Rfc64ControlObjectStoreIoV1, +): Promise { + if (!Object.isFrozen(io)) { + fail('control-store-input', 'control object store I/O adapter must be immutable'); + } + const rfc64RootPath = resolve(rfc64RootPathInput); + const rootPath = resolve(rfc64RootPath, CONTROL_OBJECT_STORE_DIRECTORY); + await mapDurableFileErrors(async () => { + await assertRfc64ExistingDirectoryV1( + rfc64RootPath, + 'RFC-64 persistence root', + true, + ); + await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath, io); + await ensureRfc64SecureDirectoryTreeV1( + join(rootPath, OBJECTS_DIRECTORY), + rootPath, + io, + ); + await ensureRfc64SecureDirectoryTreeV1( + join(rootPath, SIGNATURES_DIRECTORY), + rootPath, + io, + ); + }); + return new FileRfc64ControlObjectStoreV1(rootPath, io); +} + +function prepareStageBatch( + input: readonly StageVerifiedControlObjectV1[], +): readonly PreparedStoredControlObjectV1[] { + if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { + fail('control-store-input', 'stage input must be an ordinary dense Array'); + } + const length = input.length; + if ( + !Number.isSafeInteger(length) + || length < 1 + || length > RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS + ) { + fail( + 'control-store-input', + `stage input must contain 1..${RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS} objects`, + ); + } + const result = new Array(length); + for (let index = 0; index < length; index += 1) { + if (!(index in input)) fail('control-store-input', 'stage input must not contain holes'); + const item = input[index]; + try { + const split = splitCanonicalSignedControlEnvelopeV1(item.envelope); + const envelope = deepFreezePlain(split.envelope); + assertProofMatchesEnvelope(envelope, item.issuerSignature); + const objectDigest = snapshotDigest(envelope.objectDigest, 'objectDigest'); + const signatureVariantDigest = snapshotDigest( + split.signatureVariant.signatureVariantDigest, + 'signatureVariantDigest', + ); + result[index] = Object.freeze({ + objectDigest, + signatureVariantDigest, + unsignedBytes: split.unsignedEnvelopeBytes, + signatureVariantBytes: split.signatureVariantBytes, + }); + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-input', `stage input ${index} is not one verified canonical snapshot`, cause); + } + } + return Object.freeze(result); +} + +function assertProofMatchesEnvelope( + envelope: SignedControlEnvelopeV1, + proof: VerifiedControlEnvelopeIssuerSignatureV1, +): void { + let snapshot; + try { + snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + } catch (cause) { + fail('control-store-verification', 'issuer signature proof was not minted by the verifier', cause); + } + const expectedVariant = computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ); + if ( + snapshot.objectDigest !== envelope.objectDigest + || snapshot.signatureVariantDigest !== expectedVariant + || snapshot.issuer !== envelope.issuer + || snapshot.signatureSuite !== envelope.signatureSuite + ) { + fail('control-store-verification', 'issuer signature proof is not bound to this envelope'); + } +} + +async function mapDurableFileErrors(operation: () => Promise): Promise { + try { + return await operation(); + } catch (cause) { + if (!(cause instanceof Rfc64DurableFileErrorV1)) throw cause; + const code: Rfc64ControlObjectStoreErrorCodeV1 = cause.code === 'input' + ? 'control-store-input' + : cause.code === 'unsafe-path' + ? 'control-store-unsafe-path' + : cause.code === 'corrupt' + ? 'control-store-corrupt' + : cause.code === 'io' + ? 'control-store-io' + : 'control-store-durability'; + return fail(code, cause.message, cause); + } +} + +function snapshotDigest(value: string, label: string): Digest32V1 { + try { + assertCanonicalDigest(value, label); + } catch (cause) { + fail('control-store-input', `${label} is not a canonical digest`, cause); + } + return value as Digest32V1; +} + +function deepFreezePlain(value: T): T { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) { + for (const item of value) deepFreezePlain(item); + return Object.freeze(value); + } + for (const key of Object.keys(value)) { + deepFreezePlain((value as Record)[key]); + } + return Object.freeze(value); +} + +function fail( + code: Rfc64ControlObjectStoreErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64ControlObjectStoreErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/src/rfc64/control-object-store-v1.ts b/packages/agent/src/rfc64/control-object-store-v1.ts index dec88dc2eb..2925f79ffa 100644 --- a/packages/agent/src/rfc64/control-object-store-v1.ts +++ b/packages/agent/src/rfc64/control-object-store-v1.ts @@ -1,548 +1,18 @@ -import { randomBytes } from 'node:crypto'; -import { join, resolve } from 'node:path'; - -import { - MAX_CONTROL_OBJECT_BYTES, - MAX_CONTROL_SIGNATURE_VARIANT_BYTES, - assertCanonicalDigest, - computeControlSignatureVariantDigestHex, - recombineCanonicalSignedControlEnvelopeV1, - splitCanonicalSignedControlEnvelopeV1, - type Digest32V1, - type SignedControlEnvelopeV1, -} from '@origintrail-official/dkg-core'; -import { - readVerifiedControlEnvelopeIssuerSignatureV1, - type VerifiedControlEnvelopeIssuerSignatureV1, -} from '@origintrail-official/dkg-chain'; -import { - Rfc64DurableFileErrorV1, - assertRfc64ExistingDirectoryV1, - ensureRfc64SecureDirectoryTreeV1, - putRfc64ExactBytesV1, - readRfc64OptionalBoundedBytesV1, - type Rfc64DurableFileBoundaryV1, - type Rfc64DurableFileIoV1, -} from './durable-file-store-v1.js'; -import { - readLiveRfc64PersistenceOwnerRootV1, - type Rfc64PersistenceOwnerCapabilityV1, -} from './persistence-owner-capability-v1.js'; -import { - RFC64_POSIX_NAMESPACE_DURABILITY_V1, - RFC64_SECURE_DIRECTORY_MODE_V1, - RFC64_SECURE_FILE_MODE_V1, - RFC64_WINDOWS_NAMESPACE_DURABILITY_V1, - rfc64NamespaceDurabilityV1, - type Rfc64NamespaceDurabilityV1, -} from './secure-filesystem-policy-v1.js'; - -export const RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH = - 'rfc64-sync/control-objects-v1' as const; -export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; -export const RFC64_CONTROL_OBJECT_STORE_FILE_MODE = RFC64_SECURE_FILE_MODE_V1; -export const RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS = 16; -export const RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY = - RFC64_POSIX_NAMESPACE_DURABILITY_V1; -export const RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY = - RFC64_WINDOWS_NAMESPACE_DURABILITY_V1; - -export type Rfc64ControlObjectStoreNamespaceDurabilityV1 = Rfc64NamespaceDurabilityV1; - -const OBJECTS_DIRECTORY = 'objects'; -const SIGNATURES_DIRECTORY = 'signatures'; -const CONTROL_OBJECT_STORE_DIRECTORY = 'control-objects-v1'; -const CANONICAL_FILE_SUFFIX = '.jcs'; - -export const RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1 = Object.freeze([ - 'control-store-input', - 'control-store-verification', - 'control-store-unsafe-path', - 'control-store-corrupt', - 'control-store-io', - 'control-store-durability', - 'control-store-closed', -] as const); - -export type Rfc64ControlObjectStoreErrorCodeV1 = - (typeof RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1)[number]; - -export class Rfc64ControlObjectStoreErrorV1 extends Error { - constructor( - readonly code: Rfc64ControlObjectStoreErrorCodeV1, - message: string, - options: ErrorOptions = {}, - ) { - super(`[${code}] ${message}`, options); - if (!RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1.includes(code)) { - throw new TypeError(`Unsupported RFC-64 control store error code: ${code}`); - } - this.name = 'Rfc64ControlObjectStoreErrorV1'; - } -} - -type Rfc64ControlObjectStoreFileKindV1 = 'object' | 'signature'; - -export type Rfc64ControlObjectStoreDurabilityBoundaryV1 = - Rfc64DurableFileBoundaryV1; - -interface Rfc64ControlObjectStoreIoV1 - extends Rfc64DurableFileIoV1 {} - -const PRODUCTION_IO = Object.freeze({ - boundary: (_boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1): void => {}, - randomSuffix: (): string => randomBytes(16).toString('hex'), -}) satisfies Rfc64ControlObjectStoreIoV1; - -export interface StageVerifiedControlObjectV1 { - readonly envelope: SignedControlEnvelopeV1; - readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; -} - -export interface StagedVerifiedControlObjectV1 { - readonly objectDigest: Digest32V1; - readonly signatureVariantDigest: Digest32V1; -} - -export interface StageVerifiedControlObjectsResultV1 { - /** Every named file and platform-supported containing-directory barrier has completed. */ - readonly durable: true; - /** Explicitly fences later semantic ref publication from weaker namespace barriers. */ - readonly namespaceDurability: Rfc64ControlObjectStoreNamespaceDurabilityV1; - readonly objects: readonly StagedVerifiedControlObjectV1[]; -} - -export interface GetVerifiedControlObjectInputV1 { - readonly objectDigest: Digest32V1; - readonly signatureVariantDigest: Digest32V1; - /** Re-establishes current generic envelope cryptography after reading cache bytes. */ - readonly verifyIssuerSignature: ( - envelope: SignedControlEnvelopeV1, - ) => Promise; -} - -export interface StoredVerifiedControlObjectV1 { - readonly envelope: SignedControlEnvelopeV1; - readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; -} - -export interface Rfc64ControlObjectStoreV1 { - readonly rootPath: string; - readonly closed: boolean; - readonly namespaceDurability: Rfc64ControlObjectStoreNamespaceDurabilityV1; - /** - * Durably stage immutable unsigned envelopes and detached signature variants. - * Success does not advance a semantic ref or make any catalog authoritative. - */ - stageVerifiedObjects( - input: readonly StageVerifiedControlObjectV1[], - ): Promise; - /** Read exact cache keys and reverify the reconstructed signed envelope. */ - getVerifiedObject( - input: GetVerifiedControlObjectInputV1, - ): Promise; - close(): void; -} - -export interface Rfc64ControlObjectStoreTestIoV1 { - readonly boundary?: (boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1) => void; - readonly randomSuffix?: () => string; -} - -export type Rfc64ControlObjectStoreTestOpenerV1 = ( - dataDir: string, -) => Promise; - -/** - * Open after the RFC-64 inventory foundation has secured rfc64-sync and owns - * its single-process lease. This cache deliberately does not mint that lease. - */ -export async function openRfc64ControlObjectStoreV1( - ownership: Rfc64PersistenceOwnerCapabilityV1, -): Promise { - let rfc64RootPath: string; - try { - rfc64RootPath = readLiveRfc64PersistenceOwnerRootV1(ownership); - } catch (cause) { - fail('control-store-input', 'control object store requires a live persistence owner', cause); - } - return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, PRODUCTION_IO); -} - -/** Test-only fault-boundary opener; shipped production code cannot install hooks. */ -export function createRfc64ControlObjectStoreTestOpenerV1( - testIo: Rfc64ControlObjectStoreTestIoV1 = {}, -): Rfc64ControlObjectStoreTestOpenerV1 { - if (process.env.NODE_ENV !== 'test') { - fail('control-store-input', 'control store test opener is available only under NODE_ENV=test'); - } - const boundary = testIo.boundary; - const randomSuffix = testIo.randomSuffix; - const io = Object.freeze({ - boundary: (value: Rfc64ControlObjectStoreDurabilityBoundaryV1): void => { - boundary?.(value); - }, - randomSuffix: (): string => randomSuffix?.() ?? randomBytes(16).toString('hex'), - }) satisfies Rfc64ControlObjectStoreIoV1; - return async (dataDir: string): Promise => - openRfc64ControlObjectStoreForTestV1(dataDir, io); -} - -interface PreparedStoredControlObjectV1 { - readonly objectDigest: Digest32V1; - readonly signatureVariantDigest: Digest32V1; - readonly unsignedBytes: Uint8Array; - readonly signatureVariantBytes: Uint8Array; -} - -class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { - #closed = false; - readonly #fileWriteTails = new Map>(); - readonly namespaceDurability = rfc64NamespaceDurabilityV1(); - - constructor( - readonly rootPath: string, - private readonly io: Rfc64ControlObjectStoreIoV1, - ) {} - - get closed(): boolean { - return this.#closed; - } - - stageVerifiedObjects( - input: readonly StageVerifiedControlObjectV1[], - ): Promise { - this.requireOpen(); - const prepared = prepareStageBatch(input); - return (async () => { - this.requireOpen(); - const result = await Promise.all(prepared.map(async (item) => { - await this.stagePrepared(item); - return Object.freeze({ - objectDigest: item.objectDigest, - signatureVariantDigest: item.signatureVariantDigest, - }); - })); - return Object.freeze({ - durable: true as const, - namespaceDurability: this.namespaceDurability, - objects: Object.freeze(result), - }); - })(); - } - - getVerifiedObject( - input: GetVerifiedControlObjectInputV1, - ): Promise { - this.requireOpen(); - const objectDigest = snapshotDigest(input.objectDigest, 'objectDigest'); - const signatureVariantDigest = snapshotDigest( - input.signatureVariantDigest, - 'signatureVariantDigest', - ); - const verifyIssuerSignature = input.verifyIssuerSignature; - if (typeof verifyIssuerSignature !== 'function') { - fail('control-store-input', 'verifyIssuerSignature must be a function'); - } - return (async () => { - const objectPath = this.objectRelativePath(objectDigest); - const signaturePath = this.signatureRelativePath( - objectDigest, - signatureVariantDigest, - ); - const [unsignedBytes, variantBytes] = await mapDurableFileErrors(async () => - Promise.all([ - readRfc64OptionalBoundedBytesV1({ - containmentRoot: this.rootPath, - relativePath: objectPath, - maxBytes: MAX_CONTROL_OBJECT_BYTES, - label: 'control object', - }), - readRfc64OptionalBoundedBytesV1({ - containmentRoot: this.rootPath, - relativePath: signaturePath, - maxBytes: MAX_CONTROL_SIGNATURE_VARIANT_BYTES, - label: 'control signature variant', - }), - ])); - if (unsignedBytes === null || variantBytes === null) return null; - - let envelope: SignedControlEnvelopeV1; - try { - envelope = deepFreezePlain(recombineCanonicalSignedControlEnvelopeV1( - unsignedBytes, - variantBytes, - objectDigest, - signatureVariantDigest, - )); - } catch (cause) { - fail('control-store-corrupt', 'stored control object is not canonical for its exact keys', cause); - } - - // The caller callback intentionally runs outside every file-key tail. It - // may compose this cache with another exact read without deadlocking the - // write that produced the snapshot above. - let issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; - try { - issuerSignature = await verifyIssuerSignature(envelope); - assertProofMatchesEnvelope(envelope, issuerSignature); - } catch (cause) { - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-verification', 'stored control object signature verification failed', cause); - } - return Object.freeze({ envelope, issuerSignature }); - })(); - } - - close(): void { - this.#closed = true; - } - - private async stagePrepared(item: PreparedStoredControlObjectV1): Promise { - const objectPath = this.objectRelativePath(item.objectDigest); - await this.stageFileByKey(objectPath, item.unsignedBytes, 'object'); - - const signaturePath = this.signatureRelativePath( - item.objectDigest, - item.signatureVariantDigest, - ); - await this.stageFileByKey(signaturePath, item.signatureVariantBytes, 'signature'); - } - - private objectRelativePath(objectDigest: Digest32V1): string { - const hex = objectDigest.slice(2); - return join( - OBJECTS_DIRECTORY, - hex.slice(0, 2), - `${hex}${CANONICAL_FILE_SUFFIX}`, - ); - } - - private signatureRelativePath( - objectDigest: Digest32V1, - signatureVariantDigest: Digest32V1, - ): string { - const objectHex = objectDigest.slice(2); - const variantHex = signatureVariantDigest.slice(2); - return join( - SIGNATURES_DIRECTORY, - objectHex.slice(0, 2), - objectHex, - `${variantHex}${CANONICAL_FILE_SUFFIX}`, - ); - } - - private stageFileByKey( - relativePath: string, - bytes: Uint8Array, - kind: 'object' | 'signature', - ): Promise { - const previous = this.#fileWriteTails.get(relativePath) ?? Promise.resolve(); - const run = previous.then(async () => { - this.requireOpen(); - await this.putExactFile(relativePath, bytes, kind); - }, async () => { - this.requireOpen(); - await this.putExactFile(relativePath, bytes, kind); - }); - this.#fileWriteTails.set(relativePath, run); - void run.finally(() => { - if (this.#fileWriteTails.get(relativePath) === run) { - this.#fileWriteTails.delete(relativePath); - } - }).catch(() => undefined); - return run; - } - - private async putExactFile( - relativePath: string, - bytes: Uint8Array, - kind: Rfc64ControlObjectStoreFileKindV1, - ): Promise { - await mapDurableFileErrors(async () => { - await putRfc64ExactBytesV1({ - containmentRoot: this.rootPath, - relativePath, - bytes, - maxBytes: kind === 'object' - ? MAX_CONTROL_OBJECT_BYTES - : MAX_CONTROL_SIGNATURE_VARIANT_BYTES, - label: kind === 'object' ? 'control object' : 'control signature variant', - kind, - io: this.io, - }); - }); - } - - private requireOpen(): void { - if (this.#closed) fail('control-store-closed', 'control object store is closed'); - } -} - -async function openRfc64ControlObjectStoreForTestV1( - dataDirInput: string, - io: Rfc64ControlObjectStoreIoV1, -): Promise { - if (!Object.isFrozen(io)) { - fail('control-store-input', 'control object store I/O adapter must be immutable'); - } - if (typeof dataDirInput !== 'string' || dataDirInput.length === 0) { - fail('control-store-input', 'dataDir must be a non-empty path'); - } - const dataDir = resolve(dataDirInput); - const rfc64RootPath = resolve(dataDir, 'rfc64-sync'); - await mapDurableFileErrors(async () => { - await assertRfc64ExistingDirectoryV1(dataDir, 'DKG data directory', false); - await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, io); - }); - return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, io); -} - -async function openRfc64ControlObjectStoreAtRootV1( - rfc64RootPathInput: string, - io: Rfc64ControlObjectStoreIoV1, -): Promise { - if (!Object.isFrozen(io)) { - fail('control-store-input', 'control object store I/O adapter must be immutable'); - } - const rfc64RootPath = resolve(rfc64RootPathInput); - const rootPath = resolve(rfc64RootPath, CONTROL_OBJECT_STORE_DIRECTORY); - await mapDurableFileErrors(async () => { - await assertRfc64ExistingDirectoryV1( - rfc64RootPath, - 'RFC-64 persistence root', - true, - ); - await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath, io); - await ensureRfc64SecureDirectoryTreeV1( - join(rootPath, OBJECTS_DIRECTORY), - rootPath, - io, - ); - await ensureRfc64SecureDirectoryTreeV1( - join(rootPath, SIGNATURES_DIRECTORY), - rootPath, - io, - ); - }); - return new FileRfc64ControlObjectStoreV1(rootPath, io); -} - -function prepareStageBatch( - input: readonly StageVerifiedControlObjectV1[], -): readonly PreparedStoredControlObjectV1[] { - if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { - fail('control-store-input', 'stage input must be an ordinary dense Array'); - } - const length = input.length; - if ( - !Number.isSafeInteger(length) - || length < 1 - || length > RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS - ) { - fail( - 'control-store-input', - `stage input must contain 1..${RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS} objects`, - ); - } - const result = new Array(length); - for (let index = 0; index < length; index += 1) { - if (!(index in input)) fail('control-store-input', 'stage input must not contain holes'); - const item = input[index]; - try { - const split = splitCanonicalSignedControlEnvelopeV1(item.envelope); - const envelope = deepFreezePlain(split.envelope); - assertProofMatchesEnvelope(envelope, item.issuerSignature); - const objectDigest = snapshotDigest(envelope.objectDigest, 'objectDigest'); - const signatureVariantDigest = snapshotDigest( - split.signatureVariant.signatureVariantDigest, - 'signatureVariantDigest', - ); - result[index] = Object.freeze({ - objectDigest, - signatureVariantDigest, - unsignedBytes: split.unsignedEnvelopeBytes, - signatureVariantBytes: split.signatureVariantBytes, - }); - } catch (cause) { - if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; - fail('control-store-input', `stage input ${index} is not one verified canonical snapshot`, cause); - } - } - return Object.freeze(result); -} - -function assertProofMatchesEnvelope( - envelope: SignedControlEnvelopeV1, - proof: VerifiedControlEnvelopeIssuerSignatureV1, -): void { - let snapshot; - try { - snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); - } catch (cause) { - fail('control-store-verification', 'issuer signature proof was not minted by the verifier', cause); - } - const expectedVariant = computeControlSignatureVariantDigestHex( - envelope.objectDigest, - envelope.signature, - ); - if ( - snapshot.objectDigest !== envelope.objectDigest - || snapshot.signatureVariantDigest !== expectedVariant - || snapshot.issuer !== envelope.issuer - || snapshot.signatureSuite !== envelope.signatureSuite - ) { - fail('control-store-verification', 'issuer signature proof is not bound to this envelope'); - } -} - -async function mapDurableFileErrors(operation: () => Promise): Promise { - try { - return await operation(); - } catch (cause) { - if (!(cause instanceof Rfc64DurableFileErrorV1)) throw cause; - const code: Rfc64ControlObjectStoreErrorCodeV1 = cause.code === 'input' - ? 'control-store-input' - : cause.code === 'unsafe-path' - ? 'control-store-unsafe-path' - : cause.code === 'corrupt' - ? 'control-store-corrupt' - : cause.code === 'io' - ? 'control-store-io' - : 'control-store-durability'; - return fail(code, cause.message, cause); - } -} - -function snapshotDigest(value: string, label: string): Digest32V1 { - try { - assertCanonicalDigest(value, label); - } catch (cause) { - fail('control-store-input', `${label} is not a canonical digest`, cause); - } - return value as Digest32V1; -} - -function deepFreezePlain(value: T): T { - if (value === null || typeof value !== 'object') return value; - if (Array.isArray(value)) { - for (const item of value) deepFreezePlain(item); - return Object.freeze(value); - } - for (const key of Object.keys(value)) { - deepFreezePlain((value as Record)[key]); - } - return Object.freeze(value); -} - -function fail( - code: Rfc64ControlObjectStoreErrorCodeV1, - message: string, - cause?: unknown, -): never { - throw new Rfc64ControlObjectStoreErrorV1( - code, - message, - cause === undefined ? {} : { cause }, - ); -} +export { + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, + RFC64_CONTROL_OBJECT_STORE_FILE_MODE, + RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, + RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, + Rfc64ControlObjectStoreErrorV1, + type GetVerifiedControlObjectInputV1, + type Rfc64ControlObjectStoreErrorCodeV1, + type Rfc64ControlObjectStoreNamespaceDurabilityV1, + type Rfc64ControlObjectStoreV1, + type StageVerifiedControlObjectV1, + type StageVerifiedControlObjectsResultV1, + type StagedVerifiedControlObjectV1, + type StoredVerifiedControlObjectV1, +} from './control-object-store-v1-internal.js'; diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index 68b90f1b05..e199d2cfda 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -1,4 +1,4 @@ -import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'; +import { link, lstat, mkdir, open, unlink } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { @@ -34,7 +34,8 @@ type Rfc64DurableFilePublishBoundaryV1 = | 'temp-written' | 'temp-mode-secured' | 'temp-fsynced' - | 'renamed' + | 'published-no-replace' + | 'temp-unlinked' | 'parent-fsynced' | 'existing-fsynced' | 'existing-parent-fsynced'; @@ -47,7 +48,9 @@ export type Rfc64DurableFileBoundaryV1 = | `${TKind}.${Rfc64DurableFilePublishBoundaryV1}`; export interface Rfc64DurableFileIoV1 { - readonly boundary: (boundary: Rfc64DurableFileBoundaryV1) => void; + readonly boundary: ( + boundary: Rfc64DurableFileBoundaryV1, + ) => void | Promise; readonly randomSuffix: () => string; } @@ -79,53 +82,31 @@ export async function ensureRfc64SecureDirectoryTreeV1( containmentRoot: string, io: Rfc64DurableFileIoV1, ): Promise { - const resolvedTarget = resolve(target); - const resolvedRoot = resolve(containmentRoot); - const relativeTarget = relative(resolvedRoot, resolvedTarget); - if ( - relativeTarget === '..' - || relativeTarget.startsWith(`..${sep}`) - || isAbsolute(relativeTarget) - ) { - fail('unsafe-path', 'durable directory escaped its containment root'); - } - - if (relativeTarget.length === 0) { - await assertRfc64ExistingDirectoryV1( - resolvedTarget, - 'durable store directory', - true, - ); - return; - } - await assertRfc64ExistingDirectoryV1( - resolvedRoot, - 'durable store containment root', + await walkRfc64ContainedDirectoryTreeV1( + target, + containmentRoot, false, - ); - let current = resolvedRoot; - for (const component of relativeTarget.split(sep).filter(Boolean)) { - current = join(current, component); - let created = false; - try { - await mkdir(current, { mode: RFC64_SECURE_DIRECTORY_MODE_V1 }); - created = true; - io.boundary('directory.created'); - } catch (cause) { - if (!isNodeError(cause, 'EEXIST')) { - fail('io', `failed to create durable store directory ${current}`, cause); + async (current) => { + let created = false; + try { + await mkdir(current, { mode: RFC64_SECURE_DIRECTORY_MODE_V1 }); + created = true; + await io.boundary('directory.created'); + } catch (cause) { + if (!isNodeError(cause, 'EEXIST')) { + fail('io', `failed to create durable store directory ${current}`, cause); + } } - } - if (created) { - await chmodSecure(current, RFC64_SECURE_DIRECTORY_MODE_V1, 'directory'); - io.boundary('directory.mode-secured'); - await fsyncDirectory(current); - io.boundary('directory.self-fsynced'); - await fsyncDirectory(dirname(current)); - io.boundary('directory.parent-fsynced'); - } - await assertRfc64ExistingDirectoryV1(current, 'durable store directory', true); - } + if (created) { + await chmodSecure(current, RFC64_SECURE_DIRECTORY_MODE_V1, 'directory'); + await io.boundary('directory.mode-secured'); + await fsyncDirectory(current); + await io.boundary('directory.self-fsynced'); + await fsyncDirectory(dirname(current)); + await io.boundary('directory.parent-fsynced'); + } + }, + ); } export interface PutRfc64ExactBytesInputV1 { @@ -159,12 +140,12 @@ export async function putRfc64ExactBytesV1( if (!bytesEqual(existing, bytes)) { fail('corrupt', `${label} bytes differ for the same immutable key`); } - // A prior attempt can fail after rename but before the parent-directory - // barrier. Re-establish both barriers before an idempotent retry succeeds. + // A prior attempt can fail after no-replace publication but before the + // parent-directory barrier. Re-establish both barriers before retry succeeds. await fsyncRegularFile(targetPath, label); - io.boundary(`${kind}.existing-fsynced`); + await io.boundary(`${kind}.existing-fsynced`); await fsyncDirectory(dirname(targetPath)); - io.boundary(`${kind}.existing-parent-fsynced`); + await io.boundary(`${kind}.existing-parent-fsynced`); return; } @@ -174,31 +155,59 @@ export async function putRfc64ExactBytesV1( } const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.${suffix}.tmp`); let createdTemp = false; - let renamed = false; + let tempPresent = false; let handle: Awaited> | null = null; try { handle = await open(tempPath, 'wx', RFC64_SECURE_FILE_MODE_V1); createdTemp = true; + tempPresent = true; await handle.writeFile(bytes); - io.boundary(`${kind}.temp-written`); + await io.boundary(`${kind}.temp-written`); await chmodSecure(tempPath, RFC64_SECURE_FILE_MODE_V1, `${label} temp file`); - io.boundary(`${kind}.temp-mode-secured`); + await io.boundary(`${kind}.temp-mode-secured`); await handle.sync(); - io.boundary(`${kind}.temp-fsynced`); + await io.boundary(`${kind}.temp-fsynced`); await handle.close(); handle = null; - await rename(tempPath, targetPath); - renamed = true; - io.boundary(`${kind}.renamed`); + try { + // Hard-link publication creates the immutable key atomically and fails + // with EEXIST instead of replacing a target won by another writer. + await link(tempPath, targetPath); + } catch (cause) { + if (!isNodeError(cause, 'EEXIST')) throw cause; + await unlink(tempPath); + tempPresent = false; + const racedExisting = await readRfc64OptionalBoundedBytesV1({ + containmentRoot, + relativePath, + maxBytes, + label, + }); + if (racedExisting === null) { + fail('durability', `${label} disappeared after a no-replace publish conflict`); + } + if (!bytesEqual(racedExisting, bytes)) { + fail('corrupt', `${label} bytes differ for the same immutable key`); + } + await fsyncRegularFile(targetPath, label); + await io.boundary(`${kind}.existing-fsynced`); + await fsyncDirectory(dirname(targetPath)); + await io.boundary(`${kind}.existing-parent-fsynced`); + return; + } + await io.boundary(`${kind}.published-no-replace`); + await unlink(tempPath); + tempPresent = false; + await io.boundary(`${kind}.temp-unlinked`); await fsyncDirectory(dirname(targetPath)); - io.boundary(`${kind}.parent-fsynced`); + await io.boundary(`${kind}.parent-fsynced`); await assertExistingRegularFile(targetPath, `${label} cache file`, true); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; fail('durability', `failed to durably stage ${label} bytes`, cause); } finally { if (handle !== null) await handle.close().catch(() => undefined); - if (createdTemp && !renamed) await unlink(tempPath).catch(() => undefined); + if (createdTemp && tempPresent) await unlink(tempPath).catch(() => undefined); } } @@ -271,6 +280,19 @@ export async function readRfc64OptionalBoundedBytesV1( async function assertRfc64SecureDirectoryTreeV1( target: string, containmentRoot: string, +): Promise { + await walkRfc64ContainedDirectoryTreeV1( + target, + containmentRoot, + true, + ); +} + +async function walkRfc64ContainedDirectoryTreeV1( + target: string, + containmentRoot: string, + requireSecureRoot: boolean, + prepareComponent?: (path: string) => Promise, ): Promise { const resolvedTarget = resolve(target); const resolvedRoot = resolve(containmentRoot); @@ -285,11 +307,12 @@ async function assertRfc64SecureDirectoryTreeV1( await assertRfc64ExistingDirectoryV1( resolvedRoot, 'durable store containment root', - true, + relativeTarget.length === 0 || requireSecureRoot, ); let current = resolvedRoot; for (const component of relativeTarget.split(sep).filter(Boolean)) { current = join(current, component); + await prepareComponent?.(current); await assertRfc64ExistingDirectoryV1(current, 'durable store directory', true); } } diff --git a/packages/agent/src/rfc64/persistence-owner-capability-v1.ts b/packages/agent/src/rfc64/persistence-owner-capability-v1.ts deleted file mode 100644 index 6454810c93..0000000000 --- a/packages/agent/src/rfc64/persistence-owner-capability-v1.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { isAbsolute, resolve } from 'node:path'; - -interface Rfc64PersistenceOwnerCapabilityStateV1 { - readonly rfc64RootPath: string; - readonly isLive: () => boolean; -} - -const CAPABILITY_STATE = new WeakMap(); -declare const RFC64_PERSISTENCE_OWNER_CAPABILITY_BRAND: unique symbol; - -/** Opaque runtime proof owned by the aggregate RFC-64 persistence lifecycle. */ -export interface Rfc64PersistenceOwnerCapabilityV1 { - readonly [RFC64_PERSISTENCE_OWNER_CAPABILITY_BRAND]: true; -} - -/** @internal Mint only after the inventory foundation owns its DK6L lease. */ -export function createRfc64PersistenceOwnerCapabilityV1( - rfc64RootPathInput: string, - isLive: () => boolean, -): Rfc64PersistenceOwnerCapabilityV1 { - if ( - typeof rfc64RootPathInput !== 'string' - || rfc64RootPathInput.length === 0 - || !isAbsolute(rfc64RootPathInput) - ) { - throw new TypeError('RFC-64 persistence owner root must be an absolute path'); - } - if (typeof isLive !== 'function') { - throw new TypeError('RFC-64 persistence owner liveness probe must be a function'); - } - const capability = Object.freeze({}) as Rfc64PersistenceOwnerCapabilityV1; - CAPABILITY_STATE.set(capability, Object.freeze({ - rfc64RootPath: resolve(rfc64RootPathInput), - isLive, - })); - return capability; -} - -/** @internal Validate the unforgeable capability and return its canonical root. */ -export function readLiveRfc64PersistenceOwnerRootV1( - capability: Rfc64PersistenceOwnerCapabilityV1, -): string { - if (capability === null || typeof capability !== 'object') { - throw new TypeError('RFC-64 persistence owner capability is missing'); - } - const state = CAPABILITY_STATE.get(capability as object); - if (state === undefined) { - throw new TypeError('RFC-64 persistence owner capability was not minted locally'); - } - if (!state.isLive()) { - throw new TypeError('RFC-64 persistence owner capability is no longer live'); - } - return state.rfc64RootPath; -} diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index 669d9a7038..a3bf74ea37 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -5,10 +5,9 @@ import { type Rfc64InventoryV1Foundation, } from './inventory-v1/index.js'; import { - openRfc64ControlObjectStoreV1, type Rfc64ControlObjectStoreV1, } from './control-object-store-v1.js'; -import { createRfc64PersistenceOwnerCapabilityV1 } from './persistence-owner-capability-v1.js'; +import { openRfc64ControlObjectStoreAtOwnedRootV1 } from './control-object-store-v1-internal.js'; export interface OpenRfc64PersistenceOptionsV1 { /** Yield after each non-terminal fixed-size startup purge batch. */ @@ -20,12 +19,13 @@ export interface Rfc64PersistenceV1 { readonly inventory: Rfc64InventoryV1Foundation; readonly controlObjectStore: Rfc64ControlObjectStoreV1; readonly closed: boolean; - /** Close the control store before releasing inventory ownership. */ - close(): void; + /** Drain the control store before releasing inventory ownership. */ + close(): Promise; } class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { #closed = false; + #closePromise: Promise | null = null; constructor( readonly inventory: Rfc64InventoryV1Foundation, @@ -36,12 +36,17 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { return this.#closed; } - close(): void { - if (this.#closed) return; + close(): Promise { + if (this.#closePromise !== null) return this.#closePromise; this.#closed = true; + this.#closePromise = this.closeOwnedResources(); + return this.#closePromise; + } + + private async closeOwnedResources(): Promise { const failures: unknown[] = []; try { - this.controlObjectStore.close(); + await this.controlObjectStore.close(); } catch (cause) { failures.push(cause); } @@ -77,11 +82,9 @@ export async function openRfc64PersistenceV1( if (batch.done) break; await yieldAfterPurgeBatch(); } - const ownership = createRfc64PersistenceOwnerCapabilityV1( + const controlObjectStore = await openRfc64ControlObjectStoreAtOwnedRootV1( dirname(inventory.databasePath), - () => !inventory.closed, ); - const controlObjectStore = await openRfc64ControlObjectStoreV1(ownership); return new OwnedRfc64PersistenceV1(inventory, controlObjectStore); } catch (cause) { try { diff --git a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts index 2d0b4e8ab0..3e85afcdf8 100644 --- a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts +++ b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts @@ -12,9 +12,9 @@ import { open } from 'node:fs/promises'; export const RFC64_SECURE_DIRECTORY_MODE_V1 = 0o700; export const RFC64_SECURE_FILE_MODE_V1 = 0o600; export const RFC64_POSIX_NAMESPACE_DURABILITY_V1 = - 'posix-atomic-rename-directory-fsync-v1' as const; + 'posix-hardlink-no-replace-directory-fsync-v1' as const; export const RFC64_WINDOWS_NAMESPACE_DURABILITY_V1 = - 'windows-file-flush-atomic-rename-v1' as const; + 'windows-file-flush-hardlink-no-replace-v1' as const; export type Rfc64NamespaceDurabilityV1 = | typeof RFC64_POSIX_NAMESPACE_DURABILITY_V1 diff --git a/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts b/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts index cfe4b5d0dc..a8834f951b 100644 --- a/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts +++ b/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts @@ -20,13 +20,15 @@ let terminating = false; process.once('SIGTERM', () => { if (terminating) return; terminating = true; - try { - agent.closeRfc64InventoryV1(); - process.stdout.write('CLOSED\n', () => process.exit(0)); - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); - process.exit(1); - } + void (async () => { + try { + await agent.closeRfc64InventoryV1(); + process.stdout.write('CLOSED\n', () => process.exit(0)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exit(1); + } + })(); }); process.stdout.write('READY\n'); diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index a35fb046a0..08ec350ab3 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -13,12 +13,8 @@ import { } from '../src/rfc64/inventory-v1/index.js'; import { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, - openRfc64ControlObjectStoreV1, } from '../src/rfc64/control-object-store-v1.js'; -import { - createRfc64PersistenceOwnerCapabilityV1, - type Rfc64PersistenceOwnerCapabilityV1, -} from '../src/rfc64/persistence-owner-capability-v1.js'; +import { openRfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; const temporaryDirectories: string[] = []; const childProcesses = new Set(); @@ -42,6 +38,14 @@ function syntheticAgent(dataDirectory?: string): any { return agent; } +function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolvePromiseInput) => { + resolvePromise = resolvePromiseInput; + }); + return { promise, resolve: () => resolvePromise?.() }; +} + function u64be(value: bigint): Buffer { const encoded = Buffer.alloc(8); encoded.writeBigUInt64BE(value); @@ -208,8 +212,8 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await agent.prepareRfc64InventoryV1(); expect(agent.rfc64PersistenceV1).toBeUndefined(); - expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); - expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); + await expect(agent.closeRfc64InventoryV1()).resolves.toBeUndefined(); + await expect(agent.closeRfc64InventoryV1()).resolves.toBeUndefined(); }); it('owns one persistent foundation and purges every stale candidate in bounded yielding batches', async () => { @@ -235,12 +239,39 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { join(dataDirectory, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH), ); - agent.closeRfc64InventoryV1(); + await agent.closeRfc64InventoryV1(); expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(ownedPersistence.closed).toBe(true); expect(ownedControlObjectStore.closed).toBe(true); expect(candidateLoadCount(dataDirectory)).toBe(0); - expect(() => agent.closeRfc64InventoryV1()).not.toThrow(); + await expect(agent.closeRfc64InventoryV1()).resolves.toBeUndefined(); + }); + + it('retains the inventory lease until the control store drain settles', async () => { + const dataDirectory = temporaryDataDirectory(); + const agent = syntheticAgent(dataDirectory); + await agent.prepareRfc64InventoryV1(); + const persistence = agent.rfc64PersistenceV1; + const inventory = persistence.inventory; + const controlStore = persistence.controlObjectStore; + const entered = deferred(); + const release = deferred(); + const actualClose = controlStore.close.bind(controlStore); + vi.spyOn(controlStore, 'close').mockImplementation(async () => { + entered.resolve(); + await release.promise; + await actualClose(); + }); + + const close = agent.closeRfc64InventoryV1(); + await entered.promise; + expect(agent.rfc64PersistenceV1).toBeUndefined(); + expect(persistence.closed).toBe(true); + expect(inventory.closed).toBe(false); + + release.resolve(); + await expect(close).resolves.toBeUndefined(); + expect(inventory.closed).toBe(true); }); it('fails startup before node.start when inventory acquisition or recovery fails', async () => { @@ -265,16 +296,10 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { async () => { const dataDirectory = temporaryDataDirectory(); const outside = temporaryDataDirectory(); - const inventory = await openInventoryV1(dataDirectory); - const ownership = createRfc64PersistenceOwnerCapabilityV1( - dirname(inventory.databasePath), - () => !inventory.closed, - ); - const initialStore = await openRfc64ControlObjectStoreV1( - ownership, - ); - initialStore.close(); - inventory.close(); + const initialPersistence = await openRfc64PersistenceV1(dataDirectory, { + yieldAfterPurgeBatch: async () => {}, + }); + await initialPersistence.close(); const objectsPath = join( dataDirectory, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, @@ -293,24 +318,6 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { }, ); - it('requires an unforgeable live aggregate persistence owner capability', async () => { - const dataDirectory = temporaryDataDirectory(); - const inventory = await openInventoryV1(dataDirectory); - const ownership = createRfc64PersistenceOwnerCapabilityV1( - dirname(inventory.databasePath), - () => !inventory.closed, - ); - - const store = await openRfc64ControlObjectStoreV1(ownership); - store.close(); - await expect(openRfc64ControlObjectStoreV1( - {} as Rfc64PersistenceOwnerCapabilityV1, - )).rejects.toMatchObject({ code: 'control-store-input' }); - inventory.close(); - await expect(openRfc64ControlObjectStoreV1(ownership)) - .rejects.toMatchObject({ code: 'control-store-input' }); - }); - it('releases an acquired inventory when node startup fails', async () => { const failure = new Error('node start failed'); const close = vi.fn(); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index dd6ceaf6ab..54e9bfbff4 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -14,7 +14,9 @@ import { type UnsignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { + EIP1271_CANONICAL_ABI_RETURN_V1, verifyControlEnvelopeIssuerSignatureV1, + type CurrentFinalizedEvmCallV1, type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; import { ethers } from 'ethers'; @@ -29,15 +31,19 @@ import { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, Rfc64ControlObjectStoreErrorV1, - createRfc64ControlObjectStoreTestOpenerV1, - type Rfc64ControlObjectStoreDurabilityBoundaryV1, type StageVerifiedControlObjectV1, } from '../src/rfc64/control-object-store-v1.js'; import { putRfc64ExactBytesV1 } from '../src/rfc64/durable-file-store-v1.js'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; import { applyRfc64OwnerOnlyPermissionsSyncV1 } from '../src/rfc64/secure-filesystem-policy-v1.js'; +import { + createRfc64ControlObjectStoreTestOpenerV1, + type Rfc64ControlObjectStoreDurabilityBoundaryV1, +} from './support/rfc64-control-object-store-test-support.js'; const PRIVATE_KEY = `0x${'42'.repeat(32)}`; +const SAFE = '0x3333333333333333333333333333333333333333' as EvmAddressV1; +const BLOCK_HASH = `0x${'44'.repeat(32)}`; const wallet = new ethers.Wallet(PRIVATE_KEY); const ISSUER = wallet.address.toLowerCase() as EvmAddressV1; const openRfc64ControlObjectStoreV1 = createRfc64ControlObjectStoreTestOpenerV1(); @@ -104,6 +110,52 @@ async function signedFixture( }; } +const finalizedEip1271Call: CurrentFinalizedEvmCallV1 = async (request) => ({ + chainId: request.chainId, + blockNumber: '123', + blockHash: BLOCK_HASH, + returnData: EIP1271_CANONICAL_ABI_RETURN_V1, +}); + +async function contractSignatureVariantsFixture(): Promise< + readonly [StageVerifiedControlObjectV1, StageVerifiedControlObjectV1] +> { + const unsigned = { + issuer: SAFE, + objectType: 'dkg-rfc64-control-store-contract-test-v1', + payload: { sequence: 'variants' }, + signatureEvidence: { + kind: 'eip1271-current-finalized', + chainId: '20430', + contractAddress: SAFE, + }, + signatureSuite: 'eip1271-current-finalized-v1', + } satisfies UnsignedControlEnvelopeV1; + const objectDigest = computeControlObjectDigestHex(unsigned); + const firstEnvelope = { + ...unsigned, + objectDigest, + signature: '0x12', + } as SignedControlEnvelopeV1; + const secondEnvelope = { + ...unsigned, + objectDigest, + signature: '0x1234', + } as SignedControlEnvelopeV1; + const [firstProof, secondProof] = await Promise.all([ + verifyControlEnvelopeIssuerSignatureV1(firstEnvelope, { + callEvmAtCurrentFinalized: finalizedEip1271Call, + }), + verifyControlEnvelopeIssuerSignatureV1(secondEnvelope, { + callEvmAtCurrentFinalized: finalizedEip1271Call, + }), + ]); + return [{ envelope: firstEnvelope, issuerSignature: firstProof }, { + envelope: secondEnvelope, + issuerSignature: secondProof, + }]; +} + function pathsFor( dataDir: string, envelope: SignedControlEnvelopeV1, @@ -245,7 +297,8 @@ describe('RFC-64 durable control-object store v1', () => { 'object.temp-written', 'object.temp-mode-secured', 'object.temp-fsynced', - 'object.renamed', + 'object.published-no-replace', + 'object.temp-unlinked', 'object.parent-fsynced', 'directory.created', 'directory.mode-secured', @@ -260,19 +313,20 @@ describe('RFC-64 durable control-object store v1', () => { 'signature.temp-written', 'signature.temp-mode-secured', 'signature.temp-fsynced', - 'signature.renamed', + 'signature.published-no-replace', + 'signature.temp-unlinked', 'signature.parent-fsynced', 'signature.existing-fsynced', 'signature.existing-parent-fsynced', ]; expect([...boundaries].sort()).toEqual([...expectedBoundaries].sort()); expect(boundaries.indexOf('object.temp-written')) - .toBeLessThan(boundaries.indexOf('object.renamed')); - expect(boundaries.indexOf('object.renamed')) + .toBeLessThan(boundaries.indexOf('object.published-no-replace')); + expect(boundaries.indexOf('object.published-no-replace')) .toBeLessThan(boundaries.indexOf('object.existing-fsynced')); expect(boundaries.indexOf('signature.temp-written')) - .toBeLessThan(boundaries.indexOf('signature.renamed')); - expect(boundaries.indexOf('signature.renamed')) + .toBeLessThan(boundaries.indexOf('signature.published-no-replace')); + expect(boundaries.indexOf('signature.published-no-replace')) .toBeLessThan(boundaries.indexOf('signature.existing-fsynced')); boundaries.length = 0; await store.stageVerifiedObjects([fixture]); @@ -284,6 +338,39 @@ describe('RFC-64 durable control-object store v1', () => { ]); }); + it('coexists and reads exact signature variants for one immutable object', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const [first, second] = await contractSignatureVariantsFixture(); + expect(first.envelope.objectDigest).toBe(second.envelope.objectDigest); + + const result = await store.stageVerifiedObjects([first, second]); + expect(result.objects[0]?.objectDigest).toBe(result.objects[1]?.objectDigest); + expect(result.objects[0]?.signatureVariantDigest) + .not.toBe(result.objects[1]?.signatureVariantDigest); + const firstPaths = pathsFor(dataDir, first.envelope); + const secondPaths = pathsFor(dataDir, second.envelope); + expect(firstPaths.object).toBe(secondPaths.object); + expect(firstPaths.signature).not.toBe(secondPaths.signature); + await expect(readFile(firstPaths.signature)).resolves.not.toHaveLength(0); + await expect(readFile(secondPaths.signature)).resolves.not.toHaveLength(0); + + const verifyIssuerSignature = (envelope: SignedControlEnvelopeV1) => + verifyControlEnvelopeIssuerSignatureV1(envelope, { + callEvmAtCurrentFinalized: finalizedEip1271Call, + }); + await expect(store.getVerifiedObject({ + objectDigest: first.envelope.objectDigest as Digest32V1, + signatureVariantDigest: firstPaths.signatureDigest, + verifyIssuerSignature, + })).resolves.toMatchObject({ envelope: first.envelope }); + await expect(store.getVerifiedObject({ + objectDigest: second.envelope.objectDigest as Digest32V1, + signatureVariantDigest: secondPaths.signatureDigest, + verifyIssuerSignature, + })).resolves.toMatchObject({ envelope: second.envelope }); + }); + it('allows independent digest keys to stage concurrently without a store-wide queue', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); @@ -484,7 +571,38 @@ describe('RFC-64 durable control-object store v1', () => { await expect(slowRead).resolves.toMatchObject({ envelope: first.envelope }); }); - it('cleans an unrenamed temp after a pre-visibility fault and converges on retry', async () => { + it('drains an admitted durable write before close can release ownership', async () => { + const dataDir = await temporaryDataDirectory(); + const entered = deferred(); + const release = deferred(); + let paused = false; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: async (boundary) => { + if (boundary === 'object.temp-written' && !paused) { + paused = true; + entered.resolve(); + await release.promise; + } + }, + })(dataDir); + const fixture = await signedFixture('close-drain'); + const stage = store.stageVerifiedObjects([fixture]); + await entered.promise; + + let closeSettled = false; + const close = store.close().then(() => { closeSettled = true; }); + expect(store.closed).toBe(true); + await Promise.resolve(); + expect(closeSettled).toBe(false); + + release.resolve(); + await expect(stage).rejects.toMatchObject({ code: 'control-store-closed' }); + await expect(close).resolves.toBeUndefined(); + expect(closeSettled).toBe(true); + await expect(store.close()).resolves.toBeUndefined(); + }); + + it('cleans an unpublished temp after a pre-visibility fault and converges on retry', async () => { const dataDir = await temporaryDataDirectory(); const fixture = await signedFixture('7'); let injected = false; @@ -492,7 +610,7 @@ describe('RFC-64 durable control-object store v1', () => { boundary: (boundary) => { if (boundary === 'object.temp-fsynced' && !injected) { injected = true; - throw new Error('injected pre-rename fault'); + throw new Error('injected pre-publish fault'); } }, })(dataDir); @@ -504,7 +622,7 @@ describe('RFC-64 durable control-object store v1', () => { await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); }); - it('treats a post-rename fault as an unreachable orphan and safely completes on retry', async () => { + it('treats a post-publish fault as an unreachable orphan and safely completes on retry', async () => { const dataDir = await temporaryDataDirectory(); const fixture = await signedFixture('8'); let injected = false; @@ -512,9 +630,9 @@ describe('RFC-64 durable control-object store v1', () => { const store = await createRfc64ControlObjectStoreTestOpenerV1({ boundary: (boundary) => { boundaries.push(boundary); - if (boundary === 'object.renamed' && !injected) { + if (boundary === 'object.published-no-replace' && !injected) { injected = true; - throw new Error('injected post-rename fault'); + throw new Error('injected post-publish fault'); } }, })(dataDir); @@ -534,7 +652,7 @@ describe('RFC-64 durable control-object store v1', () => { const dataDir = await temporaryDataDirectory(); const outside = await temporaryDataDirectory(); const first = await openRfc64ControlObjectStoreV1(dataDir); - first.close(); + await first.close(); const objects = join(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, 'objects'); await rm(objects, { recursive: true, force: true }); await symlink(outside, objects, process.platform === 'win32' ? 'junction' : 'dir'); @@ -561,7 +679,7 @@ describe('RFC-64 durable control-object store v1', () => { await chmod(paths.object, RFC64_CONTROL_OBJECT_STORE_FILE_MODE); await chmod(dirname(dirname(paths.object)), 0o755); - store.close(); + await store.close(); await expect(openRfc64ControlObjectStoreV1(dataDir)) .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); }, @@ -577,7 +695,7 @@ describe('RFC-64 durable control-object store v1', () => { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, 'objects', ); - directoryStore.close(); + await directoryStore.close(); grantWindowsEveryoneRead(objectsDirectory, true); await expect(openRfc64ControlObjectStoreV1(directoryDataDir)) .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); @@ -626,7 +744,7 @@ describe('RFC-64 durable control-object store v1', () => { expect(() => store.stageVerifiedObjects(holey)) .toThrow(expect.objectContaining({ code: 'control-store-input' })); - store.close(); + await store.close(); expect(store.closed).toBe(true); expect(() => store.stageVerifiedObjects([fixture])) .toThrow(expect.objectContaining({ code: 'control-store-closed' })); @@ -653,6 +771,38 @@ describe('RFC-64 durable control-object store v1', () => { })).rejects.toMatchObject({ code: 'unsafe-path' }); }); + it('publishes immutable keys without clobbering a racing independent writer', async () => { + const containmentRoot = await temporaryDataDirectory(); + const relativePath = join('race', 'immutable.jcs'); + const firstBytes = new TextEncoder().encode('{"writer":"first"}'); + const secondBytes = new TextEncoder().encode('{"writer":"second"}'); + const write = (bytes: Uint8Array, suffixByte: string) => putRfc64ExactBytesV1({ + containmentRoot, + relativePath, + bytes, + maxBytes: 1024, + label: 'racing immutable fixture', + kind: 'object' as const, + io: Object.freeze({ + boundary: () => {}, + randomSuffix: () => suffixByte.repeat(32), + }), + }); + + const outcomes = await Promise.allSettled([ + write(firstBytes, 'a'), + write(secondBytes, 'b'), + ]); + expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1); + const rejected = outcomes.find((outcome) => outcome.status === 'rejected'); + expect(rejected).toMatchObject({ + status: 'rejected', + reason: { code: 'corrupt' }, + }); + const stored = await readFile(join(containmentRoot, relativePath)); + expect([firstBytes, secondBytes].some((bytes) => Buffer.from(bytes).equals(stored))).toBe(true); + }); + it('uses a closed typed error registry', () => { expect(() => new Rfc64ControlObjectStoreErrorV1( 'not-registered' as never, diff --git a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts index 963a94e40d..eabb3ac4a1 100644 --- a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts +++ b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts @@ -1,49 +1,33 @@ -import * as publicApi from '../src/index.js'; -import { - RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, - RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, - RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, - RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, - RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, - Rfc64ControlObjectStoreErrorV1, - type GetVerifiedControlObjectInputV1, - type Rfc64ControlObjectStoreErrorCodeV1, - type Rfc64ControlObjectStoreNamespaceDurabilityV1, - type Rfc64ControlObjectStoreV1, - type StageVerifiedControlObjectV1, - type StageVerifiedControlObjectsResultV1, - type StagedVerifiedControlObjectV1, - type StoredVerifiedControlObjectV1, -} from '../src/index.js'; +import * as packageRoot from '../src/index.js'; +import * as productionControlStoreModule from '../src/rfc64/control-object-store-v1.js'; -type PublicApiHasRawControlStoreOpener = - 'openRfc64ControlObjectStoreV1' extends keyof typeof publicApi ? true : false; +type PackageRootHasRawControlStoreOpener = + 'openRfc64ControlObjectStoreV1' extends keyof typeof packageRoot ? true : false; +type PackageRootHasControlStoreLayout = + 'RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH' extends keyof typeof packageRoot + ? true + : false; +type ProductionModuleHasRawControlStoreOpener = + 'openRfc64ControlObjectStoreV1' extends keyof typeof productionControlStoreModule + ? true + : false; +type ProductionModuleHasTestOpener = + 'createRfc64ControlObjectStoreTestOpenerV1' extends + keyof typeof productionControlStoreModule ? true : false; -// Package consumers cannot construct the cache without the aggregate -// persistence owner; direct-source tests retain a guarded internal seam. -const rawControlStoreOpenerIsNotPublic: PublicApiHasRawControlStoreOpener = false; +// The stable package root intentionally withholds the implementation-shaped +// cache API until an RFC-64 public workflow consumes it. Internal dist +// subpaths are unsupported; this test defines the stable root/module contract. +const packageRootHasRawControlStoreOpener: PackageRootHasRawControlStoreOpener = false; +const packageRootHasControlStoreLayout: PackageRootHasControlStoreLayout = false; +const productionModuleHasRawControlStoreOpener: ProductionModuleHasRawControlStoreOpener = false; +const productionModuleHasTestOpener: ProductionModuleHasTestOpener = false; -const publicControlStoreRuntimeExports = [ - RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, - RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, - RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, - RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, - RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, - Rfc64ControlObjectStoreErrorV1, -] as const; +// @ts-expect-error Low-level store types are not part of the stable package root. +type PackageRootStoreType = packageRoot.Rfc64ControlObjectStoreV1; -type PublicControlStoreTypesAreUsable = readonly [ - GetVerifiedControlObjectInputV1, - Rfc64ControlObjectStoreErrorCodeV1, - Rfc64ControlObjectStoreNamespaceDurabilityV1, - Rfc64ControlObjectStoreV1, - StageVerifiedControlObjectV1, - StageVerifiedControlObjectsResultV1, - StagedVerifiedControlObjectV1, - StoredVerifiedControlObjectV1, -]; -const publicControlStoreTypesAreUsable: PublicControlStoreTypesAreUsable | undefined = undefined; - -void rawControlStoreOpenerIsNotPublic; -void publicControlStoreRuntimeExports; -void publicControlStoreTypesAreUsable; +void packageRootHasRawControlStoreOpener; +void packageRootHasControlStoreLayout; +void productionModuleHasRawControlStoreOpener; +void productionModuleHasTestOpener; +void (undefined as PackageRootStoreType | undefined); diff --git a/packages/agent/test/support/rfc64-control-object-store-test-support.ts b/packages/agent/test/support/rfc64-control-object-store-test-support.ts new file mode 100644 index 0000000000..e623d011f1 --- /dev/null +++ b/packages/agent/test/support/rfc64-control-object-store-test-support.ts @@ -0,0 +1,42 @@ +import { randomBytes } from 'node:crypto'; + +import { + Rfc64ControlObjectStoreErrorV1, + openRfc64ControlObjectStoreForTestV1, + type Rfc64ControlObjectStoreDurabilityBoundaryV1, + type Rfc64ControlObjectStoreV1, +} from '../../src/rfc64/control-object-store-v1-internal.js'; + +export interface Rfc64ControlObjectStoreTestIoV1 { + readonly boundary?: ( + boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1, + ) => void | Promise; + readonly randomSuffix?: () => string; +} + +export type Rfc64ControlObjectStoreTestOpenerV1 = ( + dataDir: string, +) => Promise; + +export function createRfc64ControlObjectStoreTestOpenerV1( + testIo: Rfc64ControlObjectStoreTestIoV1 = {}, +): Rfc64ControlObjectStoreTestOpenerV1 { + if (process.env.NODE_ENV !== 'test') { + throw new Rfc64ControlObjectStoreErrorV1( + 'control-store-input', + 'control store test opener is available only under NODE_ENV=test', + ); + } + const boundary = testIo.boundary; + const randomSuffix = testIo.randomSuffix; + const io = Object.freeze({ + boundary: ( + value: Rfc64ControlObjectStoreDurabilityBoundaryV1, + ): void | Promise => boundary?.(value), + randomSuffix: (): string => randomSuffix?.() ?? randomBytes(16).toString('hex'), + }); + return async (dataDir: string): Promise => + openRfc64ControlObjectStoreForTestV1(dataDir, io); +} + +export type { Rfc64ControlObjectStoreDurabilityBoundaryV1 }; From 10358b640cf4bbd1a26f16b9b48664070a8e6559 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 14:43:33 +0200 Subject: [PATCH 073/292] test(agent): secure Windows no-clobber fixture --- packages/agent/test/rfc64-control-object-store-v1.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 54e9bfbff4..b90620ebae 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -773,6 +773,11 @@ describe('RFC-64 durable control-object store v1', () => { it('publishes immutable keys without clobbering a racing independent writer', async () => { const containmentRoot = await temporaryDataDirectory(); + applyRfc64OwnerOnlyPermissionsSyncV1( + containmentRoot, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + true, + ); const relativePath = join('race', 'immutable.jcs'); const firstBytes = new TextEncoder().encode('{"writer":"first"}'); const secondBytes = new TextEncoder().encode('{"writer":"second"}'); From 59368b48b6c8388bed5389e44e765637ba29a8ed Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 15:06:32 +0200 Subject: [PATCH 074/292] fix(agent): complete RFC-64 persistence review hardening --- packages/agent/package.json | 7 + packages/agent/src/dkg-agent-base.ts | 4 +- packages/agent/src/dkg-agent-lifecycle.ts | 4 +- packages/agent/src/dkg-agent.ts | 2 +- .../rfc64/control-object-store-v1-internal.ts | 3 - .../agent/src/rfc64/durable-file-store-v1.ts | 95 ++++++++----- .../src/rfc64/secure-filesystem-policy-v1.ts | 127 +++++++++++------- .../rfc64-agent-inventory-lifecycle-child.ts | 4 +- .../rfc64-agent-inventory-lifecycle.test.ts | 26 ++-- .../rfc64-control-object-store-v1.test.ts | 36 ++++- ...fc64-control-store-public-api.typecheck.ts | 7 +- 11 files changed, 207 insertions(+), 108 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index ed6b2e67c0..1fdc50e003 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -4,6 +4,13 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "scripts": { "build": "tsc && pnpm run test:types", "benchmark:sync-responder-pages": "node --expose-gc scripts/sync-responder-page-benchmark.cjs", diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index 3183d18327..fc17fb81c2 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -1567,7 +1567,7 @@ export class DKGAgentBase { * Acquire the RFC-64 inventory, finish bounded stale-candidate cleanup, and * open the inherited-owner control-object tree before network consumers. */ - protected async prepareRfc64InventoryV1(): Promise { + protected async prepareRfc64PersistenceV1(): Promise { if (!this.config.dataDir || this.rfc64PersistenceV1 !== undefined) return; this.rfc64PersistenceV1 = await openRfc64PersistenceV1(this.config.dataDir, { yieldAfterPurgeBatch: () => this.yieldRfc64InventoryV1StartupBatch(), @@ -1583,7 +1583,7 @@ export class DKGAgentBase { * Relinquish the control store and single inventory foundation. Clear local * references before closing so fail-stop cleanup cannot be retried. */ - protected async closeRfc64InventoryV1(): Promise { + protected async closeRfc64PersistenceV1(): Promise { const persistence = this.rfc64PersistenceV1; this.rfc64PersistenceV1 = undefined; await persistence?.close(); diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 691f0b4199..cc507434de 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -935,7 +935,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { // OT-RFC-64: persistent inventory ownership and the complete bounded // startup purge precede node.start(), protocol registration, and every // network consumer. No dataDir intentionally leaves the feature dormant. - await this.prepareRfc64InventoryV1(); + await this.prepareRfc64PersistenceV1(); try { // One-shot resident-poison sweep (OT-RFC-56 §4.4) — BEFORE networking, // so the local store is clean before this node serves or syncs anything. @@ -952,7 +952,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { await this.node.start(); } catch (cause) { try { - await this.closeRfc64InventoryV1(); + await this.closeRfc64PersistenceV1(); } catch (closeCause) { throw new AggregateError( [cause, closeCause], diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 47a021ffb8..edc84d9370 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1721,7 +1721,7 @@ export class DKGAgent extends DKGAgentBase { let inventoryCloseFailed = false; let inventoryCloseFailure: unknown; try { - await this.closeRfc64InventoryV1(); + await this.closeRfc64PersistenceV1(); } catch (error) { inventoryCloseFailed = true; inventoryCloseFailure = error; diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index 0be7b04991..3fd2f3cbed 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -180,7 +180,6 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { this.requireOpen(); const prepared = prepareStageBatch(input); const operation = (async () => { - this.requireOpen(); const result = await Promise.all(prepared.map(async (item) => { await this.stagePrepared(item); return Object.freeze({ @@ -313,10 +312,8 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { ): Promise { const previous = this.#fileWriteTails.get(relativePath) ?? Promise.resolve(); const run = previous.then(async () => { - this.requireOpen(); await this.putExactFile(relativePath, bytes, kind); }, async () => { - this.requireOpen(); await this.putExactFile(relativePath, bytes, kind); }); this.#fileWriteTails.set(relativePath, run); diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index e199d2cfda..e134eb07ce 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -130,37 +130,60 @@ export async function putRfc64ExactBytesV1( containmentRoot, io, ); + const resolvedInput = { ...input, targetPath }; + if (await reconcileRfc64ExistingImmutableV1(resolvedInput)) return; + const tempPath = await writeRfc64SecureTempFileV1(resolvedInput); + await publishRfc64NoReplaceV1(resolvedInput, tempPath); +} + +interface ResolvedPutRfc64ExactBytesInputV1 + extends PutRfc64ExactBytesInputV1 { + readonly targetPath: string; +} + +async function reconcileRfc64ExistingImmutableV1( + input: ResolvedPutRfc64ExactBytesInputV1, +): Promise { + const { containmentRoot, relativePath, bytes, maxBytes, label } = input; const existing = await readRfc64OptionalBoundedBytesV1({ containmentRoot, relativePath, maxBytes, label, }); - if (existing !== null) { - if (!bytesEqual(existing, bytes)) { - fail('corrupt', `${label} bytes differ for the same immutable key`); - } - // A prior attempt can fail after no-replace publication but before the - // parent-directory barrier. Re-establish both barriers before retry succeeds. - await fsyncRegularFile(targetPath, label); - await io.boundary(`${kind}.existing-fsynced`); - await fsyncDirectory(dirname(targetPath)); - await io.boundary(`${kind}.existing-parent-fsynced`); - return; + if (existing === null) return false; + if (!bytesEqual(existing, bytes)) { + fail('corrupt', `${label} bytes differ for the same immutable key`); } + await completeRfc64ExistingFileDurabilityV1(input); + return true; +} +async function completeRfc64ExistingFileDurabilityV1( + input: ResolvedPutRfc64ExactBytesInputV1, +): Promise { + const { targetPath, label, kind, io } = input; + // A prior attempt can fail after no-replace publication but before the + // parent-directory barrier. Re-establish both barriers before retry succeeds. + await fsyncRegularFile(targetPath, label); + await io.boundary(`${kind}.existing-fsynced`); + await fsyncDirectory(dirname(targetPath)); + await io.boundary(`${kind}.existing-parent-fsynced`); +} + +async function writeRfc64SecureTempFileV1( + input: ResolvedPutRfc64ExactBytesInputV1, +): Promise { + const { targetPath, bytes, label, kind, io } = input; const suffix = io.randomSuffix(); if (!/^[0-9a-f]{32}$/u.test(suffix)) { fail('input', 'durable file random suffix must be 16 lowercase hex bytes'); } const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.${suffix}.tmp`); - let createdTemp = false; - let tempPresent = false; + let complete = false; let handle: Awaited> | null = null; try { handle = await open(tempPath, 'wx', RFC64_SECURE_FILE_MODE_V1); - createdTemp = true; - tempPresent = true; await handle.writeFile(bytes); await io.boundary(`${kind}.temp-written`); await chmodSecure(tempPath, RFC64_SECURE_FILE_MODE_V1, `${label} temp file`); @@ -169,6 +192,24 @@ export async function putRfc64ExactBytesV1( await io.boundary(`${kind}.temp-fsynced`); await handle.close(); handle = null; + complete = true; + return tempPath; + } catch (cause) { + if (cause instanceof Rfc64DurableFileErrorV1) throw cause; + return fail('durability', `failed to write durable ${label} temp bytes`, cause); + } finally { + if (handle !== null) await handle.close().catch(() => undefined); + if (!complete) await unlink(tempPath).catch(() => undefined); + } +} + +async function publishRfc64NoReplaceV1( + input: ResolvedPutRfc64ExactBytesInputV1, + tempPath: string, +): Promise { + const { targetPath, label, kind, io } = input; + let tempPresent = true; + try { try { // Hard-link publication creates the immutable key atomically and fails // with EEXIST instead of replacing a target won by another writer. @@ -177,22 +218,9 @@ export async function putRfc64ExactBytesV1( if (!isNodeError(cause, 'EEXIST')) throw cause; await unlink(tempPath); tempPresent = false; - const racedExisting = await readRfc64OptionalBoundedBytesV1({ - containmentRoot, - relativePath, - maxBytes, - label, - }); - if (racedExisting === null) { + if (!await reconcileRfc64ExistingImmutableV1(input)) { fail('durability', `${label} disappeared after a no-replace publish conflict`); } - if (!bytesEqual(racedExisting, bytes)) { - fail('corrupt', `${label} bytes differ for the same immutable key`); - } - await fsyncRegularFile(targetPath, label); - await io.boundary(`${kind}.existing-fsynced`); - await fsyncDirectory(dirname(targetPath)); - await io.boundary(`${kind}.existing-parent-fsynced`); return; } await io.boundary(`${kind}.published-no-replace`); @@ -204,10 +232,9 @@ export async function putRfc64ExactBytesV1( await assertExistingRegularFile(targetPath, `${label} cache file`, true); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; - fail('durability', `failed to durably stage ${label} bytes`, cause); + fail('durability', `failed to publish durable ${label} bytes`, cause); } finally { - if (handle !== null) await handle.close().catch(() => undefined); - if (createdTemp && tempPresent) await unlink(tempPath).catch(() => undefined); + if (tempPresent) await unlink(tempPath).catch(() => undefined); } } @@ -237,7 +264,7 @@ export async function readRfc64OptionalBoundedBytesV1( fail('io', `failed to inspect ${label}`, cause); } - await assertRfc64SecureDirectoryTreeV1(dirname(path), containmentRoot); + await assertRfc64ExistingSecureDirectoryTreeV1(dirname(path), containmentRoot); let handle: Awaited> | null = null; try { @@ -277,7 +304,7 @@ export async function readRfc64OptionalBoundedBytesV1( return fail('io', `failed to complete bounded read of ${label}`); } -async function assertRfc64SecureDirectoryTreeV1( +async function assertRfc64ExistingSecureDirectoryTreeV1( target: string, containmentRoot: string, ): Promise { diff --git a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts index 3e85afcdf8..75afb11a55 100644 --- a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts +++ b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts @@ -42,7 +42,7 @@ export function rfc64PosixModeMatchesV1(mode: number, expected: number): boolean export function assertRfc64FilesystemOwnerSyncV1(path: string): void { if (rfc64UsesWindowsFilesystemPolicyV1()) { - runWindowsAclPolicy(path, statSync(path).isDirectory(), 'owner'); + assertWindowsFilesystemOwnerSync(path, statSync(path).isDirectory()); return; } if (!rfc64CurrentUserOwnsUidV1(statSync(path).uid)) { @@ -56,7 +56,7 @@ export function assertRfc64OwnerOnlyPermissionsSyncV1( directory: boolean, ): void { if (rfc64UsesWindowsFilesystemPolicyV1()) { - runWindowsAclPolicy(path, directory, 'assert-owner-only'); + assertWindowsOwnerOnlyPermissionsSync(path, directory); return; } const stat = statSync(path); @@ -76,7 +76,7 @@ export function applyRfc64OwnerOnlyPermissionsSyncV1( directory: boolean, ): void { if (rfc64UsesWindowsFilesystemPolicyV1()) { - runWindowsAclPolicy(path, directory, 'apply-owner-only'); + applyWindowsOwnerOnlyPermissionsSync(path, directory); return; } assertRfc64FilesystemOwnerSyncV1(path); @@ -122,21 +122,13 @@ export function rfc64RegularFileReadOpenFlagsV1(): number { | (rfc64UsesWindowsFilesystemPolicyV1() ? 0 : constants.O_NOFOLLOW); } -type WindowsAclPolicyActionV1 = 'owner' | 'assert-owner-only' | 'apply-owner-only'; - -function runWindowsAclPolicy( - path: string, - directory: boolean, - action: WindowsAclPolicyActionV1, -): void { - const script = String.raw` +const WINDOWS_ACL_POWERSHELL_PRELUDE = String.raw` $ErrorActionPreference = 'Stop' $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $userSid = $identity.User $defaultOwnerSid = $identity.Owner $target = [System.IO.Path]::GetFullPath($env:DKG_RFC64_ACL_PATH) $isDirectory = [System.Convert]::ToBoolean($env:DKG_RFC64_ACL_DIRECTORY) -$action = $env:DKG_RFC64_ACL_ACTION function Read-TargetAcl { if ($isDirectory) { @@ -160,41 +152,10 @@ function Assert-CurrentOwner($acl) { throw "owner SID $($owner.Value) is not the current token owner" } } +`; -$acl = Read-TargetAcl -Assert-CurrentOwner $acl - -if ($action -eq 'apply-owner-only') { - $acl = if ($isDirectory) { - [System.Security.AccessControl.DirectorySecurity]::new() - } else { - [System.Security.AccessControl.FileSecurity]::new() - } - $acl.SetOwner($userSid) - $acl.SetAccessRuleProtection($true, $false) - $inheritance = if ($isDirectory) { - [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' - } else { - [System.Security.AccessControl.InheritanceFlags]::None - } - $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( - $userSid, - [System.Security.AccessControl.FileSystemRights]::FullControl, - $inheritance, - [System.Security.AccessControl.PropagationFlags]::None, - [System.Security.AccessControl.AccessControlType]::Allow - ) - $acl.AddAccessRule($rule) - if ($isDirectory) { - [System.IO.Directory]::SetAccessControl($target, $acl) - } else { - [System.IO.File]::SetAccessControl($target, $acl) - } - $acl = Read-TargetAcl - Assert-CurrentOwner $acl -} - -if ($action -ne 'owner') { +const WINDOWS_OWNER_ONLY_ASSERTION = String.raw` +function Assert-OwnerOnly($acl) { if (-not $acl.AreAccessRulesProtected) { throw 'RFC-64 owner-only ACL must disable inherited access rules' } @@ -218,6 +179,77 @@ if ($action -ne 'owner') { } } `; + +function assertWindowsFilesystemOwnerSync(path: string, directory: boolean): void { + runWindowsAclOperation( + path, + directory, + 'owner assertion', + `${WINDOWS_ACL_POWERSHELL_PRELUDE} +$acl = Read-TargetAcl +Assert-CurrentOwner $acl`, + ); +} + +function assertWindowsOwnerOnlyPermissionsSync(path: string, directory: boolean): void { + runWindowsAclOperation( + path, + directory, + 'owner-only assertion', + `${WINDOWS_ACL_POWERSHELL_PRELUDE} +${WINDOWS_OWNER_ONLY_ASSERTION} +$acl = Read-TargetAcl +Assert-CurrentOwner $acl +Assert-OwnerOnly $acl`, + ); +} + +function applyWindowsOwnerOnlyPermissionsSync(path: string, directory: boolean): void { + runWindowsAclOperation( + path, + directory, + 'owner-only application', + `${WINDOWS_ACL_POWERSHELL_PRELUDE} +${WINDOWS_OWNER_ONLY_ASSERTION} +$existingAcl = Read-TargetAcl +Assert-CurrentOwner $existingAcl +$acl = if ($isDirectory) { + [System.Security.AccessControl.DirectorySecurity]::new() +} else { + [System.Security.AccessControl.FileSecurity]::new() +} +$acl.SetOwner($userSid) +$acl.SetAccessRuleProtection($true, $false) +$inheritance = if ($isDirectory) { + [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' +} else { + [System.Security.AccessControl.InheritanceFlags]::None +} +$rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $userSid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow +) +$acl.AddAccessRule($rule) +if ($isDirectory) { + [System.IO.Directory]::SetAccessControl($target, $acl) +} else { + [System.IO.File]::SetAccessControl($target, $acl) +} +$acl = Read-TargetAcl +Assert-CurrentOwner $acl +Assert-OwnerOnly $acl`, + ); +} + +function runWindowsAclOperation( + path: string, + directory: boolean, + operation: string, + script: string, +): void { const result = spawnSync( 'powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], @@ -226,7 +258,6 @@ if ($action -ne 'owner') { windowsHide: true, env: { ...process.env, - DKG_RFC64_ACL_ACTION: action, DKG_RFC64_ACL_DIRECTORY: String(directory), DKG_RFC64_ACL_PATH: path, }, @@ -234,7 +265,7 @@ if ($action -ne 'owner') { ); if (result.error !== undefined || result.status !== 0) { throw new Error( - `RFC-64 Windows owner-only ACL policy failed for ${path}: ${ + `RFC-64 Windows ACL ${operation} failed for ${path}: ${ result.error?.message ?? (result.stderr.trim() || `PowerShell exited ${result.status}`) }`, diff --git a/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts b/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts index a8834f951b..c13680799f 100644 --- a/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts +++ b/packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts @@ -14,7 +14,7 @@ Object.assign(agent, { rfc64InventoryV1: undefined, }); -await agent.prepareRfc64InventoryV1(); +await agent.prepareRfc64PersistenceV1(); let terminating = false; process.once('SIGTERM', () => { @@ -22,7 +22,7 @@ process.once('SIGTERM', () => { terminating = true; void (async () => { try { - await agent.closeRfc64InventoryV1(); + await agent.closeRfc64PersistenceV1(); process.stdout.write('CLOSED\n', () => process.exit(0)); } catch (error) { process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index 08ec350ab3..e295487cc3 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -208,12 +208,12 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { it('stays dormant without dataDir and performs no in-memory fallback', async () => { const agent = syntheticAgent(); - await agent.prepareRfc64InventoryV1(); - await agent.prepareRfc64InventoryV1(); + await agent.prepareRfc64PersistenceV1(); + await agent.prepareRfc64PersistenceV1(); expect(agent.rfc64PersistenceV1).toBeUndefined(); - await expect(agent.closeRfc64InventoryV1()).resolves.toBeUndefined(); - await expect(agent.closeRfc64InventoryV1()).resolves.toBeUndefined(); + await expect(agent.closeRfc64PersistenceV1()).resolves.toBeUndefined(); + await expect(agent.closeRfc64PersistenceV1()).resolves.toBeUndefined(); }); it('owns one persistent foundation and purges every stale candidate in bounded yielding batches', async () => { @@ -223,11 +223,11 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { const yieldBatch = vi.fn(async () => {}); agent.yieldRfc64InventoryV1StartupBatch = yieldBatch; - await agent.prepareRfc64InventoryV1(); + await agent.prepareRfc64PersistenceV1(); const ownedPersistence = agent.rfc64PersistenceV1; const ownedFoundation = ownedPersistence.inventory; const ownedControlObjectStore = ownedPersistence.controlObjectStore; - await agent.prepareRfc64InventoryV1(); + await agent.prepareRfc64PersistenceV1(); expect(agent.rfc64PersistenceV1).toBe(ownedPersistence); expect(ownedFoundation.databasePath).toBe( @@ -239,18 +239,18 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { join(dataDirectory, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH), ); - await agent.closeRfc64InventoryV1(); + await agent.closeRfc64PersistenceV1(); expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(ownedPersistence.closed).toBe(true); expect(ownedControlObjectStore.closed).toBe(true); expect(candidateLoadCount(dataDirectory)).toBe(0); - await expect(agent.closeRfc64InventoryV1()).resolves.toBeUndefined(); + await expect(agent.closeRfc64PersistenceV1()).resolves.toBeUndefined(); }); it('retains the inventory lease until the control store drain settles', async () => { const dataDirectory = temporaryDataDirectory(); const agent = syntheticAgent(dataDirectory); - await agent.prepareRfc64InventoryV1(); + await agent.prepareRfc64PersistenceV1(); const persistence = agent.rfc64PersistenceV1; const inventory = persistence.inventory; const controlStore = persistence.controlObjectStore; @@ -263,7 +263,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await actualClose(); }); - const close = agent.closeRfc64InventoryV1(); + const close = agent.closeRfc64PersistenceV1(); await entered.promise; expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(persistence.closed).toBe(true); @@ -281,7 +281,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { Object.assign(agent, { started: false, coreHostRecordingGeneration: 0, - prepareRfc64InventoryV1: vi.fn(async () => { throw failure; }), + prepareRfc64PersistenceV1: vi.fn(async () => { throw failure; }), node: { start: nodeStart }, log: { info: vi.fn(), warn: vi.fn() }, }); @@ -309,7 +309,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { symlinkSync(outside, objectsPath, 'dir'); const agent = syntheticAgent(dataDirectory); - await expect(agent.prepareRfc64InventoryV1()) + await expect(agent.prepareRfc64PersistenceV1()) .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); expect(agent.rfc64PersistenceV1).toBeUndefined(); @@ -325,7 +325,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { Object.assign(agent, { started: false, coreHostRecordingGeneration: 0, - prepareRfc64InventoryV1: vi.fn(async () => { + prepareRfc64PersistenceV1: vi.fn(async () => { agent.rfc64PersistenceV1 = { close }; }), node: { start: vi.fn(async () => { throw failure; }) }, diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index b90620ebae..73f473d810 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -596,7 +596,7 @@ describe('RFC-64 durable control-object store v1', () => { expect(closeSettled).toBe(false); release.resolve(); - await expect(stage).rejects.toMatchObject({ code: 'control-store-closed' }); + await expect(stage).resolves.toMatchObject({ durable: true }); await expect(close).resolves.toBeUndefined(); expect(closeSettled).toBe(true); await expect(store.close()).resolves.toBeUndefined(); @@ -642,6 +642,13 @@ describe('RFC-64 durable control-object store v1', () => { .rejects.toMatchObject({ code: 'control-store-durability' }); expect(await readFile(paths.object, 'utf8')).toContain('dkg-rfc64-control-store-test-v1'); await expect(stat(paths.signature)).rejects.toMatchObject({ code: 'ENOENT' }); + const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature, + })).resolves.toBeNull(); + expect(verifyIssuerSignature).not.toHaveBeenCalled(); boundaries.length = 0; await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); expect(boundaries).toContain('object.existing-fsynced'); @@ -661,6 +668,33 @@ describe('RFC-64 durable control-object store v1', () => { .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); }); + it.runIf(process.platform !== 'win32')( + 'rejects symlinked digest files before reading or verifying outside bytes', + async () => { + for (const target of ['object', 'signature'] as const) { + const dataDir = await temporaryDataDirectory(); + const outside = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture(`symlink-${target}`); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + const targetPath = paths[target]; + const outsideFile = join(outside, `${target}.jcs`); + await writeFile(outsideFile, '{"outside":true}'); + await unlink(targetPath); + await symlink(outsideFile, targetPath, 'file'); + const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature, + })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + expect(verifyIssuerSignature).not.toHaveBeenCalled(); + } + }, + ); + it.runIf(process.platform !== 'win32')( 'rejects permissive existing files and directories instead of trusting or tightening them', async () => { diff --git a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts index eabb3ac4a1..122d48c75d 100644 --- a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts +++ b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts @@ -16,8 +16,7 @@ type ProductionModuleHasTestOpener = keyof typeof productionControlStoreModule ? true : false; // The stable package root intentionally withholds the implementation-shaped -// cache API until an RFC-64 public workflow consumes it. Internal dist -// subpaths are unsupported; this test defines the stable root/module contract. +// cache API until an RFC-64 public workflow consumes it. const packageRootHasRawControlStoreOpener: PackageRootHasRawControlStoreOpener = false; const packageRootHasControlStoreLayout: PackageRootHasControlStoreLayout = false; const productionModuleHasRawControlStoreOpener: ProductionModuleHasRawControlStoreOpener = false; @@ -26,8 +25,12 @@ const productionModuleHasTestOpener: ProductionModuleHasTestOpener = false; // @ts-expect-error Low-level store types are not part of the stable package root. type PackageRootStoreType = packageRoot.Rfc64ControlObjectStoreV1; +// @ts-expect-error The package exports map blocks emitted internal subpaths. +type PublishedInternalControlStoreModule = typeof import('@origintrail-official/dkg-agent/dist/rfc64/control-object-store-v1-internal.js'); + void packageRootHasRawControlStoreOpener; void packageRootHasControlStoreLayout; void productionModuleHasRawControlStoreOpener; void productionModuleHasTestOpener; void (undefined as PackageRootStoreType | undefined); +void (undefined as PublishedInternalControlStoreModule | undefined); From 326b279fc31840d87644e8558928758532f7167f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 15:33:30 +0200 Subject: [PATCH 075/292] fix(agent): enforce RFC-64 persistence ownership --- .github/workflows/rfc64-inventory-windows.yml | 2 + .../rfc64/control-object-store-v1-internal.ts | 59 ++++++------- .../agent/src/rfc64/durable-file-store-v1.ts | 88 ++++++++++++------- packages/agent/src/rfc64/inventory-v1/open.ts | 4 +- packages/agent/src/rfc64/inventory-v1/sql.ts | 7 +- .../agent/src/rfc64/persistence-layout-v1.ts | 16 ++++ packages/agent/src/rfc64/persistence-v1.ts | 80 ++++++++++++++--- .../rfc64-agent-inventory-lifecycle.test.ts | 85 ++++++++++++++---- .../rfc64-control-object-store-v1.test.ts | 55 +++++++++--- .../test/rfc64-persistence-owner.typecheck.ts | 9 ++ ...rfc64-control-object-store-test-support.ts | 15 ++-- 11 files changed, 300 insertions(+), 120 deletions(-) create mode 100644 packages/agent/src/rfc64/persistence-layout-v1.ts create mode 100644 packages/agent/test/rfc64-persistence-owner.typecheck.ts diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 0ce776b990..a65612d1d8 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -9,6 +9,7 @@ on: - 'packages/agent/src/rfc64/control-object-store-v1.ts' - 'packages/agent/src/rfc64/control-object-store-v1-internal.ts' - 'packages/agent/src/rfc64/durable-file-store-v1.ts' + - 'packages/agent/src/rfc64/persistence-layout-v1.ts' - 'packages/agent/src/rfc64/persistence-v1.ts' - 'packages/agent/src/rfc64/secure-filesystem-policy-v1.ts' - 'packages/agent/src/dkg-agent-base.ts' @@ -19,6 +20,7 @@ on: - 'packages/agent/test/rfc64-control-object-store-v1.test.ts' - 'packages/agent/test/support/rfc64-control-object-store-test-support.ts' - 'packages/agent/test/rfc64-control-store-public-api.typecheck.ts' + - 'packages/agent/test/rfc64-persistence-owner.typecheck.ts' - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' - 'packages/agent/vitest.unit.config.ts' diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index 3fd2f3cbed..c8fda0b11e 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -1,4 +1,3 @@ -import { randomBytes } from 'node:crypto'; import { join, resolve } from 'node:path'; import { @@ -18,11 +17,11 @@ import { import { Rfc64DurableFileErrorV1, assertRfc64ExistingDirectoryV1, + createRfc64DurableFileStoreV1, ensureRfc64SecureDirectoryTreeV1, - putRfc64ExactBytesV1, - readRfc64OptionalBoundedBytesV1, type Rfc64DurableFileBoundaryV1, - type Rfc64DurableFileIoV1, + type Rfc64DurableFileLifecycleV1, + type Rfc64DurableFileStoreV1, } from './durable-file-store-v1.js'; import { RFC64_POSIX_NAMESPACE_DURABILITY_V1, @@ -82,13 +81,12 @@ type Rfc64ControlObjectStoreFileKindV1 = 'object' | 'signature'; export type Rfc64ControlObjectStoreDurabilityBoundaryV1 = Rfc64DurableFileBoundaryV1; -interface Rfc64ControlObjectStoreIoV1 - extends Rfc64DurableFileIoV1 {} +interface Rfc64ControlObjectStoreLifecycleV1 + extends Rfc64DurableFileLifecycleV1 {} -const PRODUCTION_IO = Object.freeze({ +const PRODUCTION_LIFECYCLE = Object.freeze({ boundary: (_boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1): void => {}, - randomSuffix: (): string => randomBytes(16).toString('hex'), -}) satisfies Rfc64ControlObjectStoreIoV1; +}) satisfies Rfc64ControlObjectStoreLifecycleV1; export interface StageVerifiedControlObjectV1 { readonly envelope: SignedControlEnvelopeV1; @@ -148,7 +146,7 @@ export interface Rfc64ControlObjectStoreV1 { export async function openRfc64ControlObjectStoreAtOwnedRootV1( rfc64RootPath: string, ): Promise { - return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, PRODUCTION_IO); + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, PRODUCTION_LIFECYCLE); } interface PreparedStoredControlObjectV1 { @@ -163,12 +161,15 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { #closePromise: Promise | null = null; readonly #inFlightOperations = new Set>(); readonly #fileWriteTails = new Map>(); + readonly #durableFiles: Rfc64DurableFileStoreV1; readonly namespaceDurability = rfc64NamespaceDurabilityV1(); constructor( readonly rootPath: string, - private readonly io: Rfc64ControlObjectStoreIoV1, - ) {} + lifecycle: Rfc64ControlObjectStoreLifecycleV1, + ) { + this.#durableFiles = createRfc64DurableFileStoreV1(rootPath, lifecycle); + } get closed(): boolean { return this.#closed; @@ -217,14 +218,12 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { ); const [unsignedBytes, variantBytes] = await mapDurableFileErrors(async () => Promise.all([ - readRfc64OptionalBoundedBytesV1({ - containmentRoot: this.rootPath, + this.#durableFiles.readOptionalBoundedBytes({ relativePath: objectPath, maxBytes: MAX_CONTROL_OBJECT_BYTES, label: 'control object', }), - readRfc64OptionalBoundedBytesV1({ - containmentRoot: this.rootPath, + this.#durableFiles.readOptionalBoundedBytes({ relativePath: signaturePath, maxBytes: MAX_CONTROL_SIGNATURE_VARIANT_BYTES, label: 'control signature variant', @@ -331,8 +330,7 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { kind: Rfc64ControlObjectStoreFileKindV1, ): Promise { await mapDurableFileErrors(async () => { - await putRfc64ExactBytesV1({ - containmentRoot: this.rootPath, + await this.#durableFiles.putExactBytes({ relativePath, bytes, maxBytes: kind === 'object' @@ -340,7 +338,6 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { : MAX_CONTROL_SIGNATURE_VARIANT_BYTES, label: kind === 'object' ? 'control object' : 'control signature variant', kind, - io: this.io, }); }); } @@ -361,10 +358,10 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { /** @internal Used only by the package-local test support module. */ export async function openRfc64ControlObjectStoreForTestV1( dataDirInput: string, - io: Rfc64ControlObjectStoreIoV1, + lifecycle: Rfc64ControlObjectStoreLifecycleV1, ): Promise { - if (!Object.isFrozen(io)) { - fail('control-store-input', 'control object store I/O adapter must be immutable'); + if (!Object.isFrozen(lifecycle)) { + fail('control-store-input', 'control object store lifecycle adapter must be immutable'); } if (typeof dataDirInput !== 'string' || dataDirInput.length === 0) { fail('control-store-input', 'dataDir must be a non-empty path'); @@ -373,17 +370,17 @@ export async function openRfc64ControlObjectStoreForTestV1( const rfc64RootPath = resolve(dataDir, 'rfc64-sync'); await mapDurableFileErrors(async () => { await assertRfc64ExistingDirectoryV1(dataDir, 'DKG data directory', false); - await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, io); + await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, lifecycle); }); - return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, io); + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, lifecycle); } async function openRfc64ControlObjectStoreAtRootV1( rfc64RootPathInput: string, - io: Rfc64ControlObjectStoreIoV1, + lifecycle: Rfc64ControlObjectStoreLifecycleV1, ): Promise { - if (!Object.isFrozen(io)) { - fail('control-store-input', 'control object store I/O adapter must be immutable'); + if (!Object.isFrozen(lifecycle)) { + fail('control-store-input', 'control object store lifecycle adapter must be immutable'); } const rfc64RootPath = resolve(rfc64RootPathInput); const rootPath = resolve(rfc64RootPath, CONTROL_OBJECT_STORE_DIRECTORY); @@ -393,19 +390,19 @@ async function openRfc64ControlObjectStoreAtRootV1( 'RFC-64 persistence root', true, ); - await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath, io); + await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath, lifecycle); await ensureRfc64SecureDirectoryTreeV1( join(rootPath, OBJECTS_DIRECTORY), rootPath, - io, + lifecycle, ); await ensureRfc64SecureDirectoryTreeV1( join(rootPath, SIGNATURES_DIRECTORY), rootPath, - io, + lifecycle, ); }); - return new FileRfc64ControlObjectStoreV1(rootPath, io); + return new FileRfc64ControlObjectStoreV1(rootPath, lifecycle); } function prepareStageBatch( diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index e134eb07ce..7cb15fb795 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { link, lstat, mkdir, open, unlink } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; @@ -47,11 +48,29 @@ export type Rfc64DurableFileBoundaryV1 = | 'directory.parent-fsynced' | `${TKind}.${Rfc64DurableFilePublishBoundaryV1}`; -export interface Rfc64DurableFileIoV1 { +export interface Rfc64DurableFileLifecycleV1 { readonly boundary: ( boundary: Rfc64DurableFileBoundaryV1, ) => void | Promise; - readonly randomSuffix: () => string; +} + +export interface Rfc64DurableFileStoreV1 { + putExactBytes(input: PutRfc64ExactBytesInputV1): Promise; + readOptionalBoundedBytes( + input: ReadRfc64OptionalBoundedBytesInputV1, + ): Promise; +} + +export function createRfc64DurableFileStoreV1( + containmentRoot: string, + lifecycle: Rfc64DurableFileLifecycleV1, +): Rfc64DurableFileStoreV1 { + return Object.freeze({ + putExactBytes: (input: PutRfc64ExactBytesInputV1) => + putRfc64ExactBytesV1({ ...input, containmentRoot, lifecycle }), + readOptionalBoundedBytes: (input: ReadRfc64OptionalBoundedBytesInputV1) => + readRfc64OptionalBoundedBytesV1({ ...input, containmentRoot }), + }); } export async function assertRfc64ExistingDirectoryV1( @@ -80,7 +99,7 @@ export async function assertRfc64ExistingDirectoryV1( export async function ensureRfc64SecureDirectoryTreeV1( target: string, containmentRoot: string, - io: Rfc64DurableFileIoV1, + lifecycle: Rfc64DurableFileLifecycleV1, ): Promise { await walkRfc64ContainedDirectoryTreeV1( target, @@ -91,7 +110,7 @@ export async function ensureRfc64SecureDirectoryTreeV1( try { await mkdir(current, { mode: RFC64_SECURE_DIRECTORY_MODE_V1 }); created = true; - await io.boundary('directory.created'); + await lifecycle.boundary('directory.created'); } catch (cause) { if (!isNodeError(cause, 'EEXIST')) { fail('io', `failed to create durable store directory ${current}`, cause); @@ -99,36 +118,40 @@ export async function ensureRfc64SecureDirectoryTreeV1( } if (created) { await chmodSecure(current, RFC64_SECURE_DIRECTORY_MODE_V1, 'directory'); - await io.boundary('directory.mode-secured'); + await lifecycle.boundary('directory.mode-secured'); await fsyncDirectory(current); - await io.boundary('directory.self-fsynced'); + await lifecycle.boundary('directory.self-fsynced'); await fsyncDirectory(dirname(current)); - await io.boundary('directory.parent-fsynced'); + await lifecycle.boundary('directory.parent-fsynced'); } }, ); } export interface PutRfc64ExactBytesInputV1 { - readonly containmentRoot: string; readonly relativePath: string; readonly bytes: Uint8Array; readonly maxBytes: number; readonly label: string; readonly kind: TKind; - readonly io: Rfc64DurableFileIoV1; } -export async function putRfc64ExactBytesV1( - input: PutRfc64ExactBytesInputV1, +interface InternalPutRfc64ExactBytesInputV1 + extends PutRfc64ExactBytesInputV1 { + readonly containmentRoot: string; + readonly lifecycle: Rfc64DurableFileLifecycleV1; +} + +async function putRfc64ExactBytesV1( + input: InternalPutRfc64ExactBytesInputV1, ): Promise { - const { containmentRoot, relativePath, bytes, maxBytes, label, kind, io } = input; + const { containmentRoot, relativePath, bytes, maxBytes, label, lifecycle } = input; const targetPath = resolveContainedFileTarget(containmentRoot, relativePath); assertByteBounds(bytes, maxBytes, label); await ensureRfc64SecureDirectoryTreeV1( dirname(targetPath), containmentRoot, - io, + lifecycle, ); const resolvedInput = { ...input, targetPath }; if (await reconcileRfc64ExistingImmutableV1(resolvedInput)) return; @@ -137,7 +160,7 @@ export async function putRfc64ExactBytesV1( } interface ResolvedPutRfc64ExactBytesInputV1 - extends PutRfc64ExactBytesInputV1 { + extends InternalPutRfc64ExactBytesInputV1 { readonly targetPath: string; } @@ -162,34 +185,31 @@ async function reconcileRfc64ExistingImmutableV1( async function completeRfc64ExistingFileDurabilityV1( input: ResolvedPutRfc64ExactBytesInputV1, ): Promise { - const { targetPath, label, kind, io } = input; + const { targetPath, label, kind, lifecycle } = input; // A prior attempt can fail after no-replace publication but before the // parent-directory barrier. Re-establish both barriers before retry succeeds. await fsyncRegularFile(targetPath, label); - await io.boundary(`${kind}.existing-fsynced`); + await lifecycle.boundary(`${kind}.existing-fsynced`); await fsyncDirectory(dirname(targetPath)); - await io.boundary(`${kind}.existing-parent-fsynced`); + await lifecycle.boundary(`${kind}.existing-parent-fsynced`); } async function writeRfc64SecureTempFileV1( input: ResolvedPutRfc64ExactBytesInputV1, ): Promise { - const { targetPath, bytes, label, kind, io } = input; - const suffix = io.randomSuffix(); - if (!/^[0-9a-f]{32}$/u.test(suffix)) { - fail('input', 'durable file random suffix must be 16 lowercase hex bytes'); - } + const { targetPath, bytes, label, kind, lifecycle } = input; + const suffix = randomBytes(16).toString('hex'); const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.${suffix}.tmp`); let complete = false; let handle: Awaited> | null = null; try { handle = await open(tempPath, 'wx', RFC64_SECURE_FILE_MODE_V1); await handle.writeFile(bytes); - await io.boundary(`${kind}.temp-written`); + await lifecycle.boundary(`${kind}.temp-written`); await chmodSecure(tempPath, RFC64_SECURE_FILE_MODE_V1, `${label} temp file`); - await io.boundary(`${kind}.temp-mode-secured`); + await lifecycle.boundary(`${kind}.temp-mode-secured`); await handle.sync(); - await io.boundary(`${kind}.temp-fsynced`); + await lifecycle.boundary(`${kind}.temp-fsynced`); await handle.close(); handle = null; complete = true; @@ -207,7 +227,7 @@ async function publishRfc64NoReplaceV1( input: ResolvedPutRfc64ExactBytesInputV1, tempPath: string, ): Promise { - const { targetPath, label, kind, io } = input; + const { targetPath, label, kind, lifecycle } = input; let tempPresent = true; try { try { @@ -223,12 +243,12 @@ async function publishRfc64NoReplaceV1( } return; } - await io.boundary(`${kind}.published-no-replace`); + await lifecycle.boundary(`${kind}.published-no-replace`); await unlink(tempPath); tempPresent = false; - await io.boundary(`${kind}.temp-unlinked`); + await lifecycle.boundary(`${kind}.temp-unlinked`); await fsyncDirectory(dirname(targetPath)); - await io.boundary(`${kind}.parent-fsynced`); + await lifecycle.boundary(`${kind}.parent-fsynced`); await assertExistingRegularFile(targetPath, `${label} cache file`, true); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; @@ -239,14 +259,18 @@ async function publishRfc64NoReplaceV1( } export interface ReadRfc64OptionalBoundedBytesInputV1 { - readonly containmentRoot: string; readonly relativePath: string; readonly maxBytes: number; readonly label: string; } -export async function readRfc64OptionalBoundedBytesV1( - input: ReadRfc64OptionalBoundedBytesInputV1, +interface InternalReadRfc64OptionalBoundedBytesInputV1 + extends ReadRfc64OptionalBoundedBytesInputV1 { + readonly containmentRoot: string; +} + +async function readRfc64OptionalBoundedBytesV1( + input: InternalReadRfc64OptionalBoundedBytesInputV1, ): Promise { const { containmentRoot, relativePath, maxBytes, label } = input; const path = resolveContainedFileTarget(containmentRoot, relativePath); diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index c845a35ae6..c18908913d 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -31,13 +31,13 @@ import { fsyncRfc64DirectorySyncV1, rfc64RegularFileFsyncOpenFlagsV1, } from '../secure-filesystem-policy-v1.js'; +import { resolveRfc64InventoryDatabasePathV1 } from '../persistence-layout-v1.js'; import { INVENTORY_V1_APPLICATION_ID, INVENTORY_V1_DDL, INVENTORY_V1_DIRECTORY_MODE, INVENTORY_V1_FILE_MODE, - INVENTORY_V1_RELATIVE_PATH, INVENTORY_V1_USER_OBJECTS, INVENTORY_V1_USER_VERSION, normalizeInventoryV1SchemaSql, @@ -227,7 +227,7 @@ async function openInventoryV1WithLifecycleAdapter( } const sqlite = await loadSqliteModule(); const resolvedDataDir = resolve(dataDir); - const databasePath = resolve(resolvedDataDir, INVENTORY_V1_RELATIVE_PATH); + const databasePath = resolveRfc64InventoryDatabasePathV1(resolvedDataDir); let lease: InventoryV1Lease | null = null; try { if (pathEntryExists(recoveryMarkerPath(databasePath))) { diff --git a/packages/agent/src/rfc64/inventory-v1/sql.ts b/packages/agent/src/rfc64/inventory-v1/sql.ts index c27f6b8f19..0d0dbfbd17 100644 --- a/packages/agent/src/rfc64/inventory-v1/sql.ts +++ b/packages/agent/src/rfc64/inventory-v1/sql.ts @@ -2,10 +2,15 @@ import { RFC64_SECURE_DIRECTORY_MODE_V1, RFC64_SECURE_FILE_MODE_V1, } from '../secure-filesystem-policy-v1.js'; +import { + RFC64_INVENTORY_DATABASE_FILENAME_V1, + RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1, +} from '../persistence-layout-v1.js'; export const INVENTORY_V1_APPLICATION_ID = 0x444b3634; export const INVENTORY_V1_USER_VERSION = 1; -export const INVENTORY_V1_RELATIVE_PATH = 'rfc64-sync/inventory-v1.sqlite3'; +export const INVENTORY_V1_RELATIVE_PATH = + `${RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1}/${RFC64_INVENTORY_DATABASE_FILENAME_V1}`; export const INVENTORY_V1_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; export const INVENTORY_V1_FILE_MODE = RFC64_SECURE_FILE_MODE_V1; diff --git a/packages/agent/src/rfc64/persistence-layout-v1.ts b/packages/agent/src/rfc64/persistence-layout-v1.ts new file mode 100644 index 0000000000..ef70891375 --- /dev/null +++ b/packages/agent/src/rfc64/persistence-layout-v1.ts @@ -0,0 +1,16 @@ +import { resolve } from 'node:path'; + +export const RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1 = 'rfc64-sync' as const; +export const RFC64_INVENTORY_DATABASE_FILENAME_V1 = 'inventory-v1.sqlite3' as const; + +/** Canonical boundary shared by every resource protected by the RFC-64 lease. */ +export function resolveRfc64PersistenceRootV1(dataDir: string): string { + return resolve(dataDir, RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1); +} + +export function resolveRfc64InventoryDatabasePathV1(dataDir: string): string { + return resolve( + resolveRfc64PersistenceRootV1(dataDir), + RFC64_INVENTORY_DATABASE_FILENAME_V1, + ); +} diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index a3bf74ea37..7a0f855e34 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -1,13 +1,13 @@ -import { dirname } from 'node:path'; - import { openInventoryV1, + type Rfc64InventoryV1CandidateApi, type Rfc64InventoryV1Foundation, } from './inventory-v1/index.js'; import { type Rfc64ControlObjectStoreV1, } from './control-object-store-v1.js'; import { openRfc64ControlObjectStoreAtOwnedRootV1 } from './control-object-store-v1-internal.js'; +import { resolveRfc64PersistenceRootV1 } from './persistence-layout-v1.js'; export interface OpenRfc64PersistenceOptionsV1 { /** Yield after each non-terminal fixed-size startup purge batch. */ @@ -15,9 +15,22 @@ export interface OpenRfc64PersistenceOptionsV1 { } /** One lifecycle owner for every RFC-64 resource protected by the inventory lease. */ +export type Rfc64InventoryOperationsV1 = Omit< + Rfc64InventoryV1CandidateApi, + 'purgeNextStartupStaleCandidateBatch' +>; + +export type Rfc64ControlObjectOperationsV1 = Pick< + Rfc64ControlObjectStoreV1, + 'namespaceDurability' | 'stageVerifiedObjects' | 'getVerifiedObject' +>; + export interface Rfc64PersistenceV1 { - readonly inventory: Rfc64InventoryV1Foundation; - readonly controlObjectStore: Rfc64ControlObjectStoreV1; + readonly rootPath: string; + /** Non-owning inventory operations; lifecycle methods remain private to this owner. */ + readonly inventory: Rfc64InventoryOperationsV1; + /** Non-owning cache operations; lifecycle methods remain private to this owner. */ + readonly controlObjects: Rfc64ControlObjectOperationsV1; readonly closed: boolean; /** Drain the control store before releasing inventory ownership. */ close(): Promise; @@ -26,11 +39,21 @@ export interface Rfc64PersistenceV1 { class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { #closed = false; #closePromise: Promise | null = null; + readonly #ownedInventory: Rfc64InventoryV1Foundation; + readonly #ownedControlObjectStore: Rfc64ControlObjectStoreV1; + readonly inventory: Rfc64InventoryOperationsV1; + readonly controlObjects: Rfc64ControlObjectOperationsV1; constructor( - readonly inventory: Rfc64InventoryV1Foundation, - readonly controlObjectStore: Rfc64ControlObjectStoreV1, - ) {} + readonly rootPath: string, + ownedInventory: Rfc64InventoryV1Foundation, + ownedControlObjectStore: Rfc64ControlObjectStoreV1, + ) { + this.#ownedInventory = ownedInventory; + this.#ownedControlObjectStore = ownedControlObjectStore; + this.inventory = createInventoryOperationsView(ownedInventory); + this.controlObjects = createControlObjectOperationsView(ownedControlObjectStore); + } get closed(): boolean { return this.#closed; @@ -46,12 +69,12 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { private async closeOwnedResources(): Promise { const failures: unknown[] = []; try { - await this.controlObjectStore.close(); + await this.#ownedControlObjectStore.close(); } catch (cause) { failures.push(cause); } try { - this.inventory.close(); + this.#ownedInventory.close(); } catch (cause) { failures.push(cause); } @@ -75,6 +98,7 @@ export async function openRfc64PersistenceV1( throw new TypeError('yieldAfterPurgeBatch must be a function'); } + const rootPath = resolveRfc64PersistenceRootV1(dataDir); const inventory = await openInventoryV1(dataDir); try { for (;;) { @@ -83,9 +107,9 @@ export async function openRfc64PersistenceV1( await yieldAfterPurgeBatch(); } const controlObjectStore = await openRfc64ControlObjectStoreAtOwnedRootV1( - dirname(inventory.databasePath), + rootPath, ); - return new OwnedRfc64PersistenceV1(inventory, controlObjectStore); + return new OwnedRfc64PersistenceV1(rootPath, inventory, controlObjectStore); } catch (cause) { try { inventory.close(); @@ -98,3 +122,37 @@ export async function openRfc64PersistenceV1( throw cause; } } + +function createInventoryOperationsView( + inventory: Rfc64InventoryV1Foundation, +): Rfc64InventoryOperationsV1 { + return Object.freeze({ + createCandidateSession: inventory.createCandidateSession.bind(inventory), + putVerifiedCandidateBucket: inventory.putVerifiedCandidateBucket.bind(inventory), + getCandidateBucket: inventory.getCandidateBucket.bind(inventory), + beginCandidateBucketRows: inventory.beginCandidateBucketRows.bind(inventory), + beginCandidateBucketDiff: inventory.beginCandidateBucketDiff.bind(inventory), + pageCandidateBucketRows: inventory.pageCandidateBucketRows.bind(inventory), + pageCandidateBucketAddedOrChanged: + inventory.pageCandidateBucketAddedOrChanged.bind(inventory), + pageCandidateBucketRemoved: inventory.pageCandidateBucketRemoved.bind(inventory), + readVerifiedCandidateCatalogRow: + inventory.readVerifiedCandidateCatalogRow.bind(inventory), + verifyCandidateCatalogPrecommitV1: + inventory.verifyCandidateCatalogPrecommitV1.bind(inventory), + closeCandidateTraversal: inventory.closeCandidateTraversal.bind(inventory), + discardCandidateSessionBatch: inventory.discardCandidateSessionBatch.bind(inventory), + deleteCandidateBucket: inventory.deleteCandidateBucket.bind(inventory), + }); +} + +function createControlObjectOperationsView( + controlObjectStore: Rfc64ControlObjectStoreV1, +): Rfc64ControlObjectOperationsV1 { + return Object.freeze({ + namespaceDurability: controlObjectStore.namespaceDurability, + stageVerifiedObjects: + controlObjectStore.stageVerifiedObjects.bind(controlObjectStore), + getVerifiedObject: controlObjectStore.getVerifiedObject.bind(controlObjectStore), + }); +} diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index e295487cc3..eaaed1da9a 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -4,6 +4,14 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; +import { + computeControlObjectDigestHex, + type Digest32V1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DKGAgent } from '../src/dkg-agent.js'; @@ -13,8 +21,12 @@ import { } from '../src/rfc64/inventory-v1/index.js'; import { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + type StageVerifiedControlObjectV1, } from '../src/rfc64/control-object-store-v1.js'; import { openRfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; +import { + RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1, +} from '../src/rfc64/persistence-layout-v1.js'; const temporaryDirectories: string[] = []; const childProcesses = new Set(); @@ -22,6 +34,7 @@ const CHILD_FIXTURE = resolve( import.meta.dirname, 'fixtures/rfc64-agent-inventory-lifecycle-child.ts', ); +const CONTROL_STORE_WALLET = new ethers.Wallet(`0x${'52'.repeat(32)}`); function temporaryDataDirectory(): string { const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-agent-'))); @@ -46,6 +59,28 @@ function deferred(): { readonly promise: Promise; readonly resolve: () => return { promise, resolve: () => resolvePromise?.() }; } +async function signedControlObjectFixture( + sequence: string, +): Promise { + const unsigned = { + issuer: CONTROL_STORE_WALLET.address.toLowerCase(), + objectType: 'dkg-rfc64-persistence-lifecycle-test-v1', + payload: { sequence }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } satisfies UnsignedControlEnvelopeV1; + const objectDigest = computeControlObjectDigestHex(unsigned); + const envelope = { + ...unsigned, + objectDigest, + signature: await CONTROL_STORE_WALLET.signMessage(ethers.getBytes(objectDigest)), + } as SignedControlEnvelopeV1; + return { + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }; +} + function u64be(value: bigint): Buffer { const encoded = Buffer.alloc(8); encoded.writeBigUInt64BE(value); @@ -225,24 +260,30 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { await agent.prepareRfc64PersistenceV1(); const ownedPersistence = agent.rfc64PersistenceV1; - const ownedFoundation = ownedPersistence.inventory; - const ownedControlObjectStore = ownedPersistence.controlObjectStore; + const inventory = ownedPersistence.inventory; + const controlObjects = ownedPersistence.controlObjects; await agent.prepareRfc64PersistenceV1(); expect(agent.rfc64PersistenceV1).toBe(ownedPersistence); - expect(ownedFoundation.databasePath).toBe( - join(dataDirectory, INVENTORY_V1_RELATIVE_PATH), + expect(ownedPersistence.rootPath).toBe( + join(dataDirectory, RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1), ); + expect(realpathSync(join(dataDirectory, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH))) + .toBe(join(dataDirectory, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH)); expect(yieldBatch).toHaveBeenCalledTimes(3); - expect(() => ownedFoundation.createCandidateSession()).not.toThrow(); - expect(ownedControlObjectStore.rootPath).toBe( - join(dataDirectory, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH), - ); + expect(() => inventory.createCandidateSession()).not.toThrow(); + expect(Object.isFrozen(controlObjects)).toBe(true); + expect(controlObjects).not.toHaveProperty('rootPath'); + expect(controlObjects).not.toHaveProperty('closed'); + expect(controlObjects).not.toHaveProperty('close'); + expect(Object.isFrozen(inventory)).toBe(true); + expect(inventory).not.toHaveProperty('databasePath'); + expect(inventory).not.toHaveProperty('closed'); + expect(inventory).not.toHaveProperty('close'); await agent.closeRfc64PersistenceV1(); expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(ownedPersistence.closed).toBe(true); - expect(ownedControlObjectStore.closed).toBe(true); expect(candidateLoadCount(dataDirectory)).toBe(0); await expect(agent.closeRfc64PersistenceV1()).resolves.toBeUndefined(); }); @@ -252,26 +293,32 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { const agent = syntheticAgent(dataDirectory); await agent.prepareRfc64PersistenceV1(); const persistence = agent.rfc64PersistenceV1; - const inventory = persistence.inventory; - const controlStore = persistence.controlObjectStore; + const fixture = await signedControlObjectFixture('lease-read-drain'); + const staged = await persistence.controlObjects.stageVerifiedObjects([fixture]); const entered = deferred(); const release = deferred(); - const actualClose = controlStore.close.bind(controlStore); - vi.spyOn(controlStore, 'close').mockImplementation(async () => { - entered.resolve(); - await release.promise; - await actualClose(); + const read = persistence.controlObjects.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: staged.objects[0].signatureVariantDigest, + verifyIssuerSignature: async (envelope) => { + entered.resolve(); + await release.promise; + return verifyControlEnvelopeIssuerSignatureV1(envelope); + }, }); + await entered.promise; const close = agent.closeRfc64PersistenceV1(); - await entered.promise; expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(persistence.closed).toBe(true); - expect(inventory.closed).toBe(false); + await expect(openInventoryV1(dataDirectory)) + .rejects.toMatchObject({ code: 'database-busy' }); release.resolve(); + await expect(read).resolves.toMatchObject({ envelope: fixture.envelope }); await expect(close).resolves.toBeUndefined(); - expect(inventory.closed).toBe(true); + const replacement = await openInventoryV1(dataDirectory); + replacement.close(); }); it('fails startup before node.start when inventory acquisition or recovery fails', async () => { diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 73f473d810..6707916f63 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -33,7 +33,7 @@ import { Rfc64ControlObjectStoreErrorV1, type StageVerifiedControlObjectV1, } from '../src/rfc64/control-object-store-v1.js'; -import { putRfc64ExactBytesV1 } from '../src/rfc64/durable-file-store-v1.js'; +import { createRfc64DurableFileStoreV1 } from '../src/rfc64/durable-file-store-v1.js'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; import { applyRfc64OwnerOnlyPermissionsSyncV1 } from '../src/rfc64/secure-filesystem-policy-v1.js'; import { @@ -281,7 +281,6 @@ describe('RFC-64 durable control-object store v1', () => { const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; const store = await createRfc64ControlObjectStoreTestOpenerV1({ boundary: (boundary) => boundaries.push(boundary), - randomSuffix: () => '01'.repeat(16), })(dataDir); const fixture = await signedFixture('2'); boundaries.length = 0; @@ -602,6 +601,36 @@ describe('RFC-64 durable control-object store v1', () => { await expect(store.close()).resolves.toBeUndefined(); }); + it('drains an admitted verified read before close can release ownership', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('close-read-drain'); + await store.stageVerifiedObjects([fixture]); + const entered = deferred(); + const release = deferred(); + const read = store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: pathsFor(dataDir, fixture.envelope).signatureDigest, + verifyIssuerSignature: async (envelope) => { + entered.resolve(); + await release.promise; + return verifyControlEnvelopeIssuerSignatureV1(envelope); + }, + }); + await entered.promise; + + let closeSettled = false; + const close = store.close().then(() => { closeSettled = true; }); + expect(store.closed).toBe(true); + await Promise.resolve(); + expect(closeSettled).toBe(false); + + release.resolve(); + await expect(read).resolves.toMatchObject({ envelope: fixture.envelope }); + await expect(close).resolves.toBeUndefined(); + expect(closeSettled).toBe(true); + }); + it('cleans an unpublished temp after a pre-visibility fault and converges on retry', async () => { const dataDir = await temporaryDataDirectory(); const fixture = await signedFixture('7'); @@ -791,17 +820,16 @@ describe('RFC-64 durable control-object store v1', () => { it('rejects an escaping durable-file relative key inside the write operation', async () => { const containmentRoot = await temporaryDataDirectory(); - await expect(putRfc64ExactBytesV1({ + const durableFiles = createRfc64DurableFileStoreV1( containmentRoot, + Object.freeze({ boundary: () => {} }), + ); + await expect(durableFiles.putExactBytes({ relativePath: join('..', 'escaped.jcs'), bytes: new TextEncoder().encode('{}'), maxBytes: 16, label: 'test control object', kind: 'object', - io: Object.freeze({ - boundary: () => {}, - randomSuffix: () => '00'.repeat(16), - }), })).rejects.toMatchObject({ code: 'unsafe-path' }); }); @@ -815,22 +843,21 @@ describe('RFC-64 durable control-object store v1', () => { const relativePath = join('race', 'immutable.jcs'); const firstBytes = new TextEncoder().encode('{"writer":"first"}'); const secondBytes = new TextEncoder().encode('{"writer":"second"}'); - const write = (bytes: Uint8Array, suffixByte: string) => putRfc64ExactBytesV1({ + const durableFiles = createRfc64DurableFileStoreV1( containmentRoot, + Object.freeze({ boundary: () => {} }), + ); + const write = (bytes: Uint8Array) => durableFiles.putExactBytes({ relativePath, bytes, maxBytes: 1024, label: 'racing immutable fixture', kind: 'object' as const, - io: Object.freeze({ - boundary: () => {}, - randomSuffix: () => suffixByte.repeat(32), - }), }); const outcomes = await Promise.allSettled([ - write(firstBytes, 'a'), - write(secondBytes, 'b'), + write(firstBytes), + write(secondBytes), ]); expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1); const rejected = outcomes.find((outcome) => outcome.status === 'rejected'); diff --git a/packages/agent/test/rfc64-persistence-owner.typecheck.ts b/packages/agent/test/rfc64-persistence-owner.typecheck.ts new file mode 100644 index 0000000000..335d69cc31 --- /dev/null +++ b/packages/agent/test/rfc64-persistence-owner.typecheck.ts @@ -0,0 +1,9 @@ +import type { Rfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; + +declare const persistence: Rfc64PersistenceV1; + +// The owner exposes operational views, never independently closable resources. +// @ts-expect-error inventory lifecycle belongs exclusively to persistence +persistence.inventory.close(); +// @ts-expect-error control-store lifecycle belongs exclusively to persistence +await persistence.controlObjects.close(); diff --git a/packages/agent/test/support/rfc64-control-object-store-test-support.ts b/packages/agent/test/support/rfc64-control-object-store-test-support.ts index e623d011f1..0e237e7b40 100644 --- a/packages/agent/test/support/rfc64-control-object-store-test-support.ts +++ b/packages/agent/test/support/rfc64-control-object-store-test-support.ts @@ -1,5 +1,3 @@ -import { randomBytes } from 'node:crypto'; - import { Rfc64ControlObjectStoreErrorV1, openRfc64ControlObjectStoreForTestV1, @@ -7,11 +5,10 @@ import { type Rfc64ControlObjectStoreV1, } from '../../src/rfc64/control-object-store-v1-internal.js'; -export interface Rfc64ControlObjectStoreTestIoV1 { +export interface Rfc64ControlObjectStoreTestLifecycleV1 { readonly boundary?: ( boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1, ) => void | Promise; - readonly randomSuffix?: () => string; } export type Rfc64ControlObjectStoreTestOpenerV1 = ( @@ -19,7 +16,7 @@ export type Rfc64ControlObjectStoreTestOpenerV1 = ( ) => Promise; export function createRfc64ControlObjectStoreTestOpenerV1( - testIo: Rfc64ControlObjectStoreTestIoV1 = {}, + testLifecycle: Rfc64ControlObjectStoreTestLifecycleV1 = {}, ): Rfc64ControlObjectStoreTestOpenerV1 { if (process.env.NODE_ENV !== 'test') { throw new Rfc64ControlObjectStoreErrorV1( @@ -27,16 +24,14 @@ export function createRfc64ControlObjectStoreTestOpenerV1( 'control store test opener is available only under NODE_ENV=test', ); } - const boundary = testIo.boundary; - const randomSuffix = testIo.randomSuffix; - const io = Object.freeze({ + const boundary = testLifecycle.boundary; + const lifecycle = Object.freeze({ boundary: ( value: Rfc64ControlObjectStoreDurabilityBoundaryV1, ): void | Promise => boundary?.(value), - randomSuffix: (): string => randomSuffix?.() ?? randomBytes(16).toString('hex'), }); return async (dataDir: string): Promise => - openRfc64ControlObjectStoreForTestV1(dataDir, io); + openRfc64ControlObjectStoreForTestV1(dataDir, lifecycle); } export type { Rfc64ControlObjectStoreDurabilityBoundaryV1 }; From 9ef9842eb6b362cb4a2f37e8e86516407771aa74 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 15:59:49 +0200 Subject: [PATCH 076/292] fix(agent): centralize RFC-64 persistence boundaries --- packages/agent/package.json | 3 +- .../rfc64/control-object-store-v1-internal.ts | 40 +++++++++-- .../src/rfc64/control-object-store-v1.ts | 1 + .../agent/src/rfc64/durable-file-store-v1.ts | 70 +++++++++++++------ .../agent/src/rfc64/inventory-v1/candidate.ts | 6 ++ .../agent/src/rfc64/inventory-v1/index.ts | 1 + packages/agent/src/rfc64/inventory-v1/open.ts | 49 ++++++++++--- .../agent/src/rfc64/persistence-layout-v1.ts | 7 ++ packages/agent/src/rfc64/persistence-v1.ts | 55 ++------------- .../src/rfc64/secure-filesystem-policy-v1.ts | 46 ++++++++---- .../rfc64-agent-inventory-lifecycle.test.ts | 45 +++++++++++- .../rfc64-control-object-store-v1.test.ts | 15 ++-- ...fc64-control-store-public-api.typecheck.ts | 5 ++ 13 files changed, 232 insertions(+), 111 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index 1fdc50e003..a146635169 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -12,13 +12,14 @@ } }, "scripts": { - "build": "tsc && pnpm run test:types", + "build": "tsc && pnpm run test:types && pnpm run test:package-root", "benchmark:sync-responder-pages": "node --expose-gc scripts/sync-responder-page-benchmark.cjs", "benchmark:sync-worker": "node scripts/sync-worker-benchmark.cjs", "benchmark:sync-worker-responsiveness": "node scripts/sync-worker-responsiveness-benchmark.cjs", "test": "vitest run", "test:unit": "vitest run --config vitest.unit.config.ts", "test:types": "tsc --project tsconfig.type-tests.json", + "test:package-root": "node --input-type=module -e \"const agent = await import('@origintrail-official/dkg-agent'); if (typeof agent.DKGAgent !== 'function') throw new Error('published package root did not expose DKGAgent')\"", "test:coverage": "vitest run --coverage", "clean": "rm -rf dist tsconfig.tsbuildinfo" }, diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index c8fda0b11e..e04bb0b4a8 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -31,9 +31,13 @@ import { rfc64NamespaceDurabilityV1, type Rfc64NamespaceDurabilityV1, } from './secure-filesystem-policy-v1.js'; +import { + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + resolveRfc64ControlObjectStorePathV1, + resolveRfc64PersistenceRootV1, +} from './persistence-layout-v1.js'; -export const RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH = - 'rfc64-sync/control-objects-v1' as const; +export { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH }; export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; export const RFC64_CONTROL_OBJECT_STORE_FILE_MODE = RFC64_SECURE_FILE_MODE_V1; export const RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS = 16; @@ -46,7 +50,6 @@ export type Rfc64ControlObjectStoreNamespaceDurabilityV1 = Rfc64NamespaceDurabil const OBJECTS_DIRECTORY = 'objects'; const SIGNATURES_DIRECTORY = 'signatures'; -const CONTROL_OBJECT_STORE_DIRECTORY = 'control-objects-v1'; const CANONICAL_FILE_SUFFIX = '.jcs'; export const RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1 = Object.freeze([ @@ -120,7 +123,13 @@ export interface StoredVerifiedControlObjectV1 { readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; } +export type Rfc64ControlObjectOperationsV1 = Pick< + Rfc64ControlObjectStoreV1, + 'namespaceDurability' | 'stageVerifiedObjects' | 'getVerifiedObject' +>; + export interface Rfc64ControlObjectStoreV1 { + readonly operations: Rfc64ControlObjectOperationsV1; readonly rootPath: string; readonly closed: boolean; readonly namespaceDurability: Rfc64ControlObjectStoreNamespaceDurabilityV1; @@ -163,12 +172,14 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { readonly #fileWriteTails = new Map>(); readonly #durableFiles: Rfc64DurableFileStoreV1; readonly namespaceDurability = rfc64NamespaceDurabilityV1(); + readonly operations: Rfc64ControlObjectOperationsV1; constructor( readonly rootPath: string, lifecycle: Rfc64ControlObjectStoreLifecycleV1, ) { this.#durableFiles = createRfc64DurableFileStoreV1(rootPath, lifecycle); + this.operations = createControlObjectOperationsView(this); } get closed(): boolean { @@ -355,6 +366,17 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { } } +function createControlObjectOperationsView( + controlObjectStore: Rfc64ControlObjectStoreV1, +): Rfc64ControlObjectOperationsV1 { + return Object.freeze({ + namespaceDurability: controlObjectStore.namespaceDurability, + stageVerifiedObjects: + controlObjectStore.stageVerifiedObjects.bind(controlObjectStore), + getVerifiedObject: controlObjectStore.getVerifiedObject.bind(controlObjectStore), + }); +} + /** @internal Used only by the package-local test support module. */ export async function openRfc64ControlObjectStoreForTestV1( dataDirInput: string, @@ -367,9 +389,13 @@ export async function openRfc64ControlObjectStoreForTestV1( fail('control-store-input', 'dataDir must be a non-empty path'); } const dataDir = resolve(dataDirInput); - const rfc64RootPath = resolve(dataDir, 'rfc64-sync'); + const rfc64RootPath = resolveRfc64PersistenceRootV1(dataDir); await mapDurableFileErrors(async () => { - await assertRfc64ExistingDirectoryV1(dataDir, 'DKG data directory', false); + await assertRfc64ExistingDirectoryV1( + dataDir, + 'DKG data directory', + { access: 'owner' }, + ); await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, lifecycle); }); return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, lifecycle); @@ -383,12 +409,12 @@ async function openRfc64ControlObjectStoreAtRootV1( fail('control-store-input', 'control object store lifecycle adapter must be immutable'); } const rfc64RootPath = resolve(rfc64RootPathInput); - const rootPath = resolve(rfc64RootPath, CONTROL_OBJECT_STORE_DIRECTORY); + const rootPath = resolveRfc64ControlObjectStorePathV1(rfc64RootPath); await mapDurableFileErrors(async () => { await assertRfc64ExistingDirectoryV1( rfc64RootPath, 'RFC-64 persistence root', - true, + { access: 'owner-only' }, ); await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath, lifecycle); await ensureRfc64SecureDirectoryTreeV1( diff --git a/packages/agent/src/rfc64/control-object-store-v1.ts b/packages/agent/src/rfc64/control-object-store-v1.ts index 2925f79ffa..0c67003fc0 100644 --- a/packages/agent/src/rfc64/control-object-store-v1.ts +++ b/packages/agent/src/rfc64/control-object-store-v1.ts @@ -10,6 +10,7 @@ export { type GetVerifiedControlObjectInputV1, type Rfc64ControlObjectStoreErrorCodeV1, type Rfc64ControlObjectStoreNamespaceDurabilityV1, + type Rfc64ControlObjectOperationsV1, type Rfc64ControlObjectStoreV1, type StageVerifiedControlObjectV1, type StageVerifiedControlObjectsResultV1, diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index 7cb15fb795..42be927e46 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -11,8 +11,19 @@ import { fsyncRfc64DirectoryV1, rfc64RegularFileFsyncOpenFlagsV1, rfc64RegularFileReadOpenFlagsV1, + type Rfc64FilesystemEntryKindV1, } from './secure-filesystem-policy-v1.js'; +type Rfc64ExistingAccessV1 = 'owner' | 'owner-only'; + +interface Rfc64ExistingDirectoryPolicyV1 { + readonly access: Rfc64ExistingAccessV1; +} + +interface Rfc64SecureAccessPolicyV1 extends Rfc64ExistingDirectoryPolicyV1 { + readonly entryKind: Rfc64FilesystemEntryKindV1; +} + export type Rfc64DurableFileErrorCodeV1 = | 'input' | 'unsafe-path' @@ -76,7 +87,7 @@ export function createRfc64DurableFileStoreV1( export async function assertRfc64ExistingDirectoryV1( path: string, label: string, - requireSecureMode: boolean, + policy: Rfc64ExistingDirectoryPolicyV1, ): Promise { try { const entry = await lstat(path); @@ -86,8 +97,7 @@ export async function assertRfc64ExistingDirectoryV1( assertSecureAccess( path, RFC64_SECURE_DIRECTORY_MODE_V1, - true, - requireSecureMode, + { entryKind: 'directory', access: policy.access }, label, ); } catch (cause) { @@ -104,7 +114,7 @@ export async function ensureRfc64SecureDirectoryTreeV1( await walkRfc64ContainedDirectoryTreeV1( target, containmentRoot, - false, + 'owner', async (current) => { let created = false; try { @@ -249,7 +259,11 @@ async function publishRfc64NoReplaceV1( await lifecycle.boundary(`${kind}.temp-unlinked`); await fsyncDirectory(dirname(targetPath)); await lifecycle.boundary(`${kind}.parent-fsynced`); - await assertExistingRegularFile(targetPath, `${label} cache file`, true); + await assertExistingRegularFile( + targetPath, + `${label} cache file`, + { access: 'owner-only' }, + ); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; fail('durability', `failed to publish durable ${label} bytes`, cause); @@ -314,8 +328,7 @@ async function readRfc64OptionalBoundedBytesV1( assertSecureAccess( path, RFC64_SECURE_FILE_MODE_V1, - false, - true, + { entryKind: 'file', access: 'owner-only' }, label, ); return bytes; @@ -335,14 +348,14 @@ async function assertRfc64ExistingSecureDirectoryTreeV1( await walkRfc64ContainedDirectoryTreeV1( target, containmentRoot, - true, + 'owner-only', ); } async function walkRfc64ContainedDirectoryTreeV1( target: string, containmentRoot: string, - requireSecureRoot: boolean, + containmentRootAccess: Rfc64ExistingAccessV1, prepareComponent?: (path: string) => Promise, ): Promise { const resolvedTarget = resolve(target); @@ -358,20 +371,28 @@ async function walkRfc64ContainedDirectoryTreeV1( await assertRfc64ExistingDirectoryV1( resolvedRoot, 'durable store containment root', - relativeTarget.length === 0 || requireSecureRoot, + { + access: relativeTarget.length === 0 + ? 'owner-only' + : containmentRootAccess, + }, ); let current = resolvedRoot; for (const component of relativeTarget.split(sep).filter(Boolean)) { current = join(current, component); await prepareComponent?.(current); - await assertRfc64ExistingDirectoryV1(current, 'durable store directory', true); + await assertRfc64ExistingDirectoryV1( + current, + 'durable store directory', + { access: 'owner-only' }, + ); } } async function assertExistingRegularFile( path: string, label: string, - requireSecureMode: boolean, + policy: Rfc64ExistingDirectoryPolicyV1, ): Promise { try { const entry = await lstat(path); @@ -381,8 +402,7 @@ async function assertExistingRegularFile( assertSecureAccess( path, RFC64_SECURE_FILE_MODE_V1, - false, - requireSecureMode, + { entryKind: 'file', access: policy.access }, label, ); } catch (cause) { @@ -394,7 +414,9 @@ async function assertExistingRegularFile( async function chmodSecure(path: string, mode: number, label: string): Promise { try { const entry = await lstat(path); - applyRfc64OwnerOnlyPermissionsSyncV1(path, mode, entry.isDirectory()); + applyRfc64OwnerOnlyPermissionsSyncV1(path, mode, { + entryKind: entry.isDirectory() ? 'directory' : 'file', + }); } catch (cause) { if (cause instanceof Rfc64DurableFileErrorV1) throw cause; fail('unsafe-path', `failed to secure ${label}`, cause); @@ -404,18 +426,23 @@ async function chmodSecure(path: string, mode: number, label: string): Promise { assertSecureAccess( path, RFC64_SECURE_FILE_MODE_V1, - false, - true, + { entryKind: 'file', access: 'owner-only' }, label, ); await handle.sync(); diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index 9a68f15329..97129e9103 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -244,6 +244,12 @@ export interface Rfc64InventoryV1CandidateApi { deleteCandidateBucket(loadKey: CandidateBucketLoadKeyV1): void; } +/** Candidate operations safe to share without inventory lifecycle ownership. */ +export type Rfc64InventoryV1OperationsV1 = Omit< + Rfc64InventoryV1CandidateApi, + 'purgeNextStartupStaleCandidateBatch' +>; + interface SessionContextV1 { readonly scopeHex: string; readonly authorHex: string; diff --git a/packages/agent/src/rfc64/inventory-v1/index.ts b/packages/agent/src/rfc64/inventory-v1/index.ts index 7a0eac383f..e8f012cbb0 100644 --- a/packages/agent/src/rfc64/inventory-v1/index.ts +++ b/packages/agent/src/rfc64/inventory-v1/index.ts @@ -16,6 +16,7 @@ export { type CandidateSessionV1, type InventoryV1CandidateErrorCode, type Rfc64InventoryV1CandidateApi, + type Rfc64InventoryV1OperationsV1, type VerifiedCandidateBucketLoadV1, type VerifiedCandidateCatalogRowV1, } from './candidate.js'; diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index c18908913d..c99067562d 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -55,6 +55,7 @@ import { type CandidateSessionV1, type CandidateSessionGcBatchResultV1, type Rfc64InventoryV1CandidateApi, + type Rfc64InventoryV1OperationsV1, type VerifiedCandidateBucketLoadV1, type VerifiedCandidateCatalogRowV1, } from './candidate.js'; @@ -126,6 +127,7 @@ class InventoryV1TargetCloseError extends InventoryV1OpenError { } export interface Rfc64InventoryV1Foundation extends Rfc64InventoryV1CandidateApi { + readonly operations: Rfc64InventoryV1OperationsV1; readonly databasePath: string; readonly closed: boolean; quarantineAndRebuild(): void; @@ -269,6 +271,7 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { #database: DatabaseSyncV1 | null; #candidate: CandidateInventoryV1; #lease: InventoryV1Lease | null; + readonly operations: Rfc64InventoryV1OperationsV1; constructor( private readonly sqlite: SqliteModuleV1, @@ -280,6 +283,7 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { this.#database = database; this.#candidate = this.createCandidateInventory(database); this.#lease = lease; + this.operations = createInventoryV1OperationsView(this); } get closed(): boolean { @@ -514,6 +518,29 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { } } +function createInventoryV1OperationsView( + inventory: Rfc64InventoryV1CandidateApi, +): Rfc64InventoryV1OperationsV1 { + return Object.freeze({ + createCandidateSession: inventory.createCandidateSession.bind(inventory), + putVerifiedCandidateBucket: inventory.putVerifiedCandidateBucket.bind(inventory), + getCandidateBucket: inventory.getCandidateBucket.bind(inventory), + beginCandidateBucketRows: inventory.beginCandidateBucketRows.bind(inventory), + beginCandidateBucketDiff: inventory.beginCandidateBucketDiff.bind(inventory), + pageCandidateBucketRows: inventory.pageCandidateBucketRows.bind(inventory), + pageCandidateBucketAddedOrChanged: + inventory.pageCandidateBucketAddedOrChanged.bind(inventory), + pageCandidateBucketRemoved: inventory.pageCandidateBucketRemoved.bind(inventory), + readVerifiedCandidateCatalogRow: + inventory.readVerifiedCandidateCatalogRow.bind(inventory), + verifyCandidateCatalogPrecommitV1: + inventory.verifyCandidateCatalogPrecommitV1.bind(inventory), + closeCandidateTraversal: inventory.closeCandidateTraversal.bind(inventory), + discardCandidateSessionBatch: inventory.discardCandidateSessionBatch.bind(inventory), + deleteCandidateBucket: inventory.deleteCandidateBucket.bind(inventory), + }); +} + function reopenVerifiedOwnedDatabase( sqlite: SqliteModuleV1, databasePath: string, @@ -638,7 +665,7 @@ async function acquireInventoryLease( await waitForCommittedRacingLease(path); } } - applySecurePermissions(path, INVENTORY_V1_FILE_MODE, false); + applySecurePermissions(path, INVENTORY_V1_FILE_MODE, 'file'); let database: DatabaseSyncV1 | null = null; let transactionOpen = false; @@ -1328,7 +1355,7 @@ function prepareSecureDirectory(dataDirectory: string, directoryPath: string): v } currentDirectory = nextDirectory; } - applySecurePermissions(directoryPath, INVENTORY_V1_DIRECTORY_MODE, true); + applySecurePermissions(directoryPath, INVENTORY_V1_DIRECTORY_MODE, 'directory'); } function preflightExistingRecoveryMarker( @@ -1369,11 +1396,11 @@ function preflightExistingRecoveryMarker( function createSecureEmptyFile(databasePath: string): void { const descriptor = openSync(databasePath, 'wx', INVENTORY_V1_FILE_MODE); try { fsyncSync(descriptor); } finally { closeSync(descriptor); } - applySecurePermissions(databasePath, INVENTORY_V1_FILE_MODE, false); + applySecurePermissions(databasePath, INVENTORY_V1_FILE_MODE, 'file'); } function tightenOwnedFileMode(databasePath: string): void { - applySecurePermissions(databasePath, INVENTORY_V1_FILE_MODE, false); + applySecurePermissions(databasePath, INVENTORY_V1_FILE_MODE, 'file'); } function assertFilesystemOwner(path: string): void { @@ -1397,12 +1424,16 @@ function assertOwnedUnitOwners(databasePath: string): void { } } -function applySecurePermissions(path: string, mode: number, directory: boolean): void { +function applySecurePermissions( + path: string, + mode: number, + entryKind: 'file' | 'directory', +): void { // Never let chmod or Set-Acl turn a foreign existing entry into an owned one. // Newly created entries are also checked because they must already be owned // by this process before their permissions are tightened. try { - applyRfc64OwnerOnlyPermissionsSyncV1(path, mode, directory); + applyRfc64OwnerOnlyPermissionsSyncV1(path, mode, { entryKind }); } catch (cause) { throw new InventoryV1OpenError('database-io', `failed to set secure permissions on ${path}`, { cause }); } @@ -1569,14 +1600,14 @@ function beginQuarantine( mkdirSync(quarantineRoot, { mode: INVENTORY_V1_DIRECTORY_MODE }); } assertOwnedDirectory(quarantineRoot, 'inventory quarantine directory'); - applySecurePermissions(quarantineRoot, INVENTORY_V1_DIRECTORY_MODE, true); + applySecurePermissions(quarantineRoot, INVENTORY_V1_DIRECTORY_MODE, 'directory'); fsyncDirectory(inventoryDirectory); lifecycle.boundary('begin.inventory-directory.fsync-after-quarantine-root'); const suffix = `${Date.now()}-${randomBytes(8).toString('hex')}`; const quarantineDirectory = join(quarantineRoot, `inventory-v1-${suffix}`); mkdirSync(quarantineDirectory, { mode: INVENTORY_V1_DIRECTORY_MODE }); assertOwnedDirectory(quarantineDirectory, 'inventory quarantine generation'); - applySecurePermissions(quarantineDirectory, INVENTORY_V1_DIRECTORY_MODE, true); + applySecurePermissions(quarantineDirectory, INVENTORY_V1_DIRECTORY_MODE, 'directory'); assertSameFilesystem(inventoryDirectory, quarantineDirectory, 'inventory quarantine generation'); for (const member of members) { const source = recoverySourcePath(databasePath, member); @@ -1595,7 +1626,7 @@ function beginQuarantine( const marker: RecoveryMarkerV1 = { version: 1, quarantineDirectory, members }; writeFileSync(markerPath, JSON.stringify(marker), { encoding: 'utf8', flag: 'wx', mode: INVENTORY_V1_FILE_MODE }); lifecycle.boundary('begin.marker.write'); - applySecurePermissions(markerPath, INVENTORY_V1_FILE_MODE, false); + applySecurePermissions(markerPath, INVENTORY_V1_FILE_MODE, 'file'); fsyncRegularFile(markerPath, 'inventory recovery marker'); lifecycle.boundary('begin.marker.file-fsync'); fsyncDirectory(inventoryDirectory); diff --git a/packages/agent/src/rfc64/persistence-layout-v1.ts b/packages/agent/src/rfc64/persistence-layout-v1.ts index ef70891375..2d46697669 100644 --- a/packages/agent/src/rfc64/persistence-layout-v1.ts +++ b/packages/agent/src/rfc64/persistence-layout-v1.ts @@ -2,6 +2,9 @@ import { resolve } from 'node:path'; export const RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1 = 'rfc64-sync' as const; export const RFC64_INVENTORY_DATABASE_FILENAME_V1 = 'inventory-v1.sqlite3' as const; +export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_NAME_V1 = 'control-objects-v1' as const; +export const RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH = + `${RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1}/${RFC64_CONTROL_OBJECT_STORE_DIRECTORY_NAME_V1}` as const; /** Canonical boundary shared by every resource protected by the RFC-64 lease. */ export function resolveRfc64PersistenceRootV1(dataDir: string): string { @@ -14,3 +17,7 @@ export function resolveRfc64InventoryDatabasePathV1(dataDir: string): string { RFC64_INVENTORY_DATABASE_FILENAME_V1, ); } + +export function resolveRfc64ControlObjectStorePathV1(rfc64RootPath: string): string { + return resolve(rfc64RootPath, RFC64_CONTROL_OBJECT_STORE_DIRECTORY_NAME_V1); +} diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index 7a0f855e34..29e15b4b72 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -1,9 +1,10 @@ import { openInventoryV1, - type Rfc64InventoryV1CandidateApi, type Rfc64InventoryV1Foundation, + type Rfc64InventoryV1OperationsV1, } from './inventory-v1/index.js'; import { + type Rfc64ControlObjectOperationsV1, type Rfc64ControlObjectStoreV1, } from './control-object-store-v1.js'; import { openRfc64ControlObjectStoreAtOwnedRootV1 } from './control-object-store-v1-internal.js'; @@ -15,20 +16,10 @@ export interface OpenRfc64PersistenceOptionsV1 { } /** One lifecycle owner for every RFC-64 resource protected by the inventory lease. */ -export type Rfc64InventoryOperationsV1 = Omit< - Rfc64InventoryV1CandidateApi, - 'purgeNextStartupStaleCandidateBatch' ->; - -export type Rfc64ControlObjectOperationsV1 = Pick< - Rfc64ControlObjectStoreV1, - 'namespaceDurability' | 'stageVerifiedObjects' | 'getVerifiedObject' ->; - export interface Rfc64PersistenceV1 { readonly rootPath: string; /** Non-owning inventory operations; lifecycle methods remain private to this owner. */ - readonly inventory: Rfc64InventoryOperationsV1; + readonly inventory: Rfc64InventoryV1OperationsV1; /** Non-owning cache operations; lifecycle methods remain private to this owner. */ readonly controlObjects: Rfc64ControlObjectOperationsV1; readonly closed: boolean; @@ -41,7 +32,7 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { #closePromise: Promise | null = null; readonly #ownedInventory: Rfc64InventoryV1Foundation; readonly #ownedControlObjectStore: Rfc64ControlObjectStoreV1; - readonly inventory: Rfc64InventoryOperationsV1; + readonly inventory: Rfc64InventoryV1OperationsV1; readonly controlObjects: Rfc64ControlObjectOperationsV1; constructor( @@ -51,8 +42,8 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { ) { this.#ownedInventory = ownedInventory; this.#ownedControlObjectStore = ownedControlObjectStore; - this.inventory = createInventoryOperationsView(ownedInventory); - this.controlObjects = createControlObjectOperationsView(ownedControlObjectStore); + this.inventory = ownedInventory.operations; + this.controlObjects = ownedControlObjectStore.operations; } get closed(): boolean { @@ -122,37 +113,3 @@ export async function openRfc64PersistenceV1( throw cause; } } - -function createInventoryOperationsView( - inventory: Rfc64InventoryV1Foundation, -): Rfc64InventoryOperationsV1 { - return Object.freeze({ - createCandidateSession: inventory.createCandidateSession.bind(inventory), - putVerifiedCandidateBucket: inventory.putVerifiedCandidateBucket.bind(inventory), - getCandidateBucket: inventory.getCandidateBucket.bind(inventory), - beginCandidateBucketRows: inventory.beginCandidateBucketRows.bind(inventory), - beginCandidateBucketDiff: inventory.beginCandidateBucketDiff.bind(inventory), - pageCandidateBucketRows: inventory.pageCandidateBucketRows.bind(inventory), - pageCandidateBucketAddedOrChanged: - inventory.pageCandidateBucketAddedOrChanged.bind(inventory), - pageCandidateBucketRemoved: inventory.pageCandidateBucketRemoved.bind(inventory), - readVerifiedCandidateCatalogRow: - inventory.readVerifiedCandidateCatalogRow.bind(inventory), - verifyCandidateCatalogPrecommitV1: - inventory.verifyCandidateCatalogPrecommitV1.bind(inventory), - closeCandidateTraversal: inventory.closeCandidateTraversal.bind(inventory), - discardCandidateSessionBatch: inventory.discardCandidateSessionBatch.bind(inventory), - deleteCandidateBucket: inventory.deleteCandidateBucket.bind(inventory), - }); -} - -function createControlObjectOperationsView( - controlObjectStore: Rfc64ControlObjectStoreV1, -): Rfc64ControlObjectOperationsV1 { - return Object.freeze({ - namespaceDurability: controlObjectStore.namespaceDurability, - stageVerifiedObjects: - controlObjectStore.stageVerifiedObjects.bind(controlObjectStore), - getVerifiedObject: controlObjectStore.getVerifiedObject.bind(controlObjectStore), - }); -} diff --git a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts index 75afb11a55..026018b031 100644 --- a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts +++ b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts @@ -20,6 +20,12 @@ export type Rfc64NamespaceDurabilityV1 = | typeof RFC64_POSIX_NAMESPACE_DURABILITY_V1 | typeof RFC64_WINDOWS_NAMESPACE_DURABILITY_V1; +export type Rfc64FilesystemEntryKindV1 = 'file' | 'directory'; + +export interface Rfc64FilesystemEntryPolicyV1 { + readonly entryKind: Rfc64FilesystemEntryKindV1; +} + export function rfc64UsesWindowsFilesystemPolicyV1(): boolean { return process.platform === 'win32'; } @@ -42,7 +48,10 @@ export function rfc64PosixModeMatchesV1(mode: number, expected: number): boolean export function assertRfc64FilesystemOwnerSyncV1(path: string): void { if (rfc64UsesWindowsFilesystemPolicyV1()) { - assertWindowsFilesystemOwnerSync(path, statSync(path).isDirectory()); + assertWindowsFilesystemOwnerSync( + path, + statSync(path).isDirectory() ? 'directory' : 'file', + ); return; } if (!rfc64CurrentUserOwnsUidV1(statSync(path).uid)) { @@ -53,10 +62,10 @@ export function assertRfc64FilesystemOwnerSyncV1(path: string): void { export function assertRfc64OwnerOnlyPermissionsSyncV1( path: string, expectedMode: number, - directory: boolean, + policy: Rfc64FilesystemEntryPolicyV1, ): void { if (rfc64UsesWindowsFilesystemPolicyV1()) { - assertWindowsOwnerOnlyPermissionsSync(path, directory); + assertWindowsOwnerOnlyPermissionsSync(path, policy.entryKind); return; } const stat = statSync(path); @@ -73,15 +82,15 @@ export function assertRfc64OwnerOnlyPermissionsSyncV1( export function applyRfc64OwnerOnlyPermissionsSyncV1( path: string, mode: number, - directory: boolean, + policy: Rfc64FilesystemEntryPolicyV1, ): void { if (rfc64UsesWindowsFilesystemPolicyV1()) { - applyWindowsOwnerOnlyPermissionsSync(path, directory); + applyWindowsOwnerOnlyPermissionsSync(path, policy.entryKind); return; } assertRfc64FilesystemOwnerSyncV1(path); chmodSync(path, mode); - assertRfc64OwnerOnlyPermissionsSyncV1(path, mode, directory); + assertRfc64OwnerOnlyPermissionsSyncV1(path, mode, policy); } /** Node cannot FlushFileBuffers on a Windows directory handle. */ @@ -180,10 +189,13 @@ function Assert-OwnerOnly($acl) { } `; -function assertWindowsFilesystemOwnerSync(path: string, directory: boolean): void { +function assertWindowsFilesystemOwnerSync( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): void { runWindowsAclOperation( path, - directory, + entryKind, 'owner assertion', `${WINDOWS_ACL_POWERSHELL_PRELUDE} $acl = Read-TargetAcl @@ -191,10 +203,13 @@ Assert-CurrentOwner $acl`, ); } -function assertWindowsOwnerOnlyPermissionsSync(path: string, directory: boolean): void { +function assertWindowsOwnerOnlyPermissionsSync( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): void { runWindowsAclOperation( path, - directory, + entryKind, 'owner-only assertion', `${WINDOWS_ACL_POWERSHELL_PRELUDE} ${WINDOWS_OWNER_ONLY_ASSERTION} @@ -204,10 +219,13 @@ Assert-OwnerOnly $acl`, ); } -function applyWindowsOwnerOnlyPermissionsSync(path: string, directory: boolean): void { +function applyWindowsOwnerOnlyPermissionsSync( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): void { runWindowsAclOperation( path, - directory, + entryKind, 'owner-only application', `${WINDOWS_ACL_POWERSHELL_PRELUDE} ${WINDOWS_OWNER_ONLY_ASSERTION} @@ -246,7 +264,7 @@ Assert-OwnerOnly $acl`, function runWindowsAclOperation( path: string, - directory: boolean, + entryKind: Rfc64FilesystemEntryKindV1, operation: string, script: string, ): void { @@ -258,7 +276,7 @@ function runWindowsAclOperation( windowsHide: true, env: { ...process.env, - DKG_RFC64_ACL_DIRECTORY: String(directory), + DKG_RFC64_ACL_DIRECTORY: String(entryKind === 'directory'), DKG_RFC64_ACL_PATH: path, }, }, diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index eaaed1da9a..aff5033889 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -365,9 +365,10 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { }, ); - it('releases an acquired inventory when node startup fails', async () => { + it('awaits asynchronous persistence cleanup before rejecting node startup', async () => { const failure = new Error('node start failed'); - const close = vi.fn(); + const cleanup = deferred(); + const close = vi.fn(() => cleanup.promise); const agent = syntheticAgent(); Object.assign(agent, { started: false, @@ -379,7 +380,45 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { log: { info: vi.fn(), warn: vi.fn() }, }); - await expect(agent.start()).rejects.toBe(failure); + const start = agent.start(); + let settled = false; + void start.then( + () => { settled = true; }, + () => { settled = true; }, + ); + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + await Promise.resolve(); + expect(settled).toBe(false); + expect(agent.rfc64PersistenceV1).toBeUndefined(); + + cleanup.resolve(); + await expect(start).rejects.toBe(failure); + expect(close).toHaveBeenCalledOnce(); + expect(agent.rfc64PersistenceV1).toBeUndefined(); + expect(agent.started).toBe(false); + }); + + it('aggregates asynchronous persistence cleanup failure with node startup failure', async () => { + const startupFailure = new Error('node start failed'); + const cleanupFailure = new Error('persistence cleanup failed'); + const close = vi.fn(async () => { throw cleanupFailure; }); + const agent = syntheticAgent(); + Object.assign(agent, { + started: false, + coreHostRecordingGeneration: 0, + prepareRfc64PersistenceV1: vi.fn(async () => { + agent.rfc64PersistenceV1 = { close }; + }), + node: { start: vi.fn(async () => { throw startupFailure; }) }, + log: { info: vi.fn(), warn: vi.fn() }, + }); + + const rejection = await agent.start().catch((cause: unknown) => cause); + expect(rejection).toBeInstanceOf(AggregateError); + expect((rejection as AggregateError).errors).toEqual([ + startupFailure, + cleanupFailure, + ]); expect(close).toHaveBeenCalledOnce(); expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(agent.started).toBe(false); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 6707916f63..2c0539ec2a 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -62,8 +62,11 @@ async function temporaryDataDirectory(): Promise { return path; } -function grantWindowsEveryoneRead(path: string, directory: boolean): void { - const permission = directory +function grantWindowsEveryoneRead( + path: string, + entryKind: 'file' | 'directory', +): void { + const permission = entryKind === 'directory' ? '*S-1-1-0:(OI)(CI)(RX)' : '*S-1-1-0:(R)'; const result = spawnSync( @@ -493,7 +496,7 @@ describe('RFC-64 durable control-object store v1', () => { applyRfc64OwnerOnlyPermissionsSyncV1( wrongPath, RFC64_CONTROL_OBJECT_STORE_FILE_MODE, - false, + { entryKind: 'file' }, ); const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); @@ -759,7 +762,7 @@ describe('RFC-64 durable control-object store v1', () => { 'objects', ); await directoryStore.close(); - grantWindowsEveryoneRead(objectsDirectory, true); + grantWindowsEveryoneRead(objectsDirectory, 'directory'); await expect(openRfc64ControlObjectStoreV1(directoryDataDir)) .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); @@ -768,7 +771,7 @@ describe('RFC-64 durable control-object store v1', () => { const fixture = await signedFixture('8c'); await fileStore.stageVerifiedObjects([fixture]); const paths = pathsFor(fileDataDir, fixture.envelope); - grantWindowsEveryoneRead(paths.object, false); + grantWindowsEveryoneRead(paths.object, 'file'); await expect(fileStore.getVerifiedObject({ objectDigest: fixture.envelope.objectDigest as Digest32V1, signatureVariantDigest: paths.signatureDigest, @@ -838,7 +841,7 @@ describe('RFC-64 durable control-object store v1', () => { applyRfc64OwnerOnlyPermissionsSyncV1( containmentRoot, RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, - true, + { entryKind: 'directory' }, ); const relativePath = join('race', 'immutable.jcs'); const firstBytes = new TextEncoder().encode('{"writer":"first"}'); diff --git a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts index 122d48c75d..c7e8936d81 100644 --- a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts +++ b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts @@ -1,5 +1,6 @@ import * as packageRoot from '../src/index.js'; import * as productionControlStoreModule from '../src/rfc64/control-object-store-v1.js'; +import { DKGAgent as PublishedDkgAgent } from '@origintrail-official/dkg-agent'; type PackageRootHasRawControlStoreOpener = 'openRfc64ControlObjectStoreV1' extends keyof typeof packageRoot ? true : false; @@ -14,6 +15,8 @@ type ProductionModuleHasRawControlStoreOpener = type ProductionModuleHasTestOpener = 'createRfc64ControlObjectStoreTestOpenerV1' extends keyof typeof productionControlStoreModule ? true : false; +type PublishedPackageRootHasDkgAgent = + 'create' extends keyof typeof PublishedDkgAgent ? true : false; // The stable package root intentionally withholds the implementation-shaped // cache API until an RFC-64 public workflow consumes it. @@ -21,6 +24,7 @@ const packageRootHasRawControlStoreOpener: PackageRootHasRawControlStoreOpener = const packageRootHasControlStoreLayout: PackageRootHasControlStoreLayout = false; const productionModuleHasRawControlStoreOpener: ProductionModuleHasRawControlStoreOpener = false; const productionModuleHasTestOpener: ProductionModuleHasTestOpener = false; +const publishedPackageRootHasDkgAgent: PublishedPackageRootHasDkgAgent = true; // @ts-expect-error Low-level store types are not part of the stable package root. type PackageRootStoreType = packageRoot.Rfc64ControlObjectStoreV1; @@ -32,5 +36,6 @@ void packageRootHasRawControlStoreOpener; void packageRootHasControlStoreLayout; void productionModuleHasRawControlStoreOpener; void productionModuleHasTestOpener; +void publishedPackageRootHasDkgAgent; void (undefined as PackageRootStoreType | undefined); void (undefined as PublishedInternalControlStoreModule | undefined); From d3183e06a4d32406f804f628dfe0bad6ba323311 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 16:13:08 +0200 Subject: [PATCH 077/292] fix(agent): centralize RFC-64 operation views --- .../rfc64/control-object-store-v1-internal.ts | 14 ------- packages/agent/src/rfc64/inventory-v1/open.ts | 27 ------------- packages/agent/src/rfc64/persistence-v1.ts | 40 ++++++++++++++++++- .../rfc64-control-object-store-v1.test.ts | 1 + 4 files changed, 39 insertions(+), 43 deletions(-) diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index e04bb0b4a8..63d428050e 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -129,7 +129,6 @@ export type Rfc64ControlObjectOperationsV1 = Pick< >; export interface Rfc64ControlObjectStoreV1 { - readonly operations: Rfc64ControlObjectOperationsV1; readonly rootPath: string; readonly closed: boolean; readonly namespaceDurability: Rfc64ControlObjectStoreNamespaceDurabilityV1; @@ -172,14 +171,12 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { readonly #fileWriteTails = new Map>(); readonly #durableFiles: Rfc64DurableFileStoreV1; readonly namespaceDurability = rfc64NamespaceDurabilityV1(); - readonly operations: Rfc64ControlObjectOperationsV1; constructor( readonly rootPath: string, lifecycle: Rfc64ControlObjectStoreLifecycleV1, ) { this.#durableFiles = createRfc64DurableFileStoreV1(rootPath, lifecycle); - this.operations = createControlObjectOperationsView(this); } get closed(): boolean { @@ -366,17 +363,6 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { } } -function createControlObjectOperationsView( - controlObjectStore: Rfc64ControlObjectStoreV1, -): Rfc64ControlObjectOperationsV1 { - return Object.freeze({ - namespaceDurability: controlObjectStore.namespaceDurability, - stageVerifiedObjects: - controlObjectStore.stageVerifiedObjects.bind(controlObjectStore), - getVerifiedObject: controlObjectStore.getVerifiedObject.bind(controlObjectStore), - }); -} - /** @internal Used only by the package-local test support module. */ export async function openRfc64ControlObjectStoreForTestV1( dataDirInput: string, diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index c99067562d..8106e8afd0 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -55,7 +55,6 @@ import { type CandidateSessionV1, type CandidateSessionGcBatchResultV1, type Rfc64InventoryV1CandidateApi, - type Rfc64InventoryV1OperationsV1, type VerifiedCandidateBucketLoadV1, type VerifiedCandidateCatalogRowV1, } from './candidate.js'; @@ -127,7 +126,6 @@ class InventoryV1TargetCloseError extends InventoryV1OpenError { } export interface Rfc64InventoryV1Foundation extends Rfc64InventoryV1CandidateApi { - readonly operations: Rfc64InventoryV1OperationsV1; readonly databasePath: string; readonly closed: boolean; quarantineAndRebuild(): void; @@ -271,7 +269,6 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { #database: DatabaseSyncV1 | null; #candidate: CandidateInventoryV1; #lease: InventoryV1Lease | null; - readonly operations: Rfc64InventoryV1OperationsV1; constructor( private readonly sqlite: SqliteModuleV1, @@ -283,7 +280,6 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { this.#database = database; this.#candidate = this.createCandidateInventory(database); this.#lease = lease; - this.operations = createInventoryV1OperationsView(this); } get closed(): boolean { @@ -518,29 +514,6 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { } } -function createInventoryV1OperationsView( - inventory: Rfc64InventoryV1CandidateApi, -): Rfc64InventoryV1OperationsV1 { - return Object.freeze({ - createCandidateSession: inventory.createCandidateSession.bind(inventory), - putVerifiedCandidateBucket: inventory.putVerifiedCandidateBucket.bind(inventory), - getCandidateBucket: inventory.getCandidateBucket.bind(inventory), - beginCandidateBucketRows: inventory.beginCandidateBucketRows.bind(inventory), - beginCandidateBucketDiff: inventory.beginCandidateBucketDiff.bind(inventory), - pageCandidateBucketRows: inventory.pageCandidateBucketRows.bind(inventory), - pageCandidateBucketAddedOrChanged: - inventory.pageCandidateBucketAddedOrChanged.bind(inventory), - pageCandidateBucketRemoved: inventory.pageCandidateBucketRemoved.bind(inventory), - readVerifiedCandidateCatalogRow: - inventory.readVerifiedCandidateCatalogRow.bind(inventory), - verifyCandidateCatalogPrecommitV1: - inventory.verifyCandidateCatalogPrecommitV1.bind(inventory), - closeCandidateTraversal: inventory.closeCandidateTraversal.bind(inventory), - discardCandidateSessionBatch: inventory.discardCandidateSessionBatch.bind(inventory), - deleteCandidateBucket: inventory.deleteCandidateBucket.bind(inventory), - }); -} - function reopenVerifiedOwnedDatabase( sqlite: SqliteModuleV1, databasePath: string, diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index 29e15b4b72..8a9d30d5f6 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -1,5 +1,6 @@ import { openInventoryV1, + type Rfc64InventoryV1CandidateApi, type Rfc64InventoryV1Foundation, type Rfc64InventoryV1OperationsV1, } from './inventory-v1/index.js'; @@ -42,8 +43,8 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { ) { this.#ownedInventory = ownedInventory; this.#ownedControlObjectStore = ownedControlObjectStore; - this.inventory = ownedInventory.operations; - this.controlObjects = ownedControlObjectStore.operations; + this.inventory = createInventoryOperationsView(ownedInventory); + this.controlObjects = createControlObjectOperationsView(ownedControlObjectStore); } get closed(): boolean { @@ -76,6 +77,41 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { } } +/** The aggregate owner is the sole boundary that strips child lifecycle authority. */ +function createInventoryOperationsView( + inventory: Rfc64InventoryV1CandidateApi, +): Rfc64InventoryV1OperationsV1 { + return Object.freeze({ + createCandidateSession: inventory.createCandidateSession.bind(inventory), + putVerifiedCandidateBucket: inventory.putVerifiedCandidateBucket.bind(inventory), + getCandidateBucket: inventory.getCandidateBucket.bind(inventory), + beginCandidateBucketRows: inventory.beginCandidateBucketRows.bind(inventory), + beginCandidateBucketDiff: inventory.beginCandidateBucketDiff.bind(inventory), + pageCandidateBucketRows: inventory.pageCandidateBucketRows.bind(inventory), + pageCandidateBucketAddedOrChanged: + inventory.pageCandidateBucketAddedOrChanged.bind(inventory), + pageCandidateBucketRemoved: inventory.pageCandidateBucketRemoved.bind(inventory), + readVerifiedCandidateCatalogRow: + inventory.readVerifiedCandidateCatalogRow.bind(inventory), + verifyCandidateCatalogPrecommitV1: + inventory.verifyCandidateCatalogPrecommitV1.bind(inventory), + closeCandidateTraversal: inventory.closeCandidateTraversal.bind(inventory), + discardCandidateSessionBatch: inventory.discardCandidateSessionBatch.bind(inventory), + deleteCandidateBucket: inventory.deleteCandidateBucket.bind(inventory), + }); +} + +function createControlObjectOperationsView( + controlObjectStore: Rfc64ControlObjectStoreV1, +): Rfc64ControlObjectOperationsV1 { + return Object.freeze({ + namespaceDurability: controlObjectStore.namespaceDurability, + stageVerifiedObjects: + controlObjectStore.stageVerifiedObjects.bind(controlObjectStore), + getVerifiedObject: controlObjectStore.getVerifiedObject.bind(controlObjectStore), + }); +} + /** * Acquire the inventory lease, finish bounded stale-candidate cleanup, then * open the immutable control-object cache under that same ownership boundary. diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 2c0539ec2a..194a146c07 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -190,6 +190,7 @@ describe('RFC-64 durable control-object store v1', () => { const fixture = await signedFixture('1'); const result = await store.stageVerifiedObjects([fixture]); + expect(store).not.toHaveProperty('operations'); expect(result).toEqual({ durable: true, namespaceDurability: process.platform === 'win32' From 3bd6ae383baaaba9559df83fe92a7059bc86b2ac Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 16:23:30 +0200 Subject: [PATCH 078/292] fix(agent): drain failed RFC-64 batches --- .../rfc64/control-object-store-v1-internal.ts | 32 +++++++++++---- .../rfc64-control-object-store-v1.test.ts | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index 63d428050e..7917111a61 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -189,13 +189,13 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { this.requireOpen(); const prepared = prepareStageBatch(input); const operation = (async () => { - const result = await Promise.all(prepared.map(async (item) => { + const result = await settleAllOrThrowV1(prepared.map(async (item) => { await this.stagePrepared(item); return Object.freeze({ objectDigest: item.objectDigest, signatureVariantDigest: item.signatureVariantDigest, }); - })); + }), 'RFC-64 control-object batch staging failed'); return Object.freeze({ durable: true as const, namespaceDurability: this.namespaceDurability, @@ -224,19 +224,20 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { objectDigest, signatureVariantDigest, ); - const [unsignedBytes, variantBytes] = await mapDurableFileErrors(async () => - Promise.all([ + const [unsignedBytes, variantBytes] = await settleAllOrThrowV1([ + mapDurableFileErrors(async () => this.#durableFiles.readOptionalBoundedBytes({ relativePath: objectPath, maxBytes: MAX_CONTROL_OBJECT_BYTES, label: 'control object', - }), + })), + mapDurableFileErrors(async () => this.#durableFiles.readOptionalBoundedBytes({ relativePath: signaturePath, maxBytes: MAX_CONTROL_SIGNATURE_VARIANT_BYTES, label: 'control signature variant', - }), - ])); + })), + ], 'RFC-64 control-object cache reads failed'); if (unsignedBytes === null || variantBytes === null) return null; let envelope: SignedControlEnvelopeV1; @@ -363,6 +364,23 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { } } +async function settleAllOrThrowV1( + operations: readonly Promise[], + aggregateMessage: string, +): Promise { + const outcomes = await Promise.allSettled(operations); + const failures = outcomes.flatMap((outcome) => + outcome.status === 'rejected' ? [outcome.reason] : []); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, aggregateMessage); + return outcomes.map((outcome) => { + if (outcome.status === 'rejected') { + throw new TypeError('settled RFC-64 operation lost its recorded failure'); + } + return outcome.value; + }); +} + /** @internal Used only by the package-local test support module. */ export async function openRfc64ControlObjectStoreForTestV1( dataDirInput: string, diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 194a146c07..bb37c2d28f 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -605,6 +605,47 @@ describe('RFC-64 durable control-object store v1', () => { await expect(store.close()).resolves.toBeUndefined(); }); + it('drains every sibling write in a failed batch before close resolves', async () => { + const dataDir = await temporaryDataDirectory(); + const entered = deferred(); + const release = deferred(); + let pauseEnabled = false; + let paused = false; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: async (boundary) => { + if (pauseEnabled && boundary === 'object.temp-written' && !paused) { + paused = true; + entered.resolve(); + await release.promise; + } + }, + })(dataDir); + const corrupt = await signedFixture('failed-batch-corrupt'); + const slow = await signedFixture('failed-batch-slow'); + await store.stageVerifiedObjects([corrupt]); + await writeFile(pathsFor(dataDir, corrupt.envelope).object, '{}'); + pauseEnabled = true; + + let stageSettled = false; + const stage = store.stageVerifiedObjects([corrupt, slow]); + void stage.finally(() => { stageSettled = true; }).catch(() => undefined); + await entered.promise; + await new Promise((resolve) => { setImmediate(resolve); }); + expect(stageSettled).toBe(false); + + let closeSettled = false; + const close = store.close().then(() => { closeSettled = true; }); + await new Promise((resolve) => { setImmediate(resolve); }); + expect(closeSettled).toBe(false); + + release.resolve(); + await expect(stage).rejects.toMatchObject({ code: 'control-store-corrupt' }); + await expect(close).resolves.toBeUndefined(); + expect(closeSettled).toBe(true); + await expect(readFile(pathsFor(dataDir, slow.envelope).object)).resolves.not.toHaveLength(0); + await expect(readFile(pathsFor(dataDir, slow.envelope).signature)).resolves.not.toHaveLength(0); + }); + it('drains an admitted verified read before close can release ownership', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); From e02499f1fc285475a06eab5f8b05b587c5b0eefc Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 16:40:27 +0200 Subject: [PATCH 079/292] fix(agent): harden RFC-64 persistence ownership --- .../rfc64/control-object-store-v1-internal.ts | 35 ++- .../agent/src/rfc64/durable-file-store-v1.ts | 24 +-- packages/agent/src/rfc64/inventory-v1/open.ts | 38 +++- packages/agent/src/rfc64/persistence-v1.ts | 6 +- .../src/rfc64/secure-filesystem-policy-v1.ts | 204 ++++++++++++++---- .../rfc64-agent-inventory-lifecycle.test.ts | 52 ++++- .../rfc64-control-object-store-v1.test.ts | 13 ++ .../test/rfc64-persistence-owner.typecheck.ts | 11 + 8 files changed, 318 insertions(+), 65 deletions(-) diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index 7917111a61..ed8c7ed252 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -36,6 +36,9 @@ import { resolveRfc64ControlObjectStorePathV1, resolveRfc64PersistenceRootV1, } from './persistence-layout-v1.js'; +import type { + Rfc64InventoryOwnedRootCapabilityV1, +} from './inventory-v1/open.js'; export { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH }; export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; @@ -147,13 +150,11 @@ export interface Rfc64ControlObjectStoreV1 { close(): Promise; } -/** - * Open after the RFC-64 inventory foundation has secured rfc64-sync and owns - * its single-process lease. This cache deliberately does not mint that lease. - */ -export async function openRfc64ControlObjectStoreAtOwnedRootV1( - rfc64RootPath: string, +/** Open only with lifecycle authority minted by the leased inventory owner. */ +export async function openRfc64ControlObjectStoreForOwnedInventoryV1( + ownership: Rfc64InventoryOwnedRootCapabilityV1, ): Promise { + const rfc64RootPath = ownership.assertOwnedAndGetRootPathV1(); return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, PRODUCTION_LIFECYCLE); } @@ -386,12 +387,21 @@ export async function openRfc64ControlObjectStoreForTestV1( dataDirInput: string, lifecycle: Rfc64ControlObjectStoreLifecycleV1, ): Promise { + assertRfc64ControlObjectStoreTestEnvironmentV1(); if (!Object.isFrozen(lifecycle)) { fail('control-store-input', 'control object store lifecycle adapter must be immutable'); } if (typeof dataDirInput !== 'string' || dataDirInput.length === 0) { fail('control-store-input', 'dataDir must be a non-empty path'); } + const guardedLifecycle = Object.freeze({ + boundary: async ( + boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1, + ): Promise => { + assertRfc64ControlObjectStoreTestEnvironmentV1(); + await lifecycle.boundary(boundary); + }, + }); const dataDir = resolve(dataDirInput); const rfc64RootPath = resolveRfc64PersistenceRootV1(dataDir); await mapDurableFileErrors(async () => { @@ -400,9 +410,18 @@ export async function openRfc64ControlObjectStoreForTestV1( 'DKG data directory', { access: 'owner' }, ); - await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, lifecycle); + await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, guardedLifecycle); }); - return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, lifecycle); + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, guardedLifecycle); +} + +function assertRfc64ControlObjectStoreTestEnvironmentV1(): void { + if (process.env.NODE_ENV !== 'test') { + fail( + 'control-store-input', + 'control store test opener is available only under NODE_ENV=test', + ); + } } async function openRfc64ControlObjectStoreAtRootV1( diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index 42be927e46..1e15d19366 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -5,9 +5,9 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod import { RFC64_SECURE_DIRECTORY_MODE_V1, RFC64_SECURE_FILE_MODE_V1, - applyRfc64OwnerOnlyPermissionsSyncV1, - assertRfc64FilesystemOwnerSyncV1, - assertRfc64OwnerOnlyPermissionsSyncV1, + applyRfc64OwnerOnlyPermissionsV1, + assertRfc64FilesystemOwnerV1, + assertRfc64OwnerOnlyPermissionsV1, fsyncRfc64DirectoryV1, rfc64RegularFileFsyncOpenFlagsV1, rfc64RegularFileReadOpenFlagsV1, @@ -94,7 +94,7 @@ export async function assertRfc64ExistingDirectoryV1( if (entry.isSymbolicLink() || !entry.isDirectory()) { fail('unsafe-path', `${label} must be a non-symlink directory`); } - assertSecureAccess( + await assertSecureAccess( path, RFC64_SECURE_DIRECTORY_MODE_V1, { entryKind: 'directory', access: policy.access }, @@ -325,7 +325,7 @@ async function readRfc64OptionalBoundedBytesV1( if (tail.bytesRead !== 0) { fail('corrupt', `${label} grew during its bounded read`); } - assertSecureAccess( + await assertSecureAccess( path, RFC64_SECURE_FILE_MODE_V1, { entryKind: 'file', access: 'owner-only' }, @@ -399,7 +399,7 @@ async function assertExistingRegularFile( if (entry.isSymbolicLink() || !entry.isFile()) { fail('unsafe-path', `${label} must be a regular non-symlink file`); } - assertSecureAccess( + await assertSecureAccess( path, RFC64_SECURE_FILE_MODE_V1, { entryKind: 'file', access: policy.access }, @@ -414,7 +414,7 @@ async function assertExistingRegularFile( async function chmodSecure(path: string, mode: number, label: string): Promise { try { const entry = await lstat(path); - applyRfc64OwnerOnlyPermissionsSyncV1(path, mode, { + await applyRfc64OwnerOnlyPermissionsV1(path, mode, { entryKind: entry.isDirectory() ? 'directory' : 'file', }); } catch (cause) { @@ -423,19 +423,19 @@ async function chmodSecure(path: string, mode: number, label: string): Promise { try { if (policy.access === 'owner-only') { - assertRfc64OwnerOnlyPermissionsSyncV1(path, mode, { + await assertRfc64OwnerOnlyPermissionsV1(path, mode, { entryKind: policy.entryKind, }); } else { - assertRfc64FilesystemOwnerSyncV1(path); + await assertRfc64FilesystemOwnerV1(path); } } catch (cause) { fail( @@ -462,7 +462,7 @@ async function fsyncRegularFile(path: string, label: string): Promise { if (!stat.isFile()) { fail('unsafe-path', `${label} fsync target is not a regular file`); } - assertSecureAccess( + await assertSecureAccess( path, RFC64_SECURE_FILE_MODE_V1, { entryKind: 'file', access: 'owner-only' }, diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 8106e8afd0..6039783300 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -31,7 +31,10 @@ import { fsyncRfc64DirectorySyncV1, rfc64RegularFileFsyncOpenFlagsV1, } from '../secure-filesystem-policy-v1.js'; -import { resolveRfc64InventoryDatabasePathV1 } from '../persistence-layout-v1.js'; +import { + resolveRfc64InventoryDatabasePathV1, + resolveRfc64PersistenceRootV1, +} from '../persistence-layout-v1.js'; import { INVENTORY_V1_APPLICATION_ID, @@ -125,8 +128,23 @@ class InventoryV1TargetCloseError extends InventoryV1OpenError { } } +const RFC64_INVENTORY_OWNED_ROOT_V1: unique symbol = Symbol( + 'rfc64-inventory-owned-root-v1', +); + +/** + * Nominal capability minted only after the inventory lease is acquired. + * Internal sibling resources must present it instead of accepting a raw path. + */ +export interface Rfc64InventoryOwnedRootCapabilityV1 { + readonly [RFC64_INVENTORY_OWNED_ROOT_V1]: true; + assertOwnedAndGetRootPathV1(): string; +} + export interface Rfc64InventoryV1Foundation extends Rfc64InventoryV1CandidateApi { readonly databasePath: string; + /** Lifecycle authority intentionally omitted from non-owning operation views. */ + readonly controlObjectStoreOwnership: Rfc64InventoryOwnedRootCapabilityV1; readonly closed: boolean; quarantineAndRebuild(): void; close(): void; @@ -248,7 +266,14 @@ async function openInventoryV1WithLifecycleAdapter( lease = await acquireInventoryLease(sqlite, databasePath); finishPendingQuarantine(sqlite, databasePath, lifecycle); const database = openOrRebuildOwnedDatabase(sqlite, databasePath, lifecycle); - return new InventoryV1Foundation(sqlite, databasePath, database, lease, lifecycle); + return new InventoryV1Foundation( + sqlite, + databasePath, + resolveRfc64PersistenceRootV1(resolvedDataDir), + database, + lease, + lifecycle, + ); } catch (cause) { if (cause instanceof InventoryV1TargetCloseError && lease !== null) { retainInventoryFailStop(lease, cause); @@ -269,10 +294,12 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { #database: DatabaseSyncV1 | null; #candidate: CandidateInventoryV1; #lease: InventoryV1Lease | null; + readonly controlObjectStoreOwnership: Rfc64InventoryOwnedRootCapabilityV1; constructor( private readonly sqlite: SqliteModuleV1, readonly databasePath: string, + rfc64RootPath: string, database: DatabaseSyncV1, lease: InventoryV1Lease, private readonly lifecycle: InventoryV1LifecycleAdapter, @@ -280,6 +307,13 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { this.#database = database; this.#candidate = this.createCandidateInventory(database); this.#lease = lease; + this.controlObjectStoreOwnership = Object.freeze({ + [RFC64_INVENTORY_OWNED_ROOT_V1]: true as const, + assertOwnedAndGetRootPathV1: (): string => { + this.requireOpen(); + return rfc64RootPath; + }, + }); } get closed(): boolean { diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index 8a9d30d5f6..d3e73a2ead 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -8,7 +8,7 @@ import { type Rfc64ControlObjectOperationsV1, type Rfc64ControlObjectStoreV1, } from './control-object-store-v1.js'; -import { openRfc64ControlObjectStoreAtOwnedRootV1 } from './control-object-store-v1-internal.js'; +import { openRfc64ControlObjectStoreForOwnedInventoryV1 } from './control-object-store-v1-internal.js'; import { resolveRfc64PersistenceRootV1 } from './persistence-layout-v1.js'; export interface OpenRfc64PersistenceOptionsV1 { @@ -133,8 +133,8 @@ export async function openRfc64PersistenceV1( if (batch.done) break; await yieldAfterPurgeBatch(); } - const controlObjectStore = await openRfc64ControlObjectStoreAtOwnedRootV1( - rootPath, + const controlObjectStore = await openRfc64ControlObjectStoreForOwnedInventoryV1( + inventory.controlObjectStoreOwnership, ); return new OwnedRfc64PersistenceV1(rootPath, inventory, controlObjectStore); } catch (cause) { diff --git a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts index 026018b031..173cdcddf9 100644 --- a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts +++ b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { chmodSync, closeSync, @@ -7,7 +7,7 @@ import { openSync, statSync, } from 'node:fs'; -import { open } from 'node:fs/promises'; +import { chmod, open, stat } from 'node:fs/promises'; export const RFC64_SECURE_DIRECTORY_MODE_V1 = 0o700; export const RFC64_SECURE_FILE_MODE_V1 = 0o600; @@ -59,6 +59,17 @@ export function assertRfc64FilesystemOwnerSyncV1(path: string): void { } } +export async function assertRfc64FilesystemOwnerV1(path: string): Promise { + if (rfc64UsesWindowsFilesystemPolicyV1()) { + const entry = await stat(path); + await assertWindowsFilesystemOwner(path, entry.isDirectory() ? 'directory' : 'file'); + return; + } + if (!rfc64CurrentUserOwnsUidV1((await stat(path)).uid)) { + throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); + } +} + export function assertRfc64OwnerOnlyPermissionsSyncV1( path: string, expectedMode: number, @@ -79,6 +90,26 @@ export function assertRfc64OwnerOnlyPermissionsSyncV1( } } +export async function assertRfc64OwnerOnlyPermissionsV1( + path: string, + expectedMode: number, + policy: Rfc64FilesystemEntryPolicyV1, +): Promise { + if (rfc64UsesWindowsFilesystemPolicyV1()) { + await assertWindowsOwnerOnlyPermissions(path, policy.entryKind); + return; + } + const entry = await stat(path); + if (!rfc64CurrentUserOwnsUidV1(entry.uid)) { + throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); + } + if (!rfc64PosixModeMatchesV1(entry.mode, expectedMode)) { + throw new Error( + `RFC-64 path mode ${(entry.mode & 0o777).toString(8)} does not match ${expectedMode.toString(8)}`, + ); + } +} + export function applyRfc64OwnerOnlyPermissionsSyncV1( path: string, mode: number, @@ -93,6 +124,20 @@ export function applyRfc64OwnerOnlyPermissionsSyncV1( assertRfc64OwnerOnlyPermissionsSyncV1(path, mode, policy); } +export async function applyRfc64OwnerOnlyPermissionsV1( + path: string, + mode: number, + policy: Rfc64FilesystemEntryPolicyV1, +): Promise { + if (rfc64UsesWindowsFilesystemPolicyV1()) { + await applyWindowsOwnerOnlyPermissions(path, policy.entryKind); + return; + } + await assertRfc64FilesystemOwnerV1(path); + await chmod(path, mode); + await assertRfc64OwnerOnlyPermissionsV1(path, mode, policy); +} + /** Node cannot FlushFileBuffers on a Windows directory handle. */ export async function fsyncRfc64DirectoryV1(path: string): Promise { if (rfc64UsesWindowsFilesystemPolicyV1()) return; @@ -189,45 +234,17 @@ function Assert-OwnerOnly($acl) { } `; -function assertWindowsFilesystemOwnerSync( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, -): void { - runWindowsAclOperation( - path, - entryKind, - 'owner assertion', - `${WINDOWS_ACL_POWERSHELL_PRELUDE} +const WINDOWS_FILESYSTEM_OWNER_SCRIPT = `${WINDOWS_ACL_POWERSHELL_PRELUDE} $acl = Read-TargetAcl -Assert-CurrentOwner $acl`, - ); -} +Assert-CurrentOwner $acl`; -function assertWindowsOwnerOnlyPermissionsSync( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, -): void { - runWindowsAclOperation( - path, - entryKind, - 'owner-only assertion', - `${WINDOWS_ACL_POWERSHELL_PRELUDE} +const WINDOWS_OWNER_ONLY_ASSERTION_SCRIPT = `${WINDOWS_ACL_POWERSHELL_PRELUDE} ${WINDOWS_OWNER_ONLY_ASSERTION} $acl = Read-TargetAcl Assert-CurrentOwner $acl -Assert-OwnerOnly $acl`, - ); -} +Assert-OwnerOnly $acl`; -function applyWindowsOwnerOnlyPermissionsSync( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, -): void { - runWindowsAclOperation( - path, - entryKind, - 'owner-only application', - `${WINDOWS_ACL_POWERSHELL_PRELUDE} +const WINDOWS_OWNER_ONLY_APPLICATION_SCRIPT = `${WINDOWS_ACL_POWERSHELL_PRELUDE} ${WINDOWS_OWNER_ONLY_ASSERTION} $existingAcl = Read-TargetAcl Assert-CurrentOwner $existingAcl @@ -258,7 +275,77 @@ if ($isDirectory) { } $acl = Read-TargetAcl Assert-CurrentOwner $acl -Assert-OwnerOnly $acl`, +Assert-OwnerOnly $acl`; + +function assertWindowsFilesystemOwnerSync( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): void { + runWindowsAclOperation( + path, + entryKind, + 'owner assertion', + WINDOWS_FILESYSTEM_OWNER_SCRIPT, + ); +} + +async function assertWindowsFilesystemOwner( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): Promise { + await runWindowsAclOperationAsync( + path, + entryKind, + 'owner assertion', + WINDOWS_FILESYSTEM_OWNER_SCRIPT, + ); +} + +function assertWindowsOwnerOnlyPermissionsSync( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): void { + runWindowsAclOperation( + path, + entryKind, + 'owner-only assertion', + WINDOWS_OWNER_ONLY_ASSERTION_SCRIPT, + ); +} + +async function assertWindowsOwnerOnlyPermissions( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): Promise { + await runWindowsAclOperationAsync( + path, + entryKind, + 'owner-only assertion', + WINDOWS_OWNER_ONLY_ASSERTION_SCRIPT, + ); +} + +function applyWindowsOwnerOnlyPermissionsSync( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): void { + runWindowsAclOperation( + path, + entryKind, + 'owner-only application', + WINDOWS_OWNER_ONLY_APPLICATION_SCRIPT, + ); +} + +async function applyWindowsOwnerOnlyPermissions( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): Promise { + await runWindowsAclOperationAsync( + path, + entryKind, + 'owner-only application', + WINDOWS_OWNER_ONLY_APPLICATION_SCRIPT, ); } @@ -291,3 +378,48 @@ function runWindowsAclOperation( ); } } + +async function runWindowsAclOperationAsync( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, + operation: string, + script: string, +): Promise { + await new Promise((resolveOperation, rejectOperation) => { + const child = spawn( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { + windowsHide: true, + stdio: ['ignore', 'ignore', 'pipe'], + env: { + ...process.env, + DKG_RFC64_ACL_DIRECTORY: String(entryKind === 'directory'), + DKG_RFC64_ACL_PATH: path, + }, + }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', (cause) => { + rejectOperation(new Error( + `RFC-64 Windows ACL ${operation} failed for ${path}: ${cause.message}`, + { cause }, + )); + }); + child.once('close', (code) => { + if (code === 0) { + resolveOperation(); + return; + } + rejectOperation(new Error( + `RFC-64 Windows ACL ${operation} failed for ${path}: ${ + stderr.trim() || `PowerShell exited ${code}` + }`, + )); + }); + }); +} diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index aff5033889..00bfc41571 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -24,6 +24,7 @@ import { type StageVerifiedControlObjectV1, } from '../src/rfc64/control-object-store-v1.js'; import { openRfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; +import { openRfc64ControlObjectStoreForOwnedInventoryV1 } from '../src/rfc64/control-object-store-v1-internal.js'; import { RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1, } from '../src/rfc64/persistence-layout-v1.js'; @@ -136,7 +137,7 @@ function candidateLoadCount(dataDirectory: string): number { function minimalStartedAgent( order: string[], - inventoryClose: () => void, + inventoryClose: () => void | Promise, ): any { const agent = syntheticAgent(); Object.assign(agent, { @@ -156,7 +157,7 @@ function minimalStartedAgent( rfc64PersistenceV1: { close: () => { order.push('control-store'); - inventoryClose(); + return inventoryClose(); }, }, store: { close: vi.fn(async () => { order.push('store'); }) }, @@ -321,6 +322,16 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { replacement.close(); }); + it('invalidates sibling-resource ownership capability when inventory closes', async () => { + const dataDirectory = temporaryDataDirectory(); + const inventory = await openInventoryV1(dataDirectory); + const ownership = inventory.controlObjectStoreOwnership; + inventory.close(); + + await expect(openRfc64ControlObjectStoreForOwnedInventoryV1(ownership)) + .rejects.toMatchObject({ code: 'database-closed' }); + }); + it('fails startup before node.start when inventory acquisition or recovery fails', async () => { const failure = new Error('inventory unavailable'); const nodeStart = vi.fn(async () => {}); @@ -438,10 +449,43 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { expect(agent.started).toBe(false); }); - it('finishes store teardown but rejects shutdown when inventory close fails', async () => { + it('keeps shutdown active and the triple store open until persistence drain settles', async () => { + const order: string[] = []; + const drain = deferred(); + const persistenceClose = vi.fn(async () => { + await drain.promise; + order.push('inventory'); + }); + const agent = minimalStartedAgent(order, persistenceClose); + + const stop = agent.stop(); + let settled = false; + void stop.finally(() => { settled = true; }); + await vi.waitFor(() => expect(persistenceClose).toHaveBeenCalledOnce()); + await Promise.resolve(); + + expect(order).toEqual(['node', 'sync-worker', 'control-store']); + expect(agent.store.close).not.toHaveBeenCalled(); + expect(agent.started).toBe(true); + expect(settled).toBe(false); + + drain.resolve(); + await expect(stop).resolves.toBeUndefined(); + expect(order).toEqual([ + 'node', + 'sync-worker', + 'control-store', + 'inventory', + 'store', + ]); + expect(agent.started).toBe(false); + }); + + it('finishes store teardown but rejects shutdown when asynchronous persistence close fails', async () => { const order: string[] = []; const failure = new Error('inventory close failed'); - const agent = minimalStartedAgent(order, () => { + const agent = minimalStartedAgent(order, async () => { + await Promise.resolve(); order.push('inventory'); throw failure; }); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index bb37c2d28f..6846dd3263 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -184,6 +184,19 @@ function pathsFor( } describe('RFC-64 durable control-object store v1', () => { + it('rechecks NODE_ENV when the source-level test opener is invoked', async () => { + const opener = createRfc64ControlObjectStoreTestOpenerV1(); + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + await expect(opener(await temporaryDataDirectory())).rejects.toMatchObject({ + code: 'control-store-input', + }); + } finally { + process.env.NODE_ENV = previousNodeEnv; + } + }); + it('durably splits unsigned object bytes from a detached signature and reverifies reads', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); diff --git a/packages/agent/test/rfc64-persistence-owner.typecheck.ts b/packages/agent/test/rfc64-persistence-owner.typecheck.ts index 335d69cc31..e27b1c162a 100644 --- a/packages/agent/test/rfc64-persistence-owner.typecheck.ts +++ b/packages/agent/test/rfc64-persistence-owner.typecheck.ts @@ -1,9 +1,20 @@ import type { Rfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; +import type { Rfc64InventoryV1Foundation } from '../src/rfc64/inventory-v1/index.js'; +import { openRfc64ControlObjectStoreForOwnedInventoryV1 } from '../src/rfc64/control-object-store-v1-internal.js'; declare const persistence: Rfc64PersistenceV1; +declare const ownedInventory: Rfc64InventoryV1Foundation; // The owner exposes operational views, never independently closable resources. // @ts-expect-error inventory lifecycle belongs exclusively to persistence persistence.inventory.close(); // @ts-expect-error control-store lifecycle belongs exclusively to persistence await persistence.controlObjects.close(); +// @ts-expect-error the non-owning inventory view cannot mint sibling resources +persistence.inventory.controlObjectStoreOwnership; + +await openRfc64ControlObjectStoreForOwnedInventoryV1( + ownedInventory.controlObjectStoreOwnership, +); +// @ts-expect-error a raw filesystem path cannot bypass inventory lease ownership +await openRfc64ControlObjectStoreForOwnedInventoryV1('/tmp/rfc64-sync'); From aeb80f2d6311f9d84169d0b352c11c9b2f492e0b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 16:42:18 +0200 Subject: [PATCH 080/292] fix(agent): fence RFC-64 inventory on close --- packages/agent/src/rfc64/persistence-v1.ts | 50 +++++++++++++------ .../rfc64-agent-inventory-lifecycle.test.ts | 4 ++ 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index d3e73a2ead..e2a7b3f1c5 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -43,7 +43,10 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { ) { this.#ownedInventory = ownedInventory; this.#ownedControlObjectStore = ownedControlObjectStore; - this.inventory = createInventoryOperationsView(ownedInventory); + this.inventory = createInventoryOperationsView( + ownedInventory, + () => this.requireOpen(), + ); this.controlObjects = createControlObjectOperationsView(ownedControlObjectStore); } @@ -75,29 +78,48 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { throw new AggregateError(failures, 'RFC-64 persistence resources failed to close'); } } + + private requireOpen(): void { + if (this.#closed) throw new Error('RFC-64 persistence owner is closed'); + } } /** The aggregate owner is the sole boundary that strips child lifecycle authority. */ function createInventoryOperationsView( inventory: Rfc64InventoryV1CandidateApi, + requireOwnerOpen: () => void, ): Rfc64InventoryV1OperationsV1 { + const fence = ( + operation: (...arguments_: TArguments) => TResult, + ): ((...arguments_: TArguments) => TResult) => + (...arguments_: TArguments): TResult => { + requireOwnerOpen(); + return operation(...arguments_); + }; return Object.freeze({ - createCandidateSession: inventory.createCandidateSession.bind(inventory), - putVerifiedCandidateBucket: inventory.putVerifiedCandidateBucket.bind(inventory), - getCandidateBucket: inventory.getCandidateBucket.bind(inventory), - beginCandidateBucketRows: inventory.beginCandidateBucketRows.bind(inventory), - beginCandidateBucketDiff: inventory.beginCandidateBucketDiff.bind(inventory), - pageCandidateBucketRows: inventory.pageCandidateBucketRows.bind(inventory), - pageCandidateBucketAddedOrChanged: + createCandidateSession: fence(inventory.createCandidateSession.bind(inventory)), + putVerifiedCandidateBucket: fence(inventory.putVerifiedCandidateBucket.bind(inventory)), + getCandidateBucket: fence(inventory.getCandidateBucket.bind(inventory)), + beginCandidateBucketRows: fence(inventory.beginCandidateBucketRows.bind(inventory)), + beginCandidateBucketDiff: fence(inventory.beginCandidateBucketDiff.bind(inventory)), + pageCandidateBucketRows: fence(inventory.pageCandidateBucketRows.bind(inventory)), + pageCandidateBucketAddedOrChanged: fence( inventory.pageCandidateBucketAddedOrChanged.bind(inventory), - pageCandidateBucketRemoved: inventory.pageCandidateBucketRemoved.bind(inventory), - readVerifiedCandidateCatalogRow: + ), + pageCandidateBucketRemoved: fence( + inventory.pageCandidateBucketRemoved.bind(inventory), + ), + readVerifiedCandidateCatalogRow: fence( inventory.readVerifiedCandidateCatalogRow.bind(inventory), - verifyCandidateCatalogPrecommitV1: + ), + verifyCandidateCatalogPrecommitV1: fence( inventory.verifyCandidateCatalogPrecommitV1.bind(inventory), - closeCandidateTraversal: inventory.closeCandidateTraversal.bind(inventory), - discardCandidateSessionBatch: inventory.discardCandidateSessionBatch.bind(inventory), - deleteCandidateBucket: inventory.deleteCandidateBucket.bind(inventory), + ), + closeCandidateTraversal: fence(inventory.closeCandidateTraversal.bind(inventory)), + discardCandidateSessionBatch: fence( + inventory.discardCandidateSessionBatch.bind(inventory), + ), + deleteCandidateBucket: fence(inventory.deleteCandidateBucket.bind(inventory)), }); } diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index 00bfc41571..a3286fa32b 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -312,6 +312,10 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { const close = agent.closeRfc64PersistenceV1(); expect(agent.rfc64PersistenceV1).toBeUndefined(); expect(persistence.closed).toBe(true); + for (const operation of Object.values(persistence.inventory)) { + expect(() => (operation as (...arguments_: unknown[]) => unknown)()) + .toThrow('RFC-64 persistence owner is closed'); + } await expect(openInventoryV1(dataDirectory)) .rejects.toMatchObject({ code: 'database-busy' }); From 11a848a57a8f30305716dc548ea3c83de286d024 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:01:34 +0200 Subject: [PATCH 081/292] fix(agent): simplify RFC-64 persistence boundaries --- packages/agent/package.json | 6 +- .../rfc64/control-object-store-v1-internal.ts | 127 ++++++--- .../agent/src/rfc64/inventory-v1/candidate.ts | 16 +- .../src/rfc64/secure-filesystem-policy-v1.ts | 249 ++++++++---------- .../rfc64-control-object-store-v1.test.ts | 74 +++++- ...fc64-control-store-public-api.typecheck.ts | 5 + .../test/rfc64-persistence-owner.typecheck.ts | 2 + 7 files changed, 292 insertions(+), 187 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index a146635169..9e6efc41b7 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -9,7 +9,9 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" - } + }, + "./dist/rfc64/control-object-store-v1-internal.js": null, + "./dist/*": "./dist/*" }, "scripts": { "build": "tsc && pnpm run test:types && pnpm run test:package-root", @@ -19,7 +21,7 @@ "test": "vitest run", "test:unit": "vitest run --config vitest.unit.config.ts", "test:types": "tsc --project tsconfig.type-tests.json", - "test:package-root": "node --input-type=module -e \"const agent = await import('@origintrail-official/dkg-agent'); if (typeof agent.DKGAgent !== 'function') throw new Error('published package root did not expose DKGAgent')\"", + "test:package-root": "node --input-type=module -e \"const root = await import('@origintrail-official/dkg-agent'); const legacy = await import('@origintrail-official/dkg-agent/dist/dkg-agent.js'); if (typeof root.DKGAgent !== 'function' || typeof legacy.DKGAgent !== 'function') throw new Error('published agent entry points did not expose DKGAgent'); try { await import('@origintrail-official/dkg-agent/dist/rfc64/control-object-store-v1-internal.js'); throw new Error('internal RFC-64 module unexpectedly resolved'); } catch (error) { if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; }\"", "test:coverage": "vitest run --coverage", "clean": "rm -rf dist tsconfig.tsbuildinfo" }, diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index ed8c7ed252..f7a5b7c9a1 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -165,11 +165,21 @@ interface PreparedStoredControlObjectV1 { readonly signatureVariantBytes: Uint8Array; } +interface PreparedControlObjectFileV1 { + readonly relativePath: string; + readonly bytes: Uint8Array; + readonly kind: Rfc64ControlObjectStoreFileKindV1; +} + +interface PreparedControlObjectGroupV1 { + readonly object: PreparedControlObjectFileV1; + readonly signatures: readonly PreparedControlObjectFileV1[]; +} + class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { #closed = false; #closePromise: Promise | null = null; readonly #inFlightOperations = new Set>(); - readonly #fileWriteTails = new Map>(); readonly #durableFiles: Rfc64DurableFileStoreV1; readonly namespaceDurability = rfc64NamespaceDurabilityV1(); @@ -189,14 +199,23 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { ): Promise { this.requireOpen(); const prepared = prepareStageBatch(input); + const groups = this.planStageGroups(prepared); const operation = (async () => { - const result = await settleAllOrThrowV1(prepared.map(async (item) => { - await this.stagePrepared(item); - return Object.freeze({ + await settleAllOrThrowV1(groups.map(async (group) => { + await this.putExactFile( + group.object.relativePath, + group.object.bytes, + group.object.kind, + ); + await settleAllOrThrowV1(group.signatures.map(async (signature) => { + await this.putExactFile(signature.relativePath, signature.bytes, signature.kind); + }), 'RFC-64 control-object signature staging failed'); + }), 'RFC-64 control-object batch staging failed'); + const result = prepared.map((item) => + Object.freeze({ objectDigest: item.objectDigest, signatureVariantDigest: item.signatureVariantDigest, - }); - }), 'RFC-64 control-object batch staging failed'); + })); return Object.freeze({ durable: true as const, namespaceDurability: this.namespaceDurability, @@ -253,9 +272,8 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { fail('control-store-corrupt', 'stored control object is not canonical for its exact keys', cause); } - // The caller callback intentionally runs outside every file-key tail. It - // may compose this cache with another exact read without deadlocking the - // write that produced the snapshot above. + // The caller callback runs after both bounded file snapshots and outside + // durable-file publication, so it may compose another exact cache read. let issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; try { issuerSignature = await verifyIssuerSignature(envelope); @@ -280,15 +298,60 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { return this.#closePromise; } - private async stagePrepared(item: PreparedStoredControlObjectV1): Promise { - const objectPath = this.objectRelativePath(item.objectDigest); - await this.stageFileByKey(objectPath, item.unsignedBytes, 'object'); - - const signaturePath = this.signatureRelativePath( - item.objectDigest, - item.signatureVariantDigest, - ); - await this.stageFileByKey(signaturePath, item.signatureVariantBytes, 'signature'); + private planStageGroups( + prepared: readonly PreparedStoredControlObjectV1[], + ): readonly PreparedControlObjectGroupV1[] { + const groups = new Map; + }>(); + const addUnique = ( + files: Map, + relativePath: string, + bytes: Uint8Array, + kind: Rfc64ControlObjectStoreFileKindV1, + ): PreparedControlObjectFileV1 => { + const existing = files.get(relativePath); + if (existing !== undefined) { + if (!byteArraysEqual(existing.bytes, bytes) || existing.kind !== kind) { + fail( + 'control-store-verification', + 'one immutable control-object key resolved to conflicting prepared bytes', + ); + } + return existing; + } + const file = Object.freeze({ relativePath, bytes, kind }); + files.set(relativePath, file); + return file; + }; + const objects = new Map(); + for (const item of prepared) { + const objectPath = this.objectRelativePath(item.objectDigest); + let group = groups.get(objectPath); + if (group === undefined) { + group = { + object: addUnique(objects, objectPath, item.unsignedBytes, 'object'), + signatures: new Map(), + }; + groups.set(objectPath, group); + } else if (!byteArraysEqual(group.object.bytes, item.unsignedBytes)) { + fail( + 'control-store-verification', + 'one immutable control-object key resolved to conflicting prepared bytes', + ); + } + addUnique( + group.signatures, + this.signatureRelativePath(item.objectDigest, item.signatureVariantDigest), + item.signatureVariantBytes, + 'signature', + ); + } + return Object.freeze([...groups.values()].map((group) => Object.freeze({ + object: group.object, + signatures: Object.freeze([...group.signatures.values()]), + }))); } private objectRelativePath(objectDigest: Digest32V1): string { @@ -314,26 +377,6 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { ); } - private stageFileByKey( - relativePath: string, - bytes: Uint8Array, - kind: 'object' | 'signature', - ): Promise { - const previous = this.#fileWriteTails.get(relativePath) ?? Promise.resolve(); - const run = previous.then(async () => { - await this.putExactFile(relativePath, bytes, kind); - }, async () => { - await this.putExactFile(relativePath, bytes, kind); - }); - this.#fileWriteTails.set(relativePath, run); - void run.finally(() => { - if (this.#fileWriteTails.get(relativePath) === run) { - this.#fileWriteTails.delete(relativePath); - } - }).catch(() => undefined); - return run; - } - private async putExactFile( relativePath: string, bytes: Uint8Array, @@ -365,6 +408,14 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { } } +function byteArraysEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + async function settleAllOrThrowV1( operations: readonly Promise[], aggregateMessage: string, diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index 97129e9103..74b91f520b 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -245,9 +245,21 @@ export interface Rfc64InventoryV1CandidateApi { } /** Candidate operations safe to share without inventory lifecycle ownership. */ -export type Rfc64InventoryV1OperationsV1 = Omit< +export type Rfc64InventoryV1OperationsV1 = Pick< Rfc64InventoryV1CandidateApi, - 'purgeNextStartupStaleCandidateBatch' + | 'createCandidateSession' + | 'putVerifiedCandidateBucket' + | 'getCandidateBucket' + | 'beginCandidateBucketRows' + | 'beginCandidateBucketDiff' + | 'pageCandidateBucketRows' + | 'pageCandidateBucketAddedOrChanged' + | 'pageCandidateBucketRemoved' + | 'readVerifiedCandidateCatalogRow' + | 'verifyCandidateCatalogPrecommitV1' + | 'closeCandidateTraversal' + | 'discardCandidateSessionBatch' + | 'deleteCandidateBucket' >; interface SessionContextV1 { diff --git a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts index 173cdcddf9..aca2e63ad8 100644 --- a/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts +++ b/packages/agent/src/rfc64/secure-filesystem-policy-v1.ts @@ -26,6 +26,11 @@ export interface Rfc64FilesystemEntryPolicyV1 { readonly entryKind: Rfc64FilesystemEntryKindV1; } +interface Rfc64PosixFilesystemSnapshotV1 { + readonly uid: number; + readonly mode: number; +} + export function rfc64UsesWindowsFilesystemPolicyV1(): boolean { return process.platform === 'win32'; } @@ -47,27 +52,29 @@ export function rfc64PosixModeMatchesV1(mode: number, expected: number): boolean } export function assertRfc64FilesystemOwnerSyncV1(path: string): void { + const entry = statSync(path); if (rfc64UsesWindowsFilesystemPolicyV1()) { - assertWindowsFilesystemOwnerSync( + runWindowsAclOperationSync( path, - statSync(path).isDirectory() ? 'directory' : 'file', + entry.isDirectory() ? 'directory' : 'file', + WINDOWS_FILESYSTEM_OWNER_OPERATION, ); return; } - if (!rfc64CurrentUserOwnsUidV1(statSync(path).uid)) { - throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); - } + assertRfc64PosixFilesystemOwnerV1(entry); } export async function assertRfc64FilesystemOwnerV1(path: string): Promise { + const entry = await stat(path); if (rfc64UsesWindowsFilesystemPolicyV1()) { - const entry = await stat(path); - await assertWindowsFilesystemOwner(path, entry.isDirectory() ? 'directory' : 'file'); + await runWindowsAclOperationAsync( + path, + entry.isDirectory() ? 'directory' : 'file', + WINDOWS_FILESYSTEM_OWNER_OPERATION, + ); return; } - if (!rfc64CurrentUserOwnsUidV1((await stat(path)).uid)) { - throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); - } + assertRfc64PosixFilesystemOwnerV1(entry); } export function assertRfc64OwnerOnlyPermissionsSyncV1( @@ -76,18 +83,10 @@ export function assertRfc64OwnerOnlyPermissionsSyncV1( policy: Rfc64FilesystemEntryPolicyV1, ): void { if (rfc64UsesWindowsFilesystemPolicyV1()) { - assertWindowsOwnerOnlyPermissionsSync(path, policy.entryKind); + runWindowsAclOperationSync(path, policy.entryKind, WINDOWS_OWNER_ONLY_ASSERTION_OPERATION); return; } - const stat = statSync(path); - if (!rfc64CurrentUserOwnsUidV1(stat.uid)) { - throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); - } - if (!rfc64PosixModeMatchesV1(stat.mode, expectedMode)) { - throw new Error( - `RFC-64 path mode ${(stat.mode & 0o777).toString(8)} does not match ${expectedMode.toString(8)}`, - ); - } + assertRfc64PosixOwnerOnlyPermissionsV1(statSync(path), expectedMode); } export async function assertRfc64OwnerOnlyPermissionsV1( @@ -96,18 +95,14 @@ export async function assertRfc64OwnerOnlyPermissionsV1( policy: Rfc64FilesystemEntryPolicyV1, ): Promise { if (rfc64UsesWindowsFilesystemPolicyV1()) { - await assertWindowsOwnerOnlyPermissions(path, policy.entryKind); - return; - } - const entry = await stat(path); - if (!rfc64CurrentUserOwnsUidV1(entry.uid)) { - throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); - } - if (!rfc64PosixModeMatchesV1(entry.mode, expectedMode)) { - throw new Error( - `RFC-64 path mode ${(entry.mode & 0o777).toString(8)} does not match ${expectedMode.toString(8)}`, + await runWindowsAclOperationAsync( + path, + policy.entryKind, + WINDOWS_OWNER_ONLY_ASSERTION_OPERATION, ); + return; } + assertRfc64PosixOwnerOnlyPermissionsV1(await stat(path), expectedMode); } export function applyRfc64OwnerOnlyPermissionsSyncV1( @@ -116,12 +111,12 @@ export function applyRfc64OwnerOnlyPermissionsSyncV1( policy: Rfc64FilesystemEntryPolicyV1, ): void { if (rfc64UsesWindowsFilesystemPolicyV1()) { - applyWindowsOwnerOnlyPermissionsSync(path, policy.entryKind); + runWindowsAclOperationSync(path, policy.entryKind, WINDOWS_OWNER_ONLY_APPLICATION_OPERATION); return; } - assertRfc64FilesystemOwnerSyncV1(path); + assertRfc64PosixFilesystemOwnerV1(statSync(path)); chmodSync(path, mode); - assertRfc64OwnerOnlyPermissionsSyncV1(path, mode, policy); + assertRfc64PosixOwnerOnlyPermissionsV1(statSync(path), mode); } export async function applyRfc64OwnerOnlyPermissionsV1( @@ -130,12 +125,36 @@ export async function applyRfc64OwnerOnlyPermissionsV1( policy: Rfc64FilesystemEntryPolicyV1, ): Promise { if (rfc64UsesWindowsFilesystemPolicyV1()) { - await applyWindowsOwnerOnlyPermissions(path, policy.entryKind); + await runWindowsAclOperationAsync( + path, + policy.entryKind, + WINDOWS_OWNER_ONLY_APPLICATION_OPERATION, + ); return; } - await assertRfc64FilesystemOwnerV1(path); + assertRfc64PosixFilesystemOwnerV1(await stat(path)); await chmod(path, mode); - await assertRfc64OwnerOnlyPermissionsV1(path, mode, policy); + assertRfc64PosixOwnerOnlyPermissionsV1(await stat(path), mode); +} + +function assertRfc64PosixFilesystemOwnerV1( + entry: Rfc64PosixFilesystemSnapshotV1, +): void { + if (!rfc64CurrentUserOwnsUidV1(entry.uid)) { + throw new Error('RFC-64 filesystem entry is not owned by the current process uid'); + } +} + +function assertRfc64PosixOwnerOnlyPermissionsV1( + entry: Rfc64PosixFilesystemSnapshotV1, + expectedMode: number, +): void { + assertRfc64PosixFilesystemOwnerV1(entry); + if (!rfc64PosixModeMatchesV1(entry.mode, expectedMode)) { + throw new Error( + `RFC-64 path mode ${(entry.mode & 0o777).toString(8)} does not match ${expectedMode.toString(8)}`, + ); + } } /** Node cannot FlushFileBuffers on a Windows directory handle. */ @@ -277,104 +296,47 @@ $acl = Read-TargetAcl Assert-CurrentOwner $acl Assert-OwnerOnly $acl`; -function assertWindowsFilesystemOwnerSync( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, -): void { - runWindowsAclOperation( - path, - entryKind, - 'owner assertion', - WINDOWS_FILESYSTEM_OWNER_SCRIPT, - ); +interface Rfc64WindowsAclOperationV1 { + readonly label: string; + readonly script: string; } -async function assertWindowsFilesystemOwner( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, -): Promise { - await runWindowsAclOperationAsync( - path, - entryKind, - 'owner assertion', - WINDOWS_FILESYSTEM_OWNER_SCRIPT, - ); -} +const WINDOWS_FILESYSTEM_OWNER_OPERATION = Object.freeze({ + label: 'owner assertion', + script: WINDOWS_FILESYSTEM_OWNER_SCRIPT, +}) satisfies Rfc64WindowsAclOperationV1; -function assertWindowsOwnerOnlyPermissionsSync( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, -): void { - runWindowsAclOperation( - path, - entryKind, - 'owner-only assertion', - WINDOWS_OWNER_ONLY_ASSERTION_SCRIPT, - ); -} +const WINDOWS_OWNER_ONLY_ASSERTION_OPERATION = Object.freeze({ + label: 'owner-only assertion', + script: WINDOWS_OWNER_ONLY_ASSERTION_SCRIPT, +}) satisfies Rfc64WindowsAclOperationV1; -async function assertWindowsOwnerOnlyPermissions( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, -): Promise { - await runWindowsAclOperationAsync( - path, - entryKind, - 'owner-only assertion', - WINDOWS_OWNER_ONLY_ASSERTION_SCRIPT, - ); -} +const WINDOWS_OWNER_ONLY_APPLICATION_OPERATION = Object.freeze({ + label: 'owner-only application', + script: WINDOWS_OWNER_ONLY_APPLICATION_SCRIPT, +}) satisfies Rfc64WindowsAclOperationV1; -function applyWindowsOwnerOnlyPermissionsSync( +function runWindowsAclOperationSync( path: string, entryKind: Rfc64FilesystemEntryKindV1, -): void { - runWindowsAclOperation( - path, - entryKind, - 'owner-only application', - WINDOWS_OWNER_ONLY_APPLICATION_SCRIPT, - ); -} - -async function applyWindowsOwnerOnlyPermissions( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, -): Promise { - await runWindowsAclOperationAsync( - path, - entryKind, - 'owner-only application', - WINDOWS_OWNER_ONLY_APPLICATION_SCRIPT, - ); -} - -function runWindowsAclOperation( - path: string, - entryKind: Rfc64FilesystemEntryKindV1, - operation: string, - script: string, + operation: Rfc64WindowsAclOperationV1, ): void { const result = spawnSync( 'powershell.exe', - ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + windowsAclArgumentsV1(operation), { encoding: 'utf8', windowsHide: true, - env: { - ...process.env, - DKG_RFC64_ACL_DIRECTORY: String(entryKind === 'directory'), - DKG_RFC64_ACL_PATH: path, - }, + env: windowsAclEnvironmentV1(path, entryKind), }, ); if (result.error !== undefined || result.status !== 0) { - throw new Error( - `RFC-64 Windows ACL ${operation} failed for ${path}: ${ - result.error?.message - ?? (result.stderr.trim() || `PowerShell exited ${result.status}`) - }`, - result.error === undefined ? {} : { cause: result.error }, + throw windowsAclFailureV1( + path, + operation, + result.error?.message + ?? (result.stderr.trim() || `PowerShell exited ${result.status}`), + result.error, ); } } @@ -382,21 +344,16 @@ function runWindowsAclOperation( async function runWindowsAclOperationAsync( path: string, entryKind: Rfc64FilesystemEntryKindV1, - operation: string, - script: string, + operation: Rfc64WindowsAclOperationV1, ): Promise { await new Promise((resolveOperation, rejectOperation) => { const child = spawn( 'powershell.exe', - ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + windowsAclArgumentsV1(operation), { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'], - env: { - ...process.env, - DKG_RFC64_ACL_DIRECTORY: String(entryKind === 'directory'), - DKG_RFC64_ACL_PATH: path, - }, + env: windowsAclEnvironmentV1(path, entryKind), }, ); let stderr = ''; @@ -405,21 +362,45 @@ async function runWindowsAclOperationAsync( stderr += chunk; }); child.once('error', (cause) => { - rejectOperation(new Error( - `RFC-64 Windows ACL ${operation} failed for ${path}: ${cause.message}`, - { cause }, - )); + rejectOperation(windowsAclFailureV1(path, operation, cause.message, cause)); }); child.once('close', (code) => { if (code === 0) { resolveOperation(); return; } - rejectOperation(new Error( - `RFC-64 Windows ACL ${operation} failed for ${path}: ${ - stderr.trim() || `PowerShell exited ${code}` - }`, + rejectOperation(windowsAclFailureV1( + path, + operation, + stderr.trim() || `PowerShell exited ${code}`, )); }); }); } + +function windowsAclArgumentsV1(operation: Rfc64WindowsAclOperationV1): string[] { + return ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', operation.script]; +} + +function windowsAclEnvironmentV1( + path: string, + entryKind: Rfc64FilesystemEntryKindV1, +): NodeJS.ProcessEnv { + return { + ...process.env, + DKG_RFC64_ACL_DIRECTORY: String(entryKind === 'directory'), + DKG_RFC64_ACL_PATH: path, + }; +} + +function windowsAclFailureV1( + path: string, + operation: Rfc64WindowsAclOperationV1, + detail: string, + cause?: unknown, +): Error { + return new Error( + `RFC-64 Windows ACL ${operation.label} failed for ${path}: ${detail}`, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 6846dd3263..96ba63b4c1 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -35,7 +35,14 @@ import { } from '../src/rfc64/control-object-store-v1.js'; import { createRfc64DurableFileStoreV1 } from '../src/rfc64/durable-file-store-v1.js'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; -import { applyRfc64OwnerOnlyPermissionsSyncV1 } from '../src/rfc64/secure-filesystem-policy-v1.js'; +import { + applyRfc64OwnerOnlyPermissionsSyncV1, + applyRfc64OwnerOnlyPermissionsV1, + assertRfc64FilesystemOwnerSyncV1, + assertRfc64FilesystemOwnerV1, + assertRfc64OwnerOnlyPermissionsSyncV1, + assertRfc64OwnerOnlyPermissionsV1, +} from '../src/rfc64/secure-filesystem-policy-v1.js'; import { createRfc64ControlObjectStoreTestOpenerV1, type Rfc64ControlObjectStoreDurabilityBoundaryV1, @@ -184,6 +191,48 @@ function pathsFor( } describe('RFC-64 durable control-object store v1', () => { + it('keeps synchronous inventory and asynchronous durable policy twins aligned', async () => { + const dataDir = await temporaryDataDirectory(); + const policy = { entryKind: 'directory' as const }; + applyRfc64OwnerOnlyPermissionsSyncV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + ); + + expect(() => assertRfc64FilesystemOwnerSyncV1(dataDir)).not.toThrow(); + await expect(assertRfc64FilesystemOwnerV1(dataDir)).resolves.toBeUndefined(); + expect(() => assertRfc64OwnerOnlyPermissionsSyncV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).not.toThrow(); + await expect(assertRfc64OwnerOnlyPermissionsV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).resolves.toBeUndefined(); + await expect(applyRfc64OwnerOnlyPermissionsV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).resolves.toBeUndefined(); + + if (process.platform !== 'win32') { + await chmod(dataDir, 0o755); + expect(() => assertRfc64OwnerOnlyPermissionsSyncV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).toThrow(/path mode 755/); + await expect(assertRfc64OwnerOnlyPermissionsV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).rejects.toThrow(/path mode 755/); + } + }); + it('rechecks NODE_ENV when the source-level test opener is invoked', async () => { const opener = createRfc64ControlObjectStoreTestOpenerV1(); const previousNodeEnv = process.env.NODE_ENV; @@ -293,7 +342,7 @@ describe('RFC-64 durable control-object store v1', () => { expect(store).not.toHaveProperty('advanceHead'); }); - it('is idempotent and serializes concurrent staging for exact digest keys', async () => { + it('converges concurrent staging through durable no-replace keys', async () => { const dataDir = await temporaryDataDirectory(); const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; const store = await createRfc64ControlObjectStoreTestOpenerV1({ @@ -313,6 +362,9 @@ describe('RFC-64 durable control-object store v1', () => { 'object.temp-written', 'object.temp-mode-secured', 'object.temp-fsynced', + 'object.temp-written', + 'object.temp-mode-secured', + 'object.temp-fsynced', 'object.published-no-replace', 'object.temp-unlinked', 'object.parent-fsynced', @@ -329,6 +381,9 @@ describe('RFC-64 durable control-object store v1', () => { 'signature.temp-written', 'signature.temp-mode-secured', 'signature.temp-fsynced', + 'signature.temp-written', + 'signature.temp-mode-secured', + 'signature.temp-fsynced', 'signature.published-no-replace', 'signature.temp-unlinked', 'signature.parent-fsynced', @@ -336,14 +391,6 @@ describe('RFC-64 durable control-object store v1', () => { 'signature.existing-parent-fsynced', ]; expect([...boundaries].sort()).toEqual([...expectedBoundaries].sort()); - expect(boundaries.indexOf('object.temp-written')) - .toBeLessThan(boundaries.indexOf('object.published-no-replace')); - expect(boundaries.indexOf('object.published-no-replace')) - .toBeLessThan(boundaries.indexOf('object.existing-fsynced')); - expect(boundaries.indexOf('signature.temp-written')) - .toBeLessThan(boundaries.indexOf('signature.published-no-replace')); - expect(boundaries.indexOf('signature.published-no-replace')) - .toBeLessThan(boundaries.indexOf('signature.existing-fsynced')); boundaries.length = 0; await store.stageVerifiedObjects([fixture]); expect(boundaries).toEqual([ @@ -356,7 +403,10 @@ describe('RFC-64 durable control-object store v1', () => { it('coexists and reads exact signature variants for one immutable object', async () => { const dataDir = await temporaryDataDirectory(); - const store = await openRfc64ControlObjectStoreV1(dataDir); + const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: (boundary) => boundaries.push(boundary), + })(dataDir); const [first, second] = await contractSignatureVariantsFixture(); expect(first.envelope.objectDigest).toBe(second.envelope.objectDigest); @@ -368,6 +418,8 @@ describe('RFC-64 durable control-object store v1', () => { const secondPaths = pathsFor(dataDir, second.envelope); expect(firstPaths.object).toBe(secondPaths.object); expect(firstPaths.signature).not.toBe(secondPaths.signature); + expect(boundaries.filter((boundary) => boundary === 'object.temp-written')).toHaveLength(1); + expect(boundaries.filter((boundary) => boundary === 'signature.temp-written')).toHaveLength(2); await expect(readFile(firstPaths.signature)).resolves.not.toHaveLength(0); await expect(readFile(secondPaths.signature)).resolves.not.toHaveLength(0); diff --git a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts index c7e8936d81..e9d5ef975b 100644 --- a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts +++ b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts @@ -1,6 +1,7 @@ import * as packageRoot from '../src/index.js'; import * as productionControlStoreModule from '../src/rfc64/control-object-store-v1.js'; import { DKGAgent as PublishedDkgAgent } from '@origintrail-official/dkg-agent'; +import { DKGAgent as LegacySubpathDkgAgent } from '@origintrail-official/dkg-agent/dist/dkg-agent.js'; type PackageRootHasRawControlStoreOpener = 'openRfc64ControlObjectStoreV1' extends keyof typeof packageRoot ? true : false; @@ -17,6 +18,8 @@ type ProductionModuleHasTestOpener = keyof typeof productionControlStoreModule ? true : false; type PublishedPackageRootHasDkgAgent = 'create' extends keyof typeof PublishedDkgAgent ? true : false; +type LegacySubpathHasDkgAgent = + 'create' extends keyof typeof LegacySubpathDkgAgent ? true : false; // The stable package root intentionally withholds the implementation-shaped // cache API until an RFC-64 public workflow consumes it. @@ -25,6 +28,7 @@ const packageRootHasControlStoreLayout: PackageRootHasControlStoreLayout = false const productionModuleHasRawControlStoreOpener: ProductionModuleHasRawControlStoreOpener = false; const productionModuleHasTestOpener: ProductionModuleHasTestOpener = false; const publishedPackageRootHasDkgAgent: PublishedPackageRootHasDkgAgent = true; +const legacySubpathHasDkgAgent: LegacySubpathHasDkgAgent = true; // @ts-expect-error Low-level store types are not part of the stable package root. type PackageRootStoreType = packageRoot.Rfc64ControlObjectStoreV1; @@ -37,5 +41,6 @@ void packageRootHasControlStoreLayout; void productionModuleHasRawControlStoreOpener; void productionModuleHasTestOpener; void publishedPackageRootHasDkgAgent; +void legacySubpathHasDkgAgent; void (undefined as PackageRootStoreType | undefined); void (undefined as PublishedInternalControlStoreModule | undefined); diff --git a/packages/agent/test/rfc64-persistence-owner.typecheck.ts b/packages/agent/test/rfc64-persistence-owner.typecheck.ts index e27b1c162a..1ec8b43926 100644 --- a/packages/agent/test/rfc64-persistence-owner.typecheck.ts +++ b/packages/agent/test/rfc64-persistence-owner.typecheck.ts @@ -12,6 +12,8 @@ persistence.inventory.close(); await persistence.controlObjects.close(); // @ts-expect-error the non-owning inventory view cannot mint sibling resources persistence.inventory.controlObjectStoreOwnership; +// @ts-expect-error startup purge authority is not a shared inventory operation +persistence.inventory.purgeNextStartupStaleCandidateBatch(); await openRfc64ControlObjectStoreForOwnedInventoryV1( ownedInventory.controlObjectStoreOwnership, From 7606698828653c5b565b8be1b38ee2b1af051c29 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:16:23 +0200 Subject: [PATCH 082/292] fix(agent): harden RFC-64 package and write boundaries --- packages/agent/package.json | 4 +- .../agent/src/rfc64/durable-file-store-v1.ts | 4 +- .../agent/src/rfc64/inventory-v1/candidate.ts | 45 +++++++++++++++++++ .../agent/src/rfc64/inventory-v1/index.ts | 1 + packages/agent/src/rfc64/persistence-v1.ts | 43 +----------------- .../rfc64-control-object-store-v1.test.ts | 18 ++++++++ ...fc64-control-store-public-api.typecheck.ts | 6 +++ 7 files changed, 77 insertions(+), 44 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index 9e6efc41b7..f3e8b5808b 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -10,7 +10,7 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, - "./dist/rfc64/control-object-store-v1-internal.js": null, + "./dist/rfc64/*": null, "./dist/*": "./dist/*" }, "scripts": { @@ -21,7 +21,7 @@ "test": "vitest run", "test:unit": "vitest run --config vitest.unit.config.ts", "test:types": "tsc --project tsconfig.type-tests.json", - "test:package-root": "node --input-type=module -e \"const root = await import('@origintrail-official/dkg-agent'); const legacy = await import('@origintrail-official/dkg-agent/dist/dkg-agent.js'); if (typeof root.DKGAgent !== 'function' || typeof legacy.DKGAgent !== 'function') throw new Error('published agent entry points did not expose DKGAgent'); try { await import('@origintrail-official/dkg-agent/dist/rfc64/control-object-store-v1-internal.js'); throw new Error('internal RFC-64 module unexpectedly resolved'); } catch (error) { if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; }\"", + "test:package-root": "node --input-type=module -e \"const root = await import('@origintrail-official/dkg-agent'); const legacy = await import('@origintrail-official/dkg-agent/dist/dkg-agent.js'); if (typeof root.DKGAgent !== 'function' || typeof legacy.DKGAgent !== 'function') throw new Error('published agent entry points did not expose DKGAgent'); for (const path of ['control-object-store-v1-internal.js','durable-file-store-v1.js','secure-filesystem-policy-v1.js']) { try { await import('@origintrail-official/dkg-agent/dist/rfc64/' + path); throw new Error('internal RFC-64 module unexpectedly resolved: ' + path); } catch (error) { if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; } }\"", "test:coverage": "vitest run --coverage", "clean": "rm -rf dist tsconfig.tsbuildinfo" }, diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index 1e15d19366..9cf210f3bd 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -110,11 +110,12 @@ export async function ensureRfc64SecureDirectoryTreeV1( target: string, containmentRoot: string, lifecycle: Rfc64DurableFileLifecycleV1, + containmentRootAccess: Rfc64ExistingAccessV1 = 'owner', ): Promise { await walkRfc64ContainedDirectoryTreeV1( target, containmentRoot, - 'owner', + containmentRootAccess, async (current) => { let created = false; try { @@ -162,6 +163,7 @@ async function putRfc64ExactBytesV1( dirname(targetPath), containmentRoot, lifecycle, + 'owner-only', ); const resolvedInput = { ...input, targetPath }; if (await reconcileRfc64ExistingImmutableV1(resolvedInput)) return; diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index 74b91f520b..ba34467c0c 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -262,6 +262,51 @@ export type Rfc64InventoryV1OperationsV1 = Pick< | 'deleteCandidateBucket' >; +/** + * Build the canonical non-owning candidate view next to the API that owns it. + * The aggregate persistence lifecycle supplies only its close fence; it does + * not duplicate the inventory operation surface or receive purge/close + * authority. + * @internal + */ +export function createRfc64InventoryOperationsViewV1( + inventory: Rfc64InventoryV1CandidateApi, + requireOwnerOpen: () => void, +): Rfc64InventoryV1OperationsV1 { + const fence = ( + operation: (...arguments_: TArguments) => TResult, + ): ((...arguments_: TArguments) => TResult) => + (...arguments_: TArguments): TResult => { + requireOwnerOpen(); + return operation(...arguments_); + }; + return Object.freeze({ + createCandidateSession: fence(inventory.createCandidateSession.bind(inventory)), + putVerifiedCandidateBucket: fence(inventory.putVerifiedCandidateBucket.bind(inventory)), + getCandidateBucket: fence(inventory.getCandidateBucket.bind(inventory)), + beginCandidateBucketRows: fence(inventory.beginCandidateBucketRows.bind(inventory)), + beginCandidateBucketDiff: fence(inventory.beginCandidateBucketDiff.bind(inventory)), + pageCandidateBucketRows: fence(inventory.pageCandidateBucketRows.bind(inventory)), + pageCandidateBucketAddedOrChanged: fence( + inventory.pageCandidateBucketAddedOrChanged.bind(inventory), + ), + pageCandidateBucketRemoved: fence( + inventory.pageCandidateBucketRemoved.bind(inventory), + ), + readVerifiedCandidateCatalogRow: fence( + inventory.readVerifiedCandidateCatalogRow.bind(inventory), + ), + verifyCandidateCatalogPrecommitV1: fence( + inventory.verifyCandidateCatalogPrecommitV1.bind(inventory), + ), + closeCandidateTraversal: fence(inventory.closeCandidateTraversal.bind(inventory)), + discardCandidateSessionBatch: fence( + inventory.discardCandidateSessionBatch.bind(inventory), + ), + deleteCandidateBucket: fence(inventory.deleteCandidateBucket.bind(inventory)), + }); +} + interface SessionContextV1 { readonly scopeHex: string; readonly authorHex: string; diff --git a/packages/agent/src/rfc64/inventory-v1/index.ts b/packages/agent/src/rfc64/inventory-v1/index.ts index e8f012cbb0..92d82b8395 100644 --- a/packages/agent/src/rfc64/inventory-v1/index.ts +++ b/packages/agent/src/rfc64/inventory-v1/index.ts @@ -2,6 +2,7 @@ // receive only the foundation API from openInventoryV1 and cannot supply an // unverified low-level reopen callback. export { + createRfc64InventoryOperationsViewV1, InventoryV1CandidateError, type CandidateCatalogPrecommitResultV1, type CandidateBucketDiffTraversalV1, diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index e2a7b3f1c5..c026cf8933 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -1,6 +1,6 @@ import { + createRfc64InventoryOperationsViewV1, openInventoryV1, - type Rfc64InventoryV1CandidateApi, type Rfc64InventoryV1Foundation, type Rfc64InventoryV1OperationsV1, } from './inventory-v1/index.js'; @@ -43,7 +43,7 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { ) { this.#ownedInventory = ownedInventory; this.#ownedControlObjectStore = ownedControlObjectStore; - this.inventory = createInventoryOperationsView( + this.inventory = createRfc64InventoryOperationsViewV1( ownedInventory, () => this.requireOpen(), ); @@ -84,45 +84,6 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { } } -/** The aggregate owner is the sole boundary that strips child lifecycle authority. */ -function createInventoryOperationsView( - inventory: Rfc64InventoryV1CandidateApi, - requireOwnerOpen: () => void, -): Rfc64InventoryV1OperationsV1 { - const fence = ( - operation: (...arguments_: TArguments) => TResult, - ): ((...arguments_: TArguments) => TResult) => - (...arguments_: TArguments): TResult => { - requireOwnerOpen(); - return operation(...arguments_); - }; - return Object.freeze({ - createCandidateSession: fence(inventory.createCandidateSession.bind(inventory)), - putVerifiedCandidateBucket: fence(inventory.putVerifiedCandidateBucket.bind(inventory)), - getCandidateBucket: fence(inventory.getCandidateBucket.bind(inventory)), - beginCandidateBucketRows: fence(inventory.beginCandidateBucketRows.bind(inventory)), - beginCandidateBucketDiff: fence(inventory.beginCandidateBucketDiff.bind(inventory)), - pageCandidateBucketRows: fence(inventory.pageCandidateBucketRows.bind(inventory)), - pageCandidateBucketAddedOrChanged: fence( - inventory.pageCandidateBucketAddedOrChanged.bind(inventory), - ), - pageCandidateBucketRemoved: fence( - inventory.pageCandidateBucketRemoved.bind(inventory), - ), - readVerifiedCandidateCatalogRow: fence( - inventory.readVerifiedCandidateCatalogRow.bind(inventory), - ), - verifyCandidateCatalogPrecommitV1: fence( - inventory.verifyCandidateCatalogPrecommitV1.bind(inventory), - ), - closeCandidateTraversal: fence(inventory.closeCandidateTraversal.bind(inventory)), - discardCandidateSessionBatch: fence( - inventory.discardCandidateSessionBatch.bind(inventory), - ), - deleteCandidateBucket: fence(inventory.deleteCandidateBucket.bind(inventory)), - }); -} - function createControlObjectOperationsView( controlObjectStore: Rfc64ControlObjectStoreV1, ): Rfc64ControlObjectOperationsV1 { diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 96ba63b4c1..17bd380242 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -858,6 +858,24 @@ describe('RFC-64 durable control-object store v1', () => { }, ); + it('rejects a permissive control-store root before publishing a new immutable key', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('permissive-root-after-open'); + const paths = pathsFor(dataDir, fixture.envelope); + if (process.platform === 'win32') { + grantWindowsEveryoneRead(paths.root, 'directory'); + } else { + await chmod(paths.root, 0o777); + } + + await expect(store.stageVerifiedObjects([fixture])) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + await expect(stat(paths.object)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(paths.signature)).rejects.toMatchObject({ code: 'ENOENT' }); + await store.close(); + }); + it.runIf(process.platform === 'win32')( 'rejects permissive existing Windows file and directory ACLs', async () => { diff --git a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts index e9d5ef975b..ca270d8668 100644 --- a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts +++ b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts @@ -35,6 +35,10 @@ type PackageRootStoreType = packageRoot.Rfc64ControlObjectStoreV1; // @ts-expect-error The package exports map blocks emitted internal subpaths. type PublishedInternalControlStoreModule = typeof import('@origintrail-official/dkg-agent/dist/rfc64/control-object-store-v1-internal.js'); +// @ts-expect-error The package exports map blocks the complete emitted RFC-64 implementation tree. +type PublishedDurableFileStoreModule = typeof import('@origintrail-official/dkg-agent/dist/rfc64/durable-file-store-v1.js'); +// @ts-expect-error The package exports map blocks the complete emitted RFC-64 implementation tree. +type PublishedSecureFilesystemPolicyModule = typeof import('@origintrail-official/dkg-agent/dist/rfc64/secure-filesystem-policy-v1.js'); void packageRootHasRawControlStoreOpener; void packageRootHasControlStoreLayout; @@ -44,3 +48,5 @@ void publishedPackageRootHasDkgAgent; void legacySubpathHasDkgAgent; void (undefined as PackageRootStoreType | undefined); void (undefined as PublishedInternalControlStoreModule | undefined); +void (undefined as PublishedDurableFileStoreModule | undefined); +void (undefined as PublishedSecureFilesystemPolicyModule | undefined); From 690272397634a82b88b340f148bc7e9de14fdc0f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:23:19 +0200 Subject: [PATCH 083/292] test(devnet): add deterministic RFC-64 evidence helper --- devnet/_bootstrap/RFC64_EVIDENCE.md | 68 ++ devnet/_bootstrap/package.json | 6 +- devnet/_bootstrap/rfc64-evidence.test.ts | 286 +++++++++ devnet/_bootstrap/rfc64-evidence.ts | 662 ++++++++++++++++++++ devnet/_bootstrap/vitest.evidence.config.ts | 15 + package.json | 1 + 6 files changed, 1036 insertions(+), 2 deletions(-) create mode 100644 devnet/_bootstrap/RFC64_EVIDENCE.md create mode 100644 devnet/_bootstrap/rfc64-evidence.test.ts create mode 100644 devnet/_bootstrap/rfc64-evidence.ts create mode 100644 devnet/_bootstrap/vitest.evidence.config.ts diff --git a/devnet/_bootstrap/RFC64_EVIDENCE.md b/devnet/_bootstrap/RFC64_EVIDENCE.md new file mode 100644 index 0000000000..7d93de8f84 --- /dev/null +++ b/devnet/_bootstrap/RFC64_EVIDENCE.md @@ -0,0 +1,68 @@ +# RFC-64 deterministic devnet evidence + +`rfc64-evidence.ts` is a protocol-independent evidence layer for RFC-64 Gate 0+ +devnet harnesses. It does not discover peers, transfer data, retry operations, +or modify runtime sync behavior. A harness supplies the expected and observed +Knowledge Asset datasets after its own operation completes. + +## Deterministic snapshot rules + +1. UALs are parsed with `parseDeterministicKnowledgeAssetUal`, converted to the + canonical protocol spelling, rejected on duplicate canonical identity, and + sorted lexically. +2. Each KA's semantic N-Quads are canonicalized with the existing RDFC-1.0 + helper, deduplicated, sorted lexically, joined with LF, and terminated by one + LF when non-empty. +3. `ualsSha256` is SHA-256 over the sorted UAL list (`UAL + LF` per entry). +4. Each `semanticNQuadsSha256` is SHA-256 over that KA's exact canonical + N-Quads UTF-8 bytes. +5. The snapshot-level semantic digest is SHA-256 over the domain string + `rfc64-semantic-nquads-manifest/v1\n` followed by stable JSON containing the + sorted `(UAL, quadCount, per-KA digest)` manifest. +6. The stable JSON writer recursively sorts object keys, uses two-space + indentation, rejects lossy/non-finite values, and writes one trailing LF. + +Snapshots contain KA and quad counts plus exact digests, without embedding the +potentially large N-Quads payload. Validation recomputes every redundant count +and manifest digest before comparison. Duplicate UALs, malformed snapshots, +missing KAs, unexpected KAs, count differences, and digest differences cannot +produce a passing comparison. + +The run artifact adds the stable gate/observer label, selected source peer, +canonical ISO timing and duration, attempt/retry/failure details, expected and +observed snapshots, and the derived comparison. `passed` is derived from the +comparison and terminal failure; a caller cannot manually force it to `true`. + +## Harness use + +```ts +import { + createRfc64DevnetEvidence, + createRfc64SemanticSnapshot, + writeStableJsonArtifact, +} from '@origintrail-official/dkg-devnet-harness/rfc64-evidence'; + +const expected = await createRfc64SemanticSnapshot(expectedAssets); +const observed = await createRfc64SemanticSnapshot(observedAssets); +const evidence = createRfc64DevnetEvidence({ + gate: 'gate-1-semantic-recovery', + observer: 'receiver-node-2', + sourcePeerId, + startedAt, + completedAt: new Date(), + attemptCount, + retryFailures, + terminalFailure: null, + expected, + observed, +}); + +writeStableJsonArtifact(artifactPath, evidence); +if (!evidence.passed) throw new Error('RFC-64 evidence gate failed'); +``` + +Run the focused no-devnet verification with: + +```sh +pnpm test:devnet:rfc64-evidence +``` diff --git a/devnet/_bootstrap/package.json b/devnet/_bootstrap/package.json index 0acdc5a0f3..95b1e02849 100644 --- a/devnet/_bootstrap/package.json +++ b/devnet/_bootstrap/package.json @@ -4,10 +4,12 @@ "private": true, "type": "module", "exports": { - ".": "./harness.ts" + ".": "./harness.ts", + "./rfc64-evidence": "./rfc64-evidence.ts" }, "scripts": { - "test:smoke": "vitest run --config vitest.config.ts" + "test:smoke": "vitest run --config vitest.config.ts", + "test:rfc64-evidence": "vitest run --config vitest.evidence.config.ts" }, "dependencies": { "ethers": "^6.16.0" diff --git a/devnet/_bootstrap/rfc64-evidence.test.ts b/devnet/_bootstrap/rfc64-evidence.test.ts new file mode 100644 index 0000000000..6ab12af480 --- /dev/null +++ b/devnet/_bootstrap/rfc64-evidence.test.ts @@ -0,0 +1,286 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + Rfc64EvidenceMismatchError, + Rfc64EvidenceValidationError, + assertRfc64SemanticSnapshotsEqual, + canonicalizeSemanticNQuads, + compareRfc64SemanticSnapshots, + createRfc64DevnetEvidence, + createRfc64SemanticSnapshot, + stableJsonStringify, + validateRfc64SemanticSnapshot, + writeStableJsonArtifact, + type Rfc64SemanticSnapshotV1, +} from './rfc64-evidence.js'; + +const AUTHOR_A = '0xa111111111111111111111111111111111111111'; +const AUTHOR_B = '0xb222222222222222222222222222222222222222'; +const UAL_A = `did:dkg:otp:20430/${AUTHOR_A}/7`; +const UAL_B = `did:dkg:otp:20430/${AUTHOR_B}/8`; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('RFC-64 semantic snapshot evidence', () => { + it('canonicalizes blank nodes, line order, duplicate quads, and UAL aliases', async () => { + const first = await createRfc64SemanticSnapshot([ + { + ual: ` did:dkg:otp:20430/${AUTHOR_B.toUpperCase().replace('0X', '0x')}/0008 `, + semanticNQuads: [ + '_:right "B" .', + '_:left _:right .', + ], + }, + { + ual: UAL_A, + semanticNQuads: [ + ' "Alice" .', + ' "Alice" .', + ], + }, + ]); + const second = await createRfc64SemanticSnapshot([ + { + ual: `did:dkg:otp:20430/${AUTHOR_A}/07`, + semanticNQuads: + ' "Alice" .\r\n', + }, + { + ual: UAL_B, + semanticNQuads: [ + '_:other1 _:other2 .', + '_:other2 "B" .', + ], + }, + ]); + + expect(first).toEqual(second); + expect(first.knowledgeAssets.map((asset) => asset.ual)).toEqual([UAL_A, UAL_B]); + expect(first).toMatchObject({ kaCount: 2, quadCount: 3 }); + expect(first.ualsSha256).toBe( + 'sha256:b7365d82e247bbe3932e3f732e6419dbc677ff57721fae6f2c1cd1436522f384', + ); + }); + + it('pins the exact canonical N-Quads bytes and SHA-256 digest', async () => { + const canonical = await canonicalizeSemanticNQuads([ + ' "B" .', + ' "A" .', + ]); + + expect(canonical.text).toBe( + ' "A" .\n' + + ' "B" .\n', + ); + expect(canonical.sha256).toBe( + 'sha256:d4495ca31733ad54c3532fb44d33a23a419f71cf8e7164eeffb37c32718e9d7e', + ); + }); + + it('rejects duplicate canonical UALs instead of silently merging observations', async () => { + await expect(createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: '' }, + { + ual: `did:dkg:otp:20430/${AUTHOR_A.toUpperCase().replace('0X', '0x')}/007`, + semanticNQuads: '', + }, + ])).rejects.toThrow(/Duplicate canonical Knowledge Asset UAL/); + }); + + it('rejects malformed N-Quads instead of hashing unchecked text', async () => { + await expect(canonicalizeSemanticNQuads('this is not N-Quads')) + .rejects.toThrow(/Invalid semantic N-Quads/); + }); + + it('reports granular mismatches and the assert helper fails closed', async () => { + const expected = await createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: ' "expected" .' }, + { ual: UAL_B, semanticNQuads: ' "expected" .' }, + ]); + const observed = await createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: ' "observed" .' }, + ]); + + expect(compareRfc64SemanticSnapshots(expected, observed).mismatches) + .toEqual([ + { + code: 'SEMANTIC_NQUADS_DIGEST_MISMATCH', + ual: UAL_A, + expected: expected.knowledgeAssets[0]!.semanticNQuadsSha256, + observed: observed.knowledgeAssets[0]!.semanticNQuadsSha256, + }, + { code: 'KA_MISSING', ual: UAL_B }, + ]); + expect(() => assertRfc64SemanticSnapshotsEqual(expected, observed)) + .toThrow(Rfc64EvidenceMismatchError); + }); + + it('rejects a snapshot whose redundant digest was tampered with', async () => { + const snapshot = await createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: ' "A" .' }, + ]); + const tampered = { + ...snapshot, + knowledgeAssets: snapshot.knowledgeAssets.map((asset) => ({ + ...asset, + semanticNQuadsSha256: `sha256:${'0'.repeat(64)}`, + })), + } as Rfc64SemanticSnapshotV1; + + expect(() => validateRfc64SemanticSnapshot(tampered)) + .toThrow(/semanticNQuadsSha256 .* does not equal computed/); + }); +}); + +describe('RFC-64 devnet run artifact', () => { + it('passes only an exact observation without a terminal failure', async () => { + const snapshot = await createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: ' "exact" .' }, + ]); + const evidence = createRfc64DevnetEvidence({ + gate: 'gate-1-semantic-recovery', + observer: 'receiver-node-2', + sourcePeerId: '12D3KooWSource', + startedAt: '2026-07-19T12:00:00Z', + completedAt: '2026-07-19T12:00:01Z', + attemptCount: 1, + expected: snapshot, + observed: snapshot, + }); + + expect(evidence).toMatchObject({ + passed: true, + comparison: { passed: true, mismatches: [] }, + attempts: { total: 1, retries: 0, failures: [] }, + terminalFailure: null, + }); + }); + + it('records peer, timing, retry, failure, counts, and a fail-closed comparison', async () => { + const expected = await createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: ' "expected" .' }, + ]); + const observed = await createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: ' "observed" .' }, + ]); + + const evidence = createRfc64DevnetEvidence({ + gate: 'gate-1-semantic-recovery', + observer: 'receiver-node-2', + sourcePeerId: '12D3KooWSource', + startedAt: '2026-07-19T12:00:00.000Z', + completedAt: '2026-07-19T12:00:01.250Z', + attemptCount: 2, + retryFailures: [{ + attempt: 1, + code: 'PEER_STREAM_RESET', + message: 'source reset the first stream', + retryable: true, + }], + expected, + observed, + }); + + expect(evidence).toMatchObject({ + sourcePeerId: '12D3KooWSource', + timing: { durationMs: 1_250 }, + attempts: { total: 2, retries: 1 }, + comparison: { passed: false }, + terminalFailure: null, + passed: false, + }); + expect(evidence.expected).toMatchObject({ kaCount: 1, quadCount: 1 }); + }); + + it('records a missing observation as failed when discovery/fetch terminates', async () => { + const expected = await createRfc64SemanticSnapshot([]); + const evidence = createRfc64DevnetEvidence({ + gate: 'gate-1-discovery', + observer: 'receiver-node-2', + sourcePeerId: null, + startedAt: '2026-07-19T12:00:00Z', + completedAt: '2026-07-19T12:00:05Z', + attemptCount: 1, + terminalFailure: { + code: 'NO_SOURCE_PEER', + message: 'no eligible source peer discovered', + retryable: true, + }, + expected, + observed: null, + }); + + expect(evidence.passed).toBe(false); + expect(evidence.comparison.mismatches).toEqual([{ + code: 'OBSERVED_SNAPSHOT_MISSING', + expected: expected.semanticNQuadsSha256, + observed: null, + }]); + }); + + it('does not let callers omit terminal failure evidence for a missing observation', async () => { + const expected = await createRfc64SemanticSnapshot([]); + expect(() => createRfc64DevnetEvidence({ + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: null, + startedAt: '2026-07-19T12:00:00Z', + completedAt: '2026-07-19T12:00:01Z', + attemptCount: 1, + expected, + observed: null, + })).toThrow(/missing observed snapshot requires terminalFailure/); + }); + + it('requires evidence for every retried attempt', async () => { + const snapshot = await createRfc64SemanticSnapshot([]); + expect(() => createRfc64DevnetEvidence({ + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + startedAt: '2026-07-19T12:00:00Z', + completedAt: '2026-07-19T12:00:01Z', + attemptCount: 2, + expected: snapshot, + observed: snapshot, + })).toThrow(/one failure for each of the 1 retried attempts/); + }); + + it('writes byte-identical stable JSON independent of object insertion order', () => { + const directory = mkdtempSync(join(tmpdir(), 'rfc64-evidence-')); + temporaryDirectories.push(directory); + const firstPath = join(directory, 'nested', 'first.json'); + const secondPath = join(directory, 'second.json'); + const firstValue = { z: 3, nested: { b: true, a: 'x' }, a: [2, 1] }; + const secondValue = { a: [2, 1], nested: { a: 'x', b: true }, z: 3 }; + + const first = writeStableJsonArtifact(firstPath, firstValue); + const second = writeStableJsonArtifact(secondPath, secondValue); + const firstBytes = readFileSync(firstPath); + const secondBytes = readFileSync(secondPath); + + expect(firstBytes.equals(secondBytes)).toBe(true); + expect(first).toEqual(second); + expect(first.sha256).toBe( + `sha256:${createHash('sha256').update(firstBytes).digest('hex')}`, + ); + expect(firstBytes.toString('utf8')).toBe(stableJsonStringify(firstValue)); + expect(firstBytes.at(-1)).toBe(0x0a); + }); + + it('rejects lossy JSON values', () => { + expect(() => stableJsonStringify({ value: undefined })) + .toThrow(Rfc64EvidenceValidationError); + expect(() => stableJsonStringify({ value: Number.NaN })) + .toThrow(/non-finite number/); + }); +}); diff --git a/devnet/_bootstrap/rfc64-evidence.ts b/devnet/_bootstrap/rfc64-evidence.ts new file mode 100644 index 0000000000..44779546df --- /dev/null +++ b/devnet/_bootstrap/rfc64-evidence.ts @@ -0,0 +1,662 @@ +/** + * Deterministic semantic evidence for RFC-64 devnet gates. + * + * This module deliberately owns evidence formatting only. It does not discover + * peers, fetch Knowledge Assets, retry transfers, or make sync decisions. + * Harnesses pass their observations in after those protocol-owned operations + * finish, then persist the returned fail-closed comparison artifact. + */ +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { canonicalize } from '../../packages/core/src/crypto/canonicalize.js'; +import { parseDeterministicKnowledgeAssetUal } from '../../packages/core/src/ka-content-scope.js'; + +export const RFC64_SEMANTIC_SNAPSHOT_SCHEMA = + 'rfc64-semantic-snapshot/v1' as const; +export const RFC64_DEVNET_EVIDENCE_SCHEMA = + 'rfc64-devnet-evidence/v1' as const; + +const SEMANTIC_MANIFEST_DOMAIN = + 'rfc64-semantic-nquads-manifest/v1\n'; +const SHA256_RE = /^sha256:[0-9a-f]{64}$/; + +export type Sha256Digest = `sha256:${string}`; + +export interface Rfc64KnowledgeAssetObservation { + readonly ual: string; + /** One N-Quads document or a list of N-Quads document fragments. */ + readonly semanticNQuads: string | readonly string[]; +} + +export interface CanonicalSemanticNQuads { + /** RDFC-1.0 canonical, deduplicated, lexically sorted N-Quads lines. */ + readonly lines: readonly string[]; + /** Canonical lines joined with LF and one trailing LF when non-empty. */ + readonly text: string; + readonly quadCount: number; + /** SHA-256 of the UTF-8 bytes in {@link text}. */ + readonly sha256: Sha256Digest; +} + +export interface Rfc64KnowledgeAssetEvidenceV1 { + readonly ual: string; + readonly quadCount: number; + readonly semanticNQuadsSha256: Sha256Digest; +} + +export interface Rfc64SemanticSnapshotV1 { + readonly schemaVersion: typeof RFC64_SEMANTIC_SNAPSHOT_SCHEMA; + readonly kaCount: number; + readonly quadCount: number; + /** SHA-256 of sorted canonical UALs, one UTF-8 UAL plus LF per entry. */ + readonly ualsSha256: Sha256Digest; + /** + * SHA-256 of the domain-separated, stable JSON manifest of per-KA UALs, + * quad counts, and canonical N-Quads digests. + */ + readonly semanticNQuadsSha256: Sha256Digest; + readonly knowledgeAssets: readonly Rfc64KnowledgeAssetEvidenceV1[]; +} + +export type Rfc64SnapshotMismatchCode = + | 'OBSERVED_SNAPSHOT_MISSING' + | 'KA_MISSING' + | 'KA_UNEXPECTED' + | 'QUAD_COUNT_MISMATCH' + | 'SEMANTIC_NQUADS_DIGEST_MISMATCH'; + +export interface Rfc64SnapshotMismatchV1 { + readonly code: Rfc64SnapshotMismatchCode; + readonly ual?: string; + readonly expected?: number | string; + readonly observed?: number | string | null; +} + +export interface Rfc64SnapshotComparisonV1 { + readonly passed: boolean; + readonly mismatches: readonly Rfc64SnapshotMismatchV1[]; +} + +export interface Rfc64FailureV1 { + readonly code: string; + readonly message: string; + readonly retryable: boolean; +} + +export interface Rfc64RetryFailureInput extends Rfc64FailureV1 { + /** One-based attempt which failed before a later attempt ran. */ + readonly attempt: number; +} + +export interface Rfc64RetryFailureV1 extends Rfc64FailureV1 { + readonly attempt: number; +} + +export interface Rfc64DevnetEvidenceInput { + readonly gate: string; + /** Stable harness label such as `receiver-node-2`, never a temp path. */ + readonly observer: string; + /** Null when discovery failed before a source was selected. */ + readonly sourcePeerId: string | null; + readonly startedAt: Date | string; + readonly completedAt: Date | string; + /** Total calls, including the first call and the terminal call. */ + readonly attemptCount: number; + /** Failures which led to another attempt; the terminal failure is separate. */ + readonly retryFailures?: readonly Rfc64RetryFailureInput[]; + readonly terminalFailure?: Rfc64FailureV1 | null; + readonly expected: Rfc64SemanticSnapshotV1; + readonly observed: Rfc64SemanticSnapshotV1 | null; +} + +export interface Rfc64DevnetEvidenceV1 { + readonly schemaVersion: typeof RFC64_DEVNET_EVIDENCE_SCHEMA; + readonly gate: string; + readonly observer: string; + readonly sourcePeerId: string | null; + readonly timing: { + readonly startedAt: string; + readonly completedAt: string; + readonly durationMs: number; + }; + readonly attempts: { + readonly total: number; + readonly retries: number; + readonly failures: readonly Rfc64RetryFailureV1[]; + }; + readonly expected: Rfc64SemanticSnapshotV1; + readonly observed: Rfc64SemanticSnapshotV1 | null; + readonly comparison: Rfc64SnapshotComparisonV1; + readonly terminalFailure: Rfc64FailureV1 | null; + readonly passed: boolean; +} + +export interface WrittenStableJsonArtifact { + readonly byteLength: number; + readonly sha256: Sha256Digest; +} + +export class Rfc64EvidenceValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'Rfc64EvidenceValidationError'; + } +} + +export class Rfc64EvidenceMismatchError extends Error { + readonly comparison: Rfc64SnapshotComparisonV1; + + constructor(comparison: Rfc64SnapshotComparisonV1) { + super( + `RFC-64 semantic snapshot mismatch: ${comparison.mismatches + .map((entry) => `${entry.code}${entry.ual ? `:${entry.ual}` : ''}`) + .join(', ')}`, + ); + this.name = 'Rfc64EvidenceMismatchError'; + this.comparison = comparison; + } +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function sha256Text(text: string): Sha256Digest { + return `sha256:${createHash('sha256').update(text, 'utf8').digest('hex')}`; +} + +function canonicalUal(rawUal: string): string { + if (typeof rawUal !== 'string') { + throw new Rfc64EvidenceValidationError('Knowledge Asset UAL must be a string'); + } + try { + return parseDeterministicKnowledgeAssetUal(rawUal).ual; + } catch (error) { + throw new Rfc64EvidenceValidationError( + `Invalid RFC-64 Knowledge Asset UAL ${JSON.stringify(rawUal)}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +function nquadsInputText(input: string | readonly string[]): string { + if (typeof input === 'string') return input; + if (!Array.isArray(input)) { + throw new Rfc64EvidenceValidationError( + 'semanticNQuads must be a string or an array of strings', + ); + } + for (const [index, fragment] of input.entries()) { + if (typeof fragment !== 'string') { + throw new Rfc64EvidenceValidationError( + `semanticNQuads[${index}] must be a string`, + ); + } + } + return input.join('\n'); +} + +/** + * Canonicalize a semantic RDF dataset with the protocol's RDFC-1.0 helper, + * then explicitly deduplicate and sort its lines for byte-stable evidence. + */ +export async function canonicalizeSemanticNQuads( + input: string | readonly string[], +): Promise { + let canonical: string; + try { + canonical = await canonicalize(nquadsInputText(input)); + } catch (error) { + throw new Rfc64EvidenceValidationError( + `Invalid semantic N-Quads: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const lines = [...new Set( + canonical + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0), + )].sort(compareText); + const text = lines.length === 0 ? '' : `${lines.join('\n')}\n`; + return { + lines, + text, + quadCount: lines.length, + sha256: sha256Text(text), + }; +} + +function ualsDigest(assets: readonly Rfc64KnowledgeAssetEvidenceV1[]): Sha256Digest { + const text = assets.length === 0 + ? '' + : `${assets.map((asset) => asset.ual).join('\n')}\n`; + return sha256Text(text); +} + +function semanticManifestDigest( + assets: readonly Rfc64KnowledgeAssetEvidenceV1[], +): Sha256Digest { + const manifest = assets.map((asset) => ({ + quadCount: asset.quadCount, + semanticNQuadsSha256: asset.semanticNQuadsSha256, + ual: asset.ual, + })); + return sha256Text( + `${SEMANTIC_MANIFEST_DOMAIN}${stableJsonStringify(manifest)}`, + ); +} + +/** Build a compact, order-independent semantic snapshot from raw observations. */ +export async function createRfc64SemanticSnapshot( + observations: readonly Rfc64KnowledgeAssetObservation[], +): Promise { + if (!Array.isArray(observations)) { + throw new Rfc64EvidenceValidationError('observations must be an array'); + } + + const assets = await Promise.all(observations.map(async (observation, index) => { + if (!observation || typeof observation !== 'object') { + throw new Rfc64EvidenceValidationError( + `observations[${index}] must be an object`, + ); + } + const ual = canonicalUal(observation.ual); + const nquads = await canonicalizeSemanticNQuads(observation.semanticNQuads); + return { + ual, + quadCount: nquads.quadCount, + semanticNQuadsSha256: nquads.sha256, + } satisfies Rfc64KnowledgeAssetEvidenceV1; + })); + + assets.sort((left, right) => compareText(left.ual, right.ual)); + for (let index = 1; index < assets.length; index += 1) { + if (assets[index - 1]!.ual === assets[index]!.ual) { + throw new Rfc64EvidenceValidationError( + `Duplicate canonical Knowledge Asset UAL: ${assets[index]!.ual}`, + ); + } + } + + const snapshot: Rfc64SemanticSnapshotV1 = { + schemaVersion: RFC64_SEMANTIC_SNAPSHOT_SCHEMA, + kaCount: assets.length, + quadCount: assets.reduce((sum, asset) => sum + asset.quadCount, 0), + ualsSha256: ualsDigest(assets), + semanticNQuadsSha256: semanticManifestDigest(assets), + knowledgeAssets: assets, + }; + return validateRfc64SemanticSnapshot(snapshot); +} + +function assertNonNegativeSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Rfc64EvidenceValidationError( + `${label} must be a non-negative safe integer`, + ); + } + return value as number; +} + +function assertDigest(value: unknown, label: string): asserts value is Sha256Digest { + if (typeof value !== 'string' || !SHA256_RE.test(value)) { + throw new Rfc64EvidenceValidationError( + `${label} must be a lowercase sha256:<64-hex> digest`, + ); + } +} + +/** + * Validate every redundant count/digest in a snapshot before it is compared. + * A malformed or self-inconsistent snapshot is rejected, never treated as an + * ordinary mismatch which a caller might accidentally ignore. + */ +export function validateRfc64SemanticSnapshot( + snapshot: Rfc64SemanticSnapshotV1, +): Rfc64SemanticSnapshotV1 { + if (!snapshot || typeof snapshot !== 'object') { + throw new Rfc64EvidenceValidationError('snapshot must be an object'); + } + if (snapshot.schemaVersion !== RFC64_SEMANTIC_SNAPSHOT_SCHEMA) { + throw new Rfc64EvidenceValidationError( + `Unsupported semantic snapshot schema: ${String(snapshot.schemaVersion)}`, + ); + } + const kaCount = assertNonNegativeSafeInteger(snapshot.kaCount, 'snapshot.kaCount'); + const quadCount = assertNonNegativeSafeInteger( + snapshot.quadCount, + 'snapshot.quadCount', + ); + assertDigest(snapshot.ualsSha256, 'snapshot.ualsSha256'); + assertDigest( + snapshot.semanticNQuadsSha256, + 'snapshot.semanticNQuadsSha256', + ); + if (!Array.isArray(snapshot.knowledgeAssets)) { + throw new Rfc64EvidenceValidationError( + 'snapshot.knowledgeAssets must be an array', + ); + } + + let previousUal: string | null = null; + let actualQuadCount = 0; + for (const [index, asset] of snapshot.knowledgeAssets.entries()) { + if (!asset || typeof asset !== 'object') { + throw new Rfc64EvidenceValidationError( + `snapshot.knowledgeAssets[${index}] must be an object`, + ); + } + const ual = canonicalUal(asset.ual); + if (ual !== asset.ual) { + throw new Rfc64EvidenceValidationError( + `snapshot.knowledgeAssets[${index}].ual is not canonical: ${asset.ual}`, + ); + } + if (previousUal !== null && compareText(previousUal, ual) >= 0) { + throw new Rfc64EvidenceValidationError( + 'snapshot.knowledgeAssets must contain unique UALs in lexical order', + ); + } + previousUal = ual; + actualQuadCount += assertNonNegativeSafeInteger( + asset.quadCount, + `snapshot.knowledgeAssets[${index}].quadCount`, + ); + assertDigest( + asset.semanticNQuadsSha256, + `snapshot.knowledgeAssets[${index}].semanticNQuadsSha256`, + ); + } + + if (kaCount !== snapshot.knowledgeAssets.length) { + throw new Rfc64EvidenceValidationError( + `snapshot.kaCount ${kaCount} does not equal knowledgeAssets.length ${snapshot.knowledgeAssets.length}`, + ); + } + if (quadCount !== actualQuadCount) { + throw new Rfc64EvidenceValidationError( + `snapshot.quadCount ${quadCount} does not equal per-KA total ${actualQuadCount}`, + ); + } + const actualUalsDigest = ualsDigest(snapshot.knowledgeAssets); + if (snapshot.ualsSha256 !== actualUalsDigest) { + throw new Rfc64EvidenceValidationError( + `snapshot.ualsSha256 ${snapshot.ualsSha256} does not equal computed ${actualUalsDigest}`, + ); + } + const actualSemanticDigest = semanticManifestDigest(snapshot.knowledgeAssets); + if (snapshot.semanticNQuadsSha256 !== actualSemanticDigest) { + throw new Rfc64EvidenceValidationError( + `snapshot.semanticNQuadsSha256 ${snapshot.semanticNQuadsSha256} does not equal computed ${actualSemanticDigest}`, + ); + } + return snapshot; +} + +/** Compare two validated snapshots and return a stable, granular diff. */ +export function compareRfc64SemanticSnapshots( + expected: Rfc64SemanticSnapshotV1, + observed: Rfc64SemanticSnapshotV1, +): Rfc64SnapshotComparisonV1 { + validateRfc64SemanticSnapshot(expected); + validateRfc64SemanticSnapshot(observed); + + const expectedByUal = new Map( + expected.knowledgeAssets.map((asset) => [asset.ual, asset] as const), + ); + const observedByUal = new Map( + observed.knowledgeAssets.map((asset) => [asset.ual, asset] as const), + ); + const allUals = [...new Set([ + ...expectedByUal.keys(), + ...observedByUal.keys(), + ])].sort(compareText); + const mismatches: Rfc64SnapshotMismatchV1[] = []; + + for (const ual of allUals) { + const expectedAsset = expectedByUal.get(ual); + const observedAsset = observedByUal.get(ual); + if (!observedAsset) { + mismatches.push({ code: 'KA_MISSING', ual }); + continue; + } + if (!expectedAsset) { + mismatches.push({ code: 'KA_UNEXPECTED', ual }); + continue; + } + if (expectedAsset.quadCount !== observedAsset.quadCount) { + mismatches.push({ + code: 'QUAD_COUNT_MISMATCH', + ual, + expected: expectedAsset.quadCount, + observed: observedAsset.quadCount, + }); + } + if ( + expectedAsset.semanticNQuadsSha256 + !== observedAsset.semanticNQuadsSha256 + ) { + mismatches.push({ + code: 'SEMANTIC_NQUADS_DIGEST_MISMATCH', + ual, + expected: expectedAsset.semanticNQuadsSha256, + observed: observedAsset.semanticNQuadsSha256, + }); + } + } + + return { passed: mismatches.length === 0, mismatches }; +} + +/** Compare and throw on any missing, unexpected, or content-mismatched KA. */ +export function assertRfc64SemanticSnapshotsEqual( + expected: Rfc64SemanticSnapshotV1, + observed: Rfc64SemanticSnapshotV1, +): void { + const comparison = compareRfc64SemanticSnapshots(expected, observed); + if (!comparison.passed) throw new Rfc64EvidenceMismatchError(comparison); +} + +function requiredLabel(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Rfc64EvidenceValidationError(`${label} must be a non-empty string`); + } + return value.trim(); +} + +function canonicalFailure(value: Rfc64FailureV1, label: string): Rfc64FailureV1 { + if (!value || typeof value !== 'object') { + throw new Rfc64EvidenceValidationError(`${label} must be an object`); + } + if (typeof value.retryable !== 'boolean') { + throw new Rfc64EvidenceValidationError(`${label}.retryable must be boolean`); + } + return { + code: requiredLabel(value.code, `${label}.code`), + message: requiredLabel(value.message, `${label}.message`), + retryable: value.retryable, + }; +} + +function canonicalInstant(value: Date | string, label: string): { + readonly epochMs: number; + readonly iso: string; +} { + const date = value instanceof Date ? value : new Date(value); + const epochMs = date.getTime(); + if (!Number.isFinite(epochMs)) { + throw new Rfc64EvidenceValidationError(`${label} must be a valid timestamp`); + } + return { epochMs, iso: date.toISOString() }; +} + +/** + * Combine deterministic expected/observed snapshots with transport evidence. + * `passed` is derived: callers cannot mark a mismatch or terminal failure green. + */ +export function createRfc64DevnetEvidence( + input: Rfc64DevnetEvidenceInput, +): Rfc64DevnetEvidenceV1 { + const gate = requiredLabel(input.gate, 'gate'); + const observer = requiredLabel(input.observer, 'observer'); + const sourcePeerId = input.sourcePeerId === null + ? null + : requiredLabel(input.sourcePeerId, 'sourcePeerId'); + const startedAt = canonicalInstant(input.startedAt, 'startedAt'); + const completedAt = canonicalInstant(input.completedAt, 'completedAt'); + if (completedAt.epochMs < startedAt.epochMs) { + throw new Rfc64EvidenceValidationError( + 'completedAt must not be before startedAt', + ); + } + const attemptCount = assertNonNegativeSafeInteger( + input.attemptCount, + 'attemptCount', + ); + if (attemptCount < 1) { + throw new Rfc64EvidenceValidationError('attemptCount must be at least 1'); + } + + validateRfc64SemanticSnapshot(input.expected); + if (input.observed !== null) validateRfc64SemanticSnapshot(input.observed); + const terminalFailure = input.terminalFailure == null + ? null + : canonicalFailure(input.terminalFailure, 'terminalFailure'); + if (input.observed === null && terminalFailure === null) { + throw new Rfc64EvidenceValidationError( + 'a missing observed snapshot requires terminalFailure evidence', + ); + } + + const failures = (input.retryFailures ?? []).map((failure, index) => { + const canonical = canonicalFailure(failure, `retryFailures[${index}]`); + const attempt = assertNonNegativeSafeInteger( + failure.attempt, + `retryFailures[${index}].attempt`, + ); + if (attempt < 1 || attempt >= attemptCount) { + throw new Rfc64EvidenceValidationError( + `retryFailures[${index}].attempt must be between 1 and attemptCount - 1`, + ); + } + return { attempt, ...canonical } satisfies Rfc64RetryFailureV1; + }).sort((left, right) => left.attempt - right.attempt); + for (let index = 1; index < failures.length; index += 1) { + if (failures[index - 1]!.attempt === failures[index]!.attempt) { + throw new Rfc64EvidenceValidationError( + `retryFailures contains duplicate attempt ${failures[index]!.attempt}`, + ); + } + } + if (failures.length !== attemptCount - 1) { + throw new Rfc64EvidenceValidationError( + `retryFailures must contain one failure for each of the ${attemptCount - 1} retried attempts`, + ); + } + + const comparison: Rfc64SnapshotComparisonV1 = input.observed === null + ? { + passed: false, + mismatches: [{ + code: 'OBSERVED_SNAPSHOT_MISSING', + expected: input.expected.semanticNQuadsSha256, + observed: null, + }], + } + : compareRfc64SemanticSnapshots(input.expected, input.observed); + + return { + schemaVersion: RFC64_DEVNET_EVIDENCE_SCHEMA, + gate, + observer, + sourcePeerId, + timing: { + startedAt: startedAt.iso, + completedAt: completedAt.iso, + durationMs: completedAt.epochMs - startedAt.epochMs, + }, + attempts: { + total: attemptCount, + retries: attemptCount - 1, + failures, + }, + expected: input.expected, + observed: input.observed, + comparison, + terminalFailure, + passed: comparison.passed && terminalFailure === null, + }; +} + +function stableJsonValue(value: unknown, path: string, ancestors: Set): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Rfc64EvidenceValidationError(`${path} contains a non-finite number`); + } + return Object.is(value, -0) ? 0 : value; + } + if (Array.isArray(value)) { + if (ancestors.has(value)) { + throw new Rfc64EvidenceValidationError(`${path} contains a cycle`); + } + ancestors.add(value); + const result = value.map((entry, index) => + stableJsonValue(entry, `${path}[${index}]`, ancestors)); + ancestors.delete(value); + return result; + } + if (typeof value === 'object' && value !== null) { + if (ancestors.has(value)) { + throw new Rfc64EvidenceValidationError(`${path} contains a cycle`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Rfc64EvidenceValidationError( + `${path} must contain only plain JSON objects`, + ); + } + ancestors.add(value); + const source = value as Record; + const result: Record = {}; + for (const key of Object.keys(source).sort(compareText)) { + const entry = source[key]; + if (entry === undefined || typeof entry === 'bigint' || typeof entry === 'function') { + throw new Rfc64EvidenceValidationError( + `${path}.${key} is not a stable JSON value`, + ); + } + result[key] = stableJsonValue(entry, `${path}.${key}`, ancestors); + } + ancestors.delete(value); + return result; + } + throw new Rfc64EvidenceValidationError(`${path} is not a stable JSON value`); +} + +/** Recursively sort object keys and append exactly one LF. */ +export function stableJsonStringify(value: unknown): string { + return `${JSON.stringify(stableJsonValue(value, '$', new Set()), null, 2)}\n`; +} + +/** Write a byte-stable JSON artifact and return its exact byte count/digest. */ +export function writeStableJsonArtifact( + path: string, + value: unknown, +): WrittenStableJsonArtifact { + const target = requiredLabel(path, 'path'); + const json = stableJsonStringify(value); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, json, { encoding: 'utf8', mode: 0o600 }); + return { + byteLength: Buffer.byteLength(json, 'utf8'), + sha256: sha256Text(json), + }; +} diff --git a/devnet/_bootstrap/vitest.evidence.config.ts b/devnet/_bootstrap/vitest.evidence.config.ts new file mode 100644 index 0000000000..35eb6bb24b --- /dev/null +++ b/devnet/_bootstrap/vitest.evidence.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +// Pure evidence-unit tests. No running devnet or built package artifacts needed. +export default defineConfig({ + test: { + include: [resolve(import.meta.dirname, 'rfc64-evidence.test.ts')], + pool: 'forks', + sequence: { concurrent: false }, + globals: false, + }, + resolve: { + modules: [resolve(import.meta.dirname, '../../node_modules'), 'node_modules'], + }, +}); diff --git a/package.json b/package.json index b6d2b4e5ec..01d615b300 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "test:devnet:rich-scenario": "vitest run --config devnet/rich-scenario/vitest.config.ts", "test:devnet:rpc-quiet-window": "vitest run --config devnet/rpc-quiet-window/vitest.config.ts", "test:devnet:storage-ack-store-outage": "vitest run --config devnet/storage-ack-store-outage/vitest.config.ts", + "test:devnet:rfc64-evidence": "vitest run --config devnet/_bootstrap/vitest.evidence.config.ts", "test:devnet:v10-rs-prune": "vitest run --config devnet/v10-rs-prune/vitest.config.ts", "test:devnet:v10-rs-wallet-rotation": "vitest run --config devnet/v10-rs-wallet-rotation/vitest.config.ts", "test:devnet:v10-rs-wallet-rotation:required": "DKG_REQUIRE_RS_ROTATION=1 DKG_RS_ROT_WINDOW=900000 vitest run --config devnet/v10-rs-wallet-rotation/vitest.config.ts", From ce2090c5b7c3abb9e55b739fea7ec8628b841786 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:35:12 +0200 Subject: [PATCH 084/292] fix(agent): close RFC-64 persistence review gaps --- packages/agent/package.json | 10 +- packages/agent/scripts/test-package-root.mjs | 37 +++++ .../rfc64/control-object-store-v1-internal.ts | 54 +++---- .../agent/src/rfc64/durable-file-store-v1.ts | 132 +++++++++++++++++- packages/agent/src/rfc64/inventory-v1/open.ts | 30 +--- .../persistence-root-ownership-v1-internal.ts | 43 ++++++ packages/agent/src/rfc64/persistence-v1.ts | 3 +- .../rfc64-agent-inventory-lifecycle.test.ts | 5 +- .../rfc64-control-object-store-v1.test.ts | 10 +- ...fc64-control-store-public-api.typecheck.ts | 4 + .../test/rfc64-persistence-owner.typecheck.ts | 5 +- 11 files changed, 263 insertions(+), 70 deletions(-) create mode 100644 packages/agent/scripts/test-package-root.mjs create mode 100644 packages/agent/src/rfc64/persistence-root-ownership-v1-internal.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index f3e8b5808b..2f4527c594 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -10,7 +10,13 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, - "./dist/rfc64/*": null, + "./dist/rfc64/control-object-store-v1-internal.js": null, + "./dist/rfc64/control-object-store-v1.js": null, + "./dist/rfc64/durable-file-store-v1.js": null, + "./dist/rfc64/persistence-layout-v1.js": null, + "./dist/rfc64/persistence-root-ownership-v1-internal.js": null, + "./dist/rfc64/persistence-v1.js": null, + "./dist/rfc64/secure-filesystem-policy-v1.js": null, "./dist/*": "./dist/*" }, "scripts": { @@ -21,7 +27,7 @@ "test": "vitest run", "test:unit": "vitest run --config vitest.unit.config.ts", "test:types": "tsc --project tsconfig.type-tests.json", - "test:package-root": "node --input-type=module -e \"const root = await import('@origintrail-official/dkg-agent'); const legacy = await import('@origintrail-official/dkg-agent/dist/dkg-agent.js'); if (typeof root.DKGAgent !== 'function' || typeof legacy.DKGAgent !== 'function') throw new Error('published agent entry points did not expose DKGAgent'); for (const path of ['control-object-store-v1-internal.js','durable-file-store-v1.js','secure-filesystem-policy-v1.js']) { try { await import('@origintrail-official/dkg-agent/dist/rfc64/' + path); throw new Error('internal RFC-64 module unexpectedly resolved: ' + path); } catch (error) { if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; } }\"", + "test:package-root": "node scripts/test-package-root.mjs", "test:coverage": "vitest run --coverage", "clean": "rm -rf dist tsconfig.tsbuildinfo" }, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs new file mode 100644 index 0000000000..a018251ff2 --- /dev/null +++ b/packages/agent/scripts/test-package-root.mjs @@ -0,0 +1,37 @@ +const root = await import('@origintrail-official/dkg-agent'); +const legacyAgent = await import('@origintrail-official/dkg-agent/dist/dkg-agent.js'); +const legacyCatalogProducer = await import( + '@origintrail-official/dkg-agent/dist/rfc64/author-catalog-producer.js' +); +const legacyInventory = await import( + '@origintrail-official/dkg-agent/dist/rfc64/inventory-v1/index.js' +); + +if (typeof root.DKGAgent !== 'function' || typeof legacyAgent.DKGAgent !== 'function') { + throw new Error('published agent entry points did not expose DKGAgent'); +} +if ( + typeof legacyCatalogProducer.produceEmptyAuthorCatalogGenesisV1 !== 'function' + || typeof legacyInventory.openInventoryV1 !== 'function' +) { + throw new Error('historical RFC-64 deep imports no longer resolve'); +} + +const blockedRfc64Modules = [ + 'control-object-store-v1-internal.js', + 'control-object-store-v1.js', + 'durable-file-store-v1.js', + 'persistence-layout-v1.js', + 'persistence-root-ownership-v1-internal.js', + 'persistence-v1.js', + 'secure-filesystem-policy-v1.js', +]; + +for (const path of blockedRfc64Modules) { + try { + await import(`@origintrail-official/dkg-agent/dist/rfc64/${path}`); + throw new Error(`internal RFC-64 module unexpectedly resolved: ${path}`); + } catch (error) { + if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; + } +} diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index f7a5b7c9a1..4164eccd3d 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -17,7 +17,9 @@ import { import { Rfc64DurableFileErrorV1, assertRfc64ExistingDirectoryV1, + createRfc64DurableFileStoreForTestV1, createRfc64DurableFileStoreV1, + ensureRfc64SecureDirectoryTreeForTestV1, ensureRfc64SecureDirectoryTreeV1, type Rfc64DurableFileBoundaryV1, type Rfc64DurableFileLifecycleV1, @@ -36,9 +38,7 @@ import { resolveRfc64ControlObjectStorePathV1, resolveRfc64PersistenceRootV1, } from './persistence-layout-v1.js'; -import type { - Rfc64InventoryOwnedRootCapabilityV1, -} from './inventory-v1/open.js'; +import type { Rfc64PersistenceRootOwnershipV1 } from './persistence-root-ownership-v1-internal.js'; export { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH }; export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; @@ -90,10 +90,6 @@ export type Rfc64ControlObjectStoreDurabilityBoundaryV1 = interface Rfc64ControlObjectStoreLifecycleV1 extends Rfc64DurableFileLifecycleV1 {} -const PRODUCTION_LIFECYCLE = Object.freeze({ - boundary: (_boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1): void => {}, -}) satisfies Rfc64ControlObjectStoreLifecycleV1; - export interface StageVerifiedControlObjectV1 { readonly envelope: SignedControlEnvelopeV1; readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; @@ -152,10 +148,10 @@ export interface Rfc64ControlObjectStoreV1 { /** Open only with lifecycle authority minted by the leased inventory owner. */ export async function openRfc64ControlObjectStoreForOwnedInventoryV1( - ownership: Rfc64InventoryOwnedRootCapabilityV1, + ownership: Rfc64PersistenceRootOwnershipV1, ): Promise { - const rfc64RootPath = ownership.assertOwnedAndGetRootPathV1(); - return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, PRODUCTION_LIFECYCLE); + const rfc64RootPath = ownership.assertHeldAndGetRootPathV1(); + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, null); } interface PreparedStoredControlObjectV1 { @@ -185,9 +181,9 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { constructor( readonly rootPath: string, - lifecycle: Rfc64ControlObjectStoreLifecycleV1, + durableFiles: Rfc64DurableFileStoreV1, ) { - this.#durableFiles = createRfc64DurableFileStoreV1(rootPath, lifecycle); + this.#durableFiles = durableFiles; } get closed(): boolean { @@ -461,7 +457,11 @@ export async function openRfc64ControlObjectStoreForTestV1( 'DKG data directory', { access: 'owner' }, ); - await ensureRfc64SecureDirectoryTreeV1(rfc64RootPath, dataDir, guardedLifecycle); + await ensureRfc64SecureDirectoryTreeForTestV1( + rfc64RootPath, + dataDir, + guardedLifecycle, + ); }); return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, guardedLifecycle); } @@ -477,9 +477,9 @@ function assertRfc64ControlObjectStoreTestEnvironmentV1(): void { async function openRfc64ControlObjectStoreAtRootV1( rfc64RootPathInput: string, - lifecycle: Rfc64ControlObjectStoreLifecycleV1, + testLifecycle: Rfc64ControlObjectStoreLifecycleV1 | null, ): Promise { - if (!Object.isFrozen(lifecycle)) { + if (testLifecycle !== null && !Object.isFrozen(testLifecycle)) { fail('control-store-input', 'control object store lifecycle adapter must be immutable'); } const rfc64RootPath = resolve(rfc64RootPathInput); @@ -490,19 +490,19 @@ async function openRfc64ControlObjectStoreAtRootV1( 'RFC-64 persistence root', { access: 'owner-only' }, ); - await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath, lifecycle); - await ensureRfc64SecureDirectoryTreeV1( - join(rootPath, OBJECTS_DIRECTORY), - rootPath, - lifecycle, - ); - await ensureRfc64SecureDirectoryTreeV1( - join(rootPath, SIGNATURES_DIRECTORY), - rootPath, - lifecycle, - ); + const ensureTree = testLifecycle === null + ? (target: string, root: string) => + ensureRfc64SecureDirectoryTreeV1(target, root) + : (target: string, root: string) => + ensureRfc64SecureDirectoryTreeForTestV1(target, root, testLifecycle); + await ensureTree(rootPath, rfc64RootPath); + await ensureTree(join(rootPath, OBJECTS_DIRECTORY), rootPath); + await ensureTree(join(rootPath, SIGNATURES_DIRECTORY), rootPath); }); - return new FileRfc64ControlObjectStoreV1(rootPath, lifecycle); + const durableFiles = testLifecycle === null + ? createRfc64DurableFileStoreV1(rootPath) + : createRfc64DurableFileStoreForTestV1(rootPath, testLifecycle); + return new FileRfc64ControlObjectStoreV1(rootPath, durableFiles); } function prepareStageBatch( diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index 9cf210f3bd..737ca2315f 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -74,11 +74,83 @@ export interface Rfc64DurableFileStoreV1 { export function createRfc64DurableFileStoreV1( containmentRoot: string, +): Rfc64DurableFileStoreV1 { + return createRfc64DurableFileStoreWithLifecycleV1( + containmentRoot, + PRODUCTION_DURABLE_FILE_LIFECYCLE_V1, + ); +} + +/** @internal Package-local fault injection; unavailable outside source tests. */ +export function createRfc64DurableFileStoreForTestV1( + containmentRoot: string, lifecycle: Rfc64DurableFileLifecycleV1, ): Rfc64DurableFileStoreV1 { + assertRfc64DurableFileTestEnvironmentV1(); + const guardedLifecycle = Object.freeze({ + boundary: async (boundary: Rfc64DurableFileBoundaryV1): Promise => { + assertRfc64DurableFileTestEnvironmentV1(); + await lifecycle.boundary(boundary); + }, + }); + return createRfc64DurableFileStoreWithLifecycleV1( + containmentRoot, + guardedLifecycle, + ); +} + +const PRODUCTION_DURABLE_FILE_LIFECYCLE_V1 = Object.freeze({ + boundary: (): void => {}, +}) satisfies Rfc64DurableFileLifecycleV1; + +function createRfc64DurableFileStoreWithLifecycleV1( + containmentRoot: string, + lifecycle: Rfc64DurableFileLifecycleV1, +): Rfc64DurableFileStoreV1 { + const directoryPreparations = new Map>(); + const prepareDirectoryTree = async ( + target: string, + containmentRootAccess: Rfc64ExistingAccessV1, + ): Promise => { + // Every caller independently revalidates the protected root before it may + // join an in-process preparation. The keyed promise only closes the brief + // Windows mkdir-before-DACL race; it does not weaken fail-closed admission. + if (containmentRootAccess === 'owner-only') { + await assertRfc64ExistingDirectoryV1( + containmentRoot, + 'durable store containment root', + { access: 'owner-only' }, + ); + } + const key = resolve(target); + const current = directoryPreparations.get(key); + if (current !== undefined) { + await current; + return; + } + const operation = ensureRfc64SecureDirectoryTreeWithLifecycleV1( + target, + containmentRoot, + lifecycle, + containmentRootAccess, + ); + directoryPreparations.set(key, operation); + try { + await operation; + } finally { + if (directoryPreparations.get(key) === operation) { + directoryPreparations.delete(key); + } + } + }; return Object.freeze({ putExactBytes: (input: PutRfc64ExactBytesInputV1) => - putRfc64ExactBytesV1({ ...input, containmentRoot, lifecycle }), + putRfc64ExactBytesV1({ + ...input, + containmentRoot, + lifecycle, + prepareDirectoryTree, + }), readOptionalBoundedBytes: (input: ReadRfc64OptionalBoundedBytesInputV1) => readRfc64OptionalBoundedBytesV1({ ...input, containmentRoot }), }); @@ -107,10 +179,45 @@ export async function assertRfc64ExistingDirectoryV1( } export async function ensureRfc64SecureDirectoryTreeV1( + target: string, + containmentRoot: string, + containmentRootAccess: Rfc64ExistingAccessV1 = 'owner', +): Promise { + return ensureRfc64SecureDirectoryTreeWithLifecycleV1( + target, + containmentRoot, + PRODUCTION_DURABLE_FILE_LIFECYCLE_V1, + containmentRootAccess, + ); +} + +/** @internal Package-local fault injection; unavailable outside source tests. */ +export async function ensureRfc64SecureDirectoryTreeForTestV1( target: string, containmentRoot: string, lifecycle: Rfc64DurableFileLifecycleV1, containmentRootAccess: Rfc64ExistingAccessV1 = 'owner', +): Promise { + assertRfc64DurableFileTestEnvironmentV1(); + const guardedLifecycle = Object.freeze({ + boundary: async (boundary: Rfc64DurableFileBoundaryV1): Promise => { + assertRfc64DurableFileTestEnvironmentV1(); + await lifecycle.boundary(boundary); + }, + }); + return ensureRfc64SecureDirectoryTreeWithLifecycleV1( + target, + containmentRoot, + guardedLifecycle, + containmentRootAccess, + ); +} + +async function ensureRfc64SecureDirectoryTreeWithLifecycleV1( + target: string, + containmentRoot: string, + lifecycle: Rfc64DurableFileLifecycleV1, + containmentRootAccess: Rfc64ExistingAccessV1, ): Promise { await walkRfc64ContainedDirectoryTreeV1( target, @@ -151,18 +258,27 @@ interface InternalPutRfc64ExactBytesInputV1 extends PutRfc64ExactBytesInputV1 { readonly containmentRoot: string; readonly lifecycle: Rfc64DurableFileLifecycleV1; + readonly prepareDirectoryTree: ( + target: string, + containmentRootAccess: Rfc64ExistingAccessV1, + ) => Promise; } async function putRfc64ExactBytesV1( input: InternalPutRfc64ExactBytesInputV1, ): Promise { - const { containmentRoot, relativePath, bytes, maxBytes, label, lifecycle } = input; + const { + containmentRoot, + relativePath, + bytes, + maxBytes, + label, + prepareDirectoryTree, + } = input; const targetPath = resolveContainedFileTarget(containmentRoot, relativePath); assertByteBounds(bytes, maxBytes, label); - await ensureRfc64SecureDirectoryTreeV1( + await prepareDirectoryTree( dirname(targetPath), - containmentRoot, - lifecycle, 'owner-only', ); const resolvedInput = { ...input, targetPath }; @@ -543,3 +659,9 @@ function fail( cause === undefined ? {} : { cause }, ); } + +function assertRfc64DurableFileTestEnvironmentV1(): void { + if (process.env.NODE_ENV !== 'test') { + fail('input', 'RFC-64 durable-file fault injection is unavailable outside NODE_ENV=test'); + } +} diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 6039783300..f753aa11ef 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -35,6 +35,7 @@ import { resolveRfc64InventoryDatabasePathV1, resolveRfc64PersistenceRootV1, } from '../persistence-layout-v1.js'; +import { registerRfc64PersistenceRootOwnershipV1 } from '../persistence-root-ownership-v1-internal.js'; import { INVENTORY_V1_APPLICATION_ID, @@ -128,23 +129,8 @@ class InventoryV1TargetCloseError extends InventoryV1OpenError { } } -const RFC64_INVENTORY_OWNED_ROOT_V1: unique symbol = Symbol( - 'rfc64-inventory-owned-root-v1', -); - -/** - * Nominal capability minted only after the inventory lease is acquired. - * Internal sibling resources must present it instead of accepting a raw path. - */ -export interface Rfc64InventoryOwnedRootCapabilityV1 { - readonly [RFC64_INVENTORY_OWNED_ROOT_V1]: true; - assertOwnedAndGetRootPathV1(): string; -} - export interface Rfc64InventoryV1Foundation extends Rfc64InventoryV1CandidateApi { readonly databasePath: string; - /** Lifecycle authority intentionally omitted from non-owning operation views. */ - readonly controlObjectStoreOwnership: Rfc64InventoryOwnedRootCapabilityV1; readonly closed: boolean; quarantineAndRebuild(): void; close(): void; @@ -294,8 +280,6 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { #database: DatabaseSyncV1 | null; #candidate: CandidateInventoryV1; #lease: InventoryV1Lease | null; - readonly controlObjectStoreOwnership: Rfc64InventoryOwnedRootCapabilityV1; - constructor( private readonly sqlite: SqliteModuleV1, readonly databasePath: string, @@ -307,13 +291,11 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { this.#database = database; this.#candidate = this.createCandidateInventory(database); this.#lease = lease; - this.controlObjectStoreOwnership = Object.freeze({ - [RFC64_INVENTORY_OWNED_ROOT_V1]: true as const, - assertOwnedAndGetRootPathV1: (): string => { - this.requireOpen(); - return rfc64RootPath; - }, - }); + registerRfc64PersistenceRootOwnershipV1( + this, + rfc64RootPath, + () => { this.requireOpen(); }, + ); } get closed(): boolean { diff --git a/packages/agent/src/rfc64/persistence-root-ownership-v1-internal.ts b/packages/agent/src/rfc64/persistence-root-ownership-v1-internal.ts new file mode 100644 index 0000000000..b101c2e0c7 --- /dev/null +++ b/packages/agent/src/rfc64/persistence-root-ownership-v1-internal.ts @@ -0,0 +1,43 @@ +const RFC64_PERSISTENCE_ROOT_OWNERSHIP_V1: unique symbol = Symbol( + 'rfc64-persistence-root-ownership-v1', +); + +/** + * Package-internal proof that the RFC-64 persistence lease is still held. + * Sibling resources consume this generic authority instead of accepting paths + * or teaching the public inventory API about their existence. + */ +export interface Rfc64PersistenceRootOwnershipV1 { + readonly [RFC64_PERSISTENCE_ROOT_OWNERSHIP_V1]: true; + assertHeldAndGetRootPathV1(): string; +} + +const ownershipByInventory = new WeakMap(); + +export function registerRfc64PersistenceRootOwnershipV1( + inventory: object, + rootPath: string, + assertLeaseHeld: () => void, +): void { + if (ownershipByInventory.has(inventory)) { + throw new TypeError('RFC-64 persistence root ownership is already registered'); + } + const ownership = Object.freeze({ + [RFC64_PERSISTENCE_ROOT_OWNERSHIP_V1]: true as const, + assertHeldAndGetRootPathV1: (): string => { + assertLeaseHeld(); + return rootPath; + }, + }); + ownershipByInventory.set(inventory, ownership); +} + +export function getRfc64PersistenceRootOwnershipForInventoryV1( + inventory: object, +): Rfc64PersistenceRootOwnershipV1 { + const ownership = ownershipByInventory.get(inventory); + if (ownership === undefined) { + throw new TypeError('inventory does not own an RFC-64 persistence root'); + } + return ownership; +} diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index c026cf8933..cd86fa0a2b 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -10,6 +10,7 @@ import { } from './control-object-store-v1.js'; import { openRfc64ControlObjectStoreForOwnedInventoryV1 } from './control-object-store-v1-internal.js'; import { resolveRfc64PersistenceRootV1 } from './persistence-layout-v1.js'; +import { getRfc64PersistenceRootOwnershipForInventoryV1 } from './persistence-root-ownership-v1-internal.js'; export interface OpenRfc64PersistenceOptionsV1 { /** Yield after each non-terminal fixed-size startup purge batch. */ @@ -117,7 +118,7 @@ export async function openRfc64PersistenceV1( await yieldAfterPurgeBatch(); } const controlObjectStore = await openRfc64ControlObjectStoreForOwnedInventoryV1( - inventory.controlObjectStoreOwnership, + getRfc64PersistenceRootOwnershipForInventoryV1(inventory), ); return new OwnedRfc64PersistenceV1(rootPath, inventory, controlObjectStore); } catch (cause) { diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index a3286fa32b..5a6267ed75 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -25,6 +25,7 @@ import { } from '../src/rfc64/control-object-store-v1.js'; import { openRfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; import { openRfc64ControlObjectStoreForOwnedInventoryV1 } from '../src/rfc64/control-object-store-v1-internal.js'; +import { getRfc64PersistenceRootOwnershipForInventoryV1 } from '../src/rfc64/persistence-root-ownership-v1-internal.js'; import { RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1, } from '../src/rfc64/persistence-layout-v1.js'; @@ -326,10 +327,10 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { replacement.close(); }); - it('invalidates sibling-resource ownership capability when inventory closes', async () => { + it('invalidates package-internal persistence-root ownership when inventory closes', async () => { const dataDirectory = temporaryDataDirectory(); const inventory = await openInventoryV1(dataDirectory); - const ownership = inventory.controlObjectStoreOwnership; + const ownership = getRfc64PersistenceRootOwnershipForInventoryV1(inventory); inventory.close(); await expect(openRfc64ControlObjectStoreForOwnedInventoryV1(ownership)) diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 17bd380242..3596aafbdd 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -948,10 +948,7 @@ describe('RFC-64 durable control-object store v1', () => { it('rejects an escaping durable-file relative key inside the write operation', async () => { const containmentRoot = await temporaryDataDirectory(); - const durableFiles = createRfc64DurableFileStoreV1( - containmentRoot, - Object.freeze({ boundary: () => {} }), - ); + const durableFiles = createRfc64DurableFileStoreV1<'object'>(containmentRoot); await expect(durableFiles.putExactBytes({ relativePath: join('..', 'escaped.jcs'), bytes: new TextEncoder().encode('{}'), @@ -971,10 +968,7 @@ describe('RFC-64 durable control-object store v1', () => { const relativePath = join('race', 'immutable.jcs'); const firstBytes = new TextEncoder().encode('{"writer":"first"}'); const secondBytes = new TextEncoder().encode('{"writer":"second"}'); - const durableFiles = createRfc64DurableFileStoreV1( - containmentRoot, - Object.freeze({ boundary: () => {} }), - ); + const durableFiles = createRfc64DurableFileStoreV1<'object'>(containmentRoot); const write = (bytes: Uint8Array) => durableFiles.putExactBytes({ relativePath, bytes, diff --git a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts index ca270d8668..fe0ca7d804 100644 --- a/packages/agent/test/rfc64-control-store-public-api.typecheck.ts +++ b/packages/agent/test/rfc64-control-store-public-api.typecheck.ts @@ -2,6 +2,8 @@ import * as packageRoot from '../src/index.js'; import * as productionControlStoreModule from '../src/rfc64/control-object-store-v1.js'; import { DKGAgent as PublishedDkgAgent } from '@origintrail-official/dkg-agent'; import { DKGAgent as LegacySubpathDkgAgent } from '@origintrail-official/dkg-agent/dist/dkg-agent.js'; +import { produceEmptyAuthorCatalogGenesisV1 as LegacyCatalogProducer } from '@origintrail-official/dkg-agent/dist/rfc64/author-catalog-producer.js'; +import { openInventoryV1 as LegacyInventoryOpener } from '@origintrail-official/dkg-agent/dist/rfc64/inventory-v1/index.js'; type PackageRootHasRawControlStoreOpener = 'openRfc64ControlObjectStoreV1' extends keyof typeof packageRoot ? true : false; @@ -46,6 +48,8 @@ void productionModuleHasRawControlStoreOpener; void productionModuleHasTestOpener; void publishedPackageRootHasDkgAgent; void legacySubpathHasDkgAgent; +void LegacyCatalogProducer; +void LegacyInventoryOpener; void (undefined as PackageRootStoreType | undefined); void (undefined as PublishedInternalControlStoreModule | undefined); void (undefined as PublishedDurableFileStoreModule | undefined); diff --git a/packages/agent/test/rfc64-persistence-owner.typecheck.ts b/packages/agent/test/rfc64-persistence-owner.typecheck.ts index 1ec8b43926..e401bd8dbe 100644 --- a/packages/agent/test/rfc64-persistence-owner.typecheck.ts +++ b/packages/agent/test/rfc64-persistence-owner.typecheck.ts @@ -15,8 +15,11 @@ persistence.inventory.controlObjectStoreOwnership; // @ts-expect-error startup purge authority is not a shared inventory operation persistence.inventory.purgeNextStartupStaleCandidateBatch(); +// @ts-expect-error public inventory foundations do not expose sibling-resource authority +ownedInventory.controlObjectStoreOwnership; await openRfc64ControlObjectStoreForOwnedInventoryV1( - ownedInventory.controlObjectStoreOwnership, + // @ts-expect-error an inventory object is not the package-internal ownership capability + ownedInventory, ); // @ts-expect-error a raw filesystem path cannot bypass inventory lease ownership await openRfc64ControlObjectStoreForOwnedInventoryV1('/tmp/rfc64-sync'); From d956e8b77619ba0831d16ead5c19c6c71f6af830 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:37:16 +0200 Subject: [PATCH 085/292] refactor(agent): name RFC-64 root ownership generically --- .../agent/src/rfc64/control-object-store-v1-internal.ts | 4 ++-- packages/agent/src/rfc64/persistence-v1.ts | 4 ++-- packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts | 4 ++-- packages/agent/test/rfc64-persistence-owner.typecheck.ts | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index 4164eccd3d..a0309c1b77 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -146,8 +146,8 @@ export interface Rfc64ControlObjectStoreV1 { close(): Promise; } -/** Open only with lifecycle authority minted by the leased inventory owner. */ -export async function openRfc64ControlObjectStoreForOwnedInventoryV1( +/** Open only with package-internal authority backed by the live persistence lease. */ +export async function openRfc64ControlObjectStoreForOwnedPersistenceRootV1( ownership: Rfc64PersistenceRootOwnershipV1, ): Promise { const rfc64RootPath = ownership.assertHeldAndGetRootPathV1(); diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index cd86fa0a2b..7c43906daf 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -8,7 +8,7 @@ import { type Rfc64ControlObjectOperationsV1, type Rfc64ControlObjectStoreV1, } from './control-object-store-v1.js'; -import { openRfc64ControlObjectStoreForOwnedInventoryV1 } from './control-object-store-v1-internal.js'; +import { openRfc64ControlObjectStoreForOwnedPersistenceRootV1 } from './control-object-store-v1-internal.js'; import { resolveRfc64PersistenceRootV1 } from './persistence-layout-v1.js'; import { getRfc64PersistenceRootOwnershipForInventoryV1 } from './persistence-root-ownership-v1-internal.js'; @@ -117,7 +117,7 @@ export async function openRfc64PersistenceV1( if (batch.done) break; await yieldAfterPurgeBatch(); } - const controlObjectStore = await openRfc64ControlObjectStoreForOwnedInventoryV1( + const controlObjectStore = await openRfc64ControlObjectStoreForOwnedPersistenceRootV1( getRfc64PersistenceRootOwnershipForInventoryV1(inventory), ); return new OwnedRfc64PersistenceV1(rootPath, inventory, controlObjectStore); diff --git a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts index 5a6267ed75..f97b7c42cf 100644 --- a/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts +++ b/packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts @@ -24,7 +24,7 @@ import { type StageVerifiedControlObjectV1, } from '../src/rfc64/control-object-store-v1.js'; import { openRfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; -import { openRfc64ControlObjectStoreForOwnedInventoryV1 } from '../src/rfc64/control-object-store-v1-internal.js'; +import { openRfc64ControlObjectStoreForOwnedPersistenceRootV1 } from '../src/rfc64/control-object-store-v1-internal.js'; import { getRfc64PersistenceRootOwnershipForInventoryV1 } from '../src/rfc64/persistence-root-ownership-v1-internal.js'; import { RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1, @@ -333,7 +333,7 @@ describe('DKGAgent RFC-64 inventory lifecycle', () => { const ownership = getRfc64PersistenceRootOwnershipForInventoryV1(inventory); inventory.close(); - await expect(openRfc64ControlObjectStoreForOwnedInventoryV1(ownership)) + await expect(openRfc64ControlObjectStoreForOwnedPersistenceRootV1(ownership)) .rejects.toMatchObject({ code: 'database-closed' }); }); diff --git a/packages/agent/test/rfc64-persistence-owner.typecheck.ts b/packages/agent/test/rfc64-persistence-owner.typecheck.ts index e401bd8dbe..a12fa05eba 100644 --- a/packages/agent/test/rfc64-persistence-owner.typecheck.ts +++ b/packages/agent/test/rfc64-persistence-owner.typecheck.ts @@ -1,6 +1,6 @@ import type { Rfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; import type { Rfc64InventoryV1Foundation } from '../src/rfc64/inventory-v1/index.js'; -import { openRfc64ControlObjectStoreForOwnedInventoryV1 } from '../src/rfc64/control-object-store-v1-internal.js'; +import { openRfc64ControlObjectStoreForOwnedPersistenceRootV1 } from '../src/rfc64/control-object-store-v1-internal.js'; declare const persistence: Rfc64PersistenceV1; declare const ownedInventory: Rfc64InventoryV1Foundation; @@ -17,9 +17,9 @@ persistence.inventory.purgeNextStartupStaleCandidateBatch(); // @ts-expect-error public inventory foundations do not expose sibling-resource authority ownedInventory.controlObjectStoreOwnership; -await openRfc64ControlObjectStoreForOwnedInventoryV1( +await openRfc64ControlObjectStoreForOwnedPersistenceRootV1( // @ts-expect-error an inventory object is not the package-internal ownership capability ownedInventory, ); // @ts-expect-error a raw filesystem path cannot bypass inventory lease ownership -await openRfc64ControlObjectStoreForOwnedInventoryV1('/tmp/rfc64-sync'); +await openRfc64ControlObjectStoreForOwnedPersistenceRootV1('/tmp/rfc64-sync'); From 5f696a8b74960a77e20abdaf8f4a15fbc5c5f960 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:42:58 +0200 Subject: [PATCH 086/292] test(agent): split RFC-64 control store suites --- ...-control-object-store-lifecycle-v1.test.ts | 320 ++++++++++++ .../rfc64-control-object-store-v1.test.ts | 470 +----------------- .../test/rfc64-durable-file-store-v1.test.ts | 65 +++ .../rfc64-secure-filesystem-policy-v1.test.ts | 67 +++ .../rfc64-control-object-store-fixtures.ts | 96 ++++ packages/agent/vitest.unit.config.ts | 3 + 6 files changed, 564 insertions(+), 457 deletions(-) create mode 100644 packages/agent/test/rfc64-control-object-store-lifecycle-v1.test.ts create mode 100644 packages/agent/test/rfc64-durable-file-store-v1.test.ts create mode 100644 packages/agent/test/rfc64-secure-filesystem-policy-v1.test.ts create mode 100644 packages/agent/test/support/rfc64-control-object-store-fixtures.ts diff --git a/packages/agent/test/rfc64-control-object-store-lifecycle-v1.test.ts b/packages/agent/test/rfc64-control-object-store-lifecycle-v1.test.ts new file mode 100644 index 0000000000..b28495e180 --- /dev/null +++ b/packages/agent/test/rfc64-control-object-store-lifecycle-v1.test.ts @@ -0,0 +1,320 @@ +import { spawnSync } from 'node:child_process'; +import { chmod, readFile, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { type Digest32V1 } from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + RFC64_CONTROL_OBJECT_STORE_FILE_MODE, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, +} from '../src/rfc64/control-object-store-v1.js'; +import { + createRfc64ControlObjectStoreTestOpenerV1, + type Rfc64ControlObjectStoreDurabilityBoundaryV1, +} from './support/rfc64-control-object-store-test-support.js'; +import { + createTemporaryDataDirectoryFixture, + deferred, + pathsFor, + signedFixture, +} from './support/rfc64-control-object-store-fixtures.js'; + +const openRfc64ControlObjectStoreV1 = createRfc64ControlObjectStoreTestOpenerV1(); +const temporaryDirectories = createTemporaryDataDirectoryFixture(); +const { temporaryDataDirectory } = temporaryDirectories; + +function grantWindowsEveryoneRead( + path: string, + entryKind: 'file' | 'directory', +): void { + const permission = entryKind === 'directory' + ? '*S-1-1-0:(OI)(CI)(RX)' + : '*S-1-1-0:(R)'; + const result = spawnSync( + 'icacls.exe', + [path, '/grant', permission], + { encoding: 'utf8', windowsHide: true }, + ); + if (result.error !== undefined || result.status !== 0) { + throw new Error( + `failed to grant the Windows ACL test permission: ${ + result.error?.message ?? (result.stderr.trim() || `icacls exited ${result.status}`) + }`, + result.error === undefined ? {} : { cause: result.error }, + ); + } +} + +afterEach(async () => { + await temporaryDirectories.cleanup(); +}); + +describe('RFC-64 durable control-object store lifecycle v1', () => { + it('drains an admitted durable write before close can release ownership', async () => { + const dataDir = await temporaryDataDirectory(); + const entered = deferred(); + const release = deferred(); + let paused = false; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: async (boundary) => { + if (boundary === 'object.temp-written' && !paused) { + paused = true; + entered.resolve(); + await release.promise; + } + }, + })(dataDir); + const fixture = await signedFixture('close-drain'); + const stage = store.stageVerifiedObjects([fixture]); + await entered.promise; + + let closeSettled = false; + const close = store.close().then(() => { closeSettled = true; }); + expect(store.closed).toBe(true); + await Promise.resolve(); + expect(closeSettled).toBe(false); + + release.resolve(); + await expect(stage).resolves.toMatchObject({ durable: true }); + await expect(close).resolves.toBeUndefined(); + expect(closeSettled).toBe(true); + await expect(store.close()).resolves.toBeUndefined(); + }); + + it('drains every sibling write in a failed batch before close resolves', async () => { + const dataDir = await temporaryDataDirectory(); + const entered = deferred(); + const release = deferred(); + let pauseEnabled = false; + let paused = false; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: async (boundary) => { + if (pauseEnabled && boundary === 'object.temp-written' && !paused) { + paused = true; + entered.resolve(); + await release.promise; + } + }, + })(dataDir); + const corrupt = await signedFixture('failed-batch-corrupt'); + const slow = await signedFixture('failed-batch-slow'); + await store.stageVerifiedObjects([corrupt]); + await writeFile(pathsFor(dataDir, corrupt.envelope).object, '{}'); + pauseEnabled = true; + + let stageSettled = false; + const stage = store.stageVerifiedObjects([corrupt, slow]); + void stage.finally(() => { stageSettled = true; }).catch(() => undefined); + await entered.promise; + await new Promise((resolve) => { setImmediate(resolve); }); + expect(stageSettled).toBe(false); + + let closeSettled = false; + const close = store.close().then(() => { closeSettled = true; }); + await new Promise((resolve) => { setImmediate(resolve); }); + expect(closeSettled).toBe(false); + + release.resolve(); + await expect(stage).rejects.toMatchObject({ code: 'control-store-corrupt' }); + await expect(close).resolves.toBeUndefined(); + expect(closeSettled).toBe(true); + await expect(readFile(pathsFor(dataDir, slow.envelope).object)).resolves.not.toHaveLength(0); + await expect(readFile(pathsFor(dataDir, slow.envelope).signature)).resolves.not.toHaveLength(0); + }); + + it('drains an admitted verified read before close can release ownership', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('close-read-drain'); + await store.stageVerifiedObjects([fixture]); + const entered = deferred(); + const release = deferred(); + const read = store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: pathsFor(dataDir, fixture.envelope).signatureDigest, + verifyIssuerSignature: async (envelope) => { + entered.resolve(); + await release.promise; + return verifyControlEnvelopeIssuerSignatureV1(envelope); + }, + }); + await entered.promise; + + let closeSettled = false; + const close = store.close().then(() => { closeSettled = true; }); + expect(store.closed).toBe(true); + await Promise.resolve(); + expect(closeSettled).toBe(false); + + release.resolve(); + await expect(read).resolves.toMatchObject({ envelope: fixture.envelope }); + await expect(close).resolves.toBeUndefined(); + expect(closeSettled).toBe(true); + }); + + it('cleans an unpublished temp after a pre-visibility fault and converges on retry', async () => { + const dataDir = await temporaryDataDirectory(); + const fixture = await signedFixture('7'); + let injected = false; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: (boundary) => { + if (boundary === 'object.temp-fsynced' && !injected) { + injected = true; + throw new Error('injected pre-publish fault'); + } + }, + })(dataDir); + const paths = pathsFor(dataDir, fixture.envelope); + + await expect(store.stageVerifiedObjects([fixture])) + .rejects.toMatchObject({ code: 'control-store-durability' }); + await expect(stat(paths.object)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); + }); + + it('treats a post-publish fault as an unreachable orphan and safely completes on retry', async () => { + const dataDir = await temporaryDataDirectory(); + const fixture = await signedFixture('8'); + let injected = false; + const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; + const store = await createRfc64ControlObjectStoreTestOpenerV1({ + boundary: (boundary) => { + boundaries.push(boundary); + if (boundary === 'object.published-no-replace' && !injected) { + injected = true; + throw new Error('injected post-publish fault'); + } + }, + })(dataDir); + const paths = pathsFor(dataDir, fixture.envelope); + + await expect(store.stageVerifiedObjects([fixture])) + .rejects.toMatchObject({ code: 'control-store-durability' }); + expect(await readFile(paths.object, 'utf8')).toContain('dkg-rfc64-control-store-test-v1'); + await expect(stat(paths.signature)).rejects.toMatchObject({ code: 'ENOENT' }); + const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature, + })).resolves.toBeNull(); + expect(verifyIssuerSignature).not.toHaveBeenCalled(); + boundaries.length = 0; + await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); + expect(boundaries).toContain('object.existing-fsynced'); + expect(boundaries).toContain('object.existing-parent-fsynced'); + }); + + it('rejects symlinked store topology instead of following it', async () => { + const dataDir = await temporaryDataDirectory(); + const outside = await temporaryDataDirectory(); + const first = await openRfc64ControlObjectStoreV1(dataDir); + await first.close(); + const objects = join(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, 'objects'); + await rm(objects, { recursive: true, force: true }); + await symlink(outside, objects, process.platform === 'win32' ? 'junction' : 'dir'); + + await expect(openRfc64ControlObjectStoreV1(dataDir)) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + }); + + it.runIf(process.platform !== 'win32')( + 'rejects symlinked digest files before reading or verifying outside bytes', + async () => { + for (const target of ['object', 'signature'] as const) { + const dataDir = await temporaryDataDirectory(); + const outside = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture(`symlink-${target}`); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + const targetPath = paths[target]; + const outsideFile = join(outside, `${target}.jcs`); + await writeFile(outsideFile, '{"outside":true}'); + await unlink(targetPath); + await symlink(outsideFile, targetPath, 'file'); + const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature, + })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + expect(verifyIssuerSignature).not.toHaveBeenCalled(); + } + }, + ); + + it.runIf(process.platform !== 'win32')( + 'rejects permissive existing files and directories instead of trusting or tightening them', + async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('8b'); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + + await chmod(paths.object, 0o644); + await expect(store.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + + await chmod(paths.object, RFC64_CONTROL_OBJECT_STORE_FILE_MODE); + await chmod(dirname(dirname(paths.object)), 0o755); + await store.close(); + await expect(openRfc64ControlObjectStoreV1(dataDir)) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + }, + ); + + it('rejects a permissive control-store root before publishing a new immutable key', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('permissive-root-after-open'); + const paths = pathsFor(dataDir, fixture.envelope); + if (process.platform === 'win32') { + grantWindowsEveryoneRead(paths.root, 'directory'); + } else { + await chmod(paths.root, 0o777); + } + + await expect(store.stageVerifiedObjects([fixture])) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + await expect(stat(paths.object)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(paths.signature)).rejects.toMatchObject({ code: 'ENOENT' }); + await store.close(); + }); + + it.runIf(process.platform === 'win32')( + 'rejects permissive existing Windows file and directory ACLs', + async () => { + const directoryDataDir = await temporaryDataDirectory(); + const directoryStore = await openRfc64ControlObjectStoreV1(directoryDataDir); + const objectsDirectory = join( + directoryDataDir, + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + 'objects', + ); + await directoryStore.close(); + grantWindowsEveryoneRead(objectsDirectory, 'directory'); + await expect(openRfc64ControlObjectStoreV1(directoryDataDir)) + .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + + const fileDataDir = await temporaryDataDirectory(); + const fileStore = await openRfc64ControlObjectStoreV1(fileDataDir); + const fixture = await signedFixture('8c'); + await fileStore.stageVerifiedObjects([fixture]); + const paths = pathsFor(fileDataDir, fixture.envelope); + grantWindowsEveryoneRead(paths.object, 'file'); + await expect(fileStore.getVerifiedObject({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + signatureVariantDigest: paths.signatureDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + }, + ); +}); diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 3596aafbdd..f63f8db03c 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -1,11 +1,8 @@ -import { spawnSync } from 'node:child_process'; -import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { readFile, stat, unlink, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { computeControlObjectDigestHex, - computeControlSignatureVariantDigestHex, type AuthorCatalogScopeV1, type Digest32V1, type EvmAddressV1, @@ -19,7 +16,6 @@ import { type CurrentFinalizedEvmCallV1, type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; -import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -28,98 +24,35 @@ import { RFC64_CONTROL_OBJECT_STORE_FILE_MODE, RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, - RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, Rfc64ControlObjectStoreErrorV1, type StageVerifiedControlObjectV1, } from '../src/rfc64/control-object-store-v1.js'; -import { createRfc64DurableFileStoreV1 } from '../src/rfc64/durable-file-store-v1.js'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; -import { - applyRfc64OwnerOnlyPermissionsSyncV1, - applyRfc64OwnerOnlyPermissionsV1, - assertRfc64FilesystemOwnerSyncV1, - assertRfc64FilesystemOwnerV1, - assertRfc64OwnerOnlyPermissionsSyncV1, - assertRfc64OwnerOnlyPermissionsV1, -} from '../src/rfc64/secure-filesystem-policy-v1.js'; +import { applyRfc64OwnerOnlyPermissionsSyncV1 } from '../src/rfc64/secure-filesystem-policy-v1.js'; import { createRfc64ControlObjectStoreTestOpenerV1, type Rfc64ControlObjectStoreDurabilityBoundaryV1, } from './support/rfc64-control-object-store-test-support.js'; +import { + createTemporaryDataDirectoryFixture, + deferred, + ISSUER, + pathsFor, + signedFixture, + wallet, +} from './support/rfc64-control-object-store-fixtures.js'; -const PRIVATE_KEY = `0x${'42'.repeat(32)}`; const SAFE = '0x3333333333333333333333333333333333333333' as EvmAddressV1; const BLOCK_HASH = `0x${'44'.repeat(32)}`; -const wallet = new ethers.Wallet(PRIVATE_KEY); -const ISSUER = wallet.address.toLowerCase() as EvmAddressV1; const openRfc64ControlObjectStoreV1 = createRfc64ControlObjectStoreTestOpenerV1(); - -const temporaryDirectories: string[] = []; - -function deferred(): { readonly promise: Promise; readonly resolve: () => void } { - let resolvePromise: (() => void) | undefined; - const promise = new Promise((resolve) => { resolvePromise = resolve; }); - return { promise, resolve: () => resolvePromise?.() }; -} - -async function temporaryDataDirectory(): Promise { - const path = await mkdtemp(join(tmpdir(), 'dkg-rfc64-control-store-')); - temporaryDirectories.push(path); - return path; -} - -function grantWindowsEveryoneRead( - path: string, - entryKind: 'file' | 'directory', -): void { - const permission = entryKind === 'directory' - ? '*S-1-1-0:(OI)(CI)(RX)' - : '*S-1-1-0:(R)'; - const result = spawnSync( - 'icacls.exe', - [path, '/grant', permission], - { encoding: 'utf8', windowsHide: true }, - ); - if (result.error !== undefined || result.status !== 0) { - throw new Error( - `failed to grant the Windows ACL test permission: ${ - result.error?.message ?? (result.stderr.trim() || `icacls exited ${result.status}`) - }`, - result.error === undefined ? {} : { cause: result.error }, - ); - } -} +const temporaryDirectories = createTemporaryDataDirectoryFixture(); +const { temporaryDataDirectory } = temporaryDirectories; afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map(async (path) => { - await rm(path, { recursive: true, force: true }); - })); + await temporaryDirectories.cleanup(); }); -async function signedFixture( - sequence: string, -): Promise { - const unsigned = { - issuer: ISSUER, - objectType: 'dkg-rfc64-control-store-test-v1', - payload: { sequence }, - signatureEvidence: { kind: 'none' }, - signatureSuite: 'eip191-personal-sign-digest-v1', - } satisfies UnsignedControlEnvelopeV1; - const objectDigest = computeControlObjectDigestHex(unsigned); - const signature = await wallet.signMessage(ethers.getBytes(objectDigest)); - const envelope = { - ...unsigned, - objectDigest, - signature, - } as SignedControlEnvelopeV1; - return { - envelope, - issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), - }; -} - const finalizedEip1271Call: CurrentFinalizedEvmCallV1 = async (request) => ({ chainId: request.chainId, blockNumber: '123', @@ -166,73 +99,7 @@ async function contractSignatureVariantsFixture(): Promise< }]; } -function pathsFor( - dataDir: string, - envelope: SignedControlEnvelopeV1, -): { root: string; object: string; signature: string; signatureDigest: Digest32V1 } { - const root = join(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH); - const objectHex = envelope.objectDigest.slice(2); - const signatureDigest = computeControlSignatureVariantDigestHex( - envelope.objectDigest, - envelope.signature, - ) as Digest32V1; - return { - root, - object: join(root, 'objects', objectHex.slice(0, 2), `${objectHex}.jcs`), - signature: join( - root, - 'signatures', - objectHex.slice(0, 2), - objectHex, - `${signatureDigest.slice(2)}.jcs`, - ), - signatureDigest, - }; -} - describe('RFC-64 durable control-object store v1', () => { - it('keeps synchronous inventory and asynchronous durable policy twins aligned', async () => { - const dataDir = await temporaryDataDirectory(); - const policy = { entryKind: 'directory' as const }; - applyRfc64OwnerOnlyPermissionsSyncV1( - dataDir, - RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, - policy, - ); - - expect(() => assertRfc64FilesystemOwnerSyncV1(dataDir)).not.toThrow(); - await expect(assertRfc64FilesystemOwnerV1(dataDir)).resolves.toBeUndefined(); - expect(() => assertRfc64OwnerOnlyPermissionsSyncV1( - dataDir, - RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, - policy, - )).not.toThrow(); - await expect(assertRfc64OwnerOnlyPermissionsV1( - dataDir, - RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, - policy, - )).resolves.toBeUndefined(); - await expect(applyRfc64OwnerOnlyPermissionsV1( - dataDir, - RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, - policy, - )).resolves.toBeUndefined(); - - if (process.platform !== 'win32') { - await chmod(dataDir, 0o755); - expect(() => assertRfc64OwnerOnlyPermissionsSyncV1( - dataDir, - RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, - policy, - )).toThrow(/path mode 755/); - await expect(assertRfc64OwnerOnlyPermissionsV1( - dataDir, - RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, - policy, - )).rejects.toThrow(/path mode 755/); - } - }); - it('rechecks NODE_ENV when the source-level test opener is invoked', async () => { const opener = createRfc64ControlObjectStoreTestOpenerV1(); const previousNodeEnv = process.env.NODE_ENV; @@ -639,272 +506,6 @@ describe('RFC-64 durable control-object store v1', () => { await expect(slowRead).resolves.toMatchObject({ envelope: first.envelope }); }); - it('drains an admitted durable write before close can release ownership', async () => { - const dataDir = await temporaryDataDirectory(); - const entered = deferred(); - const release = deferred(); - let paused = false; - const store = await createRfc64ControlObjectStoreTestOpenerV1({ - boundary: async (boundary) => { - if (boundary === 'object.temp-written' && !paused) { - paused = true; - entered.resolve(); - await release.promise; - } - }, - })(dataDir); - const fixture = await signedFixture('close-drain'); - const stage = store.stageVerifiedObjects([fixture]); - await entered.promise; - - let closeSettled = false; - const close = store.close().then(() => { closeSettled = true; }); - expect(store.closed).toBe(true); - await Promise.resolve(); - expect(closeSettled).toBe(false); - - release.resolve(); - await expect(stage).resolves.toMatchObject({ durable: true }); - await expect(close).resolves.toBeUndefined(); - expect(closeSettled).toBe(true); - await expect(store.close()).resolves.toBeUndefined(); - }); - - it('drains every sibling write in a failed batch before close resolves', async () => { - const dataDir = await temporaryDataDirectory(); - const entered = deferred(); - const release = deferred(); - let pauseEnabled = false; - let paused = false; - const store = await createRfc64ControlObjectStoreTestOpenerV1({ - boundary: async (boundary) => { - if (pauseEnabled && boundary === 'object.temp-written' && !paused) { - paused = true; - entered.resolve(); - await release.promise; - } - }, - })(dataDir); - const corrupt = await signedFixture('failed-batch-corrupt'); - const slow = await signedFixture('failed-batch-slow'); - await store.stageVerifiedObjects([corrupt]); - await writeFile(pathsFor(dataDir, corrupt.envelope).object, '{}'); - pauseEnabled = true; - - let stageSettled = false; - const stage = store.stageVerifiedObjects([corrupt, slow]); - void stage.finally(() => { stageSettled = true; }).catch(() => undefined); - await entered.promise; - await new Promise((resolve) => { setImmediate(resolve); }); - expect(stageSettled).toBe(false); - - let closeSettled = false; - const close = store.close().then(() => { closeSettled = true; }); - await new Promise((resolve) => { setImmediate(resolve); }); - expect(closeSettled).toBe(false); - - release.resolve(); - await expect(stage).rejects.toMatchObject({ code: 'control-store-corrupt' }); - await expect(close).resolves.toBeUndefined(); - expect(closeSettled).toBe(true); - await expect(readFile(pathsFor(dataDir, slow.envelope).object)).resolves.not.toHaveLength(0); - await expect(readFile(pathsFor(dataDir, slow.envelope).signature)).resolves.not.toHaveLength(0); - }); - - it('drains an admitted verified read before close can release ownership', async () => { - const dataDir = await temporaryDataDirectory(); - const store = await openRfc64ControlObjectStoreV1(dataDir); - const fixture = await signedFixture('close-read-drain'); - await store.stageVerifiedObjects([fixture]); - const entered = deferred(); - const release = deferred(); - const read = store.getVerifiedObject({ - objectDigest: fixture.envelope.objectDigest as Digest32V1, - signatureVariantDigest: pathsFor(dataDir, fixture.envelope).signatureDigest, - verifyIssuerSignature: async (envelope) => { - entered.resolve(); - await release.promise; - return verifyControlEnvelopeIssuerSignatureV1(envelope); - }, - }); - await entered.promise; - - let closeSettled = false; - const close = store.close().then(() => { closeSettled = true; }); - expect(store.closed).toBe(true); - await Promise.resolve(); - expect(closeSettled).toBe(false); - - release.resolve(); - await expect(read).resolves.toMatchObject({ envelope: fixture.envelope }); - await expect(close).resolves.toBeUndefined(); - expect(closeSettled).toBe(true); - }); - - it('cleans an unpublished temp after a pre-visibility fault and converges on retry', async () => { - const dataDir = await temporaryDataDirectory(); - const fixture = await signedFixture('7'); - let injected = false; - const store = await createRfc64ControlObjectStoreTestOpenerV1({ - boundary: (boundary) => { - if (boundary === 'object.temp-fsynced' && !injected) { - injected = true; - throw new Error('injected pre-publish fault'); - } - }, - })(dataDir); - const paths = pathsFor(dataDir, fixture.envelope); - - await expect(store.stageVerifiedObjects([fixture])) - .rejects.toMatchObject({ code: 'control-store-durability' }); - await expect(stat(paths.object)).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); - }); - - it('treats a post-publish fault as an unreachable orphan and safely completes on retry', async () => { - const dataDir = await temporaryDataDirectory(); - const fixture = await signedFixture('8'); - let injected = false; - const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; - const store = await createRfc64ControlObjectStoreTestOpenerV1({ - boundary: (boundary) => { - boundaries.push(boundary); - if (boundary === 'object.published-no-replace' && !injected) { - injected = true; - throw new Error('injected post-publish fault'); - } - }, - })(dataDir); - const paths = pathsFor(dataDir, fixture.envelope); - - await expect(store.stageVerifiedObjects([fixture])) - .rejects.toMatchObject({ code: 'control-store-durability' }); - expect(await readFile(paths.object, 'utf8')).toContain('dkg-rfc64-control-store-test-v1'); - await expect(stat(paths.signature)).rejects.toMatchObject({ code: 'ENOENT' }); - const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); - await expect(store.getVerifiedObject({ - objectDigest: fixture.envelope.objectDigest as Digest32V1, - signatureVariantDigest: paths.signatureDigest, - verifyIssuerSignature, - })).resolves.toBeNull(); - expect(verifyIssuerSignature).not.toHaveBeenCalled(); - boundaries.length = 0; - await expect(store.stageVerifiedObjects([fixture])).resolves.toMatchObject({ durable: true }); - expect(boundaries).toContain('object.existing-fsynced'); - expect(boundaries).toContain('object.existing-parent-fsynced'); - }); - - it('rejects symlinked store topology instead of following it', async () => { - const dataDir = await temporaryDataDirectory(); - const outside = await temporaryDataDirectory(); - const first = await openRfc64ControlObjectStoreV1(dataDir); - await first.close(); - const objects = join(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, 'objects'); - await rm(objects, { recursive: true, force: true }); - await symlink(outside, objects, process.platform === 'win32' ? 'junction' : 'dir'); - - await expect(openRfc64ControlObjectStoreV1(dataDir)) - .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); - }); - - it.runIf(process.platform !== 'win32')( - 'rejects symlinked digest files before reading or verifying outside bytes', - async () => { - for (const target of ['object', 'signature'] as const) { - const dataDir = await temporaryDataDirectory(); - const outside = await temporaryDataDirectory(); - const store = await openRfc64ControlObjectStoreV1(dataDir); - const fixture = await signedFixture(`symlink-${target}`); - await store.stageVerifiedObjects([fixture]); - const paths = pathsFor(dataDir, fixture.envelope); - const targetPath = paths[target]; - const outsideFile = join(outside, `${target}.jcs`); - await writeFile(outsideFile, '{"outside":true}'); - await unlink(targetPath); - await symlink(outsideFile, targetPath, 'file'); - const verifyIssuerSignature = vi.fn(verifyControlEnvelopeIssuerSignatureV1); - - await expect(store.getVerifiedObject({ - objectDigest: fixture.envelope.objectDigest as Digest32V1, - signatureVariantDigest: paths.signatureDigest, - verifyIssuerSignature, - })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); - expect(verifyIssuerSignature).not.toHaveBeenCalled(); - } - }, - ); - - it.runIf(process.platform !== 'win32')( - 'rejects permissive existing files and directories instead of trusting or tightening them', - async () => { - const dataDir = await temporaryDataDirectory(); - const store = await openRfc64ControlObjectStoreV1(dataDir); - const fixture = await signedFixture('8b'); - await store.stageVerifiedObjects([fixture]); - const paths = pathsFor(dataDir, fixture.envelope); - - await chmod(paths.object, 0o644); - await expect(store.getVerifiedObject({ - objectDigest: fixture.envelope.objectDigest as Digest32V1, - signatureVariantDigest: paths.signatureDigest, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); - - await chmod(paths.object, RFC64_CONTROL_OBJECT_STORE_FILE_MODE); - await chmod(dirname(dirname(paths.object)), 0o755); - await store.close(); - await expect(openRfc64ControlObjectStoreV1(dataDir)) - .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); - }, - ); - - it('rejects a permissive control-store root before publishing a new immutable key', async () => { - const dataDir = await temporaryDataDirectory(); - const store = await openRfc64ControlObjectStoreV1(dataDir); - const fixture = await signedFixture('permissive-root-after-open'); - const paths = pathsFor(dataDir, fixture.envelope); - if (process.platform === 'win32') { - grantWindowsEveryoneRead(paths.root, 'directory'); - } else { - await chmod(paths.root, 0o777); - } - - await expect(store.stageVerifiedObjects([fixture])) - .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); - await expect(stat(paths.object)).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(stat(paths.signature)).rejects.toMatchObject({ code: 'ENOENT' }); - await store.close(); - }); - - it.runIf(process.platform === 'win32')( - 'rejects permissive existing Windows file and directory ACLs', - async () => { - const directoryDataDir = await temporaryDataDirectory(); - const directoryStore = await openRfc64ControlObjectStoreV1(directoryDataDir); - const objectsDirectory = join( - directoryDataDir, - RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, - 'objects', - ); - await directoryStore.close(); - grantWindowsEveryoneRead(objectsDirectory, 'directory'); - await expect(openRfc64ControlObjectStoreV1(directoryDataDir)) - .rejects.toMatchObject({ code: 'control-store-unsafe-path' }); - - const fileDataDir = await temporaryDataDirectory(); - const fileStore = await openRfc64ControlObjectStoreV1(fileDataDir); - const fixture = await signedFixture('8c'); - await fileStore.stageVerifiedObjects([fixture]); - const paths = pathsFor(fileDataDir, fixture.envelope); - grantWindowsEveryoneRead(paths.object, 'file'); - await expect(fileStore.getVerifiedObject({ - objectDigest: fixture.envelope.objectDigest as Digest32V1, - signatureVariantDigest: paths.signatureDigest, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); - }, - ); - it('returns null for an exact cache miss without calling the verifier', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); @@ -946,51 +547,6 @@ describe('RFC-64 durable control-object store v1', () => { })).toThrow(expect.objectContaining({ code: 'control-store-closed' })); }); - it('rejects an escaping durable-file relative key inside the write operation', async () => { - const containmentRoot = await temporaryDataDirectory(); - const durableFiles = createRfc64DurableFileStoreV1<'object'>(containmentRoot); - await expect(durableFiles.putExactBytes({ - relativePath: join('..', 'escaped.jcs'), - bytes: new TextEncoder().encode('{}'), - maxBytes: 16, - label: 'test control object', - kind: 'object', - })).rejects.toMatchObject({ code: 'unsafe-path' }); - }); - - it('publishes immutable keys without clobbering a racing independent writer', async () => { - const containmentRoot = await temporaryDataDirectory(); - applyRfc64OwnerOnlyPermissionsSyncV1( - containmentRoot, - RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, - { entryKind: 'directory' }, - ); - const relativePath = join('race', 'immutable.jcs'); - const firstBytes = new TextEncoder().encode('{"writer":"first"}'); - const secondBytes = new TextEncoder().encode('{"writer":"second"}'); - const durableFiles = createRfc64DurableFileStoreV1<'object'>(containmentRoot); - const write = (bytes: Uint8Array) => durableFiles.putExactBytes({ - relativePath, - bytes, - maxBytes: 1024, - label: 'racing immutable fixture', - kind: 'object' as const, - }); - - const outcomes = await Promise.allSettled([ - write(firstBytes), - write(secondBytes), - ]); - expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1); - const rejected = outcomes.find((outcome) => outcome.status === 'rejected'); - expect(rejected).toMatchObject({ - status: 'rejected', - reason: { code: 'corrupt' }, - }); - const stored = await readFile(join(containmentRoot, relativePath)); - expect([firstBytes, secondBytes].some((bytes) => Buffer.from(bytes).equals(stored))).toBe(true); - }); - it('uses a closed typed error registry', () => { expect(() => new Rfc64ControlObjectStoreErrorV1( 'not-registered' as never, diff --git a/packages/agent/test/rfc64-durable-file-store-v1.test.ts b/packages/agent/test/rfc64-durable-file-store-v1.test.ts new file mode 100644 index 0000000000..3ad357a19a --- /dev/null +++ b/packages/agent/test/rfc64-durable-file-store-v1.test.ts @@ -0,0 +1,65 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE } from '../src/rfc64/control-object-store-v1.js'; +import { createRfc64DurableFileStoreV1 } from '../src/rfc64/durable-file-store-v1.js'; +import { applyRfc64OwnerOnlyPermissionsSyncV1 } from '../src/rfc64/secure-filesystem-policy-v1.js'; +import { + createTemporaryDataDirectoryFixture, +} from './support/rfc64-control-object-store-fixtures.js'; + +const temporaryDirectories = createTemporaryDataDirectoryFixture(); +const { temporaryDataDirectory } = temporaryDirectories; + +afterEach(async () => { + await temporaryDirectories.cleanup(); +}); + +describe('RFC-64 durable file store v1', () => { + it('rejects an escaping durable-file relative key inside the write operation', async () => { + const containmentRoot = await temporaryDataDirectory(); + const durableFiles = createRfc64DurableFileStoreV1<'object'>(containmentRoot); + await expect(durableFiles.putExactBytes({ + relativePath: join('..', 'escaped.jcs'), + bytes: new TextEncoder().encode('{}'), + maxBytes: 16, + label: 'test control object', + kind: 'object', + })).rejects.toMatchObject({ code: 'unsafe-path' }); + }); + + it('publishes immutable keys without clobbering a racing independent writer', async () => { + const containmentRoot = await temporaryDataDirectory(); + applyRfc64OwnerOnlyPermissionsSyncV1( + containmentRoot, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + { entryKind: 'directory' }, + ); + const relativePath = join('race', 'immutable.jcs'); + const firstBytes = new TextEncoder().encode('{"writer":"first"}'); + const secondBytes = new TextEncoder().encode('{"writer":"second"}'); + const durableFiles = createRfc64DurableFileStoreV1<'object'>(containmentRoot); + const write = (bytes: Uint8Array) => durableFiles.putExactBytes({ + relativePath, + bytes, + maxBytes: 1024, + label: 'racing immutable fixture', + kind: 'object' as const, + }); + + const outcomes = await Promise.allSettled([ + write(firstBytes), + write(secondBytes), + ]); + expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1); + const rejected = outcomes.find((outcome) => outcome.status === 'rejected'); + expect(rejected).toMatchObject({ + status: 'rejected', + reason: { code: 'corrupt' }, + }); + const stored = await readFile(join(containmentRoot, relativePath)); + expect([firstBytes, secondBytes].some((bytes) => Buffer.from(bytes).equals(stored))).toBe(true); + }); +}); diff --git a/packages/agent/test/rfc64-secure-filesystem-policy-v1.test.ts b/packages/agent/test/rfc64-secure-filesystem-policy-v1.test.ts new file mode 100644 index 0000000000..8aa89f2d07 --- /dev/null +++ b/packages/agent/test/rfc64-secure-filesystem-policy-v1.test.ts @@ -0,0 +1,67 @@ +import { chmod } from 'node:fs/promises'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE } from '../src/rfc64/control-object-store-v1.js'; +import { + applyRfc64OwnerOnlyPermissionsSyncV1, + applyRfc64OwnerOnlyPermissionsV1, + assertRfc64FilesystemOwnerSyncV1, + assertRfc64FilesystemOwnerV1, + assertRfc64OwnerOnlyPermissionsSyncV1, + assertRfc64OwnerOnlyPermissionsV1, +} from '../src/rfc64/secure-filesystem-policy-v1.js'; +import { + createTemporaryDataDirectoryFixture, +} from './support/rfc64-control-object-store-fixtures.js'; + +const temporaryDirectories = createTemporaryDataDirectoryFixture(); +const { temporaryDataDirectory } = temporaryDirectories; + +afterEach(async () => { + await temporaryDirectories.cleanup(); +}); + +describe('RFC-64 secure filesystem policy v1', () => { + it('keeps synchronous inventory and asynchronous durable policy twins aligned', async () => { + const dataDir = await temporaryDataDirectory(); + const policy = { entryKind: 'directory' as const }; + applyRfc64OwnerOnlyPermissionsSyncV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + ); + + expect(() => assertRfc64FilesystemOwnerSyncV1(dataDir)).not.toThrow(); + await expect(assertRfc64FilesystemOwnerV1(dataDir)).resolves.toBeUndefined(); + expect(() => assertRfc64OwnerOnlyPermissionsSyncV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).not.toThrow(); + await expect(assertRfc64OwnerOnlyPermissionsV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).resolves.toBeUndefined(); + await expect(applyRfc64OwnerOnlyPermissionsV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).resolves.toBeUndefined(); + + if (process.platform !== 'win32') { + await chmod(dataDir, 0o755); + expect(() => assertRfc64OwnerOnlyPermissionsSyncV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).toThrow(/path mode 755/); + await expect(assertRfc64OwnerOnlyPermissionsV1( + dataDir, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + policy, + )).rejects.toThrow(/path mode 755/); + } + }); +}); diff --git a/packages/agent/test/support/rfc64-control-object-store-fixtures.ts b/packages/agent/test/support/rfc64-control-object-store-fixtures.ts new file mode 100644 index 0000000000..fa16ff269d --- /dev/null +++ b/packages/agent/test/support/rfc64-control-object-store-fixtures.ts @@ -0,0 +1,96 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + computeControlObjectDigestHex, + computeControlSignatureVariantDigestHex, + type Digest32V1, + type EvmAddressV1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; + +import { + RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, + type StageVerifiedControlObjectV1, +} from '../../src/rfc64/control-object-store-v1.js'; + +const PRIVATE_KEY = `0x${'42'.repeat(32)}`; + +export const wallet = new ethers.Wallet(PRIVATE_KEY); +export const ISSUER = wallet.address.toLowerCase() as EvmAddressV1; + +export function createTemporaryDataDirectoryFixture(): { + readonly cleanup: () => Promise; + readonly temporaryDataDirectory: () => Promise; +} { + const temporaryDirectories: string[] = []; + return { + cleanup: async () => { + await Promise.all(temporaryDirectories.splice(0).map(async (path) => { + await rm(path, { recursive: true, force: true }); + })); + }, + temporaryDataDirectory: async () => { + const path = await mkdtemp(join(tmpdir(), 'dkg-rfc64-control-store-')); + temporaryDirectories.push(path); + return path; + }, + }; +} + +export function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { resolvePromise = resolve; }); + return { promise, resolve: () => resolvePromise?.() }; +} + +export async function signedFixture( + sequence: string, +): Promise { + const unsigned = { + issuer: ISSUER, + objectType: 'dkg-rfc64-control-store-test-v1', + payload: { sequence }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } satisfies UnsignedControlEnvelopeV1; + const objectDigest = computeControlObjectDigestHex(unsigned); + const signature = await wallet.signMessage(ethers.getBytes(objectDigest)); + const envelope = { + ...unsigned, + objectDigest, + signature, + } as SignedControlEnvelopeV1; + return { + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }; +} + +export function pathsFor( + dataDir: string, + envelope: SignedControlEnvelopeV1, +): { root: string; object: string; signature: string; signatureDigest: Digest32V1 } { + const root = join(dataDir, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH); + const objectHex = envelope.objectDigest.slice(2); + const signatureDigest = computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ) as Digest32V1; + return { + root, + object: join(root, 'objects', objectHex.slice(0, 2), `${objectHex}.jcs`), + signature: join( + root, + 'signatures', + objectHex.slice(0, 2), + objectHex, + `${signatureDigest.slice(2)}.jcs`, + ), + signatureDigest, + }; +} diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index e0c5c8434a..5652eb78b2 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -116,6 +116,9 @@ export default defineConfig({ "test/rfc64-agent-inventory-lifecycle.test.ts", "test/rfc64-author-catalog-producer.test.ts", "test/rfc64-control-object-store-v1.test.ts", + "test/rfc64-control-object-store-lifecycle-v1.test.ts", + "test/rfc64-durable-file-store-v1.test.ts", + "test/rfc64-secure-filesystem-policy-v1.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 3ac099a4a5ed073b14ea723c98ac591132948910 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:50:31 +0200 Subject: [PATCH 087/292] fix(agent): preserve package manifest subpath --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/agent/package.json b/packages/agent/package.json index 2f4527c594..6dd7df228c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -10,6 +10,7 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./package.json": "./package.json", "./dist/rfc64/control-object-store-v1-internal.js": null, "./dist/rfc64/control-object-store-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index a018251ff2..8df173b886 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -1,5 +1,9 @@ +import { createRequire } from 'node:module'; + const root = await import('@origintrail-official/dkg-agent'); const legacyAgent = await import('@origintrail-official/dkg-agent/dist/dkg-agent.js'); +const require = createRequire(import.meta.url); +const packageManifest = require('@origintrail-official/dkg-agent/package.json'); const legacyCatalogProducer = await import( '@origintrail-official/dkg-agent/dist/rfc64/author-catalog-producer.js' ); @@ -10,6 +14,9 @@ const legacyInventory = await import( if (typeof root.DKGAgent !== 'function' || typeof legacyAgent.DKGAgent !== 'function') { throw new Error('published agent entry points did not expose DKGAgent'); } +if (packageManifest.name !== '@origintrail-official/dkg-agent') { + throw new Error('historical package.json subpath no longer resolves'); +} if ( typeof legacyCatalogProducer.produceEmptyAuthorCatalogGenesisV1 !== 'function' || typeof legacyInventory.openInventoryV1 !== 'function' From 9846c30783b292ee4b29453dd922a7256b451152 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:04:25 +0200 Subject: [PATCH 088/292] fix(agent): close RFC-64 Windows persistence gaps --- .github/workflows/rfc64-inventory-windows.yml | 3 + .../agent/src/rfc64/durable-file-store-v1.ts | 116 +++++++++++------- .../test/rfc64-durable-file-store-v1.test.ts | 88 ++++++++++++- 3 files changed, 165 insertions(+), 42 deletions(-) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index a65612d1d8..7f20803bd4 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -78,3 +78,6 @@ jobs: test/rfc64-agent-inventory-lifecycle.test.ts test/rfc64-author-catalog-producer.test.ts test/rfc64-control-object-store-v1.test.ts + test/rfc64-control-object-store-lifecycle-v1.test.ts + test/rfc64-durable-file-store-v1.test.ts + test/rfc64-secure-filesystem-policy-v1.test.ts diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index 737ca2315f..69b2ddf5d9 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -65,6 +65,20 @@ export interface Rfc64DurableFileLifecycleV1 { ) => void | Promise; } +/** @internal Source-test lifecycle with deterministic directory single-flight observation. */ +export interface Rfc64DurableFileTestLifecycleV1 + extends Rfc64DurableFileLifecycleV1 { + readonly directoryPreparation?: ( + observation: Rfc64DirectoryPreparationObservationV1, + ) => void | Promise; +} + +/** @internal Source-test observation emitted by the guarded test factory. */ +export interface Rfc64DirectoryPreparationObservationV1 { + readonly path: string; + readonly disposition: 'started' | 'joined'; +} + export interface Rfc64DurableFileStoreV1 { putExactBytes(input: PutRfc64ExactBytesInputV1): Promise; readOptionalBoundedBytes( @@ -84,14 +98,21 @@ export function createRfc64DurableFileStoreV1( /** @internal Package-local fault injection; unavailable outside source tests. */ export function createRfc64DurableFileStoreForTestV1( containmentRoot: string, - lifecycle: Rfc64DurableFileLifecycleV1, + lifecycle: Rfc64DurableFileTestLifecycleV1, ): Rfc64DurableFileStoreV1 { assertRfc64DurableFileTestEnvironmentV1(); + const observeDirectoryPreparation = lifecycle.directoryPreparation; const guardedLifecycle = Object.freeze({ boundary: async (boundary: Rfc64DurableFileBoundaryV1): Promise => { assertRfc64DurableFileTestEnvironmentV1(); await lifecycle.boundary(boundary); }, + directoryPreparation: async ( + observation: Rfc64DirectoryPreparationObservationV1, + ): Promise => { + assertRfc64DurableFileTestEnvironmentV1(); + await observeDirectoryPreparation?.(observation); + }, }); return createRfc64DurableFileStoreWithLifecycleV1( containmentRoot, @@ -101,20 +122,42 @@ export function createRfc64DurableFileStoreForTestV1( const PRODUCTION_DURABLE_FILE_LIFECYCLE_V1 = Object.freeze({ boundary: (): void => {}, -}) satisfies Rfc64DurableFileLifecycleV1; +}) satisfies Rfc64DurableFileTestLifecycleV1; function createRfc64DurableFileStoreWithLifecycleV1( containmentRoot: string, - lifecycle: Rfc64DurableFileLifecycleV1, + lifecycle: Rfc64DurableFileTestLifecycleV1, ): Rfc64DurableFileStoreV1 { const directoryPreparations = new Map>(); + const prepareDirectoryComponent = async (path: string): Promise => { + const key = resolve(path); + const current = directoryPreparations.get(key); + if (current !== undefined) { + await lifecycle.directoryPreparation?.({ path: key, disposition: 'joined' }); + await current; + return; + } + const operation = Promise.resolve().then(async () => { + await lifecycle.directoryPreparation?.({ path: key, disposition: 'started' }); + await prepareRfc64SecureDirectoryComponentV1(path, lifecycle); + }); + directoryPreparations.set(key, operation); + try { + await operation; + } finally { + if (directoryPreparations.get(key) === operation) { + directoryPreparations.delete(key); + } + } + }; const prepareDirectoryTree = async ( target: string, containmentRootAccess: Rfc64ExistingAccessV1, ): Promise => { // Every caller independently revalidates the protected root before it may - // join an in-process preparation. The keyed promise only closes the brief - // Windows mkdir-before-DACL race; it does not weaken fail-closed admission. + // join an in-process preparation. Per-component promises close the brief + // Windows mkdir-before-DACL race even when distinct targets share a newly + // created ancestor; they do not weaken fail-closed admission. if (containmentRootAccess === 'owner-only') { await assertRfc64ExistingDirectoryV1( containmentRoot, @@ -122,26 +165,12 @@ function createRfc64DurableFileStoreWithLifecycleV1( { access: 'owner-only' }, ); } - const key = resolve(target); - const current = directoryPreparations.get(key); - if (current !== undefined) { - await current; - return; - } - const operation = ensureRfc64SecureDirectoryTreeWithLifecycleV1( + await walkRfc64ContainedDirectoryTreeV1( target, containmentRoot, - lifecycle, containmentRootAccess, + prepareDirectoryComponent, ); - directoryPreparations.set(key, operation); - try { - await operation; - } finally { - if (directoryPreparations.get(key) === operation) { - directoryPreparations.delete(key); - } - } }; return Object.freeze({ putExactBytes: (input: PutRfc64ExactBytesInputV1) => @@ -223,29 +252,34 @@ async function ensureRfc64SecureDirectoryTreeWithLifecycleV1 { - let created = false; - try { - await mkdir(current, { mode: RFC64_SECURE_DIRECTORY_MODE_V1 }); - created = true; - await lifecycle.boundary('directory.created'); - } catch (cause) { - if (!isNodeError(cause, 'EEXIST')) { - fail('io', `failed to create durable store directory ${current}`, cause); - } - } - if (created) { - await chmodSecure(current, RFC64_SECURE_DIRECTORY_MODE_V1, 'directory'); - await lifecycle.boundary('directory.mode-secured'); - await fsyncDirectory(current); - await lifecycle.boundary('directory.self-fsynced'); - await fsyncDirectory(dirname(current)); - await lifecycle.boundary('directory.parent-fsynced'); - } - }, + (current) => prepareRfc64SecureDirectoryComponentV1(current, lifecycle), ); } +async function prepareRfc64SecureDirectoryComponentV1( + path: string, + lifecycle: Rfc64DurableFileLifecycleV1, +): Promise { + let created = false; + try { + await mkdir(path, { mode: RFC64_SECURE_DIRECTORY_MODE_V1 }); + created = true; + await lifecycle.boundary('directory.created'); + } catch (cause) { + if (!isNodeError(cause, 'EEXIST')) { + fail('io', `failed to create durable store directory ${path}`, cause); + } + } + if (created) { + await chmodSecure(path, RFC64_SECURE_DIRECTORY_MODE_V1, 'directory'); + await lifecycle.boundary('directory.mode-secured'); + await fsyncDirectory(path); + await lifecycle.boundary('directory.self-fsynced'); + await fsyncDirectory(dirname(path)); + await lifecycle.boundary('directory.parent-fsynced'); + } +} + export interface PutRfc64ExactBytesInputV1 { readonly relativePath: string; readonly bytes: Uint8Array; diff --git a/packages/agent/test/rfc64-durable-file-store-v1.test.ts b/packages/agent/test/rfc64-durable-file-store-v1.test.ts index 3ad357a19a..dcb65d4a63 100644 --- a/packages/agent/test/rfc64-durable-file-store-v1.test.ts +++ b/packages/agent/test/rfc64-durable-file-store-v1.test.ts @@ -4,10 +4,14 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE } from '../src/rfc64/control-object-store-v1.js'; -import { createRfc64DurableFileStoreV1 } from '../src/rfc64/durable-file-store-v1.js'; +import { + createRfc64DurableFileStoreForTestV1, + createRfc64DurableFileStoreV1, +} from '../src/rfc64/durable-file-store-v1.js'; import { applyRfc64OwnerOnlyPermissionsSyncV1 } from '../src/rfc64/secure-filesystem-policy-v1.js'; import { createTemporaryDataDirectoryFixture, + deferred, } from './support/rfc64-control-object-store-fixtures.js'; const temporaryDirectories = createTemporaryDataDirectoryFixture(); @@ -62,4 +66,86 @@ describe('RFC-64 durable file store v1', () => { const stored = await readFile(join(containmentRoot, relativePath)); expect([firstBytes, secondBytes].some((bytes) => Buffer.from(bytes).equals(stored))).toBe(true); }); + + it('coalesces distinct descendants on every newly created shared directory', async () => { + const containmentRoot = await temporaryDataDirectory(); + applyRfc64OwnerOnlyPermissionsSyncV1( + containmentRoot, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + { entryKind: 'directory' }, + ); + const created = deferred(); + const releaseCreated = deferred(); + const joined = deferred(); + const sharedAncestor = join(containmentRoot, 'signatures', 'shared'); + let pauseSharedAncestor = false; + let paused = false; + let joinedPath: string | null = null; + const durableFiles = createRfc64DurableFileStoreForTestV1<'signature'>( + containmentRoot, + Object.freeze({ + boundary: async (boundary) => { + if (pauseSharedAncestor && boundary === 'directory.created' && !paused) { + paused = true; + created.resolve(); + await releaseCreated.promise; + } + }, + directoryPreparation: (observation) => { + if (observation.disposition === 'joined') { + joinedPath = observation.path; + joined.resolve(); + } + }, + }), + ); + const put = (relativePath: string, value: string) => durableFiles.putExactBytes({ + relativePath, + bytes: new TextEncoder().encode(value), + maxBytes: 64, + label: 'shared-ancestor signature fixture', + kind: 'signature', + }); + await put(join('signatures', 'seed', 'prepared.jcs'), 'seed'); + pauseSharedAncestor = true; + + const firstRelativePath = join('signatures', 'shared', 'object-a', 'variant.jcs'); + const secondRelativePath = join('signatures', 'shared', 'object-b', 'variant.jcs'); + const first = put(firstRelativePath, 'first'); + await created.promise; + const second = put(secondRelativePath, 'second'); + await joined.promise; + + expect(joinedPath).toBe(sharedAncestor); + releaseCreated.resolve(); + await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]); + await expect(readFile(join(containmentRoot, firstRelativePath), 'utf8')) + .resolves.toBe('first'); + await expect(readFile(join(containmentRoot, secondRelativePath), 'utf8')) + .resolves.toBe('second'); + }); + + it('rejects an existing durable file larger than the read-side byte ceiling', async () => { + const containmentRoot = await temporaryDataDirectory(); + applyRfc64OwnerOnlyPermissionsSyncV1( + containmentRoot, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + { entryKind: 'directory' }, + ); + const durableFiles = createRfc64DurableFileStoreV1<'object'>(containmentRoot); + const relativePath = join('bounded', 'oversized.jcs'); + await durableFiles.putExactBytes({ + relativePath, + bytes: new Uint8Array(17).fill(0x61), + maxBytes: 32, + label: 'oversized read fixture', + kind: 'object', + }); + + await expect(durableFiles.readOptionalBoundedBytes({ + relativePath, + maxBytes: 16, + label: 'oversized read fixture', + })).rejects.toMatchObject({ code: 'corrupt' }); + }); }); From 813d53835e9730f54218ab6c0ce90ee4804cf522 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:08:53 +0200 Subject: [PATCH 089/292] ci: trigger Windows RFC-64 split suites --- .github/workflows/rfc64-inventory-windows.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 7f20803bd4..70ffd9756c 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -17,7 +17,11 @@ on: - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' - 'packages/agent/test/rfc64-author-catalog-producer.test.ts' + - 'packages/agent/test/rfc64-control-object-store-lifecycle-v1.test.ts' - 'packages/agent/test/rfc64-control-object-store-v1.test.ts' + - 'packages/agent/test/rfc64-durable-file-store-v1.test.ts' + - 'packages/agent/test/rfc64-secure-filesystem-policy-v1.test.ts' + - 'packages/agent/test/support/rfc64-control-object-store-fixtures.ts' - 'packages/agent/test/support/rfc64-control-object-store-test-support.ts' - 'packages/agent/test/rfc64-control-store-public-api.typecheck.ts' - 'packages/agent/test/rfc64-persistence-owner.typecheck.ts' From a2cbc7ab462415dd8e8a86eeb9da755e5db8062f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:42:33 +0200 Subject: [PATCH 090/292] fix(devnet): harden RFC-64 evidence boundaries --- devnet/_bootstrap/RFC64_EVIDENCE.md | 28 +- devnet/_bootstrap/package.json | 3 +- devnet/_bootstrap/rfc64-evidence.test.ts | 215 +++++++++- devnet/_bootstrap/rfc64-evidence.ts | 485 ++++++++++++++++++++--- packages/core/src/rdf-canonize.d.ts | 18 +- pnpm-lock.yaml | 3 + 6 files changed, 683 insertions(+), 69 deletions(-) diff --git a/devnet/_bootstrap/RFC64_EVIDENCE.md b/devnet/_bootstrap/RFC64_EVIDENCE.md index 7d93de8f84..2a707f82be 100644 --- a/devnet/_bootstrap/RFC64_EVIDENCE.md +++ b/devnet/_bootstrap/RFC64_EVIDENCE.md @@ -10,17 +10,20 @@ Knowledge Asset datasets after its own operation completes. 1. UALs are parsed with `parseDeterministicKnowledgeAssetUal`, converted to the canonical protocol spelling, rejected on duplicate canonical identity, and sorted lexically. -2. Each KA's semantic N-Quads are canonicalized with the existing RDFC-1.0 - helper, deduplicated, sorted lexically, joined with LF, and terminated by one - LF when non-empty. -3. `ualsSha256` is SHA-256 over the sorted UAL list (`UAL + LF` per entry). -4. Each `semanticNQuadsSha256` is SHA-256 over that KA's exact canonical +2. Received N-Quads are parsed row-by-row and projected to S/P/O before hashing; + physical graph placement is never part of semantic equality. Any duplicate + S/P/O row after projection is rejected instead of silently deduplicated. +3. The placement-neutral dataset is canonicalized with RDFC-1.0, sorted + lexically, joined with LF, and terminated by one LF when non-empty. +4. `ualsSha256` is SHA-256 over the sorted UAL list (`UAL + LF` per entry). +5. Each `semanticNQuadsSha256` is SHA-256 over that KA's exact canonical N-Quads UTF-8 bytes. -5. The snapshot-level semantic digest is SHA-256 over the domain string +6. The snapshot-level semantic digest is SHA-256 over the domain string `rfc64-semantic-nquads-manifest/v1\n` followed by stable JSON containing the sorted `(UAL, quadCount, per-KA digest)` manifest. -6. The stable JSON writer recursively sorts object keys, uses two-space - indentation, rejects lossy/non-finite values, and writes one trailing LF. +7. Stable JSON recursively sorts object keys and rejects sparse/custom arrays, + accessors, symbols, hidden properties, custom prototypes, lossy values, and + non-finite numbers. An own `__proto__` key remains ordinary JSON data. Snapshots contain KA and quad counts plus exact digests, without embedding the potentially large N-Quads payload. Validation recomputes every redundant count @@ -28,10 +31,19 @@ and manifest digest before comparison. Duplicate UALs, malformed snapshots, missing KAs, unexpected KAs, count differences, and digest differences cannot produce a passing comparison. +Created and validated snapshots are defensively copied and deeply frozen. Run +evidence closes over its own frozen expected/observed copies, so later caller +mutation cannot change a previously derived `passed` result. String timestamps +must carry `Z` or an explicit UTC offset and are emitted in canonical UTC form. + The run artifact adds the stable gate/observer label, selected source peer, canonical ISO timing and duration, attempt/retry/failure details, expected and observed snapshots, and the derived comparison. `passed` is derived from the comparison and terminal failure; a caller cannot manually force it to `true`. +Artifact publication uses a same-directory exclusive 0600 temporary file, +fsyncs its contents, atomically renames it, verifies the published bytes/mode, +and fsyncs the containing directory. Existing symlink targets and symlinked or +changing directory topology are rejected. ## Harness use diff --git a/devnet/_bootstrap/package.json b/devnet/_bootstrap/package.json index 95b1e02849..b9d90a73d3 100644 --- a/devnet/_bootstrap/package.json +++ b/devnet/_bootstrap/package.json @@ -12,7 +12,8 @@ "test:rfc64-evidence": "vitest run --config vitest.evidence.config.ts" }, "dependencies": { - "ethers": "^6.16.0" + "ethers": "^6.16.0", + "rdf-canonize": "^5.0.0" }, "devDependencies": { "vitest": "^4.0.18" diff --git a/devnet/_bootstrap/rfc64-evidence.test.ts b/devnet/_bootstrap/rfc64-evidence.test.ts index 6ab12af480..3071afcbd6 100644 --- a/devnet/_bootstrap/rfc64-evidence.test.ts +++ b/devnet/_bootstrap/rfc64-evidence.test.ts @@ -1,5 +1,17 @@ import { createHash } from 'node:crypto'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -24,6 +36,14 @@ const UAL_B = `did:dkg:otp:20430/${AUTHOR_B}/8`; const temporaryDirectories: string[] = []; +function createTemporaryDirectory(): string { + const directory = mkdtempSync( + join(realpathSync(tmpdir()), 'rfc64-evidence-'), + ); + temporaryDirectories.push(directory); + return directory; +} + afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }); @@ -31,7 +51,7 @@ afterEach(() => { }); describe('RFC-64 semantic snapshot evidence', () => { - it('canonicalizes blank nodes, line order, duplicate quads, and UAL aliases', async () => { + it('canonicalizes blank nodes, line order, and UAL aliases', async () => { const first = await createRfc64SemanticSnapshot([ { ual: ` did:dkg:otp:20430/${AUTHOR_B.toUpperCase().replace('0X', '0x')}/0008 `, @@ -42,10 +62,8 @@ describe('RFC-64 semantic snapshot evidence', () => { }, { ual: UAL_A, - semanticNQuads: [ - ' "Alice" .', + semanticNQuads: ' "Alice" .', - ], }, ]); const second = await createRfc64SemanticSnapshot([ @@ -71,6 +89,38 @@ describe('RFC-64 semantic snapshot evidence', () => { ); }); + it('projects physical graph placement to the RFC-64 semantic S/P/O view', async () => { + const inGraphA = await createRfc64SemanticSnapshot([{ + ual: UAL_A, + semanticNQuads: + ' "value" .', + }]); + const inGraphB = await createRfc64SemanticSnapshot([{ + ual: UAL_A, + semanticNQuads: + ' "value" .', + }]); + const canonical = await canonicalizeSemanticNQuads( + ' "value" .', + ); + + expect(inGraphA).toEqual(inGraphB); + expect(canonical.text).toBe(' "value" .\n'); + expect(canonical.text).not.toContain('physical:node-a'); + }); + + it('rejects duplicates at the received semantic projection boundary', async () => { + await expect(canonicalizeSemanticNQuads([ + ' "value" .', + ' "value" .', + ])).rejects.toThrow(/Duplicate received semantic S\/P\/O projection/); + + await expect(canonicalizeSemanticNQuads([ + ' "value" .', + ' "value" .', + ])).rejects.toThrow(/Duplicate received semantic S\/P\/O projection/); + }); + it('pins the exact canonical N-Quads bytes and SHA-256 digest', async () => { const canonical = await canonicalizeSemanticNQuads([ ' "B" .', @@ -139,6 +189,19 @@ describe('RFC-64 semantic snapshot evidence', () => { expect(() => validateRfc64SemanticSnapshot(tampered)) .toThrow(/semanticNQuadsSha256 .* does not equal computed/); }); + + it('returns deeply frozen semantic snapshots', async () => { + const snapshot = await createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: ' "A" .' }, + ]); + + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.knowledgeAssets)).toBe(true); + expect(Object.isFrozen(snapshot.knowledgeAssets[0])).toBe(true); + expect(() => { + (snapshot as unknown as { kaCount: number }).kaCount = 99; + }).toThrow(TypeError); + }); }); describe('RFC-64 devnet run artifact', () => { @@ -165,6 +228,35 @@ describe('RFC-64 devnet run artifact', () => { }); }); + it('closes evidence over defensive frozen snapshot copies', async () => { + const snapshot = await createRfc64SemanticSnapshot([ + { ual: UAL_A, semanticNQuads: ' "exact" .' }, + ]); + const mutable = JSON.parse(JSON.stringify(snapshot)) as Rfc64SemanticSnapshotV1; + const evidence = createRfc64DevnetEvidence({ + gate: 'gate-1-semantic-recovery', + observer: 'receiver-node-2', + sourcePeerId: '12D3KooWSource', + startedAt: '2026-07-19T12:00:00Z', + completedAt: '2026-07-19T12:00:01Z', + attemptCount: 1, + expected: mutable, + observed: mutable, + }); + const closedBytes = stableJsonStringify(evidence); + + (mutable as unknown as { kaCount: number }).kaCount = 99; + (mutable.knowledgeAssets[0] as unknown as { quadCount: number }).quadCount = 99; + + expect(evidence.passed).toBe(true); + expect(evidence.expected.kaCount).toBe(1); + expect(evidence.expected.knowledgeAssets[0]!.quadCount).toBe(1); + expect(stableJsonStringify(evidence)).toBe(closedBytes); + expect(Object.isFrozen(evidence)).toBe(true); + expect(Object.isFrozen(evidence.expected.knowledgeAssets[0])).toBe(true); + expect(Object.isFrozen(evidence.comparison.mismatches)).toBe(true); + }); + it('records peer, timing, retry, failure, counts, and a fail-closed comparison', async () => { const expected = await createRfc64SemanticSnapshot([ { ual: UAL_A, semanticNQuads: ' "expected" .' }, @@ -255,9 +347,38 @@ describe('RFC-64 devnet run artifact', () => { })).toThrow(/one failure for each of the 1 retried attempts/); }); + it('rejects timezone-ambiguous strings and canonicalizes explicit offsets', async () => { + const snapshot = await createRfc64SemanticSnapshot([]); + expect(() => createRfc64DevnetEvidence({ + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + startedAt: '2026-07-19T12:00:00', + completedAt: '2026-07-19T12:00:01Z', + attemptCount: 1, + expected: snapshot, + observed: snapshot, + })).toThrow(/startedAt must be an ISO timestamp with Z or an explicit UTC offset/); + + const evidence = createRfc64DevnetEvidence({ + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + startedAt: '2026-07-19T12:00:00+02:00', + completedAt: '2026-07-19T12:00:01+02:00', + attemptCount: 1, + expected: snapshot, + observed: snapshot, + }); + expect(evidence.timing).toEqual({ + startedAt: '2026-07-19T10:00:00.000Z', + completedAt: '2026-07-19T10:00:01.000Z', + durationMs: 1_000, + }); + }); + it('writes byte-identical stable JSON independent of object insertion order', () => { - const directory = mkdtempSync(join(tmpdir(), 'rfc64-evidence-')); - temporaryDirectories.push(directory); + const directory = createTemporaryDirectory(); const firstPath = join(directory, 'nested', 'first.json'); const secondPath = join(directory, 'second.json'); const firstValue = { z: 3, nested: { b: true, a: 'x' }, a: [2, 1] }; @@ -275,6 +396,8 @@ describe('RFC-64 devnet run artifact', () => { ); expect(firstBytes.toString('utf8')).toBe(stableJsonStringify(firstValue)); expect(firstBytes.at(-1)).toBe(0x0a); + expect(statSync(firstPath).mode & 0o777).toBe(0o600); + expect(readdirSync(join(directory, 'nested'))).toEqual(['first.json']); }); it('rejects lossy JSON values', () => { @@ -283,4 +406,82 @@ describe('RFC-64 devnet run artifact', () => { expect(() => stableJsonStringify({ value: Number.NaN })) .toThrow(/non-finite number/); }); + + it('rejects sparse, custom, accessor, symbol, and custom-prototype JSON containers', () => { + const sparse: unknown[] = []; + sparse[1] = 'present'; + expect(() => stableJsonStringify(sparse)).toThrow(/sparse array/); + + const customArray = [1] as number[] & { extra?: number }; + customArray.extra = 2; + expect(() => stableJsonStringify(customArray)).toThrow(/custom array property/); + + let accessorRead = false; + const accessor = {}; + Object.defineProperty(accessor, 'value', { + enumerable: true, + get: () => { + accessorRead = true; + return 1; + }, + }); + expect(() => stableJsonStringify(accessor)).toThrow(/accessor property/); + expect(accessorRead).toBe(false); + + const symbolBearing = { value: 1 } as Record; + symbolBearing[Symbol('hidden')] = 2; + expect(() => stableJsonStringify(symbolBearing)).toThrow(/symbol keys/); + + const hiddenProperty = { visible: 1 }; + Object.defineProperty(hiddenProperty, 'hidden', { value: 2 }); + expect(() => stableJsonStringify(hiddenProperty)).toThrow(/hidden non-enumerable/); + + const customPrototype = Object.create({ inherited: true }) as object; + expect(() => stableJsonStringify(customPrototype)).toThrow(/plain JSON objects/); + }); + + it('preserves an own __proto__ JSON key without changing object prototypes', () => { + const input = JSON.parse('{"z":1,"__proto__":{"polluted":true}}') as object; + const parsed = JSON.parse(stableJsonStringify(input)) as Record; + + expect(Object.hasOwn(parsed, '__proto__')).toBe(true); + expect(parsed.__proto__).toEqual({ polluted: true }); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + }); + + it('atomically replaces regular targets as 0600 without temp remnants', () => { + const directory = createTemporaryDirectory(); + const target = join(directory, 'artifact.json'); + writeFileSync(target, 'old', { mode: 0o644 }); + chmodSync(target, 0o644); + + writeStableJsonArtifact(target, { generation: 2 }); + + expect(readFileSync(target, 'utf8')).toBe('{\n "generation": 2\n}\n'); + expect(statSync(target).mode & 0o777).toBe(0o600); + expect(readdirSync(directory)).toEqual(['artifact.json']); + }); + + it('rejects symlinked targets and parent topology without following them', () => { + const directory = createTemporaryDirectory(); + const realTarget = join(directory, 'real.json'); + const linkedTarget = join(directory, 'linked.json'); + writeFileSync(realTarget, 'sentinel', { mode: 0o600 }); + symlinkSync(realTarget, linkedTarget); + + expect(() => writeStableJsonArtifact(linkedTarget, { unsafe: true })) + .toThrow(/target must not be a symbolic link/); + expect(readFileSync(realTarget, 'utf8')).toBe('sentinel'); + + const realDirectory = join(directory, 'real-directory'); + const linkedDirectory = join(directory, 'linked-directory'); + mkdirSync(realDirectory, { mode: 0o700 }); + symlinkSync(realDirectory, linkedDirectory, 'dir'); + + expect(() => writeStableJsonArtifact( + join(linkedDirectory, 'escaped.json'), + { unsafe: true }, + )).toThrow(/directory topology contains a symbolic link/); + expect(existsSync(join(realDirectory, 'escaped.json'))).toBe(false); + }); }); diff --git a/devnet/_bootstrap/rfc64-evidence.ts b/devnet/_bootstrap/rfc64-evidence.ts index 44779546df..73171585c7 100644 --- a/devnet/_bootstrap/rfc64-evidence.ts +++ b/devnet/_bootstrap/rfc64-evidence.ts @@ -6,10 +6,32 @@ * Harnesses pass their observations in after those protocol-owned operations * finish, then persist the returned fail-closed comparison artifact. */ -import { createHash } from 'node:crypto'; -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; -import { canonicalize } from '../../packages/core/src/crypto/canonicalize.js'; +import { createHash, randomUUID } from 'node:crypto'; +import { + closeSync, + constants as fsConstants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import type { Stats } from 'node:fs'; +import { + basename, + dirname, + join, + parse as parsePath, + relative, + resolve, + sep, +} from 'node:path'; +import rdfCanonize from 'rdf-canonize'; import { parseDeterministicKnowledgeAssetUal } from '../../packages/core/src/ka-content-scope.js'; export const RFC64_SEMANTIC_SNAPSHOT_SCHEMA = @@ -20,6 +42,8 @@ export const RFC64_DEVNET_EVIDENCE_SCHEMA = const SEMANTIC_MANIFEST_DOMAIN = 'rfc64-semantic-nquads-manifest/v1\n'; const SHA256_RE = /^sha256:[0-9a-f]{64}$/; +const TIMEZONE_QUALIFIED_ISO_RE = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/u; export type Sha256Digest = `sha256:${string}`; @@ -30,7 +54,7 @@ export interface Rfc64KnowledgeAssetObservation { } export interface CanonicalSemanticNQuads { - /** RDFC-1.0 canonical, deduplicated, lexically sorted N-Quads lines. */ + /** RDFC-1.0 canonical, placement-neutral, lexically sorted S/P/O lines. */ readonly lines: readonly string[]; /** Canonical lines joined with LF and one trailing LF when non-empty. */ readonly text: string; @@ -198,16 +222,68 @@ function nquadsInputText(input: string | readonly string[]): string { return input.join('\n'); } +const DEFAULT_GRAPH_TERM = Object.freeze({ + termType: 'DefaultGraph' as const, + value: '', +}); + +/** + * Parse the received rows and project away their physical graph placement. + * RFC-64 semantic equality is defined over S/P/O. A repeated projected triple + * is rejected at this boundary; silently turning a duplicate-bearing response + * into a set would hide responder/store faults and corrupt the recorded count. + */ +function receivedSemanticProjection( + input: string | readonly string[], +): string { + // rdf-canonize parses into an RDF dataset and therefore legitimately folds + // exact duplicate lines. Parse each received row independently so evidence + // can reject duplicates before dataset set-semantics erase that signal. + const received = nquadsInputText(input) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .flatMap((line, index) => { + const parsed = rdfCanonize.NQuads.parse(`${line}\n`); + if (parsed.length !== 1) { + throw new Rfc64EvidenceValidationError( + `semantic N-Quads row ${index} did not decode to exactly one quad`, + ); + } + return parsed; + }); + const projected = received.map((quad) => ({ + ...quad, + graph: DEFAULT_GRAPH_TERM, + })); + const seen = new Set(); + for (const quad of projected) { + const line = rdfCanonize.NQuads.serialize([quad]).trimEnd(); + if (seen.has(line)) { + throw new Rfc64EvidenceValidationError( + `Duplicate received semantic S/P/O projection: ${line}`, + ); + } + seen.add(line); + } + return rdfCanonize.NQuads.serialize(projected); +} + /** - * Canonicalize a semantic RDF dataset with the protocol's RDFC-1.0 helper, - * then explicitly deduplicate and sort its lines for byte-stable evidence. + * Project physical N-Quads to semantic S/P/O, reject duplicate projected rows, + * and canonicalize blank nodes with the protocol's RDFC-1.0 helper. */ export async function canonicalizeSemanticNQuads( input: string | readonly string[], ): Promise { let canonical: string; try { - canonical = await canonicalize(nquadsInputText(input)); + canonical = await rdfCanonize.canonize(receivedSemanticProjection(input), { + algorithm: 'RDFC-1.0', + inputFormat: 'application/n-quads', + format: 'application/n-quads', + maxWorkFactor: 1, + }); } catch (error) { throw new Rfc64EvidenceValidationError( `Invalid semantic N-Quads: ${ @@ -216,19 +292,23 @@ export async function canonicalizeSemanticNQuads( ); } - const lines = [...new Set( - canonical - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter((line) => line.length > 0), - )].sort(compareText); + const lines = canonical + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .sort(compareText); + if (new Set(lines).size !== lines.length) { + throw new Rfc64EvidenceValidationError( + 'RDFC-1.0 emitted duplicate semantic projection rows', + ); + } const text = lines.length === 0 ? '' : `${lines.join('\n')}\n`; - return { - lines, + return Object.freeze({ + lines: Object.freeze(lines), text, quadCount: lines.length, sha256: sha256Text(text), - }; + }); } function ualsDigest(assets: readonly Rfc64KnowledgeAssetEvidenceV1[]): Sha256Digest { @@ -251,6 +331,35 @@ function semanticManifestDigest( ); } +function closeSemanticSnapshot( + snapshot: Rfc64SemanticSnapshotV1, +): Rfc64SemanticSnapshotV1 { + const knowledgeAssets = Object.freeze(snapshot.knowledgeAssets.map((asset) => + Object.freeze({ + ual: asset.ual, + quadCount: asset.quadCount, + semanticNQuadsSha256: asset.semanticNQuadsSha256, + }))); + return Object.freeze({ + schemaVersion: RFC64_SEMANTIC_SNAPSHOT_SCHEMA, + kaCount: snapshot.kaCount, + quadCount: snapshot.quadCount, + ualsSha256: snapshot.ualsSha256, + semanticNQuadsSha256: snapshot.semanticNQuadsSha256, + knowledgeAssets, + }); +} + +function closeComparison( + comparison: Rfc64SnapshotComparisonV1, +): Rfc64SnapshotComparisonV1 { + return Object.freeze({ + passed: comparison.passed, + mismatches: Object.freeze(comparison.mismatches.map((mismatch) => + Object.freeze({ ...mismatch }))), + }); +} + /** Build a compact, order-independent semantic snapshot from raw observations. */ export async function createRfc64SemanticSnapshot( observations: readonly Rfc64KnowledgeAssetObservation[], @@ -395,7 +504,7 @@ export function validateRfc64SemanticSnapshot( `snapshot.semanticNQuadsSha256 ${snapshot.semanticNQuadsSha256} does not equal computed ${actualSemanticDigest}`, ); } - return snapshot; + return closeSemanticSnapshot(snapshot); } /** Compare two validated snapshots and return a stable, granular diff. */ @@ -403,14 +512,14 @@ export function compareRfc64SemanticSnapshots( expected: Rfc64SemanticSnapshotV1, observed: Rfc64SemanticSnapshotV1, ): Rfc64SnapshotComparisonV1 { - validateRfc64SemanticSnapshot(expected); - validateRfc64SemanticSnapshot(observed); + const closedExpected = validateRfc64SemanticSnapshot(expected); + const closedObserved = validateRfc64SemanticSnapshot(observed); const expectedByUal = new Map( - expected.knowledgeAssets.map((asset) => [asset.ual, asset] as const), + closedExpected.knowledgeAssets.map((asset) => [asset.ual, asset] as const), ); const observedByUal = new Map( - observed.knowledgeAssets.map((asset) => [asset.ual, asset] as const), + closedObserved.knowledgeAssets.map((asset) => [asset.ual, asset] as const), ); const allUals = [...new Set([ ...expectedByUal.keys(), @@ -450,7 +559,7 @@ export function compareRfc64SemanticSnapshots( } } - return { passed: mismatches.length === 0, mismatches }; + return closeComparison({ passed: mismatches.length === 0, mismatches }); } /** Compare and throw on any missing, unexpected, or content-mismatched KA. */ @@ -476,17 +585,22 @@ function canonicalFailure(value: Rfc64FailureV1, label: string): Rfc64FailureV1 if (typeof value.retryable !== 'boolean') { throw new Rfc64EvidenceValidationError(`${label}.retryable must be boolean`); } - return { + return Object.freeze({ code: requiredLabel(value.code, `${label}.code`), message: requiredLabel(value.message, `${label}.message`), retryable: value.retryable, - }; + }); } function canonicalInstant(value: Date | string, label: string): { readonly epochMs: number; readonly iso: string; } { + if (typeof value === 'string' && !TIMEZONE_QUALIFIED_ISO_RE.test(value)) { + throw new Rfc64EvidenceValidationError( + `${label} must be an ISO timestamp with Z or an explicit UTC offset`, + ); + } const date = value instanceof Date ? value : new Date(value); const epochMs = date.getTime(); if (!Number.isFinite(epochMs)) { @@ -522,8 +636,10 @@ export function createRfc64DevnetEvidence( throw new Rfc64EvidenceValidationError('attemptCount must be at least 1'); } - validateRfc64SemanticSnapshot(input.expected); - if (input.observed !== null) validateRfc64SemanticSnapshot(input.observed); + const expected = validateRfc64SemanticSnapshot(input.expected); + const observed = input.observed === null + ? null + : validateRfc64SemanticSnapshot(input.observed); const terminalFailure = input.terminalFailure == null ? null : canonicalFailure(input.terminalFailure, 'terminalFailure'); @@ -544,7 +660,7 @@ export function createRfc64DevnetEvidence( `retryFailures[${index}].attempt must be between 1 and attemptCount - 1`, ); } - return { attempt, ...canonical } satisfies Rfc64RetryFailureV1; + return Object.freeze({ attempt, ...canonical }) satisfies Rfc64RetryFailureV1; }).sort((left, right) => left.attempt - right.attempt); for (let index = 1; index < failures.length; index += 1) { if (failures[index - 1]!.attempt === failures[index]!.attempt) { @@ -559,38 +675,38 @@ export function createRfc64DevnetEvidence( ); } - const comparison: Rfc64SnapshotComparisonV1 = input.observed === null - ? { + const comparison: Rfc64SnapshotComparisonV1 = observed === null + ? closeComparison({ passed: false, mismatches: [{ code: 'OBSERVED_SNAPSHOT_MISSING', - expected: input.expected.semanticNQuadsSha256, + expected: expected.semanticNQuadsSha256, observed: null, }], - } - : compareRfc64SemanticSnapshots(input.expected, input.observed); + }) + : compareRfc64SemanticSnapshots(expected, observed); - return { + return Object.freeze({ schemaVersion: RFC64_DEVNET_EVIDENCE_SCHEMA, gate, observer, sourcePeerId, - timing: { + timing: Object.freeze({ startedAt: startedAt.iso, completedAt: completedAt.iso, durationMs: completedAt.epochMs - startedAt.epochMs, - }, - attempts: { + }), + attempts: Object.freeze({ total: attemptCount, retries: attemptCount - 1, - failures, - }, - expected: input.expected, - observed: input.observed, + failures: Object.freeze(failures), + }), + expected, + observed, comparison, terminalFailure, passed: comparison.passed && terminalFailure === null, - }; + }); } function stableJsonValue(value: unknown, path: string, ancestors: Set): unknown { @@ -604,12 +720,44 @@ function stableJsonValue(value: unknown, path: string, ancestors: Set): return Object.is(value, -0) ? 0 : value; } if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw new Rfc64EvidenceValidationError( + `${path} must not use a custom array prototype`, + ); + } if (ancestors.has(value)) { throw new Rfc64EvidenceValidationError(`${path} contains a cycle`); } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key === 'symbol')) { + throw new Rfc64EvidenceValidationError(`${path} must not contain symbol keys`); + } + const allowedKeys = new Set(['length']); + for (let index = 0; index < value.length; index += 1) { + allowedKeys.add(String(index)); + if (!Object.hasOwn(value, index)) { + throw new Rfc64EvidenceValidationError(`${path} must not be a sparse array`); + } + } + for (const key of ownKeys as string[]) { + if (!allowedKeys.has(key)) { + throw new Rfc64EvidenceValidationError( + `${path} must not contain custom array property ${JSON.stringify(key)}`, + ); + } + if (key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + if (!('value' in descriptor) || !descriptor.enumerable) { + throw new Rfc64EvidenceValidationError( + `${path}[${key}] must be an enumerable data property`, + ); + } + } ancestors.add(value); - const result = value.map((entry, index) => - stableJsonValue(entry, `${path}[${index}]`, ancestors)); + const result = Array.from({ length: value.length }, (_, index) => { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index))!; + return stableJsonValue(descriptor.value, `${path}[${index}]`, ancestors); + }); ancestors.delete(value); return result; } @@ -625,9 +773,24 @@ function stableJsonValue(value: unknown, path: string, ancestors: Set): } ancestors.add(value); const source = value as Record; - const result: Record = {}; - for (const key of Object.keys(source).sort(compareText)) { - const entry = source[key]; + const ownKeys = Reflect.ownKeys(source); + if (ownKeys.some((key) => typeof key === 'symbol')) { + throw new Rfc64EvidenceValidationError(`${path} must not contain symbol keys`); + } + const result: Record = Object.create(null) as Record; + for (const key of (ownKeys as string[]).sort(compareText)) { + const descriptor = Object.getOwnPropertyDescriptor(source, key)!; + if (!('value' in descriptor)) { + throw new Rfc64EvidenceValidationError( + `${path}.${key} must not be an accessor property`, + ); + } + if (!descriptor.enumerable) { + throw new Rfc64EvidenceValidationError( + `${path}.${key} must not be a hidden non-enumerable property`, + ); + } + const entry = descriptor.value; if (entry === undefined || typeof entry === 'bigint' || typeof entry === 'function') { throw new Rfc64EvidenceValidationError( `${path}.${key} is not a stable JSON value`, @@ -646,15 +809,235 @@ export function stableJsonStringify(value: unknown): string { return `${JSON.stringify(stableJsonValue(value, '$', new Set()), null, 2)}\n`; } -/** Write a byte-stable JSON artifact and return its exact byte count/digest. */ +interface DirectoryTopologyEntry { + readonly path: string; + readonly dev: number; + readonly ino: number; +} + +function lstatOptional(path: string): Stats | null { + try { + return lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +function assertDirectory(path: string, stat: Stats): void { + if (stat.isSymbolicLink()) { + throw new Rfc64EvidenceValidationError( + `artifact directory topology contains a symbolic link: ${path}`, + ); + } + if (!stat.isDirectory()) { + throw new Rfc64EvidenceValidationError( + `artifact directory topology contains a non-directory: ${path}`, + ); + } +} + +function ensureArtifactDirectoryTopology( + directory: string, +): readonly DirectoryTopologyEntry[] { + const root = parsePath(directory).root; + const relativeDirectory = relative(root, directory); + const components = relativeDirectory.length === 0 + ? [] + : relativeDirectory.split(sep); + const entries: DirectoryTopologyEntry[] = []; + let current = root; + + const rootStat = lstatSync(root); + assertDirectory(root, rootStat); + entries.push({ path: root, dev: rootStat.dev, ino: rootStat.ino }); + + for (const component of components) { + current = join(current, component); + let stat = lstatOptional(current); + if (stat === null) { + try { + mkdirSync(current, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + stat = lstatSync(current); + } + assertDirectory(current, stat); + entries.push({ path: current, dev: stat.dev, ino: stat.ino }); + } + return Object.freeze(entries.map((entry) => Object.freeze(entry))); +} + +function assertArtifactDirectoryTopology( + entries: readonly DirectoryTopologyEntry[], +): void { + for (const expected of entries) { + const actual = lstatOptional(expected.path); + if (actual === null) { + throw new Rfc64EvidenceValidationError( + `artifact directory disappeared during publication: ${expected.path}`, + ); + } + assertDirectory(expected.path, actual); + if (actual.dev !== expected.dev || actual.ino !== expected.ino) { + throw new Rfc64EvidenceValidationError( + `artifact directory topology changed during publication: ${expected.path}`, + ); + } + } +} + +function assertArtifactTargetReplaceable(target: string): void { + const stat = lstatOptional(target); + if (stat === null) return; + if (stat.isSymbolicLink()) { + throw new Rfc64EvidenceValidationError( + `artifact target must not be a symbolic link: ${target}`, + ); + } + if (!stat.isFile()) { + throw new Rfc64EvidenceValidationError( + `artifact target must be a regular file: ${target}`, + ); + } +} + +function verifyPublishedArtifact(target: string, expectedJson: string): void { + const noFollow = fsConstants.O_NOFOLLOW ?? 0; + const fd = openSync(target, fsConstants.O_RDONLY | noFollow); + try { + const stat = fstatSync(fd); + if (!stat.isFile()) { + throw new Rfc64EvidenceValidationError( + `published artifact is not a regular file: ${target}`, + ); + } + if ((stat.mode & 0o777) !== 0o600) { + throw new Rfc64EvidenceValidationError( + `published artifact mode must be 0600, got 0${(stat.mode & 0o777).toString(8)}`, + ); + } + if (readFileSync(fd, 'utf8') !== expectedJson) { + throw new Rfc64EvidenceValidationError( + `published artifact bytes changed during publication: ${target}`, + ); + } + } finally { + closeSync(fd); + } +} + +function fsyncArtifactDirectory( + directory: string, + topology: readonly DirectoryTopologyEntry[], +): void { + const expected = topology[topology.length - 1]!; + const noFollow = fsConstants.O_NOFOLLOW ?? 0; + const directoryOnly = fsConstants.O_DIRECTORY ?? 0; + const fd = openSync( + directory, + fsConstants.O_RDONLY | noFollow | directoryOnly, + ); + try { + const stat = fstatSync(fd); + if (!stat.isDirectory() || stat.dev !== expected.dev || stat.ino !== expected.ino) { + throw new Rfc64EvidenceValidationError( + `artifact directory handle does not match checked topology: ${directory}`, + ); + } + fsyncSync(fd); + } finally { + closeSync(fd); + } +} + +function cleanupTemporaryArtifact( + temporaryPath: string, + topology: readonly DirectoryTopologyEntry[], +): void { + try { + assertArtifactDirectoryTopology(topology); + } catch { + // Do not traverse a directory topology which changed under us. + return; + } + try { + unlinkSync(temporaryPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} + +/** + * Atomically publish byte-stable JSON through a same-directory 0600 temporary + * file, fsync the file and containing directory, and reject symlinked or + * changing path topology throughout the operation. + */ export function writeStableJsonArtifact( path: string, value: unknown, ): WrittenStableJsonArtifact { - const target = requiredLabel(path, 'path'); + const requestedTarget = requiredLabel(path, 'path'); + const target = resolve(requestedTarget); + const targetName = basename(target); + if (targetName.length === 0) { + throw new Rfc64EvidenceValidationError('path must identify an artifact file'); + } const json = stableJsonStringify(value); - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, json, { encoding: 'utf8', mode: 0o600 }); + const directory = dirname(target); + const topology = ensureArtifactDirectoryTopology(directory); + assertArtifactDirectoryTopology(topology); + assertArtifactTargetReplaceable(target); + + const temporaryPath = join( + directory, + `.${targetName}.${process.pid}.${randomUUID()}.tmp`, + ); + const noFollow = fsConstants.O_NOFOLLOW ?? 0; + let temporaryFd: number | null = null; + let renamed = false; + try { + temporaryFd = openSync( + temporaryPath, + fsConstants.O_WRONLY + | fsConstants.O_CREAT + | fsConstants.O_EXCL + | noFollow, + 0o600, + ); + const opened = fstatSync(temporaryFd); + if (!opened.isFile()) { + throw new Rfc64EvidenceValidationError( + `temporary artifact is not a regular file: ${temporaryPath}`, + ); + } + fchmodSync(temporaryFd, 0o600); + writeFileSync(temporaryFd, json, { encoding: 'utf8' }); + fsyncSync(temporaryFd); + closeSync(temporaryFd); + temporaryFd = null; + + assertArtifactDirectoryTopology(topology); + assertArtifactTargetReplaceable(target); + renameSync(temporaryPath, target); + renamed = true; + + assertArtifactDirectoryTopology(topology); + verifyPublishedArtifact(target, json); + fsyncArtifactDirectory(directory, topology); + assertArtifactDirectoryTopology(topology); + } catch (error) { + if (temporaryFd !== null) { + try { + closeSync(temporaryFd); + } catch { + // Preserve the primary publication error. + } + } + if (!renamed) cleanupTemporaryArtifact(temporaryPath, topology); + throw error; + } return { byteLength: Buffer.byteLength(json, 'utf8'), sha256: sha256Text(json), diff --git a/packages/core/src/rdf-canonize.d.ts b/packages/core/src/rdf-canonize.d.ts index 17ab54517a..252adcb9a7 100644 --- a/packages/core/src/rdf-canonize.d.ts +++ b/packages/core/src/rdf-canonize.d.ts @@ -1,4 +1,18 @@ declare module 'rdf-canonize' { + interface RdfTerm { + termType: 'NamedNode' | 'BlankNode' | 'Literal' | 'DefaultGraph'; + value: string; + datatype?: RdfTerm; + language?: string; + } + + interface RdfQuad { + subject: RdfTerm; + predicate: RdfTerm; + object: RdfTerm; + graph: RdfTerm; + } + interface CanonizeOptions { algorithm: 'RDFC-1.0' | 'URDNA2015'; inputFormat?: 'application/n-quads'; @@ -13,8 +27,8 @@ declare module 'rdf-canonize' { interface RdfCanonize { canonize(input: string, options: CanonizeOptions): Promise; NQuads: { - parse(nquads: string): object[]; - serialize(dataset: object[]): string; + parse(nquads: string): RdfQuad[]; + serialize(dataset: readonly RdfQuad[]): string; }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffd7b68809..4315ccfb8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: ethers: specifier: ^6.16.0 version: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + rdf-canonize: + specifier: ^5.0.0 + version: 5.0.0 devDependencies: vitest: specifier: ^4.0.18 From f802ee2d0b8b643be10f59af2c9214554089a0e8 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:03:20 +0200 Subject: [PATCH 091/292] fix(devnet): close RFC-64 evidence review gaps --- devnet/_bootstrap/RFC64_EVIDENCE.md | 19 ++- devnet/_bootstrap/package.json | 3 +- devnet/_bootstrap/rdf-canonize.d.ts | 33 +++++ devnet/_bootstrap/rfc64-evidence.test.ts | 145 ++++++++++++++++++++-- devnet/_bootstrap/rfc64-evidence.ts | 150 ++++++++++++++++++----- devnet/_bootstrap/tsconfig.evidence.json | 16 +++ package.json | 1 + 7 files changed, 323 insertions(+), 44 deletions(-) create mode 100644 devnet/_bootstrap/rdf-canonize.d.ts create mode 100644 devnet/_bootstrap/tsconfig.evidence.json diff --git a/devnet/_bootstrap/RFC64_EVIDENCE.md b/devnet/_bootstrap/RFC64_EVIDENCE.md index 2a707f82be..b1bb1d90db 100644 --- a/devnet/_bootstrap/RFC64_EVIDENCE.md +++ b/devnet/_bootstrap/RFC64_EVIDENCE.md @@ -34,16 +34,24 @@ produce a passing comparison. Created and validated snapshots are defensively copied and deeply frozen. Run evidence closes over its own frozen expected/observed copies, so later caller mutation cannot change a previously derived `passed` result. String timestamps -must carry `Z` or an explicit UTC offset and are emitted in canonical UTC form. +must carry `Z` or an explicit UTC offset, contain a real Gregorian calendar +instant, and are emitted in canonical UTC form. Snapshot validation rejects +accessors, proxies, sparse/custom arrays, and custom containers before capture. The run artifact adds the stable gate/observer label, selected source peer, canonical ISO timing and duration, attempt/retry/failure details, expected and observed snapshots, and the derived comparison. `passed` is derived from the comparison and terminal failure; a caller cannot manually force it to `true`. -Artifact publication uses a same-directory exclusive 0600 temporary file, -fsyncs its contents, atomically renames it, verifies the published bytes/mode, -and fsyncs the containing directory. Existing symlink targets and symlinked or -changing directory topology are rejected. +Artifact publication uses a same-directory exclusive temporary file, fsyncs +its contents, atomically renames it, and verifies the published bytes. POSIX +publication enforces mode 0600 and fsyncs the containing directory. Every +directory created for a nested artifact path is made durable through a +parent-directory fsync before it is used. Existing symlink targets and +symlinked or changing directory topology are rejected. On Windows, Node cannot +fsync a directory through its ordinary filesystem API and POSIX mode bits do +not prove ACL isolation, so the returned publication metadata explicitly +reports `file-flush-rename-no-directory-flush` and `windows-inherited-acl` +instead of claiming the stronger POSIX policies. ## Harness use @@ -77,4 +85,5 @@ Run the focused no-devnet verification with: ```sh pnpm test:devnet:rfc64-evidence +pnpm typecheck:devnet:rfc64-evidence ``` diff --git a/devnet/_bootstrap/package.json b/devnet/_bootstrap/package.json index b9d90a73d3..3cd1a5a6d7 100644 --- a/devnet/_bootstrap/package.json +++ b/devnet/_bootstrap/package.json @@ -9,7 +9,8 @@ }, "scripts": { "test:smoke": "vitest run --config vitest.config.ts", - "test:rfc64-evidence": "vitest run --config vitest.evidence.config.ts" + "test:rfc64-evidence": "vitest run --config vitest.evidence.config.ts", + "typecheck:rfc64-evidence": "tsc --project tsconfig.evidence.json" }, "dependencies": { "ethers": "^6.16.0", diff --git a/devnet/_bootstrap/rdf-canonize.d.ts b/devnet/_bootstrap/rdf-canonize.d.ts new file mode 100644 index 0000000000..c70e790367 --- /dev/null +++ b/devnet/_bootstrap/rdf-canonize.d.ts @@ -0,0 +1,33 @@ +declare module 'rdf-canonize' { + interface RdfTerm { + termType: 'NamedNode' | 'BlankNode' | 'Literal' | 'DefaultGraph'; + value: string; + datatype?: RdfTerm; + language?: string; + } + + interface RdfQuad { + subject: RdfTerm; + predicate: RdfTerm; + object: RdfTerm; + graph: RdfTerm; + } + + interface CanonizeOptions { + algorithm: 'RDFC-1.0' | 'URDNA2015'; + inputFormat?: 'application/n-quads'; + format?: 'application/n-quads'; + maxWorkFactor?: number; + } + + interface RdfCanonize { + canonize(input: string, options: CanonizeOptions): Promise; + NQuads: { + parse(nquads: string): RdfQuad[]; + serialize(dataset: readonly RdfQuad[]): string; + }; + } + + const rdfCanonize: RdfCanonize; + export default rdfCanonize; +} diff --git a/devnet/_bootstrap/rfc64-evidence.test.ts b/devnet/_bootstrap/rfc64-evidence.test.ts index 3071afcbd6..81647de1e1 100644 --- a/devnet/_bootstrap/rfc64-evidence.test.ts +++ b/devnet/_bootstrap/rfc64-evidence.test.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import fs from 'node:fs'; import { chmodSync, existsSync, @@ -12,10 +13,15 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { + RFC64_ARTIFACT_POSIX_ACCESS_POLICY, + RFC64_ARTIFACT_POSIX_NAMESPACE_DURABILITY, + RFC64_ARTIFACT_WINDOWS_ACCESS_POLICY, + RFC64_ARTIFACT_WINDOWS_NAMESPACE_DURABILITY, Rfc64EvidenceMismatchError, Rfc64EvidenceValidationError, assertRfc64SemanticSnapshotsEqual, @@ -202,6 +208,44 @@ describe('RFC-64 semantic snapshot evidence', () => { (snapshot as unknown as { kaCount: number }).kaCount = 99; }).toThrow(TypeError); }); + + it('rejects snapshot accessors, proxies, and custom arrays before comparison', async () => { + const snapshot = await createRfc64SemanticSnapshot([]); + let accessorReads = 0; + const accessorSnapshot = { ...snapshot }; + Object.defineProperty(accessorSnapshot, 'knowledgeAssets', { + enumerable: true, + get: () => { + accessorReads += 1; + return accessorReads % 6 === 0 + ? [{ + ual: 'not-a-ual', + quadCount: 99, + semanticNQuadsSha256: `sha256:${'0'.repeat(64)}`, + }] + : []; + }, + }); + + expect(() => compareRfc64SemanticSnapshots( + accessorSnapshot as Rfc64SemanticSnapshotV1, + accessorSnapshot as Rfc64SemanticSnapshotV1, + )).toThrow(/must not be an accessor property/); + expect(accessorReads).toBe(0); + + expect(() => validateRfc64SemanticSnapshot( + new Proxy({ ...snapshot }, {}) as Rfc64SemanticSnapshotV1, + )).toThrow(/must not be a proxy/); + + const customAssets = [...snapshot.knowledgeAssets] as Rfc64SemanticSnapshotV1[ + 'knowledgeAssets' + ] & { marker?: boolean }; + customAssets.marker = true; + expect(() => validateRfc64SemanticSnapshot({ + ...snapshot, + knowledgeAssets: customAssets, + })).toThrow(/custom array property/); + }); }); describe('RFC-64 devnet run artifact', () => { @@ -375,6 +419,25 @@ describe('RFC-64 devnet run artifact', () => { completedAt: '2026-07-19T10:00:01.000Z', durationMs: 1_000, }); + + for (const impossible of [ + '2026-02-30T12:00:00Z', + '2025-02-29T12:00:00Z', + '2026-13-01T12:00:00Z', + '2026-01-01T24:00:00Z', + '2026-01-01T12:00:00-00:00', + ]) { + expect(() => createRfc64DevnetEvidence({ + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + startedAt: impossible, + completedAt: '2026-03-02T12:00:01Z', + attemptCount: 1, + expected: snapshot, + observed: snapshot, + })).toThrow(/must be a valid, representable RFC 3339 timestamp/); + } }); it('writes byte-identical stable JSON independent of object insertion order', () => { @@ -396,8 +459,19 @@ describe('RFC-64 devnet run artifact', () => { ); expect(firstBytes.toString('utf8')).toBe(stableJsonStringify(firstValue)); expect(firstBytes.at(-1)).toBe(0x0a); - expect(statSync(firstPath).mode & 0o777).toBe(0o600); expect(readdirSync(join(directory, 'nested'))).toEqual(['first.json']); + if (process.platform === 'win32') { + expect(first).toMatchObject({ + namespaceDurability: RFC64_ARTIFACT_WINDOWS_NAMESPACE_DURABILITY, + accessPolicy: RFC64_ARTIFACT_WINDOWS_ACCESS_POLICY, + }); + } else { + expect(statSync(firstPath).mode & 0o777).toBe(0o600); + expect(first).toMatchObject({ + namespaceDurability: RFC64_ARTIFACT_POSIX_NAMESPACE_DURABILITY, + accessPolicy: RFC64_ARTIFACT_POSIX_ACCESS_POLICY, + }); + } }); it('rejects lossy JSON values', () => { @@ -449,7 +523,7 @@ describe('RFC-64 devnet run artifact', () => { expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); - it('atomically replaces regular targets as 0600 without temp remnants', () => { + it('atomically replaces regular targets under the platform access policy', () => { const directory = createTemporaryDirectory(); const target = join(directory, 'artifact.json'); writeFileSync(target, 'old', { mode: 0o644 }); @@ -458,25 +532,76 @@ describe('RFC-64 devnet run artifact', () => { writeStableJsonArtifact(target, { generation: 2 }); expect(readFileSync(target, 'utf8')).toBe('{\n "generation": 2\n}\n'); - expect(statSync(target).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') { + expect(statSync(target).mode & 0o777).toBe(0o600); + } expect(readdirSync(directory)).toEqual(['artifact.json']); }); + it.runIf(process.platform !== 'win32')( + 'persists every newly created directory entry through its parent', + () => { + const directory = createTemporaryDirectory(); + const target = join(directory, 'first', 'second', 'artifact.json'); + const originalFsync = fs.fsyncSync; + let fileFsyncs = 0; + let directoryFsyncs = 0; + fs.fsyncSync = (descriptor) => { + if (fs.fstatSync(descriptor).isDirectory()) directoryFsyncs += 1; + else fileFsyncs += 1; + originalFsync(descriptor); + }; + syncBuiltinESMExports(); + try { + writeStableJsonArtifact(target, { durable: true }); + } finally { + fs.fsyncSync = originalFsync; + syncBuiltinESMExports(); + } + + expect(fileFsyncs).toBe(1); + expect(directoryFsyncs).toBe(3); + }, + ); + + it('declares the weaker Windows namespace and inherited-ACL policy', () => { + const directory = createTemporaryDirectory(); + const target = join(directory, 'windows-policy.json'); + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + try { + const written = writeStableJsonArtifact(target, { platform: 'windows' }); + expect(written).toMatchObject({ + namespaceDurability: RFC64_ARTIFACT_WINDOWS_NAMESPACE_DURABILITY, + accessPolicy: RFC64_ARTIFACT_WINDOWS_ACCESS_POLICY, + }); + expect(readFileSync(target, 'utf8')) + .toBe(stableJsonStringify({ platform: 'windows' })); + } finally { + platform.mockRestore(); + } + }); + it('rejects symlinked targets and parent topology without following them', () => { const directory = createTemporaryDirectory(); const realTarget = join(directory, 'real.json'); const linkedTarget = join(directory, 'linked.json'); - writeFileSync(realTarget, 'sentinel', { mode: 0o600 }); - symlinkSync(realTarget, linkedTarget); + if (process.platform !== 'win32') { + writeFileSync(realTarget, 'sentinel', { mode: 0o600 }); + symlinkSync(realTarget, linkedTarget); - expect(() => writeStableJsonArtifact(linkedTarget, { unsafe: true })) - .toThrow(/target must not be a symbolic link/); - expect(readFileSync(realTarget, 'utf8')).toBe('sentinel'); + expect(() => writeStableJsonArtifact(linkedTarget, { unsafe: true })) + .toThrow(/target must not be a symbolic link/); + expect(readFileSync(realTarget, 'utf8')).toBe('sentinel'); + } const realDirectory = join(directory, 'real-directory'); const linkedDirectory = join(directory, 'linked-directory'); mkdirSync(realDirectory, { mode: 0o700 }); - symlinkSync(realDirectory, linkedDirectory, 'dir'); + symlinkSync( + realDirectory, + linkedDirectory, + process.platform === 'win32' ? 'junction' : 'dir', + ); expect(() => writeStableJsonArtifact( join(linkedDirectory, 'escaped.json'), diff --git a/devnet/_bootstrap/rfc64-evidence.ts b/devnet/_bootstrap/rfc64-evidence.ts index 73171585c7..74f33bdf9b 100644 --- a/devnet/_bootstrap/rfc64-evidence.ts +++ b/devnet/_bootstrap/rfc64-evidence.ts @@ -31,6 +31,7 @@ import { resolve, sep, } from 'node:path'; +import { types as utilTypes } from 'node:util'; import rdfCanonize from 'rdf-canonize'; import { parseDeterministicKnowledgeAssetUal } from '../../packages/core/src/ka-content-scope.js'; @@ -42,8 +43,17 @@ export const RFC64_DEVNET_EVIDENCE_SCHEMA = const SEMANTIC_MANIFEST_DOMAIN = 'rfc64-semantic-nquads-manifest/v1\n'; const SHA256_RE = /^sha256:[0-9a-f]{64}$/; -const TIMEZONE_QUALIFIED_ISO_RE = - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/u; +const RFC3339_INSTANT_RE = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/u; + +export const RFC64_ARTIFACT_POSIX_NAMESPACE_DURABILITY = + 'file-fsync-rename-directory-fsync' as const; +export const RFC64_ARTIFACT_WINDOWS_NAMESPACE_DURABILITY = + 'file-flush-rename-no-directory-flush' as const; +export const RFC64_ARTIFACT_POSIX_ACCESS_POLICY = + 'posix-owner-read-write-mode-0600' as const; +export const RFC64_ARTIFACT_WINDOWS_ACCESS_POLICY = + 'windows-inherited-acl' as const; export type Sha256Digest = `sha256:${string}`; @@ -159,6 +169,14 @@ export interface Rfc64DevnetEvidenceV1 { export interface WrittenStableJsonArtifact { readonly byteLength: number; readonly sha256: Sha256Digest; + /** Windows Node.js cannot flush directory handles through fsync. */ + readonly namespaceDurability: + | typeof RFC64_ARTIFACT_POSIX_NAMESPACE_DURABILITY + | typeof RFC64_ARTIFACT_WINDOWS_NAMESPACE_DURABILITY; + /** POSIX mode bits are not presented as an ACL guarantee on Windows. */ + readonly accessPolicy: + | typeof RFC64_ARTIFACT_POSIX_ACCESS_POLICY + | typeof RFC64_ARTIFACT_WINDOWS_ACCESS_POLICY; } export class Rfc64EvidenceValidationError extends Error { @@ -431,22 +449,30 @@ export function validateRfc64SemanticSnapshot( if (!snapshot || typeof snapshot !== 'object') { throw new Rfc64EvidenceValidationError('snapshot must be an object'); } - if (snapshot.schemaVersion !== RFC64_SEMANTIC_SNAPSHOT_SCHEMA) { + // Capture each own data property exactly once before validation. This keeps + // accessors, proxies, sparse/custom arrays, and other exotic containers from + // changing the value between a successful check and the frozen result. + const captured = stableJsonValue( + snapshot, + 'snapshot', + new Set(), + ) as unknown as Rfc64SemanticSnapshotV1; + if (captured.schemaVersion !== RFC64_SEMANTIC_SNAPSHOT_SCHEMA) { throw new Rfc64EvidenceValidationError( - `Unsupported semantic snapshot schema: ${String(snapshot.schemaVersion)}`, + `Unsupported semantic snapshot schema: ${String(captured.schemaVersion)}`, ); } - const kaCount = assertNonNegativeSafeInteger(snapshot.kaCount, 'snapshot.kaCount'); + const kaCount = assertNonNegativeSafeInteger(captured.kaCount, 'snapshot.kaCount'); const quadCount = assertNonNegativeSafeInteger( - snapshot.quadCount, + captured.quadCount, 'snapshot.quadCount', ); - assertDigest(snapshot.ualsSha256, 'snapshot.ualsSha256'); + assertDigest(captured.ualsSha256, 'snapshot.ualsSha256'); assertDigest( - snapshot.semanticNQuadsSha256, + captured.semanticNQuadsSha256, 'snapshot.semanticNQuadsSha256', ); - if (!Array.isArray(snapshot.knowledgeAssets)) { + if (!Array.isArray(captured.knowledgeAssets)) { throw new Rfc64EvidenceValidationError( 'snapshot.knowledgeAssets must be an array', ); @@ -454,7 +480,7 @@ export function validateRfc64SemanticSnapshot( let previousUal: string | null = null; let actualQuadCount = 0; - for (const [index, asset] of snapshot.knowledgeAssets.entries()) { + for (const [index, asset] of captured.knowledgeAssets.entries()) { if (!asset || typeof asset !== 'object') { throw new Rfc64EvidenceValidationError( `snapshot.knowledgeAssets[${index}] must be an object`, @@ -482,9 +508,9 @@ export function validateRfc64SemanticSnapshot( ); } - if (kaCount !== snapshot.knowledgeAssets.length) { + if (kaCount !== captured.knowledgeAssets.length) { throw new Rfc64EvidenceValidationError( - `snapshot.kaCount ${kaCount} does not equal knowledgeAssets.length ${snapshot.knowledgeAssets.length}`, + `snapshot.kaCount ${kaCount} does not equal knowledgeAssets.length ${captured.knowledgeAssets.length}`, ); } if (quadCount !== actualQuadCount) { @@ -492,19 +518,19 @@ export function validateRfc64SemanticSnapshot( `snapshot.quadCount ${quadCount} does not equal per-KA total ${actualQuadCount}`, ); } - const actualUalsDigest = ualsDigest(snapshot.knowledgeAssets); - if (snapshot.ualsSha256 !== actualUalsDigest) { + const actualUalsDigest = ualsDigest(captured.knowledgeAssets); + if (captured.ualsSha256 !== actualUalsDigest) { throw new Rfc64EvidenceValidationError( - `snapshot.ualsSha256 ${snapshot.ualsSha256} does not equal computed ${actualUalsDigest}`, + `snapshot.ualsSha256 ${captured.ualsSha256} does not equal computed ${actualUalsDigest}`, ); } - const actualSemanticDigest = semanticManifestDigest(snapshot.knowledgeAssets); - if (snapshot.semanticNQuadsSha256 !== actualSemanticDigest) { + const actualSemanticDigest = semanticManifestDigest(captured.knowledgeAssets); + if (captured.semanticNQuadsSha256 !== actualSemanticDigest) { throw new Rfc64EvidenceValidationError( - `snapshot.semanticNQuadsSha256 ${snapshot.semanticNQuadsSha256} does not equal computed ${actualSemanticDigest}`, + `snapshot.semanticNQuadsSha256 ${captured.semanticNQuadsSha256} does not equal computed ${actualSemanticDigest}`, ); } - return closeSemanticSnapshot(snapshot); + return closeSemanticSnapshot(captured); } /** Compare two validated snapshots and return a stable, granular diff. */ @@ -592,14 +618,51 @@ function canonicalFailure(value: Rfc64FailureV1, label: string): Rfc64FailureV1 }); } +function isGregorianLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function gregorianMonthLength(year: number, month: number): number { + if (month === 2) return isGregorianLeapYear(year) ? 29 : 28; + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + function canonicalInstant(value: Date | string, label: string): { readonly epochMs: number; readonly iso: string; } { - if (typeof value === 'string' && !TIMEZONE_QUALIFIED_ISO_RE.test(value)) { - throw new Rfc64EvidenceValidationError( - `${label} must be an ISO timestamp with Z or an explicit UTC offset`, - ); + if (typeof value === 'string') { + const match = RFC3339_INSTANT_RE.exec(value); + if (match === null) { + throw new Rfc64EvidenceValidationError( + `${label} must be an ISO timestamp with Z or an explicit UTC offset`, + ); + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[10] === undefined ? 0 : Number(match[10]); + const offsetMinute = match[11] === undefined ? 0 : Number(match[11]); + const unknownLocalOffset = match[8] === '-00:00'; + if ( + month < 1 + || month > 12 + || day < 1 + || day > gregorianMonthLength(year, month) + || hour > 23 + || minute > 59 + || second > 59 + || offsetHour > 23 + || offsetMinute > 59 + || unknownLocalOffset + ) { + throw new Rfc64EvidenceValidationError( + `${label} must be a valid, representable RFC 3339 timestamp`, + ); + } } const date = value instanceof Date ? value : new Date(value); const epochMs = date.getTime(); @@ -710,6 +773,13 @@ export function createRfc64DevnetEvidence( } function stableJsonValue(value: unknown, path: string, ancestors: Set): unknown { + if ( + value !== null + && (typeof value === 'object' || typeof value === 'function') + && utilTypes.isProxy(value) + ) { + throw new Rfc64EvidenceValidationError(`${path} must not be a proxy`); + } if (value === null || typeof value === 'string' || typeof value === 'boolean') { return value; } @@ -855,15 +925,22 @@ function ensureArtifactDirectoryTopology( for (const component of components) { current = join(current, component); let stat = lstatOptional(current); + let created = false; if (stat === null) { try { mkdirSync(current, { mode: 0o700 }); + created = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } stat = lstatSync(current); } assertDirectory(current, stat); + if (created) { + // Persist the new directory entry before relying on it as the parent of + // another directory or of the artifact itself. + fsyncArtifactDirectory(dirname(current), entries); + } entries.push({ path: current, dev: stat.dev, ino: stat.ino }); } return Object.freeze(entries.map((entry) => Object.freeze(entry))); @@ -913,7 +990,7 @@ function verifyPublishedArtifact(target: string, expectedJson: string): void { `published artifact is not a regular file: ${target}`, ); } - if ((stat.mode & 0o777) !== 0o600) { + if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) { throw new Rfc64EvidenceValidationError( `published artifact mode must be 0600, got 0${(stat.mode & 0o777).toString(8)}`, ); @@ -932,7 +1009,17 @@ function fsyncArtifactDirectory( directory: string, topology: readonly DirectoryTopologyEntry[], ): void { + // Node/libuv opens this directory with read access, but Windows implements + // fsync with FlushFileBuffers, which requires a writable handle. Report the + // weaker namespace policy instead of publishing successfully and then + // throwing a false failure from an unsupported durability operation. + if (process.platform === 'win32') return; const expected = topology[topology.length - 1]!; + if (expected.path !== directory) { + throw new Rfc64EvidenceValidationError( + `artifact directory barrier does not match checked topology: ${directory}`, + ); + } const noFollow = fsConstants.O_NOFOLLOW ?? 0; const directoryOnly = fsConstants.O_DIRECTORY ?? 0; const fd = openSync( @@ -970,9 +1057,10 @@ function cleanupTemporaryArtifact( } /** - * Atomically publish byte-stable JSON through a same-directory 0600 temporary - * file, fsync the file and containing directory, and reject symlinked or - * changing path topology throughout the operation. + * Atomically publish byte-stable JSON through a same-directory temporary file. + * POSIX publication enforces mode 0600 and directory-fsync namespace barriers; + * Windows flushes the file and reports rename-only namespace durability plus + * inherited ACL protection. Both policies reject symlinked/changing topology. */ export function writeStableJsonArtifact( path: string, @@ -1012,7 +1100,7 @@ export function writeStableJsonArtifact( `temporary artifact is not a regular file: ${temporaryPath}`, ); } - fchmodSync(temporaryFd, 0o600); + if (process.platform !== 'win32') fchmodSync(temporaryFd, 0o600); writeFileSync(temporaryFd, json, { encoding: 'utf8' }); fsyncSync(temporaryFd); closeSync(temporaryFd); @@ -1041,5 +1129,11 @@ export function writeStableJsonArtifact( return { byteLength: Buffer.byteLength(json, 'utf8'), sha256: sha256Text(json), + namespaceDurability: process.platform === 'win32' + ? RFC64_ARTIFACT_WINDOWS_NAMESPACE_DURABILITY + : RFC64_ARTIFACT_POSIX_NAMESPACE_DURABILITY, + accessPolicy: process.platform === 'win32' + ? RFC64_ARTIFACT_WINDOWS_ACCESS_POLICY + : RFC64_ARTIFACT_POSIX_ACCESS_POLICY, }; } diff --git a/devnet/_bootstrap/tsconfig.evidence.json b/devnet/_bootstrap/tsconfig.evidence.json new file mode 100644 index 0000000000..b4a4ee9afb --- /dev/null +++ b/devnet/_bootstrap/tsconfig.evidence.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "../..", + "sourceMap": false, + "types": ["node", "vitest"] + }, + "include": [ + "rdf-canonize.d.ts", + "rfc64-evidence.ts", + "rfc64-evidence.test.ts" + ] +} diff --git a/package.json b/package.json index 01d615b300..b120d27a97 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "test:devnet:rpc-quiet-window": "vitest run --config devnet/rpc-quiet-window/vitest.config.ts", "test:devnet:storage-ack-store-outage": "vitest run --config devnet/storage-ack-store-outage/vitest.config.ts", "test:devnet:rfc64-evidence": "vitest run --config devnet/_bootstrap/vitest.evidence.config.ts", + "typecheck:devnet:rfc64-evidence": "tsc --project devnet/_bootstrap/tsconfig.evidence.json", "test:devnet:v10-rs-prune": "vitest run --config devnet/v10-rs-prune/vitest.config.ts", "test:devnet:v10-rs-wallet-rotation": "vitest run --config devnet/v10-rs-wallet-rotation/vitest.config.ts", "test:devnet:v10-rs-wallet-rotation:required": "DKG_REQUIRE_RS_ROTATION=1 DKG_RS_ROT_WINDOW=900000 vitest run --config devnet/v10-rs-wallet-rotation/vitest.config.ts", From 0947ad550a6b3e4e47933fb818c9be6b66608b72 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:12:46 +0200 Subject: [PATCH 092/292] fix(devnet): harden RFC-64 evidence verification --- .github/workflows/rfc64-inventory-windows.yml | 13 +++ devnet/_bootstrap/RFC64_EVIDENCE.md | 3 +- devnet/_bootstrap/rfc64-evidence.test.ts | 100 ++++++++++++++++++ devnet/_bootstrap/rfc64-evidence.ts | 52 ++++++--- 4 files changed, 152 insertions(+), 16 deletions(-) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 70ffd9756c..528f57f745 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -4,6 +4,12 @@ on: pull_request: paths: - '.github/workflows/rfc64-inventory-windows.yml' + - 'devnet/_bootstrap/package.json' + - 'devnet/_bootstrap/rdf-canonize.d.ts' + - 'devnet/_bootstrap/rfc64-evidence*' + - 'devnet/_bootstrap/tsconfig.evidence.json' + - 'devnet/_bootstrap/vitest.evidence.config.ts' + - 'package.json' - 'packages/agent/src/rfc64/inventory-v1/**' - 'packages/agent/src/rfc64/author-catalog-producer.ts' - 'packages/agent/src/rfc64/control-object-store-v1.ts' @@ -32,6 +38,7 @@ on: - 'packages/core/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - 'tsconfig.base.json' workflow_dispatch: concurrency: @@ -64,6 +71,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Typecheck RFC-64 evidence harness + run: pnpm typecheck:devnet:rfc64-evidence + + - name: Test RFC-64 evidence harness + run: pnpm test:devnet:rfc64-evidence + - name: Build agent dependency closure run: pnpm --filter @origintrail-official/dkg-agent... run build diff --git a/devnet/_bootstrap/RFC64_EVIDENCE.md b/devnet/_bootstrap/RFC64_EVIDENCE.md index b1bb1d90db..96169cc616 100644 --- a/devnet/_bootstrap/RFC64_EVIDENCE.md +++ b/devnet/_bootstrap/RFC64_EVIDENCE.md @@ -35,7 +35,8 @@ Created and validated snapshots are defensively copied and deeply frozen. Run evidence closes over its own frozen expected/observed copies, so later caller mutation cannot change a previously derived `passed` result. String timestamps must carry `Z` or an explicit UTC offset, contain a real Gregorian calendar -instant, and are emitted in canonical UTC form. Snapshot validation rejects +instant, use at most millisecond fractional precision, and are emitted in +canonical UTC form. Snapshot and failure-record validation reject accessors, proxies, sparse/custom arrays, and custom containers before capture. The run artifact adds the stable gate/observer label, selected source peer, diff --git a/devnet/_bootstrap/rfc64-evidence.test.ts b/devnet/_bootstrap/rfc64-evidence.test.ts index 81647de1e1..05de74a971 100644 --- a/devnet/_bootstrap/rfc64-evidence.test.ts +++ b/devnet/_bootstrap/rfc64-evidence.test.ts @@ -391,6 +391,50 @@ describe('RFC-64 devnet run artifact', () => { })).toThrow(/one failure for each of the 1 retried attempts/); }); + it('rejects accessor and proxy failure records before reading their fields', async () => { + const snapshot = await createRfc64SemanticSnapshot([]); + let retryableReads = 0; + const accessorFailure = { + attempt: 1, + code: 'TRANSIENT_FAILURE', + message: 'retry me', + get retryable(): boolean { + retryableReads += 1; + return (retryableReads === 1 ? true : 'not-boolean') as unknown as boolean; + }, + }; + + expect(() => createRfc64DevnetEvidence({ + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + startedAt: '2026-07-19T12:00:00Z', + completedAt: '2026-07-19T12:00:01Z', + attemptCount: 2, + retryFailures: [accessorFailure], + expected: snapshot, + observed: snapshot, + })).toThrow(/retryFailures\[0\]\.retryable must not be an accessor property/); + expect(retryableReads).toBe(0); + + const proxyFailure = new Proxy({ + code: 'TERMINAL_FAILURE', + message: 'stop', + retryable: false, + }, {}); + expect(() => createRfc64DevnetEvidence({ + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + startedAt: '2026-07-19T12:00:00Z', + completedAt: '2026-07-19T12:00:01Z', + attemptCount: 1, + terminalFailure: proxyFailure, + expected: snapshot, + observed: snapshot, + })).toThrow(/terminalFailure must not be a proxy/); + }); + it('rejects timezone-ambiguous strings and canonicalizes explicit offsets', async () => { const snapshot = await createRfc64SemanticSnapshot([]); expect(() => createRfc64DevnetEvidence({ @@ -420,6 +464,23 @@ describe('RFC-64 devnet run artifact', () => { durationMs: 1_000, }); + for (const excessivePrecision of [ + '2026-01-01T12:00:00.0000Z', + '2026-01-01T12:00:00.1234Z', + '2026-01-01T12:00:00.123456789+02:00', + ]) { + expect(() => createRfc64DevnetEvidence({ + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + startedAt: excessivePrecision, + completedAt: '2026-03-02T12:00:01Z', + attemptCount: 1, + expected: snapshot, + observed: snapshot, + })).toThrow(/must be a valid, representable RFC 3339 timestamp/); + } + for (const impossible of [ '2026-02-30T12:00:00Z', '2025-02-29T12:00:00Z', @@ -564,6 +625,45 @@ describe('RFC-64 devnet run artifact', () => { }, ); + it.runIf(process.platform !== 'win32')( + 'persists a parent after a concurrent creator wins the mkdir race', + () => { + const directory = createTemporaryDirectory(); + const racedDirectory = join(directory, 'raced'); + const target = join(racedDirectory, 'artifact.json'); + mkdirSync(racedDirectory, { mode: 0o700 }); + const originalLstat = fs.lstatSync; + const originalFsync = fs.fsyncSync; + let injectedMissing = false; + let fileFsyncs = 0; + let directoryFsyncs = 0; + const lstat = vi.spyOn(fs, 'lstatSync').mockImplementation(((path: fs.PathLike) => { + if (!injectedMissing && String(path) === racedDirectory) { + injectedMissing = true; + throw Object.assign(new Error('simulated missing directory'), { code: 'ENOENT' }); + } + return originalLstat(path); + }) as typeof fs.lstatSync); + fs.fsyncSync = (descriptor) => { + if (fs.fstatSync(descriptor).isDirectory()) directoryFsyncs += 1; + else fileFsyncs += 1; + originalFsync(descriptor); + }; + syncBuiltinESMExports(); + try { + writeStableJsonArtifact(target, { durable: true }); + } finally { + lstat.mockRestore(); + fs.fsyncSync = originalFsync; + syncBuiltinESMExports(); + } + + expect(injectedMissing).toBe(true); + expect(fileFsyncs).toBe(1); + expect(directoryFsyncs).toBe(2); + }, + ); + it('declares the weaker Windows namespace and inherited-ACL policy', () => { const directory = createTemporaryDirectory(); const target = join(directory, 'windows-policy.json'); diff --git a/devnet/_bootstrap/rfc64-evidence.ts b/devnet/_bootstrap/rfc64-evidence.ts index 74f33bdf9b..113c242f88 100644 --- a/devnet/_bootstrap/rfc64-evidence.ts +++ b/devnet/_bootstrap/rfc64-evidence.ts @@ -604,20 +604,36 @@ function requiredLabel(value: unknown, label: string): string { return value.trim(); } -function canonicalFailure(value: Rfc64FailureV1, label: string): Rfc64FailureV1 { - if (!value || typeof value !== 'object') { +function captureFailureRecord( + value: Rfc64FailureV1, + label: string, +): Record { + const captured = stableJsonValue(value, label, new Set()); + if (!captured || typeof captured !== 'object' || Array.isArray(captured)) { throw new Rfc64EvidenceValidationError(`${label} must be an object`); } - if (typeof value.retryable !== 'boolean') { + return captured as Record; +} + +function canonicalFailureFromCaptured( + captured: Record, + label: string, +): Rfc64FailureV1 { + const retryable = captured.retryable; + if (typeof retryable !== 'boolean') { throw new Rfc64EvidenceValidationError(`${label}.retryable must be boolean`); } return Object.freeze({ - code: requiredLabel(value.code, `${label}.code`), - message: requiredLabel(value.message, `${label}.message`), - retryable: value.retryable, + code: requiredLabel(captured.code, `${label}.code`), + message: requiredLabel(captured.message, `${label}.message`), + retryable, }); } +function canonicalFailure(value: Rfc64FailureV1, label: string): Rfc64FailureV1 { + return canonicalFailureFromCaptured(captureFailureRecord(value, label), label); +} + function isGregorianLeapYear(year: number): boolean { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); } @@ -644,6 +660,7 @@ function canonicalInstant(value: Date | string, label: string): { const hour = Number(match[4]); const minute = Number(match[5]); const second = Number(match[6]); + const fractionalSecondDigits = match[7]?.length ?? 0; const offsetHour = match[10] === undefined ? 0 : Number(match[10]); const offsetMinute = match[11] === undefined ? 0 : Number(match[11]); const unknownLocalOffset = match[8] === '-00:00'; @@ -655,6 +672,7 @@ function canonicalInstant(value: Date | string, label: string): { || hour > 23 || minute > 59 || second > 59 + || fractionalSecondDigits > 3 || offsetHour > 23 || offsetMinute > 59 || unknownLocalOffset @@ -703,9 +721,10 @@ export function createRfc64DevnetEvidence( const observed = input.observed === null ? null : validateRfc64SemanticSnapshot(input.observed); - const terminalFailure = input.terminalFailure == null + const terminalFailureInput = input.terminalFailure; + const terminalFailure = terminalFailureInput == null ? null - : canonicalFailure(input.terminalFailure, 'terminalFailure'); + : canonicalFailure(terminalFailureInput, 'terminalFailure'); if (input.observed === null && terminalFailure === null) { throw new Rfc64EvidenceValidationError( 'a missing observed snapshot requires terminalFailure evidence', @@ -713,10 +732,12 @@ export function createRfc64DevnetEvidence( } const failures = (input.retryFailures ?? []).map((failure, index) => { - const canonical = canonicalFailure(failure, `retryFailures[${index}]`); + const label = `retryFailures[${index}]`; + const captured = captureFailureRecord(failure, label); + const canonical = canonicalFailureFromCaptured(captured, label); const attempt = assertNonNegativeSafeInteger( - failure.attempt, - `retryFailures[${index}].attempt`, + captured.attempt, + `${label}.attempt`, ); if (attempt < 1 || attempt >= attemptCount) { throw new Rfc64EvidenceValidationError( @@ -925,20 +946,21 @@ function ensureArtifactDirectoryTopology( for (const component of components) { current = join(current, component); let stat = lstatOptional(current); - let created = false; + let observedMissing = false; if (stat === null) { + observedMissing = true; try { mkdirSync(current, { mode: 0o700 }); - created = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } stat = lstatSync(current); } assertDirectory(current, stat); - if (created) { + if (observedMissing) { // Persist the new directory entry before relying on it as the parent of - // another directory or of the artifact itself. + // another directory or of the artifact itself. Also issue this barrier + // when a concurrent creator won the ENOENT-to-mkdir EEXIST race. fsyncArtifactDirectory(dirname(current), entries); } entries.push({ path: current, dev: stat.dev, ino: stat.ino }); From eece16357b9b44b6bc5399a23ab5912fb9e9e06e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:25:11 +0200 Subject: [PATCH 093/292] test(agent): add RFC-64 Gate 0 lifecycle harness --- devnet/rfc64-persistence-lifecycle/.gitignore | 1 + devnet/rfc64-persistence-lifecycle/README.md | 32 ++ .../agent-process.ts | 197 +++++++++ .../lease-probe.ts | 28 ++ .../rfc64-persistence-lifecycle/package.json | 13 + devnet/rfc64-persistence-lifecycle/run.ts | 413 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 18 + pnpm-workspace.yaml | 1 + 9 files changed, 704 insertions(+) create mode 100644 devnet/rfc64-persistence-lifecycle/.gitignore create mode 100644 devnet/rfc64-persistence-lifecycle/README.md create mode 100644 devnet/rfc64-persistence-lifecycle/agent-process.ts create mode 100644 devnet/rfc64-persistence-lifecycle/lease-probe.ts create mode 100644 devnet/rfc64-persistence-lifecycle/package.json create mode 100644 devnet/rfc64-persistence-lifecycle/run.ts diff --git a/devnet/rfc64-persistence-lifecycle/.gitignore b/devnet/rfc64-persistence-lifecycle/.gitignore new file mode 100644 index 0000000000..d4f588edfe --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/.gitignore @@ -0,0 +1 @@ +artifacts/ diff --git a/devnet/rfc64-persistence-lifecycle/README.md b/devnet/rfc64-persistence-lifecycle/README.md new file mode 100644 index 0000000000..1f91460c64 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/README.md @@ -0,0 +1,32 @@ +# OT-RFC-64 Gate 0: production persistence lifecycle + +This bounded runtime gate starts the built, production `DKGAgent` in a real +child process. It stages one deterministic, signature-verified control object +through the agent-owned RFC-64 persistence boundary, proves that another +process cannot acquire the inventory lease, then exercises both shutdown paths: + +1. graceful `SIGTERM` -> `DKGAgent.stop()` -> successful restart; +2. `SIGKILL` (no JavaScript cleanup) -> operating-system lease recovery. + +Each restarted agent re-reads and cryptographically verifies the exact durable +object before the harness compares byte hashes, object/signature counts, and +owner-only file modes. The result is written as stable, sorted JSON without +timestamps, PIDs, durations, or temporary absolute paths. + +Run from the repository root: + +```sh +pnpm test:gate0:rfc64-persistence-lifecycle +``` + +The default artifact is: + +```text +devnet/rfc64-persistence-lifecycle/artifacts/gate0-result.json +``` + +Set `DKG_RFC64_GATE0_ARTIFACT` to override that path. The gate deliberately +does not add an HTTP/API route. Its only control channel is the parent/child +stdio and process-signal boundary. A shared `scripts/devnet.sh` cluster is not +required: RFC-64 persistence is acquired before networking, and isolating this +gate avoids mutating a developer's existing devnet. diff --git a/devnet/rfc64-persistence-lifecycle/agent-process.ts b/devnet/rfc64-persistence-lifecycle/agent-process.ts new file mode 100644 index 0000000000..3eff67fd24 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/agent-process.ts @@ -0,0 +1,197 @@ +import process from 'node:process'; + +import { + computeControlObjectDigestHex, + splitCanonicalSignedControlEnvelopeV1, + type Digest32V1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + verifyControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; +import { DKGAgent } from '@origintrail-official/dkg-agent'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; + +const EVENT_PREFIX = 'RFC64_GATE0_EVENT '; +const FIXTURE_WALLET = new ethers.Wallet(`0x${'52'.repeat(32)}`); + +interface CandidateSessionV1 {} + +interface HarnessPersistenceV1 { + readonly rootPath: string; + readonly closed: boolean; + readonly inventory: { + createCandidateSession(): CandidateSessionV1; + }; + readonly controlObjects: { + readonly namespaceDurability: string; + stageVerifiedObjects(input: readonly [{ + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; + }]): Promise<{ + readonly durable: true; + readonly objects: readonly [{ + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + }]; + }>; + getVerifiedObject(input: { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + readonly verifyIssuerSignature: typeof verifyControlEnvelopeIssuerSignatureV1; + }): Promise<{ readonly envelope: SignedControlEnvelopeV1 } | null>; + }; +} + +interface HarnessAgentInternals { + readonly started: boolean; + readonly rfc64PersistenceV1?: HarnessPersistenceV1; +} + +function emit(event: Record): void { + process.stdout.write(`${EVENT_PREFIX}${JSON.stringify(event)}\n`); +} + +async function fixture(): Promise<{ + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; +}> { + const unsigned = { + issuer: FIXTURE_WALLET.address.toLowerCase(), + objectType: 'dkg-rfc64-gate0-persistence-lifecycle-v1', + payload: { + gate: 'gate-0', + purpose: 'durable-control-object-restart-proof', + sequence: '0001', + }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } satisfies UnsignedControlEnvelopeV1; + const objectDigest = computeControlObjectDigestHex(unsigned) as Digest32V1; + const envelope = { + ...unsigned, + objectDigest, + signature: await FIXTURE_WALLET.signMessage(ethers.getBytes(objectDigest)), + } as SignedControlEnvelopeV1; + const split = splitCanonicalSignedControlEnvelopeV1(envelope); + return { + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + objectDigest, + signatureVariantDigest: split.signatureVariant.signatureVariantDigest as Digest32V1, + }; +} + +const dataDir = process.env.DKG_RFC64_GATE0_DATA_DIR; +if (!dataDir) throw new Error('DKG_RFC64_GATE0_DATA_DIR is required'); + +const shouldStage = process.argv.includes('--stage'); +const expected = await fixture(); +const agent = await DKGAgent.create({ + name: 'RFC64Gate0PersistenceLifecycle', + dataDir, + listenHost: '127.0.0.1', + listenPort: 0, + bootstrapPeers: [], + nodeRole: 'edge', + store: new OxigraphStore(), + syncSharedMemoryOnConnect: false, + syncReconcilerEnabled: false, + syncOnConnectEnabled: false, + durableSyncEnabled: false, + agentProfileHeartbeatMs: 0, +}); + +let stopping = false; +async function stop(exitCode: number): Promise { + if (stopping) return await new Promise(() => undefined); + stopping = true; + try { + await agent.stop(); + emit({ event: 'stopped', graceful: true }); + process.exit(exitCode); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exit(1); + } +} + +process.once('SIGTERM', () => { void stop(0); }); +process.once('SIGINT', () => { void stop(130); }); + +try { + await agent.start(); + const internals = agent as unknown as HarnessAgentInternals; + const persistence = internals.rfc64PersistenceV1; + if (!internals.started || persistence === undefined || persistence.closed) { + throw new Error('production DKGAgent did not own an open RFC-64 persistence boundary'); + } + + // A production inventory operation through the DKGAgent-owned, non-lifecycle + // view. The opaque empty session is process-local and intentionally needs no + // durable cleanup; successful creation proves the inventory is operational. + persistence.inventory.createCandidateSession(); + + let staged = false; + if (shouldStage) { + const result = await persistence.controlObjects.stageVerifiedObjects([{ + envelope: expected.envelope, + issuerSignature: expected.issuerSignature, + }]); + if (!result.durable || result.objects.length !== 1) { + throw new Error('control-object stage did not report one durable object'); + } + if ( + result.objects[0].objectDigest !== expected.objectDigest + || result.objects[0].signatureVariantDigest !== expected.signatureVariantDigest + ) { + throw new Error('control-object stage returned unexpected digest keys'); + } + staged = true; + } + + const stored = await persistence.controlObjects.getVerifiedObject({ + objectDigest: expected.objectDigest, + signatureVariantDigest: expected.signatureVariantDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + const storedSplit = stored === null + ? null + : splitCanonicalSignedControlEnvelopeV1(stored.envelope); + const expectedSplit = splitCanonicalSignedControlEnvelopeV1(expected.envelope); + if ( + storedSplit === null + || !Buffer.from(storedSplit.unsignedEnvelopeBytes) + .equals(Buffer.from(expectedSplit.unsignedEnvelopeBytes)) + || !Buffer.from(storedSplit.signatureVariantBytes) + .equals(Buffer.from(expectedSplit.signatureVariantBytes)) + ) { + throw new Error('agent-owned control store did not return the exact verified fixture'); + } + + emit({ + event: 'ready', + agentClass: agent.constructor.name, + agentStarted: internals.started, + inventoryOperational: true, + persistenceClosed: persistence.closed, + namespaceDurability: persistence.controlObjects.namespaceDurability, + objectDigest: expected.objectDigest, + signatureVariantDigest: expected.signatureVariantDigest, + staged, + verifiedRead: true, + }); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + await stop(1); +} + +// Keep the real agent process alive while the parent proves exclusive lease +// ownership and snapshots immutable durable bytes. Timers are deliberately not +// used as an observation or test seam. +await new Promise(() => undefined); diff --git a/devnet/rfc64-persistence-lifecycle/lease-probe.ts b/devnet/rfc64-persistence-lifecycle/lease-probe.ts new file mode 100644 index 0000000000..2a7b7ab57d --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/lease-probe.ts @@ -0,0 +1,28 @@ +import process from 'node:process'; + +import { openRfc64PersistenceV1 } from '../../packages/agent/dist/rfc64/persistence-v1.js'; + +const EVENT_PREFIX = 'RFC64_GATE0_EVENT '; +const dataDir = process.env.DKG_RFC64_GATE0_DATA_DIR; +if (!dataDir) throw new Error('DKG_RFC64_GATE0_DATA_DIR is required'); + +try { + const persistence = await openRfc64PersistenceV1(dataDir, { + yieldAfterPurgeBatch: async () => {}, + }); + await persistence.close(); + process.stdout.write(`${EVENT_PREFIX}${JSON.stringify({ + event: 'lease-probe', + acquired: true, + errorCode: null, + })}\n`); +} catch (error) { + const errorCode = typeof error === 'object' && error !== null && 'code' in error + ? String(error.code) + : null; + process.stdout.write(`${EVENT_PREFIX}${JSON.stringify({ + event: 'lease-probe', + acquired: false, + errorCode, + })}\n`); +} diff --git a/devnet/rfc64-persistence-lifecycle/package.json b/devnet/rfc64-persistence-lifecycle/package.json new file mode 100644 index 0000000000..2f5dd9a770 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/package.json @@ -0,0 +1,13 @@ +{ + "name": "@origintrail-official/dkg-devnet-rfc64-persistence-lifecycle", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@origintrail-official/dkg-agent": "workspace:*", + "@origintrail-official/dkg-chain": "workspace:*", + "@origintrail-official/dkg-core": "workspace:*", + "@origintrail-official/dkg-storage": "workspace:*", + "ethers": "^6.16.0" + } +} diff --git a/devnet/rfc64-persistence-lifecycle/run.ts b/devnet/rfc64-persistence-lifecycle/run.ts new file mode 100644 index 0000000000..c4e0ef07b7 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/run.ts @@ -0,0 +1,413 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import process from 'node:process'; + +const EVENT_PREFIX = 'RFC64_GATE0_EVENT '; +const PRODUCTION_BASELINE_COMMIT = '11a848a57a8f30305716dc548ea3c83de286d024'; +const REPO_ROOT = resolve(import.meta.dirname, '../..'); +const AGENT_PROCESS = join(import.meta.dirname, 'agent-process.ts'); +const LEASE_PROBE = join(import.meta.dirname, 'lease-probe.ts'); +const DEFAULT_ARTIFACT = join(import.meta.dirname, 'artifacts/gate0-result.json'); +const CONTROL_ROOT_RELATIVE = 'rfc64-sync/control-objects-v1'; +const INVENTORY_RELATIVE = 'rfc64-sync/inventory-v1.sqlite3'; +const LEASE_RELATIVE = 'rfc64-sync/inventory-v1.lease.sqlite3'; +const PROCESS_TIMEOUT_MS = 60_000; + +interface ProcessEvent { + readonly event: string; + readonly [key: string]: unknown; +} + +interface AgentHandle { + readonly child: ChildProcessWithoutNullStreams; + readonly ready: ProcessEvent; + readonly stdout: () => string; + readonly stderr: () => string; +} + +interface ContentFileEvidence { + readonly kind: 'object' | 'signature'; + readonly relativePath: string; + readonly byteLength: number; + readonly sha256: string; + readonly mode: string | null; +} + +interface ContentSnapshot { + readonly counts: { readonly objects: number; readonly signatures: number }; + readonly inventoryFileMode: string | null; + readonly leaseFileMode: string | null; + readonly namespaceDirectoryModes: readonly { + readonly relativePath: string; + readonly mode: string | null; + }[]; + readonly files: readonly ContentFileEvidence[]; +} + +function log(message: string): void { + process.stdout.write(`[rfc64-gate0] ${message}\n`); +} + +function parseEvent(line: string): ProcessEvent | null { + if (!line.startsWith(EVENT_PREFIX)) return null; + return JSON.parse(line.slice(EVENT_PREFIX.length)) as ProcessEvent; +} + +function childArguments(script: string, extra: readonly string[] = []): string[] { + return ['--experimental-sqlite', '--import', 'tsx', script, ...extra]; +} + +function spawnAgent(dataDir: string, stage: boolean): Promise { + return new Promise((resolveReady, rejectReady) => { + const child = spawn(process.execPath, childArguments(AGENT_PROCESS, stage ? ['--stage'] : []), { + cwd: REPO_ROOT, + env: { + ...process.env, + NODE_ENV: 'production', + DKG_RFC64_GATE0_DATA_DIR: dataDir, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let lineBuffer = ''; + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + rejectReady(new Error(`agent process timed out before ready\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }, PROCESS_TIMEOUT_MS); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + lineBuffer += chunk; + const lines = lineBuffer.split('\n'); + lineBuffer = lines.pop() ?? ''; + for (const line of lines) { + let event: ProcessEvent | null = null; + try { event = parseEvent(line); } catch { /* parent reports complete output on timeout/exit */ } + if (event?.event !== 'ready' || settled) continue; + settled = true; + clearTimeout(timeout); + resolveReady({ child, ready: event, stdout: () => stdout, stderr: () => stderr }); + } + }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + child.once('error', (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + rejectReady(error); + }); + child.once('exit', (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + rejectReady(new Error( + `agent exited before ready: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`, + )); + }); + }); +} + +async function stopAgent( + handle: AgentHandle, + signal: 'SIGTERM' | 'SIGKILL', +): Promise<{ readonly code: number | null; readonly signal: NodeJS.Signals | null }> { + const exit = new Promise<{ readonly code: number | null; readonly signal: NodeJS.Signals | null }>( + (resolveExit, rejectExit) => { + const timeout = setTimeout(() => { + handle.child.kill('SIGKILL'); + rejectExit(new Error( + `agent did not exit after ${signal}\nstdout:\n${handle.stdout()}\nstderr:\n${handle.stderr()}`, + )); + }, PROCESS_TIMEOUT_MS); + handle.child.once('exit', (code, exitSignal) => { + clearTimeout(timeout); + resolveExit({ code, signal: exitSignal }); + }); + }, + ); + handle.child.kill(signal); + return await exit; +} + +async function probeLease(dataDir: string): Promise { + const child = spawn(process.execPath, childArguments(LEASE_PROBE), { + cwd: REPO_ROOT, + env: { + ...process.env, + NODE_ENV: 'production', + DKG_RFC64_GATE0_DATA_DIR: dataDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveExit, rejectExit) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + rejectExit(new Error(`lease probe timed out\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + }, PROCESS_TIMEOUT_MS); + child.once('error', rejectExit); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + resolveExit({ code, signal }); + }); + }, + ); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`lease probe failed: ${JSON.stringify(exit)}\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } + const event = stdout.split('\n').map(parseEvent).find((value) => value?.event === 'lease-probe'); + if (event === undefined) { + throw new Error(`lease probe emitted no result\nstdout:\n${stdout}\nstderr:\n${stderr}`); + } + return event; +} + +function posixMode(path: string): string | null { + if (process.platform === 'win32') return null; + return (statSync(path).mode & 0o777).toString(8).padStart(4, '0'); +} + +function slashPath(path: string): string { + return sep === '/' ? path : path.split(sep).join('/'); +} + +function walkDirectories(root: string): string[] { + const directories: string[] = [root]; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.isDirectory()) directories.push(...walkDirectories(join(root, entry.name))); + } + return directories; +} + +function walkFiles(root: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) files.push(...walkFiles(path)); + else if (entry.isFile()) files.push(path); + } + return files; +} + +function sha256(bytes: Buffer): string { + return `0x${createHash('sha256').update(bytes).digest('hex')}`; +} + +function snapshotContent(dataDir: string): ContentSnapshot { + const controlRoot = join(dataDir, CONTROL_ROOT_RELATIVE); + const files = walkFiles(controlRoot) + .filter((path) => path.endsWith('.jcs')) + .map((path): ContentFileEvidence => { + const bytes = readFileSync(path); + const controlRelative = slashPath(relative(controlRoot, path)); + const kind = controlRelative.startsWith('objects/') ? 'object' : 'signature'; + return { + kind, + relativePath: slashPath(relative(dataDir, path)), + byteLength: bytes.byteLength, + sha256: sha256(bytes), + mode: posixMode(path), + }; + }) + .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); + const namespaceDirectoryModes = walkDirectories(join(dataDir, 'rfc64-sync')) + .map((path) => ({ + relativePath: slashPath(relative(dataDir, path)), + mode: posixMode(path), + })) + .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); + return { + counts: { + objects: files.filter((file) => file.kind === 'object').length, + signatures: files.filter((file) => file.kind === 'signature').length, + }, + inventoryFileMode: posixMode(join(dataDir, INVENTORY_RELATIVE)), + leaseFileMode: posixMode(join(dataDir, LEASE_RELATIVE)), + namespaceDirectoryModes, + files, + }; +} + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, stableValue(nested)]), + ); + } + return value; +} + +function stableJson(value: unknown): string { + return `${JSON.stringify(stableValue(value), null, 2)}\n`; +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertReady(event: ProcessEvent, expectedStage: boolean): void { + assert(event.agentClass === 'DKGAgent', 'child did not run the production DKGAgent class'); + assert(event.agentStarted === true, 'DKGAgent was not started'); + assert(event.inventoryOperational === true, 'agent-owned inventory operation failed'); + assert(event.persistenceClosed === false, 'agent-owned persistence was already closed'); + assert(event.verifiedRead === true, 'agent-owned control-object verification read failed'); + assert(event.staged === expectedStage, `unexpected staged=${String(event.staged)}`); +} + +function assertLeaseBusy(event: ProcessEvent): void { + assert(event.acquired === false, 'contender unexpectedly acquired the live inventory lease'); + assert(event.errorCode === 'database-busy', `contender failed with ${String(event.errorCode)}, not database-busy`); +} + +function assertExactSnapshot(snapshot: ContentSnapshot): void { + assert(snapshot.counts.objects === 1, `expected one object, got ${snapshot.counts.objects}`); + assert(snapshot.counts.signatures === 1, `expected one signature, got ${snapshot.counts.signatures}`); + if (process.platform !== 'win32') { + assert(snapshot.inventoryFileMode === '0600', `inventory mode was ${snapshot.inventoryFileMode}`); + assert(snapshot.leaseFileMode === '0600', `lease mode was ${snapshot.leaseFileMode}`); + for (const file of snapshot.files) assert(file.mode === '0600', `${file.relativePath} mode was ${file.mode}`); + for (const directory of snapshot.namespaceDirectoryModes) { + assert(directory.mode === '0700', `${directory.relativePath} mode was ${directory.mode}`); + } + } +} + +const dataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate0-')); +const artifactPath = resolve(process.env.DKG_RFC64_GATE0_ARTIFACT ?? DEFAULT_ARTIFACT); +const active = new Set(); + +try { + log('phase 1/3: start production DKGAgent and stage deterministic control object'); + const initial = await spawnAgent(dataDir, true); + active.add(initial.child); + initial.child.once('exit', () => active.delete(initial.child)); + assertReady(initial.ready, true); + const initialProbe = await probeLease(dataDir); + assertLeaseBusy(initialProbe); + const initialSnapshot = snapshotContent(dataDir); + assertExactSnapshot(initialSnapshot); + const initialExit = await stopAgent(initial, 'SIGTERM'); + assert(initialExit.code === 0 && initialExit.signal === null, 'graceful stop did not exit cleanly'); + + log('phase 2/3: restart, re-read exact bytes, then simulate unclean process death'); + const gracefulRestart = await spawnAgent(dataDir, false); + active.add(gracefulRestart.child); + gracefulRestart.child.once('exit', () => active.delete(gracefulRestart.child)); + assertReady(gracefulRestart.ready, false); + const gracefulProbe = await probeLease(dataDir); + assertLeaseBusy(gracefulProbe); + const gracefulSnapshot = snapshotContent(dataDir); + assertExactSnapshot(gracefulSnapshot); + assert( + stableJson(gracefulSnapshot.files) === stableJson(initialSnapshot.files), + 'durable control-object bytes changed across graceful restart', + ); + const crashExit = await stopAgent(gracefulRestart, 'SIGKILL'); + assert(crashExit.code === null && crashExit.signal === 'SIGKILL', 'unclean stop was not SIGKILL'); + + log('phase 3/3: recover OS lease, re-read and re-verify exact durable bytes'); + const recovered = await spawnAgent(dataDir, false); + active.add(recovered.child); + recovered.child.once('exit', () => active.delete(recovered.child)); + assertReady(recovered.ready, false); + const recoveredProbe = await probeLease(dataDir); + assertLeaseBusy(recoveredProbe); + const recoveredSnapshot = snapshotContent(dataDir); + assertExactSnapshot(recoveredSnapshot); + assert( + stableJson(recoveredSnapshot.files) === stableJson(initialSnapshot.files), + 'durable control-object bytes changed after OS-lease recovery', + ); + const recoveredExit = await stopAgent(recovered, 'SIGTERM'); + assert(recoveredExit.code === 0 && recoveredExit.signal === null, 'recovered agent did not stop cleanly'); + + const artifact = { + schemaVersion: 'dkg-rfc64-gate0-persistence-evidence-v1', + gate: 'OT-RFC-64 Gate 0', + productionBaselineCommit: PRODUCTION_BASELINE_COMMIT, + invocation: 'pnpm test:gate0:rfc64-persistence-lifecycle', + runtimeBoundary: { + agentClass: 'DKGAgent', + processModel: 'three independent production agent processes', + controlChannel: 'stdio and POSIX process signals only', + publicDebugEndpointAdded: false, + }, + fixture: { + objectDigest: initial.ready.objectDigest, + signatureVariantDigest: initial.ready.signatureVariantDigest, + objectFileSha256: initialSnapshot.files.find((file) => file.kind === 'object')?.sha256, + signatureFileSha256: initialSnapshot.files.find((file) => file.kind === 'signature')?.sha256, + }, + phases: { + initialRunningOwner: { + inventoryOperational: initial.ready.inventoryOperational, + namespaceDurability: initial.ready.namespaceDurability, + contender: initialProbe, + snapshot: initialSnapshot, + }, + gracefulRestart: { + priorExit: initialExit, + verifiedRead: gracefulRestart.ready.verifiedRead, + contender: gracefulProbe, + exactContentFilesEqual: true, + snapshot: gracefulSnapshot, + }, + operatingSystemLeaseRecovery: { + uncleanExit: crashExit, + verifiedRead: recovered.ready.verifiedRead, + contender: recoveredProbe, + exactContentFilesEqual: true, + finalGracefulExit: recoveredExit, + snapshot: recoveredSnapshot, + }, + }, + checks: { + productionAgentStarted: true, + inventoryOperationalWhileRunning: true, + competingLeaseRejectedWhileRunning: true, + gracefulRestartSucceeded: true, + operatingSystemLeaseRecoveredAfterSigkill: true, + durableControlObjectBytesPreserved: true, + storedEnvelopeSignatureReverifiedOnEveryStart: true, + exactObjectCount: 1, + exactSignatureCount: 1, + ownerOnlyFileModes: process.platform === 'win32' ? 'not-applicable' : true, + publicRuntimeDebugEndpointAdded: false, + }, + passed: true, + }; + mkdirSync(dirname(artifactPath), { recursive: true }); + writeFileSync(artifactPath, stableJson(artifact), { mode: 0o600 }); + log(`PASS: deterministic evidence written to ${artifactPath}`); +} finally { + for (const child of active) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } + rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/package.json b/package.json index b120d27a97..da8600590a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "test:e2e:ui": "pnpm --filter @origintrail-official/dkg-node-ui test:e2e", "test:e2e:agent-provenance": "pnpm --filter @origintrail-official/dkg-publisher exec vitest run test/agent-provenance-e2e.test.ts", "test:devnet:agent-provenance": "vitest run --config devnet/agent-provenance/vitest.config.ts", + "test:gate0:rfc64-persistence-lifecycle": "pnpm -r --filter @origintrail-official/dkg-agent... --filter '!@origintrail-official/dkg-evm-module' run build && node --experimental-sqlite --import tsx devnet/rfc64-persistence-lifecycle/run.ts", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", "test:devnet:v10-stress": "vitest run --config devnet/v10-stress/vitest.config.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4315ccfb8e..9440cb6242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,6 +298,24 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.11)(happy-dom@20.8.9(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + devnet/rfc64-persistence-lifecycle: + dependencies: + '@origintrail-official/dkg-agent': + specifier: workspace:* + version: link:../../packages/agent + '@origintrail-official/dkg-chain': + specifier: workspace:* + version: link:../../packages/chain + '@origintrail-official/dkg-core': + specifier: workspace:* + version: link:../../packages/core + '@origintrail-official/dkg-storage': + specifier: workspace:* + version: link:../../packages/storage + ethers: + specifier: ^6.16.0 + version: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + devnet/rich-scenario: dependencies: '@origintrail-official/dkg-chain': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eccc9ea164..bc697858b0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,7 @@ packages: - "devnet/v10-rs-prune" - "devnet/v10-rs-wallet-rotation" - "devnet/_bootstrap" + - "devnet/rfc64-persistence-lifecycle" - "devnet/pr1386-term-canon" - "devnet/pr1385-subgraph-rs" - "devnet/pr1388-okf-integration" From 414fd18501c9b92cdf08ee568c346438e40792fe Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:47:03 +0200 Subject: [PATCH 094/292] test(agent): harden RFC-64 Gate 0 evidence --- devnet/rfc64-persistence-lifecycle/README.md | 29 +- .../evidence.test.ts | 188 ++++++++++++ .../rfc64-persistence-lifecycle/evidence.ts | 285 ++++++++++++++++++ devnet/rfc64-persistence-lifecycle/run.ts | 59 ++-- package.json | 1 + 5 files changed, 533 insertions(+), 29 deletions(-) create mode 100644 devnet/rfc64-persistence-lifecycle/evidence.test.ts create mode 100644 devnet/rfc64-persistence-lifecycle/evidence.ts diff --git a/devnet/rfc64-persistence-lifecycle/README.md b/devnet/rfc64-persistence-lifecycle/README.md index 1f91460c64..d682f3caa1 100644 --- a/devnet/rfc64-persistence-lifecycle/README.md +++ b/devnet/rfc64-persistence-lifecycle/README.md @@ -10,8 +10,16 @@ process cannot acquire the inventory lease, then exercises both shutdown paths: Each restarted agent re-reads and cryptographically verifies the exact durable object before the harness compares byte hashes, object/signature counts, and -owner-only file modes. The result is written as stable, sorted JSON without -timestamps, PIDs, durations, or temporary absolute paths. +owner-only file modes. Before any child is spawned, the runner requires a clean +tracked worktree and reads the tested commit from `git rev-parse HEAD`; it repeats +that check before publishing evidence. Untracked and ignored build/artifact files +do not affect this tracked-source check. + +The result is encoded as stable, sorted JSON without timestamps, PIDs, durations, +or temporary absolute paths. The encoder accepts only lossless plain JSON data: +finite non-negative-zero numbers, strings, booleans, null, dense plain arrays, and +plain enumerable data-property objects without cycles, aliases, accessors, symbols, +or hidden fields. Run from the repository root: @@ -19,6 +27,12 @@ Run from the repository root: pnpm test:gate0:rfc64-persistence-lifecycle ``` +Focused evidence-encoder, repository-state, and artifact-publication tests run with: + +```sh +pnpm test:gate0:rfc64-persistence-lifecycle:unit +``` + The default artifact is: ```text @@ -30,3 +44,14 @@ does not add an HTTP/API route. Its only control channel is the parent/child stdio and process-signal boundary. A shared `scripts/devnet.sh` cluster is not required: RFC-64 persistence is acquired before networking, and isolating this gate avoids mutating a developer's existing devnet. + +Artifact publication uses an exclusive `0600` sibling temporary file, file +`fsync`, atomic rename, parent-directory topology revalidation, and a parent +directory `fsync` on POSIX. Windows performs the same file flush, rename, and +topology validation while reporting that directory `fsync` is unavailable. A +symlink target or symlink parent is rejected. The runner logs the SHA-256 of the +final bytes, allowing two runs at the same clean commit to prove byte identity. + +This harness can prove that its bounded lifecycle checks completed. It does not +declare OT-RFC-64 Gate 0 passed: final Gate 0 evaluation remains unavailable until +the final RFC-64 integration has been assembled and tested at its own exact HEAD. diff --git a/devnet/rfc64-persistence-lifecycle/evidence.test.ts b/devnet/rfc64-persistence-lifecycle/evidence.test.ts new file mode 100644 index 0000000000..5b83914278 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/evidence.test.ts @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; +import test from 'node:test'; + +import { + atomicWriteStableJson, + readCleanRepositoryHead, + stableJson, +} from './evidence.js'; + +const temporaryDirectories: string[] = []; + +test.afterEach(() => { + for (const path of temporaryDirectories.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +test('stableJson sorts keys and preserves supported plain data exactly', () => { + const value = { + z: [true, null, 17.25, 'plain'], + a: { second: 2, first: 1 }, + }; + const encoded = stableJson(value); + assert.equal( + encoded, + '{\n "a": {\n "first": 1,\n "second": 2\n },\n "z": [\n true,\n null,\n 17.25,\n "plain"\n ]\n}\n', + ); + assert.deepEqual(JSON.parse(encoded), value); +}); + +test('stableJson rejects values that JSON would omit, coerce, or reshape', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const shared = { value: true }; + const symbolKeyed = { visible: true } as Record; + symbolKeyed[Symbol('hidden')] = false; + const hidden = { visible: true }; + Object.defineProperty(hidden, 'hidden', { value: false, enumerable: false }); + const sparse = new Array(1); + const accessor = {}; + Object.defineProperty(accessor, 'value', { enumerable: true, get: () => 1 }); + + for (const value of [ + undefined, + { value: undefined }, + Number.NaN, + Number.POSITIVE_INFINITY, + -0, + 1n, + new Date(0), + Object.create(null), + sparse, + symbolKeyed, + hidden, + accessor, + cyclic, + [shared, shared], + ]) { + assert.throws(() => stableJson(value)); + } +}); + +test('readCleanRepositoryHead returns HEAD, permits untracked files, and rejects tracked dirt', () => { + const repo = temporaryDirectory('dkg-rfc64-gate0-git-'); + git(repo, ['init', '-q']); + writeFileSync(join(repo, 'tracked.txt'), 'committed\n'); + git(repo, ['add', 'tracked.txt']); + git(repo, [ + '-c', + 'user.name=Gate 0 Test', + '-c', + 'user.email=gate0@example.invalid', + 'commit', + '-qm', + 'initial', + ]); + const expectedHead = git(repo, ['rev-parse', 'HEAD']); + + assert.equal(readCleanRepositoryHead(repo), expectedHead); + writeFileSync(join(repo, 'untracked.txt'), 'ignored by tracked-source check\n'); + assert.equal(readCleanRepositoryHead(repo), expectedHead); + + writeFileSync(join(repo, 'tracked.txt'), 'modified\n'); + assert.throws( + () => readCleanRepositoryHead(repo), + /refuses to spawn with tracked source changes/u, + ); + git(repo, ['add', 'tracked.txt']); + assert.throws( + () => readCleanRepositoryHead(repo), + /refuses to spawn with tracked source changes/u, + ); +}); + +test('atomicWriteStableJson replaces through a 0600 sibling temp and returns final SHA-256', () => { + const root = temporaryDirectory('dkg-rfc64-gate0-artifact-'); + const parent = join(root, 'artifacts'); + const artifact = join(parent, 'gate0-result.json'); + const first = atomicWriteStableJson(artifact, { z: 2, a: 1 }); + const firstBytes = readFileSync(artifact); + assert.equal(first.sha256, sha256(firstBytes)); + assert.deepEqual(JSON.parse(firstBytes.toString('utf8')), { a: 1, z: 2 }); + assert.deepEqual(readdirSync(parent), ['gate0-result.json']); + if (process.platform !== 'win32') { + assert.equal(lstatSync(artifact).mode & 0o777, 0o600); + } + + chmodSync(artifact, 0o644); + const second = atomicWriteStableJson(artifact, { replacement: true }); + const secondBytes = readFileSync(artifact); + assert.equal(second.sha256, sha256(secondBytes)); + assert.deepEqual(JSON.parse(secondBytes.toString('utf8')), { replacement: true }); + assert.deepEqual(readdirSync(parent), ['gate0-result.json']); + if (process.platform !== 'win32') { + assert.equal(lstatSync(artifact).mode & 0o777, 0o600); + assert.equal(second.durability, 'posix-file-fsync-rename-directory-fsync-v1'); + } else { + assert.equal(second.durability, 'windows-file-fsync-rename-topology-validated-v1'); + } +}); + +test( + 'atomicWriteStableJson rejects a symlink artifact target', + { skip: process.platform === 'win32' }, + () => { + const root = temporaryDirectory('dkg-rfc64-gate0-target-link-'); + const victim = join(root, 'victim.json'); + const artifact = join(root, 'gate0-result.json'); + writeFileSync(victim, 'do not replace through link\n'); + symlinkSync(victim, artifact); + assert.throws( + () => atomicWriteStableJson(artifact, { safe: true }), + /non-symlink regular file/u, + ); + assert.equal(readFileSync(victim, 'utf8'), 'do not replace through link\n'); + }, +); + +test( + 'atomicWriteStableJson rejects a symlink parent directory', + { skip: process.platform === 'win32' }, + () => { + const root = temporaryDirectory('dkg-rfc64-gate0-parent-link-'); + const realParent = join(root, 'real'); + const linkedParent = join(root, 'linked'); + mkdirSync(realParent); + symlinkSync(realParent, linkedParent, 'dir'); + assert.throws( + () => atomicWriteStableJson(join(linkedParent, 'gate0-result.json'), { safe: true }), + /non-symlink directory/u, + ); + assert.deepEqual(readdirSync(realParent), []); + }, +); + +function temporaryDirectory(prefix: string): string { + const path = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirectories.push(path); + return path; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function sha256(bytes: Uint8Array): string { + return `0x${createHash('sha256').update(bytes).digest('hex')}`; +} diff --git a/devnet/rfc64-persistence-lifecycle/evidence.ts b/devnet/rfc64-persistence-lifecycle/evidence.ts new file mode 100644 index 0000000000..700ee634d4 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/evidence.ts @@ -0,0 +1,285 @@ +import { execFileSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { + closeSync, + constants, + fchmodSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeSync, +} from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; +import process from 'node:process'; + +export interface AtomicArtifactWriteResult { + readonly sha256: string; + readonly durability: + | 'posix-file-fsync-rename-directory-fsync-v1' + | 'windows-file-fsync-rename-topology-validated-v1'; +} + +interface DirectoryIdentity { + readonly realPath: string; + readonly device: string; + readonly inode: string; +} + +export function readCleanRepositoryHead(repoRootInput: string): string { + const repoRoot = resolve(repoRootInput); + const discoveredRoot = git(repoRoot, ['rev-parse', '--show-toplevel']); + if (realpathSync.native(discoveredRoot) !== realpathSync.native(repoRoot)) { + throw new Error( + `Gate 0 repository root mismatch: expected ${repoRoot}, git reported ${discoveredRoot}`, + ); + } + const status = git(repoRoot, [ + 'status', + '--porcelain=v1', + '--untracked-files=no', + '--ignore-submodules=untracked', + ]); + if (status !== '') { + throw new Error( + `Gate 0 refuses to spawn with tracked source changes:\n${status}`, + ); + } + const head = git(repoRoot, ['rev-parse', '--verify', 'HEAD^{commit}']); + if (!/^[0-9a-f]{40,64}$/u.test(head)) { + throw new Error(`Git returned an invalid repository HEAD: ${JSON.stringify(head)}`); + } + return head; +} + +export function stableJson(value: unknown): string { + const normalized = normalizePlainJsonValue(value, '$', new WeakSet()); + return `${JSON.stringify(normalized, null, 2)}\n`; +} + +export function atomicWriteStableJson( + artifactPathInput: string, + value: unknown, +): AtomicArtifactWriteResult { + const artifactPath = resolve(artifactPathInput); + const parentPath = dirname(artifactPath); + mkdirSync(parentPath, { recursive: true, mode: 0o700 }); + const parentIdentity = inspectDirectory(parentPath, 'artifact parent directory'); + assertReplaceableArtifactTarget(artifactPath); + + const bytes = Buffer.from(stableJson(value), 'utf8'); + const intendedSha256 = sha256(bytes); + const tempPath = resolve( + parentPath, + `.${basename(artifactPath)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`, + ); + let fileDescriptor: number | null = null; + let renamed = false; + try { + const noFollow = constants.O_NOFOLLOW ?? 0; + fileDescriptor = openSync( + tempPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, + 0o600, + ); + fchmodSync(fileDescriptor, 0o600); + writeAll(fileDescriptor, bytes); + fsyncSync(fileDescriptor); + closeSync(fileDescriptor); + fileDescriptor = null; + + assertSameDirectory(parentPath, parentIdentity); + assertRegularOwnerOnlyFile(tempPath, 'artifact sibling temp file'); + assertReplaceableArtifactTarget(artifactPath); + renameSync(tempPath, artifactPath); + renamed = true; + + assertSameDirectory(parentPath, parentIdentity); + assertRegularOwnerOnlyFile(artifactPath, 'published artifact'); + const durability = fsyncArtifactParent(parentPath); + assertSameDirectory(parentPath, parentIdentity); + + const publishedBytes = readFileSync(artifactPath); + const publishedSha256 = sha256(publishedBytes); + if (!publishedBytes.equals(bytes) || publishedSha256 !== intendedSha256) { + throw new Error('Published Gate 0 artifact bytes did not match the fsynced temp file'); + } + return { sha256: publishedSha256, durability }; + } finally { + if (fileDescriptor !== null) closeSync(fileDescriptor); + if (!renamed) rmSync(tempPath, { force: true }); + } +} + +function git(repoRoot: string, args: readonly string[]): string { + try { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (cause) { + throw new Error(`Git repository inspection failed: git ${args.join(' ')}`, { cause }); + } +} + +function normalizePlainJsonValue( + value: unknown, + path: string, + seen: WeakSet, +): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value) || Object.is(value, -0)) { + throw new TypeError(`${path} contains a non-lossless JSON number`); + } + return value; + } + if (typeof value !== 'object') { + throw new TypeError(`${path} contains unsupported ${typeof value}`); + } + if (seen.has(value)) { + throw new TypeError(`${path} repeats or cycles an object reference`); + } + seen.add(value); + + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) { + throw new TypeError(`${path} is not a plain array`); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key === 'symbol')) { + throw new TypeError(`${path} has a symbol-keyed array property`); + } + const expectedKeys = new Set(['length']); + for (let index = 0; index < value.length; index += 1) { + expectedKeys.add(String(index)); + } + if ( + ownKeys.length !== expectedKeys.size + || ownKeys.some((key) => typeof key !== 'string' || !expectedKeys.has(key)) + ) { + throw new TypeError(`${path} is sparse or has non-index array properties`); + } + return value.map((_, index) => { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError(`${path}[${index}] is not an enumerable data property`); + } + return normalizePlainJsonValue(descriptor.value, `${path}[${index}]`, seen); + }); + } + + if (Object.getPrototypeOf(value) !== Object.prototype) { + throw new TypeError(`${path} is not a plain object`); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key === 'symbol')) { + throw new TypeError(`${path} has a symbol-keyed object property`); + } + const entries = ownKeys + .map((key): readonly [string, unknown] => { + if (typeof key !== 'string') throw new TypeError(`${path} has an invalid key`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError(`${path}.${key} is not an enumerable data property`); + } + return [key, normalizePlainJsonValue(descriptor.value, `${path}.${key}`, seen)]; + }) + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + return Object.fromEntries(entries); +} + +function inspectDirectory(path: string, label: string): DirectoryIdentity { + const topology = lstatSync(path); + if (topology.isSymbolicLink() || !topology.isDirectory()) { + throw new Error(`${label} must be a non-symlink directory: ${path}`); + } + const identity = statSync(path, { bigint: true }); + return { + realPath: realpathSync.native(path), + device: identity.dev.toString(), + inode: identity.ino.toString(), + }; +} + +function assertSameDirectory(path: string, expected: DirectoryIdentity): void { + const actual = inspectDirectory(path, 'artifact parent directory'); + if ( + actual.realPath !== expected.realPath + || actual.device !== expected.device + || actual.inode !== expected.inode + ) { + throw new Error(`Artifact parent directory topology changed during publication: ${path}`); + } +} + +function assertReplaceableArtifactTarget(path: string): void { + try { + const topology = lstatSync(path); + if (topology.isSymbolicLink() || !topology.isFile()) { + throw new Error(`Artifact target must be absent or a non-symlink regular file: ${path}`); + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return; + throw error; + } +} + +function assertRegularOwnerOnlyFile(path: string, label: string): void { + const topology = lstatSync(path); + if (topology.isSymbolicLink() || !topology.isFile()) { + throw new Error(`${label} must be a non-symlink regular file: ${path}`); + } + if (process.platform !== 'win32' && (topology.mode & 0o777) !== 0o600) { + throw new Error(`${label} must have mode 0600: ${path}`); + } +} + +function writeAll(fileDescriptor: number, bytes: Buffer): void { + let offset = 0; + while (offset < bytes.byteLength) { + const written = writeSync( + fileDescriptor, + bytes, + offset, + bytes.byteLength - offset, + offset, + ); + if (written <= 0) throw new Error('Artifact sibling temp write made no progress'); + offset += written; + } +} + +function fsyncArtifactParent( + parentPath: string, +): AtomicArtifactWriteResult['durability'] { + if (process.platform === 'win32') { + return 'windows-file-fsync-rename-topology-validated-v1'; + } + const directoryDescriptor = openSync( + parentPath, + constants.O_RDONLY | constants.O_DIRECTORY, + ); + try { + fsyncSync(directoryDescriptor); + } finally { + closeSync(directoryDescriptor); + } + return 'posix-file-fsync-rename-directory-fsync-v1'; +} + +function sha256(bytes: Uint8Array): string { + return `0x${createHash('sha256').update(bytes).digest('hex')}`; +} + +function isNodeError(value: unknown): value is NodeJS.ErrnoException { + return value instanceof Error && 'code' in value; +} diff --git a/devnet/rfc64-persistence-lifecycle/run.ts b/devnet/rfc64-persistence-lifecycle/run.ts index c4e0ef07b7..bb317beff9 100644 --- a/devnet/rfc64-persistence-lifecycle/run.ts +++ b/devnet/rfc64-persistence-lifecycle/run.ts @@ -1,20 +1,23 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { createHash } from 'node:crypto'; import { - mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, - writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { dirname, join, relative, resolve, sep } from 'node:path'; +import { join, relative, resolve, sep } from 'node:path'; import process from 'node:process'; +import { + atomicWriteStableJson, + readCleanRepositoryHead, + stableJson, +} from './evidence.js'; + const EVENT_PREFIX = 'RFC64_GATE0_EVENT '; -const PRODUCTION_BASELINE_COMMIT = '11a848a57a8f30305716dc548ea3c83de286d024'; const REPO_ROOT = resolve(import.meta.dirname, '../..'); const AGENT_PROCESS = join(import.meta.dirname, 'agent-process.ts'); const LEASE_PROBE = join(import.meta.dirname, 'lease-probe.ts'); @@ -251,22 +254,6 @@ function snapshotContent(dataDir: string): ContentSnapshot { }; } -function stableValue(value: unknown): unknown { - if (Array.isArray(value)) return value.map(stableValue); - if (value !== null && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, nested]) => [key, stableValue(nested)]), - ); - } - return value; -} - -function stableJson(value: unknown): string { - return `${JSON.stringify(stableValue(value), null, 2)}\n`; -} - function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } @@ -298,8 +285,10 @@ function assertExactSnapshot(snapshot: ContentSnapshot): void { } } -const dataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate0-')); const artifactPath = resolve(process.env.DKG_RFC64_GATE0_ARTIFACT ?? DEFAULT_ARTIFACT); +const testedRepositoryHead = readCleanRepositoryHead(REPO_ROOT); +log(`testing clean repository HEAD ${testedRepositoryHead}`); +const dataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate0-')); const active = new Set(); try { @@ -347,11 +336,21 @@ try { const recoveredExit = await stopAgent(recovered, 'SIGTERM'); assert(recoveredExit.code === 0 && recoveredExit.signal === null, 'recovered agent did not stop cleanly'); + const finalRepositoryHead = readCleanRepositoryHead(REPO_ROOT); + assert( + finalRepositoryHead === testedRepositoryHead, + `repository HEAD changed during harness execution: ${testedRepositoryHead} -> ${finalRepositoryHead}`, + ); const artifact = { - schemaVersion: 'dkg-rfc64-gate0-persistence-evidence-v1', + schemaVersion: 'dkg-rfc64-gate0-persistence-evidence-v2', gate: 'OT-RFC-64 Gate 0', - productionBaselineCommit: PRODUCTION_BASELINE_COMMIT, + productionBaselineCommit: testedRepositoryHead, invocation: 'pnpm test:gate0:rfc64-persistence-lifecycle', + repository: { + testedHeadCommit: testedRepositoryHead, + trackedSourceCleanBeforeSpawn: true, + trackedSourceCleanAfterProcesses: true, + }, runtimeBoundary: { agentClass: 'DKGAgent', processModel: 'three independent production agent processes', @@ -400,11 +399,17 @@ try { ownerOnlyFileModes: process.platform === 'win32' ? 'not-applicable' : true, publicRuntimeDebugEndpointAdded: false, }, - passed: true, + harnessChecksPassed: true, + gateEvaluation: { + status: 'not-evaluated', + reason: 'final RFC-64 integration has not been assembled', + }, }; - mkdirSync(dirname(artifactPath), { recursive: true }); - writeFileSync(artifactPath, stableJson(artifact), { mode: 0o600 }); - log(`PASS: deterministic evidence written to ${artifactPath}`); + const publication = atomicWriteStableJson(artifactPath, artifact); + log(`harness checks complete; evidence written to ${artifactPath}`); + log(`artifact publication durability: ${publication.durability}`); + log(`artifact SHA-256: ${publication.sha256}`); + log('Gate 0 remains not evaluated until the final RFC-64 integration is assembled'); } finally { for (const child of active) { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); diff --git a/package.json b/package.json index da8600590a..b68074858f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "test:e2e:agent-provenance": "pnpm --filter @origintrail-official/dkg-publisher exec vitest run test/agent-provenance-e2e.test.ts", "test:devnet:agent-provenance": "vitest run --config devnet/agent-provenance/vitest.config.ts", "test:gate0:rfc64-persistence-lifecycle": "pnpm -r --filter @origintrail-official/dkg-agent... --filter '!@origintrail-official/dkg-evm-module' run build && node --experimental-sqlite --import tsx devnet/rfc64-persistence-lifecycle/run.ts", + "test:gate0:rfc64-persistence-lifecycle:unit": "node --import tsx --test devnet/rfc64-persistence-lifecycle/evidence.test.ts", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", "test:devnet:v10-stress": "vitest run --config devnet/v10-stress/vitest.config.ts", From 1cc29638e53879fed75c539e5886da3bc13d7512 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:01:28 +0200 Subject: [PATCH 095/292] test(agent): verify RFC-64 Gate 0 evidence --- devnet/rfc64-persistence-lifecycle/README.md | 41 +- devnet/rfc64-persistence-lifecycle/run.ts | 32 +- .../verifier.test.ts | 314 ++++++++++ .../rfc64-persistence-lifecycle/verifier.ts | 574 ++++++++++++++++++ devnet/rfc64-persistence-lifecycle/verify.ts | 79 +++ package.json | 6 +- 6 files changed, 1033 insertions(+), 13 deletions(-) create mode 100644 devnet/rfc64-persistence-lifecycle/verifier.test.ts create mode 100644 devnet/rfc64-persistence-lifecycle/verifier.ts create mode 100644 devnet/rfc64-persistence-lifecycle/verify.ts diff --git a/devnet/rfc64-persistence-lifecycle/README.md b/devnet/rfc64-persistence-lifecycle/README.md index d682f3caa1..828cef1335 100644 --- a/devnet/rfc64-persistence-lifecycle/README.md +++ b/devnet/rfc64-persistence-lifecycle/README.md @@ -27,6 +27,18 @@ Run from the repository root: pnpm test:gate0:rfc64-persistence-lifecycle ``` +The root command has two distinct steps. `:generate` builds and exercises the +production lifecycle, writing raw evidence with `gateEvaluation.status` set to +`not-evaluated`. `:verify` then reads that artifact as a closed schema, pins its +source commit to the current clean tracked `HEAD`, evaluates every required +runtime/lifecycle/filesystem invariant, and prints `PASS` only after successful +verification. Either step can be invoked separately: + +```sh +pnpm test:gate0:rfc64-persistence-lifecycle:generate +pnpm test:gate0:rfc64-persistence-lifecycle:verify +``` + Focused evidence-encoder, repository-state, and artifact-publication tests run with: ```sh @@ -39,11 +51,18 @@ The default artifact is: devnet/rfc64-persistence-lifecycle/artifacts/gate0-result.json ``` -Set `DKG_RFC64_GATE0_ARTIFACT` to override that path. The gate deliberately -does not add an HTTP/API route. Its only control channel is the parent/child -stdio and process-signal boundary. A shared `scripts/devnet.sh` cluster is not -required: RFC-64 persistence is acquired before networking, and isolating this -gate avoids mutating a developer's existing devnet. +The verifier writes a separate deterministic verdict artifact: + +```text +devnet/rfc64-persistence-lifecycle/artifacts/gate0-verdict.json +``` + +Set `DKG_RFC64_GATE0_ARTIFACT` or `DKG_RFC64_GATE0_VERDICT_ARTIFACT` to override +those paths. The gate deliberately does not add an HTTP/API route. Its only +control channel is the parent/child stdio and process-signal boundary. A shared +`scripts/devnet.sh` cluster is not required: RFC-64 persistence is acquired +before networking, and isolating this gate avoids mutating a developer's existing +devnet. Artifact publication uses an exclusive `0600` sibling temporary file, file `fsync`, atomic rename, parent-directory topology revalidation, and a parent @@ -52,6 +71,12 @@ topology validation while reporting that directory `fsync` is unavailable. A symlink target or symlink parent is rejected. The runner logs the SHA-256 of the final bytes, allowing two runs at the same clean commit to prove byte identity. -This harness can prove that its bounded lifecycle checks completed. It does not -declare OT-RFC-64 Gate 0 passed: final Gate 0 evaluation remains unavailable until -the final RFC-64 integration has been assembled and tested at its own exact HEAD. +The verifier rejects missing or extra fields, non-canonical/lossy JSON, a dirty or +mismatched source commit, malformed lifecycle evidence, changed file lists or +digests, unsuccessful exits, non-busy lease probes, and incomplete POSIX mode or +Windows protected-owner ACL-policy evidence. Mutation tests delete, alter, and +extend every required schema location. + +A PASS on this standalone harness branch covers only the bounded evidence verifier. +It is not a formal OT-RFC-64 Gate 0 result. Formal evaluation requires running this +exact generator-plus-verifier command on the assembled integration commit. diff --git a/devnet/rfc64-persistence-lifecycle/run.ts b/devnet/rfc64-persistence-lifecycle/run.ts index bb317beff9..98bac5bed9 100644 --- a/devnet/rfc64-persistence-lifecycle/run.ts +++ b/devnet/rfc64-persistence-lifecycle/run.ts @@ -342,7 +342,7 @@ try { `repository HEAD changed during harness execution: ${testedRepositoryHead} -> ${finalRepositoryHead}`, ); const artifact = { - schemaVersion: 'dkg-rfc64-gate0-persistence-evidence-v2', + schemaVersion: 'dkg-rfc64-gate0-persistence-evidence-v3', gate: 'OT-RFC-64 Gate 0', productionBaselineCommit: testedRepositoryHead, invocation: 'pnpm test:gate0:rfc64-persistence-lifecycle', @@ -351,6 +351,20 @@ try { trackedSourceCleanBeforeSpawn: true, trackedSourceCleanAfterProcesses: true, }, + filesystemSecurity: process.platform === 'win32' + ? { + platform: 'win32', + policy: 'windows-protected-current-user-owner-only-full-control-v1', + productionPolicyChecksPassed: true, + inheritedAccessRulesAllowed: false, + otherSidAllowRulesAllowed: false, + } + : { + platform: process.platform, + policy: 'posix-owner-only-mode-v1', + requiredFileMode: '0600', + requiredDirectoryMode: '0700', + }, runtimeBoundary: { agentClass: 'DKGAgent', processModel: 'three independent production agent processes', @@ -365,12 +379,20 @@ try { }, phases: { initialRunningOwner: { + agentStarted: initial.ready.agentStarted, inventoryOperational: initial.ready.inventoryOperational, + persistenceClosed: initial.ready.persistenceClosed, + staged: initial.ready.staged, + verifiedRead: initial.ready.verifiedRead, namespaceDurability: initial.ready.namespaceDurability, contender: initialProbe, snapshot: initialSnapshot, }, gracefulRestart: { + agentStarted: gracefulRestart.ready.agentStarted, + inventoryOperational: gracefulRestart.ready.inventoryOperational, + persistenceClosed: gracefulRestart.ready.persistenceClosed, + staged: gracefulRestart.ready.staged, priorExit: initialExit, verifiedRead: gracefulRestart.ready.verifiedRead, contender: gracefulProbe, @@ -378,6 +400,10 @@ try { snapshot: gracefulSnapshot, }, operatingSystemLeaseRecovery: { + agentStarted: recovered.ready.agentStarted, + inventoryOperational: recovered.ready.inventoryOperational, + persistenceClosed: recovered.ready.persistenceClosed, + staged: recovered.ready.staged, uncleanExit: crashExit, verifiedRead: recovered.ready.verifiedRead, contender: recoveredProbe, @@ -402,14 +428,14 @@ try { harnessChecksPassed: true, gateEvaluation: { status: 'not-evaluated', - reason: 'final RFC-64 integration has not been assembled', + reason: 'raw lifecycle evidence requires separate fail-closed verification', }, }; const publication = atomicWriteStableJson(artifactPath, artifact); log(`harness checks complete; evidence written to ${artifactPath}`); log(`artifact publication durability: ${publication.durability}`); log(`artifact SHA-256: ${publication.sha256}`); - log('Gate 0 remains not evaluated until the final RFC-64 integration is assembled'); + log('raw Gate 0 evidence remains not evaluated until the separate verifier accepts it'); } finally { for (const child of active) { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); diff --git a/devnet/rfc64-persistence-lifecycle/verifier.test.ts b/devnet/rfc64-persistence-lifecycle/verifier.test.ts new file mode 100644 index 0000000000..8fdb226c9c --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/verifier.test.ts @@ -0,0 +1,314 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { stableJson } from './evidence.js'; +import { + GATE0_RAW_SCHEMA_VERSION, + verifyGate0ArtifactBytes, +} from './verifier.js'; + +const HEAD = 'a'.repeat(40); +const OBJECT_DIGEST = `0x${'11'.repeat(32)}`; +const SIGNATURE_DIGEST = `0x${'22'.repeat(32)}`; +const OBJECT_SHA256 = `0x${'33'.repeat(32)}`; +const SIGNATURE_SHA256 = `0x${'44'.repeat(32)}`; + +type JsonPath = readonly (string | number)[]; + +test('verifier accepts the exact closed POSIX and Windows evidence schemas', () => { + for (const platform of ['linux', 'win32'] as const) { + const verified = verifyGate0ArtifactBytes( + Buffer.from(stableJson(validArtifact(platform))), + HEAD, + platform, + ); + assert.equal(verified.sourceCommit, HEAD); + assert.match(verified.rawArtifactSha256, /^0x[0-9a-f]{64}$/u); + } +}); + +test('every required field, leaf value, object closure, and array bound fails closed', () => { + for (const platform of ['linux', 'win32'] as const) { + const valid = validArtifact(platform); + const locations = collectLocations(valid); + assert.ok(locations.properties.length > 100); + assert.ok(locations.leaves.length > 100); + assert.ok(locations.objects.length > 30); + assert.ok(locations.arrays.length >= 6); + + for (const path of locations.properties) { + const mutation = structuredClone(valid); + const parent = valueAt(mutation, path.slice(0, -1)) as Record; + delete parent[path.at(-1) as string]; + assertRejected(mutation, platform, `deleted ${formatPath(path)}`); + } + for (const path of locations.leaves) { + const mutation = structuredClone(valid); + const parent = valueAt(mutation, path.slice(0, -1)) as Record; + const key = path.at(-1) as string | number; + parent[key] = mutatedLeaf(parent[key]); + assertRejected(mutation, platform, `mutated ${formatPath(path)}`); + } + for (const path of locations.objects) { + const mutation = structuredClone(valid); + const record = valueAt(mutation, path) as Record; + record.unexpectedVerifierMutation = true; + assertRejected(mutation, platform, `extended ${formatPath(path)}`); + } + for (const path of locations.arrays) { + const mutation = structuredClone(valid); + const array = valueAt(mutation, path) as unknown[]; + array.push(null); + assertRejected(mutation, platform, `extended ${formatPath(path)}`); + } + } +}); + +test('verifier rejects malformed, non-canonical, duplicate, and lossy JSON encodings', () => { + const valid = validArtifact('linux'); + const canonical = stableJson(valid); + const duplicate = canonical.replace( + /^\{\n/u, + '{\n "gate": "OT-RFC-64 Gate 0",\n', + ); + const negativeZero = canonical.replace('"byteLength": 292', '"byteLength": -0'); + for (const bytes of [ + Buffer.from('{'), + Buffer.from(JSON.stringify(valid)), + Buffer.from(`${canonical} `), + Buffer.from(duplicate), + Buffer.from(negativeZero), + Buffer.from([0xff]), + ]) { + assert.throws(() => verifyGate0ArtifactBytes(bytes, HEAD, 'linux')); + } +}); + +test('verifier rejects reordered or phase-divergent exact file evidence', () => { + const reordered = validArtifact('linux'); + reordered.phases.initialRunningOwner.snapshot.files.reverse(); + assertRejected(reordered, 'linux', 'reordered initial files'); + + const reorderedDirectories = validArtifact('linux'); + reorderedDirectories.phases.gracefulRestart.snapshot.namespaceDirectoryModes.reverse(); + assertRejected(reorderedDirectories, 'linux', 'reordered directories'); + + const changedLength = validArtifact('linux'); + changedLength.phases.operatingSystemLeaseRecovery.snapshot.files[0].byteLength += 1; + assertRejected(changedLength, 'linux', 'phase-divergent byte length'); +}); + +function assertRejected( + artifact: ReturnType, + platform: NodeJS.Platform, + label: string, +): void { + assert.throws( + () => verifyGate0ArtifactBytes(Buffer.from(stableJson(artifact)), HEAD, platform), + undefined, + `verifier accepted mutation: ${label}`, + ); +} + +function validArtifact(platform: 'linux' | 'win32') { + const posix = platform !== 'win32'; + const snapshot = () => ({ + counts: { objects: 1, signatures: 1 }, + inventoryFileMode: posix ? '0600' : null, + leaseFileMode: posix ? '0600' : null, + namespaceDirectoryModes: directoryPaths().map((relativePath) => ({ + relativePath, + mode: posix ? '0700' : null, + })), + files: [ + { + kind: 'object', + relativePath: objectPath(), + byteLength: 292, + sha256: OBJECT_SHA256, + mode: posix ? '0600' : null, + }, + { + kind: 'signature', + relativePath: signaturePath(), + byteLength: 326, + sha256: SIGNATURE_SHA256, + mode: posix ? '0600' : null, + }, + ], + }); + const contender = () => ({ + event: 'lease-probe', + acquired: false, + errorCode: 'database-busy', + }); + return { + schemaVersion: GATE0_RAW_SCHEMA_VERSION, + gate: 'OT-RFC-64 Gate 0', + productionBaselineCommit: HEAD, + invocation: 'pnpm test:gate0:rfc64-persistence-lifecycle', + repository: { + testedHeadCommit: HEAD, + trackedSourceCleanBeforeSpawn: true, + trackedSourceCleanAfterProcesses: true, + }, + filesystemSecurity: posix + ? { + platform, + policy: 'posix-owner-only-mode-v1', + requiredFileMode: '0600', + requiredDirectoryMode: '0700', + } + : { + platform: 'win32', + policy: 'windows-protected-current-user-owner-only-full-control-v1', + productionPolicyChecksPassed: true, + inheritedAccessRulesAllowed: false, + otherSidAllowRulesAllowed: false, + }, + runtimeBoundary: { + agentClass: 'DKGAgent', + processModel: 'three independent production agent processes', + controlChannel: 'stdio and POSIX process signals only', + publicDebugEndpointAdded: false, + }, + fixture: { + objectDigest: OBJECT_DIGEST, + signatureVariantDigest: SIGNATURE_DIGEST, + objectFileSha256: OBJECT_SHA256, + signatureFileSha256: SIGNATURE_SHA256, + }, + phases: { + initialRunningOwner: { + agentStarted: true, + inventoryOperational: true, + persistenceClosed: false, + staged: true, + verifiedRead: true, + namespaceDurability: posix + ? 'posix-hardlink-no-replace-directory-fsync-v1' + : 'windows-file-flush-hardlink-no-replace-v1', + contender: contender(), + snapshot: snapshot(), + }, + gracefulRestart: { + agentStarted: true, + inventoryOperational: true, + persistenceClosed: false, + staged: false, + priorExit: { code: 0, signal: null }, + verifiedRead: true, + contender: contender(), + exactContentFilesEqual: true, + snapshot: snapshot(), + }, + operatingSystemLeaseRecovery: { + agentStarted: true, + inventoryOperational: true, + persistenceClosed: false, + staged: false, + uncleanExit: { code: null, signal: 'SIGKILL' }, + verifiedRead: true, + contender: contender(), + exactContentFilesEqual: true, + finalGracefulExit: { code: 0, signal: null }, + snapshot: snapshot(), + }, + }, + checks: { + productionAgentStarted: true, + inventoryOperationalWhileRunning: true, + competingLeaseRejectedWhileRunning: true, + gracefulRestartSucceeded: true, + operatingSystemLeaseRecoveredAfterSigkill: true, + durableControlObjectBytesPreserved: true, + storedEnvelopeSignatureReverifiedOnEveryStart: true, + exactObjectCount: 1, + exactSignatureCount: 1, + ownerOnlyFileModes: posix ? true : 'not-applicable', + publicRuntimeDebugEndpointAdded: false, + }, + harnessChecksPassed: true, + gateEvaluation: { + status: 'not-evaluated', + reason: 'raw lifecycle evidence requires separate fail-closed verification', + }, + }; +} + +function objectPath(): string { + const objectHex = OBJECT_DIGEST.slice(2); + return `rfc64-sync/control-objects-v1/objects/${objectHex.slice(0, 2)}/${objectHex}.jcs`; +} + +function signaturePath(): string { + const objectHex = OBJECT_DIGEST.slice(2); + return `rfc64-sync/control-objects-v1/signatures/${objectHex.slice(0, 2)}/${objectHex}/${SIGNATURE_DIGEST.slice(2)}.jcs`; +} + +function directoryPaths(): string[] { + const objectHex = OBJECT_DIGEST.slice(2); + const prefix = objectHex.slice(0, 2); + return [ + 'rfc64-sync', + 'rfc64-sync/control-objects-v1', + 'rfc64-sync/control-objects-v1/objects', + `rfc64-sync/control-objects-v1/objects/${prefix}`, + 'rfc64-sync/control-objects-v1/signatures', + `rfc64-sync/control-objects-v1/signatures/${prefix}`, + `rfc64-sync/control-objects-v1/signatures/${prefix}/${objectHex}`, + ]; +} + +function mutatedLeaf(value: unknown): unknown { + if (value === null) return 'mutated-null'; + if (typeof value === 'boolean') return !value; + if (typeof value === 'number') return value + 1; + if (typeof value === 'string') return `${value}-mutated`; + throw new Error(`Unexpected non-leaf mutation value: ${String(value)}`); +} + +function collectLocations(root: unknown): { + properties: JsonPath[]; + leaves: JsonPath[]; + objects: JsonPath[]; + arrays: JsonPath[]; +} { + const result = { + properties: [] as JsonPath[], + leaves: [] as JsonPath[], + objects: [] as JsonPath[], + arrays: [] as JsonPath[], + }; + const visit = (value: unknown, path: JsonPath): void => { + if (Array.isArray(value)) { + result.arrays.push(path); + value.forEach((nested, index) => visit(nested, [...path, index])); + return; + } + if (value !== null && typeof value === 'object') { + result.objects.push(path); + for (const [key, nested] of Object.entries(value)) { + const propertyPath = [...path, key]; + result.properties.push(propertyPath); + visit(nested, propertyPath); + } + return; + } + result.leaves.push(path); + }; + visit(root, []); + return result; +} + +function valueAt(root: unknown, path: JsonPath): unknown { + let current = root; + for (const key of path) { + current = (current as Record)[key]; + } + return current; +} + +function formatPath(path: JsonPath): string { + return path.length === 0 ? '$' : `$.${path.join('.')}`; +} diff --git a/devnet/rfc64-persistence-lifecycle/verifier.ts b/devnet/rfc64-persistence-lifecycle/verifier.ts new file mode 100644 index 0000000000..85ea9fdb1c --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/verifier.ts @@ -0,0 +1,574 @@ +import { createHash } from 'node:crypto'; + +import { stableJson } from './evidence.js'; + +export const GATE0_RAW_SCHEMA_VERSION = 'dkg-rfc64-gate0-persistence-evidence-v3'; +export const GATE0_VERDICT_SCHEMA_VERSION = 'dkg-rfc64-gate0-persistence-verdict-v1'; + +const SHA256_PATTERN = /^0x[0-9a-f]{64}$/u; +const COMMIT_PATTERN = /^[0-9a-f]{40,64}$/u; +const OBJECT_ROOT = 'rfc64-sync/control-objects-v1'; + +interface VerifiedSnapshot { + readonly filesCanonical: string; +} + +export interface Gate0VerifiedEvidence { + readonly sourceCommit: string; + readonly rawArtifactSha256: string; +} + +export function verifyGate0ArtifactBytes( + rawBytes: Uint8Array, + expectedHead: string, + expectedPlatform: NodeJS.Platform, +): Gate0VerifiedEvidence { + requireMatch(expectedHead, COMMIT_PATTERN, 'expected repository HEAD'); + if (rawBytes.byteLength === 0 || rawBytes.byteLength > 1_000_000) { + fail('$', 'raw artifact size is outside the closed verifier bound'); + } + let rawText: string; + try { + rawText = new TextDecoder('utf-8', { fatal: true }).decode(rawBytes); + } catch (cause) { + throw new Error('Gate 0 artifact is not valid UTF-8', { cause }); + } + let parsed: unknown; + try { + parsed = JSON.parse(rawText) as unknown; + } catch (cause) { + throw new Error('Gate 0 artifact is not valid JSON', { cause }); + } + let canonical: string; + try { + canonical = stableJson(parsed); + } catch (cause) { + throw new Error('Gate 0 artifact contains a lossy or non-plain data shape', { cause }); + } + if (canonical !== rawText) { + fail('$', 'raw artifact is not the exact canonical stable JSON encoding'); + } + + verifyClosedArtifact(parsed, expectedHead, expectedPlatform); + return { + sourceCommit: expectedHead, + rawArtifactSha256: sha256(rawBytes), + }; +} + +function verifyClosedArtifact( + value: unknown, + expectedHead: string, + expectedPlatform: NodeJS.Platform, +): void { + const artifact = closedRecord(value, '$', [ + 'checks', + 'filesystemSecurity', + 'fixture', + 'gate', + 'gateEvaluation', + 'harnessChecksPassed', + 'invocation', + 'phases', + 'productionBaselineCommit', + 'repository', + 'runtimeBoundary', + 'schemaVersion', + ]); + exact(artifact.schemaVersion, GATE0_RAW_SCHEMA_VERSION, '$.schemaVersion'); + exact(artifact.gate, 'OT-RFC-64 Gate 0', '$.gate'); + exact( + artifact.invocation, + 'pnpm test:gate0:rfc64-persistence-lifecycle', + '$.invocation', + ); + exact(artifact.productionBaselineCommit, expectedHead, '$.productionBaselineCommit'); + exact(artifact.harnessChecksPassed, true, '$.harnessChecksPassed'); + + const repository = closedRecord(artifact.repository, '$.repository', [ + 'testedHeadCommit', + 'trackedSourceCleanAfterProcesses', + 'trackedSourceCleanBeforeSpawn', + ]); + exact(repository.testedHeadCommit, expectedHead, '$.repository.testedHeadCommit'); + exact( + repository.trackedSourceCleanBeforeSpawn, + true, + '$.repository.trackedSourceCleanBeforeSpawn', + ); + exact( + repository.trackedSourceCleanAfterProcesses, + true, + '$.repository.trackedSourceCleanAfterProcesses', + ); + + const runtime = closedRecord(artifact.runtimeBoundary, '$.runtimeBoundary', [ + 'agentClass', + 'controlChannel', + 'processModel', + 'publicDebugEndpointAdded', + ]); + exact(runtime.agentClass, 'DKGAgent', '$.runtimeBoundary.agentClass'); + exact( + runtime.processModel, + 'three independent production agent processes', + '$.runtimeBoundary.processModel', + ); + exact( + runtime.controlChannel, + 'stdio and POSIX process signals only', + '$.runtimeBoundary.controlChannel', + ); + exact( + runtime.publicDebugEndpointAdded, + false, + '$.runtimeBoundary.publicDebugEndpointAdded', + ); + + const evaluation = closedRecord(artifact.gateEvaluation, '$.gateEvaluation', [ + 'reason', + 'status', + ]); + exact(evaluation.status, 'not-evaluated', '$.gateEvaluation.status'); + exact( + evaluation.reason, + 'raw lifecycle evidence requires separate fail-closed verification', + '$.gateEvaluation.reason', + ); + + verifyFilesystemSecurity(artifact.filesystemSecurity, expectedPlatform); + verifyChecks(artifact.checks, expectedPlatform); + + const fixture = closedRecord(artifact.fixture, '$.fixture', [ + 'objectDigest', + 'objectFileSha256', + 'signatureFileSha256', + 'signatureVariantDigest', + ]); + const objectDigest = digest(fixture.objectDigest, '$.fixture.objectDigest'); + const signatureVariantDigest = digest( + fixture.signatureVariantDigest, + '$.fixture.signatureVariantDigest', + ); + const objectFileSha256 = digest( + fixture.objectFileSha256, + '$.fixture.objectFileSha256', + ); + const signatureFileSha256 = digest( + fixture.signatureFileSha256, + '$.fixture.signatureFileSha256', + ); + if (objectDigest === signatureVariantDigest || objectFileSha256 === signatureFileSha256) { + fail('$.fixture', 'object and signature identities must be distinct'); + } + + const phases = closedRecord(artifact.phases, '$.phases', [ + 'gracefulRestart', + 'initialRunningOwner', + 'operatingSystemLeaseRecovery', + ]); + const expectedFiles = expectedFileEvidence( + objectDigest, + signatureVariantDigest, + objectFileSha256, + signatureFileSha256, + ); + + const initial = closedRecord(phases.initialRunningOwner, '$.phases.initialRunningOwner', [ + 'agentStarted', + 'contender', + 'inventoryOperational', + 'namespaceDurability', + 'persistenceClosed', + 'snapshot', + 'staged', + 'verifiedRead', + ]); + exact(initial.agentStarted, true, '$.phases.initialRunningOwner.agentStarted'); + exact( + initial.inventoryOperational, + true, + '$.phases.initialRunningOwner.inventoryOperational', + ); + exact(initial.persistenceClosed, false, '$.phases.initialRunningOwner.persistenceClosed'); + exact(initial.staged, true, '$.phases.initialRunningOwner.staged'); + exact(initial.verifiedRead, true, '$.phases.initialRunningOwner.verifiedRead'); + exact( + initial.namespaceDurability, + expectedPlatform === 'win32' + ? 'windows-file-flush-hardlink-no-replace-v1' + : 'posix-hardlink-no-replace-directory-fsync-v1', + '$.phases.initialRunningOwner.namespaceDurability', + ); + verifyLeaseBusy(initial.contender, '$.phases.initialRunningOwner.contender'); + const initialSnapshot = verifySnapshot( + initial.snapshot, + '$.phases.initialRunningOwner.snapshot', + expectedFiles, + objectDigest, + expectedPlatform, + ); + + const graceful = closedRecord(phases.gracefulRestart, '$.phases.gracefulRestart', [ + 'agentStarted', + 'contender', + 'exactContentFilesEqual', + 'inventoryOperational', + 'persistenceClosed', + 'priorExit', + 'snapshot', + 'staged', + 'verifiedRead', + ]); + exact(graceful.agentStarted, true, '$.phases.gracefulRestart.agentStarted'); + exact( + graceful.inventoryOperational, + true, + '$.phases.gracefulRestart.inventoryOperational', + ); + exact(graceful.persistenceClosed, false, '$.phases.gracefulRestart.persistenceClosed'); + exact(graceful.staged, false, '$.phases.gracefulRestart.staged'); + exact(graceful.verifiedRead, true, '$.phases.gracefulRestart.verifiedRead'); + exact( + graceful.exactContentFilesEqual, + true, + '$.phases.gracefulRestart.exactContentFilesEqual', + ); + verifyLeaseBusy(graceful.contender, '$.phases.gracefulRestart.contender'); + verifyExit(graceful.priorExit, '$.phases.gracefulRestart.priorExit', 0, null); + const gracefulSnapshot = verifySnapshot( + graceful.snapshot, + '$.phases.gracefulRestart.snapshot', + expectedFiles, + objectDigest, + expectedPlatform, + ); + + const recovery = closedRecord( + phases.operatingSystemLeaseRecovery, + '$.phases.operatingSystemLeaseRecovery', + [ + 'agentStarted', + 'contender', + 'exactContentFilesEqual', + 'finalGracefulExit', + 'inventoryOperational', + 'persistenceClosed', + 'snapshot', + 'staged', + 'uncleanExit', + 'verifiedRead', + ], + ); + exact(recovery.agentStarted, true, '$.phases.operatingSystemLeaseRecovery.agentStarted'); + exact( + recovery.inventoryOperational, + true, + '$.phases.operatingSystemLeaseRecovery.inventoryOperational', + ); + exact( + recovery.persistenceClosed, + false, + '$.phases.operatingSystemLeaseRecovery.persistenceClosed', + ); + exact(recovery.staged, false, '$.phases.operatingSystemLeaseRecovery.staged'); + exact(recovery.verifiedRead, true, '$.phases.operatingSystemLeaseRecovery.verifiedRead'); + exact( + recovery.exactContentFilesEqual, + true, + '$.phases.operatingSystemLeaseRecovery.exactContentFilesEqual', + ); + verifyLeaseBusy( + recovery.contender, + '$.phases.operatingSystemLeaseRecovery.contender', + ); + verifyExit( + recovery.uncleanExit, + '$.phases.operatingSystemLeaseRecovery.uncleanExit', + null, + 'SIGKILL', + ); + verifyExit( + recovery.finalGracefulExit, + '$.phases.operatingSystemLeaseRecovery.finalGracefulExit', + 0, + null, + ); + const recoverySnapshot = verifySnapshot( + recovery.snapshot, + '$.phases.operatingSystemLeaseRecovery.snapshot', + expectedFiles, + objectDigest, + expectedPlatform, + ); + + if ( + gracefulSnapshot.filesCanonical !== initialSnapshot.filesCanonical + || recoverySnapshot.filesCanonical !== initialSnapshot.filesCanonical + ) { + fail('$.phases', 'durable file evidence changed across lifecycle phases'); + } +} + +function verifyFilesystemSecurity(value: unknown, platform: NodeJS.Platform): void { + if (platform === 'win32') { + const evidence = closedRecord(value, '$.filesystemSecurity', [ + 'inheritedAccessRulesAllowed', + 'otherSidAllowRulesAllowed', + 'platform', + 'policy', + 'productionPolicyChecksPassed', + ]); + exact(evidence.platform, 'win32', '$.filesystemSecurity.platform'); + exact( + evidence.policy, + 'windows-protected-current-user-owner-only-full-control-v1', + '$.filesystemSecurity.policy', + ); + exact( + evidence.productionPolicyChecksPassed, + true, + '$.filesystemSecurity.productionPolicyChecksPassed', + ); + exact( + evidence.inheritedAccessRulesAllowed, + false, + '$.filesystemSecurity.inheritedAccessRulesAllowed', + ); + exact( + evidence.otherSidAllowRulesAllowed, + false, + '$.filesystemSecurity.otherSidAllowRulesAllowed', + ); + return; + } + const evidence = closedRecord(value, '$.filesystemSecurity', [ + 'platform', + 'policy', + 'requiredDirectoryMode', + 'requiredFileMode', + ]); + exact(evidence.platform, platform, '$.filesystemSecurity.platform'); + exact(evidence.policy, 'posix-owner-only-mode-v1', '$.filesystemSecurity.policy'); + exact(evidence.requiredFileMode, '0600', '$.filesystemSecurity.requiredFileMode'); + exact( + evidence.requiredDirectoryMode, + '0700', + '$.filesystemSecurity.requiredDirectoryMode', + ); +} + +function verifyChecks(value: unknown, platform: NodeJS.Platform): void { + const checks = closedRecord(value, '$.checks', [ + 'competingLeaseRejectedWhileRunning', + 'durableControlObjectBytesPreserved', + 'exactObjectCount', + 'exactSignatureCount', + 'gracefulRestartSucceeded', + 'inventoryOperationalWhileRunning', + 'operatingSystemLeaseRecoveredAfterSigkill', + 'ownerOnlyFileModes', + 'productionAgentStarted', + 'publicRuntimeDebugEndpointAdded', + 'storedEnvelopeSignatureReverifiedOnEveryStart', + ]); + for (const key of [ + 'competingLeaseRejectedWhileRunning', + 'durableControlObjectBytesPreserved', + 'gracefulRestartSucceeded', + 'inventoryOperationalWhileRunning', + 'operatingSystemLeaseRecoveredAfterSigkill', + 'productionAgentStarted', + 'storedEnvelopeSignatureReverifiedOnEveryStart', + ]) { + exact(checks[key], true, `$.checks.${key}`); + } + exact(checks.exactObjectCount, 1, '$.checks.exactObjectCount'); + exact(checks.exactSignatureCount, 1, '$.checks.exactSignatureCount'); + exact( + checks.ownerOnlyFileModes, + platform === 'win32' ? 'not-applicable' : true, + '$.checks.ownerOnlyFileModes', + ); + exact( + checks.publicRuntimeDebugEndpointAdded, + false, + '$.checks.publicRuntimeDebugEndpointAdded', + ); +} + +function verifySnapshot( + value: unknown, + path: string, + expectedFiles: readonly Record[], + objectDigest: string, + platform: NodeJS.Platform, +): VerifiedSnapshot { + const snapshot = closedRecord(value, path, [ + 'counts', + 'files', + 'inventoryFileMode', + 'leaseFileMode', + 'namespaceDirectoryModes', + ]); + const counts = closedRecord(snapshot.counts, `${path}.counts`, ['objects', 'signatures']); + exact(counts.objects, 1, `${path}.counts.objects`); + exact(counts.signatures, 1, `${path}.counts.signatures`); + + if (!Array.isArray(snapshot.files) || snapshot.files.length !== 2) { + fail(`${path}.files`, 'must contain exactly one object and one signature file'); + } + for (let index = 0; index < expectedFiles.length; index += 1) { + const actual = closedRecord(snapshot.files[index], `${path}.files[${index}]`, [ + 'byteLength', + 'kind', + 'mode', + 'relativePath', + 'sha256', + ]); + const expected = expectedFiles[index]; + exact(actual.kind, expected.kind, `${path}.files[${index}].kind`); + exact( + actual.relativePath, + expected.relativePath, + `${path}.files[${index}].relativePath`, + ); + exact(actual.sha256, expected.sha256, `${path}.files[${index}].sha256`); + if (!Number.isSafeInteger(actual.byteLength) || (actual.byteLength as number) <= 0) { + fail(`${path}.files[${index}].byteLength`, 'must be a positive safe integer'); + } + exact( + actual.mode, + platform === 'win32' ? null : '0600', + `${path}.files[${index}].mode`, + ); + } + + exact(snapshot.inventoryFileMode, platform === 'win32' ? null : '0600', `${path}.inventoryFileMode`); + exact(snapshot.leaseFileMode, platform === 'win32' ? null : '0600', `${path}.leaseFileMode`); + const objectHex = objectDigest.slice(2); + const prefix = objectHex.slice(0, 2); + const expectedDirectories = [ + 'rfc64-sync', + OBJECT_ROOT, + `${OBJECT_ROOT}/objects`, + `${OBJECT_ROOT}/objects/${prefix}`, + `${OBJECT_ROOT}/signatures`, + `${OBJECT_ROOT}/signatures/${prefix}`, + `${OBJECT_ROOT}/signatures/${prefix}/${objectHex}`, + ]; + if ( + !Array.isArray(snapshot.namespaceDirectoryModes) + || snapshot.namespaceDirectoryModes.length !== expectedDirectories.length + ) { + fail(`${path}.namespaceDirectoryModes`, 'contains an unexpected directory list'); + } + for (let index = 0; index < expectedDirectories.length; index += 1) { + const directory = closedRecord( + snapshot.namespaceDirectoryModes[index], + `${path}.namespaceDirectoryModes[${index}]`, + ['mode', 'relativePath'], + ); + exact( + directory.relativePath, + expectedDirectories[index], + `${path}.namespaceDirectoryModes[${index}].relativePath`, + ); + exact( + directory.mode, + platform === 'win32' ? null : '0700', + `${path}.namespaceDirectoryModes[${index}].mode`, + ); + } + return { filesCanonical: stableJson(snapshot.files) }; +} + +function expectedFileEvidence( + objectDigest: string, + signatureVariantDigest: string, + objectFileSha256: string, + signatureFileSha256: string, +): readonly Record[] { + const objectHex = objectDigest.slice(2); + const signatureHex = signatureVariantDigest.slice(2); + const prefix = objectHex.slice(0, 2); + return [ + { + kind: 'object', + relativePath: `${OBJECT_ROOT}/objects/${prefix}/${objectHex}.jcs`, + sha256: objectFileSha256, + }, + { + kind: 'signature', + relativePath: `${OBJECT_ROOT}/signatures/${prefix}/${objectHex}/${signatureHex}.jcs`, + sha256: signatureFileSha256, + }, + ]; +} + +function verifyLeaseBusy(value: unknown, path: string): void { + const contender = closedRecord(value, path, ['acquired', 'errorCode', 'event']); + exact(contender.event, 'lease-probe', `${path}.event`); + exact(contender.acquired, false, `${path}.acquired`); + exact(contender.errorCode, 'database-busy', `${path}.errorCode`); +} + +function verifyExit( + value: unknown, + path: string, + expectedCode: number | null, + expectedSignal: string | null, +): void { + const exit = closedRecord(value, path, ['code', 'signal']); + exact(exit.code, expectedCode, `${path}.code`); + exact(exit.signal, expectedSignal, `${path}.signal`); +} + +function closedRecord( + value: unknown, + path: string, + expectedKeys: readonly string[], +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'must be a closed plain object'); + } + const record = value as Record; + const actualKeys = Object.keys(record).sort(compareCodeUnits); + const sortedExpected = [...expectedKeys].sort(compareCodeUnits); + if ( + actualKeys.length !== sortedExpected.length + || actualKeys.some((key, index) => key !== sortedExpected[index]) + ) { + fail(path, `has unexpected fields: ${JSON.stringify(actualKeys)}`); + } + return record; +} + +function digest(value: unknown, path: string): string { + requireMatch(value, SHA256_PATTERN, path); + return value as string; +} + +function requireMatch(value: unknown, pattern: RegExp, path: string): void { + if (typeof value !== 'string' || !pattern.test(value)) { + fail(path, 'has an invalid exact encoding'); + } +} + +function exact(actual: unknown, expected: unknown, path: string): void { + if (!Object.is(actual, expected)) { + fail(path, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function sha256(bytes: Uint8Array): string { + return `0x${createHash('sha256').update(bytes).digest('hex')}`; +} + +function fail(path: string, message: string): never { + throw new Error(`Gate 0 artifact verification failed at ${path}: ${message}`); +} diff --git a/devnet/rfc64-persistence-lifecycle/verify.ts b/devnet/rfc64-persistence-lifecycle/verify.ts new file mode 100644 index 0000000000..3d4d6a33ef --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/verify.ts @@ -0,0 +1,79 @@ +import { lstatSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; + +import { + atomicWriteStableJson, + readCleanRepositoryHead, +} from './evidence.js'; +import { + GATE0_RAW_SCHEMA_VERSION, + GATE0_VERDICT_SCHEMA_VERSION, + verifyGate0ArtifactBytes, +} from './verifier.js'; + +const REPO_ROOT = resolve(import.meta.dirname, '../..'); +const DEFAULT_RAW_ARTIFACT = join(import.meta.dirname, 'artifacts/gate0-result.json'); +const DEFAULT_VERDICT_ARTIFACT = join(import.meta.dirname, 'artifacts/gate0-verdict.json'); + +const rawArtifactPath = resolve( + process.env.DKG_RFC64_GATE0_ARTIFACT ?? DEFAULT_RAW_ARTIFACT, +); +const verdictArtifactPath = resolve( + process.env.DKG_RFC64_GATE0_VERDICT_ARTIFACT ?? DEFAULT_VERDICT_ARTIFACT, +); + +const sourceCommit = readCleanRepositoryHead(REPO_ROOT); +const rawBytes = readStableRegularFile(rawArtifactPath); +const verified = verifyGate0ArtifactBytes(rawBytes, sourceCommit, process.platform); +const finalSourceCommit = readCleanRepositoryHead(REPO_ROOT); +if (finalSourceCommit !== sourceCommit) { + throw new Error( + `Repository HEAD changed during Gate 0 verification: ${sourceCommit} -> ${finalSourceCommit}`, + ); +} + +const verdict = { + schemaVersion: GATE0_VERDICT_SCHEMA_VERSION, + verdict: 'pass', + scope: 'OT-RFC-64 Gate 0 lifecycle evidence verifier', + sourceCommit, + rawArtifact: { + schemaVersion: GATE0_RAW_SCHEMA_VERSION, + sha256: verified.rawArtifactSha256, + }, +}; +const verdictPublication = atomicWriteStableJson(verdictArtifactPath, verdict); +process.stdout.write(`[rfc64-gate0-verifier] raw artifact SHA-256: ${verified.rawArtifactSha256}\n`); +process.stdout.write(`[rfc64-gate0-verifier] verdict artifact: ${verdictArtifactPath}\n`); +process.stdout.write(`[rfc64-gate0-verifier] verdict SHA-256: ${verdictPublication.sha256}\n`); +process.stdout.write('PASS: Gate 0 lifecycle evidence verified\n'); + +function readStableRegularFile(path: string): Buffer { + const parentTopology = lstatSync(dirname(path)); + if (parentTopology.isSymbolicLink() || !parentTopology.isDirectory()) { + throw new Error(`Gate 0 artifact parent must be a non-symlink directory: ${dirname(path)}`); + } + const beforeTopology = lstatSync(path); + if (beforeTopology.isSymbolicLink() || !beforeTopology.isFile()) { + throw new Error(`Gate 0 artifact must be a non-symlink regular file: ${path}`); + } + if (process.platform !== 'win32' && (beforeTopology.mode & 0o777) !== 0o600) { + throw new Error(`Gate 0 artifact must have mode 0600: ${path}`); + } + const beforeIdentity = statSync(path, { bigint: true }); + const bytes = readFileSync(path); + const afterTopology = lstatSync(path); + const afterIdentity = statSync(path, { bigint: true }); + if ( + afterTopology.isSymbolicLink() + || !afterTopology.isFile() + || beforeIdentity.dev !== afterIdentity.dev + || beforeIdentity.ino !== afterIdentity.ino + || beforeIdentity.size !== afterIdentity.size + || BigInt(bytes.byteLength) !== afterIdentity.size + ) { + throw new Error(`Gate 0 artifact topology changed while it was read: ${path}`); + } + return bytes; +} diff --git a/package.json b/package.json index b68074858f..69ea3d7da7 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,10 @@ "test:e2e:ui": "pnpm --filter @origintrail-official/dkg-node-ui test:e2e", "test:e2e:agent-provenance": "pnpm --filter @origintrail-official/dkg-publisher exec vitest run test/agent-provenance-e2e.test.ts", "test:devnet:agent-provenance": "vitest run --config devnet/agent-provenance/vitest.config.ts", - "test:gate0:rfc64-persistence-lifecycle": "pnpm -r --filter @origintrail-official/dkg-agent... --filter '!@origintrail-official/dkg-evm-module' run build && node --experimental-sqlite --import tsx devnet/rfc64-persistence-lifecycle/run.ts", - "test:gate0:rfc64-persistence-lifecycle:unit": "node --import tsx --test devnet/rfc64-persistence-lifecycle/evidence.test.ts", + "test:gate0:rfc64-persistence-lifecycle": "pnpm run test:gate0:rfc64-persistence-lifecycle:generate && pnpm run test:gate0:rfc64-persistence-lifecycle:verify", + "test:gate0:rfc64-persistence-lifecycle:generate": "pnpm -r --filter @origintrail-official/dkg-agent... --filter '!@origintrail-official/dkg-evm-module' run build && node --experimental-sqlite --import tsx devnet/rfc64-persistence-lifecycle/run.ts", + "test:gate0:rfc64-persistence-lifecycle:verify": "node --import tsx devnet/rfc64-persistence-lifecycle/verify.ts", + "test:gate0:rfc64-persistence-lifecycle:unit": "node --import tsx --test devnet/rfc64-persistence-lifecycle/evidence.test.ts devnet/rfc64-persistence-lifecycle/verifier.test.ts", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", "test:devnet:v10-stress": "vitest run --config devnet/v10-stress/vitest.config.ts", From dee3979ae02121cbc476c72250b96e3bfb9985ae Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:09:36 +0200 Subject: [PATCH 096/292] test(agent): pin Gate 0 fixture evidence --- .../verifier.test.ts | 56 ++++++++++++++++--- .../rfc64-persistence-lifecycle/verifier.ts | 37 ++++++++++-- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/devnet/rfc64-persistence-lifecycle/verifier.test.ts b/devnet/rfc64-persistence-lifecycle/verifier.test.ts index 8fdb226c9c..bd699e5a44 100644 --- a/devnet/rfc64-persistence-lifecycle/verifier.test.ts +++ b/devnet/rfc64-persistence-lifecycle/verifier.test.ts @@ -8,10 +8,10 @@ import { } from './verifier.js'; const HEAD = 'a'.repeat(40); -const OBJECT_DIGEST = `0x${'11'.repeat(32)}`; -const SIGNATURE_DIGEST = `0x${'22'.repeat(32)}`; -const OBJECT_SHA256 = `0x${'33'.repeat(32)}`; -const SIGNATURE_SHA256 = `0x${'44'.repeat(32)}`; +const OBJECT_DIGEST = '0xeec5dff9739afed395f8723e22c2a269e90f094422d42dc4c83b7cbb78c4a320'; +const SIGNATURE_DIGEST = '0x446e5876feb11ef43d611fa634bb0d1960896cff889c06bcbcab98790246f1d9'; +const OBJECT_SHA256 = '0x37ca991c9bc1757d7b4e0839f6308aa3970a7020a940c5d885879e2c0db9d67f'; +const SIGNATURE_SHA256 = '0x04f0668c46bd319cfd7353ec7831f01e2e1fb04dc7b3920a496b0ec4d2fe1947'; type JsonPath = readonly (string | number)[]; @@ -98,6 +98,30 @@ test('verifier rejects reordered or phase-divergent exact file evidence', () => assertRejected(changedLength, 'linux', 'phase-divergent byte length'); }); +test('verifier rejects a different but internally self-consistent fixture', () => { + const mutation = validArtifact('linux'); + const alternateObjectDigest = `0x${'11'.repeat(32)}`; + const alternateSignatureDigest = `0x${'22'.repeat(32)}`; + const alternateObjectSha256 = `0x${'33'.repeat(32)}`; + const alternateSignatureSha256 = `0x${'44'.repeat(32)}`; + mutation.fixture.objectDigest = alternateObjectDigest; + mutation.fixture.signatureVariantDigest = alternateSignatureDigest; + mutation.fixture.objectFileSha256 = alternateObjectSha256; + mutation.fixture.signatureFileSha256 = alternateSignatureSha256; + const alternatePaths = filePaths(alternateObjectDigest, alternateSignatureDigest); + const alternateDirectories = directoryPathsFor(alternateObjectDigest); + for (const phase of Object.values(mutation.phases)) { + phase.snapshot.files[0].relativePath = alternatePaths.object; + phase.snapshot.files[0].sha256 = alternateObjectSha256; + phase.snapshot.files[1].relativePath = alternatePaths.signature; + phase.snapshot.files[1].sha256 = alternateSignatureSha256; + phase.snapshot.namespaceDirectoryModes.forEach((entry, index) => { + entry.relativePath = alternateDirectories[index]!; + }); + } + assertRejected(mutation, 'linux', 'self-consistent alternate fixture'); +}); + function assertRejected( artifact: ReturnType, platform: NodeJS.Platform, @@ -237,17 +261,31 @@ function validArtifact(platform: 'linux' | 'win32') { } function objectPath(): string { - const objectHex = OBJECT_DIGEST.slice(2); - return `rfc64-sync/control-objects-v1/objects/${objectHex.slice(0, 2)}/${objectHex}.jcs`; + return filePaths(OBJECT_DIGEST, SIGNATURE_DIGEST).object; } function signaturePath(): string { - const objectHex = OBJECT_DIGEST.slice(2); - return `rfc64-sync/control-objects-v1/signatures/${objectHex.slice(0, 2)}/${objectHex}/${SIGNATURE_DIGEST.slice(2)}.jcs`; + return filePaths(OBJECT_DIGEST, SIGNATURE_DIGEST).signature; } function directoryPaths(): string[] { - const objectHex = OBJECT_DIGEST.slice(2); + return directoryPathsFor(OBJECT_DIGEST); +} + +function filePaths( + objectDigest: string, + signatureDigest: string, +): { readonly object: string; readonly signature: string } { + const objectHex = objectDigest.slice(2); + const prefix = objectHex.slice(0, 2); + return { + object: `rfc64-sync/control-objects-v1/objects/${prefix}/${objectHex}.jcs`, + signature: `rfc64-sync/control-objects-v1/signatures/${prefix}/${objectHex}/${signatureDigest.slice(2)}.jcs`, + }; +} + +function directoryPathsFor(objectDigest: string): string[] { + const objectHex = objectDigest.slice(2); const prefix = objectHex.slice(0, 2); return [ 'rfc64-sync', diff --git a/devnet/rfc64-persistence-lifecycle/verifier.ts b/devnet/rfc64-persistence-lifecycle/verifier.ts index 85ea9fdb1c..c79ee35aff 100644 --- a/devnet/rfc64-persistence-lifecycle/verifier.ts +++ b/devnet/rfc64-persistence-lifecycle/verifier.ts @@ -8,6 +8,14 @@ export const GATE0_VERDICT_SCHEMA_VERSION = 'dkg-rfc64-gate0-persistence-verdict const SHA256_PATTERN = /^0x[0-9a-f]{64}$/u; const COMMIT_PATTERN = /^[0-9a-f]{40,64}$/u; const OBJECT_ROOT = 'rfc64-sync/control-objects-v1'; +const EXPECTED_FIXTURE = Object.freeze({ + objectDigest: '0xeec5dff9739afed395f8723e22c2a269e90f094422d42dc4c83b7cbb78c4a320', + signatureVariantDigest: '0x446e5876feb11ef43d611fa634bb0d1960896cff889c06bcbcab98790246f1d9', + objectFileSha256: '0x37ca991c9bc1757d7b4e0839f6308aa3970a7020a940c5d885879e2c0db9d67f', + signatureFileSha256: '0x04f0668c46bd319cfd7353ec7831f01e2e1fb04dc7b3920a496b0ec4d2fe1947', + objectFileByteLength: 292, + signatureFileByteLength: 326, +}); interface VerifiedSnapshot { readonly filesCanonical: string; @@ -158,9 +166,22 @@ function verifyClosedArtifact( fixture.signatureFileSha256, '$.fixture.signatureFileSha256', ); - if (objectDigest === signatureVariantDigest || objectFileSha256 === signatureFileSha256) { - fail('$.fixture', 'object and signature identities must be distinct'); - } + exact(objectDigest, EXPECTED_FIXTURE.objectDigest, '$.fixture.objectDigest'); + exact( + signatureVariantDigest, + EXPECTED_FIXTURE.signatureVariantDigest, + '$.fixture.signatureVariantDigest', + ); + exact( + objectFileSha256, + EXPECTED_FIXTURE.objectFileSha256, + '$.fixture.objectFileSha256', + ); + exact( + signatureFileSha256, + EXPECTED_FIXTURE.signatureFileSha256, + '$.fixture.signatureFileSha256', + ); const phases = closedRecord(artifact.phases, '$.phases', [ 'gracefulRestart', @@ -434,9 +455,11 @@ function verifySnapshot( `${path}.files[${index}].relativePath`, ); exact(actual.sha256, expected.sha256, `${path}.files[${index}].sha256`); - if (!Number.isSafeInteger(actual.byteLength) || (actual.byteLength as number) <= 0) { - fail(`${path}.files[${index}].byteLength`, 'must be a positive safe integer'); - } + exact( + actual.byteLength, + expected.byteLength, + `${path}.files[${index}].byteLength`, + ); exact( actual.mode, platform === 'win32' ? null : '0600', @@ -497,11 +520,13 @@ function expectedFileEvidence( kind: 'object', relativePath: `${OBJECT_ROOT}/objects/${prefix}/${objectHex}.jcs`, sha256: objectFileSha256, + byteLength: EXPECTED_FIXTURE.objectFileByteLength, }, { kind: 'signature', relativePath: `${OBJECT_ROOT}/signatures/${prefix}/${objectHex}/${signatureHex}.jcs`, sha256: signatureFileSha256, + byteLength: EXPECTED_FIXTURE.signatureFileByteLength, }, ]; } From 06f0c039aa16fa3584be7b6c12be15f54438687b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:26:15 +0200 Subject: [PATCH 097/292] test(agent): synchronize no-replace collision proof --- .../rfc64-control-object-store-v1.test.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index f63f8db03c..674d6d5fad 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import { computeControlObjectDigestHex, type AuthorCatalogScopeV1, + type DecimalU64V1, type Digest32V1, type EvmAddressV1, type SignedControlEnvelopeV1, @@ -44,7 +45,7 @@ import { } from './support/rfc64-control-object-store-fixtures.js'; const SAFE = '0x3333333333333333333333333333333333333333' as EvmAddressV1; -const BLOCK_HASH = `0x${'44'.repeat(32)}`; +const BLOCK_HASH = `0x${'44'.repeat(32)}` as Digest32V1; const openRfc64ControlObjectStoreV1 = createRfc64ControlObjectStoreTestOpenerV1(); const temporaryDirectories = createTemporaryDataDirectoryFixture(); const { temporaryDataDirectory } = temporaryDirectories; @@ -55,7 +56,7 @@ afterEach(async () => { const finalizedEip1271Call: CurrentFinalizedEvmCallV1 = async (request) => ({ chainId: request.chainId, - blockNumber: '123', + blockNumber: '123' as DecimalU64V1, blockHash: BLOCK_HASH, returnData: EIP1271_CANONICAL_ABI_RETURN_V1, }); @@ -212,8 +213,27 @@ describe('RFC-64 durable control-object store v1', () => { it('converges concurrent staging through durable no-replace keys', async () => { const dataDir = await temporaryDataDirectory(); const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; + const objectTempsReady = deferred(); + const signatureTempsReady = deferred(); + let objectTempsFsynced = 0; + let signatureTempsFsynced = 0; const store = await createRfc64ControlObjectStoreTestOpenerV1({ - boundary: (boundary) => boundaries.push(boundary), + boundary: async (boundary) => { + boundaries.push(boundary); + // Make both callers reach each immutable-key collision before either + // may publish. Without this rendezvous, a slower platform may validly + // let the second call reconcile the winner before allocating a temp. + if (boundary === 'object.temp-fsynced') { + objectTempsFsynced += 1; + if (objectTempsFsynced === 2) objectTempsReady.resolve(); + await objectTempsReady.promise; + } + if (boundary === 'signature.temp-fsynced') { + signatureTempsFsynced += 1; + if (signatureTempsFsynced === 2) signatureTempsReady.resolve(); + await signatureTempsReady.promise; + } + }, })(dataDir); const fixture = await signedFixture('2'); boundaries.length = 0; @@ -272,7 +292,9 @@ describe('RFC-64 durable control-object store v1', () => { const dataDir = await temporaryDataDirectory(); const boundaries: Rfc64ControlObjectStoreDurabilityBoundaryV1[] = []; const store = await createRfc64ControlObjectStoreTestOpenerV1({ - boundary: (boundary) => boundaries.push(boundary), + boundary: (boundary) => { + boundaries.push(boundary); + }, })(dataDir); const [first, second] = await contractSignatureVariantsFixture(); expect(first.envelope.objectDigest).toBe(second.envelope.objectDigest); From 895ae367af69304af374a79b3557536f289ccc99 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:38:41 +0200 Subject: [PATCH 098/292] fix(agent): harden RFC-64 internal boundaries --- packages/agent/package.json | 10 ++ packages/agent/scripts/test-package-root.mjs | 82 +++++++++++--- .../rfc64/control-object-store-v1-internal.ts | 78 ++++++------- .../agent/src/rfc64/durable-file-store-v1.ts | 103 +++++------------- .../test/rfc64-durable-file-store-v1.test.ts | 11 +- ...rfc64-control-object-store-test-support.ts | 34 ++++-- .../rfc64-durable-file-store-test-support.ts | 49 +++++++++ 7 files changed, 218 insertions(+), 149 deletions(-) create mode 100644 packages/agent/test/support/rfc64-durable-file-store-test-support.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 6dd7df228c..74633ce3af 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -11,6 +11,15 @@ "default": "./dist/index.js" }, "./package.json": "./package.json", + "./dist/rfc64/author-catalog-producer.js": "./dist/rfc64/author-catalog-producer.js", + "./dist/rfc64/catalog-row-authorship.js": "./dist/rfc64/catalog-row-authorship.js", + "./dist/rfc64/inventory-v1/candidate.js": "./dist/rfc64/inventory-v1/candidate.js", + "./dist/rfc64/inventory-v1/index.js": "./dist/rfc64/inventory-v1/index.js", + "./dist/rfc64/inventory-v1/lifecycle-adapter.js": "./dist/rfc64/inventory-v1/lifecycle-adapter.js", + "./dist/rfc64/inventory-v1/open.js": "./dist/rfc64/inventory-v1/open.js", + "./dist/rfc64/inventory-v1/scalars.js": "./dist/rfc64/inventory-v1/scalars.js", + "./dist/rfc64/inventory-v1/sql.js": "./dist/rfc64/inventory-v1/sql.js", + "./dist/rfc64/inventory-v1/statements.js": "./dist/rfc64/inventory-v1/statements.js", "./dist/rfc64/control-object-store-v1-internal.js": null, "./dist/rfc64/control-object-store-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, @@ -18,6 +27,7 @@ "./dist/rfc64/persistence-root-ownership-v1-internal.js": null, "./dist/rfc64/persistence-v1.js": null, "./dist/rfc64/secure-filesystem-policy-v1.js": null, + "./dist/rfc64/*": null, "./dist/*": "./dist/*" }, "scripts": { diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 8df173b886..3a970ec22c 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -1,15 +1,12 @@ +import { readdir } from 'node:fs/promises'; import { createRequire } from 'node:module'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; const root = await import('@origintrail-official/dkg-agent'); const legacyAgent = await import('@origintrail-official/dkg-agent/dist/dkg-agent.js'); const require = createRequire(import.meta.url); const packageManifest = require('@origintrail-official/dkg-agent/package.json'); -const legacyCatalogProducer = await import( - '@origintrail-official/dkg-agent/dist/rfc64/author-catalog-producer.js' -); -const legacyInventory = await import( - '@origintrail-official/dkg-agent/dist/rfc64/inventory-v1/index.js' -); if (typeof root.DKGAgent !== 'function' || typeof legacyAgent.DKGAgent !== 'function') { throw new Error('published agent entry points did not expose DKGAgent'); @@ -17,13 +14,17 @@ if (typeof root.DKGAgent !== 'function' || typeof legacyAgent.DKGAgent !== 'func if (packageManifest.name !== '@origintrail-official/dkg-agent') { throw new Error('historical package.json subpath no longer resolves'); } -if ( - typeof legacyCatalogProducer.produceEmptyAuthorCatalogGenesisV1 !== 'function' - || typeof legacyInventory.openInventoryV1 !== 'function' -) { - throw new Error('historical RFC-64 deep imports no longer resolve'); -} - +const publicRfc64Modules = [ + 'author-catalog-producer.js', + 'catalog-row-authorship.js', + 'inventory-v1/candidate.js', + 'inventory-v1/index.js', + 'inventory-v1/lifecycle-adapter.js', + 'inventory-v1/open.js', + 'inventory-v1/scalars.js', + 'inventory-v1/sql.js', + 'inventory-v1/statements.js', +]; const blockedRfc64Modules = [ 'control-object-store-v1-internal.js', 'control-object-store-v1.js', @@ -33,8 +34,37 @@ const blockedRfc64Modules = [ 'persistence-v1.js', 'secure-filesystem-policy-v1.js', ]; +const packageExports = packageManifest.exports; +const emittedRfc64Modules = await listEmittedRfc64Modules(); +const classifiedRfc64Modules = new Set([ + ...publicRfc64Modules, + ...blockedRfc64Modules, +]); + +for (const path of emittedRfc64Modules) { + const subpath = `./dist/rfc64/${path}`; + if (!Object.hasOwn(packageExports, subpath) || !classifiedRfc64Modules.has(path)) { + throw new Error(`emitted RFC-64 module is not explicitly classified: ${path}`); + } +} +for (const path of classifiedRfc64Modules) { + if (!emittedRfc64Modules.includes(path)) { + throw new Error(`classified RFC-64 module was not emitted: ${path}`); + } +} +for (const path of publicRfc64Modules) { + const subpath = `./dist/rfc64/${path}`; + if (packageExports[subpath] !== subpath) { + throw new Error(`legacy RFC-64 module is not explicitly public: ${path}`); + } + await import(`@origintrail-official/dkg-agent/dist/rfc64/${path}`); +} for (const path of blockedRfc64Modules) { + const subpath = `./dist/rfc64/${path}`; + if (packageExports[subpath] !== null) { + throw new Error(`internal RFC-64 module is not explicitly blocked: ${path}`); + } try { await import(`@origintrail-official/dkg-agent/dist/rfc64/${path}`); throw new Error(`internal RFC-64 module unexpectedly resolved: ${path}`); @@ -42,3 +72,29 @@ for (const path of blockedRfc64Modules) { if (error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; } } + +if (packageExports['./dist/rfc64/*'] !== null) { + throw new Error('unclassified RFC-64 deep imports are not blocked by default'); +} +if (packageExports['./dist/*'] !== './dist/*') { + throw new Error('historical non-RFC-64 ./dist/* compatibility was not preserved'); +} + +async function listEmittedRfc64Modules() { + const rootPath = fileURLToPath(new URL('../dist/rfc64/', import.meta.url)); + const pending = [rootPath]; + const modules = []; + while (pending.length > 0) { + const directory = pending.pop(); + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + modules.push(relative(rootPath, entryPath).split(sep).join('/')); + } + } + } + return modules.sort(); +} diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index a0309c1b77..0990c8c148 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -17,12 +17,10 @@ import { import { Rfc64DurableFileErrorV1, assertRfc64ExistingDirectoryV1, - createRfc64DurableFileStoreForTestV1, - createRfc64DurableFileStoreV1, - ensureRfc64SecureDirectoryTreeForTestV1, - ensureRfc64SecureDirectoryTreeV1, + createRfc64DurableFileStoreWithInstrumentationV1, + ensureRfc64SecureDirectoryTreeWithInstrumentationV1, type Rfc64DurableFileBoundaryV1, - type Rfc64DurableFileLifecycleV1, + type Rfc64DurableFileInstrumentationV1, type Rfc64DurableFileStoreV1, } from './durable-file-store-v1.js'; import { @@ -87,8 +85,8 @@ type Rfc64ControlObjectStoreFileKindV1 = 'object' | 'signature'; export type Rfc64ControlObjectStoreDurabilityBoundaryV1 = Rfc64DurableFileBoundaryV1; -interface Rfc64ControlObjectStoreLifecycleV1 - extends Rfc64DurableFileLifecycleV1 {} +export interface Rfc64ControlObjectStoreInstrumentationV1 + extends Rfc64DurableFileInstrumentationV1 {} export interface StageVerifiedControlObjectV1 { readonly envelope: SignedControlEnvelopeV1; @@ -151,9 +149,16 @@ export async function openRfc64ControlObjectStoreForOwnedPersistenceRootV1( ownership: Rfc64PersistenceRootOwnershipV1, ): Promise { const rfc64RootPath = ownership.assertHeldAndGetRootPathV1(); - return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, null); + return openRfc64ControlObjectStoreAtRootV1( + rfc64RootPath, + NOOP_RFC64_CONTROL_OBJECT_STORE_INSTRUMENTATION_V1, + ); } +const NOOP_RFC64_CONTROL_OBJECT_STORE_INSTRUMENTATION_V1 = Object.freeze({ + boundary: (): void => {}, +}) satisfies Rfc64ControlObjectStoreInstrumentationV1; + interface PreparedStoredControlObjectV1 { readonly objectDigest: Digest32V1; readonly signatureVariantDigest: Digest32V1; @@ -429,26 +434,17 @@ async function settleAllOrThrowV1( }); } -/** @internal Used only by the package-local test support module. */ -export async function openRfc64ControlObjectStoreForTestV1( +/** @internal Package-local lifecycle/instrumentation composition seam. */ +export async function openRfc64ControlObjectStoreWithInstrumentationV1( dataDirInput: string, - lifecycle: Rfc64ControlObjectStoreLifecycleV1, + instrumentation: Rfc64ControlObjectStoreInstrumentationV1, ): Promise { - assertRfc64ControlObjectStoreTestEnvironmentV1(); - if (!Object.isFrozen(lifecycle)) { - fail('control-store-input', 'control object store lifecycle adapter must be immutable'); + if (!Object.isFrozen(instrumentation)) { + fail('control-store-input', 'control object store instrumentation must be immutable'); } if (typeof dataDirInput !== 'string' || dataDirInput.length === 0) { fail('control-store-input', 'dataDir must be a non-empty path'); } - const guardedLifecycle = Object.freeze({ - boundary: async ( - boundary: Rfc64ControlObjectStoreDurabilityBoundaryV1, - ): Promise => { - assertRfc64ControlObjectStoreTestEnvironmentV1(); - await lifecycle.boundary(boundary); - }, - }); const dataDir = resolve(dataDirInput); const rfc64RootPath = resolveRfc64PersistenceRootV1(dataDir); await mapDurableFileErrors(async () => { @@ -457,30 +453,21 @@ export async function openRfc64ControlObjectStoreForTestV1( 'DKG data directory', { access: 'owner' }, ); - await ensureRfc64SecureDirectoryTreeForTestV1( + await ensureRfc64SecureDirectoryTreeWithInstrumentationV1( rfc64RootPath, dataDir, - guardedLifecycle, + instrumentation, ); }); - return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, guardedLifecycle); -} - -function assertRfc64ControlObjectStoreTestEnvironmentV1(): void { - if (process.env.NODE_ENV !== 'test') { - fail( - 'control-store-input', - 'control store test opener is available only under NODE_ENV=test', - ); - } + return openRfc64ControlObjectStoreAtRootV1(rfc64RootPath, instrumentation); } async function openRfc64ControlObjectStoreAtRootV1( rfc64RootPathInput: string, - testLifecycle: Rfc64ControlObjectStoreLifecycleV1 | null, + instrumentation: Rfc64ControlObjectStoreInstrumentationV1, ): Promise { - if (testLifecycle !== null && !Object.isFrozen(testLifecycle)) { - fail('control-store-input', 'control object store lifecycle adapter must be immutable'); + if (!Object.isFrozen(instrumentation)) { + fail('control-store-input', 'control object store instrumentation must be immutable'); } const rfc64RootPath = resolve(rfc64RootPathInput); const rootPath = resolveRfc64ControlObjectStorePathV1(rfc64RootPath); @@ -490,18 +477,19 @@ async function openRfc64ControlObjectStoreAtRootV1( 'RFC-64 persistence root', { access: 'owner-only' }, ); - const ensureTree = testLifecycle === null - ? (target: string, root: string) => - ensureRfc64SecureDirectoryTreeV1(target, root) - : (target: string, root: string) => - ensureRfc64SecureDirectoryTreeForTestV1(target, root, testLifecycle); + const ensureTree = (target: string, root: string) => + ensureRfc64SecureDirectoryTreeWithInstrumentationV1( + target, + root, + instrumentation, + ); await ensureTree(rootPath, rfc64RootPath); await ensureTree(join(rootPath, OBJECTS_DIRECTORY), rootPath); await ensureTree(join(rootPath, SIGNATURES_DIRECTORY), rootPath); }); - const durableFiles = testLifecycle === null - ? createRfc64DurableFileStoreV1(rootPath) - : createRfc64DurableFileStoreForTestV1(rootPath, testLifecycle); + const durableFiles = createRfc64DurableFileStoreWithInstrumentationV1< + Rfc64ControlObjectStoreFileKindV1 + >(rootPath, instrumentation); return new FileRfc64ControlObjectStoreV1(rootPath, durableFiles); } diff --git a/packages/agent/src/rfc64/durable-file-store-v1.ts b/packages/agent/src/rfc64/durable-file-store-v1.ts index 69b2ddf5d9..fba8b6fea2 100644 --- a/packages/agent/src/rfc64/durable-file-store-v1.ts +++ b/packages/agent/src/rfc64/durable-file-store-v1.ts @@ -59,21 +59,16 @@ export type Rfc64DurableFileBoundaryV1 = | 'directory.parent-fsynced' | `${TKind}.${Rfc64DurableFilePublishBoundaryV1}`; -export interface Rfc64DurableFileLifecycleV1 { +/** Package-internal observation seam for durability and directory coordination. */ +export interface Rfc64DurableFileInstrumentationV1 { readonly boundary: ( boundary: Rfc64DurableFileBoundaryV1, ) => void | Promise; -} - -/** @internal Source-test lifecycle with deterministic directory single-flight observation. */ -export interface Rfc64DurableFileTestLifecycleV1 - extends Rfc64DurableFileLifecycleV1 { readonly directoryPreparation?: ( observation: Rfc64DirectoryPreparationObservationV1, ) => void | Promise; } -/** @internal Source-test observation emitted by the guarded test factory. */ export interface Rfc64DirectoryPreparationObservationV1 { readonly path: string; readonly disposition: 'started' | 'joined'; @@ -89,45 +84,21 @@ export interface Rfc64DurableFileStoreV1 { export function createRfc64DurableFileStoreV1( containmentRoot: string, ): Rfc64DurableFileStoreV1 { - return createRfc64DurableFileStoreWithLifecycleV1( - containmentRoot, - PRODUCTION_DURABLE_FILE_LIFECYCLE_V1, - ); -} - -/** @internal Package-local fault injection; unavailable outside source tests. */ -export function createRfc64DurableFileStoreForTestV1( - containmentRoot: string, - lifecycle: Rfc64DurableFileTestLifecycleV1, -): Rfc64DurableFileStoreV1 { - assertRfc64DurableFileTestEnvironmentV1(); - const observeDirectoryPreparation = lifecycle.directoryPreparation; - const guardedLifecycle = Object.freeze({ - boundary: async (boundary: Rfc64DurableFileBoundaryV1): Promise => { - assertRfc64DurableFileTestEnvironmentV1(); - await lifecycle.boundary(boundary); - }, - directoryPreparation: async ( - observation: Rfc64DirectoryPreparationObservationV1, - ): Promise => { - assertRfc64DurableFileTestEnvironmentV1(); - await observeDirectoryPreparation?.(observation); - }, - }); - return createRfc64DurableFileStoreWithLifecycleV1( + return createRfc64DurableFileStoreWithInstrumentationV1( containmentRoot, - guardedLifecycle, + NOOP_RFC64_DURABLE_FILE_INSTRUMENTATION_V1, ); } -const PRODUCTION_DURABLE_FILE_LIFECYCLE_V1 = Object.freeze({ - boundary: (): void => {}, -}) satisfies Rfc64DurableFileTestLifecycleV1; - -function createRfc64DurableFileStoreWithLifecycleV1( +/** @internal Package-local lifecycle/instrumentation composition seam. */ +export function createRfc64DurableFileStoreWithInstrumentationV1( containmentRoot: string, - lifecycle: Rfc64DurableFileTestLifecycleV1, + instrumentation: Rfc64DurableFileInstrumentationV1, ): Rfc64DurableFileStoreV1 { + if (!Object.isFrozen(instrumentation)) { + fail('input', 'RFC-64 durable-file instrumentation must be immutable'); + } + const lifecycle = instrumentation; const directoryPreparations = new Map>(); const prepareDirectoryComponent = async (path: string): Promise => { const key = resolve(path); @@ -185,6 +156,10 @@ function createRfc64DurableFileStoreWithLifecycleV1( }); } +const NOOP_RFC64_DURABLE_FILE_INSTRUMENTATION_V1 = Object.freeze({ + boundary: (): void => {}, +}) satisfies Rfc64DurableFileInstrumentationV1; + export async function assertRfc64ExistingDirectoryV1( path: string, label: string, @@ -212,53 +187,37 @@ export async function ensureRfc64SecureDirectoryTreeV1( containmentRoot: string, containmentRootAccess: Rfc64ExistingAccessV1 = 'owner', ): Promise { - return ensureRfc64SecureDirectoryTreeWithLifecycleV1( + return ensureRfc64SecureDirectoryTreeWithInstrumentationV1( target, containmentRoot, - PRODUCTION_DURABLE_FILE_LIFECYCLE_V1, + NOOP_RFC64_DURABLE_FILE_INSTRUMENTATION_V1, containmentRootAccess, ); } -/** @internal Package-local fault injection; unavailable outside source tests. */ -export async function ensureRfc64SecureDirectoryTreeForTestV1( +/** @internal Package-local lifecycle/instrumentation composition seam. */ +export async function ensureRfc64SecureDirectoryTreeWithInstrumentationV1< + TKind extends string, +>( target: string, containmentRoot: string, - lifecycle: Rfc64DurableFileLifecycleV1, + instrumentation: Rfc64DurableFileInstrumentationV1, containmentRootAccess: Rfc64ExistingAccessV1 = 'owner', ): Promise { - assertRfc64DurableFileTestEnvironmentV1(); - const guardedLifecycle = Object.freeze({ - boundary: async (boundary: Rfc64DurableFileBoundaryV1): Promise => { - assertRfc64DurableFileTestEnvironmentV1(); - await lifecycle.boundary(boundary); - }, - }); - return ensureRfc64SecureDirectoryTreeWithLifecycleV1( - target, - containmentRoot, - guardedLifecycle, - containmentRootAccess, - ); -} - -async function ensureRfc64SecureDirectoryTreeWithLifecycleV1( - target: string, - containmentRoot: string, - lifecycle: Rfc64DurableFileLifecycleV1, - containmentRootAccess: Rfc64ExistingAccessV1, -): Promise { + if (!Object.isFrozen(instrumentation)) { + fail('input', 'RFC-64 durable-file instrumentation must be immutable'); + } await walkRfc64ContainedDirectoryTreeV1( target, containmentRoot, containmentRootAccess, - (current) => prepareRfc64SecureDirectoryComponentV1(current, lifecycle), + (current) => prepareRfc64SecureDirectoryComponentV1(current, instrumentation), ); } async function prepareRfc64SecureDirectoryComponentV1( path: string, - lifecycle: Rfc64DurableFileLifecycleV1, + lifecycle: Rfc64DurableFileInstrumentationV1, ): Promise { let created = false; try { @@ -291,7 +250,7 @@ export interface PutRfc64ExactBytesInputV1 { interface InternalPutRfc64ExactBytesInputV1 extends PutRfc64ExactBytesInputV1 { readonly containmentRoot: string; - readonly lifecycle: Rfc64DurableFileLifecycleV1; + readonly lifecycle: Rfc64DurableFileInstrumentationV1; readonly prepareDirectoryTree: ( target: string, containmentRootAccess: Rfc64ExistingAccessV1, @@ -693,9 +652,3 @@ function fail( cause === undefined ? {} : { cause }, ); } - -function assertRfc64DurableFileTestEnvironmentV1(): void { - if (process.env.NODE_ENV !== 'test') { - fail('input', 'RFC-64 durable-file fault injection is unavailable outside NODE_ENV=test'); - } -} diff --git a/packages/agent/test/rfc64-durable-file-store-v1.test.ts b/packages/agent/test/rfc64-durable-file-store-v1.test.ts index dcb65d4a63..df13211cfe 100644 --- a/packages/agent/test/rfc64-durable-file-store-v1.test.ts +++ b/packages/agent/test/rfc64-durable-file-store-v1.test.ts @@ -4,15 +4,16 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE } from '../src/rfc64/control-object-store-v1.js'; -import { - createRfc64DurableFileStoreForTestV1, - createRfc64DurableFileStoreV1, -} from '../src/rfc64/durable-file-store-v1.js'; +import { createRfc64DurableFileStoreV1 } from '../src/rfc64/durable-file-store-v1.js'; import { applyRfc64OwnerOnlyPermissionsSyncV1 } from '../src/rfc64/secure-filesystem-policy-v1.js'; import { createTemporaryDataDirectoryFixture, deferred, } from './support/rfc64-control-object-store-fixtures.js'; +import { + createRfc64DurableFileStoreForTestV1, + type Rfc64DurableFileTestLifecycleV1, +} from './support/rfc64-durable-file-store-test-support.js'; const temporaryDirectories = createTemporaryDataDirectoryFixture(); const { temporaryDataDirectory } = temporaryDirectories; @@ -97,7 +98,7 @@ describe('RFC-64 durable file store v1', () => { joined.resolve(); } }, - }), + } satisfies Rfc64DurableFileTestLifecycleV1<'signature'>), ); const put = (relativePath: string, value: string) => durableFiles.putExactBytes({ relativePath, diff --git a/packages/agent/test/support/rfc64-control-object-store-test-support.ts b/packages/agent/test/support/rfc64-control-object-store-test-support.ts index 0e237e7b40..694578307a 100644 --- a/packages/agent/test/support/rfc64-control-object-store-test-support.ts +++ b/packages/agent/test/support/rfc64-control-object-store-test-support.ts @@ -1,6 +1,6 @@ import { Rfc64ControlObjectStoreErrorV1, - openRfc64ControlObjectStoreForTestV1, + openRfc64ControlObjectStoreWithInstrumentationV1, type Rfc64ControlObjectStoreDurabilityBoundaryV1, type Rfc64ControlObjectStoreV1, } from '../../src/rfc64/control-object-store-v1-internal.js'; @@ -18,20 +18,32 @@ export type Rfc64ControlObjectStoreTestOpenerV1 = ( export function createRfc64ControlObjectStoreTestOpenerV1( testLifecycle: Rfc64ControlObjectStoreTestLifecycleV1 = {}, ): Rfc64ControlObjectStoreTestOpenerV1 { - if (process.env.NODE_ENV !== 'test') { - throw new Rfc64ControlObjectStoreErrorV1( - 'control-store-input', - 'control store test opener is available only under NODE_ENV=test', - ); - } + assertRfc64ControlObjectStoreTestEnvironmentV1(); const boundary = testLifecycle.boundary; - const lifecycle = Object.freeze({ + const instrumentation = Object.freeze({ boundary: ( value: Rfc64ControlObjectStoreDurabilityBoundaryV1, - ): void | Promise => boundary?.(value), + ): void | Promise => { + assertRfc64ControlObjectStoreTestEnvironmentV1(); + return boundary?.(value); + }, }); - return async (dataDir: string): Promise => - openRfc64ControlObjectStoreForTestV1(dataDir, lifecycle); + return async (dataDir: string): Promise => { + assertRfc64ControlObjectStoreTestEnvironmentV1(); + return openRfc64ControlObjectStoreWithInstrumentationV1( + dataDir, + instrumentation, + ); + }; } export type { Rfc64ControlObjectStoreDurabilityBoundaryV1 }; + +function assertRfc64ControlObjectStoreTestEnvironmentV1(): void { + if (process.env.NODE_ENV !== 'test') { + throw new Rfc64ControlObjectStoreErrorV1( + 'control-store-input', + 'control store test opener is available only under NODE_ENV=test', + ); + } +} diff --git a/packages/agent/test/support/rfc64-durable-file-store-test-support.ts b/packages/agent/test/support/rfc64-durable-file-store-test-support.ts new file mode 100644 index 0000000000..ff2396b86a --- /dev/null +++ b/packages/agent/test/support/rfc64-durable-file-store-test-support.ts @@ -0,0 +1,49 @@ +import { + Rfc64DurableFileErrorV1, + createRfc64DurableFileStoreWithInstrumentationV1, + type Rfc64DirectoryPreparationObservationV1, + type Rfc64DurableFileBoundaryV1, + type Rfc64DurableFileStoreV1, +} from '../../src/rfc64/durable-file-store-v1.js'; + +export interface Rfc64DurableFileTestLifecycleV1 { + readonly boundary: ( + boundary: Rfc64DurableFileBoundaryV1, + ) => void | Promise; + readonly directoryPreparation?: ( + observation: Rfc64DirectoryPreparationObservationV1, + ) => void | Promise; +} + +export function createRfc64DurableFileStoreForTestV1( + containmentRoot: string, + lifecycle: Rfc64DurableFileTestLifecycleV1, +): Rfc64DurableFileStoreV1 { + assertRfc64DurableFileTestEnvironmentV1(); + const observeDirectoryPreparation = lifecycle.directoryPreparation; + const instrumentation = Object.freeze({ + boundary: async (boundary: Rfc64DurableFileBoundaryV1): Promise => { + assertRfc64DurableFileTestEnvironmentV1(); + await lifecycle.boundary(boundary); + }, + directoryPreparation: async ( + observation: Rfc64DirectoryPreparationObservationV1, + ): Promise => { + assertRfc64DurableFileTestEnvironmentV1(); + await observeDirectoryPreparation?.(observation); + }, + }); + return createRfc64DurableFileStoreWithInstrumentationV1( + containmentRoot, + instrumentation, + ); +} + +function assertRfc64DurableFileTestEnvironmentV1(): void { + if (process.env.NODE_ENV !== 'test') { + throw new Rfc64DurableFileErrorV1( + 'input', + 'RFC-64 durable-file fault injection is unavailable outside NODE_ENV=test', + ); + } +} From 9fbb9e071b04af01fbe8efc4a6b29aba7e323fe0 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:20:25 +0200 Subject: [PATCH 099/292] test(agent): prove cross-platform Gate 0 shutdown --- .github/workflows/rfc64-inventory-windows.yml | 5 + devnet/rfc64-persistence-lifecycle/README.md | 25 +++-- .../agent-process.ts | 38 ++++++- devnet/rfc64-persistence-lifecycle/run.ts | 102 ++++++++++++++---- .../verifier.test.ts | 45 +++++++- .../rfc64-persistence-lifecycle/verifier.ts | 20 +++- 6 files changed, 201 insertions(+), 34 deletions(-) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 528f57f745..becacd0bd4 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -9,6 +9,7 @@ on: - 'devnet/_bootstrap/rfc64-evidence*' - 'devnet/_bootstrap/tsconfig.evidence.json' - 'devnet/_bootstrap/vitest.evidence.config.ts' + - 'devnet/rfc64-persistence-lifecycle/**' - 'package.json' - 'packages/agent/src/rfc64/inventory-v1/**' - 'packages/agent/src/rfc64/author-catalog-producer.ts' @@ -77,6 +78,10 @@ jobs: - name: Test RFC-64 evidence harness run: pnpm test:devnet:rfc64-evidence + - name: Run Gate 0 production persistence lifecycle + timeout-minutes: 10 + run: pnpm test:gate0:rfc64-persistence-lifecycle + - name: Build agent dependency closure run: pnpm --filter @origintrail-official/dkg-agent... run build diff --git a/devnet/rfc64-persistence-lifecycle/README.md b/devnet/rfc64-persistence-lifecycle/README.md index 828cef1335..4fa3d07d1d 100644 --- a/devnet/rfc64-persistence-lifecycle/README.md +++ b/devnet/rfc64-persistence-lifecycle/README.md @@ -5,8 +5,10 @@ child process. It stages one deterministic, signature-verified control object through the agent-owned RFC-64 persistence boundary, proves that another process cannot acquire the inventory lease, then exercises both shutdown paths: -1. graceful `SIGTERM` -> `DKGAgent.stop()` -> successful restart; -2. `SIGKILL` (no JavaScript cleanup) -> operating-system lease recovery. +1. cross-platform stdin command -> `DKGAgent.stop()` -> closed persistence -> + successful restart; +2. forced process termination (`SIGKILL` in the Node child-process API, with no + JavaScript cleanup) -> operating-system lease recovery. Each restarted agent re-reads and cryptographically verifies the exact durable object before the harness compares byte hashes, object/signature counts, and @@ -59,10 +61,12 @@ devnet/rfc64-persistence-lifecycle/artifacts/gate0-verdict.json Set `DKG_RFC64_GATE0_ARTIFACT` or `DKG_RFC64_GATE0_VERDICT_ARTIFACT` to override those paths. The gate deliberately does not add an HTTP/API route. Its only -control channel is the parent/child stdio and process-signal boundary. A shared -`scripts/devnet.sh` cluster is not required: RFC-64 persistence is acquired -before networking, and isolating this gate avoids mutating a developer's existing -devnet. +control channel is the parent/child stdio graceful-stop command plus the forced +process-termination boundary used for crash recovery. Each graceful child must +emit an exact closed-state event after `DKGAgent.stop()` and before a clean exit. +A shared `scripts/devnet.sh` cluster is not required: RFC-64 persistence is +acquired before networking, and isolating this gate avoids mutating a developer's +existing devnet. Artifact publication uses an exclusive `0600` sibling temporary file, file `fsync`, atomic rename, parent-directory topology revalidation, and a parent @@ -75,7 +79,14 @@ The verifier rejects missing or extra fields, non-canonical/lossy JSON, a dirty mismatched source commit, malformed lifecycle evidence, changed file lists or digests, unsuccessful exits, non-busy lease probes, and incomplete POSIX mode or Windows protected-owner ACL-policy evidence. Mutation tests delete, alter, and -extend every required schema location. +extend every required schema location, including both graceful closed-state +events. + +The `RFC-64 inventory Windows gate` hosted workflow runs the exact +generator-plus-verifier command on `windows-latest`. It is expected to reach the +same final `PASS` while proving that the stdin graceful-stop path closes +persistence and the forced-termination path still recovers the operating-system +lease on Windows. A PASS on this standalone harness branch covers only the bounded evidence verifier. It is not a formal OT-RFC-64 Gate 0 result. Formal evaluation requires running this diff --git a/devnet/rfc64-persistence-lifecycle/agent-process.ts b/devnet/rfc64-persistence-lifecycle/agent-process.ts index 3eff67fd24..095c61b2bf 100644 --- a/devnet/rfc64-persistence-lifecycle/agent-process.ts +++ b/devnet/rfc64-persistence-lifecycle/agent-process.ts @@ -16,6 +16,7 @@ import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { ethers } from 'ethers'; const EVENT_PREFIX = 'RFC64_GATE0_EVENT '; +const GRACEFUL_STOP_COMMAND = 'RFC64_GATE0_GRACEFUL_STOP_V1'; const FIXTURE_WALLET = new ethers.Wallet(`0x${'52'.repeat(32)}`); interface CandidateSessionV1 {} @@ -55,6 +56,15 @@ function emit(event: Record): void { process.stdout.write(`${EVENT_PREFIX}${JSON.stringify(event)}\n`); } +async function emitAndFlush(event: Record): Promise { + await new Promise((resolveWrite, rejectWrite) => { + process.stdout.write( + `${EVENT_PREFIX}${JSON.stringify(event)}\n`, + (error) => error === null || error === undefined ? resolveWrite() : rejectWrite(error), + ); + }); +} + async function fixture(): Promise<{ readonly envelope: SignedControlEnvelopeV1; readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; @@ -108,12 +118,20 @@ const agent = await DKGAgent.create({ }); let stopping = false; +let ownedPersistence: HarnessPersistenceV1 | null = null; async function stop(exitCode: number): Promise { if (stopping) return await new Promise(() => undefined); stopping = true; try { await agent.stop(); - emit({ event: 'stopped', graceful: true }); + if (ownedPersistence?.closed !== true) { + throw new Error('DKGAgent.stop() returned without closing RFC-64 persistence'); + } + await emitAndFlush({ + event: 'stopped', + graceful: true, + persistenceClosed: true, + }); process.exit(exitCode); } catch (error) { process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); @@ -121,8 +139,21 @@ async function stop(exitCode: number): Promise { } } -process.once('SIGTERM', () => { void stop(0); }); -process.once('SIGINT', () => { void stop(130); }); +let commandBuffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk: string) => { + commandBuffer += chunk; + const commands = commandBuffer.split('\n'); + commandBuffer = commands.pop() ?? ''; + for (const command of commands) { + if (command !== GRACEFUL_STOP_COMMAND) { + process.stderr.write(`Unexpected Gate 0 lifecycle command: ${JSON.stringify(command)}\n`); + void stop(1); + continue; + } + void stop(0); + } +}); try { await agent.start(); @@ -131,6 +162,7 @@ try { if (!internals.started || persistence === undefined || persistence.closed) { throw new Error('production DKGAgent did not own an open RFC-64 persistence boundary'); } + ownedPersistence = persistence; // A production inventory operation through the DKGAgent-owned, non-lifecycle // view. The opaque empty session is process-local and intentionally needs no diff --git a/devnet/rfc64-persistence-lifecycle/run.ts b/devnet/rfc64-persistence-lifecycle/run.ts index 98bac5bed9..eff54259b2 100644 --- a/devnet/rfc64-persistence-lifecycle/run.ts +++ b/devnet/rfc64-persistence-lifecycle/run.ts @@ -18,6 +18,7 @@ import { } from './evidence.js'; const EVENT_PREFIX = 'RFC64_GATE0_EVENT '; +const GRACEFUL_STOP_COMMAND = 'RFC64_GATE0_GRACEFUL_STOP_V1'; const REPO_ROOT = resolve(import.meta.dirname, '../..'); const AGENT_PROCESS = join(import.meta.dirname, 'agent-process.ts'); const LEASE_PROBE = join(import.meta.dirname, 'lease-probe.ts'); @@ -35,10 +36,27 @@ interface ProcessEvent { interface AgentHandle { readonly child: ChildProcessWithoutNullStreams; readonly ready: ProcessEvent; + readonly events: () => readonly ProcessEvent[]; readonly stdout: () => string; readonly stderr: () => string; } +interface ProcessExitEvidence { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +} + +interface GracefulStopEvidence { + readonly event: 'stopped'; + readonly graceful: true; + readonly persistenceClosed: true; +} + +interface GracefulStopResult { + readonly exit: ProcessExitEvidence; + readonly stop: GracefulStopEvidence; +} + interface ContentFileEvidence { readonly kind: 'object' | 'signature'; readonly relativePath: string; @@ -85,6 +103,7 @@ function spawnAgent(dataDir: string, stage: boolean): Promise { let stdout = ''; let stderr = ''; let lineBuffer = ''; + const events: ProcessEvent[] = []; let settled = false; const timeout = setTimeout(() => { if (settled) return; @@ -103,10 +122,17 @@ function spawnAgent(dataDir: string, stage: boolean): Promise { for (const line of lines) { let event: ProcessEvent | null = null; try { event = parseEvent(line); } catch { /* parent reports complete output on timeout/exit */ } + if (event !== null) events.push(event); if (event?.event !== 'ready' || settled) continue; settled = true; clearTimeout(timeout); - resolveReady({ child, ready: event, stdout: () => stdout, stderr: () => stderr }); + resolveReady({ + child, + ready: event, + events: () => events, + stdout: () => stdout, + stderr: () => stderr, + }); } }); child.stderr.on('data', (chunk: string) => { stderr += chunk; }); @@ -127,26 +153,62 @@ function spawnAgent(dataDir: string, stage: boolean): Promise { }); } -async function stopAgent( +function waitForAgentClose( handle: AgentHandle, - signal: 'SIGTERM' | 'SIGKILL', -): Promise<{ readonly code: number | null; readonly signal: NodeJS.Signals | null }> { - const exit = new Promise<{ readonly code: number | null; readonly signal: NodeJS.Signals | null }>( + action: string, +): Promise { + return new Promise( (resolveExit, rejectExit) => { const timeout = setTimeout(() => { handle.child.kill('SIGKILL'); rejectExit(new Error( - `agent did not exit after ${signal}\nstdout:\n${handle.stdout()}\nstderr:\n${handle.stderr()}`, + `agent did not exit after ${action}\nstdout:\n${handle.stdout()}\nstderr:\n${handle.stderr()}`, )); }, PROCESS_TIMEOUT_MS); - handle.child.once('exit', (code, exitSignal) => { + handle.child.once('close', (code, exitSignal) => { clearTimeout(timeout); resolveExit({ code, signal: exitSignal }); }); }, ); - handle.child.kill(signal); - return await exit; +} + +async function stopAgentGracefully(handle: AgentHandle): Promise { + const close = waitForAgentClose(handle, 'the graceful stdin command'); + const write = new Promise((resolveWrite, rejectWrite) => { + const onError = (error: Error): void => rejectWrite(error); + handle.child.stdin.once('error', onError); + handle.child.stdin.end(`${GRACEFUL_STOP_COMMAND}\n`, () => { + handle.child.stdin.off('error', onError); + resolveWrite(); + }); + }); + const [, exit] = await Promise.all([write, close]); + assert(exit.code === 0 && exit.signal === null, 'graceful stop did not exit cleanly'); + const stoppedEvents = handle.events().filter((event) => event.event === 'stopped'); + assert(stoppedEvents.length === 1, `expected one graceful stop event, got ${stoppedEvents.length}`); + const expected = { + event: 'stopped', + graceful: true, + persistenceClosed: true, + } satisfies GracefulStopEvidence; + assert( + stableJson(stoppedEvents[0]) === stableJson(expected), + `graceful stop event was not exact: ${JSON.stringify(stoppedEvents[0])}`, + ); + return { exit, stop: expected }; +} + +async function stopAgentForcibly(handle: AgentHandle): Promise { + const close = waitForAgentClose(handle, 'SIGKILL'); + handle.child.kill('SIGKILL'); + const exit = await close; + assert(exit.code === null && exit.signal === 'SIGKILL', 'unclean stop was not SIGKILL'); + assert( + handle.events().every((event) => event.event !== 'stopped'), + 'SIGKILL process emitted unexpected graceful stop evidence', + ); + return exit; } async function probeLease(dataDir: string): Promise { @@ -182,7 +244,7 @@ async function probeLease(dataDir: string): Promise { throw new Error(`lease probe failed: ${JSON.stringify(exit)}\nstdout:\n${stdout}\nstderr:\n${stderr}`); } const event = stdout.split('\n').map(parseEvent).find((value) => value?.event === 'lease-probe'); - if (event === undefined) { + if (event === undefined || event === null) { throw new Error(`lease probe emitted no result\nstdout:\n${stdout}\nstderr:\n${stderr}`); } return event; @@ -301,8 +363,7 @@ try { assertLeaseBusy(initialProbe); const initialSnapshot = snapshotContent(dataDir); assertExactSnapshot(initialSnapshot); - const initialExit = await stopAgent(initial, 'SIGTERM'); - assert(initialExit.code === 0 && initialExit.signal === null, 'graceful stop did not exit cleanly'); + const initialStop = await stopAgentGracefully(initial); log('phase 2/3: restart, re-read exact bytes, then simulate unclean process death'); const gracefulRestart = await spawnAgent(dataDir, false); @@ -317,8 +378,7 @@ try { stableJson(gracefulSnapshot.files) === stableJson(initialSnapshot.files), 'durable control-object bytes changed across graceful restart', ); - const crashExit = await stopAgent(gracefulRestart, 'SIGKILL'); - assert(crashExit.code === null && crashExit.signal === 'SIGKILL', 'unclean stop was not SIGKILL'); + const crashExit = await stopAgentForcibly(gracefulRestart); log('phase 3/3: recover OS lease, re-read and re-verify exact durable bytes'); const recovered = await spawnAgent(dataDir, false); @@ -333,8 +393,7 @@ try { stableJson(recoveredSnapshot.files) === stableJson(initialSnapshot.files), 'durable control-object bytes changed after OS-lease recovery', ); - const recoveredExit = await stopAgent(recovered, 'SIGTERM'); - assert(recoveredExit.code === 0 && recoveredExit.signal === null, 'recovered agent did not stop cleanly'); + const recoveredStop = await stopAgentGracefully(recovered); const finalRepositoryHead = readCleanRepositoryHead(REPO_ROOT); assert( @@ -342,7 +401,7 @@ try { `repository HEAD changed during harness execution: ${testedRepositoryHead} -> ${finalRepositoryHead}`, ); const artifact = { - schemaVersion: 'dkg-rfc64-gate0-persistence-evidence-v3', + schemaVersion: 'dkg-rfc64-gate0-persistence-evidence-v4', gate: 'OT-RFC-64 Gate 0', productionBaselineCommit: testedRepositoryHead, invocation: 'pnpm test:gate0:rfc64-persistence-lifecycle', @@ -368,7 +427,7 @@ try { runtimeBoundary: { agentClass: 'DKGAgent', processModel: 'three independent production agent processes', - controlChannel: 'stdio and POSIX process signals only', + controlChannel: 'stdio graceful-stop command and forced process termination only', publicDebugEndpointAdded: false, }, fixture: { @@ -393,7 +452,8 @@ try { inventoryOperational: gracefulRestart.ready.inventoryOperational, persistenceClosed: gracefulRestart.ready.persistenceClosed, staged: gracefulRestart.ready.staged, - priorExit: initialExit, + priorExit: initialStop.exit, + priorStop: initialStop.stop, verifiedRead: gracefulRestart.ready.verifiedRead, contender: gracefulProbe, exactContentFilesEqual: true, @@ -408,7 +468,8 @@ try { verifiedRead: recovered.ready.verifiedRead, contender: recoveredProbe, exactContentFilesEqual: true, - finalGracefulExit: recoveredExit, + finalGracefulExit: recoveredStop.exit, + finalGracefulStop: recoveredStop.stop, snapshot: recoveredSnapshot, }, }, @@ -417,6 +478,7 @@ try { inventoryOperationalWhileRunning: true, competingLeaseRejectedWhileRunning: true, gracefulRestartSucceeded: true, + gracefulStopsClosedPersistence: true, operatingSystemLeaseRecoveredAfterSigkill: true, durableControlObjectBytesPreserved: true, storedEnvelopeSignatureReverifiedOnEveryStart: true, diff --git a/devnet/rfc64-persistence-lifecycle/verifier.test.ts b/devnet/rfc64-persistence-lifecycle/verifier.test.ts index bd699e5a44..f400d901d3 100644 --- a/devnet/rfc64-persistence-lifecycle/verifier.test.ts +++ b/devnet/rfc64-persistence-lifecycle/verifier.test.ts @@ -27,6 +27,20 @@ test('verifier accepts the exact closed POSIX and Windows evidence schemas', () } }); +test('Windows evidence requires the cross-platform stop boundary and closed stop events', () => { + const valid = validArtifact('win32'); + const verified = verifyGate0ArtifactBytes( + Buffer.from(stableJson(valid)), + HEAD, + 'win32', + ); + assert.equal(verified.sourceCommit, HEAD); + + const oldSignalBoundary = validArtifact('win32'); + oldSignalBoundary.runtimeBoundary.controlChannel = 'stdio and POSIX process signals only'; + assertRejected(oldSignalBoundary, 'win32', 'Windows POSIX signal boundary'); +}); + test('every required field, leaf value, object closure, and array bound fails closed', () => { for (const platform of ['linux', 'win32'] as const) { const valid = validArtifact(platform); @@ -122,6 +136,31 @@ test('verifier rejects a different but internally self-consistent fixture', () = assertRejected(mutation, 'linux', 'self-consistent alternate fixture'); }); +test('verifier rejects missing, false, or extended graceful-stop evidence', () => { + const stopPaths: readonly JsonPath[] = [ + ['phases', 'gracefulRestart', 'priorStop'], + ['phases', 'operatingSystemLeaseRecovery', 'finalGracefulStop'], + ]; + for (const platform of ['linux', 'win32'] as const) { + for (const path of stopPaths) { + const missing = validArtifact(platform); + const missingParent = valueAt(missing, path.slice(0, -1)) as Record; + delete missingParent[path.at(-1) as string]; + assertRejected(missing, platform, `missing ${formatPath(path)}`); + + const falseClosed = validArtifact(platform); + const falseStop = valueAt(falseClosed, path) as Record; + falseStop.persistenceClosed = false; + assertRejected(falseClosed, platform, `false ${formatPath(path)}.persistenceClosed`); + + const extended = validArtifact(platform); + const extendedStop = valueAt(extended, path) as Record; + extendedStop.unexpected = true; + assertRejected(extended, platform, `extended ${formatPath(path)}`); + } + } +}); + function assertRejected( artifact: ReturnType, platform: NodeJS.Platform, @@ -129,7 +168,6 @@ function assertRejected( ): void { assert.throws( () => verifyGate0ArtifactBytes(Buffer.from(stableJson(artifact)), HEAD, platform), - undefined, `verifier accepted mutation: ${label}`, ); } @@ -193,7 +231,7 @@ function validArtifact(platform: 'linux' | 'win32') { runtimeBoundary: { agentClass: 'DKGAgent', processModel: 'three independent production agent processes', - controlChannel: 'stdio and POSIX process signals only', + controlChannel: 'stdio graceful-stop command and forced process termination only', publicDebugEndpointAdded: false, }, fixture: { @@ -221,6 +259,7 @@ function validArtifact(platform: 'linux' | 'win32') { persistenceClosed: false, staged: false, priorExit: { code: 0, signal: null }, + priorStop: { event: 'stopped', graceful: true, persistenceClosed: true }, verifiedRead: true, contender: contender(), exactContentFilesEqual: true, @@ -236,6 +275,7 @@ function validArtifact(platform: 'linux' | 'win32') { contender: contender(), exactContentFilesEqual: true, finalGracefulExit: { code: 0, signal: null }, + finalGracefulStop: { event: 'stopped', graceful: true, persistenceClosed: true }, snapshot: snapshot(), }, }, @@ -244,6 +284,7 @@ function validArtifact(platform: 'linux' | 'win32') { inventoryOperationalWhileRunning: true, competingLeaseRejectedWhileRunning: true, gracefulRestartSucceeded: true, + gracefulStopsClosedPersistence: true, operatingSystemLeaseRecoveredAfterSigkill: true, durableControlObjectBytesPreserved: true, storedEnvelopeSignatureReverifiedOnEveryStart: true, diff --git a/devnet/rfc64-persistence-lifecycle/verifier.ts b/devnet/rfc64-persistence-lifecycle/verifier.ts index c79ee35aff..3ebb9505e8 100644 --- a/devnet/rfc64-persistence-lifecycle/verifier.ts +++ b/devnet/rfc64-persistence-lifecycle/verifier.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { stableJson } from './evidence.js'; -export const GATE0_RAW_SCHEMA_VERSION = 'dkg-rfc64-gate0-persistence-evidence-v3'; +export const GATE0_RAW_SCHEMA_VERSION = 'dkg-rfc64-gate0-persistence-evidence-v4'; export const GATE0_VERDICT_SCHEMA_VERSION = 'dkg-rfc64-gate0-persistence-verdict-v1'; const SHA256_PATTERN = /^0x[0-9a-f]{64}$/u; @@ -124,7 +124,7 @@ function verifyClosedArtifact( ); exact( runtime.controlChannel, - 'stdio and POSIX process signals only', + 'stdio graceful-stop command and forced process termination only', '$.runtimeBoundary.controlChannel', ); exact( @@ -237,6 +237,7 @@ function verifyClosedArtifact( 'inventoryOperational', 'persistenceClosed', 'priorExit', + 'priorStop', 'snapshot', 'staged', 'verifiedRead', @@ -257,6 +258,7 @@ function verifyClosedArtifact( ); verifyLeaseBusy(graceful.contender, '$.phases.gracefulRestart.contender'); verifyExit(graceful.priorExit, '$.phases.gracefulRestart.priorExit', 0, null); + verifyGracefulStop(graceful.priorStop, '$.phases.gracefulRestart.priorStop'); const gracefulSnapshot = verifySnapshot( graceful.snapshot, '$.phases.gracefulRestart.snapshot', @@ -273,6 +275,7 @@ function verifyClosedArtifact( 'contender', 'exactContentFilesEqual', 'finalGracefulExit', + 'finalGracefulStop', 'inventoryOperational', 'persistenceClosed', 'snapshot', @@ -315,6 +318,10 @@ function verifyClosedArtifact( 0, null, ); + verifyGracefulStop( + recovery.finalGracefulStop, + '$.phases.operatingSystemLeaseRecovery.finalGracefulStop', + ); const recoverySnapshot = verifySnapshot( recovery.snapshot, '$.phases.operatingSystemLeaseRecovery.snapshot', @@ -386,6 +393,7 @@ function verifyChecks(value: unknown, platform: NodeJS.Platform): void { 'exactObjectCount', 'exactSignatureCount', 'gracefulRestartSucceeded', + 'gracefulStopsClosedPersistence', 'inventoryOperationalWhileRunning', 'operatingSystemLeaseRecoveredAfterSigkill', 'ownerOnlyFileModes', @@ -397,6 +405,7 @@ function verifyChecks(value: unknown, platform: NodeJS.Platform): void { 'competingLeaseRejectedWhileRunning', 'durableControlObjectBytesPreserved', 'gracefulRestartSucceeded', + 'gracefulStopsClosedPersistence', 'inventoryOperationalWhileRunning', 'operatingSystemLeaseRecoveredAfterSigkill', 'productionAgentStarted', @@ -549,6 +558,13 @@ function verifyExit( exact(exit.signal, expectedSignal, `${path}.signal`); } +function verifyGracefulStop(value: unknown, path: string): void { + const stop = closedRecord(value, path, ['event', 'graceful', 'persistenceClosed']); + exact(stop.event, 'stopped', `${path}.event`); + exact(stop.graceful, true, `${path}.graceful`); + exact(stop.persistenceClosed, true, `${path}.persistenceClosed`); +} + function closedRecord( value: unknown, path: string, From 57f004272c4bcc5f8029634c4d0a30f6536fc1c8 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:33:21 +0200 Subject: [PATCH 100/292] test(rfc64): harden Gate 0 harness cleanup --- .github/workflows/rfc64-inventory-windows.yml | 35 ++--- devnet/rfc64-persistence-lifecycle/README.md | 9 +- .../process-lifecycle.test.ts | 97 ++++++++++++++ .../process-lifecycle.ts | 96 ++++++++++++++ devnet/rfc64-persistence-lifecycle/run.ts | 120 +++++++++++++----- .../rfc64-persistence-lifecycle/tsconfig.json | 23 ++++ package.json | 3 +- 7 files changed, 321 insertions(+), 62 deletions(-) create mode 100644 devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts create mode 100644 devnet/rfc64-persistence-lifecycle/process-lifecycle.ts create mode 100644 devnet/rfc64-persistence-lifecycle/tsconfig.json diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index becacd0bd4..2c3dc835b5 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -11,32 +11,14 @@ on: - 'devnet/_bootstrap/vitest.evidence.config.ts' - 'devnet/rfc64-persistence-lifecycle/**' - 'package.json' - - 'packages/agent/src/rfc64/inventory-v1/**' - - 'packages/agent/src/rfc64/author-catalog-producer.ts' - - 'packages/agent/src/rfc64/control-object-store-v1.ts' - - 'packages/agent/src/rfc64/control-object-store-v1-internal.ts' - - 'packages/agent/src/rfc64/durable-file-store-v1.ts' - - 'packages/agent/src/rfc64/persistence-layout-v1.ts' - - 'packages/agent/src/rfc64/persistence-v1.ts' - - 'packages/agent/src/rfc64/secure-filesystem-policy-v1.ts' - - 'packages/agent/src/dkg-agent-base.ts' - - 'packages/agent/src/index.ts' - - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' - - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' - - 'packages/agent/test/rfc64-author-catalog-producer.test.ts' - - 'packages/agent/test/rfc64-control-object-store-lifecycle-v1.test.ts' - - 'packages/agent/test/rfc64-control-object-store-v1.test.ts' - - 'packages/agent/test/rfc64-durable-file-store-v1.test.ts' - - 'packages/agent/test/rfc64-secure-filesystem-policy-v1.test.ts' - - 'packages/agent/test/support/rfc64-control-object-store-fixtures.ts' - - 'packages/agent/test/support/rfc64-control-object-store-test-support.ts' - - 'packages/agent/test/rfc64-control-store-public-api.typecheck.ts' - - 'packages/agent/test/rfc64-persistence-owner.typecheck.ts' - - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' - - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' - - 'packages/agent/vitest.unit.config.ts' - - 'packages/agent/package.json' + - 'packages/agent/**' + - 'packages/chain/**' - 'packages/core/**' + - 'packages/publisher/**' + - 'packages/query/**' + - 'packages/random-sampling/**' + - 'packages/rdf-utils/**' + - 'packages/storage/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - 'tsconfig.base.json' @@ -78,6 +60,9 @@ jobs: - name: Test RFC-64 evidence harness run: pnpm test:devnet:rfc64-evidence + - name: Strict-typecheck Gate 0 lifecycle harness + run: pnpm typecheck:gate0:rfc64-persistence-lifecycle + - name: Run Gate 0 production persistence lifecycle timeout-minutes: 10 run: pnpm test:gate0:rfc64-persistence-lifecycle diff --git a/devnet/rfc64-persistence-lifecycle/README.md b/devnet/rfc64-persistence-lifecycle/README.md index 4fa3d07d1d..56b83f5814 100644 --- a/devnet/rfc64-persistence-lifecycle/README.md +++ b/devnet/rfc64-persistence-lifecycle/README.md @@ -41,12 +41,19 @@ pnpm test:gate0:rfc64-persistence-lifecycle:generate pnpm test:gate0:rfc64-persistence-lifecycle:verify ``` -Focused evidence-encoder, repository-state, and artifact-publication tests run with: +Focused evidence-encoder, child-process cleanup, repository-state, and +artifact-publication tests run with: ```sh pnpm test:gate0:rfc64-persistence-lifecycle:unit ``` +Strictly typecheck the producer, runner, verifier, and focused tests with: + +```sh +pnpm typecheck:gate0:rfc64-persistence-lifecycle +``` + The default artifact is: ```text diff --git a/devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts b/devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts new file mode 100644 index 0000000000..9bd77d06c8 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + ChildProcessRegistry, + cleanupPreservingPrimaryFailure, + terminateBeforeRejecting, + type ManagedChildProcess, +} from './process-lifecycle.js'; + +class FakeChild extends EventEmitter implements ManagedChildProcess { + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + readonly deliveredSignals: NodeJS.Signals[] = []; + + kill(signal: NodeJS.Signals): boolean { + this.deliveredSignals.push(signal); + return true; + } + + close(code: number | null, signal: NodeJS.Signals | null): void { + this.exitCode = code; + this.signalCode = signal; + this.emit('close', code, signal); + } +} + +test('timeout rejection waits for child close and preserves the primary failure', async () => { + const registry = new ChildProcessRegistry(); + const child = new FakeChild(); + const tracked = registry.track(child); + const primary = new Error('ready timeout'); + let settled = false; + const rejection = terminateBeforeRejecting( + registry, + tracked, + primary, + () => assert.fail('termination should not fail'), + ).finally(() => { settled = true; }); + + await Promise.resolve(); + assert.deepEqual(child.deliveredSignals, ['SIGKILL']); + assert.equal(settled, false); + child.close(null, 'SIGKILL'); + await assert.rejects(rejection, (error) => error === primary); +}); + +test('registry terminates every active child and waits for every close', async () => { + const registry = new ChildProcessRegistry(); + const first = new FakeChild(); + const second = new FakeChild(); + registry.track(first); + registry.track(second); + let settled = false; + const cleanup = registry.terminateAllAndWait().finally(() => { settled = true; }); + + await Promise.resolve(); + assert.deepEqual(first.deliveredSignals, ['SIGKILL']); + assert.deepEqual(second.deliveredSignals, ['SIGKILL']); + first.close(null, 'SIGKILL'); + await Promise.resolve(); + assert.equal(settled, false); + second.close(null, 'SIGKILL'); + await cleanup; + assert.equal(settled, true); +}); + +test('final cleanup reports its failure without replacing the operation failure', async () => { + const primary = new Error('primary harness failure'); + const cleanup = new Error('secondary cleanup failure'); + const reports: Array = []; + + await assert.rejects( + cleanupPreservingPrimaryFailure({ + operationFailed: true, + primaryFailure: primary, + cleanup: async () => { throw cleanup; }, + reportSecondaryFailure: (first, second) => reports.push([first, second]), + }), + (error) => error === primary, + ); + assert.deepEqual(reports, [[primary, cleanup]]); +}); + +test('final cleanup failure remains fatal after a successful operation', async () => { + const cleanup = new Error('cleanup failure'); + await assert.rejects( + cleanupPreservingPrimaryFailure({ + operationFailed: false, + primaryFailure: undefined, + cleanup: async () => { throw cleanup; }, + reportSecondaryFailure: () => assert.fail('there is no primary failure'), + }), + (error) => error === cleanup, + ); +}); diff --git a/devnet/rfc64-persistence-lifecycle/process-lifecycle.ts b/devnet/rfc64-persistence-lifecycle/process-lifecycle.ts new file mode 100644 index 0000000000..1f9063ca21 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/process-lifecycle.ts @@ -0,0 +1,96 @@ +export interface ProcessExitEvidence { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +} + +export interface ManagedChildProcess { + readonly exitCode: number | null; + readonly signalCode: NodeJS.Signals | null; + kill(signal: NodeJS.Signals): boolean; + once( + event: 'close', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): this; +} + +export interface TrackedChildProcess { + readonly child: ManagedChildProcess; + readonly closed: Promise; +} + +export class ChildProcessRegistry { + readonly #active = new Set(); + + track(child: ManagedChildProcess): TrackedChildProcess { + let tracked: TrackedChildProcess; + const closed = new Promise((resolveClose) => { + child.once('close', (code, signal) => resolveClose({ code, signal })); + }); + tracked = Object.freeze({ child, closed }); + this.#active.add(tracked); + void closed.then(() => this.#active.delete(tracked)); + return tracked; + } + + async terminateAndWait( + tracked: TrackedChildProcess, + signal: NodeJS.Signals = 'SIGKILL', + ): Promise { + const { child } = tracked; + let deliveryFailure: Error | undefined; + if (child.exitCode === null && child.signalCode === null) { + const delivered = child.kill(signal); + if (!delivered && child.exitCode === null && child.signalCode === null) { + deliveryFailure = new Error(`failed to deliver ${signal} to a tracked child process`); + } + } + const exit = await tracked.closed; + if (deliveryFailure !== undefined) throw deliveryFailure; + return exit; + } + + async terminateAllAndWait(): Promise { + const results = await Promise.allSettled( + [...this.#active].map((tracked) => this.terminateAndWait(tracked)), + ); + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason as unknown); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError(failures, 'multiple child processes failed to terminate'); + } + } +} + +export async function terminateBeforeRejecting( + registry: ChildProcessRegistry, + tracked: TrackedChildProcess, + primaryFailure: unknown, + reportSecondaryFailure: (primaryFailure: unknown, secondaryFailure: unknown) => void, +): Promise { + try { + await registry.terminateAndWait(tracked); + } catch (secondaryFailure) { + reportSecondaryFailure(primaryFailure, secondaryFailure); + } + throw primaryFailure; +} + +export async function cleanupPreservingPrimaryFailure(input: { + readonly operationFailed: boolean; + readonly primaryFailure: unknown; + readonly cleanup: () => Promise; + readonly reportSecondaryFailure: ( + primaryFailure: unknown, + secondaryFailure: unknown, + ) => void; +}): Promise { + try { + await input.cleanup(); + } catch (cleanupFailure) { + if (!input.operationFailed) throw cleanupFailure; + input.reportSecondaryFailure(input.primaryFailure, cleanupFailure); + } + if (input.operationFailed) throw input.primaryFailure; +} diff --git a/devnet/rfc64-persistence-lifecycle/run.ts b/devnet/rfc64-persistence-lifecycle/run.ts index eff54259b2..24accff985 100644 --- a/devnet/rfc64-persistence-lifecycle/run.ts +++ b/devnet/rfc64-persistence-lifecycle/run.ts @@ -16,6 +16,13 @@ import { readCleanRepositoryHead, stableJson, } from './evidence.js'; +import { + ChildProcessRegistry, + cleanupPreservingPrimaryFailure, + terminateBeforeRejecting, + type ProcessExitEvidence, + type TrackedChildProcess, +} from './process-lifecycle.js'; const EVENT_PREFIX = 'RFC64_GATE0_EVENT '; const GRACEFUL_STOP_COMMAND = 'RFC64_GATE0_GRACEFUL_STOP_V1'; @@ -27,6 +34,7 @@ const CONTROL_ROOT_RELATIVE = 'rfc64-sync/control-objects-v1'; const INVENTORY_RELATIVE = 'rfc64-sync/inventory-v1.sqlite3'; const LEASE_RELATIVE = 'rfc64-sync/inventory-v1.lease.sqlite3'; const PROCESS_TIMEOUT_MS = 60_000; +const children = new ChildProcessRegistry(); interface ProcessEvent { readonly event: string; @@ -35,17 +43,13 @@ interface ProcessEvent { interface AgentHandle { readonly child: ChildProcessWithoutNullStreams; + readonly tracked: TrackedChildProcess; readonly ready: ProcessEvent; readonly events: () => readonly ProcessEvent[]; readonly stdout: () => string; readonly stderr: () => string; } -interface ProcessExitEvidence { - readonly code: number | null; - readonly signal: NodeJS.Signals | null; -} - interface GracefulStopEvidence { readonly event: 'stopped'; readonly graceful: true; @@ -80,6 +84,16 @@ function log(message: string): void { process.stdout.write(`[rfc64-gate0] ${message}\n`); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function reportSecondaryFailure(primaryFailure: unknown, secondaryFailure: unknown): void { + process.stderr.write( + `[rfc64-gate0] secondary cleanup failure while preserving primary failure "${errorMessage(primaryFailure)}": ${errorMessage(secondaryFailure)}\n`, + ); +} + function parseEvent(line: string): ProcessEvent | null { if (!line.startsWith(EVENT_PREFIX)) return null; return JSON.parse(line.slice(EVENT_PREFIX.length)) as ProcessEvent; @@ -100,6 +114,7 @@ function spawnAgent(dataDir: string, stage: boolean): Promise { }, stdio: ['pipe', 'pipe', 'pipe'], }); + const tracked = children.track(child); let stdout = ''; let stderr = ''; let lineBuffer = ''; @@ -108,8 +123,15 @@ function spawnAgent(dataDir: string, stage: boolean): Promise { const timeout = setTimeout(() => { if (settled) return; settled = true; - child.kill('SIGKILL'); - rejectReady(new Error(`agent process timed out before ready\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + const failure = new Error( + `agent process timed out before ready\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ); + void terminateBeforeRejecting( + children, + tracked, + failure, + reportSecondaryFailure, + ).catch(rejectReady); }, PROCESS_TIMEOUT_MS); child.stdout.setEncoding('utf8'); @@ -128,6 +150,7 @@ function spawnAgent(dataDir: string, stage: boolean): Promise { clearTimeout(timeout); resolveReady({ child, + tracked, ready: event, events: () => events, stdout: () => stdout, @@ -140,15 +163,16 @@ function spawnAgent(dataDir: string, stage: boolean): Promise { if (settled) return; settled = true; clearTimeout(timeout); - rejectReady(error); + void tracked.closed.then(() => rejectReady(error)); }); child.once('exit', (code, signal) => { if (settled) return; settled = true; clearTimeout(timeout); - rejectReady(new Error( + const failure = new Error( `agent exited before ready: code=${code} signal=${signal}\nstdout:\n${stdout}\nstderr:\n${stderr}`, - )); + ); + void tracked.closed.then(() => rejectReady(failure)); }); }); } @@ -159,15 +183,23 @@ function waitForAgentClose( ): Promise { return new Promise( (resolveExit, rejectExit) => { + let timedOut = false; const timeout = setTimeout(() => { - handle.child.kill('SIGKILL'); - rejectExit(new Error( + timedOut = true; + const failure = new Error( `agent did not exit after ${action}\nstdout:\n${handle.stdout()}\nstderr:\n${handle.stderr()}`, - )); + ); + void terminateBeforeRejecting( + children, + handle.tracked, + failure, + reportSecondaryFailure, + ).catch(rejectExit); }, PROCESS_TIMEOUT_MS); - handle.child.once('close', (code, exitSignal) => { + void handle.tracked.closed.then((exit) => { + if (timedOut) return; clearTimeout(timeout); - resolveExit({ code, signal: exitSignal }); + resolveExit(exit); }); }, ); @@ -201,7 +233,11 @@ async function stopAgentGracefully(handle: AgentHandle): Promise { const close = waitForAgentClose(handle, 'SIGKILL'); - handle.child.kill('SIGKILL'); + const delivered = handle.child.kill('SIGKILL'); + assert( + delivered || handle.child.exitCode !== null || handle.child.signalCode !== null, + 'failed to deliver SIGKILL to agent process', + ); const exit = await close; assert(exit.code === null && exit.signal === 'SIGKILL', 'unclean stop was not SIGKILL'); assert( @@ -221,25 +257,38 @@ async function probeLease(dataDir: string): Promise { }, stdio: ['ignore', 'pipe', 'pipe'], }); + const tracked = children.track(child); let stdout = ''; let stderr = ''; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => { stdout += chunk; }); child.stderr.on('data', (chunk: string) => { stderr += chunk; }); - const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + let processError: Error | undefined; + child.once('error', (error) => { processError = error; }); + const exit = await new Promise( (resolveExit, rejectExit) => { + let timedOut = false; const timeout = setTimeout(() => { - child.kill('SIGKILL'); - rejectExit(new Error(`lease probe timed out\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + timedOut = true; + const failure = new Error( + `lease probe timed out\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ); + void terminateBeforeRejecting( + children, + tracked, + failure, + reportSecondaryFailure, + ).catch(rejectExit); }, PROCESS_TIMEOUT_MS); - child.once('error', rejectExit); - child.once('exit', (code, signal) => { + void tracked.closed.then((evidence) => { + if (timedOut) return; clearTimeout(timeout); - resolveExit({ code, signal }); + resolveExit(evidence); }); }, ); + if (processError !== undefined) throw processError; if (exit.code !== 0 || exit.signal !== null) { throw new Error(`lease probe failed: ${JSON.stringify(exit)}\nstdout:\n${stdout}\nstderr:\n${stderr}`); } @@ -351,13 +400,11 @@ const artifactPath = resolve(process.env.DKG_RFC64_GATE0_ARTIFACT ?? DEFAULT_ART const testedRepositoryHead = readCleanRepositoryHead(REPO_ROOT); log(`testing clean repository HEAD ${testedRepositoryHead}`); const dataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate0-')); -const active = new Set(); - +let operationFailed = false; +let primaryFailure: unknown; try { log('phase 1/3: start production DKGAgent and stage deterministic control object'); const initial = await spawnAgent(dataDir, true); - active.add(initial.child); - initial.child.once('exit', () => active.delete(initial.child)); assertReady(initial.ready, true); const initialProbe = await probeLease(dataDir); assertLeaseBusy(initialProbe); @@ -367,8 +414,6 @@ try { log('phase 2/3: restart, re-read exact bytes, then simulate unclean process death'); const gracefulRestart = await spawnAgent(dataDir, false); - active.add(gracefulRestart.child); - gracefulRestart.child.once('exit', () => active.delete(gracefulRestart.child)); assertReady(gracefulRestart.ready, false); const gracefulProbe = await probeLease(dataDir); assertLeaseBusy(gracefulProbe); @@ -382,8 +427,6 @@ try { log('phase 3/3: recover OS lease, re-read and re-verify exact durable bytes'); const recovered = await spawnAgent(dataDir, false); - active.add(recovered.child); - recovered.child.once('exit', () => active.delete(recovered.child)); assertReady(recovered.ready, false); const recoveredProbe = await probeLease(dataDir); assertLeaseBusy(recoveredProbe); @@ -498,9 +541,16 @@ try { log(`artifact publication durability: ${publication.durability}`); log(`artifact SHA-256: ${publication.sha256}`); log('raw Gate 0 evidence remains not evaluated until the separate verifier accepts it'); -} finally { - for (const child of active) { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); - } - rmSync(dataDir, { recursive: true, force: true }); +} catch (error) { + operationFailed = true; + primaryFailure = error; } +await cleanupPreservingPrimaryFailure({ + operationFailed, + primaryFailure, + cleanup: async () => { + await children.terminateAllAndWait(); + rmSync(dataDir, { recursive: true, force: true }); + }, + reportSecondaryFailure, +}); diff --git a/devnet/rfc64-persistence-lifecycle/tsconfig.json b/devnet/rfc64-persistence-lifecycle/tsconfig.json new file mode 100644 index 0000000000..84e2a8fc57 --- /dev/null +++ b/devnet/rfc64-persistence-lifecycle/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "../..", + "sourceMap": false, + "types": ["node"] + }, + "include": [ + "agent-process.ts", + "evidence.test.ts", + "evidence.ts", + "lease-probe.ts", + "process-lifecycle.test.ts", + "process-lifecycle.ts", + "run.ts", + "verifier.test.ts", + "verifier.ts", + "verify.ts" + ] +} diff --git a/package.json b/package.json index 69ea3d7da7..21169440f9 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,8 @@ "test:gate0:rfc64-persistence-lifecycle": "pnpm run test:gate0:rfc64-persistence-lifecycle:generate && pnpm run test:gate0:rfc64-persistence-lifecycle:verify", "test:gate0:rfc64-persistence-lifecycle:generate": "pnpm -r --filter @origintrail-official/dkg-agent... --filter '!@origintrail-official/dkg-evm-module' run build && node --experimental-sqlite --import tsx devnet/rfc64-persistence-lifecycle/run.ts", "test:gate0:rfc64-persistence-lifecycle:verify": "node --import tsx devnet/rfc64-persistence-lifecycle/verify.ts", - "test:gate0:rfc64-persistence-lifecycle:unit": "node --import tsx --test devnet/rfc64-persistence-lifecycle/evidence.test.ts devnet/rfc64-persistence-lifecycle/verifier.test.ts", + "test:gate0:rfc64-persistence-lifecycle:unit": "node --import tsx --test devnet/rfc64-persistence-lifecycle/evidence.test.ts devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts devnet/rfc64-persistence-lifecycle/verifier.test.ts", + "typecheck:gate0:rfc64-persistence-lifecycle": "tsc --project devnet/rfc64-persistence-lifecycle/tsconfig.json", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", "test:devnet:v10-stress": "vitest run --config devnet/v10-stress/vitest.config.ts", From 8c8d7bb1275869bab293c188b7b8769207dcf6e3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:36:21 +0200 Subject: [PATCH 101/292] fix(devnet): close RFC-64 evidence input boundaries --- .github/workflows/rfc64-inventory-windows.yml | 24 ++ devnet/_bootstrap/RFC64_EVIDENCE.md | 31 ++- devnet/_bootstrap/rfc64-evidence.test.ts | 237 ++++++++++++++++++ devnet/_bootstrap/rfc64-evidence.ts | 194 +++++++++++--- 4 files changed, 437 insertions(+), 49 deletions(-) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 2c3dc835b5..60401fcf5a 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -1,6 +1,30 @@ name: RFC-64 inventory Windows gate on: + push: + branches: + - integration/rfc64-devnet + paths: + - '.github/workflows/rfc64-inventory-windows.yml' + - 'devnet/_bootstrap/package.json' + - 'devnet/_bootstrap/rdf-canonize.d.ts' + - 'devnet/_bootstrap/rfc64-evidence*' + - 'devnet/_bootstrap/tsconfig.evidence.json' + - 'devnet/_bootstrap/vitest.evidence.config.ts' + - 'package.json' + - 'packages/agent/src/rfc64/inventory-v1/**' + - 'packages/agent/src/rfc64/author-catalog-producer.ts' + - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' + - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' + - 'packages/agent/test/rfc64-author-catalog-producer.test.ts' + - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' + - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' + - 'packages/agent/vitest.unit.config.ts' + - 'packages/agent/package.json' + - 'packages/core/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.base.json' pull_request: paths: - '.github/workflows/rfc64-inventory-windows.yml' diff --git a/devnet/_bootstrap/RFC64_EVIDENCE.md b/devnet/_bootstrap/RFC64_EVIDENCE.md index 96169cc616..541e5d715e 100644 --- a/devnet/_bootstrap/RFC64_EVIDENCE.md +++ b/devnet/_bootstrap/RFC64_EVIDENCE.md @@ -33,11 +33,15 @@ produce a passing comparison. Created and validated snapshots are defensively copied and deeply frozen. Run evidence closes over its own frozen expected/observed copies, so later caller -mutation cannot change a previously derived `passed` result. String timestamps -must carry `Z` or an explicit UTC offset, contain a real Gregorian calendar -instant, use at most millisecond fractional precision, and are emitted in -canonical UTC form. Snapshot and failure-record validation reject -accessors, proxies, sparse/custom arrays, and custom containers before capture. +mutation cannot change a previously derived `passed` result. Public constructors +capture caller-owned records and arrays exactly once through data descriptors; +proxies, accessors, sparse/custom arrays, and custom containers are rejected +before caller code can affect validation. String timestamps must carry `Z` or an +explicit UTC offset, contain a real Gregorian calendar instant, and use at most +millisecond fractional precision. Timestamp inputs are exactly primitive strings +or genuine non-proxy `Date` objects. Dates are read through the intrinsic +`Date.prototype.getTime` and normalized through a fresh `Date`; durations must be +non-negative safe integers. Emitted timestamps use canonical UTC form. The run artifact adds the stable gate/observer label, selected source peer, canonical ISO timing and duration, attempt/retry/failure details, expected and @@ -47,12 +51,17 @@ Artifact publication uses a same-directory exclusive temporary file, fsyncs its contents, atomically renames it, and verifies the published bytes. POSIX publication enforces mode 0600 and fsyncs the containing directory. Every directory created for a nested artifact path is made durable through a -parent-directory fsync before it is used. Existing symlink targets and -symlinked or changing directory topology are rejected. On Windows, Node cannot -fsync a directory through its ordinary filesystem API and POSIX mode bits do -not prove ACL isolation, so the returned publication metadata explicitly -reports `file-flush-rename-no-directory-flush` and `windows-inherited-acl` -instead of claiming the stronger POSIX policies. +parent-directory fsync before it is used. The caller must provide a trusted, +static parent-directory topology for the full call. Node has no portable +directory-handle-relative `openat`/`renameat` surface, so path checks cannot +prevent a cooperating process from changing a parent between validation and +rename. Initial checks reject existing symlinks and post-operation checks provide +best-effort detection, but an error raised after rename can leave publication +side effects in the changed topology. On Windows, Node cannot fsync a directory +through its ordinary filesystem API and POSIX mode bits do not prove ACL +isolation, so the returned publication metadata explicitly reports +`file-flush-rename-no-directory-flush` and `windows-inherited-acl` instead of +claiming the stronger POSIX policies. ## Harness use diff --git a/devnet/_bootstrap/rfc64-evidence.test.ts b/devnet/_bootstrap/rfc64-evidence.test.ts index 05de74a971..f5d0c402e7 100644 --- a/devnet/_bootstrap/rfc64-evidence.test.ts +++ b/devnet/_bootstrap/rfc64-evidence.test.ts @@ -32,6 +32,8 @@ import { stableJsonStringify, validateRfc64SemanticSnapshot, writeStableJsonArtifact, + type Rfc64DevnetEvidenceInput, + type Rfc64KnowledgeAssetObservation, type Rfc64SemanticSnapshotV1, } from './rfc64-evidence.js'; @@ -152,6 +154,90 @@ describe('RFC-64 semantic snapshot evidence', () => { ])).rejects.toThrow(/Duplicate canonical Knowledge Asset UAL/); }); + it('rejects hostile observation containers before invoking caller code', async () => { + let customMapCalls = 0; + const customObservations = [{ + ual: 'not-a-ual', + semanticNQuads: 'not N-Quads', + }]; + Object.defineProperty(customObservations, 'map', { + configurable: true, + enumerable: true, + value: () => { + customMapCalls += 1; + return []; + }, + }); + await expect(createRfc64SemanticSnapshot(customObservations)) + .rejects.toThrow(/custom array property "map"/); + expect(customMapCalls).toBe(0); + + const sparseObservations = new Array(1) as Rfc64KnowledgeAssetObservation[]; + await expect(createRfc64SemanticSnapshot(sparseObservations)) + .rejects.toThrow(/sparse array/); + + const customPrototypeObservations = [{ + ual: UAL_A, + semanticNQuads: '', + }]; + Object.setPrototypeOf( + customPrototypeObservations, + Object.create(Array.prototype), + ); + await expect(createRfc64SemanticSnapshot(customPrototypeObservations)) + .rejects.toThrow(/custom array prototype/); + + let proxyReads = 0; + const proxiedObservations = new Proxy([{ + ual: UAL_A, + semanticNQuads: '', + }], { + get(target, property, receiver) { + proxyReads += 1; + return Reflect.get(target, property, receiver); + }, + }); + await expect(createRfc64SemanticSnapshot(proxiedObservations)) + .rejects.toThrow(/observations must not be a proxy/); + expect(proxyReads).toBe(0); + + let ualReads = 0; + const accessorObservation = { semanticNQuads: '' } as { + ual: string; + semanticNQuads: string; + }; + Object.defineProperty(accessorObservation, 'ual', { + enumerable: true, + get: () => { + ualReads += 1; + return UAL_A; + }, + }); + await expect(createRfc64SemanticSnapshot([accessorObservation])) + .rejects.toThrow(/observations\[0\]\.ual must not be an accessor property/); + expect(ualReads).toBe(0); + }); + + it('captures N-Quads fragment arrays once without reading accessors', async () => { + let fragmentReads = 0; + const fragments: string[] = []; + Object.defineProperty(fragments, '0', { + configurable: true, + enumerable: true, + get: () => { + fragmentReads += 1; + return fragmentReads === 1 + ? ' .' + : ' .'; + }, + }); + fragments.length = 1; + + await expect(canonicalizeSemanticNQuads(fragments)) + .rejects.toThrow(/semanticNQuads\[0\] must be an enumerable data property/); + expect(fragmentReads).toBe(0); + }); + it('rejects malformed N-Quads instead of hashing unchecked text', async () => { await expect(canonicalizeSemanticNQuads('this is not N-Quads')) .rejects.toThrow(/Invalid semantic N-Quads/); @@ -391,6 +477,73 @@ describe('RFC-64 devnet run artifact', () => { })).toThrow(/one failure for each of the 1 retried attempts/); }); + it('captures the run record and retry array once before field validation', async () => { + const snapshot = await createRfc64SemanticSnapshot([]); + const baseInput = { + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + startedAt: '2026-07-19T12:00:00Z', + completedAt: '2026-07-19T12:00:01Z', + attemptCount: 1, + expected: snapshot, + }; + + let observedReads = 0; + const accessorInput = { ...baseInput } as Rfc64DevnetEvidenceInput; + Object.defineProperty(accessorInput, 'observed', { + enumerable: true, + get: () => { + observedReads += 1; + return observedReads === 1 ? null : snapshot; + }, + }); + expect(() => createRfc64DevnetEvidence(accessorInput)) + .toThrow(/input\.observed must not be an accessor property/); + expect(observedReads).toBe(0); + + let proxyReads = 0; + const proxiedInput = new Proxy({ ...baseInput, observed: snapshot }, { + get(target, property, receiver) { + proxyReads += 1; + return Reflect.get(target, property, receiver); + }, + }); + expect(() => createRfc64DevnetEvidence(proxiedInput)) + .toThrow(/input must not be a proxy/); + expect(proxyReads).toBe(0); + + const customPrototypeInput = Object.assign( + Object.create({ inherited: true }) as Rfc64DevnetEvidenceInput, + { ...baseInput, observed: snapshot }, + ); + expect(() => createRfc64DevnetEvidence(customPrototypeInput)) + .toThrow(/input must be a plain data object/); + + let retryMapCalls = 0; + const retryFailures = [{ + attempt: 1, + code: 'TRANSIENT', + message: 'retry', + retryable: true, + }]; + Object.defineProperty(retryFailures, 'map', { + configurable: true, + enumerable: true, + value: () => { + retryMapCalls += 1; + return []; + }, + }); + expect(() => createRfc64DevnetEvidence({ + ...baseInput, + attemptCount: 2, + retryFailures, + observed: snapshot, + })).toThrow(/retryFailures must not contain custom array property "map"/); + expect(retryMapCalls).toBe(0); + }); + it('rejects accessor and proxy failure records before reading their fields', async () => { const snapshot = await createRfc64SemanticSnapshot([]); let retryableReads = 0; @@ -501,6 +654,57 @@ describe('RFC-64 devnet run artifact', () => { } }); + it('normalizes genuine Dates intrinsically and rejects non-Date or unsafe timing', async () => { + const snapshot = await createRfc64SemanticSnapshot([]); + const baseInput = { + gate: 'gate-1', + observer: 'receiver', + sourcePeerId: 'source', + attemptCount: 1, + expected: snapshot, + observed: snapshot, + }; + + expect(() => createRfc64DevnetEvidence({ + ...baseInput, + startedAt: 0 as unknown as string, + completedAt: 1_000 as unknown as string, + })).toThrow(/primitive timestamp string or a non-proxy Date/); + + class HostileDate extends Date { + override getTime(): number { + return 0; + } + + override toISOString(): string { + return 'not-an-ISO-instant'; + } + } + const subclassEvidence = createRfc64DevnetEvidence({ + ...baseInput, + startedAt: new HostileDate('2026-07-19T12:00:00.000Z'), + completedAt: new HostileDate('2026-07-19T12:00:01.000Z'), + }); + expect(subclassEvidence.timing).toEqual({ + startedAt: '2026-07-19T12:00:00.000Z', + completedAt: '2026-07-19T12:00:01.000Z', + durationMs: 1_000, + }); + + const proxiedDate = new Proxy(new Date('2026-07-19T12:00:00Z'), {}); + expect(() => createRfc64DevnetEvidence({ + ...baseInput, + startedAt: proxiedDate, + completedAt: new Date('2026-07-19T12:00:01Z'), + })).toThrow(/primitive timestamp string or a non-proxy Date/); + + expect(() => createRfc64DevnetEvidence({ + ...baseInput, + startedAt: new Date(-8_640_000_000_000_000 + 1), + completedAt: new Date(8_640_000_000_000_000), + })).toThrow(/timing\.durationMs must be a non-negative safe integer/); + }); + it('writes byte-identical stable JSON independent of object insertion order', () => { const directory = createTemporaryDirectory(); const firstPath = join(directory, 'nested', 'first.json'); @@ -664,6 +868,39 @@ describe('RFC-64 devnet run artifact', () => { }, ); + it.runIf(process.platform !== 'win32')( + 'detects a parent swap after rename while preserving the possible side effect', + () => { + const directory = createTemporaryDirectory(); + const checkedDirectory = join(directory, 'checked'); + const movedDirectory = join(directory, 'moved'); + const target = join(checkedDirectory, 'artifact.json'); + mkdirSync(checkedDirectory, { mode: 0o700 }); + const originalRename = fs.renameSync; + let injectedSwap = false; + fs.renameSync = ((source, destination) => { + if (!injectedSwap && String(destination) === target) { + injectedSwap = true; + originalRename(checkedDirectory, movedDirectory); + symlinkSync(movedDirectory, checkedDirectory, 'dir'); + } + originalRename(source, destination); + }) as typeof fs.renameSync; + syncBuiltinESMExports(); + try { + expect(() => writeStableJsonArtifact(target, { generation: 1 })) + .toThrow(/directory topology contains a symbolic link/); + } finally { + fs.renameSync = originalRename; + syncBuiltinESMExports(); + } + + expect(injectedSwap).toBe(true); + expect(readFileSync(join(movedDirectory, 'artifact.json'), 'utf8')) + .toBe(stableJsonStringify({ generation: 1 })); + }, + ); + it('declares the weaker Windows namespace and inherited-ACL policy', () => { const directory = createTemporaryDirectory(); const target = join(directory, 'windows-policy.json'); diff --git a/devnet/_bootstrap/rfc64-evidence.ts b/devnet/_bootstrap/rfc64-evidence.ts index 113c242f88..73279a097e 100644 --- a/devnet/_bootstrap/rfc64-evidence.ts +++ b/devnet/_bootstrap/rfc64-evidence.ts @@ -224,20 +224,24 @@ function canonicalUal(rawUal: string): string { } function nquadsInputText(input: string | readonly string[]): string { - if (typeof input === 'string') return input; - if (!Array.isArray(input)) { + const captured = stableJsonValue(input, 'semanticNQuads', new Set()); + if (typeof captured === 'string') return captured; + if (!Array.isArray(captured)) { throw new Rfc64EvidenceValidationError( 'semanticNQuads must be a string or an array of strings', ); } - for (const [index, fragment] of input.entries()) { + const fragments = new Array(captured.length); + for (let index = 0; index < captured.length; index += 1) { + const fragment = captured[index]; if (typeof fragment !== 'string') { throw new Rfc64EvidenceValidationError( `semanticNQuads[${index}] must be a string`, ); } + fragments[index] = fragment; } - return input.join('\n'); + return fragments.join('\n'); } const DEFAULT_GRAPH_TERM = Object.freeze({ @@ -382,24 +386,41 @@ function closeComparison( export async function createRfc64SemanticSnapshot( observations: readonly Rfc64KnowledgeAssetObservation[], ): Promise { - if (!Array.isArray(observations)) { + // Capture the complete caller-owned tree before doing any asynchronous work. + // The capture reads data descriptors once, rejects proxies/accessors and + // exotic containers, and gives the rest of this function ordinary arrays it + // owns. In particular, never dispatch through a caller-provided `map` method. + const captured = stableJsonValue( + observations, + 'observations', + new Set(), + ); + if (!Array.isArray(captured)) { throw new Rfc64EvidenceValidationError('observations must be an array'); } - const assets = await Promise.all(observations.map(async (observation, index) => { + const pendingAssets: Promise[] = []; + for (let index = 0; index < captured.length; index += 1) { + const observation = captured[index]; if (!observation || typeof observation !== 'object') { throw new Rfc64EvidenceValidationError( `observations[${index}] must be an object`, ); } - const ual = canonicalUal(observation.ual); - const nquads = await canonicalizeSemanticNQuads(observation.semanticNQuads); - return { - ual, - quadCount: nquads.quadCount, - semanticNQuadsSha256: nquads.sha256, - } satisfies Rfc64KnowledgeAssetEvidenceV1; - })); + const record = observation as Record; + pendingAssets[index] = (async () => { + const ual = canonicalUal(record.ual as string); + const nquads = await canonicalizeSemanticNQuads( + record.semanticNQuads as string | readonly string[], + ); + return { + ual, + quadCount: nquads.quadCount, + semanticNQuadsSha256: nquads.sha256, + } satisfies Rfc64KnowledgeAssetEvidenceV1; + })(); + } + const assets = await Promise.all(pendingAssets); assets.sort((left, right) => compareText(left.ual, right.ual)); for (let index = 1; index < assets.length; index += 1) { @@ -604,6 +625,58 @@ function requiredLabel(value: unknown, label: string): string { return value.trim(); } +/** + * Capture an API input record without recursively interpreting its values. + * Dates need specialized handling, while snapshots and failure records are + * captured by their own validators. Every own field is nevertheless resolved + * from one data descriptor before any field-level validation starts. + */ +function capturePlainDataRecord( + value: unknown, + label: string, +): Record { + if ( + value !== null + && (typeof value === 'object' || typeof value === 'function') + && utilTypes.isProxy(value) + ) { + throw new Rfc64EvidenceValidationError(`${label} must not be a proxy`); + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Rfc64EvidenceValidationError(`${label} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Rfc64EvidenceValidationError( + `${label} must be a plain data object`, + ); + } + const source = value as Record; + const ownKeys = Reflect.ownKeys(source); + if (ownKeys.some((key) => typeof key === 'symbol')) { + throw new Rfc64EvidenceValidationError(`${label} must not contain symbol keys`); + } + const captured: Record = Object.create(null) as Record< + string, + unknown + >; + for (const key of (ownKeys as string[]).sort(compareText)) { + const descriptor = Object.getOwnPropertyDescriptor(source, key)!; + if (!('value' in descriptor)) { + throw new Rfc64EvidenceValidationError( + `${label}.${key} must not be an accessor property`, + ); + } + if (!descriptor.enumerable) { + throw new Rfc64EvidenceValidationError( + `${label}.${key} must not be a hidden non-enumerable property`, + ); + } + captured[key] = descriptor.value; + } + return captured; +} + function captureFailureRecord( value: Rfc64FailureV1, label: string, @@ -643,10 +716,11 @@ function gregorianMonthLength(year: number, month: number): number { return [4, 6, 9, 11].includes(month) ? 30 : 31; } -function canonicalInstant(value: Date | string, label: string): { +function canonicalInstant(value: unknown, label: string): { readonly epochMs: number; readonly iso: string; } { + let epochMs: number; if (typeof value === 'string') { const match = RFC3339_INSTANT_RE.exec(value); if (match === null) { @@ -681,13 +755,25 @@ function canonicalInstant(value: Date | string, label: string): { `${label} must be a valid, representable RFC 3339 timestamp`, ); } + epochMs = Date.prototype.getTime.call(new Date(value)); + } else { + if ( + value === null + || typeof value !== 'object' + || utilTypes.isProxy(value) + || !utilTypes.isDate(value) + ) { + throw new Rfc64EvidenceValidationError( + `${label} must be a primitive timestamp string or a non-proxy Date`, + ); + } + // Ignore subclass overrides and read only the genuine Date internal slot. + epochMs = Date.prototype.getTime.call(value); } - const date = value instanceof Date ? value : new Date(value); - const epochMs = date.getTime(); - if (!Number.isFinite(epochMs)) { + if (!Number.isFinite(epochMs) || !Number.isInteger(epochMs)) { throw new Rfc64EvidenceValidationError(`${label} must be a valid timestamp`); } - return { epochMs, iso: date.toISOString() }; + return { epochMs, iso: new Date(epochMs).toISOString() }; } /** @@ -697,43 +783,67 @@ function canonicalInstant(value: Date | string, label: string): { export function createRfc64DevnetEvidence( input: Rfc64DevnetEvidenceInput, ): Rfc64DevnetEvidenceV1 { - const gate = requiredLabel(input.gate, 'gate'); - const observer = requiredLabel(input.observer, 'observer'); - const sourcePeerId = input.sourcePeerId === null + const capturedInput = capturePlainDataRecord(input, 'input'); + const gate = requiredLabel(capturedInput.gate, 'gate'); + const observer = requiredLabel(capturedInput.observer, 'observer'); + const sourcePeerIdInput = capturedInput.sourcePeerId; + const sourcePeerId = sourcePeerIdInput === null ? null - : requiredLabel(input.sourcePeerId, 'sourcePeerId'); - const startedAt = canonicalInstant(input.startedAt, 'startedAt'); - const completedAt = canonicalInstant(input.completedAt, 'completedAt'); + : requiredLabel(sourcePeerIdInput, 'sourcePeerId'); + const startedAt = canonicalInstant(capturedInput.startedAt, 'startedAt'); + const completedAt = canonicalInstant(capturedInput.completedAt, 'completedAt'); if (completedAt.epochMs < startedAt.epochMs) { throw new Rfc64EvidenceValidationError( 'completedAt must not be before startedAt', ); } + const durationMs = completedAt.epochMs - startedAt.epochMs; + if (!Number.isSafeInteger(durationMs)) { + throw new Rfc64EvidenceValidationError( + 'timing.durationMs must be a non-negative safe integer', + ); + } const attemptCount = assertNonNegativeSafeInteger( - input.attemptCount, + capturedInput.attemptCount, 'attemptCount', ); if (attemptCount < 1) { throw new Rfc64EvidenceValidationError('attemptCount must be at least 1'); } - const expected = validateRfc64SemanticSnapshot(input.expected); - const observed = input.observed === null + const expected = validateRfc64SemanticSnapshot( + capturedInput.expected as Rfc64SemanticSnapshotV1, + ); + const observedInput = capturedInput.observed; + const observed = observedInput === null ? null - : validateRfc64SemanticSnapshot(input.observed); - const terminalFailureInput = input.terminalFailure; + : validateRfc64SemanticSnapshot(observedInput as Rfc64SemanticSnapshotV1); + const terminalFailureInput = capturedInput.terminalFailure; const terminalFailure = terminalFailureInput == null ? null - : canonicalFailure(terminalFailureInput, 'terminalFailure'); - if (input.observed === null && terminalFailure === null) { + : canonicalFailure(terminalFailureInput as Rfc64FailureV1, 'terminalFailure'); + if (observedInput === null && terminalFailure === null) { throw new Rfc64EvidenceValidationError( 'a missing observed snapshot requires terminalFailure evidence', ); } - const failures = (input.retryFailures ?? []).map((failure, index) => { + const capturedRetryFailures = stableJsonValue( + capturedInput.retryFailures ?? [], + 'retryFailures', + new Set(), + ); + if (!Array.isArray(capturedRetryFailures)) { + throw new Rfc64EvidenceValidationError('retryFailures must be an array'); + } + const failures: Rfc64RetryFailureV1[] = []; + for (let index = 0; index < capturedRetryFailures.length; index += 1) { const label = `retryFailures[${index}]`; - const captured = captureFailureRecord(failure, label); + const failure = capturedRetryFailures[index]; + if (!failure || typeof failure !== 'object' || Array.isArray(failure)) { + throw new Rfc64EvidenceValidationError(`${label} must be an object`); + } + const captured = failure as Record; const canonical = canonicalFailureFromCaptured(captured, label); const attempt = assertNonNegativeSafeInteger( captured.attempt, @@ -744,8 +854,12 @@ export function createRfc64DevnetEvidence( `retryFailures[${index}].attempt must be between 1 and attemptCount - 1`, ); } - return Object.freeze({ attempt, ...canonical }) satisfies Rfc64RetryFailureV1; - }).sort((left, right) => left.attempt - right.attempt); + failures[index] = Object.freeze({ + attempt, + ...canonical, + }) satisfies Rfc64RetryFailureV1; + } + failures.sort((left, right) => left.attempt - right.attempt); for (let index = 1; index < failures.length; index += 1) { if (failures[index - 1]!.attempt === failures[index]!.attempt) { throw new Rfc64EvidenceValidationError( @@ -778,7 +892,7 @@ export function createRfc64DevnetEvidence( timing: Object.freeze({ startedAt: startedAt.iso, completedAt: completedAt.iso, - durationMs: completedAt.epochMs - startedAt.epochMs, + durationMs, }), attempts: Object.freeze({ total: attemptCount, @@ -1082,7 +1196,11 @@ function cleanupTemporaryArtifact( * Atomically publish byte-stable JSON through a same-directory temporary file. * POSIX publication enforces mode 0600 and directory-fsync namespace barriers; * Windows flushes the file and reports rename-only namespace durability plus - * inherited ACL protection. Both policies reject symlinked/changing topology. + * inherited ACL protection. The caller must keep the parent directory topology + * trusted and static for the duration of this call: Node exposes no portable + * directory-handle-relative rename/open API with which to close path TOCTOU. + * The checks below reject pre-existing symlinks and detect many concurrent + * changes, but a post-rename error can still leave publication side effects. */ export function writeStableJsonArtifact( path: string, From 2c209ae1fa4d94231c1d5610170627fdeecd191c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:42:36 +0200 Subject: [PATCH 102/292] ci(rfc64): align Gate 0 integration coverage --- .github/workflows/rfc64-inventory-windows.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 60401fcf5a..b37f46402a 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -11,17 +11,16 @@ on: - 'devnet/_bootstrap/rfc64-evidence*' - 'devnet/_bootstrap/tsconfig.evidence.json' - 'devnet/_bootstrap/vitest.evidence.config.ts' + - 'devnet/rfc64-persistence-lifecycle/**' - 'package.json' - - 'packages/agent/src/rfc64/inventory-v1/**' - - 'packages/agent/src/rfc64/author-catalog-producer.ts' - - 'packages/agent/test/rfc64-inventory-v1-*.test.ts' - - 'packages/agent/test/rfc64-agent-inventory-lifecycle.test.ts' - - 'packages/agent/test/rfc64-author-catalog-producer.test.ts' - - 'packages/agent/test/fixtures/rfc64-inventory-v1-child.ts' - - 'packages/agent/test/fixtures/rfc64-agent-inventory-lifecycle-child.ts' - - 'packages/agent/vitest.unit.config.ts' - - 'packages/agent/package.json' + - 'packages/agent/**' + - 'packages/chain/**' - 'packages/core/**' + - 'packages/publisher/**' + - 'packages/query/**' + - 'packages/random-sampling/**' + - 'packages/rdf-utils/**' + - 'packages/storage/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - 'tsconfig.base.json' From 7aa38c38ad817efd34a5f5d6c3ed724dd2c15df7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 18:52:13 +0200 Subject: [PATCH 103/292] test(rfc64): bound Gate 0 process cleanup --- .github/workflows/rfc64-inventory-windows.yml | 6 +- .../process-lifecycle.test.ts | 84 ++++++++++++- .../process-lifecycle.ts | 112 ++++++++++++++---- devnet/rfc64-persistence-lifecycle/run.ts | 16 +-- 4 files changed, 183 insertions(+), 35 deletions(-) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index b37f46402a..1bd19c3120 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -58,7 +58,7 @@ jobs: inventory-lifecycle: name: SQLite lifecycle (Windows) runs-on: windows-latest - timeout-minutes: 25 + timeout-minutes: 40 steps: - name: Checkout candidate uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -87,7 +87,9 @@ jobs: run: pnpm typecheck:gate0:rfc64-persistence-lifecycle - name: Run Gate 0 production persistence lifecycle - timeout-minutes: 10 + # Nine sequential 60s process bounds plus bounded forced-close cleanup + # fit below 10m; reserve additional time for the build and verifier. + timeout-minutes: 20 run: pnpm test:gate0:rfc64-persistence-lifecycle - name: Build agent dependency closure diff --git a/devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts b/devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts index 9bd77d06c8..684daa829c 100644 --- a/devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts +++ b/devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts @@ -14,9 +14,13 @@ class FakeChild extends EventEmitter implements ManagedChildProcess { signalCode: NodeJS.Signals | null = null; readonly deliveredSignals: NodeJS.Signals[] = []; + constructor(readonly killResult = true) { + super(); + } + kill(signal: NodeJS.Signals): boolean { this.deliveredSignals.push(signal); - return true; + return this.killResult; } close(code: number | null, signal: NodeJS.Signals | null): void { @@ -46,14 +50,17 @@ test('timeout rejection waits for child close and preserves the primary failure' await assert.rejects(rejection, (error) => error === primary); }); -test('registry terminates every active child and waits for every close', async () => { +test('ordered cleanup waits for every child close before removing data', async () => { const registry = new ChildProcessRegistry(); const first = new FakeChild(); const second = new FakeChild(); registry.track(first); registry.track(second); let settled = false; - const cleanup = registry.terminateAllAndWait().finally(() => { settled = true; }); + let dataRemoved = false; + const cleanup = registry.terminateAllThenCleanup(() => { + dataRemoved = true; + }).finally(() => { settled = true; }); await Promise.resolve(); assert.deepEqual(first.deliveredSignals, ['SIGKILL']); @@ -61,9 +68,53 @@ test('registry terminates every active child and waits for every close', async ( first.close(null, 'SIGKILL'); await Promise.resolve(); assert.equal(settled, false); + assert.equal(dataRemoved, false); second.close(null, 'SIGKILL'); await cleanup; assert.equal(settled, true); + assert.equal(dataRemoved, true); +}); + +test('post-SIGKILL close deadline is bounded and prevents data removal', async () => { + const registry = new ChildProcessRegistry(10); + const child = new FakeChild(); + const tracked = registry.track(child); + let dataRemoved = false; + + await assert.rejects( + registry.terminateAndWait(tracked), + /did not emit close within 10ms after SIGKILL/, + ); + assert.deepEqual(child.deliveredSignals, ['SIGKILL']); + + await assert.rejects( + registry.terminateAllThenCleanup(() => { dataRemoved = true; }), + /did not emit close within 10ms after SIGKILL/, + ); + assert.equal(dataRemoved, false); +}); + +test('ordered cleanup aggregates termination and data-removal failures after close', async () => { + const registry = new ChildProcessRegistry(); + const child = new FakeChild(false); + registry.track(child); + const removalFailure = new Error('data removal failed'); + let dataRemovalAttempted = false; + const cleanup = registry.terminateAllThenCleanup(() => { + dataRemovalAttempted = true; + throw removalFailure; + }); + + await Promise.resolve(); + child.close(null, 'SIGKILL'); + await assert.rejects(cleanup, (error) => { + assert(error instanceof AggregateError); + assert.equal(error.errors.length, 2); + assert.match(String(error.errors[0]), /failed to deliver SIGKILL/); + assert.equal(error.errors[1], removalFailure); + return true; + }); + assert.equal(dataRemovalAttempted, true); }); test('final cleanup reports its failure without replacing the operation failure', async () => { @@ -95,3 +146,30 @@ test('final cleanup failure remains fatal after a successful operation', async ( (error) => error === cleanup, ); }); + +test('reporter exceptions never replace the primary failure', async () => { + const primary = new Error('primary failure'); + const registry = new ChildProcessRegistry(); + const child = new FakeChild(false); + const tracked = registry.track(child); + const termination = terminateBeforeRejecting( + registry, + tracked, + primary, + () => { throw new Error('reporter failed'); }, + ); + + await Promise.resolve(); + child.close(null, 'SIGKILL'); + await assert.rejects(termination, (error) => error === primary); + + await assert.rejects( + cleanupPreservingPrimaryFailure({ + operationFailed: true, + primaryFailure: primary, + cleanup: async () => { throw new Error('cleanup failed'); }, + reportSecondaryFailure: () => { throw new Error('reporter failed'); }, + }), + (error) => error === primary, + ); +}); diff --git a/devnet/rfc64-persistence-lifecycle/process-lifecycle.ts b/devnet/rfc64-persistence-lifecycle/process-lifecycle.ts index 1f9063ca21..244388b257 100644 --- a/devnet/rfc64-persistence-lifecycle/process-lifecycle.ts +++ b/devnet/rfc64-persistence-lifecycle/process-lifecycle.ts @@ -18,8 +18,38 @@ export interface TrackedChildProcess { readonly closed: Promise; } +interface TerminationAttempt { + readonly exit: ProcessExitEvidence | null; + readonly failures: readonly unknown[]; +} + +function throwCollectedFailures(failures: readonly unknown[], message: string): void { + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, message); +} + +function reportSecondaryFailureSafely( + reportSecondaryFailure: (primaryFailure: unknown, secondaryFailure: unknown) => void, + primaryFailure: unknown, + secondaryFailure: unknown, +): void { + try { + reportSecondaryFailure(primaryFailure, secondaryFailure); + } catch { + // Reporting is best-effort and must never replace the failure being preserved. + } +} + export class ChildProcessRegistry { readonly #active = new Set(); + readonly #closeDeadlineMs: number; + + constructor(closeDeadlineMs = 15_000) { + if (!Number.isSafeInteger(closeDeadlineMs) || closeDeadlineMs < 1) { + throw new TypeError('closeDeadlineMs must be a positive safe integer'); + } + this.#closeDeadlineMs = closeDeadlineMs; + } track(child: ManagedChildProcess): TrackedChildProcess { let tracked: TrackedChildProcess; @@ -32,34 +62,68 @@ export class ChildProcessRegistry { return tracked; } - async terminateAndWait( + async #terminateAndInspect( tracked: TrackedChildProcess, signal: NodeJS.Signals = 'SIGKILL', - ): Promise { + ): Promise { const { child } = tracked; - let deliveryFailure: Error | undefined; + const failures: unknown[] = []; if (child.exitCode === null && child.signalCode === null) { - const delivered = child.kill(signal); - if (!delivered && child.exitCode === null && child.signalCode === null) { - deliveryFailure = new Error(`failed to deliver ${signal} to a tracked child process`); + try { + const delivered = child.kill(signal); + if (!delivered && child.exitCode === null && child.signalCode === null) { + failures.push(new Error(`failed to deliver ${signal} to a tracked child process`)); + } + } catch (error) { + failures.push(error); } } - const exit = await tracked.closed; - if (deliveryFailure !== undefined) throw deliveryFailure; - return exit; + let exit: ProcessExitEvidence | null = null; + try { + exit = await new Promise((resolveClose, rejectClose) => { + let settled = false; + const deadline = setTimeout(() => { + if (settled) return; + settled = true; + rejectClose(new Error( + `tracked child did not emit close within ${this.#closeDeadlineMs}ms after ${signal}`, + )); + }, this.#closeDeadlineMs); + void tracked.closed.then((evidence) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + resolveClose(evidence); + }); + }); + } catch (error) { + failures.push(error); + } + return Object.freeze({ exit, failures: Object.freeze(failures) }); } - async terminateAllAndWait(): Promise { - const results = await Promise.allSettled( - [...this.#active].map((tracked) => this.terminateAndWait(tracked)), + async terminateAndWait( + tracked: TrackedChildProcess, + signal: NodeJS.Signals = 'SIGKILL', + ): Promise { + const attempt = await this.#terminateAndInspect(tracked, signal); + throwCollectedFailures(attempt.failures, 'tracked child process failed to terminate'); + return attempt.exit!; + } + + async terminateAllThenCleanup(cleanup: () => void | Promise): Promise { + const attempts = await Promise.all( + [...this.#active].map((tracked) => this.#terminateAndInspect(tracked)), ); - const failures = results - .filter((result): result is PromiseRejectedResult => result.status === 'rejected') - .map((result) => result.reason as unknown); - if (failures.length === 1) throw failures[0]; - if (failures.length > 1) { - throw new AggregateError(failures, 'multiple child processes failed to terminate'); + const failures = attempts.flatMap((attempt) => attempt.failures); + if (attempts.every((attempt) => attempt.exit !== null)) { + try { + await cleanup(); + } catch (error) { + failures.push(error); + } } + throwCollectedFailures(failures, 'child termination or ordered cleanup failed'); } } @@ -72,7 +136,11 @@ export async function terminateBeforeRejecting( try { await registry.terminateAndWait(tracked); } catch (secondaryFailure) { - reportSecondaryFailure(primaryFailure, secondaryFailure); + reportSecondaryFailureSafely( + reportSecondaryFailure, + primaryFailure, + secondaryFailure, + ); } throw primaryFailure; } @@ -90,7 +158,11 @@ export async function cleanupPreservingPrimaryFailure(input: { await input.cleanup(); } catch (cleanupFailure) { if (!input.operationFailed) throw cleanupFailure; - input.reportSecondaryFailure(input.primaryFailure, cleanupFailure); + reportSecondaryFailureSafely( + input.reportSecondaryFailure, + input.primaryFailure, + cleanupFailure, + ); } if (input.operationFailed) throw input.primaryFailure; } diff --git a/devnet/rfc64-persistence-lifecycle/run.ts b/devnet/rfc64-persistence-lifecycle/run.ts index 24accff985..a31382d2c9 100644 --- a/devnet/rfc64-persistence-lifecycle/run.ts +++ b/devnet/rfc64-persistence-lifecycle/run.ts @@ -34,7 +34,8 @@ const CONTROL_ROOT_RELATIVE = 'rfc64-sync/control-objects-v1'; const INVENTORY_RELATIVE = 'rfc64-sync/inventory-v1.sqlite3'; const LEASE_RELATIVE = 'rfc64-sync/inventory-v1.lease.sqlite3'; const PROCESS_TIMEOUT_MS = 60_000; -const children = new ChildProcessRegistry(); +const POST_SIGKILL_CLOSE_DEADLINE_MS = 15_000; +const children = new ChildProcessRegistry(POST_SIGKILL_CLOSE_DEADLINE_MS); interface ProcessEvent { readonly event: string; @@ -232,13 +233,7 @@ async function stopAgentGracefully(handle: AgentHandle): Promise { - const close = waitForAgentClose(handle, 'SIGKILL'); - const delivered = handle.child.kill('SIGKILL'); - assert( - delivered || handle.child.exitCode !== null || handle.child.signalCode !== null, - 'failed to deliver SIGKILL to agent process', - ); - const exit = await close; + const exit = await children.terminateAndWait(handle.tracked, 'SIGKILL'); assert(exit.code === null && exit.signal === 'SIGKILL', 'unclean stop was not SIGKILL'); assert( handle.events().every((event) => event.event !== 'stopped'), @@ -549,8 +544,9 @@ await cleanupPreservingPrimaryFailure({ operationFailed, primaryFailure, cleanup: async () => { - await children.terminateAllAndWait(); - rmSync(dataDir, { recursive: true, force: true }); + await children.terminateAllThenCleanup(() => { + rmSync(dataDir, { recursive: true, force: true }); + }); }, reportSecondaryFailure, }); From 12eaae32253129d01f9d8f17a74a797891723137 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:00:02 +0200 Subject: [PATCH 104/292] fix(devnet): make evidence discovery cross-platform --- devnet/_bootstrap/rfc64-evidence.test.ts | 21 ++++++++++++++++++++- devnet/_bootstrap/vitest.evidence.config.ts | 8 ++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/devnet/_bootstrap/rfc64-evidence.test.ts b/devnet/_bootstrap/rfc64-evidence.test.ts index f5d0c402e7..fd0d061334 100644 --- a/devnet/_bootstrap/rfc64-evidence.test.ts +++ b/devnet/_bootstrap/rfc64-evidence.test.ts @@ -15,8 +15,9 @@ import { } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import evidenceVitestConfig from './vitest.evidence.config.js'; import { RFC64_ARTIFACT_POSIX_ACCESS_POLICY, RFC64_ARTIFACT_POSIX_NAMESPACE_DURABILITY, @@ -58,6 +59,24 @@ afterEach(() => { } }); +describe('RFC-64 evidence Vitest discovery', () => { + it('keeps the evidence include repository-relative and POSIX-normalized', () => { + const config = evidenceVitestConfig as unknown as { + readonly root?: string; + readonly test?: { readonly include?: readonly string[] }; + }; + const include = config.test?.include?.[0]; + + expect(config.root).toBe(resolve(import.meta.dirname, '../..')); + expect(config.test?.include).toEqual([ + 'devnet/_bootstrap/rfc64-evidence.test.ts', + ]); + expect(include).toBeDefined(); + expect(isAbsolute(include!)).toBe(false); + expect(include).not.toContain('\\'); + }); +}); + describe('RFC-64 semantic snapshot evidence', () => { it('canonicalizes blank nodes, line order, and UAL aliases', async () => { const first = await createRfc64SemanticSnapshot([ diff --git a/devnet/_bootstrap/vitest.evidence.config.ts b/devnet/_bootstrap/vitest.evidence.config.ts index 35eb6bb24b..76c2d2b6d4 100644 --- a/devnet/_bootstrap/vitest.evidence.config.ts +++ b/devnet/_bootstrap/vitest.evidence.config.ts @@ -1,15 +1,15 @@ import { defineConfig } from 'vitest/config'; import { resolve } from 'node:path'; +const repositoryRoot = resolve(import.meta.dirname, '../..'); + // Pure evidence-unit tests. No running devnet or built package artifacts needed. export default defineConfig({ + root: repositoryRoot, test: { - include: [resolve(import.meta.dirname, 'rfc64-evidence.test.ts')], + include: ['devnet/_bootstrap/rfc64-evidence.test.ts'], pool: 'forks', sequence: { concurrent: false }, globals: false, }, - resolve: { - modules: [resolve(import.meta.dirname, '../../node_modules'), 'node_modules'], - }, }); From 9dda05edb0fc489da8b6990e2e605898eafe3f16 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:03:28 +0200 Subject: [PATCH 105/292] ci(rfc64): build before Gate 0 typecheck --- .github/workflows/rfc64-inventory-windows.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rfc64-inventory-windows.yml b/.github/workflows/rfc64-inventory-windows.yml index 1bd19c3120..b997f0800e 100644 --- a/.github/workflows/rfc64-inventory-windows.yml +++ b/.github/workflows/rfc64-inventory-windows.yml @@ -83,6 +83,9 @@ jobs: - name: Test RFC-64 evidence harness run: pnpm test:devnet:rfc64-evidence + - name: Build agent dependency closure + run: pnpm --filter @origintrail-official/dkg-agent... run build + - name: Strict-typecheck Gate 0 lifecycle harness run: pnpm typecheck:gate0:rfc64-persistence-lifecycle @@ -92,9 +95,6 @@ jobs: timeout-minutes: 20 run: pnpm test:gate0:rfc64-persistence-lifecycle - - name: Build agent dependency closure - run: pnpm --filter @origintrail-official/dkg-agent... run build - - name: Typecheck the lifecycle crash harness run: >- pnpm exec tsc --noEmit --target ES2022 --module NodeNext From f1103623d2a86e914198493e25009b8741b42d16 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:00:01 +0200 Subject: [PATCH 106/292] feat(agent): wire RFC-64 public catalog transport into DKGAgent (Gate 1) Wire the existing Rfc64PublicCatalogTransportV1 onto the real DKGAgent production router with startup/shutdown lifecycle; add durable author-head publication + best-effort announcement and a receiver scheduling/dedup path that fetches by exact digest, re-verifies, and durably stages the head. Terminal at durable staging: no candidate admission, no KA/SWM/VM activation (spec Phase 1 + start of Phase 2). - rfc64/open-catalog-policy-v1: honest open-policy authorizer. Returns the digest of a locally-held ContextGraphPolicyV1 (via the core primitive computeContextGraphPolicyObjectDigestV1), never the untrusted wire hint; fail-closed for unknown or non-open (accessPolicy!=0) CGs. - rfc64/public-catalog-receiver-v1: non-blocking scheduler (the transport awaits onCatalogHeadAvailable before ACK, so schedule() enqueues and returns synchronously) with dedup (in-flight + already-staged), bounded concurrency/queue, retry/backoff, and drain-on-close that awaits in-flight durable stage writes. - rfc64/public-catalog-service-v1 + dkg-agent-rfc64-catalog mixin: construct and start the service on this.router during start() (dormant without dataDir), drain + close it in stop() before node.stop()/persistence release; author publish path (produce genesis -> verify -> durable stage -> announce peers). - Admission: no protocol exemption. On a chain-free node admission is disabled (no networkId) so open catalog works; with a networkId it is admission-gated like every other node protocol. - Tests: receiver scheduler unit (9) + two-real-DKGAgent integration (announce/fetch/reverify/durably-stage the exact head; dedup; fail-closed without an accepted policy; no CG activation). - devnet/rfc64-gate1-public-catalog: two-real-process demo + deterministic evidence artifact (git-ignored output). Does not touch integration/rfc64-devnet. Co-Authored-By: Claude Opus 4.8 --- .../rfc64-gate1-public-catalog/.gitignore | 1 + .../rfc64-gate1-public-catalog/README.md | 34 +++ .../agent-process.mjs | 144 +++++++++ .../devnet/rfc64-gate1-public-catalog/run.mjs | 183 +++++++++++ packages/agent/src/dkg-agent-base.ts | 7 + packages/agent/src/dkg-agent-lifecycle.ts | 5 + packages/agent/src/dkg-agent-rfc64-catalog.ts | 209 +++++++++++++ packages/agent/src/dkg-agent.ts | 18 +- packages/agent/src/index.ts | 4 + .../agent/src/rfc64/open-catalog-policy-v1.ts | 201 ++++++++++++ .../src/rfc64/public-catalog-receiver-v1.ts | 288 ++++++++++++++++++ .../src/rfc64/public-catalog-service-v1.ts | 280 +++++++++++++++++ ...4-public-catalog-gate1.integration.test.ts | 179 +++++++++++ .../rfc64-public-catalog-receiver-v1.test.ts | 197 ++++++++++++ packages/agent/vitest.unit.config.ts | 3 + 15 files changed, 1751 insertions(+), 2 deletions(-) create mode 100644 packages/agent/devnet/rfc64-gate1-public-catalog/.gitignore create mode 100644 packages/agent/devnet/rfc64-gate1-public-catalog/README.md create mode 100644 packages/agent/devnet/rfc64-gate1-public-catalog/agent-process.mjs create mode 100644 packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs create mode 100644 packages/agent/src/dkg-agent-rfc64-catalog.ts create mode 100644 packages/agent/src/rfc64/open-catalog-policy-v1.ts create mode 100644 packages/agent/src/rfc64/public-catalog-receiver-v1.ts create mode 100644 packages/agent/src/rfc64/public-catalog-service-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts create mode 100644 packages/agent/test/rfc64-public-catalog-receiver-v1.test.ts diff --git a/packages/agent/devnet/rfc64-gate1-public-catalog/.gitignore b/packages/agent/devnet/rfc64-gate1-public-catalog/.gitignore new file mode 100644 index 0000000000..d4f588edfe --- /dev/null +++ b/packages/agent/devnet/rfc64-gate1-public-catalog/.gitignore @@ -0,0 +1 @@ +artifacts/ diff --git a/packages/agent/devnet/rfc64-gate1-public-catalog/README.md b/packages/agent/devnet/rfc64-gate1-public-catalog/README.md new file mode 100644 index 0000000000..23cd01f233 --- /dev/null +++ b/packages/agent/devnet/rfc64-gate1-public-catalog/README.md @@ -0,0 +1,34 @@ +# RFC-64 Gate 1 — two-process public author-catalog demo + +Demonstrates the Gate 1 wiring across **two real `DKGAgent` OS processes**: an +author announces + serves a signed empty author-catalog genesis head, and a +receiver's *wired* `onCatalogHeadAvailable` + scheduler fetch it by exact +digest, re-verify it, and durably stage it into its control-object store. + +Everything runs through the wired agent API (`DKGAgent.start()` constructs the +service on the production router); no transport is hand-built. + +## Run + +```sh +# Build the agent package + its workspace deps to dist first: +pnpm turbo run build --filter=@origintrail-official/dkg-agent... + +node devnet/rfc64-gate1-public-catalog/run.mjs +``` + +The orchestrator (`run.mjs`) spawns `agent-process.mjs` twice (author + +receiver), connects them over libp2p, drives publish → announce → fetch → +re-verify → durable stage, and writes deterministic evidence to +`artifacts/gate1-result.json`. Exit code 0 == all checks `PASS`. + +## What it proves (and what it deliberately does not) + +- Author + receiver each start the catalog service on their production router. +- The receiver independently accepts the same open (`accessPolicy=0`) policy and + computes the identical `policyDigest` — never derived from the wire hint. +- The announcement is acknowledged over the production router; the receiver + fetches the exact head, the transport re-verifies it, and it is durably staged. +- The receiver reads the exact head back from its control-object store. +- **No activation:** the CG is not activated as queryable knowledge — Gate 1 + stages the head and stops (no candidate admission, no KA/SWM/VM). diff --git a/packages/agent/devnet/rfc64-gate1-public-catalog/agent-process.mjs b/packages/agent/devnet/rfc64-gate1-public-catalog/agent-process.mjs new file mode 100644 index 0000000000..99e001fcd2 --- /dev/null +++ b/packages/agent/devnet/rfc64-gate1-public-catalog/agent-process.mjs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// RFC-64 Gate 1 demo child: one REAL DKGAgent process. It boots the agent +// (which wires the public author-catalog service onto its production router), +// then drives the author/receiver flow via a line protocol on stdin/stdout. +// Everything runs through the WIRED agent API — no transport is hand-built. + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; + +import { multiaddr } from '@multiformats/multiaddr'; +import { DKGAgent } from '@origintrail-official/dkg-agent'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; + +const ROLE = process.argv[2] ?? 'agent'; + +function emit(event) { + process.stdout.write(`RFC64_GATE1_EVENT ${JSON.stringify({ role: ROLE, ...event })}\n`); +} + +let agent; +let dataDir; + +async function boot() { + dataDir = await mkdtemp(join(tmpdir(), `dkg-rfc64-gate1-${ROLE}-`)); + agent = await DKGAgent.create({ + name: `RFC64Gate1${ROLE}`, + dataDir, + listenHost: '127.0.0.1', + listenPort: 0, + bootstrapPeers: [], + nodeRole: 'edge', + store: new OxigraphStore(), + syncSharedMemoryOnConnect: false, + syncReconcilerEnabled: false, + syncOnConnectEnabled: false, + durableSyncEnabled: false, + agentProfileHeartbeatMs: 0, + }); + await agent.start(); + const tcp = agent.multiaddrs.find((a) => a.includes('/tcp/')); + emit({ + event: 'ready', + agentClass: agent.constructor.name, + peerId: agent.peerId, + multiaddr: tcp, + catalogServiceStarted: agent.rfc64PublicCatalogStatsV1()?.started === true, + }); +} + +async function handle(cmd) { + switch (cmd.cmd) { + case 'dial': { + await agent.node.libp2p.dial(multiaddr(cmd.multiaddr)); + emit({ event: 'dialed', to: cmd.peerId }); + break; + } + case 'accept-policy': { + const accepted = agent.acceptOpenContextGraphPolicyV1({ + networkId: cmd.networkId, + contextGraphId: cmd.contextGraphId, + ownerAddress: cmd.ownerAddress, + }); + emit({ event: 'policy-accepted', policyDigest: accepted.policyDigest }); + break; + } + case 'publish': { + const wallet = new ethers.Wallet(cmd.authorPrivateKey); + const result = await agent.publishOpenAuthorCatalogGenesisV1({ + networkId: cmd.networkId, + contextGraphId: cmd.contextGraphId, + author: wallet, + peers: cmd.peers, + issuedAt: cmd.issuedAt, + }); + emit({ + event: 'published', + headObjectDigest: result.headObjectDigest, + signatureVariantDigest: result.signatureVariantDigest, + policyDigest: result.announcement.policyDigest, + announcedPeers: result.announcedPeers, + failedPeers: result.failedPeers, + }); + break; + } + case 'await-staged': { + // The wired receiver scheduler stages asynchronously on the announcement. + await agent.whenRfc64PublicCatalogReceiverIdleV1(); + const stagedDigest = await agent.readRfc64StagedAuthorCatalogHeadV1({ + objectDigest: cmd.headObjectDigest, + signatureVariantDigest: cmd.signatureVariantDigest, + }); + emit({ + event: 'staged', + readBackFromControlStore: stagedDigest, + matchesExactHead: stagedDigest === cmd.headObjectDigest, + receiverStats: agent.rfc64PublicCatalogStatsV1()?.receiver ?? null, + activeContextGraphs: (await agent.listContextGraphs()) + .filter((row) => row.id === cmd.contextGraphId || row.uri.includes(cmd.contextGraphId)) + .map((row) => row.id), + }); + break; + } + case 'stop': { + await shutdown(0); + break; + } + default: + emit({ event: 'error', message: `unknown command ${cmd.cmd}` }); + } +} + +async function shutdown(code) { + try { await agent?.stop(); } catch { /* best-effort */ } + try { if (dataDir) await rm(dataDir, { recursive: true, force: true }); } catch { /* best-effort */ } + process.exit(code); +} + +process.on('SIGTERM', () => { void shutdown(0); }); +process.on('SIGINT', () => { void shutdown(130); }); + +const rl = createInterface({ input: process.stdin }); +rl.on('line', (line) => { + const trimmed = line.trim(); + if (trimmed.length === 0) return; + let cmd; + try { + cmd = JSON.parse(trimmed); + } catch { + emit({ event: 'error', message: 'invalid command json' }); + return; + } + handle(cmd).catch((error) => { + emit({ event: 'error', message: error instanceof Error ? error.message : String(error) }); + }); +}); + +boot().catch((error) => { + emit({ event: 'boot-failed', message: error instanceof Error ? error.message : String(error) }); + process.exit(1); +}); diff --git a/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs b/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs new file mode 100644 index 0000000000..f5158f2738 --- /dev/null +++ b/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// RFC-64 Gate 1 demonstration: TWO real DKGAgent OS processes. +// +// The author process publishes + announces a signed empty author-catalog +// genesis head; the receiver process's WIRED onCatalogHeadAvailable + scheduler +// fetch it by exact digest, re-verify it, and durably stage it into its +// control-object store. Correctness proof: the receiver reads the exact head +// back from its own control store, and the CG is NOT activated as queryable +// knowledge (Gate 1 stages; it does not activate KA/SWM/VM). +// +// Usage: node devnet/rfc64-gate1-public-catalog/run.mjs +// Requires: packages/agent + workspace deps built to dist. + +import { spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createInterface } from 'node:readline'; + +import { ethers } from 'ethers'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..'); +const AGENT_PROCESS = join(HERE, 'agent-process.mjs'); +const ARTIFACT = join(HERE, 'artifacts', 'gate1-result.json'); + +const NETWORK_ID = 'otp:20430'; +const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/gate-1-demo'; +const ISSUED_AT = '1773900000000'; +const AUTHOR_PRIVATE_KEY = `0x${'64'.repeat(32)}`; +const AUTHOR_ADDRESS = new ethers.Wallet(AUTHOR_PRIVATE_KEY).address.toLowerCase(); + +function stableJson(value) { + return JSON.stringify(sortKeys(value), null, 2); +} +function sortKeys(value) { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])])); + } + return value; +} + +class Child { + constructor(role) { + this.role = role; + this.events = []; + this.waiters = []; + this.proc = spawn(process.execPath, [AGENT_PROCESS, role], { + cwd: REPO_ROOT, + env: { ...process.env, NODE_ENV: 'production' }, + stdio: ['pipe', 'pipe', 'inherit'], + }); + createInterface({ input: this.proc.stdout }).on('line', (line) => { + const marker = 'RFC64_GATE1_EVENT '; + if (!line.startsWith(marker)) return; + const event = JSON.parse(line.slice(marker.length)); + this.events.push(event); + this.waiters = this.waiters.filter((w) => { + if (w.name === event.event) { w.resolve(event); return false; } + return true; + }); + }); + } + + send(cmd) { + this.proc.stdin.write(`${JSON.stringify(cmd)}\n`); + } + + waitFor(name, timeoutMs = 45_000) { + const existing = this.events.find((e) => e.event === name); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${this.role}: timed out waiting for '${name}'`)), + timeoutMs, + ); + this.waiters.push({ name, resolve: (e) => { clearTimeout(timer); resolve(e); } }); + }); + } + + stop() { + try { this.proc.stdin.write(`${JSON.stringify({ cmd: 'stop' })}\n`); } catch { /* */ } + setTimeout(() => { try { this.proc.kill('SIGTERM'); } catch { /* */ } }, 1500).unref(); + } +} + +async function main() { + const author = new Child('author'); + const receiver = new Child('receiver'); + + const authorReady = await author.waitFor('ready'); + const receiverReady = await receiver.waitFor('ready'); + + // Real libp2p connectivity between the two processes (both directions). + receiver.send({ cmd: 'dial', multiaddr: authorReady.multiaddr, peerId: authorReady.peerId }); + author.send({ cmd: 'dial', multiaddr: receiverReady.multiaddr, peerId: receiverReady.peerId }); + await Promise.all([receiver.waitFor('dialed'), author.waitFor('dialed')]); + + // Receiver independently accepts the SAME open policy from CG identity facts + // (owner = author EOA) — never derived from the wire announcement. + receiver.send({ + cmd: 'accept-policy', + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR_ADDRESS, + }); + const receiverPolicy = await receiver.waitFor('policy-accepted'); + + // Author produces + durably stages + announces the genesis head. + author.send({ + cmd: 'publish', + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + authorPrivateKey: AUTHOR_PRIVATE_KEY, + peers: [receiverReady.peerId], + issuedAt: ISSUED_AT, + }); + const published = await author.waitFor('published'); + + // Receiver's wired scheduler fetches + re-verifies + durably stages the head. + receiver.send({ + cmd: 'await-staged', + headObjectDigest: published.headObjectDigest, + signatureVariantDigest: published.signatureVariantDigest, + contextGraphId: CONTEXT_GRAPH_ID, + }); + const staged = await receiver.waitFor('staged'); + + const checks = { + authorCatalogServiceStarted: authorReady.catalogServiceStarted === true, + receiverCatalogServiceStarted: receiverReady.catalogServiceStarted === true, + policyDigestsMatchIndependently: receiverPolicy.policyDigest === published.policyDigest, + announcementAcknowledged: + Array.isArray(published.announcedPeers) + && published.announcedPeers.includes(receiverReady.peerId) + && published.failedPeers.length === 0, + receiverStagedExactHead: staged.matchesExactHead === true + && staged.readBackFromControlStore === published.headObjectDigest, + exactlyOneDurableStage: staged.receiverStats?.staged === 1 && staged.receiverStats?.failed === 0, + noKaSwmActivation: Array.isArray(staged.activeContextGraphs) + && staged.activeContextGraphs.length === 0, + }; + const status = Object.values(checks).every(Boolean) ? 'PASS' : 'FAIL'; + + const artifact = { + schema: 'dkg-rfc64-gate1-public-catalog-evidence-v1', + status, + checks, + scope: { networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, authorAddress: AUTHOR_ADDRESS }, + processes: { + author: { agentClass: authorReady.agentClass, peerId: authorReady.peerId }, + receiver: { agentClass: receiverReady.agentClass, peerId: receiverReady.peerId }, + }, + head: { + objectDigest: published.headObjectDigest, + signatureVariantDigest: published.signatureVariantDigest, + policyDigest: published.policyDigest, + }, + receiver: { + independentlyAcceptedPolicyDigest: receiverPolicy.policyDigest, + readBackFromControlStore: staged.readBackFromControlStore, + receiverStats: staged.receiverStats, + activeContextGraphsForCg: staged.activeContextGraphs, + }, + }; + + await mkdir(dirname(ARTIFACT), { recursive: true }); + await writeFile(ARTIFACT, `${stableJson(artifact)}\n`); + + author.stop(); + receiver.stop(); + + process.stdout.write(`\n${stableJson(artifact)}\n`); + process.stdout.write(`\nGate 1 two-process demo: ${status}\n`); + process.exit(status === 'PASS' ? 0 : 1); +} + +main().catch((error) => { + process.stderr.write(`gate1 demo failed: ${error instanceof Error ? error.stack : String(error)}\n`); + process.exit(1); +}); diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index fc17fb81c2..9f534d366f 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -15,6 +15,7 @@ import { openRfc64PersistenceV1, type Rfc64PersistenceV1, } from './rfc64/persistence-v1.js'; +import type { Rfc64PublicCatalogServiceV1 } from './rfc64/public-catalog-service-v1.js'; import { resolveVmReconcileStartupMaxDelayMs } from './startup-jitter.js'; import { DKGNode, ProtocolRouter, GossipSubManager, TypedEventBus, DKGEvent, @@ -992,6 +993,12 @@ export class DKGAgentBase { * protected by it. Agents without dataDir remain deliberately dormant. */ protected rfc64PersistenceV1?: Rfc64PersistenceV1; + /** + * RFC-64 Gate 1 public author-catalog service, wired onto the production + * router during `start()` when {@link rfc64PersistenceV1} is open. Undefined + * while dormant (no dataDir) or after `stop()`. + */ + protected rfc64PublicCatalogServiceV1?: Rfc64PublicCatalogServiceV1; protected readonly subscribedContextGraphs = new Map(); protected contextGraphSubscriptionRehydrationStatus: ContextGraphSubscriptionRehydrationStatus | null = null; protected readonly contextGraphSubscriptionRehydrationAccountedIds = new Set(); diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index cc507434de..4e609b364c 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -1298,6 +1298,11 @@ export class LifecycleSyncMethods extends DKGAgentBase { // gates by node role + per-CG authorization. this.messenger.register(PROTOCOL_GET_CIPHERTEXT_CHUNK, (data, fromPeerId) => this.handleGetCiphertextChunk(data, fromPeerId)); + // OT-RFC-64 Gate 1: wire the public author-catalog transport onto the + // production router. Announce/fetch protocols are admission-gated like + // every other node protocol. Dormant when no dataDir opened persistence. + this.startRfc64PublicCatalogServiceV1(ctx); + const effectiveRole = this.config.nodeRole ?? 'edge'; const ackSignerCandidates = this.getACKSignerCandidateWallets(ctx); let onChainIdentityId = 0n; diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts new file mode 100644 index 0000000000..99d8b5896d --- /dev/null +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * RFC-64 Gate 1 public author-catalog wiring, extracted as a DKGAgent mixin + * holder. Methods take `this: DKGAgent` so cross-mixin calls resolve against + * the composed class. + * + * Responsibilities: + * - construct + start {@link Rfc64PublicCatalogServiceV1} on the production + * router during `start()` (dormant when no `dataDir` opened the RFC-64 + * persistence), and drain + close it during `stop()`; + * - expose the author path (produce + durably stage + best-effort announce a + * genesis head) and the accepted-open-policy seed used by both sides; + * - expose read-only observability for tests / evidence. + * + * Gate-1 boundary: staging a fetched head is the terminal step. Nothing here + * admits candidate rows or activates KA / SWM / VM state. + */ + +import { + createOperationContext, + type OperationContext, + type ContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type SubGraphNameV1, + type TimestampMsV1, + type DecimalU64V1, + type AuthorCatalogScopeV1, + type CountV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; + +import { DKGAgentBase } from './dkg-agent-base.js'; +import type { DKGAgent } from './dkg-agent.js'; +import type { Rfc64AuthorCatalogEip191SignerV1 } from './rfc64/author-catalog-producer.js'; +import type { AcceptedOpenCatalogPolicyV1 } from './rfc64/open-catalog-policy-v1.js'; +import { + Rfc64PublicCatalogServiceV1, + type PublishOpenAuthorCatalogGenesisResultV1, + type Rfc64PublicCatalogServiceStatsV1, +} from './rfc64/public-catalog-service-v1.js'; + +/** + * Gate-1 simplification: a genesis head carries a placeholder catalog-issuer + * delegation digest. The transport verifies generic envelope cryptography, not + * issuer delegation, so this does not weaken any Gate-1 check; real + * delegation-authority binding lands in a later phase. + */ +export const RFC64_GATE1_PLACEHOLDER_DELEGATION_DIGEST_V1 = + `0x${'11'.repeat(32)}` as Digest32V1; + +/** Minimal EIP-191 EOA signer (ethers.Wallet-compatible) for author-catalog objects. */ +export interface Rfc64OpenCatalogAuthorSignerV1 { + readonly address: string; + signMessage(message: Uint8Array): Promise; +} + +export interface AcceptOpenContextGraphPolicyInputV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly ownerAddress: EvmAddressV1; + readonly ownerAuthorityEra?: DecimalU64V1; + readonly issuedAt?: TimestampMsV1; + readonly effectiveAt?: TimestampMsV1; +} + +export interface PublishOpenAuthorCatalogGenesisParamsV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName?: SubGraphNameV1 | null; + /** The EOA that authors and owns the open CG (also the open-policy owner). */ + readonly author: Rfc64OpenCatalogAuthorSignerV1; + /** Peers to announce head availability to (best-effort hints). */ + readonly peers: readonly string[]; + /** Head object timestamp; defaults to now. */ + readonly issuedAt?: TimestampMsV1; + /** Open-policy timestamps; MUST match on every party (default '0'). */ + readonly policyIssuedAt?: TimestampMsV1; + readonly policyEffectiveAt?: TimestampMsV1; + readonly ownerAuthorityEra?: DecimalU64V1; + readonly catalogIssuerDelegationDigest?: Digest32V1; +} + +export interface Rfc64StagedAuthorCatalogHeadRefV1 { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; +} + +export class Rfc64CatalogMethods extends DKGAgentBase { + /** + * Construct + start the public catalog service on the production router. + * No-op when RFC-64 persistence is dormant (no `dataDir`) or already started. + */ + startRfc64PublicCatalogServiceV1(this: DKGAgent, ctx: OperationContext): void { + if (this.rfc64PublicCatalogServiceV1 !== undefined) return; + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) return; + const service = new Rfc64PublicCatalogServiceV1({ + router: this.router, + controlObjects: persistence.controlObjects, + }); + service.start(); + this.rfc64PublicCatalogServiceV1 = service; + this.log.info(ctx, 'RFC-64 public author-catalog transport started'); + } + + /** Stop serving and drain in-flight receiver work. Idempotent + undefined-safe. */ + async closeRfc64PublicCatalogServiceV1(this: DKGAgent): Promise { + const service = this.rfc64PublicCatalogServiceV1; + this.rfc64PublicCatalogServiceV1 = undefined; + await service?.close(); + } + + /** + * Accept the CG's open (accessPolicy=0) current policy into the authorizer. + * Author and receiver each call this from independently-held CG identity + * facts — the accepted digest, not the untrusted wire hint, gates operations. + */ + acceptOpenContextGraphPolicyV1( + this: DKGAgent, + input: AcceptOpenContextGraphPolicyInputV1, + ): AcceptedOpenCatalogPolicyV1 { + return this.requireRfc64PublicCatalogServiceV1().acceptOpenPolicy(input); + } + + /** + * Author path: accept the CG's open policy, produce a signed genesis head, + * durably stage its immutable objects, then best-effort announce availability. + */ + async publishOpenAuthorCatalogGenesisV1( + this: DKGAgent, + params: PublishOpenAuthorCatalogGenesisParamsV1, + ): Promise { + const service = this.requireRfc64PublicCatalogServiceV1(); + const authorAddress = params.author.address.toLowerCase() as EvmAddressV1; + const policy = service.acceptOpenPolicy({ + networkId: params.networkId, + contextGraphId: params.contextGraphId, + ownerAddress: authorAddress, + ownerAuthorityEra: params.ownerAuthorityEra, + issuedAt: params.policyIssuedAt, + effectiveAt: params.policyEffectiveAt, + }); + const scope: AuthorCatalogScopeV1 = { + networkId: params.networkId, + contextGraphId: params.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: params.subGraphName ?? null, + authorAddress, + era: '0' as DecimalU64V1, + bucketCount: '1' as CountV1, + }; + const signer: Rfc64AuthorCatalogEip191SignerV1 = { + issuer: authorAddress, + signDigest: (objectDigest) => params.author.signMessage(objectDigest), + }; + return service.publishOpenAuthorCatalogGenesis({ + scope, + signer, + catalogIssuerDelegationDigest: + params.catalogIssuerDelegationDigest ?? RFC64_GATE1_PLACEHOLDER_DELEGATION_DIGEST_V1, + issuedAt: params.issuedAt ?? (Date.now().toString() as TimestampMsV1), + policy, + peers: params.peers, + }); + } + + /** + * Read a head back from the control-object store by its exact digests — the + * "durably staged the exact head" proof. Returns null when not staged. + */ + async readRfc64StagedAuthorCatalogHeadV1( + this: DKGAgent, + ref: Rfc64StagedAuthorCatalogHeadRefV1, + ): Promise { + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) return null; + const stored = await persistence.controlObjects.getVerifiedObject({ + objectDigest: ref.objectDigest, + signatureVariantDigest: ref.signatureVariantDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + return stored === null ? null : (stored.envelope.objectDigest as Digest32V1); + } + + /** Await the receiver scheduler draining all queued + in-flight fetch/stage work. */ + whenRfc64PublicCatalogReceiverIdleV1(this: DKGAgent): Promise { + const service = this.rfc64PublicCatalogServiceV1; + return service === undefined ? Promise.resolve() : service.whenReceiverIdle(); + } + + rfc64PublicCatalogStatsV1(this: DKGAgent): Rfc64PublicCatalogServiceStatsV1 | null { + return this.rfc64PublicCatalogServiceV1?.stats() ?? null; + } + + private requireRfc64PublicCatalogServiceV1(this: DKGAgent): Rfc64PublicCatalogServiceV1 { + const service = this.rfc64PublicCatalogServiceV1; + if (service === undefined) { + throw new Error( + 'RFC-64 public catalog service is not available (agent not started or no dataDir)', + ); + } + return service; + } +} diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index edc84d9370..5b213625cc 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -400,6 +400,7 @@ import { OwnershipMethods } from './dkg-agent-ownership.js'; import { ContextGraphResolveMethods } from './dkg-agent-cg-resolve.js'; import { CclPolicyMethods } from './dkg-agent-ccl.js'; import { EndorseVerifyMethods } from './dkg-agent-endorse.js'; +import { Rfc64CatalogMethods } from './dkg-agent-rfc64-catalog.js'; import { ContextGraphRegistryMethods } from './dkg-agent-cg-registry.js'; import { JoinRequestMethods } from './dkg-agent-join.js'; import { SwmSubstrateMethods } from './dkg-agent-swm-substrate.js'; @@ -1699,6 +1700,19 @@ export class DKGAgent extends DKGAgentBase { ); } } + // OT-RFC-64 Gate 1: unregister the public catalog protocols and drain the + // receiver scheduler (awaiting in-flight durable stage writes) while the + // router, node, and control-object store are all still live — before + // node.stop() below and before closeRfc64PersistenceV1() releases the store. + try { + await this.closeRfc64PublicCatalogServiceV1(); + } catch (err) { + this.log.warn( + createOperationContext('connect'), + `RFC-64 public catalog service close failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + // Tear down any pooled wire-protocol overlays before libp2p // stops so per-peer streams close gracefully rather than via // libp2p teardown (which would surface as recoverable resets @@ -3010,5 +3024,5 @@ export class DKGAgent extends DKGAgentBase { } -export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods {} -applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods]); +export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods {} +applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods]); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3edd885080..8a70e174a4 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -36,6 +36,10 @@ export { } from './auth/agent-delegation.js'; export * from './rfc64/catalog-row-authorship.js'; export * from './rfc64/author-catalog-producer.js'; +export * from './rfc64/public-catalog-transport-v1.js'; +export * from './rfc64/open-catalog-policy-v1.js'; +export * from './rfc64/public-catalog-receiver-v1.js'; +export * from './rfc64/public-catalog-service-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/open-catalog-policy-v1.ts b/packages/agent/src/rfc64/open-catalog-policy-v1.ts new file mode 100644 index 0000000000..8173fc2a54 --- /dev/null +++ b/packages/agent/src/rfc64/open-catalog-policy-v1.ts @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * RFC-64 Gate 1 open (public) access-policy authorizer. + * + * The public catalog transport ({@link ./public-catalog-transport-v1.js}) gates + * every announce/fetch on a caller-minted current-policy decision and + * independently requires `accessPolicy === 0` plus an exact `policyDigest` + * match against the untrusted wire hint. This module supplies that decision + * from *accepted local policy state*, never from the wire: + * + * - The author of an open CG mints a canonical {@link ContextGraphPolicyV1} + * (`accessPolicy = 0`, owner-signed / unregistered) and stamps the wire + * announcement with its object digest. + * - A receiver that has independently accepted the same open policy object + * recomputes the identical digest and holds it in this registry. + * + * Because both sides derive the digest from a policy object they each hold — + * and the transport compares the returned digest to the wire value — echoing + * the wire hint is impossible: an unknown CG or a non-open policy fails closed. + * + * Gate-1 boundary: this does NOT distribute policy objects, build member + * rosters, or support invite-only (`accessPolicy = 1`). Policy-object + * distribution and roster enforcement are later RFC-64 phases. + */ + +import { + CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + computeContextGraphPolicyObjectDigestV1, + type ContextGraphIdV1, + type ContextGraphPolicyV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type TimestampMsV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; + +import type { + Rfc64PublicCatalogAuthorizationInputV1, + Rfc64PublicCatalogAuthorizationV1, +} from './public-catalog-transport-v1.js'; + +/** The only access policy Gate 1 admits: open / public read + SWM submission. */ +export const RFC64_OPEN_ACCESS_POLICY_V1 = 0 as const; + +export interface BuildOpenOwnerContextGraphPolicyInputV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + /** The CG owner EOA; also the control-object issuer bound into the digest. */ + readonly ownerAddress: EvmAddressV1; + /** Owner authority generation; defaults to '0' for an unregistered genesis. */ + readonly ownerAuthorityEra?: DecimalU64V1; + /** Genesis policy timestamps; MUST be byte-identical on every party. */ + readonly issuedAt?: TimestampMsV1; + readonly effectiveAt?: TimestampMsV1; +} + +const ZERO_U64 = '0' as DecimalU64V1; +const ZERO_TIMESTAMP = '0' as TimestampMsV1; + +/** + * Build the canonical open, owner-signed / unregistered genesis + * {@link ContextGraphPolicyV1}. Open sharing (`accessPolicy = 0`) is paired + * with open contribution (`publishPolicy = 1`), which the codec requires to + * carry a null `publishAuthority` and account id `'0'`. + */ +export function buildOpenOwnerContextGraphPolicyV1( + input: BuildOpenOwnerContextGraphPolicyInputV1, +): ContextGraphPolicyV1 { + const ownerAuthorityEra = input.ownerAuthorityEra ?? ZERO_U64; + return Object.freeze({ + networkId: input.networkId, + contextGraphId: input.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: ZERO_U64, + version: ZERO_U64, + previousPolicyDigest: null, + accessPolicy: RFC64_OPEN_ACCESS_POLICY_V1, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0' as ContextGraphPolicyV1['publishAuthorityAccountId'], + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: Object.freeze({ + kind: 'owner-signed-unregistered', + ownerAddress: input.ownerAddress, + ownerAuthorityEra, + }), + effectiveAt: input.effectiveAt ?? ZERO_TIMESTAMP, + issuedAt: input.issuedAt ?? ZERO_TIMESTAMP, + }) satisfies ContextGraphPolicyV1; +} + +/** Wrap a policy in the unsigned control envelope whose object digest is the policyDigest. */ +export function unsignedOpenContextGraphPolicyEnvelopeV1( + policy: ContextGraphPolicyV1, +): UnsignedControlEnvelopeV1 { + // The strongly-typed policy payload does not structurally overlap the codec's + // `CanonicalJsonValue` payload, so the assembly is cast through `unknown` at + // this boundary; `computeContextGraphPolicyObjectDigestV1` re-validates the + // exact envelope shape (issuer/suite/evidence/type/payload) at runtime. + const envelope = { + issuer: openPolicyIssuer(policy), + objectType: CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + payload: policy, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }; + return envelope as unknown as UnsignedControlEnvelopeV1; +} + +/** + * The RFC-64 policy object digest bound into announcement / fetch `policyDigest`. + * This is the real control-object digest of the policy snapshot — never an + * ad-hoc value and never the untrusted wire hint. + */ +export function computeOpenContextGraphPolicyDigestV1( + policy: ContextGraphPolicyV1, +): Digest32V1 { + return computeContextGraphPolicyObjectDigestV1( + unsignedOpenContextGraphPolicyEnvelopeV1(policy), + ); +} + +function openPolicyIssuer(policy: ContextGraphPolicyV1): EvmAddressV1 { + if (policy.source.kind !== 'owner-signed-unregistered') { + throw new Error( + 'RFC-64 Gate 1 open policy requires an owner-signed / unregistered source', + ); + } + return policy.source.ownerAddress; +} + +export interface AcceptedOpenCatalogPolicyV1 { + readonly policy: ContextGraphPolicyV1; + readonly policyDigest: Digest32V1; +} + +/** + * Accepted current open-policy state, keyed by `(networkId, contextGraphId)` — + * network-scoped and per-CG; all sub-graph lanes share one CG policy generation. + * The registry is the sole source the authorizer consults. + */ +export class Rfc64AcceptedOpenCatalogPolicyRegistryV1 { + readonly #byKey = new Map(); + + /** + * Accept a locally-minted (author) or independently-verified (receiver) open + * policy. Rejects non-open policies so invite-only can never be admitted. + * Returns the accepted record including its computed digest. + */ + accept(policy: ContextGraphPolicyV1): AcceptedOpenCatalogPolicyV1 { + if (policy.accessPolicy !== RFC64_OPEN_ACCESS_POLICY_V1) { + throw new Error('RFC-64 Gate 1 only accepts open (accessPolicy=0) catalog policies'); + } + const record: AcceptedOpenCatalogPolicyV1 = Object.freeze({ + policy, + policyDigest: computeOpenContextGraphPolicyDigestV1(policy), + }); + this.#byKey.set(policyKey(policy.networkId, policy.contextGraphId), record); + return record; + } + + lookup( + networkId: NetworkIdV1, + contextGraphId: ContextGraphIdV1, + ): AcceptedOpenCatalogPolicyV1 | null { + return this.#byKey.get(policyKey(networkId, contextGraphId)) ?? null; + } + + get size(): number { + return this.#byKey.size; + } + + /** + * The transport authorizer. Returns the *held* open-policy decision for the + * operation's CG, or null (fail-closed) when no accepted open policy exists. + * It never reads `input.policyDigest`; the transport performs the wire match. + */ + readonly authorize = async ( + input: Rfc64PublicCatalogAuthorizationInputV1, + ): Promise => { + const record = this.lookup(input.networkId, input.contextGraphId); + if (record === null || record.policy.accessPolicy !== RFC64_OPEN_ACCESS_POLICY_V1) { + return null; + } + return Object.freeze({ + accessPolicy: RFC64_OPEN_ACCESS_POLICY_V1, + policyDigest: record.policyDigest, + }); + }; +} + +function policyKey(networkId: NetworkIdV1, contextGraphId: ContextGraphIdV1): string { + return `${networkId}\n${contextGraphId}`; +} diff --git a/packages/agent/src/rfc64/public-catalog-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-receiver-v1.ts new file mode 100644 index 0000000000..fa67373e24 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-receiver-v1.ts @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * RFC-64 Gate 1 receiver scheduler for public author-catalog head availability. + * + * `onCatalogHeadAvailable` hands us an untrusted, policy-admitted hint. The + * transport awaits that callback *before* it ACKs the announcement, so this + * scheduler MUST return synchronously: {@link Rfc64PublicCatalogReceiverV1.schedule} + * only enqueues and pumps; the fetch/verify/stage work runs on the pool after + * the announcement handler has returned. + * + * Per hinted head it: (1) deduplicates against both in-flight work and heads + * already durably staged, (2) fetches the exact head by digest (the transport + * re-verifies structure + issuer signature), and (3) durably stages the + * verified head into the control-object store. It STOPS there — Gate 1 never + * admits candidate rows, activates catalog state, or advances any SWM/VM + * pointer. Correctness comes from pull: a dropped or failed announcement is + * simply re-triggered by a later hint or a future reconcile cadence. + */ + +import type { + FetchedRfc64PublicCatalogHeadV1, + Rfc64PublicCatalogHeadAnnouncementV1, +} from './public-catalog-transport-v1.js'; + +/** Side effects the scheduler drives; all supplied by the wired service. */ +export interface Rfc64PublicCatalogReceiverStagerV1 { + /** True when the exact head is already durably staged (restart/prior-fetch dedup). */ + isHeadStaged(announcement: Rfc64PublicCatalogHeadAnnouncementV1): Promise; + /** Fetch the exact head by digest; null == authoritative not-found. */ + fetchHead( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + ): Promise; + /** Durably stage the verified head. Must not return before the write is durable. */ + stageHead(fetched: FetchedRfc64PublicCatalogHeadV1): Promise; +} + +export interface Rfc64PublicCatalogReceiverOptionsV1 { + /** Max concurrent fetch/stage chains. Default 4. */ + readonly maxConcurrent?: number; + /** Max queued distinct heads before new hints are dropped. Default 1024. */ + readonly maxQueue?: number; + /** Max fetch attempts per head before giving up. Default 3. */ + readonly maxAttempts?: number; + /** Base backoff between attempts (doubled per retry). Default 250ms. */ + readonly retryBackoffMs?: number; + readonly onHeadStaged?: ( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + remotePeerId: string, + ) => void; + readonly onError?: ( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + error: unknown, + ) => void; +} + +export interface Rfc64PublicCatalogReceiverStatsV1 { + readonly scheduled: number; + readonly dedupedInFlight: number; + readonly dedupedAlreadyStaged: number; + readonly staged: number; + readonly notFound: number; + readonly failed: number; + readonly droppedQueueFull: number; + readonly inFlight: number; + readonly queued: number; +} + +interface ReceiverTaskV1 { + readonly key: string; + readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; + readonly remotePeerId: string; +} + +const DEFAULTS = Object.freeze({ + maxConcurrent: 4, + maxQueue: 1024, + maxAttempts: 3, + retryBackoffMs: 250, +}); + +export class Rfc64PublicCatalogReceiverV1 { + readonly #stager: Rfc64PublicCatalogReceiverStagerV1; + readonly #maxConcurrent: number; + readonly #maxQueue: number; + readonly #maxAttempts: number; + readonly #retryBackoffMs: number; + readonly #onHeadStaged?: Rfc64PublicCatalogReceiverOptionsV1['onHeadStaged']; + readonly #onError?: Rfc64PublicCatalogReceiverOptionsV1['onError']; + + readonly #queue: ReceiverTaskV1[] = []; + /** Every head key currently queued or in-flight — the dedup set. */ + readonly #pendingKeys = new Set(); + readonly #active = new Set>(); + readonly #closing = new AbortController(); + #closed = false; + #idleWaiters: Array<() => void> = []; + + #scheduled = 0; + #dedupedInFlight = 0; + #dedupedAlreadyStaged = 0; + #staged = 0; + #notFound = 0; + #failed = 0; + #droppedQueueFull = 0; + + constructor( + stager: Rfc64PublicCatalogReceiverStagerV1, + options: Rfc64PublicCatalogReceiverOptionsV1 = {}, + ) { + this.#stager = stager; + this.#maxConcurrent = positiveInt(options.maxConcurrent, DEFAULTS.maxConcurrent); + this.#maxQueue = positiveInt(options.maxQueue, DEFAULTS.maxQueue); + this.#maxAttempts = positiveInt(options.maxAttempts, DEFAULTS.maxAttempts); + this.#retryBackoffMs = nonNegativeInt(options.retryBackoffMs, DEFAULTS.retryBackoffMs); + this.#onHeadStaged = options.onHeadStaged; + this.#onError = options.onError; + } + + /** + * Enqueue an announced head for fetch+stage. Non-blocking and synchronous: + * it never awaits the fetch, so the transport's ACK path is not stalled. + * Duplicate (already queued/in-flight) heads and post-close hints are dropped. + */ + schedule( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + remotePeerId: string, + ): void { + if (this.#closed) return; + this.#scheduled += 1; + const key = headKey(announcement); + if (this.#pendingKeys.has(key)) { + this.#dedupedInFlight += 1; + return; + } + if (this.#queue.length >= this.#maxQueue) { + this.#droppedQueueFull += 1; + return; + } + this.#pendingKeys.add(key); + this.#queue.push({ key, announcement, remotePeerId }); + this.#pump(); + } + + /** Resolve once no work is queued or in-flight. */ + whenIdle(): Promise { + if (this.#isIdle()) return Promise.resolve(); + return new Promise((resolve) => this.#idleWaiters.push(resolve)); + } + + /** + * Stop accepting hints, abort in-flight fetch retries, and await every + * in-flight chain so no durable stage write races the control store close. + */ + async close(): Promise { + if (this.#closed) { + await Promise.allSettled([...this.#active]); + return; + } + this.#closed = true; + this.#queue.length = 0; + this.#closing.abort(new Error('RFC-64 public catalog receiver closing')); + await Promise.allSettled([...this.#active]); + this.#resolveIdle(); + } + + stats(): Rfc64PublicCatalogReceiverStatsV1 { + return Object.freeze({ + scheduled: this.#scheduled, + dedupedInFlight: this.#dedupedInFlight, + dedupedAlreadyStaged: this.#dedupedAlreadyStaged, + staged: this.#staged, + notFound: this.#notFound, + failed: this.#failed, + droppedQueueFull: this.#droppedQueueFull, + inFlight: this.#active.size, + queued: this.#queue.length, + }); + } + + #pump(): void { + while (!this.#closed && this.#active.size < this.#maxConcurrent && this.#queue.length > 0) { + const task = this.#queue.shift()!; + const run = this.#runTask(task).finally(() => { + this.#active.delete(run); + this.#pendingKeys.delete(task.key); + if (!this.#closed) this.#pump(); + if (this.#isIdle()) this.#resolveIdle(); + }); + this.#active.add(run); + } + } + + async #runTask(task: ReceiverTaskV1): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < this.#maxAttempts; attempt += 1) { + if (this.#closing.signal.aborted) return; + try { + if (await this.#stager.isHeadStaged(task.announcement)) { + this.#dedupedAlreadyStaged += 1; + return; + } + const fetched = await this.#stager.fetchHead(task.remotePeerId, task.announcement); + if (fetched === null) { + this.#notFound += 1; + return; + } + await this.#stager.stageHead(fetched); + this.#staged += 1; + this.#safeNotify(() => this.#onHeadStaged?.(task.announcement, task.remotePeerId)); + return; + } catch (error) { + lastError = error; + if (this.#closing.signal.aborted) return; + if (attempt + 1 >= this.#maxAttempts) break; + await this.#backoff(attempt); + } + } + if (this.#closing.signal.aborted) return; + this.#failed += 1; + this.#safeNotify(() => this.#onError?.(task.announcement, lastError)); + } + + #backoff(attempt: number): Promise { + const delay = this.#retryBackoffMs * 2 ** attempt; + if (delay <= 0) return Promise.resolve(); + return new Promise((resolve) => { + const signal = this.#closing.signal; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, delay); + (timer as { unref?: () => void }).unref?.(); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); + } + + #safeNotify(fn: () => void): void { + try { + fn(); + } catch { + // Observer callbacks must never break the scheduler. + } + } + + #isIdle(): boolean { + return this.#active.size === 0 && this.#queue.length === 0; + } + + #resolveIdle(): void { + if (!this.#isIdle()) return; + const waiters = this.#idleWaiters; + this.#idleWaiters = []; + for (const resolve of waiters) resolve(); + } +} + +/** + * Dedup key: the exact head identity (scope + both digests). Heads at a new + * era/version or with a different object/signature digest are distinct work. + * `policyDigest` is intentionally excluded — the head binds to scope, not to a + * policy generation, and a stale policy fails the transport's own check. + */ +function headKey(a: Rfc64PublicCatalogHeadAnnouncementV1): string { + return [ + a.networkId, + a.contextGraphId, + a.subGraphName ?? '', + a.authorAddress, + a.catalogEra, + a.catalogVersion, + a.catalogHeadObjectDigest, + a.signatureVariantDigest, + ].join('\n'); +} + +function positiveInt(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback; +} + +function nonNegativeInt(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback; +} diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts new file mode 100644 index 0000000000..2fb33ebe6d --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * RFC-64 Gate 1 public author-catalog service. + * + * Cohesive owner of the public catalog slice wired into a running DKGAgent: + * - constructs {@link Rfc64PublicCatalogTransportV1} on the agent's PRODUCTION + * {@link ProtocolRouter} (admission-gated exactly like every other node + * protocol; on a chain-free node admission is disabled and it is open), + * - routes untrusted availability hints into the {@link Rfc64PublicCatalogReceiverV1} + * scheduler (fetch-by-digest → transport re-verify → durable stage; no + * activation), + * - answers the transport's open-policy check from the accepted-policy + * registry ({@link Rfc64AcceptedOpenCatalogPolicyRegistryV1}), + * - and provides the author path: produce a genesis head, durably stage it, + * then best-effort announce its availability to peers. + * + * Gate-1 boundary: no candidate-row admission, no KA/SWM/VM activation, no + * invite-only policy, no successor heads. + */ + +import { + type ProtocolRouter, + type SendOptions, + type SignedControlEnvelopeV1, + type AuthorCatalogScopeV1, + type Digest32V1, + type TimestampMsV1, +} from '@origintrail-official/dkg-core'; +import { + verifyControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; + +import { + produceEmptyAuthorCatalogGenesisV1, + type Rfc64AuthorCatalogEip191SignerV1, +} from './author-catalog-producer.js'; +import type { Rfc64ControlObjectOperationsV1 } from './control-object-store-v1.js'; +import { + Rfc64AcceptedOpenCatalogPolicyRegistryV1, + buildOpenOwnerContextGraphPolicyV1, + type AcceptedOpenCatalogPolicyV1, + type BuildOpenOwnerContextGraphPolicyInputV1, +} from './open-catalog-policy-v1.js'; +import { + Rfc64PublicCatalogReceiverV1, + type Rfc64PublicCatalogReceiverOptionsV1, + type Rfc64PublicCatalogReceiverStatsV1, +} from './public-catalog-receiver-v1.js'; +import { + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + Rfc64PublicCatalogTransportV1, + type Rfc64PublicCatalogHeadAnnouncementV1, +} from './public-catalog-transport-v1.js'; + +/** Default per-peer announce/fetch deadline (ms). */ +const DEFAULT_TRANSPORT_TIMEOUT_MS = 10_000; + +export interface Rfc64PublicCatalogServiceOptionsV1 { + readonly router: ProtocolRouter; + readonly controlObjects: Rfc64ControlObjectOperationsV1; + readonly receiver?: Rfc64PublicCatalogReceiverOptionsV1; + /** Per-peer announce/fetch timeout (ms). */ + readonly transportTimeoutMs?: number; + /** + * Override the generic envelope verifier. Defaults to the pure dkg-chain + * EIP-191 verifier — sufficient for author-catalog objects (no chain call). + */ + readonly verifyIssuerSignature?: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; + readonly onHeadStaged?: ( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + remotePeerId: string, + ) => void; +} + +export interface PublishOpenAuthorCatalogGenesisInputV1 { + readonly scope: AuthorCatalogScopeV1; + readonly signer: Rfc64AuthorCatalogEip191SignerV1; + readonly catalogIssuerDelegationDigest: Digest32V1; + readonly issuedAt: TimestampMsV1; + /** The accepted open policy for the CG; its digest stamps the announcement. */ + readonly policy: AcceptedOpenCatalogPolicyV1; + /** Peers to announce availability to. Announcements are best-effort hints. */ + readonly peers: readonly string[]; +} + +export interface PublishOpenAuthorCatalogGenesisResultV1 { + readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; + readonly headObjectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + /** Peers the announcement was acknowledged by. */ + readonly announcedPeers: readonly string[]; + /** Peers whose announcement failed (best-effort; correctness comes from pull). */ + readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; +} + +export interface Rfc64PublicCatalogServiceStatsV1 { + readonly started: boolean; + readonly acceptedPolicies: number; + readonly receiver: Rfc64PublicCatalogReceiverStatsV1; +} + +export class Rfc64PublicCatalogServiceV1 { + readonly #controlObjects: Rfc64ControlObjectOperationsV1; + readonly #verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; + readonly #policies = new Rfc64AcceptedOpenCatalogPolicyRegistryV1(); + readonly #receiver: Rfc64PublicCatalogReceiverV1; + readonly #transport: Rfc64PublicCatalogTransportV1; + readonly #transportTimeoutMs: number; + #started = false; + #closed = false; + + constructor(options: Rfc64PublicCatalogServiceOptionsV1) { + this.#controlObjects = options.controlObjects; + this.#verifyIssuerSignature = + options.verifyIssuerSignature ?? verifyControlEnvelopeIssuerSignatureV1; + this.#transportTimeoutMs = options.transportTimeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS; + + this.#receiver = new Rfc64PublicCatalogReceiverV1( + { + isHeadStaged: (announcement) => this.#isHeadStaged(announcement), + fetchHead: (remotePeerId, announcement) => + this.#transport.fetchCatalogHead(remotePeerId, announcement, this.#sendOptions()), + stageHead: (fetched) => this.#stageFetchedHead(fetched), + }, + { + ...options.receiver, + onHeadStaged: options.onHeadStaged ?? options.receiver?.onHeadStaged, + }, + ); + + this.#transport = new Rfc64PublicCatalogTransportV1(options.router, { + controlObjects: this.#controlObjects, + authorizeOpenCatalogOperation: this.#policies.authorize, + verifyIssuerSignature: this.#verifyIssuerSignature, + // Non-blocking: schedule() enqueues synchronously so the transport's ACK + // path (which awaits this callback) is never stalled on a fetch. + onCatalogHeadAvailable: (announcement, remotePeerId) => { + this.#receiver.schedule(announcement, remotePeerId); + }, + }); + } + + get started(): boolean { + return this.#started; + } + + /** Registry accessor for accepting the CG's open policy (author or receiver). */ + acceptOpenPolicy( + input: BuildOpenOwnerContextGraphPolicyInputV1, + ): AcceptedOpenCatalogPolicyV1 { + return this.#policies.accept(buildOpenOwnerContextGraphPolicyV1(input)); + } + + start(): void { + if (this.#closed) throw new Error('RFC-64 public catalog service is closed'); + if (this.#started) return; + this.#transport.start(); + this.#started = true; + } + + /** Stop serving, drain in-flight receiver work, then release. Idempotent. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + this.#started = false; + this.#transport.stop(); + await this.#receiver.close(); + } + + /** + * Author path: produce a signed empty genesis head, durably stage its + * immutable objects, then best-effort announce availability to `peers`. The + * head is durable before any announcement; announcements grant no authority. + */ + async publishOpenAuthorCatalogGenesis( + input: PublishOpenAuthorCatalogGenesisInputV1, + ): Promise { + this.#requireStarted(); + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope: input.scope, + catalogIssuerDelegationDigest: input.catalogIssuerDelegationDigest, + issuedAt: input.issuedAt, + signer: input.signer, + }); + + const verified = await Promise.all( + produced.stagedObjects.map(async (envelope) => ({ + envelope, + issuerSignature: await this.#verifyIssuerSignature(envelope), + })), + ); + const staged = await this.#controlObjects.stageVerifiedObjects(verified); + const headKeys = staged.objects.at(-1); + if (headKeys === undefined) { + throw new Error('RFC-64 author catalog producer staged no head object'); + } + + const announcement: Rfc64PublicCatalogHeadAnnouncementV1 = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: produced.head.payload.networkId, + contextGraphId: produced.head.payload.contextGraphId, + subGraphName: produced.head.payload.subGraphName, + authorAddress: produced.head.payload.authorAddress, + catalogEra: produced.head.payload.era, + catalogVersion: produced.head.payload.version, + policyDigest: input.policy.policyDigest, + catalogHeadObjectDigest: headKeys.objectDigest, + signatureVariantDigest: headKeys.signatureVariantDigest, + }); + + const announcedPeers: string[] = []; + const failedPeers: Array<{ peerId: string; error: string }> = []; + for (const peerId of input.peers) { + try { + await this.#transport.announceCatalogHead(peerId, announcement, this.#sendOptions()); + announcedPeers.push(peerId); + } catch (error) { + failedPeers.push({ + peerId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return Object.freeze({ + announcement, + headObjectDigest: headKeys.objectDigest, + signatureVariantDigest: headKeys.signatureVariantDigest, + announcedPeers: Object.freeze(announcedPeers), + failedPeers: Object.freeze(failedPeers), + }); + } + + /** Idle-await the receiver (tests / graceful shutdown coordination). */ + whenReceiverIdle(): Promise { + return this.#receiver.whenIdle(); + } + + stats(): Rfc64PublicCatalogServiceStatsV1 { + return Object.freeze({ + started: this.#started, + acceptedPolicies: this.#policies.size, + receiver: this.#receiver.stats(), + }); + } + + #sendOptions(): SendOptions { + return { timeoutMs: this.#transportTimeoutMs }; + } + + async #isHeadStaged( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + ): Promise { + const stored = await this.#controlObjects.getVerifiedObject({ + objectDigest: announcement.catalogHeadObjectDigest, + signatureVariantDigest: announcement.signatureVariantDigest, + verifyIssuerSignature: this.#verifyIssuerSignature, + }); + return stored !== null; + } + + async #stageFetchedHead(fetched: { + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; + }): Promise { + await this.#controlObjects.stageVerifiedObjects([fetched]); + } + + #requireStarted(): void { + if (!this.#started || this.#closed) { + throw new Error('RFC-64 public catalog service is not started'); + } + } +} diff --git a/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts new file mode 100644 index 0000000000..df65d32e40 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { multiaddr } from '@multiformats/multiaddr'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { DKGAgent } from '../src/dkg-agent.js'; +import type { + ContextGraphIdV1, + NetworkIdV1, + TimestampMsV1, +} from '@origintrail-official/dkg-core'; + +/** + * Gate 1 end-to-end across TWO real DKGAgent instances, driven entirely through + * the WIRED path: `DKGAgent.create()` + `agent.start()` constructs and starts + * the public catalog service on each agent's production router; the author + * publishes+announces via the agent method; the receiver's wired + * `onCatalogHeadAvailable` + scheduler fetch, re-verify, and durably stage. + * Nothing here reconstructs the transport by hand. + */ + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); +const NETWORK_ID = 'otp:20430' as NetworkIdV1; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/gate-1' as ContextGraphIdV1; +const FIXED_HEAD_ISSUED_AT = '1773900000000' as TimestampMsV1; + +const agents: DKGAgent[] = []; +const tempDirs: string[] = []; + +afterEach(async () => { + for (const agent of agents.splice(0)) { + try { await agent.stop(); } catch { /* best-effort */ } + } + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +async function startAgent(name: string): Promise { + const dataDir = await mkdtemp(join(tmpdir(), `dkg-rfc64-gate1-${name}-`)); + tempDirs.push(dataDir); + const agent = await DKGAgent.create({ + name, + dataDir, + listenHost: '127.0.0.1', + listenPort: 0, + bootstrapPeers: [], + nodeRole: 'edge', + store: new OxigraphStore(), + syncSharedMemoryOnConnect: false, + syncReconcilerEnabled: false, + syncOnConnectEnabled: false, + durableSyncEnabled: false, + agentProfileHeartbeatMs: 0, + }); + agents.push(agent); + await agent.start(); + return agent; +} + +function tcpMultiaddr(agent: DKGAgent): string { + const address = agent.multiaddrs.find((candidate) => candidate.includes('/tcp/')); + if (address === undefined) throw new Error('agent has no TCP multiaddr'); + return address; +} + +async function connectBothWays(a: DKGAgent, b: DKGAgent): Promise { + // Raw libp2p dials (chain-free → network admission is disabled). A single + // libp2p connection is bidirectional; dialing both ways just seeds both + // peer stores so the production router's send() can resolve either peer. + await a.node.libp2p.dial(multiaddr(tcpMultiaddr(b))); + await b.node.libp2p.dial(multiaddr(tcpMultiaddr(a))); +} + +describe('RFC-64 Gate 1 public catalog wiring — two real DKGAgent instances', () => { + it('announces, fetches, re-verifies, and durably stages the exact head; dedups; no activation', async () => { + const [author, receiver] = await Promise.all([startAgent('author'), startAgent('receiver')]); + + // The wired service must be live on both started agents (assert via the + // agent's own accessor, not a hand-built transport). + expect(author.rfc64PublicCatalogStatsV1()?.started).toBe(true); + expect(receiver.rfc64PublicCatalogStatsV1()?.started).toBe(true); + + // The receiver independently accepts the SAME open policy from CG-identity + // facts it holds locally (owner = the author EOA) — BEFORE any announcement. + // This is the honesty boundary: the receiver's policyDigest comes from its + // own accepted policy object, never from the untrusted wire announcement. + const receiverPolicy = receiver.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR_WALLET.address.toLowerCase() as never, + }); + + await connectBothWays(author, receiver); + + const published = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [receiver.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + }); + + // Author and receiver independently computed the same policy digest. + expect(published.announcement.policyDigest).toBe(receiverPolicy.policyDigest); + // The announcement was acknowledged by the receiver over the production + // router — proving send() delivers across the dialed connection + admission. + expect(published.announcedPeers).toEqual([receiver.peerId]); + expect(published.failedPeers).toEqual([]); + + // The receiver's wired scheduler fetched + staged the head. + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + + // Durably staged the EXACT head: read it back from the receiver's + // control-object store by the announced digests. + const stagedDigest = await receiver.readRfc64StagedAuthorCatalogHeadV1({ + objectDigest: published.headObjectDigest, + signatureVariantDigest: published.signatureVariantDigest, + }); + expect(stagedDigest).toBe(published.headObjectDigest); + + const receiverStats = receiver.rfc64PublicCatalogStatsV1(); + expect(receiverStats?.receiver).toMatchObject({ staged: 1, notFound: 0, failed: 0 }); + + // Re-announcing the identical head is deduped against durable state — no + // second fetch/stage. + const republished = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [receiver.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + }); + expect(republished.announcement.catalogHeadObjectDigest).toBe(published.headObjectDigest); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + + const afterStats = receiver.rfc64PublicCatalogStatsV1(); + expect(afterStats?.receiver.staged).toBe(1); // still exactly one durable stage + expect(afterStats?.receiver.dedupedAlreadyStaged).toBeGreaterThanOrEqual(1); + + // NO KA/SWM activation: the head lives only in the control-object cache; + // the receiver never activated the CG as queryable knowledge. + const activeContextGraphs = await receiver.listContextGraphs(); + expect( + activeContextGraphs.some( + (row) => row.id === CONTEXT_GRAPH_ID || row.uri.includes(CONTEXT_GRAPH_ID), + ), + ).toBe(false); + }, 60_000); + + it('fails closed when the receiver holds no accepted open policy for the CG', async () => { + const [author, receiver] = await Promise.all([startAgent('author2'), startAgent('receiver2')]); + // Receiver does NOT accept any policy → its authorizer returns null → + // inbound announce is denied and the head is never fetched/staged. + await connectBothWays(author, receiver); + + const published = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [receiver.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + }); + // The announcement is refused by the receiver's policy gate. + expect(published.announcedPeers).toEqual([]); + expect(published.failedPeers).toHaveLength(1); + + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + const stagedDigest = await receiver.readRfc64StagedAuthorCatalogHeadV1({ + objectDigest: published.headObjectDigest, + signatureVariantDigest: published.signatureVariantDigest, + }); + expect(stagedDigest).toBeNull(); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver.staged).toBe(0); + }, 60_000); +}); diff --git a/packages/agent/test/rfc64-public-catalog-receiver-v1.test.ts b/packages/agent/test/rfc64-public-catalog-receiver-v1.test.ts new file mode 100644 index 0000000000..22913efd4d --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-receiver-v1.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + Rfc64PublicCatalogReceiverV1, + type Rfc64PublicCatalogReceiverStagerV1, +} from '../src/rfc64/public-catalog-receiver-v1.js'; +import type { + FetchedRfc64PublicCatalogHeadV1, + Rfc64PublicCatalogHeadAnnouncementV1, +} from '../src/rfc64/public-catalog-transport-v1.js'; + +function announcement( + overrides: Partial = {}, +): Rfc64PublicCatalogHeadAnnouncementV1 { + return { + kind: 'rfc64-author-catalog-head-availability-v1', + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/lane', + subGraphName: null, + authorAddress: '0x2222222222222222222222222222222222222222', + catalogEra: '0', + catalogVersion: '0', + policyDigest: `0x${'71'.repeat(32)}`, + catalogHeadObjectDigest: `0x${'aa'.repeat(32)}`, + signatureVariantDigest: `0x${'bb'.repeat(32)}`, + ...overrides, + } as Rfc64PublicCatalogHeadAnnouncementV1; +} + +function headWith(objectDigest: string): Rfc64PublicCatalogHeadAnnouncementV1 { + return announcement({ catalogHeadObjectDigest: objectDigest as `0x${string}` & string }); +} + +const FAKE_FETCHED = { envelope: {}, issuerSignature: {} } as unknown as FetchedRfc64PublicCatalogHeadV1; + +function deferred(): { promise: Promise; resolve: (v: T) => void; reject: (e: unknown) => void } { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('RFC-64 public catalog receiver scheduler v1', () => { + it('fetches, durably stages, and reports the staged head', async () => { + const staged: FetchedRfc64PublicCatalogHeadV1[] = []; + const onHeadStaged = vi.fn(); + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => false, + fetchHead: async () => FAKE_FETCHED, + stageHead: async (f) => { staged.push(f); }, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager, { onHeadStaged }); + receiver.schedule(announcement(), 'peerA'); + await receiver.whenIdle(); + + expect(staged).toEqual([FAKE_FETCHED]); + expect(onHeadStaged).toHaveBeenCalledTimes(1); + expect(receiver.stats()).toMatchObject({ scheduled: 1, staged: 1, notFound: 0, failed: 0 }); + }); + + it('schedule() returns synchronously without awaiting the fetch (ACK path is not stalled)', () => { + const fetchStarted = deferred(); + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => { fetchStarted.resolve(); return false; }, + fetchHead: () => new Promise(() => {}), // never resolves + stageHead: async () => {}, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager); + const returned = receiver.schedule(announcement(), 'peerA'); + // schedule returns void synchronously even though fetch never completes. + expect(returned).toBeUndefined(); + expect(receiver.stats().scheduled).toBe(1); + }); + + it('deduplicates a head already in flight', async () => { + const gate = deferred(); + let fetchCalls = 0; + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => false, + fetchHead: async () => { fetchCalls += 1; return gate.promise; }, + stageHead: async () => {}, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager); + receiver.schedule(announcement(), 'peerA'); + receiver.schedule(announcement(), 'peerB'); // same head identity → in-flight dedup + expect(receiver.stats().dedupedInFlight).toBe(1); + gate.resolve(FAKE_FETCHED); + await receiver.whenIdle(); + expect(fetchCalls).toBe(1); + expect(receiver.stats()).toMatchObject({ scheduled: 2, dedupedInFlight: 1, staged: 1 }); + }); + + it('skips a head that is already durably staged (no fetch)', async () => { + const fetchHead = vi.fn(async () => FAKE_FETCHED); + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => true, + fetchHead, + stageHead: async () => {}, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager); + receiver.schedule(announcement(), 'peerA'); + await receiver.whenIdle(); + expect(fetchHead).not.toHaveBeenCalled(); + expect(receiver.stats()).toMatchObject({ dedupedAlreadyStaged: 1, staged: 0 }); + }); + + it('records not-found without staging', async () => { + const stageHead = vi.fn(async () => {}); + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => false, + fetchHead: async () => null, + stageHead, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager); + receiver.schedule(announcement(), 'peerA'); + await receiver.whenIdle(); + expect(stageHead).not.toHaveBeenCalled(); + expect(receiver.stats()).toMatchObject({ notFound: 1, staged: 0 }); + }); + + it('drops distinct heads when the queue is full', async () => { + const gate = deferred(); + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => false, + fetchHead: async () => gate.promise, + stageHead: async () => {}, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager, { maxConcurrent: 1, maxQueue: 1 }); + receiver.schedule(headWith(`0x${'a1'.repeat(32)}`), 'peer'); // active + receiver.schedule(headWith(`0x${'a2'.repeat(32)}`), 'peer'); // queued + receiver.schedule(headWith(`0x${'a3'.repeat(32)}`), 'peer'); // dropped (queue full) + expect(receiver.stats().droppedQueueFull).toBe(1); + gate.resolve(null); + await receiver.whenIdle(); + }); + + it('retries transient fetch failures with bounded backoff then succeeds', async () => { + let attempts = 0; + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => false, + fetchHead: async () => { + attempts += 1; + if (attempts < 3) throw new Error('transient network failure'); + return FAKE_FETCHED; + }, + stageHead: async () => {}, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager, { maxAttempts: 3, retryBackoffMs: 1 }); + receiver.schedule(announcement(), 'peerA'); + await receiver.whenIdle(); + expect(attempts).toBe(3); + expect(receiver.stats()).toMatchObject({ staged: 1, failed: 0 }); + }); + + it('gives up after maxAttempts and reports failure', async () => { + const onError = vi.fn(); + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => false, + fetchHead: async () => { throw new Error('down'); }, + stageHead: async () => {}, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager, { maxAttempts: 2, retryBackoffMs: 1, onError }); + receiver.schedule(announcement(), 'peerA'); + await receiver.whenIdle(); + expect(onError).toHaveBeenCalledTimes(1); + expect(receiver.stats()).toMatchObject({ failed: 1, staged: 0 }); + }); + + it('close() awaits in-flight stage writes and rejects new work', async () => { + const stageGate = deferred(); + let stageDone = false; + const stager: Rfc64PublicCatalogReceiverStagerV1 = { + isHeadStaged: async () => false, + fetchHead: async () => FAKE_FETCHED, + stageHead: async () => { await stageGate.promise; stageDone = true; }, + }; + const receiver = new Rfc64PublicCatalogReceiverV1(stager); + receiver.schedule(announcement(), 'peerA'); + // Let the task reach the stage await. + await Promise.resolve(); + await Promise.resolve(); + const closing = receiver.close(); + let closed = false; + void closing.then(() => { closed = true; }); + await Promise.resolve(); + expect(closed).toBe(false); // close is blocked on the in-flight stage write + stageGate.resolve(); + await closing; + expect(stageDone).toBe(true); + // Post-close schedules are dropped. + receiver.schedule(announcement({ catalogHeadObjectDigest: `0x${'cc'.repeat(32)}` as never }), 'peerA'); + expect(receiver.stats().scheduled).toBe(1); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 5652eb78b2..4006d161c7 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -119,6 +119,9 @@ export default defineConfig({ "test/rfc64-control-object-store-lifecycle-v1.test.ts", "test/rfc64-durable-file-store-v1.test.ts", "test/rfc64-secure-filesystem-policy-v1.test.ts", + "test/rfc64-public-catalog-transport-v1.test.ts", + "test/rfc64-public-catalog-receiver-v1.test.ts", + "test/rfc64-public-catalog-gate1.integration.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 3a88afccddb767369965c53bee9a1fc8cc98e277 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:00:40 +0200 Subject: [PATCH 107/292] feat(agent): add RFC-64 catalog content transport --- packages/agent/src/index.ts | 1 + .../public-catalog-native-transport-v1.ts | 691 ++++++++++++++++++ ...public-catalog-native-transport-v1.test.ts | 219 ++++++ packages/agent/vitest.unit.config.ts | 1 + 4 files changed, 912 insertions(+) create mode 100644 packages/agent/src/rfc64/public-catalog-native-transport-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 8a70e174a4..c53a901c41 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -40,6 +40,7 @@ export * from './rfc64/public-catalog-transport-v1.js'; export * from './rfc64/open-catalog-policy-v1.js'; export * from './rfc64/public-catalog-receiver-v1.js'; export * from './rfc64/public-catalog-service-v1.js'; +export * from './rfc64/public-catalog-native-transport-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts new file mode 100644 index 0000000000..0e282fbec4 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts @@ -0,0 +1,691 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * RFC-64 public/open catalog content transport. + * + * This is the digest-following half of the Gate-1 transport. A catalog-head + * announcement still travels through `Rfc64PublicCatalogTransportV1`; after the + * receiver verifies that exact head it uses this transport to fetch the signed + * directory/bucket objects committed by the head and the opaque KA bundle + * committed by a selected catalog row. + * + * The two protocols are deliberately additive and policy gated. Responses are + * re-bound to exact digests locally, so an announcement or provider response + * never grants catalog or activation authority by itself. + */ + +import { + MAX_CONTROL_OBJECT_BYTES, + ProtocolRouter, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertContextGraphIdV1, + assertNetworkIdV1, + assertSignedControlEnvelope, + assertSubGraphNameV1, + canonicalizeSignedControlEnvelopeBytes, + decodeOpaqueKaBundleV1, + parseCanonicalSignedControlEnvelope, + type ContextGraphAccessPolicyV1, + type ContextGraphIdV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type SendOptions, + type SignedControlEnvelopeV1, + type SubGraphNameV1, +} from '@origintrail-official/dkg-core'; +import { + readVerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; + +export const RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1 = + '/dkg/catalog/1/control-object/by-digest' as const; +export const RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1 = + '/dkg/catalog/1/ka-bundle/by-digest' as const; + +export const RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1 = + 'rfc64-public-catalog-object-fetch-v1' as const; +export const RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1 = + 'rfc64-public-catalog-bundle-fetch-v1' as const; + +export const RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1 = 4 * 1024; +export const RFC64_PUBLIC_CATALOG_OBJECT_FETCH_RESPONSE_MAX_BYTES_V1 = + MAX_CONTROL_OBJECT_BYTES + 1; +/** First vertical slice resource ceiling; protocol descriptors may advertise more. */ +export const RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1 = + 8 * 1024 * 1024; + +const FETCH_NOT_FOUND = 0; +const FETCH_FOUND = 1; +const FETCH_DENIED = 2; +const MAX_PEER_ID_BYTES = 256; +const UTF8 = new TextEncoder(); +const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +const SCOPE_KEYS = Object.freeze([ + 'authorAddress', + 'catalogEra', + 'catalogHeadObjectDigest', + 'catalogVersion', + 'contextGraphId', + 'kind', + 'networkId', + 'policyDigest', + 'subGraphName', +] as const); +const OBJECT_REQUEST_KEYS = Object.freeze([ + ...SCOPE_KEYS, + 'targetObjectDigest', + 'targetObjectType', +].sort()); +const BUNDLE_REQUEST_KEYS = Object.freeze([ + ...SCOPE_KEYS, + 'blobDigest', + 'byteLength', +].sort()); + +export interface Rfc64PublicCatalogNativeFetchScopeV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogEra: DecimalU64V1; + readonly catalogVersion: DecimalU64V1; + readonly policyDigest: Digest32V1; + readonly catalogHeadObjectDigest: Digest32V1; +} + +export interface Rfc64PublicCatalogObjectFetchRequestV1 + extends Rfc64PublicCatalogNativeFetchScopeV1 { + readonly kind: typeof RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1; + readonly targetObjectType: string; + readonly targetObjectDigest: Digest32V1; +} + +export interface Rfc64PublicCatalogBundleFetchRequestV1 + extends Rfc64PublicCatalogNativeFetchScopeV1 { + readonly kind: typeof RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1; + readonly blobDigest: Digest32V1; + readonly byteLength: DecimalU64V1; +} + +export type Rfc64PublicCatalogNativeOperationV1 = + | 'catalog-object-fetch-outbound' + | 'catalog-object-fetch-inbound' + | 'ka-bundle-fetch-outbound' + | 'ka-bundle-fetch-inbound'; + +export interface Rfc64PublicCatalogNativeAuthorizationInputV1 { + readonly operation: Rfc64PublicCatalogNativeOperationV1; + readonly remotePeerId: string; + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly policyDigest: Digest32V1; + readonly catalogHeadObjectDigest: Digest32V1; +} + +export interface Rfc64PublicCatalogNativeAuthorizationV1 { + readonly accessPolicy: ContextGraphAccessPolicyV1; + readonly policyDigest: Digest32V1; +} + +export interface FetchedRfc64PublicCatalogObjectV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; +} + +export interface Rfc64PublicCatalogNativeTransportOptionsV1 { + /** Provider-side exact immutable catalog-object lookup. */ + readonly readCatalogObjectByDigest: ( + objectDigest: Digest32V1, + ) => Promise; + /** Provider-side exact immutable bundle lookup. */ + readonly readKaBundleByDigest: ( + blobDigest: Digest32V1, + ) => Promise; + /** Must consult accepted current policy state, never the untrusted request. */ + readonly authorizeOpenCatalogOperation: ( + input: Rfc64PublicCatalogNativeAuthorizationInputV1, + ) => Promise; + readonly verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; +} + +export type Rfc64PublicCatalogNativeTransportErrorCodeV1 = + | 'catalog-native-input' + | 'catalog-native-wire' + | 'catalog-native-policy-denied' + | 'catalog-native-object-mismatch' + | 'catalog-native-signature' + | 'catalog-native-resource-refused' + | 'catalog-native-state'; + +export class Rfc64PublicCatalogNativeTransportErrorV1 extends Error { + constructor( + readonly code: Rfc64PublicCatalogNativeTransportErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'Rfc64PublicCatalogNativeTransportErrorV1'; + } +} + +export class Rfc64PublicCatalogNativeTransportV1 { + #started = false; + + constructor( + private readonly router: ProtocolRouter, + private readonly options: Rfc64PublicCatalogNativeTransportOptionsV1, + ) { + if (typeof options?.readCatalogObjectByDigest !== 'function') { + fail('catalog-native-input', 'readCatalogObjectByDigest must be a function'); + } + if (typeof options.readKaBundleByDigest !== 'function') { + fail('catalog-native-input', 'readKaBundleByDigest must be a function'); + } + if (typeof options.authorizeOpenCatalogOperation !== 'function') { + fail('catalog-native-input', 'authorizeOpenCatalogOperation must be a function'); + } + if (typeof options.verifyIssuerSignature !== 'function') { + fail('catalog-native-input', 'verifyIssuerSignature must be a function'); + } + } + + get started(): boolean { + return this.#started; + } + + start(): void { + if (this.#started) return; + this.#started = true; + try { + this.router.register( + RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, + async (data, peerId) => this.handleCatalogObjectFetch(data, peerId.toString()), + { maxReadBytes: RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1 }, + ); + this.router.register( + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, + async (data, peerId) => this.handleBundleFetch(data, peerId.toString()), + { maxReadBytes: RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1 }, + ); + } catch (cause) { + this.#started = false; + this.router.unregister(RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1); + this.router.unregister(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1); + throw cause; + } + } + + stop(): void { + if (!this.#started) return; + this.#started = false; + this.router.unregister(RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1); + this.router.unregister(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1); + } + + async fetchCatalogObject( + remotePeerIdInput: string, + requestInput: Rfc64PublicCatalogObjectFetchRequestV1, + sendOptions?: SendOptions, + ): Promise { + this.requireStarted(); + const remotePeerId = snapshotPeerId(remotePeerIdInput); + const request = parseCatalogObjectRequest(encodeRequest(requestInput)); + await this.requireOpenPolicy('catalog-object-fetch-outbound', remotePeerId, request); + const response = await this.router.send( + remotePeerId, + RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, + encodeRequest(request), + sendOptions, + ); + const envelope = parseCatalogObjectResponse(response); + await this.requireOpenPolicy('catalog-object-fetch-outbound', remotePeerId, request); + if (envelope === null) return null; + assertCatalogObjectMatchesRequest(envelope, request); + const issuerSignature = await this.verifyExactIssuerSignature(envelope); + return Object.freeze({ envelope: deepFreeze(envelope), issuerSignature }); + } + + async fetchKaBundle( + remotePeerIdInput: string, + requestInput: Rfc64PublicCatalogBundleFetchRequestV1, + sendOptions?: SendOptions, + ): Promise { + this.requireStarted(); + const remotePeerId = snapshotPeerId(remotePeerIdInput); + const request = parseBundleRequest(encodeRequest(requestInput)); + if (BigInt(request.byteLength) + 1n + > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1)) { + fail( + 'catalog-native-resource-refused', + 'advertised KA bundle exceeds this receiver transport resource ceiling', + ); + } + await this.requireOpenPolicy('ka-bundle-fetch-outbound', remotePeerId, request); + const response = await this.router.send( + remotePeerId, + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, + encodeRequest(request), + sendOptions, + ); + const bundle = parseBundleResponse(response, request); + await this.requireOpenPolicy('ka-bundle-fetch-outbound', remotePeerId, request); + return bundle; + } + + private async handleCatalogObjectFetch( + data: Uint8Array, + remotePeerIdInput: string, + ): Promise { + this.requireStarted(); + const remotePeerId = snapshotPeerId(remotePeerIdInput); + const request = parseCatalogObjectRequest(data); + if (!await this.isOpenPolicy('catalog-object-fetch-inbound', remotePeerId, request)) { + return Uint8Array.of(FETCH_DENIED); + } + const envelope = await this.options.readCatalogObjectByDigest(request.targetObjectDigest); + if (envelope === null) return Uint8Array.of(FETCH_NOT_FOUND); + assertCatalogObjectMatchesRequest(envelope, request); + await this.verifyExactIssuerSignature(envelope); + if (!await this.isOpenPolicy('catalog-object-fetch-inbound', remotePeerId, request)) { + return Uint8Array.of(FETCH_DENIED); + } + const bytes = canonicalizeSignedControlEnvelopeBytes(envelope); + if (bytes.byteLength + 1 > RFC64_PUBLIC_CATALOG_OBJECT_FETCH_RESPONSE_MAX_BYTES_V1) { + fail('catalog-native-resource-refused', 'catalog object exceeds the response ceiling'); + } + return foundResponse(bytes); + } + + private async handleBundleFetch( + data: Uint8Array, + remotePeerIdInput: string, + ): Promise { + this.requireStarted(); + const remotePeerId = snapshotPeerId(remotePeerIdInput); + const request = parseBundleRequest(data); + if (BigInt(request.byteLength) + 1n + > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1)) { + fail('catalog-native-resource-refused', 'requested KA bundle exceeds the response ceiling'); + } + if (!await this.isOpenPolicy('ka-bundle-fetch-inbound', remotePeerId, request)) { + return Uint8Array.of(FETCH_DENIED); + } + const bundle = await this.options.readKaBundleByDigest(request.blobDigest); + if (bundle === null) return Uint8Array.of(FETCH_NOT_FOUND); + assertExactBundle(bundle, request); + if (!await this.isOpenPolicy('ka-bundle-fetch-inbound', remotePeerId, request)) { + return Uint8Array.of(FETCH_DENIED); + } + return foundResponse(bundle); + } + + private async isOpenPolicy( + operation: Rfc64PublicCatalogNativeOperationV1, + remotePeerId: string, + request: Rfc64PublicCatalogObjectFetchRequestV1 + | Rfc64PublicCatalogBundleFetchRequestV1, + ): Promise { + try { + await this.requireOpenPolicy(operation, remotePeerId, request); + return true; + } catch (cause) { + if ( + cause instanceof Rfc64PublicCatalogNativeTransportErrorV1 + && cause.code === 'catalog-native-policy-denied' + ) return false; + throw cause; + } + } + + private async requireOpenPolicy( + operation: Rfc64PublicCatalogNativeOperationV1, + remotePeerId: string, + request: Rfc64PublicCatalogObjectFetchRequestV1 + | Rfc64PublicCatalogBundleFetchRequestV1, + ): Promise { + let authorization: Rfc64PublicCatalogNativeAuthorizationV1 | null; + try { + authorization = await this.options.authorizeOpenCatalogOperation(Object.freeze({ + operation, + remotePeerId, + networkId: request.networkId, + contextGraphId: request.contextGraphId, + subGraphName: request.subGraphName, + policyDigest: request.policyDigest, + catalogHeadObjectDigest: request.catalogHeadObjectDigest, + })); + } catch (cause) { + fail('catalog-native-policy-denied', 'open catalog policy authorization failed', cause); + } + if (authorization === null || authorization.accessPolicy !== 0) { + fail('catalog-native-policy-denied', 'catalog content fetch is not open-policy authorized'); + } + try { + assertCanonicalDigest(authorization.policyDigest, 'authorized policyDigest'); + } catch (cause) { + fail('catalog-native-policy-denied', 'authorization returned an invalid digest', cause); + } + if (authorization.policyDigest !== request.policyDigest) { + fail('catalog-native-policy-denied', 'catalog policy generation is stale or mismatched'); + } + } + + private async verifyExactIssuerSignature( + envelope: SignedControlEnvelopeV1, + ): Promise { + try { + const proof = await this.options.verifyIssuerSignature(envelope); + const snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + if ( + snapshot.objectDigest !== envelope.objectDigest + || snapshot.issuer !== envelope.issuer + || snapshot.signatureSuite !== envelope.signatureSuite + ) { + throw new Error('issuer-signature proof identifies another envelope'); + } + return proof; + } catch (cause) { + fail('catalog-native-signature', 'catalog object issuer signature is invalid', cause); + } + } + + private requireStarted(): void { + if (!this.#started) fail('catalog-native-state', 'catalog native transport is not started'); + } +} + +export function encodeRfc64PublicCatalogObjectFetchRequestV1( + input: Rfc64PublicCatalogObjectFetchRequestV1, +): Uint8Array { + return encodeRequest(validateObjectRequest(input)); +} + +export function parseRfc64PublicCatalogObjectFetchRequestV1( + input: Uint8Array, +): Rfc64PublicCatalogObjectFetchRequestV1 { + return parseCatalogObjectRequest(input); +} + +export function encodeRfc64PublicCatalogBundleFetchRequestV1( + input: Rfc64PublicCatalogBundleFetchRequestV1, +): Uint8Array { + return encodeRequest(validateBundleRequest(input)); +} + +export function parseRfc64PublicCatalogBundleFetchRequestV1( + input: Uint8Array, +): Rfc64PublicCatalogBundleFetchRequestV1 { + return parseBundleRequest(input); +} + +function parseCatalogObjectRequest(input: Uint8Array): Rfc64PublicCatalogObjectFetchRequestV1 { + const parsed = parseRequest(input, OBJECT_REQUEST_KEYS); + return validateObjectRequest(parsed); +} + +function parseBundleRequest(input: Uint8Array): Rfc64PublicCatalogBundleFetchRequestV1 { + const parsed = parseRequest(input, BUNDLE_REQUEST_KEYS); + return validateBundleRequest(parsed); +} + +function validateObjectRequest(value: unknown): Rfc64PublicCatalogObjectFetchRequestV1 { + const scope = validateScope(value, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1); + if (!isPlainRecord(value)) throw new Error('unreachable'); + if (typeof value.targetObjectType !== 'string' || value.targetObjectType.length < 1 + || UTF8.encode(value.targetObjectType).byteLength > 256) { + fail('catalog-native-wire', 'targetObjectType is empty or oversized'); + } + try { + assertCanonicalDigest(value.targetObjectDigest, 'targetObjectDigest'); + } catch (cause) { + fail('catalog-native-wire', 'targetObjectDigest is invalid', cause); + } + return Object.freeze({ + ...scope, + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + targetObjectType: value.targetObjectType, + targetObjectDigest: value.targetObjectDigest, + }) as Rfc64PublicCatalogObjectFetchRequestV1; +} + +function validateBundleRequest(value: unknown): Rfc64PublicCatalogBundleFetchRequestV1 { + const scope = validateScope(value, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1); + if (!isPlainRecord(value)) throw new Error('unreachable'); + try { + assertCanonicalDigest(value.blobDigest, 'blobDigest'); + assertCanonicalDecimalU64(value.byteLength, 'byteLength'); + } catch (cause) { + fail('catalog-native-wire', 'bundle request contains an invalid digest or length', cause); + } + return Object.freeze({ + ...scope, + kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + blobDigest: value.blobDigest, + byteLength: value.byteLength, + }) as Rfc64PublicCatalogBundleFetchRequestV1; +} + +function validateScope( + value: unknown, + kind: typeof RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1 + | typeof RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, +): Rfc64PublicCatalogNativeFetchScopeV1 { + if (!isPlainRecord(value) || value.kind !== kind) { + fail('catalog-native-wire', `catalog native request kind must be ${kind}`); + } + try { + assertNetworkIdV1(value.networkId); + assertContextGraphIdV1(value.contextGraphId); + if (value.subGraphName !== null) assertSubGraphNameV1(value.subGraphName); + assertCanonicalEvmAddress(value.authorAddress, 'authorAddress'); + assertCanonicalDecimalU64(value.catalogEra, 'catalogEra'); + assertCanonicalDecimalU64(value.catalogVersion, 'catalogVersion'); + assertCanonicalDigest(value.policyDigest, 'policyDigest'); + assertCanonicalDigest(value.catalogHeadObjectDigest, 'catalogHeadObjectDigest'); + } catch (cause) { + fail('catalog-native-wire', 'catalog native request contains an invalid scope', cause); + } + return Object.freeze({ + networkId: value.networkId, + contextGraphId: value.contextGraphId, + subGraphName: value.subGraphName, + authorAddress: value.authorAddress, + catalogEra: value.catalogEra, + catalogVersion: value.catalogVersion, + policyDigest: value.policyDigest, + catalogHeadObjectDigest: value.catalogHeadObjectDigest, + }) as Rfc64PublicCatalogNativeFetchScopeV1; +} + +function encodeRequest(value: object): Uint8Array { + if (!isPlainRecord(value)) { + fail('catalog-native-wire', 'catalog native request must be a plain object'); + } + const fields: string[] = []; + for (const key of Object.keys(value).sort()) { + const field = value[key]; + if (field !== null && typeof field !== 'string') { + fail('catalog-native-wire', 'catalog native requests accept only string or null fields'); + } + fields.push(`${JSON.stringify(key)}:${JSON.stringify(field)}`); + } + const bytes = UTF8.encode(`{${fields.join(',')}}`); + if (bytes.byteLength > RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1) { + fail('catalog-native-wire', 'catalog native request exceeds its byte ceiling'); + } + return bytes; +} + +function parseRequest(input: Uint8Array, expectedKeys: readonly string[]): Record { + if ( + !(input instanceof Uint8Array) + || input.byteLength < 2 + || input.byteLength > RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1 + ) { + fail('catalog-native-wire', 'catalog native request is empty or oversized'); + } + let parsed: unknown; + try { + parsed = JSON.parse(UTF8_FATAL.decode(input)); + } catch (cause) { + fail('catalog-native-wire', 'catalog native request is not strict UTF-8 JSON', cause); + } + if (!isPlainRecord(parsed)) fail('catalog-native-wire', 'catalog native request must be an object'); + const actual = Object.keys(parsed).sort(); + if ( + actual.length !== expectedKeys.length + || actual.some((key, index) => key !== expectedKeys[index]) + ) { + fail('catalog-native-wire', 'catalog native request has missing or unknown fields'); + } + const canonical = encodeRequest(parsed); + if (!bytesEqual(canonical, input)) { + fail('catalog-native-wire', 'catalog native request bytes are not canonical JCS'); + } + return parsed; +} + +function parseCatalogObjectResponse(input: Uint8Array): SignedControlEnvelopeV1 | null { + const payload = responsePayload(input, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_RESPONSE_MAX_BYTES_V1); + if (payload === null) return null; + try { + const envelope = parseCanonicalSignedControlEnvelope(payload, { + maxBytes: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_RESPONSE_MAX_BYTES_V1 - 1, + }); + assertSignedControlEnvelope(envelope); + return envelope; + } catch (cause) { + fail('catalog-native-wire', 'catalog object response is not canonical', cause); + } +} + +function parseBundleResponse( + input: Uint8Array, + request: Rfc64PublicCatalogBundleFetchRequestV1, +): Uint8Array | null { + const payload = responsePayload(input, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1); + if (payload === null) return null; + const snapshot = new Uint8Array(payload); + assertExactBundle(snapshot, request); + return snapshot; +} + +function responsePayload(input: Uint8Array, maxBytes: number): Uint8Array | null { + if (!(input instanceof Uint8Array) || input.byteLength < 1 || input.byteLength > maxBytes) { + fail('catalog-native-wire', 'catalog native response is empty or oversized'); + } + if (input[0] === FETCH_NOT_FOUND) { + if (input.byteLength !== 1) fail('catalog-native-wire', 'not-found response has trailing bytes'); + return null; + } + if (input[0] === FETCH_DENIED) { + if (input.byteLength !== 1) fail('catalog-native-wire', 'denied response has trailing bytes'); + fail('catalog-native-policy-denied', 'remote peer denied the catalog native fetch'); + } + if (input[0] !== FETCH_FOUND || input.byteLength === 1) { + fail('catalog-native-wire', 'catalog native response has an invalid status'); + } + return input.subarray(1); +} + +function assertCatalogObjectMatchesRequest( + envelope: SignedControlEnvelopeV1, + request: Rfc64PublicCatalogObjectFetchRequestV1, +): void { + try { + assertSignedControlEnvelope(envelope); + } catch (cause) { + fail('catalog-native-object-mismatch', 'fetched value is not a signed control object', cause); + } + if ( + envelope.objectType !== request.targetObjectType + || envelope.objectDigest !== request.targetObjectDigest + || envelope.issuer !== request.authorAddress + ) { + fail('catalog-native-object-mismatch', 'catalog object differs from requested type, digest, or author'); + } +} + +function assertExactBundle( + bundle: Uint8Array, + request: Rfc64PublicCatalogBundleFetchRequestV1, +): void { + if (!(bundle instanceof Uint8Array) || bundle.byteLength.toString() !== request.byteLength) { + fail('catalog-native-object-mismatch', 'KA bundle length differs from its catalog row'); + } + try { + const decoded = decodeOpaqueKaBundleV1(bundle); + if (decoded.blobDigest !== request.blobDigest) { + fail('catalog-native-object-mismatch', 'KA bundle digest differs from its catalog row'); + } + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogNativeTransportErrorV1) throw cause; + fail('catalog-native-object-mismatch', 'KA bundle is not one exact opaque bundle', cause); + } +} + +function foundResponse(payload: Uint8Array): Uint8Array { + const result = new Uint8Array(payload.byteLength + 1); + result[0] = FETCH_FOUND; + result.set(payload, 1); + return result; +} + +function assertCanonicalEvmAddress(value: unknown, label: string): asserts value is EvmAddressV1 { + if ( + typeof value !== 'string' + || !/^0x[0-9a-f]{40}$/.test(value) + || value === '0x0000000000000000000000000000000000000000' + ) { + fail('catalog-native-wire', `${label} must be a lowercase nonzero EVM address`); + } +} + +function snapshotPeerId(value: unknown): string { + if (typeof value !== 'string') fail('catalog-native-input', 'remotePeerId must be a string'); + const byteLength = UTF8.encode(value).byteLength; + if (byteLength < 1 || byteLength > MAX_PEER_ID_BYTES || value.trim() !== value) { + fail('catalog-native-input', 'remotePeerId is empty, oversized, or noncanonical'); + } + return value; +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function deepFreeze(value: T): T { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value; + for (const nested of Object.values(value as Record)) deepFreeze(nested); + return Object.freeze(value); +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function fail( + code: Rfc64PublicCatalogNativeTransportErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64PublicCatalogNativeTransportErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts new file mode 100644 index 0000000000..bfac95e561 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts @@ -0,0 +1,219 @@ +import { multiaddr } from '@multiformats/multiaddr'; +import { + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + DKGNode, + ProtocolRouter, + encodeOpaqueKaBundleV1, + type AuthorCatalogScopeV1, + type Digest32V1, + type EvmAddressV1, + type SignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, + Rfc64PublicCatalogNativeTransportV1, + type Rfc64PublicCatalogNativeFetchScopeV1, +} from '../src/rfc64/public-catalog-native-transport-v1.js'; + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'65'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const POLICY_DIGEST = `0x${'73'.repeat(32)}` as Digest32V1; +const DELEGATION_DIGEST = `0x${'74'.repeat(32)}` as Digest32V1; +const UTF8 = new TextEncoder(); + +const nodes: DKGNode[] = []; +const transports: Rfc64PublicCatalogNativeTransportV1[] = []; + +afterEach(async () => { + for (const transport of transports.splice(0)) transport.stop(); + for (const node of nodes.splice(0)) { + try { await node.stop(); } catch {} + } +}); + +async function startNode(): Promise { + const node = new DKGNode({ + listenAddresses: ['/ip4/127.0.0.1/tcp/0'], + enableMdns: false, + }); + nodes.push(node); + await node.start(); + return node; +} + +async function connect(from: DKGNode, to: DKGNode): Promise { + const address = to.multiaddrs.find((candidate) => candidate.includes('/tcp/')); + if (address === undefined) throw new Error('test node has no TCP multiaddr'); + await from.libp2p.dial(multiaddr(address)); +} + +describe('RFC-64 public catalog native content transport v1', () => { + it('fetches exact directory and bundle digests across two live libp2p nodes', async () => { + const [authorNode, receiverNode] = await Promise.all([startNode(), startNode()]); + await connect(receiverNode, authorNode); + + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope: { + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/native-transport', + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1773900000000', + signer: { + issuer: AUTHOR, + signDigest: async (digest) => AUTHOR_WALLET.signMessage(digest), + }, + }); + const catalogObjects = new Map( + produced.stagedObjects.map((envelope) => [envelope.objectDigest, envelope]), + ); + const bundle = encodeOpaqueKaBundleV1( + UTF8.encode(' "A" .\n'), + new Uint8Array(), + ); + const bundles = new Map([[bundle.blobDigest, bundle.bundleBytes]]); + const authorCatalogReads = vi.fn(async (digest: Digest32V1) => catalogObjects.get(digest) ?? null); + const authorBundleReads = vi.fn(async (digest: Digest32V1) => bundles.get(digest) ?? null); + const authorAuthorizations: string[] = []; + const receiverAuthorizations: string[] = []; + const openPolicy = () => Object.freeze({ accessPolicy: 0 as const, policyDigest: POLICY_DIGEST }); + + const authorTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(authorNode), + { + readCatalogObjectByDigest: authorCatalogReads, + readKaBundleByDigest: authorBundleReads, + authorizeOpenCatalogOperation: async (input) => { + authorAuthorizations.push(input.operation); + return openPolicy(); + }, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + const receiverTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(receiverNode), + { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeOpenCatalogOperation: async (input) => { + receiverAuthorizations.push(input.operation); + return openPolicy(); + }, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + transports.push(authorTransport, receiverTransport); + authorTransport.start(); + receiverTransport.start(); + + expect(RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1) + .toBe('/dkg/catalog/1/control-object/by-digest'); + expect(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1) + .toBe('/dkg/catalog/1/ka-bundle/by-digest'); + + const scope = Object.freeze({ + networkId: produced.head.payload.networkId, + contextGraphId: produced.head.payload.contextGraphId, + subGraphName: produced.head.payload.subGraphName, + authorAddress: produced.head.payload.authorAddress, + catalogEra: produced.head.payload.era, + catalogVersion: produced.head.payload.version, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: produced.head.objectDigest, + }) satisfies Rfc64PublicCatalogNativeFetchScopeV1; + const fetchedRoot = await receiverTransport.fetchCatalogObject(authorNode.peerId, { + ...scope, + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + targetObjectDigest: produced.head.payload.directoryRootDigest, + }); + expect(fetchedRoot?.envelope).toEqual(produced.directoryPath[0]); + + const fetchedBundle = await receiverTransport.fetchKaBundle(authorNode.peerId, { + ...scope, + kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + blobDigest: bundle.blobDigest, + byteLength: bundle.bundleBytes.byteLength.toString() as never, + }); + expect(fetchedBundle).toEqual(bundle.bundleBytes); + expect(fetchedBundle).not.toBe(bundle.bundleBytes); + + expect(authorCatalogReads).toHaveBeenCalledWith(produced.head.payload.directoryRootDigest); + expect(authorBundleReads).toHaveBeenCalledWith(bundle.blobDigest); + expect(authorAuthorizations).toEqual([ + 'catalog-object-fetch-inbound', + 'catalog-object-fetch-inbound', + 'ka-bundle-fetch-inbound', + 'ka-bundle-fetch-inbound', + ]); + expect(receiverAuthorizations).toEqual([ + 'catalog-object-fetch-outbound', + 'catalog-object-fetch-outbound', + 'ka-bundle-fetch-outbound', + 'ka-bundle-fetch-outbound', + ]); + }, 30_000); + + it('denies a private-policy request before provider lookup', async () => { + const [authorNode, receiverNode] = await Promise.all([startNode(), startNode()]); + await connect(receiverNode, authorNode); + const providerRead = vi.fn(async () => null); + const authorTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(authorNode), + { + readCatalogObjectByDigest: providerRead, + readKaBundleByDigest: async () => null, + authorizeOpenCatalogOperation: async () => ({ + accessPolicy: 1, + policyDigest: POLICY_DIGEST, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + const receiverTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(receiverNode), + { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeOpenCatalogOperation: async () => ({ + accessPolicy: 0, + policyDigest: POLICY_DIGEST, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + transports.push(authorTransport, receiverTransport); + authorTransport.start(); + receiverTransport.start(); + + await expect(receiverTransport.fetchCatalogObject(authorNode.peerId, { + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + networkId: 'otp:20430' as never, + contextGraphId: '0x1111111111111111111111111111111111111111/denied' as never, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0' as never, + catalogVersion: '1' as never, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}` as Digest32V1, + targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + targetObjectDigest: `0x${'82'.repeat(32)}` as Digest32V1, + }, { timeoutMs: 4_000 })).rejects.toThrow(/policy/); + expect(providerRead).not.toHaveBeenCalled(); + }, 15_000); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 4006d161c7..ef1cb612de 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -122,6 +122,7 @@ export default defineConfig({ "test/rfc64-public-catalog-transport-v1.test.ts", "test/rfc64-public-catalog-receiver-v1.test.ts", "test/rfc64-public-catalog-gate1.integration.test.ts", + "test/rfc64-public-catalog-native-transport-v1.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 4ace7541be56138e1f40b11f528fa77b3e502bff Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:04:46 +0200 Subject: [PATCH 108/292] feat(agent): activate one RFC-64 catalog row in SWM --- packages/agent/src/index.ts | 1 + .../public-catalog-native-receiver-v1.ts | 394 ++++++++++++++++++ ...c-catalog-native-gate1.integration.test.ts | 346 +++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 4 files changed, 742 insertions(+) create mode 100644 packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index c53a901c41..15595e08a3 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -41,6 +41,7 @@ export * from './rfc64/open-catalog-policy-v1.js'; export * from './rfc64/public-catalog-receiver-v1.js'; export * from './rfc64/public-catalog-service-v1.js'; export * from './rfc64/public-catalog-native-transport-v1.js'; +export * from './rfc64/public-catalog-native-receiver-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts new file mode 100644 index 0000000000..4f1a2652bc --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Bounded Gate-1 receiver for one public/open root-lane catalog row. + * + * The supported first vertical slice is intentionally narrow: one successor + * head, one bucket, one row, root context-graph lane, and one complete bundle. + * Every network hop is RFC-64 catalog-native. Activation happens only after + * signed head/path/bucket verification, transfer verification, canonical + * projection verification, one atomic SWM graph replace, and exact post-read. + */ + +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + ZERO_DIGEST32_V1, + assertAuthorCatalogBucketScopeBindingV1, + assertAuthorCatalogDirectoryNodeScopeBindingV1, + assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + canonicalizeAuthorCatalogBucketPayloadBytesV1, + computeControlSignatureVariantDigestHex, + contextGraphWorkspaceGraphUri, + deriveAuthorCatalogScopeFromHeadV1, + readVerifiedAuthorCatalogBucketDescriptorV1, + readVerifiedCgSharedProjectionBytesV1, + readVerifiedCgSharedProjectionMetadataV1, + verifyAuthorCatalogDirectoryPathV1, + verifyCgSharedProjectionV1, + verifyTransferredCatalogBundleV1, + type AuthorCatalogRowV1, + type CatalogSealDeploymentProfileV1, + type Digest32V1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + quadsToNQuads, + readExactGraphPaged, + tryReplaceGraphAtomically, + type TripleStore, +} from '@origintrail-official/dkg-storage'; + +import { parseNQuads } from '../dkg-agent-utils.js'; +import { unpackKnowledgeAssetId } from '../ka-identity.js'; +import type { Rfc64ControlObjectOperationsV1 } from './control-object-store-v1.js'; +import { + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + type Rfc64PublicCatalogNativeFetchScopeV1, + type Rfc64PublicCatalogNativeTransportV1, +} from './public-catalog-native-transport-v1.js'; +import type { + Rfc64PublicCatalogHeadAnnouncementV1, + Rfc64PublicCatalogTransportV1, +} from './public-catalog-transport-v1.js'; + +const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { + readonly headTransport: Rfc64PublicCatalogTransportV1; + readonly contentTransport: Rfc64PublicCatalogNativeTransportV1; + readonly controlObjects: Pick; + readonly store: TripleStore; + readonly transportTimeoutMs?: number; +} + +export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { + /** Exact signed successor head digest: the complete one-row inventory commitment. */ + readonly inventoryDigest: Digest32V1; + readonly catalogRowDigest: Digest32V1; + readonly contentDigest: Digest32V1; + readonly bundleDigest: Digest32V1; + readonly kaUal: string; + readonly inventoryRowCount: 1; + readonly activatedTripleCount: number; + readonly swmGraph: string; +} + +export type Rfc64PublicCatalogNativeReceiverErrorCodeV1 = + | 'catalog-native-receiver-input' + | 'catalog-native-receiver-not-found' + | 'catalog-native-receiver-slice' + | 'catalog-native-receiver-catalog' + | 'catalog-native-receiver-transfer' + | 'catalog-native-receiver-activation'; + +export class Rfc64PublicCatalogNativeReceiverErrorV1 extends Error { + constructor( + readonly code: Rfc64PublicCatalogNativeReceiverErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'Rfc64PublicCatalogNativeReceiverErrorV1'; + } +} + +export class Rfc64PublicCatalogNativeReceiverV1 { + readonly #timeoutMs: number; + + constructor(private readonly options: Rfc64PublicCatalogNativeReceiverOptionsV1) { + if ( + typeof options?.headTransport?.fetchCatalogHead !== 'function' + || typeof options?.contentTransport?.fetchCatalogObject !== 'function' + || typeof options.controlObjects?.stageVerifiedObjects !== 'function' + || typeof options.store?.query !== 'function' + ) { + fail('catalog-native-receiver-input', 'receiver dependencies are incomplete'); + } + const timeoutMs = options.transportTimeoutMs ?? 10_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { + fail('catalog-native-receiver-input', 'transportTimeoutMs must be a positive safe integer'); + } + this.#timeoutMs = timeoutMs; + } + + async synchronizeOnePublicOpenRow( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + deployment: CatalogSealDeploymentProfileV1, + ): Promise { + const fetchedHead = await this.options.headTransport.fetchCatalogHead( + remotePeerId, + announcement, + { timeoutMs: this.#timeoutMs }, + ); + if (fetchedHead === null) { + fail('catalog-native-receiver-not-found', 'announced successor head was not found'); + } + const head = fetchedHead.envelope; + assertFirstSliceHead(head); + const scope = nativeScope(announcement, head); + + const fetchedDirectory = await this.options.contentTransport.fetchCatalogObject( + remotePeerId, + { + ...scope, + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + targetObjectDigest: head.payload.directoryRootDigest, + }, + { timeoutMs: this.#timeoutMs }, + ); + if (fetchedDirectory === null) { + fail('catalog-native-receiver-not-found', 'successor directory root was not found'); + } + let directory: SignedAuthorCatalogDirectoryNodeEnvelopeV1; + try { + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1( + fetchedDirectory.envelope, + head.payload.bucketCount, + ); + directory = fetchedDirectory.envelope; + assertAuthorCatalogDirectoryNodeScopeBindingV1( + directory.payload, + deriveAuthorCatalogScopeFromHeadV1(head.payload), + ); + if (directory.issuer !== head.issuer) throw new Error('directory issuer differs from head'); + } catch (cause) { + fail('catalog-native-receiver-catalog', 'directory root is not bound to the successor head', cause); + } + + let descriptor: ReturnType; + try { + const path = verifyAuthorCatalogDirectoryPathV1(head, [directory], '0' as never); + descriptor = readVerifiedAuthorCatalogBucketDescriptorV1(path, head); + } catch (cause) { + fail('catalog-native-receiver-catalog', 'successor directory path is invalid', cause); + } + if (descriptor.rowCount !== '1' || descriptor.bucketDigest === ZERO_DIGEST32_V1) { + fail('catalog-native-receiver-slice', 'first receiver slice requires one non-empty bucket row'); + } + + const fetchedBucket = await this.options.contentTransport.fetchCatalogObject( + remotePeerId, + { + ...scope, + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + targetObjectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + targetObjectDigest: descriptor.bucketDigest, + }, + { timeoutMs: this.#timeoutMs }, + ); + if (fetchedBucket === null) { + fail('catalog-native-receiver-not-found', 'successor catalog bucket was not found'); + } + let bucket: SignedAuthorCatalogBucketEnvelopeV1; + try { + assertSignedAuthorCatalogBucketEnvelopeV1(fetchedBucket.envelope); + bucket = fetchedBucket.envelope; + assertAuthorCatalogBucketScopeBindingV1( + bucket.payload, + deriveAuthorCatalogScopeFromHeadV1(head.payload), + ); + if ( + bucket.issuer !== head.issuer + || bucket.objectDigest !== descriptor.bucketDigest + || bucket.payload.bucketId !== descriptor.bucketId + || bucket.payload.rows.length !== 1 + || bucket.payload.rows.length.toString() !== descriptor.rowCount + || canonicalizeAuthorCatalogBucketPayloadBytesV1(bucket.payload).byteLength.toString() + !== descriptor.byteLength + ) { + throw new Error('bucket differs from its verified directory descriptor'); + } + } catch (cause) { + fail('catalog-native-receiver-catalog', 'catalog bucket is not bound to its directory', cause); + } + const row = bucket.payload.rows[0]; + if (row === undefined) { + fail('catalog-native-receiver-catalog', 'verified one-row bucket did not contain its row'); + } + + const bundle = await this.options.contentTransport.fetchKaBundle( + remotePeerId, + { + ...scope, + kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + blobDigest: row.transfer.blobDigest, + byteLength: row.transfer.byteLength as never, + }, + { timeoutMs: this.#timeoutMs }, + ); + if (bundle === null) { + fail('catalog-native-receiver-not-found', 'catalog row KA bundle was not found'); + } + + let projectionMetadata: ReturnType; + let projectionBytes: Uint8Array; + try { + const transferred = verifyTransferredCatalogBundleV1(head, row, bundle, deployment); + const projection = verifyCgSharedProjectionV1(transferred, head, row, deployment); + projectionMetadata = readVerifiedCgSharedProjectionMetadataV1( + projection, + transferred, + head, + row, + deployment, + ); + projectionBytes = readVerifiedCgSharedProjectionBytesV1( + projection, + transferred, + head, + row, + deployment, + ); + } catch (cause) { + fail('catalog-native-receiver-transfer', 'KA bundle or shared projection verification failed', cause); + } + + try { + await this.options.controlObjects.stageVerifiedObjects([ + fetchedHead, + fetchedDirectory, + fetchedBucket, + ]); + } catch (cause) { + fail('catalog-native-receiver-catalog', 'verified catalog objects could not be staged', cause); + } + + const swmGraph = await activateExactPublicProjection( + this.options.store, + head, + row, + projectionMetadata.kaUal, + projectionBytes, + Number(projectionMetadata.publicTripleCount), + ); + + return Object.freeze({ + inventoryDigest: head.objectDigest as Digest32V1, + catalogRowDigest: projectionMetadata.catalogRowDigest, + contentDigest: projectionMetadata.projectionDigest, + bundleDigest: row.transfer.blobDigest, + kaUal: projectionMetadata.kaUal, + inventoryRowCount: 1 as const, + activatedTripleCount: Number(projectionMetadata.publicTripleCount), + swmGraph, + }); + } +} + +function assertFirstSliceHead(head: SignedAuthorCatalogHeadEnvelopeV1): void { + if ( + head.payload.subGraphName !== null + || head.payload.bucketCount !== '1' + || head.payload.directoryHeight !== '0' + || head.payload.totalRows !== '1' + || head.payload.version === '0' + || head.payload.previousHeadDigest === null + ) { + fail( + 'catalog-native-receiver-slice', + 'first receiver slice requires one root-lane row in a non-genesis successor', + ); + } +} + +function nativeScope( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + head: SignedAuthorCatalogHeadEnvelopeV1, +): Rfc64PublicCatalogNativeFetchScopeV1 { + return Object.freeze({ + networkId: head.payload.networkId, + contextGraphId: head.payload.contextGraphId, + subGraphName: head.payload.subGraphName, + authorAddress: head.payload.authorAddress, + catalogEra: head.payload.era, + catalogVersion: head.payload.version, + policyDigest: announcement.policyDigest, + catalogHeadObjectDigest: head.objectDigest as Digest32V1, + }); +} + +async function activateExactPublicProjection( + store: TripleStore, + head: SignedAuthorCatalogHeadEnvelopeV1, + row: AuthorCatalogRowV1, + kaUal: string, + projectionBytes: Uint8Array, + expectedTripleCount: number, +): Promise { + let projectionText: string; + let quads; + try { + projectionText = UTF8.decode(projectionBytes); + quads = parseNQuads(projectionText); + } catch (cause) { + fail('catalog-native-receiver-activation', 'verified projection could not be materialized', cause); + } + if ( + !Number.isSafeInteger(expectedTripleCount) + || expectedTripleCount < 1 + || quads.length !== expectedTripleCount + || quads.some((quad) => quad.graph !== '') + ) { + fail('catalog-native-receiver-activation', 'projection/parser triple count or graph scope changed'); + } + const identity = unpackKnowledgeAssetId(BigInt(row.kaId)); + const swmGraph = `${contextGraphWorkspaceGraphUri(head.payload.contextGraphId)}` + + `/${identity.agentAddress}/${identity.kaNumber.toString()}`; + const graphQuads = quads.map((quad) => ({ ...quad, graph: swmGraph })); + let replaced: boolean; + try { + replaced = await tryReplaceGraphAtomically(store, swmGraph, graphQuads, { + source: 'rfc64-public-catalog-native-activation', + }); + } catch (cause) { + fail('catalog-native-receiver-activation', `atomic SWM replace failed for ${kaUal}`, cause); + } + if (!replaced) { + fail('catalog-native-receiver-activation', 'store lacks atomic named-graph replacement'); + } + + let readBack; + try { + readBack = await readExactGraphPaged(store, swmGraph, { + expectedQuadCount: expectedTripleCount, + maxQuadCount: expectedTripleCount, + maxNQuadsBytes: projectionBytes.byteLength, + outputGraph: '', + queryOptions: { source: 'rfc64-public-catalog-native-post-read' }, + }); + } catch (cause) { + fail('catalog-native-receiver-activation', 'exact SWM post-read failed', cause); + } + if (`${quadsToNQuads(readBack)}\n` !== projectionText) { + fail('catalog-native-receiver-activation', 'exact SWM post-read differs from verified projection'); + } + return swmGraph; +} + +export function rfc64CatalogSignatureVariantDigestV1( + envelope: SignedAuthorCatalogHeadEnvelopeV1, +): Digest32V1 { + return computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ) as Digest32V1; +} + +function fail( + code: Rfc64PublicCatalogNativeReceiverErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64PublicCatalogNativeReceiverErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts new file mode 100644 index 0000000000..887090ab79 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -0,0 +1,346 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { multiaddr } from '@multiformats/multiaddr'; +import { + DKGNode, + ProtocolRouter, + assertAuthorCatalogRowV1, + assertCanonicalGraphScopedAuthorSealV1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + computeAuthorCatalogRowDigestV1, + computeAuthorCatalogScopeDigestV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + computeKaChunkTreeRootV1, + deriveAuthorCatalogScopeFromHeadV1, + encodeOpaqueKaBundleV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, + type ContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type SignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + produceEmptyAuthorCatalogGenesisV1, + produceSparseAuthorCatalogSuccessorV1, +} from '../src/rfc64/author-catalog-producer.js'; +import { + Rfc64PublicCatalogNativeReceiverV1, +} from '../src/rfc64/public-catalog-native-receiver-v1.js'; +import { + Rfc64PublicCatalogNativeTransportV1, +} from '../src/rfc64/public-catalog-native-transport-v1.js'; +import { + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + Rfc64PublicCatalogTransportV1, + type Rfc64PublicCatalogHeadAnnouncementV1, +} from '../src/rfc64/public-catalog-transport-v1.js'; +import { + openRfc64PersistenceV1, + type Rfc64PersistenceV1, +} from '../src/rfc64/persistence-v1.js'; + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'66'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const NETWORK_ID = 'otp:20430' as NetworkIdV1; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/native-gate-1' as ContextGraphIdV1; +const GOVERNANCE = '0x2222222222222222222222222222222222222222' as EvmAddressV1; +const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const POLICY_DIGEST = `0x${'75'.repeat(32)}` as Digest32V1; +const DELEGATION_DIGEST = `0x${'76'.repeat(32)}` as Digest32V1; +const KA_NUMBER = 7n; +const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); +const UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; +const PROJECTION = + ' "42"^^ .\n' + + ' "Alice" .\n'; +const ASSERTION_ROOT = + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f' as Digest32V1; +const UTF8 = new TextEncoder(); +const DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +}) as CatalogSealDeploymentProfileV1; + +const temporaryDirectories: string[] = []; +const nodes: DKGNode[] = []; +const persistences: Rfc64PersistenceV1[] = []; +const headTransports: Rfc64PublicCatalogTransportV1[] = []; +const nativeTransports: Rfc64PublicCatalogNativeTransportV1[] = []; + +afterEach(async () => { + for (const transport of nativeTransports.splice(0)) transport.stop(); + for (const transport of headTransports.splice(0)) transport.stop(); + for (const persistence of persistences.splice(0)) { + try { await persistence.close(); } catch {} + } + for (const node of nodes.splice(0)) { + try { await node.stop(); } catch {} + } + await Promise.all(temporaryDirectories.splice(0).map(async (path) => { + await rm(path, { recursive: true, force: true }); + })); +}); + +async function openPersistence(label: string): Promise { + const directory = await mkdtemp(join(tmpdir(), `dkg-rfc64-native-${label}-`)); + temporaryDirectories.push(directory); + const persistence = await openRfc64PersistenceV1( + directory, + { yieldAfterPurgeBatch: async () => {} }, + ); + persistences.push(persistence); + return persistence; +} + +async function startNode(): Promise { + const node = new DKGNode({ + listenAddresses: ['/ip4/127.0.0.1/tcp/0'], + enableMdns: false, + }); + nodes.push(node); + await node.start(); + return node; +} + +async function connect(from: DKGNode, to: DKGNode): Promise { + const address = to.multiaddrs.find((candidate) => candidate.includes('/tcp/')); + if (address === undefined) throw new Error('test node has no TCP multiaddr'); + await from.libp2p.dial(multiaddr(address)); +} + +describe('RFC-64 Gate 1 native successor to public SWM', () => { + it('fetches head to row to bundle and activates one exact KA on a live receiver', async () => { + const [authorNode, receiverNode, authorPersistence, receiverPersistence] = await Promise.all([ + startNode(), + startNode(), + openPersistence('author'), + openPersistence('receiver'), + ]); + await connect(receiverNode, authorNode); + const receiverStore = new OxigraphStore(); + + const scope = { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1; + const signer = { + issuer: AUTHOR, + signDigest: async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest), + }; + const rowBundle = buildRowBundle(); + const genesis = await produceEmptyAuthorCatalogGenesisV1({ + scope, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1773900000000' as never, + signer, + }); + const successor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as never, + nextRows: [rowBundle.row], + issuedAt: '1773900001000' as never, + signer, + }); + const authorObjects = new Map( + successor.stagedObjects.map((envelope) => [envelope.objectDigest, envelope]), + ); + const authorObjectRead = vi.fn(async (digest: Digest32V1) => + authorObjects.get(digest) ?? null); + const authorBundleRead = vi.fn(async (digest: Digest32V1) => + digest === rowBundle.row.transfer.blobDigest ? rowBundle.bundleBytes : null); + + const verifiedObjects = await Promise.all( + [...genesis.stagedObjects, ...successor.stagedObjects].map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + })), + ); + const staged = await authorPersistence.controlObjects.stageVerifiedObjects(verifiedObjects); + const headKeys = staged.objects.at(-1); + if (headKeys === undefined) throw new Error('successor head was not staged'); + + const receivedAnnouncements: Rfc64PublicCatalogHeadAnnouncementV1[] = []; + const openPolicy = async () => Object.freeze({ + accessPolicy: 0 as const, + policyDigest: POLICY_DIGEST, + }); + const authorHeadTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(authorNode), + { + controlObjects: authorPersistence.controlObjects, + authorizeOpenCatalogOperation: openPolicy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }, + ); + const receiverHeadTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(receiverNode), + { + controlObjects: receiverPersistence.controlObjects, + authorizeOpenCatalogOperation: openPolicy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async (announcement) => { + receivedAnnouncements.push(announcement); + }, + }, + ); + const authorNativeTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(authorNode), + { + readCatalogObjectByDigest: authorObjectRead, + readKaBundleByDigest: authorBundleRead, + authorizeOpenCatalogOperation: openPolicy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + const receiverNativeTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(receiverNode), + { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeOpenCatalogOperation: openPolicy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + headTransports.push(authorHeadTransport, receiverHeadTransport); + nativeTransports.push(authorNativeTransport, receiverNativeTransport); + authorHeadTransport.start(); + receiverHeadTransport.start(); + authorNativeTransport.start(); + receiverNativeTransport.start(); + + const announcement = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: successor.head.payload.networkId, + contextGraphId: successor.head.payload.contextGraphId, + subGraphName: successor.head.payload.subGraphName, + authorAddress: successor.head.payload.authorAddress, + catalogEra: successor.head.payload.era, + catalogVersion: successor.head.payload.version, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: headKeys.objectDigest, + signatureVariantDigest: headKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + await authorHeadTransport.announceCatalogHead(receiverNode.peerId, announcement); + expect(receivedAnnouncements).toEqual([announcement]); + + const receiver = new Rfc64PublicCatalogNativeReceiverV1({ + headTransport: receiverHeadTransport, + contentTransport: receiverNativeTransport, + controlObjects: receiverPersistence.controlObjects, + store: receiverStore, + }); + const evidence = await receiver.synchronizeOnePublicOpenRow( + authorNode.peerId, + receivedAnnouncements[0], + DEPLOYMENT, + ); + + const expectedRowDigest = computeAuthorCatalogRowDigestV1( + computeAuthorCatalogScopeDigestV1(deriveAuthorCatalogScopeFromHeadV1(successor.head.payload)), + rowBundle.row, + ); + expect(evidence).toEqual({ + inventoryDigest: successor.head.objectDigest, + catalogRowDigest: expectedRowDigest, + contentDigest: rowBundle.row.projectionDigest, + bundleDigest: rowBundle.row.transfer.blobDigest, + kaUal: UAL, + inventoryRowCount: 1, + activatedTripleCount: 2, + swmGraph: `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${KA_NUMBER}`, + }); + + const activated = await receiverStore.query( + `SELECT ?s ?p ?o WHERE { GRAPH <${evidence.swmGraph}> { ?s ?p ?o } } ORDER BY ?s ?p ?o`, + ); + expect(activated).toMatchObject({ type: 'bindings' }); + if (activated.type !== 'bindings') throw new Error('receiver SWM query was not bindings'); + expect(activated.bindings).toEqual([ + { + s: 'https://example.org/alice', + p: 'https://schema.org/age', + o: '"42"^^', + }, + { + s: 'https://example.org/alice', + p: 'https://schema.org/name', + o: '"Alice"', + }, + ]); + expect(authorObjectRead.mock.calls.map(([digest]) => digest)).toEqual([ + successor.head.payload.directoryRootDigest, + successor.bucket?.objectDigest, + ]); + expect(authorBundleRead).toHaveBeenCalledOnce(); + expect(authorBundleRead).toHaveBeenCalledWith(rowBundle.row.transfer.blobDigest); + }, 30_000); +}); + +function buildRowBundle(): { row: AuthorCatalogRowV1; bundleBytes: Uint8Array } { + const seal = { + assertionMerkleRoot: ASSERTION_ROOT, + authorAddress: AUTHOR, + authorAttestationR: `0x${'11'.repeat(32)}`, + authorAttestationVS: `0x${'22'.repeat(32)}`, + authorSchemeVersion: '1', + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: UAL, + assertionVersion: '1', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(seal); + const encoded = encodeOpaqueKaBundleV1( + UTF8.encode(PROJECTION), + canonicalizeCanonicalGraphScopedAuthorSealBytesV1(seal), + ); + const byteLength = BigInt(encoded.bundleBytes.byteLength); + const row = { + kaId: KA_ID, + assertionCoordinate: 'gate-1-object', + assertionVersion: '1', + projectionId: 'cg-shared-v1', + projectionDigest: encoded.projectionDigest, + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), + transfer: { + codec: 'dkg-ka-bundle-v1', + projectionId: 'cg-shared-v1', + projectionDigest: encoded.projectionDigest, + byteLength: byteLength.toString(), + chunkSize: '262144', + chunkCount: (((byteLength - 1n) / 262_144n) + 1n).toString(), + blobDigest: encoded.blobDigest, + chunkTreeRoot: computeKaChunkTreeRootV1(encoded.bundleBytes), + }, + } as unknown as AuthorCatalogRowV1; + assertAuthorCatalogRowV1(row); + return { row, bundleBytes: encoded.bundleBytes }; +} diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index ef1cb612de..e4af40fde3 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -123,6 +123,7 @@ export default defineConfig({ "test/rfc64-public-catalog-receiver-v1.test.ts", "test/rfc64-public-catalog-gate1.integration.test.ts", "test/rfc64-public-catalog-native-transport-v1.test.ts", + "test/rfc64-public-catalog-native-gate1.integration.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 70e146ffd25654c5c3b97a2dded2aa1d66243f69 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:10:40 +0200 Subject: [PATCH 109/292] fix(agent): authenticate RFC-64 native activation --- .../public-catalog-native-receiver-v1.ts | 140 ++++++- ...c-catalog-native-gate1.integration.test.ts | 343 ++++++++++-------- 2 files changed, 329 insertions(+), 154 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 4f1a2652bc..2ec3d5c56a 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -11,6 +11,7 @@ */ import { + AUTHOR_SCHEME_VERSION_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, ZERO_DIGEST32_V1, @@ -18,13 +19,16 @@ import { assertAuthorCatalogDirectoryNodeScopeBindingV1, assertSignedAuthorCatalogBucketEnvelopeV1, assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + buildAuthorAttestationTypedData, canonicalizeAuthorCatalogBucketPayloadBytesV1, computeControlSignatureVariantDigestHex, contextGraphWorkspaceGraphUri, deriveAuthorCatalogScopeFromHeadV1, + readVerifiedCatalogSealBindingV1, readVerifiedAuthorCatalogBucketDescriptorV1, readVerifiedCgSharedProjectionBytesV1, readVerifiedCgSharedProjectionMetadataV1, + readVerifiedTransferredCatalogBundleMetadataV1, verifyAuthorCatalogDirectoryPathV1, verifyCgSharedProjectionV1, verifyTransferredCatalogBundleV1, @@ -34,13 +38,15 @@ import { type SignedAuthorCatalogBucketEnvelopeV1, type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, + type VerifiedCatalogSealBindingSnapshotV1, } from '@origintrail-official/dkg-core'; import { quadsToNQuads, readExactGraphPaged, - tryReplaceGraphAtomically, + tryReplaceGraphAndSubjectAtomically, type TripleStore, } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; import { parseNQuads } from '../dkg-agent-utils.js'; import { unpackKnowledgeAssetId } from '../ka-identity.js'; @@ -228,9 +234,20 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } let projectionMetadata: ReturnType; + let sealBinding: VerifiedCatalogSealBindingSnapshotV1; let projectionBytes: Uint8Array; try { const transferred = verifyTransferredCatalogBundleV1(head, row, bundle, deployment); + const transferredMetadata = readVerifiedTransferredCatalogBundleMetadataV1( + transferred, + head, + row, + deployment, + ); + sealBinding = readVerifiedCatalogSealBindingV1( + transferredMetadata.catalogSealBinding, + ); + assertRecoverableAuthorAttestationV1(sealBinding); const projection = verifyCgSharedProjectionV1(transferred, head, row, deployment); projectionMetadata = readVerifiedCgSharedProjectionMetadataV1( projection, @@ -267,6 +284,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { projectionMetadata.kaUal, projectionBytes, Number(projectionMetadata.publicTripleCount), + sealBinding, ); return Object.freeze({ @@ -321,6 +339,7 @@ async function activateExactPublicProjection( kaUal: string, projectionBytes: Uint8Array, expectedTripleCount: number, + sealBinding: VerifiedCatalogSealBindingSnapshotV1, ): Promise { let projectionText: string; let quads; @@ -344,14 +363,29 @@ async function activateExactPublicProjection( const graphQuads = quads.map((quad) => ({ ...quad, graph: swmGraph })); let replaced: boolean; try { - replaced = await tryReplaceGraphAtomically(store, swmGraph, graphQuads, { - source: 'rfc64-public-catalog-native-activation', - }); + replaced = await tryReplaceGraphAndSubjectAtomically( + store, + swmGraph, + graphQuads, + sealBinding.placement.metaGraph, + sealBinding.placement.subject, + [...sealBinding.sealRows], + { + source: 'rfc64-public-catalog-native-activation', + }, + ); } catch (cause) { - fail('catalog-native-receiver-activation', `atomic SWM replace failed for ${kaUal}`, cause); + fail( + 'catalog-native-receiver-activation', + `atomic SWM projection and author-seal replace failed for ${kaUal}`, + cause, + ); } if (!replaced) { - fail('catalog-native-receiver-activation', 'store lacks atomic named-graph replacement'); + fail( + 'catalog-native-receiver-activation', + 'store lacks atomic named-graph and author-seal replacement', + ); } let readBack; @@ -369,9 +403,103 @@ async function activateExactPublicProjection( if (`${quadsToNQuads(readBack)}\n` !== projectionText) { fail('catalog-native-receiver-activation', 'exact SWM post-read differs from verified projection'); } + await assertExactAuthorSealPostRead(store, sealBinding); return swmGraph; } +/** + * Require the transferred v1 AuthorAttestation to recover the catalog author. + * This first receiver slice intentionally supports the recoverable EOA scheme; + * EIP-1271 contract-author admission needs a separately pinned chain verifier. + */ +export function assertRecoverableAuthorAttestationV1( + binding: VerifiedCatalogSealBindingSnapshotV1, +): void { + const { seal } = binding; + if (seal.authorSchemeVersion !== String(AUTHOR_SCHEME_VERSION_V1)) { + fail('catalog-native-receiver-transfer', 'unsupported author attestation scheme'); + } + try { + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(seal.assertedAtChainId), + kav10Address: seal.assertedAtKav10Address, + merkleRoot: ethers.getBytes(seal.assertionMerkleRoot), + authorAddress: seal.authorAddress, + reservedKaId: BigInt(seal.reservedKaId), + schemeVersion: AUTHOR_SCHEME_VERSION_V1, + }); + const digest = ethers.TypedDataEncoder.hash( + typedData.domain, + typedData.types, + typedData.message, + ); + const signature = ethers.Signature.from({ + r: seal.authorAttestationR, + yParityAndS: seal.authorAttestationVS, + }); + const recovered = ethers.recoverAddress(digest, signature); + if (recovered.toLowerCase() !== seal.authorAddress) { + throw new Error( + `signature recovers ${recovered.toLowerCase()} instead of ${seal.authorAddress}`, + ); + } + } catch (cause) { + fail( + 'catalog-native-receiver-transfer', + 'author attestation does not recover the catalog author', + cause, + ); + } +} + +async function assertExactAuthorSealPostRead( + store: TripleStore, + binding: VerifiedCatalogSealBindingSnapshotV1, +): Promise { + const expected = [...binding.sealRows].sort(compareQuads); + let result; + try { + result = await store.query( + `SELECT ?p ?o WHERE { GRAPH <${binding.placement.metaGraph}> { ` + + `<${binding.placement.subject}> ?p ?o } } ORDER BY ?p ?o ` + + `LIMIT ${expected.length + 1}`, + { + source: 'rfc64-public-catalog-native-seal-post-read', + maxResponseBytes: 64 * 1024, + }, + ); + } catch (cause) { + fail('catalog-native-receiver-activation', 'exact author-seal post-read failed', cause); + } + if (result.type !== 'bindings' || result.bindings.length !== expected.length) { + fail('catalog-native-receiver-activation', 'author-seal post-read cardinality changed'); + } + const actual = result.bindings.map((row) => { + if (typeof row.p !== 'string' || typeof row.o !== 'string') { + fail('catalog-native-receiver-activation', 'author-seal post-read row is incomplete'); + } + return { + subject: binding.placement.subject, + predicate: row.p, + object: row.o, + graph: binding.placement.metaGraph, + }; + }).sort(compareQuads); + if (quadsToNQuads(actual) !== quadsToNQuads(expected)) { + fail('catalog-native-receiver-activation', 'author-seal post-read differs from verified seal'); + } +} + +function compareQuads( + left: { subject: string; predicate: string; object: string; graph: string }, + right: { subject: string; predicate: string; object: string; graph: string }, +): number { + return left.predicate.localeCompare(right.predicate) + || left.object.localeCompare(right.object) + || left.subject.localeCompare(right.subject) + || left.graph.localeCompare(right.graph); +} + export function rfc64CatalogSignatureVariantDigestV1( envelope: SignedAuthorCatalogHeadEnvelopeV1, ): Digest32V1 { diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 887090ab79..3ed90f18e3 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -8,6 +8,7 @@ import { ProtocolRouter, assertAuthorCatalogRowV1, assertCanonicalGraphScopedAuthorSealV1, + buildAuthorAttestationTypedData, canonicalizeCanonicalGraphScopedAuthorSealBytesV1, computeAuthorCatalogRowDigestV1, computeAuthorCatalogScopeDigestV1, @@ -123,157 +124,27 @@ async function connect(from: DKGNode, to: DKGNode): Promise { describe('RFC-64 Gate 1 native successor to public SWM', () => { it('fetches head to row to bundle and activates one exact KA on a live receiver', async () => { - const [authorNode, receiverNode, authorPersistence, receiverPersistence] = await Promise.all([ - startNode(), - startNode(), - openPersistence('author'), - openPersistence('receiver'), - ]); - await connect(receiverNode, authorNode); - const receiverStore = new OxigraphStore(); - - const scope = { - networkId: NETWORK_ID, - contextGraphId: CONTEXT_GRAPH_ID, - governanceChainId: '20430', - governanceContractAddress: GOVERNANCE, - ownershipTransitionDigest: null, - subGraphName: null, - authorAddress: AUTHOR, - era: '0', - bucketCount: '1', - } as AuthorCatalogScopeV1; - const signer = { - issuer: AUTHOR, - signDigest: async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest), - }; - const rowBundle = buildRowBundle(); - const genesis = await produceEmptyAuthorCatalogGenesisV1({ - scope, - catalogIssuerDelegationDigest: DELEGATION_DIGEST, - issuedAt: '1773900000000' as never, - signer, - }); - const successor = await produceSparseAuthorCatalogSuccessorV1({ - previousHead: genesis.head, - previousDirectoryPath: genesis.directoryPath, - previousBucket: null, - selectedBucketId: '0' as never, - nextRows: [rowBundle.row], - issuedAt: '1773900001000' as never, - signer, - }); - const authorObjects = new Map( - successor.stagedObjects.map((envelope) => [envelope.objectDigest, envelope]), - ); - const authorObjectRead = vi.fn(async (digest: Digest32V1) => - authorObjects.get(digest) ?? null); - const authorBundleRead = vi.fn(async (digest: Digest32V1) => - digest === rowBundle.row.transfer.blobDigest ? rowBundle.bundleBytes : null); - - const verifiedObjects = await Promise.all( - [...genesis.stagedObjects, ...successor.stagedObjects].map(async (envelope) => ({ - envelope, - issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), - })), - ); - const staged = await authorPersistence.controlObjects.stageVerifiedObjects(verifiedObjects); - const headKeys = staged.objects.at(-1); - if (headKeys === undefined) throw new Error('successor head was not staged'); - - const receivedAnnouncements: Rfc64PublicCatalogHeadAnnouncementV1[] = []; - const openPolicy = async () => Object.freeze({ - accessPolicy: 0 as const, - policyDigest: POLICY_DIGEST, - }); - const authorHeadTransport = new Rfc64PublicCatalogTransportV1( - new ProtocolRouter(authorNode), - { - controlObjects: authorPersistence.controlObjects, - authorizeOpenCatalogOperation: openPolicy, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - onCatalogHeadAvailable: async () => {}, - }, - ); - const receiverHeadTransport = new Rfc64PublicCatalogTransportV1( - new ProtocolRouter(receiverNode), - { - controlObjects: receiverPersistence.controlObjects, - authorizeOpenCatalogOperation: openPolicy, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - onCatalogHeadAvailable: async (announcement) => { - receivedAnnouncements.push(announcement); - }, - }, - ); - const authorNativeTransport = new Rfc64PublicCatalogNativeTransportV1( - new ProtocolRouter(authorNode), - { - readCatalogObjectByDigest: authorObjectRead, - readKaBundleByDigest: authorBundleRead, - authorizeOpenCatalogOperation: openPolicy, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - }, - ); - const receiverNativeTransport = new Rfc64PublicCatalogNativeTransportV1( - new ProtocolRouter(receiverNode), - { - readCatalogObjectByDigest: async () => null, - readKaBundleByDigest: async () => null, - authorizeOpenCatalogOperation: openPolicy, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - }, - ); - headTransports.push(authorHeadTransport, receiverHeadTransport); - nativeTransports.push(authorNativeTransport, receiverNativeTransport); - authorHeadTransport.start(); - receiverHeadTransport.start(); - authorNativeTransport.start(); - receiverNativeTransport.start(); - - const announcement = Object.freeze({ - kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, - networkId: successor.head.payload.networkId, - contextGraphId: successor.head.payload.contextGraphId, - subGraphName: successor.head.payload.subGraphName, - authorAddress: successor.head.payload.authorAddress, - catalogEra: successor.head.payload.era, - catalogVersion: successor.head.payload.version, - policyDigest: POLICY_DIGEST, - catalogHeadObjectDigest: headKeys.objectDigest, - signatureVariantDigest: headKeys.signatureVariantDigest, - }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; - await authorHeadTransport.announceCatalogHead(receiverNode.peerId, announcement); - expect(receivedAnnouncements).toEqual([announcement]); - - const receiver = new Rfc64PublicCatalogNativeReceiverV1({ - headTransport: receiverHeadTransport, - contentTransport: receiverNativeTransport, - controlObjects: receiverPersistence.controlObjects, - store: receiverStore, - }); - const evidence = await receiver.synchronizeOnePublicOpenRow( - authorNode.peerId, - receivedAnnouncements[0], - DEPLOYMENT, - ); + const fixture = await setupLiveReceiver(); + const evidence = await fixture.synchronize(); const expectedRowDigest = computeAuthorCatalogRowDigestV1( - computeAuthorCatalogScopeDigestV1(deriveAuthorCatalogScopeFromHeadV1(successor.head.payload)), - rowBundle.row, + computeAuthorCatalogScopeDigestV1( + deriveAuthorCatalogScopeFromHeadV1(fixture.successor.head.payload), + ), + fixture.rowBundle.row, ); expect(evidence).toEqual({ - inventoryDigest: successor.head.objectDigest, + inventoryDigest: fixture.successor.head.objectDigest, catalogRowDigest: expectedRowDigest, - contentDigest: rowBundle.row.projectionDigest, - bundleDigest: rowBundle.row.transfer.blobDigest, + contentDigest: fixture.rowBundle.row.projectionDigest, + bundleDigest: fixture.rowBundle.row.transfer.blobDigest, kaUal: UAL, inventoryRowCount: 1, activatedTripleCount: 2, swmGraph: `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${KA_NUMBER}`, }); - const activated = await receiverStore.query( + const activated = await fixture.receiverStore.query( `SELECT ?s ?p ?o WHERE { GRAPH <${evidence.swmGraph}> { ?s ?p ?o } } ORDER BY ?s ?p ?o`, ); expect(activated).toMatchObject({ type: 'bindings' }); @@ -290,21 +161,197 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { o: '"Alice"', }, ]); - expect(authorObjectRead.mock.calls.map(([digest]) => digest)).toEqual([ - successor.head.payload.directoryRootDigest, - successor.bucket?.objectDigest, + const seals = await fixture.receiverStore.query( + 'SELECT ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } ' + + 'FILTER(STRENDS(STR(?g), "/_meta")) } ORDER BY ?s ?p ?o', + ); + expect(seals).toMatchObject({ type: 'bindings' }); + if (seals.type !== 'bindings') throw new Error('receiver seal query was not bindings'); + expect(seals.bindings).toHaveLength(14); + expect(seals.bindings).toContainEqual(expect.objectContaining({ + p: 'http://dkg.io/ontology/authorAttestationR', + })); + + expect(fixture.authorObjectRead.mock.calls.map(([digest]) => digest)).toEqual([ + fixture.successor.head.payload.directoryRootDigest, + fixture.successor.bucket?.objectDigest, ]); - expect(authorBundleRead).toHaveBeenCalledOnce(); - expect(authorBundleRead).toHaveBeenCalledWith(rowBundle.row.transfer.blobDigest); + expect(fixture.authorBundleRead).toHaveBeenCalledOnce(); + expect(fixture.authorBundleRead).toHaveBeenCalledWith( + fixture.rowBundle.row.transfer.blobDigest, + ); + }, 30_000); + + it('rejects a live bundle whose AuthorAttestation does not recover its catalog author', async () => { + const attacker = new ethers.Wallet(`0x${'77'.repeat(32)}`); + const fixture = await setupLiveReceiver(attacker); + + await expect(fixture.synchronize()).rejects.toMatchObject({ + code: 'catalog-native-receiver-transfer', + }); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); }, 30_000); }); -function buildRowBundle(): { row: AuthorCatalogRowV1; bundleBytes: Uint8Array } { +async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { + const [authorNode, receiverNode, authorPersistence, receiverPersistence] = await Promise.all([ + startNode(), + startNode(), + openPersistence('author'), + openPersistence('receiver'), + ]); + await connect(receiverNode, authorNode); + const receiverStore = new OxigraphStore(); + const scope = { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1; + const signer = { + issuer: AUTHOR, + signDigest: async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest), + }; + const rowBundle = await buildRowBundle(signingWallet); + const genesis = await produceEmptyAuthorCatalogGenesisV1({ + scope, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1773900000000' as never, + signer, + }); + const successor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as never, + nextRows: [rowBundle.row], + issuedAt: '1773900001000' as never, + signer, + }); + const authorObjects = new Map( + successor.stagedObjects.map((envelope) => [envelope.objectDigest, envelope]), + ); + const authorObjectRead = vi.fn(async (digest: Digest32V1) => + authorObjects.get(digest) ?? null); + const authorBundleRead = vi.fn(async (digest: Digest32V1) => + digest === rowBundle.row.transfer.blobDigest ? rowBundle.bundleBytes : null); + const verifiedObjects = await Promise.all( + [...genesis.stagedObjects, ...successor.stagedObjects].map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + })), + ); + const staged = await authorPersistence.controlObjects.stageVerifiedObjects(verifiedObjects); + const headKeys = staged.objects.at(-1); + if (headKeys === undefined) throw new Error('successor head was not staged'); + const receivedAnnouncements: Rfc64PublicCatalogHeadAnnouncementV1[] = []; + const openPolicy = async () => Object.freeze({ + accessPolicy: 0 as const, + policyDigest: POLICY_DIGEST, + }); + const authorHeadTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(authorNode), + { + controlObjects: authorPersistence.controlObjects, + authorizeOpenCatalogOperation: openPolicy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }, + ); + const receiverHeadTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(receiverNode), + { + controlObjects: receiverPersistence.controlObjects, + authorizeOpenCatalogOperation: openPolicy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async (announcement) => { + receivedAnnouncements.push(announcement); + }, + }, + ); + const authorNativeTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(authorNode), + { + readCatalogObjectByDigest: authorObjectRead, + readKaBundleByDigest: authorBundleRead, + authorizeOpenCatalogOperation: openPolicy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + const receiverNativeTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(receiverNode), + { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeOpenCatalogOperation: openPolicy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + headTransports.push(authorHeadTransport, receiverHeadTransport); + nativeTransports.push(authorNativeTransport, receiverNativeTransport); + authorHeadTransport.start(); + receiverHeadTransport.start(); + authorNativeTransport.start(); + receiverNativeTransport.start(); + const announcement = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: successor.head.payload.networkId, + contextGraphId: successor.head.payload.contextGraphId, + subGraphName: successor.head.payload.subGraphName, + authorAddress: successor.head.payload.authorAddress, + catalogEra: successor.head.payload.era, + catalogVersion: successor.head.payload.version, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: headKeys.objectDigest, + signatureVariantDigest: headKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + await authorHeadTransport.announceCatalogHead(receiverNode.peerId, announcement); + expect(receivedAnnouncements).toEqual([announcement]); + const receiver = new Rfc64PublicCatalogNativeReceiverV1({ + headTransport: receiverHeadTransport, + contentTransport: receiverNativeTransport, + controlObjects: receiverPersistence.controlObjects, + store: receiverStore, + }); + return { + authorBundleRead, + authorObjectRead, + receiverStore, + rowBundle, + successor, + synchronize: () => receiver.synchronizeOnePublicOpenRow( + authorNode.peerId, + receivedAnnouncements[0], + DEPLOYMENT, + ), + }; +} + +async function buildRowBundle( + signingWallet: ethers.Wallet = AUTHOR_WALLET, +): Promise<{ row: AuthorCatalogRowV1; bundleBytes: Uint8Array }> { + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(DEPLOYMENT.assertedAtChainId), + kav10Address: DEPLOYMENT.assertedAtKav10Address, + merkleRoot: ethers.getBytes(ASSERTION_ROOT), + authorAddress: AUTHOR, + reservedKaId: BigInt(KA_ID), + }); + const authorSignature = ethers.Signature.from(await signingWallet.signTypedData( + typedData.domain, + typedData.types, + typedData.message, + )); const seal = { assertionMerkleRoot: ASSERTION_ROOT, authorAddress: AUTHOR, - authorAttestationR: `0x${'11'.repeat(32)}`, - authorAttestationVS: `0x${'22'.repeat(32)}`, + authorAttestationR: authorSignature.r, + authorAttestationVS: authorSignature.yParityAndS, authorSchemeVersion: '1', assertedAtChainId: '20430', assertedAtKav10Address: KAV10, From b5ad0a493b5bff341c5d15eee5c9f0814a5253ee Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 17:27:00 +0200 Subject: [PATCH 110/292] feat(agent): add RFC-64 public catalog head transport --- .../src/rfc64/public-catalog-transport-v1.ts | 755 ++++++++++++++++++ .../rfc64-public-catalog-transport-v1.test.ts | 304 +++++++ 2 files changed, 1059 insertions(+) create mode 100644 packages/agent/src/rfc64/public-catalog-transport-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-transport-v1.test.ts diff --git a/packages/agent/src/rfc64/public-catalog-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-transport-v1.ts new file mode 100644 index 0000000000..c30abcf7a4 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-transport-v1.ts @@ -0,0 +1,755 @@ +import { + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + ProtocolRouter, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertContextGraphIdV1, + assertNetworkIdV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + assertSubGraphNameV1, + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, + computeControlSignatureVariantDigestHex, + parseCanonicalSignedAuthorCatalogHeadEnvelopeV1, + type ContextGraphAccessPolicyV1, + type ContextGraphIdV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type SendOptions, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedControlEnvelopeV1, + type SubGraphNameV1, +} from '@origintrail-official/dkg-core'; +import { + readVerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; + +/** + * Additive RFC-64 protocol IDs. Their `/catalog/1` component is the wire + * compatibility boundary; neither protocol is negotiated under a legacy sync ID. + */ +export const RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1 = + '/dkg/catalog/1/author-head-availability' as const; +export const RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1 = + '/dkg/catalog/1/control-object/author-head' as const; + +export const RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1 = + 'rfc64-author-catalog-head-availability-v1' as const; +export const RFC64_PUBLIC_CATALOG_HEAD_FETCH_KIND_V1 = + 'rfc64-author-catalog-head-fetch-v1' as const; + +/** Flat JCS request caps; the fetched signed head has a separate response cap. */ +export const RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_MAX_BYTES_V1 = 2 * 1024; +export const RFC64_PUBLIC_CATALOG_HEAD_FETCH_REQUEST_MAX_BYTES_V1 = 2 * 1024; +export const RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1 = 32 * 1024; + +const ACK = Uint8Array.of(1); +const ANNOUNCEMENT_DENIED = 0; +const FETCH_NOT_FOUND = 0; +const FETCH_FOUND = 1; +const FETCH_DENIED = 2; +const MAX_PEER_ID_BYTES = 256; +const UTF8 = new TextEncoder(); +// Keep a leading BOM visible so canonical re-encoding rejects it. +const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +const ANNOUNCEMENT_KEYS = Object.freeze([ + 'authorAddress', + 'catalogEra', + 'catalogHeadObjectDigest', + 'catalogVersion', + 'contextGraphId', + 'kind', + 'networkId', + 'policyDigest', + 'signatureVariantDigest', + 'subGraphName', +] as const); + +const FETCH_REQUEST_KEYS = ANNOUNCEMENT_KEYS; + +export interface Rfc64PublicCatalogHeadAnnouncementV1 { + readonly kind: typeof RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1; + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogEra: DecimalU64V1; + readonly catalogVersion: DecimalU64V1; + readonly policyDigest: Digest32V1; + readonly catalogHeadObjectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; +} + +export interface Rfc64PublicCatalogHeadFetchRequestV1 { + readonly kind: typeof RFC64_PUBLIC_CATALOG_HEAD_FETCH_KIND_V1; + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogEra: DecimalU64V1; + readonly catalogVersion: DecimalU64V1; + readonly policyDigest: Digest32V1; + readonly catalogHeadObjectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; +} + +export type Rfc64PublicCatalogOperationV1 = + | 'announce-outbound' + | 'announce-inbound' + | 'fetch-outbound' + | 'fetch-inbound'; + +export interface Rfc64PublicCatalogAuthorizationInputV1 { + readonly operation: Rfc64PublicCatalogOperationV1; + readonly remotePeerId: string; + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly policyDigest: Digest32V1; + readonly objectType: typeof AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1; +} + +/** + * A caller-minted current-policy decision. The transport independently requires + * `accessPolicy === 0` and an exact digest match, so returning a private policy or + * a different policy generation always fails closed. + */ +export interface Rfc64PublicCatalogAuthorizationV1 { + readonly accessPolicy: ContextGraphAccessPolicyV1; + readonly policyDigest: Digest32V1; +} + +export interface Rfc64PublicCatalogControlObjectReaderV1 { + getVerifiedObject(input: { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + readonly verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; + }): Promise<{ + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; + } | null>; +} + +export interface FetchedRfc64PublicCatalogHeadV1 { + readonly envelope: SignedAuthorCatalogHeadEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; +} + +export interface Rfc64PublicCatalogTransportOptionsV1 { + readonly controlObjects: Rfc64PublicCatalogControlObjectReaderV1; + /** Must consult accepted current policy state, not the untrusted wire hint. */ + readonly authorizeOpenCatalogOperation: ( + input: Rfc64PublicCatalogAuthorizationInputV1, + ) => Promise; + /** Generic envelope cryptography only; object-specific head/scope binding is local. */ + readonly verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; + /** Receives an untrusted, policy-admitted hint. It grants no catalog authority. */ + readonly onCatalogHeadAvailable: ( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + remotePeerId: string, + ) => void | Promise; +} + +export const RFC64_PUBLIC_CATALOG_TRANSPORT_ERROR_CODES_V1 = Object.freeze([ + 'catalog-transport-input', + 'catalog-transport-wire', + 'catalog-transport-policy-denied', + 'catalog-transport-object-mismatch', + 'catalog-transport-signature', + 'catalog-transport-state', +] as const); + +export type Rfc64PublicCatalogTransportErrorCodeV1 = + (typeof RFC64_PUBLIC_CATALOG_TRANSPORT_ERROR_CODES_V1)[number]; + +export class Rfc64PublicCatalogTransportErrorV1 extends Error { + constructor( + readonly code: Rfc64PublicCatalogTransportErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'Rfc64PublicCatalogTransportErrorV1'; + } +} + +/** + * Small public/open RFC-64 transport slice. + * + * It deliberately does not select peers, activate catalog state, admit candidate + * rows, or support invite-only policy. Announcements are only untrusted hints; + * every served and received head is fetched by both exact digests, structurally + * bound to the hint, and reverified with the generic signature verifier. + */ +export class Rfc64PublicCatalogTransportV1 { + #started = false; + + constructor( + private readonly router: ProtocolRouter, + private readonly options: Rfc64PublicCatalogTransportOptionsV1, + ) { + if (typeof options?.controlObjects?.getVerifiedObject !== 'function') { + fail('catalog-transport-input', 'controlObjects.getVerifiedObject must be a function'); + } + if (typeof options.authorizeOpenCatalogOperation !== 'function') { + fail('catalog-transport-input', 'authorizeOpenCatalogOperation must be a function'); + } + if (typeof options.verifyIssuerSignature !== 'function') { + fail('catalog-transport-input', 'verifyIssuerSignature must be a function'); + } + if (typeof options.onCatalogHeadAvailable !== 'function') { + fail('catalog-transport-input', 'onCatalogHeadAvailable must be a function'); + } + } + + get started(): boolean { + return this.#started; + } + + start(): void { + if (this.#started) return; + this.#started = true; + try { + this.router.register( + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + async (data, peerId) => this.handleAnnouncement(data, peerId.toString()), + { maxReadBytes: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_MAX_BYTES_V1 }, + ); + this.router.register( + RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1, + async (data, peerId) => this.handleFetch(data, peerId.toString()), + { maxReadBytes: RFC64_PUBLIC_CATALOG_HEAD_FETCH_REQUEST_MAX_BYTES_V1 }, + ); + } catch (cause) { + this.#started = false; + this.router.unregister(RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1); + this.router.unregister(RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1); + throw cause; + } + } + + stop(): void { + if (!this.#started) return; + this.#started = false; + this.router.unregister(RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1); + this.router.unregister(RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1); + } + + async announceCatalogHead( + remotePeerId: string, + announcementInput: Rfc64PublicCatalogHeadAnnouncementV1, + sendOptions?: SendOptions, + ): Promise { + this.requireStarted(); + const peerId = snapshotPeerId(remotePeerId); + const announcement = parseAnnouncement(encodeAnnouncement(announcementInput)); + await this.requireOpenPolicy('announce-outbound', peerId, announcement); + const response = await this.router.send( + peerId, + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + encodeAnnouncement(announcement), + sendOptions, + ); + if (response.byteLength === 1 && response[0] === ANNOUNCEMENT_DENIED) { + fail('catalog-transport-policy-denied', 'remote peer denied the catalog-head announcement'); + } + if (response.byteLength !== 1 || response[0] !== ACK[0]) { + fail('catalog-transport-wire', 'catalog-head announcement returned an invalid acknowledgement'); + } + await this.requireOpenPolicy('announce-outbound', peerId, announcement); + } + + async fetchCatalogHead( + remotePeerId: string, + announcementInput: Rfc64PublicCatalogHeadAnnouncementV1, + sendOptions?: SendOptions, + ): Promise { + this.requireStarted(); + const peerId = snapshotPeerId(remotePeerId); + const announcement = parseAnnouncement(encodeAnnouncement(announcementInput)); + await this.requireOpenPolicy('fetch-outbound', peerId, announcement); + const request = requestFromAnnouncement(announcement); + const response = await this.router.send( + peerId, + RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1, + encodeFetchRequest(request), + sendOptions, + ); + const envelope = parseFetchResponse(response); + await this.requireOpenPolicy('fetch-outbound', peerId, announcement); + if (envelope === null) return null; + assertHeadMatchesAnnouncement(envelope, announcement); + const issuerSignature = await this.verifyExactIssuerSignature(envelope); + return Object.freeze({ + envelope: deepFreeze(envelope), + issuerSignature, + }); + } + + private async handleAnnouncement( + data: Uint8Array, + remotePeerIdInput: string, + ): Promise { + this.requireStarted(); + const remotePeerId = snapshotPeerId(remotePeerIdInput); + const announcement = parseAnnouncement(data); + if (!await this.isOpenPolicy('announce-inbound', remotePeerId, announcement)) { + return Uint8Array.of(ANNOUNCEMENT_DENIED); + } + await this.options.onCatalogHeadAvailable(announcement, remotePeerId); + if (!await this.isOpenPolicy('announce-inbound', remotePeerId, announcement)) { + return Uint8Array.of(ANNOUNCEMENT_DENIED); + } + return ACK; + } + + private async handleFetch( + data: Uint8Array, + remotePeerIdInput: string, + ): Promise { + this.requireStarted(); + const remotePeerId = snapshotPeerId(remotePeerIdInput); + const request = parseFetchRequest(data); + if (!await this.isOpenPolicy('fetch-inbound', remotePeerId, request)) { + return Uint8Array.of(FETCH_DENIED); + } + const stored = await this.options.controlObjects.getVerifiedObject({ + objectDigest: request.catalogHeadObjectDigest, + signatureVariantDigest: request.signatureVariantDigest, + verifyIssuerSignature: this.options.verifyIssuerSignature, + }); + if (stored === null) { + if (!await this.isOpenPolicy('fetch-inbound', remotePeerId, request)) { + return Uint8Array.of(FETCH_DENIED); + } + return Uint8Array.of(FETCH_NOT_FOUND); + } + let envelope: SignedAuthorCatalogHeadEnvelopeV1; + try { + assertSignedAuthorCatalogHeadEnvelopeV1(stored.envelope); + envelope = stored.envelope; + assertHeadMatchesRequest(envelope, request); + assertExactIssuerSignatureProof(envelope, stored.issuerSignature); + } catch (cause) { + fail( + 'catalog-transport-object-mismatch', + 'stored object is not the exact requested author-catalog head', + cause, + ); + } + const envelopeBytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(envelope); + const response = new Uint8Array(1 + envelopeBytes.byteLength); + response[0] = FETCH_FOUND; + response.set(envelopeBytes, 1); + if (response.byteLength > RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1) { + fail('catalog-transport-wire', 'author-catalog head exceeds the v1 fetch response cap'); + } + if (!await this.isOpenPolicy('fetch-inbound', remotePeerId, request)) { + return Uint8Array.of(FETCH_DENIED); + } + return response; + } + + private async isOpenPolicy( + operation: Rfc64PublicCatalogOperationV1, + remotePeerId: string, + scope: Rfc64PublicCatalogHeadAnnouncementV1 | Rfc64PublicCatalogHeadFetchRequestV1, + ): Promise { + try { + await this.requireOpenPolicy(operation, remotePeerId, scope); + return true; + } catch (cause) { + if ( + cause instanceof Rfc64PublicCatalogTransportErrorV1 + && cause.code === 'catalog-transport-policy-denied' + ) return false; + throw cause; + } + } + + private async requireOpenPolicy( + operation: Rfc64PublicCatalogOperationV1, + remotePeerId: string, + scope: Rfc64PublicCatalogHeadAnnouncementV1 | Rfc64PublicCatalogHeadFetchRequestV1, + ): Promise { + const input = Object.freeze({ + operation, + remotePeerId, + networkId: scope.networkId, + contextGraphId: scope.contextGraphId, + subGraphName: scope.subGraphName, + policyDigest: scope.policyDigest, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + }) satisfies Rfc64PublicCatalogAuthorizationInputV1; + let authorization: Rfc64PublicCatalogAuthorizationV1 | null; + try { + authorization = await this.options.authorizeOpenCatalogOperation(input); + } catch (cause) { + fail('catalog-transport-policy-denied', 'open catalog policy authorization failed', cause); + } + if (authorization === null || authorization.accessPolicy !== 0) { + fail('catalog-transport-policy-denied', 'catalog operation is not authorized by open access policy'); + } + try { + assertCanonicalDigest(authorization.policyDigest, 'authorized policyDigest'); + } catch (cause) { + fail('catalog-transport-policy-denied', 'policy authorization returned an invalid digest', cause); + } + if (authorization.policyDigest !== scope.policyDigest) { + fail('catalog-transport-policy-denied', 'catalog operation policy generation is stale or mismatched'); + } + } + + private async verifyExactIssuerSignature( + envelope: SignedAuthorCatalogHeadEnvelopeV1, + ): Promise { + let proof: VerifiedControlEnvelopeIssuerSignatureV1; + try { + proof = await this.options.verifyIssuerSignature(envelope); + assertExactIssuerSignatureProof(envelope, proof); + return proof; + } catch (cause) { + fail('catalog-transport-signature', 'received author-catalog head signature is invalid', cause); + } + } + + private requireStarted(): void { + if (!this.#started) { + fail('catalog-transport-state', 'RFC-64 public catalog transport is not started'); + } + } +} + +export function encodeRfc64PublicCatalogHeadAnnouncementV1( + input: Rfc64PublicCatalogHeadAnnouncementV1, +): Uint8Array { + return encodeAnnouncement(input); +} + +export function parseRfc64PublicCatalogHeadAnnouncementV1( + input: Uint8Array, +): Rfc64PublicCatalogHeadAnnouncementV1 { + return parseAnnouncement(input); +} + +export function encodeRfc64PublicCatalogHeadFetchRequestV1( + input: Rfc64PublicCatalogHeadFetchRequestV1, +): Uint8Array { + return encodeFetchRequest(input); +} + +export function parseRfc64PublicCatalogHeadFetchRequestV1( + input: Uint8Array, +): Rfc64PublicCatalogHeadFetchRequestV1 { + return parseFetchRequest(input); +} + +function requestFromAnnouncement( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, +): Rfc64PublicCatalogHeadFetchRequestV1 { + return Object.freeze({ + ...announcement, + kind: RFC64_PUBLIC_CATALOG_HEAD_FETCH_KIND_V1, + }); +} + +function encodeAnnouncement(input: Rfc64PublicCatalogHeadAnnouncementV1): Uint8Array { + const snapshot = validateWireScope(input, RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1); + return encodeFlatCanonicalJson(snapshot, RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_MAX_BYTES_V1); +} + +function parseAnnouncement(input: Uint8Array): Rfc64PublicCatalogHeadAnnouncementV1 { + const parsed = parseFlatCanonicalJson( + input, + ANNOUNCEMENT_KEYS, + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_MAX_BYTES_V1, + ); + return validateWireScope( + parsed, + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + ); +} + +function encodeFetchRequest(input: Rfc64PublicCatalogHeadFetchRequestV1): Uint8Array { + const snapshot = validateWireScope(input, RFC64_PUBLIC_CATALOG_HEAD_FETCH_KIND_V1); + return encodeFlatCanonicalJson(snapshot, RFC64_PUBLIC_CATALOG_HEAD_FETCH_REQUEST_MAX_BYTES_V1); +} + +function parseFetchRequest(input: Uint8Array): Rfc64PublicCatalogHeadFetchRequestV1 { + const parsed = parseFlatCanonicalJson( + input, + FETCH_REQUEST_KEYS, + RFC64_PUBLIC_CATALOG_HEAD_FETCH_REQUEST_MAX_BYTES_V1, + ); + return validateWireScope(parsed, RFC64_PUBLIC_CATALOG_HEAD_FETCH_KIND_V1); +} + +function validateWireScope( + value: unknown, + expectedKind: Kind, +): Kind extends typeof RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1 + ? Rfc64PublicCatalogHeadAnnouncementV1 + : Rfc64PublicCatalogHeadFetchRequestV1 { + if (!isPlainRecord(value)) { + fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain object'); + } + assertExactWireKeys(value, expectedKind === RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1 + ? ANNOUNCEMENT_KEYS + : FETCH_REQUEST_KEYS); + if (value.kind !== expectedKind) { + fail('catalog-transport-wire', `RFC-64 catalog message kind must be ${expectedKind}`); + } + try { + assertNetworkIdV1(value.networkId); + assertContextGraphIdV1(value.contextGraphId); + if (value.subGraphName !== null) assertSubGraphNameV1(value.subGraphName); + assertCanonicalEvmAddressV1(value.authorAddress, 'authorAddress'); + assertCanonicalDecimalU64(value.catalogEra, 'catalogEra'); + assertCanonicalDecimalU64(value.catalogVersion, 'catalogVersion'); + assertCanonicalDigest(value.policyDigest, 'policyDigest'); + assertCanonicalDigest(value.catalogHeadObjectDigest, 'catalogHeadObjectDigest'); + assertCanonicalDigest(value.signatureVariantDigest, 'signatureVariantDigest'); + return Object.freeze({ + authorAddress: value.authorAddress, + catalogEra: value.catalogEra, + catalogHeadObjectDigest: value.catalogHeadObjectDigest, + catalogVersion: value.catalogVersion, + contextGraphId: value.contextGraphId, + kind: expectedKind, + networkId: value.networkId, + policyDigest: value.policyDigest, + signatureVariantDigest: value.signatureVariantDigest, + subGraphName: value.subGraphName, + }) as never; + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogTransportErrorV1) throw cause; + fail('catalog-transport-wire', 'RFC-64 catalog message contains an invalid scalar', cause); + } +} + +function parseFetchResponse(input: Uint8Array): SignedAuthorCatalogHeadEnvelopeV1 | null { + if ( + input.byteLength < 1 + || input.byteLength > RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1 + ) { + fail('catalog-transport-wire', 'author-catalog head response is empty or oversized'); + } + if (input[0] === FETCH_NOT_FOUND) { + if (input.byteLength !== 1) { + fail('catalog-transport-wire', 'not-found author-catalog response has trailing bytes'); + } + return null; + } + if (input[0] === FETCH_DENIED) { + if (input.byteLength !== 1) { + fail('catalog-transport-wire', 'denied author-catalog response has trailing bytes'); + } + fail('catalog-transport-policy-denied', 'remote peer denied the author-catalog fetch'); + } + if (input[0] !== FETCH_FOUND || input.byteLength === 1) { + fail('catalog-transport-wire', 'author-catalog head response has an invalid status'); + } + try { + return parseCanonicalSignedAuthorCatalogHeadEnvelopeV1(input.subarray(1), { + maxBytes: RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1 - 1, + }); + } catch (cause) { + fail('catalog-transport-wire', 'author-catalog head response is not canonical', cause); + } +} + +function assertHeadMatchesAnnouncement( + envelope: SignedAuthorCatalogHeadEnvelopeV1, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, +): void { + assertHeadMatchesScope(envelope, announcement, 'announcement'); +} + +function assertHeadMatchesRequest( + envelope: SignedAuthorCatalogHeadEnvelopeV1, + request: Rfc64PublicCatalogHeadFetchRequestV1, +): void { + assertHeadMatchesScope(envelope, request, 'request'); +} + +function assertHeadMatchesScope( + envelope: SignedAuthorCatalogHeadEnvelopeV1, + scope: Rfc64PublicCatalogHeadAnnouncementV1 | Rfc64PublicCatalogHeadFetchRequestV1, + label: string, +): void { + try { + assertSignedAuthorCatalogHeadEnvelopeV1(envelope); + } catch (cause) { + fail('catalog-transport-object-mismatch', `fetched object is not an author-catalog head`, cause); + } + const payload = envelope.payload; + if ( + envelope.objectDigest !== scope.catalogHeadObjectDigest + || computeControlSignatureVariantDigestHex(envelope.objectDigest, envelope.signature) + !== scope.signatureVariantDigest + || payload.networkId !== scope.networkId + || payload.contextGraphId !== scope.contextGraphId + || payload.subGraphName !== scope.subGraphName + || payload.authorAddress !== scope.authorAddress + || payload.era !== scope.catalogEra + || payload.version !== scope.catalogVersion + ) { + fail( + 'catalog-transport-object-mismatch', + `author-catalog head does not match its exact ${label} scope and keys`, + ); + } + const bytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(envelope); + if (bytes.byteLength > RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1 - 1) { + fail('catalog-transport-object-mismatch', 'author-catalog head exceeds the transport cap'); + } +} + +function assertExactIssuerSignatureProof( + envelope: SignedControlEnvelopeV1, + proof: VerifiedControlEnvelopeIssuerSignatureV1, +): void { + let snapshot; + try { + snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + } catch (cause) { + fail('catalog-transport-signature', 'issuer signature proof was not minted by the verifier', cause); + } + const expectedVariant = computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ); + if ( + snapshot.objectDigest !== envelope.objectDigest + || snapshot.signatureVariantDigest !== expectedVariant + || snapshot.issuer !== envelope.issuer + || snapshot.signatureSuite !== envelope.signatureSuite + ) { + fail('catalog-transport-signature', 'issuer signature proof is not bound to the exact envelope'); + } +} + +function encodeFlatCanonicalJson( + value: object, + maxBytes: number, +): Uint8Array { + if (!isPlainRecord(value)) { + fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain object'); + } + const keys = Object.keys(value).sort(); + const fields: string[] = []; + for (const key of keys) { + const field = value[key]; + if (field !== null && typeof field !== 'string') { + fail('catalog-transport-wire', 'RFC-64 catalog messages accept only string or null fields'); + } + fields.push(`${JSON.stringify(key)}:${JSON.stringify(field)}`); + } + const bytes = UTF8.encode(`{${fields.join(',')}}`); + if (bytes.byteLength > maxBytes) { + fail('catalog-transport-wire', `RFC-64 catalog message exceeds ${maxBytes} bytes`); + } + return bytes; +} + +function parseFlatCanonicalJson( + input: Uint8Array, + expectedKeys: readonly string[], + maxBytes: number, +): Record { + if (!(input instanceof Uint8Array) || input.byteLength < 2 || input.byteLength > maxBytes) { + fail('catalog-transport-wire', 'RFC-64 catalog message is empty or oversized'); + } + let text: string; + let parsed: unknown; + try { + text = UTF8_FATAL.decode(input); + parsed = JSON.parse(text); + } catch (cause) { + fail('catalog-transport-wire', 'RFC-64 catalog message is not strict UTF-8 JSON', cause); + } + if (!isPlainRecord(parsed)) { + fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain JSON object'); + } + assertExactWireKeys(parsed, expectedKeys); + const canonical = encodeFlatCanonicalJson(parsed, maxBytes); + if (!bytesEqual(canonical, input)) { + fail('catalog-transport-wire', 'RFC-64 catalog message bytes are not canonical JCS'); + } + return parsed; +} + +function assertExactWireKeys( + value: Record, + expectedKeys: readonly string[], +): void { + const actual = Object.keys(value).sort(); + if ( + actual.length !== expectedKeys.length + || actual.some((key, index) => key !== expectedKeys[index]) + ) { + fail('catalog-transport-wire', 'RFC-64 catalog message has missing or unknown fields'); + } +} + +function assertCanonicalEvmAddressV1(value: unknown, label: string): asserts value is EvmAddressV1 { + if ( + typeof value !== 'string' + || !/^0x[0-9a-f]{40}$/.test(value) + || value === '0x0000000000000000000000000000000000000000' + ) { + fail('catalog-transport-wire', `${label} must be a canonical lowercase nonzero EVM address`); + } +} + +function snapshotPeerId(value: unknown): string { + if (typeof value !== 'string') { + fail('catalog-transport-input', 'remotePeerId must be a string'); + } + const byteLength = UTF8.encode(value).byteLength; + if (byteLength < 1 || byteLength > MAX_PEER_ID_BYTES || value.trim() !== value) { + fail('catalog-transport-input', 'remotePeerId is empty, oversized, or noncanonical'); + } + return value; +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function deepFreeze(value: T): T { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value; + for (const nested of Object.values(value as Record)) deepFreeze(nested); + return Object.freeze(value); +} + +function fail( + code: Rfc64PublicCatalogTransportErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64PublicCatalogTransportErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts b/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts new file mode 100644 index 0000000000..3e043aeacf --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts @@ -0,0 +1,304 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { multiaddr } from '@multiformats/multiaddr'; +import { + DKGNode, + ProtocolRouter, + type AuthorCatalogScopeV1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { openRfc64PersistenceV1, type Rfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; +import { + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1, + Rfc64PublicCatalogTransportV1, + encodeRfc64PublicCatalogHeadAnnouncementV1, + parseRfc64PublicCatalogHeadAnnouncementV1, + type Rfc64PublicCatalogAuthorizationInputV1, + type Rfc64PublicCatalogHeadAnnouncementV1, +} from '../src/rfc64/public-catalog-transport-v1.js'; + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const POLICY_DIGEST = `0x${'71'.repeat(32)}` as Digest32V1; +const DELEGATION_DIGEST = `0x${'72'.repeat(32)}` as Digest32V1; + +const temporaryDirectories: string[] = []; +const nodes: DKGNode[] = []; +const persistences: Rfc64PersistenceV1[] = []; +const transports: Rfc64PublicCatalogTransportV1[] = []; + +afterEach(async () => { + for (const transport of transports.splice(0)) transport.stop(); + for (const persistence of persistences.splice(0)) { + try { await persistence.close(); } catch {} + } + for (const node of nodes.splice(0)) { + try { await node.stop(); } catch {} + } + await Promise.all(temporaryDirectories.splice(0).map(async (path) => { + await rm(path, { recursive: true, force: true }); + })); +}); + +async function temporaryDataDirectory(label: string): Promise { + const path = await mkdtemp(join(tmpdir(), `dkg-rfc64-${label}-`)); + temporaryDirectories.push(path); + return path; +} + +async function openPersistence(label: string): Promise { + const persistence = await openRfc64PersistenceV1( + await temporaryDataDirectory(label), + { yieldAfterPurgeBatch: async () => {} }, + ); + persistences.push(persistence); + return persistence; +} + +async function startNode(): Promise { + const node = new DKGNode({ + listenAddresses: ['/ip4/127.0.0.1/tcp/0'], + enableMdns: false, + }); + nodes.push(node); + await node.start(); + return node; +} + +async function connect(from: DKGNode, to: DKGNode): Promise { + const address = to.multiaddrs.find((candidate) => candidate.includes('/tcp/')); + if (address === undefined) throw new Error('test node has no TCP multiaddr'); + await from.libp2p.dial(multiaddr(address)); +} + +async function stageOpenCatalogHead( + persistence: Rfc64PersistenceV1, +): Promise { + const scope = { + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/gate-1', + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1; + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1773900000000', + signer: { + issuer: AUTHOR, + signDigest: async (digest) => AUTHOR_WALLET.signMessage(digest), + }, + }); + const verified = await Promise.all(produced.stagedObjects.map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }))); + const staged = await persistence.controlObjects.stageVerifiedObjects(verified); + const headKeys = staged.objects.at(-1); + if (headKeys === undefined) throw new Error('catalog producer staged no head'); + return Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: produced.head.payload.networkId, + contextGraphId: produced.head.payload.contextGraphId, + subGraphName: produced.head.payload.subGraphName, + authorAddress: produced.head.payload.authorAddress, + catalogEra: produced.head.payload.era, + catalogVersion: produced.head.payload.version, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: headKeys.objectDigest, + signatureVariantDigest: headKeys.signatureVariantDigest, + }); +} + +const OPEN_POLICY = async () => Object.freeze({ + accessPolicy: 0 as const, + policyDigest: POLICY_DIGEST, +}); + +describe('RFC-64 public/open author catalog transport v1', () => { + it('announces and fetches one exact signed head across two live libp2p nodes', async () => { + const [authorNode, receiverNode, authorPersistence, receiverPersistence] = await Promise.all([ + startNode(), + startNode(), + openPersistence('author'), + openPersistence('receiver'), + ]); + await connect(receiverNode, authorNode); + + const announcement = await stageOpenCatalogHead(authorPersistence); + const receivedAnnouncements: Array<{ + announcement: Rfc64PublicCatalogHeadAnnouncementV1; + remotePeerId: string; + }> = []; + const authorAuthorizations: Rfc64PublicCatalogAuthorizationInputV1[] = []; + const receiverAuthorizations: Rfc64PublicCatalogAuthorizationInputV1[] = []; + + const authorTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(authorNode), + { + controlObjects: authorPersistence.controlObjects, + authorizeOpenCatalogOperation: async (input) => { + authorAuthorizations.push(input); + return OPEN_POLICY(); + }, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }, + ); + const receiverTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(receiverNode), + { + controlObjects: receiverPersistence.controlObjects, + authorizeOpenCatalogOperation: async (input) => { + receiverAuthorizations.push(input); + return OPEN_POLICY(); + }, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async (received, remotePeerId) => { + receivedAnnouncements.push({ announcement: received, remotePeerId }); + }, + }, + ); + transports.push(authorTransport, receiverTransport); + authorTransport.start(); + receiverTransport.start(); + + expect(RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1) + .toBe('/dkg/catalog/1/author-head-availability'); + expect(RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1) + .toBe('/dkg/catalog/1/control-object/author-head'); + + await authorTransport.announceCatalogHead(receiverNode.peerId, announcement); + expect(receivedAnnouncements).toEqual([{ + announcement, + remotePeerId: authorNode.peerId, + }]); + + const fetched = await receiverTransport.fetchCatalogHead(authorNode.peerId, announcement); + expect(fetched?.envelope.objectDigest).toBe(announcement.catalogHeadObjectDigest); + expect(fetched?.envelope.payload).toMatchObject({ + authorAddress: announcement.authorAddress, + contextGraphId: announcement.contextGraphId, + era: announcement.catalogEra, + version: announcement.catalogVersion, + }); + + const receiverStage = await receiverPersistence.controlObjects.stageVerifiedObjects([fetched!]); + expect(receiverStage.objects).toEqual([{ + objectDigest: announcement.catalogHeadObjectDigest, + signatureVariantDigest: announcement.signatureVariantDigest, + }]); + const receiverRead = await receiverPersistence.controlObjects.getVerifiedObject({ + objectDigest: announcement.catalogHeadObjectDigest, + signatureVariantDigest: announcement.signatureVariantDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + expect(receiverRead?.envelope).toEqual(fetched?.envelope); + + expect(authorAuthorizations.map((input) => input.operation)).toEqual([ + 'announce-outbound', + 'announce-outbound', + 'fetch-inbound', + 'fetch-inbound', + ]); + expect(receiverAuthorizations.map((input) => input.operation)).toEqual([ + 'announce-inbound', + 'announce-inbound', + 'fetch-outbound', + 'fetch-outbound', + ]); + }, 30_000); + + it('denies private-policy fetch before revealing cache hit or miss state', async () => { + const [providerNode, requesterNode] = await Promise.all([startNode(), startNode()]); + await connect(requesterNode, providerNode); + const getVerifiedObject = vi.fn(async () => null); + const announcement = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/private', + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + catalogVersion: '0', + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}`, + signatureVariantDigest: `0x${'82'.repeat(32)}`, + }) as Rfc64PublicCatalogHeadAnnouncementV1; + + const providerTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(providerNode), + { + controlObjects: { getVerifiedObject }, + authorizeOpenCatalogOperation: async () => ({ + accessPolicy: 1, + policyDigest: POLICY_DIGEST, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }, + ); + const requesterTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(requesterNode), + { + controlObjects: { getVerifiedObject: vi.fn(async () => null) }, + authorizeOpenCatalogOperation: OPEN_POLICY, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }, + ); + transports.push(providerTransport, requesterTransport); + providerTransport.start(); + requesterTransport.start(); + + await expect(requesterTransport.fetchCatalogHead( + providerNode.peerId, + announcement, + { timeoutMs: 4_000 }, + )).rejects.toThrow(); + expect(getVerifiedObject).not.toHaveBeenCalled(); + }, 15_000); + + it('round-trips only exact canonical announcement fields', () => { + const announcement = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/codec', + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + catalogVersion: '1', + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'91'.repeat(32)}`, + signatureVariantDigest: `0x${'92'.repeat(32)}`, + }) as Rfc64PublicCatalogHeadAnnouncementV1; + const encoded = encodeRfc64PublicCatalogHeadAnnouncementV1(announcement); + expect(parseRfc64PublicCatalogHeadAnnouncementV1(encoded)).toEqual(announcement); + + const parsed = JSON.parse(new TextDecoder().decode(encoded)); + const noncanonical = new TextEncoder().encode(JSON.stringify( + Object.fromEntries(Object.entries(parsed).reverse()), + )); + expect(() => parseRfc64PublicCatalogHeadAnnouncementV1(noncanonical)) + .toThrow(/canonical JCS/); + + const withUnknown = new TextEncoder().encode(JSON.stringify({ ...parsed, surprise: 'x' })); + expect(() => parseRfc64PublicCatalogHeadAnnouncementV1(withUnknown)) + .toThrow(/missing or unknown fields/); + }); +}); From 1f9119ac8aca8dc912a941b9cb9d09da9714cbca Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:20:59 +0200 Subject: [PATCH 111/292] build(agent): classify RFC-64 catalog modules --- packages/agent/package.json | 6 ++++++ packages/agent/scripts/test-package-root.mjs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/packages/agent/package.json b/packages/agent/package.json index 74633ce3af..f7f515f8cc 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -23,9 +23,15 @@ "./dist/rfc64/control-object-store-v1-internal.js": null, "./dist/rfc64/control-object-store-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, + "./dist/rfc64/open-catalog-policy-v1.js": null, "./dist/rfc64/persistence-layout-v1.js": null, "./dist/rfc64/persistence-root-ownership-v1-internal.js": null, "./dist/rfc64/persistence-v1.js": null, + "./dist/rfc64/public-catalog-native-receiver-v1.js": null, + "./dist/rfc64/public-catalog-native-transport-v1.js": null, + "./dist/rfc64/public-catalog-receiver-v1.js": null, + "./dist/rfc64/public-catalog-service-v1.js": null, + "./dist/rfc64/public-catalog-transport-v1.js": null, "./dist/rfc64/secure-filesystem-policy-v1.js": null, "./dist/rfc64/*": null, "./dist/*": "./dist/*" diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 3a970ec22c..c2417595dd 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -29,9 +29,15 @@ const blockedRfc64Modules = [ 'control-object-store-v1-internal.js', 'control-object-store-v1.js', 'durable-file-store-v1.js', + 'open-catalog-policy-v1.js', 'persistence-layout-v1.js', 'persistence-root-ownership-v1-internal.js', 'persistence-v1.js', + 'public-catalog-native-receiver-v1.js', + 'public-catalog-native-transport-v1.js', + 'public-catalog-receiver-v1.js', + 'public-catalog-service-v1.js', + 'public-catalog-transport-v1.js', 'secure-filesystem-policy-v1.js', ]; const packageExports = packageManifest.exports; From 283071c01c4c8c019401261177c06722749bba35 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:41:49 +0200 Subject: [PATCH 112/292] fix(agent): reconcile RFC-64 heads from applied state --- .../devnet/rfc64-gate1-public-catalog/run.mjs | 4 +- .../src/rfc64/public-catalog-receiver-v1.ts | 237 ++++++++--- .../src/rfc64/public-catalog-service-v1.ts | 136 +++++-- ...4-public-catalog-gate1.integration.test.ts | 24 +- .../rfc64-public-catalog-receiver-v1.test.ts | 372 ++++++++++++------ 5 files changed, 543 insertions(+), 230 deletions(-) diff --git a/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs b/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs index f5158f2738..0690f494cb 100644 --- a/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs +++ b/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs @@ -138,7 +138,9 @@ async function main() { && published.failedPeers.length === 0, receiverStagedExactHead: staged.matchesExactHead === true && staged.readBackFromControlStore === published.headObjectDigest, - exactlyOneDurableStage: staged.receiverStats?.staged === 1 && staged.receiverStats?.failed === 0, + exactlyOneDurableStage: staged.receiverStats?.stagedOnly === 1 + && staged.receiverStats?.applied === 0 + && staged.receiverStats?.failed === 0, noKaSwmActivation: Array.isArray(staged.activeContextGraphs) && staged.activeContextGraphs.length === 0, }; diff --git a/packages/agent/src/rfc64/public-catalog-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-receiver-v1.ts index fa67373e24..6183442975 100644 --- a/packages/agent/src/rfc64/public-catalog-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-receiver-v1.ts @@ -9,31 +9,32 @@ * only enqueues and pumps; the fetch/verify/stage work runs on the pool after * the announcement handler has returned. * - * Per hinted head it: (1) deduplicates against both in-flight work and heads - * already durably staged, (2) fetches the exact head by digest (the transport - * re-verifies structure + issuer signature), and (3) durably stages the - * verified head into the control-object store. It STOPS there — Gate 1 never - * admits candidate rows, activates catalog state, or advances any SWM/VM - * pointer. Correctness comes from pull: a dropped or failed announcement is - * simply re-triggered by a later hint or a future reconcile cadence. + * Per hinted head it deduplicates exact work, retains every distinct provider, + * serializes mutations for one author-catalog scope, and delegates the full + * fetch/verify/activate/applied-inventory transaction to a reconciler. Durable + * applied state -- never mere control-object staging -- is the restart dedup + * boundary. Correctness still comes from pull: a dropped or failed hint is + * retriggered by a later announcement or reconcile cadence. */ -import type { - FetchedRfc64PublicCatalogHeadV1, - Rfc64PublicCatalogHeadAnnouncementV1, -} from './public-catalog-transport-v1.js'; +import type { Rfc64PublicCatalogHeadAnnouncementV1 } from './public-catalog-transport-v1.js'; -/** Side effects the scheduler drives; all supplied by the wired service. */ -export interface Rfc64PublicCatalogReceiverStagerV1 { - /** True when the exact head is already durably staged (restart/prior-fetch dedup). */ - isHeadStaged(announcement: Rfc64PublicCatalogHeadAnnouncementV1): Promise; - /** Fetch the exact head by digest; null == authoritative not-found. */ - fetchHead( +export type Rfc64PublicCatalogReconcileResultV1 = 'applied' | 'not-found' | 'staged-only'; + +/** Full semantic reconciliation supplied by the wired service. */ +export interface Rfc64PublicCatalogReceiverReconcilerV1 { + /** True only when this exact inventory head is durably recorded as applied. */ + isHeadApplied(announcement: Rfc64PublicCatalogHeadAnnouncementV1): Promise; + /** + * Fetch, verify, activate, exact-post-read, then durably commit applied state. + * The operation must be idempotent so a restart can repair the semantic-store + * / SQLite crash gap by replaying it. + */ + reconcileHead( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, - ): Promise; - /** Durably stage the verified head. Must not return before the write is durable. */ - stageHead(fetched: FetchedRfc64PublicCatalogHeadV1): Promise; + signal: AbortSignal, + ): Promise; } export interface Rfc64PublicCatalogReceiverOptionsV1 { @@ -41,11 +42,13 @@ export interface Rfc64PublicCatalogReceiverOptionsV1 { readonly maxConcurrent?: number; /** Max queued distinct heads before new hints are dropped. Default 1024. */ readonly maxQueue?: number; - /** Max fetch attempts per head before giving up. Default 3. */ + /** Max reconcile attempts per provider before giving up on that provider. Default 3. */ readonly maxAttempts?: number; + /** Max distinct providers retained for one exact head. Default 8. */ + readonly maxProvidersPerHead?: number; /** Base backoff between attempts (doubled per retry). Default 250ms. */ readonly retryBackoffMs?: number; - readonly onHeadStaged?: ( + readonly onHeadApplied?: ( announcement: Rfc64PublicCatalogHeadAnnouncementV1, remotePeerId: string, ) => void; @@ -58,40 +61,53 @@ export interface Rfc64PublicCatalogReceiverOptionsV1 { export interface Rfc64PublicCatalogReceiverStatsV1 { readonly scheduled: number; readonly dedupedInFlight: number; - readonly dedupedAlreadyStaged: number; - readonly staged: number; + readonly dedupedAlreadyApplied: number; + readonly applied: number; + readonly stagedOnly: number; readonly notFound: number; readonly failed: number; readonly droppedQueueFull: number; + readonly droppedProviders: number; readonly inFlight: number; readonly queued: number; } interface ReceiverTaskV1 { readonly key: string; + readonly scopeKey: string; + readonly providers: ReceiverProviderV1[]; + readonly providerKeys: Set; +} + +interface ReceiverProviderV1 { + readonly key: string; + readonly peerId: string; readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; - readonly remotePeerId: string; } const DEFAULTS = Object.freeze({ maxConcurrent: 4, maxQueue: 1024, maxAttempts: 3, + maxProvidersPerHead: 8, retryBackoffMs: 250, }); export class Rfc64PublicCatalogReceiverV1 { - readonly #stager: Rfc64PublicCatalogReceiverStagerV1; + readonly #reconciler: Rfc64PublicCatalogReceiverReconcilerV1; readonly #maxConcurrent: number; readonly #maxQueue: number; readonly #maxAttempts: number; + readonly #maxProvidersPerHead: number; readonly #retryBackoffMs: number; - readonly #onHeadStaged?: Rfc64PublicCatalogReceiverOptionsV1['onHeadStaged']; + readonly #onHeadApplied?: Rfc64PublicCatalogReceiverOptionsV1['onHeadApplied']; readonly #onError?: Rfc64PublicCatalogReceiverOptionsV1['onError']; readonly #queue: ReceiverTaskV1[] = []; - /** Every head key currently queued or in-flight — the dedup set. */ - readonly #pendingKeys = new Set(); + /** Every exact head currently queued or in-flight, including alternate peers. */ + readonly #pendingByKey = new Map(); + /** One semantic writer per exact author-catalog scope. */ + readonly #activeScopeKeys = new Set(); readonly #active = new Set>(); readonly #closing = new AbortController(); #closed = false; @@ -99,29 +115,36 @@ export class Rfc64PublicCatalogReceiverV1 { #scheduled = 0; #dedupedInFlight = 0; - #dedupedAlreadyStaged = 0; - #staged = 0; + #dedupedAlreadyApplied = 0; + #applied = 0; + #stagedOnly = 0; #notFound = 0; #failed = 0; #droppedQueueFull = 0; + #droppedProviders = 0; constructor( - stager: Rfc64PublicCatalogReceiverStagerV1, + reconciler: Rfc64PublicCatalogReceiverReconcilerV1, options: Rfc64PublicCatalogReceiverOptionsV1 = {}, ) { - this.#stager = stager; + this.#reconciler = reconciler; this.#maxConcurrent = positiveInt(options.maxConcurrent, DEFAULTS.maxConcurrent); this.#maxQueue = positiveInt(options.maxQueue, DEFAULTS.maxQueue); this.#maxAttempts = positiveInt(options.maxAttempts, DEFAULTS.maxAttempts); + this.#maxProvidersPerHead = positiveInt( + options.maxProvidersPerHead, + DEFAULTS.maxProvidersPerHead, + ); this.#retryBackoffMs = nonNegativeInt(options.retryBackoffMs, DEFAULTS.retryBackoffMs); - this.#onHeadStaged = options.onHeadStaged; + this.#onHeadApplied = options.onHeadApplied; this.#onError = options.onError; } /** - * Enqueue an announced head for fetch+stage. Non-blocking and synchronous: - * it never awaits the fetch, so the transport's ACK path is not stalled. - * Duplicate (already queued/in-flight) heads and post-close hints are dropped. + * Enqueue an announced head for reconciliation. Non-blocking and synchronous: + * it never awaits network or storage work, so the ACK path is not stalled. + * Duplicate heads contribute alternate providers instead of creating a second + * semantic writer. */ schedule( announcement: Rfc64PublicCatalogHeadAnnouncementV1, @@ -130,16 +153,33 @@ export class Rfc64PublicCatalogReceiverV1 { if (this.#closed) return; this.#scheduled += 1; const key = headKey(announcement); - if (this.#pendingKeys.has(key)) { + const existing = this.#pendingByKey.get(key); + if (existing !== undefined) { this.#dedupedInFlight += 1; + const providerKey = providerContextKey(remotePeerId, announcement); + if (!existing.providerKeys.has(providerKey)) { + if (existing.providers.length >= this.#maxProvidersPerHead) { + this.#droppedProviders += 1; + return; + } + existing.providerKeys.add(providerKey); + existing.providers.push({ key: providerKey, peerId: remotePeerId, announcement }); + } return; } if (this.#queue.length >= this.#maxQueue) { this.#droppedQueueFull += 1; return; } - this.#pendingKeys.add(key); - this.#queue.push({ key, announcement, remotePeerId }); + const providerKey = providerContextKey(remotePeerId, announcement); + const task: ReceiverTaskV1 = { + key, + scopeKey: catalogScopeKey(announcement), + providers: [{ key: providerKey, peerId: remotePeerId, announcement }], + providerKeys: new Set([providerKey]), + }; + this.#pendingByKey.set(key, task); + this.#queue.push(task); this.#pump(); } @@ -159,7 +199,8 @@ export class Rfc64PublicCatalogReceiverV1 { return; } this.#closed = true; - this.#queue.length = 0; + const abandoned = this.#queue.splice(0); + for (const task of abandoned) this.#pendingByKey.delete(task.key); this.#closing.abort(new Error('RFC-64 public catalog receiver closing')); await Promise.allSettled([...this.#active]); this.#resolveIdle(); @@ -169,11 +210,13 @@ export class Rfc64PublicCatalogReceiverV1 { return Object.freeze({ scheduled: this.#scheduled, dedupedInFlight: this.#dedupedInFlight, - dedupedAlreadyStaged: this.#dedupedAlreadyStaged, - staged: this.#staged, + dedupedAlreadyApplied: this.#dedupedAlreadyApplied, + applied: this.#applied, + stagedOnly: this.#stagedOnly, notFound: this.#notFound, failed: this.#failed, droppedQueueFull: this.#droppedQueueFull, + droppedProviders: this.#droppedProviders, inFlight: this.#active.size, queued: this.#queue.length, }); @@ -181,10 +224,17 @@ export class Rfc64PublicCatalogReceiverV1 { #pump(): void { while (!this.#closed && this.#active.size < this.#maxConcurrent && this.#queue.length > 0) { - const task = this.#queue.shift()!; + const taskIndex = this.#queue.findIndex( + (candidate) => !this.#activeScopeKeys.has(candidate.scopeKey), + ); + if (taskIndex < 0) return; + const [task] = this.#queue.splice(taskIndex, 1); + if (task === undefined) return; + this.#activeScopeKeys.add(task.scopeKey); const run = this.#runTask(task).finally(() => { this.#active.delete(run); - this.#pendingKeys.delete(task.key); + this.#activeScopeKeys.delete(task.scopeKey); + this.#pendingByKey.delete(task.key); if (!this.#closed) this.#pump(); if (this.#isIdle()) this.#resolveIdle(); }); @@ -194,32 +244,61 @@ export class Rfc64PublicCatalogReceiverV1 { async #runTask(task: ReceiverTaskV1): Promise { let lastError: unknown; - for (let attempt = 0; attempt < this.#maxAttempts; attempt += 1) { + const notFoundProviders = new Set(); + const attemptsByProvider = new Map(); + let providerCursor = 0; + while (true) { if (this.#closing.signal.aborted) return; + const selection = nextEligibleProvider( + task.providers, + notFoundProviders, + attemptsByProvider, + this.#maxAttempts, + providerCursor, + ); + if (selection === null) { + if ( + task.providers.length > 0 + && task.providers.every((provider) => notFoundProviders.has(provider.key)) + ) { + this.#notFound += 1; + return; + } + this.#failed += 1; + this.#safeNotify(() => this.#onError?.(task.providers[0]!.announcement, lastError)); + return; + } + const { provider, nextCursor } = selection; + providerCursor = nextCursor; + const providerAttempt = (attemptsByProvider.get(provider.key) ?? 0) + 1; + attemptsByProvider.set(provider.key, providerAttempt); try { - if (await this.#stager.isHeadStaged(task.announcement)) { - this.#dedupedAlreadyStaged += 1; + if (await this.#reconciler.isHeadApplied(provider.announcement)) { + this.#dedupedAlreadyApplied += 1; return; } - const fetched = await this.#stager.fetchHead(task.remotePeerId, task.announcement); - if (fetched === null) { - this.#notFound += 1; + const result = await this.#reconciler.reconcileHead( + provider.peerId, + provider.announcement, + this.#closing.signal, + ); + if (result === 'not-found') { + notFoundProviders.add(provider.key); + continue; + } + if (result === 'staged-only') { + this.#stagedOnly += 1; return; } - await this.#stager.stageHead(fetched); - this.#staged += 1; - this.#safeNotify(() => this.#onHeadStaged?.(task.announcement, task.remotePeerId)); + this.#applied += 1; + this.#safeNotify(() => this.#onHeadApplied?.(provider.announcement, provider.peerId)); return; } catch (error) { lastError = error; if (this.#closing.signal.aborted) return; - if (attempt + 1 >= this.#maxAttempts) break; - await this.#backoff(attempt); + await this.#backoff(providerAttempt - 1); } } - if (this.#closing.signal.aborted) return; - this.#failed += 1; - this.#safeNotify(() => this.#onError?.(task.announcement, lastError)); } #backoff(attempt: number): Promise { @@ -279,6 +358,46 @@ function headKey(a: Rfc64PublicCatalogHeadAnnouncementV1): string { ].join('\n'); } +function catalogScopeKey(a: Rfc64PublicCatalogHeadAnnouncementV1): string { + return [ + a.networkId, + a.contextGraphId, + a.subGraphName ?? '', + a.authorAddress, + a.catalogEra, + ].join('\n'); +} + +function providerContextKey( + peerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, +): string { + return `${peerId}\n${announcement.policyDigest}`; +} + +function nextEligibleProvider( + providers: readonly ReceiverProviderV1[], + notFoundProviders: ReadonlySet, + attemptsByProvider: ReadonlyMap, + maxAttempts: number, + cursor: number, +): { readonly provider: ReceiverProviderV1; readonly nextCursor: number } | null { + for (let offset = 0; offset < providers.length; offset += 1) { + const index = (cursor + offset) % providers.length; + const provider = providers[index]; + if ( + provider !== undefined + && !notFoundProviders.has(provider.key) + && (attemptsByProvider.get(provider.key) ?? 0) < maxAttempts + ) { + // Keep this monotonic. A modulo cursor would select the first provider + // again when a new provider is appended after the first attempt. + return { provider, nextCursor: cursor + offset + 1 }; + } + } + return null; +} + function positiveInt(value: number | undefined, fallback: number): number { return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback; } diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 2fb33ebe6d..c26c8d5da4 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -8,15 +8,16 @@ * {@link ProtocolRouter} (admission-gated exactly like every other node * protocol; on a chain-free node admission is disabled and it is open), * - routes untrusted availability hints into the {@link Rfc64PublicCatalogReceiverV1} - * scheduler (fetch-by-digest → transport re-verify → durable stage; no - * activation), + * scheduler, whose production reconciler owns fetch, semantic activation, + * exact post-read, and durable applied-inventory commit, * - answers the transport's open-policy check from the accepted-policy * registry ({@link Rfc64AcceptedOpenCatalogPolicyRegistryV1}), * - and provides the author path: produce a genesis head, durably stage it, * then best-effort announce its availability to peers. * - * Gate-1 boundary: no candidate-row admission, no KA/SWM/VM activation, no - * invite-only policy, no successor heads. + * Omitting a reconciler retains a staging-only diagnostic mode for the earlier + * Gate-1A demo. That mode never reports a head as applied and never uses staged + * control objects as restart dedup state. */ import { @@ -45,9 +46,16 @@ import { } from './open-catalog-policy-v1.js'; import { Rfc64PublicCatalogReceiverV1, + type Rfc64PublicCatalogReceiverReconcilerV1, type Rfc64PublicCatalogReceiverOptionsV1, type Rfc64PublicCatalogReceiverStatsV1, } from './public-catalog-receiver-v1.js'; +import { + Rfc64PublicCatalogNativeTransportV1, + type Rfc64PublicCatalogNativeAuthorizationInputV1, + type Rfc64PublicCatalogNativeAuthorizationV1, + type Rfc64PublicCatalogNativeTransportOptionsV1, +} from './public-catalog-native-transport-v1.js'; import { RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, Rfc64PublicCatalogTransportV1, @@ -61,6 +69,8 @@ export interface Rfc64PublicCatalogServiceOptionsV1 { readonly router: ProtocolRouter; readonly controlObjects: Rfc64ControlObjectOperationsV1; readonly receiver?: Rfc64PublicCatalogReceiverOptionsV1; + /** Full production native content/reconciliation path. Omission is diagnostic-only. */ + readonly native?: Rfc64PublicCatalogServiceNativeOptionsV1; /** Per-peer announce/fetch timeout (ms). */ readonly transportTimeoutMs?: number; /** @@ -76,6 +86,32 @@ export interface Rfc64PublicCatalogServiceOptionsV1 { ) => void; } +export type Rfc64PublicCatalogHeadFetchClientV1 = Pick< + Rfc64PublicCatalogTransportV1, + 'fetchCatalogHead' +>; + +export type Rfc64PublicCatalogContentFetchClientV1 = Pick< + Rfc64PublicCatalogNativeTransportV1, + 'fetchCatalogObject' | 'fetchKaBundle' +>; + +export interface Rfc64PublicCatalogReconcilerClientsV1 { + readonly headTransport: Rfc64PublicCatalogHeadFetchClientV1; + readonly contentTransport: Rfc64PublicCatalogContentFetchClientV1; + readonly transportTimeoutMs: number; +} + +export interface Rfc64PublicCatalogServiceNativeOptionsV1 extends Pick< + Rfc64PublicCatalogNativeTransportOptionsV1, + 'readCatalogObjectByDigest' | 'readKaBundleByDigest' +> { + /** Construct exactly one reconciler around the service-owned transports. */ + readonly createReconciler: ( + clients: Readonly, + ) => Rfc64PublicCatalogReceiverReconcilerV1; +} + export interface PublishOpenAuthorCatalogGenesisInputV1 { readonly scope: AuthorCatalogScopeV1; readonly signer: Rfc64AuthorCatalogEip191SignerV1; @@ -111,6 +147,7 @@ export class Rfc64PublicCatalogServiceV1 { readonly #policies = new Rfc64AcceptedOpenCatalogPolicyRegistryV1(); readonly #receiver: Rfc64PublicCatalogReceiverV1; readonly #transport: Rfc64PublicCatalogTransportV1; + readonly #nativeTransport: Rfc64PublicCatalogNativeTransportV1 | undefined; readonly #transportTimeoutMs: number; #started = false; #closed = false; @@ -121,19 +158,6 @@ export class Rfc64PublicCatalogServiceV1 { options.verifyIssuerSignature ?? verifyControlEnvelopeIssuerSignatureV1; this.#transportTimeoutMs = options.transportTimeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS; - this.#receiver = new Rfc64PublicCatalogReceiverV1( - { - isHeadStaged: (announcement) => this.#isHeadStaged(announcement), - fetchHead: (remotePeerId, announcement) => - this.#transport.fetchCatalogHead(remotePeerId, announcement, this.#sendOptions()), - stageHead: (fetched) => this.#stageFetchedHead(fetched), - }, - { - ...options.receiver, - onHeadStaged: options.onHeadStaged ?? options.receiver?.onHeadStaged, - }, - ); - this.#transport = new Rfc64PublicCatalogTransportV1(options.router, { controlObjects: this.#controlObjects, authorizeOpenCatalogOperation: this.#policies.authorize, @@ -144,6 +168,27 @@ export class Rfc64PublicCatalogServiceV1 { this.#receiver.schedule(announcement, remotePeerId); }, }); + + this.#nativeTransport = options.native === undefined + ? undefined + : new Rfc64PublicCatalogNativeTransportV1(options.router, { + readCatalogObjectByDigest: options.native.readCatalogObjectByDigest, + readKaBundleByDigest: options.native.readKaBundleByDigest, + authorizeOpenCatalogOperation: (input) => this.#authorizeNativeOperation(input), + verifyIssuerSignature: this.#verifyIssuerSignature, + }); + const reconciler = options.native === undefined + ? { + isHeadApplied: async () => false, + reconcileHead: (remotePeerId, announcement, signal) => + this.#stageHeadOnly(remotePeerId, announcement, signal, options.onHeadStaged), + } satisfies Rfc64PublicCatalogReceiverReconcilerV1 + : options.native.createReconciler({ + headTransport: this.#transport, + contentTransport: this.#nativeTransport!, + transportTimeoutMs: this.#transportTimeoutMs, + }); + this.#receiver = new Rfc64PublicCatalogReceiverV1(reconciler, options.receiver); } get started(): boolean { @@ -160,8 +205,16 @@ export class Rfc64PublicCatalogServiceV1 { start(): void { if (this.#closed) throw new Error('RFC-64 public catalog service is closed'); if (this.#started) return; - this.#transport.start(); - this.#started = true; + this.#nativeTransport?.start(); + try { + // Register the announcement protocol last so no callback can schedule + // reconciliation before the content-fetch protocols are live. + this.#transport.start(); + this.#started = true; + } catch (cause) { + this.#nativeTransport?.stop(); + throw cause; + } } /** Stop serving, drain in-flight receiver work, then release. Idempotent. */ @@ -169,8 +222,14 @@ export class Rfc64PublicCatalogServiceV1 { if (this.#closed) return; this.#closed = true; this.#started = false; - this.#transport.stop(); - await this.#receiver.close(); + try { + // Keep both outbound transports live until the scheduler has drained. + // Post-close availability callbacks are harmless: schedule() rejects them. + await this.#receiver.close(); + } finally { + this.#transport.stop(); + this.#nativeTransport?.stop(); + } } /** @@ -254,22 +313,33 @@ export class Rfc64PublicCatalogServiceV1 { return { timeoutMs: this.#transportTimeoutMs }; } - async #isHeadStaged( - announcement: Rfc64PublicCatalogHeadAnnouncementV1, - ): Promise { - const stored = await this.#controlObjects.getVerifiedObject({ - objectDigest: announcement.catalogHeadObjectDigest, - signatureVariantDigest: announcement.signatureVariantDigest, - verifyIssuerSignature: this.#verifyIssuerSignature, + async #authorizeNativeOperation( + input: Rfc64PublicCatalogNativeAuthorizationInputV1, + ): Promise { + const record = this.#policies.lookup(input.networkId, input.contextGraphId); + if (record === null || record.policy.accessPolicy !== 0) return null; + return Object.freeze({ + accessPolicy: 0, + policyDigest: record.policyDigest, }); - return stored !== null; } - async #stageFetchedHead(fetched: { - readonly envelope: SignedControlEnvelopeV1; - readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; - }): Promise { + async #stageHeadOnly( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + signal: AbortSignal, + onHeadStaged?: Rfc64PublicCatalogServiceOptionsV1['onHeadStaged'], + ): Promise<'not-found' | 'staged-only'> { + if (signal.aborted) throw signal.reason; + const fetched = await this.#transport.fetchCatalogHead( + remotePeerId, + announcement, + this.#sendOptions(), + ); + if (fetched === null) return 'not-found'; await this.#controlObjects.stageVerifiedObjects([fetched]); + onHeadStaged?.(announcement, remotePeerId); + return 'staged-only'; } #requireStarted(): void { diff --git a/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts index df65d32e40..38af21703f 100644 --- a/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts @@ -123,10 +123,16 @@ describe('RFC-64 Gate 1 public catalog wiring — two real DKGAgent instances', expect(stagedDigest).toBe(published.headObjectDigest); const receiverStats = receiver.rfc64PublicCatalogStatsV1(); - expect(receiverStats?.receiver).toMatchObject({ staged: 1, notFound: 0, failed: 0 }); + expect(receiverStats?.receiver).toMatchObject({ + stagedOnly: 1, + applied: 0, + notFound: 0, + failed: 0, + }); - // Re-announcing the identical head is deduped against durable state — no - // second fetch/stage. + // Re-announcing the identical head may replay the idempotent diagnostic + // stage, because staging alone is deliberately no longer the completion + // boundary. Only a durable applied-inventory record may suppress work. const republished = await author.publishOpenAuthorCatalogGenesisV1({ networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, @@ -138,8 +144,11 @@ describe('RFC-64 Gate 1 public catalog wiring — two real DKGAgent instances', await receiver.whenRfc64PublicCatalogReceiverIdleV1(); const afterStats = receiver.rfc64PublicCatalogStatsV1(); - expect(afterStats?.receiver.staged).toBe(1); // still exactly one durable stage - expect(afterStats?.receiver.dedupedAlreadyStaged).toBeGreaterThanOrEqual(1); + expect(afterStats?.receiver).toMatchObject({ + stagedOnly: 2, + applied: 0, + dedupedAlreadyApplied: 0, + }); // NO KA/SWM activation: the head lives only in the control-object cache; // the receiver never activated the CG as queryable knowledge. @@ -174,6 +183,9 @@ describe('RFC-64 Gate 1 public catalog wiring — two real DKGAgent instances', signatureVariantDigest: published.signatureVariantDigest, }); expect(stagedDigest).toBeNull(); - expect(receiver.rfc64PublicCatalogStatsV1()?.receiver.staged).toBe(0); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + stagedOnly: 0, + applied: 0, + }); }, 60_000); }); diff --git a/packages/agent/test/rfc64-public-catalog-receiver-v1.test.ts b/packages/agent/test/rfc64-public-catalog-receiver-v1.test.ts index 22913efd4d..a5a706feb4 100644 --- a/packages/agent/test/rfc64-public-catalog-receiver-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-receiver-v1.test.ts @@ -2,12 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; import { Rfc64PublicCatalogReceiverV1, - type Rfc64PublicCatalogReceiverStagerV1, + type Rfc64PublicCatalogReceiverReconcilerV1, + type Rfc64PublicCatalogReconcileResultV1, } from '../src/rfc64/public-catalog-receiver-v1.js'; -import type { - FetchedRfc64PublicCatalogHeadV1, - Rfc64PublicCatalogHeadAnnouncementV1, -} from '../src/rfc64/public-catalog-transport-v1.js'; +import type { Rfc64PublicCatalogHeadAnnouncementV1 } from '../src/rfc64/public-catalog-transport-v1.js'; function announcement( overrides: Partial = {}, @@ -19,7 +17,7 @@ function announcement( subGraphName: null, authorAddress: '0x2222222222222222222222222222222222222222', catalogEra: '0', - catalogVersion: '0', + catalogVersion: '1', policyDigest: `0x${'71'.repeat(32)}`, catalogHeadObjectDigest: `0x${'aa'.repeat(32)}`, signatureVariantDigest: `0x${'bb'.repeat(32)}`, @@ -31,167 +29,279 @@ function headWith(objectDigest: string): Rfc64PublicCatalogHeadAnnouncementV1 { return announcement({ catalogHeadObjectDigest: objectDigest as `0x${string}` & string }); } -const FAKE_FETCHED = { envelope: {}, issuerSignature: {} } as unknown as FetchedRfc64PublicCatalogHeadV1; - -function deferred(): { promise: Promise; resolve: (v: T) => void; reject: (e: unknown) => void } { +function deferred(): { promise: Promise; resolve: (v: T) => void } { let resolve!: (v: T) => void; - let reject!: (e: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +function reconciler( + reconcileHead: Rfc64PublicCatalogReceiverReconcilerV1['reconcileHead'], + isHeadApplied: Rfc64PublicCatalogReceiverReconcilerV1['isHeadApplied'] = async () => false, +): Rfc64PublicCatalogReceiverReconcilerV1 { + return { isHeadApplied, reconcileHead }; } describe('RFC-64 public catalog receiver scheduler v1', () => { - it('fetches, durably stages, and reports the staged head', async () => { - const staged: FetchedRfc64PublicCatalogHeadV1[] = []; - const onHeadStaged = vi.fn(); - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => false, - fetchHead: async () => FAKE_FETCHED, - stageHead: async (f) => { staged.push(f); }, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager, { onHeadStaged }); + it('reconciles and reports one durably applied inventory head', async () => { + const appliedPeers: string[] = []; + const onHeadApplied = vi.fn(); + const receiver = new Rfc64PublicCatalogReceiverV1( + reconciler(async (peerId) => { appliedPeers.push(peerId); return 'applied'; }), + { onHeadApplied }, + ); receiver.schedule(announcement(), 'peerA'); await receiver.whenIdle(); - expect(staged).toEqual([FAKE_FETCHED]); - expect(onHeadStaged).toHaveBeenCalledTimes(1); - expect(receiver.stats()).toMatchObject({ scheduled: 1, staged: 1, notFound: 0, failed: 0 }); - }); - - it('schedule() returns synchronously without awaiting the fetch (ACK path is not stalled)', () => { - const fetchStarted = deferred(); - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => { fetchStarted.resolve(); return false; }, - fetchHead: () => new Promise(() => {}), // never resolves - stageHead: async () => {}, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager); - const returned = receiver.schedule(announcement(), 'peerA'); - // schedule returns void synchronously even though fetch never completes. - expect(returned).toBeUndefined(); + expect(appliedPeers).toEqual(['peerA']); + expect(onHeadApplied).toHaveBeenCalledTimes(1); + expect(receiver.stats()).toMatchObject({ scheduled: 1, applied: 1, notFound: 0, failed: 0 }); + }); + + it('schedule returns synchronously without awaiting reconciliation', () => { + const started = deferred(); + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async () => { + started.resolve(); + return new Promise(() => {}); + })); + expect(receiver.schedule(announcement(), 'peerA')).toBeUndefined(); expect(receiver.stats().scheduled).toBe(1); }); - it('deduplicates a head already in flight', async () => { - const gate = deferred(); - let fetchCalls = 0; - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => false, - fetchHead: async () => { fetchCalls += 1; return gate.promise; }, - stageHead: async () => {}, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager); + it('deduplicates one head while retaining an alternate provider', async () => { + const gate = deferred(); + const peers: string[] = []; + let calls = 0; + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async (peerId) => { + peers.push(peerId); + calls += 1; + if (calls === 1) return gate.promise; + return 'applied'; + }), { maxAttempts: 2, retryBackoffMs: 0 }); + receiver.schedule(announcement(), 'peerA'); - receiver.schedule(announcement(), 'peerB'); // same head identity → in-flight dedup + receiver.schedule(announcement(), 'peerB'); expect(receiver.stats().dedupedInFlight).toBe(1); - gate.resolve(FAKE_FETCHED); + gate.resolve('not-found'); + await receiver.whenIdle(); + + expect(peers).toEqual(['peerA', 'peerB']); + expect(receiver.stats()).toMatchObject({ scheduled: 2, applied: 1, notFound: 0 }); + }); + + it('never retries an authoritative not-found peer while a viable peer can retry', async () => { + const peers: string[] = []; + let peerBAttempts = 0; + const firstAttempt = deferred(); + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async (peerId) => { + peers.push(peerId); + if (peerId === 'peerA') { + firstAttempt.resolve(); + return 'not-found'; + } + peerBAttempts += 1; + if (peerBAttempts === 1) throw new Error('peerB transient'); + return 'applied'; + }), { maxAttempts: 3, retryBackoffMs: 0 }); + + receiver.schedule(announcement(), 'peerA'); + await firstAttempt.promise; + receiver.schedule(announcement(), 'peerB'); await receiver.whenIdle(); - expect(fetchCalls).toBe(1); - expect(receiver.stats()).toMatchObject({ scheduled: 2, dedupedInFlight: 1, staged: 1 }); - }); - - it('skips a head that is already durably staged (no fetch)', async () => { - const fetchHead = vi.fn(async () => FAKE_FETCHED); - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => true, - fetchHead, - stageHead: async () => {}, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager); + + expect(peers).toEqual(['peerA', 'peerB', 'peerB']); + expect(receiver.stats()).toMatchObject({ applied: 1, notFound: 0, failed: 0 }); + }); + + it('tries a newly retained provider even when the first provider used its only attempt', async () => { + const peers: string[] = []; + const firstAttempt = deferred(); + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async (peerId) => { + peers.push(peerId); + if (peerId === 'peerA') { + firstAttempt.resolve(); + return 'not-found'; + } + return 'applied'; + }), { maxAttempts: 1, retryBackoffMs: 0 }); receiver.schedule(announcement(), 'peerA'); + await firstAttempt.promise; + receiver.schedule(announcement(), 'peerB'); await receiver.whenIdle(); - expect(fetchHead).not.toHaveBeenCalled(); - expect(receiver.stats()).toMatchObject({ dedupedAlreadyStaged: 1, staged: 0 }); - }); - - it('records not-found without staging', async () => { - const stageHead = vi.fn(async () => {}); - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => false, - fetchHead: async () => null, - stageHead, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager); + expect(peers).toEqual(['peerA', 'peerB']); + expect(receiver.stats()).toMatchObject({ applied: 1, notFound: 0, failed: 0 }); + }); + + it('round-robins transient failures with a bounded per-provider budget', async () => { + const peers: string[] = []; + const firstAttempt = deferred(); + const onError = vi.fn(); + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async (peerId) => { + peers.push(peerId); + firstAttempt.resolve(); + throw new Error(`${peerId} transient`); + }), { maxAttempts: 2, retryBackoffMs: 0, onError }); receiver.schedule(announcement(), 'peerA'); + await firstAttempt.promise; + receiver.schedule(announcement(), 'peerB'); await receiver.whenIdle(); - expect(stageHead).not.toHaveBeenCalled(); - expect(receiver.stats()).toMatchObject({ notFound: 1, staged: 0 }); - }); - - it('drops distinct heads when the queue is full', async () => { - const gate = deferred(); - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => false, - fetchHead: async () => gate.promise, - stageHead: async () => {}, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager, { maxConcurrent: 1, maxQueue: 1 }); - receiver.schedule(headWith(`0x${'a1'.repeat(32)}`), 'peer'); // active - receiver.schedule(headWith(`0x${'a2'.repeat(32)}`), 'peer'); // queued - receiver.schedule(headWith(`0x${'a3'.repeat(32)}`), 'peer'); // dropped (queue full) + expect(peers).toEqual(['peerA', 'peerB', 'peerA', 'peerB']); + expect(onError).toHaveBeenCalledTimes(1); + expect(receiver.stats()).toMatchObject({ applied: 0, failed: 1 }); + }); + + it('retains a same-peer announcement under a rotated accepted policy', async () => { + const oldPolicy = `0x${'71'.repeat(32)}`; + const newPolicy = `0x${'72'.repeat(32)}`; + const firstResult = deferred(); + const seenPolicies: string[] = []; + let calls = 0; + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async (_peerId, head) => { + seenPolicies.push(head.policyDigest); + calls += 1; + if (calls === 1) return firstResult.promise; + return 'applied'; + }), { maxAttempts: 1, retryBackoffMs: 0 }); + + receiver.schedule(announcement({ policyDigest: oldPolicy as never }), 'peerA'); + receiver.schedule(announcement({ policyDigest: newPolicy as never }), 'peerA'); + firstResult.resolve('not-found'); + await receiver.whenIdle(); + + expect(seenPolicies).toEqual([oldPolicy, newPolicy]); + expect(receiver.stats()).toMatchObject({ applied: 1, notFound: 0, failed: 0 }); + }); + + it('caps retained providers for one exact head', async () => { + const gate = deferred(); + const receiver = new Rfc64PublicCatalogReceiverV1( + reconciler(async () => gate.promise), + { maxProvidersPerHead: 2 }, + ); + receiver.schedule(announcement(), 'peerA'); + receiver.schedule(announcement(), 'peerB'); + receiver.schedule(announcement(), 'peerC'); + expect(receiver.stats()).toMatchObject({ dedupedInFlight: 2, droppedProviders: 1 }); + gate.resolve('applied'); + await receiver.whenIdle(); + }); + + it('skips an exact head only when durable applied state says complete', async () => { + const reconcileHead = vi.fn(async () => 'applied' as const); + const receiver = new Rfc64PublicCatalogReceiverV1( + reconciler(reconcileHead, async () => true), + ); + receiver.schedule(announcement(), 'peerA'); + await receiver.whenIdle(); + expect(reconcileHead).not.toHaveBeenCalled(); + expect(receiver.stats()).toMatchObject({ dedupedAlreadyApplied: 1, applied: 0 }); + }); + + it('does not treat a not-found response as applied', async () => { + const receiver = new Rfc64PublicCatalogReceiverV1( + reconciler(async () => 'not-found'), + ); + receiver.schedule(announcement(), 'peerA'); + await receiver.whenIdle(); + expect(receiver.stats()).toMatchObject({ notFound: 1, applied: 0 }); + }); + + it('drops distinct heads when the bounded queue is full', async () => { + const gate = deferred(); + const receiver = new Rfc64PublicCatalogReceiverV1( + reconciler(async () => gate.promise), + { maxConcurrent: 1, maxQueue: 1 }, + ); + receiver.schedule(headWith(`0x${'a1'.repeat(32)}`), 'peer'); + receiver.schedule(headWith(`0x${'a2'.repeat(32)}`), 'peer'); + receiver.schedule(headWith(`0x${'a3'.repeat(32)}`), 'peer'); expect(receiver.stats().droppedQueueFull).toBe(1); - gate.resolve(null); + gate.resolve('not-found'); await receiver.whenIdle(); }); - it('retries transient fetch failures with bounded backoff then succeeds', async () => { + it('retries transient failures with bounded backoff', async () => { let attempts = 0; - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => false, - fetchHead: async () => { - attempts += 1; - if (attempts < 3) throw new Error('transient network failure'); - return FAKE_FETCHED; - }, - stageHead: async () => {}, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager, { maxAttempts: 3, retryBackoffMs: 1 }); + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async () => { + attempts += 1; + if (attempts < 3) throw new Error('transient network failure'); + return 'applied'; + }), { maxAttempts: 3, retryBackoffMs: 1 }); receiver.schedule(announcement(), 'peerA'); await receiver.whenIdle(); expect(attempts).toBe(3); - expect(receiver.stats()).toMatchObject({ staged: 1, failed: 0 }); + expect(receiver.stats()).toMatchObject({ applied: 1, failed: 0 }); }); - it('gives up after maxAttempts and reports failure', async () => { + it('reports failure after maxAttempts', async () => { const onError = vi.fn(); - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => false, - fetchHead: async () => { throw new Error('down'); }, - stageHead: async () => {}, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager, { maxAttempts: 2, retryBackoffMs: 1, onError }); + const receiver = new Rfc64PublicCatalogReceiverV1( + reconciler(async () => { throw new Error('down'); }), + { maxAttempts: 2, retryBackoffMs: 1, onError }, + ); receiver.schedule(announcement(), 'peerA'); await receiver.whenIdle(); expect(onError).toHaveBeenCalledTimes(1); - expect(receiver.stats()).toMatchObject({ failed: 1, staged: 0 }); - }); - - it('close() awaits in-flight stage writes and rejects new work', async () => { - const stageGate = deferred(); - let stageDone = false; - const stager: Rfc64PublicCatalogReceiverStagerV1 = { - isHeadStaged: async () => false, - fetchHead: async () => FAKE_FETCHED, - stageHead: async () => { await stageGate.promise; stageDone = true; }, - }; - const receiver = new Rfc64PublicCatalogReceiverV1(stager); + expect(receiver.stats()).toMatchObject({ failed: 1, applied: 0 }); + }); + + it('serializes different heads in one catalog scope', async () => { + const firstGate = deferred(); + let active = 0; + let maxActive = 0; + let calls = 0; + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + calls += 1; + if (calls === 1) await firstGate.promise; + active -= 1; + return 'applied'; + }), { maxConcurrent: 4 }); + receiver.schedule(headWith(`0x${'a1'.repeat(32)}`), 'peerA'); + receiver.schedule(headWith(`0x${'a2'.repeat(32)}`), 'peerB'); + await Promise.resolve(); + expect(calls).toBe(1); + firstGate.resolve('applied'); + await receiver.whenIdle(); + expect(calls).toBe(2); + expect(maxActive).toBe(1); + }); + + it('allows independent catalog scopes to use the bounded pool concurrently', async () => { + const gate = deferred(); + let active = 0; + let maxActive = 0; + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await gate.promise; + active -= 1; + return 'applied'; + }), { maxConcurrent: 2 }); receiver.schedule(announcement(), 'peerA'); - // Let the task reach the stage await. + receiver.schedule(announcement({ authorAddress: '0x3333333333333333333333333333333333333333' as never }), 'peerB'); await Promise.resolve(); await Promise.resolve(); - const closing = receiver.close(); - let closed = false; - void closing.then(() => { closed = true; }); + expect(maxActive).toBe(2); + gate.resolve('applied'); + await receiver.whenIdle(); + }); + + it('close awaits in-flight reconciliation, passes an abort signal, and rejects new work', async () => { + const reconcileGate = deferred(); + let observedSignal: AbortSignal | undefined; + const receiver = new Rfc64PublicCatalogReceiverV1(reconciler(async (_peer, _head, signal) => { + observedSignal = signal; + return reconcileGate.promise; + })); + receiver.schedule(announcement(), 'peerA'); await Promise.resolve(); - expect(closed).toBe(false); // close is blocked on the in-flight stage write - stageGate.resolve(); + const closing = receiver.close(); + expect(observedSignal?.aborted).toBe(true); + reconcileGate.resolve('applied'); await closing; - expect(stageDone).toBe(true); - // Post-close schedules are dropped. - receiver.schedule(announcement({ catalogHeadObjectDigest: `0x${'cc'.repeat(32)}` as never }), 'peerA'); + receiver.schedule(headWith(`0x${'cc'.repeat(32)}`), 'peerA'); expect(receiver.stats().scheduled).toBe(1); }); }); From 406e52e78a6ae1b8dc2c5d992cab10efbec05bca Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:24:46 +0200 Subject: [PATCH 113/292] fix(agent): serialize RFC-64 native activation --- .../public-catalog-native-receiver-v1.ts | 206 +++++++++++++++++- ...c-catalog-native-gate1.integration.test.ts | 138 ++++++++++-- 2 files changed, 323 insertions(+), 21 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 2ec3d5c56a..bdc1ff0658 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -7,7 +7,8 @@ * head, one bucket, one row, root context-graph lane, and one complete bundle. * Every network hop is RFC-64 catalog-native. Activation happens only after * signed head/path/bucket verification, transfer verification, canonical - * projection verification, one atomic SWM graph replace, and exact post-read. + * projection verification, one atomic projection-plus-seal replace, exact + * post-read, and a durable applied-head compare-and-swap. */ import { @@ -21,6 +22,7 @@ import { assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, buildAuthorAttestationTypedData, canonicalizeAuthorCatalogBucketPayloadBytesV1, + computeAuthorCatalogScopeDigestV1, computeControlSignatureVariantDigestHex, contextGraphWorkspaceGraphUri, deriveAuthorCatalogScopeFromHeadV1, @@ -40,6 +42,7 @@ import { type SignedAuthorCatalogHeadEnvelopeV1, type VerifiedCatalogSealBindingSnapshotV1, } from '@origintrail-official/dkg-core'; +import { sha256 } from '@noble/hashes/sha2.js'; import { quadsToNQuads, readExactGraphPaged, @@ -51,6 +54,10 @@ import { ethers } from 'ethers'; import { parseNQuads } from '../dkg-agent-utils.js'; import { unpackKnowledgeAssetId } from '../ka-identity.js'; import type { Rfc64ControlObjectOperationsV1 } from './control-object-store-v1.js'; +import type { + AppliedCatalogHeadSnapshotV1, + Rfc64InventoryV1OperationsV1, +} from './inventory-v1/index.js'; import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, @@ -63,18 +70,25 @@ import type { } from './public-catalog-transport-v1.js'; const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); +const UTF8_ENCODER = new TextEncoder(); +const APPLIED_INVENTORY_DIGEST_DOMAIN_V1 = 'dkg-rfc64-applied-inventory-v1\n'; export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { readonly headTransport: Rfc64PublicCatalogTransportV1; readonly contentTransport: Rfc64PublicCatalogNativeTransportV1; readonly controlObjects: Pick; + readonly inventory: Pick< + Rfc64InventoryV1OperationsV1, + 'readAppliedCatalogHeadV1' | 'compareAndSwapAppliedCatalogHeadV1' + >; readonly store: TripleStore; readonly transportTimeoutMs?: number; } export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { - /** Exact signed successor head digest: the complete one-row inventory commitment. */ + /** Digest computed from the exact semantic projection+seal post-read. */ readonly inventoryDigest: Digest32V1; + readonly catalogHeadDigest: Digest32V1; readonly catalogRowDigest: Digest32V1; readonly contentDigest: Digest32V1; readonly bundleDigest: Digest32V1; @@ -82,6 +96,7 @@ export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { readonly inventoryRowCount: 1; readonly activatedTripleCount: number; readonly swmGraph: string; + readonly appliedHeadStatus: 'applied' | 'existing'; } export type Rfc64PublicCatalogNativeReceiverErrorCodeV1 = @@ -90,7 +105,8 @@ export type Rfc64PublicCatalogNativeReceiverErrorCodeV1 = | 'catalog-native-receiver-slice' | 'catalog-native-receiver-catalog' | 'catalog-native-receiver-transfer' - | 'catalog-native-receiver-activation'; + | 'catalog-native-receiver-activation' + | 'catalog-native-receiver-history'; export class Rfc64PublicCatalogNativeReceiverErrorV1 extends Error { constructor( @@ -105,12 +121,15 @@ export class Rfc64PublicCatalogNativeReceiverErrorV1 extends Error { export class Rfc64PublicCatalogNativeReceiverV1 { readonly #timeoutMs: number; + readonly #scopeSynchronizations = new Map>(); constructor(private readonly options: Rfc64PublicCatalogNativeReceiverOptionsV1) { if ( typeof options?.headTransport?.fetchCatalogHead !== 'function' || typeof options?.contentTransport?.fetchCatalogObject !== 'function' || typeof options.controlObjects?.stageVerifiedObjects !== 'function' + || typeof options.inventory?.readAppliedCatalogHeadV1 !== 'function' + || typeof options.inventory?.compareAndSwapAppliedCatalogHeadV1 !== 'function' || typeof options.store?.query !== 'function' ) { fail('catalog-native-receiver-input', 'receiver dependencies are incomplete'); @@ -126,6 +145,17 @@ export class Rfc64PublicCatalogNativeReceiverV1 { remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, deployment: CatalogSealDeploymentProfileV1, + ): Promise { + return this.withScopeSerialization( + catalogScopeLockKey(announcement), + () => this.synchronizeOnePublicOpenRowSerialized(remotePeerId, announcement, deployment), + ); + } + + private async synchronizeOnePublicOpenRowSerialized( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + deployment: CatalogSealDeploymentProfileV1, ): Promise { const fetchedHead = await this.options.headTransport.fetchCatalogHead( remotePeerId, @@ -137,6 +167,14 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } const head = fetchedHead.envelope; assertFirstSliceHead(head); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1( + deriveAuthorCatalogScopeFromHeadV1(head.payload), + ); + const currentAppliedHead = this.options.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + head.payload.authorAddress, + ); + const replay = assertMonotonicSuccessorHistory(currentAppliedHead, head); const scope = nativeScope(announcement, head); const fetchedDirectory = await this.options.contentTransport.fetchCatalogObject( @@ -277,7 +315,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { fail('catalog-native-receiver-catalog', 'verified catalog objects could not be staged', cause); } - const swmGraph = await activateExactPublicProjection( + const activation = await activateExactPublicProjection( this.options.store, head, row, @@ -287,17 +325,115 @@ export class Rfc64PublicCatalogNativeReceiverV1 { sealBinding, ); + let appliedHeadStatus: 'applied' | 'existing'; + if (replay) { + if (currentAppliedHead!.appliedInventoryDigest !== activation.inventoryDigest) { + fail( + 'catalog-native-receiver-history', + 'durable applied-head digest differs from exact semantic post-read', + ); + } + appliedHeadStatus = 'existing'; + } else { + try { + appliedHeadStatus = this.options.inventory.compareAndSwapAppliedCatalogHeadV1({ + catalogScopeDigest, + authorAddress: head.payload.authorAddress, + expectedCurrentCatalogHeadDigest: head.payload.previousHeadDigest, + currentCatalogHeadDigest: head.objectDigest as Digest32V1, + appliedInventoryDigest: activation.inventoryDigest, + catalogVersion: head.payload.version, + inventoryRowCount: '1' as never, + }).status; + } catch (cause) { + const reconciled = this.options.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + head.payload.authorAddress, + ); + if ( + reconciled?.currentCatalogHeadDigest !== head.objectDigest + || reconciled.appliedInventoryDigest !== activation.inventoryDigest + ) { + fail( + 'catalog-native-receiver-history', + 'applied-head CAS lost outside the serialized receiver; semantic state requires repair', + cause, + ); + } + appliedHeadStatus = 'existing'; + } + } + return Object.freeze({ - inventoryDigest: head.objectDigest as Digest32V1, + inventoryDigest: activation.inventoryDigest, + catalogHeadDigest: head.objectDigest as Digest32V1, catalogRowDigest: projectionMetadata.catalogRowDigest, contentDigest: projectionMetadata.projectionDigest, bundleDigest: row.transfer.blobDigest, kaUal: projectionMetadata.kaUal, inventoryRowCount: 1 as const, activatedTripleCount: Number(projectionMetadata.publicTripleCount), - swmGraph, + swmGraph: activation.swmGraph, + appliedHeadStatus, }); } + + private async withScopeSerialization( + key: string, + operation: () => Promise, + ): Promise { + const previous = this.#scopeSynchronizations.get(key) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const tail = previous.catch(() => undefined).then(() => gate); + this.#scopeSynchronizations.set(key, tail); + await previous.catch(() => undefined); + try { + return await operation(); + } finally { + release(); + if (this.#scopeSynchronizations.get(key) === tail) { + this.#scopeSynchronizations.delete(key); + } + } + } +} + +function catalogScopeLockKey(announcement: Rfc64PublicCatalogHeadAnnouncementV1): string { + return JSON.stringify([ + announcement.networkId, + announcement.contextGraphId, + announcement.subGraphName, + announcement.authorAddress, + ]); +} + +function assertMonotonicSuccessorHistory( + current: AppliedCatalogHeadSnapshotV1 | null, + head: SignedAuthorCatalogHeadEnvelopeV1, +): boolean { + if (current === null) { + fail( + 'catalog-native-receiver-history', + 'successor requires a durable initialized predecessor head', + ); + } + if (current.currentCatalogHeadDigest === head.objectDigest) { + if (current.catalogVersion !== head.payload.version || current.inventoryRowCount !== '1') { + fail('catalog-native-receiver-history', 'replayed head differs from its durable applied state'); + } + return true; + } + if ( + current.currentCatalogHeadDigest !== head.payload.previousHeadDigest + || BigInt(current.catalogVersion) + 1n !== BigInt(head.payload.version) + ) { + fail( + 'catalog-native-receiver-history', + 'successor does not monotonically extend the durable current head', + ); + } + return false; } function assertFirstSliceHead(head: SignedAuthorCatalogHeadEnvelopeV1): void { @@ -340,7 +476,7 @@ async function activateExactPublicProjection( projectionBytes: Uint8Array, expectedTripleCount: number, sealBinding: VerifiedCatalogSealBindingSnapshotV1, -): Promise { +): Promise<{ swmGraph: string; inventoryDigest: Digest32V1 }> { let projectionText: string; let quads; try { @@ -404,7 +540,61 @@ async function activateExactPublicProjection( fail('catalog-native-receiver-activation', 'exact SWM post-read differs from verified projection'); } await assertExactAuthorSealPostRead(store, sealBinding); - return swmGraph; + return { + swmGraph, + inventoryDigest: computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: sealBinding.catalogScopeDigest, + rows: [{ + catalogRowDigest: sealBinding.catalogRowDigest, + contentDigest: row.projectionDigest, + sealDigest: sealBinding.sealDigest, + kaUal, + activatedTripleCount: expectedTripleCount, + }], + }), + }; +} + +export interface Rfc64AppliedInventoryDigestRowV1 { + readonly catalogRowDigest: Digest32V1; + readonly contentDigest: Digest32V1; + readonly sealDigest: Digest32V1; + readonly kaUal: string; + readonly activatedTripleCount: number; +} + +/** Compute an applied inventory commitment from exact semantic post-read evidence. */ +export function computeRfc64AppliedInventoryDigestV1(input: { + readonly catalogScopeDigest: Digest32V1; + readonly rows: readonly Rfc64AppliedInventoryDigestRowV1[]; +}): Digest32V1 { + const hasher = sha256.create(); + hasher.update(UTF8_ENCODER.encode(APPLIED_INVENTORY_DIGEST_DOMAIN_V1)); + hasher.update(ethers.getBytes(input.catalogScopeDigest)); + hasher.update(encodeU64(input.rows.length, 'inventory row count')); + for (const row of [...input.rows].sort((left, right) => left.kaUal.localeCompare(right.kaUal))) { + hasher.update(ethers.getBytes(row.catalogRowDigest)); + hasher.update(ethers.getBytes(row.contentDigest)); + hasher.update(ethers.getBytes(row.sealDigest)); + const ual = UTF8_ENCODER.encode(row.kaUal); + hasher.update(encodeU64(ual.byteLength, 'KA UAL byte length')); + hasher.update(ual); + hasher.update(encodeU64(row.activatedTripleCount, 'activated triple count')); + } + return ethers.hexlify(hasher.digest()) as Digest32V1; +} + +function encodeU64(value: number, label: string): Uint8Array { + if (!Number.isSafeInteger(value) || value < 0) { + fail('catalog-native-receiver-activation', `${label} is not a safe unsigned integer`); + } + const result = new Uint8Array(8); + let remaining = BigInt(value); + for (let index = result.length - 1; index >= 0; index -= 1) { + result[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return result; } /** diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 3ed90f18e3..5dcf4cc1f5 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -36,6 +36,7 @@ import { produceSparseAuthorCatalogSuccessorV1, } from '../src/rfc64/author-catalog-producer.js'; import { + computeRfc64AppliedInventoryDigestV1, Rfc64PublicCatalogNativeReceiverV1, } from '../src/rfc64/public-catalog-native-receiver-v1.js'; import { @@ -134,7 +135,19 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { fixture.rowBundle.row, ); expect(evidence).toEqual({ - inventoryDigest: fixture.successor.head.objectDigest, + inventoryDigest: computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1( + deriveAuthorCatalogScopeFromHeadV1(fixture.successor.head.payload), + ), + rows: [{ + catalogRowDigest: expectedRowDigest, + contentDigest: fixture.rowBundle.row.projectionDigest, + sealDigest: fixture.rowBundle.row.sealDigest, + kaUal: UAL, + activatedTripleCount: 2, + }], + }), + catalogHeadDigest: fixture.successor.head.objectDigest, catalogRowDigest: expectedRowDigest, contentDigest: fixture.rowBundle.row.projectionDigest, bundleDigest: fixture.rowBundle.row.transfer.blobDigest, @@ -142,7 +155,9 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { inventoryRowCount: 1, activatedTripleCount: 2, swmGraph: `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${KA_NUMBER}`, + appliedHeadStatus: 'applied', }); + expect(evidence.inventoryDigest).not.toBe(evidence.catalogHeadDigest); const activated = await fixture.receiverStore.query( `SELECT ?s ?p ?o WHERE { GRAPH <${evidence.swmGraph}> { ?s ?p ?o } } ORDER BY ?s ?p ?o`, @@ -191,6 +206,52 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { }); await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); }, 30_000); + + it('serializes one scope so a competing successor never activates over the winner', async () => { + const fixture = await setupLiveReceiver(); + const winner = fixture.synchronize(); + const loser = fixture.synchronize(fixture.competingAnnouncement); + + await expect(winner).resolves.toMatchObject({ appliedHeadStatus: 'applied' }); + await expect(loser).rejects.toMatchObject({ code: 'catalog-native-receiver-history' }); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + }, 30_000); + + it('repairs the semantic-before-CAS crash gap idempotently on a new receiver instance', async () => { + const fixture = await setupLiveReceiver(); + const crashGapReceiver = fixture.createReceiver({ + readAppliedCatalogHeadV1: + fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1.bind( + fixture.receiverPersistence.inventory, + ), + compareAndSwapAppliedCatalogHeadV1: () => { + throw new Error('simulated crash after semantic post-read and before applied-head CAS'); + }, + }); + await expect(fixture.synchronize(fixture.announcement, crashGapReceiver)).rejects.toMatchObject({ + code: 'catalog-native-receiver-history', + }); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + + const repaired = await fixture.synchronize( + fixture.announcement, + fixture.createReceiver(fixture.receiverPersistence.inventory), + ); + expect(repaired.appliedHeadStatus).toBe('applied'); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + }, 30_000); }); async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { @@ -224,6 +285,19 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900000000' as never, signer, }); + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR, + expectedCurrentCatalogHeadDigest: null, + currentCatalogHeadDigest: genesis.head.objectDigest as Digest32V1, + appliedInventoryDigest: computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: scopeDigest, + rows: [], + }), + catalogVersion: genesis.head.payload.version, + inventoryRowCount: '0' as never, + }); const successor = await produceSparseAuthorCatalogSuccessorV1({ previousHead: genesis.head, previousDirectoryPath: genesis.directoryPath, @@ -233,22 +307,39 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900001000' as never, signer, }); + const competingSuccessor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as never, + nextRows: [rowBundle.row], + issuedAt: '1773900001001' as never, + signer, + }); const authorObjects = new Map( - successor.stagedObjects.map((envelope) => [envelope.objectDigest, envelope]), + [...successor.stagedObjects, ...competingSuccessor.stagedObjects] + .map((envelope) => [envelope.objectDigest, envelope]), ); const authorObjectRead = vi.fn(async (digest: Digest32V1) => authorObjects.get(digest) ?? null); const authorBundleRead = vi.fn(async (digest: Digest32V1) => digest === rowBundle.row.transfer.blobDigest ? rowBundle.bundleBytes : null); const verifiedObjects = await Promise.all( - [...genesis.stagedObjects, ...successor.stagedObjects].map(async (envelope) => ({ + [...genesis.stagedObjects, ...successor.stagedObjects, ...competingSuccessor.stagedObjects] + .map(async (envelope) => ({ envelope, issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), - })), + })), ); const staged = await authorPersistence.controlObjects.stageVerifiedObjects(verifiedObjects); - const headKeys = staged.objects.at(-1); + const headKeys = staged.objects.find( + (keys) => keys.objectDigest === successor.head.objectDigest, + ); + const competingHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === competingSuccessor.head.objectDigest, + ); if (headKeys === undefined) throw new Error('successor head was not staged'); + if (competingHeadKeys === undefined) throw new Error('competing successor head was not staged'); const receivedAnnouncements: Rfc64PublicCatalogHeadAnnouncementV1[] = []; const openPolicy = async () => Object.freeze({ accessPolicy: 0 as const, @@ -310,23 +401,44 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { catalogHeadObjectDigest: headKeys.objectDigest, signatureVariantDigest: headKeys.signatureVariantDigest, }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const competingAnnouncement = Object.freeze({ + ...announcement, + catalogHeadObjectDigest: competingHeadKeys.objectDigest, + signatureVariantDigest: competingHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; await authorHeadTransport.announceCatalogHead(receiverNode.peerId, announcement); expect(receivedAnnouncements).toEqual([announcement]); - const receiver = new Rfc64PublicCatalogNativeReceiverV1({ - headTransport: receiverHeadTransport, - contentTransport: receiverNativeTransport, - controlObjects: receiverPersistence.controlObjects, - store: receiverStore, - }); + const createReceiver = ( + inventory: Pick< + Rfc64PersistenceV1['inventory'], + 'readAppliedCatalogHeadV1' | 'compareAndSwapAppliedCatalogHeadV1' + >, + ) => new Rfc64PublicCatalogNativeReceiverV1({ + headTransport: receiverHeadTransport, + contentTransport: receiverNativeTransport, + controlObjects: receiverPersistence.controlObjects, + inventory, + store: receiverStore, + }); + const receiver = createReceiver(receiverPersistence.inventory); return { + announcement, authorBundleRead, authorObjectRead, + competingAnnouncement, + createReceiver, + genesis, + receiverPersistence, receiverStore, rowBundle, + scopeDigest, successor, - synchronize: () => receiver.synchronizeOnePublicOpenRow( + synchronize: ( + selectedAnnouncement = announcement, + selectedReceiver = receiver, + ) => selectedReceiver.synchronizeOnePublicOpenRow( authorNode.peerId, - receivedAnnouncements[0], + selectedAnnouncement, DEPLOYMENT, ), }; From b60b07f28ef1cbc7cc4eae74ed6f1a1c186afe37 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:19:57 +0200 Subject: [PATCH 114/292] feat(agent): persist RFC-64 applied catalog heads --- .../agent/src/rfc64/inventory-v1/candidate.ts | 309 ++++++++++++++++++ .../agent/src/rfc64/inventory-v1/index.ts | 3 + packages/agent/src/rfc64/inventory-v1/open.ts | 98 +++++- packages/agent/src/rfc64/inventory-v1/sql.ts | 55 +++- .../src/rfc64/inventory-v1/statements.ts | 40 +++ .../rfc64-inventory-v1-applied-head.test.ts | 148 +++++++++ ...rfc64-inventory-v1-candidate-plans.test.ts | 14 + .../test/rfc64-inventory-v1-lifecycle.test.ts | 10 +- packages/agent/vitest.unit.config.ts | 1 + 9 files changed, 664 insertions(+), 14 deletions(-) create mode 100644 packages/agent/test/rfc64-inventory-v1-applied-head.test.ts diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index ba34467c0c..0072de5de1 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -9,6 +9,7 @@ import { MAX_AUTHOR_CATALOG_BUCKET_PAYLOAD_BYTES_V1, MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, ZERO_DIGEST32_V1, + assertCanonicalDigest, assertAssertionCoordinateV1, assertAuthorCatalogBucketScopeBindingV1, assertAuthorCatalogBucketV1, @@ -152,6 +153,26 @@ export interface CandidateSessionGcBatchResultV1 { readonly done: boolean; } +export interface AppliedCatalogHeadSnapshotV1 { + readonly catalogScopeDigest: Digest32V1; + readonly authorAddress: EvmAddressV1; + readonly currentCatalogHeadDigest: Digest32V1; + readonly appliedInventoryDigest: Digest32V1; + readonly catalogVersion: DecimalU64V1; + readonly inventoryRowCount: CountV1; +} + +export interface CompareAndSwapAppliedCatalogHeadInputV1 + extends AppliedCatalogHeadSnapshotV1 { + /** `null` initializes a scope; otherwise the exact current head must match. */ + readonly expectedCurrentCatalogHeadDigest: Digest32V1 | null; +} + +export interface AppliedCatalogHeadCasResultV1 { + readonly status: 'applied' | 'existing'; + readonly snapshot: AppliedCatalogHeadSnapshotV1; +} + /** * Precommit evidence for one exact live candidate row. Both fields are opaque, * process-local capabilities. This container proves neither catalog-head authority @@ -182,6 +203,9 @@ export type InventoryV1CandidateErrorCode = | 'candidate-cursor-mismatch' | 'candidate-stream-complete' | 'candidate-in-use' + | 'applied-head-input' + | 'applied-head-cas-conflict' + | 'applied-head-database-corrupt' | 'candidate-database-corrupt' | 'latency-budget-exceeded' | 'candidate-database-error'; @@ -242,6 +266,13 @@ export interface Rfc64InventoryV1CandidateApi { ): void; discardCandidateSessionBatch(session: CandidateSessionV1): CandidateSessionGcBatchResultV1; deleteCandidateBucket(loadKey: CandidateBucketLoadKeyV1): void; + readAppliedCatalogHeadV1( + catalogScopeDigest: Digest32V1, + authorAddress: EvmAddressV1, + ): AppliedCatalogHeadSnapshotV1 | null; + compareAndSwapAppliedCatalogHeadV1( + input: CompareAndSwapAppliedCatalogHeadInputV1, + ): AppliedCatalogHeadCasResultV1; } /** Candidate operations safe to share without inventory lifecycle ownership. */ @@ -260,6 +291,8 @@ export type Rfc64InventoryV1OperationsV1 = Pick< | 'closeCandidateTraversal' | 'discardCandidateSessionBatch' | 'deleteCandidateBucket' + | 'readAppliedCatalogHeadV1' + | 'compareAndSwapAppliedCatalogHeadV1' >; /** @@ -304,9 +337,23 @@ export function createRfc64InventoryOperationsViewV1( inventory.discardCandidateSessionBatch.bind(inventory), ), deleteCandidateBucket: fence(inventory.deleteCandidateBucket.bind(inventory)), + readAppliedCatalogHeadV1: fence(inventory.readAppliedCatalogHeadV1.bind(inventory)), + compareAndSwapAppliedCatalogHeadV1: fence( + inventory.compareAndSwapAppliedCatalogHeadV1.bind(inventory), + ), }); } +interface EncodedAppliedCatalogHeadV1 { + readonly scope: Uint8Array; + readonly author: Uint8Array; + readonly currentHead: Uint8Array; + readonly inventoryDigest: Uint8Array; + readonly catalogVersion: Uint8Array; + readonly inventoryRowCount: Uint8Array; + readonly publicSnapshot: AppliedCatalogHeadSnapshotV1; +} + interface SessionContextV1 { readonly scopeHex: string; readonly authorHex: string; @@ -513,6 +560,48 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { return handle; } + readAppliedCatalogHeadV1( + catalogScopeDigest: Digest32V1, + authorAddress: EvmAddressV1, + ): AppliedCatalogHeadSnapshotV1 | null { + this.assertOpen(); + const key = encodeAppliedHeadKey(catalogScopeDigest, authorAddress); + return this.readTransaction(() => this.readAppliedHead(key)); + } + + compareAndSwapAppliedCatalogHeadV1( + input: CompareAndSwapAppliedCatalogHeadInputV1, + ): AppliedCatalogHeadCasResultV1 { + this.assertOpen(); + const next = encodeAppliedHead(input); + const expected = input.expectedCurrentCatalogHeadDigest === null + ? null + : encodeAppliedDigest(input.expectedCurrentCatalogHeadDigest, 'expected current head'); + if (expected !== null && sqlBlobsEqualV1(expected, next.currentHead)) { + throw new InventoryV1CandidateError( + 'applied-head-input', + 'applied-head CAS must advance to a different catalog head', + ); + } + try { + return this.writeTransaction( + 'compare-and-swap applied catalog head', + () => this.applyAppliedHeadCas(next, expected), + { + resolve: () => this.resolveAppliedHeadCas(next, expected), + retry: () => this.applyAppliedHeadCas(next, expected), + resolvedCommittedResult: () => Object.freeze({ + status: 'applied' as const, + snapshot: next.publicSnapshot, + }), + }, + ); + } catch (cause) { + if (cause instanceof InventoryV1CandidateError) throw cause; + throw databaseError('failed to compare-and-swap applied catalog head', cause); + } + } + putVerifiedCandidateBucket(load: VerifiedCandidateBucketLoadV1): CandidateBucketPutResultV1 { this.assertOpen(); // A poisoned session is terminal. Inspect only the opaque local capability @@ -1367,6 +1456,107 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { .get({ ...keyParameters(key) }) as SqlRowV1 | undefined); } + private readAppliedHead( + key: Pick, + ): AppliedCatalogHeadSnapshotV1 | null { + const query = this.prepare(INVENTORY_V1_STATEMENT_SQL.getAppliedHead); + const row = this.statement(() => query.get({ + scope: key.scope, + author: key.author, + }) as SqlRowV1 | undefined); + if (row === undefined) return null; + try { + return Object.freeze({ + catalogScopeDigest: sqlBlobToDigest32V1(key.scope), + authorAddress: sqlBlobToEvmAddressV1(key.author), + currentCatalogHeadDigest: sqlBlobToDigest32V1(row.current_catalog_head_digest), + appliedInventoryDigest: sqlBlobToDigest32V1(row.applied_inventory_digest), + catalogVersion: sqlBlobToDecimalU64V1(row.catalog_version_u64be), + inventoryRowCount: sqlBlobToDecimalU64V1(row.inventory_row_count_u64be) as CountV1, + }); + } catch (cause) { + throw new InventoryV1CandidateError( + 'applied-head-database-corrupt', + 'stored applied-head row is not canonical', + { cause }, + ); + } + } + + private applyAppliedHeadCas( + next: EncodedAppliedCatalogHeadV1, + expected: Uint8Array | null, + ): AppliedCatalogHeadCasResultV1 { + const current = this.readAppliedHead(next); + if (current !== null && appliedHeadSnapshotEquals(current, next.publicSnapshot)) { + return Object.freeze({ status: 'existing' as const, snapshot: current }); + } + if (expected === null) { + if (current !== null) throw appliedHeadCasConflict(current.currentCatalogHeadDigest, null); + const insert = this.prepare(INVENTORY_V1_STATEMENT_SQL.insertAppliedHead); + const result = this.statement(() => insert.run(appliedHeadParameters(next))); + if (Number(result.changes) !== 1) { + throw new InventoryV1CandidateError( + 'applied-head-database-corrupt', + 'applied-head initialization did not insert exactly one row', + ); + } + } else { + if ( + current === null + || !sqlBlobsEqualV1( + digest32ToSqlBlobV1(current.currentCatalogHeadDigest), + expected, + ) + ) { + throw appliedHeadCasConflict(current?.currentCatalogHeadDigest ?? null, inputDigest(expected)); + } + const update = this.prepare(INVENTORY_V1_STATEMENT_SQL.updateAppliedHeadCas); + const result = this.statement(() => update.run({ + ...appliedHeadParameters(next), + expectedHead: expected, + })); + if (Number(result.changes) !== 1) { + throw appliedHeadCasConflict(current.currentCatalogHeadDigest, inputDigest(expected)); + } + } + const committed = this.readAppliedHead(next); + if (committed === null || !appliedHeadSnapshotEquals(committed, next.publicSnapshot)) { + throw new InventoryV1CandidateError( + 'applied-head-database-corrupt', + 'applied-head write did not exact-read as the requested next state', + ); + } + return Object.freeze({ status: 'applied' as const, snapshot: committed }); + } + + private resolveAppliedHeadCas( + next: EncodedAppliedCatalogHeadV1, + expected: Uint8Array | null, + ): IndeterminateCommitResolutionV1 { + const current = this.readAppliedHead(next); + if (current !== null && appliedHeadSnapshotEquals(current, next.publicSnapshot)) { + return 'committed'; + } + if ( + (expected === null && current === null) + || ( + expected !== null + && current !== null + && sqlBlobsEqualV1( + digest32ToSqlBlobV1(current.currentCatalogHeadDigest), + expected, + ) + ) + ) { + return 'not-committed'; + } + throw appliedHeadCasConflict( + current?.currentCatalogHeadDigest ?? null, + expected === null ? null : inputDigest(expected), + ); + } + private captureDeleteTarget(row: SqlRowV1): DeleteTargetV1 { const key: EncodedLoadKeyV1 = { session: assertSqlKeyBlob(row.session_id, 32, 'selected session_id'), @@ -1898,6 +2088,125 @@ function canonicalU64(value: unknown, label: string): DecimalU64V1 { } } +function encodeAppliedHeadKey( + catalogScopeDigest: unknown, + authorAddress: unknown, +): Pick { + try { + assertCanonicalDigest(catalogScopeDigest, 'catalogScopeDigest'); + return { + scope: digest32ToSqlBlobV1(catalogScopeDigest), + author: evmAddressToSqlBlobV1(authorAddress as EvmAddressV1), + }; + } catch (cause) { + throw new InventoryV1CandidateError( + 'applied-head-input', + 'applied-head key is not canonical', + { cause }, + ); + } +} + +function encodeAppliedDigest(value: unknown, label: string): Uint8Array { + try { + assertCanonicalDigest(value, label); + return digest32ToSqlBlobV1(value); + } catch (cause) { + throw new InventoryV1CandidateError( + 'applied-head-input', + `${label} is not canonical`, + { cause }, + ); + } +} + +function encodeAppliedHead(input: unknown): EncodedAppliedCatalogHeadV1 { + if (typeof input !== 'object' || input === null) { + throw new InventoryV1CandidateError('applied-head-input', 'applied-head CAS input is invalid'); + } + const candidate = input as CompareAndSwapAppliedCatalogHeadInputV1; + const key = encodeAppliedHeadKey(candidate.catalogScopeDigest, candidate.authorAddress); + let catalogVersion: DecimalU64V1; + let inventoryRowCount: DecimalU64V1; + try { + catalogVersion = parseCanonicalDecimalU64( + candidate.catalogVersion, + 'catalogVersion', + ).toString() as DecimalU64V1; + inventoryRowCount = parseCanonicalDecimalU64( + candidate.inventoryRowCount, + 'inventoryRowCount', + ).toString() as DecimalU64V1; + } catch (cause) { + throw new InventoryV1CandidateError( + 'applied-head-input', + 'applied-head version or row count is not canonical', + { cause }, + ); + } + const currentHead = encodeAppliedDigest( + candidate.currentCatalogHeadDigest, + 'currentCatalogHeadDigest', + ); + const inventoryDigest = encodeAppliedDigest( + candidate.appliedInventoryDigest, + 'appliedInventoryDigest', + ); + const publicSnapshot = Object.freeze({ + catalogScopeDigest: sqlBlobToDigest32V1(key.scope), + authorAddress: sqlBlobToEvmAddressV1(key.author), + currentCatalogHeadDigest: sqlBlobToDigest32V1(currentHead), + appliedInventoryDigest: sqlBlobToDigest32V1(inventoryDigest), + catalogVersion, + inventoryRowCount: inventoryRowCount as CountV1, + }); + return { + ...key, + currentHead, + inventoryDigest, + catalogVersion: decimalU64ToSqlBlobV1(catalogVersion), + inventoryRowCount: decimalU64ToSqlBlobV1(inventoryRowCount), + publicSnapshot, + }; +} + +function appliedHeadParameters(head: EncodedAppliedCatalogHeadV1): SqlParametersV1 { + return { + scope: head.scope, + author: head.author, + nextHead: head.currentHead, + inventoryDigest: head.inventoryDigest, + catalogVersion: head.catalogVersion, + inventoryRowCount: head.inventoryRowCount, + }; +} + +function appliedHeadSnapshotEquals( + left: AppliedCatalogHeadSnapshotV1, + right: AppliedCatalogHeadSnapshotV1, +): boolean { + return left.catalogScopeDigest === right.catalogScopeDigest + && left.authorAddress === right.authorAddress + && left.currentCatalogHeadDigest === right.currentCatalogHeadDigest + && left.appliedInventoryDigest === right.appliedInventoryDigest + && left.catalogVersion === right.catalogVersion + && left.inventoryRowCount === right.inventoryRowCount; +} + +function inputDigest(bytes: Uint8Array): Digest32V1 { + return sqlBlobToDigest32V1(assertSqlBlobWidthV1(bytes, 32, 'expected current head')); +} + +function appliedHeadCasConflict( + actual: Digest32V1 | null, + expected: Digest32V1 | null, +): InventoryV1CandidateError { + return new InventoryV1CandidateError( + 'applied-head-cas-conflict', + `applied-head CAS expected ${expected ?? 'no current head'} but found ${actual ?? 'none'}`, + ); +} + function exactPlainRecord( value: unknown, expectedKeys: readonly string[], diff --git a/packages/agent/src/rfc64/inventory-v1/index.ts b/packages/agent/src/rfc64/inventory-v1/index.ts index 92d82b8395..ea9a378dae 100644 --- a/packages/agent/src/rfc64/inventory-v1/index.ts +++ b/packages/agent/src/rfc64/inventory-v1/index.ts @@ -4,6 +4,8 @@ export { createRfc64InventoryOperationsViewV1, InventoryV1CandidateError, + type AppliedCatalogHeadCasResultV1, + type AppliedCatalogHeadSnapshotV1, type CandidateCatalogPrecommitResultV1, type CandidateBucketDiffTraversalV1, type CandidateBucketHeaderV1, @@ -15,6 +17,7 @@ export { type CandidateBucketRowsTraversalV1, type CandidateSessionGcBatchResultV1, type CandidateSessionV1, + type CompareAndSwapAppliedCatalogHeadInputV1, type InventoryV1CandidateErrorCode, type Rfc64InventoryV1CandidateApi, type Rfc64InventoryV1OperationsV1, diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index f753aa11ef..4f6a833540 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -42,12 +42,17 @@ import { INVENTORY_V1_DDL, INVENTORY_V1_DIRECTORY_MODE, INVENTORY_V1_FILE_MODE, + INVENTORY_V1_LEGACY_USER_OBJECTS, + INVENTORY_V1_LEGACY_USER_VERSION, + INVENTORY_V1_MIGRATE_V1_TO_V2_SQL, INVENTORY_V1_USER_OBJECTS, INVENTORY_V1_USER_VERSION, normalizeInventoryV1SchemaSql, } from './sql.js'; import { CandidateInventoryV1, + type AppliedCatalogHeadCasResultV1, + type AppliedCatalogHeadSnapshotV1, type CandidateCatalogPrecommitResultV1, type CandidateBucketDiffTraversalV1, type CandidateBucketHeaderV1, @@ -58,6 +63,7 @@ import { type CandidateBucketRowsTraversalV1, type CandidateSessionV1, type CandidateSessionGcBatchResultV1, + type CompareAndSwapAppliedCatalogHeadInputV1, type Rfc64InventoryV1CandidateApi, type VerifiedCandidateBucketLoadV1, type VerifiedCandidateCatalogRowV1, @@ -65,6 +71,8 @@ import { import type { CatalogSealDeploymentProfileV1, CgSharedProjectionVerificationLimitsV1, + Digest32V1, + EvmAddressV1, KaIdV1, SignedAuthorCatalogHeadEnvelopeV1, } from '@origintrail-official/dkg-core'; @@ -456,6 +464,21 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { this.#candidate.deleteCandidateBucket(loadKey); } + readAppliedCatalogHeadV1( + catalogScopeDigest: Digest32V1, + authorAddress: EvmAddressV1, + ): AppliedCatalogHeadSnapshotV1 | null { + this.requireOpen(); + return this.#candidate.readAppliedCatalogHeadV1(catalogScopeDigest, authorAddress); + } + + compareAndSwapAppliedCatalogHeadV1( + input: CompareAndSwapAppliedCatalogHeadInputV1, + ): AppliedCatalogHeadCasResultV1 { + this.requireOpen(); + return this.#candidate.compareAndSwapAppliedCatalogHeadV1(input); + } + private requireOpen(): DatabaseSyncV1 { if (this.#database === null) { throw new InventoryV1OpenError('database-closed', 'inventory database is closed'); @@ -909,6 +932,9 @@ function openOrRebuildOwnedDatabase( const identity = readIdentity(database); assertCommittedTargetIdentity(identity); try { + if (identity.userVersion === INVENTORY_V1_LEGACY_USER_VERSION) { + migrateInventoryV1ToV2(database, databasePath); + } verifyOwnedSchema(database); } catch (error) { if (!(error instanceof OwnedInventoryV1SchemaError)) throw error; @@ -1050,13 +1076,16 @@ function assertExistingTargetHeader(databasePath: string): void { if (identity.userVersion > INVENTORY_V1_USER_VERSION) { throw new InventoryV1OpenError( 'newer-schema', - `inventory user_version ${identity.userVersion} is newer than supported version 1`, + `inventory user_version ${identity.userVersion} is newer than supported version ${INVENTORY_V1_USER_VERSION}`, ); } - if (identity.userVersion !== INVENTORY_V1_USER_VERSION) { + if ( + identity.userVersion !== INVENTORY_V1_LEGACY_USER_VERSION + && identity.userVersion !== INVENTORY_V1_USER_VERSION + ) { throw new InventoryV1OpenError( 'ambiguous-database', - `inventory application_id is DK64 but user_version ${identity.userVersion} is not committed v1`, + `inventory application_id is DK64 but user_version ${identity.userVersion} is unsupported`, ); } } @@ -1064,7 +1093,10 @@ function assertExistingTargetHeader(databasePath: string): void { function assertCommittedTargetIdentity(identity: DatabaseIdentityV1): void { if ( identity.applicationId !== INVENTORY_V1_APPLICATION_ID - || identity.userVersion !== INVENTORY_V1_USER_VERSION + || ( + identity.userVersion !== INVENTORY_V1_LEGACY_USER_VERSION + && identity.userVersion !== INVENTORY_V1_USER_VERSION + ) ) { throw new InventoryV1OpenError( 'ambiguous-database', @@ -1096,16 +1128,68 @@ function isFreshIdentity(identity: DatabaseIdentityV1): boolean { return identity.applicationId === 0 && identity.userVersion === 0; } -function schemaMatches(objects: DatabaseIdentityV1['userObjects']): boolean { - if (objects.length !== Object.keys(INVENTORY_V1_USER_OBJECTS).length) return false; +function schemaMatches( + objects: DatabaseIdentityV1['userObjects'], + expectedObjects: Readonly> = INVENTORY_V1_USER_OBJECTS, +): boolean { + if (objects.length !== Object.keys(expectedObjects).length) return false; return objects.every((object) => { - const expected = INVENTORY_V1_USER_OBJECTS[object.name]; + const expected = expectedObjects[object.name]; return expected !== undefined && object.sql !== null && normalizeInventoryV1SchemaSql(object.sql) === expected; }); } +function migrateInventoryV1ToV2(database: DatabaseSyncV1, databasePath: string): void { + const identity = readIdentity(database); + if ( + identity.applicationId !== INVENTORY_V1_APPLICATION_ID + || identity.userVersion !== INVENTORY_V1_LEGACY_USER_VERSION + || !schemaMatches(identity.userObjects, INVENTORY_V1_LEGACY_USER_OBJECTS) + ) { + throw new OwnedInventoryV1SchemaError( + 'RFC-64 inventory v1 schema is not eligible for the v2 migration', + ); + } + let transactionOpen = false; + try { + database.exec('BEGIN IMMEDIATE'); + transactionOpen = true; + database.exec(INVENTORY_V1_MIGRATE_V1_TO_V2_SQL); + database.exec('COMMIT'); + transactionOpen = false; + } catch (cause) { + if (transactionOpen) { + try { database.exec('ROLLBACK'); } catch { /* retain migration failure */ } + } + throw new InventoryV1OpenError( + 'database-io', + 'failed to migrate RFC-64 inventory schema from v1 to v2', + { cause }, + ); + } + const checkpoint = database.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get(); + if (checkpoint?.busy !== 0) { + throw new InventoryV1OpenError( + checkpoint?.busy === 1 ? 'database-busy' : 'database-io', + 'RFC-64 inventory v2 migration could not checkpoint its committed schema', + ); + } + fsyncRegularFile(databasePath, 'migrated inventory database'); + fsyncDirectory(dirname(databasePath)); + const migrated = readIdentity(database); + if ( + migrated.userVersion !== INVENTORY_V1_USER_VERSION + || !schemaMatches(migrated.userObjects) + ) { + throw new InventoryV1OpenError( + 'database-io', + 'RFC-64 inventory v2 migration did not commit its exact schema', + ); + } +} + function initializeFreshDatabase(database: DatabaseSyncV1, databasePath: string): void { const journalMode = database.prepare('PRAGMA journal_mode = DELETE').get(); database.exec(` diff --git a/packages/agent/src/rfc64/inventory-v1/sql.ts b/packages/agent/src/rfc64/inventory-v1/sql.ts index 0d0dbfbd17..0e2bf2994f 100644 --- a/packages/agent/src/rfc64/inventory-v1/sql.ts +++ b/packages/agent/src/rfc64/inventory-v1/sql.ts @@ -8,7 +8,8 @@ import { } from '../persistence-layout-v1.js'; export const INVENTORY_V1_APPLICATION_ID = 0x444b3634; -export const INVENTORY_V1_USER_VERSION = 1; +export const INVENTORY_V1_LEGACY_USER_VERSION = 1; +export const INVENTORY_V1_USER_VERSION = 2; export const INVENTORY_V1_RELATIVE_PATH = `${RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1}/${RFC64_INVENTORY_DATABASE_FILENAME_V1}`; export const INVENTORY_V1_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; @@ -287,16 +288,64 @@ CREATE TABLE rfc64_candidate_bucket_rows_v1 ( ON DELETE CASCADE ) WITHOUT ROWID, STRICT`; -export const INVENTORY_V1_DDL = [ +/** + * One durable current/applied ref per exact author-catalog scope. The row is + * advanced only through a head-digest compare-and-swap after semantic commit. + */ +export const INVENTORY_V1_APPLIED_HEADS_TABLE_SQL = ` +CREATE TABLE rfc64_applied_catalog_heads_v1 ( + catalog_scope_digest BLOB NOT NULL CHECK ( + typeof(catalog_scope_digest) = 'blob' AND length(catalog_scope_digest) = 32 + ), + author_address BLOB NOT NULL CHECK ( + typeof(author_address) = 'blob' AND length(author_address) = 20 + AND author_address <> zeroblob(20) + ), + current_catalog_head_digest BLOB NOT NULL CHECK ( + typeof(current_catalog_head_digest) = 'blob' + AND length(current_catalog_head_digest) = 32 + ), + applied_inventory_digest BLOB NOT NULL CHECK ( + typeof(applied_inventory_digest) = 'blob' + AND length(applied_inventory_digest) = 32 + ), + catalog_version_u64be BLOB NOT NULL CHECK ( + typeof(catalog_version_u64be) = 'blob' + AND length(catalog_version_u64be) = 8 + ), + inventory_row_count_u64be BLOB NOT NULL CHECK ( + typeof(inventory_row_count_u64be) = 'blob' + AND length(inventory_row_count_u64be) = 8 + ), + PRIMARY KEY (catalog_scope_digest, author_address) +) WITHOUT ROWID, STRICT`; + +export const INVENTORY_V1_LEGACY_DDL = [ INVENTORY_V1_LOADS_TABLE_SQL, INVENTORY_V1_ROWS_TABLE_SQL, ].join(';\n\n').concat(';'); -export const INVENTORY_V1_USER_OBJECTS: Readonly> = Object.freeze({ +export const INVENTORY_V1_DDL = [ + INVENTORY_V1_LEGACY_DDL, + INVENTORY_V1_APPLIED_HEADS_TABLE_SQL, +].join(';\n\n').concat(';'); + +export const INVENTORY_V1_LEGACY_USER_OBJECTS: Readonly> = Object.freeze({ rfc64_candidate_bucket_loads_v1: normalizeInventoryV1SchemaSql(INVENTORY_V1_LOADS_TABLE_SQL), rfc64_candidate_bucket_rows_v1: normalizeInventoryV1SchemaSql(INVENTORY_V1_ROWS_TABLE_SQL), }); +export const INVENTORY_V1_USER_OBJECTS: Readonly> = Object.freeze({ + ...INVENTORY_V1_LEGACY_USER_OBJECTS, + rfc64_applied_catalog_heads_v1: normalizeInventoryV1SchemaSql( + INVENTORY_V1_APPLIED_HEADS_TABLE_SQL, + ), +}); + +export const INVENTORY_V1_MIGRATE_V1_TO_V2_SQL = ` +${INVENTORY_V1_APPLIED_HEADS_TABLE_SQL}; +PRAGMA user_version = ${INVENTORY_V1_USER_VERSION};`; + export function normalizeInventoryV1SchemaSql(sql: string): string { return sql.replace(/\s+/g, ' ').trim().replace(/;$/, ''); } diff --git a/packages/agent/src/rfc64/inventory-v1/statements.ts b/packages/agent/src/rfc64/inventory-v1/statements.ts index 48dbe93710..9ed9acdc3e 100644 --- a/packages/agent/src/rfc64/inventory-v1/statements.ts +++ b/packages/agent/src/rfc64/inventory-v1/statements.ts @@ -231,6 +231,40 @@ WHERE session_id = :session AND author_address = :author AND target_catalog_head_digest = :head AND bucket_id_u64be = :bucket;`, + + getAppliedHead: ` +SELECT current_catalog_head_digest, applied_inventory_digest, + catalog_version_u64be, inventory_row_count_u64be +FROM rfc64_applied_catalog_heads_v1 +WHERE catalog_scope_digest = :scope + AND author_address = :author;`, + + insertAppliedHead: ` +INSERT INTO rfc64_applied_catalog_heads_v1 ( + catalog_scope_digest, + author_address, + current_catalog_head_digest, + applied_inventory_digest, + catalog_version_u64be, + inventory_row_count_u64be +) VALUES ( + :scope, + :author, + :nextHead, + :inventoryDigest, + :catalogVersion, + :inventoryRowCount +);`, + + updateAppliedHeadCas: ` +UPDATE rfc64_applied_catalog_heads_v1 +SET current_catalog_head_digest = :nextHead, + applied_inventory_digest = :inventoryDigest, + catalog_version_u64be = :catalogVersion, + inventory_row_count_u64be = :inventoryRowCount +WHERE catalog_scope_digest = :scope + AND author_address = :author + AND current_catalog_head_digest = :expectedHead;`, }); export type InventoryV1StatementKey = keyof typeof INVENTORY_V1_STATEMENT_SQL; @@ -251,6 +285,9 @@ export const INVENTORY_V1_STATEMENT_IDS = Object.freeze({ diffRemovedNext: 'rfc64.candidate-bucket.diff-removed.next.v1', countBucketRows: 'rfc64.candidate-bucket.rows.count.v1', deleteHeader: 'rfc64.candidate-bucket.delete.v1', + getAppliedHead: 'rfc64.applied-head.get.v1', + insertAppliedHead: 'rfc64.applied-head.insert.v1', + updateAppliedHeadCas: 'rfc64.applied-head.cas-update.v1', } as const satisfies Readonly>); export type InventoryV1StatementId = @@ -268,9 +305,12 @@ export const INVENTORY_V1_PERSISTENT_READ_STATEMENT_KEYS = Object.freeze([ 'diffRemovedFirst', 'diffRemovedNext', 'countBucketRows', + 'getAppliedHead', ] as const satisfies readonly InventoryV1StatementKey[]); export const INVENTORY_V1_PLAN_STATEMENT_KEYS = Object.freeze([ ...INVENTORY_V1_PERSISTENT_READ_STATEMENT_KEYS, 'deleteHeader', + 'insertAppliedHead', + 'updateAppliedHeadCas', ] as const satisfies readonly InventoryV1StatementKey[]); diff --git a/packages/agent/test/rfc64-inventory-v1-applied-head.test.ts b/packages/agent/test/rfc64-inventory-v1-applied-head.test.ts new file mode 100644 index 0000000000..17887c64bb --- /dev/null +++ b/packages/agent/test/rfc64-inventory-v1-applied-head.test.ts @@ -0,0 +1,148 @@ +import { chmodSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + INVENTORY_V1_APPLICATION_ID, + INVENTORY_V1_DIRECTORY_MODE, + INVENTORY_V1_FILE_MODE, + INVENTORY_V1_LEGACY_USER_VERSION, + INVENTORY_V1_RELATIVE_PATH, + INVENTORY_V1_USER_VERSION, + openInventoryV1, + type CompareAndSwapAppliedCatalogHeadInputV1, + type Rfc64InventoryV1Foundation, +} from '../src/rfc64/inventory-v1/index.js'; + +const SCOPE = `0x${'11'.repeat(32)}` as const; +const AUTHOR = `0x${'22'.repeat(20)}` as const; +const GENESIS = `0x${'33'.repeat(32)}` as const; +const GENESIS_INVENTORY = `0x${'44'.repeat(32)}` as const; +const SUCCESSOR = `0x${'55'.repeat(32)}` as const; +const SUCCESSOR_INVENTORY = `0x${'66'.repeat(32)}` as const; +const LOSING_HEAD = `0x${'77'.repeat(32)}` as const; +const directories: string[] = []; +const foundations: Rfc64InventoryV1Foundation[] = []; + +afterEach(() => { + for (const foundation of foundations.splice(0)) foundation.close(); + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('RFC-64 SQL-1 durable applied-head CAS', () => { + it('initializes, advances exactly once, rejects a stale writer, and survives restart', async () => { + const directory = temporaryDirectory(); + let inventory = await openInventoryV1(directory); + foundations.push(inventory); + + expect(inventory.readAppliedCatalogHeadV1(SCOPE, AUTHOR)).toBeNull(); + const genesis = input(null, GENESIS, GENESIS_INVENTORY, '0', '0'); + expect(inventory.compareAndSwapAppliedCatalogHeadV1(genesis)).toEqual({ + status: 'applied', + snapshot: expectedSnapshot(GENESIS, GENESIS_INVENTORY, '0', '0'), + }); + expect(inventory.compareAndSwapAppliedCatalogHeadV1(genesis).status).toBe('existing'); + + const successor = input(GENESIS, SUCCESSOR, SUCCESSOR_INVENTORY, '1', '1'); + expect(inventory.compareAndSwapAppliedCatalogHeadV1(successor).status).toBe('applied'); + expect(() => inventory.compareAndSwapAppliedCatalogHeadV1( + input(GENESIS, LOSING_HEAD, `0x${'88'.repeat(32)}`, '1', '1'), + )).toThrowError(expect.objectContaining({ code: 'applied-head-cas-conflict' })); + expect(inventory.readAppliedCatalogHeadV1(SCOPE, AUTHOR)).toEqual( + expectedSnapshot(SUCCESSOR, SUCCESSOR_INVENTORY, '1', '1'), + ); + + inventory.close(); + foundations.splice(foundations.indexOf(inventory), 1); + inventory = await openInventoryV1(directory); + foundations.push(inventory); + expect(inventory.readAppliedCatalogHeadV1(SCOPE, AUTHOR)).toEqual( + expectedSnapshot(SUCCESSOR, SUCCESSOR_INVENTORY, '1', '1'), + ); + }); + + it('migrates the exact prior v1 schema before accepting applied-head state', async () => { + const directory = temporaryDirectory(); + const initialized = await openInventoryV1(directory); + initialized.close(); + const path = join(directory, INVENTORY_V1_RELATIVE_PATH); + const legacy = new DatabaseSync(path); + legacy.exec(` + PRAGMA journal_mode = DELETE; + DROP TABLE rfc64_applied_catalog_heads_v1; + PRAGMA user_version = ${INVENTORY_V1_LEGACY_USER_VERSION}; + `); + legacy.close(); + chmodSync(dirname(path), INVENTORY_V1_DIRECTORY_MODE); + chmodSync(path, INVENTORY_V1_FILE_MODE); + + const migrated = await openInventoryV1(directory); + foundations.push(migrated); + const database = new DatabaseSync(path, { readOnly: true }); + try { + expect(database.prepare('PRAGMA application_id').get()?.application_id) + .toBe(INVENTORY_V1_APPLICATION_ID); + expect(database.prepare('PRAGMA user_version').get()?.user_version) + .toBe(INVENTORY_V1_USER_VERSION); + expect(database.prepare( + "SELECT count(*) AS count FROM sqlite_schema WHERE name = 'rfc64_applied_catalog_heads_v1'", + ).get()?.count).toBe(1); + } finally { + database.close(); + } + expect(migrated.compareAndSwapAppliedCatalogHeadV1( + input(null, GENESIS, GENESIS_INVENTORY, '0', '0'), + ).status).toBe('applied'); + }); +}); + +function temporaryDirectory(): string { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'dkg-rfc64-applied-head-'))); + directories.push(directory); + return directory; +} + +function input( + expectedCurrentCatalogHeadDigest: CompareAndSwapAppliedCatalogHeadInputV1[ + 'expectedCurrentCatalogHeadDigest' + ], + currentCatalogHeadDigest: CompareAndSwapAppliedCatalogHeadInputV1[ + 'currentCatalogHeadDigest' + ], + appliedInventoryDigest: CompareAndSwapAppliedCatalogHeadInputV1[ + 'appliedInventoryDigest' + ], + catalogVersion: string, + inventoryRowCount: string, +): CompareAndSwapAppliedCatalogHeadInputV1 { + return { + catalogScopeDigest: SCOPE, + authorAddress: AUTHOR, + expectedCurrentCatalogHeadDigest, + currentCatalogHeadDigest, + appliedInventoryDigest, + catalogVersion: catalogVersion as never, + inventoryRowCount: inventoryRowCount as never, + }; +} + +function expectedSnapshot( + currentCatalogHeadDigest: string, + appliedInventoryDigest: string, + catalogVersion: string, + inventoryRowCount: string, +) { + return { + catalogScopeDigest: SCOPE, + authorAddress: AUTHOR, + currentCatalogHeadDigest, + appliedInventoryDigest, + catalogVersion, + inventoryRowCount, + }; +} diff --git a/packages/agent/test/rfc64-inventory-v1-candidate-plans.test.ts b/packages/agent/test/rfc64-inventory-v1-candidate-plans.test.ts index a9ebcdd8b5..01ddc01b73 100644 --- a/packages/agent/test/rfc64-inventory-v1-candidate-plans.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-candidate-plans.test.ts @@ -219,6 +219,11 @@ function parametersFor(sql: string): Record { head: hexBytes(NEW_HEAD_HEX), oldHead: hexBytes(OLD_HEAD_HEX), newHead: hexBytes(NEW_HEAD_HEX), + nextHead: hexBytes(NEW_HEAD_HEX), + expectedHead: hexBytes(OLD_HEAD_HEX), + inventoryDigest: hexBytes('77'.repeat(32)), + catalogVersion: hexBytes('0000000000000001'), + inventoryRowCount: hexBytes('0000000000000001'), bucket: hexBytes(SELECTED_BUCKET_HEX), afterKaIdU256be: hexBytes(`${AUTHOR_HEX}${'00'.repeat(11)}01`), limit: 256, @@ -288,6 +293,15 @@ function expectPlanGate(plans: PlanClass): void { && detail.includes('bucket_id_u64be=?')), 'candidate cascade must use the bucket-first child primary key', ).toBe(true); + + expect( + plans.getAppliedHead.some((detail) => detail.includes('USING PRIMARY KEY')), + `${INVENTORY_V1_STATEMENT_IDS.getAppliedHead} must use its exact scope primary key`, + ).toBe(true); + expect( + plans.updateAppliedHeadCas.some((detail) => detail.includes('USING PRIMARY KEY')), + `${INVENTORY_V1_STATEMENT_IDS.updateAppliedHeadCas} must use its exact scope primary key`, + ).toBe(true); } function oldHeadWidePrimaryKeyDdl(): string { diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index a2f2841b99..7c6a504aae 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -28,8 +28,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { INVENTORY_V1_APPLICATION_ID, INVENTORY_V1_DDL, + INVENTORY_V1_LEGACY_DDL, INVENTORY_V1_RELATIVE_PATH, INVENTORY_V1_USER_OBJECTS, + INVENTORY_V1_USER_VERSION, INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, InventoryV1OpenError, normalizeInventoryV1SchemaSql, @@ -427,7 +429,7 @@ function assertInitializedInventory(path: string): void { const database = new DatabaseSync(path, { readOnly: true }); try { expect(pragmaInteger(database, 'application_id')).toBe(INVENTORY_V1_APPLICATION_ID); - expect(pragmaInteger(database, 'user_version')).toBe(1); + expect(pragmaInteger(database, 'user_version')).toBe(INVENTORY_V1_USER_VERSION); const rows = database.prepare( `SELECT name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name`, ).all(); @@ -1131,7 +1133,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc newer.exec(` CREATE TABLE future_schema (value TEXT); PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; - PRAGMA user_version = 2; + PRAGMA user_version = ${INVENTORY_V1_USER_VERSION + 1}; `); newer.close(); @@ -1140,7 +1142,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc expectNoQuarantine(path); }); - it('quarantines an incompatible owned v1 schema and rebuilds exact v1', async () => { + it('quarantines an incompatible owned v1 schema and rebuilds exact current schema', async () => { const dataDirectory = temporaryDataDirectory(); const path = databasePath(dataDirectory); mkdirSync(dirname(path), { recursive: true }); @@ -1446,7 +1448,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc newer.exec(` CREATE TABLE future_schema (value TEXT); PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; - PRAGMA user_version = 2; + PRAGMA user_version = ${INVENTORY_V1_USER_VERSION + 1}; `); newer.close(); truncateSync(path, 100); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index e4af40fde3..1c3513503d 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -108,6 +108,7 @@ export default defineConfig({ "test/rfc64-inventory-v1-scalars.test.ts", "test/rfc64-inventory-v1-lifecycle.test.ts", "test/rfc64-inventory-v1-candidates.test.ts", + "test/rfc64-inventory-v1-applied-head.test.ts", "test/rfc64-inventory-v1-candidate-plans.test.ts", "test/rfc64-inventory-v1-candidate-latency.test.ts", "test/rfc64-inventory-v1-candidate-faults.test.ts", From 63ca51fb91fc82482a415c1bb7017a72ff16e9b0 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:43:07 +0200 Subject: [PATCH 115/292] test(devnet): freeze RFC-64 Gate 1 evidence contract --- devnet/rfc64-gate1-public-open/.gitignore | 1 + devnet/rfc64-gate1-public-open/README.md | 55 +++ .../adapter-process.ts | 241 ++++++++++ devnet/rfc64-gate1-public-open/model.test.ts | 34 ++ devnet/rfc64-gate1-public-open/model.ts | 198 ++++++++ devnet/rfc64-gate1-public-open/package.json | 6 + devnet/rfc64-gate1-public-open/run.ts | 423 ++++++++++++++++++ devnet/rfc64-gate1-public-open/tsconfig.json | 20 + .../rfc64-gate1-public-open/verifier.test.ts | 311 +++++++++++++ devnet/rfc64-gate1-public-open/verifier.ts | 420 +++++++++++++++++ devnet/rfc64-gate1-public-open/verify.ts | 28 ++ package.json | 5 + pnpm-lock.yaml | 2 + pnpm-workspace.yaml | 1 + 14 files changed, 1745 insertions(+) create mode 100644 devnet/rfc64-gate1-public-open/.gitignore create mode 100644 devnet/rfc64-gate1-public-open/README.md create mode 100644 devnet/rfc64-gate1-public-open/adapter-process.ts create mode 100644 devnet/rfc64-gate1-public-open/model.test.ts create mode 100644 devnet/rfc64-gate1-public-open/model.ts create mode 100644 devnet/rfc64-gate1-public-open/package.json create mode 100644 devnet/rfc64-gate1-public-open/run.ts create mode 100644 devnet/rfc64-gate1-public-open/tsconfig.json create mode 100644 devnet/rfc64-gate1-public-open/verifier.test.ts create mode 100644 devnet/rfc64-gate1-public-open/verifier.ts create mode 100644 devnet/rfc64-gate1-public-open/verify.ts diff --git a/devnet/rfc64-gate1-public-open/.gitignore b/devnet/rfc64-gate1-public-open/.gitignore new file mode 100644 index 0000000000..d4f588edfe --- /dev/null +++ b/devnet/rfc64-gate1-public-open/.gitignore @@ -0,0 +1 @@ +artifacts/ diff --git a/devnet/rfc64-gate1-public-open/README.md b/devnet/rfc64-gate1-public-open/README.md new file mode 100644 index 0000000000..4aace07f36 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/README.md @@ -0,0 +1,55 @@ +# OT-RFC-64 Gate 1 public/open harness contract + +This bounded harness freezes the deterministic evidence contract and process +orchestration needed for the first RFC-64 public/open one-row synchronization. +It runs an author adapter process and a receiver adapter process concurrently, +then kills and restarts the receiver against the same durable directory. + +The artifact proves that the harness and verifier require: + +- distinct author and receiver peer identities; +- exact successor head, catalog-row, bundle, and public-content digests; +- exact KA UAL, inventory row count, SWM graph, and activated quad count; +- an exact durable applied-head readback after positive activation; +- a forged author attestation failure with zero activation and no applied head; +- a durable repair intent followed by `SIGKILL`, receiver restart, repair of the + applied-head gap, and exact semantic plus applied-state post-read. + +The current adapter is deliberately identified as +`deterministic-fixture-adapter-v1` and the raw artifact has +`gateEvaluation.status = "not-evaluated"`. It does **not** claim a production +Gate 1 pass. Combined commit `1f9119ac8` does not yet contain the successor +producer from `6c14bd4ad15b79cc889d0308dd1d1cac60467747` or the serialized +durable applied-head behavior from +`ebbfb34f9bd0a0833ee5adb925cba67c527c91a8`. Once those APIs are assembled, +replace the adapter commands with production `DKGAgent` calls while preserving +the closed evidence schema and verifier. + +The closed adapter boundary requires exactly six production operations, without +depending on current service internals: `publishGenesis`, `publishSuccessor`, +`announce`, `appliedHeadReadback`, `exactInventoryReadback`, and `killRestart`. + +Run the deterministic process exercise and separate fail-closed verifier: + +```sh +pnpm test:gate1:rfc64-public-open-harness +``` + +Run only the schema/model mutation tests or strict typecheck: + +```sh +pnpm test:gate1:rfc64-public-open-harness:unit +pnpm typecheck:gate1:rfc64-public-open-harness +``` + +The raw and verdict artifacts are written atomically as owner-only stable JSON: + +```text +devnet/rfc64-gate1-public-open/artifacts/gate1-result.json +devnet/rfc64-gate1-public-open/artifacts/gate1-verdict.json +``` + +They contain no timestamp, PID, duration, temporary path, or random identifier. +The generator pins a clean tracked repository `HEAD` before spawning and after +all processes exit; the verifier independently pins the same commit. Artifact +bytes therefore remain identical across runs on the same tested commit. diff --git a/devnet/rfc64-gate1-public-open/adapter-process.ts b/devnet/rfc64-gate1-public-open/adapter-process.ts new file mode 100644 index 0000000000..f1dcc76cd6 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/adapter-process.ts @@ -0,0 +1,241 @@ +import { readFileSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; +import { createInterface } from 'node:readline'; + +import { + atomicWriteStableJson, + stableJson, +} from '../rfc64-persistence-lifecycle/evidence.js'; +import { + GATE1_ADAPTER_PROTOCOL_VERSION, + GATE1_FIXTURE, + GATE1_FIXTURE_ADAPTER_ID, + assertFixtureDerivations, + expectedAppliedReadBack, + type Gate1TransferFixture, +} from './model.js'; + +const EVENT_PREFIX = 'RFC64_GATE1_ADAPTER_EVENT '; +const role = process.argv[2]; +const dataDirInput = process.env.DKG_RFC64_GATE1_ADAPTER_DATA_DIR; +if (role !== 'author' && role !== 'receiver') throw new Error('adapter role is required'); +if (!dataDirInput) throw new Error('DKG_RFC64_GATE1_ADAPTER_DATA_DIR is required'); +const dataDir = resolve(dataDirInput); +const semanticPath = join(dataDir, 'semantic-state.json'); +const appliedPath = join(dataDir, 'applied-head.json'); +const repairIntentPath = join(dataDir, 'repair-intent.json'); + +interface Command { + readonly command: string; + readonly requestId: string; + readonly fixture?: unknown; +} + +function emit(event: Record): void { + process.stdout.write(`${EVENT_PREFIX}${JSON.stringify({ role, ...event })}\n`); +} + +async function emitAndFlush(event: Record): Promise { + await new Promise((resolveWrite, rejectWrite) => { + process.stdout.write( + `${EVENT_PREFIX}${JSON.stringify({ role, ...event })}\n`, + (error) => error === null || error === undefined ? resolveWrite() : rejectWrite(error), + ); + }); +} + +function exactFixture(input: unknown, expected: Gate1TransferFixture, label: string): void { + if (stableJson(input) !== stableJson(expected)) { + throw new Error(`${label} differs from the adapter-pinned exact fixture`); + } +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')) as unknown; +} + +function writeState(path: string, value: unknown): void { + atomicWriteStableJson(path, value); +} + +async function repairAtStartup(): Promise | null> { + if (role !== 'receiver') return null; + let intent: unknown; + try { + intent = readJson(repairIntentPath); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return null; + throw error; + } + const expectedIntent = { + durable: true, + target: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), + }; + if (stableJson(intent) !== stableJson(expectedIntent)) { + throw new Error('restart repair intent differs from the pinned successor'); + } + const semantic = readJson(semanticPath); + const expectedSemantic = semanticState(GATE1_FIXTURE.repairSuccessor); + if (stableJson(semantic) !== stableJson(expectedSemantic)) { + throw new Error('restart repair semantic state differs from the durable intent'); + } + const before = readJson(appliedPath); + const expectedBefore = expectedAppliedReadBack(GATE1_FIXTURE.positive); + if (stableJson(before) !== stableJson(expectedBefore)) { + throw new Error('restart repair predecessor applied head is not exact'); + } + const after = expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor); + writeState(appliedPath, after); + await rm(repairIntentPath, { force: true }); + return { + action: 'advanced-applied-head-from-durable-intent', + after: readJson(appliedPath), + before, + repaired: true, + semanticPostRead: semantic, + }; +} + +function semanticState(fixture: Gate1TransferFixture): Readonly> { + return Object.freeze({ + activatedQuadCount: fixture.activatedQuadCount, + catalogHeadDigest: fixture.head.catalogHeadDigest, + catalogRowDigest: fixture.catalogRowDigest, + contentDigest: fixture.contentDigest, + kaUal: fixture.kaUal, + swmGraph: fixture.swmGraph, + }); +} + +async function handle(command: Command): Promise { + const { requestId } = command; + if (typeof requestId !== 'string' || requestId.length === 0) { + throw new Error('requestId is required'); + } + switch (command.command) { + case 'serve-positive': + requireRole('author'); + emit({ event: 'positive-served', fixture: GATE1_FIXTURE.positive, requestId }); + return; + case 'serve-repair-successor': + requireRole('author'); + emit({ event: 'repair-successor-served', fixture: GATE1_FIXTURE.repairSuccessor, requestId }); + return; + case 'serve-forged': + requireRole('author'); + emit({ event: 'forged-served', fixture: GATE1_FIXTURE.forged, requestId }); + return; + case 'attempt-forged': { + requireRole('receiver'); + if (stableJson(command.fixture) !== stableJson(GATE1_FIXTURE.forged)) { + throw new Error('forged attempt differs from the adapter-pinned fixture'); + } + emit({ + activationAfter: 0, + activationBefore: 0, + appliedHeadAfter: null, + appliedHeadBefore: null, + attemptedCatalogHeadDigest: GATE1_FIXTURE.forged.attemptedCatalogHeadDigest, + event: 'forged-author-rejected', + failureCode: GATE1_FIXTURE.forged.expectedFailureCode, + recoveredAuthorAddress: GATE1_FIXTURE.forged.recoveredAuthorAddress, + requestId, + }); + return; + } + case 'activate-positive': { + requireRole('receiver'); + exactFixture(command.fixture, GATE1_FIXTURE.positive, 'positive transfer'); + const semantic = semanticState(GATE1_FIXTURE.positive); + const applied = expectedAppliedReadBack(GATE1_FIXTURE.positive); + writeState(semanticPath, semantic); + writeState(appliedPath, applied); + emit({ + appliedReadBack: readJson(appliedPath), + controlObjectsVerified: 3, + event: 'positive-activated', + exact: GATE1_FIXTURE.positive, + requestId, + semanticPostRead: readJson(semanticPath), + }); + return; + } + case 'prepare-repair-crash': { + requireRole('receiver'); + exactFixture(command.fixture, GATE1_FIXTURE.repairSuccessor, 'repair successor'); + const before = readJson(appliedPath); + const expectedBefore = expectedAppliedReadBack(GATE1_FIXTURE.positive); + if (stableJson(before) !== stableJson(expectedBefore)) { + throw new Error('repair predecessor does not equal the positive durable applied head'); + } + writeState(semanticPath, semanticState(GATE1_FIXTURE.repairSuccessor)); + writeState(repairIntentPath, { + durable: true, + target: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), + }); + emit({ + appliedBeforeCrash: before, + event: 'repair-gap-durable', + repairIntentDurable: true, + requestId, + semanticBeforeCrash: readJson(semanticPath), + target: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), + }); + return; + } + case 'read-repaired': { + requireRole('receiver'); + emit({ + appliedReadBack: readJson(appliedPath), + event: 'repair-read-back', + requestId, + semanticPostRead: readJson(semanticPath), + }); + return; + } + case 'stop': + await emitAndFlush({ event: 'stopped', requestId }); + process.exit(0); + default: + throw new Error(`unknown adapter command: ${command.command}`); + } +} + +function requireRole(expected: 'author' | 'receiver'): void { + if (role !== expected) throw new Error(`${role} cannot handle ${expected} command`); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +assertFixtureDerivations(); +await mkdir(dataDir, { recursive: true, mode: 0o700 }); +const startupRepair = await repairAtStartup(); +emit({ + adapterId: GATE1_FIXTURE_ADAPTER_ID, + event: 'ready', + peerId: role === 'author' ? GATE1_FIXTURE.authorPeerId : GATE1_FIXTURE.receiverPeerId, + protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, + startupRepair, +}); + +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); +lines.on('line', (line) => { + let command: Command; + try { + command = JSON.parse(line) as Command; + } catch (error) { + emit({ event: 'error', message: `invalid command JSON: ${String(error)}` }); + return; + } + void handle(command).catch((error) => { + emit({ + event: 'error', + message: error instanceof Error ? error.message : String(error), + requestId: command.requestId, + }); + }); +}); diff --git a/devnet/rfc64-gate1-public-open/model.test.ts b/devnet/rfc64-gate1-public-open/model.test.ts new file mode 100644 index 0000000000..a414156efc --- /dev/null +++ b/devnet/rfc64-gate1-public-open/model.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + GATE1_FIXTURE, + assertFixtureDerivations, + expectedAppliedReadBack, +} from './model.js'; + +test('pinned Gate 1 fixture digests derive from exact deterministic bytes', () => { + assert.doesNotThrow(() => assertFixtureDerivations()); + assert.equal( + GATE1_FIXTURE.projectionNQuads.trimEnd().split('\n').length, + GATE1_FIXTURE.positive.activatedQuadCount, + ); + assert.equal( + Buffer.byteLength(GATE1_FIXTURE.projectionNQuads), + GATE1_FIXTURE.positive.contentByteLength, + ); +}); +test('durable applied readback is derived from the exact head and count', () => { + assert.deepEqual(expectedAppliedReadBack(GATE1_FIXTURE.positive), { + appliedInventoryDigest: GATE1_FIXTURE.positive.head.appliedInventoryDigest, + catalogVersion: '1', + currentCatalogHeadDigest: GATE1_FIXTURE.positive.head.catalogHeadDigest, + inventoryRowCount: 1, + }); + assert.deepEqual(expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), { + appliedInventoryDigest: GATE1_FIXTURE.repairSuccessor.head.appliedInventoryDigest, + catalogVersion: '2', + currentCatalogHeadDigest: GATE1_FIXTURE.repairSuccessor.head.catalogHeadDigest, + inventoryRowCount: 1, + }); +}); diff --git a/devnet/rfc64-gate1-public-open/model.ts b/devnet/rfc64-gate1-public-open/model.ts new file mode 100644 index 0000000000..3442648af9 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/model.ts @@ -0,0 +1,198 @@ +import { createHash } from 'node:crypto'; + +export const GATE1_RAW_SCHEMA_VERSION = 'dkg-rfc64-gate1-public-open-evidence-v1'; +export const GATE1_VERDICT_SCHEMA_VERSION = 'dkg-rfc64-gate1-public-open-verdict-v1'; +export const GATE1_ADAPTER_PROTOCOL_VERSION = 'dkg-rfc64-gate1-adapter-protocol-v1'; +export const GATE1_FIXTURE_ADAPTER_ID = 'deterministic-fixture-adapter-v1'; +export const REQUIRED_PRODUCTION_ADAPTER_OPERATIONS = Object.freeze([ + 'publishGenesis', + 'publishSuccessor', + 'announce', + 'appliedHeadReadback', + 'exactInventoryReadback', + 'killRestart', +] as const); + +export const INSPECTED_PRODUCT_COMMITS = Object.freeze([ + '6c14bd4ad15b79cc889d0308dd1d1cac60467747', + 'ebbfb34f9bd0a0833ee5adb925cba67c527c91a8', +] as const); + +const AUTHOR_ADDRESS = '0xdb2430b4e9ac14be6554d3942822be74811a1af9'; +const ATTACKER_ADDRESS = '0xae72a48c1a36bd18af168541c53037965d26e4a8'; +const CONTENT_DIGEST = '0xb9621d6cd997ab772d2efc3aa2afa2bcdacc46c74359bfb282c058fa46bb431a'; +const BUNDLE_DIGEST = '0x14d4274f8eddb559e4a66ea245f697aba41a4263ea049b77e0b3c88adc936c7a'; +const CATALOG_ROW_DIGEST = '0x6420e45a533a22aebf4628a123cfed9ee11f033c239af875c07aa05231547638'; +const PREDECESSOR_HEAD_DIGEST = + '0x2ab4b76154241174ba4dc08b4ca38094b6f996518662707bdec599f8de87be71'; +const POSITIVE_HEAD_DIGEST = + '0xe761f8c9ce23a199803881221a8bac6ded18a2f366e0f22a7599adfe76eb8838'; +const POSITIVE_INVENTORY_DIGEST = + '0x0a3f2f203019eb21778e270436da1ea01d70b5c2fce0ed811679cd2ffe82c980'; +const REPAIR_HEAD_DIGEST = + '0x26423bc0d28c8d49b5f7c59bf3e8563c23b55c31ba28d3aa745bc1a885de4761'; +const REPAIR_INVENTORY_DIGEST = + '0xd256bbc0577d7c0bb7f6cb4a2e4d29ff552cacee08f0c5ca0e073d0111e60fc2'; +const FORGED_HEAD_DIGEST = + '0xa0b122ac45e5ddeda48ca2aa54e1c02279af5156d93b63b0120d76a815bee2a7'; + +export interface Gate1HeadFixture { + readonly appliedInventoryDigest: string; + readonly catalogHeadDigest: string; + readonly catalogVersion: string; + readonly previousCatalogHeadDigest: string; +} + +export interface Gate1TransferFixture { + readonly activatedQuadCount: number; + readonly authorAddress: string; + readonly bundleByteLength: number; + readonly bundleDigest: string; + readonly catalogRowDigest: string; + readonly contentByteLength: number; + readonly contentDigest: string; + readonly head: Gate1HeadFixture; + readonly inventoryRowCount: number; + readonly kaUal: string; + readonly swmGraph: string; +} + +export interface Gate1ForgedFixture { + readonly attemptedCatalogHeadDigest: string; + readonly catalogAuthorAddress: string; + readonly expectedFailureCode: string; + readonly recoveredAuthorAddress: string; +} + +export const GATE1_FIXTURE = Object.freeze({ + authorPeerId: 'fixture-peer-author-v1', + receiverPeerId: 'fixture-peer-receiver-v1', + projectionNQuads: + ' "42"^^ .\n' + + ' "Alice" .\n', + positive: Object.freeze({ + activatedQuadCount: 2, + authorAddress: AUTHOR_ADDRESS, + bundleByteLength: 203, + bundleDigest: BUNDLE_DIGEST, + catalogRowDigest: CATALOG_ROW_DIGEST, + contentByteLength: 168, + contentDigest: CONTENT_DIGEST, + head: Object.freeze({ + appliedInventoryDigest: POSITIVE_INVENTORY_DIGEST, + catalogHeadDigest: POSITIVE_HEAD_DIGEST, + catalogVersion: '1', + previousCatalogHeadDigest: PREDECESSOR_HEAD_DIGEST, + }), + inventoryRowCount: 1, + kaUal: `did:dkg:otp:20430/${AUTHOR_ADDRESS}/7`, + swmGraph: `did:dkg:swm:0x1111111111111111111111111111111111111111/gate-1/${AUTHOR_ADDRESS}/7`, + }) satisfies Gate1TransferFixture, + repairSuccessor: Object.freeze({ + activatedQuadCount: 2, + authorAddress: AUTHOR_ADDRESS, + bundleByteLength: 203, + bundleDigest: BUNDLE_DIGEST, + catalogRowDigest: CATALOG_ROW_DIGEST, + contentByteLength: 168, + contentDigest: CONTENT_DIGEST, + head: Object.freeze({ + appliedInventoryDigest: REPAIR_INVENTORY_DIGEST, + catalogHeadDigest: REPAIR_HEAD_DIGEST, + catalogVersion: '2', + previousCatalogHeadDigest: POSITIVE_HEAD_DIGEST, + }), + inventoryRowCount: 1, + kaUal: `did:dkg:otp:20430/${AUTHOR_ADDRESS}/7`, + swmGraph: `did:dkg:swm:0x1111111111111111111111111111111111111111/gate-1/${AUTHOR_ADDRESS}/7`, + }) satisfies Gate1TransferFixture, + forged: Object.freeze({ + attemptedCatalogHeadDigest: FORGED_HEAD_DIGEST, + catalogAuthorAddress: AUTHOR_ADDRESS, + expectedFailureCode: 'catalog-native-receiver-transfer', + recoveredAuthorAddress: ATTACKER_ADDRESS, + }) satisfies Gate1ForgedFixture, +}); + +export function expectedAppliedReadBack(fixture: Gate1TransferFixture): Readonly<{ + appliedInventoryDigest: string; + catalogVersion: string; + currentCatalogHeadDigest: string; + inventoryRowCount: number; +}> { + return Object.freeze({ + appliedInventoryDigest: fixture.head.appliedInventoryDigest, + catalogVersion: fixture.head.catalogVersion, + currentCatalogHeadDigest: fixture.head.catalogHeadDigest, + inventoryRowCount: fixture.inventoryRowCount, + }); +} + +export function assertFixtureDerivations(): void { + const projection = GATE1_FIXTURE.projectionNQuads; + const bundle = `dkg-rfc64-opaque-bundle-fixture-v1\n${projection}`; + const row = JSON.stringify({ + assertionCoordinate: 'gate-1-object', + bundleDigest: BUNDLE_DIGEST, + contentDigest: CONTENT_DIGEST, + kaUal: GATE1_FIXTURE.positive.kaUal, + quadCount: 2, + }); + const positiveHead = JSON.stringify({ + catalogRowDigest: CATALOG_ROW_DIGEST, + catalogVersion: '1', + previousCatalogHeadDigest: PREDECESSOR_HEAD_DIGEST, + totalRows: '1', + }); + const repairHead = JSON.stringify({ + catalogRowDigest: CATALOG_ROW_DIGEST, + catalogVersion: '2', + previousCatalogHeadDigest: POSITIVE_HEAD_DIGEST, + totalRows: '1', + }); + const forgedHead = JSON.stringify({ + catalogAuthorAddress: AUTHOR_ADDRESS, + catalogRowDigest: CATALOG_ROW_DIGEST, + recoveredAuthorAddress: ATTACKER_ADDRESS, + }); + requireDerived(sha256(projection), CONTENT_DIGEST, 'content digest'); + requireDerived(Buffer.byteLength(projection), 168, 'content byte length'); + requireDerived(sha256(bundle), BUNDLE_DIGEST, 'bundle digest'); + requireDerived(Buffer.byteLength(bundle), 203, 'bundle byte length'); + requireDerived(sha256(`${row}\n`), CATALOG_ROW_DIGEST, 'catalog row digest'); + requireDerived( + sha256('dkg-rfc64-gate1-predecessor-v1\n'), + PREDECESSOR_HEAD_DIGEST, + 'predecessor head digest', + ); + requireDerived(sha256(`${positiveHead}\n`), POSITIVE_HEAD_DIGEST, 'positive head digest'); + requireDerived(sha256(`${repairHead}\n`), REPAIR_HEAD_DIGEST, 'repair head digest'); + requireDerived(sha256(`${forgedHead}\n`), FORGED_HEAD_DIGEST, 'forged head digest'); + requireDerived( + inventoryDigest(POSITIVE_HEAD_DIGEST), + POSITIVE_INVENTORY_DIGEST, + 'positive inventory digest', + ); + requireDerived( + inventoryDigest(REPAIR_HEAD_DIGEST), + REPAIR_INVENTORY_DIGEST, + 'repair inventory digest', + ); +} + +function inventoryDigest(headDigest: string): string { + return sha256( + `dkg-rfc64-applied-inventory-v1\n${headDigest}\n${CATALOG_ROW_DIGEST}\n` + + `${CONTENT_DIGEST}\n2\n`, + ); +} + +function sha256(value: string): string { + return `0x${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} + +function requireDerived(actual: string | number, expected: string | number, label: string): void { + if (actual !== expected) { + throw new Error(`RFC-64 Gate 1 fixture ${label} changed: ${actual} != ${expected}`); + } +} diff --git a/devnet/rfc64-gate1-public-open/package.json b/devnet/rfc64-gate1-public-open/package.json new file mode 100644 index 0000000000..09ef834852 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/package.json @@ -0,0 +1,6 @@ +{ + "name": "@origintrail-official/dkg-devnet-rfc64-gate1-public-open", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/devnet/rfc64-gate1-public-open/run.ts b/devnet/rfc64-gate1-public-open/run.ts new file mode 100644 index 0000000000..bef3ba8236 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/run.ts @@ -0,0 +1,423 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; + +import { + atomicWriteStableJson, + readCleanRepositoryHead, + stableJson, +} from '../rfc64-persistence-lifecycle/evidence.js'; +import { + ChildProcessRegistry, + cleanupPreservingPrimaryFailure, + type ProcessExitEvidence, + type TrackedChildProcess, +} from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { + GATE1_ADAPTER_PROTOCOL_VERSION, + GATE1_FIXTURE, + GATE1_FIXTURE_ADAPTER_ID, + GATE1_RAW_SCHEMA_VERSION, + INSPECTED_PRODUCT_COMMITS, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + assertFixtureDerivations, + expectedAppliedReadBack, + type Gate1TransferFixture, +} from './model.js'; + +const EVENT_PREFIX = 'RFC64_GATE1_ADAPTER_EVENT '; +const REPO_ROOT = resolve(import.meta.dirname, '../..'); +const ADAPTER_PROCESS = join(import.meta.dirname, 'adapter-process.ts'); +const DEFAULT_ARTIFACT = join(import.meta.dirname, 'artifacts/gate1-result.json'); +const PROCESS_TIMEOUT_MS = 30_000; +const children = new ChildProcessRegistry(15_000); + +interface AdapterEvent { + readonly event: string; + readonly requestId?: string; + readonly role: 'author' | 'receiver'; + readonly [key: string]: unknown; +} + +interface PendingEvent { + readonly expectedEvent: string; + readonly reject: (error: Error) => void; + readonly requestId: string | null; + readonly resolve: (event: AdapterEvent) => void; + readonly timer: NodeJS.Timeout; +} + +class AdapterChild { + readonly child: ChildProcessWithoutNullStreams; + readonly tracked: TrackedChildProcess; + readonly #events: AdapterEvent[] = []; + readonly #pending = new Set(); + #stderr = ''; + #stdout = ''; + #lineBuffer = ''; + + constructor( + readonly role: 'author' | 'receiver', + dataDir: string, + ) { + this.child = spawn( + process.execPath, + ['--import', 'tsx', ADAPTER_PROCESS, role], + { + cwd: REPO_ROOT, + env: { + ...process.env, + DKG_RFC64_GATE1_ADAPTER_DATA_DIR: dataDir, + NODE_ENV: 'production', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + this.tracked = children.track(this.child); + this.child.stdout.setEncoding('utf8'); + this.child.stderr.setEncoding('utf8'); + this.child.stdout.on('data', (chunk: string) => this.consumeStdout(chunk)); + this.child.stderr.on('data', (chunk: string) => { this.#stderr += chunk; }); + this.child.once('error', (error) => this.rejectAll(error)); + void this.tracked.closed.then((exit) => { + if (this.#pending.size === 0) return; + this.rejectAll(new Error( + `${this.role} adapter closed before its expected event: ${JSON.stringify(exit)}\n` + + `stdout:\n${this.#stdout}\nstderr:\n${this.#stderr}`, + )); + }); + } + + waitFor(expectedEvent: string, requestId: string | null = null): Promise { + const existing = this.#events.find((event) => + event.event === expectedEvent && (requestId === null || event.requestId === requestId)); + if (existing !== undefined) return Promise.resolve(existing); + return new Promise((resolveEvent, rejectEvent) => { + const timer = setTimeout(() => { + this.#pending.delete(pending); + rejectEvent(new Error( + `${this.role} adapter timed out waiting for ${expectedEvent}/${requestId ?? '*'}\n` + + `stdout:\n${this.#stdout}\nstderr:\n${this.#stderr}`, + )); + }, PROCESS_TIMEOUT_MS); + const pending: PendingEvent = { + expectedEvent, + reject: rejectEvent, + requestId, + resolve: resolveEvent, + timer, + }; + this.#pending.add(pending); + }); + } + + async request( + command: string, + requestId: string, + expectedEvent: string, + fixture?: unknown, + ): Promise { + const event = this.waitFor(expectedEvent, requestId); + const payload = fixture === undefined + ? { command, requestId } + : { command, fixture, requestId }; + await new Promise((resolveWrite, rejectWrite) => { + this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => { + if (error === null || error === undefined) resolveWrite(); + else rejectWrite(error); + }); + }); + return event; + } + + async stop(requestId: string): Promise { + const stopped = this.request('stop', requestId, 'stopped'); + await stopped; + const exit = await this.tracked.closed; + requireCondition(exit.code === 0 && exit.signal === null, `${this.role} did not stop cleanly`); + return exit; + } + + private consumeStdout(chunk: string): void { + this.#stdout += chunk; + this.#lineBuffer += chunk; + const lines = this.#lineBuffer.split('\n'); + this.#lineBuffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.startsWith(EVENT_PREFIX)) continue; + let event: AdapterEvent; + try { + event = JSON.parse(line.slice(EVENT_PREFIX.length)) as AdapterEvent; + } catch (error) { + this.rejectAll(new Error(`invalid adapter event JSON: ${String(error)}`)); + continue; + } + this.#events.push(event); + for (const pending of this.#pending) { + if ( + event.event !== pending.expectedEvent + || (pending.requestId !== null && event.requestId !== pending.requestId) + ) continue; + clearTimeout(pending.timer); + this.#pending.delete(pending); + pending.resolve(event); + } + if (event.event === 'error') { + this.rejectAll(new Error( + `${this.role} adapter error for ${event.requestId ?? 'unknown request'}: ` + + `${String(event.message)}`, + )); + } + } + } + + private rejectAll(error: Error): void { + for (const pending of this.#pending) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.#pending.clear(); + } +} + +function exactEventValue(event: AdapterEvent, key: string, expected: unknown): unknown { + const actual = event[key]; + requireCondition( + stableJson(actual) === stableJson(expected), + `${event.role}/${event.event}.${key} differed from the pinned evidence contract`, + ); + return actual; +} + +function selectReady(event: AdapterEvent): Record { + return { + adapterId: event.adapterId, + peerId: event.peerId, + protocolVersion: event.protocolVersion, + role: event.role, + startupRepair: event.startupRepair, + }; +} + +async function execute(): Promise { + assertFixtureDerivations(); + const headBefore = readCleanRepositoryHead(REPO_ROOT); + const authorDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate1-author-')); + const receiverDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate1-receiver-')); + let operationFailed = true; + let primaryFailure: unknown; + try { + const author = new AdapterChild('author', authorDataDir); + const receiver = new AdapterChild('receiver', receiverDataDir); + const [authorReady, receiverReady] = await Promise.all([ + author.waitFor('ready'), + receiver.waitFor('ready'), + ]); + requireReady(authorReady, GATE1_FIXTURE.authorPeerId, null); + requireReady(receiverReady, GATE1_FIXTURE.receiverPeerId, null); + requireCondition(authorReady.peerId !== receiverReady.peerId, 'peer identities are not distinct'); + + const forgedServed = await author.request( + 'serve-forged', + 'forged-served-v1', + 'forged-served', + ); + const forgedFixture = exactEventValue(forgedServed, 'fixture', GATE1_FIXTURE.forged); + const forgedRejected = await receiver.request( + 'attempt-forged', + 'forged-rejected-v1', + 'forged-author-rejected', + forgedFixture, + ); + + const positiveServed = await author.request( + 'serve-positive', + 'positive-served-v1', + 'positive-served', + ); + const positiveFixture = exactEventValue(positiveServed, 'fixture', GATE1_FIXTURE.positive); + const positiveActivated = await receiver.request( + 'activate-positive', + 'positive-activated-v1', + 'positive-activated', + positiveFixture, + ); + + const repairServed = await author.request( + 'serve-repair-successor', + 'repair-served-v1', + 'repair-successor-served', + ); + const repairFixture = exactEventValue( + repairServed, + 'fixture', + GATE1_FIXTURE.repairSuccessor, + ); + const repairGap = await receiver.request( + 'prepare-repair-crash', + 'repair-gap-v1', + 'repair-gap-durable', + repairFixture, + ); + const receiverCrashExit = await children.terminateAndWait(receiver.tracked, 'SIGKILL'); + requireCondition( + receiverCrashExit.code === null && receiverCrashExit.signal === 'SIGKILL', + 'receiver crash boundary was not SIGKILL', + ); + + const restartedReceiver = new AdapterChild('receiver', receiverDataDir); + const restartedReady = await restartedReceiver.waitFor('ready'); + const expectedRepair = { + action: 'advanced-applied-head-from-durable-intent', + after: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), + before: expectedAppliedReadBack(GATE1_FIXTURE.positive), + repaired: true, + semanticPostRead: semanticEvidence(GATE1_FIXTURE.repairSuccessor), + }; + requireReady(restartedReady, GATE1_FIXTURE.receiverPeerId, expectedRepair); + const repairReadBack = await restartedReceiver.request( + 'read-repaired', + 'repair-read-back-v1', + 'repair-read-back', + ); + + const restartedReceiverExit = await restartedReceiver.stop('receiver-stop-v1'); + const authorExit = await author.stop('author-stop-v1'); + const headAfter = readCleanRepositoryHead(REPO_ROOT); + requireCondition(headAfter === headBefore, 'tracked source commit changed during Gate 1 run'); + + const artifact = { + adapter: { + id: GATE1_FIXTURE_ADAPTER_ID, + inspectedProductCommits: INSPECTED_PRODUCT_COMMITS, + productBoundary: 'not-connected', + protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, + requiredProductionOperations: REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + replacementContract: + 'replace adapter-process commands with production DKGAgent operations without changing evidence schema', + }, + fixture: { + forged: GATE1_FIXTURE.forged, + positive: GATE1_FIXTURE.positive, + repairSuccessor: GATE1_FIXTURE.repairSuccessor, + }, + gate: 'OT-RFC-64 Gate 1 harness contract', + gateEvaluation: { + reason: + 'deterministic adapter proves orchestration and fail-closed evidence verification, not production Gate 1', + status: 'not-evaluated', + }, + harnessChecksPassed: true, + invocation: 'pnpm test:gate1:rfc64-public-open-harness', + phases: { + forgedAuthor: { + activationAfter: forgedRejected.activationAfter, + activationBefore: forgedRejected.activationBefore, + appliedHeadAfter: forgedRejected.appliedHeadAfter, + appliedHeadBefore: forgedRejected.appliedHeadBefore, + attemptedCatalogHeadDigest: forgedRejected.attemptedCatalogHeadDigest, + failureCode: forgedRejected.failureCode, + recoveredAuthorAddress: forgedRejected.recoveredAuthorAddress, + servedByPeerId: authorReady.peerId, + testedByPeerId: receiverReady.peerId, + }, + positiveSync: { + appliedReadBack: positiveActivated.appliedReadBack, + controlObjectsVerified: positiveActivated.controlObjectsVerified, + exact: positiveActivated.exact, + receivedByPeerId: receiverReady.peerId, + semanticPostRead: positiveActivated.semanticPostRead, + servedByPeerId: authorReady.peerId, + }, + restartRepair: { + crashExit: receiverCrashExit, + gap: { + appliedBeforeCrash: repairGap.appliedBeforeCrash, + repairIntentDurable: repairGap.repairIntentDurable, + semanticBeforeCrash: repairGap.semanticBeforeCrash, + target: repairGap.target, + }, + readBack: { + appliedReadBack: repairReadBack.appliedReadBack, + semanticPostRead: repairReadBack.semanticPostRead, + }, + restartedReady: selectReady(restartedReady), + successorServedByPeerId: authorReady.peerId, + }, + }, + processBoundary: { + authorInstances: 1, + model: 'two concurrent adapter peer processes plus one receiver restart', + receiverInstances: 2, + stoppedExits: { + author: authorExit, + restartedReceiver: restartedReceiverExit, + }, + }, + ready: { + author: selectReady(authorReady), + receiver: selectReady(receiverReady), + }, + repository: { + testedHeadCommit: headBefore, + trackedSourceCleanAfterProcesses: true, + trackedSourceCleanBeforeSpawn: true, + }, + schemaVersion: GATE1_RAW_SCHEMA_VERSION, + }; + + const artifactPath = process.env.DKG_RFC64_GATE1_ARTIFACT ?? DEFAULT_ARTIFACT; + const publication = atomicWriteStableJson(artifactPath, artifact); + process.stdout.write( + `[rfc64-gate1-harness] wrote ${artifactPath} sha256=${publication.sha256}\n`, + ); + operationFailed = false; + } catch (error) { + primaryFailure = error; + } finally { + await cleanupPreservingPrimaryFailure({ + operationFailed, + primaryFailure, + cleanup: () => children.terminateAllThenCleanup(() => { + rmSync(authorDataDir, { force: true, recursive: true }); + rmSync(receiverDataDir, { force: true, recursive: true }); + }), + reportSecondaryFailure: (primary, secondary) => { + process.stderr.write( + `[rfc64-gate1-harness] cleanup failure after ${String(primary)}: ${String(secondary)}\n`, + ); + }, + }); + } +} + +function semanticEvidence(fixture: Gate1TransferFixture): Record { + return { + activatedQuadCount: fixture.activatedQuadCount, + catalogHeadDigest: fixture.head.catalogHeadDigest, + catalogRowDigest: fixture.catalogRowDigest, + contentDigest: fixture.contentDigest, + kaUal: fixture.kaUal, + swmGraph: fixture.swmGraph, + }; +} + +function requireReady(event: AdapterEvent, peerId: string, startupRepair: unknown): void { + requireCondition(event.role === (peerId === GATE1_FIXTURE.authorPeerId ? 'author' : 'receiver'), + 'ready role differs from peer identity'); + requireCondition(event.adapterId === GATE1_FIXTURE_ADAPTER_ID, 'adapter ID changed'); + requireCondition(event.protocolVersion === GATE1_ADAPTER_PROTOCOL_VERSION, 'protocol changed'); + requireCondition(event.peerId === peerId, 'peer ID changed'); + requireCondition( + stableJson(event.startupRepair) === stableJson(startupRepair), + 'startup repair evidence changed', + ); +} + +function requireCondition(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +await execute(); diff --git a/devnet/rfc64-gate1-public-open/tsconfig.json b/devnet/rfc64-gate1-public-open/tsconfig.json new file mode 100644 index 0000000000..8ea62fb139 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "../..", + "sourceMap": false, + "types": ["node"] + }, + "include": [ + "adapter-process.ts", + "model.test.ts", + "model.ts", + "run.ts", + "verifier.test.ts", + "verifier.ts", + "verify.ts" + ] +} diff --git a/devnet/rfc64-gate1-public-open/verifier.test.ts b/devnet/rfc64-gate1-public-open/verifier.test.ts new file mode 100644 index 0000000000..57cd4cd9a8 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/verifier.test.ts @@ -0,0 +1,311 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { stableJson } from '../rfc64-persistence-lifecycle/evidence.js'; +import { + GATE1_ADAPTER_PROTOCOL_VERSION, + GATE1_FIXTURE, + GATE1_FIXTURE_ADAPTER_ID, + GATE1_RAW_SCHEMA_VERSION, + INSPECTED_PRODUCT_COMMITS, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + expectedAppliedReadBack, + type Gate1TransferFixture, +} from './model.js'; +import { verifyGate1ArtifactBytes } from './verifier.js'; + +const HEAD = 'a'.repeat(40); + +test('accepts the complete canonical deterministic harness evidence', () => { + const result = verifyGate1ArtifactBytes(bytes(goldenArtifact()), HEAD); + assert.equal(result.sourceCommit, HEAD); + assert.match(result.rawArtifactSha256, /^0x[0-9a-f]{64}$/u); +}); + +test('rejects non-canonical JSON before interpreting evidence', () => { + const text = JSON.stringify(goldenArtifact()); + assert.throws( + () => verifyGate1ArtifactBytes(Buffer.from(text), HEAD), + /not exact canonical stable JSON/, + ); +}); + +test('rejects an extra top-level field', () => { + const artifact = goldenArtifact() as Record; + artifact.untrusted = true; + reject(artifact, /failed at \$: must contain exactly keys/); +}); + +test('pins the artifact to the independently observed repository commit', () => { + const artifact = goldenArtifact(); + artifact.repository.testedHeadCommit = 'b'.repeat(40); + reject(artifact, /repository\.testedHeadCommit/); +}); + +test('pins both inspected product commits at the adapter boundary', () => { + const artifact = goldenArtifact(); + artifact.adapter.inspectedProductCommits[0] = 'c'.repeat(40); + reject(artifact, /adapter\.inspectedProductCommits/); +}); + +test('pins the six production adapter operations without interim service internals', () => { + const artifact = goldenArtifact(); + artifact.adapter.requiredProductionOperations[0] = 'interimServiceMethod'; + reject(artifact, /adapter\.requiredProductionOperations/); +}); + +test('does not allow the fixture adapter to claim a formal production gate', () => { + const artifact = goldenArtifact(); + artifact.adapter.productBoundary = 'production'; + reject(artifact, /adapter\.productBoundary/); + const evaluated = goldenArtifact(); + evaluated.gateEvaluation.status = 'PASS'; + reject(evaluated, /gateEvaluation\.status/); +}); + +test('requires exact distinct author and receiver peer identities', () => { + const artifact = goldenArtifact(); + artifact.ready.receiver.peerId = artifact.ready.author.peerId; + reject(artifact, /ready\.receiver\.peerId/); +}); + +const exactPositiveMutations: ReadonlyArray void]> = [ + ['catalog head digest', (artifact) => { + artifact.phases.positiveSync.exact.head.catalogHeadDigest = digest('1'); + }], + ['catalog row digest', (artifact) => { + artifact.phases.positiveSync.exact.catalogRowDigest = digest('2'); + }], + ['bundle digest', (artifact) => { + artifact.phases.positiveSync.exact.bundleDigest = digest('3'); + }], + ['content digest', (artifact) => { + artifact.phases.positiveSync.exact.contentDigest = digest('4'); + }], + ['UAL', (artifact) => { artifact.phases.positiveSync.exact.kaUal += '/wrong'; }], + ['inventory row count', (artifact) => { + artifact.phases.positiveSync.exact.inventoryRowCount = 2; + }], + ['activated quad count', (artifact) => { + artifact.phases.positiveSync.exact.activatedQuadCount = 3; + }], +]; + +for (const [label, mutate] of exactPositiveMutations) { + test(`rejects changed exact positive ${label}`, () => { + const artifact = goldenArtifact(); + mutate(artifact); + reject(artifact, /phases\.positiveSync\.exact/); + }); +} + +test('requires exact durable applied-head readback after positive activation', () => { + const artifact = goldenArtifact(); + artifact.phases.positiveSync.appliedReadBack.currentCatalogHeadDigest = digest('5'); + reject(artifact, /phases\.positiveSync\.appliedReadBack/); +}); + +test('requires the exact semantic quad/count/digest post-read', () => { + const artifact = goldenArtifact(); + artifact.phases.positiveSync.semanticPostRead.activatedQuadCount = 1; + reject(artifact, /phases\.positiveSync\.semanticPostRead/); +}); + +test('requires forged-author failure to leave zero activation and no applied head', () => { + const activated = goldenArtifact(); + activated.phases.forgedAuthor.activationAfter = 1; + reject(activated, /forgedAuthor\.activationAfter/); + + const applied = goldenArtifact(); + applied.phases.forgedAuthor.appliedHeadAfter = expectedAppliedReadBack(GATE1_FIXTURE.positive); + reject(applied, /forgedAuthor\.appliedHeadAfter/); +}); + +test('requires the forged author to fail at cryptographic transfer admission', () => { + const artifact = goldenArtifact(); + artifact.phases.forgedAuthor.failureCode = 'catalog-native-receiver-activation'; + reject(artifact, /forgedAuthor\.failureCode/); +}); + +test('requires an unclean receiver crash after a durable repair intent', () => { + const cleanExit = goldenArtifact(); + cleanExit.phases.restartRepair.crashExit = { code: 0, signal: null }; + reject(cleanExit, /restartRepair\.crashExit\.code/); + + const noIntent = goldenArtifact(); + noIntent.phases.restartRepair.gap.repairIntentDurable = false; + reject(noIntent, /restartRepair\.gap\.repairIntentDurable/); +}); + +test('requires restart to advance the durable predecessor to the exact successor', () => { + const artifact = goldenArtifact(); + artifact.phases.restartRepair.restartedReady.startupRepair.after.currentCatalogHeadDigest = + GATE1_FIXTURE.positive.head.catalogHeadDigest; + reject(artifact, /restartRepair\.restartedReady\.startupRepair/); +}); + +test('requires exact semantic and applied readback after restart repair', () => { + const applied = goldenArtifact(); + applied.phases.restartRepair.readBack.appliedReadBack.appliedInventoryDigest = digest('6'); + reject(applied, /restartRepair\.readBack\.appliedReadBack/); + + const semantic = goldenArtifact(); + semantic.phases.restartRepair.readBack.semanticPostRead.contentDigest = digest('7'); + reject(semantic, /restartRepair\.readBack\.semanticPostRead/); +}); + +function buildGoldenArtifact() { + const positiveApplied = mutable(expectedAppliedReadBack(GATE1_FIXTURE.positive)); + const repairApplied = mutable(expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor)); + const positiveSemantic = semantic(GATE1_FIXTURE.positive); + const repairSemantic = semantic(GATE1_FIXTURE.repairSuccessor); + const startupRepair = { + action: 'advanced-applied-head-from-durable-intent', + after: mutable(repairApplied), + before: mutable(positiveApplied), + repaired: true, + semanticPostRead: mutable(repairSemantic), + }; + return { + adapter: { + id: GATE1_FIXTURE_ADAPTER_ID, + inspectedProductCommits: [...INSPECTED_PRODUCT_COMMITS], + productBoundary: 'not-connected', + protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, + requiredProductionOperations: [...REQUIRED_PRODUCTION_ADAPTER_OPERATIONS], + replacementContract: + 'replace adapter-process commands with production DKGAgent operations without changing evidence schema', + }, + fixture: { + forged: mutable(GATE1_FIXTURE.forged), + positive: mutable(GATE1_FIXTURE.positive), + repairSuccessor: mutable(GATE1_FIXTURE.repairSuccessor), + }, + gate: 'OT-RFC-64 Gate 1 harness contract', + gateEvaluation: { + reason: + 'deterministic adapter proves orchestration and fail-closed evidence verification, not production Gate 1', + status: 'not-evaluated', + }, + harnessChecksPassed: true, + invocation: 'pnpm test:gate1:rfc64-public-open-harness', + phases: { + forgedAuthor: { + activationAfter: 0, + activationBefore: 0, + appliedHeadAfter: null as unknown, + appliedHeadBefore: null as unknown, + attemptedCatalogHeadDigest: GATE1_FIXTURE.forged.attemptedCatalogHeadDigest, + failureCode: GATE1_FIXTURE.forged.expectedFailureCode, + recoveredAuthorAddress: GATE1_FIXTURE.forged.recoveredAuthorAddress, + servedByPeerId: GATE1_FIXTURE.authorPeerId, + testedByPeerId: GATE1_FIXTURE.receiverPeerId, + }, + positiveSync: { + appliedReadBack: mutable(positiveApplied), + controlObjectsVerified: 3, + exact: mutable(GATE1_FIXTURE.positive), + receivedByPeerId: GATE1_FIXTURE.receiverPeerId, + semanticPostRead: mutable(positiveSemantic), + servedByPeerId: GATE1_FIXTURE.authorPeerId, + }, + restartRepair: { + crashExit: { code: null as number | null, signal: 'SIGKILL' as string | null }, + gap: { + appliedBeforeCrash: mutable(positiveApplied), + repairIntentDurable: true, + semanticBeforeCrash: mutable(repairSemantic), + target: mutable(repairApplied), + }, + readBack: { + appliedReadBack: mutable(repairApplied), + semanticPostRead: mutable(repairSemantic), + }, + restartedReady: { + adapterId: GATE1_FIXTURE_ADAPTER_ID, + peerId: GATE1_FIXTURE.receiverPeerId, + protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, + role: 'receiver', + startupRepair, + }, + successorServedByPeerId: GATE1_FIXTURE.authorPeerId, + }, + }, + processBoundary: { + authorInstances: 1, + model: 'two concurrent adapter peer processes plus one receiver restart', + receiverInstances: 2, + stoppedExits: { + author: { code: 0, signal: null }, + restartedReceiver: { code: 0, signal: null }, + }, + }, + ready: { + author: { + adapterId: GATE1_FIXTURE_ADAPTER_ID, + peerId: GATE1_FIXTURE.authorPeerId, + protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, + role: 'author', + startupRepair: null as unknown, + }, + receiver: { + adapterId: GATE1_FIXTURE_ADAPTER_ID, + peerId: GATE1_FIXTURE.receiverPeerId, + protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, + role: 'receiver', + startupRepair: null as unknown, + }, + }, + repository: { + testedHeadCommit: HEAD, + trackedSourceCleanAfterProcesses: true, + trackedSourceCleanBeforeSpawn: true, + }, + schemaVersion: GATE1_RAW_SCHEMA_VERSION, + }; +} + +type DeepMutable = T extends string + ? string + : T extends number + ? number + : T extends boolean + ? boolean + : T extends readonly (infer Item)[] + ? DeepMutable[] + : T extends object + ? { -readonly [Key in keyof T]: DeepMutable } + : T; + +type GoldenArtifact = DeepMutable>; + +function goldenArtifact(): GoldenArtifact { + return structuredClone(buildGoldenArtifact()) as GoldenArtifact; +} + +function semantic(fixture: Gate1TransferFixture) { + return { + activatedQuadCount: fixture.activatedQuadCount, + catalogHeadDigest: fixture.head.catalogHeadDigest, + catalogRowDigest: fixture.catalogRowDigest, + contentDigest: fixture.contentDigest, + kaUal: fixture.kaUal, + swmGraph: fixture.swmGraph, + }; +} + +function mutable(value: T): T { + return structuredClone(value); +} + +function bytes(value: unknown): Buffer { + return Buffer.from(stableJson(value), 'utf8'); +} + +function reject(value: unknown, pattern: RegExp): void { + assert.throws(() => verifyGate1ArtifactBytes(bytes(value), HEAD), pattern); +} + +function digest(nibble: string): string { + return `0x${nibble.repeat(64)}`; +} diff --git a/devnet/rfc64-gate1-public-open/verifier.ts b/devnet/rfc64-gate1-public-open/verifier.ts new file mode 100644 index 0000000000..665a6e689a --- /dev/null +++ b/devnet/rfc64-gate1-public-open/verifier.ts @@ -0,0 +1,420 @@ +import { createHash } from 'node:crypto'; + +import { stableJson } from '../rfc64-persistence-lifecycle/evidence.js'; +import { + GATE1_ADAPTER_PROTOCOL_VERSION, + GATE1_FIXTURE, + GATE1_FIXTURE_ADAPTER_ID, + GATE1_RAW_SCHEMA_VERSION, + INSPECTED_PRODUCT_COMMITS, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + assertFixtureDerivations, + expectedAppliedReadBack, + type Gate1TransferFixture, +} from './model.js'; + +const COMMIT_PATTERN = /^[0-9a-f]{40,64}$/u; + +export interface Gate1VerifiedEvidence { + readonly rawArtifactSha256: string; + readonly sourceCommit: string; +} +export function verifyGate1ArtifactBytes( + rawBytes: Uint8Array, + expectedHead: string, +): Gate1VerifiedEvidence { + assertFixtureDerivations(); + requireMatch(expectedHead, COMMIT_PATTERN, 'expected repository HEAD'); + if (rawBytes.byteLength === 0 || rawBytes.byteLength > 1_000_000) { + fail('$', 'raw artifact size is outside the closed verifier bound'); + } + let rawText: string; + try { + rawText = new TextDecoder('utf-8', { fatal: true }).decode(rawBytes); + } catch (cause) { + throw new Error('Gate 1 artifact is not valid UTF-8', { cause }); + } + let parsed: unknown; + try { + parsed = JSON.parse(rawText) as unknown; + } catch (cause) { + throw new Error('Gate 1 artifact is not valid JSON', { cause }); + } + let canonical: string; + try { + canonical = stableJson(parsed); + } catch (cause) { + throw new Error('Gate 1 artifact contains a lossy or non-plain JSON shape', { cause }); + } + if (canonical !== rawText) fail('$', 'raw artifact is not exact canonical stable JSON'); + verifyClosedArtifact(parsed, expectedHead); + return Object.freeze({ + rawArtifactSha256: `0x${createHash('sha256').update(rawBytes).digest('hex')}`, + sourceCommit: expectedHead, + }); +} + +function verifyClosedArtifact(value: unknown, expectedHead: string): void { + const artifact = closedRecord(value, '$', [ + 'adapter', + 'fixture', + 'gate', + 'gateEvaluation', + 'harnessChecksPassed', + 'invocation', + 'phases', + 'processBoundary', + 'ready', + 'repository', + 'schemaVersion', + ]); + exact(artifact.schemaVersion, GATE1_RAW_SCHEMA_VERSION, '$.schemaVersion'); + exact(artifact.gate, 'OT-RFC-64 Gate 1 harness contract', '$.gate'); + exact( + artifact.invocation, + 'pnpm test:gate1:rfc64-public-open-harness', + '$.invocation', + ); + exact(artifact.harnessChecksPassed, true, '$.harnessChecksPassed'); + + const evaluation = closedRecord(artifact.gateEvaluation, '$.gateEvaluation', [ + 'reason', + 'status', + ]); + exact(evaluation.status, 'not-evaluated', '$.gateEvaluation.status'); + exact( + evaluation.reason, + 'deterministic adapter proves orchestration and fail-closed evidence verification, not production Gate 1', + '$.gateEvaluation.reason', + ); + + const adapter = closedRecord(artifact.adapter, '$.adapter', [ + 'id', + 'inspectedProductCommits', + 'productBoundary', + 'protocolVersion', + 'requiredProductionOperations', + 'replacementContract', + ]); + exact(adapter.id, GATE1_FIXTURE_ADAPTER_ID, '$.adapter.id'); + exact(adapter.protocolVersion, GATE1_ADAPTER_PROTOCOL_VERSION, '$.adapter.protocolVersion'); + exact(adapter.productBoundary, 'not-connected', '$.adapter.productBoundary'); + exactJson( + adapter.requiredProductionOperations, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + '$.adapter.requiredProductionOperations', + ); + exact( + adapter.replacementContract, + 'replace adapter-process commands with production DKGAgent operations without changing evidence schema', + '$.adapter.replacementContract', + ); + exactJson( + adapter.inspectedProductCommits, + INSPECTED_PRODUCT_COMMITS, + '$.adapter.inspectedProductCommits', + ); + + const repository = closedRecord(artifact.repository, '$.repository', [ + 'testedHeadCommit', + 'trackedSourceCleanAfterProcesses', + 'trackedSourceCleanBeforeSpawn', + ]); + exact(repository.testedHeadCommit, expectedHead, '$.repository.testedHeadCommit'); + exact( + repository.trackedSourceCleanBeforeSpawn, + true, + '$.repository.trackedSourceCleanBeforeSpawn', + ); + exact( + repository.trackedSourceCleanAfterProcesses, + true, + '$.repository.trackedSourceCleanAfterProcesses', + ); + + verifyFixture(artifact.fixture); + const peers = verifyReady(artifact.ready); + verifyProcessBoundary(artifact.processBoundary); + verifyPhases(artifact.phases, peers); +} + +function verifyFixture(value: unknown): void { + const fixture = closedRecord(value, '$.fixture', ['forged', 'positive', 'repairSuccessor']); + exactJson(fixture.forged, GATE1_FIXTURE.forged, '$.fixture.forged'); + exactJson(fixture.positive, GATE1_FIXTURE.positive, '$.fixture.positive'); + exactJson( + fixture.repairSuccessor, + GATE1_FIXTURE.repairSuccessor, + '$.fixture.repairSuccessor', + ); +} + +function verifyReady(value: unknown): { author: string; receiver: string } { + const ready = closedRecord(value, '$.ready', ['author', 'receiver']); + const author = verifyReadyEvent(ready.author, '$.ready.author', 'author', null); + const receiver = verifyReadyEvent(ready.receiver, '$.ready.receiver', 'receiver', null); + exact(author, GATE1_FIXTURE.authorPeerId, '$.ready.author.peerId'); + exact(receiver, GATE1_FIXTURE.receiverPeerId, '$.ready.receiver.peerId'); + if (author === receiver) fail('$.ready', 'author and receiver peer IDs must be distinct'); + return { author, receiver }; +} + +function verifyReadyEvent( + value: unknown, + path: string, + role: 'author' | 'receiver', + expectedRepair: unknown, +): string { + const event = closedRecord(value, path, [ + 'adapterId', + 'peerId', + 'protocolVersion', + 'role', + 'startupRepair', + ]); + exact(event.adapterId, GATE1_FIXTURE_ADAPTER_ID, `${path}.adapterId`); + exact(event.protocolVersion, GATE1_ADAPTER_PROTOCOL_VERSION, `${path}.protocolVersion`); + exact(event.role, role, `${path}.role`); + exactJson(event.startupRepair, expectedRepair, `${path}.startupRepair`); + return nonEmptyString(event.peerId, `${path}.peerId`); +} + +function verifyProcessBoundary(value: unknown): void { + const boundary = closedRecord(value, '$.processBoundary', [ + 'authorInstances', + 'model', + 'receiverInstances', + 'stoppedExits', + ]); + exact(boundary.authorInstances, 1, '$.processBoundary.authorInstances'); + exact(boundary.receiverInstances, 2, '$.processBoundary.receiverInstances'); + exact( + boundary.model, + 'two concurrent adapter peer processes plus one receiver restart', + '$.processBoundary.model', + ); + const exits = closedRecord(boundary.stoppedExits, '$.processBoundary.stoppedExits', [ + 'author', + 'restartedReceiver', + ]); + verifyExit(exits.author, '$.processBoundary.stoppedExits.author', 0, null); + verifyExit( + exits.restartedReceiver, + '$.processBoundary.stoppedExits.restartedReceiver', + 0, + null, + ); +} + +function verifyPhases( + value: unknown, + peers: { author: string; receiver: string }, +): void { + const phases = closedRecord(value, '$.phases', [ + 'forgedAuthor', + 'positiveSync', + 'restartRepair', + ]); + verifyForgedAuthor(phases.forgedAuthor, peers); + verifyPositiveSync(phases.positiveSync, peers); + verifyRestartRepair(phases.restartRepair, peers); +} + +function verifyForgedAuthor( + value: unknown, + peers: { author: string; receiver: string }, +): void { + const phase = closedRecord(value, '$.phases.forgedAuthor', [ + 'activationAfter', + 'activationBefore', + 'appliedHeadAfter', + 'appliedHeadBefore', + 'attemptedCatalogHeadDigest', + 'failureCode', + 'recoveredAuthorAddress', + 'servedByPeerId', + 'testedByPeerId', + ]); + exact(phase.servedByPeerId, peers.author, '$.phases.forgedAuthor.servedByPeerId'); + exact(phase.testedByPeerId, peers.receiver, '$.phases.forgedAuthor.testedByPeerId'); + exact(phase.activationBefore, 0, '$.phases.forgedAuthor.activationBefore'); + exact(phase.activationAfter, 0, '$.phases.forgedAuthor.activationAfter'); + exact(phase.appliedHeadBefore, null, '$.phases.forgedAuthor.appliedHeadBefore'); + exact(phase.appliedHeadAfter, null, '$.phases.forgedAuthor.appliedHeadAfter'); + exact( + phase.attemptedCatalogHeadDigest, + GATE1_FIXTURE.forged.attemptedCatalogHeadDigest, + '$.phases.forgedAuthor.attemptedCatalogHeadDigest', + ); + exact( + phase.failureCode, + GATE1_FIXTURE.forged.expectedFailureCode, + '$.phases.forgedAuthor.failureCode', + ); + exact( + phase.recoveredAuthorAddress, + GATE1_FIXTURE.forged.recoveredAuthorAddress, + '$.phases.forgedAuthor.recoveredAuthorAddress', + ); +} + +function verifyPositiveSync( + value: unknown, + peers: { author: string; receiver: string }, +): void { + const phase = closedRecord(value, '$.phases.positiveSync', [ + 'appliedReadBack', + 'controlObjectsVerified', + 'exact', + 'receivedByPeerId', + 'semanticPostRead', + 'servedByPeerId', + ]); + exact(phase.servedByPeerId, peers.author, '$.phases.positiveSync.servedByPeerId'); + exact(phase.receivedByPeerId, peers.receiver, '$.phases.positiveSync.receivedByPeerId'); + exact(phase.controlObjectsVerified, 3, '$.phases.positiveSync.controlObjectsVerified'); + exactJson(phase.exact, GATE1_FIXTURE.positive, '$.phases.positiveSync.exact'); + exactJson( + phase.appliedReadBack, + expectedAppliedReadBack(GATE1_FIXTURE.positive), + '$.phases.positiveSync.appliedReadBack', + ); + exactJson( + phase.semanticPostRead, + expectedSemantic(GATE1_FIXTURE.positive), + '$.phases.positiveSync.semanticPostRead', + ); +} + +function verifyRestartRepair( + value: unknown, + peers: { author: string; receiver: string }, +): void { + const phase = closedRecord(value, '$.phases.restartRepair', [ + 'crashExit', + 'gap', + 'readBack', + 'restartedReady', + 'successorServedByPeerId', + ]); + exact( + phase.successorServedByPeerId, + peers.author, + '$.phases.restartRepair.successorServedByPeerId', + ); + verifyExit(phase.crashExit, '$.phases.restartRepair.crashExit', null, 'SIGKILL'); + const gap = closedRecord(phase.gap, '$.phases.restartRepair.gap', [ + 'appliedBeforeCrash', + 'repairIntentDurable', + 'semanticBeforeCrash', + 'target', + ]); + exact(gap.repairIntentDurable, true, '$.phases.restartRepair.gap.repairIntentDurable'); + exactJson( + gap.appliedBeforeCrash, + expectedAppliedReadBack(GATE1_FIXTURE.positive), + '$.phases.restartRepair.gap.appliedBeforeCrash', + ); + exactJson( + gap.target, + expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), + '$.phases.restartRepair.gap.target', + ); + exactJson( + gap.semanticBeforeCrash, + expectedSemantic(GATE1_FIXTURE.repairSuccessor), + '$.phases.restartRepair.gap.semanticBeforeCrash', + ); + + const expectedRepair = { + action: 'advanced-applied-head-from-durable-intent', + after: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), + before: expectedAppliedReadBack(GATE1_FIXTURE.positive), + repaired: true, + semanticPostRead: expectedSemantic(GATE1_FIXTURE.repairSuccessor), + }; + const restartedPeer = verifyReadyEvent( + phase.restartedReady, + '$.phases.restartRepair.restartedReady', + 'receiver', + expectedRepair, + ); + exact(restartedPeer, peers.receiver, '$.phases.restartRepair.restartedReady.peerId'); + const readBack = closedRecord(phase.readBack, '$.phases.restartRepair.readBack', [ + 'appliedReadBack', + 'semanticPostRead', + ]); + exactJson( + readBack.appliedReadBack, + expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), + '$.phases.restartRepair.readBack.appliedReadBack', + ); + exactJson( + readBack.semanticPostRead, + expectedSemantic(GATE1_FIXTURE.repairSuccessor), + '$.phases.restartRepair.readBack.semanticPostRead', + ); +} + +function expectedSemantic(fixture: Gate1TransferFixture): Record { + return { + activatedQuadCount: fixture.activatedQuadCount, + catalogHeadDigest: fixture.head.catalogHeadDigest, + catalogRowDigest: fixture.catalogRowDigest, + contentDigest: fixture.contentDigest, + kaUal: fixture.kaUal, + swmGraph: fixture.swmGraph, + }; +} + +function verifyExit( + value: unknown, + path: string, + expectedCode: number | null, + expectedSignal: string | null, +): void { + const exit = closedRecord(value, path, ['code', 'signal']); + exact(exit.code, expectedCode, `${path}.code`); + exact(exit.signal, expectedSignal, `${path}.signal`); +} + +function closedRecord( + value: unknown, + path: string, + expectedKeys: readonly string[], +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'must be a plain object'); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) fail(path, 'must be a plain object'); + const keys = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + if (stableJson(keys) !== stableJson(expected)) { + fail(path, `must contain exactly keys ${expected.join(', ')}`); + } + return value as Record; +} + +function exact(actual: unknown, expected: unknown, path: string): void { + if (!Object.is(actual, expected)) fail(path, `must equal ${JSON.stringify(expected)}`); +} + +function exactJson(actual: unknown, expected: unknown, path: string): void { + if (stableJson(actual) !== stableJson(expected)) fail(path, 'does not equal pinned exact value'); +} + +function nonEmptyString(value: unknown, path: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 256) { + fail(path, 'must be a bounded non-empty string'); + } + return value; +} + +function requireMatch(value: string, pattern: RegExp, label: string): void { + if (!pattern.test(value)) throw new Error(`${label} is malformed`); +} + +function fail(path: string, message: string): never { + throw new Error(`Gate 1 evidence verification failed at ${path}: ${message}`); +} diff --git a/devnet/rfc64-gate1-public-open/verify.ts b/devnet/rfc64-gate1-public-open/verify.ts new file mode 100644 index 0000000000..c08312b638 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/verify.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; + +import { + atomicWriteStableJson, + readCleanRepositoryHead, +} from '../rfc64-persistence-lifecycle/evidence.js'; +import { GATE1_VERDICT_SCHEMA_VERSION } from './model.js'; +import { verifyGate1ArtifactBytes } from './verifier.js'; + +const REPO_ROOT = resolve(import.meta.dirname, '../..'); +const artifactPath = process.env.DKG_RFC64_GATE1_ARTIFACT + ?? join(import.meta.dirname, 'artifacts/gate1-result.json'); +const verdictPath = process.env.DKG_RFC64_GATE1_VERDICT_ARTIFACT + ?? join(import.meta.dirname, 'artifacts/gate1-verdict.json'); +const expectedHead = readCleanRepositoryHead(REPO_ROOT); +const verified = verifyGate1ArtifactBytes(readFileSync(artifactPath), expectedHead); +const publication = atomicWriteStableJson(verdictPath, { + rawArtifactSha256: verified.rawArtifactSha256, + schemaVersion: GATE1_VERDICT_SCHEMA_VERSION, + scope: 'harness-contract-only', + sourceCommit: verified.sourceCommit, + status: 'PASS', +}); +process.stdout.write( + `[rfc64-gate1-harness] PASS verdict=${verdictPath} sha256=${publication.sha256}\n`, +); diff --git a/package.json b/package.json index 21169440f9..8cbee7d404 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,11 @@ "test:gate0:rfc64-persistence-lifecycle:verify": "node --import tsx devnet/rfc64-persistence-lifecycle/verify.ts", "test:gate0:rfc64-persistence-lifecycle:unit": "node --import tsx --test devnet/rfc64-persistence-lifecycle/evidence.test.ts devnet/rfc64-persistence-lifecycle/process-lifecycle.test.ts devnet/rfc64-persistence-lifecycle/verifier.test.ts", "typecheck:gate0:rfc64-persistence-lifecycle": "tsc --project devnet/rfc64-persistence-lifecycle/tsconfig.json", + "test:gate1:rfc64-public-open-harness": "pnpm run test:gate1:rfc64-public-open-harness:generate && pnpm run test:gate1:rfc64-public-open-harness:verify", + "test:gate1:rfc64-public-open-harness:generate": "node --import tsx devnet/rfc64-gate1-public-open/run.ts", + "test:gate1:rfc64-public-open-harness:verify": "node --import tsx devnet/rfc64-gate1-public-open/verify.ts", + "test:gate1:rfc64-public-open-harness:unit": "node --import tsx --test devnet/rfc64-gate1-public-open/model.test.ts devnet/rfc64-gate1-public-open/verifier.test.ts", + "typecheck:gate1:rfc64-public-open-harness": "tsc --project devnet/rfc64-gate1-public-open/tsconfig.json", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", "test:devnet:v10-stress": "vitest run --config devnet/v10-stress/vitest.config.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9440cb6242..c623d00f93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,6 +298,8 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.11)(happy-dom@20.8.9(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + devnet/rfc64-gate1-public-open: {} + devnet/rfc64-persistence-lifecycle: dependencies: '@origintrail-official/dkg-agent': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bc697858b0..f3aece375d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,7 @@ packages: - "devnet/v10-rs-wallet-rotation" - "devnet/_bootstrap" - "devnet/rfc64-persistence-lifecycle" + - "devnet/rfc64-gate1-public-open" - "devnet/pr1386-term-canon" - "devnet/pr1385-subgraph-rs" - "devnet/pr1388-okf-integration" From b0c72e75cf8189b14f7688f617fbc84cf5de4d3d Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:46:24 +0200 Subject: [PATCH 116/292] feat(agent): bootstrap RFC-64 public catalog genesis --- .../public-catalog-native-receiver-v1.ts | 278 +++++++++++++++++- ...c-catalog-native-gate1.integration.test.ts | 240 +++++++++++++-- 2 files changed, 488 insertions(+), 30 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index bdc1ff0658..1ff35e806b 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -65,6 +65,7 @@ import { type Rfc64PublicCatalogNativeTransportV1, } from './public-catalog-native-transport-v1.js'; import type { + FetchedRfc64PublicCatalogHeadV1, Rfc64PublicCatalogHeadAnnouncementV1, Rfc64PublicCatalogTransportV1, } from './public-catalog-transport-v1.js'; @@ -99,6 +100,21 @@ export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { readonly appliedHeadStatus: 'applied' | 'existing'; } +/** Exact durable evidence for the canonical empty catalog bootstrap. */ +export interface Rfc64PublicCatalogNativeGenesisEvidenceV1 { + /** Digest of the exact empty applied inventory (zero rows). */ + readonly inventoryDigest: Digest32V1; + readonly catalogHeadDigest: Digest32V1; + readonly inventoryRowCount: 0; + readonly activatedTripleCount: 0; + readonly stagedObjectCount: 2; + readonly appliedHeadStatus: 'applied' | 'existing'; +} + +export type Rfc64PublicCatalogNativeSynchronizationEvidenceV1 = + | Rfc64PublicCatalogNativeGenesisEvidenceV1 + | Rfc64PublicCatalogNativeActivationEvidenceV1; + export type Rfc64PublicCatalogNativeReceiverErrorCodeV1 = | 'catalog-native-receiver-input' | 'catalog-native-receiver-not-found' @@ -148,23 +164,222 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ): Promise { return this.withScopeSerialization( catalogScopeLockKey(announcement), - () => this.synchronizeOnePublicOpenRowSerialized(remotePeerId, announcement, deployment), + async () => { + const evidence = await this.synchronizePublicOpenCatalogSerialized( + remotePeerId, + announcement, + deployment, + 'successor', + ); + if (evidence.inventoryRowCount !== 1) { + fail('catalog-native-receiver-slice', 'one-row synchronization returned genesis evidence'); + } + return evidence; + }, ); } - private async synchronizeOnePublicOpenRowSerialized( + /** + * Fetch and apply either the canonical empty genesis or the bounded one-row + * successor. This is the production-facing entrypoint for a fresh receiver: + * callers do not need to seed durable history out of band. + */ + async synchronizePublicOpenCatalog( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, deployment: CatalogSealDeploymentProfileV1, - ): Promise { + ): Promise { + return this.withScopeSerialization( + catalogScopeLockKey(announcement), + () => this.synchronizePublicOpenCatalogSerialized( + remotePeerId, + announcement, + deployment, + 'any', + ), + ); + } + + /** Fetch, fully verify, and durably initialize exactly one empty genesis. */ + async bootstrapEmptyPublicOpenCatalog( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + deployment: CatalogSealDeploymentProfileV1, + ): Promise { + return this.withScopeSerialization( + catalogScopeLockKey(announcement), + async () => { + const evidence = await this.synchronizePublicOpenCatalogSerialized( + remotePeerId, + announcement, + deployment, + 'genesis', + ); + if (evidence.inventoryRowCount !== 0) { + fail('catalog-native-receiver-slice', 'genesis bootstrap returned successor evidence'); + } + return evidence; + }, + ); + } + + private async synchronizePublicOpenCatalogSerialized( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + deployment: CatalogSealDeploymentProfileV1, + expected: 'any' | 'genesis' | 'successor', + ): Promise { const fetchedHead = await this.options.headTransport.fetchCatalogHead( remotePeerId, announcement, { timeoutMs: this.#timeoutMs }, ); if (fetchedHead === null) { - fail('catalog-native-receiver-not-found', 'announced successor head was not found'); + fail('catalog-native-receiver-not-found', 'announced catalog head was not found'); + } + const head = fetchedHead.envelope; + if (expected === 'genesis' || (expected === 'any' && claimsGenesisHistory(head))) { + return this.bootstrapEmptyPublicOpenCatalogFetched( + remotePeerId, + announcement, + fetchedHead, + ); + } + if (expected === 'successor' && claimsGenesisHistory(head)) { + fail('catalog-native-receiver-slice', 'one-row synchronization does not accept genesis'); + } + return this.synchronizeOnePublicOpenRowFetched( + remotePeerId, + announcement, + deployment, + fetchedHead, + ); + } + + private async bootstrapEmptyPublicOpenCatalogFetched( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + fetchedHead: FetchedRfc64PublicCatalogHeadV1, + ): Promise { + const head = fetchedHead.envelope; + assertEmptyGenesisHead(head); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1( + deriveAuthorCatalogScopeFromHeadV1(head.payload), + ); + const inventoryDigest = computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest, + rows: [], + }); + const current = this.options.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + head.payload.authorAddress, + ); + const replay = assertEmptyGenesisHistory(current, head, inventoryDigest); + const scope = nativeScope(announcement, head); + const fetchedDirectory = await this.options.contentTransport.fetchCatalogObject( + remotePeerId, + { + ...scope, + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + targetObjectDigest: head.payload.directoryRootDigest, + }, + { timeoutMs: this.#timeoutMs }, + ); + if (fetchedDirectory === null) { + fail('catalog-native-receiver-not-found', 'genesis directory root was not found'); + } + try { + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1( + fetchedDirectory.envelope, + head.payload.bucketCount, + ); + const directory = fetchedDirectory.envelope; + assertAuthorCatalogDirectoryNodeScopeBindingV1( + directory.payload, + deriveAuthorCatalogScopeFromHeadV1(head.payload), + ); + if (directory.issuer !== head.issuer) { + throw new Error('genesis directory issuer differs from head'); + } + const path = verifyAuthorCatalogDirectoryPathV1(head, [directory], '0' as never); + const descriptor = readVerifiedAuthorCatalogBucketDescriptorV1(path, head); + if ( + descriptor.bucketId !== '0' + || descriptor.bucketDigest !== ZERO_DIGEST32_V1 + || descriptor.rowCount !== '0' + || descriptor.byteLength !== '0' + ) { + throw new Error('genesis directory descriptor is not canonically empty'); + } + } catch (cause) { + fail('catalog-native-receiver-catalog', 'empty genesis directory is invalid', cause); + } + + try { + await this.options.controlObjects.stageVerifiedObjects([ + fetchedDirectory, + fetchedHead, + ]); + } catch (cause) { + fail('catalog-native-receiver-catalog', 'verified genesis objects could not be staged', cause); + } + + let appliedHeadStatus: 'applied' | 'existing'; + if (replay) { + appliedHeadStatus = 'existing'; + } else { + try { + appliedHeadStatus = this.options.inventory.compareAndSwapAppliedCatalogHeadV1({ + catalogScopeDigest, + authorAddress: head.payload.authorAddress, + expectedCurrentCatalogHeadDigest: null, + currentCatalogHeadDigest: head.objectDigest as Digest32V1, + appliedInventoryDigest: inventoryDigest, + catalogVersion: head.payload.version, + inventoryRowCount: '0' as never, + }).status; + } catch (cause) { + const reconciled = this.options.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + head.payload.authorAddress, + ); + if (!isExactEmptyGenesisSnapshot(reconciled, head, inventoryDigest)) { + fail( + 'catalog-native-receiver-history', + 'genesis applied-head CAS lost to a different durable history', + cause, + ); + } + appliedHeadStatus = 'existing'; + } + } + const postRead = this.options.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + head.payload.authorAddress, + ); + if (!isExactEmptyGenesisSnapshot(postRead, head, inventoryDigest)) { + fail( + 'catalog-native-receiver-history', + 'empty genesis durable post-read differs in head, digest, version, or row count', + ); } + return Object.freeze({ + inventoryDigest, + catalogHeadDigest: head.objectDigest as Digest32V1, + inventoryRowCount: 0 as const, + activatedTripleCount: 0 as const, + stagedObjectCount: 2 as const, + appliedHeadStatus, + }); + } + + private async synchronizeOnePublicOpenRowFetched( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + deployment: CatalogSealDeploymentProfileV1, + fetchedHead: FetchedRfc64PublicCatalogHeadV1, + ): Promise { const head = fetchedHead.envelope; assertFirstSliceHead(head); const catalogScopeDigest = computeAuthorCatalogScopeDigestV1( @@ -351,7 +566,8 @@ export class Rfc64PublicCatalogNativeReceiverV1 { head.payload.authorAddress, ); if ( - reconciled?.currentCatalogHeadDigest !== head.objectDigest + reconciled === null + || reconciled.currentCatalogHeadDigest !== head.objectDigest || reconciled.appliedInventoryDigest !== activation.inventoryDigest ) { fail( @@ -408,6 +624,58 @@ function catalogScopeLockKey(announcement: Rfc64PublicCatalogHeadAnnouncementV1) ]); } +function claimsGenesisHistory(head: SignedAuthorCatalogHeadEnvelopeV1): boolean { + return head.payload.version === '0' || head.payload.previousHeadDigest === null; +} + +function assertEmptyGenesisHead(head: SignedAuthorCatalogHeadEnvelopeV1): void { + if ( + head.payload.subGraphName !== null + || head.payload.era !== '0' + || head.payload.bucketCount !== '1' + || head.payload.directoryHeight !== '0' + || head.payload.totalRows !== '0' + || head.payload.version !== '0' + || head.payload.previousHeadDigest !== null + ) { + fail( + 'catalog-native-receiver-slice', + 'genesis bootstrap requires the canonical empty public/open root catalog', + ); + } +} + +function assertEmptyGenesisHistory( + current: AppliedCatalogHeadSnapshotV1 | null, + head: SignedAuthorCatalogHeadEnvelopeV1, + inventoryDigest: Digest32V1, +): boolean { + if (current === null) return false; + if (!isExactEmptyGenesisSnapshot(current, head, inventoryDigest)) { + fail( + 'catalog-native-receiver-history', + 'genesis cannot replace or diverge from existing durable applied history', + ); + } + return true; +} + +function isExactEmptyGenesisSnapshot( + current: AppliedCatalogHeadSnapshotV1 | null, + head: SignedAuthorCatalogHeadEnvelopeV1, + inventoryDigest: Digest32V1, +): boolean { + return current !== null + && current.catalogScopeDigest === computeAuthorCatalogScopeDigestV1( + deriveAuthorCatalogScopeFromHeadV1(head.payload), + ) + && current.authorAddress === head.payload.authorAddress + && current.currentCatalogHeadDigest === head.objectDigest + && current.appliedInventoryDigest === inventoryDigest + && current.catalogVersion === '0' + && current.inventoryRowCount === '0'; +} + function assertMonotonicSuccessorHistory( current: AppliedCatalogHeadSnapshotV1 | null, head: SignedAuthorCatalogHeadEnvelopeV1, diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 5dcf4cc1f5..f4074eb0dd 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, DKGNode, ProtocolRouter, assertAuthorCatalogRowV1, @@ -12,19 +13,24 @@ import { canonicalizeCanonicalGraphScopedAuthorSealBytesV1, computeAuthorCatalogRowDigestV1, computeAuthorCatalogScopeDigestV1, + computeAuthorCatalogHeadObjectDigestV1, computeCanonicalGraphScopedAuthorSealDigestV1, computeKaChunkTreeRootV1, deriveAuthorCatalogScopeFromHeadV1, encodeOpaqueKaBundleV1, type AuthorCatalogRowV1, type AuthorCatalogScopeV1, + type AuthorCatalogHeadV1, type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, type ContextGraphIdV1, type Digest32V1, type EvmAddressV1, type NetworkIdV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; @@ -96,7 +102,10 @@ afterEach(async () => { })); }); -async function openPersistence(label: string): Promise { +async function openPersistence(label: string): Promise<{ + directory: string; + persistence: Rfc64PersistenceV1; +}> { const directory = await mkdtemp(join(tmpdir(), `dkg-rfc64-native-${label}-`)); temporaryDirectories.push(directory); const persistence = await openRfc64PersistenceV1( @@ -104,7 +113,7 @@ async function openPersistence(label: string): Promise { { yieldAfterPurgeBatch: async () => {} }, ); persistences.push(persistence); - return persistence; + return { directory, persistence }; } async function startNode(): Promise { @@ -124,8 +133,21 @@ async function connect(from: DKGNode, to: DKGNode): Promise { } describe('RFC-64 Gate 1 native successor to public SWM', () => { - it('fetches head to row to bundle and activates one exact KA on a live receiver', async () => { + it('bootstraps exact empty genesis then activates one successor without manual seeding', async () => { const fixture = await setupLiveReceiver(); + const genesisEvidence = await fixture.bootstrap(); + expect(genesisEvidence).toEqual({ + inventoryDigest: computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: fixture.scopeDigest, + rows: [], + }), + catalogHeadDigest: fixture.genesis.head.objectDigest, + inventoryRowCount: 0, + activatedTripleCount: 0, + stagedObjectCount: 2, + appliedHeadStatus: 'applied', + }); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); const evidence = await fixture.synchronize(); const expectedRowDigest = computeAuthorCatalogRowDigestV1( @@ -188,6 +210,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { })); expect(fixture.authorObjectRead.mock.calls.map(([digest]) => digest)).toEqual([ + fixture.genesis.head.payload.directoryRootDigest, fixture.successor.head.payload.directoryRootDigest, fixture.successor.bucket?.objectDigest, ]); @@ -197,9 +220,95 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { ); }, 30_000); + it('rejects a signed genesis whose directory is not exactly empty', async () => { + const fixture = await setupLiveReceiver(); + + await expect(fixture.bootstrap(fixture.invalidGenesisAnnouncement)).rejects.toMatchObject({ + code: 'catalog-native-receiver-catalog', + }); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toBeNull(); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + }, 30_000); + + it('retries genesis idempotently after verified staging wins but its CAS crashes', async () => { + const fixture = await setupLiveReceiver(); + const crashGapReceiver = fixture.createReceiver({ + readAppliedCatalogHeadV1: + fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1.bind( + fixture.receiverPersistence.inventory, + ), + compareAndSwapAppliedCatalogHeadV1: () => { + throw new Error('simulated crash after genesis staging and before applied-head CAS'); + }, + }); + + await expect(fixture.bootstrap( + fixture.genesisAnnouncement, + crashGapReceiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-history' }); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toBeNull(); + await expect(fixture.bootstrap()).resolves.toMatchObject({ + appliedHeadStatus: 'applied', + inventoryRowCount: 0, + }); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + }, 30_000); + + it('replays genesis after persistence restart and then accepts its successor', async () => { + const fixture = await setupLiveReceiver(); + await expect(fixture.bootstrap()).resolves.toMatchObject({ + appliedHeadStatus: 'applied', + inventoryRowCount: 0, + }); + await fixture.receiverPersistence.close(); + const reopened = await openRfc64PersistenceV1( + fixture.receiverDirectory, + { yieldAfterPurgeBatch: async () => {} }, + ); + persistences.push(reopened); + const restartedReceiver = fixture.createReceiver( + reopened.inventory, + reopened.controlObjects, + ); + + await expect(fixture.bootstrap( + fixture.genesisAnnouncement, + restartedReceiver, + )).resolves.toMatchObject({ + appliedHeadStatus: 'existing', + inventoryRowCount: 0, + }); + expect(reopened.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toMatchObject({ + currentCatalogHeadDigest: fixture.genesis.head.objectDigest, + appliedInventoryDigest: computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: fixture.scopeDigest, + rows: [], + }), + catalogVersion: '0', + inventoryRowCount: '0', + }); + await expect(fixture.synchronize( + fixture.announcement, + restartedReceiver, + )).resolves.toMatchObject({ + appliedHeadStatus: 'applied', + inventoryRowCount: 1, + }); + }, 30_000); + it('rejects a live bundle whose AuthorAttestation does not recover its catalog author', async () => { const attacker = new ethers.Wallet(`0x${'77'.repeat(32)}`); const fixture = await setupLiveReceiver(attacker); + await fixture.bootstrap(); await expect(fixture.synchronize()).rejects.toMatchObject({ code: 'catalog-native-receiver-transfer', @@ -209,6 +318,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { it('serializes one scope so a competing successor never activates over the winner', async () => { const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); const winner = fixture.synchronize(); const loser = fixture.synchronize(fixture.competingAnnouncement); @@ -223,6 +333,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { it('repairs the semantic-before-CAS crash gap idempotently on a new receiver instance', async () => { const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); const crashGapReceiver = fixture.createReceiver({ readAppliedCatalogHeadV1: fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1.bind( @@ -255,12 +366,14 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { }); async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { - const [authorNode, receiverNode, authorPersistence, receiverPersistence] = await Promise.all([ + const [authorNode, receiverNode, authorOpened, receiverOpened] = await Promise.all([ startNode(), startNode(), openPersistence('author'), openPersistence('receiver'), ]); + const authorPersistence = authorOpened.persistence; + const receiverPersistence = receiverOpened.persistence; await connect(receiverNode, authorNode); const receiverStore = new OxigraphStore(); const scope = { @@ -286,18 +399,6 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { signer, }); const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); - receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1({ - catalogScopeDigest: scopeDigest, - authorAddress: AUTHOR, - expectedCurrentCatalogHeadDigest: null, - currentCatalogHeadDigest: genesis.head.objectDigest as Digest32V1, - appliedInventoryDigest: computeRfc64AppliedInventoryDigestV1({ - catalogScopeDigest: scopeDigest, - rows: [], - }), - catalogVersion: genesis.head.payload.version, - inventoryRowCount: '0' as never, - }); const successor = await produceSparseAuthorCatalogSuccessorV1({ previousHead: genesis.head, previousDirectoryPath: genesis.directoryPath, @@ -316,8 +417,17 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900001001' as never, signer, }); + const invalidGenesis = await buildInvalidEmptyGenesis( + genesis.head, + successor.directoryPath[0]!, + ); const authorObjects = new Map( - [...successor.stagedObjects, ...competingSuccessor.stagedObjects] + [ + ...genesis.stagedObjects, + ...invalidGenesis.stagedObjects, + ...successor.stagedObjects, + ...competingSuccessor.stagedObjects, + ] .map((envelope) => [envelope.objectDigest, envelope]), ); const authorObjectRead = vi.fn(async (digest: Digest32V1) => @@ -325,7 +435,12 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const authorBundleRead = vi.fn(async (digest: Digest32V1) => digest === rowBundle.row.transfer.blobDigest ? rowBundle.bundleBytes : null); const verifiedObjects = await Promise.all( - [...genesis.stagedObjects, ...successor.stagedObjects, ...competingSuccessor.stagedObjects] + [ + ...genesis.stagedObjects, + ...invalidGenesis.stagedObjects, + ...successor.stagedObjects, + ...competingSuccessor.stagedObjects, + ] .map(async (envelope) => ({ envelope, issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), @@ -335,10 +450,18 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const headKeys = staged.objects.find( (keys) => keys.objectDigest === successor.head.objectDigest, ); + const genesisHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === genesis.head.objectDigest, + ); + const invalidGenesisHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === invalidGenesis.head.objectDigest, + ); const competingHeadKeys = staged.objects.find( (keys) => keys.objectDigest === competingSuccessor.head.objectDigest, ); if (headKeys === undefined) throw new Error('successor head was not staged'); + if (genesisHeadKeys === undefined) throw new Error('genesis head was not staged'); + if (invalidGenesisHeadKeys === undefined) throw new Error('invalid genesis head was not staged'); if (competingHeadKeys === undefined) throw new Error('competing successor head was not staged'); const receivedAnnouncements: Rfc64PublicCatalogHeadAnnouncementV1[] = []; const openPolicy = async () => Object.freeze({ @@ -401,25 +524,38 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { catalogHeadObjectDigest: headKeys.objectDigest, signatureVariantDigest: headKeys.signatureVariantDigest, }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const genesisAnnouncement = Object.freeze({ + ...announcement, + catalogVersion: genesis.head.payload.version, + catalogHeadObjectDigest: genesisHeadKeys.objectDigest, + signatureVariantDigest: genesisHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; const competingAnnouncement = Object.freeze({ ...announcement, catalogHeadObjectDigest: competingHeadKeys.objectDigest, signatureVariantDigest: competingHeadKeys.signatureVariantDigest, }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const invalidGenesisAnnouncement = Object.freeze({ + ...genesisAnnouncement, + catalogHeadObjectDigest: invalidGenesisHeadKeys.objectDigest, + signatureVariantDigest: invalidGenesisHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + await authorHeadTransport.announceCatalogHead(receiverNode.peerId, genesisAnnouncement); await authorHeadTransport.announceCatalogHead(receiverNode.peerId, announcement); - expect(receivedAnnouncements).toEqual([announcement]); + expect(receivedAnnouncements).toEqual([genesisAnnouncement, announcement]); const createReceiver = ( inventory: Pick< Rfc64PersistenceV1['inventory'], 'readAppliedCatalogHeadV1' | 'compareAndSwapAppliedCatalogHeadV1' >, + controlObjects = receiverPersistence.controlObjects, ) => new Rfc64PublicCatalogNativeReceiverV1({ - headTransport: receiverHeadTransport, - contentTransport: receiverNativeTransport, - controlObjects: receiverPersistence.controlObjects, - inventory, - store: receiverStore, - }); + headTransport: receiverHeadTransport, + contentTransport: receiverNativeTransport, + controlObjects, + inventory, + store: receiverStore, + }); const receiver = createReceiver(receiverPersistence.inventory); return { announcement, @@ -428,11 +564,22 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { competingAnnouncement, createReceiver, genesis, + genesisAnnouncement, + invalidGenesisAnnouncement, + receiverDirectory: receiverOpened.directory, receiverPersistence, receiverStore, rowBundle, scopeDigest, successor, + bootstrap: ( + selectedAnnouncement = genesisAnnouncement, + selectedReceiver = receiver, + ) => selectedReceiver.bootstrapEmptyPublicOpenCatalog( + authorNode.peerId, + selectedAnnouncement, + DEPLOYMENT, + ), synchronize: ( selectedAnnouncement = announcement, selectedReceiver = receiver, @@ -444,6 +591,49 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { }; } +async function buildInvalidEmptyGenesis( + sourceHead: SignedAuthorCatalogHeadEnvelopeV1, + sourceDirectory: SignedAuthorCatalogDirectoryNodeEnvelopeV1, +): Promise<{ + head: SignedAuthorCatalogHeadEnvelopeV1; + stagedObjects: readonly SignedControlEnvelopeV1[]; +}> { + const headPayload = { + ...sourceHead.payload, + directoryRootDigest: sourceDirectory.objectDigest, + } as AuthorCatalogHeadV1; + const headUnsigned = testUnsignedEnvelope(AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, headPayload); + const head = await signTestEnvelope( + headUnsigned, + computeAuthorCatalogHeadObjectDigestV1(headUnsigned), + ) as SignedAuthorCatalogHeadEnvelopeV1; + return Object.freeze({ head, stagedObjects: Object.freeze([sourceDirectory, head]) }); +} + +function testUnsignedEnvelope( + objectType: string, + payload: unknown, +): UnsignedControlEnvelopeV1 { + return Object.freeze({ + issuer: AUTHOR, + objectType, + payload, + signatureEvidence: Object.freeze({ kind: 'none' }), + signatureSuite: 'eip191-personal-sign-digest-v1', + }) as UnsignedControlEnvelopeV1; +} + +async function signTestEnvelope( + unsigned: UnsignedControlEnvelopeV1, + objectDigest: Digest32V1, +): Promise { + return Object.freeze({ + ...unsigned, + objectDigest, + signature: await AUTHOR_WALLET.signMessage(ethers.getBytes(objectDigest)), + }) as SignedControlEnvelopeV1; +} + async function buildRowBundle( signingWallet: ethers.Wallet = AUTHOR_WALLET, ): Promise<{ row: AuthorCatalogRowV1; bundleBytes: Uint8Array }> { From ef875bfd52edc90d9669a6a0da1a1fbe661f371e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:47:04 +0200 Subject: [PATCH 117/292] test(agent): prove RFC-64 catalog service lifecycle --- .../src/rfc64/public-catalog-service-v1.ts | 18 +- .../rfc64-public-catalog-service-v1.test.ts | 387 ++++++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 3 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 packages/agent/test/rfc64-public-catalog-service-v1.test.ts diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index c26c8d5da4..0c85dc14e7 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -183,11 +183,21 @@ export class Rfc64PublicCatalogServiceV1 { reconcileHead: (remotePeerId, announcement, signal) => this.#stageHeadOnly(remotePeerId, announcement, signal, options.onHeadStaged), } satisfies Rfc64PublicCatalogReceiverReconcilerV1 - : options.native.createReconciler({ - headTransport: this.#transport, - contentTransport: this.#nativeTransport!, + : options.native.createReconciler(Object.freeze({ + // Pass explicit capability objects rather than the owned transport + // instances. The reconciler may fetch, but cannot start/stop protocols + // or retain the router through a runtime-private implementation field. + headTransport: Object.freeze({ + fetchCatalogHead: this.#transport.fetchCatalogHead.bind(this.#transport), + }), + contentTransport: Object.freeze({ + fetchCatalogObject: this.#nativeTransport!.fetchCatalogObject.bind( + this.#nativeTransport!, + ), + fetchKaBundle: this.#nativeTransport!.fetchKaBundle.bind(this.#nativeTransport!), + }), transportTimeoutMs: this.#transportTimeoutMs, - }); + })); this.#receiver = new Rfc64PublicCatalogReceiverV1(reconciler, options.receiver); } diff --git a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts new file mode 100644 index 0000000000..bce49f5ad1 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts @@ -0,0 +1,387 @@ +import { + canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, + computeControlSignatureVariantDigestHex, + type AuthorCatalogScopeV1, + type Digest32V1, + type EvmAddressV1, + type ProtocolRouter, + type TimestampMsV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; +import { describe, expect, it, vi } from 'vitest'; + +import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import type { Rfc64ControlObjectOperationsV1 } from '../src/rfc64/control-object-store-v1.js'; +import { + Rfc64PublicCatalogServiceV1, + type Rfc64PublicCatalogReconcilerClientsV1, +} from '../src/rfc64/public-catalog-service-v1.js'; +import { + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, +} from '../src/rfc64/public-catalog-native-transport-v1.js'; +import type { Rfc64PublicCatalogReceiverReconcilerV1 } from '../src/rfc64/public-catalog-receiver-v1.js'; +import { + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1, + encodeRfc64PublicCatalogHeadAnnouncementV1, + type Rfc64PublicCatalogHeadAnnouncementV1, +} from '../src/rfc64/public-catalog-transport-v1.js'; + +const NETWORK_ID = 'otp:20430' as const; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/service-lifecycle' as const; +const GOVERNANCE_CONTRACT = + '0x2222222222222222222222222222222222222222' as EvmAddressV1; +const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const DELEGATION_DIGEST = `0x${'72'.repeat(32)}` as Digest32V1; + +type RouterHandler = ( + data: Uint8Array, + peerId: { toString(): string }, +) => Promise; + +class RecordingRouter { + readonly handlers = new Map(); + readonly events: string[] = []; + failRegistrationFor: string | undefined; + sendResponse: (protocolId: string) => Promise = async () => Uint8Array.of(0); + + register(protocolId: string, handler: RouterHandler): void { + this.events.push(`register:${protocolId}`); + if (protocolId === this.failRegistrationFor) { + throw new Error(`registration failed for ${protocolId}`); + } + this.handlers.set(protocolId, handler); + } + + unregister(protocolId: string): void { + this.events.push(`unregister:${protocolId}`); + this.handlers.delete(protocolId); + } + + async send( + _peerId: string, + protocolId: string, + _data: Uint8Array, + ): Promise { + this.events.push(`send:${protocolId}`); + return this.sendResponse(protocolId); + } + + asProtocolRouter(): ProtocolRouter { + return this as unknown as ProtocolRouter; + } + + async invoke( + protocolId: string, + data: Uint8Array, + remotePeerId = 'peer-a', + ): Promise { + const handler = this.handlers.get(protocolId); + if (handler === undefined) throw new Error(`protocol is not registered: ${protocolId}`); + return handler(data, { toString: () => remotePeerId }); + } +} + +function controlObjects(): Rfc64ControlObjectOperationsV1 { + return { + namespaceDurability: 'posix-hardlink-no-replace-directory-fsync-v1', + getVerifiedObject: vi.fn(async () => null), + stageVerifiedObjects: vi.fn(async (input) => ({ + durable: true, + namespaceDurability: 'posix-hardlink-no-replace-directory-fsync-v1', + objects: input.map(({ envelope }) => ({ + objectDigest: envelope.objectDigest, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ), + })), + })), + }; +} + +function inertReconciler(): Rfc64PublicCatalogReceiverReconcilerV1 { + return { + isHeadApplied: async () => false, + reconcileHead: async () => 'not-found', + }; +} + +function nativeOptions( + createReconciler: ( + clients: Readonly, + ) => Rfc64PublicCatalogReceiverReconcilerV1, +) { + return { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + createReconciler, + } as const; +} + +function acceptPolicy(service: Rfc64PublicCatalogServiceV1) { + return service.acceptOpenPolicy({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); +} + +function announcement(policyDigest: Digest32V1): Rfc64PublicCatalogHeadAnnouncementV1 { + return { + kind: 'rfc64-author-catalog-head-availability-v1', + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + catalogVersion: '0', + policyDigest, + catalogHeadObjectDigest: `0x${'aa'.repeat(32)}`, + signatureVariantDigest: `0x${'bb'.repeat(32)}`, + } as Rfc64PublicCatalogHeadAnnouncementV1; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +function countEvent(router: RecordingRouter, event: string): number { + return router.events.filter((candidate) => candidate === event).length; +} + +describe('RFC-64 public catalog service v1 lifecycle ownership', () => { + it('constructs one reconciler with frozen fetch-only capabilities and the configured timeout', async () => { + const router = new RecordingRouter(); + let clients: Readonly | undefined; + const createReconciler = vi.fn((input: Readonly) => { + clients = input; + return inertReconciler(); + }); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: controlObjects(), + transportTimeoutMs: 4_321, + native: nativeOptions(createReconciler), + }); + + expect(createReconciler).toHaveBeenCalledTimes(1); + expect(clients).toBeDefined(); + expect(Object.keys(clients!)).toEqual([ + 'headTransport', + 'contentTransport', + 'transportTimeoutMs', + ]); + expect(clients!.transportTimeoutMs).toBe(4_321); + expect(Object.keys(clients!.headTransport)).toEqual(['fetchCatalogHead']); + expect(Object.keys(clients!.contentTransport)).toEqual([ + 'fetchCatalogObject', + 'fetchKaBundle', + ]); + expect('start' in clients!.headTransport).toBe(false); + expect('stop' in clients!.headTransport).toBe(false); + expect('router' in clients!.headTransport).toBe(false); + expect('start' in clients!.contentTransport).toBe(false); + expect('stop' in clients!.contentTransport).toBe(false); + expect('router' in clients!.contentTransport).toBe(false); + expect(Object.isFrozen(clients)).toBe(true); + expect(Object.isFrozen(clients!.headTransport)).toBe(true); + expect(Object.isFrozen(clients!.contentTransport)).toBe(true); + + service.start(); + service.start(); + expect(createReconciler).toHaveBeenCalledTimes(1); + await service.close(); + }); + + it('starts native content protocols before exposing head announcements', async () => { + const router = new RecordingRouter(); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: controlObjects(), + native: nativeOptions(() => inertReconciler()), + }); + + service.start(); + expect(router.events.filter((event) => event.startsWith('register:'))).toEqual([ + `register:${RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1}`, + `register:${RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1}`, + `register:${RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1}`, + `register:${RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1}`, + ]); + await service.close(); + }); + + it('rolls native content protocols back when head transport startup fails', async () => { + const router = new RecordingRouter(); + router.failRegistrationFor = RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1; + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: controlObjects(), + native: nativeOptions(() => inertReconciler()), + }); + + expect(() => service.start()).toThrow('registration failed'); + expect(service.started).toBe(false); + expect(router.handlers.size).toBe(0); + expect(countEvent( + router, + `unregister:${RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1}`, + )).toBe(1); + expect(countEvent( + router, + `unregister:${RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1}`, + )).toBe(1); + await service.close(); + }); + + it('rejects new schedules while close drains, keeps fetch clients live, then stops once', async () => { + const router = new RecordingRouter(); + const reconcileStarted = deferred(); + const reconcileResult = deferred<'applied'>(); + let clients: Readonly | undefined; + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: controlObjects(), + receiver: { retryBackoffMs: 0 }, + native: nativeOptions((input) => { + clients = input; + return { + isHeadApplied: async () => false, + reconcileHead: async () => { + reconcileStarted.resolve(); + return reconcileResult.promise; + }, + }; + }), + }); + const policy = acceptPolicy(service); + const head = announcement(policy.policyDigest); + const wireHead = encodeRfc64PublicCatalogHeadAnnouncementV1(head); + + service.start(); + await expect(router.invoke( + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + wireHead, + )).resolves.toEqual(Uint8Array.of(1)); + await reconcileStarted.promise; + expect(service.stats().receiver.scheduled).toBe(1); + + const closePromise = service.close(); + expect(service.started).toBe(false); + expect(router.handlers.has(RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1)).toBe(true); + expect(router.handlers.has(RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1)).toBe(true); + expect(router.handlers.has(RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1)).toBe(true); + expect(router.handlers.has(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1)).toBe(true); + + await expect(clients!.headTransport.fetchCatalogHead('peer-b', head)).resolves.toBeNull(); + await expect(clients!.contentTransport.fetchKaBundle('peer-b', { + kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + networkId: head.networkId, + contextGraphId: head.contextGraphId, + subGraphName: head.subGraphName, + authorAddress: head.authorAddress, + catalogEra: head.catalogEra, + catalogVersion: head.catalogVersion, + policyDigest: head.policyDigest, + catalogHeadObjectDigest: head.catalogHeadObjectDigest, + blobDigest: `0x${'cc'.repeat(32)}` as Digest32V1, + byteLength: '0', + })).resolves.toBeNull(); + + await expect(router.invoke( + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + wireHead, + 'peer-c', + )).resolves.toEqual(Uint8Array.of(1)); + expect(service.stats().receiver.scheduled).toBe(1); + + reconcileResult.resolve('applied'); + await closePromise; + await service.close(); + expect(router.handlers.size).toBe(0); + for (const protocolId of [ + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, + ]) { + expect(countEvent(router, `unregister:${protocolId}`)).toBe(1); + } + }); + + it('keeps diagnostic staging-only mode explicitly non-applied across replays', async () => { + const router = new RecordingRouter(); + const store = controlObjects(); + const onHeadStaged = vi.fn(); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: store, + onHeadStaged, + }); + const policy = acceptPolicy(service); + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1773900000000' as TimestampMsV1, + signer: { + issuer: AUTHOR, + signDigest: (digest) => AUTHOR_WALLET.signMessage(digest), + }, + }); + const head: Rfc64PublicCatalogHeadAnnouncementV1 = { + kind: 'rfc64-author-catalog-head-availability-v1', + networkId: produced.head.payload.networkId, + contextGraphId: produced.head.payload.contextGraphId, + subGraphName: produced.head.payload.subGraphName, + authorAddress: produced.head.payload.authorAddress, + catalogEra: produced.head.payload.era, + catalogVersion: produced.head.payload.version, + policyDigest: policy.policyDigest, + catalogHeadObjectDigest: produced.head.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + produced.head.objectDigest, + produced.head.signature, + ), + }; + const headBytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(produced.head); + const foundResponse = new Uint8Array(headBytes.byteLength + 1); + foundResponse[0] = 1; + foundResponse.set(headBytes, 1); + router.sendResponse = async (protocolId) => protocolId === RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1 + ? foundResponse + : Uint8Array.of(0); + + service.start(); + const wireHead = encodeRfc64PublicCatalogHeadAnnouncementV1(head); + await router.invoke(RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, wireHead); + await service.whenReceiverIdle(); + await router.invoke(RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, wireHead); + await service.whenReceiverIdle(); + + expect(store.stageVerifiedObjects).toHaveBeenCalledTimes(2); + expect(onHeadStaged).toHaveBeenCalledTimes(2); + expect(service.stats().receiver).toMatchObject({ + stagedOnly: 2, + applied: 0, + dedupedAlreadyApplied: 0, + }); + await service.close(); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 1c3513503d..bb31de8ebd 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -122,6 +122,7 @@ export default defineConfig({ "test/rfc64-secure-filesystem-policy-v1.test.ts", "test/rfc64-public-catalog-transport-v1.test.ts", "test/rfc64-public-catalog-receiver-v1.test.ts", + "test/rfc64-public-catalog-service-v1.test.ts", "test/rfc64-public-catalog-gate1.integration.test.ts", "test/rfc64-public-catalog-native-transport-v1.test.ts", "test/rfc64-public-catalog-native-gate1.integration.test.ts", From f84894cfdbd61b49588e177aa8bf343b66f4112c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:50:17 +0200 Subject: [PATCH 118/292] feat(agent): persist RFC-64 catalog provider content --- packages/agent/package.json | 2 + packages/agent/scripts/test-package-root.mjs | 2 + .../rfc64/control-object-store-v1-internal.ts | 312 +++++++++++++++++- .../src/rfc64/control-object-store-v1.ts | 2 + .../src/rfc64/ka-bundle-store-v1-internal.ts | 284 ++++++++++++++++ .../agent/src/rfc64/ka-bundle-store-v1.ts | 13 + .../agent/src/rfc64/persistence-layout-v1.ts | 7 + packages/agent/src/rfc64/persistence-v1.ts | 70 +++- .../rfc64-control-object-store-v1.test.ts | 144 ++++++-- .../test/rfc64-persistence-owner.typecheck.ts | 5 + ...c64-persistent-catalog-provider-v1.test.ts | 173 ++++++++++ packages/agent/vitest.unit.config.ts | 1 + 12 files changed, 968 insertions(+), 47 deletions(-) create mode 100644 packages/agent/src/rfc64/ka-bundle-store-v1-internal.ts create mode 100644 packages/agent/src/rfc64/ka-bundle-store-v1.ts create mode 100644 packages/agent/test/rfc64-persistent-catalog-provider-v1.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index f7f515f8cc..0cec54150a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -23,6 +23,8 @@ "./dist/rfc64/control-object-store-v1-internal.js": null, "./dist/rfc64/control-object-store-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, + "./dist/rfc64/ka-bundle-store-v1-internal.js": null, + "./dist/rfc64/ka-bundle-store-v1.js": null, "./dist/rfc64/open-catalog-policy-v1.js": null, "./dist/rfc64/persistence-layout-v1.js": null, "./dist/rfc64/persistence-root-ownership-v1-internal.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index c2417595dd..154a8c3736 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -29,6 +29,8 @@ const blockedRfc64Modules = [ 'control-object-store-v1-internal.js', 'control-object-store-v1.js', 'durable-file-store-v1.js', + 'ka-bundle-store-v1-internal.js', + 'ka-bundle-store-v1.js', 'open-catalog-policy-v1.js', 'persistence-layout-v1.js', 'persistence-root-ownership-v1-internal.js', diff --git a/packages/agent/src/rfc64/control-object-store-v1-internal.ts b/packages/agent/src/rfc64/control-object-store-v1-internal.ts index 0990c8c148..e635fbfd84 100644 --- a/packages/agent/src/rfc64/control-object-store-v1-internal.ts +++ b/packages/agent/src/rfc64/control-object-store-v1-internal.ts @@ -1,3 +1,4 @@ +import { lstat, opendir } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { @@ -42,6 +43,7 @@ export { RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH }; export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; export const RFC64_CONTROL_OBJECT_STORE_FILE_MODE = RFC64_SECURE_FILE_MODE_V1; export const RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS = 16; +export const RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT = 64; export const RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY = RFC64_POSIX_NAMESPACE_DURABILITY_V1; export const RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY = @@ -115,6 +117,14 @@ export interface GetVerifiedControlObjectInputV1 { ) => Promise; } +export interface GetVerifiedControlObjectByDigestInputV1 { + readonly objectDigest: Digest32V1; + /** Re-establishes current generic envelope cryptography before any cache hit is returned. */ + readonly verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; +} + export interface StoredVerifiedControlObjectV1 { readonly envelope: SignedControlEnvelopeV1; readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; @@ -122,7 +132,10 @@ export interface StoredVerifiedControlObjectV1 { export type Rfc64ControlObjectOperationsV1 = Pick< Rfc64ControlObjectStoreV1, - 'namespaceDurability' | 'stageVerifiedObjects' | 'getVerifiedObject' + | 'namespaceDurability' + | 'stageVerifiedObjects' + | 'getVerifiedObject' + | 'getVerifiedObjectByDigest' >; export interface Rfc64ControlObjectStoreV1 { @@ -140,6 +153,13 @@ export interface Rfc64ControlObjectStoreV1 { getVerifiedObject( input: GetVerifiedControlObjectInputV1, ): Promise; + /** + * Resolve one deterministic stored signature variant by object digest, then + * reconstruct and reverify the exact envelope before returning it. + */ + getVerifiedObjectByDigest( + input: GetVerifiedControlObjectByDigestInputV1, + ): Promise; /** Reject new operations, then settle every admitted read/write. */ close(): Promise; } @@ -173,7 +193,9 @@ interface PreparedControlObjectFileV1 { } interface PreparedControlObjectGroupV1 { + readonly objectDigest: Digest32V1; readonly object: PreparedControlObjectFileV1; + readonly signatureVariantDigests: readonly Digest32V1[]; readonly signatures: readonly PreparedControlObjectFileV1[]; } @@ -181,6 +203,11 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { #closed = false; #closePromise: Promise | null = null; readonly #inFlightOperations = new Set>(); + readonly #objectOperationTails = new Map>(); + readonly #reservedSignatureVariants = new Map< + Digest32V1, + Map + >(); readonly #durableFiles: Rfc64DurableFileStoreV1; readonly namespaceDurability = rfc64NamespaceDurabilityV1(); @@ -203,14 +230,19 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { const groups = this.planStageGroups(prepared); const operation = (async () => { await settleAllOrThrowV1(groups.map(async (group) => { - await this.putExactFile( - group.object.relativePath, - group.object.bytes, - group.object.kind, - ); - await settleAllOrThrowV1(group.signatures.map(async (signature) => { - await this.putExactFile(signature.relativePath, signature.bytes, signature.kind); - }), 'RFC-64 control-object signature staging failed'); + const releaseAdmission = await this.reserveSignatureVariants(group); + try { + await this.putExactFile( + group.object.relativePath, + group.object.bytes, + group.object.kind, + ); + await settleAllOrThrowV1(group.signatures.map(async (signature) => { + await this.putExactFile(signature.relativePath, signature.bytes, signature.kind); + }), 'RFC-64 control-object signature staging failed'); + } finally { + await releaseAdmission(); + } }), 'RFC-64 control-object batch staging failed'); const result = prepared.map((item) => Object.freeze({ @@ -239,7 +271,48 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { if (typeof verifyIssuerSignature !== 'function') { fail('control-store-input', 'verifyIssuerSignature must be a function'); } + return this.trackOperation(this.readVerifiedObject( + objectDigest, + signatureVariantDigest, + verifyIssuerSignature, + )); + } + + getVerifiedObjectByDigest( + input: GetVerifiedControlObjectByDigestInputV1, + ): Promise { + this.requireOpen(); + const objectDigest = snapshotDigest(input.objectDigest, 'objectDigest'); + const verifyIssuerSignature = input.verifyIssuerSignature; + if (typeof verifyIssuerSignature !== 'function') { + fail('control-store-input', 'verifyIssuerSignature must be a function'); + } const operation = (async () => { + const signatureVariantDigests = await this.listSignatureVariantDigests(objectDigest); + if (signatureVariantDigests.length === 0) return null; + const loaded = await this.readVerifiedObject( + objectDigest, + signatureVariantDigests[0], + verifyIssuerSignature, + ); + if (loaded === null) { + fail( + 'control-store-corrupt', + 'selected control signature variant disappeared during exact lookup', + ); + } + return loaded; + })(); + return this.trackOperation(operation); + } + + private async readVerifiedObject( + objectDigest: Digest32V1, + signatureVariantDigest: Digest32V1, + verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise, + ): Promise { const objectPath = this.objectRelativePath(objectDigest); const signaturePath = this.signatureRelativePath( objectDigest, @@ -284,8 +357,145 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { fail('control-store-verification', 'stored control object signature verification failed', cause); } return Object.freeze({ envelope, issuerSignature }); - })(); - return this.trackOperation(operation); + } + + private async listSignatureVariantDigests( + objectDigest: Digest32V1, + ): Promise { + const objectHex = objectDigest.slice(2); + const signaturesPath = join(this.rootPath, SIGNATURES_DIRECTORY); + const shardPath = join(signaturesPath, objectHex.slice(0, 2)); + const directoryPath = join(shardPath, objectHex); + await this.requireSecureSignatureDirectory( + this.rootPath, + 'control-object store root', + false, + ); + await this.requireSecureSignatureDirectory( + signaturesPath, + 'control signature store directory', + false, + ); + if (!await this.requireSecureSignatureDirectory( + shardPath, + 'control signature shard directory', + true, + )) { + await this.requireSecureSignatureDirectory( + this.rootPath, + 'control-object store root', + false, + ); + await this.requireSecureSignatureDirectory( + signaturesPath, + 'control signature store directory', + false, + ); + return Object.freeze([]); + } + if (!await this.requireSecureSignatureDirectory( + directoryPath, + 'control signature variant directory', + true, + )) { + await this.requireSecureSignatureDirectory( + this.rootPath, + 'control-object store root', + false, + ); + await this.requireSecureSignatureDirectory( + signaturesPath, + 'control signature store directory', + false, + ); + await this.requireSecureSignatureDirectory( + shardPath, + 'control signature shard directory', + false, + ); + return Object.freeze([]); + } + + let directory: Awaited>; + try { + directory = await opendir(directoryPath); + } catch (cause) { + fail('control-store-io', 'failed to enumerate control signature variants', cause); + } + const variants: Digest32V1[] = []; + let entryCount = 0; + try { + for await (const entry of directory) { + entryCount += 1; + if (entryCount > RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT * 4) { + fail( + 'control-store-corrupt', + 'control signature variant directory exceeds its entry ceiling', + ); + } + if (!entry.isFile()) { + fail( + entry.isSymbolicLink() ? 'control-store-unsafe-path' : 'control-store-corrupt', + 'control signature variant entry is not a regular file', + ); + } + if (/^\.[0-9a-f]{64}\.jcs\.[0-9a-f]{32}\.tmp$/.test(entry.name)) continue; + const match = /^([0-9a-f]{64})\.jcs$/.exec(entry.name); + if (match === null) { + fail( + 'control-store-corrupt', + 'control signature variant directory contains an unknown entry', + ); + } + variants.push(`0x${match[1]}` as Digest32V1); + } + } catch (cause) { + if (cause instanceof Rfc64ControlObjectStoreErrorV1) throw cause; + fail('control-store-io', 'failed to iterate control signature variants', cause); + } + // Detect path replacement while the bounded directory handle was consumed. + await this.requireSecureSignatureDirectory(this.rootPath, 'control-object store root', false); + await this.requireSecureSignatureDirectory( + signaturesPath, + 'control signature store directory', + false, + ); + await this.requireSecureSignatureDirectory( + shardPath, + 'control signature shard directory', + false, + ); + await this.requireSecureSignatureDirectory( + directoryPath, + 'control signature variant directory', + false, + ); + if (variants.length > RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT) { + fail('control-store-corrupt', 'control object exceeds its signature-variant ceiling'); + } + variants.sort(); + return Object.freeze(variants); + } + + private async requireSecureSignatureDirectory( + path: string, + label: string, + optional: boolean, + ): Promise { + let entry: Awaited>; + try { + entry = await lstat(path); + } catch (cause) { + if (optional && isNodeError(cause, 'ENOENT')) return false; + fail('control-store-io', `failed to inspect ${label}`, cause); + } + if (entry.isSymbolicLink() || !entry.isDirectory()) { + fail('control-store-unsafe-path', `${label} must be a non-symlink directory`); + } + await mapDurableFileErrors(async () => { + await assertRfc64ExistingDirectoryV1(path, label, { access: 'owner-only' }); + }); + return true; } close(): Promise { @@ -303,7 +513,9 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { prepared: readonly PreparedStoredControlObjectV1[], ): readonly PreparedControlObjectGroupV1[] { const groups = new Map; readonly signatures: Map; }>(); const addUnique = ( @@ -332,7 +544,9 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { let group = groups.get(objectPath); if (group === undefined) { group = { + objectDigest: item.objectDigest, object: addUnique(objects, objectPath, item.unsignedBytes, 'object'), + signatureVariantDigests: new Set(), signatures: new Map(), }; groups.set(objectPath, group); @@ -342,6 +556,7 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { 'one immutable control-object key resolved to conflicting prepared bytes', ); } + group.signatureVariantDigests.add(item.signatureVariantDigest); addUnique( group.signatures, this.signatureRelativePath(item.objectDigest, item.signatureVariantDigest), @@ -350,7 +565,9 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { ); } return Object.freeze([...groups.values()].map((group) => Object.freeze({ + objectDigest: group.objectDigest, object: group.object, + signatureVariantDigests: Object.freeze([...group.signatureVariantDigests]), signatures: Object.freeze([...group.signatures.values()]), }))); } @@ -400,6 +617,73 @@ class FileRfc64ControlObjectStoreV1 implements Rfc64ControlObjectStoreV1 { if (this.#closed) fail('control-store-closed', 'control object store is closed'); } + private reserveSignatureVariants( + group: PreparedControlObjectGroupV1, + ): Promise<() => Promise> { + return this.withObjectCoordination(group.objectDigest, async () => { + const storedVariants = await this.listSignatureVariantDigests(group.objectDigest); + let reservations = this.#reservedSignatureVariants.get(group.objectDigest); + if (reservations === undefined) reservations = new Map(); + const admittedVariants = new Set([ + ...storedVariants, + ...reservations.keys(), + ...group.signatureVariantDigests, + ]); + if (admittedVariants.size + > RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT) { + fail( + 'control-store-input', + 'control object would exceed its signature-variant ceiling', + ); + } + for (const digest of group.signatureVariantDigests) { + reservations.set(digest, (reservations.get(digest) ?? 0) + 1); + } + this.#reservedSignatureVariants.set(group.objectDigest, reservations); + + let released = false; + return async () => { + if (released) return; + released = true; + await this.withObjectCoordination(group.objectDigest, async () => { + const current = this.#reservedSignatureVariants.get(group.objectDigest); + if (current === undefined) { + fail('control-store-corrupt', 'signature-variant admission reservation was lost'); + } + for (const digest of group.signatureVariantDigests) { + const count = current.get(digest); + if (count === undefined || count < 1) { + fail('control-store-corrupt', 'signature-variant admission count was lost'); + } + if (count === 1) current.delete(digest); + else current.set(digest, count - 1); + } + if (current.size === 0) this.#reservedSignatureVariants.delete(group.objectDigest); + }); + }; + }); + } + + private async withObjectCoordination( + objectDigest: Digest32V1, + operation: () => Promise, + ): Promise { + const previous = this.#objectOperationTails.get(objectDigest) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolveGate) => { release = resolveGate; }); + const tail = previous.catch(() => undefined).then(() => gate); + this.#objectOperationTails.set(objectDigest, tail); + await previous.catch(() => undefined); + try { + return await operation(); + } finally { + release(); + if (this.#objectOperationTails.get(objectDigest) === tail) { + this.#objectOperationTails.delete(objectDigest); + } + } + } + private trackOperation(operation: Promise): Promise { this.#inFlightOperations.add(operation); void operation.finally(() => { @@ -417,6 +701,12 @@ function byteArraysEqual(left: Uint8Array, right: Uint8Array): boolean { return true; } +function isNodeError(cause: unknown, code: string): boolean { + return cause instanceof Error + && 'code' in cause + && (cause as NodeJS.ErrnoException).code === code; +} + async function settleAllOrThrowV1( operations: readonly Promise[], aggregateMessage: string, diff --git a/packages/agent/src/rfc64/control-object-store-v1.ts b/packages/agent/src/rfc64/control-object-store-v1.ts index 0c67003fc0..81d9e9327a 100644 --- a/packages/agent/src/rfc64/control-object-store-v1.ts +++ b/packages/agent/src/rfc64/control-object-store-v1.ts @@ -3,11 +3,13 @@ export { RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, RFC64_CONTROL_OBJECT_STORE_FILE_MODE, RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, + RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT, RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH, RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, Rfc64ControlObjectStoreErrorV1, type GetVerifiedControlObjectInputV1, + type GetVerifiedControlObjectByDigestInputV1, type Rfc64ControlObjectStoreErrorCodeV1, type Rfc64ControlObjectStoreNamespaceDurabilityV1, type Rfc64ControlObjectOperationsV1, diff --git a/packages/agent/src/rfc64/ka-bundle-store-v1-internal.ts b/packages/agent/src/rfc64/ka-bundle-store-v1-internal.ts new file mode 100644 index 0000000000..c72054b8bd --- /dev/null +++ b/packages/agent/src/rfc64/ka-bundle-store-v1-internal.ts @@ -0,0 +1,284 @@ +import { join, resolve } from 'node:path'; + +import { + assertCanonicalDigest, + decodeOpaqueKaBundleV1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; + +import { + Rfc64DurableFileErrorV1, + assertRfc64ExistingDirectoryV1, + createRfc64DurableFileStoreV1, + ensureRfc64SecureDirectoryTreeV1, + type Rfc64DurableFileStoreV1, +} from './durable-file-store-v1.js'; +import { + RFC64_SECURE_DIRECTORY_MODE_V1, + RFC64_SECURE_FILE_MODE_V1, + rfc64NamespaceDurabilityV1, + type Rfc64NamespaceDurabilityV1, +} from './secure-filesystem-policy-v1.js'; +import { + RFC64_KA_BUNDLE_STORE_RELATIVE_PATH, + resolveRfc64KaBundleStorePathV1, +} from './persistence-layout-v1.js'; +import type { Rfc64PersistenceRootOwnershipV1 } from './persistence-root-ownership-v1-internal.js'; + +export { RFC64_KA_BUNDLE_STORE_RELATIVE_PATH }; +export const RFC64_KA_BUNDLE_STORE_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; +export const RFC64_KA_BUNDLE_STORE_FILE_MODE = RFC64_SECURE_FILE_MODE_V1; +/** Gate-1 native transport response ceiling minus its one-byte status prefix. */ +export const RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1 = 8 * 1024 * 1024 - 1; + +const BUNDLES_DIRECTORY = 'bundles'; +const BUNDLE_FILE_SUFFIX = '.bundle'; + +export const RFC64_KA_BUNDLE_STORE_ERROR_CODES_V1 = Object.freeze([ + 'ka-bundle-store-input', + 'ka-bundle-store-verification', + 'ka-bundle-store-unsafe-path', + 'ka-bundle-store-corrupt', + 'ka-bundle-store-io', + 'ka-bundle-store-durability', + 'ka-bundle-store-closed', +] as const); + +export type Rfc64KaBundleStoreErrorCodeV1 = + (typeof RFC64_KA_BUNDLE_STORE_ERROR_CODES_V1)[number]; + +export class Rfc64KaBundleStoreErrorV1 extends Error { + constructor( + readonly code: Rfc64KaBundleStoreErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + if (!RFC64_KA_BUNDLE_STORE_ERROR_CODES_V1.includes(code)) { + throw new TypeError(`Unsupported RFC-64 KA-bundle store error code: ${code}`); + } + this.name = 'Rfc64KaBundleStoreErrorV1'; + } +} + +export interface PutRfc64KaBundleInputV1 { + readonly blobDigest: Digest32V1; + /** One complete strict opaque KA-bundle frame. */ + readonly bundleBytes: Uint8Array; +} + +export interface PutRfc64KaBundleResultV1 { + readonly durable: true; + readonly namespaceDurability: Rfc64NamespaceDurabilityV1; + readonly blobDigest: Digest32V1; + readonly byteLength: number; +} + +export type Rfc64KaBundleOperationsV1 = Pick< + Rfc64KaBundleStoreV1, + 'namespaceDurability' | 'putKaBundle' | 'readKaBundleByDigest' +>; + +export interface Rfc64KaBundleStoreV1 { + readonly rootPath: string; + readonly closed: boolean; + readonly namespaceDurability: Rfc64NamespaceDurabilityV1; + putKaBundle(input: PutRfc64KaBundleInputV1): Promise; + readKaBundleByDigest(blobDigest: Digest32V1): Promise; + close(): Promise; +} + +type Rfc64KaBundleStoreFileKindV1 = 'ka-bundle'; + +class FileRfc64KaBundleStoreV1 implements Rfc64KaBundleStoreV1 { + #closed = false; + #closePromise: Promise | null = null; + readonly #inFlightOperations = new Set>(); + readonly #durableFiles: Rfc64DurableFileStoreV1; + readonly namespaceDurability = rfc64NamespaceDurabilityV1(); + + constructor( + readonly rootPath: string, + durableFiles: Rfc64DurableFileStoreV1, + ) { + this.#durableFiles = durableFiles; + } + + get closed(): boolean { + return this.#closed; + } + + putKaBundle(input: PutRfc64KaBundleInputV1): Promise { + this.requireOpen(); + const prepared = prepareBundle(input); + const operation = (async () => { + await mapDurableFileErrors(async () => { + await this.#durableFiles.putExactBytes({ + relativePath: bundleRelativePath(prepared.blobDigest), + bytes: prepared.bundleBytes, + maxBytes: RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1, + label: 'opaque KA bundle', + kind: 'ka-bundle', + }); + }); + return Object.freeze({ + durable: true as const, + namespaceDurability: this.namespaceDurability, + blobDigest: prepared.blobDigest, + byteLength: prepared.bundleBytes.byteLength, + }); + })(); + return this.trackOperation(operation); + } + + readKaBundleByDigest(blobDigestInput: Digest32V1): Promise { + this.requireOpen(); + const blobDigest = snapshotDigest(blobDigestInput, 'blobDigest'); + const operation = (async () => { + const bundleBytes = await mapDurableFileErrors(async () => + this.#durableFiles.readOptionalBoundedBytes({ + relativePath: bundleRelativePath(blobDigest), + maxBytes: RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1, + label: 'opaque KA bundle', + })); + if (bundleBytes === null) return null; + assertBundleMatchesDigest(bundleBytes, blobDigest, 'ka-bundle-store-corrupt'); + return bundleBytes; + })(); + return this.trackOperation(operation); + } + + close(): Promise { + if (this.#closePromise !== null) return this.#closePromise; + this.#closed = true; + this.#closePromise = (async () => { + while (this.#inFlightOperations.size > 0) { + await Promise.allSettled([...this.#inFlightOperations]); + } + })(); + return this.#closePromise; + } + + private requireOpen(): void { + if (this.#closed) fail('ka-bundle-store-closed', 'KA-bundle store is closed'); + } + + private trackOperation(operation: Promise): Promise { + this.#inFlightOperations.add(operation); + void operation.finally(() => { + this.#inFlightOperations.delete(operation); + }).catch(() => undefined); + return operation; + } +} + +interface PreparedRfc64KaBundleV1 { + readonly blobDigest: Digest32V1; + readonly bundleBytes: Uint8Array; +} + +function prepareBundle(input: PutRfc64KaBundleInputV1): PreparedRfc64KaBundleV1 { + const blobDigest = snapshotDigest(input?.blobDigest, 'blobDigest'); + const bundleBytes = snapshotBundleBytes(input?.bundleBytes); + assertBundleMatchesDigest(bundleBytes, blobDigest, 'ka-bundle-store-verification'); + return Object.freeze({ blobDigest, bundleBytes }); +} + +function snapshotBundleBytes(value: unknown): Uint8Array { + if (!(value instanceof Uint8Array)) { + fail('ka-bundle-store-input', 'bundleBytes must be a Uint8Array'); + } + if (!(value.buffer instanceof ArrayBuffer)) { + fail('ka-bundle-store-input', 'bundleBytes must not use shared backing memory'); + } + if ((value.buffer as ArrayBuffer & { readonly resizable?: boolean }).resizable === true) { + fail('ka-bundle-store-input', 'bundleBytes must not use resizable backing memory'); + } + if (value.byteLength < 1 || value.byteLength > RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1) { + fail( + 'ka-bundle-store-input', + `bundleBytes must contain 1..${RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1} bytes`, + ); + } + return Uint8Array.from(value); +} + +function assertBundleMatchesDigest( + bundleBytes: Uint8Array, + expectedDigest: Digest32V1, + errorCode: 'ka-bundle-store-verification' | 'ka-bundle-store-corrupt', +): void { + try { + const decoded = decodeOpaqueKaBundleV1(bundleBytes); + if (decoded.blobDigest !== expectedDigest) { + throw new Error('opaque KA-bundle digest differs from its immutable key'); + } + } catch (cause) { + fail(errorCode, 'opaque KA bundle is not canonical for its blob digest', cause); + } +} + +function bundleRelativePath(blobDigest: Digest32V1): string { + const hex = blobDigest.slice(2); + return join(BUNDLES_DIRECTORY, hex.slice(0, 2), `${hex}${BUNDLE_FILE_SUFFIX}`); +} + +function snapshotDigest(value: unknown, label: string): Digest32V1 { + try { + assertCanonicalDigest(value as string, label); + } catch (cause) { + fail('ka-bundle-store-input', `${label} is not a canonical digest`, cause); + } + return value as Digest32V1; +} + +async function mapDurableFileErrors(operation: () => Promise): Promise { + try { + return await operation(); + } catch (cause) { + if (!(cause instanceof Rfc64DurableFileErrorV1)) throw cause; + const code: Rfc64KaBundleStoreErrorCodeV1 = cause.code === 'input' + ? 'ka-bundle-store-input' + : cause.code === 'unsafe-path' + ? 'ka-bundle-store-unsafe-path' + : cause.code === 'corrupt' + ? 'ka-bundle-store-corrupt' + : cause.code === 'io' + ? 'ka-bundle-store-io' + : 'ka-bundle-store-durability'; + return fail(code, cause.message, cause); + } +} + +/** Open only with package-internal authority backed by the live persistence lease. */ +export async function openRfc64KaBundleStoreForOwnedPersistenceRootV1( + ownership: Rfc64PersistenceRootOwnershipV1, +): Promise { + const rfc64RootPath = ownership.assertHeldAndGetRootPathV1(); + const rootPath = resolveRfc64KaBundleStorePathV1(rfc64RootPath); + await mapDurableFileErrors(async () => { + await assertRfc64ExistingDirectoryV1( + rfc64RootPath, + 'RFC-64 persistence root', + { access: 'owner-only' }, + ); + await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath); + await ensureRfc64SecureDirectoryTreeV1(join(rootPath, BUNDLES_DIRECTORY), rootPath); + }); + return new FileRfc64KaBundleStoreV1( + resolve(rootPath), + createRfc64DurableFileStoreV1(rootPath), + ); +} + +function fail( + code: Rfc64KaBundleStoreErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64KaBundleStoreErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/src/rfc64/ka-bundle-store-v1.ts b/packages/agent/src/rfc64/ka-bundle-store-v1.ts new file mode 100644 index 0000000000..abe532e3ca --- /dev/null +++ b/packages/agent/src/rfc64/ka-bundle-store-v1.ts @@ -0,0 +1,13 @@ +export { + RFC64_KA_BUNDLE_STORE_DIRECTORY_MODE, + RFC64_KA_BUNDLE_STORE_ERROR_CODES_V1, + RFC64_KA_BUNDLE_STORE_FILE_MODE, + RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1, + RFC64_KA_BUNDLE_STORE_RELATIVE_PATH, + Rfc64KaBundleStoreErrorV1, + type PutRfc64KaBundleInputV1, + type PutRfc64KaBundleResultV1, + type Rfc64KaBundleOperationsV1, + type Rfc64KaBundleStoreErrorCodeV1, + type Rfc64KaBundleStoreV1, +} from './ka-bundle-store-v1-internal.js'; diff --git a/packages/agent/src/rfc64/persistence-layout-v1.ts b/packages/agent/src/rfc64/persistence-layout-v1.ts index 2d46697669..45182d9b1e 100644 --- a/packages/agent/src/rfc64/persistence-layout-v1.ts +++ b/packages/agent/src/rfc64/persistence-layout-v1.ts @@ -3,8 +3,11 @@ import { resolve } from 'node:path'; export const RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1 = 'rfc64-sync' as const; export const RFC64_INVENTORY_DATABASE_FILENAME_V1 = 'inventory-v1.sqlite3' as const; export const RFC64_CONTROL_OBJECT_STORE_DIRECTORY_NAME_V1 = 'control-objects-v1' as const; +export const RFC64_KA_BUNDLE_STORE_DIRECTORY_NAME_V1 = 'ka-bundles-v1' as const; export const RFC64_CONTROL_OBJECT_STORE_RELATIVE_PATH = `${RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1}/${RFC64_CONTROL_OBJECT_STORE_DIRECTORY_NAME_V1}` as const; +export const RFC64_KA_BUNDLE_STORE_RELATIVE_PATH = + `${RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1}/${RFC64_KA_BUNDLE_STORE_DIRECTORY_NAME_V1}` as const; /** Canonical boundary shared by every resource protected by the RFC-64 lease. */ export function resolveRfc64PersistenceRootV1(dataDir: string): string { @@ -21,3 +24,7 @@ export function resolveRfc64InventoryDatabasePathV1(dataDir: string): string { export function resolveRfc64ControlObjectStorePathV1(rfc64RootPath: string): string { return resolve(rfc64RootPath, RFC64_CONTROL_OBJECT_STORE_DIRECTORY_NAME_V1); } + +export function resolveRfc64KaBundleStorePathV1(rfc64RootPath: string): string { + return resolve(rfc64RootPath, RFC64_KA_BUNDLE_STORE_DIRECTORY_NAME_V1); +} diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index 7c43906daf..9df6ff3c13 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -9,6 +9,11 @@ import { type Rfc64ControlObjectStoreV1, } from './control-object-store-v1.js'; import { openRfc64ControlObjectStoreForOwnedPersistenceRootV1 } from './control-object-store-v1-internal.js'; +import { + type Rfc64KaBundleOperationsV1, + type Rfc64KaBundleStoreV1, +} from './ka-bundle-store-v1.js'; +import { openRfc64KaBundleStoreForOwnedPersistenceRootV1 } from './ka-bundle-store-v1-internal.js'; import { resolveRfc64PersistenceRootV1 } from './persistence-layout-v1.js'; import { getRfc64PersistenceRootOwnershipForInventoryV1 } from './persistence-root-ownership-v1-internal.js'; @@ -24,8 +29,10 @@ export interface Rfc64PersistenceV1 { readonly inventory: Rfc64InventoryV1OperationsV1; /** Non-owning cache operations; lifecycle methods remain private to this owner. */ readonly controlObjects: Rfc64ControlObjectOperationsV1; + /** Durable content-addressed opaque KA bundles served by the native catalog transport. */ + readonly kaBundles: Rfc64KaBundleOperationsV1; readonly closed: boolean; - /** Drain the control store before releasing inventory ownership. */ + /** Drain every durable content store before releasing inventory ownership. */ close(): Promise; } @@ -34,21 +41,26 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { #closePromise: Promise | null = null; readonly #ownedInventory: Rfc64InventoryV1Foundation; readonly #ownedControlObjectStore: Rfc64ControlObjectStoreV1; + readonly #ownedKaBundleStore: Rfc64KaBundleStoreV1; readonly inventory: Rfc64InventoryV1OperationsV1; readonly controlObjects: Rfc64ControlObjectOperationsV1; + readonly kaBundles: Rfc64KaBundleOperationsV1; constructor( readonly rootPath: string, ownedInventory: Rfc64InventoryV1Foundation, ownedControlObjectStore: Rfc64ControlObjectStoreV1, + ownedKaBundleStore: Rfc64KaBundleStoreV1, ) { this.#ownedInventory = ownedInventory; this.#ownedControlObjectStore = ownedControlObjectStore; + this.#ownedKaBundleStore = ownedKaBundleStore; this.inventory = createRfc64InventoryOperationsViewV1( ownedInventory, () => this.requireOpen(), ); this.controlObjects = createControlObjectOperationsView(ownedControlObjectStore); + this.kaBundles = createKaBundleOperationsView(ownedKaBundleStore); } get closed(): boolean { @@ -64,10 +76,12 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { private async closeOwnedResources(): Promise { const failures: unknown[] = []; - try { - await this.#ownedControlObjectStore.close(); - } catch (cause) { - failures.push(cause); + const cacheClosures = await Promise.allSettled([ + this.#ownedControlObjectStore.close(), + this.#ownedKaBundleStore.close(), + ]); + for (const outcome of cacheClosures) { + if (outcome.status === 'rejected') failures.push(outcome.reason); } try { this.#ownedInventory.close(); @@ -93,12 +107,24 @@ function createControlObjectOperationsView( stageVerifiedObjects: controlObjectStore.stageVerifiedObjects.bind(controlObjectStore), getVerifiedObject: controlObjectStore.getVerifiedObject.bind(controlObjectStore), + getVerifiedObjectByDigest: + controlObjectStore.getVerifiedObjectByDigest.bind(controlObjectStore), + }); +} + +function createKaBundleOperationsView( + kaBundleStore: Rfc64KaBundleStoreV1, +): Rfc64KaBundleOperationsV1 { + return Object.freeze({ + namespaceDurability: kaBundleStore.namespaceDurability, + putKaBundle: kaBundleStore.putKaBundle.bind(kaBundleStore), + readKaBundleByDigest: kaBundleStore.readKaBundleByDigest.bind(kaBundleStore), }); } /** * Acquire the inventory lease, finish bounded stale-candidate cleanup, then - * open the immutable control-object cache under that same ownership boundary. + * open the immutable control-object and KA-bundle caches under that ownership boundary. */ export async function openRfc64PersistenceV1( dataDir: string, @@ -111,25 +137,41 @@ export async function openRfc64PersistenceV1( const rootPath = resolveRfc64PersistenceRootV1(dataDir); const inventory = await openInventoryV1(dataDir); + let controlObjectStore: Rfc64ControlObjectStoreV1 | undefined; + let kaBundleStore: Rfc64KaBundleStoreV1 | undefined; try { for (;;) { const batch = inventory.purgeNextStartupStaleCandidateBatch(); if (batch.done) break; await yieldAfterPurgeBatch(); } - const controlObjectStore = await openRfc64ControlObjectStoreForOwnedPersistenceRootV1( - getRfc64PersistenceRootOwnershipForInventoryV1(inventory), + const ownership = getRfc64PersistenceRootOwnershipForInventoryV1(inventory); + controlObjectStore = await openRfc64ControlObjectStoreForOwnedPersistenceRootV1(ownership); + kaBundleStore = await openRfc64KaBundleStoreForOwnedPersistenceRootV1(ownership); + return new OwnedRfc64PersistenceV1( + rootPath, + inventory, + controlObjectStore, + kaBundleStore, ); - return new OwnedRfc64PersistenceV1(rootPath, inventory, controlObjectStore); } catch (cause) { + const failures: unknown[] = [cause]; + const cacheClosures = await Promise.allSettled([ + controlObjectStore?.close(), + kaBundleStore?.close(), + ].filter((operation): operation is Promise => operation !== undefined)); + for (const outcome of cacheClosures) { + if (outcome.status === 'rejected') failures.push(outcome.reason); + } try { inventory.close(); } catch (closeCause) { - throw new AggregateError( - [cause, closeCause], - 'RFC-64 persistence startup and inventory cleanup both failed', - ); + failures.push(closeCause); } - throw cause; + if (failures.length === 1) throw cause; + throw new AggregateError( + failures, + 'RFC-64 persistence startup and owned-resource cleanup failed', + ); } } diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 674d6d5fad..3396a3bea9 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -1,5 +1,5 @@ -import { readFile, stat, unlink, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { mkdir, readFile, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; import { computeControlObjectDigestHex, @@ -23,6 +23,7 @@ import { RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, RFC64_CONTROL_OBJECT_STORE_ERROR_CODES_V1, RFC64_CONTROL_OBJECT_STORE_FILE_MODE, + RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT, RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, RFC64_CONTROL_OBJECT_STORE_POSIX_NAMESPACE_DURABILITY, RFC64_CONTROL_OBJECT_STORE_WINDOWS_NAMESPACE_DURABILITY, @@ -64,10 +65,18 @@ const finalizedEip1271Call: CurrentFinalizedEvmCallV1 = async (request) => ({ async function contractSignatureVariantsFixture(): Promise< readonly [StageVerifiedControlObjectV1, StageVerifiedControlObjectV1] > { + const variants = await contractSignatureVariantBatchFixture(2, 'variants'); + return [variants[0]!, variants[1]!]; +} + +async function contractSignatureVariantBatchFixture( + count: number, + sequence: string, +): Promise { const unsigned = { issuer: SAFE, objectType: 'dkg-rfc64-control-store-contract-test-v1', - payload: { sequence: 'variants' }, + payload: { sequence }, signatureEvidence: { kind: 'eip1271-current-finalized', chainId: '20430', @@ -76,28 +85,19 @@ async function contractSignatureVariantsFixture(): Promise< signatureSuite: 'eip1271-current-finalized-v1', } satisfies UnsignedControlEnvelopeV1; const objectDigest = computeControlObjectDigestHex(unsigned); - const firstEnvelope = { - ...unsigned, - objectDigest, - signature: '0x12', - } as SignedControlEnvelopeV1; - const secondEnvelope = { + const envelopes = Array.from({ length: count }, (_, index) => ({ ...unsigned, objectDigest, - signature: '0x1234', - } as SignedControlEnvelopeV1; - const [firstProof, secondProof] = await Promise.all([ - verifyControlEnvelopeIssuerSignatureV1(firstEnvelope, { + signature: `0x${(index + 1).toString(16).padStart(4, '0')}`, + } as SignedControlEnvelopeV1)); + const proofs = await Promise.all(envelopes.map((envelope) => + verifyControlEnvelopeIssuerSignatureV1(envelope, { callEvmAtCurrentFinalized: finalizedEip1271Call, - }), - verifyControlEnvelopeIssuerSignatureV1(secondEnvelope, { - callEvmAtCurrentFinalized: finalizedEip1271Call, - }), - ]); - return [{ envelope: firstEnvelope, issuerSignature: firstProof }, { - envelope: secondEnvelope, - issuerSignature: secondProof, - }]; + }))); + return Object.freeze(envelopes.map((envelope, index) => Object.freeze({ + envelope, + issuerSignature: proofs[index], + }))); } describe('RFC-64 durable control-object store v1', () => { @@ -326,8 +326,108 @@ describe('RFC-64 durable control-object store v1', () => { signatureVariantDigest: secondPaths.signatureDigest, verifyIssuerSignature, })).resolves.toMatchObject({ envelope: second.envelope }); + const deterministic = firstPaths.signatureDigest < secondPaths.signatureDigest + ? first.envelope + : second.envelope; + await expect(store.getVerifiedObjectByDigest({ + objectDigest: first.envelope.objectDigest as Digest32V1, + verifyIssuerSignature, + })).resolves.toMatchObject({ envelope: deterministic }); + }); + + it('atomically refuses a 65th valid signature variant without poisoning digest lookup', async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const variants = await contractSignatureVariantBatchFixture( + RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT + 1, + 'variant-ceiling', + ); + const initial = variants.slice( + 0, + RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT - 1, + ); + for ( + let offset = 0; + offset < initial.length; + offset += RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS + ) { + await store.stageVerifiedObjects(initial.slice( + offset, + offset + RFC64_CONTROL_OBJECT_STORE_MAX_STAGE_OBJECTS, + )); + } + + const boundary = await Promise.allSettled([ + store.stageVerifiedObjects([ + variants[RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT - 1], + ]), + store.stageVerifiedObjects([ + variants[RFC64_CONTROL_OBJECT_STORE_MAX_SIGNATURE_VARIANTS_PER_OBJECT], + ]), + ]); + expect(boundary.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1); + const rejected = boundary.filter((outcome) => outcome.status === 'rejected'); + expect(rejected).toHaveLength(1); + expect(rejected[0]).toMatchObject({ + reason: expect.objectContaining({ code: 'control-store-input' }), + }); + + const verifyIssuerSignature = (envelope: SignedControlEnvelopeV1) => + verifyControlEnvelopeIssuerSignatureV1(envelope, { + callEvmAtCurrentFinalized: finalizedEip1271Call, + }); + await expect(store.getVerifiedObjectByDigest({ + objectDigest: variants[0].envelope.objectDigest as Digest32V1, + verifyIssuerSignature, + })).resolves.toMatchObject({ + envelope: expect.objectContaining({ objectDigest: variants[0].envelope.objectDigest }), + }); }); + it.runIf(process.platform !== 'win32')( + 'fails closed when a signature shard is replaced by an owner-only symlink', + async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('unsafe-signature-shard'); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + const shardPath = dirname(dirname(paths.signature)); + const outsidePath = join(dataDir, 'outside-signature-shard'); + await mkdir(outsidePath, { mode: RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE }); + applyRfc64OwnerOnlyPermissionsSyncV1( + outsidePath, + RFC64_CONTROL_OBJECT_STORE_DIRECTORY_MODE, + { entryKind: 'directory' }, + ); + await rm(shardPath, { recursive: true }); + await symlink(outsidePath, shardPath, 'dir'); + + await expect(store.getVerifiedObjectByDigest({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + }, + ); + + it.runIf(process.platform !== 'win32')( + 'rejects a nonregular entry even when its name has the durable temp shape', + async () => { + const dataDir = await temporaryDataDirectory(); + const store = await openRfc64ControlObjectStoreV1(dataDir); + const fixture = await signedFixture('unsafe-temp-entry'); + await store.stageVerifiedObjects([fixture]); + const paths = pathsFor(dataDir, fixture.envelope); + const tempName = `.${basename(paths.signature)}.${'ab'.repeat(16)}.tmp`; + await symlink(paths.signature, join(dirname(paths.signature), tempName)); + + await expect(store.getVerifiedObjectByDigest({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).rejects.toMatchObject({ code: 'control-store-unsafe-path' }); + }, + ); + it('allows independent digest keys to stage concurrently without a store-wide queue', async () => { const dataDir = await temporaryDataDirectory(); const store = await openRfc64ControlObjectStoreV1(dataDir); diff --git a/packages/agent/test/rfc64-persistence-owner.typecheck.ts b/packages/agent/test/rfc64-persistence-owner.typecheck.ts index a12fa05eba..3aa41a10a2 100644 --- a/packages/agent/test/rfc64-persistence-owner.typecheck.ts +++ b/packages/agent/test/rfc64-persistence-owner.typecheck.ts @@ -1,6 +1,7 @@ import type { Rfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; import type { Rfc64InventoryV1Foundation } from '../src/rfc64/inventory-v1/index.js'; import { openRfc64ControlObjectStoreForOwnedPersistenceRootV1 } from '../src/rfc64/control-object-store-v1-internal.js'; +import { openRfc64KaBundleStoreForOwnedPersistenceRootV1 } from '../src/rfc64/ka-bundle-store-v1-internal.js'; declare const persistence: Rfc64PersistenceV1; declare const ownedInventory: Rfc64InventoryV1Foundation; @@ -10,6 +11,8 @@ declare const ownedInventory: Rfc64InventoryV1Foundation; persistence.inventory.close(); // @ts-expect-error control-store lifecycle belongs exclusively to persistence await persistence.controlObjects.close(); +// @ts-expect-error KA-bundle-store lifecycle belongs exclusively to persistence +await persistence.kaBundles.close(); // @ts-expect-error the non-owning inventory view cannot mint sibling resources persistence.inventory.controlObjectStoreOwnership; // @ts-expect-error startup purge authority is not a shared inventory operation @@ -23,3 +26,5 @@ await openRfc64ControlObjectStoreForOwnedPersistenceRootV1( ); // @ts-expect-error a raw filesystem path cannot bypass inventory lease ownership await openRfc64ControlObjectStoreForOwnedPersistenceRootV1('/tmp/rfc64-sync'); +// @ts-expect-error a raw filesystem path cannot bypass inventory lease ownership +await openRfc64KaBundleStoreForOwnedPersistenceRootV1('/tmp/rfc64-sync'); diff --git a/packages/agent/test/rfc64-persistent-catalog-provider-v1.test.ts b/packages/agent/test/rfc64-persistent-catalog-provider-v1.test.ts new file mode 100644 index 0000000000..607598300d --- /dev/null +++ b/packages/agent/test/rfc64-persistent-catalog-provider-v1.test.ts @@ -0,0 +1,173 @@ +import { stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + encodeOpaqueKaBundleV1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + RFC64_KA_BUNDLE_STORE_DIRECTORY_MODE, + RFC64_KA_BUNDLE_STORE_FILE_MODE, + RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1, + RFC64_KA_BUNDLE_STORE_RELATIVE_PATH, +} from '../src/rfc64/ka-bundle-store-v1.js'; +import { + openRfc64PersistenceV1, + type Rfc64PersistenceV1, +} from '../src/rfc64/persistence-v1.js'; +import { + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1, +} from '../src/rfc64/public-catalog-native-transport-v1.js'; +import { + createTemporaryDataDirectoryFixture, + pathsFor, + signedFixture, +} from './support/rfc64-control-object-store-fixtures.js'; + +const UTF8 = new TextEncoder(); +const MISSING_DIGEST = `0x${'ff'.repeat(32)}` as Digest32V1; +const temporaryDirectories = createTemporaryDataDirectoryFixture(); +const { temporaryDataDirectory } = temporaryDirectories; +const persistences: Rfc64PersistenceV1[] = []; + +afterEach(async () => { + await Promise.allSettled(persistences.splice(0).map((persistence) => persistence.close())); + await temporaryDirectories.cleanup(); +}); + +async function openPersistence(dataDir: string): Promise { + const persistence = await openRfc64PersistenceV1(dataDir, { + yieldAfterPurgeBatch: async () => {}, + }); + persistences.push(persistence); + return persistence; +} + +function bundlePath(dataDir: string, blobDigest: Digest32V1): string { + const hex = blobDigest.slice(2); + return join( + dataDir, + RFC64_KA_BUNDLE_STORE_RELATIVE_PATH, + 'bundles', + hex.slice(0, 2), + `${hex}.bundle`, + ); +} + +describe('RFC-64 persistent native catalog providers v1', () => { + it('reopens exact verified control objects and content-addressed opaque bundles', async () => { + const dataDir = await temporaryDataDirectory(); + const fixture = await signedFixture('persistent-provider-reopen'); + const bundle = encodeOpaqueKaBundleV1( + UTF8.encode(' "v" .\n'), + UTF8.encode('canonical-author-seal'), + ); + const first = await openPersistence(dataDir); + + await first.controlObjects.stageVerifiedObjects([fixture]); + const put = await first.kaBundles.putKaBundle({ + blobDigest: bundle.blobDigest, + bundleBytes: bundle.bundleBytes, + }); + expect(put).toEqual({ + durable: true, + namespaceDurability: first.kaBundles.namespaceDurability, + blobDigest: bundle.blobDigest, + byteLength: bundle.bundleBytes.byteLength, + }); + expect(Object.isFrozen(put)).toBe(true); + + const verifyFirst = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + await expect(first.controlObjects.getVerifiedObjectByDigest({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + verifyIssuerSignature: verifyFirst, + })).resolves.toMatchObject({ envelope: fixture.envelope }); + expect(verifyFirst).toHaveBeenCalledTimes(1); + await expect(first.kaBundles.readKaBundleByDigest(bundle.blobDigest)) + .resolves.toEqual(bundle.bundleBytes); + + if (process.platform !== 'win32') { + expect((await stat(join(dataDir, RFC64_KA_BUNDLE_STORE_RELATIVE_PATH))).mode & 0o777) + .toBe(RFC64_KA_BUNDLE_STORE_DIRECTORY_MODE); + expect((await stat(bundlePath(dataDir, bundle.blobDigest))).mode & 0o777) + .toBe(RFC64_KA_BUNDLE_STORE_FILE_MODE); + } + + await first.close(); + const reopened = await openPersistence(dataDir); + const verifyReopened = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + const loaded = await reopened.controlObjects.getVerifiedObjectByDigest({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + verifyIssuerSignature: verifyReopened, + }); + expect(loaded?.envelope).toEqual(fixture.envelope); + expect(verifyReopened).toHaveBeenCalledTimes(1); + await expect(reopened.kaBundles.readKaBundleByDigest(bundle.blobDigest)) + .resolves.toEqual(bundle.bundleBytes); + }); + + it('returns no provider value for an unknown digest without invoking verification', async () => { + const persistence = await openPersistence(await temporaryDataDirectory()); + const verifier = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + + await expect(persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: MISSING_DIGEST, + verifyIssuerSignature: verifier, + })).resolves.toBeNull(); + expect(verifier).not.toHaveBeenCalled(); + await expect(persistence.kaBundles.readKaBundleByDigest(MISSING_DIGEST)) + .resolves.toBeNull(); + }); + + it('rejects mismatched and over-ceiling bundle writes before publication', async () => { + const dataDir = await temporaryDataDirectory(); + const persistence = await openPersistence(dataDir); + const bundle = encodeOpaqueKaBundleV1(UTF8.encode('projection'), UTF8.encode('seal')); + + expect(RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1 + 1) + .toBe(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1); + expect(() => persistence.kaBundles.putKaBundle({ + blobDigest: MISSING_DIGEST, + bundleBytes: bundle.bundleBytes, + })).toThrow(expect.objectContaining({ code: 'ka-bundle-store-verification' })); + await expect(persistence.kaBundles.readKaBundleByDigest(MISSING_DIGEST)) + .resolves.toBeNull(); + + expect(() => persistence.kaBundles.putKaBundle({ + blobDigest: MISSING_DIGEST, + bundleBytes: new Uint8Array(RFC64_KA_BUNDLE_STORE_MAX_BYTES_V1 + 1), + })).toThrow(expect.objectContaining({ code: 'ka-bundle-store-input' })); + await expect(stat(bundlePath(dataDir, MISSING_DIGEST))) + .rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('fails closed on corrupted durable object variants and bundle bytes after reopen', async () => { + const dataDir = await temporaryDataDirectory(); + const fixture = await signedFixture('persistent-provider-corrupt'); + const firstBundle = encodeOpaqueKaBundleV1(UTF8.encode('first'), UTF8.encode('seal-a')); + const replacement = encodeOpaqueKaBundleV1(UTF8.encode('second'), UTF8.encode('seal-b')); + const first = await openPersistence(dataDir); + await first.controlObjects.stageVerifiedObjects([fixture]); + await first.kaBundles.putKaBundle({ + blobDigest: firstBundle.blobDigest, + bundleBytes: firstBundle.bundleBytes, + }); + await first.close(); + + await writeFile(pathsFor(dataDir, fixture.envelope).signature, '{}'); + await writeFile(bundlePath(dataDir, firstBundle.blobDigest), replacement.bundleBytes); + + const reopened = await openPersistence(dataDir); + const verifier = vi.fn(verifyControlEnvelopeIssuerSignatureV1); + await expect(reopened.controlObjects.getVerifiedObjectByDigest({ + objectDigest: fixture.envelope.objectDigest as Digest32V1, + verifyIssuerSignature: verifier, + })).rejects.toMatchObject({ code: 'control-store-corrupt' }); + expect(verifier).not.toHaveBeenCalled(); + await expect(reopened.kaBundles.readKaBundleByDigest(firstBundle.blobDigest)) + .rejects.toMatchObject({ code: 'ka-bundle-store-corrupt' }); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index bb31de8ebd..6476e4c531 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -126,6 +126,7 @@ export default defineConfig({ "test/rfc64-public-catalog-gate1.integration.test.ts", "test/rfc64-public-catalog-native-transport-v1.test.ts", "test/rfc64-public-catalog-native-gate1.integration.test.ts", + "test/rfc64-persistent-catalog-provider-v1.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 0cbde1d32a571531b8fc7ff1c3c8ff0947306bf4 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:28:13 +0200 Subject: [PATCH 119/292] feat(agent): produce verified RFC-64 catalog successor --- packages/agent/src/index.ts | 1 + .../public-catalog-successor-producer-v1.ts | 392 ++++++++++++++++++ ...blic-catalog-successor-producer-v1.test.ts | 235 +++++++++++ packages/agent/vitest.unit.config.ts | 1 + 4 files changed, 629 insertions(+) create mode 100644 packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 15595e08a3..22b58aa90f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -42,6 +42,7 @@ export * from './rfc64/public-catalog-receiver-v1.js'; export * from './rfc64/public-catalog-service-v1.js'; export * from './rfc64/public-catalog-native-transport-v1.js'; export * from './rfc64/public-catalog-native-receiver-v1.js'; +export * from './rfc64/public-catalog-successor-producer-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts new file mode 100644 index 0000000000..ca12d36bf6 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Production boundary for the Gate-1 public/open one-row successor slice. + * + * The adapter deliberately delegates catalog construction and every bundle, + * seal, and projection codec check to the canonical RFC-64 helpers. Neither + * the immutable bundle nor the signed control objects are exposed to durable + * provider storage until the complete successor has passed those checks. + */ + +import { + KA_TRANSFER_CHUNK_SIZE_BYTES_V1, + KA_TRANSFER_CHUNK_SIZE_V1, + KA_TRANSFER_CODEC_V1, + KA_TRANSFER_PROJECTION_V1, + ZERO_DIGEST32_V1, + canonicalizeAuthorCatalogRowV1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + computeKaChunkTreeRootV1, + encodeOpaqueKaBundleV1, + parseCanonicalAuthorCatalogRowV1, + parseCanonicalGraphScopedAuthorSealV1, + readVerifiedCatalogSealBindingV1, + readVerifiedCgSharedProjectionMetadataV1, + readVerifiedTransferredCatalogBundleMetadataV1, + verifyCatalogSealBindingV1, + verifyCgSharedProjectionV1, + verifyTransferredCatalogBundleV1, + type AssertionCoordinateV1, + type AuthorCatalogRowV1, + type ByteLengthV1, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, + type CountV1, + type DecimalU64V1, + type Digest32V1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type TimestampMsV1, + type VerifiedCatalogSealBindingSnapshotV1, + type VerifiedCgSharedProjectionMetadataV1, + type VerifiedTransferredCatalogBundleMetadataV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; + +import { + produceSparseAuthorCatalogSuccessorV1, + type ProducedAuthorCatalogPublicationV1, + type Rfc64AuthorCatalogEip191SignerV1, +} from './author-catalog-producer.js'; +import type { + Rfc64ControlObjectOperationsV1, + StageVerifiedControlObjectsResultV1, +} from './control-object-store-v1.js'; +import { assertRecoverableAuthorAttestationV1 } from './public-catalog-native-receiver-v1.js'; + +export type Rfc64PublicCatalogSuccessorProducerErrorCodeV1 = + | 'catalog-successor-producer-input' + | 'catalog-successor-producer-history' + | 'catalog-successor-producer-binding' + | 'catalog-successor-producer-verification' + | 'catalog-successor-producer-bundle-stage' + | 'catalog-successor-producer-control-stage'; + +export class Rfc64PublicCatalogSuccessorProducerErrorV1 extends Error { + constructor( + readonly code: Rfc64PublicCatalogSuccessorProducerErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'Rfc64PublicCatalogSuccessorProducerErrorV1'; + } +} + +export interface StageRfc64PublicCatalogBundleV1 { + readonly blobDigest: Digest32V1; + /** Independently backed exact opaque bundle bytes. */ + readonly bundleBytes: Uint8Array; +} + +export interface Rfc64PublicCatalogSuccessorProducerOptionsV1 { + readonly controlObjects: Pick; + /** Must resolve only after the immutable bundle is durably available by digest. */ + readonly stageKaBundle: (input: StageRfc64PublicCatalogBundleV1) => Promise; +} + +export interface ProduceAndStagePublicOpenOneRowSuccessorInputV1 { + readonly previousHead: SignedAuthorCatalogHeadEnvelopeV1; + readonly previousDirectoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; + readonly previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null; + readonly assertionCoordinate: AssertionCoordinateV1; + readonly projectionBytes: Uint8Array; + readonly seal: CanonicalGraphScopedAuthorSealV1; + readonly deployment: CatalogSealDeploymentProfileV1; + readonly issuedAt: TimestampMsV1; + readonly catalogSigner: Rfc64AuthorCatalogEip191SignerV1; +} + +export interface ProducedAndStagedPublicOpenOneRowSuccessorV1 { + readonly publication: ProducedAuthorCatalogPublicationV1; + readonly row: Readonly; + readonly bundleDigest: Digest32V1; + /** Fresh caller-owned copy; provider storage retains its own staged bytes. */ + readonly bundleBytes: Uint8Array; + readonly sealBinding: VerifiedCatalogSealBindingSnapshotV1; + readonly transfer: VerifiedTransferredCatalogBundleMetadataV1; + readonly projection: VerifiedCgSharedProjectionMetadataV1; + readonly stagedControlObjects: StageVerifiedControlObjectsResultV1; +} + +/** + * Build, authenticate, fully verify, and durably stage one public/open + * root-lane successor. Bundle-first staging prevents a served head from + * referring to an unavailable bundle; a later control-stage failure can leave + * only an unreferenced immutable bundle. + */ +export class Rfc64PublicCatalogSuccessorProducerV1 { + readonly #stageVerifiedObjects: Rfc64ControlObjectOperationsV1['stageVerifiedObjects']; + readonly #stageKaBundle: Rfc64PublicCatalogSuccessorProducerOptionsV1['stageKaBundle']; + + constructor(options: Rfc64PublicCatalogSuccessorProducerOptionsV1) { + if ( + typeof options?.controlObjects?.stageVerifiedObjects !== 'function' + || typeof options?.stageKaBundle !== 'function' + ) { + fail('catalog-successor-producer-input', 'producer staging dependencies are incomplete'); + } + this.#stageVerifiedObjects = options.controlObjects.stageVerifiedObjects + .bind(options.controlObjects); + this.#stageKaBundle = options.stageKaBundle; + } + + async produceAndStage( + input: ProduceAndStagePublicOpenOneRowSuccessorInputV1, + ): Promise { + const prepared = prepareRowAndBundle(input); + assertSupportedPreviousSlice(input.previousHead, input.previousBucket); + + // PR #1780's strict typed-row reconstruction, exact deployment binding, + // and recoverable EOA author proof all run before catalog signing or I/O. + let initialSealBinding: ReturnType; + try { + initialSealBinding = verifyCatalogSealBindingV1( + prepared.scope, + prepared.row, + prepared.sealBytes, + prepared.deployment, + ); + assertRecoverableAuthorAttestationV1( + readVerifiedCatalogSealBindingV1(initialSealBinding), + ); + } catch (cause) { + fail( + 'catalog-successor-producer-binding', + 'author seal does not bind to the exact public catalog row', + cause, + ); + } + + let publication: ProducedAuthorCatalogPublicationV1; + try { + publication = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: input.previousHead, + previousDirectoryPath: input.previousDirectoryPath, + previousBucket: input.previousBucket, + selectedBucketId: '0' as DecimalU64V1, + nextRows: [prepared.row], + issuedAt: input.issuedAt, + signer: input.catalogSigner, + }); + } catch (cause) { + fail( + 'catalog-successor-producer-history', + 'one-row successor could not be built from the supplied history', + cause, + ); + } + + const producedBucket = publication.bucket; + if ( + publication.head.payload.subGraphName !== null + || publication.head.payload.bucketCount !== '1' + || publication.head.payload.directoryHeight !== '0' + || publication.head.payload.totalRows !== '1' + || publication.head.payload.version === '0' + || publication.head.payload.previousHeadDigest === null + || producedBucket === null + || producedBucket.payload.rows.length !== 1 + ) { + fail( + 'catalog-successor-producer-verification', + 'produced catalog is outside the public/open one-row successor slice', + ); + } + const producedRow = producedBucket.payload.rows[0]; + if (producedRow === undefined) { + fail( + 'catalog-successor-producer-verification', + 'produced one-row bucket did not retain its catalog row', + ); + } + + let transferred: ReturnType; + let transfer: VerifiedTransferredCatalogBundleMetadataV1; + let projection: VerifiedCgSharedProjectionMetadataV1; + let sealBinding: VerifiedCatalogSealBindingSnapshotV1; + try { + transferred = verifyTransferredCatalogBundleV1( + publication.head, + producedRow, + prepared.bundleBytes, + prepared.deployment, + ); + transfer = readVerifiedTransferredCatalogBundleMetadataV1( + transferred, + publication.head, + producedRow, + prepared.deployment, + ); + sealBinding = readVerifiedCatalogSealBindingV1(transfer.catalogSealBinding); + assertRecoverableAuthorAttestationV1(sealBinding); + const verifiedProjection = verifyCgSharedProjectionV1( + transferred, + publication.head, + producedRow, + prepared.deployment, + ); + projection = readVerifiedCgSharedProjectionMetadataV1( + verifiedProjection, + transferred, + publication.head, + producedRow, + prepared.deployment, + ); + } catch (cause) { + fail( + 'catalog-successor-producer-verification', + 'produced successor failed exact bundle, seal, or shared-projection verification', + cause, + ); + } + + let verifiedObjects; + try { + verifiedObjects = await Promise.all(publication.stagedObjects.map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }))); + } catch (cause) { + fail( + 'catalog-successor-producer-verification', + 'produced control-object signature verification failed', + cause, + ); + } + + try { + await this.#stageKaBundle(Object.freeze({ + blobDigest: prepared.encoded.blobDigest, + bundleBytes: new Uint8Array(prepared.bundleBytes), + })); + } catch (cause) { + fail( + 'catalog-successor-producer-bundle-stage', + 'verified opaque KA bundle could not be staged', + cause, + ); + } + + let stagedControlObjects: StageVerifiedControlObjectsResultV1; + try { + stagedControlObjects = await this.#stageVerifiedObjects(verifiedObjects); + } catch (cause) { + fail( + 'catalog-successor-producer-control-stage', + 'verified successor control objects could not be staged', + cause, + ); + } + + return Object.freeze({ + publication, + row: producedRow, + bundleDigest: prepared.encoded.blobDigest, + bundleBytes: new Uint8Array(prepared.bundleBytes), + sealBinding, + transfer, + projection, + stagedControlObjects, + }); + } +} + +function prepareRowAndBundle(input: ProduceAndStagePublicOpenOneRowSuccessorInputV1) { + let sealBytes: Uint8Array; + let seal: CanonicalGraphScopedAuthorSealV1; + let encoded: ReturnType; + let row: AuthorCatalogRowV1; + try { + const assertionCoordinate = input?.assertionCoordinate; + const previousHead = input?.previousHead; + sealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(input.seal); + seal = parseCanonicalGraphScopedAuthorSealV1(sealBytes); + encoded = encodeOpaqueKaBundleV1(input.projectionBytes, sealBytes); + const byteLength = BigInt(encoded.bundleBytes.byteLength); + const chunkCount = ((byteLength - 1n) / KA_TRANSFER_CHUNK_SIZE_BYTES_V1) + 1n; + row = parseCanonicalAuthorCatalogRowV1(canonicalizeAuthorCatalogRowV1({ + kaId: seal.reservedKaId, + assertionCoordinate, + assertionVersion: seal.assertionVersion, + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: encoded.projectionDigest, + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), + transfer: { + codec: KA_TRANSFER_CODEC_V1, + projectionId: KA_TRANSFER_PROJECTION_V1, + projectionDigest: encoded.projectionDigest, + byteLength: byteLength.toString() as ByteLengthV1, + chunkSize: KA_TRANSFER_CHUNK_SIZE_V1, + chunkCount: chunkCount.toString() as CountV1, + blobDigest: encoded.blobDigest, + chunkTreeRoot: computeKaChunkTreeRootV1(encoded.bundleBytes), + }, + })); + const deployment = Object.freeze({ + networkId: input.deployment.networkId, + assertedAtChainId: input.deployment.assertedAtChainId, + assertedAtKav10Address: input.deployment.assertedAtKav10Address, + }); + const scope = Object.freeze({ + networkId: previousHead.payload.networkId, + contextGraphId: previousHead.payload.contextGraphId, + governanceChainId: previousHead.payload.governanceChainId, + governanceContractAddress: previousHead.payload.governanceContractAddress, + ownershipTransitionDigest: previousHead.payload.ownershipTransitionDigest, + subGraphName: previousHead.payload.subGraphName, + authorAddress: previousHead.payload.authorAddress, + era: previousHead.payload.era, + bucketCount: previousHead.payload.bucketCount, + }); + return Object.freeze({ + deployment, + scope, + row: Object.freeze(row), + sealBytes, + encoded, + bundleBytes: new Uint8Array(encoded.bundleBytes), + }); + } catch (cause) { + fail( + 'catalog-successor-producer-input', + 'projection, seal, or catalog-row input is not canonical', + cause, + ); + } +} + +function assertSupportedPreviousSlice( + head: SignedAuthorCatalogHeadEnvelopeV1, + bucket: SignedAuthorCatalogBucketEnvelopeV1 | null, +): void { + if ( + head.payload.subGraphName !== null + || head.payload.bucketCount !== '1' + || head.payload.directoryHeight !== '0' + || (head.payload.totalRows !== '0' && head.payload.totalRows !== '1') + || (head.payload.totalRows === '0' && bucket !== null) + || (head.payload.totalRows === '1' && bucket?.payload.rows.length !== 1) + || head.payload.directoryRootDigest === ZERO_DIGEST32_V1 + ) { + fail( + 'catalog-successor-producer-history', + 'previous catalog is outside the public/open one-row root-lane slice', + ); + } +} + +function fail( + code: Rfc64PublicCatalogSuccessorProducerErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64PublicCatalogSuccessorProducerErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts new file mode 100644 index 0000000000..9a5ca1b910 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts @@ -0,0 +1,235 @@ +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + assertCanonicalGraphScopedAuthorSealV1, + buildAuthorAttestationTypedData, + decodeOpaqueKaBundleV1, + type AuthorCatalogScopeV1, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, + type ContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; +import { describe, expect, it, vi } from 'vitest'; + +import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { + Rfc64PublicCatalogSuccessorProducerV1, +} from '../src/rfc64/public-catalog-successor-producer-v1.js'; + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'66'.repeat(32)}`); +const ATTACKER_WALLET = new ethers.Wallet(`0x${'77'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const NETWORK_ID = 'otp:20430' as NetworkIdV1; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/successor-producer' as ContextGraphIdV1; +const GOVERNANCE = '0x2222222222222222222222222222222222222222' as EvmAddressV1; +const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const DELEGATION_DIGEST = `0x${'76'.repeat(32)}` as Digest32V1; +const KA_NUMBER = 7n; +const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); +const UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; +const ASSERTION_ROOT = + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f' as Digest32V1; +const PROJECTION = new TextEncoder().encode( + ' "42"^^ .\n' + + ' "Alice" .\n', +); +const DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +}) as CatalogSealDeploymentProfileV1; + +describe('RFC-64 public/open one-row successor producer', () => { + it('verifies the exact successor before staging its bundle and signed objects', async () => { + const genesis = await emptyGenesis(); + const events: string[] = []; + let stagedBundle: { blobDigest: Digest32V1; bundleBytes: Uint8Array } | undefined; + let stagedObjects: readonly { envelope: { objectType: string } }[] | undefined; + const stageKaBundle = vi.fn(async (input) => { + events.push('bundle'); + stagedBundle = input; + }); + const stageVerifiedObjects = vi.fn(async (input) => { + events.push('objects'); + stagedObjects = input; + return Object.freeze({ + durable: true as const, + namespaceDurability: 'test-exact-durable' as never, + objects: Object.freeze([]), + }); + }); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + const result = await producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(AUTHOR_WALLET), + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: catalogSigner(), + }); + + expect(events).toEqual(['bundle', 'objects']); + expect(result.publication.head.payload).toMatchObject({ + subGraphName: null, + bucketCount: '1', + directoryHeight: '0', + totalRows: '1', + version: '1', + previousHeadDigest: genesis.head.objectDigest, + }); + expect(result.publication.bucket?.payload.rows).toEqual([result.row]); + expect(stagedObjects?.map(({ envelope }) => envelope.objectType)).toEqual([ + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + ]); + expect(stagedBundle?.blobDigest).toBe(result.bundleDigest); + expect(stagedBundle?.bundleBytes).toEqual(result.bundleBytes); + expect(stagedBundle?.bundleBytes).not.toBe(result.bundleBytes); + expect(decodeOpaqueKaBundleV1(result.bundleBytes).projectionBytes).toEqual(PROJECTION); + expect(result.transfer).toMatchObject({ + blobDigest: result.bundleDigest, + catalogRowDigest: result.sealBinding.catalogRowDigest, + }); + expect(result.projection).toMatchObject({ + kaUal: UAL, + publicTripleCount: '2', + privateTripleCount: '0', + assertionMerkleRoot: ASSERTION_ROOT, + }); + expect(result.sealBinding).toMatchObject({ + authorAddress: AUTHOR, + kaId: KA_ID, + assertionCoordinate: 'gate-1-object', + }); + }); + + it('rejects a non-author attestation before either durable staging callback', async () => { + const genesis = await emptyGenesis(); + const stageKaBundle = vi.fn(async () => {}); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const catalogSignDigest = vi.fn(async (digest: Uint8Array) => + AUTHOR_WALLET.signMessage(digest)); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + await expect(producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(ATTACKER_WALLET), + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: { issuer: AUTHOR, signDigest: catalogSignDigest }, + })).rejects.toMatchObject({ code: 'catalog-successor-producer-binding' }); + + expect(catalogSignDigest).not.toHaveBeenCalled(); + expect(stageKaBundle).not.toHaveBeenCalled(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); + + it('rejects a deployment/seal binding mismatch before durable staging', async () => { + const genesis = await emptyGenesis(); + const stageKaBundle = vi.fn(async () => {}); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + await expect(producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(AUTHOR_WALLET), + deployment: { + ...DEPLOYMENT, + assertedAtChainId: '20431', + } as CatalogSealDeploymentProfileV1, + issuedAt: '1773900001000' as never, + catalogSigner: catalogSigner(), + })).rejects.toMatchObject({ code: 'catalog-successor-producer-binding' }); + + expect(stageKaBundle).not.toHaveBeenCalled(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); +}); + +async function emptyGenesis() { + return produceEmptyAuthorCatalogGenesisV1({ + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1773900000000' as never, + signer: catalogSigner(), + }); +} + +function catalogSigner() { + return { + issuer: AUTHOR, + signDigest: async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest), + }; +} + +async function authorSeal(signingWallet: ethers.Wallet): Promise { + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(DEPLOYMENT.assertedAtChainId), + kav10Address: DEPLOYMENT.assertedAtKav10Address, + merkleRoot: ethers.getBytes(ASSERTION_ROOT), + authorAddress: AUTHOR, + reservedKaId: BigInt(KA_ID), + }); + const signature = ethers.Signature.from(await signingWallet.signTypedData( + typedData.domain, + typedData.types, + typedData.message, + )); + const seal = { + assertionMerkleRoot: ASSERTION_ROOT, + authorAddress: AUTHOR, + authorAttestationR: signature.r, + authorAttestationVS: signature.yParityAndS, + authorSchemeVersion: '1', + assertedAtChainId: DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: UAL, + assertionVersion: '1', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(seal); + return seal; +} diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 6476e4c531..dc343d1eab 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -127,6 +127,7 @@ export default defineConfig({ "test/rfc64-public-catalog-native-transport-v1.test.ts", "test/rfc64-public-catalog-native-gate1.integration.test.ts", "test/rfc64-persistent-catalog-provider-v1.test.ts", + "test/rfc64-public-catalog-successor-producer-v1.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From d0a9559af5c89944fb81d26cdba5841ab604cbb5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:49:46 +0200 Subject: [PATCH 120/292] fix(agent): authorize RFC-64 successor publication --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 9 +- .../public-catalog-successor-producer-v1.ts | 144 +++++++++++- ...blic-catalog-successor-producer-v1.test.ts | 222 +++++++++++++++++- 4 files changed, 357 insertions(+), 19 deletions(-) diff --git a/packages/agent/package.json b/packages/agent/package.json index 0cec54150a..9fc342a3c7 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -33,6 +33,7 @@ "./dist/rfc64/public-catalog-native-transport-v1.js": null, "./dist/rfc64/public-catalog-receiver-v1.js": null, "./dist/rfc64/public-catalog-service-v1.js": null, + "./dist/rfc64/public-catalog-successor-producer-v1.js": null, "./dist/rfc64/public-catalog-transport-v1.js": null, "./dist/rfc64/secure-filesystem-policy-v1.js": null, "./dist/rfc64/*": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 154a8c3736..1da9611da8 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -8,8 +8,12 @@ const legacyAgent = await import('@origintrail-official/dkg-agent/dist/dkg-agent const require = createRequire(import.meta.url); const packageManifest = require('@origintrail-official/dkg-agent/package.json'); -if (typeof root.DKGAgent !== 'function' || typeof legacyAgent.DKGAgent !== 'function') { - throw new Error('published agent entry points did not expose DKGAgent'); +if ( + typeof root.DKGAgent !== 'function' + || typeof legacyAgent.DKGAgent !== 'function' + || typeof root.Rfc64PublicCatalogSuccessorProducerV1 !== 'function' +) { + throw new Error('published agent entry points did not expose required root APIs'); } if (packageManifest.name !== '@origintrail-official/dkg-agent') { throw new Error('historical package.json subpath no longer resolves'); @@ -39,6 +43,7 @@ const blockedRfc64Modules = [ 'public-catalog-native-transport-v1.js', 'public-catalog-receiver-v1.js', 'public-catalog-service-v1.js', + 'public-catalog-successor-producer-v1.js', 'public-catalog-transport-v1.js', 'secure-filesystem-policy-v1.js', ]; diff --git a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts index ca12d36bf6..49bf5e85b5 100644 --- a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts @@ -14,7 +14,9 @@ import { KA_TRANSFER_CHUNK_SIZE_V1, KA_TRANSFER_CODEC_V1, KA_TRANSFER_PROJECTION_V1, + MIN_KA_BUNDLE_BYTES_V1, ZERO_DIGEST32_V1, + calculateOpaqueKaBundleByteLengthV1, canonicalizeAuthorCatalogRowV1, canonicalizeCanonicalGraphScopedAuthorSealBytesV1, computeCanonicalGraphScopedAuthorSealDigestV1, @@ -25,6 +27,7 @@ import { readVerifiedCatalogSealBindingV1, readVerifiedCgSharedProjectionMetadataV1, readVerifiedTransferredCatalogBundleMetadataV1, + verifyAuthorCatalogDirectoryPathV1, verifyCatalogSealBindingV1, verifyCgSharedProjectionV1, verifyTransferredCatalogBundleV1, @@ -36,6 +39,7 @@ import { type CountV1, type DecimalU64V1, type Digest32V1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, type SignedAuthorCatalogBucketEnvelopeV1, type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, @@ -46,6 +50,12 @@ import { } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { + readVerifiedAuthorCatalogRowAuthorshipV1, + verifyAuthorCatalogRowAuthorshipV1, + type AuthorAgentDelegationEvidenceV1, + type VerifiedAuthorCatalogRowAuthorshipSnapshotV1, +} from './catalog-row-authorship.js'; import { produceSparseAuthorCatalogSuccessorV1, type ProducedAuthorCatalogPublicationV1, @@ -56,6 +66,7 @@ import type { StageVerifiedControlObjectsResultV1, } from './control-object-store-v1.js'; import { assertRecoverableAuthorAttestationV1 } from './public-catalog-native-receiver-v1.js'; +import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1 } from './public-catalog-native-transport-v1.js'; export type Rfc64PublicCatalogSuccessorProducerErrorCodeV1 = | 'catalog-successor-producer-input' @@ -82,10 +93,24 @@ export interface StageRfc64PublicCatalogBundleV1 { readonly bundleBytes: Uint8Array; } +/** Exact durable provider receipt for the immutable bundle write. */ +export interface StagedRfc64PublicCatalogBundleReceiptV1 { + readonly durable: true; + readonly blobDigest: Digest32V1; + readonly byteLength: number; +} + +export interface Rfc64PublicCatalogIssuerAuthorizationV1 { + readonly catalogIssuerDelegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1; + readonly parentAuthorAgentEvidence: AuthorAgentDelegationEvidenceV1 | null; +} + export interface Rfc64PublicCatalogSuccessorProducerOptionsV1 { readonly controlObjects: Pick; - /** Must resolve only after the immutable bundle is durably available by digest. */ - readonly stageKaBundle: (input: StageRfc64PublicCatalogBundleV1) => Promise; + /** Must return an exact receipt only after the immutable bytes are durably available. */ + readonly stageKaBundle: ( + input: StageRfc64PublicCatalogBundleV1, + ) => Promise; } export interface ProduceAndStagePublicOpenOneRowSuccessorInputV1 { @@ -98,6 +123,8 @@ export interface ProduceAndStagePublicOpenOneRowSuccessorInputV1 { readonly deployment: CatalogSealDeploymentProfileV1; readonly issuedAt: TimestampMsV1; readonly catalogSigner: Rfc64AuthorCatalogEip191SignerV1; + /** Exact author/delegation closure authorizing the catalog signer for this lane. */ + readonly catalogIssuerAuthorization: Rfc64PublicCatalogIssuerAuthorizationV1; } export interface ProducedAndStagedPublicOpenOneRowSuccessorV1 { @@ -109,6 +136,7 @@ export interface ProducedAndStagedPublicOpenOneRowSuccessorV1 { readonly sealBinding: VerifiedCatalogSealBindingSnapshotV1; readonly transfer: VerifiedTransferredCatalogBundleMetadataV1; readonly projection: VerifiedCgSharedProjectionMetadataV1; + readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; readonly stagedControlObjects: StageVerifiedControlObjectsResultV1; } @@ -245,24 +273,74 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { } let verifiedObjects; + let authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; try { - verifiedObjects = await Promise.all(publication.stagedObjects.map(async (envelope) => ({ - envelope, - issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), - }))); + const authorization = input.catalogIssuerAuthorization; + const catalogIssuerDelegationSignature = await verifyControlEnvelopeIssuerSignatureV1( + authorization.catalogIssuerDelegation, + ); + verifiedObjects = await Promise.all( + publication.stagedObjects.map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + })), + ); + const signaturesByDigest = new Map( + verifiedObjects.map(({ envelope, issuerSignature }) => [ + envelope.objectDigest, + issuerSignature, + ] as const), + ); + const directoryPathProof = verifyAuthorCatalogDirectoryPathV1( + publication.head, + publication.directoryPath, + '0' as DecimalU64V1, + ); + const authorshipToken = verifyAuthorCatalogRowAuthorshipV1({ + catalogIssuerDelegation: authorization.catalogIssuerDelegation, + catalogIssuerDelegationSignature, + parentAuthorAgentEvidence: authorization.parentAuthorAgentEvidence, + catalogHead: publication.head, + catalogHeadSignature: requiredSignature( + signaturesByDigest, + publication.head.objectDigest, + 'catalog head', + ), + directoryPathEnvelopes: publication.directoryPath, + directoryPathSignatures: publication.directoryPath.map((envelope, index) => + requiredSignature( + signaturesByDigest, + envelope.objectDigest, + `directory path node ${index}`, + )), + directoryPathProof, + catalogBucket: producedBucket, + catalogBucketSignature: requiredSignature( + signaturesByDigest, + producedBucket.objectDigest, + 'catalog bucket', + ), + targetKaId: producedRow.kaId, + }); + authorship = readVerifiedAuthorCatalogRowAuthorshipV1(authorshipToken); } catch (cause) { fail( 'catalog-successor-producer-verification', - 'produced control-object signature verification failed', + 'produced control objects failed signature or author-catalog authorship verification', cause, ); } try { - await this.#stageKaBundle(Object.freeze({ + const bundleReceipt = await this.#stageKaBundle(Object.freeze({ blobDigest: prepared.encoded.blobDigest, bundleBytes: new Uint8Array(prepared.bundleBytes), })); + assertExactDurableBundleReceipt( + bundleReceipt, + prepared.encoded.blobDigest, + prepared.bundleBytes.byteLength, + ); } catch (cause) { fail( 'catalog-successor-producer-bundle-stage', @@ -290,6 +368,7 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { sealBinding, transfer, projection, + authorship, stagedControlObjects, }); } @@ -301,10 +380,29 @@ function prepareRowAndBundle(input: ProduceAndStagePublicOpenOneRowSuccessorInpu let encoded: ReturnType; let row: AuthorCatalogRowV1; try { + if (!(input?.projectionBytes instanceof Uint8Array)) { + throw new TypeError('projectionBytes must be a Uint8Array'); + } + // Reject a projection that cannot possibly fit the Gate-1 transport before + // canonicalizing the seal or allocating/copying a complete bundle. + if ( + BigInt(input.projectionBytes.byteLength) + > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1) + - MIN_KA_BUNDLE_BYTES_V1 + ) { + throw new RangeError('projection exceeds the Gate-1 complete-bundle ceiling'); + } const assertionCoordinate = input?.assertionCoordinate; const previousHead = input?.previousHead; sealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(input.seal); seal = parseCanonicalGraphScopedAuthorSealV1(sealBytes); + const bundleByteLength = calculateOpaqueKaBundleByteLengthV1( + BigInt(input.projectionBytes.byteLength), + BigInt(sealBytes.byteLength), + ); + if (bundleByteLength > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1)) { + throw new RangeError('bundle exceeds the Gate-1 complete-bundle ceiling'); + } encoded = encodeOpaqueKaBundleV1(input.projectionBytes, sealBytes); const byteLength = BigInt(encoded.bundleBytes.byteLength); const chunkCount = ((byteLength - 1n) / KA_TRANSFER_CHUNK_SIZE_BYTES_V1) + 1n; @@ -359,6 +457,36 @@ function prepareRowAndBundle(input: ProduceAndStagePublicOpenOneRowSuccessorInpu } } +function requiredSignature( + signaturesByDigest: ReadonlyMap< + string, + Awaited> + >, + objectDigest: string, + label: string, +): Awaited> { + const signature = signaturesByDigest.get(objectDigest); + if (signature === undefined) { + throw new Error(`verified signature for ${label} is unavailable`); + } + return signature; +} + +function assertExactDurableBundleReceipt( + receipt: StagedRfc64PublicCatalogBundleReceiptV1, + expectedBlobDigest: Digest32V1, + expectedByteLength: number, +): void { + if ( + (typeof receipt !== 'object' || receipt === null) + || receipt.durable !== true + || receipt.blobDigest !== expectedBlobDigest + || receipt.byteLength !== expectedByteLength + ) { + throw new Error('bundle provider did not return the exact durable digest/length receipt'); + } +} + function assertSupportedPreviousSlice( head: SignedAuthorCatalogHeadEnvelopeV1, bucket: SignedAuthorCatalogBucketEnvelopeV1 | null, diff --git a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts index 9a5ca1b910..abb0a412a1 100644 --- a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts @@ -1,9 +1,11 @@ import { + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, assertCanonicalGraphScopedAuthorSealV1, buildAuthorAttestationTypedData, + computeControlObjectDigestHex, decodeOpaqueKaBundleV1, type AuthorCatalogScopeV1, type CanonicalGraphScopedAuthorSealV1, @@ -12,6 +14,8 @@ import { type Digest32V1, type EvmAddressV1, type NetworkIdV1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, + type UnsignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; import { describe, expect, it, vi } from 'vitest'; @@ -20,6 +24,7 @@ import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog- import { Rfc64PublicCatalogSuccessorProducerV1, } from '../src/rfc64/public-catalog-successor-producer-v1.js'; +import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1 } from '../src/rfc64/public-catalog-native-transport-v1.js'; const AUTHOR_WALLET = new ethers.Wallet(`0x${'66'.repeat(32)}`); const ATTACKER_WALLET = new ethers.Wallet(`0x${'77'.repeat(32)}`); @@ -29,7 +34,6 @@ const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/successor-producer' as ContextGraphIdV1; const GOVERNANCE = '0x2222222222222222222222222222222222222222' as EvmAddressV1; const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; -const DELEGATION_DIGEST = `0x${'76'.repeat(32)}` as Digest32V1; const KA_NUMBER = 7n; const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); const UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; @@ -47,13 +51,14 @@ const DEPLOYMENT = Object.freeze({ describe('RFC-64 public/open one-row successor producer', () => { it('verifies the exact successor before staging its bundle and signed objects', async () => { - const genesis = await emptyGenesis(); + const { genesis, authorization } = await producerHistory(); const events: string[] = []; let stagedBundle: { blobDigest: Digest32V1; bundleBytes: Uint8Array } | undefined; let stagedObjects: readonly { envelope: { objectType: string } }[] | undefined; const stageKaBundle = vi.fn(async (input) => { events.push('bundle'); stagedBundle = input; + return durableBundleReceipt(input); }); const stageVerifiedObjects = vi.fn(async (input) => { events.push('objects'); @@ -79,6 +84,7 @@ describe('RFC-64 public/open one-row successor producer', () => { deployment: DEPLOYMENT, issuedAt: '1773900001000' as never, catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, }); expect(events).toEqual(['bundle', 'objects']); @@ -115,11 +121,19 @@ describe('RFC-64 public/open one-row successor producer', () => { kaId: KA_ID, assertionCoordinate: 'gate-1-object', }); + expect(result.authorship).toMatchObject({ + authorAddress: AUTHOR, + catalogIssuerKey: AUTHOR, + catalogIssuerDelegationObjectDigest: authorization.catalogIssuerDelegation.objectDigest, + catalogHeadObjectDigest: result.publication.head.objectDigest, + catalogRowDigest: result.sealBinding.catalogRowDigest, + row: result.row, + }); }); it('rejects a non-author attestation before either durable staging callback', async () => { - const genesis = await emptyGenesis(); - const stageKaBundle = vi.fn(async () => {}); + const { genesis, authorization } = await producerHistory(); + const stageKaBundle = vi.fn(durableBundleReceipt); const stageVerifiedObjects = vi.fn(async () => undefined as never); const catalogSignDigest = vi.fn(async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest)); @@ -138,6 +152,7 @@ describe('RFC-64 public/open one-row successor producer', () => { deployment: DEPLOYMENT, issuedAt: '1773900001000' as never, catalogSigner: { issuer: AUTHOR, signDigest: catalogSignDigest }, + catalogIssuerAuthorization: authorization, })).rejects.toMatchObject({ code: 'catalog-successor-producer-binding' }); expect(catalogSignDigest).not.toHaveBeenCalled(); @@ -146,8 +161,8 @@ describe('RFC-64 public/open one-row successor producer', () => { }); it('rejects a deployment/seal binding mismatch before durable staging', async () => { - const genesis = await emptyGenesis(); - const stageKaBundle = vi.fn(async () => {}); + const { genesis, authorization } = await producerHistory(); + const stageKaBundle = vi.fn(durableBundleReceipt); const stageVerifiedObjects = vi.fn(async () => undefined as never); const producer = new Rfc64PublicCatalogSuccessorProducerV1({ controlObjects: { stageVerifiedObjects } as never, @@ -167,15 +182,155 @@ describe('RFC-64 public/open one-row successor producer', () => { } as CatalogSealDeploymentProfileV1, issuedAt: '1773900001000' as never, catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, })).rejects.toMatchObject({ code: 'catalog-successor-producer-binding' }); expect(stageKaBundle).not.toHaveBeenCalled(); expect(stageVerifiedObjects).not.toHaveBeenCalled(); }); + + it('rejects a valid delegation from another author lane with zero staging', async () => { + const { genesis, authorization } = await producerHistory(); + const crossAuthorAuthorization = await directCatalogAuthorization( + ATTACKER_WALLET, + `${CONTEXT_GRAPH_ID}-attacker` as ContextGraphIdV1, + ); + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + await expect(producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(AUTHOR_WALLET), + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: catalogSigner(), + catalogIssuerAuthorization: crossAuthorAuthorization, + })).rejects.toMatchObject({ code: 'catalog-successor-producer-verification' }); + + expect(authorization.catalogIssuerDelegation.objectDigest) + .not.toBe(crossAuthorAuthorization.catalogIssuerDelegation.objectDigest); + expect(stageKaBundle).not.toHaveBeenCalled(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); + + it('rejects an arbitrary catalog signer with zero staging', async () => { + const { genesis, authorization } = await producerHistory(); + const signDigest = vi.fn(async (digest: Uint8Array) => ATTACKER_WALLET.signMessage(digest)); + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + await expect(producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(AUTHOR_WALLET), + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: { + issuer: ATTACKER_WALLET.address.toLowerCase() as EvmAddressV1, + signDigest, + }, + catalogIssuerAuthorization: authorization, + })).rejects.toMatchObject({ code: 'catalog-successor-producer-history' }); + + expect(signDigest).not.toHaveBeenCalled(); + expect(stageKaBundle).not.toHaveBeenCalled(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); + + it.each([ + ['non-durable', (input: BundleStageInput) => ({ + durable: false, + blobDigest: input.blobDigest, + byteLength: input.bundleBytes.byteLength, + })], + ['wrong digest', (input: BundleStageInput) => ({ + durable: true, + blobDigest: `0x${'00'.repeat(32)}`, + byteLength: input.bundleBytes.byteLength, + })], + ['wrong byte length', (input: BundleStageInput) => ({ + durable: true, + blobDigest: input.blobDigest, + byteLength: input.bundleBytes.byteLength + 1, + })], + ])('rejects a %s bundle provider receipt before staging control objects', async (_label, buildReceipt) => { + const { genesis, authorization } = await producerHistory(); + const stageKaBundle = vi.fn(async (input) => buildReceipt(input) as never); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + await expect(producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(AUTHOR_WALLET), + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, + })).rejects.toMatchObject({ code: 'catalog-successor-producer-bundle-stage' }); + + expect(stageKaBundle).toHaveBeenCalledOnce(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); + + it('rejects an oversized Gate-1 projection before catalog signing or durable staging', async () => { + const { genesis, authorization } = await producerHistory(); + const signDigest = vi.fn(async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest)); + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + await expect(producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: new Uint8Array(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1), + seal: await authorSeal(AUTHOR_WALLET), + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: { issuer: AUTHOR, signDigest }, + catalogIssuerAuthorization: authorization, + })).rejects.toMatchObject({ code: 'catalog-successor-producer-input' }); + + expect(signDigest).not.toHaveBeenCalled(); + expect(stageKaBundle).not.toHaveBeenCalled(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); }); -async function emptyGenesis() { - return produceEmptyAuthorCatalogGenesisV1({ +type BundleStageInput = { + readonly blobDigest: Digest32V1; + readonly bundleBytes: Uint8Array; +}; + +async function producerHistory() { + const authorization = await directCatalogAuthorization(AUTHOR_WALLET, CONTEXT_GRAPH_ID); + const genesis = await produceEmptyAuthorCatalogGenesisV1({ scope: { networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, @@ -187,10 +342,59 @@ async function emptyGenesis() { era: '0', bucketCount: '1', } as AuthorCatalogScopeV1, - catalogIssuerDelegationDigest: DELEGATION_DIGEST, + catalogIssuerDelegationDigest: authorization.catalogIssuerDelegation.objectDigest, issuedAt: '1773900000000' as never, signer: catalogSigner(), }); + return { genesis, authorization }; +} + +async function directCatalogAuthorization( + wallet: ethers.Wallet, + contextGraphId: ContextGraphIdV1, +) { + const authorAddress = wallet.address.toLowerCase() as EvmAddressV1; + const unsigned: UnsignedControlEnvelopeV1 = { + issuer: authorAddress, + objectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + payload: { + authorAddress, + authorAuthorityEvidenceDigest: null, + catalogEra: '0', + catalogIssuerKey: authorAddress, + contextGraphId, + effectiveAt: '1773899999000', + expiresAt: '1774000000000', + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE, + networkId: NETWORK_ID, + ownershipTransitionDigest: null, + previousDelegationDigest: null, + subGraphName: null, + }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }; + const objectDigest = computeControlObjectDigestHex(unsigned); + const catalogIssuerDelegation = { + ...unsigned, + objectDigest, + signature: await wallet.signMessage(ethers.getBytes(objectDigest)), + } as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + return Object.freeze({ + catalogIssuerDelegation, + parentAuthorAgentEvidence: null, + }); +} + +function durableBundleReceipt( + input: BundleStageInput, +) { + return Promise.resolve(Object.freeze({ + durable: true as const, + blobDigest: input.blobDigest, + byteLength: input.bundleBytes.byteLength, + })); } function catalogSigner() { From e9432ba0207702c3511f180446819a902547f6d6 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 19:59:31 +0200 Subject: [PATCH 121/292] feat(agent): adapt RFC-64 native catalog reconciliation --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/index.ts | 1 + .../public-catalog-native-receiver-v1.ts | 36 +++- .../public-catalog-native-reconciler-v1.ts | 143 ++++++++++++ ...c-catalog-native-gate1.integration.test.ts | 73 ++++++- ...ublic-catalog-native-reconciler-v1.test.ts | 204 ++++++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 8 files changed, 448 insertions(+), 12 deletions(-) create mode 100644 packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 9fc342a3c7..70075d1f6e 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -30,6 +30,7 @@ "./dist/rfc64/persistence-root-ownership-v1-internal.js": null, "./dist/rfc64/persistence-v1.js": null, "./dist/rfc64/public-catalog-native-receiver-v1.js": null, + "./dist/rfc64/public-catalog-native-reconciler-v1.js": null, "./dist/rfc64/public-catalog-native-transport-v1.js": null, "./dist/rfc64/public-catalog-receiver-v1.js": null, "./dist/rfc64/public-catalog-service-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 1da9611da8..66a4ffeda0 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -39,6 +39,7 @@ const blockedRfc64Modules = [ 'persistence-layout-v1.js', 'persistence-root-ownership-v1-internal.js', 'persistence-v1.js', + 'public-catalog-native-reconciler-v1.js', 'public-catalog-native-receiver-v1.js', 'public-catalog-native-transport-v1.js', 'public-catalog-receiver-v1.js', diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 22b58aa90f..265a061069 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -43,6 +43,7 @@ export * from './rfc64/public-catalog-service-v1.js'; export * from './rfc64/public-catalog-native-transport-v1.js'; export * from './rfc64/public-catalog-native-receiver-v1.js'; export * from './rfc64/public-catalog-successor-producer-v1.js'; +export * from './rfc64/public-catalog-native-reconciler-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 1ff35e806b..73880ddb99 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -75,8 +75,13 @@ const UTF8_ENCODER = new TextEncoder(); const APPLIED_INVENTORY_DIGEST_DOMAIN_V1 = 'dkg-rfc64-applied-inventory-v1\n'; export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { - readonly headTransport: Rfc64PublicCatalogTransportV1; - readonly contentTransport: Rfc64PublicCatalogNativeTransportV1; + /** Fetch-only capability; lifecycle ownership remains with the catalog service. */ + readonly headTransport: Pick; + /** Fetch-only capability; lifecycle ownership remains with the catalog service. */ + readonly contentTransport: Pick< + Rfc64PublicCatalogNativeTransportV1, + 'fetchCatalogObject' | 'fetchKaBundle' + >; readonly controlObjects: Pick; readonly inventory: Pick< Rfc64InventoryV1OperationsV1, @@ -143,6 +148,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { if ( typeof options?.headTransport?.fetchCatalogHead !== 'function' || typeof options?.contentTransport?.fetchCatalogObject !== 'function' + || typeof options?.contentTransport?.fetchKaBundle !== 'function' || typeof options.controlObjects?.stageVerifiedObjects !== 'function' || typeof options.inventory?.readAppliedCatalogHeadV1 !== 'function' || typeof options.inventory?.compareAndSwapAppliedCatalogHeadV1 !== 'function' @@ -161,6 +167,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, deployment: CatalogSealDeploymentProfileV1, + signal?: AbortSignal, ): Promise { return this.withScopeSerialization( catalogScopeLockKey(announcement), @@ -170,6 +177,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { announcement, deployment, 'successor', + signal, ); if (evidence.inventoryRowCount !== 1) { fail('catalog-native-receiver-slice', 'one-row synchronization returned genesis evidence'); @@ -188,6 +196,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, deployment: CatalogSealDeploymentProfileV1, + signal?: AbortSignal, ): Promise { return this.withScopeSerialization( catalogScopeLockKey(announcement), @@ -196,6 +205,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { announcement, deployment, 'any', + signal, ), ); } @@ -205,6 +215,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, deployment: CatalogSealDeploymentProfileV1, + signal?: AbortSignal, ): Promise { return this.withScopeSerialization( catalogScopeLockKey(announcement), @@ -214,6 +225,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { announcement, deployment, 'genesis', + signal, ); if (evidence.inventoryRowCount !== 0) { fail('catalog-native-receiver-slice', 'genesis bootstrap returned successor evidence'); @@ -228,11 +240,13 @@ export class Rfc64PublicCatalogNativeReceiverV1 { announcement: Rfc64PublicCatalogHeadAnnouncementV1, deployment: CatalogSealDeploymentProfileV1, expected: 'any' | 'genesis' | 'successor', + signal: AbortSignal | undefined, ): Promise { + throwIfAborted(signal); const fetchedHead = await this.options.headTransport.fetchCatalogHead( remotePeerId, announcement, - { timeoutMs: this.#timeoutMs }, + { timeoutMs: this.#timeoutMs, signal }, ); if (fetchedHead === null) { fail('catalog-native-receiver-not-found', 'announced catalog head was not found'); @@ -243,6 +257,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { remotePeerId, announcement, fetchedHead, + signal, ); } if (expected === 'successor' && claimsGenesisHistory(head)) { @@ -253,6 +268,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { announcement, deployment, fetchedHead, + signal, ); } @@ -260,6 +276,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, fetchedHead: FetchedRfc64PublicCatalogHeadV1, + signal: AbortSignal | undefined, ): Promise { const head = fetchedHead.envelope; assertEmptyGenesisHead(head); @@ -284,7 +301,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, targetObjectDigest: head.payload.directoryRootDigest, }, - { timeoutMs: this.#timeoutMs }, + { timeoutMs: this.#timeoutMs, signal }, ); if (fetchedDirectory === null) { fail('catalog-native-receiver-not-found', 'genesis directory root was not found'); @@ -379,6 +396,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { announcement: Rfc64PublicCatalogHeadAnnouncementV1, deployment: CatalogSealDeploymentProfileV1, fetchedHead: FetchedRfc64PublicCatalogHeadV1, + signal: AbortSignal | undefined, ): Promise { const head = fetchedHead.envelope; assertFirstSliceHead(head); @@ -400,7 +418,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, targetObjectDigest: head.payload.directoryRootDigest, }, - { timeoutMs: this.#timeoutMs }, + { timeoutMs: this.#timeoutMs, signal }, ); if (fetchedDirectory === null) { fail('catalog-native-receiver-not-found', 'successor directory root was not found'); @@ -440,7 +458,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { targetObjectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, targetObjectDigest: descriptor.bucketDigest, }, - { timeoutMs: this.#timeoutMs }, + { timeoutMs: this.#timeoutMs, signal }, ); if (fetchedBucket === null) { fail('catalog-native-receiver-not-found', 'successor catalog bucket was not found'); @@ -480,7 +498,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { blobDigest: row.transfer.blobDigest, byteLength: row.transfer.byteLength as never, }, - { timeoutMs: this.#timeoutMs }, + { timeoutMs: this.#timeoutMs, signal }, ); if (bundle === null) { fail('catalog-native-receiver-not-found', 'catalog row KA bundle was not found'); @@ -978,3 +996,7 @@ function fail( cause === undefined ? {} : { cause }, ); } + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw signal.reason; +} diff --git a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts new file mode 100644 index 0000000000..a735e0b49b --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Production scheduler adapter for the bounded RFC-64 public/open native lane. + * + * The scheduler reasons about durable semantic application, while the native + * receiver owns fetch, verification, activation, exact post-read, and the + * applied-head CAS. This adapter joins those contracts without giving either + * side transport lifecycle ownership. + */ + +import { + computeAuthorCatalogScopeDigestV1, + type AuthorCatalogScopeV1, + type CatalogSealDeploymentProfileV1, + type CountV1, +} from '@origintrail-official/dkg-core'; + +import type { Rfc64InventoryV1OperationsV1 } from './inventory-v1/index.js'; +import { + Rfc64PublicCatalogNativeReceiverErrorV1, + type Rfc64PublicCatalogNativeReceiverV1, +} from './public-catalog-native-receiver-v1.js'; +import type { + Rfc64PublicCatalogReceiverReconcilerV1, + Rfc64PublicCatalogReconcileResultV1, +} from './public-catalog-receiver-v1.js'; +import type { + Rfc64PublicCatalogHeadAnnouncementV1, +} from './public-catalog-transport-v1.js'; + +export type Rfc64PublicOpenCatalogNativeReceiverClientV1 = Pick< + Rfc64PublicCatalogNativeReceiverV1, + 'synchronizePublicOpenCatalog' +>; + +export type Rfc64PublicOpenCatalogDeploymentResolverV1 = ( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + signal: AbortSignal, +) => Promise; + +export interface Rfc64PublicOpenCatalogNativeReconcilerOptionsV1 { + readonly nativeReceiver: Rfc64PublicOpenCatalogNativeReceiverClientV1; + readonly inventory: Pick; + /** Resolve the locally trusted deployment tuple; never copy it from the wire. */ + readonly resolveDeployment: Rfc64PublicOpenCatalogDeploymentResolverV1; +} + +/** + * Derive the one fixed Gate-1 public/open scope represented by an announcement. + * Policy and signature-variant digests are transport/authentication context, + * not semantic catalog identity, and therefore do not participate. + */ +export function deriveRfc64PublicOpenCatalogScopeV1( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, +): AuthorCatalogScopeV1 { + if (announcement.subGraphName !== null) { + throw new Error('RFC-64 Gate 1 public/open reconciler requires the root catalog lane'); + } + return Object.freeze({ + networkId: announcement.networkId, + contextGraphId: announcement.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: announcement.authorAddress, + era: announcement.catalogEra, + bucketCount: '1' as CountV1, + }); +} + +export class Rfc64PublicOpenCatalogNativeReconcilerV1 + implements Rfc64PublicCatalogReceiverReconcilerV1 { + constructor( + private readonly options: Rfc64PublicOpenCatalogNativeReconcilerOptionsV1, + ) { + if ( + typeof options?.nativeReceiver?.synchronizePublicOpenCatalog !== 'function' + || typeof options?.inventory?.readAppliedCatalogHeadV1 !== 'function' + || typeof options?.resolveDeployment !== 'function' + ) { + throw new TypeError('RFC-64 public/open native reconciler dependencies are incomplete'); + } + } + + async isHeadApplied( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + ): Promise { + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1( + deriveRfc64PublicOpenCatalogScopeV1(announcement), + ); + const current = this.options.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + announcement.authorAddress, + ); + const expectedInventoryRowCount = announcement.catalogVersion === '0' ? '0' : '1'; + return current !== null + && current.catalogScopeDigest === catalogScopeDigest + && current.authorAddress === announcement.authorAddress + && current.currentCatalogHeadDigest === announcement.catalogHeadObjectDigest + && current.catalogVersion === announcement.catalogVersion + && current.inventoryRowCount === expectedInventoryRowCount; + } + + async reconcileHead( + remotePeerId: string, + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + signal: AbortSignal, + ): Promise { + throwIfAborted(signal); + const deployment = await this.options.resolveDeployment(announcement, signal); + throwIfAborted(signal); + try { + await this.options.nativeReceiver.synchronizePublicOpenCatalog( + remotePeerId, + announcement, + deployment, + signal, + ); + return 'applied'; + } catch (cause) { + if ( + cause instanceof Rfc64PublicCatalogNativeReceiverErrorV1 + && cause.code === 'catalog-native-receiver-not-found' + ) { + return 'not-found'; + } + throw cause; + } + } +} + +/** Construct the production scheduler adapter around one native receiver. */ +export function createRfc64PublicOpenCatalogNativeReconcilerV1( + options: Rfc64PublicOpenCatalogNativeReconcilerOptionsV1, +): Rfc64PublicCatalogReceiverReconcilerV1 { + return new Rfc64PublicOpenCatalogNativeReconcilerV1(options); +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw signal.reason; +} diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index f4074eb0dd..367cba703f 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -63,7 +63,6 @@ const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; const NETWORK_ID = 'otp:20430' as NetworkIdV1; const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/native-gate-1' as ContextGraphIdV1; -const GOVERNANCE = '0x2222222222222222222222222222222222222222' as EvmAddressV1; const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; const POLICY_DIGEST = `0x${'75'.repeat(32)}` as Digest32V1; const DELEGATION_DIGEST = `0x${'76'.repeat(32)}` as Digest32V1; @@ -233,6 +232,40 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); }, 30_000); + it('threads one AbortSignal and timeout through every head, object, and bundle fetch path', async () => { + const fixture = await setupLiveReceiver(); + const signal = new AbortController().signal; + + await fixture.bootstrap( + fixture.genesisAnnouncement, + fixture.receiver, + signal, + ); + await fixture.synchronize( + fixture.announcement, + fixture.receiver, + signal, + ); + await fixture.synchronizeAny( + fixture.announcement, + fixture.receiver, + signal, + ); + + expect(fixture.receiverHeadFetch).toHaveBeenCalledTimes(3); + expect(fixture.receiverObjectFetch).toHaveBeenCalledTimes(5); + expect(fixture.receiverBundleFetch).toHaveBeenCalledTimes(2); + for (const fetch of [ + fixture.receiverHeadFetch, + fixture.receiverObjectFetch, + fixture.receiverBundleFetch, + ]) { + for (const call of fetch.mock.calls) { + expect(call.at(-1)).toEqual({ timeoutMs: 10_000, signal }); + } + } + }, 30_000); + it('retries genesis idempotently after verified staging wins but its CAS crashes', async () => { const fixture = await setupLiveReceiver(); const crashGapReceiver = fixture.createReceiver({ @@ -379,8 +412,8 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const scope = { networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, - governanceChainId: '20430', - governanceContractAddress: GOVERNANCE, + governanceChainId: null, + governanceContractAddress: null, ownershipTransitionDigest: null, subGraphName: null, authorAddress: AUTHOR, @@ -543,6 +576,15 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { await authorHeadTransport.announceCatalogHead(receiverNode.peerId, genesisAnnouncement); await authorHeadTransport.announceCatalogHead(receiverNode.peerId, announcement); expect(receivedAnnouncements).toEqual([genesisAnnouncement, announcement]); + const receiverHeadFetch = vi.fn( + receiverHeadTransport.fetchCatalogHead.bind(receiverHeadTransport), + ); + const receiverObjectFetch = vi.fn( + receiverNativeTransport.fetchCatalogObject.bind(receiverNativeTransport), + ); + const receiverBundleFetch = vi.fn( + receiverNativeTransport.fetchKaBundle.bind(receiverNativeTransport), + ); const createReceiver = ( inventory: Pick< Rfc64PersistenceV1['inventory'], @@ -550,8 +592,11 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { >, controlObjects = receiverPersistence.controlObjects, ) => new Rfc64PublicCatalogNativeReceiverV1({ - headTransport: receiverHeadTransport, - contentTransport: receiverNativeTransport, + headTransport: { fetchCatalogHead: receiverHeadFetch }, + contentTransport: { + fetchCatalogObject: receiverObjectFetch, + fetchKaBundle: receiverBundleFetch, + }, controlObjects, inventory, store: receiverStore, @@ -566,7 +611,11 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { genesis, genesisAnnouncement, invalidGenesisAnnouncement, + receiver, + receiverBundleFetch, receiverDirectory: receiverOpened.directory, + receiverHeadFetch, + receiverObjectFetch, receiverPersistence, receiverStore, rowBundle, @@ -575,18 +624,32 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { bootstrap: ( selectedAnnouncement = genesisAnnouncement, selectedReceiver = receiver, + signal?: AbortSignal, ) => selectedReceiver.bootstrapEmptyPublicOpenCatalog( authorNode.peerId, selectedAnnouncement, DEPLOYMENT, + signal, ), synchronize: ( selectedAnnouncement = announcement, selectedReceiver = receiver, + signal?: AbortSignal, ) => selectedReceiver.synchronizeOnePublicOpenRow( authorNode.peerId, selectedAnnouncement, DEPLOYMENT, + signal, + ), + synchronizeAny: ( + selectedAnnouncement = announcement, + selectedReceiver = receiver, + signal?: AbortSignal, + ) => selectedReceiver.synchronizePublicOpenCatalog( + authorNode.peerId, + selectedAnnouncement, + DEPLOYMENT, + signal, ), }; } diff --git a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts new file mode 100644 index 0000000000..ff99345f77 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts @@ -0,0 +1,204 @@ +import { + computeAuthorCatalogScopeDigestV1, + type CatalogSealDeploymentProfileV1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; +import { describe, expect, it, vi } from 'vitest'; + +import type { AppliedCatalogHeadSnapshotV1 } from '../src/rfc64/inventory-v1/index.js'; +import { + createRfc64PublicOpenCatalogNativeReconcilerV1, + deriveRfc64PublicOpenCatalogScopeV1, + type Rfc64PublicOpenCatalogNativeReceiverClientV1, +} from '../src/rfc64/public-catalog-native-reconciler-v1.js'; +import { + Rfc64PublicCatalogNativeReceiverErrorV1, +} from '../src/rfc64/public-catalog-native-receiver-v1.js'; +import type { + Rfc64PublicCatalogHeadAnnouncementV1, +} from '../src/rfc64/public-catalog-transport-v1.js'; + +const NETWORK_ID = 'otp:20430' as const; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/native-reconciler' as const; +const AUTHOR = '0x2222222222222222222222222222222222222222' as EvmAddressV1; +const KAV10 = '0x3333333333333333333333333333333333333333' as EvmAddressV1; +const HEAD = `0x${'44'.repeat(32)}` as Digest32V1; +const INVENTORY = `0x${'55'.repeat(32)}` as Digest32V1; +const POLICY = `0x${'66'.repeat(32)}` as Digest32V1; +const SIGNATURE_VARIANT = `0x${'77'.repeat(32)}` as Digest32V1; +const DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +}) as CatalogSealDeploymentProfileV1; + +function announcement( + catalogVersion = '0', + overrides: Partial = {}, +): Rfc64PublicCatalogHeadAnnouncementV1 { + return Object.freeze({ + kind: 'rfc64-author-catalog-head-availability-v1', + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + catalogVersion, + policyDigest: POLICY, + catalogHeadObjectDigest: HEAD, + signatureVariantDigest: SIGNATURE_VARIANT, + ...overrides, + }) as Rfc64PublicCatalogHeadAnnouncementV1; +} + +function snapshot( + input: Rfc64PublicCatalogHeadAnnouncementV1, + overrides: Partial = {}, +): AppliedCatalogHeadSnapshotV1 { + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1({ + networkId: input.networkId, + contextGraphId: input.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: input.authorAddress, + era: input.catalogEra, + bucketCount: '1', + }); + return { + catalogScopeDigest, + authorAddress: input.authorAddress, + currentCatalogHeadDigest: input.catalogHeadObjectDigest, + appliedInventoryDigest: INVENTORY, + catalogVersion: input.catalogVersion, + inventoryRowCount: input.catalogVersion === '0' ? '0' : '1', + ...overrides, + } as AppliedCatalogHeadSnapshotV1; +} + +function receiver( + synchronizePublicOpenCatalog: Rfc64PublicOpenCatalogNativeReceiverClientV1[ + 'synchronizePublicOpenCatalog' + ], +): Rfc64PublicOpenCatalogNativeReceiverClientV1 { + return { synchronizePublicOpenCatalog }; +} + +describe('RFC-64 public/open native reconciler v1', () => { + it('maps both genesis and successor evidence to applied and passes deployment plus cancellation', async () => { + const synchronize = vi.fn(async () => ({ inventoryRowCount: 0 } as never)); + const resolveDeployment = vi.fn(async () => DEPLOYMENT); + const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + nativeReceiver: receiver(synchronize), + inventory: { readAppliedCatalogHeadV1: () => null }, + resolveDeployment, + }); + const signal = new AbortController().signal; + const genesis = announcement('0'); + const successor = announcement('1'); + + await expect(reconciler.reconcileHead('peer-a', genesis, signal)).resolves.toBe('applied'); + await expect(reconciler.reconcileHead('peer-b', successor, signal)).resolves.toBe('applied'); + + expect(resolveDeployment).toHaveBeenNthCalledWith(1, genesis, signal); + expect(resolveDeployment).toHaveBeenNthCalledWith(2, successor, signal); + expect(synchronize).toHaveBeenNthCalledWith(1, 'peer-a', genesis, DEPLOYMENT, signal); + expect(synchronize).toHaveBeenNthCalledWith(2, 'peer-b', successor, DEPLOYMENT, signal); + }); + + it('dedupes only an exact fixed public/open applied head', async () => { + const readAppliedCatalogHeadV1 = vi.fn(); + const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + nativeReceiver: receiver(vi.fn()), + inventory: { readAppliedCatalogHeadV1 }, + resolveDeployment: async () => DEPLOYMENT, + }); + const genesis = announcement('0'); + readAppliedCatalogHeadV1.mockReturnValue(snapshot(genesis)); + + await expect(reconciler.isHeadApplied(genesis)).resolves.toBe(true); + await expect(reconciler.isHeadApplied(announcement('0', { + policyDigest: `0x${'88'.repeat(32)}` as Digest32V1, + signatureVariantDigest: `0x${'99'.repeat(32)}` as Digest32V1, + }))).resolves.toBe(true); + + const successor = announcement('1'); + readAppliedCatalogHeadV1.mockReturnValue(snapshot(successor)); + await expect(reconciler.isHeadApplied(successor)).resolves.toBe(true); + + for (const mismatch of [ + { currentCatalogHeadDigest: `0x${'aa'.repeat(32)}` as Digest32V1 }, + { catalogVersion: '2' }, + { inventoryRowCount: '0' }, + { catalogScopeDigest: `0x${'bb'.repeat(32)}` as Digest32V1 }, + { authorAddress: '0xcccccccccccccccccccccccccccccccccccccccc' as EvmAddressV1 }, + ] satisfies Array>) { + readAppliedCatalogHeadV1.mockReturnValue(snapshot(successor, mismatch)); + await expect(reconciler.isHeadApplied(successor)).resolves.toBe(false); + } + + const expectedScopeDigest = computeAuthorCatalogScopeDigestV1( + deriveRfc64PublicOpenCatalogScopeV1(successor), + ); + expect(readAppliedCatalogHeadV1).toHaveBeenLastCalledWith(expectedScopeDigest, AUTHOR); + expect(() => deriveRfc64PublicOpenCatalogScopeV1(announcement('1', { + subGraphName: 'not-root' as never, + }))).toThrow('root catalog lane'); + }); + + it('maps only the explicit native not-found error and propagates all other failures', async () => { + const notFound = new Rfc64PublicCatalogNativeReceiverErrorV1( + 'catalog-native-receiver-not-found', + 'missing', + ); + const synchronize = vi.fn(async () => { throw notFound; }); + const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + nativeReceiver: receiver(synchronize), + inventory: { readAppliedCatalogHeadV1: () => null }, + resolveDeployment: async () => DEPLOYMENT, + }); + const signal = new AbortController().signal; + + await expect(reconciler.reconcileHead('peer-a', announcement(), signal)) + .resolves.toBe('not-found'); + + const historyFailure = new Rfc64PublicCatalogNativeReceiverErrorV1( + 'catalog-native-receiver-history', + 'fork', + ); + synchronize.mockRejectedValueOnce(historyFailure); + await expect(reconciler.reconcileHead('peer-a', announcement(), signal)) + .rejects.toBe(historyFailure); + + const integrityFailure = new Error('deployment binding changed'); + synchronize.mockRejectedValueOnce(integrityFailure); + await expect(reconciler.reconcileHead('peer-a', announcement(), signal)) + .rejects.toBe(integrityFailure); + }); + + it('propagates deployment and cancellation failures without entering the native receiver', async () => { + const synchronize = vi.fn(); + const deploymentFailure = new Error('trusted deployment unavailable'); + const resolveDeployment = vi.fn(async () => { throw deploymentFailure; }); + const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + nativeReceiver: receiver(synchronize), + inventory: { readAppliedCatalogHeadV1: () => null }, + resolveDeployment, + }); + const signal = new AbortController().signal; + await expect(reconciler.reconcileHead('peer-a', announcement(), signal)) + .rejects.toBe(deploymentFailure); + expect(synchronize).not.toHaveBeenCalled(); + + const controller = new AbortController(); + const cancellation = new Error('closing'); + controller.abort(cancellation); + await expect(reconciler.reconcileHead('peer-a', announcement(), controller.signal)) + .rejects.toBe(cancellation); + expect(resolveDeployment).toHaveBeenCalledTimes(1); + expect(synchronize).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index dc343d1eab..2b9dcdd383 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -125,6 +125,7 @@ export default defineConfig({ "test/rfc64-public-catalog-service-v1.test.ts", "test/rfc64-public-catalog-gate1.integration.test.ts", "test/rfc64-public-catalog-native-transport-v1.test.ts", + "test/rfc64-public-catalog-native-reconciler-v1.test.ts", "test/rfc64-public-catalog-native-gate1.integration.test.ts", "test/rfc64-persistent-catalog-provider-v1.test.ts", "test/rfc64-public-catalog-successor-producer-v1.test.ts", From 20cc4eeeeca5f8fc5f0de1ccf178572241443c74 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:02:16 +0200 Subject: [PATCH 122/292] test(agent): align inventory lifecycle with schema v2 --- packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index 7c6a504aae..a3e98592c3 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -658,7 +658,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc const committedHeader = readFileSync(path).subarray(0, 100); expect(committedHeader.subarray(0, 16).toString('binary')).toBe('SQLite format 3\u0000'); expect(committedHeader.readUInt32BE(68)).toBe(INVENTORY_V1_APPLICATION_ID); - expect(committedHeader.readUInt32BE(60)).toBe(1); + expect(committedHeader.readUInt32BE(60)).toBe(INVENTORY_V1_USER_VERSION); if (process.platform !== 'win32') { expect(statSync(dirname(path)).mode & 0o777).toBe(0o700); expect(statSync(path).mode & 0o777).toBe(0o600); @@ -1664,7 +1664,7 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc PRAGMA journal_mode = WAL; CREATE TABLE owned_data (value TEXT); PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}; - PRAGMA user_version = 1; + PRAGMA user_version = ${INVENTORY_V1_USER_VERSION}; `); seed.close(); const holder = new DatabaseSync(path); From 2a3664373dfd7b11bc2f29db79c28f91722b4c48 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:04:47 +0200 Subject: [PATCH 123/292] fix(agent): authorize RFC-64 native receiver rows --- .../public-catalog-native-receiver-v1.ts | 146 +++++++++- ...c-catalog-native-gate1.integration.test.ts | 251 +++++++++++++++++- 2 files changed, 389 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 73880ddb99..5daff744dd 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -12,6 +12,7 @@ */ import { + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, AUTHOR_SCHEME_VERSION_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, @@ -20,6 +21,7 @@ import { assertAuthorCatalogDirectoryNodeScopeBindingV1, assertSignedAuthorCatalogBucketEnvelopeV1, assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, buildAuthorAttestationTypedData, canonicalizeAuthorCatalogBucketPayloadBytesV1, computeAuthorCatalogScopeDigestV1, @@ -40,6 +42,7 @@ import { type SignedAuthorCatalogBucketEnvelopeV1, type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, type VerifiedCatalogSealBindingSnapshotV1, } from '@origintrail-official/dkg-core'; import { sha256 } from '@noble/hashes/sha2.js'; @@ -53,6 +56,11 @@ import { ethers } from 'ethers'; import { parseNQuads } from '../dkg-agent-utils.js'; import { unpackKnowledgeAssetId } from '../ka-identity.js'; +import { + readVerifiedAuthorCatalogRowAuthorshipV1, + verifyAuthorCatalogRowAuthorshipV1, + type VerifiedAuthorCatalogRowAuthorshipSnapshotV1, +} from './catalog-row-authorship.js'; import type { Rfc64ControlObjectOperationsV1 } from './control-object-store-v1.js'; import type { AppliedCatalogHeadSnapshotV1, @@ -61,6 +69,7 @@ import type { import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + type FetchedRfc64PublicCatalogObjectV1, type Rfc64PublicCatalogNativeFetchScopeV1, type Rfc64PublicCatalogNativeTransportV1, } from './public-catalog-native-transport-v1.js'; @@ -102,6 +111,8 @@ export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { readonly inventoryRowCount: 1; readonly activatedTripleCount: number; readonly swmGraph: string; + /** Exact signed delegation/head/path/bucket/row authorization closure. */ + readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; readonly appliedHeadStatus: 'applied' | 'existing'; } @@ -112,7 +123,7 @@ export interface Rfc64PublicCatalogNativeGenesisEvidenceV1 { readonly catalogHeadDigest: Digest32V1; readonly inventoryRowCount: 0; readonly activatedTripleCount: 0; - readonly stagedObjectCount: 2; + readonly stagedObjectCount: 3; readonly appliedHeadStatus: 'applied' | 'existing'; } @@ -125,6 +136,7 @@ export type Rfc64PublicCatalogNativeReceiverErrorCodeV1 = | 'catalog-native-receiver-not-found' | 'catalog-native-receiver-slice' | 'catalog-native-receiver-catalog' + | 'catalog-native-receiver-authorization' | 'catalog-native-receiver-transfer' | 'catalog-native-receiver-activation' | 'catalog-native-receiver-history'; @@ -293,6 +305,12 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ); const replay = assertEmptyGenesisHistory(current, head, inventoryDigest); const scope = nativeScope(announcement, head); + const fetchedDelegation = await this.fetchDirectAuthorCatalogIssuerDelegation( + remotePeerId, + scope, + head, + signal, + ); const fetchedDirectory = await this.options.contentTransport.fetchCatalogObject( remotePeerId, { @@ -335,6 +353,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { try { await this.options.controlObjects.stageVerifiedObjects([ + fetchedDelegation, fetchedDirectory, fetchedHead, ]); @@ -386,7 +405,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { catalogHeadDigest: head.objectDigest as Digest32V1, inventoryRowCount: 0 as const, activatedTripleCount: 0 as const, - stagedObjectCount: 2 as const, + stagedObjectCount: 3 as const, appliedHeadStatus, }); } @@ -409,6 +428,12 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ); const replay = assertMonotonicSuccessorHistory(currentAppliedHead, head); const scope = nativeScope(announcement, head); + const fetchedDelegation = await this.fetchDirectAuthorCatalogIssuerDelegation( + remotePeerId, + scope, + head, + signal, + ); const fetchedDirectory = await this.options.contentTransport.fetchCatalogObject( remotePeerId, @@ -439,10 +464,11 @@ export class Rfc64PublicCatalogNativeReceiverV1 { fail('catalog-native-receiver-catalog', 'directory root is not bound to the successor head', cause); } + let directoryPathProof: ReturnType; let descriptor: ReturnType; try { - const path = verifyAuthorCatalogDirectoryPathV1(head, [directory], '0' as never); - descriptor = readVerifiedAuthorCatalogBucketDescriptorV1(path, head); + directoryPathProof = verifyAuthorCatalogDirectoryPathV1(head, [directory], '0' as never); + descriptor = readVerifiedAuthorCatalogBucketDescriptorV1(directoryPathProof, head); } catch (cause) { fail('catalog-native-receiver-catalog', 'successor directory path is invalid', cause); } @@ -490,6 +516,30 @@ export class Rfc64PublicCatalogNativeReceiverV1 { fail('catalog-native-receiver-catalog', 'verified one-row bucket did not contain its row'); } + let authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + try { + const authorshipToken = verifyAuthorCatalogRowAuthorshipV1({ + catalogIssuerDelegation: fetchedDelegation.envelope, + catalogIssuerDelegationSignature: fetchedDelegation.issuerSignature, + parentAuthorAgentEvidence: null, + catalogHead: head, + catalogHeadSignature: fetchedHead.issuerSignature, + directoryPathEnvelopes: [directory], + directoryPathSignatures: [fetchedDirectory.issuerSignature], + directoryPathProof, + catalogBucket: bucket, + catalogBucketSignature: fetchedBucket.issuerSignature, + targetKaId: row.kaId, + }); + authorship = readVerifiedAuthorCatalogRowAuthorshipV1(authorshipToken); + } catch (cause) { + fail( + 'catalog-native-receiver-authorization', + 'catalog row is not authorized by the exact direct-author delegation closure', + cause, + ); + } + const bundle = await this.options.contentTransport.fetchKaBundle( remotePeerId, { @@ -540,6 +590,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { try { await this.options.controlObjects.stageVerifiedObjects([ + fetchedDelegation, fetchedHead, fetchedDirectory, fetchedBucket, @@ -608,10 +659,59 @@ export class Rfc64PublicCatalogNativeReceiverV1 { inventoryRowCount: 1 as const, activatedTripleCount: Number(projectionMetadata.publicTripleCount), swmGraph: activation.swmGraph, + authorship, appliedHeadStatus, }); } + private async fetchDirectAuthorCatalogIssuerDelegation( + remotePeerId: string, + scope: Rfc64PublicCatalogNativeFetchScopeV1, + head: SignedAuthorCatalogHeadEnvelopeV1, + signal: AbortSignal | undefined, + ): Promise { + let fetched: FetchedRfc64PublicCatalogObjectV1 | null; + try { + fetched = await this.options.contentTransport.fetchCatalogObject( + remotePeerId, + { + ...scope, + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + targetObjectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + targetObjectDigest: head.payload.catalogIssuerDelegationDigest, + }, + { timeoutMs: this.#timeoutMs, signal }, + ); + } catch (cause) { + if (signal?.aborted) throw signal.reason; + fail( + 'catalog-native-receiver-authorization', + 'catalog issuer delegation fetch or generic signature verification failed', + cause, + ); + } + if (fetched === null) { + fail('catalog-native-receiver-not-found', 'catalog issuer delegation was not found'); + } + throwIfAborted(signal); + try { + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(fetched.envelope); + assertDirectAuthorCatalogIssuerDelegationBindingV1(fetched.envelope, head); + } catch (cause) { + fail( + 'catalog-native-receiver-authorization', + 'catalog issuer delegation is not the exact direct-author authority for this head', + cause, + ); + } + return Object.freeze({ + envelope: fetched.envelope, + issuerSignature: fetched.issuerSignature, + }); + } + private async withScopeSerialization( key: string, operation: () => Promise, @@ -754,6 +854,44 @@ function nativeScope( }); } +/** Gate 1 accepts only an author-signed issuer grant with no parent-agent hop. */ +function assertDirectAuthorCatalogIssuerDelegationBindingV1( + delegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1, + head: SignedAuthorCatalogHeadEnvelopeV1, +): void { + const left = delegation.payload; + const right = head.payload; + if ( + delegation.objectDigest !== right.catalogIssuerDelegationDigest + || delegation.issuer !== left.authorAddress + || left.authorAuthorityEvidenceDigest !== null + || left.catalogIssuerKey !== head.issuer + ) { + throw new Error( + 'delegation digest, direct author issuer, null parent evidence, or catalog issuer key differs', + ); + } + if ( + left.networkId !== right.networkId + || left.contextGraphId !== right.contextGraphId + || left.governanceChainId !== right.governanceChainId + || left.governanceContractAddress !== right.governanceContractAddress + || left.ownershipTransitionDigest !== right.ownershipTransitionDigest + || left.subGraphName !== right.subGraphName + || left.authorAddress !== right.authorAddress + || left.catalogEra !== right.era + ) { + throw new Error('delegation scope, governance tuple, author, lane, or era differs from head'); + } + if ( + (left.catalogEra === '0') !== (left.previousDelegationDigest === null) + || BigInt(right.issuedAt) < BigInt(left.effectiveAt) + || BigInt(right.issuedAt) >= BigInt(left.expiresAt) + ) { + throw new Error('delegation history or half-open validity interval does not authorize head'); + } +} + async function activateExactPublicProjection( store: TripleStore, head: SignedAuthorCatalogHeadEnvelopeV1, diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 367cba703f..150b549c98 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, DKGNode, ProtocolRouter, @@ -15,6 +16,7 @@ import { computeAuthorCatalogScopeDigestV1, computeAuthorCatalogHeadObjectDigestV1, computeCanonicalGraphScopedAuthorSealDigestV1, + computeControlObjectDigestHex, computeKaChunkTreeRootV1, deriveAuthorCatalogScopeFromHeadV1, encodeOpaqueKaBundleV1, @@ -29,6 +31,7 @@ import { type NetworkIdV1, type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, type SignedControlEnvelopeV1, type UnsignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; @@ -65,7 +68,7 @@ const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/native-gate-1' as ContextGraphIdV1; const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; const POLICY_DIGEST = `0x${'75'.repeat(32)}` as Digest32V1; -const DELEGATION_DIGEST = `0x${'76'.repeat(32)}` as Digest32V1; +const MISSING_DELEGATION_DIGEST = `0x${'76'.repeat(32)}` as Digest32V1; const KA_NUMBER = 7n; const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); const UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; @@ -143,7 +146,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { catalogHeadDigest: fixture.genesis.head.objectDigest, inventoryRowCount: 0, activatedTripleCount: 0, - stagedObjectCount: 2, + stagedObjectCount: 3, appliedHeadStatus: 'applied', }); await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); @@ -155,7 +158,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { ), fixture.rowBundle.row, ); - expect(evidence).toEqual({ + expect(evidence).toMatchObject({ inventoryDigest: computeRfc64AppliedInventoryDigestV1({ catalogScopeDigest: computeAuthorCatalogScopeDigestV1( deriveAuthorCatalogScopeFromHeadV1(fixture.successor.head.payload), @@ -178,6 +181,19 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { swmGraph: `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${KA_NUMBER}`, appliedHeadStatus: 'applied', }); + expect(evidence.authorship).toMatchObject({ + authorAddress: AUTHOR, + catalogIssuerKey: AUTHOR, + catalogIssuerDelegationObjectDigest: fixture.catalogIssuerDelegation.objectDigest, + catalogHeadObjectDigest: fixture.successor.head.objectDigest, + catalogRowDigest: expectedRowDigest, + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + era: '0', + version: '1', + }); + expect(Object.isFrozen(evidence.authorship)).toBe(true); expect(evidence.inventoryDigest).not.toBe(evidence.catalogHeadDigest); const activated = await fixture.receiverStore.query( @@ -209,7 +225,9 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { })); expect(fixture.authorObjectRead.mock.calls.map(([digest]) => digest)).toEqual([ + fixture.catalogIssuerDelegation.objectDigest, fixture.genesis.head.payload.directoryRootDigest, + fixture.catalogIssuerDelegation.objectDigest, fixture.successor.head.payload.directoryRootDigest, fixture.successor.bucket?.objectDigest, ]); @@ -217,6 +235,12 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { expect(fixture.authorBundleRead).toHaveBeenCalledWith( fixture.rowBundle.row.transfer.blobDigest, ); + await expect(fixture.receiverPersistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: fixture.catalogIssuerDelegation.objectDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).resolves.toMatchObject({ + envelope: { objectDigest: fixture.catalogIssuerDelegation.objectDigest }, + }); }, 30_000); it('rejects a signed genesis whose directory is not exactly empty', async () => { @@ -349,6 +373,91 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); }, 30_000); + it('rejects a forged catalog-issuer delegation signature before activation or applied-head CAS', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + fixture.authorObjects.set( + fixture.catalogIssuerDelegation.objectDigest, + fixture.forgedCatalogIssuerDelegation, + ); + fixture.authorBundleRead.mockClear(); + const observed = fixture.createCasObservedReceiver(); + + await expect(fixture.synchronize(fixture.announcement, observed.receiver)).rejects.toMatchObject({ + code: 'catalog-native-receiver-authorization', + }); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + expect(fixture.authorBundleRead).not.toHaveBeenCalled(); + }, 30_000); + + it('rejects a cross-lane catalog-issuer delegation before activation or applied-head CAS', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + fixture.authorBundleRead.mockClear(); + const observed = fixture.createCasObservedReceiver(); + + await expect(fixture.synchronize( + fixture.crossLaneAnnouncement, + observed.receiver, + )).rejects.toMatchObject({ + code: 'catalog-native-receiver-authorization', + }); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + expect(fixture.authorBundleRead).not.toHaveBeenCalled(); + }, 30_000); + + it('rejects an expired catalog-issuer delegation before activation or applied-head CAS', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + fixture.authorBundleRead.mockClear(); + const observed = fixture.createCasObservedReceiver(); + + await expect(fixture.synchronize( + fixture.expiredAnnouncement, + observed.receiver, + )).rejects.toMatchObject({ + code: 'catalog-native-receiver-authorization', + }); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + expect(fixture.authorBundleRead).not.toHaveBeenCalled(); + }, 30_000); + + it('rejects a missing catalog-issuer delegation before activation or applied-head CAS', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + fixture.authorBundleRead.mockClear(); + const observed = fixture.createCasObservedReceiver(); + + await expect(fixture.synchronize( + fixture.missingDelegationAnnouncement, + observed.receiver, + )).rejects.toMatchObject({ + code: 'catalog-native-receiver-not-found', + }); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + expect(fixture.authorBundleRead).not.toHaveBeenCalled(); + }, 30_000); + it('serializes one scope so a competing successor never activates over the winner', async () => { const fixture = await setupLiveReceiver(); await fixture.bootstrap(); @@ -424,10 +533,25 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuer: AUTHOR, signDigest: async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest), }; + const catalogIssuerDelegation = await buildDirectCatalogIssuerDelegation(); + const forgedCatalogIssuerDelegation = Object.freeze({ + ...catalogIssuerDelegation, + signature: await new ethers.Wallet(`0x${'78'.repeat(32)}`).signMessage( + ethers.getBytes(catalogIssuerDelegation.objectDigest), + ), + }) as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + const crossLaneDelegation = await buildDirectCatalogIssuerDelegation({ + contextGraphId: + '0x1111111111111111111111111111111111111111/native-gate-1-other' as ContextGraphIdV1, + }); + const expiredDelegation = await buildDirectCatalogIssuerDelegation({ + effectiveAt: '1773890000000', + expiresAt: '1773899999999', + }); const rowBundle = await buildRowBundle(signingWallet); const genesis = await produceEmptyAuthorCatalogGenesisV1({ scope, - catalogIssuerDelegationDigest: DELEGATION_DIGEST, + catalogIssuerDelegationDigest: catalogIssuerDelegation.objectDigest, issuedAt: '1773900000000' as never, signer, }); @@ -450,16 +574,34 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900001001' as never, signer, }); + const crossLaneHead = await rewriteCatalogHeadDelegation( + successor.head, + crossLaneDelegation.objectDigest, + ); + const expiredHead = await rewriteCatalogHeadDelegation( + successor.head, + expiredDelegation.objectDigest, + ); + const missingDelegationHead = await rewriteCatalogHeadDelegation( + successor.head, + MISSING_DELEGATION_DIGEST, + ); const invalidGenesis = await buildInvalidEmptyGenesis( genesis.head, successor.directoryPath[0]!, ); const authorObjects = new Map( [ + catalogIssuerDelegation, + crossLaneDelegation, + expiredDelegation, ...genesis.stagedObjects, ...invalidGenesis.stagedObjects, ...successor.stagedObjects, ...competingSuccessor.stagedObjects, + crossLaneHead, + expiredHead, + missingDelegationHead, ] .map((envelope) => [envelope.objectDigest, envelope]), ); @@ -469,10 +611,16 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { digest === rowBundle.row.transfer.blobDigest ? rowBundle.bundleBytes : null); const verifiedObjects = await Promise.all( [ + catalogIssuerDelegation, + crossLaneDelegation, + expiredDelegation, ...genesis.stagedObjects, ...invalidGenesis.stagedObjects, ...successor.stagedObjects, ...competingSuccessor.stagedObjects, + crossLaneHead, + expiredHead, + missingDelegationHead, ] .map(async (envelope) => ({ envelope, @@ -492,10 +640,22 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const competingHeadKeys = staged.objects.find( (keys) => keys.objectDigest === competingSuccessor.head.objectDigest, ); + const crossLaneHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === crossLaneHead.objectDigest, + ); + const expiredHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === expiredHead.objectDigest, + ); + const missingDelegationHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === missingDelegationHead.objectDigest, + ); if (headKeys === undefined) throw new Error('successor head was not staged'); if (genesisHeadKeys === undefined) throw new Error('genesis head was not staged'); if (invalidGenesisHeadKeys === undefined) throw new Error('invalid genesis head was not staged'); if (competingHeadKeys === undefined) throw new Error('competing successor head was not staged'); + if (crossLaneHeadKeys === undefined) throw new Error('cross-lane successor head was not staged'); + if (expiredHeadKeys === undefined) throw new Error('expired-delegation head was not staged'); + if (missingDelegationHeadKeys === undefined) throw new Error('missing-delegation head was not staged'); const receivedAnnouncements: Rfc64PublicCatalogHeadAnnouncementV1[] = []; const openPolicy = async () => Object.freeze({ accessPolicy: 0 as const, @@ -568,6 +728,21 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { catalogHeadObjectDigest: competingHeadKeys.objectDigest, signatureVariantDigest: competingHeadKeys.signatureVariantDigest, }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const crossLaneAnnouncement = Object.freeze({ + ...announcement, + catalogHeadObjectDigest: crossLaneHeadKeys.objectDigest, + signatureVariantDigest: crossLaneHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const expiredAnnouncement = Object.freeze({ + ...announcement, + catalogHeadObjectDigest: expiredHeadKeys.objectDigest, + signatureVariantDigest: expiredHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const missingDelegationAnnouncement = Object.freeze({ + ...announcement, + catalogHeadObjectDigest: missingDelegationHeadKeys.objectDigest, + signatureVariantDigest: missingDelegationHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; const invalidGenesisAnnouncement = Object.freeze({ ...genesisAnnouncement, catalogHeadObjectDigest: invalidGenesisHeadKeys.objectDigest, @@ -601,18 +776,42 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { inventory, store: receiverStore, }); + const createCasObservedReceiver = () => { + const compareAndSwapAppliedCatalogHeadV1 = vi.fn( + receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1.bind( + receiverPersistence.inventory, + ), + ); + return Object.freeze({ + compareAndSwapAppliedCatalogHeadV1, + receiver: createReceiver({ + readAppliedCatalogHeadV1: + receiverPersistence.inventory.readAppliedCatalogHeadV1.bind( + receiverPersistence.inventory, + ), + compareAndSwapAppliedCatalogHeadV1, + }), + }); + }; const receiver = createReceiver(receiverPersistence.inventory); return { announcement, authorBundleRead, authorObjectRead, + authorObjects, + catalogIssuerDelegation, competingAnnouncement, + crossLaneAnnouncement, + createCasObservedReceiver, createReceiver, + expiredAnnouncement, + forgedCatalogIssuerDelegation, genesis, genesisAnnouncement, invalidGenesisAnnouncement, receiver, receiverBundleFetch, + missingDelegationAnnouncement, receiverDirectory: receiverOpened.directory, receiverHeadFetch, receiverObjectFetch, @@ -654,6 +853,50 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { }; } +async function buildDirectCatalogIssuerDelegation(options: { + readonly contextGraphId?: ContextGraphIdV1; + readonly effectiveAt?: string; + readonly expiresAt?: string; +} = {}): Promise { + const unsigned = testUnsignedEnvelope( + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + { + authorAddress: AUTHOR, + authorAuthorityEvidenceDigest: null, + catalogEra: '0', + catalogIssuerKey: AUTHOR, + contextGraphId: options.contextGraphId ?? CONTEXT_GRAPH_ID, + effectiveAt: options.effectiveAt ?? '1773899999000', + expiresAt: options.expiresAt ?? '1774000000000', + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE, + networkId: NETWORK_ID, + ownershipTransitionDigest: null, + previousDelegationDigest: null, + subGraphName: null, + }, + ); + return signTestEnvelope( + unsigned, + computeControlObjectDigestHex(unsigned) as Digest32V1, + ) as Promise; +} + +async function rewriteCatalogHeadDelegation( + sourceHead: SignedAuthorCatalogHeadEnvelopeV1, + catalogIssuerDelegationDigest: Digest32V1, +): Promise { + const headPayload = { + ...sourceHead.payload, + catalogIssuerDelegationDigest, + } as AuthorCatalogHeadV1; + const unsigned = testUnsignedEnvelope(AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, headPayload); + return signTestEnvelope( + unsigned, + computeAuthorCatalogHeadObjectDigestV1(unsigned), + ) as Promise; +} + async function buildInvalidEmptyGenesis( sourceHead: SignedAuthorCatalogHeadEnvelopeV1, sourceDirectory: SignedAuthorCatalogDirectoryNodeEnvelopeV1, From f3af914407a6a27ab6bbf3fd2219a05800f8f10c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:07:19 +0200 Subject: [PATCH 124/292] test(agent): preserve public-open receiver composition --- .../rfc64-public-catalog-native-gate1.integration.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 150b549c98..60589648c8 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -277,7 +277,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { ); expect(fixture.receiverHeadFetch).toHaveBeenCalledTimes(3); - expect(fixture.receiverObjectFetch).toHaveBeenCalledTimes(5); + expect(fixture.receiverObjectFetch).toHaveBeenCalledTimes(8); expect(fixture.receiverBundleFetch).toHaveBeenCalledTimes(2); for (const fetch of [ fixture.receiverHeadFetch, @@ -868,8 +868,8 @@ async function buildDirectCatalogIssuerDelegation(options: { contextGraphId: options.contextGraphId ?? CONTEXT_GRAPH_ID, effectiveAt: options.effectiveAt ?? '1773899999000', expiresAt: options.expiresAt ?? '1774000000000', - governanceChainId: '20430', - governanceContractAddress: GOVERNANCE, + governanceChainId: null, + governanceContractAddress: null, networkId: NETWORK_ID, ownershipTransitionDigest: null, previousDelegationDigest: null, From 591ff321e9cd88f91206d15a87c882873f005c37 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:06:25 +0200 Subject: [PATCH 125/292] feat(agent): authorize RFC-64 catalog genesis --- .../agent-process.mjs | 10 + .../devnet/rfc64-gate1-public-catalog/run.mjs | 9 + packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/dkg-agent-rfc64-catalog.ts | 51 ++-- packages/agent/src/index.ts | 1 + .../public-catalog-issuer-delegation-v1.ts | 252 ++++++++++++++++++ .../src/rfc64/public-catalog-service-v1.ts | 148 +++++++++- ...4-public-catalog-gate1.integration.test.ts | 16 ++ ...ublic-catalog-issuer-delegation-v1.test.ts | 137 ++++++++++ .../rfc64-public-catalog-service-v1.test.ts | 184 +++++++++++++ ...blic-catalog-successor-producer-v1.test.ts | 43 ++- packages/agent/vitest.unit.config.ts | 1 + 13 files changed, 797 insertions(+), 57 deletions(-) create mode 100644 packages/agent/src/rfc64/public-catalog-issuer-delegation-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-issuer-delegation-v1.test.ts diff --git a/packages/agent/devnet/rfc64-gate1-public-catalog/agent-process.mjs b/packages/agent/devnet/rfc64-gate1-public-catalog/agent-process.mjs index 99e001fcd2..0c57be4ec5 100644 --- a/packages/agent/devnet/rfc64-gate1-public-catalog/agent-process.mjs +++ b/packages/agent/devnet/rfc64-gate1-public-catalog/agent-process.mjs @@ -75,11 +75,21 @@ async function handle(cmd) { author: wallet, peers: cmd.peers, issuedAt: cmd.issuedAt, + catalogIssuerDelegationEffectiveAt: cmd.catalogIssuerDelegationEffectiveAt, + catalogIssuerDelegationExpiresAt: cmd.catalogIssuerDelegationExpiresAt, + }); + const delegationReadBack = await agent.readRfc64StagedCatalogIssuerDelegationV1({ + objectDigest: result.catalogIssuerDelegationObjectDigest, + signatureVariantDigest: result.catalogIssuerDelegationSignatureVariantDigest, }); emit({ event: 'published', headObjectDigest: result.headObjectDigest, signatureVariantDigest: result.signatureVariantDigest, + catalogIssuerDelegationObjectDigest: result.catalogIssuerDelegationObjectDigest, + catalogIssuerDelegationSignatureVariantDigest: + result.catalogIssuerDelegationSignatureVariantDigest, + catalogIssuerDelegationReadBack: delegationReadBack, policyDigest: result.announcement.policyDigest, announcedPeers: result.announcedPeers, failedPeers: result.failedPeers, diff --git a/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs b/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs index 0690f494cb..56b7e67609 100644 --- a/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs +++ b/packages/agent/devnet/rfc64-gate1-public-catalog/run.mjs @@ -116,6 +116,8 @@ async function main() { authorPrivateKey: AUTHOR_PRIVATE_KEY, peers: [receiverReady.peerId], issuedAt: ISSUED_AT, + catalogIssuerDelegationEffectiveAt: '1773899999000', + catalogIssuerDelegationExpiresAt: '1774000000000', }); const published = await author.waitFor('published'); @@ -132,6 +134,10 @@ async function main() { authorCatalogServiceStarted: authorReady.catalogServiceStarted === true, receiverCatalogServiceStarted: receiverReady.catalogServiceStarted === true, policyDigestsMatchIndependently: receiverPolicy.policyDigest === published.policyDigest, + signedDirectAuthorDelegation: + /^0x[0-9a-f]{64}$/.test(published.catalogIssuerDelegationObjectDigest) + && published.catalogIssuerDelegationReadBack + === published.catalogIssuerDelegationObjectDigest, announcementAcknowledged: Array.isArray(published.announcedPeers) && published.announcedPeers.includes(receiverReady.peerId) @@ -159,6 +165,9 @@ async function main() { objectDigest: published.headObjectDigest, signatureVariantDigest: published.signatureVariantDigest, policyDigest: published.policyDigest, + catalogIssuerDelegationObjectDigest: published.catalogIssuerDelegationObjectDigest, + catalogIssuerDelegationSignatureVariantDigest: + published.catalogIssuerDelegationSignatureVariantDigest, }, receiver: { independentlyAcceptedPolicyDigest: receiverPolicy.policyDigest, diff --git a/packages/agent/package.json b/packages/agent/package.json index 70075d1f6e..88a15ff114 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -34,6 +34,7 @@ "./dist/rfc64/public-catalog-native-transport-v1.js": null, "./dist/rfc64/public-catalog-receiver-v1.js": null, "./dist/rfc64/public-catalog-service-v1.js": null, + "./dist/rfc64/public-catalog-issuer-delegation-v1.js": null, "./dist/rfc64/public-catalog-successor-producer-v1.js": null, "./dist/rfc64/public-catalog-transport-v1.js": null, "./dist/rfc64/secure-filesystem-policy-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 66a4ffeda0..06ae5b466e 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -44,6 +44,7 @@ const blockedRfc64Modules = [ 'public-catalog-native-transport-v1.js', 'public-catalog-receiver-v1.js', 'public-catalog-service-v1.js', + 'public-catalog-issuer-delegation-v1.js', 'public-catalog-successor-producer-v1.js', 'public-catalog-transport-v1.js', 'secure-filesystem-policy-v1.js', diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 99d8b5896d..93381003c4 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -9,7 +9,8 @@ * - construct + start {@link Rfc64PublicCatalogServiceV1} on the production * router during `start()` (dormant when no `dataDir` opened the RFC-64 * persistence), and drain + close it during `stop()`; - * - expose the author path (produce + durably stage + best-effort announce a + * - expose the author path (sign + durably stage the direct-author issuer + * delegation, produce + durably stage + best-effort announce its bound * genesis head) and the accepted-open-policy seed used by both sides; * - expose read-only observability for tests / evidence. * @@ -18,6 +19,7 @@ */ import { + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, createOperationContext, type OperationContext, type ContextGraphIdV1, @@ -42,15 +44,6 @@ import { type Rfc64PublicCatalogServiceStatsV1, } from './rfc64/public-catalog-service-v1.js'; -/** - * Gate-1 simplification: a genesis head carries a placeholder catalog-issuer - * delegation digest. The transport verifies generic envelope cryptography, not - * issuer delegation, so this does not weaken any Gate-1 check; real - * delegation-authority binding lands in a later phase. - */ -export const RFC64_GATE1_PLACEHOLDER_DELEGATION_DIGEST_V1 = - `0x${'11'.repeat(32)}` as Digest32V1; - /** Minimal EIP-191 EOA signer (ethers.Wallet-compatible) for author-catalog objects. */ export interface Rfc64OpenCatalogAuthorSignerV1 { readonly address: string; @@ -76,11 +69,13 @@ export interface PublishOpenAuthorCatalogGenesisParamsV1 { readonly peers: readonly string[]; /** Head object timestamp; defaults to now. */ readonly issuedAt?: TimestampMsV1; + /** Required half-open validity interval for the signed direct-author delegation. */ + readonly catalogIssuerDelegationEffectiveAt: TimestampMsV1; + readonly catalogIssuerDelegationExpiresAt: TimestampMsV1; /** Open-policy timestamps; MUST match on every party (default '0'). */ readonly policyIssuedAt?: TimestampMsV1; readonly policyEffectiveAt?: TimestampMsV1; readonly ownerAuthorityEra?: DecimalU64V1; - readonly catalogIssuerDelegationDigest?: Digest32V1; } export interface Rfc64StagedAuthorCatalogHeadRefV1 { @@ -88,6 +83,11 @@ export interface Rfc64StagedAuthorCatalogHeadRefV1 { readonly signatureVariantDigest: Digest32V1; } +export interface Rfc64StagedCatalogIssuerDelegationRefV1 { + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; +} + export class Rfc64CatalogMethods extends DKGAgentBase { /** * Construct + start the public catalog service on the production router. @@ -126,8 +126,9 @@ export class Rfc64CatalogMethods extends DKGAgentBase { } /** - * Author path: accept the CG's open policy, produce a signed genesis head, - * durably stage its immutable objects, then best-effort announce availability. + * Author path: accept the CG's open policy, durably stage its signed direct- + * author issuer delegation, durably stage the bound genesis, then best-effort + * announce availability. */ async publishOpenAuthorCatalogGenesisV1( this: DKGAgent, @@ -158,12 +159,13 @@ export class Rfc64CatalogMethods extends DKGAgentBase { issuer: authorAddress, signDigest: (objectDigest) => params.author.signMessage(objectDigest), }; + const issuedAt = params.issuedAt ?? (Date.now().toString() as TimestampMsV1); return service.publishOpenAuthorCatalogGenesis({ scope, signer, - catalogIssuerDelegationDigest: - params.catalogIssuerDelegationDigest ?? RFC64_GATE1_PLACEHOLDER_DELEGATION_DIGEST_V1, - issuedAt: params.issuedAt ?? (Date.now().toString() as TimestampMsV1), + issuedAt, + catalogIssuerDelegationEffectiveAt: params.catalogIssuerDelegationEffectiveAt, + catalogIssuerDelegationExpiresAt: params.catalogIssuerDelegationExpiresAt, policy, peers: params.peers, }); @@ -187,6 +189,23 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return stored === null ? null : (stored.envelope.objectDigest as Digest32V1); } + /** Read back the exact durably staged delegation returned by genesis publication. */ + async readRfc64StagedCatalogIssuerDelegationV1( + this: DKGAgent, + ref: Rfc64StagedCatalogIssuerDelegationRefV1, + ): Promise { + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) return null; + const stored = await persistence.controlObjects.getVerifiedObject({ + objectDigest: ref.objectDigest, + signatureVariantDigest: ref.signatureVariantDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (stored === null) return null; + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(stored.envelope); + return stored.envelope.objectDigest as Digest32V1; + } + /** Await the receiver scheduler draining all queued + in-flight fetch/stage work. */ whenRfc64PublicCatalogReceiverIdleV1(this: DKGAgent): Promise { const service = this.rfc64PublicCatalogServiceV1; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 265a061069..54f5379fd8 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -40,6 +40,7 @@ export * from './rfc64/public-catalog-transport-v1.js'; export * from './rfc64/open-catalog-policy-v1.js'; export * from './rfc64/public-catalog-receiver-v1.js'; export * from './rfc64/public-catalog-service-v1.js'; +export * from './rfc64/public-catalog-issuer-delegation-v1.js'; export * from './rfc64/public-catalog-native-transport-v1.js'; export * from './rfc64/public-catalog-native-receiver-v1.js'; export * from './rfc64/public-catalog-successor-producer-v1.js'; diff --git a/packages/agent/src/rfc64/public-catalog-issuer-delegation-v1.ts b/packages/agent/src/rfc64/public-catalog-issuer-delegation-v1.ts new file mode 100644 index 0000000000..1cb59d9258 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-issuer-delegation-v1.ts @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Canonical direct-author catalog-issuer delegation bootstrap. + * + * A genesis catalog head is not authoritative merely because its EIP-191 + * signature recovers an EOA. It must name an exact, signed issuer delegation + * whose scope and half-open validity interval authorize that same catalog key. + * This producer closes that dependency before any durable publication occurs. + */ + +import { + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + assertAuthorCatalogScopeV1, + assertCanonicalTimestampMs, + canonicalizeSignedAuthorCatalogIssuerDelegationEnvelopeBytesV1, + computeAuthorCatalogIssuerDelegationObjectDigestV1, + parseCanonicalSignedAuthorCatalogIssuerDelegationEnvelopeV1, + type AuthorCatalogIssuerDelegationV1, + type AuthorCatalogScopeV1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, + type TimestampMsV1, + type UnsignedAuthorCatalogIssuerDelegationEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + readVerifiedControlEnvelopeIssuerSignatureV1, + verifyControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; + +import type { Rfc64AuthorCatalogEip191SignerV1 } from './author-catalog-producer.js'; +import type { Rfc64PublicCatalogIssuerAuthorizationV1 } from './public-catalog-successor-producer-v1.js'; + +export type Rfc64DirectCatalogIssuerDelegationErrorCodeV1 = + | 'catalog-delegation-input' + | 'catalog-delegation-scope' + | 'catalog-delegation-time' + | 'catalog-delegation-signer'; + +export class Rfc64DirectCatalogIssuerDelegationErrorV1 extends Error { + constructor( + readonly code: Rfc64DirectCatalogIssuerDelegationErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'Rfc64DirectCatalogIssuerDelegationErrorV1'; + } +} + +export interface ProduceDirectAuthorCatalogIssuerDelegationInputV1 { + /** Exact catalog lane later copied into the genesis head. */ + readonly scope: AuthorCatalogScopeV1; + /** The direct author and selected catalog issuer are this same EOA. */ + readonly signer: Rfc64AuthorCatalogEip191SignerV1; + readonly effectiveAt: TimestampMsV1; + readonly expiresAt: TimestampMsV1; + /** Genesis head timestamp that must fall in [effectiveAt, expiresAt). */ + readonly catalogHeadIssuedAt: TimestampMsV1; +} + +export interface ProducedDirectAuthorCatalogIssuerDelegationV1 { + readonly authorization: Rfc64PublicCatalogIssuerAuthorizationV1; + /** Generic cryptographic proof bound to the exact returned envelope. */ + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; +} + +/** + * Produce and re-verify an era-zero direct-author issuer delegation. This + * function performs no I/O; callers must durably stage the returned exact + * envelope before making a head that references it observable. + */ +export async function produceDirectAuthorCatalogIssuerDelegationV1( + input: ProduceDirectAuthorCatalogIssuerDelegationInputV1, +): Promise { + let scope: Readonly; + let effectiveAt: TimestampMsV1; + let expiresAt: TimestampMsV1; + let catalogHeadIssuedAt: TimestampMsV1; + let signer: Rfc64AuthorCatalogEip191SignerV1; + try { + const suppliedScope = input.scope; + scope = Object.freeze({ + networkId: suppliedScope.networkId, + contextGraphId: suppliedScope.contextGraphId, + governanceChainId: suppliedScope.governanceChainId, + governanceContractAddress: suppliedScope.governanceContractAddress, + ownershipTransitionDigest: suppliedScope.ownershipTransitionDigest, + subGraphName: suppliedScope.subGraphName, + authorAddress: suppliedScope.authorAddress, + era: suppliedScope.era, + bucketCount: suppliedScope.bucketCount, + }); + assertAuthorCatalogScopeV1(scope); + effectiveAt = input.effectiveAt; + expiresAt = input.expiresAt; + catalogHeadIssuedAt = input.catalogHeadIssuedAt; + assertCanonicalTimestampMs(effectiveAt, 'effectiveAt'); + assertCanonicalTimestampMs(expiresAt, 'expiresAt'); + assertCanonicalTimestampMs(catalogHeadIssuedAt, 'catalogHeadIssuedAt'); + const suppliedSigner = input.signer; + signer = Object.freeze({ + issuer: suppliedSigner.issuer, + signDigest: suppliedSigner.signDigest, + }); + if (typeof signer.signDigest !== 'function') { + throw new TypeError('signer.signDigest must be a function'); + } + } catch (cause) { + fail('catalog-delegation-input', 'direct delegation input is not canonical', cause); + } + + if (scope.era !== '0') { + fail('catalog-delegation-scope', 'direct genesis delegation requires catalog era zero'); + } + if (signer.issuer !== scope.authorAddress) { + fail( + 'catalog-delegation-scope', + 'direct delegation signer must equal the exact catalog author', + ); + } + if ( + BigInt(effectiveAt) >= BigInt(expiresAt) + || BigInt(catalogHeadIssuedAt) < BigInt(effectiveAt) + || BigInt(catalogHeadIssuedAt) >= BigInt(expiresAt) + ) { + fail( + 'catalog-delegation-time', + 'catalog head issuedAt must be inside the delegation half-open interval', + ); + } + + const payload: AuthorCatalogIssuerDelegationV1 = Object.freeze({ + networkId: scope.networkId, + contextGraphId: scope.contextGraphId, + governanceChainId: scope.governanceChainId, + governanceContractAddress: scope.governanceContractAddress, + ownershipTransitionDigest: scope.ownershipTransitionDigest, + subGraphName: scope.subGraphName, + authorAddress: scope.authorAddress, + catalogEra: scope.era, + previousDelegationDigest: null, + catalogIssuerKey: scope.authorAddress, + authorAuthorityEvidenceDigest: null, + effectiveAt, + expiresAt, + }); + const unsigned = Object.freeze({ + issuer: scope.authorAddress, + objectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + payload, + signatureEvidence: Object.freeze({ kind: 'none' as const }), + signatureSuite: 'eip191-personal-sign-digest-v1' as const, + }) as unknown as UnsignedAuthorCatalogIssuerDelegationEnvelopeV1; + const objectDigest = computeAuthorCatalogIssuerDelegationObjectDigestV1(unsigned); + + let signature: string; + try { + signature = await signer.signDigest(ethers.getBytes(objectDigest)); + } catch (cause) { + fail('catalog-delegation-signer', 'direct author failed to sign the delegation', cause); + } + + let delegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1; + let issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; + try { + const signed = { + ...unsigned, + objectDigest, + signature, + } as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + delegation = deepFreezePlain(parseCanonicalSignedAuthorCatalogIssuerDelegationEnvelopeV1( + canonicalizeSignedAuthorCatalogIssuerDelegationEnvelopeBytesV1(signed), + )); + issuerSignature = await verifyControlEnvelopeIssuerSignatureV1(delegation); + assertExactDirectBinding(delegation, scope, effectiveAt, expiresAt); + const proof = readVerifiedControlEnvelopeIssuerSignatureV1(issuerSignature); + if ( + proof.objectDigest !== delegation.objectDigest + || proof.issuer !== delegation.issuer + || proof.signatureSuite !== delegation.signatureSuite + ) { + throw new Error('signature proof is not bound to the exact delegation'); + } + } catch (cause) { + fail( + 'catalog-delegation-signer', + 'signer did not produce one canonical direct-author delegation', + cause, + ); + } + + return Object.freeze({ + authorization: Object.freeze({ + catalogIssuerDelegation: delegation, + parentAuthorAgentEvidence: null, + }), + issuerSignature, + }); +} + +function assertExactDirectBinding( + delegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1, + scope: Readonly, + effectiveAt: TimestampMsV1, + expiresAt: TimestampMsV1, +): void { + const payload = delegation.payload; + if ( + delegation.issuer !== scope.authorAddress + || delegation.objectType !== AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1 + || delegation.signatureSuite !== 'eip191-personal-sign-digest-v1' + || delegation.signatureEvidence.kind !== 'none' + || payload.networkId !== scope.networkId + || payload.contextGraphId !== scope.contextGraphId + || payload.governanceChainId !== scope.governanceChainId + || payload.governanceContractAddress !== scope.governanceContractAddress + || payload.ownershipTransitionDigest !== scope.ownershipTransitionDigest + || payload.subGraphName !== scope.subGraphName + || payload.authorAddress !== scope.authorAddress + || payload.catalogEra !== scope.era + || payload.previousDelegationDigest !== null + || payload.catalogIssuerKey !== scope.authorAddress + || payload.authorAuthorityEvidenceDigest !== null + || payload.effectiveAt !== effectiveAt + || payload.expiresAt !== expiresAt + ) { + fail( + 'catalog-delegation-scope', + 'signed delegation changed the exact direct-author catalog scope', + ); + } +} + +function deepFreezePlain(value: T): T { + if (typeof value !== 'object' || value === null) return value; + for (const nested of Object.values(value)) deepFreezePlain(nested); + return Object.isFrozen(value) ? value : Object.freeze(value); +} + +function fail( + code: Rfc64DirectCatalogIssuerDelegationErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64DirectCatalogIssuerDelegationErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 0c85dc14e7..41f79f11d0 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -12,8 +12,9 @@ * exact post-read, and durable applied-inventory commit, * - answers the transport's open-policy check from the accepted-policy * registry ({@link Rfc64AcceptedOpenCatalogPolicyRegistryV1}), - * - and provides the author path: produce a genesis head, durably stage it, - * then best-effort announce its availability to peers. + * - and provides the author path: sign + durably stage the direct-author + * issuer delegation, produce + durably stage its bound genesis head, then + * best-effort announce availability to peers. * * Omitting a reconciler retains a staging-only diagnostic mode for the earlier * Gate-1A demo. That mode never reports a head as applied and never uses staged @@ -21,6 +22,8 @@ */ import { + assertAuthorCatalogScopeV1, + computeControlSignatureVariantDigestHex, type ProtocolRouter, type SendOptions, type SignedControlEnvelopeV1, @@ -37,10 +40,15 @@ import { produceEmptyAuthorCatalogGenesisV1, type Rfc64AuthorCatalogEip191SignerV1, } from './author-catalog-producer.js'; -import type { Rfc64ControlObjectOperationsV1 } from './control-object-store-v1.js'; +import type { + Rfc64ControlObjectOperationsV1, + StageVerifiedControlObjectV1, + StageVerifiedControlObjectsResultV1, +} from './control-object-store-v1.js'; import { Rfc64AcceptedOpenCatalogPolicyRegistryV1, buildOpenOwnerContextGraphPolicyV1, + computeOpenContextGraphPolicyDigestV1, type AcceptedOpenCatalogPolicyV1, type BuildOpenOwnerContextGraphPolicyInputV1, } from './open-catalog-policy-v1.js'; @@ -56,6 +64,12 @@ import { type Rfc64PublicCatalogNativeAuthorizationV1, type Rfc64PublicCatalogNativeTransportOptionsV1, } from './public-catalog-native-transport-v1.js'; +import { + produceDirectAuthorCatalogIssuerDelegationV1, +} from './public-catalog-issuer-delegation-v1.js'; +import type { + Rfc64PublicCatalogIssuerAuthorizationV1, +} from './public-catalog-successor-producer-v1.js'; import { RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, Rfc64PublicCatalogTransportV1, @@ -115,8 +129,9 @@ export interface Rfc64PublicCatalogServiceNativeOptionsV1 extends Pick< export interface PublishOpenAuthorCatalogGenesisInputV1 { readonly scope: AuthorCatalogScopeV1; readonly signer: Rfc64AuthorCatalogEip191SignerV1; - readonly catalogIssuerDelegationDigest: Digest32V1; readonly issuedAt: TimestampMsV1; + readonly catalogIssuerDelegationEffectiveAt: TimestampMsV1; + readonly catalogIssuerDelegationExpiresAt: TimestampMsV1; /** The accepted open policy for the CG; its digest stamps the announcement. */ readonly policy: AcceptedOpenCatalogPolicyV1; /** Peers to announce availability to. Announcements are best-effort hints. */ @@ -127,6 +142,10 @@ export interface PublishOpenAuthorCatalogGenesisResultV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; readonly headObjectDigest: Digest32V1; readonly signatureVariantDigest: Digest32V1; + /** Exact signed direct-author proof usable by the hardened successor producer. */ + readonly catalogIssuerAuthorization: Rfc64PublicCatalogIssuerAuthorizationV1; + readonly catalogIssuerDelegationObjectDigest: Digest32V1; + readonly catalogIssuerDelegationSignatureVariantDigest: Digest32V1; /** Peers the announcement was acknowledged by. */ readonly announcedPeers: readonly string[]; /** Peers whose announcement failed (best-effort; correctness comes from pull). */ @@ -243,19 +262,55 @@ export class Rfc64PublicCatalogServiceV1 { } /** - * Author path: produce a signed empty genesis head, durably stage its - * immutable objects, then best-effort announce availability to `peers`. The - * head is durable before any announcement; announcements grant no authority. + * Author path: produce and durably stage the signed issuer delegation, then + * produce and durably stage its bound empty genesis, then best-effort + * announce availability to `peers`. Both durability barriers complete before + * any announcement; announcements grant no authority. */ async publishOpenAuthorCatalogGenesis( input: PublishOpenAuthorCatalogGenesisInputV1, ): Promise { this.#requireStarted(); + const scope = snapshotCatalogScope(input.scope); + const signer = Object.freeze({ + issuer: input.signer.issuer, + signDigest: input.signer.signDigest, + }); + const issuedAt = input.issuedAt; + const effectiveAt = input.catalogIssuerDelegationEffectiveAt; + const expiresAt = input.catalogIssuerDelegationExpiresAt; + const peers = Object.freeze(Array.from(input.peers)); + const heldPolicy = this.#policies.lookup(scope.networkId, scope.contextGraphId); + assertOpenPolicyMatchesCatalogScope(input.policy, heldPolicy, scope); + const policyDigest = heldPolicy!.policyDigest; + const delegation = await produceDirectAuthorCatalogIssuerDelegationV1({ + scope, + signer, + effectiveAt, + expiresAt, + catalogHeadIssuedAt: issuedAt, + }); + + // The delegation is an independent durable prerequisite. A single batch + // would permit the store to write its objects concurrently; two awaited + // barriers prove the named delegation is durable before any head that + // references it can be durably staged or announced. + const delegationEnvelope = delegation.authorization.catalogIssuerDelegation; + const delegationObject: StageVerifiedControlObjectV1 = Object.freeze({ + envelope: delegationEnvelope, + issuerSignature: delegation.issuerSignature, + }); + const stagedDelegation = await this.#controlObjects.stageVerifiedObjects([ + delegationObject, + ]); + assertExactDurableStageReceipt(stagedDelegation, [delegationEnvelope]); + const delegationKeys = stagedDelegation.objects[0]!; + const produced = await produceEmptyAuthorCatalogGenesisV1({ - scope: input.scope, - catalogIssuerDelegationDigest: input.catalogIssuerDelegationDigest, - issuedAt: input.issuedAt, - signer: input.signer, + scope, + catalogIssuerDelegationDigest: delegationEnvelope.objectDigest as Digest32V1, + issuedAt, + signer, }); const verified = await Promise.all( @@ -265,6 +320,7 @@ export class Rfc64PublicCatalogServiceV1 { })), ); const staged = await this.#controlObjects.stageVerifiedObjects(verified); + assertExactDurableStageReceipt(staged, produced.stagedObjects); const headKeys = staged.objects.at(-1); if (headKeys === undefined) { throw new Error('RFC-64 author catalog producer staged no head object'); @@ -278,14 +334,14 @@ export class Rfc64PublicCatalogServiceV1 { authorAddress: produced.head.payload.authorAddress, catalogEra: produced.head.payload.era, catalogVersion: produced.head.payload.version, - policyDigest: input.policy.policyDigest, + policyDigest, catalogHeadObjectDigest: headKeys.objectDigest, signatureVariantDigest: headKeys.signatureVariantDigest, }); const announcedPeers: string[] = []; const failedPeers: Array<{ peerId: string; error: string }> = []; - for (const peerId of input.peers) { + for (const peerId of peers) { try { await this.#transport.announceCatalogHead(peerId, announcement, this.#sendOptions()); announcedPeers.push(peerId); @@ -301,6 +357,9 @@ export class Rfc64PublicCatalogServiceV1 { announcement, headObjectDigest: headKeys.objectDigest, signatureVariantDigest: headKeys.signatureVariantDigest, + catalogIssuerAuthorization: delegation.authorization, + catalogIssuerDelegationObjectDigest: delegationKeys.objectDigest, + catalogIssuerDelegationSignatureVariantDigest: delegationKeys.signatureVariantDigest, announcedPeers: Object.freeze(announcedPeers), failedPeers: Object.freeze(failedPeers), }); @@ -358,3 +417,66 @@ export class Rfc64PublicCatalogServiceV1 { } } } + +function snapshotCatalogScope(input: AuthorCatalogScopeV1): Readonly { + const scope = Object.freeze({ + networkId: input.networkId, + contextGraphId: input.contextGraphId, + governanceChainId: input.governanceChainId, + governanceContractAddress: input.governanceContractAddress, + ownershipTransitionDigest: input.ownershipTransitionDigest, + subGraphName: input.subGraphName, + authorAddress: input.authorAddress, + era: input.era, + bucketCount: input.bucketCount, + }); + assertAuthorCatalogScopeV1(scope); + return scope; +} + +function assertOpenPolicyMatchesCatalogScope( + supplied: AcceptedOpenCatalogPolicyV1, + held: AcceptedOpenCatalogPolicyV1 | null, + scope: AuthorCatalogScopeV1, +): void { + const policy = supplied.policy; + if ( + held === null + || held.policyDigest !== supplied.policyDigest + || supplied.policyDigest !== computeOpenContextGraphPolicyDigestV1(policy) + || policy.networkId !== scope.networkId + || policy.contextGraphId !== scope.contextGraphId + || policy.governanceChainId !== scope.governanceChainId + || policy.governanceContractAddress !== scope.governanceContractAddress + || policy.ownershipTransitionDigest !== scope.ownershipTransitionDigest + || policy.era !== scope.era + || policy.source.kind !== 'owner-signed-unregistered' + || policy.source.ownerAddress !== scope.authorAddress + ) { + throw new Error( + 'RFC-64 open policy is not bound to the exact catalog network, CG, governance scope, era, and author', + ); + } +} + +function assertExactDurableStageReceipt( + receipt: StageVerifiedControlObjectsResultV1, + expected: readonly SignedControlEnvelopeV1[], +): void { + if (receipt?.durable !== true || receipt.objects.length !== expected.length) { + throw new Error('RFC-64 control-object store did not return an exact durable receipt'); + } + for (let index = 0; index < expected.length; index += 1) { + const envelope = expected[index]; + const staged = receipt.objects[index]; + if ( + staged.objectDigest !== envelope.objectDigest + || staged.signatureVariantDigest !== computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ) + ) { + throw new Error('RFC-64 control-object store receipt changed an exact staged object'); + } + } +} diff --git a/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts index 38af21703f..40e9439f54 100644 --- a/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-gate1.integration.test.ts @@ -28,6 +28,8 @@ const NETWORK_ID = 'otp:20430' as NetworkIdV1; const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/gate-1' as ContextGraphIdV1; const FIXED_HEAD_ISSUED_AT = '1773900000000' as TimestampMsV1; +const FIXED_DELEGATION_EFFECTIVE_AT = '1773899999000' as TimestampMsV1; +const FIXED_DELEGATION_EXPIRES_AT = '1774000000000' as TimestampMsV1; const agents: DKGAgent[] = []; const tempDirs: string[] = []; @@ -102,6 +104,8 @@ describe('RFC-64 Gate 1 public catalog wiring — two real DKGAgent instances', author: AUTHOR_WALLET, peers: [receiver.peerId], issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: FIXED_DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: FIXED_DELEGATION_EXPIRES_AT, }); // Author and receiver independently computed the same policy digest. @@ -110,6 +114,10 @@ describe('RFC-64 Gate 1 public catalog wiring — two real DKGAgent instances', // router — proving send() delivers across the dialed connection + admission. expect(published.announcedPeers).toEqual([receiver.peerId]); expect(published.failedPeers).toEqual([]); + expect(await author.readRfc64StagedCatalogIssuerDelegationV1({ + objectDigest: published.catalogIssuerDelegationObjectDigest, + signatureVariantDigest: published.catalogIssuerDelegationSignatureVariantDigest, + })).toBe(published.catalogIssuerDelegationObjectDigest); // The receiver's wired scheduler fetched + staged the head. await receiver.whenRfc64PublicCatalogReceiverIdleV1(); @@ -139,6 +147,8 @@ describe('RFC-64 Gate 1 public catalog wiring — two real DKGAgent instances', author: AUTHOR_WALLET, peers: [receiver.peerId], issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: FIXED_DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: FIXED_DELEGATION_EXPIRES_AT, }); expect(republished.announcement.catalogHeadObjectDigest).toBe(published.headObjectDigest); await receiver.whenRfc64PublicCatalogReceiverIdleV1(); @@ -172,10 +182,16 @@ describe('RFC-64 Gate 1 public catalog wiring — two real DKGAgent instances', author: AUTHOR_WALLET, peers: [receiver.peerId], issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: FIXED_DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: FIXED_DELEGATION_EXPIRES_AT, }); // The announcement is refused by the receiver's policy gate. expect(published.announcedPeers).toEqual([]); expect(published.failedPeers).toHaveLength(1); + expect(await author.readRfc64StagedCatalogIssuerDelegationV1({ + objectDigest: published.catalogIssuerDelegationObjectDigest, + signatureVariantDigest: published.catalogIssuerDelegationSignatureVariantDigest, + })).toBe(published.catalogIssuerDelegationObjectDigest); await receiver.whenRfc64PublicCatalogReceiverIdleV1(); const stagedDigest = await receiver.readRfc64StagedAuthorCatalogHeadV1({ diff --git a/packages/agent/test/rfc64-public-catalog-issuer-delegation-v1.test.ts b/packages/agent/test/rfc64-public-catalog-issuer-delegation-v1.test.ts new file mode 100644 index 0000000000..2065f69eaa --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-issuer-delegation-v1.test.ts @@ -0,0 +1,137 @@ +import { + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + computeAuthorCatalogIssuerDelegationObjectDigestV1, + type AuthorCatalogScopeV1, + type ContextGraphIdV1, + type EvmAddressV1, + type NetworkIdV1, + type UnsignedAuthorCatalogIssuerDelegationEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { readVerifiedControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; +import { describe, expect, it } from 'vitest'; + +import { + produceDirectAuthorCatalogIssuerDelegationV1, +} from '../src/rfc64/public-catalog-issuer-delegation-v1.js'; + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); +const OTHER_WALLET = new ethers.Wallet(`0x${'65'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const NETWORK_ID = 'otp:20430' as NetworkIdV1; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/direct-delegation' as ContextGraphIdV1; +const EFFECTIVE_AT = '1773899999000' as const; +const HEAD_ISSUED_AT = '1773900000000' as const; +const EXPIRES_AT = '1774000000000' as const; + +const SCOPE = Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', +}) as AuthorCatalogScopeV1; + +describe('RFC-64 direct-author catalog issuer delegation producer', () => { + it('builds one canonical signed direct-author authorization bound to exact scope and time', async () => { + const produced = await produceDirectAuthorCatalogIssuerDelegationV1({ + scope: SCOPE, + signer: signer(AUTHOR_WALLET, AUTHOR), + effectiveAt: EFFECTIVE_AT, + expiresAt: EXPIRES_AT, + catalogHeadIssuedAt: HEAD_ISSUED_AT, + }); + const delegation = produced.authorization.catalogIssuerDelegation; + + expect(delegation).toMatchObject({ + issuer: AUTHOR, + objectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + signatureSuite: 'eip191-personal-sign-digest-v1', + signatureEvidence: { kind: 'none' }, + payload: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + previousDelegationDigest: null, + catalogIssuerKey: AUTHOR, + authorAuthorityEvidenceDigest: null, + effectiveAt: EFFECTIVE_AT, + expiresAt: EXPIRES_AT, + }, + }); + const { objectDigest: _objectDigest, signature: _signature, ...unsigned } = delegation; + expect(delegation.objectDigest).toBe( + computeAuthorCatalogIssuerDelegationObjectDigestV1( + unsigned as UnsignedAuthorCatalogIssuerDelegationEnvelopeV1, + ), + ); + expect(produced.authorization.parentAuthorAgentEvidence).toBeNull(); + expect(readVerifiedControlEnvelopeIssuerSignatureV1(produced.issuerSignature)).toMatchObject({ + objectDigest: delegation.objectDigest, + issuer: AUTHOR, + signatureSuite: 'eip191-personal-sign-digest-v1', + verificationEvidence: { kind: 'eip191' }, + }); + expect(Object.isFrozen(delegation)).toBe(true); + expect(Object.isFrozen(delegation.payload)).toBe(true); + }); + + it('rejects a signer that is not the exact scoped author', async () => { + await expect(produceDirectAuthorCatalogIssuerDelegationV1({ + scope: SCOPE, + signer: signer(OTHER_WALLET, OTHER_WALLET.address.toLowerCase() as EvmAddressV1), + effectiveAt: EFFECTIVE_AT, + expiresAt: EXPIRES_AT, + catalogHeadIssuedAt: HEAD_ISSUED_AT, + })).rejects.toThrow(/direct delegation signer must equal the exact catalog author/); + }); + + it.each([ + { effectiveAt: HEAD_ISSUED_AT, expiresAt: HEAD_ISSUED_AT, label: 'empty interval' }, + { effectiveAt: '1773900000001', expiresAt: EXPIRES_AT, label: 'not yet effective' }, + { effectiveAt: EFFECTIVE_AT, expiresAt: HEAD_ISSUED_AT, label: 'expired' }, + ] as const)('rejects $label timing before signing', async ({ effectiveAt, expiresAt }) => { + let signCalls = 0; + await expect(produceDirectAuthorCatalogIssuerDelegationV1({ + scope: SCOPE, + signer: { + issuer: AUTHOR, + signDigest: async (digest) => { + signCalls += 1; + return AUTHOR_WALLET.signMessage(digest); + }, + }, + effectiveAt, + expiresAt, + catalogHeadIssuedAt: HEAD_ISSUED_AT, + })).rejects.toThrow(/half-open interval/); + expect(signCalls).toBe(0); + }); + + it('rejects a signature produced by another EOA despite the claimed author issuer', async () => { + await expect(produceDirectAuthorCatalogIssuerDelegationV1({ + scope: SCOPE, + signer: signer(OTHER_WALLET, AUTHOR), + effectiveAt: EFFECTIVE_AT, + expiresAt: EXPIRES_AT, + catalogHeadIssuedAt: HEAD_ISSUED_AT, + })).rejects.toThrow(/canonical direct-author delegation/); + }); +}); + +function signer(wallet: ethers.Wallet, issuer: EvmAddressV1) { + return { + issuer, + signDigest: (digest: Uint8Array) => wallet.signMessage(digest), + }; +} diff --git a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts index bce49f5ad1..5662fb47f8 100644 --- a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts @@ -1,4 +1,7 @@ import { + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, computeControlSignatureVariantDigestHex, type AuthorCatalogScopeV1, @@ -35,7 +38,11 @@ const CONTEXT_GRAPH_ID = const GOVERNANCE_CONTRACT = '0x2222222222222222222222222222222222222222' as EvmAddressV1; const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); +const OTHER_WALLET = new ethers.Wallet(`0x${'65'.repeat(32)}`); const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const HEAD_ISSUED_AT = '1773900000000' as TimestampMsV1; +const DELEGATION_EFFECTIVE_AT = '1773899999000' as TimestampMsV1; +const DELEGATION_EXPIRES_AT = '1774000000000' as TimestampMsV1; const DELEGATION_DIGEST = `0x${'72'.repeat(32)}` as Digest32V1; type RouterHandler = ( @@ -104,6 +111,22 @@ function controlObjects(): Rfc64ControlObjectOperationsV1 { }; } +function exactStageReceipt( + input: readonly { readonly envelope: { readonly objectDigest: string; readonly signature: string } }[], +) { + return Object.freeze({ + durable: true as const, + namespaceDurability: 'posix-hardlink-no-replace-directory-fsync-v1' as const, + objects: Object.freeze(input.map(({ envelope }) => Object.freeze({ + objectDigest: envelope.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ), + }))), + }); +} + function inertReconciler(): Rfc64PublicCatalogReceiverReconcilerV1 { return { isHeadApplied: async () => false, @@ -131,6 +154,35 @@ function acceptPolicy(service: Rfc64PublicCatalogServiceV1) { }); } +function genesisInput( + service: Rfc64PublicCatalogServiceV1, + overrides: Record = {}, +) { + return { + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + signer: { + issuer: AUTHOR, + signDigest: (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest), + }, + issuedAt: HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, + policy: acceptPolicy(service), + peers: ['peer-a'], + ...overrides, + }; +} + function announcement(policyDigest: Digest32V1): Rfc64PublicCatalogHeadAnnouncementV1 { return { kind: 'rfc64-author-catalog-head-availability-v1', @@ -157,6 +209,138 @@ function countEvent(router: RecordingRouter, event: string): number { } describe('RFC-64 public catalog service v1 lifecycle ownership', () => { + it('durably stages the exact signed delegation before genesis and only then announces', async () => { + const router = new RecordingRouter(); + const store = controlObjects(); + vi.mocked(store.stageVerifiedObjects).mockImplementation(async (input) => { + router.events.push(`stage:${input.map(({ envelope }) => envelope.objectType).join(',')}`); + return exactStageReceipt(input); + }); + router.sendResponse = async () => Uint8Array.of(1); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: store, + }); + service.start(); + + const result = await service.publishOpenAuthorCatalogGenesis(genesisInput(service)); + const stageCalls = vi.mocked(store.stageVerifiedObjects).mock.calls; + expect(stageCalls).toHaveLength(2); + expect(stageCalls[0][0].map(({ envelope }) => envelope.objectType)).toEqual([ + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + ]); + expect(stageCalls[1][0].map(({ envelope }) => envelope.objectType)).toEqual([ + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + ]); + const delegation = result.catalogIssuerAuthorization.catalogIssuerDelegation; + expect(result.catalogIssuerDelegationObjectDigest).toBe(delegation.objectDigest); + expect(result.catalogIssuerDelegationSignatureVariantDigest).toBe( + computeControlSignatureVariantDigestHex(delegation.objectDigest, delegation.signature), + ); + const head = stageCalls[1][0].at(-1)?.envelope; + expect(head?.payload).toMatchObject({ + catalogIssuerDelegationDigest: delegation.objectDigest, + issuedAt: HEAD_ISSUED_AT, + }); + expect(result.catalogIssuerAuthorization.parentAuthorAgentEvidence).toBeNull(); + expect(router.events.filter((event) => event.startsWith('stage:') || event.startsWith('send:'))) + .toEqual([ + `stage:${AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1}`, + `stage:${AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1},${AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1}`, + `send:${RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1}`, + ]); + expect(result.announcedPeers).toEqual(['peer-a']); + await service.close(); + }); + + it('rejects signer and accepted-policy scope mismatches before staging or announcing', async () => { + const router = new RecordingRouter(); + const store = controlObjects(); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: store, + }); + service.start(); + + await expect(service.publishOpenAuthorCatalogGenesis(genesisInput(service, { + signer: { + issuer: OTHER_WALLET.address.toLowerCase(), + signDigest: (digest: Uint8Array) => OTHER_WALLET.signMessage(digest), + }, + }) as never)).rejects.toThrow(/signer must equal the exact catalog author/); + + const wrongPolicy = service.acceptOpenPolicy({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: OTHER_WALLET.address.toLowerCase() as EvmAddressV1, + }); + await expect(service.publishOpenAuthorCatalogGenesis(genesisInput(service, { + policy: wrongPolicy, + }) as never)).rejects.toThrow(/not bound to the exact catalog/); + expect(store.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(router.events.some((event) => event.startsWith('send:'))).toBe(false); + await service.close(); + }); + + it('rejects an expired delegation before signing, staging, or announcing', async () => { + const router = new RecordingRouter(); + const store = controlObjects(); + const signDigest = vi.fn((digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest)); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: store, + }); + service.start(); + + await expect(service.publishOpenAuthorCatalogGenesis(genesisInput(service, { + signer: { issuer: AUTHOR, signDigest }, + catalogIssuerDelegationExpiresAt: HEAD_ISSUED_AT, + }) as never)).rejects.toThrow(/half-open interval/); + expect(signDigest).not.toHaveBeenCalled(); + expect(store.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(router.events.some((event) => event.startsWith('send:'))).toBe(false); + await service.close(); + }); + + it('does not construct or announce a head when delegation staging is not durable', async () => { + const router = new RecordingRouter(); + const store = controlObjects(); + vi.mocked(store.stageVerifiedObjects).mockRejectedValueOnce( + new Error('delegation durability barrier failed'), + ); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: store, + }); + service.start(); + + await expect(service.publishOpenAuthorCatalogGenesis(genesisInput(service))) + .rejects.toThrow('delegation durability barrier failed'); + expect(store.stageVerifiedObjects).toHaveBeenCalledTimes(1); + expect(router.events.some((event) => event.startsWith('send:'))).toBe(false); + await service.close(); + }); + + it('does not announce when genesis staging fails after durable delegation staging', async () => { + const router = new RecordingRouter(); + const store = controlObjects(); + vi.mocked(store.stageVerifiedObjects) + .mockImplementationOnce(async (input) => exactStageReceipt(input)) + .mockRejectedValueOnce(new Error('genesis durability barrier failed')); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: store, + }); + service.start(); + + await expect(service.publishOpenAuthorCatalogGenesis(genesisInput(service))) + .rejects.toThrow('genesis durability barrier failed'); + expect(store.stageVerifiedObjects).toHaveBeenCalledTimes(2); + expect(router.events.some((event) => event.startsWith('send:'))).toBe(false); + await service.close(); + }); + it('constructs one reconciler with frozen fetch-only capabilities and the configured timeout', async () => { const router = new RecordingRouter(); let clients: Readonly | undefined; diff --git a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts index abb0a412a1..9df98a2e6a 100644 --- a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts @@ -1,11 +1,9 @@ import { - AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, assertCanonicalGraphScopedAuthorSealV1, buildAuthorAttestationTypedData, - computeControlObjectDigestHex, decodeOpaqueKaBundleV1, type AuthorCatalogScopeV1, type CanonicalGraphScopedAuthorSealV1, @@ -14,13 +12,12 @@ import { type Digest32V1, type EvmAddressV1, type NetworkIdV1, - type SignedAuthorCatalogIssuerDelegationEnvelopeV1, - type UnsignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; import { describe, expect, it, vi } from 'vitest'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { produceDirectAuthorCatalogIssuerDelegationV1 } from '../src/rfc64/public-catalog-issuer-delegation-v1.js'; import { Rfc64PublicCatalogSuccessorProducerV1, } from '../src/rfc64/public-catalog-successor-producer-v1.js'; @@ -354,37 +351,27 @@ async function directCatalogAuthorization( contextGraphId: ContextGraphIdV1, ) { const authorAddress = wallet.address.toLowerCase() as EvmAddressV1; - const unsigned: UnsignedControlEnvelopeV1 = { - issuer: authorAddress, - objectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, - payload: { - authorAddress, - authorAuthorityEvidenceDigest: null, - catalogEra: '0', - catalogIssuerKey: authorAddress, + const produced = await produceDirectAuthorCatalogIssuerDelegationV1({ + scope: { + networkId: NETWORK_ID, contextGraphId, - effectiveAt: '1773899999000', - expiresAt: '1774000000000', governanceChainId: '20430', governanceContractAddress: GOVERNANCE, - networkId: NETWORK_ID, ownershipTransitionDigest: null, - previousDelegationDigest: null, subGraphName: null, + authorAddress, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + signer: { + issuer: authorAddress, + signDigest: (digest) => wallet.signMessage(digest), }, - signatureEvidence: { kind: 'none' }, - signatureSuite: 'eip191-personal-sign-digest-v1', - }; - const objectDigest = computeControlObjectDigestHex(unsigned); - const catalogIssuerDelegation = { - ...unsigned, - objectDigest, - signature: await wallet.signMessage(ethers.getBytes(objectDigest)), - } as SignedAuthorCatalogIssuerDelegationEnvelopeV1; - return Object.freeze({ - catalogIssuerDelegation, - parentAuthorAgentEvidence: null, + effectiveAt: '1773899999000' as never, + expiresAt: '1774000000000' as never, + catalogHeadIssuedAt: '1773900000000' as never, }); + return produced.authorization; } function durableBundleReceipt( diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 2b9dcdd383..017299c699 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -123,6 +123,7 @@ export default defineConfig({ "test/rfc64-public-catalog-transport-v1.test.ts", "test/rfc64-public-catalog-receiver-v1.test.ts", "test/rfc64-public-catalog-service-v1.test.ts", + "test/rfc64-public-catalog-issuer-delegation-v1.test.ts", "test/rfc64-public-catalog-gate1.integration.test.ts", "test/rfc64-public-catalog-native-transport-v1.test.ts", "test/rfc64-public-catalog-native-reconciler-v1.test.ts", From 5bf493b3391b35a6f774d2871f6d7bd564c32e1f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:21:28 +0200 Subject: [PATCH 126/292] fix(agent): lazily create RFC-64 KA bundle namespace --- .../src/rfc64/ka-bundle-store-v1-internal.ts | 99 ++++++++++++++++++- ...c64-persistent-catalog-provider-v1.test.ts | 56 ++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/rfc64/ka-bundle-store-v1-internal.ts b/packages/agent/src/rfc64/ka-bundle-store-v1-internal.ts index c72054b8bd..83af493370 100644 --- a/packages/agent/src/rfc64/ka-bundle-store-v1-internal.ts +++ b/packages/agent/src/rfc64/ka-bundle-store-v1-internal.ts @@ -1,3 +1,4 @@ +import { lstat } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { @@ -93,14 +94,18 @@ type Rfc64KaBundleStoreFileKindV1 = 'ka-bundle'; class FileRfc64KaBundleStoreV1 implements Rfc64KaBundleStoreV1 { #closed = false; #closePromise: Promise | null = null; + #namespacePreparation: Promise | null = null; readonly #inFlightOperations = new Set>(); + readonly #rfc64RootPath: string; readonly #durableFiles: Rfc64DurableFileStoreV1; readonly namespaceDurability = rfc64NamespaceDurabilityV1(); constructor( readonly rootPath: string, + rfc64RootPath: string, durableFiles: Rfc64DurableFileStoreV1, ) { + this.#rfc64RootPath = rfc64RootPath; this.#durableFiles = durableFiles; } @@ -112,6 +117,7 @@ class FileRfc64KaBundleStoreV1 implements Rfc64KaBundleStoreV1 { this.requireOpen(); const prepared = prepareBundle(input); const operation = (async () => { + await this.prepareNamespaceForWrite(); await mapDurableFileErrors(async () => { await this.#durableFiles.putExactBytes({ relativePath: bundleRelativePath(prepared.blobDigest), @@ -135,6 +141,7 @@ class FileRfc64KaBundleStoreV1 implements Rfc64KaBundleStoreV1 { this.requireOpen(); const blobDigest = snapshotDigest(blobDigestInput, 'blobDigest'); const operation = (async () => { + if (!await this.hasReadableNamespaceForDigest(blobDigest)) return null; const bundleBytes = await mapDurableFileErrors(async () => this.#durableFiles.readOptionalBoundedBytes({ relativePath: bundleRelativePath(blobDigest), @@ -163,6 +170,62 @@ class FileRfc64KaBundleStoreV1 implements Rfc64KaBundleStoreV1 { if (this.#closed) fail('ka-bundle-store-closed', 'KA-bundle store is closed'); } + private async prepareNamespaceForWrite(): Promise { + if (this.#namespacePreparation !== null) { + await this.#namespacePreparation; + return; + } + const preparation = mapDurableFileErrors(async () => { + await assertRfc64ExistingDirectoryV1( + this.#rfc64RootPath, + 'RFC-64 persistence root', + { access: 'owner-only' }, + ); + await ensureRfc64SecureDirectoryTreeV1( + this.rootPath, + this.#rfc64RootPath, + 'owner-only', + ); + await ensureRfc64SecureDirectoryTreeV1( + join(this.rootPath, BUNDLES_DIRECTORY), + this.rootPath, + 'owner-only', + ); + }); + this.#namespacePreparation = preparation; + try { + await preparation; + } catch (cause) { + if (this.#namespacePreparation === preparation) { + this.#namespacePreparation = null; + } + throw cause; + } + } + + private async hasReadableNamespaceForDigest(blobDigest: Digest32V1): Promise { + return mapDurableFileErrors(async () => { + await assertRfc64ExistingDirectoryV1( + this.#rfc64RootPath, + 'RFC-64 persistence root', + { access: 'owner-only' }, + ); + if (!await assertOptionalRfc64SecureDirectoryV1( + this.rootPath, + 'RFC-64 KA-bundle namespace', + )) return false; + const bundlesPath = join(this.rootPath, BUNDLES_DIRECTORY); + if (!await assertOptionalRfc64SecureDirectoryV1( + bundlesPath, + 'RFC-64 KA-bundle directory', + )) return false; + return assertOptionalRfc64SecureDirectoryV1( + join(bundlesPath, blobDigest.slice(2, 4)), + 'RFC-64 KA-bundle digest shard', + ); + }); + } + private trackOperation(operation: Promise): Promise { this.#inFlightOperations.add(operation); void operation.finally(() => { @@ -262,15 +325,47 @@ export async function openRfc64KaBundleStoreForOwnedPersistenceRootV1( 'RFC-64 persistence root', { access: 'owner-only' }, ); - await ensureRfc64SecureDirectoryTreeV1(rootPath, rfc64RootPath); - await ensureRfc64SecureDirectoryTreeV1(join(rootPath, BUNDLES_DIRECTORY), rootPath); + if (await assertOptionalRfc64SecureDirectoryV1( + rootPath, + 'RFC-64 KA-bundle namespace', + )) { + await assertOptionalRfc64SecureDirectoryV1( + join(rootPath, BUNDLES_DIRECTORY), + 'RFC-64 KA-bundle directory', + ); + } }); return new FileRfc64KaBundleStoreV1( resolve(rootPath), + resolve(rfc64RootPath), createRfc64DurableFileStoreV1(rootPath), ); } +async function assertOptionalRfc64SecureDirectoryV1( + path: string, + label: string, +): Promise { + try { + await lstat(path); + } catch (cause) { + if (isNodeError(cause, 'ENOENT')) return false; + throw new Rfc64DurableFileErrorV1( + 'io', + `failed to inspect ${label}`, + { cause }, + ); + } + await assertRfc64ExistingDirectoryV1(path, label, { access: 'owner-only' }); + return true; +} + +function isNodeError(cause: unknown, code: string): boolean { + return cause instanceof Error + && 'code' in cause + && (cause as NodeJS.ErrnoException).code === code; +} + function fail( code: Rfc64KaBundleStoreErrorCodeV1, message: string, diff --git a/packages/agent/test/rfc64-persistent-catalog-provider-v1.test.ts b/packages/agent/test/rfc64-persistent-catalog-provider-v1.test.ts index 607598300d..12a35eeec5 100644 --- a/packages/agent/test/rfc64-persistent-catalog-provider-v1.test.ts +++ b/packages/agent/test/rfc64-persistent-catalog-provider-v1.test.ts @@ -1,4 +1,4 @@ -import { stat, writeFile } from 'node:fs/promises'; +import { lstat, symlink, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { @@ -58,6 +58,60 @@ function bundlePath(dataDir: string, blobDigest: Digest32V1): string { } describe('RFC-64 persistent native catalog providers v1', () => { + it('does not create the KA-bundle namespace until the first valid write', async () => { + const dataDir = await temporaryDataDirectory(); + const namespacePath = join(dataDir, RFC64_KA_BUNDLE_STORE_RELATIVE_PATH); + const persistence = await openPersistence(dataDir); + + await expect(lstat(namespacePath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(persistence.kaBundles.readKaBundleByDigest(MISSING_DIGEST)) + .resolves.toBeNull(); + await expect(lstat(namespacePath)).rejects.toMatchObject({ code: 'ENOENT' }); + + await persistence.close(); + await expect(lstat(namespacePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('safely joins concurrent first writes and reopens their exact immutable bytes', async () => { + const dataDir = await temporaryDataDirectory(); + const bundle = encodeOpaqueKaBundleV1( + UTF8.encode('concurrent-first-projection'), + UTF8.encode('concurrent-first-seal'), + ); + const first = await openPersistence(dataDir); + + const puts = await Promise.all(Array.from({ length: 8 }, () => + first.kaBundles.putKaBundle({ + blobDigest: bundle.blobDigest, + bundleBytes: bundle.bundleBytes, + }))); + expect(puts).toHaveLength(8); + expect(puts.every((put) => put.durable && put.blobDigest === bundle.blobDigest)) + .toBe(true); + await expect(first.kaBundles.readKaBundleByDigest(bundle.blobDigest)) + .resolves.toEqual(bundle.bundleBytes); + + await first.close(); + const reopened = await openPersistence(dataDir); + await expect(reopened.kaBundles.readKaBundleByDigest(bundle.blobDigest)) + .resolves.toEqual(bundle.bundleBytes); + }); + + it('rejects a preexisting symlink in the lazy KA-bundle namespace', async () => { + const dataDir = await temporaryDataDirectory(); + const outside = await temporaryDataDirectory(); + const first = await openPersistence(dataDir); + await first.close(); + await symlink( + outside, + join(dataDir, RFC64_KA_BUNDLE_STORE_RELATIVE_PATH), + process.platform === 'win32' ? 'junction' : 'dir', + ); + + await expect(openPersistence(dataDir)) + .rejects.toMatchObject({ code: 'ka-bundle-store-unsafe-path' }); + }); + it('reopens exact verified control objects and content-addressed opaque bundles', async () => { const dataDir = await temporaryDataDirectory(); const fixture = await signedFixture('persistent-provider-reopen'); From b7256e0b8aba9d0399d4baabe9abbb8e712a2fe6 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:25:20 +0200 Subject: [PATCH 127/292] feat(agent): wire RFC-64 native catalog service --- packages/agent/src/dkg-agent-base.ts | 4 + packages/agent/src/dkg-agent-rfc64-catalog.ts | 230 ++++++++++++++- packages/agent/src/dkg-agent-types.ts | 9 + packages/agent/src/dkg-agent.ts | 15 +- .../src/rfc64/public-catalog-service-v1.ts | 117 ++++++-- ...kg-agent-native-wiring.integration.test.ts | 268 ++++++++++++++++++ .../rfc64-public-catalog-service-v1.test.ts | 56 ++++ packages/agent/vitest.unit.config.ts | 1 + 8 files changed, 679 insertions(+), 21 deletions(-) create mode 100644 packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index 9f534d366f..8769deea2c 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -16,6 +16,7 @@ import { type Rfc64PersistenceV1, } from './rfc64/persistence-v1.js'; import type { Rfc64PublicCatalogServiceV1 } from './rfc64/public-catalog-service-v1.js'; +import type { Rfc64PublicCatalogNativeSynchronizationEvidenceV1 } from './rfc64/public-catalog-native-receiver-v1.js'; import { resolveVmReconcileStartupMaxDelayMs } from './startup-jitter.js'; import { DKGNode, ProtocolRouter, GossipSubManager, TypedEventBus, DKGEvent, @@ -999,6 +1000,9 @@ export class DKGAgentBase { * while dormant (no dataDir) or after `stop()`. */ protected rfc64PublicCatalogServiceV1?: Rfc64PublicCatalogServiceV1; + /** Exact process-local post-verification evidence, keyed by applied head. */ + protected readonly rfc64PublicCatalogSynchronizationEvidenceV1 = + new Map(); protected readonly subscribedContextGraphs = new Map(); protected contextGraphSubscriptionRehydrationStatus: ContextGraphSubscriptionRehydrationStatus | null = null; protected readonly contextGraphSubscriptionRehydrationAccountedIds = new Set(); diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 93381003c4..a47653986a 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -31,18 +31,36 @@ import { type DecimalU64V1, type AuthorCatalogScopeV1, type CountV1, + assertCanonicalChainId, + assertNetworkIdV1, + type CatalogSealDeploymentProfileV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; import { DKGAgentBase } from './dkg-agent-base.js'; import type { DKGAgent } from './dkg-agent.js'; import type { Rfc64AuthorCatalogEip191SignerV1 } from './rfc64/author-catalog-producer.js'; import type { AcceptedOpenCatalogPolicyV1 } from './rfc64/open-catalog-policy-v1.js'; +import type { Rfc64PublicCatalogReceiverReconcilerV1 } from './rfc64/public-catalog-receiver-v1.js'; import { Rfc64PublicCatalogServiceV1, type PublishOpenAuthorCatalogGenesisResultV1, + type AnnounceRfc64PublicCatalogHeadInputV1, + type AnnounceRfc64PublicCatalogHeadResultV1, + type Rfc64PublicCatalogReconcilerClientsV1, + type Rfc64PublicCatalogServiceNativeOptionsV1, type Rfc64PublicCatalogServiceStatsV1, } from './rfc64/public-catalog-service-v1.js'; +import { + Rfc64PublicCatalogNativeReceiverV1, + type Rfc64PublicCatalogNativeSynchronizationEvidenceV1, +} from './rfc64/public-catalog-native-receiver-v1.js'; +import { + createRfc64PublicOpenCatalogNativeReconcilerV1, + type Rfc64PublicOpenCatalogDeploymentResolverV1, +} from './rfc64/public-catalog-native-reconciler-v1.js'; +import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; /** Minimal EIP-191 EOA signer (ethers.Wallet-compatible) for author-catalog objects. */ export interface Rfc64OpenCatalogAuthorSignerV1 { @@ -88,6 +106,59 @@ export interface Rfc64StagedCatalogIssuerDelegationRefV1 { readonly signatureVariantDigest: Digest32V1; } +/** Exact durable applied-head key exposed for devnet evidence collection. */ +export interface Rfc64AppliedCatalogHeadRefV1 { + readonly catalogScopeDigest: Digest32V1; + readonly authorAddress: EvmAddressV1; +} + +/** + * Validate and detach a locally configured deployment tuple from caller-owned + * state. Exported so `DKGAgent.create()` can snapshot the override before any + * asynchronous startup work begins. + */ +export function snapshotRfc64CatalogDeploymentProfileV1( + input: CatalogSealDeploymentProfileV1 | undefined, +): Readonly | undefined { + if (input === undefined) return undefined; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('rfc64CatalogDeploymentProfile must be a plain object'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('rfc64CatalogDeploymentProfile must be a plain object'); + } + const keys = Object.keys(input).sort(); + const expectedKeys = [ + 'assertedAtChainId', + 'assertedAtKav10Address', + 'networkId', + ]; + if ( + keys.length !== expectedKeys.length + || keys.some((key, index) => key !== expectedKeys[index]) + ) { + throw new TypeError( + 'rfc64CatalogDeploymentProfile must contain exactly networkId, ' + + 'assertedAtChainId, and assertedAtKav10Address', + ); + } + assertNetworkIdV1(input.networkId); + assertCanonicalChainId(input.assertedAtChainId, 'assertedAtChainId'); + if (!ethers.isAddress(input.assertedAtKav10Address)) { + throw new TypeError('assertedAtKav10Address must be a non-zero EVM address'); + } + const assertedAtKav10Address = input.assertedAtKav10Address.toLowerCase() as EvmAddressV1; + if (assertedAtKav10Address === `0x${'00'.repeat(20)}`) { + throw new TypeError('assertedAtKav10Address must be a non-zero EVM address'); + } + return Object.freeze({ + networkId: input.networkId, + assertedAtChainId: input.assertedAtChainId, + assertedAtKav10Address, + }); +} + export class Rfc64CatalogMethods extends DKGAgentBase { /** * Construct + start the public catalog service on the production router. @@ -100,6 +171,7 @@ export class Rfc64CatalogMethods extends DKGAgentBase { const service = new Rfc64PublicCatalogServiceV1({ router: this.router, controlObjects: persistence.controlObjects, + native: this.createRfc64PublicCatalogNativeOptionsV1(), }); service.start(); this.rfc64PublicCatalogServiceV1 = service; @@ -110,7 +182,11 @@ export class Rfc64CatalogMethods extends DKGAgentBase { async closeRfc64PublicCatalogServiceV1(this: DKGAgent): Promise { const service = this.rfc64PublicCatalogServiceV1; this.rfc64PublicCatalogServiceV1 = undefined; - await service?.close(); + try { + await service?.close(); + } finally { + this.rfc64PublicCatalogSynchronizationEvidenceV1.clear(); + } } /** @@ -171,6 +247,14 @@ export class Rfc64CatalogMethods extends DKGAgentBase { }); } + /** Explicit best-effort availability fan-out for an already durable head. */ + announceRfc64PublicCatalogHeadV1( + this: DKGAgent, + input: AnnounceRfc64PublicCatalogHeadInputV1, + ): Promise { + return this.requireRfc64PublicCatalogServiceV1().announceCatalogHead(input); + } + /** * Read a head back from the control-object store by its exact digests — the * "durably staged the exact head" proof. Returns null when not staged. @@ -206,6 +290,33 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return stored.envelope.objectDigest as Digest32V1; } + /** Read the exact durable applied-head record; returns null while dormant/missing. */ + readRfc64AppliedCatalogHeadV1( + this: DKGAgent, + ref: Rfc64AppliedCatalogHeadRefV1, + ): AppliedCatalogHeadSnapshotV1 | null { + return this.rfc64PersistenceV1?.inventory.readAppliedCatalogHeadV1( + ref.catalogScopeDigest, + ref.authorAddress, + ) ?? null; + } + + /** + * Read the receiver's exact post-verification synchronization evidence for + * one head in this process. Durable restart truth remains the applied-head + * API above; this process-local record proves the semantic post-read that + * immediately preceded that durable commit. + */ + readRfc64PublicCatalogSynchronizationEvidenceV1( + this: DKGAgent, + catalogHeadDigest: Digest32V1, + ): Rfc64PublicCatalogNativeSynchronizationEvidenceV1 | null { + const evidence = this.rfc64PublicCatalogSynchronizationEvidenceV1.get( + catalogHeadDigest, + ); + return evidence === undefined ? null : Object.freeze({ ...evidence }); + } + /** Await the receiver scheduler draining all queued + in-flight fetch/stage work. */ whenRfc64PublicCatalogReceiverIdleV1(this: DKGAgent): Promise { const service = this.rfc64PublicCatalogServiceV1; @@ -225,4 +336,121 @@ export class Rfc64CatalogMethods extends DKGAgentBase { } return service; } + + /** Build native mode only when a local deployment source exists. */ + private createRfc64PublicCatalogNativeOptionsV1( + this: DKGAgent, + ): Rfc64PublicCatalogServiceNativeOptionsV1 | undefined { + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) return undefined; + if ( + this.config.rfc64CatalogDeploymentProfile === undefined + && this.chain.chainId === 'none' + ) { + return undefined; + } + this.rfc64PublicCatalogSynchronizationEvidenceV1.clear(); + const resolveDeployment: Rfc64PublicOpenCatalogDeploymentResolverV1 = + (announcement, signal) => this.resolveRfc64CatalogDeploymentProfileV1( + announcement.networkId, + signal, + ); + return Object.freeze({ + readCatalogObjectByDigest: async (objectDigest: Digest32V1) => { + const stored = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + return stored?.envelope ?? null; + }, + readKaBundleByDigest: persistence.kaBundles.readKaBundleByDigest, + createReconciler: (clients: Readonly) => { + const nativeReceiver = new Rfc64PublicCatalogNativeReceiverV1({ + headTransport: clients.headTransport, + contentTransport: clients.contentTransport, + controlObjects: persistence.controlObjects, + inventory: persistence.inventory, + store: this.store, + transportTimeoutMs: clients.transportTimeoutMs, + }); + const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + nativeReceiver: Object.freeze({ + synchronizePublicOpenCatalog: async (...args) => { + const evidence = await nativeReceiver.synchronizePublicOpenCatalog(...args); + this.rfc64PublicCatalogSynchronizationEvidenceV1.set( + evidence.catalogHeadDigest, + evidence, + ); + return evidence; + }, + }), + inventory: persistence.inventory, + resolveDeployment, + }); + const deploymentAwareReconciler: Rfc64PublicCatalogReceiverReconcilerV1 = { + isHeadApplied: (announcement) => { + this.assertRfc64CatalogNetworkMatchesTrustedSourceV1(announcement.networkId); + return reconciler.isHeadApplied(announcement); + }, + reconcileHead: (remotePeerId, announcement, signal) => + reconciler.reconcileHead(remotePeerId, announcement, signal), + }; + return Object.freeze(deploymentAwareReconciler); + }, + }); + } + + /** Reject a stale applied-head dedupe before trusting any durable row. */ + private assertRfc64CatalogNetworkMatchesTrustedSourceV1( + this: DKGAgent, + catalogNetworkId: NetworkIdV1, + ): void { + const trustedNetworkId = this.config.rfc64CatalogDeploymentProfile?.networkId + ?? this.chain.chainId; + if (trustedNetworkId === 'none') { + throw new Error( + 'RFC-64 native reconciliation requires a trusted chain or ' + + 'rfc64CatalogDeploymentProfile override', + ); + } + if (trustedNetworkId !== catalogNetworkId) { + throw new Error( + `RFC-64 catalog network ${catalogNetworkId} differs from locally trusted ` + + `deployment network ${trustedNetworkId}`, + ); + } + } + + /** Resolve only from the create-time snapshot or this node's chain adapter. */ + private async resolveRfc64CatalogDeploymentProfileV1( + this: DKGAgent, + catalogNetworkId: NetworkIdV1, + signal: AbortSignal, + ): Promise { + if (signal.aborted) throw signal.reason; + this.assertRfc64CatalogNetworkMatchesTrustedSourceV1(catalogNetworkId); + let deployment = this.config.rfc64CatalogDeploymentProfile; + if (deployment === undefined) { + if (this.chain.chainId === 'none') { + throw new Error( + 'RFC-64 native reconciliation requires a trusted chain or ' + + 'rfc64CatalogDeploymentProfile override', + ); + } + const [chainId, kav10Address] = await Promise.all([ + this.chain.getEvmChainId(), + this.chain.getKnowledgeAssetsLifecycleAddress(), + ]); + if (signal.aborted) throw signal.reason; + deployment = snapshotRfc64CatalogDeploymentProfileV1({ + networkId: this.chain.chainId as NetworkIdV1, + assertedAtChainId: chainId.toString() as never, + assertedAtKav10Address: kav10Address as EvmAddressV1, + }); + if (deployment === undefined) { + throw new Error('RFC-64 chain deployment profile resolution failed'); + } + } + return deployment; + } } diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index 2a234b497c..dd419e1ef7 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -30,6 +30,7 @@ import type { SwmSenderKeyPackageAckReasonCode, ContextGraphJoinPolicyMode as CoreContextGraphJoinPolicyMode, ContextGraphJoinPolicyRecord as CoreContextGraphJoinPolicyRecord, + CatalogSealDeploymentProfileV1, } from '@origintrail-official/dkg-core'; import type { PhaseCallback, @@ -1046,6 +1047,14 @@ export interface DKGAgentConfig { genesisId?: string; /** Active network identity used to isolate libp2p and app workflow boundaries. */ networkIdentity?: DkgNetworkIdentity; + /** + * Locally trusted RFC-64 catalog-seal deployment tuple. This deterministic + * override is intended for chain-free devnets; production nodes normally + * derive the same tuple from their configured chain adapter. It is snapshotted + * and validated during `DKGAgent.create()` and is never accepted from catalog + * announcement wire data. + */ + rfc64CatalogDeploymentProfile?: CatalogSealDeploymentProfileV1; /** * public-projection enable flag. When set, a private CG's confirmed VM * publishes emit/refresh a verifiable public projection (the floor: existence, diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 5b213625cc..55e3170640 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -400,7 +400,10 @@ import { OwnershipMethods } from './dkg-agent-ownership.js'; import { ContextGraphResolveMethods } from './dkg-agent-cg-resolve.js'; import { CclPolicyMethods } from './dkg-agent-ccl.js'; import { EndorseVerifyMethods } from './dkg-agent-endorse.js'; -import { Rfc64CatalogMethods } from './dkg-agent-rfc64-catalog.js'; +import { + Rfc64CatalogMethods, + snapshotRfc64CatalogDeploymentProfileV1, +} from './dkg-agent-rfc64-catalog.js'; import { ContextGraphRegistryMethods } from './dkg-agent-cg-registry.js'; import { JoinRequestMethods } from './dkg-agent-join.js'; import { SwmSubstrateMethods } from './dkg-agent-swm-substrate.js'; @@ -692,6 +695,9 @@ export class DKGAgent extends DKGAgentBase { inputConfig.syncContextGraphPriorities, ), }); + const rfc64CatalogDeploymentProfile = snapshotRfc64CatalogDeploymentProfileV1( + config.rfc64CatalogDeploymentProfile, + ); let wallet: DKGAgentWallet; if (config.dataDir) { try { @@ -792,7 +798,12 @@ export class DKGAgent extends DKGAgentBase { networkId: computedNetworkId, chainId: adapterChainId ?? config.networkIdentity?.chainId, }; - const resolvedConfig: ResolvedDKGAgentConfig = { ...config, genesisId, networkIdentity }; + const resolvedConfig: ResolvedDKGAgentConfig = { + ...config, + genesisId, + networkIdentity, + rfc64CatalogDeploymentProfile, + }; const port = config.listenPort ?? 0; const host = config.listenHost ?? '0.0.0.0'; diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 41f79f11d0..9061d052e9 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -73,11 +73,17 @@ import type { import { RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, Rfc64PublicCatalogTransportV1, + encodeRfc64PublicCatalogHeadAnnouncementV1, + parseRfc64PublicCatalogHeadAnnouncementV1, type Rfc64PublicCatalogHeadAnnouncementV1, } from './public-catalog-transport-v1.js'; /** Default per-peer announce/fetch deadline (ms). */ const DEFAULT_TRANSPORT_TIMEOUT_MS = 10_000; +/** Hard fan-out bound for one explicit best-effort announcement call. */ +export const RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1 = 64; +const RFC64_PUBLIC_CATALOG_PEER_ID_MAX_BYTES_V1 = 256; +const UTF8 = new TextEncoder(); export interface Rfc64PublicCatalogServiceOptionsV1 { readonly router: ProtocolRouter; @@ -152,6 +158,21 @@ export interface PublishOpenAuthorCatalogGenesisResultV1 { readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; } +export interface AnnounceRfc64PublicCatalogHeadInputV1 { + readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; + /** Unique peer IDs; at most RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1. */ + readonly peers: readonly string[]; +} + +export interface AnnounceRfc64PublicCatalogHeadResultV1 { + /** Validated immutable snapshot used for every delivery attempt. */ + readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; + /** Input-order peers that returned the exact transport ACK. */ + readonly announcedPeers: readonly string[]; + /** Input-order peers whose bounded attempt threw or returned a non-ACK. */ + readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; +} + export interface Rfc64PublicCatalogServiceStatsV1 { readonly started: boolean; readonly acceptedPolicies: number; @@ -279,7 +300,7 @@ export class Rfc64PublicCatalogServiceV1 { const issuedAt = input.issuedAt; const effectiveAt = input.catalogIssuerDelegationEffectiveAt; const expiresAt = input.catalogIssuerDelegationExpiresAt; - const peers = Object.freeze(Array.from(input.peers)); + const peers = snapshotAnnouncementPeers(input.peers); const heldPolicy = this.#policies.lookup(scope.networkId, scope.contextGraphId); assertOpenPolicyMatchesCatalogScope(input.policy, heldPolicy, scope); const policyDigest = heldPolicy!.policyDigest; @@ -305,7 +326,6 @@ export class Rfc64PublicCatalogServiceV1 { ]); assertExactDurableStageReceipt(stagedDelegation, [delegationEnvelope]); const delegationKeys = stagedDelegation.objects[0]!; - const produced = await produceEmptyAuthorCatalogGenesisV1({ scope, catalogIssuerDelegationDigest: delegationEnvelope.objectDigest as Digest32V1, @@ -339,32 +359,37 @@ export class Rfc64PublicCatalogServiceV1 { signatureVariantDigest: headKeys.signatureVariantDigest, }); - const announcedPeers: string[] = []; - const failedPeers: Array<{ peerId: string; error: string }> = []; - for (const peerId of peers) { - try { - await this.#transport.announceCatalogHead(peerId, announcement, this.#sendOptions()); - announcedPeers.push(peerId); - } catch (error) { - failedPeers.push({ - peerId, - error: error instanceof Error ? error.message : String(error), - }); - } - } + const delivery = await this.#announceCatalogHeadSnapshot(announcement, peers); return Object.freeze({ - announcement, + announcement: delivery.announcement, headObjectDigest: headKeys.objectDigest, signatureVariantDigest: headKeys.signatureVariantDigest, catalogIssuerAuthorization: delegation.authorization, catalogIssuerDelegationObjectDigest: delegationKeys.objectDigest, catalogIssuerDelegationSignatureVariantDigest: delegationKeys.signatureVariantDigest, - announcedPeers: Object.freeze(announcedPeers), - failedPeers: Object.freeze(failedPeers), + announcedPeers: delivery.announcedPeers, + failedPeers: delivery.failedPeers, }); } + /** + * Best-effort availability fan-out for an already durable head (including a + * successor produced by a separate authoring path). The announcement and + * peer list are fully snapshotted before the first send; one peer failure + * never suppresses later attempts. + */ + async announceCatalogHead( + input: AnnounceRfc64PublicCatalogHeadInputV1, + ): Promise { + this.#requireStarted(); + const announcement = parseRfc64PublicCatalogHeadAnnouncementV1( + encodeRfc64PublicCatalogHeadAnnouncementV1(input.announcement), + ); + const peers = snapshotAnnouncementPeers(input.peers); + return this.#announceCatalogHeadSnapshot(announcement, peers); + } + /** Idle-await the receiver (tests / graceful shutdown coordination). */ whenReceiverIdle(): Promise { return this.#receiver.whenIdle(); @@ -382,6 +407,30 @@ export class Rfc64PublicCatalogServiceV1 { return { timeoutMs: this.#transportTimeoutMs }; } + async #announceCatalogHeadSnapshot( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + peers: readonly string[], + ): Promise { + const announcedPeers: string[] = []; + const failedPeers: Array<{ peerId: string; error: string }> = []; + for (const peerId of peers) { + try { + await this.#transport.announceCatalogHead(peerId, announcement, this.#sendOptions()); + announcedPeers.push(peerId); + } catch (error) { + failedPeers.push({ + peerId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return Object.freeze({ + announcement, + announcedPeers: Object.freeze(announcedPeers), + failedPeers: Object.freeze(failedPeers.map((failure) => Object.freeze(failure))), + }); + } + async #authorizeNativeOperation( input: Rfc64PublicCatalogNativeAuthorizationInputV1, ): Promise { @@ -480,3 +529,35 @@ function assertExactDurableStageReceipt( } } } + +function snapshotAnnouncementPeers(input: readonly string[]): readonly string[] { + if (!Array.isArray(input)) { + throw new TypeError('RFC-64 catalog announcement peers must be an array'); + } + if (input.length > RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1) { + throw new RangeError( + `RFC-64 catalog announcement accepts at most ` + + `${RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1} peers`, + ); + } + const seen = new Set(); + const peers: string[] = []; + for (let index = 0; index < input.length; index += 1) { + const peerId = input[index]; + const byteLength = typeof peerId === 'string' ? UTF8.encode(peerId).byteLength : 0; + if ( + typeof peerId !== 'string' + || byteLength === 0 + || byteLength > RFC64_PUBLIC_CATALOG_PEER_ID_MAX_BYTES_V1 + || peerId.trim() !== peerId + ) { + throw new TypeError(`RFC-64 catalog announcement peer ${index} is invalid`); + } + if (seen.has(peerId)) { + throw new TypeError(`RFC-64 catalog announcement peer ${index} is duplicated`); + } + seen.add(peerId); + peers.push(peerId); + } + return Object.freeze(peers); +} diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts new file mode 100644 index 0000000000..ebf8a527c3 --- /dev/null +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -0,0 +1,268 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { multiaddr } from '@multiformats/multiaddr'; +import { + computeAuthorCatalogScopeDigestV1, + type CatalogSealDeploymentProfileV1, + type ContextGraphIdV1, + type NetworkIdV1, + type TimestampMsV1, +} from '@origintrail-official/dkg-core'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { DKGAgent } from '../src/dkg-agent.js'; +import { snapshotRfc64CatalogDeploymentProfileV1 } from '../src/dkg-agent-rfc64-catalog.js'; + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); +const NETWORK_ID = 'otp:20430' as NetworkIdV1; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/native-wiring' as ContextGraphIdV1; +const FIXED_HEAD_ISSUED_AT = '1773900000000' as TimestampMsV1; +const NATIVE_DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: '0x4444444444444444444444444444444444444444', +}) as CatalogSealDeploymentProfileV1; + +const agents: DKGAgent[] = []; +const tempDirs: string[] = []; + +afterEach(async () => { + for (const agent of agents.splice(0)) { + try { await agent.stop(); } catch { /* best-effort */ } + } + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +async function startNativeAgent( + name: string, + deployment: CatalogSealDeploymentProfileV1 = NATIVE_DEPLOYMENT, + existingDataDir?: string, +): Promise { + const dataDir = existingDataDir + ?? await mkdtemp(join(tmpdir(), `dkg-rfc64-native-${name}-`)); + if (existingDataDir === undefined) tempDirs.push(dataDir); + const agent = await DKGAgent.create({ + name, + dataDir, + listenHost: '127.0.0.1', + listenPort: 0, + bootstrapPeers: [], + nodeRole: 'edge', + store: new OxigraphStore(), + syncSharedMemoryOnConnect: false, + syncReconcilerEnabled: false, + syncOnConnectEnabled: false, + durableSyncEnabled: false, + agentProfileHeartbeatMs: 0, + rfc64CatalogDeploymentProfile: deployment, + }); + agents.push(agent); + await agent.start(); + return agent; +} + +function tcpMultiaddr(agent: DKGAgent): string { + const address = agent.multiaddrs.find((candidate) => candidate.includes('/tcp/')); + if (address === undefined) throw new Error('agent has no TCP multiaddr'); + return address; +} + +async function connectBothWays(a: DKGAgent, b: DKGAgent): Promise { + await a.node.libp2p.dial(multiaddr(tcpMultiaddr(b))); + await b.node.libp2p.dial(multiaddr(tcpMultiaddr(a))); +} + +function catalogScopeDigest() { + return computeAuthorCatalogScopeDigestV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR_WALLET.address.toLowerCase() as never, + era: '0' as never, + bucketCount: '1' as never, + }); +} + +describe('RFC-64 DKGAgent production native catalog wiring', () => { + it('snapshots and canonicalizes the deterministic local deployment override', () => { + const callerOwned = { + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: ethers.getAddress( + '0x4444444444444444444444444444444444444444', + ), + } as CatalogSealDeploymentProfileV1; + const snapshot = snapshotRfc64CatalogDeploymentProfileV1(callerOwned)!; + (callerOwned as { networkId: NetworkIdV1 }).networkId = 'hostile:1' as NetworkIdV1; + expect(snapshot).toEqual(NATIVE_DEPLOYMENT); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(() => snapshotRfc64CatalogDeploymentProfileV1({ + ...NATIVE_DEPLOYMENT, + assertedAtKav10Address: `0x${'00'.repeat(20)}` as never, + })).toThrow(/non-zero EVM address/); + }); + + it('uses the trusted override to fetch provider content and durably apply native genesis', async () => { + const [author, receiver] = await Promise.all([ + startNativeAgent('author'), + startNativeAgent('receiver'), + ]); + const receiverPolicy = receiver.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR_WALLET.address.toLowerCase() as never, + }); + await connectBothWays(author, receiver); + + const published = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [], + issuedAt: FIXED_HEAD_ISSUED_AT, + }); + expect(published.announcement.policyDigest).toBe(receiverPolicy.policyDigest); + expect(published.announcedPeers).toEqual([]); + const delivery = await author.announceRfc64PublicCatalogHeadV1({ + announcement: published.announcement, + peers: [receiver.peerId], + }); + expect(delivery.announcedPeers).toEqual([receiver.peerId]); + expect(delivery.failedPeers).toEqual([]); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + + const scopeDigest = catalogScopeDigest(); + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR_WALLET.address.toLowerCase() as never, + })).toMatchObject({ + catalogScopeDigest: scopeDigest, + currentCatalogHeadDigest: published.headObjectDigest, + catalogVersion: '0', + inventoryRowCount: '0', + }); + expect(receiver.readRfc64PublicCatalogSynchronizationEvidenceV1( + published.headObjectDigest, + )).toMatchObject({ + catalogHeadDigest: published.headObjectDigest, + inventoryRowCount: 0, + activatedTripleCount: 0, + stagedObjectCount: 2, + appliedHeadStatus: 'applied', + }); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + applied: 1, + stagedOnly: 0, + failed: 0, + }); + + const replay = await author.announceRfc64PublicCatalogHeadV1({ + announcement: published.announcement, + peers: [receiver.peerId], + }); + expect(replay.announcedPeers).toEqual([receiver.peerId]); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + applied: 1, + dedupedAlreadyApplied: 1, + }); + }, 60_000); + + it('fails closed when wire network identity differs from the local deployment', async () => { + const [author, receiver] = await Promise.all([ + startNativeAgent('mismatch-author'), + startNativeAgent('mismatch-receiver', { + ...NATIVE_DEPLOYMENT, + networkId: 'base:8453' as NetworkIdV1, + }), + ]); + receiver.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR_WALLET.address.toLowerCase() as never, + }); + await connectBothWays(author, receiver); + const published = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [receiver.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + }); + expect(published.announcedPeers).toEqual([receiver.peerId]); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR_WALLET.address.toLowerCase() as never, + })).toBeNull(); + expect(receiver.readRfc64PublicCatalogSynchronizationEvidenceV1( + published.headObjectDigest, + )).toBeNull(); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + applied: 0, + failed: 1, + }); + }, 60_000); + + it('rejects a stale-network durable dedupe after restart under a new local pin', async () => { + const receiverDataDir = await mkdtemp(join(tmpdir(), 'dkg-rfc64-native-repin-')); + tempDirs.push(receiverDataDir); + const [author, firstReceiver] = await Promise.all([ + startNativeAgent('repin-author'), + startNativeAgent('repin-receiver', NATIVE_DEPLOYMENT, receiverDataDir), + ]); + firstReceiver.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR_WALLET.address.toLowerCase() as never, + }); + await connectBothWays(author, firstReceiver); + const published = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [firstReceiver.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + }); + await firstReceiver.whenRfc64PublicCatalogReceiverIdleV1(); + expect(firstReceiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR_WALLET.address.toLowerCase() as never, + })?.currentCatalogHeadDigest).toBe(published.headObjectDigest); + + await firstReceiver.stop(); + agents.splice(agents.indexOf(firstReceiver), 1); + const restartedReceiver = await startNativeAgent('repin-receiver', { + ...NATIVE_DEPLOYMENT, + networkId: 'base:8453' as NetworkIdV1, + }, receiverDataDir); + restartedReceiver.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR_WALLET.address.toLowerCase() as never, + }); + await connectBothWays(author, restartedReceiver); + const delivery = await author.announceRfc64PublicCatalogHeadV1({ + announcement: published.announcement, + peers: [restartedReceiver.peerId], + }); + expect(delivery.announcedPeers).toEqual([restartedReceiver.peerId]); + await restartedReceiver.whenRfc64PublicCatalogReceiverIdleV1(); + expect(restartedReceiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + applied: 0, + dedupedAlreadyApplied: 0, + failed: 1, + }); + expect(restartedReceiver.readRfc64PublicCatalogSynchronizationEvidenceV1( + published.headObjectDigest, + )).toBeNull(); + }, 60_000); +}); diff --git a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts index 5662fb47f8..061b2f8dd4 100644 --- a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it, vi } from 'vitest'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; import type { Rfc64ControlObjectOperationsV1 } from '../src/rfc64/control-object-store-v1.js'; import { + RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1, Rfc64PublicCatalogServiceV1, type Rfc64PublicCatalogReconcilerClientsV1, } from '../src/rfc64/public-catalog-service-v1.js'; @@ -341,6 +342,61 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { await service.close(); }); + it('reports exact explicit announce ACK/failures and rejects unbounded peers pre-send', async () => { + const router = new RecordingRouter(); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: controlObjects(), + }); + const policy = acceptPolicy(service); + const head = announcement(policy.policyDigest); + let attempt = 0; + router.sendResponse = async () => Uint8Array.of(attempt++ === 0 ? 1 : 2); + service.start(); + + const result = await service.announceCatalogHead({ + announcement: head, + peers: ['peer-ack', 'peer-fail'], + }); + expect(result.announcement).toEqual(head); + expect(result.announcement).not.toBe(head); + expect(result.announcedPeers).toEqual(['peer-ack']); + expect(result.failedPeers).toEqual([{ + peerId: 'peer-fail', + error: expect.stringContaining('invalid acknowledgement'), + }]); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.announcedPeers)).toBe(true); + expect(Object.isFrozen(result.failedPeers)).toBe(true); + + const sendsBefore = countEvent( + router, + `send:${RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1}`, + ); + await expect(service.announceCatalogHead({ + announcement: head, + peers: Array.from( + { length: RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1 + 1 }, + (_, index) => `peer-${index}`, + ), + })).rejects.toThrow(/at most 64 peers/); + const sparsePeers = new Array(2); + sparsePeers[0] = 'peer-ok'; + await expect(service.announceCatalogHead({ + announcement: head, + peers: sparsePeers, + })).rejects.toThrow(/peer 1 is invalid/); + await expect(service.announceCatalogHead({ + announcement: head, + peers: ['é'.repeat(129)], + })).rejects.toThrow(/peer 0 is invalid/); + expect(countEvent( + router, + `send:${RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1}`, + )).toBe(sendsBefore); + await service.close(); + }); + it('constructs one reconciler with frozen fetch-only capabilities and the configured timeout', async () => { const router = new RecordingRouter(); let clients: Readonly | undefined; diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 017299c699..9ac16dd496 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -125,6 +125,7 @@ export default defineConfig({ "test/rfc64-public-catalog-service-v1.test.ts", "test/rfc64-public-catalog-issuer-delegation-v1.test.ts", "test/rfc64-public-catalog-gate1.integration.test.ts", + "test/rfc64-dkg-agent-native-wiring.integration.test.ts", "test/rfc64-public-catalog-native-transport-v1.test.ts", "test/rfc64-public-catalog-native-reconciler-v1.test.ts", "test/rfc64-public-catalog-native-gate1.integration.test.ts", From ad0c7a8276f314a77cdcf130cd9f338607117444 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:26:22 +0200 Subject: [PATCH 128/292] feat(agent): publish RFC-64 catalog successors --- packages/agent/src/dkg-agent-rfc64-catalog.ts | 222 +++++++++++++- .../src/rfc64/public-catalog-service-v1.ts | 98 +++--- ...-successor-publication.integration.test.ts | 288 ++++++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 4 files changed, 573 insertions(+), 36 deletions(-) create mode 100644 packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index a47653986a..7e6641a724 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -19,8 +19,17 @@ */ import { + ZERO_DIGEST32_V1, + assertSignedAuthorCatalogBucketEnvelopeV1, + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, - createOperationContext, + computeControlSignatureVariantDigestHex, + deriveAuthorCatalogScopeFromHeadV1, + type AssertionCoordinateV1, + type ByteLengthV1, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, type OperationContext, type ContextGraphIdV1, type Digest32V1, @@ -33,7 +42,9 @@ import { type CountV1, assertCanonicalChainId, assertNetworkIdV1, - type CatalogSealDeploymentProfileV1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; import { ethers } from 'ethers'; @@ -43,8 +54,10 @@ import type { DKGAgent } from './dkg-agent.js'; import type { Rfc64AuthorCatalogEip191SignerV1 } from './rfc64/author-catalog-producer.js'; import type { AcceptedOpenCatalogPolicyV1 } from './rfc64/open-catalog-policy-v1.js'; import type { Rfc64PublicCatalogReceiverReconcilerV1 } from './rfc64/public-catalog-receiver-v1.js'; +import type { Rfc64PersistenceV1 } from './rfc64/persistence-v1.js'; import { Rfc64PublicCatalogServiceV1, + snapshotRfc64PublicCatalogAnnouncementPeersV1, type PublishOpenAuthorCatalogGenesisResultV1, type AnnounceRfc64PublicCatalogHeadInputV1, type AnnounceRfc64PublicCatalogHeadResultV1, @@ -61,6 +74,14 @@ import { type Rfc64PublicOpenCatalogDeploymentResolverV1, } from './rfc64/public-catalog-native-reconciler-v1.js'; import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; +import { + Rfc64PublicCatalogSuccessorProducerV1, + type Rfc64PublicCatalogIssuerAuthorizationV1, +} from './rfc64/public-catalog-successor-producer-v1.js'; +import { + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + type Rfc64PublicCatalogHeadAnnouncementV1, +} from './rfc64/public-catalog-transport-v1.js'; /** Minimal EIP-191 EOA signer (ethers.Wallet-compatible) for author-catalog objects. */ export interface Rfc64OpenCatalogAuthorSignerV1 { @@ -159,6 +180,38 @@ export function snapshotRfc64CatalogDeploymentProfileV1( }); } +export interface PublishOpenAuthorCatalogSuccessorParamsV1 { + /** Exact durable predecessor returned by genesis or a prior successor. */ + readonly previousHead: Rfc64StagedAuthorCatalogHeadRefV1; + /** Same author/catalog key that signed the predecessor. */ + readonly author: Rfc64OpenCatalogAuthorSignerV1; + /** Exact signed authorization returned by genesis publication. */ + readonly catalogIssuerAuthorization: Rfc64PublicCatalogIssuerAuthorizationV1; + readonly assertionCoordinate: AssertionCoordinateV1; + readonly projectionBytes: Uint8Array; + readonly seal: CanonicalGraphScopedAuthorSealV1; + readonly deployment: CatalogSealDeploymentProfileV1; + /** Successor head timestamp; defaults to now. */ + readonly issuedAt?: TimestampMsV1; + /** Peers to announce head availability to after both durable barriers. */ + readonly peers: readonly string[]; +} + +export interface PublishOpenAuthorCatalogSuccessorResultV1 { + readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; + readonly headObjectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + readonly catalogRowDigest: Digest32V1; + readonly bundleDigest: Digest32V1; + readonly contentDigest: Digest32V1; + readonly contentByteLength: ByteLengthV1; + readonly bundleByteLength: ByteLengthV1; + readonly kaUal: string; + readonly inventoryRowCount: CountV1; + readonly announcedPeers: readonly string[]; + readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; +} + export class Rfc64CatalogMethods extends DKGAgentBase { /** * Construct + start the public catalog service on the production router. @@ -255,6 +308,103 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return this.requireRfc64PublicCatalogServiceV1().announceCatalogHead(input); } + /** + * Public/open author path for one exact root-lane successor. The predecessor + * is reloaded from durable provider storage, the hardened producer performs + * every authorship/bundle/projection check, and announcement happens only + * after exact bundle and control-object durability receipts. + */ + async publishOpenAuthorCatalogSuccessorV1( + this: DKGAgent, + params: PublishOpenAuthorCatalogSuccessorParamsV1, + ): Promise { + const service = this.requireRfc64PublicCatalogServiceV1(); + const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(params.peers); + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) { + throw new Error('RFC-64 persistence is not available'); + } + const history = await loadPublicOpenRootLaneHistoryV1(persistence, params.previousHead); + const scope = deriveAuthorCatalogScopeFromHeadV1(history.previousHead.payload); + const policyDigest = service.acceptedOpenPolicyDigestForCatalogScope(scope); + const authorAddress = params.author.address.toLowerCase() as EvmAddressV1; + if (authorAddress !== scope.authorAddress) { + throw new Error('RFC-64 successor author must equal the exact predecessor author'); + } + if ( + params.catalogIssuerAuthorization.catalogIssuerDelegation.objectDigest + !== history.previousHead.payload.catalogIssuerDelegationDigest + ) { + throw new Error( + 'RFC-64 successor authorization does not match the predecessor delegation digest', + ); + } + + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: persistence.controlObjects, + stageKaBundle: persistence.kaBundles.putKaBundle, + }); + const produced = await producer.produceAndStage({ + previousHead: history.previousHead, + previousDirectoryPath: history.previousDirectoryPath, + previousBucket: history.previousBucket, + assertionCoordinate: params.assertionCoordinate, + projectionBytes: params.projectionBytes, + seal: params.seal, + deployment: params.deployment, + issuedAt: params.issuedAt ?? (Date.now().toString() as TimestampMsV1), + catalogSigner: { + issuer: authorAddress, + signDigest: (objectDigest) => params.author.signMessage(objectDigest), + }, + catalogIssuerAuthorization: params.catalogIssuerAuthorization, + }); + const head = produced.publication.head; + const headKeys = produced.stagedControlObjects.objects.find( + (keys) => keys.objectDigest === head.objectDigest, + ); + const expectedSignatureVariantDigest = computeControlSignatureVariantDigestHex( + head.objectDigest, + head.signature, + ); + if ( + headKeys === undefined + || headKeys.signatureVariantDigest !== expectedSignatureVariantDigest + ) { + throw new Error('RFC-64 successor control store returned no exact durable head receipt'); + } + const announcement: Rfc64PublicCatalogHeadAnnouncementV1 = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: head.payload.networkId, + contextGraphId: head.payload.contextGraphId, + subGraphName: head.payload.subGraphName, + authorAddress: head.payload.authorAddress, + catalogEra: head.payload.era, + catalogVersion: head.payload.version, + policyDigest, + catalogHeadObjectDigest: headKeys.objectDigest, + signatureVariantDigest: headKeys.signatureVariantDigest, + }); + const delivery = await service.announceCatalogHead({ + announcement, + peers, + }); + return Object.freeze({ + announcement: delivery.announcement, + headObjectDigest: headKeys.objectDigest, + signatureVariantDigest: headKeys.signatureVariantDigest, + catalogRowDigest: produced.sealBinding.catalogRowDigest, + bundleDigest: produced.bundleDigest, + contentDigest: produced.projection.projectionDigest, + contentByteLength: produced.projection.projectionByteLength, + bundleByteLength: produced.row.transfer.byteLength, + kaUal: produced.projection.kaUal, + inventoryRowCount: head.payload.totalRows, + announcedPeers: delivery.announcedPeers, + failedPeers: delivery.failedPeers, + }); + } + /** * Read a head back from the control-object store by its exact digests — the * "durably staged the exact head" proof. Returns null when not staged. @@ -317,6 +467,15 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return evidence === undefined ? null : Object.freeze({ ...evidence }); } + /** Read a fresh copy of one exact durably staged opaque KA bundle. */ + readRfc64StagedKaBundleV1( + this: DKGAgent, + bundleDigest: Digest32V1, + ): Promise { + return this.rfc64PersistenceV1?.kaBundles.readKaBundleByDigest(bundleDigest) + ?? Promise.resolve(null); + } + /** Await the receiver scheduler draining all queued + in-flight fetch/stage work. */ whenRfc64PublicCatalogReceiverIdleV1(this: DKGAgent): Promise { const service = this.rfc64PublicCatalogServiceV1; @@ -454,3 +613,62 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return deployment; } } + +interface PublicOpenRootLaneHistoryV1 { + readonly previousHead: SignedAuthorCatalogHeadEnvelopeV1; + readonly previousDirectoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; + readonly previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null; +} + +async function loadPublicOpenRootLaneHistoryV1( + persistence: Rfc64PersistenceV1, + ref: Rfc64StagedAuthorCatalogHeadRefV1, +): Promise { + const storedHead = await persistence.controlObjects.getVerifiedObject({ + objectDigest: ref.objectDigest, + signatureVariantDigest: ref.signatureVariantDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedHead === null) throw new Error('RFC-64 predecessor head is not durably staged'); + assertSignedAuthorCatalogHeadEnvelopeV1(storedHead.envelope); + const previousHead = storedHead.envelope; + if ( + previousHead.payload.subGraphName !== null + || previousHead.payload.bucketCount !== '1' + || previousHead.payload.directoryHeight !== '0' + || (previousHead.payload.totalRows !== '0' && previousHead.payload.totalRows !== '1') + ) { + throw new Error('RFC-64 predecessor is outside the public/open root-lane slice'); + } + + const storedRoot = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: previousHead.payload.directoryRootDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedRoot === null) throw new Error('RFC-64 predecessor directory root is not staged'); + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1( + storedRoot.envelope, + previousHead.payload.bucketCount, + ); + const root = storedRoot.envelope; + const descriptor = root.payload.entries[0]; + if (descriptor === undefined || !('bucketDigest' in descriptor)) { + throw new Error('RFC-64 predecessor root has no level-zero bucket descriptor'); + } + + let previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null = null; + if (descriptor.bucketDigest !== ZERO_DIGEST32_V1) { + const storedBucket = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: descriptor.bucketDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedBucket === null) throw new Error('RFC-64 predecessor bucket is not staged'); + assertSignedAuthorCatalogBucketEnvelopeV1(storedBucket.envelope); + previousBucket = storedBucket.envelope; + } + return Object.freeze({ + previousHead, + previousDirectoryPath: Object.freeze([root]), + previousBucket, + }); +} diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 9061d052e9..04d15f5ae1 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -252,6 +252,17 @@ export class Rfc64PublicCatalogServiceV1 { return this.#policies.accept(buildOpenOwnerContextGraphPolicyV1(input)); } + /** Resolve the locally accepted open-policy digest for one exact catalog scope. */ + acceptedOpenPolicyDigestForCatalogScope(scopeInput: AuthorCatalogScopeV1): Digest32V1 { + const scope = snapshotCatalogScope(scopeInput); + const held = this.#policies.lookup(scope.networkId, scope.contextGraphId); + if (held === null) { + throw new Error('RFC-64 catalog scope has no locally accepted open policy'); + } + assertOpenPolicyMatchesCatalogScope(held, held, scope); + return held.policyDigest; + } + start(): void { if (this.#closed) throw new Error('RFC-64 public catalog service is closed'); if (this.#started) return; @@ -300,7 +311,7 @@ export class Rfc64PublicCatalogServiceV1 { const issuedAt = input.issuedAt; const effectiveAt = input.catalogIssuerDelegationEffectiveAt; const expiresAt = input.catalogIssuerDelegationExpiresAt; - const peers = snapshotAnnouncementPeers(input.peers); + const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); const heldPolicy = this.#policies.lookup(scope.networkId, scope.contextGraphId); assertOpenPolicyMatchesCatalogScope(input.policy, heldPolicy, scope); const policyDigest = heldPolicy!.policyDigest; @@ -386,7 +397,8 @@ export class Rfc64PublicCatalogServiceV1 { const announcement = parseRfc64PublicCatalogHeadAnnouncementV1( encodeRfc64PublicCatalogHeadAnnouncementV1(input.announcement), ); - const peers = snapshotAnnouncementPeers(input.peers); + const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); + this.#assertAcceptedOpenAnnouncement(announcement); return this.#announceCatalogHeadSnapshot(announcement, peers); } @@ -431,6 +443,22 @@ export class Rfc64PublicCatalogServiceV1 { }); } + #assertAcceptedOpenAnnouncement( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + ): void { + const held = this.#policies.lookup(announcement.networkId, announcement.contextGraphId); + if ( + held === null + || held.policy.accessPolicy !== 0 + || held.policyDigest !== announcement.policyDigest + || held.policy.source.kind !== 'owner-signed-unregistered' + || held.policy.source.ownerAddress !== announcement.authorAddress + ) { + throw new Error( + 'RFC-64 catalog announcement is not bound to the locally accepted open policy', + ); + } + } async #authorizeNativeOperation( input: Rfc64PublicCatalogNativeAuthorizationInputV1, ): Promise { @@ -467,6 +495,40 @@ export class Rfc64PublicCatalogServiceV1 { } } +export function snapshotRfc64PublicCatalogAnnouncementPeersV1( + input: readonly string[], +): readonly string[] { + if (!Array.isArray(input)) { + throw new TypeError('RFC-64 catalog announcement peers must be an array'); + } + if (input.length > RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1) { + throw new RangeError( + `RFC-64 catalog announcement accepts at most ` + + `${RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1} peers`, + ); + } + const seen = new Set(); + const peers: string[] = []; + for (let index = 0; index < input.length; index += 1) { + const peerId = input[index]; + const byteLength = typeof peerId === 'string' ? UTF8.encode(peerId).byteLength : 0; + if ( + typeof peerId !== 'string' + || byteLength === 0 + || byteLength > RFC64_PUBLIC_CATALOG_PEER_ID_MAX_BYTES_V1 + || peerId.trim() !== peerId + ) { + throw new TypeError(`RFC-64 catalog announcement peer ${index} is invalid`); + } + if (seen.has(peerId)) { + throw new TypeError(`RFC-64 catalog announcement peer ${index} is duplicated`); + } + seen.add(peerId); + peers.push(peerId); + } + return Object.freeze(peers); +} + function snapshotCatalogScope(input: AuthorCatalogScopeV1): Readonly { const scope = Object.freeze({ networkId: input.networkId, @@ -529,35 +591,3 @@ function assertExactDurableStageReceipt( } } } - -function snapshotAnnouncementPeers(input: readonly string[]): readonly string[] { - if (!Array.isArray(input)) { - throw new TypeError('RFC-64 catalog announcement peers must be an array'); - } - if (input.length > RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1) { - throw new RangeError( - `RFC-64 catalog announcement accepts at most ` - + `${RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1} peers`, - ); - } - const seen = new Set(); - const peers: string[] = []; - for (let index = 0; index < input.length; index += 1) { - const peerId = input[index]; - const byteLength = typeof peerId === 'string' ? UTF8.encode(peerId).byteLength : 0; - if ( - typeof peerId !== 'string' - || byteLength === 0 - || byteLength > RFC64_PUBLIC_CATALOG_PEER_ID_MAX_BYTES_V1 - || peerId.trim() !== peerId - ) { - throw new TypeError(`RFC-64 catalog announcement peer ${index} is invalid`); - } - if (seen.has(peerId)) { - throw new TypeError(`RFC-64 catalog announcement peer ${index} is duplicated`); - } - seen.add(peerId); - peers.push(peerId); - } - return Object.freeze(peers); -} diff --git a/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts new file mode 100644 index 0000000000..6324a56ba2 --- /dev/null +++ b/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts @@ -0,0 +1,288 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + assertCanonicalGraphScopedAuthorSealV1, + buildAuthorAttestationTypedData, + decodeOpaqueKaBundleV1, + type AuthorCatalogScopeV1, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, + type ContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type TimestampMsV1, +} from '@origintrail-official/dkg-core'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DKGAgent } from '../src/dkg-agent.js'; +import { produceDirectAuthorCatalogIssuerDelegationV1 } from '../src/rfc64/public-catalog-issuer-delegation-v1.js'; + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'66'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const NETWORK_ID = 'otp:20430' as NetworkIdV1; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/dkg-successor-api' as ContextGraphIdV1; +const OTHER_CONTEXT_GRAPH_ID = `${CONTEXT_GRAPH_ID}-other` as ContextGraphIdV1; +const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const KA_NUMBER = 7n; +const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); +const KA_UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; +const ASSERTION_ROOT = + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f' as Digest32V1; +const PROJECTION = new TextEncoder().encode( + ' "42"^^ .\n' + + ' "Alice" .\n', +); +const GENESIS_ISSUED_AT = '1773900000000' as TimestampMsV1; +const SUCCESSOR_ISSUED_AT = '1773900001000' as TimestampMsV1; +const DELEGATION_EFFECTIVE_AT = '1773899999000' as TimestampMsV1; +const DELEGATION_EXPIRES_AT = '1774000000000' as TimestampMsV1; +const DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +}) as CatalogSealDeploymentProfileV1; + +const agents: DKGAgent[] = []; +const tempDirs: string[] = []; + +afterEach(async () => { + for (const agent of agents.splice(0)) { + try { await agent.stop(); } catch { /* best-effort cleanup */ } + } + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe('RFC-64 DKGAgent public/open successor publication', () => { + it('produces deterministic exact evidence and durably reads the head and bundle after restart', async () => { + const firstDir = await createDataDir('first'); + const secondDir = await createDataDir('second'); + const first = await startAgent('successor-first', firstDir); + const second = await startAgent('successor-second', secondDir); + const seal = await authorSeal(); + + const firstGenesis = await publishGenesis(first); + const secondGenesis = await publishGenesis(second); + const firstResult = await first.publishOpenAuthorCatalogSuccessorV1({ + previousHead: { + objectDigest: firstGenesis.headObjectDigest, + signatureVariantDigest: firstGenesis.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: firstGenesis.catalogIssuerAuthorization, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal, + deployment: DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [], + }); + const secondResult = await second.publishOpenAuthorCatalogSuccessorV1({ + previousHead: { + objectDigest: secondGenesis.headObjectDigest, + signatureVariantDigest: secondGenesis.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: secondGenesis.catalogIssuerAuthorization, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal, + deployment: DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [], + }); + + expect(firstResult).toMatchObject({ + announcement: { + catalogVersion: '1', + catalogHeadObjectDigest: firstResult.headObjectDigest, + signatureVariantDigest: firstResult.signatureVariantDigest, + }, + contentByteLength: PROJECTION.byteLength.toString(), + kaUal: KA_UAL, + inventoryRowCount: '1', + announcedPeers: [], + failedPeers: [], + }); + expect(BigInt(firstResult.bundleByteLength)).toBeGreaterThan(BigInt(firstResult.contentByteLength)); + expect(exactEvidence(secondResult)).toEqual(exactEvidence(firstResult)); + expect(await first.readRfc64StagedAuthorCatalogHeadV1({ + objectDigest: firstResult.headObjectDigest, + signatureVariantDigest: firstResult.signatureVariantDigest, + })).toBe(firstResult.headObjectDigest); + const stagedBundle = await first.readRfc64StagedKaBundleV1(firstResult.bundleDigest); + expect(stagedBundle).not.toBeNull(); + expect(decodeOpaqueKaBundleV1(stagedBundle!).projectionBytes).toEqual(PROJECTION); + + await first.stop(); + const reopened = await startAgent('successor-reopened', firstDir); + expect(await reopened.readRfc64StagedAuthorCatalogHeadV1({ + objectDigest: firstResult.headObjectDigest, + signatureVariantDigest: firstResult.signatureVariantDigest, + })).toBe(firstResult.headObjectDigest); + const reopenedBundle = await reopened.readRfc64StagedKaBundleV1(firstResult.bundleDigest); + expect(reopenedBundle).toEqual(stagedBundle); + expect(reopenedBundle).not.toBe(stagedBundle); + }, 60_000); + + it('rejects a mismatched signed authorization before signing or durable staging', async () => { + const dataDir = await createDataDir('mismatched-auth'); + const agent = await startAgent('successor-mismatched-auth', dataDir); + const signMessage = vi.fn((digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest)); + const author = { address: AUTHOR_WALLET.address, signMessage }; + const genesis = await agent.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author, + peers: [], + issuedAt: GENESIS_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, + }); + const otherAuthorization = await directAuthorization(OTHER_CONTEXT_GRAPH_ID); + signMessage.mockClear(); + const filesBefore = await recursiveFiles(dataDir); + + await expect(agent.publishOpenAuthorCatalogSuccessorV1({ + previousHead: { + objectDigest: genesis.headObjectDigest, + signatureVariantDigest: genesis.signatureVariantDigest, + }, + author, + catalogIssuerAuthorization: otherAuthorization, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(), + deployment: DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [], + })).rejects.toThrow(/authorization does not match the predecessor delegation digest/); + + expect(signMessage).not.toHaveBeenCalled(); + expect(await recursiveFiles(dataDir)).toEqual(filesBefore); + expect(await agent.readRfc64StagedAuthorCatalogHeadV1({ + objectDigest: genesis.headObjectDigest, + signatureVariantDigest: genesis.signatureVariantDigest, + })).toBe(genesis.headObjectDigest); + }, 60_000); +}); + +async function createDataDir(label: string): Promise { + const path = await mkdtemp(join(tmpdir(), `dkg-rfc64-${label}-`)); + tempDirs.push(path); + return path; +} + +async function startAgent(name: string, dataDir: string): Promise { + const agent = await DKGAgent.create({ + name, + dataDir, + listenHost: '127.0.0.1', + listenPort: 0, + bootstrapPeers: [], + nodeRole: 'edge', + store: new OxigraphStore(), + syncSharedMemoryOnConnect: false, + syncReconcilerEnabled: false, + syncOnConnectEnabled: false, + durableSyncEnabled: false, + agentProfileHeartbeatMs: 0, + }); + agents.push(agent); + await agent.start(); + return agent; +} + +function publishGenesis(agent: DKGAgent) { + return agent.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [], + issuedAt: GENESIS_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, + }); +} + +function exactEvidence(result: Awaited>) { + return { + announcement: result.announcement, + headObjectDigest: result.headObjectDigest, + signatureVariantDigest: result.signatureVariantDigest, + catalogRowDigest: result.catalogRowDigest, + bundleDigest: result.bundleDigest, + contentDigest: result.contentDigest, + contentByteLength: result.contentByteLength, + bundleByteLength: result.bundleByteLength, + kaUal: result.kaUal, + inventoryRowCount: result.inventoryRowCount, + }; +} + +async function directAuthorization(contextGraphId: ContextGraphIdV1) { + const produced = await produceDirectAuthorCatalogIssuerDelegationV1({ + scope: { + networkId: NETWORK_ID, + contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + signer: { + issuer: AUTHOR, + signDigest: (digest) => AUTHOR_WALLET.signMessage(digest), + }, + effectiveAt: DELEGATION_EFFECTIVE_AT, + expiresAt: DELEGATION_EXPIRES_AT, + catalogHeadIssuedAt: GENESIS_ISSUED_AT, + }); + return produced.authorization; +} + +async function authorSeal(): Promise { + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(DEPLOYMENT.assertedAtChainId), + kav10Address: DEPLOYMENT.assertedAtKav10Address, + merkleRoot: ethers.getBytes(ASSERTION_ROOT), + authorAddress: AUTHOR, + reservedKaId: BigInt(KA_ID), + }); + const signature = ethers.Signature.from(await AUTHOR_WALLET.signTypedData( + typedData.domain, + typedData.types, + typedData.message, + )); + const seal = { + assertionMerkleRoot: ASSERTION_ROOT, + authorAddress: AUTHOR, + authorAttestationR: signature.r, + authorAttestationVS: signature.yParityAndS, + authorSchemeVersion: '1', + assertedAtChainId: DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: KA_UAL, + assertionVersion: '1', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(seal); + return seal; +} + +async function recursiveFiles(root: string): Promise { + return Object.freeze((await readdir(root, { recursive: true })).sort()); +} diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 9ac16dd496..a924b2a564 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -131,6 +131,7 @@ export default defineConfig({ "test/rfc64-public-catalog-native-gate1.integration.test.ts", "test/rfc64-persistent-catalog-provider-v1.test.ts", "test/rfc64-public-catalog-successor-producer-v1.test.ts", + "test/rfc64-dkg-agent-successor-publication.integration.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 847eea3e78f062565f5a3de7102dfeeb9d132ec7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:27:55 +0200 Subject: [PATCH 129/292] test(devnet): require real RFC-64 Gate 1 processes --- devnet/rfc64-gate1-public-open/README.md | 99 ++-- .../adapter-process.ts | 384 ++++++++------ .../agent-child.test.ts | 88 ++++ devnet/rfc64-gate1-public-open/agent-child.ts | 210 ++++++++ devnet/rfc64-gate1-public-open/model.test.ts | 66 ++- devnet/rfc64-gate1-public-open/model.ts | 199 ++------ devnet/rfc64-gate1-public-open/package.json | 8 +- .../product-capabilities.test.ts | 77 +++ .../product-capabilities.ts | 91 ++++ devnet/rfc64-gate1-public-open/run.ts | 482 +++++------------- devnet/rfc64-gate1-public-open/tsconfig.json | 4 + .../rfc64-gate1-public-open/verifier.test.ts | 315 ++++++------ devnet/rfc64-gate1-public-open/verifier.ts | 354 +++++++------ devnet/rfc64-gate1-public-open/verify.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 15 +- 16 files changed, 1367 insertions(+), 1029 deletions(-) create mode 100644 devnet/rfc64-gate1-public-open/agent-child.test.ts create mode 100644 devnet/rfc64-gate1-public-open/agent-child.ts create mode 100644 devnet/rfc64-gate1-public-open/product-capabilities.test.ts create mode 100644 devnet/rfc64-gate1-public-open/product-capabilities.ts diff --git a/devnet/rfc64-gate1-public-open/README.md b/devnet/rfc64-gate1-public-open/README.md index 4aace07f36..5613e8ebb5 100644 --- a/devnet/rfc64-gate1-public-open/README.md +++ b/devnet/rfc64-gate1-public-open/README.md @@ -1,55 +1,78 @@ -# OT-RFC-64 Gate 1 public/open harness contract - -This bounded harness freezes the deterministic evidence contract and process -orchestration needed for the first RFC-64 public/open one-row synchronization. -It runs an author adapter process and a receiver adapter process concurrently, -then kills and restarts the receiver against the same durable directory. - -The artifact proves that the harness and verifier require: - -- distinct author and receiver peer identities; -- exact successor head, catalog-row, bundle, and public-content digests; -- exact KA UAL, inventory row count, SWM graph, and activated quad count; -- an exact durable applied-head readback after positive activation; -- a forged author attestation failure with zero activation and no applied head; -- a durable repair intent followed by `SIGKILL`, receiver restart, repair of the - applied-head gap, and exact semantic plus applied-state post-read. - -The current adapter is deliberately identified as -`deterministic-fixture-adapter-v1` and the raw artifact has -`gateEvaluation.status = "not-evaluated"`. It does **not** claim a production -Gate 1 pass. Combined commit `1f9119ac8` does not yet contain the successor -producer from `6c14bd4ad15b79cc889d0308dd1d1cac60467747` or the serialized -durable applied-head behavior from -`ebbfb34f9bd0a0833ee5adb925cba67c527c91a8`. Once those APIs are assembled, -replace the adapter commands with production `DKGAgent` calls while preserving -the closed evidence schema and verifier. - -The closed adapter boundary requires exactly six production operations, without -depending on current service internals: `publishGenesis`, `publishSuccessor`, -`announce`, `appliedHeadReadback`, `exactInventoryReadback`, and `killRestart`. - -Run the deterministic process exercise and separate fail-closed verifier: +# OT-RFC-64 Gate 1 public/open production harness + +This harness has a closed evidence schema and a real process boundary. It boots +an author and receiver as separate `DKGAgent` OS processes, connects them over +libp2p, derives the same open policy independently on both nodes, exercises the +production RFC-64 catalog APIs, kills the receiver with `SIGKILL`, restarts it +against the same durable directory, and requires explicit reannouncement plus +idempotent replay. + +There is no fixture adapter and no fallback product result. Missing methods, +unexpected return shapes, failed process cleanup, stale artifacts, and any +attempt to verify the old `not-connected` evidence fail closed. + +The frozen adapter operations remain: + +- `publishGenesis` +- `publishSuccessor` +- `announce` +- `appliedHeadReadback` +- `exactInventoryReadback` +- `killRestart` + +The adapter currently maps those operations to: + +- `DKGAgent.publishOpenAuthorCatalogGenesisV1` +- `DKGAgent.publishOpenAuthorCatalogSuccessorV1` +- `DKGAgent.announceRfc64PublicCatalogHeadV1` +- `DKGAgent.readRfc64AppliedCatalogHeadV1` +- `DKGAgent.readRfc64PublicCatalogSynchronizationEvidenceV1` +- harness-owned `SIGKILL` and process replacement + +At combined base `591ff321e9cd88f91206d15a87c882873f005c37`, only +genesis publication exists on `DKGAgent`; the other product methods are being +assembled in the native-wiring lane. The harness therefore boots and connects +real agents, then exits non-zero with the exact missing method list. It does not +write a raw or verdict artifact on that base. A follow-up composition against +the native-wiring commit completes the six-operation scenario and result +mapping. + +The preserved raw schema requires production-returned evidence for: + +- distinct real peer identities; +- exact successor head, catalog-row, bundle, public-content, UAL, and SWM graph; +- one inventory row and exact activated triple count; +- durable applied-head and exact semantic post-read; +- forged author-transfer rejection with no activation or applied head; +- a real `SIGKILL`, restart with the same peer identity and durable directory, + explicit reannouncement, and exact replay without duplicate activation. + +The replay successor deliberately reuses the same row/content/bundle. Its head +and version advance, while the product inventory digest remains equal because +the digest commits to catalog scope plus row/content/seal/UAL/count—not head. +No durable repair intent or automatic startup repair is claimed. + +Build the agent and dependencies, then run the complete generator and verifier: ```sh +pnpm turbo run build --filter=@origintrail-official/dkg-agent... pnpm test:gate1:rfc64-public-open-harness ``` -Run only the schema/model mutation tests or strict typecheck: +Run only the model, verifier, product-capability, and process-lifecycle tests or +the strict harness typecheck: ```sh pnpm test:gate1:rfc64-public-open-harness:unit pnpm typecheck:gate1:rfc64-public-open-harness ``` -The raw and verdict artifacts are written atomically as owner-only stable JSON: +Successful production runs atomically write owner-only canonical JSON: ```text devnet/rfc64-gate1-public-open/artifacts/gate1-result.json devnet/rfc64-gate1-public-open/artifacts/gate1-verdict.json ``` -They contain no timestamp, PID, duration, temporary path, or random identifier. -The generator pins a clean tracked repository `HEAD` before spawning and after -all processes exit; the verifier independently pins the same commit. Artifact -bytes therefore remain identical across runs on the same tested commit. +The verdict scope is `production-gate1-public-open`; fixture-era +`harness-contract-only` PASS verdicts are no longer accepted. diff --git a/devnet/rfc64-gate1-public-open/adapter-process.ts b/devnet/rfc64-gate1-public-open/adapter-process.ts index f1dcc76cd6..a6ebc2c65f 100644 --- a/devnet/rfc64-gate1-public-open/adapter-process.ts +++ b/devnet/rfc64-gate1-public-open/adapter-process.ts @@ -1,229 +1,294 @@ -import { readFileSync } from 'node:fs'; -import { mkdir, rm } from 'node:fs/promises'; +import { mkdir, open, readFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import process from 'node:process'; import { createInterface } from 'node:readline'; -import { - atomicWriteStableJson, - stableJson, -} from '../rfc64-persistence-lifecycle/evidence.js'; +import { multiaddr } from '@multiformats/multiaddr'; +import { DKGAgent } from '@origintrail-official/dkg-agent'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; + import { GATE1_ADAPTER_PROTOCOL_VERSION, - GATE1_FIXTURE, - GATE1_FIXTURE_ADAPTER_ID, - assertFixtureDerivations, - expectedAppliedReadBack, - type Gate1TransferFixture, + GATE1_AGENT_EVENT_PREFIX, + GATE1_REAL_DKG_AGENT_ADAPTER_ID, + type Gate1ProductionAdapterOperation, } from './model.js'; +import { + inspectGate1ProductCapabilities, + requireGate1ProductMethod, +} from './product-capabilities.js'; -const EVENT_PREFIX = 'RFC64_GATE1_ADAPTER_EVENT '; const role = process.argv[2]; const dataDirInput = process.env.DKG_RFC64_GATE1_ADAPTER_DATA_DIR; +const masterKeyHex = process.env.DKG_RFC64_GATE1_AGENT_MASTER_KEY_HEX; if (role !== 'author' && role !== 'receiver') throw new Error('adapter role is required'); if (!dataDirInput) throw new Error('DKG_RFC64_GATE1_ADAPTER_DATA_DIR is required'); +if (!masterKeyHex || !/^[0-9a-f]{64}$/u.test(masterKeyHex)) { + throw new Error('DKG_RFC64_GATE1_AGENT_MASTER_KEY_HEX must be 32 lowercase hex bytes'); +} + const dataDir = resolve(dataDirInput); -const semanticPath = join(dataDir, 'semantic-state.json'); -const appliedPath = join(dataDir, 'applied-head.json'); -const repairIntentPath = join(dataDir, 'repair-intent.json'); +const pinnedMasterKeyHex = masterKeyHex; +let agent: DKGAgent | undefined; +let stopping = false; +let commandTail = Promise.resolve(); interface Command { readonly command: string; + readonly input?: unknown; readonly requestId: string; - readonly fixture?: unknown; } function emit(event: Record): void { - process.stdout.write(`${EVENT_PREFIX}${JSON.stringify({ role, ...event })}\n`); + const line = JSON.stringify({ role, ...event }); + if (Buffer.byteLength(line) > 1_000_000) { + throw new Error('Gate 1 adapter event exceeds the 1 MiB process-protocol bound'); + } + process.stdout.write(`${GATE1_AGENT_EVENT_PREFIX}${line}\n`); } async function emitAndFlush(event: Record): Promise { + const line = JSON.stringify({ role, ...event }); + if (Buffer.byteLength(line) > 1_000_000) { + throw new Error('Gate 1 adapter event exceeds the 1 MiB process-protocol bound'); + } await new Promise((resolveWrite, rejectWrite) => { - process.stdout.write( - `${EVENT_PREFIX}${JSON.stringify({ role, ...event })}\n`, - (error) => error === null || error === undefined ? resolveWrite() : rejectWrite(error), - ); + process.stdout.write(`${GATE1_AGENT_EVENT_PREFIX}${line}\n`, (error) => { + if (error === null || error === undefined) resolveWrite(); + else rejectWrite(error); + }); }); } -function exactFixture(input: unknown, expected: Gate1TransferFixture, label: string): void { - if (stableJson(input) !== stableJson(expected)) { - throw new Error(`${label} differs from the adapter-pinned exact fixture`); - } -} - -function readJson(path: string): unknown { - return JSON.parse(readFileSync(path, 'utf8')) as unknown; -} - -function writeState(path: string, value: unknown): void { - atomicWriteStableJson(path, value); -} - -async function repairAtStartup(): Promise | null> { - if (role !== 'receiver') return null; - let intent: unknown; +async function ensureDeterministicAgentKey(): Promise { + await mkdir(dataDir, { recursive: true, mode: 0o700 }); + const keyPath = join(dataDir, 'agent-key.bin'); + const expected = Buffer.from(pinnedMasterKeyHex, 'hex'); try { - intent = readJson(repairIntentPath); + const handle = await open(keyPath, 'wx', 0o600); + try { + await handle.writeFile(expected); + await handle.sync(); + } finally { + await handle.close(); + } } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') return null; - throw error; - } - const expectedIntent = { - durable: true, - target: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), - }; - if (stableJson(intent) !== stableJson(expectedIntent)) { - throw new Error('restart repair intent differs from the pinned successor'); - } - const semantic = readJson(semanticPath); - const expectedSemantic = semanticState(GATE1_FIXTURE.repairSuccessor); - if (stableJson(semantic) !== stableJson(expectedSemantic)) { - throw new Error('restart repair semantic state differs from the durable intent'); - } - const before = readJson(appliedPath); - const expectedBefore = expectedAppliedReadBack(GATE1_FIXTURE.positive); - if (stableJson(before) !== stableJson(expectedBefore)) { - throw new Error('restart repair predecessor applied head is not exact'); + if (!isNodeError(error) || error.code !== 'EEXIST') throw error; + const existing = await readFile(keyPath); + if (!existing.equals(expected)) { + throw new Error('existing DKGAgent master key differs from the role-pinned harness key'); + } } - const after = expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor); - writeState(appliedPath, after); - await rm(repairIntentPath, { force: true }); - return { - action: 'advanced-applied-head-from-durable-intent', - after: readJson(appliedPath), - before, - repaired: true, - semanticPostRead: semantic, - }; } -function semanticState(fixture: Gate1TransferFixture): Readonly> { - return Object.freeze({ - activatedQuadCount: fixture.activatedQuadCount, - catalogHeadDigest: fixture.head.catalogHeadDigest, - catalogRowDigest: fixture.catalogRowDigest, - contentDigest: fixture.contentDigest, - kaUal: fixture.kaUal, - swmGraph: fixture.swmGraph, +async function boot(): Promise { + await ensureDeterministicAgentKey(); + const created = await DKGAgent.create({ + name: `RFC64Gate1${role}`, + dataDir, + listenHost: '127.0.0.1', + listenPort: 0, + bootstrapPeers: [], + nodeRole: 'edge', + store: new OxigraphStore(join(dataDir, 'store.nq')), + syncSharedMemoryOnConnect: false, + syncReconcilerEnabled: false, + syncOnConnectEnabled: false, + durableSyncEnabled: false, + agentProfileHeartbeatMs: 0, + }); + agent = created; + await created.start(); + const tcp = created.multiaddrs.find((address) => address.includes('/tcp/')); + if (tcp === undefined) throw new Error('real DKGAgent exposed no TCP multiaddr'); + emit({ + adapterId: GATE1_REAL_DKG_AGENT_ADAPTER_ID, + agentClass: created.constructor.name, + capabilities: inspectGate1ProductCapabilities(created), + catalogServiceStarted: created.rfc64PublicCatalogStatsV1()?.started === true, + event: 'ready', + multiaddr: tcp, + peerId: created.peerId, + protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, + startupRepair: null, }); } async function handle(command: Command): Promise { - const { requestId } = command; - if (typeof requestId !== 'string' || requestId.length === 0) { + if (typeof command.requestId !== 'string' || command.requestId.length === 0) { throw new Error('requestId is required'); } + const currentAgent = requireAgent(); switch (command.command) { - case 'serve-positive': - requireRole('author'); - emit({ event: 'positive-served', fixture: GATE1_FIXTURE.positive, requestId }); - return; - case 'serve-repair-successor': - requireRole('author'); - emit({ event: 'repair-successor-served', fixture: GATE1_FIXTURE.repairSuccessor, requestId }); - return; - case 'serve-forged': - requireRole('author'); - emit({ event: 'forged-served', fixture: GATE1_FIXTURE.forged, requestId }); - return; - case 'attempt-forged': { - requireRole('receiver'); - if (stableJson(command.fixture) !== stableJson(GATE1_FIXTURE.forged)) { - throw new Error('forged attempt differs from the adapter-pinned fixture'); + case 'dial': { + const input = plainRecord(command.input, 'dial input'); + const address = requiredString(input.multiaddr, 'dial.multiaddr'); + const expectedPeerId = requiredString(input.peerId, 'dial.peerId'); + const connection = await currentAgent.node.libp2p.dial(multiaddr(address)); + const connectedPeerId = connection.remotePeer.toString(); + if (connectedPeerId !== expectedPeerId) { + throw new Error(`dial connected to ${connectedPeerId}, expected ${expectedPeerId}`); } - emit({ - activationAfter: 0, - activationBefore: 0, - appliedHeadAfter: null, - appliedHeadBefore: null, - attemptedCatalogHeadDigest: GATE1_FIXTURE.forged.attemptedCatalogHeadDigest, - event: 'forged-author-rejected', - failureCode: GATE1_FIXTURE.forged.expectedFailureCode, - recoveredAuthorAddress: GATE1_FIXTURE.forged.recoveredAuthorAddress, - requestId, - }); + emit({ event: 'dialed', peerId: connectedPeerId, requestId: command.requestId }); return; } - case 'activate-positive': { - requireRole('receiver'); - exactFixture(command.fixture, GATE1_FIXTURE.positive, 'positive transfer'); - const semantic = semanticState(GATE1_FIXTURE.positive); - const applied = expectedAppliedReadBack(GATE1_FIXTURE.positive); - writeState(semanticPath, semantic); - writeState(appliedPath, applied); + case 'acceptOpenPolicy': { + const accepted = currentAgent.acceptOpenContextGraphPolicyV1( + plainRecord(command.input, 'acceptOpenPolicy input') as never, + ); emit({ - appliedReadBack: readJson(appliedPath), - controlObjectsVerified: 3, - event: 'positive-activated', - exact: GATE1_FIXTURE.positive, - requestId, - semanticPostRead: readJson(semanticPath), + event: 'operation-completed', + operation: command.command, + output: accepted, + requestId: command.requestId, }); return; } - case 'prepare-repair-crash': { - requireRole('receiver'); - exactFixture(command.fixture, GATE1_FIXTURE.repairSuccessor, 'repair successor'); - const before = readJson(appliedPath); - const expectedBefore = expectedAppliedReadBack(GATE1_FIXTURE.positive); - if (stableJson(before) !== stableJson(expectedBefore)) { - throw new Error('repair predecessor does not equal the positive durable applied head'); + case 'publishGenesis': + case 'publishSuccessor': { + requireRole('author'); + const input = plainRecord(command.input, `${command.command} input`); + const authorPrivateKey = requiredString( + input.authorPrivateKey, + `${command.command}.authorPrivateKey`, + ); + const author = new ethers.Wallet(authorPrivateKey); + const forwarded: Record = { ...input, author }; + delete forwarded.authorPrivateKey; + // Publication and announcement are separate frozen operations. Genesis's + // legacy combined API is driven with an empty peer set to preserve that boundary. + if (command.command === 'publishGenesis') forwarded.peers = []; + if (typeof forwarded.projectionNQuads === 'string') { + forwarded.projectionBytes = new TextEncoder().encode(forwarded.projectionNQuads); + delete forwarded.projectionNQuads; } - writeState(semanticPath, semanticState(GATE1_FIXTURE.repairSuccessor)); - writeState(repairIntentPath, { - durable: true, - target: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), - }); - emit({ - appliedBeforeCrash: before, - event: 'repair-gap-durable', - repairIntentDurable: true, - requestId, - semanticBeforeCrash: readJson(semanticPath), - target: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), - }); + const method = requireGate1ProductMethod(currentAgent, command.command); + const output = await method(forwarded); + emitOperationResult(command, output); return; } - case 'read-repaired': { - requireRole('receiver'); - emit({ - appliedReadBack: readJson(appliedPath), - event: 'repair-read-back', - requestId, - semanticPostRead: readJson(semanticPath), - }); + case 'announce': + case 'appliedHeadReadback': + case 'exactInventoryReadback': { + const requiredRole = command.command === 'announce' ? 'author' : 'receiver'; + requireRole(requiredRole); + const method = requireGate1ProductMethod( + currentAgent, + command.command as Exclude, + ); + const output = await method(plainRecord(command.input, `${command.command} input`)); + emitOperationResult(command, output); return; } + case 'awaitReceiverIdle': + requireRole('receiver'); + await currentAgent.whenRfc64PublicCatalogReceiverIdleV1(); + emit({ event: 'receiver-idle', requestId: command.requestId }); + return; + case 'killRestart': + requireRole('receiver'); + // The parent process owns SIGKILL and replacement. This command only + // establishes an explicit process-protocol boundary; it creates no fake + // repair record and intentionally does not stop the DKGAgent. + emit({ event: 'kill-restart-ready', requestId: command.requestId }); + return; case 'stop': - await emitAndFlush({ event: 'stopped', requestId }); - process.exit(0); + await stop(0, command.requestId); + return; default: throw new Error(`unknown adapter command: ${command.command}`); } } +function emitOperationResult(command: Command, output: unknown): void { + assertJsonWireValue(output, `${command.command} output`); + emit({ + event: 'operation-completed', + operation: command.command, + output, + requestId: command.requestId, + }); +} + +function assertJsonWireValue(value: unknown, path: string, depth = 0): void { + if (depth > 32) throw new Error(`${path} exceeds the adapter JSON depth bound`); + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number' && Number.isSafeInteger(value)) return; + if (Array.isArray(value)) { + if (value.length > 10_000) throw new Error(`${path} exceeds the adapter array bound`); + value.forEach((entry, index) => assertJsonWireValue(entry, `${path}[${index}]`, depth + 1)); + return; + } + if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + for (const [key, entry] of Object.entries(value as Record)) { + assertJsonWireValue(entry, `${path}.${key}`, depth + 1); + } + return; + } + throw new Error(`${path} is not a bounded plain JSON value`); +} + +async function stop(exitCode: number, requestId?: string): Promise { + if (stopping) return await new Promise(() => undefined); + stopping = true; + try { + await agent?.stop(); + if (requestId !== undefined) { + await emitAndFlush({ event: 'stopped', requestId }); + } + process.exit(exitCode); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exit(1); + } +} + +function requireAgent(): DKGAgent { + if (agent === undefined) throw new Error('real DKGAgent is not ready'); + return agent; +} + function requireRole(expected: 'author' | 'receiver'): void { - if (role !== expected) throw new Error(`${role} cannot handle ${expected} command`); + if (role !== expected) throw new Error(`${role} cannot handle ${expected} operation`); +} + +function plainRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${label} must be a plain object`); + } + return value as Record; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) { + throw new TypeError(`${label} must be a bounded non-empty string`); + } + return value; } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error; } -assertFixtureDerivations(); -await mkdir(dataDir, { recursive: true, mode: 0o700 }); -const startupRepair = await repairAtStartup(); -emit({ - adapterId: GATE1_FIXTURE_ADAPTER_ID, - event: 'ready', - peerId: role === 'author' ? GATE1_FIXTURE.authorPeerId : GATE1_FIXTURE.receiverPeerId, - protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, - startupRepair, +process.once('SIGTERM', () => { void stop(0); }); +process.once('SIGINT', () => { void stop(130); }); + +await boot().catch(async (error) => { + emit({ event: 'boot-failed', message: error instanceof Error ? error.message : String(error) }); + await stop(1); }); const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); lines.on('line', (line) => { + if (Buffer.byteLength(line) > 1_000_000) { + emit({ event: 'error', message: 'command exceeds the 1 MiB process-protocol bound' }); + return; + } let command: Command; try { command = JSON.parse(line) as Command; @@ -231,7 +296,7 @@ lines.on('line', (line) => { emit({ event: 'error', message: `invalid command JSON: ${String(error)}` }); return; } - void handle(command).catch((error) => { + commandTail = commandTail.then(() => handle(command)).catch((error) => { emit({ event: 'error', message: error instanceof Error ? error.message : String(error), @@ -239,3 +304,4 @@ lines.on('line', (line) => { }); }); }); +lines.once('close', () => { void stop(0); }); diff --git a/devnet/rfc64-gate1-public-open/agent-child.test.ts b/devnet/rfc64-gate1-public-open/agent-child.test.ts new file mode 100644 index 0000000000..e61f787304 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/agent-child.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import test from 'node:test'; + +import { ChildProcessRegistry } from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { Gate1AgentChild } from './agent-child.js'; +import { GATE1_AGENT_EVENT_PREFIX } from './model.js'; + +const CWD = process.cwd(); + +function child(script: string, registry = new ChildProcessRegistry(5_000)): Gate1AgentChild { + return new Gate1AgentChild({ + eventTimeoutMs: 2_000, + registry, + role: 'receiver', + spawn: { + args: ['--input-type=module', '--eval', script], + command: process.execPath, + cwd: CWD, + env: { ...process.env }, + }, + }); +} + +const lineProtocolPrelude = ` + import { createInterface } from 'node:readline'; + const prefix = ${JSON.stringify(GATE1_AGENT_EVENT_PREFIX)}; + const emit = (value) => process.stdout.write(prefix + JSON.stringify({ role: 'receiver', ...value }) + '\\n'); + emit({ event: 'ready', peerId: 'real-peer' }); + const lines = createInterface({ input: process.stdin }); +`; + +test('request correlation and graceful stop use exact process events', async () => { + const proc = child(`${lineProtocolPrelude} + lines.on('line', (line) => { + const command = JSON.parse(line); + if (command.command === 'probe') emit({ event: 'probed', requestId: command.requestId }); + if (command.command === 'stop') { + emit({ event: 'stopped', requestId: command.requestId }); + process.exit(0); + } + }); + `); + assert.equal((await proc.waitFor('ready')).peerId, 'real-peer'); + assert.equal((await proc.request('probe', 'probe-1', 'probed')).requestId, 'probe-1'); + assert.deepEqual(await proc.stop('stop-1'), { code: 0, signal: null }); +}); + +test('killRestart waits for the child boundary and proves an actual SIGKILL close', async () => { + const proc = child(`${lineProtocolPrelude} + lines.on('line', (line) => { + const command = JSON.parse(line); + if (command.command === 'killRestart') { + emit({ event: 'kill-restart-ready', requestId: command.requestId }); + } + }); + `); + await proc.waitFor('ready'); + assert.deepEqual(await proc.killRestartBoundary('kill-1'), { code: null, signal: 'SIGKILL' }); +}); + +test('a process close rejects an outstanding request with captured diagnostics', async () => { + const proc = child(`${lineProtocolPrelude} + process.stderr.write('diagnostic-before-close\\n'); + lines.on('line', () => process.exit(23)); + `); + await proc.waitFor('ready'); + await assert.rejects( + proc.request('never', 'never-1', 'never-completes'), + /closed before its expected event[\s\S]*diagnostic-before-close/, + ); +}); + +test('adapter error events reject the correlated request without waiting for timeout', async () => { + const registry = new ChildProcessRegistry(5_000); + const proc = child(`${lineProtocolPrelude} + lines.on('line', (line) => { + const command = JSON.parse(line); + emit({ event: 'error', requestId: command.requestId, message: 'missing product method' }); + }); + `, registry); + await proc.waitFor('ready'); + await assert.rejects( + proc.request('publishSuccessor', 'successor-1', 'operation-completed'), + /missing product method/, + ); + await registry.terminateAndWait(proc.tracked, 'SIGKILL'); +}); diff --git a/devnet/rfc64-gate1-public-open/agent-child.ts b/devnet/rfc64-gate1-public-open/agent-child.ts new file mode 100644 index 0000000000..13df9a6e56 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/agent-child.ts @@ -0,0 +1,210 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; + +import { + ChildProcessRegistry, + type ProcessExitEvidence, + type TrackedChildProcess, +} from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { GATE1_AGENT_EVENT_PREFIX } from './model.js'; + +const DEFAULT_EVENT_TIMEOUT_MS = 45_000; +const MAX_CAPTURE_BYTES = 256 * 1024; +const MAX_EVENT_LINE_BYTES = 1_000_000; + +export interface Gate1AgentEvent { + readonly event: string; + readonly requestId?: string; + readonly role: 'author' | 'receiver'; + readonly [key: string]: unknown; +} + +export interface Gate1AgentSpawnSpec { + readonly args: readonly string[]; + readonly command: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; +} + +export interface Gate1AgentChildOptions { + readonly eventTimeoutMs?: number; + readonly registry: ChildProcessRegistry; + readonly role: 'author' | 'receiver'; + readonly spawn: Gate1AgentSpawnSpec; +} + +interface PendingEvent { + readonly expectedEvent: string; + readonly reject: (error: Error) => void; + readonly requestId: string | null; + readonly resolve: (event: Gate1AgentEvent) => void; + readonly timer: NodeJS.Timeout; +} + +export class Gate1AgentChild { + readonly child: ChildProcessWithoutNullStreams; + readonly tracked: TrackedChildProcess; + readonly #eventTimeoutMs: number; + readonly #events: Gate1AgentEvent[] = []; + readonly #pending = new Set(); + #stderr = ''; + #stdout = ''; + #lineBuffer = ''; + + constructor(readonly options: Gate1AgentChildOptions) { + this.#eventTimeoutMs = options.eventTimeoutMs ?? DEFAULT_EVENT_TIMEOUT_MS; + if (!Number.isSafeInteger(this.#eventTimeoutMs) || this.#eventTimeoutMs < 1) { + throw new TypeError('eventTimeoutMs must be a positive safe integer'); + } + this.child = spawn(options.spawn.command, [...options.spawn.args], { + cwd: options.spawn.cwd, + env: options.spawn.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.tracked = options.registry.track(this.child); + this.child.stdout.setEncoding('utf8'); + this.child.stderr.setEncoding('utf8'); + this.child.stdout.on('data', (chunk: string) => this.consumeStdout(chunk)); + this.child.stderr.on('data', (chunk: string) => { + this.#stderr = appendBounded(this.#stderr, chunk); + }); + this.child.once('error', (error) => this.rejectAll(error)); + void this.tracked.closed.then((exit) => { + if (this.#pending.size === 0) return; + this.rejectAll(new Error( + `${this.options.role} DKGAgent closed before its expected event: ${JSON.stringify(exit)}\n` + + `stdout tail:\n${this.#stdout}\nstderr tail:\n${this.#stderr}`, + )); + }); + } + + get role(): 'author' | 'receiver' { + return this.options.role; + } + + waitFor(expectedEvent: string, requestId: string | null = null): Promise { + const existing = this.#events.find((event) => + event.event === expectedEvent && (requestId === null || event.requestId === requestId)); + if (existing !== undefined) return Promise.resolve(existing); + return new Promise((resolveEvent, rejectEvent) => { + const timer = setTimeout(() => { + this.#pending.delete(pending); + rejectEvent(new Error( + `${this.role} DKGAgent timed out waiting for ${expectedEvent}/${requestId ?? '*'}\n` + + `stdout tail:\n${this.#stdout}\nstderr tail:\n${this.#stderr}`, + )); + }, this.#eventTimeoutMs); + const pending: PendingEvent = { + expectedEvent, + reject: rejectEvent, + requestId, + resolve: resolveEvent, + timer, + }; + this.#pending.add(pending); + }); + } + + async request( + command: string, + requestId: string, + expectedEvent: string, + input?: unknown, + ): Promise { + const payload = input === undefined + ? { command, requestId } + : { command, input, requestId }; + const line = `${JSON.stringify(payload)}\n`; + if (Buffer.byteLength(line) > MAX_EVENT_LINE_BYTES) { + throw new Error('Gate 1 adapter command exceeds the 1 MiB process-protocol bound'); + } + const event = this.waitFor(expectedEvent, requestId); + try { + await new Promise((resolveWrite, rejectWrite) => { + this.child.stdin.write(line, (error) => { + if (error === null || error === undefined) resolveWrite(); + else rejectWrite(error); + }); + }); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + this.rejectAll(failure); + await event.catch(() => undefined); + throw failure; + } + return event; + } + + async stop(requestId: string): Promise { + if (this.child.exitCode !== null || this.child.signalCode !== null) { + return this.tracked.closed; + } + await this.request('stop', requestId, 'stopped'); + const exit = await this.tracked.closed; + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`${this.role} DKGAgent did not stop cleanly: ${JSON.stringify(exit)}`); + } + return exit; + } + + async killRestartBoundary(requestId: string): Promise { + await this.request('killRestart', requestId, 'kill-restart-ready'); + const exit = await this.options.registry.terminateAndWait(this.tracked, 'SIGKILL'); + if (exit.code !== null || exit.signal !== 'SIGKILL') { + throw new Error(`${this.role} crash boundary was not SIGKILL: ${JSON.stringify(exit)}`); + } + return exit; + } + + private consumeStdout(chunk: string): void { + this.#stdout = appendBounded(this.#stdout, chunk); + this.#lineBuffer += chunk; + if (Buffer.byteLength(this.#lineBuffer) > MAX_EVENT_LINE_BYTES) { + this.rejectAll(new Error(`${this.role} emitted an overlong unterminated event line`)); + this.#lineBuffer = ''; + return; + } + const lines = this.#lineBuffer.split('\n'); + this.#lineBuffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.startsWith(GATE1_AGENT_EVENT_PREFIX)) continue; + let event: Gate1AgentEvent; + try { + event = JSON.parse(line.slice(GATE1_AGENT_EVENT_PREFIX.length)) as Gate1AgentEvent; + } catch (error) { + this.rejectAll(new Error(`invalid Gate 1 adapter event JSON: ${String(error)}`)); + continue; + } + this.#events.push(event); + for (const pending of this.#pending) { + if ( + event.event !== pending.expectedEvent + || (pending.requestId !== null && event.requestId !== pending.requestId) + ) continue; + clearTimeout(pending.timer); + this.#pending.delete(pending); + pending.resolve(event); + } + if (event.event === 'error' || event.event === 'boot-failed') { + this.rejectAll(new Error( + `${this.role} DKGAgent ${event.event} for ${event.requestId ?? 'startup'}: ` + + `${String(event.message)}`, + )); + } + } + } + + private rejectAll(error: Error): void { + for (const pending of this.#pending) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.#pending.clear(); + } +} + +function appendBounded(previous: string, chunk: string): string { + const combined = previous + chunk; + if (Buffer.byteLength(combined) <= MAX_CAPTURE_BYTES) return combined; + const tail = Buffer.from(combined).subarray(-MAX_CAPTURE_BYTES).toString('utf8'); + return `[earlier output truncated]\n${tail}`; +} diff --git a/devnet/rfc64-gate1-public-open/model.test.ts b/devnet/rfc64-gate1-public-open/model.test.ts index a414156efc..847f469fd8 100644 --- a/devnet/rfc64-gate1-public-open/model.test.ts +++ b/devnet/rfc64-gate1-public-open/model.test.ts @@ -2,33 +2,55 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - GATE1_FIXTURE, - assertFixtureDerivations, - expectedAppliedReadBack, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + appliedReadBackFromTransfer, + semanticReadBackFromTransfer, + type Gate1TransferEvidence, } from './model.js'; -test('pinned Gate 1 fixture digests derive from exact deterministic bytes', () => { - assert.doesNotThrow(() => assertFixtureDerivations()); - assert.equal( - GATE1_FIXTURE.projectionNQuads.trimEnd().split('\n').length, - GATE1_FIXTURE.positive.activatedQuadCount, - ); - assert.equal( - Buffer.byteLength(GATE1_FIXTURE.projectionNQuads), - GATE1_FIXTURE.positive.contentByteLength, - ); +const transfer: Gate1TransferEvidence = { + activatedQuadCount: 2, + authorAddress: `0x${'11'.repeat(20)}`, + bundleByteLength: 300, + bundleDigest: `0x${'22'.repeat(32)}`, + catalogRowDigest: `0x${'33'.repeat(32)}`, + contentByteLength: 168, + contentDigest: `0x${'44'.repeat(32)}`, + head: { + appliedInventoryDigest: `0x${'55'.repeat(32)}`, + catalogHeadDigest: `0x${'66'.repeat(32)}`, + catalogVersion: '1', + previousCatalogHeadDigest: `0x${'77'.repeat(32)}`, + }, + inventoryRowCount: 1, + kaUal: `did:dkg:otp:20430/0x${'11'.repeat(20)}/7`, + swmGraph: `did:dkg:swm:0x${'88'.repeat(20)}/gate-1/0x${'11'.repeat(20)}/7`, +}; + +test('the six-operation adapter boundary remains exact and product-facing', () => { + assert.deepEqual(REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, [ + 'publishGenesis', + 'publishSuccessor', + 'announce', + 'appliedHeadReadback', + 'exactInventoryReadback', + 'killRestart', + ]); }); -test('durable applied readback is derived from the exact head and count', () => { - assert.deepEqual(expectedAppliedReadBack(GATE1_FIXTURE.positive), { - appliedInventoryDigest: GATE1_FIXTURE.positive.head.appliedInventoryDigest, + +test('applied and semantic readbacks are selected from runtime transfer evidence', () => { + assert.deepEqual(appliedReadBackFromTransfer(transfer), { + appliedInventoryDigest: transfer.head.appliedInventoryDigest, catalogVersion: '1', - currentCatalogHeadDigest: GATE1_FIXTURE.positive.head.catalogHeadDigest, + currentCatalogHeadDigest: transfer.head.catalogHeadDigest, inventoryRowCount: 1, }); - assert.deepEqual(expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), { - appliedInventoryDigest: GATE1_FIXTURE.repairSuccessor.head.appliedInventoryDigest, - catalogVersion: '2', - currentCatalogHeadDigest: GATE1_FIXTURE.repairSuccessor.head.catalogHeadDigest, - inventoryRowCount: 1, + assert.deepEqual(semanticReadBackFromTransfer(transfer), { + activatedQuadCount: 2, + catalogHeadDigest: transfer.head.catalogHeadDigest, + catalogRowDigest: transfer.catalogRowDigest, + contentDigest: transfer.contentDigest, + kaUal: transfer.kaUal, + swmGraph: transfer.swmGraph, }); }); diff --git a/devnet/rfc64-gate1-public-open/model.ts b/devnet/rfc64-gate1-public-open/model.ts index 3442648af9..a021ca20fb 100644 --- a/devnet/rfc64-gate1-public-open/model.ts +++ b/devnet/rfc64-gate1-public-open/model.ts @@ -1,9 +1,13 @@ -import { createHash } from 'node:crypto'; - export const GATE1_RAW_SCHEMA_VERSION = 'dkg-rfc64-gate1-public-open-evidence-v1'; export const GATE1_VERDICT_SCHEMA_VERSION = 'dkg-rfc64-gate1-public-open-verdict-v1'; export const GATE1_ADAPTER_PROTOCOL_VERSION = 'dkg-rfc64-gate1-adapter-protocol-v1'; -export const GATE1_FIXTURE_ADAPTER_ID = 'deterministic-fixture-adapter-v1'; +export const GATE1_REAL_DKG_AGENT_ADAPTER_ID = 'real-dkg-agent-v1'; +export const GATE1_AGENT_EVENT_PREFIX = 'RFC64_GATE1_ADAPTER_EVENT '; + +/** + * This is a frozen harness boundary. Product method names may differ, but the + * process adapter must expose exactly these operations to the orchestrator. + */ export const REQUIRED_PRODUCTION_ADAPTER_OPERATIONS = Object.freeze([ 'publishGenesis', 'publishSuccessor', @@ -13,37 +17,33 @@ export const REQUIRED_PRODUCTION_ADAPTER_OPERATIONS = Object.freeze([ 'killRestart', ] as const); -export const INSPECTED_PRODUCT_COMMITS = Object.freeze([ - '6c14bd4ad15b79cc889d0308dd1d1cac60467747', - 'ebbfb34f9bd0a0833ee5adb925cba67c527c91a8', -] as const); +export type Gate1ProductionAdapterOperation = + (typeof REQUIRED_PRODUCTION_ADAPTER_OPERATIONS)[number]; -const AUTHOR_ADDRESS = '0xdb2430b4e9ac14be6554d3942822be74811a1af9'; -const ATTACKER_ADDRESS = '0xae72a48c1a36bd18af168541c53037965d26e4a8'; -const CONTENT_DIGEST = '0xb9621d6cd997ab772d2efc3aa2afa2bcdacc46c74359bfb282c058fa46bb431a'; -const BUNDLE_DIGEST = '0x14d4274f8eddb559e4a66ea245f697aba41a4263ea049b77e0b3c88adc936c7a'; -const CATALOG_ROW_DIGEST = '0x6420e45a533a22aebf4628a123cfed9ee11f033c239af875c07aa05231547638'; -const PREDECESSOR_HEAD_DIGEST = - '0x2ab4b76154241174ba4dc08b4ca38094b6f996518662707bdec599f8de87be71'; -const POSITIVE_HEAD_DIGEST = - '0xe761f8c9ce23a199803881221a8bac6ded18a2f366e0f22a7599adfe76eb8838'; -const POSITIVE_INVENTORY_DIGEST = - '0x0a3f2f203019eb21778e270436da1ea01d70b5c2fce0ed811679cd2ffe82c980'; -const REPAIR_HEAD_DIGEST = - '0x26423bc0d28c8d49b5f7c59bf3e8563c23b55c31ba28d3aa745bc1a885de4761'; -const REPAIR_INVENTORY_DIGEST = - '0xd256bbc0577d7c0bb7f6cb4a2e4d29ff552cacee08f0c5ca0e073d0111e60fc2'; -const FORGED_HEAD_DIGEST = - '0xa0b122ac45e5ddeda48ca2aa54e1c02279af5156d93b63b0120d76a815bee2a7'; +export interface Gate1AppliedHeadReadBack { + readonly appliedInventoryDigest: string; + readonly catalogVersion: string; + readonly currentCatalogHeadDigest: string; + readonly inventoryRowCount: number; +} + +export interface Gate1SemanticReadBack { + readonly activatedQuadCount: number; + readonly catalogHeadDigest: string; + readonly catalogRowDigest: string; + readonly contentDigest: string; + readonly kaUal: string; + readonly swmGraph: string; +} -export interface Gate1HeadFixture { +export interface Gate1HeadEvidence { readonly appliedInventoryDigest: string; readonly catalogHeadDigest: string; readonly catalogVersion: string; readonly previousCatalogHeadDigest: string; } -export interface Gate1TransferFixture { +export interface Gate1TransferEvidence { readonly activatedQuadCount: number; readonly authorAddress: string; readonly bundleByteLength: number; @@ -51,148 +51,39 @@ export interface Gate1TransferFixture { readonly catalogRowDigest: string; readonly contentByteLength: number; readonly contentDigest: string; - readonly head: Gate1HeadFixture; + readonly head: Gate1HeadEvidence; readonly inventoryRowCount: number; readonly kaUal: string; readonly swmGraph: string; } -export interface Gate1ForgedFixture { +export interface Gate1ForgedEvidence { readonly attemptedCatalogHeadDigest: string; readonly catalogAuthorAddress: string; readonly expectedFailureCode: string; readonly recoveredAuthorAddress: string; } -export const GATE1_FIXTURE = Object.freeze({ - authorPeerId: 'fixture-peer-author-v1', - receiverPeerId: 'fixture-peer-receiver-v1', - projectionNQuads: - ' "42"^^ .\n' - + ' "Alice" .\n', - positive: Object.freeze({ - activatedQuadCount: 2, - authorAddress: AUTHOR_ADDRESS, - bundleByteLength: 203, - bundleDigest: BUNDLE_DIGEST, - catalogRowDigest: CATALOG_ROW_DIGEST, - contentByteLength: 168, - contentDigest: CONTENT_DIGEST, - head: Object.freeze({ - appliedInventoryDigest: POSITIVE_INVENTORY_DIGEST, - catalogHeadDigest: POSITIVE_HEAD_DIGEST, - catalogVersion: '1', - previousCatalogHeadDigest: PREDECESSOR_HEAD_DIGEST, - }), - inventoryRowCount: 1, - kaUal: `did:dkg:otp:20430/${AUTHOR_ADDRESS}/7`, - swmGraph: `did:dkg:swm:0x1111111111111111111111111111111111111111/gate-1/${AUTHOR_ADDRESS}/7`, - }) satisfies Gate1TransferFixture, - repairSuccessor: Object.freeze({ - activatedQuadCount: 2, - authorAddress: AUTHOR_ADDRESS, - bundleByteLength: 203, - bundleDigest: BUNDLE_DIGEST, - catalogRowDigest: CATALOG_ROW_DIGEST, - contentByteLength: 168, - contentDigest: CONTENT_DIGEST, - head: Object.freeze({ - appliedInventoryDigest: REPAIR_INVENTORY_DIGEST, - catalogHeadDigest: REPAIR_HEAD_DIGEST, - catalogVersion: '2', - previousCatalogHeadDigest: POSITIVE_HEAD_DIGEST, - }), - inventoryRowCount: 1, - kaUal: `did:dkg:otp:20430/${AUTHOR_ADDRESS}/7`, - swmGraph: `did:dkg:swm:0x1111111111111111111111111111111111111111/gate-1/${AUTHOR_ADDRESS}/7`, - }) satisfies Gate1TransferFixture, - forged: Object.freeze({ - attemptedCatalogHeadDigest: FORGED_HEAD_DIGEST, - catalogAuthorAddress: AUTHOR_ADDRESS, - expectedFailureCode: 'catalog-native-receiver-transfer', - recoveredAuthorAddress: ATTACKER_ADDRESS, - }) satisfies Gate1ForgedFixture, -}); - -export function expectedAppliedReadBack(fixture: Gate1TransferFixture): Readonly<{ - appliedInventoryDigest: string; - catalogVersion: string; - currentCatalogHeadDigest: string; - inventoryRowCount: number; -}> { +export function appliedReadBackFromTransfer( + transfer: Gate1TransferEvidence, +): Readonly { return Object.freeze({ - appliedInventoryDigest: fixture.head.appliedInventoryDigest, - catalogVersion: fixture.head.catalogVersion, - currentCatalogHeadDigest: fixture.head.catalogHeadDigest, - inventoryRowCount: fixture.inventoryRowCount, + appliedInventoryDigest: transfer.head.appliedInventoryDigest, + catalogVersion: transfer.head.catalogVersion, + currentCatalogHeadDigest: transfer.head.catalogHeadDigest, + inventoryRowCount: transfer.inventoryRowCount, }); } -export function assertFixtureDerivations(): void { - const projection = GATE1_FIXTURE.projectionNQuads; - const bundle = `dkg-rfc64-opaque-bundle-fixture-v1\n${projection}`; - const row = JSON.stringify({ - assertionCoordinate: 'gate-1-object', - bundleDigest: BUNDLE_DIGEST, - contentDigest: CONTENT_DIGEST, - kaUal: GATE1_FIXTURE.positive.kaUal, - quadCount: 2, - }); - const positiveHead = JSON.stringify({ - catalogRowDigest: CATALOG_ROW_DIGEST, - catalogVersion: '1', - previousCatalogHeadDigest: PREDECESSOR_HEAD_DIGEST, - totalRows: '1', - }); - const repairHead = JSON.stringify({ - catalogRowDigest: CATALOG_ROW_DIGEST, - catalogVersion: '2', - previousCatalogHeadDigest: POSITIVE_HEAD_DIGEST, - totalRows: '1', - }); - const forgedHead = JSON.stringify({ - catalogAuthorAddress: AUTHOR_ADDRESS, - catalogRowDigest: CATALOG_ROW_DIGEST, - recoveredAuthorAddress: ATTACKER_ADDRESS, +export function semanticReadBackFromTransfer( + transfer: Gate1TransferEvidence, +): Readonly { + return Object.freeze({ + activatedQuadCount: transfer.activatedQuadCount, + catalogHeadDigest: transfer.head.catalogHeadDigest, + catalogRowDigest: transfer.catalogRowDigest, + contentDigest: transfer.contentDigest, + kaUal: transfer.kaUal, + swmGraph: transfer.swmGraph, }); - requireDerived(sha256(projection), CONTENT_DIGEST, 'content digest'); - requireDerived(Buffer.byteLength(projection), 168, 'content byte length'); - requireDerived(sha256(bundle), BUNDLE_DIGEST, 'bundle digest'); - requireDerived(Buffer.byteLength(bundle), 203, 'bundle byte length'); - requireDerived(sha256(`${row}\n`), CATALOG_ROW_DIGEST, 'catalog row digest'); - requireDerived( - sha256('dkg-rfc64-gate1-predecessor-v1\n'), - PREDECESSOR_HEAD_DIGEST, - 'predecessor head digest', - ); - requireDerived(sha256(`${positiveHead}\n`), POSITIVE_HEAD_DIGEST, 'positive head digest'); - requireDerived(sha256(`${repairHead}\n`), REPAIR_HEAD_DIGEST, 'repair head digest'); - requireDerived(sha256(`${forgedHead}\n`), FORGED_HEAD_DIGEST, 'forged head digest'); - requireDerived( - inventoryDigest(POSITIVE_HEAD_DIGEST), - POSITIVE_INVENTORY_DIGEST, - 'positive inventory digest', - ); - requireDerived( - inventoryDigest(REPAIR_HEAD_DIGEST), - REPAIR_INVENTORY_DIGEST, - 'repair inventory digest', - ); -} - -function inventoryDigest(headDigest: string): string { - return sha256( - `dkg-rfc64-applied-inventory-v1\n${headDigest}\n${CATALOG_ROW_DIGEST}\n` - + `${CONTENT_DIGEST}\n2\n`, - ); -} - -function sha256(value: string): string { - return `0x${createHash('sha256').update(value, 'utf8').digest('hex')}`; -} - -function requireDerived(actual: string | number, expected: string | number, label: string): void { - if (actual !== expected) { - throw new Error(`RFC-64 Gate 1 fixture ${label} changed: ${actual} != ${expected}`); - } } diff --git a/devnet/rfc64-gate1-public-open/package.json b/devnet/rfc64-gate1-public-open/package.json index 09ef834852..0cb9a7ff16 100644 --- a/devnet/rfc64-gate1-public-open/package.json +++ b/devnet/rfc64-gate1-public-open/package.json @@ -2,5 +2,11 @@ "name": "@origintrail-official/dkg-devnet-rfc64-gate1-public-open", "version": "0.0.0", "private": true, - "type": "module" + "type": "module", + "dependencies": { + "@multiformats/multiaddr": "^13.0.3", + "@origintrail-official/dkg-agent": "workspace:*", + "@origintrail-official/dkg-storage": "workspace:*", + "ethers": "^6.16.0" + } } diff --git a/devnet/rfc64-gate1-public-open/product-capabilities.test.ts b/devnet/rfc64-gate1-public-open/product-capabilities.test.ts new file mode 100644 index 0000000000..b8ab1033fa --- /dev/null +++ b/devnet/rfc64-gate1-public-open/product-capabilities.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + assertGate1ProductCapabilities, + inspectGate1ProductCapabilities, + requireGate1ProductMethod, +} from './product-capabilities.js'; + +function productSurface(overrides: Record = {}): object { + return { + publishOpenAuthorCatalogGenesisV1: async () => undefined, + publishOpenAuthorCatalogSuccessorV1: async () => undefined, + announceRfc64PublicCatalogHeadV1: async () => undefined, + readRfc64AppliedCatalogHeadV1: async () => undefined, + readRfc64PublicCatalogSynchronizationEvidenceV1: async () => undefined, + ...overrides, + }; +} + +test('maps the six frozen operations to the intended product and harness capabilities', () => { + assert.deepEqual(inspectGate1ProductCapabilities(productSurface()), { + announce: true, + appliedHeadReadback: true, + exactInventoryReadback: true, + killRestart: true, + publishGenesis: true, + publishSuccessor: true, + }); +}); + +test('fails closed with the exact missing product method names', () => { + const incomplete = inspectGate1ProductCapabilities({ + publishOpenAuthorCatalogGenesisV1: async () => undefined, + }); + assert.throws( + () => assertGate1ProductCapabilities({ author: incomplete, receiver: incomplete }), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /author\.publishSuccessor \(publishOpenAuthorCatalogSuccessorV1\)/); + assert.match(error.message, /author\.announce \(announceRfc64PublicCatalogHeadV1\)/); + assert.match(error.message, /receiver\.appliedHeadReadback \(readRfc64AppliedCatalogHeadV1\)/); + assert.match( + error.message, + /receiver\.exactInventoryReadback \(readRfc64PublicCatalogSynchronizationEvidenceV1\)/, + ); + assert.match(error.message, /will not fabricate product evidence/); + return true; + }, + ); +}); + +test('accepts complete role capability reports and binds real methods', async () => { + const surface = productSurface({ + readRfc64AppliedCatalogHeadV1: async (input: unknown) => ({ input }), + }); + const capabilities = inspectGate1ProductCapabilities(surface); + assert.doesNotThrow(() => assertGate1ProductCapabilities({ + author: capabilities, + receiver: capabilities, + })); + assert.deepEqual( + await requireGate1ProductMethod(surface, 'appliedHeadReadback')({ scope: 'scope' }), + { input: { scope: 'scope' } }, + ); +}); + +test('rejects capability records outside the frozen operation set', () => { + const capabilities = { + ...inspectGate1ProductCapabilities(productSurface()), + hiddenInterimMethod: true, + }; + assert.throws( + () => assertGate1ProductCapabilities({ author: capabilities, receiver: capabilities }), + /do not match the frozen operation set/, + ); +}); diff --git a/devnet/rfc64-gate1-public-open/product-capabilities.ts b/devnet/rfc64-gate1-public-open/product-capabilities.ts new file mode 100644 index 0000000000..2a65fb9000 --- /dev/null +++ b/devnet/rfc64-gate1-public-open/product-capabilities.ts @@ -0,0 +1,91 @@ +import { + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + type Gate1ProductionAdapterOperation, +} from './model.js'; + +export const PRODUCT_METHOD_BY_OPERATION = Object.freeze({ + publishGenesis: 'publishOpenAuthorCatalogGenesisV1', + publishSuccessor: 'publishOpenAuthorCatalogSuccessorV1', + announce: 'announceRfc64PublicCatalogHeadV1', + appliedHeadReadback: 'readRfc64AppliedCatalogHeadV1', + exactInventoryReadback: 'readRfc64PublicCatalogSynchronizationEvidenceV1', + // SIGKILL and process replacement are deliberately owned by the harness. + killRestart: null, +} satisfies Record); + +export type Gate1ProductCapabilities = Readonly< + Record +>; + +export function inspectGate1ProductCapabilities(agent: object): Gate1ProductCapabilities { + const surface = agent as Record; + return Object.freeze(Object.fromEntries( + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS.map((operation) => { + const method = PRODUCT_METHOD_BY_OPERATION[operation]; + return [operation, method === null || typeof surface[method] === 'function']; + }), + ) as unknown as Gate1ProductCapabilities); +} + +export function assertGate1ProductCapabilities(input: { + readonly author: unknown; + readonly receiver: unknown; +}): void { + const author = exactCapabilityRecord(input.author, 'author'); + const receiver = exactCapabilityRecord(input.receiver, 'receiver'); + const requiredByRole = { + author: ['publishGenesis', 'publishSuccessor', 'announce'], + receiver: ['appliedHeadReadback', 'exactInventoryReadback', 'killRestart'], + } as const satisfies Record<'author' | 'receiver', readonly Gate1ProductionAdapterOperation[]>; + const missing: string[] = []; + for (const [role, required] of Object.entries(requiredByRole) as Array< + readonly ['author' | 'receiver', readonly Gate1ProductionAdapterOperation[]] + >) { + const capabilities = role === 'author' ? author : receiver; + for (const operation of required) { + if (capabilities[operation]) continue; + const method = PRODUCT_METHOD_BY_OPERATION[operation]; + missing.push(`${role}.${operation}${method === null ? '' : ` (${method})`}`); + } + } + if (missing.length > 0) { + throw new Error( + 'RFC-64 Gate 1 production DKGAgent API is incomplete; missing ' + + `${missing.join(', ')}. The harness will not fabricate product evidence.`, + ); + } +} + +function exactCapabilityRecord(value: unknown, label: string): Gate1ProductCapabilities { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} Gate 1 capabilities must be an object`); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + const expected = [...REQUIRED_PRODUCTION_ADAPTER_OPERATIONS].sort(); + if (JSON.stringify(keys) !== JSON.stringify(expected)) { + throw new TypeError(`${label} Gate 1 capabilities do not match the frozen operation set`); + } + for (const operation of REQUIRED_PRODUCTION_ADAPTER_OPERATIONS) { + if (typeof record[operation] !== 'boolean') { + throw new TypeError(`${label}.${operation} capability must be boolean`); + } + } + return record as unknown as Gate1ProductCapabilities; +} + +export function requireGate1ProductMethod( + agent: object, + operation: Exclude, +): (input: unknown) => Promise { + const methodName = PRODUCT_METHOD_BY_OPERATION[operation]; + if (methodName === null) throw new Error(`${operation} is harness-owned`); + const method = (agent as Record)[methodName]; + if (typeof method !== 'function') { + throw new Error( + `RFC-64 Gate 1 operation ${operation} requires DKGAgent.${methodName}; ` + + 'the harness will not fabricate its result', + ); + } + return method.bind(agent) as (input: unknown) => Promise; +} diff --git a/devnet/rfc64-gate1-public-open/run.ts b/devnet/rfc64-gate1-public-open/run.ts index bef3ba8236..bdbac3b54b 100644 --- a/devnet/rfc64-gate1-public-open/run.ts +++ b/devnet/rfc64-gate1-public-open/run.ts @@ -1,379 +1,109 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import process from 'node:process'; -import { - atomicWriteStableJson, - readCleanRepositoryHead, - stableJson, -} from '../rfc64-persistence-lifecycle/evidence.js'; +import { ethers } from 'ethers'; + +import { readCleanRepositoryHead } from '../rfc64-persistence-lifecycle/evidence.js'; import { ChildProcessRegistry, cleanupPreservingPrimaryFailure, - type ProcessExitEvidence, - type TrackedChildProcess, } from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { Gate1AgentChild, type Gate1AgentEvent } from './agent-child.js'; import { GATE1_ADAPTER_PROTOCOL_VERSION, - GATE1_FIXTURE, - GATE1_FIXTURE_ADAPTER_ID, - GATE1_RAW_SCHEMA_VERSION, - INSPECTED_PRODUCT_COMMITS, - REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, - assertFixtureDerivations, - expectedAppliedReadBack, - type Gate1TransferFixture, + GATE1_REAL_DKG_AGENT_ADAPTER_ID, } from './model.js'; +import { assertGate1ProductCapabilities } from './product-capabilities.js'; -const EVENT_PREFIX = 'RFC64_GATE1_ADAPTER_EVENT '; const REPO_ROOT = resolve(import.meta.dirname, '../..'); const ADAPTER_PROCESS = join(import.meta.dirname, 'adapter-process.ts'); -const DEFAULT_ARTIFACT = join(import.meta.dirname, 'artifacts/gate1-result.json'); -const PROCESS_TIMEOUT_MS = 30_000; -const children = new ChildProcessRegistry(15_000); - -interface AdapterEvent { - readonly event: string; - readonly requestId?: string; - readonly role: 'author' | 'receiver'; - readonly [key: string]: unknown; -} - -interface PendingEvent { - readonly expectedEvent: string; - readonly reject: (error: Error) => void; - readonly requestId: string | null; - readonly resolve: (event: AdapterEvent) => void; - readonly timer: NodeJS.Timeout; -} - -class AdapterChild { - readonly child: ChildProcessWithoutNullStreams; - readonly tracked: TrackedChildProcess; - readonly #events: AdapterEvent[] = []; - readonly #pending = new Set(); - #stderr = ''; - #stdout = ''; - #lineBuffer = ''; - - constructor( - readonly role: 'author' | 'receiver', - dataDir: string, - ) { - this.child = spawn( - process.execPath, - ['--import', 'tsx', ADAPTER_PROCESS, role], - { - cwd: REPO_ROOT, - env: { - ...process.env, - DKG_RFC64_GATE1_ADAPTER_DATA_DIR: dataDir, - NODE_ENV: 'production', - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ); - this.tracked = children.track(this.child); - this.child.stdout.setEncoding('utf8'); - this.child.stderr.setEncoding('utf8'); - this.child.stdout.on('data', (chunk: string) => this.consumeStdout(chunk)); - this.child.stderr.on('data', (chunk: string) => { this.#stderr += chunk; }); - this.child.once('error', (error) => this.rejectAll(error)); - void this.tracked.closed.then((exit) => { - if (this.#pending.size === 0) return; - this.rejectAll(new Error( - `${this.role} adapter closed before its expected event: ${JSON.stringify(exit)}\n` - + `stdout:\n${this.#stdout}\nstderr:\n${this.#stderr}`, - )); - }); - } - - waitFor(expectedEvent: string, requestId: string | null = null): Promise { - const existing = this.#events.find((event) => - event.event === expectedEvent && (requestId === null || event.requestId === requestId)); - if (existing !== undefined) return Promise.resolve(existing); - return new Promise((resolveEvent, rejectEvent) => { - const timer = setTimeout(() => { - this.#pending.delete(pending); - rejectEvent(new Error( - `${this.role} adapter timed out waiting for ${expectedEvent}/${requestId ?? '*'}\n` - + `stdout:\n${this.#stdout}\nstderr:\n${this.#stderr}`, - )); - }, PROCESS_TIMEOUT_MS); - const pending: PendingEvent = { - expectedEvent, - reject: rejectEvent, - requestId, - resolve: resolveEvent, - timer, - }; - this.#pending.add(pending); - }); - } - - async request( - command: string, - requestId: string, - expectedEvent: string, - fixture?: unknown, - ): Promise { - const event = this.waitFor(expectedEvent, requestId); - const payload = fixture === undefined - ? { command, requestId } - : { command, fixture, requestId }; - await new Promise((resolveWrite, rejectWrite) => { - this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => { - if (error === null || error === undefined) resolveWrite(); - else rejectWrite(error); - }); - }); - return event; - } - - async stop(requestId: string): Promise { - const stopped = this.request('stop', requestId, 'stopped'); - await stopped; - const exit = await this.tracked.closed; - requireCondition(exit.code === 0 && exit.signal === null, `${this.role} did not stop cleanly`); - return exit; - } - - private consumeStdout(chunk: string): void { - this.#stdout += chunk; - this.#lineBuffer += chunk; - const lines = this.#lineBuffer.split('\n'); - this.#lineBuffer = lines.pop() ?? ''; - for (const line of lines) { - if (!line.startsWith(EVENT_PREFIX)) continue; - let event: AdapterEvent; - try { - event = JSON.parse(line.slice(EVENT_PREFIX.length)) as AdapterEvent; - } catch (error) { - this.rejectAll(new Error(`invalid adapter event JSON: ${String(error)}`)); - continue; - } - this.#events.push(event); - for (const pending of this.#pending) { - if ( - event.event !== pending.expectedEvent - || (pending.requestId !== null && event.requestId !== pending.requestId) - ) continue; - clearTimeout(pending.timer); - this.#pending.delete(pending); - pending.resolve(event); - } - if (event.event === 'error') { - this.rejectAll(new Error( - `${this.role} adapter error for ${event.requestId ?? 'unknown request'}: ` - + `${String(event.message)}`, - )); - } - } - } - - private rejectAll(error: Error): void { - for (const pending of this.#pending) { - clearTimeout(pending.timer); - pending.reject(error); - } - this.#pending.clear(); - } -} - -function exactEventValue(event: AdapterEvent, key: string, expected: unknown): unknown { - const actual = event[key]; - requireCondition( - stableJson(actual) === stableJson(expected), - `${event.role}/${event.event}.${key} differed from the pinned evidence contract`, - ); - return actual; -} - -function selectReady(event: AdapterEvent): Record { - return { - adapterId: event.adapterId, - peerId: event.peerId, - protocolVersion: event.protocolVersion, - role: event.role, - startupRepair: event.startupRepair, - }; -} +const DEFAULT_RAW_ARTIFACT = join(import.meta.dirname, 'artifacts/gate1-result.json'); +const DEFAULT_VERDICT_ARTIFACT = join(import.meta.dirname, 'artifacts/gate1-verdict.json'); +const PROCESS_TIMEOUT_MS = 60_000; + +const NETWORK_ID = 'otp:20430'; +const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/gate-1'; +const AUTHOR_PRIVATE_KEY = `0x${'64'.repeat(32)}`; +const AUTHOR_ADDRESS = new ethers.Wallet(AUTHOR_PRIVATE_KEY).address.toLowerCase(); +const ROLE_MASTER_KEYS = Object.freeze({ + author: '1a'.repeat(32), + receiver: '2b'.repeat(32), +}); async function execute(): Promise { - assertFixtureDerivations(); const headBefore = readCleanRepositoryHead(REPO_ROOT); + const rawArtifactPath = process.env.DKG_RFC64_GATE1_ARTIFACT ?? DEFAULT_RAW_ARTIFACT; + const verdictArtifactPath = process.env.DKG_RFC64_GATE1_VERDICT_ARTIFACT + ?? DEFAULT_VERDICT_ARTIFACT; + // A failed production exercise must never leave a fixture-era PASS available + // for a later standalone verifier invocation. + rmSync(rawArtifactPath, { force: true }); + rmSync(verdictArtifactPath, { force: true }); + const authorDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate1-author-')); const receiverDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate1-receiver-')); + const children = new ChildProcessRegistry(20_000); let operationFailed = true; let primaryFailure: unknown; try { - const author = new AdapterChild('author', authorDataDir); - const receiver = new AdapterChild('receiver', receiverDataDir); + const author = spawnAgent('author', authorDataDir, children); + const receiver = spawnAgent('receiver', receiverDataDir, children); const [authorReady, receiverReady] = await Promise.all([ author.waitFor('ready'), receiver.waitFor('ready'), ]); - requireReady(authorReady, GATE1_FIXTURE.authorPeerId, null); - requireReady(receiverReady, GATE1_FIXTURE.receiverPeerId, null); + requireRealReady(authorReady, 'author'); + requireRealReady(receiverReady, 'receiver'); requireCondition(authorReady.peerId !== receiverReady.peerId, 'peer identities are not distinct'); - const forgedServed = await author.request( - 'serve-forged', - 'forged-served-v1', - 'forged-served', - ); - const forgedFixture = exactEventValue(forgedServed, 'fixture', GATE1_FIXTURE.forged); - const forgedRejected = await receiver.request( - 'attempt-forged', - 'forged-rejected-v1', - 'forged-author-rejected', - forgedFixture, - ); - - const positiveServed = await author.request( - 'serve-positive', - 'positive-served-v1', - 'positive-served', - ); - const positiveFixture = exactEventValue(positiveServed, 'fixture', GATE1_FIXTURE.positive); - const positiveActivated = await receiver.request( - 'activate-positive', - 'positive-activated-v1', - 'positive-activated', - positiveFixture, - ); + await Promise.all([ + receiver.request('dial', 'receiver-dial-author-v1', 'dialed', { + multiaddr: authorReady.multiaddr, + peerId: authorReady.peerId, + }), + author.request('dial', 'author-dial-receiver-v1', 'dialed', { + multiaddr: receiverReady.multiaddr, + peerId: receiverReady.peerId, + }), + ]); - const repairServed = await author.request( - 'serve-repair-successor', - 'repair-served-v1', - 'repair-successor-served', - ); - const repairFixture = exactEventValue( - repairServed, - 'fixture', - GATE1_FIXTURE.repairSuccessor, - ); - const repairGap = await receiver.request( - 'prepare-repair-crash', - 'repair-gap-v1', - 'repair-gap-durable', - repairFixture, - ); - const receiverCrashExit = await children.terminateAndWait(receiver.tracked, 'SIGKILL'); + // Both sides derive the accepted open policy from independently supplied + // scenario identity facts; no announcement is trusted as an authorization oracle. + const [authorPolicy, receiverPolicy] = await Promise.all([ + author.request('acceptOpenPolicy', 'author-policy-v1', 'operation-completed', { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR_ADDRESS, + }), + receiver.request('acceptOpenPolicy', 'receiver-policy-v1', 'operation-completed', { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR_ADDRESS, + }), + ]); + const authorPolicyDigest = requiredOutputString(authorPolicy, 'policyDigest'); + const receiverPolicyDigest = requiredOutputString(receiverPolicy, 'policyDigest'); requireCondition( - receiverCrashExit.code === null && receiverCrashExit.signal === 'SIGKILL', - 'receiver crash boundary was not SIGKILL', - ); - - const restartedReceiver = new AdapterChild('receiver', receiverDataDir); - const restartedReady = await restartedReceiver.waitFor('ready'); - const expectedRepair = { - action: 'advanced-applied-head-from-durable-intent', - after: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), - before: expectedAppliedReadBack(GATE1_FIXTURE.positive), - repaired: true, - semanticPostRead: semanticEvidence(GATE1_FIXTURE.repairSuccessor), - }; - requireReady(restartedReady, GATE1_FIXTURE.receiverPeerId, expectedRepair); - const repairReadBack = await restartedReceiver.request( - 'read-repaired', - 'repair-read-back-v1', - 'repair-read-back', + authorPolicyDigest === receiverPolicyDigest, + 'author and receiver derived different open-policy digests', ); - const restartedReceiverExit = await restartedReceiver.stop('receiver-stop-v1'); - const authorExit = await author.stop('author-stop-v1'); - const headAfter = readCleanRepositoryHead(REPO_ROOT); - requireCondition(headAfter === headBefore, 'tracked source commit changed during Gate 1 run'); - - const artifact = { - adapter: { - id: GATE1_FIXTURE_ADAPTER_ID, - inspectedProductCommits: INSPECTED_PRODUCT_COMMITS, - productBoundary: 'not-connected', - protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, - requiredProductionOperations: REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, - replacementContract: - 'replace adapter-process commands with production DKGAgent operations without changing evidence schema', - }, - fixture: { - forged: GATE1_FIXTURE.forged, - positive: GATE1_FIXTURE.positive, - repairSuccessor: GATE1_FIXTURE.repairSuccessor, - }, - gate: 'OT-RFC-64 Gate 1 harness contract', - gateEvaluation: { - reason: - 'deterministic adapter proves orchestration and fail-closed evidence verification, not production Gate 1', - status: 'not-evaluated', - }, - harnessChecksPassed: true, - invocation: 'pnpm test:gate1:rfc64-public-open-harness', - phases: { - forgedAuthor: { - activationAfter: forgedRejected.activationAfter, - activationBefore: forgedRejected.activationBefore, - appliedHeadAfter: forgedRejected.appliedHeadAfter, - appliedHeadBefore: forgedRejected.appliedHeadBefore, - attemptedCatalogHeadDigest: forgedRejected.attemptedCatalogHeadDigest, - failureCode: forgedRejected.failureCode, - recoveredAuthorAddress: forgedRejected.recoveredAuthorAddress, - servedByPeerId: authorReady.peerId, - testedByPeerId: receiverReady.peerId, - }, - positiveSync: { - appliedReadBack: positiveActivated.appliedReadBack, - controlObjectsVerified: positiveActivated.controlObjectsVerified, - exact: positiveActivated.exact, - receivedByPeerId: receiverReady.peerId, - semanticPostRead: positiveActivated.semanticPostRead, - servedByPeerId: authorReady.peerId, - }, - restartRepair: { - crashExit: receiverCrashExit, - gap: { - appliedBeforeCrash: repairGap.appliedBeforeCrash, - repairIntentDurable: repairGap.repairIntentDurable, - semanticBeforeCrash: repairGap.semanticBeforeCrash, - target: repairGap.target, - }, - readBack: { - appliedReadBack: repairReadBack.appliedReadBack, - semanticPostRead: repairReadBack.semanticPostRead, - }, - restartedReady: selectReady(restartedReady), - successorServedByPeerId: authorReady.peerId, - }, - }, - processBoundary: { - authorInstances: 1, - model: 'two concurrent adapter peer processes plus one receiver restart', - receiverInstances: 2, - stoppedExits: { - author: authorExit, - restartedReceiver: restartedReceiverExit, - }, - }, - ready: { - author: selectReady(authorReady), - receiver: selectReady(receiverReady), - }, - repository: { - testedHeadCommit: headBefore, - trackedSourceCleanAfterProcesses: true, - trackedSourceCleanBeforeSpawn: true, - }, - schemaVersion: GATE1_RAW_SCHEMA_VERSION, - }; + assertGate1ProductCapabilities({ + author: authorReady.capabilities, + receiver: receiverReady.capabilities, + }); - const artifactPath = process.env.DKG_RFC64_GATE1_ARTIFACT ?? DEFAULT_ARTIFACT; - const publication = atomicWriteStableJson(artifactPath, artifact); - process.stdout.write( - `[rfc64-gate1-harness] wrote ${artifactPath} sha256=${publication.sha256}\n`, + // The exact successor/result mapping is intentionally not guessed on this + // base commit. A follow-up composition against the native-wiring commit + // replaces this closed failure with the six-operation scenario. Keeping the + // boundary fatal prevents a newly-added but shape-incompatible API from + // silently manufacturing an artifact. + throw new Error( + `RFC-64 Gate 1 production APIs are present at ${headBefore}, but the real successor ` + + 'result contract has not yet been composed into this harness; refusing to emit evidence', ); - operationFailed = false; } catch (error) { primaryFailure = error; } finally { @@ -393,27 +123,63 @@ async function execute(): Promise { } } -function semanticEvidence(fixture: Gate1TransferFixture): Record { - return { - activatedQuadCount: fixture.activatedQuadCount, - catalogHeadDigest: fixture.head.catalogHeadDigest, - catalogRowDigest: fixture.catalogRowDigest, - contentDigest: fixture.contentDigest, - kaUal: fixture.kaUal, - swmGraph: fixture.swmGraph, - }; +function spawnAgent( + role: 'author' | 'receiver', + dataDir: string, + registry: ChildProcessRegistry, +): Gate1AgentChild { + return new Gate1AgentChild({ + eventTimeoutMs: PROCESS_TIMEOUT_MS, + registry, + role, + spawn: { + command: process.execPath, + args: ['--import', 'tsx', ADAPTER_PROCESS, role], + cwd: REPO_ROOT, + env: { + ...process.env, + DKG_RFC64_GATE1_ADAPTER_DATA_DIR: dataDir, + DKG_RFC64_GATE1_AGENT_MASTER_KEY_HEX: ROLE_MASTER_KEYS[role], + NODE_ENV: 'production', + }, + }, + }); } -function requireReady(event: AdapterEvent, peerId: string, startupRepair: unknown): void { - requireCondition(event.role === (peerId === GATE1_FIXTURE.authorPeerId ? 'author' : 'receiver'), - 'ready role differs from peer identity'); - requireCondition(event.adapterId === GATE1_FIXTURE_ADAPTER_ID, 'adapter ID changed'); - requireCondition(event.protocolVersion === GATE1_ADAPTER_PROTOCOL_VERSION, 'protocol changed'); - requireCondition(event.peerId === peerId, 'peer ID changed'); +function requireRealReady(event: Gate1AgentEvent, expectedRole: 'author' | 'receiver'): void { + requireCondition(event.role === expectedRole, 'ready role differs from the spawned role'); + requireCondition( + event.adapterId === GATE1_REAL_DKG_AGENT_ADAPTER_ID, + 'adapter did not identify the real DKGAgent boundary', + ); requireCondition( - stableJson(event.startupRepair) === stableJson(startupRepair), - 'startup repair evidence changed', + event.protocolVersion === GATE1_ADAPTER_PROTOCOL_VERSION, + 'adapter protocol version changed', ); + requireCondition(event.agentClass === 'DKGAgent', 'child did not boot a real DKGAgent'); + requireCondition(event.catalogServiceStarted === true, 'production catalog service did not start'); + requireCondition(event.startupRepair === null, 'adapter claimed nonexistent automatic startup repair'); + requireCondition(typeof event.peerId === 'string' && event.peerId.length > 0, 'peer ID is missing'); + requireCondition( + typeof event.multiaddr === 'string' && event.multiaddr.includes('/tcp/'), + 'TCP multiaddr is missing', + ); + requireCondition( + event.capabilities !== null && typeof event.capabilities === 'object', + 'product capability report is missing', + ); +} + +function requiredOutputString(event: Gate1AgentEvent, key: string): string { + const output = event.output; + if (output === null || typeof output !== 'object' || Array.isArray(output)) { + throw new Error(`${event.role}/${event.event} output is not an object`); + } + const value = (output as Record)[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${event.role}/${event.event} output.${key} is missing`); + } + return value; } function requireCondition(condition: unknown, message: string): asserts condition { diff --git a/devnet/rfc64-gate1-public-open/tsconfig.json b/devnet/rfc64-gate1-public-open/tsconfig.json index 8ea62fb139..0f0cd4c285 100644 --- a/devnet/rfc64-gate1-public-open/tsconfig.json +++ b/devnet/rfc64-gate1-public-open/tsconfig.json @@ -9,9 +9,13 @@ "types": ["node"] }, "include": [ + "agent-child.test.ts", + "agent-child.ts", "adapter-process.ts", "model.test.ts", "model.ts", + "product-capabilities.test.ts", + "product-capabilities.ts", "run.ts", "verifier.test.ts", "verifier.ts", diff --git a/devnet/rfc64-gate1-public-open/verifier.test.ts b/devnet/rfc64-gate1-public-open/verifier.test.ts index 57cd4cd9a8..7350b9093d 100644 --- a/devnet/rfc64-gate1-public-open/verifier.test.ts +++ b/devnet/rfc64-gate1-public-open/verifier.test.ts @@ -4,188 +4,213 @@ import test from 'node:test'; import { stableJson } from '../rfc64-persistence-lifecycle/evidence.js'; import { GATE1_ADAPTER_PROTOCOL_VERSION, - GATE1_FIXTURE, - GATE1_FIXTURE_ADAPTER_ID, GATE1_RAW_SCHEMA_VERSION, - INSPECTED_PRODUCT_COMMITS, + GATE1_REAL_DKG_AGENT_ADAPTER_ID, REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, - expectedAppliedReadBack, - type Gate1TransferFixture, + appliedReadBackFromTransfer, + semanticReadBackFromTransfer, + type Gate1TransferEvidence, } from './model.js'; import { verifyGate1ArtifactBytes } from './verifier.js'; const HEAD = 'a'.repeat(40); +const AUTHOR = `0x${'11'.repeat(20)}`; +const ATTACKER = `0x${'aa'.repeat(20)}`; +const POSITIVE = transfer({ head: '6', previous: '7', version: '1' }); +const REPAIR = transfer({ head: '8', previous: '6', version: '2' }); -test('accepts the complete canonical deterministic harness evidence', () => { +test('accepts canonical production-shaped evidence with exact runtime continuity', () => { const result = verifyGate1ArtifactBytes(bytes(goldenArtifact()), HEAD); assert.equal(result.sourceCommit, HEAD); assert.match(result.rawArtifactSha256, /^0x[0-9a-f]{64}$/u); }); test('rejects non-canonical JSON before interpreting evidence', () => { - const text = JSON.stringify(goldenArtifact()); assert.throws( - () => verifyGate1ArtifactBytes(Buffer.from(text), HEAD), + () => verifyGate1ArtifactBytes(Buffer.from(JSON.stringify(goldenArtifact())), HEAD), /not exact canonical stable JSON/, ); }); +test('rejects fixture-era not-connected and not-evaluated false passes', () => { + const disconnected = goldenArtifact(); + disconnected.adapter.productBoundary = 'not-connected'; + reject(disconnected, /adapter\.productBoundary/); + + const unevaluated = goldenArtifact(); + unevaluated.gateEvaluation.status = 'not-evaluated'; + reject(unevaluated, /gateEvaluation\.status/); +}); + test('rejects an extra top-level field', () => { const artifact = goldenArtifact() as Record; artifact.untrusted = true; reject(artifact, /failed at \$: must contain exactly keys/); }); -test('pins the artifact to the independently observed repository commit', () => { - const artifact = goldenArtifact(); - artifact.repository.testedHeadCommit = 'b'.repeat(40); - reject(artifact, /repository\.testedHeadCommit/); -}); +test('pins the artifact and inspected product commit to the repository HEAD', () => { + const repository = goldenArtifact(); + repository.repository.testedHeadCommit = 'b'.repeat(40); + reject(repository, /repository\.testedHeadCommit/); -test('pins both inspected product commits at the adapter boundary', () => { - const artifact = goldenArtifact(); - artifact.adapter.inspectedProductCommits[0] = 'c'.repeat(40); - reject(artifact, /adapter\.inspectedProductCommits/); + const inspected = goldenArtifact(); + inspected.adapter.inspectedProductCommits[0] = 'c'.repeat(40); + reject(inspected, /adapter\.inspectedProductCommits/); }); -test('pins the six production adapter operations without interim service internals', () => { +test('pins the six production adapter operations', () => { const artifact = goldenArtifact(); artifact.adapter.requiredProductionOperations[0] = 'interimServiceMethod'; reject(artifact, /adapter\.requiredProductionOperations/); }); -test('does not allow the fixture adapter to claim a formal production gate', () => { +test('requires exact distinct real DKGAgent peer identities', () => { const artifact = goldenArtifact(); - artifact.adapter.productBoundary = 'production'; - reject(artifact, /adapter\.productBoundary/); - const evaluated = goldenArtifact(); - evaluated.gateEvaluation.status = 'PASS'; - reject(evaluated, /gateEvaluation\.status/); + artifact.ready.receiver.peerId = artifact.ready.author.peerId; + reject(artifact, /author and receiver peer IDs must be distinct/); }); -test('requires exact distinct author and receiver peer identities', () => { - const artifact = goldenArtifact(); - artifact.ready.receiver.peerId = artifact.ready.author.peerId; - reject(artifact, /ready\.receiver\.peerId/); +test('rejects malformed product digests, UALs, addresses, and counts', () => { + const digestMutation = goldenArtifact(); + digestMutation.fixture.positive.bundleDigest = 'fixture-bundle'; + reject(digestMutation, /fixture\.positive\.bundleDigest/); + + const ualMutation = goldenArtifact(); + ualMutation.fixture.positive.kaUal = 'did:dkg:wrong'; + reject(ualMutation, /fixture\.positive\.kaUal/); + + const addressMutation = goldenArtifact(); + addressMutation.fixture.positive.authorAddress = AUTHOR.toUpperCase(); + reject(addressMutation, /fixture\.positive\.authorAddress/); + + const countMutation = goldenArtifact(); + countMutation.fixture.positive.inventoryRowCount = 2; + reject(countMutation, /fixture\.positive\.inventoryRowCount/); }); -const exactPositiveMutations: ReadonlyArray void]> = [ - ['catalog head digest', (artifact) => { - artifact.phases.positiveSync.exact.head.catalogHeadDigest = digest('1'); - }], - ['catalog row digest', (artifact) => { - artifact.phases.positiveSync.exact.catalogRowDigest = digest('2'); - }], - ['bundle digest', (artifact) => { - artifact.phases.positiveSync.exact.bundleDigest = digest('3'); - }], - ['content digest', (artifact) => { - artifact.phases.positiveSync.exact.contentDigest = digest('4'); - }], - ['UAL', (artifact) => { artifact.phases.positiveSync.exact.kaUal += '/wrong'; }], - ['inventory row count', (artifact) => { - artifact.phases.positiveSync.exact.inventoryRowCount = 2; - }], - ['activated quad count', (artifact) => { - artifact.phases.positiveSync.exact.activatedQuadCount = 3; - }], -]; - -for (const [label, mutate] of exactPositiveMutations) { - test(`rejects changed exact positive ${label}`, () => { +test('requires replay to retain exact row, content, bundle, UAL, and counts', () => { + const fields: ReadonlyArray = [ + 'bundleDigest', + 'catalogRowDigest', + 'contentDigest', + 'kaUal', + 'activatedQuadCount', + ]; + for (const [index, field] of fields.entries()) { const artifact = goldenArtifact(); - mutate(artifact); - reject(artifact, /phases\.positiveSync\.exact/); - }); -} + (artifact.fixture.repairSuccessor as unknown as Record)[field] = + typeof artifact.fixture.repairSuccessor[field] === 'number' + ? 3 + : digest(String(index + 1)); + reject(artifact, new RegExp(`repairSuccessor\\.${field}`)); + } +}); -test('requires exact durable applied-head readback after positive activation', () => { +test('requires the product inventory digest to remain equal for the exact replayed row', () => { const artifact = goldenArtifact(); - artifact.phases.positiveSync.appliedReadBack.currentCatalogHeadDigest = digest('5'); - reject(artifact, /phases\.positiveSync\.appliedReadBack/); + artifact.fixture.repairSuccessor.head.appliedInventoryDigest = digest('9'); + reject(artifact, /repairSuccessor\.head\.appliedInventoryDigest/); }); -test('requires the exact semantic quad/count/digest post-read', () => { +test('requires the repair head to advance one version from the positive head', () => { + const wrongPrevious = goldenArtifact(); + wrongPrevious.fixture.repairSuccessor.head.previousCatalogHeadDigest = digest('9'); + reject(wrongPrevious, /repairSuccessor\.head\.previousCatalogHeadDigest/); + + const skippedVersion = goldenArtifact(); + skippedVersion.fixture.repairSuccessor.head.catalogVersion = '3'; + reject(skippedVersion, /repairSuccessor\.head\.catalogVersion/); +}); + +test('requires the four receiver-verified control objects from product evidence', () => { const artifact = goldenArtifact(); - artifact.phases.positiveSync.semanticPostRead.activatedQuadCount = 1; - reject(artifact, /phases\.positiveSync\.semanticPostRead/); + artifact.phases.positiveSync.controlObjectsVerified = 3; + reject(artifact, /positiveSync\.controlObjectsVerified/); }); -test('requires forged-author failure to leave zero activation and no applied head', () => { +test('requires exact durable and semantic readback after positive synchronization', () => { + const applied = goldenArtifact(); + applied.phases.positiveSync.appliedReadBack.currentCatalogHeadDigest = digest('9'); + reject(applied, /positiveSync\.appliedReadBack/); + + const semantic = goldenArtifact(); + semantic.phases.positiveSync.semanticPostRead.activatedQuadCount = 1; + reject(semantic, /positiveSync\.semanticPostRead/); +}); + +test('requires forged transfer rejection to leave zero activation and no applied head', () => { const activated = goldenArtifact(); activated.phases.forgedAuthor.activationAfter = 1; reject(activated, /forgedAuthor\.activationAfter/); const applied = goldenArtifact(); - applied.phases.forgedAuthor.appliedHeadAfter = expectedAppliedReadBack(GATE1_FIXTURE.positive); + applied.phases.forgedAuthor.appliedHeadAfter = appliedReadBackFromTransfer(POSITIVE); reject(applied, /forgedAuthor\.appliedHeadAfter/); -}); -test('requires the forged author to fail at cryptographic transfer admission', () => { - const artifact = goldenArtifact(); - artifact.phases.forgedAuthor.failureCode = 'catalog-native-receiver-activation'; - reject(artifact, /forgedAuthor\.failureCode/); + const wrongFailure = goldenArtifact(); + wrongFailure.fixture.forged.expectedFailureCode = 'catalog-successor-producer-binding'; + reject(wrongFailure, /fixture\.forged\.expectedFailureCode/); }); -test('requires an unclean receiver crash after a durable repair intent', () => { +test('requires real SIGKILL followed by explicit replay, not fictional startup repair', () => { const cleanExit = goldenArtifact(); cleanExit.phases.restartRepair.crashExit = { code: 0, signal: null }; reject(cleanExit, /restartRepair\.crashExit\.code/); - const noIntent = goldenArtifact(); - noIntent.phases.restartRepair.gap.repairIntentDurable = false; - reject(noIntent, /restartRepair\.gap\.repairIntentDurable/); -}); + const inventedIntent = goldenArtifact(); + inventedIntent.phases.restartRepair.gap.repairIntentDurable = true; + reject(inventedIntent, /restartRepair\.gap\.repairIntentDurable/); -test('requires restart to advance the durable predecessor to the exact successor', () => { - const artifact = goldenArtifact(); - artifact.phases.restartRepair.restartedReady.startupRepair.after.currentCatalogHeadDigest = - GATE1_FIXTURE.positive.head.catalogHeadDigest; - reject(artifact, /restartRepair\.restartedReady\.startupRepair/); + const inventedStartupRepair = goldenArtifact(); + inventedStartupRepair.phases.restartRepair.restartedReady.startupRepair = { + repaired: true, + }; + reject(inventedStartupRepair, /restartRepair\.restartedReady\.startupRepair/); }); -test('requires exact semantic and applied readback after restart repair', () => { - const applied = goldenArtifact(); - applied.phases.restartRepair.readBack.appliedReadBack.appliedInventoryDigest = digest('6'); - reject(applied, /restartRepair\.readBack\.appliedReadBack/); +test('requires pre-crash continuity and exact post-reannounce replay readback', () => { + const prematureSuccessor = goldenArtifact(); + prematureSuccessor.phases.restartRepair.gap.semanticBeforeCrash = + semanticReadBackFromTransfer(REPAIR); + reject(prematureSuccessor, /restartRepair\.gap\.semanticBeforeCrash/); - const semantic = goldenArtifact(); - semantic.phases.restartRepair.readBack.semanticPostRead.contentDigest = digest('7'); - reject(semantic, /restartRepair\.readBack\.semanticPostRead/); + const wrongApplied = goldenArtifact(); + wrongApplied.phases.restartRepair.readBack.appliedReadBack.currentCatalogHeadDigest = + POSITIVE.head.catalogHeadDigest; + reject(wrongApplied, /restartRepair\.readBack\.appliedReadBack/); + + const duplicate = goldenArtifact(); + duplicate.phases.restartRepair.readBack.semanticPostRead.activatedQuadCount = 4; + reject(duplicate, /restartRepair\.readBack\.semanticPostRead/); }); function buildGoldenArtifact() { - const positiveApplied = mutable(expectedAppliedReadBack(GATE1_FIXTURE.positive)); - const repairApplied = mutable(expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor)); - const positiveSemantic = semantic(GATE1_FIXTURE.positive); - const repairSemantic = semantic(GATE1_FIXTURE.repairSuccessor); - const startupRepair = { - action: 'advanced-applied-head-from-durable-intent', - after: mutable(repairApplied), - before: mutable(positiveApplied), - repaired: true, - semanticPostRead: mutable(repairSemantic), + const forged = { + attemptedCatalogHeadDigest: digest('9'), + catalogAuthorAddress: AUTHOR, + expectedFailureCode: 'catalog-native-receiver-transfer', + recoveredAuthorAddress: ATTACKER, }; return { adapter: { - id: GATE1_FIXTURE_ADAPTER_ID, - inspectedProductCommits: [...INSPECTED_PRODUCT_COMMITS], - productBoundary: 'not-connected', + id: GATE1_REAL_DKG_AGENT_ADAPTER_ID, + inspectedProductCommits: [HEAD], + productBoundary: 'connected', protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, requiredProductionOperations: [...REQUIRED_PRODUCTION_ADAPTER_OPERATIONS], replacementContract: - 'replace adapter-process commands with production DKGAgent operations without changing evidence schema', + 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence', }, fixture: { - forged: mutable(GATE1_FIXTURE.forged), - positive: mutable(GATE1_FIXTURE.positive), - repairSuccessor: mutable(GATE1_FIXTURE.repairSuccessor), + forged: structuredClone(forged), + positive: structuredClone(POSITIVE), + repairSuccessor: structuredClone(REPAIR), }, gate: 'OT-RFC-64 Gate 1 harness contract', gateEvaluation: { reason: - 'deterministic adapter proves orchestration and fail-closed evidence verification, not production Gate 1', - status: 'not-evaluated', + 'two real DKGAgent processes completed production publish, announce, synchronize, authorization-negative, SIGKILL, restart, reannounce, and exact readback', + status: 'PASS', }, harnessChecksPassed: true, invocation: 'pnpm test:gate1:rfc64-public-open-harness', @@ -195,45 +220,45 @@ function buildGoldenArtifact() { activationBefore: 0, appliedHeadAfter: null as unknown, appliedHeadBefore: null as unknown, - attemptedCatalogHeadDigest: GATE1_FIXTURE.forged.attemptedCatalogHeadDigest, - failureCode: GATE1_FIXTURE.forged.expectedFailureCode, - recoveredAuthorAddress: GATE1_FIXTURE.forged.recoveredAuthorAddress, - servedByPeerId: GATE1_FIXTURE.authorPeerId, - testedByPeerId: GATE1_FIXTURE.receiverPeerId, + attemptedCatalogHeadDigest: forged.attemptedCatalogHeadDigest, + failureCode: forged.expectedFailureCode, + recoveredAuthorAddress: forged.recoveredAuthorAddress, + servedByPeerId: 'peer-author-real', + testedByPeerId: 'peer-receiver-real', }, positiveSync: { - appliedReadBack: mutable(positiveApplied), - controlObjectsVerified: 3, - exact: mutable(GATE1_FIXTURE.positive), - receivedByPeerId: GATE1_FIXTURE.receiverPeerId, - semanticPostRead: mutable(positiveSemantic), - servedByPeerId: GATE1_FIXTURE.authorPeerId, + appliedReadBack: structuredClone(appliedReadBackFromTransfer(POSITIVE)), + controlObjectsVerified: 4, + exact: structuredClone(POSITIVE), + receivedByPeerId: 'peer-receiver-real', + semanticPostRead: structuredClone(semanticReadBackFromTransfer(POSITIVE)), + servedByPeerId: 'peer-author-real', }, restartRepair: { crashExit: { code: null as number | null, signal: 'SIGKILL' as string | null }, gap: { - appliedBeforeCrash: mutable(positiveApplied), - repairIntentDurable: true, - semanticBeforeCrash: mutable(repairSemantic), - target: mutable(repairApplied), + appliedBeforeCrash: structuredClone(appliedReadBackFromTransfer(POSITIVE)), + repairIntentDurable: false, + semanticBeforeCrash: structuredClone(semanticReadBackFromTransfer(POSITIVE)), + target: structuredClone(appliedReadBackFromTransfer(REPAIR)), }, readBack: { - appliedReadBack: mutable(repairApplied), - semanticPostRead: mutable(repairSemantic), + appliedReadBack: structuredClone(appliedReadBackFromTransfer(REPAIR)), + semanticPostRead: structuredClone(semanticReadBackFromTransfer(REPAIR)), }, restartedReady: { - adapterId: GATE1_FIXTURE_ADAPTER_ID, - peerId: GATE1_FIXTURE.receiverPeerId, + adapterId: GATE1_REAL_DKG_AGENT_ADAPTER_ID, + peerId: 'peer-receiver-real', protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, role: 'receiver', - startupRepair, + startupRepair: null as unknown, }, - successorServedByPeerId: GATE1_FIXTURE.authorPeerId, + successorServedByPeerId: 'peer-author-real', }, }, processBoundary: { authorInstances: 1, - model: 'two concurrent adapter peer processes plus one receiver restart', + model: 'two real DKGAgent peer processes plus one receiver restart', receiverInstances: 2, stoppedExits: { author: { code: 0, signal: null }, @@ -242,15 +267,15 @@ function buildGoldenArtifact() { }, ready: { author: { - adapterId: GATE1_FIXTURE_ADAPTER_ID, - peerId: GATE1_FIXTURE.authorPeerId, + adapterId: GATE1_REAL_DKG_AGENT_ADAPTER_ID, + peerId: 'peer-author-real', protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, role: 'author', startupRepair: null as unknown, }, receiver: { - adapterId: GATE1_FIXTURE_ADAPTER_ID, - peerId: GATE1_FIXTURE.receiverPeerId, + adapterId: GATE1_REAL_DKG_AGENT_ADAPTER_ID, + peerId: 'peer-receiver-real', protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, role: 'receiver', startupRepair: null as unknown, @@ -283,21 +308,27 @@ function goldenArtifact(): GoldenArtifact { return structuredClone(buildGoldenArtifact()) as GoldenArtifact; } -function semantic(fixture: Gate1TransferFixture) { +function transfer(input: { head: string; previous: string; version: string }): Gate1TransferEvidence { return { - activatedQuadCount: fixture.activatedQuadCount, - catalogHeadDigest: fixture.head.catalogHeadDigest, - catalogRowDigest: fixture.catalogRowDigest, - contentDigest: fixture.contentDigest, - kaUal: fixture.kaUal, - swmGraph: fixture.swmGraph, + activatedQuadCount: 2, + authorAddress: AUTHOR, + bundleByteLength: 300, + bundleDigest: digest('2'), + catalogRowDigest: digest('3'), + contentByteLength: 168, + contentDigest: digest('4'), + head: { + appliedInventoryDigest: digest('5'), + catalogHeadDigest: digest(input.head), + catalogVersion: input.version, + previousCatalogHeadDigest: digest(input.previous), + }, + inventoryRowCount: 1, + kaUal: `did:dkg:otp:20430/${AUTHOR}/7`, + swmGraph: `did:dkg:swm:0x${'bb'.repeat(20)}/gate-1/${AUTHOR}/7`, }; } -function mutable(value: T): T { - return structuredClone(value); -} - function bytes(value: unknown): Buffer { return Buffer.from(stableJson(value), 'utf8'); } diff --git a/devnet/rfc64-gate1-public-open/verifier.ts b/devnet/rfc64-gate1-public-open/verifier.ts index 665a6e689a..174069ffcf 100644 --- a/devnet/rfc64-gate1-public-open/verifier.ts +++ b/devnet/rfc64-gate1-public-open/verifier.ts @@ -3,27 +3,34 @@ import { createHash } from 'node:crypto'; import { stableJson } from '../rfc64-persistence-lifecycle/evidence.js'; import { GATE1_ADAPTER_PROTOCOL_VERSION, - GATE1_FIXTURE, - GATE1_FIXTURE_ADAPTER_ID, GATE1_RAW_SCHEMA_VERSION, - INSPECTED_PRODUCT_COMMITS, + GATE1_REAL_DKG_AGENT_ADAPTER_ID, REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, - assertFixtureDerivations, - expectedAppliedReadBack, - type Gate1TransferFixture, + appliedReadBackFromTransfer, + semanticReadBackFromTransfer, + type Gate1ForgedEvidence, + type Gate1TransferEvidence, } from './model.js'; const COMMIT_PATTERN = /^[0-9a-f]{40,64}$/u; +const DIGEST_PATTERN = /^0x[0-9a-f]{64}$/u; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/u; +const DECIMAL_PATTERN = /^(0|[1-9][0-9]*)$/u; +const UAL_PATTERN = /^did:dkg:[^/]+\/0x[0-9a-f]{40}\/(0|[1-9][0-9]*)$/u; +const PRODUCTION_REASON = + 'two real DKGAgent processes completed production publish, announce, synchronize, authorization-negative, SIGKILL, restart, reannounce, and exact readback'; +const PRODUCTION_REPLACEMENT_CONTRACT = + 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence'; export interface Gate1VerifiedEvidence { readonly rawArtifactSha256: string; readonly sourceCommit: string; } + export function verifyGate1ArtifactBytes( rawBytes: Uint8Array, expectedHead: string, ): Gate1VerifiedEvidence { - assertFixtureDerivations(); requireMatch(expectedHead, COMMIT_PATTERN, 'expected repository HEAD'); if (rawBytes.byteLength === 0 || rawBytes.byteLength > 1_000_000) { fail('$', 'raw artifact size is outside the closed verifier bound'); @@ -81,12 +88,8 @@ function verifyClosedArtifact(value: unknown, expectedHead: string): void { 'reason', 'status', ]); - exact(evaluation.status, 'not-evaluated', '$.gateEvaluation.status'); - exact( - evaluation.reason, - 'deterministic adapter proves orchestration and fail-closed evidence verification, not production Gate 1', - '$.gateEvaluation.reason', - ); + exact(evaluation.status, 'PASS', '$.gateEvaluation.status'); + exact(evaluation.reason, PRODUCTION_REASON, '$.gateEvaluation.reason'); const adapter = closedRecord(artifact.adapter, '$.adapter', [ 'id', @@ -96,24 +99,16 @@ function verifyClosedArtifact(value: unknown, expectedHead: string): void { 'requiredProductionOperations', 'replacementContract', ]); - exact(adapter.id, GATE1_FIXTURE_ADAPTER_ID, '$.adapter.id'); + exact(adapter.id, GATE1_REAL_DKG_AGENT_ADAPTER_ID, '$.adapter.id'); exact(adapter.protocolVersion, GATE1_ADAPTER_PROTOCOL_VERSION, '$.adapter.protocolVersion'); - exact(adapter.productBoundary, 'not-connected', '$.adapter.productBoundary'); + exact(adapter.productBoundary, 'connected', '$.adapter.productBoundary'); exactJson( adapter.requiredProductionOperations, REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, '$.adapter.requiredProductionOperations', ); - exact( - adapter.replacementContract, - 'replace adapter-process commands with production DKGAgent operations without changing evidence schema', - '$.adapter.replacementContract', - ); - exactJson( - adapter.inspectedProductCommits, - INSPECTED_PRODUCT_COMMITS, - '$.adapter.inspectedProductCommits', - ); + exact(adapter.replacementContract, PRODUCTION_REPLACEMENT_CONTRACT, '$.adapter.replacementContract'); + exactJson(adapter.inspectedProductCommits, [expectedHead], '$.adapter.inspectedProductCommits'); const repository = closedRecord(artifact.repository, '$.repository', [ 'testedHeadCommit', @@ -121,50 +116,139 @@ function verifyClosedArtifact(value: unknown, expectedHead: string): void { 'trackedSourceCleanBeforeSpawn', ]); exact(repository.testedHeadCommit, expectedHead, '$.repository.testedHeadCommit'); - exact( - repository.trackedSourceCleanBeforeSpawn, - true, - '$.repository.trackedSourceCleanBeforeSpawn', - ); - exact( - repository.trackedSourceCleanAfterProcesses, - true, - '$.repository.trackedSourceCleanAfterProcesses', - ); + exact(repository.trackedSourceCleanBeforeSpawn, true, '$.repository.trackedSourceCleanBeforeSpawn'); + exact(repository.trackedSourceCleanAfterProcesses, true, '$.repository.trackedSourceCleanAfterProcesses'); - verifyFixture(artifact.fixture); + const fixture = verifyRuntimeEvidence(artifact.fixture); const peers = verifyReady(artifact.ready); verifyProcessBoundary(artifact.processBoundary); - verifyPhases(artifact.phases, peers); + verifyPhases(artifact.phases, peers, fixture); } -function verifyFixture(value: unknown): void { - const fixture = closedRecord(value, '$.fixture', ['forged', 'positive', 'repairSuccessor']); - exactJson(fixture.forged, GATE1_FIXTURE.forged, '$.fixture.forged'); - exactJson(fixture.positive, GATE1_FIXTURE.positive, '$.fixture.positive'); - exactJson( - fixture.repairSuccessor, - GATE1_FIXTURE.repairSuccessor, - '$.fixture.repairSuccessor', +function verifyRuntimeEvidence(value: unknown): { + forged: Gate1ForgedEvidence; + positive: Gate1TransferEvidence; + repair: Gate1TransferEvidence; +} { + const evidence = closedRecord(value, '$.fixture', ['forged', 'positive', 'repairSuccessor']); + const positive = verifyTransfer(evidence.positive, '$.fixture.positive'); + const repair = verifyTransfer(evidence.repairSuccessor, '$.fixture.repairSuccessor'); + const forgedRecord = closedRecord(evidence.forged, '$.fixture.forged', [ + 'attemptedCatalogHeadDigest', + 'catalogAuthorAddress', + 'expectedFailureCode', + 'recoveredAuthorAddress', + ]); + const forged: Gate1ForgedEvidence = { + attemptedCatalogHeadDigest: digest(forgedRecord.attemptedCatalogHeadDigest, '$.fixture.forged.attemptedCatalogHeadDigest'), + catalogAuthorAddress: address(forgedRecord.catalogAuthorAddress, '$.fixture.forged.catalogAuthorAddress'), + expectedFailureCode: boundedString(forgedRecord.expectedFailureCode, '$.fixture.forged.expectedFailureCode'), + recoveredAuthorAddress: address(forgedRecord.recoveredAuthorAddress, '$.fixture.forged.recoveredAuthorAddress'), + }; + exact(forged.catalogAuthorAddress, positive.authorAddress, '$.fixture.forged.catalogAuthorAddress'); + exact( + forged.expectedFailureCode, + 'catalog-native-receiver-transfer', + '$.fixture.forged.expectedFailureCode', ); + if (forged.recoveredAuthorAddress === forged.catalogAuthorAddress) { + fail('$.fixture.forged.recoveredAuthorAddress', 'must differ from the catalog author'); + } + + // The restart/replay successor is the same exact one-row inventory. A new + // head/version may advance, but no semantic row, bundle, UAL, count, or + // applied inventory commitment may change or duplicate. + const stableFields: ReadonlyArray = [ + 'activatedQuadCount', + 'authorAddress', + 'bundleByteLength', + 'bundleDigest', + 'catalogRowDigest', + 'contentByteLength', + 'contentDigest', + 'inventoryRowCount', + 'kaUal', + 'swmGraph', + ]; + for (const field of stableFields) { + exactJson(repair[field], positive[field], `$.fixture.repairSuccessor.${field}`); + } + exact( + repair.head.appliedInventoryDigest, + positive.head.appliedInventoryDigest, + '$.fixture.repairSuccessor.head.appliedInventoryDigest', + ); + exact( + repair.head.previousCatalogHeadDigest, + positive.head.catalogHeadDigest, + '$.fixture.repairSuccessor.head.previousCatalogHeadDigest', + ); + if (repair.head.catalogHeadDigest === positive.head.catalogHeadDigest) { + fail('$.fixture.repairSuccessor.head.catalogHeadDigest', 'must advance to a distinct head'); + } + const positiveVersion = BigInt(positive.head.catalogVersion); + const repairVersion = BigInt(repair.head.catalogVersion); + if (repairVersion !== positiveVersion + 1n) { + fail('$.fixture.repairSuccessor.head.catalogVersion', 'must advance exactly one version'); + } + return { forged, positive, repair }; +} + +function verifyTransfer(value: unknown, path: string): Gate1TransferEvidence { + const transfer = closedRecord(value, path, [ + 'activatedQuadCount', + 'authorAddress', + 'bundleByteLength', + 'bundleDigest', + 'catalogRowDigest', + 'contentByteLength', + 'contentDigest', + 'head', + 'inventoryRowCount', + 'kaUal', + 'swmGraph', + ]); + const head = closedRecord(transfer.head, `${path}.head`, [ + 'appliedInventoryDigest', + 'catalogHeadDigest', + 'catalogVersion', + 'previousCatalogHeadDigest', + ]); + const authorAddress = address(transfer.authorAddress, `${path}.authorAddress`); + const kaUal = matchString(transfer.kaUal, UAL_PATTERN, `${path}.kaUal`); + if (!kaUal.includes(`/${authorAddress}/`)) fail(`${path}.kaUal`, 'must name the catalog author'); + const activatedQuadCount = positiveSafeInteger(transfer.activatedQuadCount, `${path}.activatedQuadCount`); + const result: Gate1TransferEvidence = { + activatedQuadCount, + authorAddress, + bundleByteLength: positiveSafeInteger(transfer.bundleByteLength, `${path}.bundleByteLength`), + bundleDigest: digest(transfer.bundleDigest, `${path}.bundleDigest`), + catalogRowDigest: digest(transfer.catalogRowDigest, `${path}.catalogRowDigest`), + contentByteLength: positiveSafeInteger(transfer.contentByteLength, `${path}.contentByteLength`), + contentDigest: digest(transfer.contentDigest, `${path}.contentDigest`), + head: { + appliedInventoryDigest: digest(head.appliedInventoryDigest, `${path}.head.appliedInventoryDigest`), + catalogHeadDigest: digest(head.catalogHeadDigest, `${path}.head.catalogHeadDigest`), + catalogVersion: matchString(head.catalogVersion, DECIMAL_PATTERN, `${path}.head.catalogVersion`), + previousCatalogHeadDigest: digest(head.previousCatalogHeadDigest, `${path}.head.previousCatalogHeadDigest`), + }, + inventoryRowCount: exactSafeInteger(transfer.inventoryRowCount, 1, `${path}.inventoryRowCount`), + kaUal, + swmGraph: boundedString(transfer.swmGraph, `${path}.swmGraph`), + }; + if (!result.swmGraph.includes(authorAddress)) fail(`${path}.swmGraph`, 'must name the catalog author'); + return result; } function verifyReady(value: unknown): { author: string; receiver: string } { const ready = closedRecord(value, '$.ready', ['author', 'receiver']); - const author = verifyReadyEvent(ready.author, '$.ready.author', 'author', null); - const receiver = verifyReadyEvent(ready.receiver, '$.ready.receiver', 'receiver', null); - exact(author, GATE1_FIXTURE.authorPeerId, '$.ready.author.peerId'); - exact(receiver, GATE1_FIXTURE.receiverPeerId, '$.ready.receiver.peerId'); + const author = verifyReadyEvent(ready.author, '$.ready.author', 'author'); + const receiver = verifyReadyEvent(ready.receiver, '$.ready.receiver', 'receiver'); if (author === receiver) fail('$.ready', 'author and receiver peer IDs must be distinct'); return { author, receiver }; } -function verifyReadyEvent( - value: unknown, - path: string, - role: 'author' | 'receiver', - expectedRepair: unknown, -): string { +function verifyReadyEvent(value: unknown, path: string, role: 'author' | 'receiver'): string { const event = closedRecord(value, path, [ 'adapterId', 'peerId', @@ -172,11 +256,11 @@ function verifyReadyEvent( 'role', 'startupRepair', ]); - exact(event.adapterId, GATE1_FIXTURE_ADAPTER_ID, `${path}.adapterId`); + exact(event.adapterId, GATE1_REAL_DKG_AGENT_ADAPTER_ID, `${path}.adapterId`); exact(event.protocolVersion, GATE1_ADAPTER_PROTOCOL_VERSION, `${path}.protocolVersion`); exact(event.role, role, `${path}.role`); - exactJson(event.startupRepair, expectedRepair, `${path}.startupRepair`); - return nonEmptyString(event.peerId, `${path}.peerId`); + exact(event.startupRepair, null, `${path}.startupRepair`); + return boundedString(event.peerId, `${path}.peerId`); } function verifyProcessBoundary(value: unknown): void { @@ -190,7 +274,7 @@ function verifyProcessBoundary(value: unknown): void { exact(boundary.receiverInstances, 2, '$.processBoundary.receiverInstances'); exact( boundary.model, - 'two concurrent adapter peer processes plus one receiver restart', + 'two real DKGAgent peer processes plus one receiver restart', '$.processBoundary.model', ); const exits = closedRecord(boundary.stoppedExits, '$.processBoundary.stoppedExits', [ @@ -198,31 +282,32 @@ function verifyProcessBoundary(value: unknown): void { 'restartedReceiver', ]); verifyExit(exits.author, '$.processBoundary.stoppedExits.author', 0, null); - verifyExit( - exits.restartedReceiver, - '$.processBoundary.stoppedExits.restartedReceiver', - 0, - null, - ); + verifyExit(exits.restartedReceiver, '$.processBoundary.stoppedExits.restartedReceiver', 0, null); } function verifyPhases( value: unknown, peers: { author: string; receiver: string }, + runtime: { + forged: Gate1ForgedEvidence; + positive: Gate1TransferEvidence; + repair: Gate1TransferEvidence; + }, ): void { const phases = closedRecord(value, '$.phases', [ 'forgedAuthor', 'positiveSync', 'restartRepair', ]); - verifyForgedAuthor(phases.forgedAuthor, peers); - verifyPositiveSync(phases.positiveSync, peers); - verifyRestartRepair(phases.restartRepair, peers); + verifyForgedAuthor(phases.forgedAuthor, peers, runtime.forged); + verifyPositiveSync(phases.positiveSync, peers, runtime.positive); + verifyRestartRepair(phases.restartRepair, peers, runtime.positive, runtime.repair); } function verifyForgedAuthor( value: unknown, peers: { author: string; receiver: string }, + forged: Gate1ForgedEvidence, ): void { const phase = closedRecord(value, '$.phases.forgedAuthor', [ 'activationAfter', @@ -241,26 +326,15 @@ function verifyForgedAuthor( exact(phase.activationAfter, 0, '$.phases.forgedAuthor.activationAfter'); exact(phase.appliedHeadBefore, null, '$.phases.forgedAuthor.appliedHeadBefore'); exact(phase.appliedHeadAfter, null, '$.phases.forgedAuthor.appliedHeadAfter'); - exact( - phase.attemptedCatalogHeadDigest, - GATE1_FIXTURE.forged.attemptedCatalogHeadDigest, - '$.phases.forgedAuthor.attemptedCatalogHeadDigest', - ); - exact( - phase.failureCode, - GATE1_FIXTURE.forged.expectedFailureCode, - '$.phases.forgedAuthor.failureCode', - ); - exact( - phase.recoveredAuthorAddress, - GATE1_FIXTURE.forged.recoveredAuthorAddress, - '$.phases.forgedAuthor.recoveredAuthorAddress', - ); + exact(phase.attemptedCatalogHeadDigest, forged.attemptedCatalogHeadDigest, '$.phases.forgedAuthor.attemptedCatalogHeadDigest'); + exact(phase.failureCode, forged.expectedFailureCode, '$.phases.forgedAuthor.failureCode'); + exact(phase.recoveredAuthorAddress, forged.recoveredAuthorAddress, '$.phases.forgedAuthor.recoveredAuthorAddress'); } function verifyPositiveSync( value: unknown, peers: { author: string; receiver: string }, + positive: Gate1TransferEvidence, ): void { const phase = closedRecord(value, '$.phases.positiveSync', [ 'appliedReadBack', @@ -272,23 +346,17 @@ function verifyPositiveSync( ]); exact(phase.servedByPeerId, peers.author, '$.phases.positiveSync.servedByPeerId'); exact(phase.receivedByPeerId, peers.receiver, '$.phases.positiveSync.receivedByPeerId'); - exact(phase.controlObjectsVerified, 3, '$.phases.positiveSync.controlObjectsVerified'); - exactJson(phase.exact, GATE1_FIXTURE.positive, '$.phases.positiveSync.exact'); - exactJson( - phase.appliedReadBack, - expectedAppliedReadBack(GATE1_FIXTURE.positive), - '$.phases.positiveSync.appliedReadBack', - ); - exactJson( - phase.semanticPostRead, - expectedSemantic(GATE1_FIXTURE.positive), - '$.phases.positiveSync.semanticPostRead', - ); + exact(phase.controlObjectsVerified, 4, '$.phases.positiveSync.controlObjectsVerified'); + exactJson(phase.exact, positive, '$.phases.positiveSync.exact'); + exactJson(phase.appliedReadBack, appliedReadBackFromTransfer(positive), '$.phases.positiveSync.appliedReadBack'); + exactJson(phase.semanticPostRead, semanticReadBackFromTransfer(positive), '$.phases.positiveSync.semanticPostRead'); } function verifyRestartRepair( value: unknown, peers: { author: string; receiver: string }, + positive: Gate1TransferEvidence, + repair: Gate1TransferEvidence, ): void { const phase = closedRecord(value, '$.phases.restartRepair', [ 'crashExit', @@ -297,11 +365,7 @@ function verifyRestartRepair( 'restartedReady', 'successorServedByPeerId', ]); - exact( - phase.successorServedByPeerId, - peers.author, - '$.phases.restartRepair.successorServedByPeerId', - ); + exact(phase.successorServedByPeerId, peers.author, '$.phases.restartRepair.successorServedByPeerId'); verifyExit(phase.crashExit, '$.phases.restartRepair.crashExit', null, 'SIGKILL'); const gap = closedRecord(phase.gap, '$.phases.restartRepair.gap', [ 'appliedBeforeCrash', @@ -309,62 +373,22 @@ function verifyRestartRepair( 'semanticBeforeCrash', 'target', ]); - exact(gap.repairIntentDurable, true, '$.phases.restartRepair.gap.repairIntentDurable'); - exactJson( - gap.appliedBeforeCrash, - expectedAppliedReadBack(GATE1_FIXTURE.positive), - '$.phases.restartRepair.gap.appliedBeforeCrash', - ); - exactJson( - gap.target, - expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), - '$.phases.restartRepair.gap.target', - ); - exactJson( - gap.semanticBeforeCrash, - expectedSemantic(GATE1_FIXTURE.repairSuccessor), - '$.phases.restartRepair.gap.semanticBeforeCrash', - ); - - const expectedRepair = { - action: 'advanced-applied-head-from-durable-intent', - after: expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), - before: expectedAppliedReadBack(GATE1_FIXTURE.positive), - repaired: true, - semanticPostRead: expectedSemantic(GATE1_FIXTURE.repairSuccessor), - }; + exact(gap.repairIntentDurable, false, '$.phases.restartRepair.gap.repairIntentDurable'); + exactJson(gap.appliedBeforeCrash, appliedReadBackFromTransfer(positive), '$.phases.restartRepair.gap.appliedBeforeCrash'); + exactJson(gap.semanticBeforeCrash, semanticReadBackFromTransfer(positive), '$.phases.restartRepair.gap.semanticBeforeCrash'); + exactJson(gap.target, appliedReadBackFromTransfer(repair), '$.phases.restartRepair.gap.target'); const restartedPeer = verifyReadyEvent( phase.restartedReady, '$.phases.restartRepair.restartedReady', 'receiver', - expectedRepair, ); exact(restartedPeer, peers.receiver, '$.phases.restartRepair.restartedReady.peerId'); const readBack = closedRecord(phase.readBack, '$.phases.restartRepair.readBack', [ 'appliedReadBack', 'semanticPostRead', ]); - exactJson( - readBack.appliedReadBack, - expectedAppliedReadBack(GATE1_FIXTURE.repairSuccessor), - '$.phases.restartRepair.readBack.appliedReadBack', - ); - exactJson( - readBack.semanticPostRead, - expectedSemantic(GATE1_FIXTURE.repairSuccessor), - '$.phases.restartRepair.readBack.semanticPostRead', - ); -} - -function expectedSemantic(fixture: Gate1TransferFixture): Record { - return { - activatedQuadCount: fixture.activatedQuadCount, - catalogHeadDigest: fixture.head.catalogHeadDigest, - catalogRowDigest: fixture.catalogRowDigest, - contentDigest: fixture.contentDigest, - kaUal: fixture.kaUal, - swmGraph: fixture.swmGraph, - }; + exactJson(readBack.appliedReadBack, appliedReadBackFromTransfer(repair), '$.phases.restartRepair.readBack.appliedReadBack'); + exactJson(readBack.semanticPostRead, semanticReadBackFromTransfer(repair), '$.phases.restartRepair.readBack.semanticPostRead'); } function verifyExit( @@ -396,21 +420,47 @@ function closedRecord( return value as Record; } -function exact(actual: unknown, expected: unknown, path: string): void { - if (!Object.is(actual, expected)) fail(path, `must equal ${JSON.stringify(expected)}`); +function digest(value: unknown, path: string): string { + return matchString(value, DIGEST_PATTERN, path); } -function exactJson(actual: unknown, expected: unknown, path: string): void { - if (stableJson(actual) !== stableJson(expected)) fail(path, 'does not equal pinned exact value'); +function address(value: unknown, path: string): string { + return matchString(value, ADDRESS_PATTERN, path); } -function nonEmptyString(value: unknown, path: string): string { - if (typeof value !== 'string' || value.length === 0 || value.length > 256) { +function matchString(value: unknown, pattern: RegExp, path: string): string { + const result = boundedString(value, path); + if (!pattern.test(result)) fail(path, 'is malformed'); + return result; +} + +function boundedString(value: unknown, path: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 512) { fail(path, 'must be a bounded non-empty string'); } return value; } +function positiveSafeInteger(value: unknown, path: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + fail(path, 'must be a positive safe integer'); + } + return value as number; +} + +function exactSafeInteger(value: unknown, expected: number, path: string): number { + if (!Number.isSafeInteger(value) || value !== expected) fail(path, `must equal ${expected}`); + return value as number; +} + +function exact(actual: unknown, expected: unknown, path: string): void { + if (!Object.is(actual, expected)) fail(path, `must equal ${JSON.stringify(expected)}`); +} + +function exactJson(actual: unknown, expected: unknown, path: string): void { + if (stableJson(actual) !== stableJson(expected)) fail(path, 'does not equal exact runtime evidence'); +} + function requireMatch(value: string, pattern: RegExp, label: string): void { if (!pattern.test(value)) throw new Error(`${label} is malformed`); } diff --git a/devnet/rfc64-gate1-public-open/verify.ts b/devnet/rfc64-gate1-public-open/verify.ts index c08312b638..eea19d8f69 100644 --- a/devnet/rfc64-gate1-public-open/verify.ts +++ b/devnet/rfc64-gate1-public-open/verify.ts @@ -19,7 +19,7 @@ const verified = verifyGate1ArtifactBytes(readFileSync(artifactPath), expectedHe const publication = atomicWriteStableJson(verdictPath, { rawArtifactSha256: verified.rawArtifactSha256, schemaVersion: GATE1_VERDICT_SCHEMA_VERSION, - scope: 'harness-contract-only', + scope: 'production-gate1-public-open', sourceCommit: verified.sourceCommit, status: 'PASS', }); diff --git a/package.json b/package.json index 8cbee7d404..c93d4707e2 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "test:gate1:rfc64-public-open-harness": "pnpm run test:gate1:rfc64-public-open-harness:generate && pnpm run test:gate1:rfc64-public-open-harness:verify", "test:gate1:rfc64-public-open-harness:generate": "node --import tsx devnet/rfc64-gate1-public-open/run.ts", "test:gate1:rfc64-public-open-harness:verify": "node --import tsx devnet/rfc64-gate1-public-open/verify.ts", - "test:gate1:rfc64-public-open-harness:unit": "node --import tsx --test devnet/rfc64-gate1-public-open/model.test.ts devnet/rfc64-gate1-public-open/verifier.test.ts", + "test:gate1:rfc64-public-open-harness:unit": "node --import tsx --test devnet/rfc64-gate1-public-open/agent-child.test.ts devnet/rfc64-gate1-public-open/model.test.ts devnet/rfc64-gate1-public-open/product-capabilities.test.ts devnet/rfc64-gate1-public-open/verifier.test.ts", "typecheck:gate1:rfc64-public-open-harness": "tsc --project devnet/rfc64-gate1-public-open/tsconfig.json", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c623d00f93..51d4c721e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,7 +298,20 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@22.19.11)(happy-dom@20.8.9(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) - devnet/rfc64-gate1-public-open: {} + devnet/rfc64-gate1-public-open: + dependencies: + '@multiformats/multiaddr': + specifier: ^13.0.3 + version: 13.0.3 + '@origintrail-official/dkg-agent': + specifier: workspace:* + version: link:../../packages/agent + '@origintrail-official/dkg-storage': + specifier: workspace:* + version: link:../../packages/storage + ethers: + specifier: ^6.16.0 + version: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) devnet/rfc64-persistence-lifecycle: dependencies: From de6e68e40e0a17455adbb1796525c9dcfe2db690 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:30:36 +0200 Subject: [PATCH 130/292] test(devnet): require unchanged state after forged catalog --- devnet/rfc64-gate1-public-open/README.md | 3 +- devnet/rfc64-gate1-public-open/model.test.ts | 4 +- .../rfc64-gate1-public-open/verifier.test.ts | 21 +++++----- devnet/rfc64-gate1-public-open/verifier.ts | 42 +++++++++++++++---- 4 files changed, 49 insertions(+), 21 deletions(-) diff --git a/devnet/rfc64-gate1-public-open/README.md b/devnet/rfc64-gate1-public-open/README.md index 5613e8ebb5..81532c81ab 100644 --- a/devnet/rfc64-gate1-public-open/README.md +++ b/devnet/rfc64-gate1-public-open/README.md @@ -43,7 +43,8 @@ The preserved raw schema requires production-returned evidence for: - exact successor head, catalog-row, bundle, public-content, UAL, and SWM graph; - one inventory row and exact activated triple count; - durable applied-head and exact semantic post-read; -- forged author-transfer rejection with no activation or applied head; +- forged author-transfer rejection with the positive activation and applied head + exactly unchanged; - a real `SIGKILL`, restart with the same peer identity and durable directory, explicit reannouncement, and exact replay without duplicate activation. diff --git a/devnet/rfc64-gate1-public-open/model.test.ts b/devnet/rfc64-gate1-public-open/model.test.ts index 847f469fd8..0c06c097e1 100644 --- a/devnet/rfc64-gate1-public-open/model.test.ts +++ b/devnet/rfc64-gate1-public-open/model.test.ts @@ -24,7 +24,9 @@ const transfer: Gate1TransferEvidence = { }, inventoryRowCount: 1, kaUal: `did:dkg:otp:20430/0x${'11'.repeat(20)}/7`, - swmGraph: `did:dkg:swm:0x${'88'.repeat(20)}/gate-1/0x${'11'.repeat(20)}/7`, + swmGraph: + `did:dkg:context-graph:0x${'88'.repeat(20)}/gate-1/_shared_memory/` + + `0x${'11'.repeat(20)}/7`, }; test('the six-operation adapter boundary remains exact and product-facing', () => { diff --git a/devnet/rfc64-gate1-public-open/verifier.test.ts b/devnet/rfc64-gate1-public-open/verifier.test.ts index 7350b9093d..9a3839695c 100644 --- a/devnet/rfc64-gate1-public-open/verifier.test.ts +++ b/devnet/rfc64-gate1-public-open/verifier.test.ts @@ -138,17 +138,17 @@ test('requires exact durable and semantic readback after positive synchronizatio reject(semantic, /positiveSync\.semanticPostRead/); }); -test('requires forged transfer rejection to leave zero activation and no applied head', () => { +test('requires forged transfer rejection to leave the positive state exactly unchanged', () => { const activated = goldenArtifact(); - activated.phases.forgedAuthor.activationAfter = 1; + activated.phases.forgedAuthor.activationAfter = 3; reject(activated, /forgedAuthor\.activationAfter/); const applied = goldenArtifact(); - applied.phases.forgedAuthor.appliedHeadAfter = appliedReadBackFromTransfer(POSITIVE); + (applied.phases.forgedAuthor as unknown as Record).appliedHeadAfter = null; reject(applied, /forgedAuthor\.appliedHeadAfter/); const wrongFailure = goldenArtifact(); - wrongFailure.fixture.forged.expectedFailureCode = 'catalog-successor-producer-binding'; + wrongFailure.fixture.forged.expectedFailureCode = 'catalog-native-receiver-transfer'; reject(wrongFailure, /fixture\.forged\.expectedFailureCode/); }); @@ -188,7 +188,7 @@ function buildGoldenArtifact() { const forged = { attemptedCatalogHeadDigest: digest('9'), catalogAuthorAddress: AUTHOR, - expectedFailureCode: 'catalog-native-receiver-transfer', + expectedFailureCode: 'catalog-native-receiver-authorization', recoveredAuthorAddress: ATTACKER, }; return { @@ -216,10 +216,10 @@ function buildGoldenArtifact() { invocation: 'pnpm test:gate1:rfc64-public-open-harness', phases: { forgedAuthor: { - activationAfter: 0, - activationBefore: 0, - appliedHeadAfter: null as unknown, - appliedHeadBefore: null as unknown, + activationAfter: POSITIVE.activatedQuadCount, + activationBefore: POSITIVE.activatedQuadCount, + appliedHeadAfter: structuredClone(appliedReadBackFromTransfer(POSITIVE)), + appliedHeadBefore: structuredClone(appliedReadBackFromTransfer(POSITIVE)), attemptedCatalogHeadDigest: forged.attemptedCatalogHeadDigest, failureCode: forged.expectedFailureCode, recoveredAuthorAddress: forged.recoveredAuthorAddress, @@ -325,7 +325,8 @@ function transfer(input: { head: string; previous: string; version: string }): G }, inventoryRowCount: 1, kaUal: `did:dkg:otp:20430/${AUTHOR}/7`, - swmGraph: `did:dkg:swm:0x${'bb'.repeat(20)}/gate-1/${AUTHOR}/7`, + swmGraph: + `did:dkg:context-graph:0x${'bb'.repeat(20)}/gate-1/_shared_memory/${AUTHOR}/7`, }; } diff --git a/devnet/rfc64-gate1-public-open/verifier.ts b/devnet/rfc64-gate1-public-open/verifier.ts index 174069ffcf..7e57ac5713 100644 --- a/devnet/rfc64-gate1-public-open/verifier.ts +++ b/devnet/rfc64-gate1-public-open/verifier.ts @@ -16,7 +16,9 @@ const COMMIT_PATTERN = /^[0-9a-f]{40,64}$/u; const DIGEST_PATTERN = /^0x[0-9a-f]{64}$/u; const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/u; const DECIMAL_PATTERN = /^(0|[1-9][0-9]*)$/u; -const UAL_PATTERN = /^did:dkg:[^/]+\/0x[0-9a-f]{40}\/(0|[1-9][0-9]*)$/u; +const UAL_PATTERN = /^did:dkg:[^/]+\/(0x[0-9a-f]{40})\/(0|[1-9][0-9]*)$/u; +const SWM_GRAPH_PATTERN = + /^did:dkg:context-graph:.+\/_shared_memory\/(0x[0-9a-f]{40})\/(0|[1-9][0-9]*)$/u; const PRODUCTION_REASON = 'two real DKGAgent processes completed production publish, announce, synchronize, authorization-negative, SIGKILL, restart, reannounce, and exact readback'; const PRODUCTION_REPLACEMENT_CONTRACT = @@ -148,7 +150,7 @@ function verifyRuntimeEvidence(value: unknown): { exact(forged.catalogAuthorAddress, positive.authorAddress, '$.fixture.forged.catalogAuthorAddress'); exact( forged.expectedFailureCode, - 'catalog-native-receiver-transfer', + 'catalog-native-receiver-authorization', '$.fixture.forged.expectedFailureCode', ); if (forged.recoveredAuthorAddress === forged.catalogAuthorAddress) { @@ -216,7 +218,8 @@ function verifyTransfer(value: unknown, path: string): Gate1TransferEvidence { ]); const authorAddress = address(transfer.authorAddress, `${path}.authorAddress`); const kaUal = matchString(transfer.kaUal, UAL_PATTERN, `${path}.kaUal`); - if (!kaUal.includes(`/${authorAddress}/`)) fail(`${path}.kaUal`, 'must name the catalog author'); + const ualMatch = UAL_PATTERN.exec(kaUal)!; + if (ualMatch[1] !== authorAddress) fail(`${path}.kaUal`, 'must name the catalog author'); const activatedQuadCount = positiveSafeInteger(transfer.activatedQuadCount, `${path}.activatedQuadCount`); const result: Gate1TransferEvidence = { activatedQuadCount, @@ -236,7 +239,11 @@ function verifyTransfer(value: unknown, path: string): Gate1TransferEvidence { kaUal, swmGraph: boundedString(transfer.swmGraph, `${path}.swmGraph`), }; - if (!result.swmGraph.includes(authorAddress)) fail(`${path}.swmGraph`, 'must name the catalog author'); + const swmMatch = SWM_GRAPH_PATTERN.exec(result.swmGraph); + if (swmMatch === null) fail(`${path}.swmGraph`, 'must use the production shared-memory graph form'); + if (swmMatch[1] !== authorAddress || swmMatch[2] !== ualMatch[2]) { + fail(`${path}.swmGraph`, 'must name the same catalog author and KA number as the UAL'); + } return result; } @@ -299,7 +306,7 @@ function verifyPhases( 'positiveSync', 'restartRepair', ]); - verifyForgedAuthor(phases.forgedAuthor, peers, runtime.forged); + verifyForgedAuthor(phases.forgedAuthor, peers, runtime.forged, runtime.positive); verifyPositiveSync(phases.positiveSync, peers, runtime.positive); verifyRestartRepair(phases.restartRepair, peers, runtime.positive, runtime.repair); } @@ -308,6 +315,7 @@ function verifyForgedAuthor( value: unknown, peers: { author: string; receiver: string }, forged: Gate1ForgedEvidence, + positive: Gate1TransferEvidence, ): void { const phase = closedRecord(value, '$.phases.forgedAuthor', [ 'activationAfter', @@ -322,10 +330,26 @@ function verifyForgedAuthor( ]); exact(phase.servedByPeerId, peers.author, '$.phases.forgedAuthor.servedByPeerId'); exact(phase.testedByPeerId, peers.receiver, '$.phases.forgedAuthor.testedByPeerId'); - exact(phase.activationBefore, 0, '$.phases.forgedAuthor.activationBefore'); - exact(phase.activationAfter, 0, '$.phases.forgedAuthor.activationAfter'); - exact(phase.appliedHeadBefore, null, '$.phases.forgedAuthor.appliedHeadBefore'); - exact(phase.appliedHeadAfter, null, '$.phases.forgedAuthor.appliedHeadAfter'); + exact( + phase.activationBefore, + positive.activatedQuadCount, + '$.phases.forgedAuthor.activationBefore', + ); + exact( + phase.activationAfter, + positive.activatedQuadCount, + '$.phases.forgedAuthor.activationAfter', + ); + exactJson( + phase.appliedHeadBefore, + appliedReadBackFromTransfer(positive), + '$.phases.forgedAuthor.appliedHeadBefore', + ); + exactJson( + phase.appliedHeadAfter, + appliedReadBackFromTransfer(positive), + '$.phases.forgedAuthor.appliedHeadAfter', + ); exact(phase.attemptedCatalogHeadDigest, forged.attemptedCatalogHeadDigest, '$.phases.forgedAuthor.attemptedCatalogHeadDigest'); exact(phase.failureCode, forged.expectedFailureCode, '$.phases.forgedAuthor.failureCode'); exact(phase.recoveredAuthorAddress, forged.recoveredAuthorAddress, '$.phases.forgedAuthor.recoveredAuthorAddress'); From 26a71594f1208ed16de07e1b604ffad32ccc5cd5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:35:32 +0200 Subject: [PATCH 131/292] test(agent): align native wiring with signed genesis --- .../rfc64-dkg-agent-native-wiring.integration.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index ebf8a527c3..f61ded1d2d 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -22,6 +22,8 @@ const NETWORK_ID = 'otp:20430' as NetworkIdV1; const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/native-wiring' as ContextGraphIdV1; const FIXED_HEAD_ISSUED_AT = '1773900000000' as TimestampMsV1; +const DELEGATION_EFFECTIVE_AT = '1773899999999' as TimestampMsV1; +const DELEGATION_EXPIRES_AT = '1773900000001' as TimestampMsV1; const NATIVE_DEPLOYMENT = Object.freeze({ networkId: NETWORK_ID, assertedAtChainId: '20430', @@ -128,6 +130,8 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { author: AUTHOR_WALLET, peers: [], issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, }); expect(published.announcement.policyDigest).toBe(receiverPolicy.policyDigest); expect(published.announcedPeers).toEqual([]); @@ -155,7 +159,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { catalogHeadDigest: published.headObjectDigest, inventoryRowCount: 0, activatedTripleCount: 0, - stagedObjectCount: 2, + stagedObjectCount: 3, appliedHeadStatus: 'applied', }); expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ @@ -196,6 +200,8 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { author: AUTHOR_WALLET, peers: [receiver.peerId], issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, }); expect(published.announcedPeers).toEqual([receiver.peerId]); await receiver.whenRfc64PublicCatalogReceiverIdleV1(); @@ -231,6 +237,8 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { author: AUTHOR_WALLET, peers: [firstReceiver.peerId], issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, }); await firstReceiver.whenRfc64PublicCatalogReceiverIdleV1(); expect(firstReceiver.readRfc64AppliedCatalogHeadV1({ From 80e64148332f548b3fdfce905a47b69ec675007e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:37:20 +0200 Subject: [PATCH 132/292] fix(agent): bind RFC-64 native authorization scope --- .../public-catalog-native-receiver-v1.ts | 185 +++++++++++-- .../public-catalog-native-reconciler-v1.ts | 50 +++- .../public-catalog-native-transport-v1.ts | 5 + .../src/rfc64/public-catalog-service-v1.ts | 42 ++- ...c-catalog-native-gate1.integration.test.ts | 243 ++++++++++++++++-- ...ublic-catalog-native-reconciler-v1.test.ts | 36 ++- ...public-catalog-native-transport-v1.test.ts | 91 +++++++ .../rfc64-public-catalog-service-v1.test.ts | 94 ++++++- 8 files changed, 682 insertions(+), 64 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 5daff744dd..d1f647fc7c 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -17,8 +17,12 @@ import { AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, ZERO_DIGEST32_V1, + assertAuthorCatalogHeadScopeBindingV1, + assertAuthorCatalogScopeV1, assertAuthorCatalogBucketScopeBindingV1, assertAuthorCatalogDirectoryNodeScopeBindingV1, + assertCanonicalChainId, + assertNetworkIdV1, assertSignedAuthorCatalogBucketEnvelopeV1, assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, @@ -37,6 +41,7 @@ import { verifyCgSharedProjectionV1, verifyTransferredCatalogBundleV1, type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, type CatalogSealDeploymentProfileV1, type Digest32V1, type SignedAuthorCatalogBucketEnvelopeV1, @@ -178,16 +183,23 @@ export class Rfc64PublicCatalogNativeReceiverV1 { async synchronizeOnePublicOpenRow( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, + trustedCatalogScope: AuthorCatalogScopeV1, deployment: CatalogSealDeploymentProfileV1, signal?: AbortSignal, ): Promise { + const trustedScope = snapshotTrustedPublicOpenScope( + trustedCatalogScope, + announcement, + ); + const trustedDeployment = snapshotTrustedDeployment(deployment, trustedScope); return this.withScopeSerialization( - catalogScopeLockKey(announcement), + computeAuthorCatalogScopeDigestV1(trustedScope), async () => { const evidence = await this.synchronizePublicOpenCatalogSerialized( remotePeerId, announcement, - deployment, + trustedScope, + trustedDeployment, 'successor', signal, ); @@ -207,15 +219,22 @@ export class Rfc64PublicCatalogNativeReceiverV1 { async synchronizePublicOpenCatalog( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, + trustedCatalogScope: AuthorCatalogScopeV1, deployment: CatalogSealDeploymentProfileV1, signal?: AbortSignal, ): Promise { + const trustedScope = snapshotTrustedPublicOpenScope( + trustedCatalogScope, + announcement, + ); + const trustedDeployment = snapshotTrustedDeployment(deployment, trustedScope); return this.withScopeSerialization( - catalogScopeLockKey(announcement), + computeAuthorCatalogScopeDigestV1(trustedScope), () => this.synchronizePublicOpenCatalogSerialized( remotePeerId, announcement, - deployment, + trustedScope, + trustedDeployment, 'any', signal, ), @@ -226,16 +245,23 @@ export class Rfc64PublicCatalogNativeReceiverV1 { async bootstrapEmptyPublicOpenCatalog( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, + trustedCatalogScope: AuthorCatalogScopeV1, deployment: CatalogSealDeploymentProfileV1, signal?: AbortSignal, ): Promise { + const trustedScope = snapshotTrustedPublicOpenScope( + trustedCatalogScope, + announcement, + ); + const trustedDeployment = snapshotTrustedDeployment(deployment, trustedScope); return this.withScopeSerialization( - catalogScopeLockKey(announcement), + computeAuthorCatalogScopeDigestV1(trustedScope), async () => { const evidence = await this.synchronizePublicOpenCatalogSerialized( remotePeerId, announcement, - deployment, + trustedScope, + trustedDeployment, 'genesis', signal, ); @@ -250,6 +276,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { private async synchronizePublicOpenCatalogSerialized( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, + trustedCatalogScope: Readonly, deployment: CatalogSealDeploymentProfileV1, expected: 'any' | 'genesis' | 'successor', signal: AbortSignal | undefined, @@ -264,10 +291,12 @@ export class Rfc64PublicCatalogNativeReceiverV1 { fail('catalog-native-receiver-not-found', 'announced catalog head was not found'); } const head = fetchedHead.envelope; + assertFetchedHeadMatchesTrustedScope(head, trustedCatalogScope); if (expected === 'genesis' || (expected === 'any' && claimsGenesisHistory(head))) { return this.bootstrapEmptyPublicOpenCatalogFetched( remotePeerId, announcement, + trustedCatalogScope, fetchedHead, signal, ); @@ -278,6 +307,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { return this.synchronizeOnePublicOpenRowFetched( remotePeerId, announcement, + trustedCatalogScope, deployment, fetchedHead, signal, @@ -287,14 +317,13 @@ export class Rfc64PublicCatalogNativeReceiverV1 { private async bootstrapEmptyPublicOpenCatalogFetched( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, + trustedCatalogScope: Readonly, fetchedHead: FetchedRfc64PublicCatalogHeadV1, signal: AbortSignal | undefined, ): Promise { const head = fetchedHead.envelope; assertEmptyGenesisHead(head); - const catalogScopeDigest = computeAuthorCatalogScopeDigestV1( - deriveAuthorCatalogScopeFromHeadV1(head.payload), - ); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(trustedCatalogScope); const inventoryDigest = computeRfc64AppliedInventoryDigestV1({ catalogScopeDigest, rows: [], @@ -304,11 +333,12 @@ export class Rfc64PublicCatalogNativeReceiverV1 { head.payload.authorAddress, ); const replay = assertEmptyGenesisHistory(current, head, inventoryDigest); - const scope = nativeScope(announcement, head); + const scope = nativeScope(announcement, trustedCatalogScope, head); const fetchedDelegation = await this.fetchDirectAuthorCatalogIssuerDelegation( remotePeerId, scope, head, + trustedCatalogScope, signal, ); const fetchedDirectory = await this.options.contentTransport.fetchCatalogObject( @@ -413,25 +443,25 @@ export class Rfc64PublicCatalogNativeReceiverV1 { private async synchronizeOnePublicOpenRowFetched( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, + trustedCatalogScope: Readonly, deployment: CatalogSealDeploymentProfileV1, fetchedHead: FetchedRfc64PublicCatalogHeadV1, signal: AbortSignal | undefined, ): Promise { const head = fetchedHead.envelope; assertFirstSliceHead(head); - const catalogScopeDigest = computeAuthorCatalogScopeDigestV1( - deriveAuthorCatalogScopeFromHeadV1(head.payload), - ); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(trustedCatalogScope); const currentAppliedHead = this.options.inventory.readAppliedCatalogHeadV1( catalogScopeDigest, head.payload.authorAddress, ); const replay = assertMonotonicSuccessorHistory(currentAppliedHead, head); - const scope = nativeScope(announcement, head); + const scope = nativeScope(announcement, trustedCatalogScope, head); const fetchedDelegation = await this.fetchDirectAuthorCatalogIssuerDelegation( remotePeerId, scope, head, + trustedCatalogScope, signal, ); @@ -668,6 +698,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { remotePeerId: string, scope: Rfc64PublicCatalogNativeFetchScopeV1, head: SignedAuthorCatalogHeadEnvelopeV1, + trustedCatalogScope: Readonly, signal: AbortSignal | undefined, ): Promise { + const scope = Object.freeze({ + networkId: input.networkId, + contextGraphId: input.contextGraphId, + governanceChainId: input.governanceChainId, + governanceContractAddress: input.governanceContractAddress, + ownershipTransitionDigest: input.ownershipTransitionDigest, + subGraphName: input.subGraphName, + authorAddress: input.authorAddress, + era: input.era, + bucketCount: input.bucketCount, + }); + try { + assertAuthorCatalogScopeV1(scope); + if ( + scope.governanceChainId !== null + || scope.governanceContractAddress !== null + || scope.ownershipTransitionDigest !== null + || scope.subGraphName !== null + || scope.bucketCount !== '1' + ) { + throw new Error('Gate 1 requires the public/open null-governance root scope'); + } + if ( + announcement.networkId !== scope.networkId + || announcement.contextGraphId !== scope.contextGraphId + || announcement.subGraphName !== scope.subGraphName + || announcement.authorAddress !== scope.authorAddress + || announcement.catalogEra !== scope.era + ) { + throw new Error('announcement differs from the locally trusted catalog scope'); + } + } catch (cause) { + fail( + 'catalog-native-receiver-authorization', + 'catalog request is not bound to the locally accepted public/open policy scope', + cause, + ); + } + return scope; +} + +function snapshotTrustedDeployment( + input: CatalogSealDeploymentProfileV1, + trustedCatalogScope: Readonly, +): Readonly { + const deployment = Object.freeze({ + networkId: input.networkId, + assertedAtChainId: input.assertedAtChainId, + assertedAtKav10Address: input.assertedAtKav10Address, + }); + try { + assertNetworkIdV1(deployment.networkId); + assertCanonicalChainId(deployment.assertedAtChainId, 'assertedAtChainId'); + if ( + !ethers.isAddress(deployment.assertedAtKav10Address) + || deployment.assertedAtKav10Address !== deployment.assertedAtKav10Address.toLowerCase() + ) { + throw new Error('assertedAtKav10Address is not a canonical EVM address'); + } + if (deployment.networkId !== trustedCatalogScope.networkId) { + throw new Error('deployment network differs from the locally trusted catalog scope'); + } + } catch (cause) { + fail( + 'catalog-native-receiver-authorization', + 'locally resolved deployment is not bound to the accepted catalog scope', + cause, + ); + } + return deployment; +} + +function assertFetchedHeadMatchesTrustedScope( + head: SignedAuthorCatalogHeadEnvelopeV1, + trustedCatalogScope: Readonly, +): void { + try { + assertAuthorCatalogHeadScopeBindingV1(head.payload, trustedCatalogScope); + } catch (cause) { + fail( + 'catalog-native-receiver-authorization', + 'fetched catalog head differs from the locally accepted public/open policy scope', + cause, + ); + } } function claimsGenesisHistory(head: SignedAuthorCatalogHeadEnvelopeV1): boolean { @@ -840,14 +957,15 @@ function assertFirstSliceHead(head: SignedAuthorCatalogHeadEnvelopeV1): void { function nativeScope( announcement: Rfc64PublicCatalogHeadAnnouncementV1, + trustedCatalogScope: Readonly, head: SignedAuthorCatalogHeadEnvelopeV1, ): Rfc64PublicCatalogNativeFetchScopeV1 { return Object.freeze({ - networkId: head.payload.networkId, - contextGraphId: head.payload.contextGraphId, - subGraphName: head.payload.subGraphName, - authorAddress: head.payload.authorAddress, - catalogEra: head.payload.era, + networkId: trustedCatalogScope.networkId, + contextGraphId: trustedCatalogScope.contextGraphId, + subGraphName: trustedCatalogScope.subGraphName, + authorAddress: trustedCatalogScope.authorAddress, + catalogEra: trustedCatalogScope.era, catalogVersion: head.payload.version, policyDigest: announcement.policyDigest, catalogHeadObjectDigest: head.objectDigest as Digest32V1, @@ -858,6 +976,7 @@ function nativeScope( function assertDirectAuthorCatalogIssuerDelegationBindingV1( delegation: SignedAuthorCatalogIssuerDelegationEnvelopeV1, head: SignedAuthorCatalogHeadEnvelopeV1, + trustedCatalogScope: Readonly, ): void { const left = delegation.payload; const right = head.payload; @@ -883,6 +1002,18 @@ function assertDirectAuthorCatalogIssuerDelegationBindingV1( ) { throw new Error('delegation scope, governance tuple, author, lane, or era differs from head'); } + if ( + left.networkId !== trustedCatalogScope.networkId + || left.contextGraphId !== trustedCatalogScope.contextGraphId + || left.governanceChainId !== trustedCatalogScope.governanceChainId + || left.governanceContractAddress !== trustedCatalogScope.governanceContractAddress + || left.ownershipTransitionDigest !== trustedCatalogScope.ownershipTransitionDigest + || left.subGraphName !== trustedCatalogScope.subGraphName + || left.authorAddress !== trustedCatalogScope.authorAddress + || left.catalogEra !== trustedCatalogScope.era + ) { + throw new Error('delegation differs from the locally trusted public/open catalog scope'); + } if ( (left.catalogEra === '0') !== (left.previousDelegationDigest === null) || BigInt(right.issuedAt) < BigInt(left.effectiveAt) diff --git a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts index a735e0b49b..5169f1d5d6 100644 --- a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts @@ -14,6 +14,7 @@ import { type AuthorCatalogScopeV1, type CatalogSealDeploymentProfileV1, type CountV1, + type ContextGraphPolicyV1, } from '@origintrail-official/dkg-core'; import type { Rfc64InventoryV1OperationsV1 } from './inventory-v1/index.js'; @@ -39,33 +40,55 @@ export type Rfc64PublicOpenCatalogDeploymentResolverV1 = ( signal: AbortSignal, ) => Promise; +/** Resolve the exact catalog scope from independently accepted local policy state. */ +export type Rfc64PublicOpenCatalogTrustedScopeResolverV1 = ( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, +) => Readonly; + export interface Rfc64PublicOpenCatalogNativeReconcilerOptionsV1 { readonly nativeReceiver: Rfc64PublicOpenCatalogNativeReceiverClientV1; readonly inventory: Pick; + /** Resolve from accepted policy state; never reconstruct authority from wire fields alone. */ + readonly resolveTrustedCatalogScope: Rfc64PublicOpenCatalogTrustedScopeResolverV1; /** Resolve the locally trusted deployment tuple; never copy it from the wire. */ readonly resolveDeployment: Rfc64PublicOpenCatalogDeploymentResolverV1; } /** - * Derive the one fixed Gate-1 public/open scope represented by an announcement. + * Derive the one fixed Gate-1 public/open scope from accepted local policy and + * require the announcement to name that exact owner/network/CG/era/root lane. * Policy and signature-variant digests are transport/authentication context, * not semantic catalog identity, and therefore do not participate. */ export function deriveRfc64PublicOpenCatalogScopeV1( announcement: Rfc64PublicCatalogHeadAnnouncementV1, + acceptedPolicy: ContextGraphPolicyV1, ): AuthorCatalogScopeV1 { - if (announcement.subGraphName !== null) { - throw new Error('RFC-64 Gate 1 public/open reconciler requires the root catalog lane'); + if ( + acceptedPolicy.accessPolicy !== 0 + || acceptedPolicy.source.kind !== 'owner-signed-unregistered' + || acceptedPolicy.networkId !== announcement.networkId + || acceptedPolicy.contextGraphId !== announcement.contextGraphId + || acceptedPolicy.governanceChainId !== null + || acceptedPolicy.governanceContractAddress !== null + || acceptedPolicy.ownershipTransitionDigest !== null + || acceptedPolicy.era !== announcement.catalogEra + || announcement.subGraphName !== null + || acceptedPolicy.source.ownerAddress !== announcement.authorAddress + ) { + throw new Error( + 'RFC-64 Gate 1 announcement is not bound to the accepted null-governance owner policy', + ); } return Object.freeze({ - networkId: announcement.networkId, - contextGraphId: announcement.contextGraphId, - governanceChainId: null, - governanceContractAddress: null, - ownershipTransitionDigest: null, + networkId: acceptedPolicy.networkId, + contextGraphId: acceptedPolicy.contextGraphId, + governanceChainId: acceptedPolicy.governanceChainId, + governanceContractAddress: acceptedPolicy.governanceContractAddress, + ownershipTransitionDigest: acceptedPolicy.ownershipTransitionDigest, subGraphName: null, - authorAddress: announcement.authorAddress, - era: announcement.catalogEra, + authorAddress: acceptedPolicy.source.ownerAddress, + era: acceptedPolicy.era, bucketCount: '1' as CountV1, }); } @@ -78,6 +101,7 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 if ( typeof options?.nativeReceiver?.synchronizePublicOpenCatalog !== 'function' || typeof options?.inventory?.readAppliedCatalogHeadV1 !== 'function' + || typeof options?.resolveTrustedCatalogScope !== 'function' || typeof options?.resolveDeployment !== 'function' ) { throw new TypeError('RFC-64 public/open native reconciler dependencies are incomplete'); @@ -87,8 +111,9 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 async isHeadApplied( announcement: Rfc64PublicCatalogHeadAnnouncementV1, ): Promise { + const trustedCatalogScope = this.options.resolveTrustedCatalogScope(announcement); const catalogScopeDigest = computeAuthorCatalogScopeDigestV1( - deriveRfc64PublicOpenCatalogScopeV1(announcement), + trustedCatalogScope, ); const current = this.options.inventory.readAppliedCatalogHeadV1( catalogScopeDigest, @@ -108,6 +133,8 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 announcement: Rfc64PublicCatalogHeadAnnouncementV1, signal: AbortSignal, ): Promise { + throwIfAborted(signal); + const trustedCatalogScope = this.options.resolveTrustedCatalogScope(announcement); throwIfAborted(signal); const deployment = await this.options.resolveDeployment(announcement, signal); throwIfAborted(signal); @@ -115,6 +142,7 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 await this.options.nativeReceiver.synchronizePublicOpenCatalog( remotePeerId, announcement, + trustedCatalogScope, deployment, signal, ); diff --git a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts index 0e282fbec4..95aea135c8 100644 --- a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts @@ -24,6 +24,7 @@ import { assertSignedControlEnvelope, assertSubGraphNameV1, canonicalizeSignedControlEnvelopeBytes, + computeControlSignatureVariantDigestHex, decodeOpaqueKaBundleV1, parseCanonicalSignedControlEnvelope, type ContextGraphAccessPolicyV1, @@ -386,6 +387,10 @@ export class Rfc64PublicCatalogNativeTransportV1 { const snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); if ( snapshot.objectDigest !== envelope.objectDigest + || snapshot.signatureVariantDigest !== computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ) || snapshot.issuer !== envelope.issuer || snapshot.signatureSuite !== envelope.signatureSuite ) { diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 04d15f5ae1..77f58a02c5 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -23,6 +23,7 @@ import { assertAuthorCatalogScopeV1, + assertAuthorCatalogHeadScopeBindingV1, computeControlSignatureVariantDigestHex, type ProtocolRouter, type SendOptions, @@ -67,6 +68,10 @@ import { import { produceDirectAuthorCatalogIssuerDelegationV1, } from './public-catalog-issuer-delegation-v1.js'; +import { + deriveRfc64PublicOpenCatalogScopeV1, + type Rfc64PublicOpenCatalogTrustedScopeResolverV1, +} from './public-catalog-native-reconciler-v1.js'; import type { Rfc64PublicCatalogIssuerAuthorizationV1, } from './public-catalog-successor-producer-v1.js'; @@ -119,6 +124,7 @@ export type Rfc64PublicCatalogContentFetchClientV1 = Pick< export interface Rfc64PublicCatalogReconcilerClientsV1 { readonly headTransport: Rfc64PublicCatalogHeadFetchClientV1; readonly contentTransport: Rfc64PublicCatalogContentFetchClientV1; + readonly resolveTrustedCatalogScope: Rfc64PublicOpenCatalogTrustedScopeResolverV1; readonly transportTimeoutMs: number; } @@ -236,6 +242,8 @@ export class Rfc64PublicCatalogServiceV1 { ), fetchKaBundle: this.#nativeTransport!.fetchKaBundle.bind(this.#nativeTransport!), }), + resolveTrustedCatalogScope: (announcement: Rfc64PublicCatalogHeadAnnouncementV1) => + this.#resolveTrustedCatalogScope(announcement), transportTimeoutMs: this.#transportTimeoutMs, })); this.#receiver = new Rfc64PublicCatalogReceiverV1(reconciler, options.receiver); @@ -415,8 +423,10 @@ export class Rfc64PublicCatalogServiceV1 { }); } - #sendOptions(): SendOptions { - return { timeoutMs: this.#transportTimeoutMs }; + #sendOptions(signal?: AbortSignal): SendOptions { + return signal === undefined + ? { timeoutMs: this.#transportTimeoutMs } + : { timeoutMs: this.#transportTimeoutMs, signal }; } async #announceCatalogHeadSnapshot( @@ -470,6 +480,20 @@ export class Rfc64PublicCatalogServiceV1 { }); } + #resolveTrustedCatalogScope( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + ): Readonly { + const record = this.#policies.lookup(announcement.networkId, announcement.contextGraphId); + if ( + record === null + || record.policyDigest !== announcement.policyDigest + || record.policy.accessPolicy !== 0 + ) { + throw new Error('RFC-64 announcement has no matching accepted open policy generation'); + } + return deriveRfc64PublicOpenCatalogScopeV1(announcement, record.policy); + } + async #stageHeadOnly( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, @@ -477,12 +501,24 @@ export class Rfc64PublicCatalogServiceV1 { onHeadStaged?: Rfc64PublicCatalogServiceOptionsV1['onHeadStaged'], ): Promise<'not-found' | 'staged-only'> { if (signal.aborted) throw signal.reason; + const trustedCatalogScope = this.#resolveTrustedCatalogScope(announcement); const fetched = await this.#transport.fetchCatalogHead( remotePeerId, announcement, - this.#sendOptions(), + this.#sendOptions(signal), ); if (fetched === null) return 'not-found'; + try { + assertAuthorCatalogHeadScopeBindingV1( + fetched.envelope.payload, + trustedCatalogScope, + ); + } catch (cause) { + throw new Error( + 'RFC-64 fetched head differs from the accepted public/open policy scope', + { cause }, + ); + } await this.#controlObjects.stageVerifiedObjects([fetched]); onHeadStaged?.(announcement, remotePeerId); return 'staged-only'; diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 60589648c8..b33b6cdef1 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -67,6 +67,8 @@ const NETWORK_ID = 'otp:20430' as NetworkIdV1; const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/native-gate-1' as ContextGraphIdV1; const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const GOVERNANCE_CONTRACT = + '0x5555555555555555555555555555555555555555' as EvmAddressV1; const POLICY_DIGEST = `0x${'75'.repeat(32)}` as Digest32V1; const MISSING_DELEGATION_DIGEST = `0x${'76'.repeat(32)}` as Digest32V1; const KA_NUMBER = 7n; @@ -256,6 +258,72 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); }, 30_000); + it('rejects a governed-scope genesis under the trusted null-governance policy before any mutation', async () => { + const fixture = await setupLiveReceiver(); + const observed = fixture.createCasObservedReceiver(); + + await expect(fixture.bootstrap( + fixture.governedGenesisAnnouncement, + observed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-authorization' }); + + expect(fixture.receiverObjectFetch).not.toHaveBeenCalled(); + expect(fixture.receiverBundleFetch).not.toHaveBeenCalled(); + expect(fixture.authorObjectRead).not.toHaveBeenCalled(); + expect(fixture.authorBundleRead).not.toHaveBeenCalled(); + expect(observed.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toBeNull(); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + }, 30_000); + + it('rejects a governed-scope successor before directory/bundle fetch, staging, CAS, or activation', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + fixture.receiverObjectFetch.mockClear(); + fixture.receiverBundleFetch.mockClear(); + fixture.authorObjectRead.mockClear(); + fixture.authorBundleRead.mockClear(); + const observed = fixture.createCasObservedReceiver(); + + await expect(fixture.synchronize( + fixture.governedSuccessorAnnouncement, + observed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-authorization' }); + + expect(fixture.receiverObjectFetch).not.toHaveBeenCalled(); + expect(fixture.receiverBundleFetch).not.toHaveBeenCalled(); + expect(fixture.authorObjectRead).not.toHaveBeenCalled(); + expect(fixture.authorBundleRead).not.toHaveBeenCalled(); + expect(observed.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + }, 30_000); + + it('rejects a locally resolved deployment for another network before head fetch', async () => { + const fixture = await setupLiveReceiver(); + await expect(fixture.receiver.bootstrapEmptyPublicOpenCatalog( + 'peer-unused', + fixture.genesisAnnouncement, + fixture.scope, + { + ...DEPLOYMENT, + networkId: 'otp:20431' as NetworkIdV1, + }, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-authorization' }); + expect(fixture.receiverHeadFetch).not.toHaveBeenCalled(); + expect(fixture.receiverObjectFetch).not.toHaveBeenCalled(); + expect(fixture.receiverBundleFetch).not.toHaveBeenCalled(); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + }, 30_000); + it('threads one AbortSignal and timeout through every head, object, and bundle fetch path', async () => { const fixture = await setupLiveReceiver(); const signal = new AbortController().signal; @@ -395,6 +463,50 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { expect(fixture.authorBundleRead).not.toHaveBeenCalled(); }, 30_000); + it('rejects a delegation proof for another exact signature variant before bundle fetch or mutation', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + const staleProof = await verifyControlEnvelopeIssuerSignatureV1( + fixture.catalogIssuerDelegation, + ); + const alternateDelegation = Object.freeze({ + ...fixture.catalogIssuerDelegation, + signature: alternateRecoveryEncoding(fixture.catalogIssuerDelegation.signature), + }) as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + expect(ethers.verifyMessage( + ethers.getBytes(alternateDelegation.objectDigest), + alternateDelegation.signature, + ).toLowerCase()).toBe(AUTHOR); + const fetchCatalogObject: Rfc64PublicCatalogNativeTransportV1['fetchCatalogObject'] = + async (peerId, request, sendOptions) => { + if (request.targetObjectDigest === fixture.catalogIssuerDelegation.objectDigest) { + return Object.freeze({ + envelope: alternateDelegation, + issuerSignature: staleProof, + }); + } + return fixture.receiverObjectFetch(peerId, request, sendOptions); + }; + fixture.receiverBundleFetch.mockClear(); + const observed = fixture.createCasObservedReceiver({ + fetchCatalogObject, + fetchKaBundle: fixture.receiverBundleFetch, + }); + + await expect(fixture.synchronize( + fixture.announcement, + observed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-authorization' }); + expect(fixture.receiverBundleFetch).not.toHaveBeenCalled(); + expect(observed.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + }, 30_000); + it('rejects a cross-lane catalog-issuer delegation before activation or applied-head CAS', async () => { const fixture = await setupLiveReceiver(); await fixture.bootstrap(); @@ -529,11 +641,20 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { era: '0', bucketCount: '1', } as AuthorCatalogScopeV1; + const governedScope = Object.freeze({ + ...scope, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: `0x${'57'.repeat(32)}` as Digest32V1, + }) as AuthorCatalogScopeV1; const signer = { issuer: AUTHOR, signDigest: async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest), }; const catalogIssuerDelegation = await buildDirectCatalogIssuerDelegation(); + const governedCatalogIssuerDelegation = await buildDirectCatalogIssuerDelegation({ + scope: governedScope, + }); const forgedCatalogIssuerDelegation = Object.freeze({ ...catalogIssuerDelegation, signature: await new ethers.Wallet(`0x${'78'.repeat(32)}`).signMessage( @@ -555,6 +676,12 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900000000' as never, signer, }); + const governedGenesis = await produceEmptyAuthorCatalogGenesisV1({ + scope: governedScope, + catalogIssuerDelegationDigest: governedCatalogIssuerDelegation.objectDigest, + issuedAt: '1773900000000' as never, + signer, + }); const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); const successor = await produceSparseAuthorCatalogSuccessorV1({ previousHead: genesis.head, @@ -565,6 +692,15 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900001000' as never, signer, }); + const governedSuccessor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: governedGenesis.head, + previousDirectoryPath: governedGenesis.directoryPath, + previousBucket: null, + selectedBucketId: '0' as never, + nextRows: [rowBundle.row], + issuedAt: '1773900001000' as never, + signer, + }); const competingSuccessor = await produceSparseAuthorCatalogSuccessorV1({ previousHead: genesis.head, previousDirectoryPath: genesis.directoryPath, @@ -593,11 +729,14 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const authorObjects = new Map( [ catalogIssuerDelegation, + governedCatalogIssuerDelegation, crossLaneDelegation, expiredDelegation, ...genesis.stagedObjects, + ...governedGenesis.stagedObjects, ...invalidGenesis.stagedObjects, ...successor.stagedObjects, + ...governedSuccessor.stagedObjects, ...competingSuccessor.stagedObjects, crossLaneHead, expiredHead, @@ -612,11 +751,14 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const verifiedObjects = await Promise.all( [ catalogIssuerDelegation, + governedCatalogIssuerDelegation, crossLaneDelegation, expiredDelegation, ...genesis.stagedObjects, + ...governedGenesis.stagedObjects, ...invalidGenesis.stagedObjects, ...successor.stagedObjects, + ...governedSuccessor.stagedObjects, ...competingSuccessor.stagedObjects, crossLaneHead, expiredHead, @@ -627,13 +769,29 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), })), ); - const staged = await authorPersistence.controlObjects.stageVerifiedObjects(verifiedObjects); + const stagedObjects: Array<{ + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + }> = []; + for (let offset = 0; offset < verifiedObjects.length; offset += 16) { + const stagedBatch = await authorPersistence.controlObjects.stageVerifiedObjects( + verifiedObjects.slice(offset, offset + 16), + ); + stagedObjects.push(...stagedBatch.objects); + } + const staged = { objects: stagedObjects }; const headKeys = staged.objects.find( (keys) => keys.objectDigest === successor.head.objectDigest, ); const genesisHeadKeys = staged.objects.find( (keys) => keys.objectDigest === genesis.head.objectDigest, ); + const governedGenesisHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === governedGenesis.head.objectDigest, + ); + const governedSuccessorHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === governedSuccessor.head.objectDigest, + ); const invalidGenesisHeadKeys = staged.objects.find( (keys) => keys.objectDigest === invalidGenesis.head.objectDigest, ); @@ -651,6 +809,8 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ); if (headKeys === undefined) throw new Error('successor head was not staged'); if (genesisHeadKeys === undefined) throw new Error('genesis head was not staged'); + if (governedGenesisHeadKeys === undefined) throw new Error('governed genesis head was not staged'); + if (governedSuccessorHeadKeys === undefined) throw new Error('governed successor head was not staged'); if (invalidGenesisHeadKeys === undefined) throw new Error('invalid genesis head was not staged'); if (competingHeadKeys === undefined) throw new Error('competing successor head was not staged'); if (crossLaneHeadKeys === undefined) throw new Error('cross-lane successor head was not staged'); @@ -728,6 +888,16 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { catalogHeadObjectDigest: competingHeadKeys.objectDigest, signatureVariantDigest: competingHeadKeys.signatureVariantDigest, }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const governedGenesisAnnouncement = Object.freeze({ + ...genesisAnnouncement, + catalogHeadObjectDigest: governedGenesisHeadKeys.objectDigest, + signatureVariantDigest: governedGenesisHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const governedSuccessorAnnouncement = Object.freeze({ + ...announcement, + catalogHeadObjectDigest: governedSuccessorHeadKeys.objectDigest, + signatureVariantDigest: governedSuccessorHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; const crossLaneAnnouncement = Object.freeze({ ...announcement, catalogHeadObjectDigest: crossLaneHeadKeys.objectDigest, @@ -765,32 +935,48 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { Rfc64PersistenceV1['inventory'], 'readAppliedCatalogHeadV1' | 'compareAndSwapAppliedCatalogHeadV1' >, - controlObjects = receiverPersistence.controlObjects, - ) => new Rfc64PublicCatalogNativeReceiverV1({ - headTransport: { fetchCatalogHead: receiverHeadFetch }, - contentTransport: { + controlObjects: Pick< + Rfc64PersistenceV1['controlObjects'], + 'stageVerifiedObjects' + > = receiverPersistence.controlObjects, + contentTransport: Pick< + Rfc64PublicCatalogNativeTransportV1, + 'fetchCatalogObject' | 'fetchKaBundle' + > = { fetchCatalogObject: receiverObjectFetch, fetchKaBundle: receiverBundleFetch, }, + ) => new Rfc64PublicCatalogNativeReceiverV1({ + headTransport: { fetchCatalogHead: receiverHeadFetch }, + contentTransport, controlObjects, inventory, store: receiverStore, }); - const createCasObservedReceiver = () => { + const createCasObservedReceiver = (contentTransport?: Pick< + Rfc64PublicCatalogNativeTransportV1, + 'fetchCatalogObject' | 'fetchKaBundle' + >) => { const compareAndSwapAppliedCatalogHeadV1 = vi.fn( receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1.bind( receiverPersistence.inventory, ), ); + const stageVerifiedObjects = vi.fn( + receiverPersistence.controlObjects.stageVerifiedObjects.bind( + receiverPersistence.controlObjects, + ), + ); return Object.freeze({ compareAndSwapAppliedCatalogHeadV1, + stageVerifiedObjects, receiver: createReceiver({ readAppliedCatalogHeadV1: receiverPersistence.inventory.readAppliedCatalogHeadV1.bind( receiverPersistence.inventory, ), compareAndSwapAppliedCatalogHeadV1, - }), + }, { stageVerifiedObjects }, contentTransport), }); }; const receiver = createReceiver(receiverPersistence.inventory); @@ -808,6 +994,10 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { forgedCatalogIssuerDelegation, genesis, genesisAnnouncement, + governedGenesis, + governedGenesisAnnouncement, + governedSuccessor, + governedSuccessorAnnouncement, invalidGenesisAnnouncement, receiver, receiverBundleFetch, @@ -818,6 +1008,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { receiverPersistence, receiverStore, rowBundle, + scope, scopeDigest, successor, bootstrap: ( @@ -827,6 +1018,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ) => selectedReceiver.bootstrapEmptyPublicOpenCatalog( authorNode.peerId, selectedAnnouncement, + scope, DEPLOYMENT, signal, ), @@ -837,6 +1029,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ) => selectedReceiver.synchronizeOnePublicOpenRow( authorNode.peerId, selectedAnnouncement, + scope, DEPLOYMENT, signal, ), @@ -847,6 +1040,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ) => selectedReceiver.synchronizePublicOpenCatalog( authorNode.peerId, selectedAnnouncement, + scope, DEPLOYMENT, signal, ), @@ -854,26 +1048,38 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { } async function buildDirectCatalogIssuerDelegation(options: { + readonly scope?: AuthorCatalogScopeV1; readonly contextGraphId?: ContextGraphIdV1; readonly effectiveAt?: string; readonly expiresAt?: string; } = {}): Promise { + const scope = options.scope ?? { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1; const unsigned = testUnsignedEnvelope( AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, { - authorAddress: AUTHOR, + authorAddress: scope.authorAddress, authorAuthorityEvidenceDigest: null, - catalogEra: '0', + catalogEra: scope.era, catalogIssuerKey: AUTHOR, - contextGraphId: options.contextGraphId ?? CONTEXT_GRAPH_ID, + contextGraphId: options.contextGraphId ?? scope.contextGraphId, effectiveAt: options.effectiveAt ?? '1773899999000', expiresAt: options.expiresAt ?? '1774000000000', - governanceChainId: null, - governanceContractAddress: null, - networkId: NETWORK_ID, - ownershipTransitionDigest: null, + governanceChainId: scope.governanceChainId, + governanceContractAddress: scope.governanceContractAddress, + networkId: scope.networkId, + ownershipTransitionDigest: scope.ownershipTransitionDigest, previousDelegationDigest: null, - subGraphName: null, + subGraphName: scope.subGraphName, }, ); return signTestEnvelope( @@ -940,6 +1146,13 @@ async function signTestEnvelope( }) as SignedControlEnvelopeV1; } +function alternateRecoveryEncoding(signature: string): string { + const recovery = signature.slice(-2); + if (recovery === '1b') return `${signature.slice(0, -2)}00`; + if (recovery === '1c') return `${signature.slice(0, -2)}01`; + throw new Error('test fixture signature did not use canonical v=27/28 encoding'); +} + async function buildRowBundle( signingWallet: ethers.Wallet = AUTHOR_WALLET, ): Promise<{ row: AuthorCatalogRowV1; bundleBytes: Uint8Array }> { diff --git a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts index ff99345f77..265a65d15a 100644 --- a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts @@ -7,6 +7,7 @@ import { import { describe, expect, it, vi } from 'vitest'; import type { AppliedCatalogHeadSnapshotV1 } from '../src/rfc64/inventory-v1/index.js'; +import { buildOpenOwnerContextGraphPolicyV1 } from '../src/rfc64/open-catalog-policy-v1.js'; import { createRfc64PublicOpenCatalogNativeReconcilerV1, deriveRfc64PublicOpenCatalogScopeV1, @@ -33,6 +34,15 @@ const DEPLOYMENT = Object.freeze({ assertedAtChainId: '20430', assertedAtKav10Address: KAV10, }) as CatalogSealDeploymentProfileV1; +const ACCEPTED_POLICY = buildOpenOwnerContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, +}); + +const resolveTrustedCatalogScope = ( + input: Rfc64PublicCatalogHeadAnnouncementV1, +) => deriveRfc64PublicOpenCatalogScopeV1(input, ACCEPTED_POLICY); function announcement( catalogVersion = '0', @@ -94,6 +104,7 @@ describe('RFC-64 public/open native reconciler v1', () => { const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ nativeReceiver: receiver(synchronize), inventory: { readAppliedCatalogHeadV1: () => null }, + resolveTrustedCatalogScope, resolveDeployment, }); const signal = new AbortController().signal; @@ -105,8 +116,22 @@ describe('RFC-64 public/open native reconciler v1', () => { expect(resolveDeployment).toHaveBeenNthCalledWith(1, genesis, signal); expect(resolveDeployment).toHaveBeenNthCalledWith(2, successor, signal); - expect(synchronize).toHaveBeenNthCalledWith(1, 'peer-a', genesis, DEPLOYMENT, signal); - expect(synchronize).toHaveBeenNthCalledWith(2, 'peer-b', successor, DEPLOYMENT, signal); + expect(synchronize).toHaveBeenNthCalledWith( + 1, + 'peer-a', + genesis, + resolveTrustedCatalogScope(genesis), + DEPLOYMENT, + signal, + ); + expect(synchronize).toHaveBeenNthCalledWith( + 2, + 'peer-b', + successor, + resolveTrustedCatalogScope(successor), + DEPLOYMENT, + signal, + ); }); it('dedupes only an exact fixed public/open applied head', async () => { @@ -114,6 +139,7 @@ describe('RFC-64 public/open native reconciler v1', () => { const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ nativeReceiver: receiver(vi.fn()), inventory: { readAppliedCatalogHeadV1 }, + resolveTrustedCatalogScope, resolveDeployment: async () => DEPLOYMENT, }); const genesis = announcement('0'); @@ -141,12 +167,12 @@ describe('RFC-64 public/open native reconciler v1', () => { } const expectedScopeDigest = computeAuthorCatalogScopeDigestV1( - deriveRfc64PublicOpenCatalogScopeV1(successor), + deriveRfc64PublicOpenCatalogScopeV1(successor, ACCEPTED_POLICY), ); expect(readAppliedCatalogHeadV1).toHaveBeenLastCalledWith(expectedScopeDigest, AUTHOR); expect(() => deriveRfc64PublicOpenCatalogScopeV1(announcement('1', { subGraphName: 'not-root' as never, - }))).toThrow('root catalog lane'); + }), ACCEPTED_POLICY)).toThrow('accepted null-governance owner policy'); }); it('maps only the explicit native not-found error and propagates all other failures', async () => { @@ -158,6 +184,7 @@ describe('RFC-64 public/open native reconciler v1', () => { const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ nativeReceiver: receiver(synchronize), inventory: { readAppliedCatalogHeadV1: () => null }, + resolveTrustedCatalogScope, resolveDeployment: async () => DEPLOYMENT, }); const signal = new AbortController().signal; @@ -186,6 +213,7 @@ describe('RFC-64 public/open native reconciler v1', () => { const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ nativeReceiver: receiver(synchronize), inventory: { readAppliedCatalogHeadV1: () => null }, + resolveTrustedCatalogScope, resolveDeployment, }); const signal = new AbortController().signal; diff --git a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts index bfac95e561..1a4ec9d6b8 100644 --- a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts @@ -3,6 +3,8 @@ import { AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, DKGNode, ProtocolRouter, + canonicalizeSignedControlEnvelopeBytes, + computeControlSignatureVariantDigestHex, encodeOpaqueKaBundleV1, type AuthorCatalogScopeV1, type Digest32V1, @@ -21,6 +23,7 @@ import { RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, Rfc64PublicCatalogNativeTransportV1, type Rfc64PublicCatalogNativeFetchScopeV1, + Rfc64PublicCatalogNativeTransportErrorV1, } from '../src/rfc64/public-catalog-native-transport-v1.js'; const AUTHOR_WALLET = new ethers.Wallet(`0x${'65'.repeat(32)}`); @@ -216,4 +219,92 @@ describe('RFC-64 public catalog native content transport v1', () => { }, { timeoutMs: 4_000 })).rejects.toThrow(/policy/); expect(providerRead).not.toHaveBeenCalled(); }, 15_000); + + it('rejects a generic signature proof minted for another exact signature variant', async () => { + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope: { + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/variant-binding', + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1773900000000', + signer: { + issuer: AUTHOR, + signDigest: async (digest) => AUTHOR_WALLET.signMessage(digest), + }, + }); + const original = produced.directoryPath[0]!; + const originalProof = await verifyControlEnvelopeIssuerSignatureV1(original); + const alternate = Object.freeze({ + ...original, + signature: alternateRecoveryEncoding(original.signature), + }) as SignedControlEnvelopeV1; + expect(ethers.verifyMessage( + ethers.getBytes(alternate.objectDigest), + alternate.signature, + ).toLowerCase()).toBe(AUTHOR); + expect(computeControlSignatureVariantDigestHex( + alternate.objectDigest, + alternate.signature, + )).not.toBe(computeControlSignatureVariantDigestHex( + original.objectDigest, + original.signature, + )); + + const bytes = canonicalizeSignedControlEnvelopeBytes(alternate); + const response = new Uint8Array(bytes.byteLength + 1); + response[0] = 1; + response.set(bytes, 1); + const router = { + register: () => {}, + unregister: () => {}, + send: async () => response, + } as unknown as ProtocolRouter; + const verifyIssuerSignature = vi.fn(async () => originalProof); + const transport = new Rfc64PublicCatalogNativeTransportV1(router, { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeOpenCatalogOperation: async () => ({ + accessPolicy: 0, + policyDigest: POLICY_DIGEST, + }), + verifyIssuerSignature, + }); + transports.push(transport); + transport.start(); + + const scope = { + networkId: produced.head.payload.networkId, + contextGraphId: produced.head.payload.contextGraphId, + subGraphName: produced.head.payload.subGraphName, + authorAddress: produced.head.payload.authorAddress, + catalogEra: produced.head.payload.era, + catalogVersion: produced.head.payload.version, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: produced.head.objectDigest, + } satisfies Rfc64PublicCatalogNativeFetchScopeV1; + await expect(transport.fetchCatalogObject('peer-a', { + ...scope, + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + targetObjectType: alternate.objectType, + targetObjectDigest: alternate.objectDigest as Digest32V1, + })).rejects.toEqual(expect.objectContaining({ + code: 'catalog-native-signature', + }) as Partial); + expect(verifyIssuerSignature).toHaveBeenCalledOnce(); + }); }); + +function alternateRecoveryEncoding(signature: string): string { + const recovery = signature.slice(-2); + if (recovery === '1b') return `${signature.slice(0, -2)}00`; + if (recovery === '1c') return `${signature.slice(0, -2)}01`; + throw new Error('test fixture signature did not use canonical v=27/28 encoding'); +} diff --git a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts index 061b2f8dd4..ecac5dca4b 100644 --- a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts @@ -36,8 +36,6 @@ import { const NETWORK_ID = 'otp:20430' as const; const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/service-lifecycle' as const; -const GOVERNANCE_CONTRACT = - '0x2222222222222222222222222222222222222222' as EvmAddressV1; const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const OTHER_WALLET = new ethers.Wallet(`0x${'65'.repeat(32)}`); const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; @@ -416,6 +414,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { expect(Object.keys(clients!)).toEqual([ 'headTransport', 'contentTransport', + 'resolveTrustedCatalogScope', 'transportTimeoutMs', ]); expect(clients!.transportTimeoutMs).toBe(4_321); @@ -434,6 +433,26 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { expect(Object.isFrozen(clients!.headTransport)).toBe(true); expect(Object.isFrozen(clients!.contentTransport)).toBe(true); + const policy = acceptPolicy(service); + expect(clients!.resolveTrustedCatalogScope(announcement(policy.policyDigest))).toEqual({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + }); + expect(() => clients!.resolveTrustedCatalogScope(announcement( + `0x${'cc'.repeat(32)}` as Digest32V1, + ))).toThrow('no matching accepted open policy generation'); + expect(() => clients!.resolveTrustedCatalogScope({ + ...announcement(policy.policyDigest), + authorAddress: OTHER_WALLET.address.toLowerCase() as EvmAddressV1, + })).toThrow('accepted null-governance owner policy'); + service.start(); service.start(); expect(createReconciler).toHaveBeenCalledTimes(1); @@ -570,8 +589,8 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { scope: { networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, - governanceChainId: '20430', - governanceContractAddress: GOVERNANCE_CONTRACT, + governanceChainId: null, + governanceContractAddress: null, ownershipTransitionDigest: null, subGraphName: null, authorAddress: AUTHOR, @@ -624,4 +643,71 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { }); await service.close(); }); + + it('refuses to stage a governed head under an accepted null-governance policy', async () => { + const router = new RecordingRouter(); + const store = controlObjects(); + const onError = vi.fn(); + const service = new Rfc64PublicCatalogServiceV1({ + router: router.asProtocolRouter(), + controlObjects: store, + receiver: { maxAttempts: 1, retryBackoffMs: 0, onError }, + }); + const policy = acceptPolicy(service); + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: '0x2222222222222222222222222222222222222222', + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt: '1773900000000' as TimestampMsV1, + signer: { + issuer: AUTHOR, + signDigest: (digest) => AUTHOR_WALLET.signMessage(digest), + }, + }); + const head: Rfc64PublicCatalogHeadAnnouncementV1 = { + kind: 'rfc64-author-catalog-head-availability-v1', + networkId: produced.head.payload.networkId, + contextGraphId: produced.head.payload.contextGraphId, + subGraphName: produced.head.payload.subGraphName, + authorAddress: produced.head.payload.authorAddress, + catalogEra: produced.head.payload.era, + catalogVersion: produced.head.payload.version, + policyDigest: policy.policyDigest, + catalogHeadObjectDigest: produced.head.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + produced.head.objectDigest, + produced.head.signature, + ), + }; + const headBytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(produced.head); + const foundResponse = new Uint8Array(headBytes.byteLength + 1); + foundResponse[0] = 1; + foundResponse.set(headBytes, 1); + router.sendResponse = async () => foundResponse; + + service.start(); + await router.invoke( + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + encodeRfc64PublicCatalogHeadAnnouncementV1(head), + ); + await service.whenReceiverIdle(); + + expect(store.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(service.stats().receiver).toMatchObject({ + stagedOnly: 0, + applied: 0, + failed: 1, + }); + await service.close(); + }); }); From 19892c1d465ea54af54e9389c32dd0eeb2e1b549 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:38:36 +0200 Subject: [PATCH 133/292] fix(agent): compose trusted RFC-64 catalog scope --- packages/agent/src/dkg-agent-rfc64-catalog.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 7e6641a724..29e32dcd68 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -544,6 +544,7 @@ export class Rfc64CatalogMethods extends DKGAgentBase { }, }), inventory: persistence.inventory, + resolveTrustedCatalogScope: clients.resolveTrustedCatalogScope, resolveDeployment, }); const deploymentAwareReconciler: Rfc64PublicCatalogReceiverReconcilerV1 = { From 0d43956734225923351676d069d26e2c19bee824 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:50:13 +0200 Subject: [PATCH 134/292] feat(agent): expose RFC-64 reconciliation failures --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/dkg-agent-base.ts | 4 + packages/agent/src/dkg-agent-rfc64-catalog.ts | 45 ++++++++- ...ublic-catalog-reconciliation-failure-v1.ts | 91 +++++++++++++++++++ ...kg-agent-native-wiring.integration.test.ts | 30 +++++- ...-catalog-reconciliation-failure-v1.test.ts | 62 +++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 8 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 packages/agent/src/rfc64/public-catalog-reconciliation-failure-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-reconciliation-failure-v1.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 88a15ff114..1ce74811f8 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -32,6 +32,7 @@ "./dist/rfc64/public-catalog-native-receiver-v1.js": null, "./dist/rfc64/public-catalog-native-reconciler-v1.js": null, "./dist/rfc64/public-catalog-native-transport-v1.js": null, + "./dist/rfc64/public-catalog-reconciliation-failure-v1.js": null, "./dist/rfc64/public-catalog-receiver-v1.js": null, "./dist/rfc64/public-catalog-service-v1.js": null, "./dist/rfc64/public-catalog-issuer-delegation-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 06ae5b466e..1209e37f5a 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -42,6 +42,7 @@ const blockedRfc64Modules = [ 'public-catalog-native-reconciler-v1.js', 'public-catalog-native-receiver-v1.js', 'public-catalog-native-transport-v1.js', + 'public-catalog-reconciliation-failure-v1.js', 'public-catalog-receiver-v1.js', 'public-catalog-service-v1.js', 'public-catalog-issuer-delegation-v1.js', diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index 8769deea2c..254b3c4fe6 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -17,6 +17,7 @@ import { } from './rfc64/persistence-v1.js'; import type { Rfc64PublicCatalogServiceV1 } from './rfc64/public-catalog-service-v1.js'; import type { Rfc64PublicCatalogNativeSynchronizationEvidenceV1 } from './rfc64/public-catalog-native-receiver-v1.js'; +import { Rfc64PublicCatalogReconciliationFailureRegistryV1 } from './rfc64/public-catalog-reconciliation-failure-v1.js'; import { resolveVmReconcileStartupMaxDelayMs } from './startup-jitter.js'; import { DKGNode, ProtocolRouter, GossipSubManager, TypedEventBus, DKGEvent, @@ -1003,6 +1004,9 @@ export class DKGAgentBase { /** Exact process-local post-verification evidence, keyed by applied head. */ protected readonly rfc64PublicCatalogSynchronizationEvidenceV1 = new Map(); + /** Bounded process-local terminal receiver failures, keyed by announced head. */ + protected readonly rfc64PublicCatalogReconciliationFailuresV1 = + new Rfc64PublicCatalogReconciliationFailureRegistryV1(); protected readonly subscribedContextGraphs = new Map(); protected contextGraphSubscriptionRehydrationStatus: ContextGraphSubscriptionRehydrationStatus | null = null; protected readonly contextGraphSubscriptionRehydrationAccountedIds = new Set(); diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 29e32dcd68..30d20af157 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -66,6 +66,7 @@ import { type Rfc64PublicCatalogServiceStatsV1, } from './rfc64/public-catalog-service-v1.js'; import { + Rfc64PublicCatalogNativeReceiverErrorV1, Rfc64PublicCatalogNativeReceiverV1, type Rfc64PublicCatalogNativeSynchronizationEvidenceV1, } from './rfc64/public-catalog-native-receiver-v1.js'; @@ -74,6 +75,9 @@ import { type Rfc64PublicOpenCatalogDeploymentResolverV1, } from './rfc64/public-catalog-native-reconciler-v1.js'; import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; +import type { + Rfc64PublicCatalogReconciliationFailureV1, +} from './rfc64/public-catalog-reconciliation-failure-v1.js'; import { Rfc64PublicCatalogSuccessorProducerV1, type Rfc64PublicCatalogIssuerAuthorizationV1, @@ -133,6 +137,11 @@ export interface Rfc64AppliedCatalogHeadRefV1 { readonly authorAddress: EvmAddressV1; } +export { + RFC64_PUBLIC_CATALOG_RECONCILIATION_FAILURE_MAX_ENTRIES_V1, + type Rfc64PublicCatalogReconciliationFailureV1, +} from './rfc64/public-catalog-reconciliation-failure-v1.js'; + /** * Validate and detach a locally configured deployment tuple from caller-owned * state. Exported so `DKGAgent.create()` can snapshot the override before any @@ -225,6 +234,14 @@ export class Rfc64CatalogMethods extends DKGAgentBase { router: this.router, controlObjects: persistence.controlObjects, native: this.createRfc64PublicCatalogNativeOptionsV1(), + receiver: { + onError: (announcement, error) => { + this.rfc64PublicCatalogReconciliationFailuresV1.record( + announcement.catalogHeadObjectDigest, + error, + ); + }, + }, }); service.start(); this.rfc64PublicCatalogServiceV1 = service; @@ -239,6 +256,7 @@ export class Rfc64CatalogMethods extends DKGAgentBase { await service?.close(); } finally { this.rfc64PublicCatalogSynchronizationEvidenceV1.clear(); + this.rfc64PublicCatalogReconciliationFailuresV1.clear(); } } @@ -467,6 +485,27 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return evidence === undefined ? null : Object.freeze({ ...evidence }); } + /** + * Read the immutable process-local terminal failure for one announced head. + * This is diagnostic evidence only; it is neither durable nor an input to + * receiver retry, deduplication, reconciliation, or authorization decisions. + */ + readRfc64PublicCatalogReconciliationFailureV1( + this: DKGAgent, + catalogHeadDigest: Digest32V1, + ): Rfc64PublicCatalogReconciliationFailureV1 | null { + const failure = this.rfc64PublicCatalogReconciliationFailuresV1.read( + catalogHeadDigest, + ); + return failure === null + ? null + : Object.freeze({ + catalogHeadDigest: failure.catalogHeadDigest, + errorName: failure.errorName, + errorCode: failure.errorCode, + }); + } + /** Read a fresh copy of one exact durably staged opaque KA bundle. */ readRfc64StagedKaBundleV1( this: DKGAgent, @@ -574,9 +613,9 @@ export class Rfc64CatalogMethods extends DKGAgentBase { ); } if (trustedNetworkId !== catalogNetworkId) { - throw new Error( - `RFC-64 catalog network ${catalogNetworkId} differs from locally trusted ` - + `deployment network ${trustedNetworkId}`, + throw new Rfc64PublicCatalogNativeReceiverErrorV1( + 'catalog-native-receiver-authorization', + 'catalog network differs from the locally trusted deployment network', ); } } diff --git a/packages/agent/src/rfc64/public-catalog-reconciliation-failure-v1.ts b/packages/agent/src/rfc64/public-catalog-reconciliation-failure-v1.ts new file mode 100644 index 0000000000..d5e6c56ca6 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-reconciliation-failure-v1.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { Digest32V1 } from '@origintrail-official/dkg-core'; + +/** Stable process-local evidence for one scheduler-terminal reconciliation failure. */ +export interface Rfc64PublicCatalogReconciliationFailureV1 { + readonly catalogHeadDigest: Digest32V1; + readonly errorName: string; + readonly errorCode: string | null; +} + +/** Hard process-memory bound for distinct terminal receiver failures. */ +export const RFC64_PUBLIC_CATALOG_RECONCILIATION_FAILURE_MAX_ENTRIES_V1 = 128; + +const STABLE_ERROR_TOKEN_MAX_LENGTH_V1 = 128; +const STABLE_ERROR_TOKEN_V1 = /^[A-Za-z][A-Za-z0-9._:-]*$/; + +/** + * Internal process-local registry owned by DKGAgent. It is intentionally not + * exported from the package root; the agent exposes only its read method. + */ +export class Rfc64PublicCatalogReconciliationFailureRegistryV1 { + readonly #failures = new Map(); + + /** Scheduler-terminal callback sink. The first failure for one head wins. */ + record(catalogHeadDigest: Digest32V1, error: unknown): void { + if (this.#failures.has(catalogHeadDigest)) return; + if ( + this.#failures.size + >= RFC64_PUBLIC_CATALOG_RECONCILIATION_FAILURE_MAX_ENTRIES_V1 + ) { + const oldestCatalogHeadDigest = this.#failures.keys().next().value as + | Digest32V1 + | undefined; + if (oldestCatalogHeadDigest !== undefined) { + this.#failures.delete(oldestCatalogHeadDigest); + } + } + this.#failures.set(catalogHeadDigest, Object.freeze({ + catalogHeadDigest, + errorName: stableErrorNameV1(error), + errorCode: stableErrorCodeV1(error), + })); + } + + read(catalogHeadDigest: Digest32V1): Rfc64PublicCatalogReconciliationFailureV1 | null { + const failure = this.#failures.get(catalogHeadDigest); + return failure === undefined ? null : failure; + } + + clear(): void { + this.#failures.clear(); + } + + /** Internal test/diagnostic bound assertion; never exposed on DKGAgent. */ + get size(): number { + return this.#failures.size; + } +} + +function stableErrorNameV1(error: unknown): string { + let candidate: unknown; + try { + candidate = error instanceof Error ? error.name : undefined; + } catch { + return 'UnknownError'; + } + return stableErrorTokenV1(candidate) ?? 'UnknownError'; +} + +function stableErrorCodeV1(error: unknown): string | null { + if ((typeof error !== 'object' && typeof error !== 'function') || error === null) { + return null; + } + let candidate: unknown; + try { + candidate = (error as { readonly code?: unknown }).code; + } catch { + return null; + } + return stableErrorTokenV1(candidate); +} + +function stableErrorTokenV1(value: unknown): string | null { + return typeof value === 'string' + && value.length > 0 + && value.length <= STABLE_ERROR_TOKEN_MAX_LENGTH_V1 + && STABLE_ERROR_TOKEN_V1.test(value) + ? value + : null; +} diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index f61ded1d2d..2ed37c0112 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -162,6 +162,9 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { stagedObjectCount: 3, appliedHeadStatus: 'applied', }); + expect(receiver.readRfc64PublicCatalogReconciliationFailureV1( + published.headObjectDigest, + )).toBeNull(); expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ applied: 1, stagedOnly: 0, @@ -178,9 +181,12 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { applied: 1, dedupedAlreadyApplied: 1, }); + expect(receiver.readRfc64PublicCatalogReconciliationFailureV1( + published.headObjectDigest, + )).toBeNull(); }, 60_000); - it('fails closed when wire network identity differs from the local deployment', async () => { + it('exposes bounded typed failure evidence when wire scope differs from local deployment', async () => { const [author, receiver] = await Promise.all([ startNativeAgent('mismatch-author'), startNativeAgent('mismatch-receiver', { @@ -216,6 +222,21 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { applied: 0, failed: 1, }); + const failure = receiver.readRfc64PublicCatalogReconciliationFailureV1( + published.headObjectDigest, + ); + expect(failure).toEqual({ + catalogHeadDigest: published.headObjectDigest, + errorName: 'Rfc64PublicCatalogNativeReceiverErrorV1', + errorCode: 'catalog-native-receiver-authorization', + }); + expect(Object.isFrozen(failure)).toBe(true); + + await receiver.stop(); + agents.splice(agents.indexOf(receiver), 1); + expect(receiver.readRfc64PublicCatalogReconciliationFailureV1( + published.headObjectDigest, + )).toBeNull(); }, 60_000); it('rejects a stale-network durable dedupe after restart under a new local pin', async () => { @@ -272,5 +293,12 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { expect(restartedReceiver.readRfc64PublicCatalogSynchronizationEvidenceV1( published.headObjectDigest, )).toBeNull(); + expect(restartedReceiver.readRfc64PublicCatalogReconciliationFailureV1( + published.headObjectDigest, + )).toEqual({ + catalogHeadDigest: published.headObjectDigest, + errorName: 'Rfc64PublicCatalogNativeReceiverErrorV1', + errorCode: 'catalog-native-receiver-authorization', + }); }, 60_000); }); diff --git a/packages/agent/test/rfc64-public-catalog-reconciliation-failure-v1.test.ts b/packages/agent/test/rfc64-public-catalog-reconciliation-failure-v1.test.ts new file mode 100644 index 0000000000..c586826704 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-reconciliation-failure-v1.test.ts @@ -0,0 +1,62 @@ +import type { Digest32V1 } from '@origintrail-official/dkg-core'; +import { describe, expect, it } from 'vitest'; + +import { Rfc64PublicCatalogNativeReceiverErrorV1 } from '../src/rfc64/public-catalog-native-receiver-v1.js'; +import { + RFC64_PUBLIC_CATALOG_RECONCILIATION_FAILURE_MAX_ENTRIES_V1, + Rfc64PublicCatalogReconciliationFailureRegistryV1, +} from '../src/rfc64/public-catalog-reconciliation-failure-v1.js'; + +function digest(index: number): Digest32V1 { + return `0x${index.toString(16).padStart(64, '0')}` as Digest32V1; +} + +describe('RFC-64 public catalog terminal failure registry v1', () => { + it('retains immutable typed identities with deterministic oldest-first eviction', () => { + const registry = new Rfc64PublicCatalogReconciliationFailureRegistryV1(); + const terminalError = new Rfc64PublicCatalogNativeReceiverErrorV1( + 'catalog-native-receiver-authorization', + 'nondeterministic message text is deliberately excluded', + ); + for ( + let index = 1; + index <= RFC64_PUBLIC_CATALOG_RECONCILIATION_FAILURE_MAX_ENTRIES_V1 + 1; + index += 1 + ) { + registry.record(digest(index), terminalError); + } + + expect(registry.size).toBe( + RFC64_PUBLIC_CATALOG_RECONCILIATION_FAILURE_MAX_ENTRIES_V1, + ); + expect(registry.read(digest(1))).toBeNull(); + const retained = registry.read(digest(2)); + expect(retained).toEqual({ + catalogHeadDigest: digest(2), + errorName: 'Rfc64PublicCatalogNativeReceiverErrorV1', + errorCode: 'catalog-native-receiver-authorization', + }); + expect(Object.isFrozen(retained)).toBe(true); + + registry.record(digest(2), Object.assign(new Error('later'), { code: 'later-code' })); + expect(registry.read(digest(2))).toEqual(retained); + }); + + it('normalizes unstable identities and clears all process-local state', () => { + const registry = new Rfc64PublicCatalogReconciliationFailureRegistryV1(); + const malformed = Object.assign(new Error('contains /tmp/random/path'), { + name: 'invalid name with spaces', + code: `x${'!'.repeat(128)}`, + }); + registry.record(digest(1), malformed); + expect(registry.read(digest(1))).toEqual({ + catalogHeadDigest: digest(1), + errorName: 'UnknownError', + errorCode: null, + }); + + registry.clear(); + expect(registry.size).toBe(0); + expect(registry.read(digest(1))).toBeNull(); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index a924b2a564..37c6730c49 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -122,6 +122,7 @@ export default defineConfig({ "test/rfc64-secure-filesystem-policy-v1.test.ts", "test/rfc64-public-catalog-transport-v1.test.ts", "test/rfc64-public-catalog-receiver-v1.test.ts", + "test/rfc64-public-catalog-reconciliation-failure-v1.test.ts", "test/rfc64-public-catalog-service-v1.test.ts", "test/rfc64-public-catalog-issuer-delegation-v1.test.ts", "test/rfc64-public-catalog-gate1.integration.test.ts", From 8f45ed2c5c597c3d1423e968ab6900bb803ade1e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 20:52:46 +0200 Subject: [PATCH 135/292] test(devnet): drive Gate 1 through production APIs --- devnet/rfc64-gate1-public-open/README.md | 35 +- .../adapter-process.ts | 293 +++++- devnet/rfc64-gate1-public-open/package.json | 2 + devnet/rfc64-gate1-public-open/run.ts | 853 +++++++++++++++++- .../rfc64-gate1-public-open/verifier.test.ts | 30 +- devnet/rfc64-gate1-public-open/verifier.ts | 46 +- pnpm-lock.yaml | 6 + 7 files changed, 1153 insertions(+), 112 deletions(-) diff --git a/devnet/rfc64-gate1-public-open/README.md b/devnet/rfc64-gate1-public-open/README.md index 81532c81ab..a79cd697f8 100644 --- a/devnet/rfc64-gate1-public-open/README.md +++ b/devnet/rfc64-gate1-public-open/README.md @@ -4,8 +4,9 @@ This harness has a closed evidence schema and a real process boundary. It boots an author and receiver as separate `DKGAgent` OS processes, connects them over libp2p, derives the same open policy independently on both nodes, exercises the production RFC-64 catalog APIs, kills the receiver with `SIGKILL`, restarts it -against the same durable directory, and requires explicit reannouncement plus -idempotent replay. +against the same durable directory, and requires explicit reannouncement of the +exact accepted successor plus durable idempotent dedupe and exact unchanged SWM +readback. There is no fixture adapter and no fallback product result. Missing methods, unexpected return shapes, failed process cleanup, stale artifacts, and any @@ -29,13 +30,19 @@ The adapter currently maps those operations to: - `DKGAgent.readRfc64PublicCatalogSynchronizationEvidenceV1` - harness-owned `SIGKILL` and process replacement -At combined base `591ff321e9cd88f91206d15a87c882873f005c37`, only -genesis publication exists on `DKGAgent`; the other product methods are being -assembled in the native-wiring lane. The harness therefore boots and connects -real agents, then exits non-zero with the exact missing method list. It does not -write a raw or verdict artifact on that base. A follow-up composition against -the native-wiring commit completes the six-operation scenario and result -mapping. +The harness maps every positive and restart field from production publication, +durable applied-head, synchronization-evidence, and staged-bundle readbacks. Its +adversarial author process stages a cryptographically valid head whose claimed +catalog author is bound to a different recovered direct-author delegation; the +real receiver must reject that mismatch without changing the previously applied +inventory or SWM projection. + +The negative remains fail-closed until the product exposes the terminal typed +receiver result through +`readRfc64PublicCatalogReconciliationFailureV1(catalogHeadDigest)`. Aggregate +failure counters are insufficient evidence for the frozen authorization error +code. Missing read-only observability prevents artifact publication but does not +weaken or alter the verifier schema. The preserved raw schema requires production-returned evidence for: @@ -48,10 +55,12 @@ The preserved raw schema requires production-returned evidence for: - a real `SIGKILL`, restart with the same peer identity and durable directory, explicit reannouncement, and exact replay without duplicate activation. -The replay successor deliberately reuses the same row/content/bundle. Its head -and version advance, while the product inventory digest remains equal because -the digest commits to catalog scope plus row/content/seal/UAL/count—not head. -No durable repair intent or automatic startup repair is claimed. +The restart path deliberately reannounces the same accepted successor head. It +does not invent a no-op catalog version: ordinary catalog publication correctly +rejects an unchanged bucket. The durable applied head and exact SWM projection +remain identical, the restarted receiver reports an already-applied dedupe, and +the explicit announcement is acknowledged. No durable repair intent or +automatic startup repair is claimed. Build the agent and dependencies, then run the complete generator and verifier: diff --git a/devnet/rfc64-gate1-public-open/adapter-process.ts b/devnet/rfc64-gate1-public-open/adapter-process.ts index a6ebc2c65f..f70cf33105 100644 --- a/devnet/rfc64-gate1-public-open/adapter-process.ts +++ b/devnet/rfc64-gate1-public-open/adapter-process.ts @@ -4,8 +4,25 @@ import process from 'node:process'; import { createInterface } from 'node:readline'; import { multiaddr } from '@multiformats/multiaddr'; -import { DKGAgent } from '@origintrail-official/dkg-agent'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { + DKGAgent, + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + produceDirectAuthorCatalogIssuerDelegationV1, + produceEmptyAuthorCatalogGenesisV1, +} from '@origintrail-official/dkg-agent'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { + computeControlSignatureVariantDigestHex, + type AuthorCatalogScopeV1, + type Digest32V1, + type EvmAddressV1, + type SignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + OxigraphStore, + quadsToNQuads, + type Quad, +} from '@origintrail-official/dkg-storage'; import { ethers } from 'ethers'; import { @@ -30,6 +47,11 @@ if (!masterKeyHex || !/^[0-9a-f]{64}$/u.test(masterKeyHex)) { const dataDir = resolve(dataDirInput); const pinnedMasterKeyHex = masterKeyHex; +const RFC64_GATE1_DEPLOYMENT = Object.freeze({ + networkId: 'otp:20430', + assertedAtChainId: '20430', + assertedAtKav10Address: '0x4444444444444444444444444444444444444444', +}); let agent: DKGAgent | undefined; let stopping = false; let commandTail = Promise.resolve(); @@ -97,6 +119,7 @@ async function boot(): Promise { syncOnConnectEnabled: false, durableSyncEnabled: false, agentProfileHeartbeatMs: 0, + rfc64CatalogDeploymentProfile: RFC64_GATE1_DEPLOYMENT as never, }); agent = created; await created.start(); @@ -165,12 +188,24 @@ async function handle(command: Command): Promise { } const method = requireGate1ProductMethod(currentAgent, command.command); const output = await method(forwarded); - emitOperationResult(command, output); + if (command.command === 'publishSuccessor') { + const result = plainRecord(output, 'publishSuccessor output'); + const bundleDigest = requiredDigest(result.bundleDigest, 'publishSuccessor.bundleDigest'); + const stagedBundle = await currentAgent.readRfc64StagedKaBundleV1(bundleDigest); + if (stagedBundle === null) { + throw new Error('published successor bundle is absent from durable product storage'); + } + emitOperationResult(command, { + ...result, + stagedBundleByteLength: stagedBundle.byteLength, + }); + } else { + emitOperationResult(command, output); + } return; } case 'announce': - case 'appliedHeadReadback': - case 'exactInventoryReadback': { + case 'appliedHeadReadback': { const requiredRole = command.command === 'announce' ? 'author' : 'receiver'; requireRole(requiredRole); const method = requireGate1ProductMethod( @@ -181,6 +216,83 @@ async function handle(command: Command): Promise { emitOperationResult(command, output); return; } + case 'exactInventoryReadback': { + requireRole('receiver'); + const input = plainRecord(command.input, 'exactInventoryReadback input'); + const catalogHeadDigest = requiredDigest( + input.catalogHeadDigest, + 'exactInventoryReadback.catalogHeadDigest', + ); + const output = currentAgent.readRfc64PublicCatalogSynchronizationEvidenceV1( + catalogHeadDigest, + ); + emitOperationResult(command, wireSynchronizationEvidence(output)); + return; + } + case 'prepareForgedAuthorizationGenesis': { + requireRole('author'); + const output = await prepareForgedAuthorizationGenesis( + currentAgent, + plainRecord(command.input, 'prepareForgedAuthorizationGenesis input'), + ); + emitOperationResult(command, output); + return; + } + case 'receiverStats': { + requireRole('receiver'); + emitOperationResult(command, currentAgent.rfc64PublicCatalogStatsV1()?.receiver ?? null); + return; + } + case 'semanticGraphReadback': { + requireRole('receiver'); + const input = plainRecord(command.input, 'semanticGraphReadback input'); + const swmGraph = requiredString(input.swmGraph, 'semanticGraphReadback.swmGraph'); + if (!/^did:dkg:context-graph:[A-Za-z0-9:._/-]+$/u.test(swmGraph)) { + throw new TypeError('semanticGraphReadback.swmGraph is not a safe production graph IRI'); + } + const result = await currentAgent.store.query( + `CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <${swmGraph}> { ?s ?p ?o } }`, + { source: 'rfc64-gate1-semantic-readback' }, + ); + if (result.type !== 'quads') { + throw new Error('semantic graph readback did not return quads'); + } + const quads = [...result.quads] + .map((quad): Quad => ({ ...quad, graph: '' })) + .sort(compareQuad); + emitOperationResult(command, { + activatedQuadCount: quads.length, + projectionNQuads: `${quadsToNQuads(quads)}\n`, + swmGraph, + }); + return; + } + case 'terminalFailureReadback': { + requireRole('receiver'); + const input = plainRecord(command.input, 'terminalFailureReadback input'); + const catalogHeadDigest = requiredDigest( + input.catalogHeadDigest, + 'terminalFailureReadback.catalogHeadDigest', + ); + const method = (currentAgent as unknown as Record)[ + 'readRfc64PublicCatalogReconciliationFailureV1' + ]; + if (typeof method !== 'function') { + throw new Error( + 'missing read-only product API: ' + + 'DKGAgent.readRfc64PublicCatalogReconciliationFailureV1(' + + 'catalogHeadDigest: Digest32V1): ' + + '{ catalogHeadDigest: Digest32V1; errorName: string; errorCode: ' + + 'Rfc64PublicCatalogNativeReceiverErrorCodeV1 | null } | null', + ); + } + const output = await (method as (digest: string) => unknown).call( + currentAgent, + catalogHeadDigest, + ); + emitOperationResult(command, output); + return; + } case 'awaitReceiverIdle': requireRole('receiver'); await currentAgent.whenRfc64PublicCatalogReceiverIdleV1(); @@ -201,6 +313,170 @@ async function handle(command: Command): Promise { } } +function compareQuad(left: Quad, right: Quad): number { + const leftKey = `${left.subject}\n${left.predicate}\n${left.object}\n${left.graph}`; + const rightKey = `${right.subject}\n${right.predicate}\n${right.object}\n${right.graph}`; + return leftKey.localeCompare(rightKey); +} + +function wireSynchronizationEvidence(output: unknown): unknown { + if (output === null) return null; + const evidence = plainRecord(output, 'exact synchronization evidence'); + if (evidence.inventoryRowCount !== 1) return evidence; + const authorship = plainRecord(evidence.authorship, 'synchronization authorship'); + const path = authorship.directoryPathObjectDigests; + const variants = authorship.directoryPathSignatureVariantDigests; + if (!Array.isArray(path) || !Array.isArray(variants) || path.length !== variants.length) { + throw new Error('synchronization authorship path evidence is incomplete'); + } + const { authorship: _authorship, ...wire } = evidence; + return Object.freeze({ + ...wire, + verifiedControlObjectCount: 3 + path.length, + }); +} + +interface HarnessControlObjectPersistenceV1 { + readonly controlObjects: { + stageVerifiedObjects(input: readonly HarnessVerifiedControlObjectV1[]): Promise<{ + readonly objects: ReadonlyArray<{ + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + }>; + }>; + }; +} + +interface HarnessVerifiedControlObjectV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: Awaited>; +} + +/** + * Harness-only adversarial setup. Both signatures are cryptographically valid, + * but the head claims the catalog author while naming an attacker-scoped + * direct-author delegation. The product receiver must reject that exact scope + * mismatch before activation or applied-head mutation. + */ +async function prepareForgedAuthorizationGenesis( + currentAgent: DKGAgent, + input: Record, +): Promise> { + const networkId = requiredString(input.networkId, 'forged.networkId'); + const contextGraphId = requiredString(input.contextGraphId, 'forged.contextGraphId'); + const policyDigest = requiredDigest(input.policyDigest, 'forged.policyDigest'); + const catalogAuthorPrivateKey = requiredString( + input.catalogAuthorPrivateKey, + 'forged.catalogAuthorPrivateKey', + ); + const attackerPrivateKey = requiredString( + input.attackerPrivateKey, + 'forged.attackerPrivateKey', + ); + const issuedAt = requiredString(input.issuedAt, 'forged.issuedAt'); + const delegationEffectiveAt = requiredString( + input.delegationEffectiveAt, + 'forged.delegationEffectiveAt', + ); + const delegationExpiresAt = requiredString( + input.delegationExpiresAt, + 'forged.delegationExpiresAt', + ); + const catalogAuthor = new ethers.Wallet(catalogAuthorPrivateKey); + const attacker = new ethers.Wallet(attackerPrivateKey); + const catalogAuthorAddress = catalogAuthor.address.toLowerCase() as EvmAddressV1; + const recoveredAuthorAddress = attacker.address.toLowerCase() as EvmAddressV1; + const attackerScope = catalogScope( + networkId, + contextGraphId, + recoveredAuthorAddress, + ); + const claimedCatalogScope = catalogScope( + networkId, + contextGraphId, + catalogAuthorAddress, + ); + const forgedDelegation = await produceDirectAuthorCatalogIssuerDelegationV1({ + scope: attackerScope, + signer: { + issuer: recoveredAuthorAddress, + signDigest: (digest) => attacker.signMessage(digest), + }, + effectiveAt: delegationEffectiveAt as never, + expiresAt: delegationExpiresAt as never, + catalogHeadIssuedAt: issuedAt as never, + }); + const forgedGenesis = await produceEmptyAuthorCatalogGenesisV1({ + scope: claimedCatalogScope, + catalogIssuerDelegationDigest: + forgedDelegation.authorization.catalogIssuerDelegation.objectDigest as Digest32V1, + issuedAt: issuedAt as never, + signer: { + issuer: catalogAuthorAddress, + signDigest: (digest) => catalogAuthor.signMessage(digest), + }, + }); + const persistence = ( + currentAgent as unknown as { rfc64PersistenceV1?: HarnessControlObjectPersistenceV1 } + ).rfc64PersistenceV1; + if (persistence === undefined) throw new Error('RFC-64 persistence is unavailable'); + await persistence.controlObjects.stageVerifiedObjects([{ + envelope: forgedDelegation.authorization.catalogIssuerDelegation, + issuerSignature: forgedDelegation.issuerSignature, + }]); + const verifiedObjects = await Promise.all(forgedGenesis.stagedObjects.map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }))); + const staged = await persistence.controlObjects.stageVerifiedObjects(verifiedObjects); + const head = forgedGenesis.head; + const headReceipt = staged.objects.find((entry) => entry.objectDigest === head.objectDigest); + if ( + headReceipt === undefined + || headReceipt.signatureVariantDigest !== computeControlSignatureVariantDigestHex( + head.objectDigest, + head.signature, + ) + ) { + throw new Error('forged authorization head did not receive an exact durable stage receipt'); + } + return Object.freeze({ + announcement: Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: head.payload.networkId, + contextGraphId: head.payload.contextGraphId, + subGraphName: head.payload.subGraphName, + authorAddress: head.payload.authorAddress, + catalogEra: head.payload.era, + catalogVersion: head.payload.version, + policyDigest, + catalogHeadObjectDigest: headReceipt.objectDigest, + signatureVariantDigest: headReceipt.signatureVariantDigest, + }), + attemptedCatalogHeadDigest: headReceipt.objectDigest, + catalogAuthorAddress, + recoveredAuthorAddress, + }); +} + +function catalogScope( + networkId: string, + contextGraphId: string, + authorAddress: EvmAddressV1, +): AuthorCatalogScopeV1 { + return Object.freeze({ + networkId, + contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress, + era: '0', + bucketCount: '1', + }) as AuthorCatalogScopeV1; +} + function emitOperationResult(command: Command, output: unknown): void { assertJsonWireValue(output, `${command.command} output`); emit({ @@ -271,6 +547,13 @@ function requiredString(value: unknown, label: string): string { return value; } +function requiredDigest(value: unknown, label: string): Digest32V1 { + if (typeof value !== 'string' || !/^0x[0-9a-f]{64}$/u.test(value)) { + throw new TypeError(`${label} must be a canonical digest`); + } + return value as Digest32V1; +} + function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error; } diff --git a/devnet/rfc64-gate1-public-open/package.json b/devnet/rfc64-gate1-public-open/package.json index 0cb9a7ff16..24fea9bfb3 100644 --- a/devnet/rfc64-gate1-public-open/package.json +++ b/devnet/rfc64-gate1-public-open/package.json @@ -6,6 +6,8 @@ "dependencies": { "@multiformats/multiaddr": "^13.0.3", "@origintrail-official/dkg-agent": "workspace:*", + "@origintrail-official/dkg-chain": "workspace:*", + "@origintrail-official/dkg-core": "workspace:*", "@origintrail-official/dkg-storage": "workspace:*", "ethers": "^6.16.0" } diff --git a/devnet/rfc64-gate1-public-open/run.ts b/devnet/rfc64-gate1-public-open/run.ts index bdbac3b54b..38ef7e4641 100644 --- a/devnet/rfc64-gate1-public-open/run.ts +++ b/devnet/rfc64-gate1-public-open/run.ts @@ -3,17 +3,35 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import process from 'node:process'; +import { + assertCanonicalGraphScopedAuthorSealV1, + buildAuthorAttestationTypedData, + computeAuthorCatalogScopeDigestV1, + type CanonicalGraphScopedAuthorSealV1, +} from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; -import { readCleanRepositoryHead } from '../rfc64-persistence-lifecycle/evidence.js'; +import { + atomicWriteStableJson, + readCleanRepositoryHead, + stableJson, +} from '../rfc64-persistence-lifecycle/evidence.js'; import { ChildProcessRegistry, cleanupPreservingPrimaryFailure, + type ProcessExitEvidence, } from '../rfc64-persistence-lifecycle/process-lifecycle.js'; import { Gate1AgentChild, type Gate1AgentEvent } from './agent-child.js'; import { GATE1_ADAPTER_PROTOCOL_VERSION, + GATE1_RAW_SCHEMA_VERSION, GATE1_REAL_DKG_AGENT_ADAPTER_ID, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + appliedReadBackFromTransfer, + semanticReadBackFromTransfer, + type Gate1AppliedHeadReadBack, + type Gate1ForgedEvidence, + type Gate1TransferEvidence, } from './model.js'; import { assertGate1ProductCapabilities } from './product-capabilities.js'; @@ -25,8 +43,33 @@ const PROCESS_TIMEOUT_MS = 60_000; const NETWORK_ID = 'otp:20430'; const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/gate-1'; +const FORGED_CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/gate-1-forged-authorization'; const AUTHOR_PRIVATE_KEY = `0x${'64'.repeat(32)}`; -const AUTHOR_ADDRESS = new ethers.Wallet(AUTHOR_PRIVATE_KEY).address.toLowerCase(); +const ATTACKER_PRIVATE_KEY = `0x${'65'.repeat(32)}`; +const AUTHOR_WALLET = new ethers.Wallet(AUTHOR_PRIVATE_KEY); +const AUTHOR_ADDRESS = AUTHOR_WALLET.address.toLowerCase(); +const ATTACKER_ADDRESS = new ethers.Wallet(ATTACKER_PRIVATE_KEY).address.toLowerCase(); +const KAV10_ADDRESS = '0x4444444444444444444444444444444444444444'; +const DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10_ADDRESS, +}); +const KA_NUMBER = 7n; +const KA_ID = ((BigInt(AUTHOR_ADDRESS) << 96n) | KA_NUMBER).toString(); +const KA_UAL = `did:dkg:${NETWORK_ID}/${AUTHOR_ADDRESS}/${KA_NUMBER}`; +const ASSERTION_ROOT = + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f'; +const PROJECTION_NQUADS = + ' ' + + '"42"^^ .\n' + + ' "Alice" .\n'; +const GENESIS_ISSUED_AT = '1773900000000'; +const POSITIVE_ISSUED_AT = '1773900001000'; +const FORGED_ISSUED_AT = '1773900003000'; +const DELEGATION_EFFECTIVE_AT = '1773899999000'; +const DELEGATION_EXPIRES_AT = '1774000000000'; const ROLE_MASTER_KEYS = Object.freeze({ author: '1a'.repeat(32), receiver: '2b'.repeat(32), @@ -37,8 +80,6 @@ async function execute(): Promise { const rawArtifactPath = process.env.DKG_RFC64_GATE1_ARTIFACT ?? DEFAULT_RAW_ARTIFACT; const verdictArtifactPath = process.env.DKG_RFC64_GATE1_VERDICT_ARTIFACT ?? DEFAULT_VERDICT_ARTIFACT; - // A failed production exercise must never leave a fixture-era PASS available - // for a later standalone verifier invocation. rmSync(rawArtifactPath, { force: true }); rmSync(verdictArtifactPath, { force: true }); @@ -57,53 +98,410 @@ async function execute(): Promise { requireRealReady(authorReady, 'author'); requireRealReady(receiverReady, 'receiver'); requireCondition(authorReady.peerId !== receiverReady.peerId, 'peer identities are not distinct'); + await connectBothWays(author, receiver, authorReady, receiverReady, 'initial'); - await Promise.all([ - receiver.request('dial', 'receiver-dial-author-v1', 'dialed', { - multiaddr: authorReady.multiaddr, - peerId: authorReady.peerId, - }), - author.request('dial', 'author-dial-receiver-v1', 'dialed', { - multiaddr: receiverReady.multiaddr, - peerId: receiverReady.peerId, - }), - ]); - - // Both sides derive the accepted open policy from independently supplied - // scenario identity facts; no announcement is trusted as an authorization oracle. const [authorPolicy, receiverPolicy] = await Promise.all([ - author.request('acceptOpenPolicy', 'author-policy-v1', 'operation-completed', { - networkId: NETWORK_ID, - contextGraphId: CONTEXT_GRAPH_ID, - ownerAddress: AUTHOR_ADDRESS, - }), - receiver.request('acceptOpenPolicy', 'receiver-policy-v1', 'operation-completed', { - networkId: NETWORK_ID, - contextGraphId: CONTEXT_GRAPH_ID, - ownerAddress: AUTHOR_ADDRESS, - }), + acceptPolicy(author, 'author-policy-v1', CONTEXT_GRAPH_ID), + acceptPolicy(receiver, 'receiver-policy-v1', CONTEXT_GRAPH_ID), ]); - const authorPolicyDigest = requiredOutputString(authorPolicy, 'policyDigest'); - const receiverPolicyDigest = requiredOutputString(receiverPolicy, 'policyDigest'); + const authorPolicyDigest = requiredOutputDigest(authorPolicy, 'policyDigest'); + const receiverPolicyDigest = requiredOutputDigest(receiverPolicy, 'policyDigest'); requireCondition( authorPolicyDigest === receiverPolicyDigest, 'author and receiver derived different open-policy digests', ); - assertGate1ProductCapabilities({ author: authorReady.capabilities, receiver: receiverReady.capabilities, }); - // The exact successor/result mapping is intentionally not guessed on this - // base commit. A follow-up composition against the native-wiring commit - // replaces this closed failure with the six-operation scenario. Keeping the - // boundary fatal prevents a newly-added but shape-incompatible API from - // silently manufacturing an artifact. - throw new Error( - `RFC-64 Gate 1 production APIs are present at ${headBefore}, but the real successor ` - + 'result contract has not yet been composed into this harness; refusing to emit evidence', + const seal = await authorSeal(); + const genesisEvent = await author.request( + 'publishGenesis', + 'publish-genesis-v1', + 'operation-completed', + { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + authorPrivateKey: AUTHOR_PRIVATE_KEY, + issuedAt: GENESIS_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, + }, + ); + const genesis = outputRecord(genesisEvent, 'genesis'); + const genesisAnnouncement = outputRecordValue(genesis, 'announcement', 'genesis'); + exact( + requiredString(genesisAnnouncement.policyDigest, 'genesis.announcement.policyDigest'), + receiverPolicyDigest, + 'genesis policy digest', + ); + await announceAndDrain( + author, + receiver, + genesisAnnouncement, + receiverReady.peerId as string, + 'genesis', + ); + + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR_ADDRESS, + era: '0', + bucketCount: '1', + } as never); + const genesisApplied = await readApplied(receiver, catalogScopeDigest, AUTHOR_ADDRESS, 'genesis'); + exact(genesisApplied.catalogVersion, '0', 'genesis applied version'); + exact(genesisApplied.inventoryRowCount, 0, 'genesis applied row count'); + exact( + genesisApplied.currentCatalogHeadDigest, + requiredString(genesis.headObjectDigest, 'genesis.headObjectDigest'), + 'genesis applied head', + ); + + const positivePublication = await publishSuccessor(author, { + requestId: 'publish-positive-v1', + previousHead: stagedHeadRef(genesis, 'genesis'), + catalogIssuerAuthorization: outputRecordValue( + genesis, + 'catalogIssuerAuthorization', + 'genesis', + ), + seal, + issuedAt: POSITIVE_ISSUED_AT, + }); + const positiveAnnouncement = outputRecordValue( + positivePublication, + 'announcement', + 'positive publication', + ); + await announceAndDrain( + author, + receiver, + positiveAnnouncement, + receiverReady.peerId as string, + 'positive', + ); + const positiveApplied = await readApplied( + receiver, + catalogScopeDigest, + AUTHOR_ADDRESS, + 'positive', + ); + const positiveSync = await readSynchronization( + receiver, + requiredString(positivePublication.headObjectDigest, 'positive.headObjectDigest'), + 'positive', + ); + const positive = transferEvidence( + positivePublication, + positiveSync, + requiredString(genesis.headObjectDigest, 'genesis.headObjectDigest'), + 'positive', + ); + exactJson(positiveApplied, appliedReadBackFromTransfer(positive), 'positive applied readback'); + const positiveControlObjectCount = verifiedControlObjectCount(positiveSync, 'positive sync'); + exact(positiveControlObjectCount, 4, 'positive verified control object count'); + const semanticBeforeCrash = await readSemanticGraph( + receiver, + positive.swmGraph, + 'positive-before-crash', + ); + exact( + requiredSafeInteger( + semanticBeforeCrash.activatedQuadCount, + 'positive semantic activatedQuadCount', + ), + positive.activatedQuadCount, + 'positive semantic quad count', + ); + exact( + requiredString(semanticBeforeCrash.projectionNQuads, 'positive semantic projection'), + PROJECTION_NQUADS, + 'positive exact projection post-read', + ); + + const [authorForgedPolicy, receiverForgedPolicy] = await Promise.all([ + acceptPolicy(author, 'author-forged-policy-v1', FORGED_CONTEXT_GRAPH_ID), + acceptPolicy(receiver, 'receiver-forged-policy-v1', FORGED_CONTEXT_GRAPH_ID), + ]); + const forgedPolicyDigest = requiredOutputDigest(authorForgedPolicy, 'policyDigest'); + exact( + requiredOutputDigest(receiverForgedPolicy, 'policyDigest'), + forgedPolicyDigest, + 'forged-CG independently accepted policy digest', + ); + const forgedPreparedEvent = await author.request( + 'prepareForgedAuthorizationGenesis', + 'prepare-forged-authorization-v1', + 'operation-completed', + { + networkId: NETWORK_ID, + contextGraphId: FORGED_CONTEXT_GRAPH_ID, + policyDigest: forgedPolicyDigest, + catalogAuthorPrivateKey: AUTHOR_PRIVATE_KEY, + attackerPrivateKey: ATTACKER_PRIVATE_KEY, + issuedAt: FORGED_ISSUED_AT, + delegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + delegationExpiresAt: DELEGATION_EXPIRES_AT, + }, ); + const forgedPrepared = outputRecord(forgedPreparedEvent, 'forged setup'); + const forgedAnnouncement = outputRecordValue( + forgedPrepared, + 'announcement', + 'forged setup', + ); + const forgedHeadDigest = requiredString( + forgedPrepared.attemptedCatalogHeadDigest, + 'forged.attemptedCatalogHeadDigest', + ); + exact( + requiredString(forgedPrepared.catalogAuthorAddress, 'forged.catalogAuthorAddress'), + AUTHOR_ADDRESS, + 'forged claimed author', + ); + exact( + requiredString(forgedPrepared.recoveredAuthorAddress, 'forged.recoveredAuthorAddress'), + ATTACKER_ADDRESS, + 'forged delegation recovered author', + ); + const statsBeforeForged = await readReceiverStats(receiver, 'before-forged'); + await announceAndDrain( + author, + receiver, + forgedAnnouncement, + receiverReady.peerId as string, + 'forged', + ); + const statsAfterForged = await readReceiverStats(receiver, 'after-forged'); + exact( + requiredSafeInteger(statsAfterForged.failed, 'statsAfterForged.failed'), + requiredSafeInteger(statsBeforeForged.failed, 'statsBeforeForged.failed') + 1, + 'forged terminal failure count', + ); + const forgedScopeDigest = computeAuthorCatalogScopeDigestV1({ + networkId: NETWORK_ID, + contextGraphId: FORGED_CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR_ADDRESS, + era: '0', + bucketCount: '1', + } as never); + const forgedApplied = await readAppliedNullable( + receiver, + forgedScopeDigest, + AUTHOR_ADDRESS, + 'forged', + ); + exact(forgedApplied, null, 'forged applied head'); + const forgedSync = await readSynchronizationNullable(receiver, forgedHeadDigest, 'forged'); + exact(forgedSync, null, 'forged synchronization evidence'); + const appliedAfterForged = await readApplied( + receiver, + catalogScopeDigest, + AUTHOR_ADDRESS, + 'after-forged', + ); + const semanticAfterForged = await readSynchronization( + receiver, + positive.head.catalogHeadDigest, + 'after-forged', + ); + exactJson(appliedAfterForged, positiveApplied, 'positive applied state after forged attempt'); + exactJson(semanticAfterForged, positiveSync, 'positive semantic state after forged attempt'); + + let terminalFailure: Record | null = null; + let terminalFailureObservabilityError: unknown; + try { + const failureEvent = await receiver.request( + 'terminalFailureReadback', + 'forged-terminal-failure-v1', + 'operation-completed', + { catalogHeadDigest: forgedHeadDigest }, + ); + terminalFailure = outputRecord(failureEvent, 'forged terminal failure'); + } catch (error) { + terminalFailureObservabilityError = error; + } + + const receiverCrashExit = await receiver.killRestartBoundary('receiver-crash-v1'); + const restartedReceiver = spawnAgent('receiver', receiverDataDir, children); + const restartedReady = await restartedReceiver.waitFor('ready'); + requireRealReady(restartedReady, 'receiver'); + exact(restartedReady.peerId, receiverReady.peerId, 'receiver peer ID after restart'); + await connectBothWays(author, restartedReceiver, authorReady, restartedReady, 'restart'); + await acceptPolicy(restartedReceiver, 'restarted-receiver-policy-v1', CONTEXT_GRAPH_ID); + + await announceAndDrain( + author, + restartedReceiver, + positiveAnnouncement, + restartedReady.peerId as string, + 'repair', + ); + const repairApplied = await readApplied( + restartedReceiver, + catalogScopeDigest, + AUTHOR_ADDRESS, + 'repair', + ); + const replayedSynchronization = await readSynchronizationNullable( + restartedReceiver, + positive.head.catalogHeadDigest, + 'repair', + ); + exact( + replayedSynchronization, + null, + 'already-applied replay process-local synchronization evidence', + ); + const restartStats = await readReceiverStats(restartedReceiver, 'restart-replay'); + exact( + requiredSafeInteger(restartStats.dedupedAlreadyApplied, 'restart dedupedAlreadyApplied'), + 1, + 'restart durable applied-head dedupe', + ); + exact( + requiredSafeInteger(restartStats.applied, 'restart applied'), + 0, + 'restart duplicate activation count', + ); + const semanticAfterRestart = await readSemanticGraph( + restartedReceiver, + positive.swmGraph, + 'positive-after-restart', + ); + exactJson( + semanticAfterRestart, + semanticBeforeCrash, + 'exact SWM state across SIGKILL/restart/reannouncement', + ); + const repair = structuredClone(positive) as Gate1TransferEvidence; + exactJson(repairApplied, appliedReadBackFromTransfer(repair), 'repair applied readback'); + + const restartedReceiverExit = await restartedReceiver.stop('receiver-stop-v1'); + const authorExit = await author.stop('author-stop-v1'); + const headAfter = readCleanRepositoryHead(REPO_ROOT); + exact(headAfter, headBefore, 'tracked source commit after process run'); + + if (terminalFailure === null) { + throw terminalFailureObservabilityError ?? new Error( + 'receiver returned no exact terminal failure evidence for forged authorization', + ); + } + const failureCode = requiredString( + terminalFailure.errorCode, + 'terminalFailure.errorCode', + ); + requiredString(terminalFailure.errorName, 'terminalFailure.errorName'); + exact( + requiredString(terminalFailure.catalogHeadDigest, 'terminalFailure.catalogHeadDigest'), + forgedHeadDigest, + 'terminal failure head digest', + ); + exact(failureCode, 'catalog-native-receiver-authorization', 'terminal failure code'); + const forged: Gate1ForgedEvidence = Object.freeze({ + attemptedCatalogHeadDigest: forgedHeadDigest, + catalogAuthorAddress: AUTHOR_ADDRESS, + expectedFailureCode: failureCode, + recoveredAuthorAddress: ATTACKER_ADDRESS, + }); + + const artifact = { + adapter: { + id: GATE1_REAL_DKG_AGENT_ADAPTER_ID, + inspectedProductCommits: [headBefore], + productBoundary: 'connected', + protocolVersion: GATE1_ADAPTER_PROTOCOL_VERSION, + requiredProductionOperations: REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + replacementContract: + 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence', + }, + fixture: { + forged, + positive, + repairSuccessor: repair, + }, + gate: 'OT-RFC-64 Gate 1 harness contract', + gateEvaluation: { + reason: + 'two real DKGAgent processes completed production publish, announce, synchronize, ' + + 'authorization-negative, SIGKILL, restart, reannounce, and exact readback', + status: 'PASS', + }, + harnessChecksPassed: true, + invocation: 'pnpm test:gate1:rfc64-public-open-harness', + phases: { + forgedAuthor: { + activationAfter: positive.activatedQuadCount, + activationBefore: positive.activatedQuadCount, + appliedHeadAfter: appliedAfterForged, + appliedHeadBefore: positiveApplied, + attemptedCatalogHeadDigest: forged.attemptedCatalogHeadDigest, + failureCode: forged.expectedFailureCode, + recoveredAuthorAddress: forged.recoveredAuthorAddress, + servedByPeerId: authorReady.peerId, + testedByPeerId: receiverReady.peerId, + }, + positiveSync: { + appliedReadBack: positiveApplied, + controlObjectsVerified: positiveControlObjectCount, + exact: positive, + receivedByPeerId: receiverReady.peerId, + semanticPostRead: semanticReadBackFromTransfer(positive), + servedByPeerId: authorReady.peerId, + }, + restartRepair: { + crashExit: receiverCrashExit, + gap: { + appliedBeforeCrash: positiveApplied, + repairIntentDurable: false, + semanticBeforeCrash: semanticReadBackFromTransfer(positive), + target: appliedReadBackFromTransfer(repair), + }, + readBack: { + appliedReadBack: repairApplied, + semanticPostRead: semanticReadBackFromTransfer(repair), + }, + reannouncementAcknowledgedByPeerId: restartedReady.peerId, + restartedReady: selectReady(restartedReady), + successorServedByPeerId: authorReady.peerId, + }, + }, + processBoundary: { + authorInstances: 1, + model: 'two real DKGAgent peer processes plus one receiver restart', + receiverInstances: 2, + stoppedExits: { + author: selectExit(authorExit), + restartedReceiver: selectExit(restartedReceiverExit), + }, + }, + ready: { + author: selectReady(authorReady), + receiver: selectReady(receiverReady), + }, + repository: { + testedHeadCommit: headBefore, + trackedSourceCleanAfterProcesses: true, + trackedSourceCleanBeforeSpawn: true, + }, + schemaVersion: GATE1_RAW_SCHEMA_VERSION, + }; + const publication = atomicWriteStableJson(rawArtifactPath, artifact); + process.stdout.write( + `[rfc64-gate1-harness] wrote ${rawArtifactPath} sha256=${publication.sha256}\n`, + ); + operationFailed = false; } catch (error) { primaryFailure = error; } finally { @@ -123,6 +521,309 @@ async function execute(): Promise { } } +async function publishSuccessor( + author: Gate1AgentChild, + input: { + readonly requestId: string; + readonly previousHead: Record; + readonly catalogIssuerAuthorization: Record; + readonly seal: CanonicalGraphScopedAuthorSealV1; + readonly issuedAt: string; + }, +): Promise> { + const event = await author.request('publishSuccessor', input.requestId, 'operation-completed', { + previousHead: input.previousHead, + authorPrivateKey: AUTHOR_PRIVATE_KEY, + catalogIssuerAuthorization: input.catalogIssuerAuthorization, + assertionCoordinate: 'gate-1-object', + projectionNQuads: PROJECTION_NQUADS, + seal: input.seal, + deployment: DEPLOYMENT, + issuedAt: input.issuedAt, + peers: [], + }); + return outputRecord(event, input.requestId); +} + +async function announceAndDrain( + author: Gate1AgentChild, + receiver: Gate1AgentChild, + announcement: Record, + receiverPeerId: string, + label: string, +): Promise { + const announced = await author.request('announce', `${label}-announce-v1`, 'operation-completed', { + announcement, + peers: [receiverPeerId], + }); + const output = outputRecord(announced, `${label} announce`); + exactJson(output.announcedPeers, [receiverPeerId], `${label} announced peers`); + exactJson(output.failedPeers, [], `${label} failed peers`); + await receiver.request('awaitReceiverIdle', `${label}-receiver-idle-v1`, 'receiver-idle'); +} + +async function acceptPolicy( + child: Gate1AgentChild, + requestId: string, + contextGraphId: string, +): Promise { + return child.request('acceptOpenPolicy', requestId, 'operation-completed', { + networkId: NETWORK_ID, + contextGraphId, + ownerAddress: AUTHOR_ADDRESS, + }); +} + +async function connectBothWays( + author: Gate1AgentChild, + receiver: Gate1AgentChild, + authorReady: Gate1AgentEvent, + receiverReady: Gate1AgentEvent, + label: string, +): Promise { + await Promise.all([ + receiver.request('dial', `${label}-receiver-dial-author-v1`, 'dialed', { + multiaddr: authorReady.multiaddr, + peerId: authorReady.peerId, + }), + author.request('dial', `${label}-author-dial-receiver-v1`, 'dialed', { + multiaddr: receiverReady.multiaddr, + peerId: receiverReady.peerId, + }), + ]); +} + +async function readApplied( + receiver: Gate1AgentChild, + catalogScopeDigest: string, + authorAddress: string, + label: string, +): Promise { + const value = await readAppliedNullable(receiver, catalogScopeDigest, authorAddress, label); + if (value === null) throw new Error(`${label} applied-head readback is missing`); + return value; +} + +async function readAppliedNullable( + receiver: Gate1AgentChild, + catalogScopeDigest: string, + authorAddress: string, + label: string, +): Promise { + const event = await receiver.request( + 'appliedHeadReadback', + `${label}-applied-head-v1`, + 'operation-completed', + { catalogScopeDigest, authorAddress }, + ); + if (event.output === null) return null; + const output = outputRecord(event, `${label} applied head`); + return Object.freeze({ + appliedInventoryDigest: requiredString( + output.appliedInventoryDigest, + `${label}.appliedInventoryDigest`, + ), + catalogVersion: requiredString(output.catalogVersion, `${label}.catalogVersion`), + currentCatalogHeadDigest: requiredString( + output.currentCatalogHeadDigest, + `${label}.currentCatalogHeadDigest`, + ), + inventoryRowCount: decimalToSafeInteger( + output.inventoryRowCount, + `${label}.inventoryRowCount`, + ), + }); +} + +async function readSynchronization( + receiver: Gate1AgentChild, + catalogHeadDigest: string, + label: string, +): Promise> { + const value = await readSynchronizationNullable(receiver, catalogHeadDigest, label); + if (value === null) throw new Error(`${label} synchronization evidence is missing`); + return value; +} + +async function readSynchronizationNullable( + receiver: Gate1AgentChild, + catalogHeadDigest: string, + label: string, +): Promise | null> { + const event = await receiver.request( + 'exactInventoryReadback', + `${label}-exact-inventory-v1`, + 'operation-completed', + { catalogHeadDigest }, + ); + return event.output === null ? null : outputRecord(event, `${label} synchronization`); +} + +async function readReceiverStats( + receiver: Gate1AgentChild, + label: string, +): Promise> { + return outputRecord( + await receiver.request('receiverStats', `${label}-receiver-stats-v1`, 'operation-completed'), + `${label} receiver stats`, + ); +} + +async function readSemanticGraph( + receiver: Gate1AgentChild, + swmGraph: string, + label: string, +): Promise> { + return outputRecord( + await receiver.request( + 'semanticGraphReadback', + `${label}-semantic-graph-v1`, + 'operation-completed', + { swmGraph }, + ), + `${label} semantic graph`, + ); +} + +function transferEvidence( + publication: Record, + synchronization: Record, + previousCatalogHeadDigest: string, + label: string, +): Gate1TransferEvidence { + const announcement = outputRecordValue(publication, 'announcement', `${label} publication`); + const headDigest = requiredString(publication.headObjectDigest, `${label}.headObjectDigest`); + exact( + requiredString(announcement.catalogHeadObjectDigest, `${label}.announcement.headDigest`), + headDigest, + `${label} announcement head digest`, + ); + exact( + requiredString(synchronization.catalogHeadDigest, `${label}.sync.catalogHeadDigest`), + headDigest, + `${label} synchronized head digest`, + ); + exact( + requiredString(synchronization.catalogRowDigest, `${label}.sync.catalogRowDigest`), + requiredString(publication.catalogRowDigest, `${label}.catalogRowDigest`), + `${label} catalog row digest`, + ); + exact( + requiredString(synchronization.contentDigest, `${label}.sync.contentDigest`), + requiredString(publication.contentDigest, `${label}.contentDigest`), + `${label} content digest`, + ); + exact( + requiredString(synchronization.bundleDigest, `${label}.sync.bundleDigest`), + requiredString(publication.bundleDigest, `${label}.bundleDigest`), + `${label} bundle digest`, + ); + exact( + requiredString(synchronization.kaUal, `${label}.sync.kaUal`), + requiredString(publication.kaUal, `${label}.kaUal`), + `${label} KA UAL`, + ); + const bundleByteLength = decimalToSafeInteger( + publication.bundleByteLength, + `${label}.bundleByteLength`, + ); + exact( + requiredSafeInteger(publication.stagedBundleByteLength, `${label}.stagedBundleByteLength`), + bundleByteLength, + `${label} staged bundle byte length`, + ); + const inventoryRowCount = decimalToSafeInteger( + publication.inventoryRowCount, + `${label}.inventoryRowCount`, + ); + exact( + requiredSafeInteger(synchronization.inventoryRowCount, `${label}.sync.inventoryRowCount`), + inventoryRowCount, + `${label} synchronized inventory row count`, + ); + return Object.freeze({ + activatedQuadCount: requiredSafeInteger( + synchronization.activatedTripleCount, + `${label}.activatedTripleCount`, + ), + authorAddress: requiredString(announcement.authorAddress, `${label}.authorAddress`), + bundleByteLength, + bundleDigest: requiredString(publication.bundleDigest, `${label}.bundleDigest`), + catalogRowDigest: requiredString(publication.catalogRowDigest, `${label}.catalogRowDigest`), + contentByteLength: decimalToSafeInteger( + publication.contentByteLength, + `${label}.contentByteLength`, + ), + contentDigest: requiredString(publication.contentDigest, `${label}.contentDigest`), + head: Object.freeze({ + appliedInventoryDigest: requiredString( + synchronization.inventoryDigest, + `${label}.inventoryDigest`, + ), + catalogHeadDigest: headDigest, + catalogVersion: requiredString(announcement.catalogVersion, `${label}.catalogVersion`), + previousCatalogHeadDigest, + }), + inventoryRowCount, + kaUal: requiredString(publication.kaUal, `${label}.kaUal`), + swmGraph: requiredString(synchronization.swmGraph, `${label}.swmGraph`), + }); +} + +function verifiedControlObjectCount( + synchronization: Record, + label: string, +): number { + return requiredSafeInteger( + synchronization.verifiedControlObjectCount, + `${label}.verifiedControlObjectCount`, + ); +} + +async function authorSeal(): Promise { + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(DEPLOYMENT.assertedAtChainId), + kav10Address: DEPLOYMENT.assertedAtKav10Address, + merkleRoot: ethers.getBytes(ASSERTION_ROOT), + authorAddress: AUTHOR_ADDRESS, + reservedKaId: BigInt(KA_ID), + }); + const signature = ethers.Signature.from(await AUTHOR_WALLET.signTypedData( + typedData.domain, + typedData.types, + typedData.message, + )); + const seal = { + assertionMerkleRoot: ASSERTION_ROOT, + authorAddress: AUTHOR_ADDRESS, + authorAttestationR: signature.r, + authorAttestationVS: signature.yParityAndS, + authorSchemeVersion: '1', + assertedAtChainId: DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10_ADDRESS, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: KA_UAL, + assertionVersion: '1', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(seal); + return seal; +} + +function stagedHeadRef(output: Record, label: string): Record { + return { + objectDigest: requiredString(output.headObjectDigest, `${label}.headObjectDigest`), + signatureVariantDigest: requiredString( + output.signatureVariantDigest, + `${label}.signatureVariantDigest`, + ), + }; +} + function spawnAgent( role: 'author' | 'receiver', dataDir: string, @@ -170,18 +871,80 @@ function requireRealReady(event: Gate1AgentEvent, expectedRole: 'author' | 'rece ); } -function requiredOutputString(event: Gate1AgentEvent, key: string): string { - const output = event.output; - if (output === null || typeof output !== 'object' || Array.isArray(output)) { - throw new Error(`${event.role}/${event.event} output is not an object`); +function selectReady(event: Gate1AgentEvent): Record { + return { + adapterId: event.adapterId, + peerId: event.peerId, + protocolVersion: event.protocolVersion, + role: event.role, + startupRepair: event.startupRepair, + }; +} + +function selectExit(exit: ProcessExitEvidence): Record { + return { code: exit.code, signal: exit.signal }; +} + +function outputRecord(event: Gate1AgentEvent, label: string): Record { + if (event.output === null || typeof event.output !== 'object' || Array.isArray(event.output)) { + throw new Error(`${label} output is not an object`); + } + return event.output as Record; +} + +function outputRecordValue( + record: Record, + key: string, + label: string, +): Record { + const value = record[key]; + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label}.${key} is not an object`); } - const value = (output as Record)[key]; - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`${event.role}/${event.event} output.${key} is missing`); + return value as Record; +} + +function requiredOutputDigest(event: Gate1AgentEvent, key: string): string { + const output = outputRecord(event, `${event.role}/${event.event}`); + const value = requiredString(output[key], `${event.role}/${event.event}.${key}`); + if (!/^0x[0-9a-f]{64}$/u.test(value)) throw new Error(`${key} is not a canonical digest`); + return value; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) { + throw new Error(`${label} must be a bounded non-empty string`); } return value; } +function requiredSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`${label} must be a non-negative safe integer`); + } + return value as number; +} + +function decimalToSafeInteger(value: unknown, label: string): number { + const decimal = requiredString(value, label); + if (!/^(0|[1-9][0-9]*)$/u.test(decimal)) throw new Error(`${label} is not canonical decimal`); + const number = Number(decimal); + if (!Number.isSafeInteger(number)) throw new Error(`${label} exceeds safe integer range`); + return number; +} + +function exact(actual: unknown, expected: unknown, label: string): void { + if (!Object.is(actual, expected)) { + throw new Error(`${label} differed: ${JSON.stringify(actual)} !== ${JSON.stringify(expected)}`); + } +} + +function exactJson(actual: unknown, expected: unknown, label: string): void { + if (stableJson(actual) !== stableJson(expected)) { + throw new Error(`${label} differed from exact production evidence`); + } +} + function requireCondition(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } diff --git a/devnet/rfc64-gate1-public-open/verifier.test.ts b/devnet/rfc64-gate1-public-open/verifier.test.ts index 9a3839695c..5c285b24fd 100644 --- a/devnet/rfc64-gate1-public-open/verifier.test.ts +++ b/devnet/rfc64-gate1-public-open/verifier.test.ts @@ -17,7 +17,7 @@ const HEAD = 'a'.repeat(40); const AUTHOR = `0x${'11'.repeat(20)}`; const ATTACKER = `0x${'aa'.repeat(20)}`; const POSITIVE = transfer({ head: '6', previous: '7', version: '1' }); -const REPAIR = transfer({ head: '8', previous: '6', version: '2' }); +const REPAIR = structuredClone(POSITIVE); test('accepts canonical production-shaped evidence with exact runtime continuity', () => { const result = verifyGate1ArtifactBytes(bytes(goldenArtifact()), HEAD); @@ -102,24 +102,24 @@ test('requires replay to retain exact row, content, bundle, UAL, and counts', () typeof artifact.fixture.repairSuccessor[field] === 'number' ? 3 : digest(String(index + 1)); - reject(artifact, new RegExp(`repairSuccessor\\.${field}`)); + reject(artifact, /repairSuccessor/); } }); test('requires the product inventory digest to remain equal for the exact replayed row', () => { const artifact = goldenArtifact(); artifact.fixture.repairSuccessor.head.appliedInventoryDigest = digest('9'); - reject(artifact, /repairSuccessor\.head\.appliedInventoryDigest/); + reject(artifact, /repairSuccessor/); }); -test('requires the repair head to advance one version from the positive head', () => { - const wrongPrevious = goldenArtifact(); - wrongPrevious.fixture.repairSuccessor.head.previousCatalogHeadDigest = digest('9'); - reject(wrongPrevious, /repairSuccessor\.head\.previousCatalogHeadDigest/); +test('requires restart replay to retain the exact accepted head and version', () => { + const wrongHead = goldenArtifact(); + wrongHead.fixture.repairSuccessor.head.catalogHeadDigest = digest('9'); + reject(wrongHead, /repairSuccessor/); - const skippedVersion = goldenArtifact(); - skippedVersion.fixture.repairSuccessor.head.catalogVersion = '3'; - reject(skippedVersion, /repairSuccessor\.head\.catalogVersion/); + const changedVersion = goldenArtifact(); + changedVersion.fixture.repairSuccessor.head.catalogVersion = '2'; + reject(changedVersion, /repairSuccessor/); }); test('requires the four receiver-verified control objects from product evidence', () => { @@ -166,17 +166,20 @@ test('requires real SIGKILL followed by explicit replay, not fictional startup r repaired: true, }; reject(inventedStartupRepair, /restartRepair\.restartedReady\.startupRepair/); + + const wrongAck = goldenArtifact(); + wrongAck.phases.restartRepair.reannouncementAcknowledgedByPeerId = 'peer-author-real'; + reject(wrongAck, /restartRepair\.reannouncementAcknowledgedByPeerId/); }); test('requires pre-crash continuity and exact post-reannounce replay readback', () => { const prematureSuccessor = goldenArtifact(); - prematureSuccessor.phases.restartRepair.gap.semanticBeforeCrash = - semanticReadBackFromTransfer(REPAIR); + prematureSuccessor.phases.restartRepair.gap.semanticBeforeCrash.contentDigest = digest('9'); reject(prematureSuccessor, /restartRepair\.gap\.semanticBeforeCrash/); const wrongApplied = goldenArtifact(); wrongApplied.phases.restartRepair.readBack.appliedReadBack.currentCatalogHeadDigest = - POSITIVE.head.catalogHeadDigest; + digest('9'); reject(wrongApplied, /restartRepair\.readBack\.appliedReadBack/); const duplicate = goldenArtifact(); @@ -246,6 +249,7 @@ function buildGoldenArtifact() { appliedReadBack: structuredClone(appliedReadBackFromTransfer(REPAIR)), semanticPostRead: structuredClone(semanticReadBackFromTransfer(REPAIR)), }, + reannouncementAcknowledgedByPeerId: 'peer-receiver-real', restartedReady: { adapterId: GATE1_REAL_DKG_AGENT_ADAPTER_ID, peerId: 'peer-receiver-real', diff --git a/devnet/rfc64-gate1-public-open/verifier.ts b/devnet/rfc64-gate1-public-open/verifier.ts index 7e57ac5713..2985476e3c 100644 --- a/devnet/rfc64-gate1-public-open/verifier.ts +++ b/devnet/rfc64-gate1-public-open/verifier.ts @@ -157,42 +157,10 @@ function verifyRuntimeEvidence(value: unknown): { fail('$.fixture.forged.recoveredAuthorAddress', 'must differ from the catalog author'); } - // The restart/replay successor is the same exact one-row inventory. A new - // head/version may advance, but no semantic row, bundle, UAL, count, or - // applied inventory commitment may change or duplicate. - const stableFields: ReadonlyArray = [ - 'activatedQuadCount', - 'authorAddress', - 'bundleByteLength', - 'bundleDigest', - 'catalogRowDigest', - 'contentByteLength', - 'contentDigest', - 'inventoryRowCount', - 'kaUal', - 'swmGraph', - ]; - for (const field of stableFields) { - exactJson(repair[field], positive[field], `$.fixture.repairSuccessor.${field}`); - } - exact( - repair.head.appliedInventoryDigest, - positive.head.appliedInventoryDigest, - '$.fixture.repairSuccessor.head.appliedInventoryDigest', - ); - exact( - repair.head.previousCatalogHeadDigest, - positive.head.catalogHeadDigest, - '$.fixture.repairSuccessor.head.previousCatalogHeadDigest', - ); - if (repair.head.catalogHeadDigest === positive.head.catalogHeadDigest) { - fail('$.fixture.repairSuccessor.head.catalogHeadDigest', 'must advance to a distinct head'); - } - const positiveVersion = BigInt(positive.head.catalogVersion); - const repairVersion = BigInt(repair.head.catalogVersion); - if (repairVersion !== positiveVersion + 1n) { - fail('$.fixture.repairSuccessor.head.catalogVersion', 'must advance exactly one version'); - } + // Restart recovery is an idempotent replay of the exact accepted successor, + // not a no-op catalog publication. Every head, row, bundle, semantic, and + // applied-inventory field must therefore remain byte-for-byte identical. + exactJson(repair, positive, '$.fixture.repairSuccessor'); return { forged, positive, repair }; } @@ -386,10 +354,16 @@ function verifyRestartRepair( 'crashExit', 'gap', 'readBack', + 'reannouncementAcknowledgedByPeerId', 'restartedReady', 'successorServedByPeerId', ]); exact(phase.successorServedByPeerId, peers.author, '$.phases.restartRepair.successorServedByPeerId'); + exact( + phase.reannouncementAcknowledgedByPeerId, + peers.receiver, + '$.phases.restartRepair.reannouncementAcknowledgedByPeerId', + ); verifyExit(phase.crashExit, '$.phases.restartRepair.crashExit', null, 'SIGKILL'); const gap = closedRecord(phase.gap, '$.phases.restartRepair.gap', [ 'appliedBeforeCrash', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51d4c721e2..b0b2d43b9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,12 @@ importers: '@origintrail-official/dkg-agent': specifier: workspace:* version: link:../../packages/agent + '@origintrail-official/dkg-chain': + specifier: workspace:* + version: link:../../packages/chain + '@origintrail-official/dkg-core': + specifier: workspace:* + version: link:../../packages/core '@origintrail-official/dkg-storage': specifier: workspace:* version: link:../../packages/storage From 1bb183f8b8bf75bea9ed3b696831a3d920011716 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:02:16 +0200 Subject: [PATCH 136/292] fix(devnet): detach Gate 1 evidence snapshots --- devnet/rfc64-gate1-public-open/run.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/devnet/rfc64-gate1-public-open/run.ts b/devnet/rfc64-gate1-public-open/run.ts index 38ef7e4641..1d1333e797 100644 --- a/devnet/rfc64-gate1-public-open/run.ts +++ b/devnet/rfc64-gate1-public-open/run.ts @@ -427,9 +427,9 @@ async function execute(): Promise { 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence', }, fixture: { - forged, - positive, - repairSuccessor: repair, + forged: structuredClone(forged), + positive: structuredClone(positive), + repairSuccessor: structuredClone(repair), }, gate: 'OT-RFC-64 Gate 1 harness contract', gateEvaluation: { @@ -444,8 +444,8 @@ async function execute(): Promise { forgedAuthor: { activationAfter: positive.activatedQuadCount, activationBefore: positive.activatedQuadCount, - appliedHeadAfter: appliedAfterForged, - appliedHeadBefore: positiveApplied, + appliedHeadAfter: structuredClone(appliedAfterForged), + appliedHeadBefore: structuredClone(positiveApplied), attemptedCatalogHeadDigest: forged.attemptedCatalogHeadDigest, failureCode: forged.expectedFailureCode, recoveredAuthorAddress: forged.recoveredAuthorAddress, @@ -453,9 +453,9 @@ async function execute(): Promise { testedByPeerId: receiverReady.peerId, }, positiveSync: { - appliedReadBack: positiveApplied, + appliedReadBack: structuredClone(positiveApplied), controlObjectsVerified: positiveControlObjectCount, - exact: positive, + exact: structuredClone(positive), receivedByPeerId: receiverReady.peerId, semanticPostRead: semanticReadBackFromTransfer(positive), servedByPeerId: authorReady.peerId, @@ -463,7 +463,7 @@ async function execute(): Promise { restartRepair: { crashExit: receiverCrashExit, gap: { - appliedBeforeCrash: positiveApplied, + appliedBeforeCrash: structuredClone(positiveApplied), repairIntentDurable: false, semanticBeforeCrash: semanticReadBackFromTransfer(positive), target: appliedReadBackFromTransfer(repair), From 4979017eafd6bcb253f228fedd60a17f5eec7b96 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:15:38 +0200 Subject: [PATCH 137/292] feat(core): add finalized VM set accumulator --- packages/core/src/finalized-vm-set-v1.ts | 410 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../core/test/finalized-vm-set-v1.test.ts | 259 +++++++++++ 3 files changed, 670 insertions(+) create mode 100644 packages/core/src/finalized-vm-set-v1.ts create mode 100644 packages/core/test/finalized-vm-set-v1.test.ts diff --git a/packages/core/src/finalized-vm-set-v1.ts b/packages/core/src/finalized-vm-set-v1.ts new file mode 100644 index 0000000000..cafff54a02 --- /dev/null +++ b/packages/core/src/finalized-vm-set-v1.ts @@ -0,0 +1,410 @@ +import { sha256 } from '@noble/hashes/sha2.js'; + +import { assertNetworkIdV1, type NetworkIdV1 } from './author-catalog-codec.js'; +import { canonicalizeJsonBytes, type CanonicalJsonValue } from './canonical-json.js'; +import { parseDeterministicKnowledgeAssetUal } from './ka-content-scope.js'; +import { + MAX_DECIMAL_U64, + assertCanonicalChainId, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertCanonicalEvmAddress, + parseCanonicalDecimalU64, + type ChainIdV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, +} from './sync-wire-scalars.js'; + +export const FINALIZED_VM_SET_LEAF_DIGEST_DOMAIN_V1 = + 'dkg-finalized-vm-set-leaf-v1\n' as const; +export const FINALIZED_VM_SET_NODE_DIGEST_DOMAIN_V1 = + 'dkg-finalized-vm-set-node-v1\n' as const; +export const FINALIZED_VM_SET_ODD_DIGEST_DOMAIN_V1 = + 'dkg-finalized-vm-set-odd-v1\n' as const; +export const FINALIZED_VM_SET_EMPTY_DIGEST_DOMAIN_V1 = + 'dkg-finalized-vm-set-empty-v1\n' as const; + +const UTF8 = new TextEncoder(); +const LEAF_DOMAIN_BYTES = UTF8.encode(FINALIZED_VM_SET_LEAF_DIGEST_DOMAIN_V1); +const NODE_DOMAIN_BYTES = UTF8.encode(FINALIZED_VM_SET_NODE_DIGEST_DOMAIN_V1); +const ODD_DOMAIN_BYTES = UTF8.encode(FINALIZED_VM_SET_ODD_DIGEST_DOMAIN_V1); +const EMPTY_DOMAIN_BYTES = UTF8.encode(FINALIZED_VM_SET_EMPTY_DIGEST_DOMAIN_V1); + +const FINALIZED_VM_LANE_KEYS = ['chainId', 'contractAddress'] as const; +const FINALIZED_VM_SET_SCOPE_KEYS = ['networkId', 'chainId', 'contractAddress'] as const; +const FINALIZED_VM_SET_ROW_KEYS = [ + 'assertionRoot', + 'assertionVersion', + 'authorAddress', + 'chainId', + 'contractAddress', + 'finalizedBlockHash', + 'finalizedBlockNumber', + 'ordinal', + 'placementEvidenceDigest', + 'ual', +] as const; + +export interface FinalizedVmLaneV1 { + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; +} + +/** Subscription-pinned v10.0.8 network profile for one finalized VM set. */ +export interface FinalizedVmSetScopeV1 extends FinalizedVmLaneV1 { + readonly networkId: NetworkIdV1; +} + +/** + * Exact placed finalized-chain row committed by one RFC-64 subgraph VM lane. + * + * This is deliberately structural evidence only. Constructing or hashing one + * of these rows does not establish finality, publisher authorization, author + * authority, subgraph placement authority, or semantic activation. + */ +export interface FinalizedVmSetRowV1 extends FinalizedVmLaneV1 { + readonly ordinal: DecimalU64V1; + readonly ual: string; + readonly authorAddress: EvmAddressV1; + readonly assertionVersion: DecimalU64V1; + readonly assertionRoot: Digest32V1; + readonly finalizedBlockNumber: DecimalU64V1; + readonly finalizedBlockHash: Digest32V1; + readonly placementEvidenceDigest: Digest32V1; +} + +export interface FinalizedVmSetEvidenceV1 { + readonly scope: Readonly; + readonly rootDigest: Digest32V1; + readonly rowCount: DecimalU64V1; + readonly highestFinalizedOrdinal: DecimalU64V1 | null; +} + +export type FinalizedVmSetV1ErrorCode = + | 'finalized-vm-set-schema' + | 'finalized-vm-set-scalar' + | 'finalized-vm-set-lane' + | 'finalized-vm-set-order' + | 'finalized-vm-set-ual' + | 'finalized-vm-set-state'; + +export class FinalizedVmSetV1Error extends Error { + constructor( + readonly code: FinalizedVmSetV1ErrorCode, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'FinalizedVmSetV1Error'; + } +} + +/** Maximum v10.0.8 deterministic UAL bytes under the frozen network/id bounds. */ +export const MAX_FINALIZED_VM_SET_UAL_BYTES_V1 = 209; + +/** Snapshot and validate the exact chain/contract lane before any row callbacks run. */ +export function snapshotFinalizedVmLaneV1(input: FinalizedVmLaneV1): Readonly { + const record = snapshotExactDataRecord(input, FINALIZED_VM_LANE_KEYS, 'finalized VM lane'); + try { + assertCanonicalChainId(record.chainId, 'lane.chainId'); + assertCanonicalEvmAddress(record.contractAddress, 'lane.contractAddress'); + } catch (cause) { + fail('finalized-vm-set-scalar', 'finalized VM lane contains a non-canonical scalar', cause); + } + return Object.freeze({ + chainId: record.chainId, + contractAddress: record.contractAddress, + }) as Readonly; +} + +/** Snapshot the trusted single-lane network profile before row iteration begins. */ +export function snapshotFinalizedVmSetScopeV1( + input: FinalizedVmSetScopeV1, +): Readonly { + const record = snapshotExactDataRecord( + input, + FINALIZED_VM_SET_SCOPE_KEYS, + 'finalized VM-set scope', + ); + try { + assertNetworkIdV1(record.networkId, 'scope.networkId'); + assertCanonicalChainId(record.chainId, 'scope.chainId'); + assertCanonicalEvmAddress(record.contractAddress, 'scope.contractAddress'); + } catch (cause) { + fail('finalized-vm-set-scalar', 'finalized VM-set scope contains a non-canonical scalar', cause); + } + return Object.freeze({ + networkId: record.networkId, + chainId: record.chainId, + contractAddress: record.contractAddress, + }) as Readonly; +} + +/** + * Snapshot one row exactly once, rejecting accessors and non-canonical aliases. + * The returned object is safe to retain across hashing or I/O callbacks. + */ +export function snapshotFinalizedVmSetRowV1( + input: FinalizedVmSetRowV1, + expectedNetworkId: NetworkIdV1, +): Readonly { + try { + assertNetworkIdV1(expectedNetworkId, 'expectedNetworkId'); + } catch (cause) { + fail('finalized-vm-set-scalar', 'trusted network profile is not canonical', cause); + } + const record = snapshotExactDataRecord(input, FINALIZED_VM_SET_ROW_KEYS, 'finalized VM row'); + try { + assertCanonicalChainId(record.chainId, 'row.chainId'); + assertCanonicalEvmAddress(record.contractAddress, 'row.contractAddress'); + assertCanonicalDecimalU64(record.ordinal, 'row.ordinal'); + assertCanonicalEvmAddress(record.authorAddress, 'row.authorAddress'); + assertCanonicalDecimalU64(record.assertionVersion, 'row.assertionVersion'); + if (parseCanonicalDecimalU64(record.assertionVersion, 'row.assertionVersion') === 0n) { + fail('finalized-vm-set-scalar', 'row.assertionVersion must be positive'); + } + assertCanonicalDigest(record.assertionRoot, 'row.assertionRoot'); + assertCanonicalDecimalU64(record.finalizedBlockNumber, 'row.finalizedBlockNumber'); + assertCanonicalDigest(record.finalizedBlockHash, 'row.finalizedBlockHash'); + assertCanonicalDigest(record.placementEvidenceDigest, 'row.placementEvidenceDigest'); + } catch (cause) { + if (cause instanceof FinalizedVmSetV1Error) throw cause; + fail('finalized-vm-set-scalar', 'finalized VM row contains a non-canonical scalar', cause); + } + + if (typeof record.ual !== 'string') { + fail('finalized-vm-set-ual', 'row.ual must be a canonical deterministic KA UAL'); + } + if ( + record.ual.length > MAX_FINALIZED_VM_SET_UAL_BYTES_V1 + || UTF8.encode(record.ual).byteLength > MAX_FINALIZED_VM_SET_UAL_BYTES_V1 + ) { + fail('finalized-vm-set-ual', 'row.ual exceeds the v10.0.8 deterministic UAL byte bound'); + } + try { + const parsed = parseDeterministicKnowledgeAssetUal(record.ual); + if (parsed.ual !== record.ual) { + fail('finalized-vm-set-ual', 'row.ual must not use a non-canonical identity alias'); + } + if (parsed.agentAddress !== record.authorAddress) { + fail('finalized-vm-set-ual', 'row.ual author differs from row.authorAddress'); + } + if (parsed.chainId !== expectedNetworkId) { + fail('finalized-vm-set-ual', 'row.ual namespace differs from the trusted network profile'); + } + } catch (cause) { + if (cause instanceof FinalizedVmSetV1Error) throw cause; + fail('finalized-vm-set-ual', 'row.ual is not a canonical deterministic KA UAL', cause); + } + + return Object.freeze({ + chainId: record.chainId, + contractAddress: record.contractAddress, + ordinal: record.ordinal, + ual: record.ual, + authorAddress: record.authorAddress, + assertionVersion: record.assertionVersion, + assertionRoot: record.assertionRoot, + finalizedBlockNumber: record.finalizedBlockNumber, + finalizedBlockHash: record.finalizedBlockHash, + placementEvidenceDigest: record.placementEvidenceDigest, + }) as Readonly; +} + +/** Compute the exact domain-separated RFC-64 leaf digest for one placed row. */ +export function computeFinalizedVmSetLeafDigestV1( + scope: FinalizedVmSetScopeV1, + row: FinalizedVmSetRowV1, +): Digest32V1 { + const trustedScope = snapshotFinalizedVmSetScopeV1(scope); + const snapshot = snapshotFinalizedVmSetRowV1(row, trustedScope.networkId); + if ( + snapshot.chainId !== trustedScope.chainId + || snapshot.contractAddress !== trustedScope.contractAddress + ) { + fail('finalized-vm-set-lane', 'finalized VM row differs from the trusted scope lane'); + } + return digestBytesToLowerHex(computeFinalizedVmSetLeafDigestBytesV1(snapshot)); +} + +/** The canonical finalized-VM empty accumulator root. */ +export function computeEmptyFinalizedVmSetRootV1(): Digest32V1 { + return digestBytesToLowerHex(digestBytes(EMPTY_DOMAIN_BYTES)); +} + +/** + * Streaming per-lane VM-set accumulator. + * + * Rows must arrive in strictly increasing unsigned ordinal order, matching an + * indexed planner stream. Memory stays O(log rowCount); the helper never sorts + * or buffers the VM inventory and therefore cannot hide duplicate ordinals. + */ +export class FinalizedVmSetAccumulatorV1 { + readonly #scope: Readonly; + readonly #levels: Array = []; + #rowCount = 0n; + #highestFinalizedOrdinal: DecimalU64V1 | null = null; + #highestOrdinalValue: bigint | null = null; + #finalEvidence: FinalizedVmSetEvidenceV1 | undefined; + #appendInProgress = false; + + constructor(scope: FinalizedVmSetScopeV1) { + this.#scope = snapshotFinalizedVmSetScopeV1(scope); + } + + append(rowInput: FinalizedVmSetRowV1): void { + if (this.#finalEvidence !== undefined) { + fail('finalized-vm-set-state', 'cannot append after the VM-set accumulator is finalized'); + } + if (this.#appendInProgress) { + fail('finalized-vm-set-state', 'cannot re-enter a finalized VM-set append'); + } + this.#appendInProgress = true; + try { + if (this.#rowCount >= MAX_DECIMAL_U64) { + fail('finalized-vm-set-state', 'finalized VM row count exceeds the u64 range'); + } + + const row = snapshotFinalizedVmSetRowV1(rowInput, this.#scope.networkId); + if ( + row.chainId !== this.#scope.chainId + || row.contractAddress !== this.#scope.contractAddress + ) { + fail('finalized-vm-set-lane', 'finalized VM row differs from the accumulator lane'); + } + const ordinal = parseCanonicalDecimalU64(row.ordinal, 'row.ordinal'); + if (this.#highestOrdinalValue !== null && ordinal <= this.#highestOrdinalValue) { + fail('finalized-vm-set-order', 'finalized VM rows must have unique increasing ordinals'); + } + + let current = computeFinalizedVmSetLeafDigestBytesV1(row); + let level = 0; + while (this.#levels[level] !== undefined) { + current = digestBytes(NODE_DOMAIN_BYTES, this.#levels[level]!, current); + this.#levels[level] = undefined; + level += 1; + } + this.#levels[level] = current; + this.#rowCount += 1n; + this.#highestOrdinalValue = ordinal; + this.#highestFinalizedOrdinal = row.ordinal; + } finally { + this.#appendInProgress = false; + } + } + + finalize(): FinalizedVmSetEvidenceV1 { + if (this.#appendInProgress) { + fail('finalized-vm-set-state', 'cannot finalize during a finalized VM-set append'); + } + if (this.#finalEvidence !== undefined) return this.#finalEvidence; + + let pending: Uint8Array | undefined; + let pendingLevel = -1; + for (let level = 0; level < this.#levels.length; level += 1) { + const left = this.#levels[level]; + if (left === undefined) continue; + if (pending === undefined) { + pending = left; + pendingLevel = level; + continue; + } + while (pendingLevel < level) { + pending = digestBytes(ODD_DOMAIN_BYTES, pending); + pendingLevel += 1; + } + pending = digestBytes(NODE_DOMAIN_BYTES, left, pending); + pendingLevel = level + 1; + } + + const rootDigest = pending === undefined + ? computeEmptyFinalizedVmSetRootV1() + : digestBytesToLowerHex(pending); + this.#finalEvidence = Object.freeze({ + scope: this.#scope, + rootDigest, + rowCount: this.#rowCount.toString() as DecimalU64V1, + highestFinalizedOrdinal: this.#highestFinalizedOrdinal, + }); + return this.#finalEvidence; + } +} + +/** Convenience wrapper over the streaming accumulator; input order remains authoritative. */ +export function computeFinalizedVmSetEvidenceV1( + scope: FinalizedVmSetScopeV1, + rows: Iterable, +): FinalizedVmSetEvidenceV1 { + const accumulator = new FinalizedVmSetAccumulatorV1(scope); + for (const row of rows) accumulator.append(row); + return accumulator.finalize(); +} + +function computeFinalizedVmSetLeafDigestBytesV1( + row: Readonly, +): Uint8Array { + return digestBytes( + LEAF_DOMAIN_BYTES, + canonicalizeJsonBytes(row as unknown as CanonicalJsonValue, { + maxBytes: 2 * 1024, + maxDepth: 2, + }), + ); +} + +function snapshotExactDataRecord( + input: unknown, + expectedKeys: Keys, + label: string, +): Readonly> { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + fail('finalized-vm-set-schema', `${label} must be a plain data object`); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + fail('finalized-vm-set-schema', `${label} must be a plain data object`); + } + const keys = Reflect.ownKeys(input); + if (keys.some((key) => typeof key !== 'string')) { + fail('finalized-vm-set-schema', `${label} must not contain symbol fields`); + } + const strings = keys as string[]; + if ( + strings.length !== expectedKeys.length + || expectedKeys.some((key) => !strings.includes(key)) + ) { + fail('finalized-vm-set-schema', `${label} has unknown or missing fields`); + } + const snapshot: Record = Object.create(null); + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail('finalized-vm-set-schema', `${label}.${key} must be an enumerable data field`); + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot) as Readonly>; +} + +function digestBytes(domain: Uint8Array, ...chunks: readonly Uint8Array[]): Uint8Array { + const hasher = sha256.create(); + hasher.update(domain); + for (const chunk of chunks) hasher.update(chunk); + return hasher.digest(); +} + +function digestBytesToLowerHex(bytes: Uint8Array): Digest32V1 { + let value = '0x'; + for (const byte of bytes) value += byte.toString(16).padStart(2, '0'); + assertCanonicalDigest(value); + return value; +} + +function fail( + code: FinalizedVmSetV1ErrorCode, + message: string, + cause?: unknown, +): never { + throw new FinalizedVmSetV1Error(code, message, cause === undefined ? {} : { cause }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b0fec84e3f..dcb0d651cc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,6 +52,7 @@ export * from './ka-bundle-v1.js'; export * from './ka-chunk-tree.js'; export * from './ka-chunk-proof.js'; export * from './cg-shared-projection.js'; +export * from './finalized-vm-set-v1.js'; export * from './author-catalog-codec.js'; export * from './author-catalog-objects.js'; export * from './author-catalog-directory.js'; diff --git a/packages/core/test/finalized-vm-set-v1.test.ts b/packages/core/test/finalized-vm-set-v1.test.ts new file mode 100644 index 0000000000..350e1159df --- /dev/null +++ b/packages/core/test/finalized-vm-set-v1.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import { sha256 } from '@noble/hashes/sha2.js'; + +import { + FinalizedVmSetAccumulatorV1, + computeEmptyFinalizedVmSetRootV1, + computeFinalizedVmSetEvidenceV1, + computeFinalizedVmSetLeafDigestV1, + snapshotFinalizedVmSetRowV1, + type FinalizedVmSetScopeV1, + type FinalizedVmSetRowV1, + type FinalizedVmSetV1ErrorCode, +} from '../src/finalized-vm-set-v1.js'; + +const AUTHOR = '0x1111111111111111111111111111111111111111'; +const CONTRACT = '0x2222222222222222222222222222222222222222'; +const OTHER_CONTRACT = '0x3333333333333333333333333333333333333333'; +const SCOPE = Object.freeze({ + networkId: 'otp:20430', + chainId: '20430', + contractAddress: CONTRACT, +}) as FinalizedVmSetScopeV1; + +describe('RFC-64 finalized VM-set accumulator', () => { + it('matches independent empty, one, even, and odd tree vectors', () => { + expect(computeEmptyFinalizedVmSetRootV1()).toBe( + '0x900f13ea9b9bfc985dfd4beedf0ae0a6f01ff3a5a211943e18a751187dd9a09d', + ); + expect(computeFinalizedVmSetLeafDigestV1(SCOPE, row(0))).toBe( + '0x45304bfc182887862fe36825a90aa37787e885d3c0cf7ee700264ff0fcdbd4d2', + ); + + const roots = new Map([ + [1, '0x45304bfc182887862fe36825a90aa37787e885d3c0cf7ee700264ff0fcdbd4d2'], + [2, '0x6c432a76137e037ae3feb0b838ad7c9f49c62b46f9c75727cd128aa4db95d0be'], + [3, '0x94b06b80d4f8f386b71cb48d8b98394a41319a06d611ee14e3ccb8069a491ec6'], + [5, '0x1f99dd55651c7942351eeef93b1662fa2795f745e460910b4b199b349689ef7d'], + ]); + for (const [count, expectedRoot] of roots) { + const evidence = computeFinalizedVmSetEvidenceV1( + SCOPE, + Array.from({ length: count }, (_, ordinal) => row(ordinal)), + ); + expect(evidence).toEqual({ + scope: SCOPE, + rootDigest: expectedRoot, + rowCount: String(count), + highestFinalizedOrdinal: String(count - 1), + }); + } + }); + + it('streams ordered rows with logarithmic retained tree state and finalizes idempotently', () => { + const accumulator = new FinalizedVmSetAccumulatorV1(SCOPE); + accumulator.append(row(0)); + accumulator.append(row(3)); + accumulator.append(row(9)); + const first = accumulator.finalize(); + expect(first.rowCount).toBe('3'); + expect(first.highestFinalizedOrdinal).toBe('9'); + expect(accumulator.finalize()).toBe(first); + expectFailureCode(() => accumulator.append(row(10)), 'finalized-vm-set-state'); + }); + + it('matches a direct level-by-level tree for every boundary through 129 rows', () => { + for (let count = 0; count <= 129; count += 1) { + const rows = Array.from({ length: count }, (_, ordinal) => row(ordinal)); + expect(computeFinalizedVmSetEvidenceV1(SCOPE, rows).rootDigest).toBe( + computeReferenceRoot(rows), + ); + } + }); + + it('rejects duplicate, descending, and cross-lane rows instead of sorting them', () => { + const duplicate = new FinalizedVmSetAccumulatorV1(SCOPE); + duplicate.append(row(2)); + expectFailureCode(() => duplicate.append(row(2)), 'finalized-vm-set-order'); + + const descending = new FinalizedVmSetAccumulatorV1(SCOPE); + descending.append(row(2)); + expectFailureCode(() => descending.append(row(1)), 'finalized-vm-set-order'); + + const crossLane = new FinalizedVmSetAccumulatorV1(SCOPE); + expectFailureCode( + () => crossLane.append({ ...row(0), contractAddress: OTHER_CONTRACT } as FinalizedVmSetRowV1), + 'finalized-vm-set-lane', + ); + expectFailureCode( + () => computeFinalizedVmSetLeafDigestV1( + SCOPE, + { ...row(0), contractAddress: OTHER_CONTRACT } as FinalizedVmSetRowV1, + ), + 'finalized-vm-set-lane', + ); + }); + + it('rejects UAL aliases and author mismatches before hashing', () => { + expectFailureCode( + () => snapshotFinalizedVmSetRowV1({ + ...row(0), + ual: `did:dkg:otp:20430/${AUTHOR.toUpperCase()}/1`, + } as FinalizedVmSetRowV1, SCOPE.networkId), + 'finalized-vm-set-ual', + ); + expectFailureCode( + () => snapshotFinalizedVmSetRowV1({ + ...row(0), + ual: `did:dkg:otp:20430/${OTHER_CONTRACT}/1`, + } as FinalizedVmSetRowV1, SCOPE.networkId), + 'finalized-vm-set-ual', + ); + expectFailureCode( + () => snapshotFinalizedVmSetRowV1({ + ...row(0), + assertionVersion: '0', + } as FinalizedVmSetRowV1, SCOPE.networkId), + 'finalized-vm-set-scalar', + ); + expectFailureCode( + () => snapshotFinalizedVmSetRowV1({ + ...row(0), + ual: `did:dkg:wrong-lane/${AUTHOR}/1`, + } as FinalizedVmSetRowV1, SCOPE.networkId), + 'finalized-vm-set-ual', + ); + }); + + it('snapshots every caller field once and never re-reads a switching Proxy', () => { + const source = row(0) as unknown as Record; + const descriptorReads = new Map(); + const proxy = new Proxy(source, { + getOwnPropertyDescriptor(target, key) { + const reads = (descriptorReads.get(key) ?? 0) + 1; + descriptorReads.set(key, reads); + if (reads > 1) throw new Error(`field ${String(key)} was re-read`); + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); + if (key === 'ordinal' && descriptor) { + return { ...descriptor, value: '0' }; + } + return descriptor; + }, + get(_target, key) { + throw new Error(`unsafe property read: ${String(key)}`); + }, + }); + + const snapshot = snapshotFinalizedVmSetRowV1( + proxy as FinalizedVmSetRowV1, + SCOPE.networkId, + ); + expect(snapshot).toEqual(row(0)); + expect([...descriptorReads.values()].every((count) => count === 1)).toBe(true); + }); + + it('rejects Proxy re-entry instead of caching evidence during an unfinished append', () => { + const accumulator = new FinalizedVmSetAccumulatorV1(SCOPE); + let reentryCode: unknown; + const proxy = new Proxy(row(0), { + getPrototypeOf(target) { + try { + accumulator.finalize(); + } catch (error) { + reentryCode = (error as Error & { code?: unknown }).code; + } + return Reflect.getPrototypeOf(target); + }, + }); + + accumulator.append(proxy); + expect(reentryCode).toBe('finalized-vm-set-state'); + expect(accumulator.finalize()).toEqual({ + scope: SCOPE, + rootDigest: computeFinalizedVmSetLeafDigestV1(SCOPE, row(0)), + rowCount: '1', + highestFinalizedOrdinal: '0', + }); + }); + + it('snapshots the lane before an input iterator can mutate its source object', () => { + const mutableScope = { + networkId: 'otp:20430', + chainId: '20430', + contractAddress: CONTRACT, + } as FinalizedVmSetScopeV1; + function* rows(): Generator { + mutableScope.contractAddress = OTHER_CONTRACT as never; + yield row(0); + } + const evidence = computeFinalizedVmSetEvidenceV1(mutableScope, rows()); + expect(evidence.scope).toEqual(SCOPE); + }); +}); + +function row(ordinal: number): FinalizedVmSetRowV1 { + const scalar = BigInt(ordinal); + return Object.freeze({ + chainId: '20430', + contractAddress: CONTRACT, + ordinal: scalar.toString(), + ual: `did:dkg:otp:20430/${AUTHOR}/${scalar + 1n}`, + authorAddress: AUTHOR, + assertionVersion: (scalar + 1n).toString(), + assertionRoot: digest(scalar + 1n), + finalizedBlockNumber: (100n + scalar).toString(), + finalizedBlockHash: digest(1000n + scalar), + placementEvidenceDigest: digest(2000n + scalar), + }) as FinalizedVmSetRowV1; +} + +function digest(value: bigint): `0x${string}` { + return `0x${value.toString(16).padStart(64, '0')}`; +} + +function computeReferenceRoot(rows: readonly FinalizedVmSetRowV1[]): `0x${string}` { + if (rows.length === 0) return computeEmptyFinalizedVmSetRootV1(); + let level = rows.map((value) => hexToBytes(computeFinalizedVmSetLeafDigestV1(SCOPE, value))); + const encoder = new TextEncoder(); + const nodeDomain = encoder.encode('dkg-finalized-vm-set-node-v1\n'); + const oddDomain = encoder.encode('dkg-finalized-vm-set-odd-v1\n'); + while (level.length > 1) { + const next: Uint8Array[] = []; + for (let index = 0; index < level.length; index += 2) { + next.push(index + 1 < level.length + ? sha256(concat(nodeDomain, level[index]!, level[index + 1]!)) + : sha256(concat(oddDomain, level[index]!))); + } + level = next; + } + return bytesToHex(level[0]!); +} + +function concat(...chunks: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + return output; +} + +function hexToBytes(value: string): Uint8Array { + return Uint8Array.from(value.slice(2).match(/../g)!, (byte) => Number.parseInt(byte, 16)); +} + +function bytesToHex(value: Uint8Array): `0x${string}` { + return `0x${[...value].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`; +} + +function expectFailureCode(operation: () => unknown, expected: FinalizedVmSetV1ErrorCode): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error & { code?: unknown }).code).toBe(expected); + return; + } + throw new Error(`expected operation to fail with ${expected}`); +} From 19a159efa32df3a8aa0e53320c9bac0c8a41d805 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:11:11 +0200 Subject: [PATCH 138/292] test(agent): close RFC-64 successor-producer Gate-1 coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the remaining test-coverage gaps from the 6c14bd4ad successor-producer review (findings F5-F8) with three focused tests over the existing correct producer — test-only, no product-code change: - control-object staging fails only AFTER the bundle is durably staged (catalog-successor-producer-control-stage; asserts bundle-first order) — closes F5 control-stage path and F8 (the 6th/last error code now covered) - a one-row previous head whose bucket is missing is rejected before any staging (catalog-successor-producer-history via assertSupportedPreviousSlice) — F6 - a version-2 successor replaces the current one-row bucket (previousBucket!=null steady-state replace: same KA/coordinate, assertionVersion incremented) — F7 Focused test count 9 -> 12 passing; agent build (tsc + test:types + test:package-root) green. Co-Authored-By: Claude Opus 4.8 --- ...blic-catalog-successor-producer-v1.test.ts | 159 +++++++++++++++++- 1 file changed, 154 insertions(+), 5 deletions(-) diff --git a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts index 9df98a2e6a..68717c1f79 100644 --- a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts @@ -318,6 +318,110 @@ describe('RFC-64 public/open one-row successor producer', () => { expect(stageKaBundle).not.toHaveBeenCalled(); expect(stageVerifiedObjects).not.toHaveBeenCalled(); }); + + it('fails control-object staging only after the bundle is durably staged first', async () => { + const { genesis, authorization } = await producerHistory(); + const events: string[] = []; + const stageKaBundle = vi.fn(async (input) => { + events.push('bundle'); + return durableBundleReceipt(input); + }); + const stageVerifiedObjects = vi.fn(async () => { + events.push('objects'); + throw new Error('durable control-object write failed'); + }); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + await expect(producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(AUTHOR_WALLET), + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, + })).rejects.toMatchObject({ code: 'catalog-successor-producer-control-stage' }); + + // Bundle-first ordering: the immutable bundle is durably staged before the + // control-object write, so a control-stage failure can leave only an + // unreferenced bundle (no head is advanced) — never a head pointing at an + // unavailable bundle. + expect(events).toEqual(['bundle', 'objects']); + expect(stageKaBundle).toHaveBeenCalledOnce(); + }); + + it('rejects a one-row previous head whose bucket is missing before any staging', async () => { + const { genesis, authorization } = await producerHistory(); + const first = await stageOne( + { head: genesis.head, directoryPath: genesis.directoryPath, bucket: null }, + authorization, + ); + expect(first.publication.head.payload).toMatchObject({ totalRows: '1' }); + + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + + await expect(producer.produceAndStage({ + previousHead: first.publication.head, + previousDirectoryPath: first.publication.directoryPath, + // A totalRows='1' head requires an exact one-row bucket; null is out of slice. + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(AUTHOR_WALLET), + deployment: DEPLOYMENT, + issuedAt: '1773900002000' as never, + catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, + })).rejects.toMatchObject({ code: 'catalog-successor-producer-history' }); + + expect(stageKaBundle).not.toHaveBeenCalled(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); + + it('produces a version-2 successor that replaces the current one-row bucket', async () => { + const { genesis, authorization } = await producerHistory(); + const first = await stageOne( + { head: genesis.head, directoryPath: genesis.directoryPath, bucket: null }, + authorization, + ); + expect(first.publication.head.payload).toMatchObject({ version: '1', totalRows: '1' }); + expect(first.publication.bucket?.payload.rows.length).toBe(1); + + // A valid one-row -> one-row replacement updates the SAME KA/coordinate and + // increments assertionVersion by exactly one (an ordinary one-KA delta). + const second = await stageOne( + { + head: first.publication.head, + directoryPath: first.publication.directoryPath, + bucket: first.publication.bucket, + }, + authorization, + '1773900002000', + await authorSeal(AUTHOR_WALLET, KA_NUMBER, '2'), + ); + + expect(second.publication.head.payload).toMatchObject({ + subGraphName: null, + bucketCount: '1', + directoryHeight: '0', + totalRows: '1', + version: '2', + previousHeadDigest: first.publication.head.objectDigest, + }); + expect(second.publication.bucket?.payload.rows).toEqual([second.row]); + expect(second.row).not.toEqual(first.row); + }); }); type BundleStageInput = { @@ -346,6 +450,45 @@ async function producerHistory() { return { genesis, authorization }; } +/** + * Drive one full produceAndStage over a previous slice through working mocks, + * reusing the exact author seal / projection / issuer authorization. Used to + * build a real one-row predecessor (successor #1) so the steady-state replace + * and unsupported-previous-slice paths can be exercised end-to-end. + */ +async function stageOne( + previous: Pick< + Awaited>, + 'head' | 'directoryPath' | 'bucket' + >, + authorization: Awaited>, + issuedAt = '1773900001000', + seal?: CanonicalGraphScopedAuthorSealV1, +) { + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => Object.freeze({ + durable: true as const, + namespaceDurability: 'test-exact-durable' as never, + objects: Object.freeze([]), + })); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + return producer.produceAndStage({ + previousHead: previous.head, + previousDirectoryPath: previous.directoryPath, + previousBucket: previous.bucket, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: seal ?? await authorSeal(AUTHOR_WALLET), + deployment: DEPLOYMENT, + issuedAt: issuedAt as never, + catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, + }); +} + async function directCatalogAuthorization( wallet: ethers.Wallet, contextGraphId: ContextGraphIdV1, @@ -391,13 +534,19 @@ function catalogSigner() { }; } -async function authorSeal(signingWallet: ethers.Wallet): Promise { +async function authorSeal( + signingWallet: ethers.Wallet, + kaNumber = KA_NUMBER, + assertionVersion = '1', +): Promise { + const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); + const kaUal = `did:dkg:${NETWORK_ID}/${AUTHOR}/${kaNumber}`; const typedData = buildAuthorAttestationTypedData({ chainId: BigInt(DEPLOYMENT.assertedAtChainId), kav10Address: DEPLOYMENT.assertedAtKav10Address, merkleRoot: ethers.getBytes(ASSERTION_ROOT), authorAddress: AUTHOR, - reservedKaId: BigInt(KA_ID), + reservedKaId: BigInt(kaId), }); const signature = ethers.Signature.from(await signingWallet.signTypedData( typedData.domain, @@ -412,11 +561,11 @@ async function authorSeal(signingWallet: ethers.Wallet): Promise Date: Sun, 19 Jul 2026 21:27:06 +0200 Subject: [PATCH 139/292] test(agent): budget Windows signature ceiling --- .../agent/test/rfc64-control-object-store-v1.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/agent/test/rfc64-control-object-store-v1.test.ts b/packages/agent/test/rfc64-control-object-store-v1.test.ts index 3396a3bea9..e8676d24fc 100644 --- a/packages/agent/test/rfc64-control-object-store-v1.test.ts +++ b/packages/agent/test/rfc64-control-object-store-v1.test.ts @@ -47,6 +47,13 @@ import { const SAFE = '0x3333333333333333333333333333333333333333' as EvmAddressV1; const BLOCK_HASH = `0x${'44'.repeat(32)}` as Digest32V1; +// Exercising the exact 64-variant production ceiling intentionally publishes +// 64 durable files. Windows validates every owner-only ACL through a separate +// PowerShell child, so retain the suite-wide 60s budget and widen only this +// platform-specific durability case. +const SIGNATURE_VARIANT_CEILING_TEST_TIMEOUT_MS = process.platform === 'win32' + ? 180_000 + : 60_000; const openRfc64ControlObjectStoreV1 = createRfc64ControlObjectStoreTestOpenerV1(); const temporaryDirectories = createTemporaryDataDirectoryFixture(); const { temporaryDataDirectory } = temporaryDirectories; @@ -382,7 +389,7 @@ describe('RFC-64 durable control-object store v1', () => { })).resolves.toMatchObject({ envelope: expect.objectContaining({ objectDigest: variants[0].envelope.objectDigest }), }); - }); + }, SIGNATURE_VARIANT_CEILING_TEST_TIMEOUT_MS); it.runIf(process.platform !== 'win32')( 'fails closed when a signature shard is replaced by an owner-only symlink', From 62a6ae3ecb3dad5465474067ec3b1d4c35ee8034 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:02:18 +0200 Subject: [PATCH 140/292] feat(agent): verify bounded RFC-64 inventory completeness --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + ...ublic-catalog-inventory-completeness-v1.ts | 394 ++++++++++++++++++ ...-catalog-inventory-completeness-v1.test.ts | 164 ++++++++ packages/agent/vitest.unit.config.ts | 1 + 5 files changed, 561 insertions(+) create mode 100644 packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 1ce74811f8..c9a5447a31 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -29,6 +29,7 @@ "./dist/rfc64/persistence-layout-v1.js": null, "./dist/rfc64/persistence-root-ownership-v1-internal.js": null, "./dist/rfc64/persistence-v1.js": null, + "./dist/rfc64/public-catalog-inventory-completeness-v1.js": null, "./dist/rfc64/public-catalog-native-receiver-v1.js": null, "./dist/rfc64/public-catalog-native-reconciler-v1.js": null, "./dist/rfc64/public-catalog-native-transport-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 1209e37f5a..632498c814 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -39,6 +39,7 @@ const blockedRfc64Modules = [ 'persistence-layout-v1.js', 'persistence-root-ownership-v1-internal.js', 'persistence-v1.js', + 'public-catalog-inventory-completeness-v1.js', 'public-catalog-native-reconciler-v1.js', 'public-catalog-native-receiver-v1.js', 'public-catalog-native-transport-v1.js', diff --git a/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts b/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts new file mode 100644 index 0000000000..7bf447a265 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Bounded exact-set completion for one RFC-64 public author catalog. + * + * Signed bucket verification establishes the expected row set. Semantic + * activation independently produces one exact post-read row per KA. This + * module is the join between those two facts: it rejects missing, duplicate, + * extra, or mismatched rows before minting deterministic inventory evidence. + * + * Gate 2 deliberately stays inside one canonical v1 bucket (at most 1,024 + * rows). Directory-tree expansion is a later, orthogonal capacity slice. + */ + +import { + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, + assertAuthorCatalogScopeV1, + assertCanonicalDigest, + assertCanonicalKaId, + compareAuthorCatalogKaIdsV1, + computeAuthorCatalogScopeDigestV1, + parseCanonicalDecimalU64, + parseDeterministicKnowledgeAssetUal, + type AuthorCatalogScopeV1, + type CountV1, + type Digest32V1, + type KaIdV1, +} from '@origintrail-official/dkg-core'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { ethers } from 'ethers'; + +const UTF8 = new TextEncoder(); +const APPLIED_INVENTORY_DIGEST_DOMAIN_V1 = 'dkg-rfc64-applied-inventory-v1\n'; + +export type Rfc64PublicCatalogInventoryCompletenessErrorCodeV1 = + | 'catalog-inventory-completeness-input' + | 'catalog-inventory-completeness-count' + | 'catalog-inventory-completeness-order' + | 'catalog-inventory-completeness-duplicate' + | 'catalog-inventory-completeness-missing' + | 'catalog-inventory-completeness-extra' + | 'catalog-inventory-completeness-mismatch'; + +export class Rfc64PublicCatalogInventoryCompletenessErrorV1 extends Error { + constructor( + readonly code: Rfc64PublicCatalogInventoryCompletenessErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'Rfc64PublicCatalogInventoryCompletenessErrorV1'; + } +} + +/** One exact semantic post-read, keyed by the signed catalog row's KA ID. */ +export interface Rfc64PublicCatalogInventoryEvidenceRowV1 { + readonly kaId: KaIdV1; + readonly catalogRowDigest: Digest32V1; + readonly contentDigest: Digest32V1; + readonly sealDigest: Digest32V1; + readonly kaUal: string; + readonly activatedTripleCount: number; +} + +/** Deterministic evidence for one complete bounded catalog live set. */ +export interface Rfc64PublicCatalogInventoryCompletenessEvidenceV1 { + readonly catalogScopeDigest: Digest32V1; + readonly inventoryRowCount: CountV1; + readonly inventoryDigest: Digest32V1; + /** Strictly increasing by mathematical `kaId`. */ + readonly rows: readonly Readonly[]; +} + +export interface VerifyRfc64PublicCatalogInventoryCompletenessInputV1 { + readonly catalogScope: AuthorCatalogScopeV1; + /** Exact `AuthorCatalogHeadV1.totalRows` value. */ + readonly expectedTotalRows: CountV1; + /** Signed expected set. It must already be in canonical numeric KA-ID order. */ + readonly expectedRows: readonly Rfc64PublicCatalogInventoryEvidenceRowV1[]; + /** Independently observed exact semantic post-reads; input order is irrelevant. */ + readonly observedRows: readonly Rfc64PublicCatalogInventoryEvidenceRowV1[]; +} + +/** + * Verify exact bounded set equality and return deterministic completion evidence. + * + * The digest framing intentionally matches Gate 1's one-row applied-inventory + * digest. Gate 2 only freezes the previously-unobservable multi-row ordering: + * numeric `kaId`, matching the signed bucket contract, never lexical UAL order. + */ +export function verifyRfc64PublicCatalogInventoryCompletenessV1( + input: VerifyRfc64PublicCatalogInventoryCompletenessInputV1, +): Rfc64PublicCatalogInventoryCompletenessEvidenceV1 { + let scope: Readonly; + let expectedCount: bigint; + try { + scope = snapshotScope(input?.catalogScope); + expectedCount = parseCanonicalDecimalU64( + input?.expectedTotalRows, + 'expectedTotalRows', + ); + } catch (cause) { + fail( + 'catalog-inventory-completeness-input', + 'catalog scope or expected row count is invalid', + cause, + ); + } + if (expectedCount > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1)) { + fail( + 'catalog-inventory-completeness-count', + `bounded catalog completion supports at most ${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1} rows`, + ); + } + + const expected = snapshotRows(input.expectedRows, scope, 'expectedRows', true); + const observed = snapshotRows(input.observedRows, scope, 'observedRows', false); + if (BigInt(expected.length) !== expectedCount) { + fail( + 'catalog-inventory-completeness-count', + `signed expected set has ${expected.length} rows but head commits ${expectedCount}`, + ); + } + + const expectedByKaId = new Map(expected.map((row) => [row.kaId, row] as const)); + const observedByKaId = new Map(observed.map((row) => [row.kaId, row] as const)); + for (const row of expected) { + const actual = observedByKaId.get(row.kaId); + if (actual === undefined) { + fail( + 'catalog-inventory-completeness-missing', + `semantic post-read is missing expected kaId ${row.kaId}`, + ); + } + if (!sameRow(row, actual)) { + fail( + 'catalog-inventory-completeness-mismatch', + `semantic post-read differs from expected kaId ${row.kaId}`, + ); + } + } + for (const row of observed) { + if (!expectedByKaId.has(row.kaId)) { + fail( + 'catalog-inventory-completeness-extra', + `semantic post-read contains unexpected kaId ${row.kaId}`, + ); + } + } + + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); + return Object.freeze({ + catalogScopeDigest, + inventoryRowCount: input.expectedTotalRows, + inventoryDigest: computeInventoryDigest(catalogScopeDigest, expected), + rows: expected, + }); +} + +function snapshotScope(input: AuthorCatalogScopeV1): Readonly { + const scope = { + networkId: input.networkId, + contextGraphId: input.contextGraphId, + governanceChainId: input.governanceChainId, + governanceContractAddress: input.governanceContractAddress, + ownershipTransitionDigest: input.ownershipTransitionDigest, + subGraphName: input.subGraphName, + authorAddress: input.authorAddress, + era: input.era, + bucketCount: input.bucketCount, + } as AuthorCatalogScopeV1; + assertAuthorCatalogScopeV1(scope); + return Object.freeze(scope); +} + +function snapshotRows( + input: readonly Rfc64PublicCatalogInventoryEvidenceRowV1[], + scope: Readonly, + label: 'expectedRows' | 'observedRows', + requireCanonicalInputOrder: boolean, +): readonly Readonly[] { + assertDenseOrdinaryArray(input, label); + if (input.length > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) { + fail( + 'catalog-inventory-completeness-count', + `${label} exceeds the ${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1}-row bucket bound`, + ); + } + + const rows = input.map((row, index) => snapshotRow(row, scope, `${label}[${index}]`)); + assertNoDuplicateRows(rows, label); + if (requireCanonicalInputOrder) { + for (let index = 1; index < rows.length; index += 1) { + if (compareAuthorCatalogKaIdsV1(rows[index - 1].kaId, rows[index].kaId) >= 0) { + fail( + 'catalog-inventory-completeness-order', + `${label} must be strictly increasing by mathematical kaId`, + ); + } + } + } else { + rows.sort((left, right) => compareAuthorCatalogKaIdsV1(left.kaId, right.kaId)); + } + return Object.freeze(rows); +} + +function assertNoDuplicateRows( + rows: readonly Readonly[], + label: string, +): void { + const seenKaIds = new Set(); + const seenUals = new Set(); + for (const row of rows) { + if (seenKaIds.has(row.kaId)) { + fail( + 'catalog-inventory-completeness-duplicate', + `${label} contains duplicate kaId ${row.kaId}`, + ); + } + if (seenUals.has(row.kaUal)) { + fail( + 'catalog-inventory-completeness-duplicate', + `${label} contains duplicate KA UAL ${row.kaUal}`, + ); + } + seenKaIds.add(row.kaId); + seenUals.add(row.kaUal); + } +} + +function snapshotRow( + input: Rfc64PublicCatalogInventoryEvidenceRowV1, + scope: Readonly, + label: string, +): Readonly { + if (!isPlainRecord(input)) { + fail('catalog-inventory-completeness-input', `${label} must be a plain object`); + } + const keys = Reflect.ownKeys(input); + const expectedKeys = [ + 'activatedTripleCount', + 'catalogRowDigest', + 'contentDigest', + 'kaId', + 'kaUal', + 'sealDigest', + ]; + if ( + keys.length !== expectedKeys.length + || keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + fail('catalog-inventory-completeness-input', `${label} has missing or extra fields`); + } + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + fail( + 'catalog-inventory-completeness-input', + `${label}.${key} must be an enumerable data property`, + ); + } + } + + try { + assertCanonicalKaId(input.kaId, `${label}.kaId`); + assertCanonicalDigest(input.catalogRowDigest, `${label}.catalogRowDigest`); + assertCanonicalDigest(input.contentDigest, `${label}.contentDigest`); + assertCanonicalDigest(input.sealDigest, `${label}.sealDigest`); + if (!Number.isSafeInteger(input.activatedTripleCount) || input.activatedTripleCount < 1) { + throw new Error(`${label}.activatedTripleCount must be a positive safe integer`); + } + const parsedUal = parseDeterministicKnowledgeAssetUal(input.kaUal); + if (parsedUal.ual !== input.kaUal) { + throw new Error(`${label}.kaUal is not canonical`); + } + if ( + parsedUal.chainId !== scope.networkId + || parsedUal.agentAddress !== scope.authorAddress + ) { + throw new Error(`${label}.kaUal differs from the trusted catalog scope`); + } + const packedKaId = (BigInt(parsedUal.agentAddress) << 96n) | BigInt(parsedUal.kaNumber); + if (packedKaId !== BigInt(input.kaId)) { + throw new Error(`${label}.kaUal does not encode its signed kaId`); + } + } catch (cause) { + fail( + 'catalog-inventory-completeness-input', + `${label} is not exact canonical semantic evidence`, + cause, + ); + } + return Object.freeze({ + kaId: input.kaId, + catalogRowDigest: input.catalogRowDigest, + contentDigest: input.contentDigest, + sealDigest: input.sealDigest, + kaUal: input.kaUal, + activatedTripleCount: input.activatedTripleCount, + }); +} + +function computeInventoryDigest( + catalogScopeDigest: Digest32V1, + rows: readonly Readonly[], +): Digest32V1 { + const hasher = sha256.create(); + hasher.update(UTF8.encode(APPLIED_INVENTORY_DIGEST_DOMAIN_V1)); + hasher.update(ethers.getBytes(catalogScopeDigest)); + hasher.update(encodeU64(BigInt(rows.length), 'inventory row count')); + for (const row of rows) { + hasher.update(ethers.getBytes(row.catalogRowDigest)); + hasher.update(ethers.getBytes(row.contentDigest)); + hasher.update(ethers.getBytes(row.sealDigest)); + const ual = UTF8.encode(row.kaUal); + hasher.update(encodeU64(BigInt(ual.byteLength), 'KA UAL byte length')); + hasher.update(ual); + hasher.update(encodeU64(BigInt(row.activatedTripleCount), 'activated triple count')); + } + return ethers.hexlify(hasher.digest()) as Digest32V1; +} + +function encodeU64(value: bigint, label: string): Uint8Array { + if (value < 0n || value > 18_446_744_073_709_551_615n) { + fail('catalog-inventory-completeness-input', `${label} is outside u64`); + } + const result = new Uint8Array(8); + let remaining = value; + for (let index = result.length - 1; index >= 0; index -= 1) { + result[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return result; +} + +function sameRow( + expected: Readonly, + observed: Readonly, +): boolean { + return expected.kaId === observed.kaId + && expected.catalogRowDigest === observed.catalogRowDigest + && expected.contentDigest === observed.contentDigest + && expected.sealDigest === observed.sealDigest + && expected.kaUal === observed.kaUal + && expected.activatedTripleCount === observed.activatedTripleCount; +} + +function assertDenseOrdinaryArray(value: unknown, label: string): asserts value is unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + fail('catalog-inventory-completeness-input', `${label} must be an ordinary Array`); + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.some((key) => typeof key !== 'string') + || ownKeys.length !== value.length + 1 + || !ownKeys.includes('length') + ) { + fail( + 'catalog-inventory-completeness-input', + `${label} must be dense and contain no custom properties`, + ); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail( + 'catalog-inventory-completeness-input', + `${label} must contain only enumerable data elements`, + ); + } + } +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && Object.getPrototypeOf(value) === Object.prototype; +} + +function fail( + code: Rfc64PublicCatalogInventoryCompletenessErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64PublicCatalogInventoryCompletenessErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts b/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts new file mode 100644 index 0000000000..b5934d3da5 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeAuthorCatalogScopeDigestV1, + type AuthorCatalogScopeV1, + type CountV1, + type Digest32V1, + type EvmAddressV1, + type KaIdV1, +} from '@origintrail-official/dkg-core'; + +import { computeRfc64AppliedInventoryDigestV1 } from '../src/rfc64/public-catalog-native-receiver-v1.js'; +import { + Rfc64PublicCatalogInventoryCompletenessErrorV1, + verifyRfc64PublicCatalogInventoryCompletenessV1, + type Rfc64PublicCatalogInventoryEvidenceRowV1, +} from '../src/rfc64/public-catalog-inventory-completeness-v1.js'; + +const AUTHOR = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const SCOPE = Object.freeze({ + networkId: 'otp:20430', + contextGraphId: 'gate2-multi-asset', + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', +}) as AuthorCatalogScopeV1; + +describe('RFC-64 public catalog bounded inventory completeness', () => { + it('mints deterministic exact-set evidence in numeric KA-ID order', () => { + const expected = [row(2), row(10), row(100)]; + const evidence = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '3' as CountV1, + expectedRows: expected, + observedRows: [expected[2], expected[0], expected[1]], + }); + + expect(evidence.catalogScopeDigest).toBe(computeAuthorCatalogScopeDigestV1(SCOPE)); + expect(evidence.inventoryRowCount).toBe('3'); + expect(evidence.rows.map((entry) => entry.kaUal)).toEqual([ + `did:dkg:otp:20430/${AUTHOR}/2`, + `did:dkg:otp:20430/${AUTHOR}/10`, + `did:dkg:otp:20430/${AUTHOR}/100`, + ]); + expect(evidence.inventoryDigest).toMatch(/^0x[0-9a-f]{64}$/); + + const repeated = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: { ...SCOPE }, + expectedTotalRows: '3' as CountV1, + expectedRows: expected.map((entry) => ({ ...entry })), + observedRows: [...expected].reverse(), + }); + expect(repeated).toEqual(evidence); + }); + + it('preserves the exact Gate-1 digest framing for a one-row set', () => { + const only = row(7); + const evidence = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '1' as CountV1, + expectedRows: [only], + observedRows: [only], + }); + expect(evidence.inventoryDigest).toBe(computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(SCOPE), + rows: [{ + catalogRowDigest: only.catalogRowDigest, + contentDigest: only.contentDigest, + sealDigest: only.sealDigest, + kaUal: only.kaUal, + activatedTripleCount: only.activatedTripleCount, + }], + })); + }); + + it.each([ + ['missing', 'catalog-inventory-completeness-missing', (rows: typeof BASE) => rows.slice(0, 1)], + ['extra', 'catalog-inventory-completeness-extra', (rows: typeof BASE) => [...rows, row(3)]], + ['duplicate', 'catalog-inventory-completeness-duplicate', (rows: typeof BASE) => [rows[0], rows[0], rows[1]]], + ['mismatch', 'catalog-inventory-completeness-mismatch', (rows: typeof BASE) => [rows[0], { ...rows[1], contentDigest: digest(99) }]], + ] as const)('rejects an observed %s row set', (_label, code, mutate) => { + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '2' as CountV1, + expectedRows: BASE, + observedRows: mutate(BASE), + }), code); + }); + + it('rejects a noncanonical signed expected-row order instead of hiding it by sorting', () => { + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '2' as CountV1, + expectedRows: [...BASE].reverse(), + observedRows: BASE, + }), 'catalog-inventory-completeness-order'); + }); + + it('classifies a duplicate signed expected row before its resulting order failure', () => { + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '3' as CountV1, + expectedRows: [BASE[0], BASE[0], BASE[1]], + observedRows: BASE, + }), 'catalog-inventory-completeness-duplicate'); + }); + + it('rejects head count disagreement, UAL aliases, and UAL/packed-ID mismatch', () => { + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '3' as CountV1, + expectedRows: BASE, + observedRows: BASE, + }), 'catalog-inventory-completeness-count'); + + const leadingZero = { ...BASE[0], kaUal: `did:dkg:otp:20430/${AUTHOR}/01` }; + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '1' as CountV1, + expectedRows: [leadingZero], + observedRows: [leadingZero], + }), 'catalog-inventory-completeness-input'); + + const wrongPackedId = { ...BASE[0], kaUal: `did:dkg:otp:20430/${AUTHOR}/999` }; + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '1' as CountV1, + expectedRows: [wrongPackedId], + observedRows: [wrongPackedId], + }), 'catalog-inventory-completeness-input'); + }); +}); + +const BASE = Object.freeze([row(1), row(2)]); + +function row(number: number): Rfc64PublicCatalogInventoryEvidenceRowV1 { + const kaId = ((BigInt(AUTHOR) << 96n) | BigInt(number)).toString() as KaIdV1; + return Object.freeze({ + kaId, + catalogRowDigest: digest(number * 4), + contentDigest: digest((number * 4) + 1), + sealDigest: digest((number * 4) + 2), + kaUal: `did:dkg:otp:20430/${AUTHOR}/${number}`, + activatedTripleCount: number + 1, + }); +} + +function digest(seed: number): Digest32V1 { + return `0x${seed.toString(16).padStart(64, '0')}` as Digest32V1; +} + +function expectCode(operation: () => unknown, expectedCode: string): void { + try { + operation(); + throw new Error(`expected ${expectedCode}`); + } catch (error) { + expect(error).toBeInstanceOf(Rfc64PublicCatalogInventoryCompletenessErrorV1); + expect((error as Rfc64PublicCatalogInventoryCompletenessErrorV1).code).toBe(expectedCode); + } +} diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 37c6730c49..7906d19759 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -130,6 +130,7 @@ export default defineConfig({ "test/rfc64-public-catalog-native-transport-v1.test.ts", "test/rfc64-public-catalog-native-reconciler-v1.test.ts", "test/rfc64-public-catalog-native-gate1.integration.test.ts", + "test/rfc64-public-catalog-inventory-completeness-v1.test.ts", "test/rfc64-persistent-catalog-provider-v1.test.ts", "test/rfc64-public-catalog-successor-producer-v1.test.ts", "test/rfc64-dkg-agent-successor-publication.integration.test.ts", From 0ffc75963dd972567c31d6707c3c8ea54b94ee48 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:04:17 +0200 Subject: [PATCH 141/292] test(agent): expose exact RFC-64 bundle evidence --- .../src/rfc64/public-catalog-inventory-completeness-v1.ts | 6 ++++++ .../rfc64-public-catalog-inventory-completeness-v1.test.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts b/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts index 7bf447a265..675cde00e0 100644 --- a/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts @@ -58,6 +58,8 @@ export interface Rfc64PublicCatalogInventoryEvidenceRowV1 { readonly catalogRowDigest: Digest32V1; readonly contentDigest: Digest32V1; readonly sealDigest: Digest32V1; + /** Exact `row.transfer.blobDigest`, exposed directly for operator evidence. */ + readonly bundleDigest: Digest32V1; readonly kaUal: string; readonly activatedTripleCount: number; } @@ -239,6 +241,7 @@ function snapshotRow( const keys = Reflect.ownKeys(input); const expectedKeys = [ 'activatedTripleCount', + 'bundleDigest', 'catalogRowDigest', 'contentDigest', 'kaId', @@ -270,6 +273,7 @@ function snapshotRow( assertCanonicalDigest(input.catalogRowDigest, `${label}.catalogRowDigest`); assertCanonicalDigest(input.contentDigest, `${label}.contentDigest`); assertCanonicalDigest(input.sealDigest, `${label}.sealDigest`); + assertCanonicalDigest(input.bundleDigest, `${label}.bundleDigest`); if (!Number.isSafeInteger(input.activatedTripleCount) || input.activatedTripleCount < 1) { throw new Error(`${label}.activatedTripleCount must be a positive safe integer`); } @@ -299,6 +303,7 @@ function snapshotRow( catalogRowDigest: input.catalogRowDigest, contentDigest: input.contentDigest, sealDigest: input.sealDigest, + bundleDigest: input.bundleDigest, kaUal: input.kaUal, activatedTripleCount: input.activatedTripleCount, }); @@ -345,6 +350,7 @@ function sameRow( && expected.catalogRowDigest === observed.catalogRowDigest && expected.contentDigest === observed.contentDigest && expected.sealDigest === observed.sealDigest + && expected.bundleDigest === observed.bundleDigest && expected.kaUal === observed.kaUal && expected.activatedTripleCount === observed.activatedTripleCount; } diff --git a/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts b/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts index b5934d3da5..aa41406f23 100644 --- a/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts @@ -144,6 +144,7 @@ function row(number: number): Rfc64PublicCatalogInventoryEvidenceRowV1 { catalogRowDigest: digest(number * 4), contentDigest: digest((number * 4) + 1), sealDigest: digest((number * 4) + 2), + bundleDigest: digest((number * 4) + 3), kaUal: `did:dkg:otp:20430/${AUTHOR}/${number}`, activatedTripleCount: number + 1, }); From 99cdde0f58d1ec12b1ba6d2c40517f47ddb7913a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:28:20 +0200 Subject: [PATCH 142/292] fix(agent): snapshot RFC-64 completeness evidence --- ...ublic-catalog-inventory-completeness-v1.ts | 268 ++++++++++++------ ...-catalog-inventory-completeness-v1.test.ts | 153 +++++++++- 2 files changed, 334 insertions(+), 87 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts b/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts index 675cde00e0..551946e182 100644 --- a/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts @@ -34,6 +34,7 @@ const APPLIED_INVENTORY_DIGEST_DOMAIN_V1 = 'dkg-rfc64-applied-inventory-v1\n'; export type Rfc64PublicCatalogInventoryCompletenessErrorCodeV1 = | 'catalog-inventory-completeness-input' + | 'catalog-inventory-completeness-slice' | 'catalog-inventory-completeness-count' | 'catalog-inventory-completeness-order' | 'catalog-inventory-completeness-duplicate' @@ -93,12 +94,13 @@ export interface VerifyRfc64PublicCatalogInventoryCompletenessInputV1 { export function verifyRfc64PublicCatalogInventoryCompletenessV1( input: VerifyRfc64PublicCatalogInventoryCompletenessInputV1, ): Rfc64PublicCatalogInventoryCompletenessEvidenceV1 { + const boundary = snapshotTopLevelInput(input); let scope: Readonly; let expectedCount: bigint; try { - scope = snapshotScope(input?.catalogScope); + scope = snapshotScope(boundary.catalogScope); expectedCount = parseCanonicalDecimalU64( - input?.expectedTotalRows, + boundary.expectedTotalRows, 'expectedTotalRows', ); } catch (cause) { @@ -108,15 +110,24 @@ export function verifyRfc64PublicCatalogInventoryCompletenessV1( cause, ); } - if (expectedCount > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1)) { + if (scope.bucketCount !== '1') { + fail( + 'catalog-inventory-completeness-slice', + 'bounded Gate-2 catalog completion requires exactly one signed bucket', + ); + } + if ( + expectedCount < 1n + || expectedCount > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) + ) { fail( 'catalog-inventory-completeness-count', - `bounded catalog completion supports at most ${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1} rows`, + `bounded catalog completion supports 1..${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1} rows`, ); } - const expected = snapshotRows(input.expectedRows, scope, 'expectedRows', true); - const observed = snapshotRows(input.observedRows, scope, 'observedRows', false); + const expected = snapshotRows(boundary.expectedRows, scope, 'expectedRows', true); + const observed = snapshotRows(boundary.observedRows, scope, 'observedRows', false); if (BigInt(expected.length) !== expectedCount) { fail( 'catalog-inventory-completeness-count', @@ -153,26 +164,54 @@ export function verifyRfc64PublicCatalogInventoryCompletenessV1( const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); return Object.freeze({ catalogScopeDigest, - inventoryRowCount: input.expectedTotalRows, + inventoryRowCount: boundary.expectedTotalRows, inventoryDigest: computeInventoryDigest(catalogScopeDigest, expected), rows: expected, }); } +function snapshotTopLevelInput( + input: VerifyRfc64PublicCatalogInventoryCompletenessInputV1, +): Readonly { + const fields = snapshotExactDataRecord(input, [ + 'catalogScope', + 'expectedRows', + 'expectedTotalRows', + 'observedRows', + ], 'inventory completeness input'); + return Object.freeze({ + catalogScope: fields.catalogScope as AuthorCatalogScopeV1, + expectedTotalRows: fields.expectedTotalRows as CountV1, + expectedRows: fields.expectedRows as readonly Rfc64PublicCatalogInventoryEvidenceRowV1[], + observedRows: fields.observedRows as readonly Rfc64PublicCatalogInventoryEvidenceRowV1[], + }); +} + function snapshotScope(input: AuthorCatalogScopeV1): Readonly { - const scope = { - networkId: input.networkId, - contextGraphId: input.contextGraphId, - governanceChainId: input.governanceChainId, - governanceContractAddress: input.governanceContractAddress, - ownershipTransitionDigest: input.ownershipTransitionDigest, - subGraphName: input.subGraphName, - authorAddress: input.authorAddress, - era: input.era, - bucketCount: input.bucketCount, - } as AuthorCatalogScopeV1; + const fields = snapshotExactDataRecord(input, [ + 'authorAddress', + 'bucketCount', + 'contextGraphId', + 'era', + 'governanceChainId', + 'governanceContractAddress', + 'networkId', + 'ownershipTransitionDigest', + 'subGraphName', + ], 'catalogScope'); + const scope = Object.freeze({ + networkId: fields.networkId, + contextGraphId: fields.contextGraphId, + governanceChainId: fields.governanceChainId, + governanceContractAddress: fields.governanceContractAddress, + ownershipTransitionDigest: fields.ownershipTransitionDigest, + subGraphName: fields.subGraphName, + authorAddress: fields.authorAddress, + era: fields.era, + bucketCount: fields.bucketCount, + } as AuthorCatalogScopeV1); assertAuthorCatalogScopeV1(scope); - return Object.freeze(scope); + return scope; } function snapshotRows( @@ -181,15 +220,12 @@ function snapshotRows( label: 'expectedRows' | 'observedRows', requireCanonicalInputOrder: boolean, ): readonly Readonly[] { - assertDenseOrdinaryArray(input, label); - if (input.length > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) { - fail( - 'catalog-inventory-completeness-count', - `${label} exceeds the ${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1}-row bucket bound`, - ); - } - - const rows = input.map((row, index) => snapshotRow(row, scope, `${label}[${index}]`)); + const values = snapshotDenseBoundedOrdinaryArray(input, label); + const rows = values.map((row, index) => snapshotRow( + row as Rfc64PublicCatalogInventoryEvidenceRowV1, + scope, + `${label}[${index}]`, + )); assertNoDuplicateRows(rows, label); if (requireCanonicalInputOrder) { for (let index = 1; index < rows.length; index += 1) { @@ -235,11 +271,7 @@ function snapshotRow( scope: Readonly, label: string, ): Readonly { - if (!isPlainRecord(input)) { - fail('catalog-inventory-completeness-input', `${label} must be a plain object`); - } - const keys = Reflect.ownKeys(input); - const expectedKeys = [ + const fields = snapshotExactDataRecord(input, [ 'activatedTripleCount', 'bundleDigest', 'catalogRowDigest', @@ -247,38 +279,31 @@ function snapshotRow( 'kaId', 'kaUal', 'sealDigest', - ]; - if ( - keys.length !== expectedKeys.length - || keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) - ) { - fail('catalog-inventory-completeness-input', `${label} has missing or extra fields`); - } - for (const key of expectedKeys) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if ( - descriptor === undefined - || !descriptor.enumerable - || !Object.prototype.hasOwnProperty.call(descriptor, 'value') - ) { - fail( - 'catalog-inventory-completeness-input', - `${label}.${key} must be an enumerable data property`, - ); - } - } + ], label); + const snapshot = Object.freeze({ + kaId: fields.kaId, + catalogRowDigest: fields.catalogRowDigest, + contentDigest: fields.contentDigest, + sealDigest: fields.sealDigest, + bundleDigest: fields.bundleDigest, + kaUal: fields.kaUal, + activatedTripleCount: fields.activatedTripleCount, + }) as unknown as Rfc64PublicCatalogInventoryEvidenceRowV1; try { - assertCanonicalKaId(input.kaId, `${label}.kaId`); - assertCanonicalDigest(input.catalogRowDigest, `${label}.catalogRowDigest`); - assertCanonicalDigest(input.contentDigest, `${label}.contentDigest`); - assertCanonicalDigest(input.sealDigest, `${label}.sealDigest`); - assertCanonicalDigest(input.bundleDigest, `${label}.bundleDigest`); - if (!Number.isSafeInteger(input.activatedTripleCount) || input.activatedTripleCount < 1) { + assertCanonicalKaId(snapshot.kaId, `${label}.kaId`); + assertCanonicalDigest(snapshot.catalogRowDigest, `${label}.catalogRowDigest`); + assertCanonicalDigest(snapshot.contentDigest, `${label}.contentDigest`); + assertCanonicalDigest(snapshot.sealDigest, `${label}.sealDigest`); + assertCanonicalDigest(snapshot.bundleDigest, `${label}.bundleDigest`); + if ( + !Number.isSafeInteger(snapshot.activatedTripleCount) + || snapshot.activatedTripleCount < 1 + ) { throw new Error(`${label}.activatedTripleCount must be a positive safe integer`); } - const parsedUal = parseDeterministicKnowledgeAssetUal(input.kaUal); - if (parsedUal.ual !== input.kaUal) { + const parsedUal = parseDeterministicKnowledgeAssetUal(snapshot.kaUal); + if (parsedUal.ual !== snapshot.kaUal) { throw new Error(`${label}.kaUal is not canonical`); } if ( @@ -288,7 +313,7 @@ function snapshotRow( throw new Error(`${label}.kaUal differs from the trusted catalog scope`); } const packedKaId = (BigInt(parsedUal.agentAddress) << 96n) | BigInt(parsedUal.kaNumber); - if (packedKaId !== BigInt(input.kaId)) { + if (packedKaId !== BigInt(snapshot.kaId)) { throw new Error(`${label}.kaUal does not encode its signed kaId`); } } catch (cause) { @@ -298,15 +323,7 @@ function snapshotRow( cause, ); } - return Object.freeze({ - kaId: input.kaId, - catalogRowDigest: input.catalogRowDigest, - contentDigest: input.contentDigest, - sealDigest: input.sealDigest, - bundleDigest: input.bundleDigest, - kaUal: input.kaUal, - activatedTripleCount: input.activatedTripleCount, - }); + return snapshot; } function computeInventoryDigest( @@ -355,29 +372,108 @@ function sameRow( && expected.activatedTripleCount === observed.activatedTripleCount; } -function assertDenseOrdinaryArray(value: unknown, label: string): asserts value is unknown[] { - if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { - fail('catalog-inventory-completeness-input', `${label} must be an ordinary Array`); - } - const ownKeys = Reflect.ownKeys(value); - if ( - ownKeys.some((key) => typeof key !== 'string') - || ownKeys.length !== value.length + 1 - || !ownKeys.includes('length') - ) { +function snapshotDenseBoundedOrdinaryArray( + value: unknown, + label: 'expectedRows' | 'observedRows', +): readonly unknown[] { + try { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + fail('catalog-inventory-completeness-input', `${label} must be an ordinary Array`); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + lengthDescriptor === undefined + || lengthDescriptor.enumerable + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + ) { + fail('catalog-inventory-completeness-input', `${label}.length is not an exact data field`); + } + const length = lengthDescriptor.value; + // Enforce the work ceiling before enumerating or copying caller rows. + if (length > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) { + fail( + 'catalog-inventory-completeness-count', + `${label} exceeds the ${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1}-row bucket bound`, + ); + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.some((key) => typeof key !== 'string') + || ownKeys.length !== length + 1 + || !ownKeys.includes('length') + ) { + fail( + 'catalog-inventory-completeness-input', + `${label} must be dense and contain no custom properties`, + ); + } + const snapshot = new Array(length); + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail( + 'catalog-inventory-completeness-input', + `${label} must contain only enumerable data elements`, + ); + } + snapshot[index] = descriptor.value; + } + return Object.freeze(snapshot); + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogInventoryCompletenessErrorV1) throw cause; fail( 'catalog-inventory-completeness-input', - `${label} must be dense and contain no custom properties`, + `${label} could not be snapshotted exactly`, + cause, ); } - for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { +} + +function snapshotExactDataRecord( + value: unknown, + expectedKeys: readonly string[], + label: string, +): Readonly> { + try { + if (!isPlainRecord(value)) { + fail('catalog-inventory-completeness-input', `${label} must be a plain object`); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length + || keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { fail( 'catalog-inventory-completeness-input', - `${label} must contain only enumerable data elements`, + `${label} has missing or extra fields`, ); } + const snapshot: Record = Object.create(null); + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + fail( + 'catalog-inventory-completeness-input', + `${label}.${key} must be an enumerable data property`, + ); + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot); + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogInventoryCompletenessErrorV1) throw cause; + fail( + 'catalog-inventory-completeness-input', + `${label} could not be snapshotted exactly`, + cause, + ); } } diff --git a/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts b/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts index aa41406f23..9ecb3fb365 100644 --- a/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts @@ -46,7 +46,9 @@ describe('RFC-64 public catalog bounded inventory completeness', () => { `did:dkg:otp:20430/${AUTHOR}/10`, `did:dkg:otp:20430/${AUTHOR}/100`, ]); - expect(evidence.inventoryDigest).toMatch(/^0x[0-9a-f]{64}$/); + expect(evidence.inventoryDigest).toBe( + '0x299d93f46a4baa1a0099f3ebfabb43f2070dc590313c4abfcf22f3541766a79e', + ); const repeated = verifyRfc64PublicCatalogInventoryCompletenessV1({ catalogScope: { ...SCOPE }, @@ -133,8 +135,157 @@ describe('RFC-64 public catalog bounded inventory completeness', () => { observedRows: [wrongPackedId], }), 'catalog-inventory-completeness-input'); }); + + it('enforces the signed-bucket 1..1024 row boundary before enumerating rows', () => { + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: { ...SCOPE, bucketCount: '2' as CountV1 }, + expectedTotalRows: '1' as CountV1, + expectedRows: [row(1)], + observedRows: [row(1)], + }), 'catalog-inventory-completeness-slice'); + + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '0' as CountV1, + expectedRows: [], + observedRows: [], + }), 'catalog-inventory-completeness-count'); + + const maximum = Array.from({ length: 1024 }, (_value, index) => row(index + 1)); + const evidence = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '1024' as CountV1, + expectedRows: maximum, + observedRows: [...maximum].reverse(), + }); + expect(evidence.inventoryRowCount).toBe('1024'); + expect(evidence.rows).toHaveLength(1024); + expect(evidence.rows[0].kaUal).toBe(`did:dkg:otp:20430/${AUTHOR}/1`); + expect(evidence.rows[1023].kaUal).toBe(`did:dkg:otp:20430/${AUTHOR}/1024`); + + let enumeratedOversizedRows = false; + const oversized = new Proxy(new Array(1025), { + ownKeys(target) { + enumeratedOversizedRows = true; + return Reflect.ownKeys(target); + }, + }) as Rfc64PublicCatalogInventoryEvidenceRowV1[]; + expectCode(() => verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '1024' as CountV1, + expectedRows: oversized, + observedRows: maximum, + }), 'catalog-inventory-completeness-count'); + expect(enumeratedOversizedRows).toBe(false); + }); + + it('snapshots top-level, array, scope, and row data without invoking switching gets', () => { + let topLevelGets = 0; + let scopeGets = 0; + let arrayGets = 0; + let rowGets = 0; + const only = row(7); + const switchingScope = new Proxy({ ...SCOPE }, { + get(target, key, receiver) { + scopeGets += 1; + return key === 'networkId' && scopeGets > 1 + ? 'otp:tampered' + : Reflect.get(target, key, receiver); + }, + }); + const switchingRow = new Proxy({ ...only }, { + get(target, key, receiver) { + rowGets += 1; + return key === 'contentDigest' && rowGets > 1 + ? digest(999) + : Reflect.get(target, key, receiver); + }, + }); + const switchingArray = new Proxy([switchingRow], { + get(target, key, receiver) { + arrayGets += 1; + return Reflect.get(target, key, receiver); + }, + }); + const switchingInput = new Proxy({ + catalogScope: switchingScope, + expectedTotalRows: '1' as CountV1, + expectedRows: switchingArray, + observedRows: switchingArray, + }, { + get(target, key, receiver) { + topLevelGets += 1; + return key === 'expectedTotalRows' && topLevelGets > 1 + ? '999' + : Reflect.get(target, key, receiver); + }, + }); + + const evidence = verifyRfc64PublicCatalogInventoryCompletenessV1(switchingInput); + expect(evidence.inventoryRowCount).toBe('1'); + expect(evidence.rows[0]).toEqual(only); + expect({ topLevelGets, scopeGets, arrayGets, rowGets }).toEqual({ + topLevelGets: 0, + scopeGets: 0, + arrayGets: 0, + rowGets: 0, + }); + }); + + it('rejects accessor inputs without invoking them and remains reentry-safe', () => { + let getterInvoked = false; + const accessorInput = Object.defineProperties({}, { + catalogScope: { value: SCOPE, enumerable: true }, + expectedTotalRows: { + enumerable: true, + get() { + getterInvoked = true; + return '1'; + }, + }, + expectedRows: { value: [row(1)], enumerable: true }, + observedRows: { value: [row(1)], enumerable: true }, + }) as VerifyParameters; + expectCode( + () => verifyRfc64PublicCatalogInventoryCompletenessV1(accessorInput), + 'catalog-inventory-completeness-input', + ); + expect(getterInvoked).toBe(false); + + const only = row(9); + let reentered = false; + let insideTrap = false; + const reentrantRow = new Proxy({ ...only }, { + getOwnPropertyDescriptor(target, key) { + if (key === 'contentDigest' && !insideTrap) { + insideTrap = true; + const nested = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '1' as CountV1, + expectedRows: [only], + observedRows: [only], + }); + reentered = nested.rows[0].kaId === only.kaId; + insideTrap = false; + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + const outer = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '1' as CountV1, + expectedRows: [reentrantRow], + observedRows: [reentrantRow], + }); + expect(reentered).toBe(true); + expect(outer.rows[0]).toEqual(only); + }); }); +type VerifyParameters = Parameters< + typeof verifyRfc64PublicCatalogInventoryCompletenessV1 +>[0]; + const BASE = Object.freeze([row(1), row(2)]); function row(number: number): Rfc64PublicCatalogInventoryEvidenceRowV1 { From bb44c45afc0154546810b65b804832a76aaf83d9 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:17:16 +0200 Subject: [PATCH 143/292] feat(agent): produce bounded RFC-64 exact sets --- packages/agent/src/dkg-agent-rfc64-catalog.ts | 118 ++++- .../public-catalog-successor-producer-v1.ts | 427 ++++++++++++------ ...-successor-publication.integration.test.ts | 93 +++- ...blic-catalog-successor-producer-v1.test.ts | 117 +++++ 4 files changed, 604 insertions(+), 151 deletions(-) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 30d20af157..79b8323d71 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -20,6 +20,7 @@ import { ZERO_DIGEST32_V1, + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, assertSignedAuthorCatalogBucketEnvelopeV1, assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, assertSignedAuthorCatalogHeadEnvelopeV1, @@ -34,6 +35,7 @@ import { type ContextGraphIdV1, type Digest32V1, type EvmAddressV1, + type KaIdV1, type NetworkIdV1, type SubGraphNameV1, type TimestampMsV1, @@ -81,6 +83,7 @@ import type { import { Rfc64PublicCatalogSuccessorProducerV1, type Rfc64PublicCatalogIssuerAuthorizationV1, + type Rfc64PublicCatalogSuccessorAssetInputV1, } from './rfc64/public-catalog-successor-producer-v1.js'; import { RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, @@ -206,6 +209,22 @@ export interface PublishOpenAuthorCatalogSuccessorParamsV1 { readonly peers: readonly string[]; } +/** One member of the complete live set supplied to an ordinary successor. */ +export type Rfc64OpenCatalogSuccessorAssetInputV1 = + Rfc64PublicCatalogSuccessorAssetInputV1; + +export interface PublishOpenAuthorCatalogExactSetSuccessorParamsV1 { + /** Exact durable predecessor returned by genesis or a prior successor. */ + readonly previousHead: Rfc64StagedAuthorCatalogHeadRefV1; + readonly author: Rfc64OpenCatalogAuthorSignerV1; + readonly catalogIssuerAuthorization: Rfc64PublicCatalogIssuerAuthorizationV1; + /** Complete 1..1024-row live set; input order does not affect the signed head. */ + readonly assets: readonly Rfc64OpenCatalogSuccessorAssetInputV1[]; + readonly deployment: CatalogSealDeploymentProfileV1; + readonly issuedAt?: TimestampMsV1; + readonly peers: readonly string[]; +} + export interface PublishOpenAuthorCatalogSuccessorResultV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; readonly headObjectDigest: Digest32V1; @@ -221,6 +240,27 @@ export interface PublishOpenAuthorCatalogSuccessorResultV1 { readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; } +export interface PublishOpenAuthorCatalogSuccessorAssetResultV1 { + readonly kaId: KaIdV1; + readonly catalogRowDigest: Digest32V1; + readonly bundleDigest: Digest32V1; + readonly contentDigest: Digest32V1; + readonly contentByteLength: ByteLengthV1; + readonly bundleByteLength: ByteLengthV1; + readonly kaUal: string; +} + +export interface PublishOpenAuthorCatalogExactSetSuccessorResultV1 { + readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; + readonly headObjectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + /** Strictly increasing by mathematical KA ID. */ + readonly assets: readonly Readonly[]; + readonly inventoryRowCount: CountV1; + readonly announcedPeers: readonly string[]; + readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; +} + export class Rfc64CatalogMethods extends DKGAgentBase { /** * Construct + start the public catalog service on the production router. @@ -336,6 +376,48 @@ export class Rfc64CatalogMethods extends DKGAgentBase { this: DKGAgent, params: PublishOpenAuthorCatalogSuccessorParamsV1, ): Promise { + const result = await this.publishOpenAuthorCatalogExactSetSuccessorV1({ + previousHead: params.previousHead, + author: params.author, + catalogIssuerAuthorization: params.catalogIssuerAuthorization, + assets: [{ + assertionCoordinate: params.assertionCoordinate, + projectionBytes: params.projectionBytes, + seal: params.seal, + }], + deployment: params.deployment, + issuedAt: params.issuedAt, + peers: params.peers, + }); + const asset = result.assets[0]; + if (asset === undefined) { + throw new Error('RFC-64 one-row successor returned no exact asset evidence'); + } + return Object.freeze({ + announcement: result.announcement, + headObjectDigest: result.headObjectDigest, + signatureVariantDigest: result.signatureVariantDigest, + catalogRowDigest: asset.catalogRowDigest, + bundleDigest: asset.bundleDigest, + contentDigest: asset.contentDigest, + contentByteLength: asset.contentByteLength, + bundleByteLength: asset.bundleByteLength, + kaUal: asset.kaUal, + inventoryRowCount: result.inventoryRowCount, + announcedPeers: result.announcedPeers, + failedPeers: result.failedPeers, + }); + } + + /** + * Public/open author path for a complete bounded exact set. Canonical RFC-64 + * ordinary-successor rules still require exactly one KA delta from the + * predecessor; callers grow a catalog through successive exact sets. + */ + async publishOpenAuthorCatalogExactSetSuccessorV1( + this: DKGAgent, + params: PublishOpenAuthorCatalogExactSetSuccessorParamsV1, + ): Promise { const service = this.requireRfc64PublicCatalogServiceV1(); const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(params.peers); const persistence = this.rfc64PersistenceV1; @@ -362,13 +444,11 @@ export class Rfc64CatalogMethods extends DKGAgentBase { controlObjects: persistence.controlObjects, stageKaBundle: persistence.kaBundles.putKaBundle, }); - const produced = await producer.produceAndStage({ + const produced = await producer.produceAndStageExactSet({ previousHead: history.previousHead, previousDirectoryPath: history.previousDirectoryPath, previousBucket: history.previousBucket, - assertionCoordinate: params.assertionCoordinate, - projectionBytes: params.projectionBytes, - seal: params.seal, + assets: params.assets, deployment: params.deployment, issuedAt: params.issuedAt ?? (Date.now().toString() as TimestampMsV1), catalogSigner: { @@ -407,16 +487,20 @@ export class Rfc64CatalogMethods extends DKGAgentBase { announcement, peers, }); + const assets = produced.assets.map((asset) => Object.freeze({ + kaId: asset.row.kaId, + catalogRowDigest: asset.sealBinding.catalogRowDigest, + bundleDigest: asset.bundleDigest, + contentDigest: asset.projection.projectionDigest, + contentByteLength: asset.projection.projectionByteLength, + bundleByteLength: asset.row.transfer.byteLength, + kaUal: asset.projection.kaUal, + })); return Object.freeze({ announcement: delivery.announcement, headObjectDigest: headKeys.objectDigest, signatureVariantDigest: headKeys.signatureVariantDigest, - catalogRowDigest: produced.sealBinding.catalogRowDigest, - bundleDigest: produced.bundleDigest, - contentDigest: produced.projection.projectionDigest, - contentByteLength: produced.projection.projectionByteLength, - bundleByteLength: produced.row.transfer.byteLength, - kaUal: produced.projection.kaUal, + assets: Object.freeze(assets), inventoryRowCount: head.payload.totalRows, announcedPeers: delivery.announcedPeers, failedPeers: delivery.failedPeers, @@ -676,7 +760,7 @@ async function loadPublicOpenRootLaneHistoryV1( previousHead.payload.subGraphName !== null || previousHead.payload.bucketCount !== '1' || previousHead.payload.directoryHeight !== '0' - || (previousHead.payload.totalRows !== '0' && previousHead.payload.totalRows !== '1') + || BigInt(previousHead.payload.totalRows) > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) ) { throw new Error('RFC-64 predecessor is outside the public/open root-lane slice'); } @@ -695,6 +779,15 @@ async function loadPublicOpenRootLaneHistoryV1( if (descriptor === undefined || !('bucketDigest' in descriptor)) { throw new Error('RFC-64 predecessor root has no level-zero bucket descriptor'); } + if (descriptor.rowCount !== previousHead.payload.totalRows) { + throw new Error('RFC-64 predecessor root row count does not match its head'); + } + if ( + (previousHead.payload.totalRows === '0') + !== (descriptor.bucketDigest === ZERO_DIGEST32_V1) + ) { + throw new Error('RFC-64 predecessor empty-bucket descriptor is inconsistent'); + } let previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null = null; if (descriptor.bucketDigest !== ZERO_DIGEST32_V1) { @@ -705,6 +798,9 @@ async function loadPublicOpenRootLaneHistoryV1( if (storedBucket === null) throw new Error('RFC-64 predecessor bucket is not staged'); assertSignedAuthorCatalogBucketEnvelopeV1(storedBucket.envelope); previousBucket = storedBucket.envelope; + if (previousBucket.payload.rows.length !== Number(previousHead.payload.totalRows)) { + throw new Error('RFC-64 predecessor bucket row count does not match its head'); + } } return Object.freeze({ previousHead, diff --git a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts index 49bf5e85b5..d36687b2d0 100644 --- a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Production boundary for the Gate-1 public/open one-row successor slice. + * Production boundary for the bounded public/open successor slice. * * The adapter deliberately delegates catalog construction and every bundle, * seal, and projection codec check to the canonical RFC-64 helpers. Neither @@ -14,11 +14,13 @@ import { KA_TRANSFER_CHUNK_SIZE_V1, KA_TRANSFER_CODEC_V1, KA_TRANSFER_PROJECTION_V1, + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, MIN_KA_BUNDLE_BYTES_V1, ZERO_DIGEST32_V1, calculateOpaqueKaBundleByteLengthV1, canonicalizeAuthorCatalogRowV1, canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + compareAuthorCatalogKaIdsV1, computeCanonicalGraphScopedAuthorSealDigestV1, computeKaChunkTreeRootV1, encodeOpaqueKaBundleV1, @@ -127,6 +129,30 @@ export interface ProduceAndStagePublicOpenOneRowSuccessorInputV1 { readonly catalogIssuerAuthorization: Rfc64PublicCatalogIssuerAuthorizationV1; } +/** One member of the complete bounded live set committed by a successor. */ +export interface Rfc64PublicCatalogSuccessorAssetInputV1 { + readonly assertionCoordinate: AssertionCoordinateV1; + readonly projectionBytes: Uint8Array; + readonly seal: CanonicalGraphScopedAuthorSealV1; +} + +/** + * Complete replacement set for the root-lane bucket. The producer sorts the + * caller-owned set by mathematical KA ID before signing it. The canonical + * catalog producer still enforces the RFC-64 ordinary-successor invariant: + * exactly one KA may be added, removed, or changed per successor. + */ +export interface ProduceAndStagePublicOpenExactSetSuccessorInputV1 { + readonly previousHead: SignedAuthorCatalogHeadEnvelopeV1; + readonly previousDirectoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; + readonly previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null; + readonly assets: readonly Rfc64PublicCatalogSuccessorAssetInputV1[]; + readonly deployment: CatalogSealDeploymentProfileV1; + readonly issuedAt: TimestampMsV1; + readonly catalogSigner: Rfc64AuthorCatalogEip191SignerV1; + readonly catalogIssuerAuthorization: Rfc64PublicCatalogIssuerAuthorizationV1; +} + export interface ProducedAndStagedPublicOpenOneRowSuccessorV1 { readonly publication: ProducedAuthorCatalogPublicationV1; readonly row: Readonly; @@ -140,6 +166,24 @@ export interface ProducedAndStagedPublicOpenOneRowSuccessorV1 { readonly stagedControlObjects: StageVerifiedControlObjectsResultV1; } +export interface ProducedAndStagedPublicOpenSuccessorAssetV1 { + readonly row: Readonly; + readonly bundleDigest: Digest32V1; + /** Fresh caller-owned copy; provider storage retains its own staged bytes. */ + readonly bundleBytes: Uint8Array; + readonly sealBinding: VerifiedCatalogSealBindingSnapshotV1; + readonly transfer: VerifiedTransferredCatalogBundleMetadataV1; + readonly projection: VerifiedCgSharedProjectionMetadataV1; + readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; +} + +export interface ProducedAndStagedPublicOpenExactSetSuccessorV1 { + readonly publication: ProducedAuthorCatalogPublicationV1; + /** Strictly increasing by mathematical KA ID. */ + readonly assets: readonly Readonly[]; + readonly stagedControlObjects: StageVerifiedControlObjectsResultV1; +} + /** * Build, authenticate, fully verify, and durably stage one public/open * root-lane successor. Bundle-first staging prevents a served head from @@ -165,29 +209,69 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { async produceAndStage( input: ProduceAndStagePublicOpenOneRowSuccessorInputV1, ): Promise { - const prepared = prepareRowAndBundle(input); - assertSupportedPreviousSlice(input.previousHead, input.previousBucket); - - // PR #1780's strict typed-row reconstruction, exact deployment binding, - // and recoverable EOA author proof all run before catalog signing or I/O. - let initialSealBinding: ReturnType; - try { - initialSealBinding = verifyCatalogSealBindingV1( - prepared.scope, - prepared.row, - prepared.sealBytes, - prepared.deployment, - ); - assertRecoverableAuthorAttestationV1( - readVerifiedCatalogSealBindingV1(initialSealBinding), - ); - } catch (cause) { + const result = await this.produceAndStageExactSet({ + previousHead: input.previousHead, + previousDirectoryPath: input.previousDirectoryPath, + previousBucket: input.previousBucket, + assets: [{ + assertionCoordinate: input.assertionCoordinate, + projectionBytes: input.projectionBytes, + seal: input.seal, + }], + deployment: input.deployment, + issuedAt: input.issuedAt, + catalogSigner: input.catalogSigner, + catalogIssuerAuthorization: input.catalogIssuerAuthorization, + }); + const asset = result.assets[0]; + if (asset === undefined) { fail( - 'catalog-successor-producer-binding', - 'author seal does not bind to the exact public catalog row', - cause, + 'catalog-successor-producer-verification', + 'one-row compatibility result did not retain its catalog asset', ); } + return Object.freeze({ + publication: result.publication, + row: asset.row, + bundleDigest: asset.bundleDigest, + bundleBytes: new Uint8Array(asset.bundleBytes), + sealBinding: asset.sealBinding, + transfer: asset.transfer, + projection: asset.projection, + authorship: asset.authorship, + stagedControlObjects: result.stagedControlObjects, + }); + } + + /** Build and durably stage one complete, canonical 1..1024-row live set. */ + async produceAndStageExactSet( + input: ProduceAndStagePublicOpenExactSetSuccessorInputV1, + ): Promise { + const preparedAssets = prepareExactSet(input); + assertSupportedPreviousSlice(input.previousHead, input.previousBucket); + + // Strict typed-row reconstruction, exact deployment binding, and the + // recoverable EOA author proof all run for the complete set before signing + // a catalog object or performing I/O. + for (const prepared of preparedAssets) { + try { + const initialSealBinding = verifyCatalogSealBindingV1( + prepared.scope, + prepared.row, + prepared.sealBytes, + prepared.deployment, + ); + assertRecoverableAuthorAttestationV1( + readVerifiedCatalogSealBindingV1(initialSealBinding), + ); + } catch (cause) { + fail( + 'catalog-successor-producer-binding', + `author seal does not bind to exact public catalog row ${prepared.row.kaId}`, + cause, + ); + } + } let publication: ProducedAuthorCatalogPublicationV1; try { @@ -196,14 +280,14 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { previousDirectoryPath: input.previousDirectoryPath, previousBucket: input.previousBucket, selectedBucketId: '0' as DecimalU64V1, - nextRows: [prepared.row], + nextRows: preparedAssets.map(({ row }) => row), issuedAt: input.issuedAt, signer: input.catalogSigner, }); } catch (cause) { fail( 'catalog-successor-producer-history', - 'one-row successor could not be built from the supplied history', + 'bounded exact-set successor could not be built from the supplied history', cause, ); } @@ -213,67 +297,66 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { publication.head.payload.subGraphName !== null || publication.head.payload.bucketCount !== '1' || publication.head.payload.directoryHeight !== '0' - || publication.head.payload.totalRows !== '1' + || publication.head.payload.totalRows !== String(preparedAssets.length) || publication.head.payload.version === '0' || publication.head.payload.previousHeadDigest === null || producedBucket === null - || producedBucket.payload.rows.length !== 1 + || producedBucket.payload.rows.length !== preparedAssets.length ) { fail( 'catalog-successor-producer-verification', - 'produced catalog is outside the public/open one-row successor slice', - ); - } - const producedRow = producedBucket.payload.rows[0]; - if (producedRow === undefined) { - fail( - 'catalog-successor-producer-verification', - 'produced one-row bucket did not retain its catalog row', + 'produced catalog is outside the bounded public/open exact-set successor slice', ); } - let transferred: ReturnType; - let transfer: VerifiedTransferredCatalogBundleMetadataV1; - let projection: VerifiedCgSharedProjectionMetadataV1; - let sealBinding: VerifiedCatalogSealBindingSnapshotV1; - try { - transferred = verifyTransferredCatalogBundleV1( - publication.head, - producedRow, - prepared.bundleBytes, - prepared.deployment, - ); - transfer = readVerifiedTransferredCatalogBundleMetadataV1( - transferred, - publication.head, - producedRow, - prepared.deployment, - ); - sealBinding = readVerifiedCatalogSealBindingV1(transfer.catalogSealBinding); - assertRecoverableAuthorAttestationV1(sealBinding); - const verifiedProjection = verifyCgSharedProjectionV1( - transferred, - publication.head, - producedRow, - prepared.deployment, - ); - projection = readVerifiedCgSharedProjectionMetadataV1( - verifiedProjection, - transferred, - publication.head, - producedRow, - prepared.deployment, - ); - } catch (cause) { - fail( - 'catalog-successor-producer-verification', - 'produced successor failed exact bundle, seal, or shared-projection verification', - cause, - ); - } + const verifiedAssets = producedBucket.payload.rows.map((producedRow, index) => { + const prepared = preparedAssets[index]; + if (prepared === undefined || producedRow.kaId !== prepared.row.kaId) { + fail( + 'catalog-successor-producer-verification', + 'produced bucket did not retain the canonical exact-set row order', + ); + } + try { + const transferred = verifyTransferredCatalogBundleV1( + publication.head, + producedRow, + prepared.bundleBytes, + prepared.deployment, + ); + const transfer = readVerifiedTransferredCatalogBundleMetadataV1( + transferred, + publication.head, + producedRow, + prepared.deployment, + ); + const sealBinding = readVerifiedCatalogSealBindingV1(transfer.catalogSealBinding); + assertRecoverableAuthorAttestationV1(sealBinding); + const verifiedProjection = verifyCgSharedProjectionV1( + transferred, + publication.head, + producedRow, + prepared.deployment, + ); + const projection = readVerifiedCgSharedProjectionMetadataV1( + verifiedProjection, + transferred, + publication.head, + producedRow, + prepared.deployment, + ); + return { prepared, row: producedRow, sealBinding, transfer, projection }; + } catch (cause) { + fail( + 'catalog-successor-producer-verification', + `produced successor failed exact verification for row ${producedRow.kaId}`, + cause, + ); + } + }); let verifiedObjects; - let authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + let authorship: readonly VerifiedAuthorCatalogRowAuthorshipSnapshotV1[]; try { const authorization = input.catalogIssuerAuthorization; const catalogIssuerDelegationSignature = await verifyControlEnvelopeIssuerSignatureV1( @@ -296,33 +379,37 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { publication.directoryPath, '0' as DecimalU64V1, ); - const authorshipToken = verifyAuthorCatalogRowAuthorshipV1({ - catalogIssuerDelegation: authorization.catalogIssuerDelegation, - catalogIssuerDelegationSignature, - parentAuthorAgentEvidence: authorization.parentAuthorAgentEvidence, - catalogHead: publication.head, - catalogHeadSignature: requiredSignature( - signaturesByDigest, - publication.head.objectDigest, - 'catalog head', - ), - directoryPathEnvelopes: publication.directoryPath, - directoryPathSignatures: publication.directoryPath.map((envelope, index) => - requiredSignature( - signaturesByDigest, - envelope.objectDigest, - `directory path node ${index}`, - )), - directoryPathProof, - catalogBucket: producedBucket, - catalogBucketSignature: requiredSignature( + const headSignature = requiredSignature( + signaturesByDigest, + publication.head.objectDigest, + 'catalog head', + ); + const directoryPathSignatures = publication.directoryPath.map((envelope, index) => + requiredSignature( signaturesByDigest, - producedBucket.objectDigest, - 'catalog bucket', - ), - targetKaId: producedRow.kaId, - }); - authorship = readVerifiedAuthorCatalogRowAuthorshipV1(authorshipToken); + envelope.objectDigest, + `directory path node ${index}`, + )); + const bucketSignature = requiredSignature( + signaturesByDigest, + producedBucket.objectDigest, + 'catalog bucket', + ); + authorship = producedBucket.payload.rows.map((row) => + readVerifiedAuthorCatalogRowAuthorshipV1(verifyAuthorCatalogRowAuthorshipV1({ + catalogIssuerDelegation: authorization.catalogIssuerDelegation, + catalogIssuerDelegationSignature, + parentAuthorAgentEvidence: authorization.parentAuthorAgentEvidence, + catalogHead: publication.head, + catalogHeadSignature: headSignature, + directoryPathEnvelopes: publication.directoryPath, + directoryPathSignatures, + directoryPathProof, + catalogBucket: producedBucket, + catalogBucketSignature: bucketSignature, + targetKaId: row.kaId, + })), + ); } catch (cause) { fail( 'catalog-successor-producer-verification', @@ -331,22 +418,24 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { ); } - try { - const bundleReceipt = await this.#stageKaBundle(Object.freeze({ - blobDigest: prepared.encoded.blobDigest, - bundleBytes: new Uint8Array(prepared.bundleBytes), - })); - assertExactDurableBundleReceipt( - bundleReceipt, - prepared.encoded.blobDigest, - prepared.bundleBytes.byteLength, - ); - } catch (cause) { - fail( - 'catalog-successor-producer-bundle-stage', - 'verified opaque KA bundle could not be staged', - cause, - ); + for (const { prepared } of verifiedAssets) { + try { + const bundleReceipt = await this.#stageKaBundle(Object.freeze({ + blobDigest: prepared.encoded.blobDigest, + bundleBytes: new Uint8Array(prepared.bundleBytes), + })); + assertExactDurableBundleReceipt( + bundleReceipt, + prepared.encoded.blobDigest, + prepared.bundleBytes.byteLength, + ); + } catch (cause) { + fail( + 'catalog-successor-producer-bundle-stage', + `verified opaque KA bundle ${prepared.row.kaId} could not be staged`, + cause, + ); + } } let stagedControlObjects: StageVerifiedControlObjectsResultV1; @@ -360,50 +449,115 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { ); } + const assets = verifiedAssets.map((verified, index) => { + const rowAuthorship = authorship[index]; + if (rowAuthorship === undefined) { + fail( + 'catalog-successor-producer-verification', + `verified authorship is unavailable for row ${verified.row.kaId}`, + ); + } + return Object.freeze({ + row: verified.row, + bundleDigest: verified.prepared.encoded.blobDigest, + bundleBytes: new Uint8Array(verified.prepared.bundleBytes), + sealBinding: verified.sealBinding, + transfer: verified.transfer, + projection: verified.projection, + authorship: rowAuthorship, + }); + }); return Object.freeze({ publication, - row: producedRow, - bundleDigest: prepared.encoded.blobDigest, - bundleBytes: new Uint8Array(prepared.bundleBytes), - sealBinding, - transfer, - projection, - authorship, + assets: Object.freeze(assets), stagedControlObjects, }); } } -function prepareRowAndBundle(input: ProduceAndStagePublicOpenOneRowSuccessorInputV1) { +function prepareExactSet(input: ProduceAndStagePublicOpenExactSetSuccessorInputV1) { + const assets = input?.assets; + if (!Array.isArray(assets) || Object.getPrototypeOf(assets) !== Array.prototype) { + fail('catalog-successor-producer-input', 'assets must be an ordinary dense Array'); + } + const ownKeys = Reflect.ownKeys(assets); + if ( + ownKeys.some((key) => typeof key !== 'string') + || ownKeys.length !== assets.length + 1 + || !ownKeys.includes('length') + ) { + fail('catalog-successor-producer-input', 'assets must be a dense Array without properties'); + } + if (assets.length < 1 || assets.length > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) { + fail( + 'catalog-successor-producer-input', + `assets must contain 1..${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1} entries`, + ); + } + for (let index = 0; index < assets.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(assets, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + fail( + 'catalog-successor-producer-input', + 'assets must contain only enumerable data elements', + ); + } + } + const prepared = assets.map((asset) => prepareRowAndBundle( + asset, + input.previousHead, + input.deployment, + )); + prepared.sort((left, right) => compareAuthorCatalogKaIdsV1(left.row.kaId, right.row.kaId)); + for (let index = 1; index < prepared.length; index += 1) { + const previous = prepared[index - 1]; + const current = prepared[index]; + if (previous === undefined || current === undefined) { + fail('catalog-successor-producer-input', 'assets are not a dense exact set'); + } + if (previous.row.kaId === current.row.kaId) { + fail( + 'catalog-successor-producer-input', + `assets contain duplicate kaId ${current.row.kaId}`, + ); + } + } + return Object.freeze(prepared); +} + +function prepareRowAndBundle( + asset: Rfc64PublicCatalogSuccessorAssetInputV1, + previousHead: SignedAuthorCatalogHeadEnvelopeV1, + deploymentInput: CatalogSealDeploymentProfileV1, +) { let sealBytes: Uint8Array; let seal: CanonicalGraphScopedAuthorSealV1; let encoded: ReturnType; let row: AuthorCatalogRowV1; try { - if (!(input?.projectionBytes instanceof Uint8Array)) { + if (!(asset?.projectionBytes instanceof Uint8Array)) { throw new TypeError('projectionBytes must be a Uint8Array'); } // Reject a projection that cannot possibly fit the Gate-1 transport before // canonicalizing the seal or allocating/copying a complete bundle. if ( - BigInt(input.projectionBytes.byteLength) + BigInt(asset.projectionBytes.byteLength) > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1) - MIN_KA_BUNDLE_BYTES_V1 ) { throw new RangeError('projection exceeds the Gate-1 complete-bundle ceiling'); } - const assertionCoordinate = input?.assertionCoordinate; - const previousHead = input?.previousHead; - sealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(input.seal); + const assertionCoordinate = asset?.assertionCoordinate; + sealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(asset.seal); seal = parseCanonicalGraphScopedAuthorSealV1(sealBytes); const bundleByteLength = calculateOpaqueKaBundleByteLengthV1( - BigInt(input.projectionBytes.byteLength), + BigInt(asset.projectionBytes.byteLength), BigInt(sealBytes.byteLength), ); if (bundleByteLength > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1)) { throw new RangeError('bundle exceeds the Gate-1 complete-bundle ceiling'); } - encoded = encodeOpaqueKaBundleV1(input.projectionBytes, sealBytes); + encoded = encodeOpaqueKaBundleV1(asset.projectionBytes, sealBytes); const byteLength = BigInt(encoded.bundleBytes.byteLength); const chunkCount = ((byteLength - 1n) / KA_TRANSFER_CHUNK_SIZE_BYTES_V1) + 1n; row = parseCanonicalAuthorCatalogRowV1(canonicalizeAuthorCatalogRowV1({ @@ -425,9 +579,9 @@ function prepareRowAndBundle(input: ProduceAndStagePublicOpenOneRowSuccessorInpu }, })); const deployment = Object.freeze({ - networkId: input.deployment.networkId, - assertedAtChainId: input.deployment.assertedAtChainId, - assertedAtKav10Address: input.deployment.assertedAtKav10Address, + networkId: deploymentInput.networkId, + assertedAtChainId: deploymentInput.assertedAtChainId, + assertedAtKav10Address: deploymentInput.assertedAtKav10Address, }); const scope = Object.freeze({ networkId: previousHead.payload.networkId, @@ -495,14 +649,15 @@ function assertSupportedPreviousSlice( head.payload.subGraphName !== null || head.payload.bucketCount !== '1' || head.payload.directoryHeight !== '0' - || (head.payload.totalRows !== '0' && head.payload.totalRows !== '1') + || BigInt(head.payload.totalRows) > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) || (head.payload.totalRows === '0' && bucket !== null) - || (head.payload.totalRows === '1' && bucket?.payload.rows.length !== 1) + || (head.payload.totalRows !== '0' + && bucket?.payload.rows.length !== Number(head.payload.totalRows)) || head.payload.directoryRootDigest === ZERO_DIGEST32_V1 ) { fail( 'catalog-successor-producer-history', - 'previous catalog is outside the public/open one-row root-lane slice', + 'previous catalog is outside the bounded public/open root-lane slice', ); } } diff --git a/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts index 6324a56ba2..31dc7df004 100644 --- a/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts @@ -32,6 +32,8 @@ const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; const KA_NUMBER = 7n; const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); const KA_UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; +const SECOND_KA_NUMBER = 8n; +const THIRD_KA_NUMBER = 9n; const ASSERTION_ROOT = '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f' as Digest32V1; const PROJECTION = new TextEncoder().encode( @@ -130,6 +132,87 @@ describe('RFC-64 DKGAgent public/open successor publication', () => { expect(reopenedBundle).not.toBe(stagedBundle); }, 60_000); + it('reloads multi-row durable history and grows a canonical exact set one KA at a time', async () => { + const dataDir = await createDataDir('multi-row-history'); + const agent = await startAgent('successor-multi-row-history', dataDir); + const genesis = await publishGenesis(agent); + const firstSeal = await authorSeal(); + const secondSeal = await authorSeal(SECOND_KA_NUMBER); + const thirdSeal = await authorSeal(THIRD_KA_NUMBER); + const firstAsset = { + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: firstSeal, + }; + const secondAsset = { + assertionCoordinate: 'gate-2-object' as never, + projectionBytes: PROJECTION, + seal: secondSeal, + }; + const thirdAsset = { + assertionCoordinate: 'gate-2-object-3' as never, + projectionBytes: PROJECTION, + seal: thirdSeal, + }; + const first = await agent.publishOpenAuthorCatalogSuccessorV1({ + previousHead: { + objectDigest: genesis.headObjectDigest, + signatureVariantDigest: genesis.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + ...firstAsset, + deployment: DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [], + }); + const second = await agent.publishOpenAuthorCatalogExactSetSuccessorV1({ + previousHead: { + objectDigest: first.headObjectDigest, + signatureVariantDigest: first.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + assets: [secondAsset, firstAsset], + deployment: DEPLOYMENT, + issuedAt: '1773900002000' as TimestampMsV1, + peers: [], + }); + const third = await agent.publishOpenAuthorCatalogExactSetSuccessorV1({ + previousHead: { + objectDigest: second.headObjectDigest, + signatureVariantDigest: second.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + assets: [thirdAsset, firstAsset, secondAsset], + deployment: DEPLOYMENT, + issuedAt: '1773900003000' as TimestampMsV1, + peers: [], + }); + + const expectedKaIds = [KA_NUMBER, SECOND_KA_NUMBER, THIRD_KA_NUMBER] + .map((number) => ((BigInt(AUTHOR) << 96n) | number).toString()); + expect(second.announcement.catalogVersion).toBe('2'); + expect(second.inventoryRowCount).toBe('2'); + expect(third.announcement.catalogVersion).toBe('3'); + expect(third.inventoryRowCount).toBe('3'); + expect(third.assets.map(({ kaId }) => kaId)).toEqual(expectedKaIds); + expect(third.assets.map(({ kaUal }) => kaUal)).toEqual( + [KA_NUMBER, SECOND_KA_NUMBER, THIRD_KA_NUMBER] + .map((number) => `did:dkg:${NETWORK_ID}/${AUTHOR}/${number}`), + ); + expect(await agent.readRfc64StagedAuthorCatalogHeadV1({ + objectDigest: third.headObjectDigest, + signatureVariantDigest: third.signatureVariantDigest, + })).toBe(third.headObjectDigest); + for (const asset of third.assets) { + const staged = await agent.readRfc64StagedKaBundleV1(asset.bundleDigest); + expect(staged).not.toBeNull(); + expect(decodeOpaqueKaBundleV1(staged!).projectionBytes).toEqual(PROJECTION); + } + }, 60_000); + it('rejects a mismatched signed authorization before signing or durable staging', async () => { const dataDir = await createDataDir('mismatched-auth'); const agent = await startAgent('successor-mismatched-auth', dataDir); @@ -249,13 +332,15 @@ async function directAuthorization(contextGraphId: ContextGraphIdV1) { return produced.authorization; } -async function authorSeal(): Promise { +async function authorSeal(kaNumber = KA_NUMBER): Promise { + const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); + const kaUal = `did:dkg:${NETWORK_ID}/${AUTHOR}/${kaNumber}`; const typedData = buildAuthorAttestationTypedData({ chainId: BigInt(DEPLOYMENT.assertedAtChainId), kav10Address: DEPLOYMENT.assertedAtKav10Address, merkleRoot: ethers.getBytes(ASSERTION_ROOT), authorAddress: AUTHOR, - reservedKaId: BigInt(KA_ID), + reservedKaId: BigInt(kaId), }); const signature = ethers.Signature.from(await AUTHOR_WALLET.signTypedData( typedData.domain, @@ -270,10 +355,10 @@ async function authorSeal(): Promise { authorSchemeVersion: '1', assertedAtChainId: DEPLOYMENT.assertedAtChainId, assertedAtKav10Address: KAV10, - reservedKaId: KA_ID, + reservedKaId: kaId, assertionFinalizedAt: '2026-07-19T12:34:56.789Z', contentScopeVersion: '2', - kaUal: KA_UAL, + kaUal, assertionVersion: '1', publicTripleCount: '2', privateTripleCount: '0', diff --git a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts index 68717c1f79..bb89b12f55 100644 --- a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts @@ -34,6 +34,9 @@ const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; const KA_NUMBER = 7n; const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); const UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; +const SECOND_KA_NUMBER = 8n; +const SECOND_KA_ID = ((BigInt(AUTHOR) << 96n) | SECOND_KA_NUMBER).toString(); +const SECOND_UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${SECOND_KA_NUMBER}`; const ASSERTION_ROOT = '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f' as Digest32V1; const PROJECTION = new TextEncoder().encode( @@ -128,6 +131,120 @@ describe('RFC-64 public/open one-row successor producer', () => { }); }); + it('canonicalizes an unordered two-row exact set and produces the same signed head', async () => { + const { genesis, authorization } = await producerHistory(); + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => Object.freeze({ + durable: true as const, + namespaceDurability: 'test-exact-durable' as never, + objects: Object.freeze([]), + })); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + const firstSeal = await authorSeal(AUTHOR_WALLET); + const secondSeal = await authorSeal(AUTHOR_WALLET, SECOND_KA_NUMBER); + const first = await producer.produceAndStage({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: firstSeal, + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, + }); + const common = { + previousHead: first.publication.head, + previousDirectoryPath: first.publication.directoryPath, + previousBucket: first.publication.bucket, + deployment: DEPLOYMENT, + issuedAt: '1773900002000' as never, + catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, + }; + const firstAsset = { + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: firstSeal, + }; + const secondAsset = { + assertionCoordinate: 'gate-2-object' as never, + projectionBytes: PROJECTION, + seal: secondSeal, + }; + + const unordered = await producer.produceAndStageExactSet({ + ...common, + assets: [secondAsset, firstAsset], + }); + const ordered = await producer.produceAndStageExactSet({ + ...common, + assets: [firstAsset, secondAsset], + }); + + expect(unordered.publication.head.payload).toMatchObject({ + totalRows: '2', + version: '2', + previousHeadDigest: first.publication.head.objectDigest, + }); + expect(unordered.assets.map(({ row }) => row.kaId)).toEqual([KA_ID, SECOND_KA_ID]); + expect(unordered.assets.map(({ projection }) => projection.kaUal)).toEqual([UAL, SECOND_UAL]); + expect(unordered.publication.bucket?.payload.rows).toEqual( + unordered.assets.map(({ row }) => row), + ); + expect(ordered.publication.head).toEqual(unordered.publication.head); + expect(ordered.publication.directoryPath).toEqual(unordered.publication.directoryPath); + expect(ordered.publication.bucket).toEqual(unordered.publication.bucket); + expect(stageKaBundle).toHaveBeenCalledTimes(5); + expect(stageVerifiedObjects).toHaveBeenCalledTimes(3); + }); + + it('rejects empty, oversized, and duplicate exact sets before signing or staging', async () => { + const { genesis, authorization } = await producerHistory(); + const signDigest = vi.fn(async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest)); + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + const asset = { + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(AUTHOR_WALLET), + }; + const common = { + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: { issuer: AUTHOR, signDigest }, + catalogIssuerAuthorization: authorization, + }; + + await expect(producer.produceAndStageExactSet({ + ...common, + assets: [], + })).rejects.toMatchObject({ code: 'catalog-successor-producer-input' }); + await expect(producer.produceAndStageExactSet({ + ...common, + assets: Array.from({ length: 1025 }, () => asset), + })).rejects.toMatchObject({ code: 'catalog-successor-producer-input' }); + await expect(producer.produceAndStageExactSet({ + ...common, + assets: [asset, asset], + })).rejects.toMatchObject({ code: 'catalog-successor-producer-input' }); + + expect(signDigest).not.toHaveBeenCalled(); + expect(stageKaBundle).not.toHaveBeenCalled(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); + it('rejects a non-author attestation before either durable staging callback', async () => { const { genesis, authorization } = await producerHistory(); const stageKaBundle = vi.fn(durableBundleReceipt); From 5187061bcebb025f390ab5816ac02705112e5c69 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:26:26 +0200 Subject: [PATCH 144/292] feat(agent): apply bounded RFC-64 multi-asset heads --- .../public-catalog-native-receiver-v1.ts | 429 +++++++++++++----- .../public-catalog-native-reconciler-v1.ts | 72 ++- ...c-catalog-native-gate1.integration.test.ts | 192 +++++++- ...ublic-catalog-native-reconciler-v1.test.ts | 70 +++ 4 files changed, 630 insertions(+), 133 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index d1f647fc7c..e1527c3cc7 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -1,10 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Bounded Gate-1 receiver for one public/open root-lane catalog row. + * Bounded receiver for one public/open root-lane catalog bucket. * - * The supported first vertical slice is intentionally narrow: one successor - * head, one bucket, one row, root context-graph lane, and one complete bundle. + * The supported vertical slice is intentionally narrow: one successor head, + * one bucket with 1..1,024 rows, the root context-graph lane, and complete + * bundles for every signed row. * Every network hop is RFC-64 catalog-native. Activation happens only after * signed head/path/bucket verification, transfer verification, canonical * projection verification, one atomic projection-plus-seal replace, exact @@ -16,6 +17,7 @@ import { AUTHOR_SCHEME_VERSION_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, ZERO_DIGEST32_V1, assertAuthorCatalogHeadScopeBindingV1, assertAuthorCatalogScopeV1, @@ -43,6 +45,7 @@ import { type AuthorCatalogRowV1, type AuthorCatalogScopeV1, type CatalogSealDeploymentProfileV1, + type CountV1, type Digest32V1, type SignedAuthorCatalogBucketEnvelopeV1, type SignedAuthorCatalogDirectoryNodeEnvelopeV1, @@ -71,6 +74,10 @@ import type { AppliedCatalogHeadSnapshotV1, Rfc64InventoryV1OperationsV1, } from './inventory-v1/index.js'; +import { + verifyRfc64PublicCatalogInventoryCompletenessV1, + type Rfc64PublicCatalogInventoryEvidenceRowV1, +} from './public-catalog-inventory-completeness-v1.js'; import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, @@ -121,6 +128,28 @@ export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { readonly appliedHeadStatus: 'applied' | 'existing'; } +export interface Rfc64PublicCatalogNativeActivatedRowEvidenceV1 + extends Rfc64PublicCatalogInventoryEvidenceRowV1 { + readonly swmGraph: string; + /** Exact signed delegation/head/path/bucket/row authorization closure. */ + readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; +} + +/** Exact evidence for one bounded multi-asset successor inventory. */ +export interface Rfc64PublicCatalogNativeMultiAssetActivationEvidenceV1 { + readonly inventoryDigest: Digest32V1; + readonly catalogHeadDigest: Digest32V1; + readonly inventoryRowCount: number; + readonly activatedTripleCount: number; + /** Strictly increasing by mathematical KA ID. */ + readonly rows: readonly Readonly[]; + readonly appliedHeadStatus: 'applied' | 'existing'; +} + +export type Rfc64PublicCatalogNativeSuccessorEvidenceV1 = + | Rfc64PublicCatalogNativeActivationEvidenceV1 + | Rfc64PublicCatalogNativeMultiAssetActivationEvidenceV1; + /** Exact durable evidence for the canonical empty catalog bootstrap. */ export interface Rfc64PublicCatalogNativeGenesisEvidenceV1 { /** Digest of the exact empty applied inventory (zero rows). */ @@ -134,7 +163,7 @@ export interface Rfc64PublicCatalogNativeGenesisEvidenceV1 { export type Rfc64PublicCatalogNativeSynchronizationEvidenceV1 = | Rfc64PublicCatalogNativeGenesisEvidenceV1 - | Rfc64PublicCatalogNativeActivationEvidenceV1; + | Rfc64PublicCatalogNativeSuccessorEvidenceV1; export type Rfc64PublicCatalogNativeReceiverErrorCodeV1 = | 'catalog-native-receiver-input' @@ -200,11 +229,11 @@ export class Rfc64PublicCatalogNativeReceiverV1 { announcement, trustedScope, trustedDeployment, - 'successor', + 'one-successor', signal, ); - if (evidence.inventoryRowCount !== 1) { - fail('catalog-native-receiver-slice', 'one-row synchronization returned genesis evidence'); + if (evidence.inventoryRowCount !== 1 || !('catalogRowDigest' in evidence)) { + fail('catalog-native-receiver-slice', 'one-row synchronization returned non-one-row evidence'); } return evidence; }, @@ -212,8 +241,8 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } /** - * Fetch and apply either the canonical empty genesis or the bounded one-row - * successor. This is the production-facing entrypoint for a fresh receiver: + * Fetch and apply either the canonical empty genesis or one bounded + * 1..1,024-row successor. This is the production-facing entrypoint for a fresh receiver: * callers do not need to seed durable history out of band. */ async synchronizePublicOpenCatalog( @@ -265,7 +294,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { 'genesis', signal, ); - if (evidence.inventoryRowCount !== 0) { + if (evidence.inventoryRowCount !== 0 || !('stagedObjectCount' in evidence)) { fail('catalog-native-receiver-slice', 'genesis bootstrap returned successor evidence'); } return evidence; @@ -278,7 +307,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { announcement: Rfc64PublicCatalogHeadAnnouncementV1, trustedCatalogScope: Readonly, deployment: CatalogSealDeploymentProfileV1, - expected: 'any' | 'genesis' | 'successor', + expected: 'any' | 'genesis' | 'one-successor', signal: AbortSignal | undefined, ): Promise { throwIfAborted(signal); @@ -301,10 +330,13 @@ export class Rfc64PublicCatalogNativeReceiverV1 { signal, ); } - if (expected === 'successor' && claimsGenesisHistory(head)) { + if (expected === 'one-successor' && claimsGenesisHistory(head)) { fail('catalog-native-receiver-slice', 'one-row synchronization does not accept genesis'); } - return this.synchronizeOnePublicOpenRowFetched( + if (expected === 'one-successor' && head.payload.totalRows !== '1') { + fail('catalog-native-receiver-slice', 'one-row synchronization does not accept multi-row heads'); + } + return this.synchronizeBoundedPublicOpenCatalogFetched( remotePeerId, announcement, trustedCatalogScope, @@ -440,22 +472,22 @@ export class Rfc64PublicCatalogNativeReceiverV1 { }); } - private async synchronizeOnePublicOpenRowFetched( + private async synchronizeBoundedPublicOpenCatalogFetched( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, trustedCatalogScope: Readonly, deployment: CatalogSealDeploymentProfileV1, fetchedHead: FetchedRfc64PublicCatalogHeadV1, signal: AbortSignal | undefined, - ): Promise { + ): Promise { const head = fetchedHead.envelope; - assertFirstSliceHead(head); + const expectedRowCount = assertBoundedSuccessorHead(head); const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(trustedCatalogScope); const currentAppliedHead = this.options.inventory.readAppliedCatalogHeadV1( catalogScopeDigest, head.payload.authorAddress, ); - const replay = assertMonotonicSuccessorHistory(currentAppliedHead, head); + const replay = assertMonotonicSuccessorHistory(currentAppliedHead, head, expectedRowCount); const scope = nativeScope(announcement, trustedCatalogScope, head); const fetchedDelegation = await this.fetchDirectAuthorCatalogIssuerDelegation( remotePeerId, @@ -502,8 +534,14 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } catch (cause) { fail('catalog-native-receiver-catalog', 'successor directory path is invalid', cause); } - if (descriptor.rowCount !== '1' || descriptor.bucketDigest === ZERO_DIGEST32_V1) { - fail('catalog-native-receiver-slice', 'first receiver slice requires one non-empty bucket row'); + if ( + descriptor.rowCount !== head.payload.totalRows + || descriptor.bucketDigest === ZERO_DIGEST32_V1 + ) { + fail( + 'catalog-native-receiver-slice', + 'bounded receiver requires one non-empty bucket containing the exact head row count', + ); } const fetchedBucket = await this.options.contentTransport.fetchCatalogObject( @@ -531,7 +569,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { bucket.issuer !== head.issuer || bucket.objectDigest !== descriptor.bucketDigest || bucket.payload.bucketId !== descriptor.bucketId - || bucket.payload.rows.length !== 1 + || bucket.payload.rows.length !== expectedRowCount || bucket.payload.rows.length.toString() !== descriptor.rowCount || canonicalizeAuthorCatalogBucketPayloadBytesV1(bucket.payload).byteLength.toString() !== descriptor.byteLength @@ -541,83 +579,129 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } catch (cause) { fail('catalog-native-receiver-catalog', 'catalog bucket is not bound to its directory', cause); } - const row = bucket.payload.rows[0]; - if (row === undefined) { - fail('catalog-native-receiver-catalog', 'verified one-row bucket did not contain its row'); + const preparedRows: Array<{ + readonly row: AuthorCatalogRowV1; + readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + readonly projectionMetadata: ReturnType; + readonly sealBinding: VerifiedCatalogSealBindingSnapshotV1; + readonly projectionBytes: Uint8Array; + readonly expectedEvidence: Rfc64PublicCatalogInventoryEvidenceRowV1; + }> = []; + for (const row of bucket.payload.rows) { + throwIfAborted(signal); + let authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + try { + const authorshipToken = verifyAuthorCatalogRowAuthorshipV1({ + catalogIssuerDelegation: fetchedDelegation.envelope, + catalogIssuerDelegationSignature: fetchedDelegation.issuerSignature, + parentAuthorAgentEvidence: null, + catalogHead: head, + catalogHeadSignature: fetchedHead.issuerSignature, + directoryPathEnvelopes: [directory], + directoryPathSignatures: [fetchedDirectory.issuerSignature], + directoryPathProof, + catalogBucket: bucket, + catalogBucketSignature: fetchedBucket.issuerSignature, + targetKaId: row.kaId, + }); + authorship = readVerifiedAuthorCatalogRowAuthorshipV1(authorshipToken); + } catch (cause) { + fail( + 'catalog-native-receiver-authorization', + `catalog row ${row.kaId} is not authorized by the exact direct-author delegation closure`, + cause, + ); + } + + const bundle = await this.options.contentTransport.fetchKaBundle( + remotePeerId, + { + ...scope, + kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + blobDigest: row.transfer.blobDigest, + byteLength: row.transfer.byteLength as never, + }, + { timeoutMs: this.#timeoutMs, signal }, + ); + if (bundle === null) { + fail('catalog-native-receiver-not-found', `catalog row ${row.kaId} KA bundle was not found`); + } + + let projectionMetadata: ReturnType; + let sealBinding: VerifiedCatalogSealBindingSnapshotV1; + let projectionBytes: Uint8Array; + try { + const transferred = verifyTransferredCatalogBundleV1(head, row, bundle, deployment); + const transferredMetadata = readVerifiedTransferredCatalogBundleMetadataV1( + transferred, + head, + row, + deployment, + ); + sealBinding = readVerifiedCatalogSealBindingV1( + transferredMetadata.catalogSealBinding, + ); + assertRecoverableAuthorAttestationV1(sealBinding); + const projection = verifyCgSharedProjectionV1(transferred, head, row, deployment); + projectionMetadata = readVerifiedCgSharedProjectionMetadataV1( + projection, + transferred, + head, + row, + deployment, + ); + projectionBytes = readVerifiedCgSharedProjectionBytesV1( + projection, + transferred, + head, + row, + deployment, + ); + } catch (cause) { + fail( + 'catalog-native-receiver-transfer', + `KA bundle or shared projection verification failed for row ${row.kaId}`, + cause, + ); + } + const expectedEvidence = Object.freeze({ + kaId: row.kaId, + catalogRowDigest: projectionMetadata.catalogRowDigest, + contentDigest: projectionMetadata.projectionDigest, + sealDigest: sealBinding.sealDigest, + bundleDigest: row.transfer.blobDigest, + kaUal: projectionMetadata.kaUal, + activatedTripleCount: Number(projectionMetadata.publicTripleCount), + }) satisfies Rfc64PublicCatalogInventoryEvidenceRowV1; + preparedRows.push(Object.freeze({ + row, + authorship, + projectionMetadata, + sealBinding, + projectionBytes, + expectedEvidence, + })); } - let authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + const expectedRows = preparedRows.map((prepared) => prepared.expectedEvidence); + // Fail closed on exact count/order/UAL/content evidence before the first + // semantic mutation. The second call below joins these expectations to + // independent exact post-reads. try { - const authorshipToken = verifyAuthorCatalogRowAuthorshipV1({ - catalogIssuerDelegation: fetchedDelegation.envelope, - catalogIssuerDelegationSignature: fetchedDelegation.issuerSignature, - parentAuthorAgentEvidence: null, - catalogHead: head, - catalogHeadSignature: fetchedHead.issuerSignature, - directoryPathEnvelopes: [directory], - directoryPathSignatures: [fetchedDirectory.issuerSignature], - directoryPathProof, - catalogBucket: bucket, - catalogBucketSignature: fetchedBucket.issuerSignature, - targetKaId: row.kaId, + verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: trustedCatalogScope, + expectedTotalRows: head.payload.totalRows as CountV1, + expectedRows, + observedRows: expectedRows, }); - authorship = readVerifiedAuthorCatalogRowAuthorshipV1(authorshipToken); } catch (cause) { fail( - 'catalog-native-receiver-authorization', - 'catalog row is not authorized by the exact direct-author delegation closure', + 'catalog-native-receiver-catalog', + 'signed catalog rows do not form one exact bounded inventory', cause, ); } - const bundle = await this.options.contentTransport.fetchKaBundle( - remotePeerId, - { - ...scope, - kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, - blobDigest: row.transfer.blobDigest, - byteLength: row.transfer.byteLength as never, - }, - { timeoutMs: this.#timeoutMs, signal }, - ); - if (bundle === null) { - fail('catalog-native-receiver-not-found', 'catalog row KA bundle was not found'); - } - - let projectionMetadata: ReturnType; - let sealBinding: VerifiedCatalogSealBindingSnapshotV1; - let projectionBytes: Uint8Array; - try { - const transferred = verifyTransferredCatalogBundleV1(head, row, bundle, deployment); - const transferredMetadata = readVerifiedTransferredCatalogBundleMetadataV1( - transferred, - head, - row, - deployment, - ); - sealBinding = readVerifiedCatalogSealBindingV1( - transferredMetadata.catalogSealBinding, - ); - assertRecoverableAuthorAttestationV1(sealBinding); - const projection = verifyCgSharedProjectionV1(transferred, head, row, deployment); - projectionMetadata = readVerifiedCgSharedProjectionMetadataV1( - projection, - transferred, - head, - row, - deployment, - ); - projectionBytes = readVerifiedCgSharedProjectionBytesV1( - projection, - transferred, - head, - row, - deployment, - ); - } catch (cause) { - fail('catalog-native-receiver-transfer', 'KA bundle or shared projection verification failed', cause); - } - try { await this.options.controlObjects.stageVerifiedObjects([ fetchedDelegation, @@ -629,19 +713,58 @@ export class Rfc64PublicCatalogNativeReceiverV1 { fail('catalog-native-receiver-catalog', 'verified catalog objects could not be staged', cause); } - const activation = await activateExactPublicProjection( - this.options.store, - head, - row, - projectionMetadata.kaUal, - projectionBytes, - Number(projectionMetadata.publicTripleCount), - sealBinding, + const activatedRows: Rfc64PublicCatalogNativeActivatedRowEvidenceV1[] = []; + for (const prepared of preparedRows) { + throwIfAborted(signal); + const activation = await activateExactPublicProjection( + this.options.store, + head, + prepared.row, + prepared.projectionMetadata.kaUal, + prepared.projectionBytes, + Number(prepared.projectionMetadata.publicTripleCount), + prepared.sealBinding, + ); + activatedRows.push(Object.freeze({ + ...activation.evidence, + swmGraph: activation.swmGraph, + authorship: prepared.authorship, + })); + } + let completion: ReturnType; + try { + completion = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: trustedCatalogScope, + expectedTotalRows: head.payload.totalRows as CountV1, + expectedRows, + observedRows: activatedRows.map((row) => ({ + kaId: row.kaId, + catalogRowDigest: row.catalogRowDigest, + contentDigest: row.contentDigest, + sealDigest: row.sealDigest, + bundleDigest: row.bundleDigest, + kaUal: row.kaUal, + activatedTripleCount: row.activatedTripleCount, + })), + }); + } catch (cause) { + fail( + 'catalog-native-receiver-activation', + 'semantic post-reads do not equal the exact signed inventory', + cause, + ); + } + const activatedTripleCount = activatedRows.reduce( + (total, row) => total + row.activatedTripleCount, + 0, ); + if (!Number.isSafeInteger(activatedTripleCount)) { + fail('catalog-native-receiver-activation', 'total activated triple count is not a safe integer'); + } let appliedHeadStatus: 'applied' | 'existing'; if (replay) { - if (currentAppliedHead!.appliedInventoryDigest !== activation.inventoryDigest) { + if (currentAppliedHead!.appliedInventoryDigest !== completion.inventoryDigest) { fail( 'catalog-native-receiver-history', 'durable applied-head digest differs from exact semantic post-read', @@ -655,9 +778,9 @@ export class Rfc64PublicCatalogNativeReceiverV1 { authorAddress: head.payload.authorAddress, expectedCurrentCatalogHeadDigest: head.payload.previousHeadDigest, currentCatalogHeadDigest: head.objectDigest as Digest32V1, - appliedInventoryDigest: activation.inventoryDigest, + appliedInventoryDigest: completion.inventoryDigest, catalogVersion: head.payload.version, - inventoryRowCount: '1' as never, + inventoryRowCount: head.payload.totalRows, }).status; } catch (cause) { const reconciled = this.options.inventory.readAppliedCatalogHeadV1( @@ -667,7 +790,11 @@ export class Rfc64PublicCatalogNativeReceiverV1 { if ( reconciled === null || reconciled.currentCatalogHeadDigest !== head.objectDigest - || reconciled.appliedInventoryDigest !== activation.inventoryDigest + || !isExactAppliedSuccessorSnapshot( + reconciled, + head, + completion.inventoryDigest, + ) ) { fail( 'catalog-native-receiver-history', @@ -679,17 +806,46 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } } + const durablePostRead = this.options.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + head.payload.authorAddress, + ); + if (!isExactAppliedSuccessorSnapshot( + durablePostRead, + head, + completion.inventoryDigest, + )) { + fail( + 'catalog-native-receiver-history', + 'successor durable post-read differs in head, digest, version, or exact row count', + ); + } + + if (activatedRows.length === 1) { + const [only] = activatedRows; + if (only === undefined) { + fail('catalog-native-receiver-activation', 'one-row completion lost its activation row'); + } + return Object.freeze({ + inventoryDigest: completion.inventoryDigest, + catalogHeadDigest: head.objectDigest as Digest32V1, + catalogRowDigest: only.catalogRowDigest, + contentDigest: only.contentDigest, + bundleDigest: only.bundleDigest, + kaUal: only.kaUal, + inventoryRowCount: 1 as const, + activatedTripleCount: only.activatedTripleCount, + swmGraph: only.swmGraph, + authorship: only.authorship, + appliedHeadStatus, + }); + } return Object.freeze({ - inventoryDigest: activation.inventoryDigest, + inventoryDigest: completion.inventoryDigest, catalogHeadDigest: head.objectDigest as Digest32V1, - catalogRowDigest: projectionMetadata.catalogRowDigest, - contentDigest: projectionMetadata.projectionDigest, - bundleDigest: row.transfer.blobDigest, - kaUal: projectionMetadata.kaUal, - inventoryRowCount: 1 as const, - activatedTripleCount: Number(projectionMetadata.publicTripleCount), - swmGraph: activation.swmGraph, - authorship, + inventoryRowCount: activatedRows.length, + activatedTripleCount, + rows: Object.freeze(activatedRows), appliedHeadStatus, }); } @@ -914,6 +1070,7 @@ function isExactEmptyGenesisSnapshot( function assertMonotonicSuccessorHistory( current: AppliedCatalogHeadSnapshotV1 | null, head: SignedAuthorCatalogHeadEnvelopeV1, + expectedRowCount: number, ): boolean { if (current === null) { fail( @@ -922,7 +1079,10 @@ function assertMonotonicSuccessorHistory( ); } if (current.currentCatalogHeadDigest === head.objectDigest) { - if (current.catalogVersion !== head.payload.version || current.inventoryRowCount !== '1') { + if ( + current.catalogVersion !== head.payload.version + || current.inventoryRowCount !== expectedRowCount.toString() + ) { fail('catalog-native-receiver-history', 'replayed head differs from its durable applied state'); } return true; @@ -939,20 +1099,43 @@ function assertMonotonicSuccessorHistory( return false; } -function assertFirstSliceHead(head: SignedAuthorCatalogHeadEnvelopeV1): void { +function assertBoundedSuccessorHead(head: SignedAuthorCatalogHeadEnvelopeV1): number { + let totalRows = 0; + try { + totalRows = Number(BigInt(head.payload.totalRows)); + } catch {} if ( head.payload.subGraphName !== null || head.payload.bucketCount !== '1' || head.payload.directoryHeight !== '0' - || head.payload.totalRows !== '1' + || !Number.isSafeInteger(totalRows) + || totalRows < 1 + || totalRows > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 || head.payload.version === '0' || head.payload.previousHeadDigest === null ) { fail( 'catalog-native-receiver-slice', - 'first receiver slice requires one root-lane row in a non-genesis successor', + `bounded receiver requires 1..${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1} root-lane rows in one non-genesis successor bucket`, ); } + return totalRows; +} + +function isExactAppliedSuccessorSnapshot( + current: AppliedCatalogHeadSnapshotV1 | null, + head: SignedAuthorCatalogHeadEnvelopeV1, + inventoryDigest: Digest32V1, +): boolean { + return current !== null + && current.catalogScopeDigest === computeAuthorCatalogScopeDigestV1( + deriveAuthorCatalogScopeFromHeadV1(head.payload), + ) + && current.authorAddress === head.payload.authorAddress + && current.currentCatalogHeadDigest === head.objectDigest + && current.appliedInventoryDigest === inventoryDigest + && current.catalogVersion === head.payload.version + && current.inventoryRowCount === head.payload.totalRows; } function nativeScope( @@ -1031,7 +1214,10 @@ async function activateExactPublicProjection( projectionBytes: Uint8Array, expectedTripleCount: number, sealBinding: VerifiedCatalogSealBindingSnapshotV1, -): Promise<{ swmGraph: string; inventoryDigest: Digest32V1 }> { +): Promise<{ + readonly swmGraph: string; + readonly evidence: Rfc64PublicCatalogInventoryEvidenceRowV1; +}> { let projectionText: string; let quads; try { @@ -1097,15 +1283,14 @@ async function activateExactPublicProjection( await assertExactAuthorSealPostRead(store, sealBinding); return { swmGraph, - inventoryDigest: computeRfc64AppliedInventoryDigestV1({ - catalogScopeDigest: sealBinding.catalogScopeDigest, - rows: [{ - catalogRowDigest: sealBinding.catalogRowDigest, - contentDigest: row.projectionDigest, - sealDigest: sealBinding.sealDigest, - kaUal, - activatedTripleCount: expectedTripleCount, - }], + evidence: Object.freeze({ + kaId: row.kaId, + catalogRowDigest: sealBinding.catalogRowDigest, + contentDigest: row.projectionDigest, + sealDigest: sealBinding.sealDigest, + bundleDigest: row.transfer.blobDigest, + kaUal, + activatedTripleCount: expectedTripleCount, }), }; } diff --git a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts index 5169f1d5d6..30ad11d69e 100644 --- a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts @@ -10,11 +10,15 @@ */ import { + assertAuthorCatalogHeadScopeBindingV1, computeAuthorCatalogScopeDigestV1, + MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, type AuthorCatalogScopeV1, type CatalogSealDeploymentProfileV1, type CountV1, type ContextGraphPolicyV1, + type Digest32V1, + type SignedAuthorCatalogHeadEnvelopeV1, } from '@origintrail-official/dkg-core'; import type { Rfc64InventoryV1OperationsV1 } from './inventory-v1/index.js'; @@ -45,6 +49,16 @@ export type Rfc64PublicOpenCatalogTrustedScopeResolverV1 = ( announcement: Rfc64PublicCatalogHeadAnnouncementV1, ) => Readonly; +/** Exact verified signature variant loaded from the durable control-object store. */ +export interface Rfc64PublicOpenCatalogStagedHeadV1 { + readonly envelope: SignedAuthorCatalogHeadEnvelopeV1; + readonly signatureVariantDigest: Digest32V1; +} + +export type Rfc64PublicOpenCatalogStagedHeadReaderV1 = ( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, +) => Promise; + export interface Rfc64PublicOpenCatalogNativeReconcilerOptionsV1 { readonly nativeReceiver: Rfc64PublicOpenCatalogNativeReceiverClientV1; readonly inventory: Pick; @@ -52,6 +66,11 @@ export interface Rfc64PublicOpenCatalogNativeReconcilerOptionsV1 { readonly resolveTrustedCatalogScope: Rfc64PublicOpenCatalogTrustedScopeResolverV1; /** Resolve the locally trusted deployment tuple; never copy it from the wire. */ readonly resolveDeployment: Rfc64PublicOpenCatalogDeploymentResolverV1; + /** + * Read the exact verified signature variant staged by the native receiver. + * Optional only for Gate-1 compatibility; a multi-row lane must provide it. + */ + readonly readStagedCatalogHead?: Rfc64PublicOpenCatalogStagedHeadReaderV1; } /** @@ -103,6 +122,10 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 || typeof options?.inventory?.readAppliedCatalogHeadV1 !== 'function' || typeof options?.resolveTrustedCatalogScope !== 'function' || typeof options?.resolveDeployment !== 'function' + || ( + options?.readStagedCatalogHead !== undefined + && typeof options.readStagedCatalogHead !== 'function' + ) ) { throw new TypeError('RFC-64 public/open native reconciler dependencies are incomplete'); } @@ -119,15 +142,58 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 catalogScopeDigest, announcement.authorAddress, ); - const expectedInventoryRowCount = announcement.catalogVersion === '0' ? '0' : '1'; - return current !== null - && current.catalogScopeDigest === catalogScopeDigest + if (current === null) return false; + const expectedInventoryRowCount = await this.readExpectedInventoryRowCount( + announcement, + trustedCatalogScope, + current.inventoryRowCount, + ); + if (expectedInventoryRowCount === null) return false; + return current.catalogScopeDigest === catalogScopeDigest && current.authorAddress === announcement.authorAddress && current.currentCatalogHeadDigest === announcement.catalogHeadObjectDigest && current.catalogVersion === announcement.catalogVersion && current.inventoryRowCount === expectedInventoryRowCount; } + private async readExpectedInventoryRowCount( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + trustedCatalogScope: Readonly, + durableInventoryRowCount: CountV1, + ): Promise { + if (this.options.readStagedCatalogHead === undefined) { + // Preserve the already-qualified Gate-1 behavior. Refuse to bless a + // multi-row snapshot without the exact staged head that commits its count. + if (announcement.catalogVersion === '0') return '0' as CountV1; + return durableInventoryRowCount === '1' ? '1' as CountV1 : null; + } + const staged = await this.options.readStagedCatalogHead(announcement); + if (staged === null) return null; + const head = staged.envelope; + try { + if ( + head.objectDigest !== announcement.catalogHeadObjectDigest + || staged.signatureVariantDigest !== announcement.signatureVariantDigest + || head.payload.version !== announcement.catalogVersion + ) { + throw new Error('staged head identity or exact signature variant differs from announcement'); + } + assertAuthorCatalogHeadScopeBindingV1(head.payload, trustedCatalogScope); + const totalRows = BigInt(head.payload.totalRows); + if ( + totalRows < 0n + || totalRows > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) + || (announcement.catalogVersion === '0' && totalRows !== 0n) + || (announcement.catalogVersion !== '0' && totalRows < 1n) + ) { + throw new Error('staged head totalRows is outside the bounded lane'); + } + } catch { + return null; + } + return head.payload.totalRows as CountV1; + } + async reconcileHead( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index b33b6cdef1..d875b2e5db 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -48,6 +48,9 @@ import { computeRfc64AppliedInventoryDigestV1, Rfc64PublicCatalogNativeReceiverV1, } from '../src/rfc64/public-catalog-native-receiver-v1.js'; +import { + verifyRfc64PublicCatalogInventoryCompletenessV1, +} from '../src/rfc64/public-catalog-inventory-completeness-v1.js'; import { Rfc64PublicCatalogNativeTransportV1, } from '../src/rfc64/public-catalog-native-transport-v1.js'; @@ -74,6 +77,8 @@ const MISSING_DELEGATION_DIGEST = `0x${'76'.repeat(32)}` as Digest32V1; const KA_NUMBER = 7n; const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); const UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; +const SECOND_KA_NUMBER = 8n; +const SECOND_UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${SECOND_KA_NUMBER}`; const PROJECTION = ' "42"^^ .\n' + ' "Alice" .\n'; @@ -245,6 +250,138 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { }); }, 30_000); + it('activates every row in one exact bounded multi-asset inventory before one head CAS', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + const observed = fixture.createCasObservedReceiver(); + + const evidence = await fixture.synchronizeAny( + fixture.multiAssetAnnouncement, + observed.receiver, + ); + if (!('rows' in evidence)) throw new Error('two-row successor returned non-multi evidence'); + const expectedRows = [fixture.rowBundle, fixture.secondRowBundle].map((bundle) => ({ + kaId: bundle.row.kaId, + catalogRowDigest: computeAuthorCatalogRowDigestV1( + fixture.scopeDigest, + bundle.row, + ), + contentDigest: bundle.row.projectionDigest, + sealDigest: bundle.row.sealDigest, + bundleDigest: bundle.row.transfer.blobDigest, + kaUal: bundle.kaUal, + activatedTripleCount: 2, + })); + const expected = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: fixture.scope, + expectedTotalRows: '2' as never, + expectedRows, + observedRows: expectedRows, + }); + + expect(evidence).toMatchObject({ + inventoryDigest: expected.inventoryDigest, + catalogHeadDigest: fixture.multiAssetSuccessor.head.objectDigest, + inventoryRowCount: 2, + activatedTripleCount: 4, + appliedHeadStatus: 'applied', + }); + expect(evidence.rows.map((row) => ({ + kaId: row.kaId, + kaUal: row.kaUal, + bundleDigest: row.bundleDigest, + activatedTripleCount: row.activatedTripleCount, + }))).toEqual(expectedRows.map((row) => ({ + kaId: row.kaId, + kaUal: row.kaUal, + bundleDigest: row.bundleDigest, + activatedTripleCount: row.activatedTripleCount, + }))); + expect(evidence.rows.map((row) => row.swmGraph)).toEqual([ + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${KA_NUMBER}`, + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${SECOND_KA_NUMBER}`, + ]); + expect(evidence.rows.every((row) => Object.isFrozen(row.authorship))).toBe(true); + expect(observed.compareAndSwapAppliedCatalogHeadV1).toHaveBeenCalledOnce(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).toHaveBeenCalledWith( + expect.objectContaining({ + currentCatalogHeadDigest: fixture.multiAssetSuccessor.head.objectDigest, + appliedInventoryDigest: expected.inventoryDigest, + inventoryRowCount: '2', + }), + ); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toMatchObject({ + currentCatalogHeadDigest: fixture.multiAssetSuccessor.head.objectDigest, + appliedInventoryDigest: expected.inventoryDigest, + inventoryRowCount: '2', + }); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(32); + }, 30_000); + + it('verifies all multi-asset bundles before staging, SWM mutation, or applied-head CAS', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + fixture.receiverBundleFetch.mockClear(); + const fetchKaBundle: Rfc64PublicCatalogNativeTransportV1['fetchKaBundle'] = + async (...args) => { + const bundle = await fixture.receiverBundleFetch(...args); + if ( + bundle === null + || args[1].blobDigest !== fixture.secondRowBundle.row.transfer.blobDigest + ) { + return bundle; + } + const tampered = bundle.slice(); + tampered[tampered.length - 1] ^= 0x01; + return tampered; + }; + const observed = fixture.createCasObservedReceiver({ + fetchCatalogObject: fixture.receiverObjectFetch, + fetchKaBundle, + }); + + await expect(fixture.synchronizeAny( + fixture.multiAssetAnnouncement, + observed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-transfer' }); + expect(fixture.receiverBundleFetch).toHaveBeenCalledTimes(2); + expect(observed.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + }, 30_000); + + it('keeps the one-row compatibility entrypoint fail-closed for a multi-row head', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + fixture.receiverObjectFetch.mockClear(); + fixture.receiverBundleFetch.mockClear(); + const observed = fixture.createCasObservedReceiver(); + + await expect(fixture.synchronize( + fixture.multiAssetAnnouncement, + observed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-slice' }); + expect(fixture.receiverObjectFetch).not.toHaveBeenCalled(); + expect(fixture.receiverBundleFetch).not.toHaveBeenCalled(); + expect(observed.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + }, 30_000); + it('rejects a signed genesis whose directory is not exactly empty', async () => { const fixture = await setupLiveReceiver(); @@ -670,6 +807,10 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { expiresAt: '1773899999999', }); const rowBundle = await buildRowBundle(signingWallet); + const secondRowBundle = await buildRowBundle(signingWallet, { + kaNumber: SECOND_KA_NUMBER, + assertionCoordinate: 'gate-2-object', + }); const genesis = await produceEmptyAuthorCatalogGenesisV1({ scope, catalogIssuerDelegationDigest: catalogIssuerDelegation.objectDigest, @@ -692,6 +833,15 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900001000' as never, signer, }); + const multiAssetSuccessor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: successor.head, + previousDirectoryPath: successor.directoryPath, + previousBucket: successor.bucket, + selectedBucketId: '0' as never, + nextRows: [rowBundle.row, secondRowBundle.row], + issuedAt: '1773900001002' as never, + signer, + }); const governedSuccessor = await produceSparseAuthorCatalogSuccessorV1({ previousHead: governedGenesis.head, previousDirectoryPath: governedGenesis.directoryPath, @@ -736,6 +886,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ...governedGenesis.stagedObjects, ...invalidGenesis.stagedObjects, ...successor.stagedObjects, + ...multiAssetSuccessor.stagedObjects, ...governedSuccessor.stagedObjects, ...competingSuccessor.stagedObjects, crossLaneHead, @@ -746,8 +897,12 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ); const authorObjectRead = vi.fn(async (digest: Digest32V1) => authorObjects.get(digest) ?? null); + const bundleBytesByDigest = new Map([ + [rowBundle.row.transfer.blobDigest, rowBundle.bundleBytes], + [secondRowBundle.row.transfer.blobDigest, secondRowBundle.bundleBytes], + ]); const authorBundleRead = vi.fn(async (digest: Digest32V1) => - digest === rowBundle.row.transfer.blobDigest ? rowBundle.bundleBytes : null); + bundleBytesByDigest.get(digest) ?? null); const verifiedObjects = await Promise.all( [ catalogIssuerDelegation, @@ -758,6 +913,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ...governedGenesis.stagedObjects, ...invalidGenesis.stagedObjects, ...successor.stagedObjects, + ...multiAssetSuccessor.stagedObjects, ...governedSuccessor.stagedObjects, ...competingSuccessor.stagedObjects, crossLaneHead, @@ -786,6 +942,9 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const genesisHeadKeys = staged.objects.find( (keys) => keys.objectDigest === genesis.head.objectDigest, ); + const multiAssetHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === multiAssetSuccessor.head.objectDigest, + ); const governedGenesisHeadKeys = staged.objects.find( (keys) => keys.objectDigest === governedGenesis.head.objectDigest, ); @@ -809,6 +968,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ); if (headKeys === undefined) throw new Error('successor head was not staged'); if (genesisHeadKeys === undefined) throw new Error('genesis head was not staged'); + if (multiAssetHeadKeys === undefined) throw new Error('multi-asset successor head was not staged'); if (governedGenesisHeadKeys === undefined) throw new Error('governed genesis head was not staged'); if (governedSuccessorHeadKeys === undefined) throw new Error('governed successor head was not staged'); if (invalidGenesisHeadKeys === undefined) throw new Error('invalid genesis head was not staged'); @@ -883,6 +1043,12 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { catalogHeadObjectDigest: genesisHeadKeys.objectDigest, signatureVariantDigest: genesisHeadKeys.signatureVariantDigest, }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const multiAssetAnnouncement = Object.freeze({ + ...announcement, + catalogVersion: multiAssetSuccessor.head.payload.version, + catalogHeadObjectDigest: multiAssetHeadKeys.objectDigest, + signatureVariantDigest: multiAssetHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; const competingAnnouncement = Object.freeze({ ...announcement, catalogHeadObjectDigest: competingHeadKeys.objectDigest, @@ -1002,12 +1168,15 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { receiver, receiverBundleFetch, missingDelegationAnnouncement, + multiAssetAnnouncement, + multiAssetSuccessor, receiverDirectory: receiverOpened.directory, receiverHeadFetch, receiverObjectFetch, receiverPersistence, receiverStore, rowBundle, + secondRowBundle, scope, scopeDigest, successor, @@ -1155,13 +1324,20 @@ function alternateRecoveryEncoding(signature: string): string { async function buildRowBundle( signingWallet: ethers.Wallet = AUTHOR_WALLET, -): Promise<{ row: AuthorCatalogRowV1; bundleBytes: Uint8Array }> { + options: { + readonly kaNumber?: bigint; + readonly assertionCoordinate?: string; + } = {}, +): Promise<{ row: AuthorCatalogRowV1; bundleBytes: Uint8Array; kaUal: string }> { + const kaNumber = options.kaNumber ?? KA_NUMBER; + const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); + const kaUal = `did:dkg:${NETWORK_ID}/${AUTHOR}/${kaNumber}`; const typedData = buildAuthorAttestationTypedData({ chainId: BigInt(DEPLOYMENT.assertedAtChainId), kav10Address: DEPLOYMENT.assertedAtKav10Address, merkleRoot: ethers.getBytes(ASSERTION_ROOT), authorAddress: AUTHOR, - reservedKaId: BigInt(KA_ID), + reservedKaId: BigInt(kaId), }); const authorSignature = ethers.Signature.from(await signingWallet.signTypedData( typedData.domain, @@ -1176,10 +1352,10 @@ async function buildRowBundle( authorSchemeVersion: '1', assertedAtChainId: '20430', assertedAtKav10Address: KAV10, - reservedKaId: KA_ID, + reservedKaId: kaId, assertionFinalizedAt: '2026-07-19T12:34:56.789Z', contentScopeVersion: '2', - kaUal: UAL, + kaUal, assertionVersion: '1', publicTripleCount: '2', privateTripleCount: '0', @@ -1192,8 +1368,8 @@ async function buildRowBundle( ); const byteLength = BigInt(encoded.bundleBytes.byteLength); const row = { - kaId: KA_ID, - assertionCoordinate: 'gate-1-object', + kaId, + assertionCoordinate: options.assertionCoordinate ?? 'gate-1-object', assertionVersion: '1', projectionId: 'cg-shared-v1', projectionDigest: encoded.projectionDigest, @@ -1210,5 +1386,5 @@ async function buildRowBundle( }, } as unknown as AuthorCatalogRowV1; assertAuthorCatalogRowV1(row); - return { row, bundleBytes: encoded.bundleBytes }; + return { row, bundleBytes: encoded.bundleBytes, kaUal }; } diff --git a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts index 265a65d15a..b86134ab1c 100644 --- a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts @@ -12,6 +12,7 @@ import { createRfc64PublicOpenCatalogNativeReconcilerV1, deriveRfc64PublicOpenCatalogScopeV1, type Rfc64PublicOpenCatalogNativeReceiverClientV1, + type Rfc64PublicOpenCatalogStagedHeadV1, } from '../src/rfc64/public-catalog-native-reconciler-v1.js'; import { Rfc64PublicCatalogNativeReceiverErrorV1, @@ -97,6 +98,38 @@ function receiver( return { synchronizePublicOpenCatalog }; } +function stagedHead( + input: Rfc64PublicCatalogHeadAnnouncementV1, + totalRows: string, + overrides: Partial = {}, +): Rfc64PublicOpenCatalogStagedHeadV1 { + return { + envelope: { + objectDigest: input.catalogHeadObjectDigest, + payload: { + networkId: input.networkId, + contextGraphId: input.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: input.authorAddress, + catalogIssuerDelegationDigest: `0x${'12'.repeat(32)}`, + era: input.catalogEra, + version: input.catalogVersion, + previousHeadDigest: input.catalogVersion === '0' ? null : `0x${'13'.repeat(32)}`, + bucketCount: '1', + totalRows, + directoryHeight: '0', + directoryRootDigest: `0x${'14'.repeat(32)}`, + issuedAt: '1773900000000', + }, + } as never, + signatureVariantDigest: input.signatureVariantDigest, + ...overrides, + }; +} + describe('RFC-64 public/open native reconciler v1', () => { it('maps both genesis and successor evidence to applied and passes deployment plus cancellation', async () => { const synchronize = vi.fn(async () => ({ inventoryRowCount: 0 } as never)); @@ -175,6 +208,43 @@ describe('RFC-64 public/open native reconciler v1', () => { }), ACCEPTED_POLICY)).toThrow('accepted null-governance owner policy'); }); + it('derives a multi-row dedupe count only from the exact staged head variant', async () => { + const successor = announcement('1'); + const readStagedCatalogHead = vi.fn(async () => stagedHead(successor, '2')); + const readAppliedCatalogHeadV1 = vi.fn(() => snapshot(successor, { + inventoryRowCount: '2', + })); + const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + nativeReceiver: receiver(vi.fn()), + inventory: { readAppliedCatalogHeadV1 }, + resolveTrustedCatalogScope, + resolveDeployment: async () => DEPLOYMENT, + readStagedCatalogHead, + }); + + await expect(reconciler.isHeadApplied(successor)).resolves.toBe(true); + expect(readStagedCatalogHead).toHaveBeenCalledWith(successor); + + readStagedCatalogHead.mockResolvedValueOnce(stagedHead(successor, '3')); + await expect(reconciler.isHeadApplied(successor)).resolves.toBe(false); + + readStagedCatalogHead.mockResolvedValueOnce(stagedHead(successor, '2', { + signatureVariantDigest: `0x${'ab'.repeat(32)}` as Digest32V1, + })); + await expect(reconciler.isHeadApplied(successor)).resolves.toBe(false); + + readStagedCatalogHead.mockResolvedValueOnce(null); + await expect(reconciler.isHeadApplied(successor)).resolves.toBe(false); + + const legacyOnly = createRfc64PublicOpenCatalogNativeReconcilerV1({ + nativeReceiver: receiver(vi.fn()), + inventory: { readAppliedCatalogHeadV1 }, + resolveTrustedCatalogScope, + resolveDeployment: async () => DEPLOYMENT, + }); + await expect(legacyOnly.isHeadApplied(successor)).resolves.toBe(false); + }); + it('maps only the explicit native not-found error and propagates all other failures', async () => { const notFound = new Rfc64PublicCatalogNativeReceiverErrorV1( 'catalog-native-receiver-not-found', From bffd3d5437200ca3b19c51a3348d14a49fc07b12 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:36:59 +0200 Subject: [PATCH 145/292] feat(agent): bind multi-row dedupe to staged head --- packages/agent/src/dkg-agent-rfc64-catalog.ts | 25 +++ ...kg-agent-native-wiring.integration.test.ts | 147 +++++++++++++++++- 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 79b8323d71..35364b283c 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -669,6 +669,31 @@ export class Rfc64CatalogMethods extends DKGAgentBase { inventory: persistence.inventory, resolveTrustedCatalogScope: clients.resolveTrustedCatalogScope, resolveDeployment, + readStagedCatalogHead: async (announcement) => { + const stored = await persistence.controlObjects.getVerifiedObject({ + objectDigest: announcement.catalogHeadObjectDigest, + signatureVariantDigest: announcement.signatureVariantDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (stored === null) return null; + assertSignedAuthorCatalogHeadEnvelopeV1(stored.envelope); + const signatureVariantDigest = computeControlSignatureVariantDigestHex( + stored.envelope.objectDigest, + stored.envelope.signature, + ) as Digest32V1; + if ( + stored.envelope.objectDigest !== announcement.catalogHeadObjectDigest + || signatureVariantDigest !== announcement.signatureVariantDigest + ) { + throw new Error( + 'RFC-64 control-object store returned a different staged head identity', + ); + } + return Object.freeze({ + envelope: stored.envelope, + signatureVariantDigest, + }); + }, }); const deploymentAwareReconciler: Rfc64PublicCatalogReceiverReconcilerV1 = { isHeadApplied: (announcement) => { diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 2ed37c0112..ae07d45f4d 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -4,9 +4,14 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { + assertCanonicalGraphScopedAuthorSealV1, + buildAuthorAttestationTypedData, computeAuthorCatalogScopeDigestV1, + type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, type ContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, type NetworkIdV1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; @@ -24,10 +29,21 @@ const CONTEXT_GRAPH_ID = const FIXED_HEAD_ISSUED_AT = '1773900000000' as TimestampMsV1; const DELEGATION_EFFECTIVE_AT = '1773899999999' as TimestampMsV1; const DELEGATION_EXPIRES_AT = '1773900000001' as TimestampMsV1; +const MULTI_DELEGATION_EXPIRES_AT = '1774000000000' as TimestampMsV1; +const SUCCESSOR_ISSUED_AT = '1773900001000' as TimestampMsV1; +const SECOND_SUCCESSOR_ISSUED_AT = '1773900002000' as TimestampMsV1; +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const ASSERTION_ROOT = + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f' as Digest32V1; +const PROJECTION = new TextEncoder().encode( + ' "42"^^ .\n' + + ' "Alice" .\n', +); const NATIVE_DEPLOYMENT = Object.freeze({ networkId: NETWORK_ID, assertedAtChainId: '20430', - assertedAtKav10Address: '0x4444444444444444444444444444444444444444', + assertedAtKav10Address: KAV10, }) as CatalogSealDeploymentProfileV1; const agents: DKGAgent[] = []; @@ -186,6 +202,99 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { )).toBeNull(); }, 60_000); + it('reads the exact staged head variant to dedupe a durably applied multi-row set', async () => { + const [author, receiver] = await Promise.all([ + startNativeAgent('multi-author'), + startNativeAgent('multi-receiver'), + ]); + receiver.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + await connectBothWays(author, receiver); + + const genesis = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [receiver.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: MULTI_DELEGATION_EXPIRES_AT, + }); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + + const firstAsset = { + assertionCoordinate: 'gate-2-object-1' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(7n), + }; + const firstSuccessor = await author.publishOpenAuthorCatalogSuccessorV1({ + previousHead: { + objectDigest: genesis.headObjectDigest, + signatureVariantDigest: genesis.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + ...firstAsset, + deployment: NATIVE_DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [receiver.peerId], + }); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + + const successor = await author.publishOpenAuthorCatalogExactSetSuccessorV1({ + previousHead: { + objectDigest: firstSuccessor.headObjectDigest, + signatureVariantDigest: firstSuccessor.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + assets: [ + { + assertionCoordinate: 'gate-2-object-2' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(8n), + }, + firstAsset, + ], + deployment: NATIVE_DEPLOYMENT, + issuedAt: SECOND_SUCCESSOR_ISSUED_AT, + peers: [receiver.peerId], + }); + expect(successor.inventoryRowCount).toBe('2'); + expect(successor.announcedPeers).toEqual([receiver.peerId]); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + + const evidence = receiver.readRfc64PublicCatalogSynchronizationEvidenceV1( + successor.headObjectDigest, + ); + expect(evidence).toMatchObject({ + catalogHeadDigest: successor.headObjectDigest, + inventoryRowCount: 2, + appliedHeadStatus: 'applied', + }); + expect(evidence?.rows.map(({ kaId, bundleDigest }) => ({ kaId, bundleDigest }))).toEqual( + successor.assets.map(({ kaId, bundleDigest }) => ({ kaId, bundleDigest })), + ); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + applied: 3, + dedupedAlreadyApplied: 0, + }); + + const replay = await author.announceRfc64PublicCatalogHeadV1({ + announcement: successor.announcement, + peers: [receiver.peerId], + }); + expect(replay.announcedPeers).toEqual([receiver.peerId]); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + applied: 3, + dedupedAlreadyApplied: 1, + }); + }, 60_000); + it('exposes bounded typed failure evidence when wire scope differs from local deployment', async () => { const [author, receiver] = await Promise.all([ startNativeAgent('mismatch-author'), @@ -302,3 +411,39 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); }); + +async function authorSeal(kaNumber: bigint): Promise { + const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); + const kaUal = `did:dkg:${NETWORK_ID}/${AUTHOR}/${kaNumber}`; + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(NATIVE_DEPLOYMENT.assertedAtChainId), + kav10Address: NATIVE_DEPLOYMENT.assertedAtKav10Address, + merkleRoot: ethers.getBytes(ASSERTION_ROOT), + authorAddress: AUTHOR, + reservedKaId: BigInt(kaId), + }); + const signature = ethers.Signature.from(await AUTHOR_WALLET.signTypedData( + typedData.domain, + typedData.types, + typedData.message, + )); + const seal = { + assertionMerkleRoot: ASSERTION_ROOT, + authorAddress: AUTHOR, + authorAttestationR: signature.r, + authorAttestationVS: signature.yParityAndS, + authorSchemeVersion: '1', + assertedAtChainId: NATIVE_DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10, + reservedKaId: kaId, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal, + assertionVersion: '1', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(seal); + return seal; +} From a161b74b8d66f7c6105edd619dbc84a7f5daaca7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:58:20 +0200 Subject: [PATCH 146/292] feat(agent): remove omitted RFC-64 catalog assets --- .../public-catalog-native-receiver-v1.ts | 268 +++++++++++++++++- 1 file changed, 264 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index e1527c3cc7..82be0debcb 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -27,12 +27,14 @@ import { assertNetworkIdV1, assertSignedAuthorCatalogBucketEnvelopeV1, assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, + assertSignedAuthorCatalogHeadEnvelopeV1, assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, buildAuthorAttestationTypedData, canonicalizeAuthorCatalogBucketPayloadBytesV1, computeAuthorCatalogScopeDigestV1, computeControlSignatureVariantDigestHex, contextGraphWorkspaceGraphUri, + deriveCanonicalGraphScopedAuthorSealPlacementV1, deriveAuthorCatalogScopeFromHeadV1, readVerifiedCatalogSealBindingV1, readVerifiedAuthorCatalogBucketDescriptorV1, @@ -46,13 +48,16 @@ import { type AuthorCatalogScopeV1, type CatalogSealDeploymentProfileV1, type CountV1, + type ContextGraphIdV1, type Digest32V1, + type KaIdV1, type SignedAuthorCatalogBucketEnvelopeV1, type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, type SignedAuthorCatalogIssuerDelegationEnvelopeV1, type VerifiedCatalogSealBindingSnapshotV1, } from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; import { sha256 } from '@noble/hashes/sha2.js'; import { quadsToNQuads, @@ -103,7 +108,10 @@ export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { Rfc64PublicCatalogNativeTransportV1, 'fetchCatalogObject' | 'fetchKaBundle' >; - readonly controlObjects: Pick; + readonly controlObjects: Pick< + Rfc64ControlObjectOperationsV1, + 'stageVerifiedObjects' | 'getVerifiedObjectByDigest' + >; readonly inventory: Pick< Rfc64InventoryV1OperationsV1, 'readAppliedCatalogHeadV1' | 'compareAndSwapAppliedCatalogHeadV1' @@ -125,9 +133,19 @@ export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { readonly swmGraph: string; /** Exact signed delegation/head/path/bucket/row authorization closure. */ readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + /** Exact predecessor rows absent from this head and physically deactivated before its CAS. */ + readonly removedRows: readonly Readonly[]; + readonly removedRowCount: number; readonly appliedHeadStatus: 'applied' | 'existing'; } +export interface Rfc64PublicCatalogNativeRemovedRowEvidenceV1 { + readonly kaId: KaIdV1; + readonly swmGraph: string; + readonly sealMetaGraph: string; + readonly sealSubject: string; +} + export interface Rfc64PublicCatalogNativeActivatedRowEvidenceV1 extends Rfc64PublicCatalogInventoryEvidenceRowV1 { readonly swmGraph: string; @@ -143,6 +161,9 @@ export interface Rfc64PublicCatalogNativeMultiAssetActivationEvidenceV1 { readonly activatedTripleCount: number; /** Strictly increasing by mathematical KA ID. */ readonly rows: readonly Readonly[]; + /** Exact predecessor rows absent from this head and physically deactivated before its CAS. */ + readonly removedRows: readonly Readonly[]; + readonly removedRowCount: number; readonly appliedHeadStatus: 'applied' | 'existing'; } @@ -196,6 +217,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { || typeof options?.contentTransport?.fetchCatalogObject !== 'function' || typeof options?.contentTransport?.fetchKaBundle !== 'function' || typeof options.controlObjects?.stageVerifiedObjects !== 'function' + || typeof options.controlObjects?.getVerifiedObjectByDigest !== 'function' || typeof options.inventory?.readAppliedCatalogHeadV1 !== 'function' || typeof options.inventory?.compareAndSwapAppliedCatalogHeadV1 !== 'function' || typeof options.store?.query !== 'function' @@ -702,6 +724,21 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ); } + // The durable applied-head points at the only catalog closure allowed to + // own materialization removals. Reconstruct and re-authorize that exact + // predecessor after every target row/bundle has verified, but before the + // first semantic mutation. This also makes exact-head replay a repair path + // for a prior indeterminate removal failure. + const predecessorRows = await loadExactAppliedPredecessorRows( + this.options.controlObjects, + head, + trustedCatalogScope, + ); + const targetKaIds = new Set(preparedRows.map(({ row }) => row.kaId)); + const plannedRemovals = predecessorRows + .filter((row) => !targetKaIds.has(row.kaId)) + .map((row) => planOwnedRowRemoval(trustedCatalogScope, row)); + try { await this.options.controlObjects.stageVerifiedObjects([ fetchedDelegation, @@ -713,6 +750,13 @@ export class Rfc64PublicCatalogNativeReceiverV1 { fail('catalog-native-receiver-catalog', 'verified catalog objects could not be staged', cause); } + const removedRows: Rfc64PublicCatalogNativeRemovedRowEvidenceV1[] = []; + for (const removal of plannedRemovals) { + throwIfAborted(signal); + await deactivateExactOwnedPublicProjection(this.options.store, removal); + removedRows.push(removal); + } + const activatedRows: Rfc64PublicCatalogNativeActivatedRowEvidenceV1[] = []; for (const prepared of preparedRows) { throwIfAborted(signal); @@ -837,6 +881,8 @@ export class Rfc64PublicCatalogNativeReceiverV1 { activatedTripleCount: only.activatedTripleCount, swmGraph: only.swmGraph, authorship: only.authorship, + removedRows: Object.freeze(removedRows), + removedRowCount: removedRows.length, appliedHeadStatus, }); } @@ -846,6 +892,8 @@ export class Rfc64PublicCatalogNativeReceiverV1 { inventoryRowCount: activatedRows.length, activatedTripleCount, rows: Object.freeze(activatedRows), + removedRows: Object.freeze(removedRows), + removedRowCount: removedRows.length, appliedHeadStatus, }); } @@ -1206,6 +1254,220 @@ function assertDirectAuthorCatalogIssuerDelegationBindingV1( } } +async function loadExactAppliedPredecessorRows( + controlObjects: Pick, + targetHead: SignedAuthorCatalogHeadEnvelopeV1, + trustedCatalogScope: Readonly, +): Promise[]> { + const predecessorDigest = targetHead.payload.previousHeadDigest; + if (predecessorDigest === null) { + fail('catalog-native-receiver-history', 'successor has no predecessor digest'); + } + try { + const storedHead = await controlObjects.getVerifiedObjectByDigest({ + objectDigest: predecessorDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedHead === null) throw new Error('applied predecessor head is not staged'); + assertSignedAuthorCatalogHeadEnvelopeV1(storedHead.envelope); + const predecessorHead = storedHead.envelope; + if ( + predecessorHead.objectDigest !== predecessorDigest + || predecessorHead.payload.version === targetHead.payload.version + || BigInt(predecessorHead.payload.version) + 1n !== BigInt(targetHead.payload.version) + || BigInt(predecessorHead.payload.totalRows) > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) + ) { + throw new Error('predecessor identity, version, or row bound differs from target history'); + } + assertAuthorCatalogHeadScopeBindingV1(predecessorHead.payload, trustedCatalogScope); + + const storedDelegation = await controlObjects.getVerifiedObjectByDigest({ + objectDigest: predecessorHead.payload.catalogIssuerDelegationDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedDelegation === null) throw new Error('predecessor delegation is not staged'); + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(storedDelegation.envelope); + assertDirectAuthorCatalogIssuerDelegationBindingV1( + storedDelegation.envelope, + predecessorHead, + trustedCatalogScope, + ); + + const storedDirectory = await controlObjects.getVerifiedObjectByDigest({ + objectDigest: predecessorHead.payload.directoryRootDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedDirectory === null) throw new Error('predecessor directory root is not staged'); + assertSignedAuthorCatalogDirectoryNodeEnvelopeV1( + storedDirectory.envelope, + predecessorHead.payload.bucketCount, + ); + const directory = storedDirectory.envelope; + assertAuthorCatalogDirectoryNodeScopeBindingV1( + directory.payload, + deriveAuthorCatalogScopeFromHeadV1(predecessorHead.payload), + ); + if ( + directory.objectDigest !== predecessorHead.payload.directoryRootDigest + || directory.issuer !== predecessorHead.issuer + ) { + throw new Error('predecessor directory identity or issuer differs from its head'); + } + const directoryPathProof = verifyAuthorCatalogDirectoryPathV1( + predecessorHead, + [directory], + '0' as never, + ); + const descriptor = readVerifiedAuthorCatalogBucketDescriptorV1( + directoryPathProof, + predecessorHead, + ); + if (descriptor.rowCount !== predecessorHead.payload.totalRows) { + throw new Error('predecessor directory row count differs from its head'); + } + if (predecessorHead.payload.totalRows === '0') { + if ( + descriptor.bucketDigest !== ZERO_DIGEST32_V1 + || descriptor.byteLength !== '0' + || descriptor.rowCount !== '0' + ) { + throw new Error('empty predecessor descriptor is not canonical'); + } + return Object.freeze([]); + } + if (descriptor.bucketDigest === ZERO_DIGEST32_V1) { + throw new Error('non-empty predecessor has an empty bucket digest'); + } + + const storedBucket = await controlObjects.getVerifiedObjectByDigest({ + objectDigest: descriptor.bucketDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedBucket === null) throw new Error('predecessor bucket is not staged'); + assertSignedAuthorCatalogBucketEnvelopeV1(storedBucket.envelope); + const bucket = storedBucket.envelope; + assertAuthorCatalogBucketScopeBindingV1( + bucket.payload, + deriveAuthorCatalogScopeFromHeadV1(predecessorHead.payload), + ); + if ( + bucket.objectDigest !== descriptor.bucketDigest + || bucket.issuer !== predecessorHead.issuer + || bucket.payload.bucketId !== descriptor.bucketId + || bucket.payload.rows.length.toString() !== descriptor.rowCount + || canonicalizeAuthorCatalogBucketPayloadBytesV1(bucket.payload).byteLength.toString() + !== descriptor.byteLength + ) { + throw new Error('predecessor bucket differs from its verified descriptor'); + } + + for (const row of bucket.payload.rows) { + verifyAuthorCatalogRowAuthorshipV1({ + catalogIssuerDelegation: storedDelegation.envelope, + catalogIssuerDelegationSignature: storedDelegation.issuerSignature, + parentAuthorAgentEvidence: null, + catalogHead: predecessorHead, + catalogHeadSignature: storedHead.issuerSignature, + directoryPathEnvelopes: [directory], + directoryPathSignatures: [storedDirectory.issuerSignature], + directoryPathProof, + catalogBucket: bucket, + catalogBucketSignature: storedBucket.issuerSignature, + targetKaId: row.kaId, + }); + } + return Object.freeze(bucket.payload.rows.map((row) => Object.freeze({ ...row }))); + } catch (cause) { + fail( + 'catalog-native-receiver-history', + 'exact applied predecessor catalog closure could not authorize removals', + cause, + ); + } +} + +function planOwnedRowRemoval( + trustedCatalogScope: Readonly, + row: Readonly, +): Readonly { + const placement = deriveCanonicalGraphScopedAuthorSealPlacementV1({ + contextGraphId: trustedCatalogScope.contextGraphId, + subGraphName: trustedCatalogScope.subGraphName, + authorAddress: trustedCatalogScope.authorAddress, + assertionCoordinate: row.assertionCoordinate, + }); + return Object.freeze({ + kaId: row.kaId, + swmGraph: derivePublicSwmGraph(trustedCatalogScope.contextGraphId, row.kaId), + sealMetaGraph: placement.metaGraph, + sealSubject: placement.subject, + }); +} + +async function deactivateExactOwnedPublicProjection( + store: TripleStore, + removal: Readonly, +): Promise { + let replaced: boolean; + try { + replaced = await tryReplaceGraphAndSubjectAtomically( + store, + removal.swmGraph, + [], + removal.sealMetaGraph, + removal.sealSubject, + [], + { source: 'rfc64-public-catalog-native-deactivation' }, + ); + } catch (cause) { + fail( + 'catalog-native-receiver-activation', + `atomic SWM projection and author-seal removal failed for KA ${removal.kaId}`, + cause, + ); + } + if (!replaced) { + fail( + 'catalog-native-receiver-activation', + 'store lacks atomic named-graph and author-seal replacement for catalog removal', + ); + } + + let graphExists: boolean; + let sealRows; + try { + graphExists = await store.hasGraph(removal.swmGraph, { + source: 'rfc64-public-catalog-native-removal-post-read', + }); + sealRows = await store.query( + `SELECT ?p ?o WHERE { GRAPH <${removal.sealMetaGraph}> { ` + + `<${removal.sealSubject}> ?p ?o } } LIMIT 1`, + { + source: 'rfc64-public-catalog-native-removal-post-read', + maxResponseBytes: 4 * 1024, + }, + ); + } catch (cause) { + fail('catalog-native-receiver-activation', 'removed-row exact post-read failed', cause); + } + if ( + graphExists + || sealRows.type !== 'bindings' + || sealRows.bindings.length !== 0 + ) { + fail( + 'catalog-native-receiver-activation', + `removed KA ${removal.kaId} projection or author seal remains present`, + ); + } +} + +function derivePublicSwmGraph(contextGraphId: ContextGraphIdV1, kaId: KaIdV1): string { + const identity = unpackKnowledgeAssetId(BigInt(kaId)); + return `${contextGraphWorkspaceGraphUri(contextGraphId)}` + + `/${identity.agentAddress}/${identity.kaNumber.toString()}`; +} + async function activateExactPublicProjection( store: TripleStore, head: SignedAuthorCatalogHeadEnvelopeV1, @@ -1234,9 +1496,7 @@ async function activateExactPublicProjection( ) { fail('catalog-native-receiver-activation', 'projection/parser triple count or graph scope changed'); } - const identity = unpackKnowledgeAssetId(BigInt(row.kaId)); - const swmGraph = `${contextGraphWorkspaceGraphUri(head.payload.contextGraphId)}` - + `/${identity.agentAddress}/${identity.kaNumber.toString()}`; + const swmGraph = derivePublicSwmGraph(head.payload.contextGraphId, row.kaId); const graphQuads = quads.map((quad) => ({ ...quad, graph: swmGraph })); let replaced: boolean; try { From f980785ce8b69b59cbc05b2e461c38064ac84c15 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:58:30 +0200 Subject: [PATCH 147/292] test(agent): prove exact RFC-64 stale removal --- ...c-catalog-native-gate1.integration.test.ts | 230 +++++++++++++++++- 1 file changed, 225 insertions(+), 5 deletions(-) diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index d875b2e5db..9622aebe27 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -18,6 +18,7 @@ import { computeCanonicalGraphScopedAuthorSealDigestV1, computeControlObjectDigestHex, computeKaChunkTreeRootV1, + deriveCanonicalGraphScopedAuthorSealPlacementV1, deriveAuthorCatalogScopeFromHeadV1, encodeOpaqueKaBundleV1, type AuthorCatalogRowV1, @@ -36,7 +37,7 @@ import { type UnsignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { OxigraphStore, type TripleStore } from '@origintrail-official/dkg-storage'; import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -78,6 +79,7 @@ const KA_NUMBER = 7n; const KA_ID = ((BigInt(AUTHOR) << 96n) | KA_NUMBER).toString(); const UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; const SECOND_KA_NUMBER = 8n; +const SECOND_KA_ID = ((BigInt(AUTHOR) << 96n) | SECOND_KA_NUMBER).toString(); const SECOND_UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${SECOND_KA_NUMBER}`; const PROJECTION = ' "42"^^ .\n' @@ -322,6 +324,196 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { await expect(fixture.receiverStore.countQuads()).resolves.toBe(32); }, 30_000); + it('converges a valid two-to-one successor by removing the omitted SWM projection and seal before CAS', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + await fixture.synchronizeAny(fixture.multiAssetAnnouncement); + const observed = fixture.createCasObservedReceiver(); + + const evidence = await fixture.synchronizeAny( + fixture.removalAnnouncement, + observed.receiver, + ); + if (!('catalogRowDigest' in evidence)) throw new Error('one-row removal returned non-one-row evidence'); + const removedSwmGraph = + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${SECOND_KA_NUMBER}`; + const removedSeal = deriveCanonicalGraphScopedAuthorSealPlacementV1({ + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + assertionCoordinate: 'gate-2-object' as never, + }); + expect(evidence).toMatchObject({ + catalogHeadDigest: fixture.removalSuccessor.head.objectDigest, + inventoryRowCount: 1, + removedRowCount: 1, + removedRows: [{ + kaId: SECOND_KA_ID, + swmGraph: removedSwmGraph, + sealMetaGraph: removedSeal.metaGraph, + sealSubject: removedSeal.subject, + }], + kaUal: UAL, + appliedHeadStatus: 'applied', + }); + expect(Object.isFrozen(evidence.removedRows)).toBe(true); + await expect(fixture.receiverStore.hasGraph(removedSwmGraph)).resolves.toBe(false); + await expect(fixture.receiverStore.hasGraph( + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${KA_NUMBER}`, + )).resolves.toBe(true); + const removedSealRows = await fixture.receiverStore.query( + `SELECT ?p ?o WHERE { GRAPH <${removedSeal.metaGraph}> { ` + + `<${removedSeal.subject}> ?p ?o } }`, + ); + expect(removedSealRows).toEqual({ type: 'bindings', bindings: [] }); + expect(observed.compareAndSwapAppliedCatalogHeadV1).toHaveBeenCalledOnce(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toMatchObject({ + currentCatalogHeadDigest: fixture.removalSuccessor.head.objectDigest, + inventoryRowCount: '1', + }); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + }, 30_000); + + it('verifies a removal target completely before an omitted predecessor row can be mutated', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + await fixture.synchronizeAny(fixture.multiAssetAnnouncement); + const omittedSwmGraph = + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${SECOND_KA_NUMBER}`; + const fetchKaBundle: Rfc64PublicCatalogNativeTransportV1['fetchKaBundle'] = + async (...args) => { + const bundle = await fixture.receiverBundleFetch(...args); + if (bundle === null) return null; + const forged = bundle.slice(); + forged[forged.length - 1] ^= 0x01; + return forged; + }; + const observed = fixture.createCasObservedReceiver({ + fetchCatalogObject: fixture.receiverObjectFetch, + fetchKaBundle, + }); + + await expect(fixture.synchronizeAny( + fixture.removalAnnouncement, + observed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-transfer' }); + expect(observed.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + await expect(fixture.receiverStore.hasGraph(omittedSwmGraph)).resolves.toBe(true); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(32); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.multiAssetSuccessor.head.objectDigest); + }, 30_000); + + it('never removes projection or seal state owned by another catalog author scope', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + await fixture.synchronizeAny(fixture.multiAssetAnnouncement); + const foreignAuthor = '0x9999999999999999999999999999999999999999' as EvmAddressV1; + const foreignGraph = + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${foreignAuthor}/${SECOND_KA_NUMBER}`; + const foreignSeal = deriveCanonicalGraphScopedAuthorSealPlacementV1({ + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: foreignAuthor, + assertionCoordinate: 'foreign-object' as never, + }); + await fixture.receiverStore.insert([ + { + subject: 'https://example.org/foreign', + predicate: 'https://schema.org/name', + object: '"Foreign"', + graph: foreignGraph, + }, + { + subject: foreignSeal.subject, + predicate: 'https://example.org/foreignSeal', + object: '"owned elsewhere"', + graph: foreignSeal.metaGraph, + }, + ]); + + await fixture.synchronizeAny(fixture.removalAnnouncement); + + await expect(fixture.receiverStore.hasGraph(foreignGraph)).resolves.toBe(true); + const foreignSealRows = await fixture.receiverStore.query( + `SELECT ?p ?o WHERE { GRAPH <${foreignSeal.metaGraph}> { ` + + `<${foreignSeal.subject}> ?p ?o } }`, + ); + expect(foreignSealRows).toMatchObject({ + type: 'bindings', + bindings: [{ + p: 'https://example.org/foreignSeal', + o: '"owned elsewhere"', + }], + }); + }, 30_000); + + it('withholds the head CAS after an indeterminate removal and converges on a new receiver instance', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + await fixture.synchronizeAny(fixture.multiAssetAnnouncement); + const omittedSwmGraph = + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${SECOND_KA_NUMBER}`; + let injectAfterCommittedRemoval = true; + const faultStore = new Proxy(fixture.receiverStore, { + get(target, property) { + if (property === 'replaceGraphAndSubject') { + return async (...args: Parameters>) => { + await target.replaceGraphAndSubject!(...args); + if (args[1].length === 0 && injectAfterCommittedRemoval) { + injectAfterCommittedRemoval = false; + throw new Error('injected indeterminate post-commit removal failure'); + } + }; + } + const value = Reflect.get(target, property, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as TripleStore; + const failed = fixture.createCasObservedReceiver(undefined, faultStore); + + await expect(fixture.synchronizeAny( + fixture.removalAnnouncement, + failed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-activation' }); + expect(failed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.multiAssetSuccessor.head.objectDigest); + await expect(fixture.receiverStore.hasGraph(omittedSwmGraph)).resolves.toBe(false); + + const restartedReceiver = fixture.createReceiver(fixture.receiverPersistence.inventory); + const repaired = await fixture.synchronizeAny( + fixture.removalAnnouncement, + restartedReceiver, + ); + expect(repaired).toMatchObject({ + catalogHeadDigest: fixture.removalSuccessor.head.objectDigest, + removedRowCount: 1, + appliedHeadStatus: 'applied', + }); + await expect(fixture.receiverStore.hasGraph(omittedSwmGraph)).resolves.toBe(false); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toMatchObject({ + currentCatalogHeadDigest: fixture.removalSuccessor.head.objectDigest, + inventoryRowCount: '1', + }); + }, 30_000); + it('verifies all multi-asset bundles before staging, SWM mutation, or applied-head CAS', async () => { const fixture = await setupLiveReceiver(); await fixture.bootstrap(); @@ -842,6 +1034,15 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900001002' as never, signer, }); + const removalSuccessor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: multiAssetSuccessor.head, + previousDirectoryPath: multiAssetSuccessor.directoryPath, + previousBucket: multiAssetSuccessor.bucket, + selectedBucketId: '0' as never, + nextRows: [rowBundle.row], + issuedAt: '1773900001003' as never, + signer, + }); const governedSuccessor = await produceSparseAuthorCatalogSuccessorV1({ previousHead: governedGenesis.head, previousDirectoryPath: governedGenesis.directoryPath, @@ -887,6 +1088,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ...invalidGenesis.stagedObjects, ...successor.stagedObjects, ...multiAssetSuccessor.stagedObjects, + ...removalSuccessor.stagedObjects, ...governedSuccessor.stagedObjects, ...competingSuccessor.stagedObjects, crossLaneHead, @@ -914,6 +1116,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ...invalidGenesis.stagedObjects, ...successor.stagedObjects, ...multiAssetSuccessor.stagedObjects, + ...removalSuccessor.stagedObjects, ...governedSuccessor.stagedObjects, ...competingSuccessor.stagedObjects, crossLaneHead, @@ -945,6 +1148,9 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const multiAssetHeadKeys = staged.objects.find( (keys) => keys.objectDigest === multiAssetSuccessor.head.objectDigest, ); + const removalHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === removalSuccessor.head.objectDigest, + ); const governedGenesisHeadKeys = staged.objects.find( (keys) => keys.objectDigest === governedGenesis.head.objectDigest, ); @@ -969,6 +1175,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { if (headKeys === undefined) throw new Error('successor head was not staged'); if (genesisHeadKeys === undefined) throw new Error('genesis head was not staged'); if (multiAssetHeadKeys === undefined) throw new Error('multi-asset successor head was not staged'); + if (removalHeadKeys === undefined) throw new Error('removal successor head was not staged'); if (governedGenesisHeadKeys === undefined) throw new Error('governed genesis head was not staged'); if (governedSuccessorHeadKeys === undefined) throw new Error('governed successor head was not staged'); if (invalidGenesisHeadKeys === undefined) throw new Error('invalid genesis head was not staged'); @@ -1049,6 +1256,12 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { catalogHeadObjectDigest: multiAssetHeadKeys.objectDigest, signatureVariantDigest: multiAssetHeadKeys.signatureVariantDigest, }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const removalAnnouncement = Object.freeze({ + ...announcement, + catalogVersion: removalSuccessor.head.payload.version, + catalogHeadObjectDigest: removalHeadKeys.objectDigest, + signatureVariantDigest: removalHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; const competingAnnouncement = Object.freeze({ ...announcement, catalogHeadObjectDigest: competingHeadKeys.objectDigest, @@ -1103,7 +1316,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { >, controlObjects: Pick< Rfc64PersistenceV1['controlObjects'], - 'stageVerifiedObjects' + 'stageVerifiedObjects' | 'getVerifiedObjectByDigest' > = receiverPersistence.controlObjects, contentTransport: Pick< Rfc64PublicCatalogNativeTransportV1, @@ -1112,17 +1325,18 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { fetchCatalogObject: receiverObjectFetch, fetchKaBundle: receiverBundleFetch, }, + store: TripleStore = receiverStore, ) => new Rfc64PublicCatalogNativeReceiverV1({ headTransport: { fetchCatalogHead: receiverHeadFetch }, contentTransport, controlObjects, inventory, - store: receiverStore, + store, }); const createCasObservedReceiver = (contentTransport?: Pick< Rfc64PublicCatalogNativeTransportV1, 'fetchCatalogObject' | 'fetchKaBundle' - >) => { + >, store: TripleStore = receiverStore) => { const compareAndSwapAppliedCatalogHeadV1 = vi.fn( receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1.bind( receiverPersistence.inventory, @@ -1133,6 +1347,10 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { receiverPersistence.controlObjects, ), ); + const getVerifiedObjectByDigest = + receiverPersistence.controlObjects.getVerifiedObjectByDigest.bind( + receiverPersistence.controlObjects, + ); return Object.freeze({ compareAndSwapAppliedCatalogHeadV1, stageVerifiedObjects, @@ -1142,7 +1360,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { receiverPersistence.inventory, ), compareAndSwapAppliedCatalogHeadV1, - }, { stageVerifiedObjects }, contentTransport), + }, { stageVerifiedObjects, getVerifiedObjectByDigest }, contentTransport, store), }); }; const receiver = createReceiver(receiverPersistence.inventory); @@ -1170,6 +1388,8 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { missingDelegationAnnouncement, multiAssetAnnouncement, multiAssetSuccessor, + removalAnnouncement, + removalSuccessor, receiverDirectory: receiverOpened.directory, receiverHeadFetch, receiverObjectFetch, From 23c5a1688be723e8654aa85bea9c77cebb0e57b3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:05:31 +0200 Subject: [PATCH 148/292] fix(core): harden finalized VM set boundaries --- packages/core/src/finalized-vm-set-v1.ts | 84 +++++++------------ packages/core/src/sync-wire-objects.ts | 33 ++++++-- .../core/test/finalized-vm-set-v1.test.ts | 46 ++++++++-- 3 files changed, 93 insertions(+), 70 deletions(-) diff --git a/packages/core/src/finalized-vm-set-v1.ts b/packages/core/src/finalized-vm-set-v1.ts index cafff54a02..5651622c63 100644 --- a/packages/core/src/finalized-vm-set-v1.ts +++ b/packages/core/src/finalized-vm-set-v1.ts @@ -3,6 +3,7 @@ import { sha256 } from '@noble/hashes/sha2.js'; import { assertNetworkIdV1, type NetworkIdV1 } from './author-catalog-codec.js'; import { canonicalizeJsonBytes, type CanonicalJsonValue } from './canonical-json.js'; import { parseDeterministicKnowledgeAssetUal } from './ka-content-scope.js'; +import { snapshotExactDataRecord } from './sync-wire-objects.js'; import { MAX_DECIMAL_U64, assertCanonicalChainId, @@ -105,7 +106,7 @@ export const MAX_FINALIZED_VM_SET_UAL_BYTES_V1 = 209; /** Snapshot and validate the exact chain/contract lane before any row callbacks run. */ export function snapshotFinalizedVmLaneV1(input: FinalizedVmLaneV1): Readonly { - const record = snapshotExactDataRecord(input, FINALIZED_VM_LANE_KEYS, 'finalized VM lane'); + const record = snapshotRecord(input, FINALIZED_VM_LANE_KEYS, 'finalized VM lane'); try { assertCanonicalChainId(record.chainId, 'lane.chainId'); assertCanonicalEvmAddress(record.contractAddress, 'lane.contractAddress'); @@ -122,7 +123,7 @@ export function snapshotFinalizedVmLaneV1(input: FinalizedVmLaneV1): Readonly { - const record = snapshotExactDataRecord( + const record = snapshotRecord( input, FINALIZED_VM_SET_SCOPE_KEYS, 'finalized VM-set scope', @@ -142,19 +143,22 @@ export function snapshotFinalizedVmSetScopeV1( } /** - * Snapshot one row exactly once, rejecting accessors and non-canonical aliases. - * The returned object is safe to retain across hashing or I/O callbacks. + * Snapshot one row against the complete trusted scope, rejecting accessors, + * non-canonical aliases, UAL namespace drift, and cross-lane rows in one boundary. */ export function snapshotFinalizedVmSetRowV1( + scopeInput: FinalizedVmSetScopeV1, input: FinalizedVmSetRowV1, - expectedNetworkId: NetworkIdV1, ): Readonly { - try { - assertNetworkIdV1(expectedNetworkId, 'expectedNetworkId'); - } catch (cause) { - fail('finalized-vm-set-scalar', 'trusted network profile is not canonical', cause); - } - const record = snapshotExactDataRecord(input, FINALIZED_VM_SET_ROW_KEYS, 'finalized VM row'); + const scope = snapshotFinalizedVmSetScopeV1(scopeInput); + return snapshotFinalizedVmSetRowForScopeV1(scope, input); +} + +function snapshotFinalizedVmSetRowForScopeV1( + scope: Readonly, + input: FinalizedVmSetRowV1, +): Readonly { + const record = snapshotRecord(input, FINALIZED_VM_SET_ROW_KEYS, 'finalized VM row'); try { assertCanonicalChainId(record.chainId, 'row.chainId'); assertCanonicalEvmAddress(record.contractAddress, 'row.contractAddress'); @@ -190,7 +194,7 @@ export function snapshotFinalizedVmSetRowV1( if (parsed.agentAddress !== record.authorAddress) { fail('finalized-vm-set-ual', 'row.ual author differs from row.authorAddress'); } - if (parsed.chainId !== expectedNetworkId) { + if (parsed.chainId !== scope.networkId) { fail('finalized-vm-set-ual', 'row.ual namespace differs from the trusted network profile'); } } catch (cause) { @@ -198,6 +202,10 @@ export function snapshotFinalizedVmSetRowV1( fail('finalized-vm-set-ual', 'row.ual is not a canonical deterministic KA UAL', cause); } + if (record.chainId !== scope.chainId || record.contractAddress !== scope.contractAddress) { + fail('finalized-vm-set-lane', 'finalized VM row differs from the trusted scope lane'); + } + return Object.freeze({ chainId: record.chainId, contractAddress: record.contractAddress, @@ -218,13 +226,7 @@ export function computeFinalizedVmSetLeafDigestV1( row: FinalizedVmSetRowV1, ): Digest32V1 { const trustedScope = snapshotFinalizedVmSetScopeV1(scope); - const snapshot = snapshotFinalizedVmSetRowV1(row, trustedScope.networkId); - if ( - snapshot.chainId !== trustedScope.chainId - || snapshot.contractAddress !== trustedScope.contractAddress - ) { - fail('finalized-vm-set-lane', 'finalized VM row differs from the trusted scope lane'); - } + const snapshot = snapshotFinalizedVmSetRowForScopeV1(trustedScope, row); return digestBytesToLowerHex(computeFinalizedVmSetLeafDigestBytesV1(snapshot)); } @@ -266,13 +268,7 @@ export class FinalizedVmSetAccumulatorV1 { fail('finalized-vm-set-state', 'finalized VM row count exceeds the u64 range'); } - const row = snapshotFinalizedVmSetRowV1(rowInput, this.#scope.networkId); - if ( - row.chainId !== this.#scope.chainId - || row.contractAddress !== this.#scope.contractAddress - ) { - fail('finalized-vm-set-lane', 'finalized VM row differs from the accumulator lane'); - } + const row = snapshotFinalizedVmSetRowForScopeV1(this.#scope, rowInput); const ordinal = parseCanonicalDecimalU64(row.ordinal, 'row.ordinal'); if (this.#highestOrdinalValue !== null && ordinal <= this.#highestOrdinalValue) { fail('finalized-vm-set-order', 'finalized VM rows must have unique increasing ordinals'); @@ -353,38 +349,20 @@ function computeFinalizedVmSetLeafDigestBytesV1( ); } -function snapshotExactDataRecord( +function snapshotRecord( input: unknown, expectedKeys: Keys, label: string, ): Readonly> { - if (input === null || typeof input !== 'object' || Array.isArray(input)) { - fail('finalized-vm-set-schema', `${label} must be a plain data object`); - } - const prototype = Object.getPrototypeOf(input); - if (prototype !== Object.prototype && prototype !== null) { - fail('finalized-vm-set-schema', `${label} must be a plain data object`); - } - const keys = Reflect.ownKeys(input); - if (keys.some((key) => typeof key !== 'string')) { - fail('finalized-vm-set-schema', `${label} must not contain symbol fields`); - } - const strings = keys as string[]; - if ( - strings.length !== expectedKeys.length - || expectedKeys.some((key) => !strings.includes(key)) - ) { - fail('finalized-vm-set-schema', `${label} has unknown or missing fields`); - } - const snapshot: Record = Object.create(null); - for (const key of expectedKeys) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - fail('finalized-vm-set-schema', `${label}.${key} must be an enumerable data field`); - } - snapshot[key] = descriptor.value; + try { + return snapshotExactDataRecord(input, expectedKeys, label); + } catch (cause) { + fail( + 'finalized-vm-set-schema', + cause instanceof Error ? cause.message : `${label} is not an exact data record`, + cause, + ); } - return Object.freeze(snapshot) as Readonly>; } function digestBytes(domain: Uint8Array, ...chunks: readonly Uint8Array[]): Uint8Array { diff --git a/packages/core/src/sync-wire-objects.ts b/packages/core/src/sync-wire-objects.ts index 677abc0c1f..fbe0edf6d8 100644 --- a/packages/core/src/sync-wire-objects.ts +++ b/packages/core/src/sync-wire-objects.ts @@ -5,13 +5,17 @@ export function isPlainRecord(value: unknown): value is Record return prototype === Object.prototype || prototype === null; } -/** Require one plain record to contain exactly enumerable string data fields. */ -export function assertExactKeys( - record: Record, - expected: readonly string[], +/** Snapshot one closed plain record without invoking accessors or re-reading fields. */ +export function snapshotExactDataRecord( + value: unknown, + expected: Keys, label: string, -): void { - const actual = Reflect.ownKeys(record); +): Readonly> { + if (!isPlainRecord(value)) { + throw new Error(`${label} must be a plain data object`); + } + + const actual = Reflect.ownKeys(value); if (actual.some((key) => typeof key !== 'string')) { throw new Error(`${label} must not contain symbol properties`); } @@ -23,10 +27,23 @@ export function assertExactKeys( ) { throw new Error(`${label} has unknown or missing fields`); } - for (const key of strings) { - const descriptor = Object.getOwnPropertyDescriptor(record, key); + + const snapshot: Record = Object.create(null); + for (const key of expected) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { throw new Error(`${label} fields must be enumerable data properties`); } + snapshot[key] = descriptor.value; } + return Object.freeze(snapshot) as Readonly>; +} + +/** Require one plain record to contain exactly enumerable string data fields. */ +export function assertExactKeys( + record: Record, + expected: readonly string[], + label: string, +): void { + snapshotExactDataRecord(record, expected, label); } diff --git a/packages/core/test/finalized-vm-set-v1.test.ts b/packages/core/test/finalized-vm-set-v1.test.ts index 350e1159df..7377a6e3b1 100644 --- a/packages/core/test/finalized-vm-set-v1.test.ts +++ b/packages/core/test/finalized-vm-set-v1.test.ts @@ -15,6 +15,7 @@ import { const AUTHOR = '0x1111111111111111111111111111111111111111'; const CONTRACT = '0x2222222222222222222222222222222222222222'; const OTHER_CONTRACT = '0x3333333333333333333333333333333333333333'; +const OTHER_CHAIN = '20431'; const SCOPE = Object.freeze({ networkId: 'otp:20430', chainId: '20430', @@ -22,6 +23,26 @@ const SCOPE = Object.freeze({ }) as FinalizedVmSetScopeV1; describe('RFC-64 finalized VM-set accumulator', () => { + it('returns the complete frozen empty-set evidence contract', () => { + const mutableScope = { + networkId: SCOPE.networkId, + chainId: SCOPE.chainId, + contractAddress: SCOPE.contractAddress, + } as FinalizedVmSetScopeV1; + const evidence = new FinalizedVmSetAccumulatorV1(mutableScope).finalize(); + mutableScope.contractAddress = OTHER_CONTRACT as never; + + expect(evidence).toEqual({ + scope: SCOPE, + rootDigest: '0x900f13ea9b9bfc985dfd4beedf0ae0a6f01ff3a5a211943e18a751187dd9a09d', + rowCount: '0', + highestFinalizedOrdinal: null, + }); + expect(Object.isFrozen(evidence)).toBe(true); + expect(Object.isFrozen(evidence.scope)).toBe(true); + expect(evidence.scope).not.toBe(mutableScope); + }); + it('matches independent empty, one, even, and odd tree vectors', () => { expect(computeEmptyFinalizedVmSetRootV1()).toBe( '0x900f13ea9b9bfc985dfd4beedf0ae0a6f01ff3a5a211943e18a751187dd9a09d', @@ -85,6 +106,13 @@ describe('RFC-64 finalized VM-set accumulator', () => { () => crossLane.append({ ...row(0), contractAddress: OTHER_CONTRACT } as FinalizedVmSetRowV1), 'finalized-vm-set-lane', ); + expectFailureCode( + () => snapshotFinalizedVmSetRowV1( + SCOPE, + { ...row(0), chainId: OTHER_CHAIN } as FinalizedVmSetRowV1, + ), + 'finalized-vm-set-lane', + ); expectFailureCode( () => computeFinalizedVmSetLeafDigestV1( SCOPE, @@ -96,31 +124,31 @@ describe('RFC-64 finalized VM-set accumulator', () => { it('rejects UAL aliases and author mismatches before hashing', () => { expectFailureCode( - () => snapshotFinalizedVmSetRowV1({ + () => snapshotFinalizedVmSetRowV1(SCOPE, { ...row(0), ual: `did:dkg:otp:20430/${AUTHOR.toUpperCase()}/1`, - } as FinalizedVmSetRowV1, SCOPE.networkId), + } as FinalizedVmSetRowV1), 'finalized-vm-set-ual', ); expectFailureCode( - () => snapshotFinalizedVmSetRowV1({ + () => snapshotFinalizedVmSetRowV1(SCOPE, { ...row(0), ual: `did:dkg:otp:20430/${OTHER_CONTRACT}/1`, - } as FinalizedVmSetRowV1, SCOPE.networkId), + } as FinalizedVmSetRowV1), 'finalized-vm-set-ual', ); expectFailureCode( - () => snapshotFinalizedVmSetRowV1({ + () => snapshotFinalizedVmSetRowV1(SCOPE, { ...row(0), assertionVersion: '0', - } as FinalizedVmSetRowV1, SCOPE.networkId), + } as FinalizedVmSetRowV1), 'finalized-vm-set-scalar', ); expectFailureCode( - () => snapshotFinalizedVmSetRowV1({ + () => snapshotFinalizedVmSetRowV1(SCOPE, { ...row(0), ual: `did:dkg:wrong-lane/${AUTHOR}/1`, - } as FinalizedVmSetRowV1, SCOPE.networkId), + } as FinalizedVmSetRowV1), 'finalized-vm-set-ual', ); }); @@ -145,8 +173,8 @@ describe('RFC-64 finalized VM-set accumulator', () => { }); const snapshot = snapshotFinalizedVmSetRowV1( + SCOPE, proxy as FinalizedVmSetRowV1, - SCOPE.networkId, ); expect(snapshot).toEqual(row(0)); expect([...descriptorReads.values()].every((count) => count === 1)).toBe(true); From d5e83585a336055b78c6f7e76179a89b9f4ca8e5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:06:41 +0200 Subject: [PATCH 149/292] test(devnet): add RFC-64 Gate 2 multi-asset completeness evidence contract Closed deterministic fixture harness under devnet/rfc64-gate2-multi-asset-completeness. Raw@1 and verdict@1 schemas, a canonical encoder (sorted keys, integers only, lowercase hex, single trailing LF), a deterministic generator (pure function of asset count), an independent inventory set root (domain-tagged, full-row leaves, sorted, recomputed from the received rows and never trusted from the declared field), and a fail-closed verifier that always returns a verdict and rejects missing, extra, duplicate, misordered, count-mismatched, digest/length-mismatched, and schema-malformed input. Fixture harness only: productBoundary is not-connected and gateEvaluation is not-evaluated on every artifact and verdict; output never asserts a real Gate 2 pass, and fixtureComplete is kept distinct from any gate disposition. Evidence at source commit 19892c1d: tsc --noEmit clean; node --test 15 pass / 0 fail (one per material invariant, each mutated artifact still parses); two-run byte determinism sha256 b5bb0ad3c1b78b67dd19fa785ac7d59bf2f9d123975a8f065d0f8ba3d64b2892. No product, Gate 0/Gate 1, integration/rfc64-devnet, or existing-worktree edits; not pushed. Co-Authored-By: Claude Opus 4.8 --- .../README.md | 74 +++++++ .../package.json | 13 ++ .../src/canonical.ts | 78 +++++++ .../src/cli/generate.ts | 18 ++ .../src/cli/verify.ts | 30 +++ .../src/generate.ts | 59 ++++++ .../src/schema.ts | 136 +++++++++++++ .../src/set-root.ts | 32 +++ .../src/verify.ts | 192 ++++++++++++++++++ .../test/completeness.test.ts | 184 +++++++++++++++++ .../tsconfig.json | 18 ++ 11 files changed, 834 insertions(+) create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/README.md create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/package.json create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/canonical.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/cli/generate.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/cli/verify.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/generate.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/schema.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/set-root.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/verify.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json diff --git a/devnet/rfc64-gate2-multi-asset-completeness/README.md b/devnet/rfc64-gate2-multi-asset-completeness/README.md new file mode 100644 index 0000000000..06c576972c --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/README.md @@ -0,0 +1,74 @@ +# RFC-64 Gate 2 — multi-asset completeness evidence contract + +A **closed, deterministic** evidence contract that proves multi-asset +completeness: that a receiver's applied row set exactly reproduces an authored +row set, with unique UALs, canonical ordering, matching content/bundle digests +and lengths, exact total count, and an independently recomputed inventory set +root — and that explicitly **rejects** missing, duplicate, and extra rows. + +> **Fixture harness only.** `productBoundary` is `not-connected` and +> `gateEvaluation` is `not-evaluated` on every artifact and verdict. This +> contract is not wired to any product runtime, so its output **never asserts a +> real Gate 2 pass**. `fixtureComplete` is a fixture-level property, deliberately +> distinct from any gate disposition. + +## Contract + +Raw evidence (`raw@1`) and verdict (`verdict@1`) schemas live in +[`src/schema.ts`](src/schema.ts). A row binds `ual`, `contentDigest`, +`contentLength`, `bundleDigest`, `bundleLength`. + +The verifier ([`src/verify.ts`](src/verify.ts)) is **fail-closed** — it always +returns a verdict, never throws on bad input — and checks: + +- `schemaWellFormed` — exact keys, types, lowercase sha-256 hex, non-negative + integer lengths; unknown/missing fields are rejected. +- `authored/receivedUniqueUals` — duplicates detected on the raw array, before + any Map/Set collapse. +- `authored/receivedCanonicalOrder` — the array **as given** equals its sorted + self (a complete-but-misordered set is still rejected). +- `totalCountMatchesAuthored`, `noMissing`, `noExtra`, `perRowExactMatch`. +- `inventorySetRootMatches` — the root is **recomputed from the received rows** + ([`src/set-root.ts`](src/set-root.ts), domain-tagged, full-row leaves, sorted) + and compared to the declared value; the declared field is never trusted. + +## Determinism + +The generator ([`src/generate.ts`](src/generate.ts)) is a pure function of the +asset count: no clock, randomness, host, or environment input. Output is +canonical ([`src/canonical.ts`](src/canonical.ts): sorted keys, integers only, +lowercase hex, single trailing LF). + +## Commands (run from the repo/worktree root toolchain) + +``` +# typecheck (erasable TS, noEmit) +tsc --noEmit -p tsconfig.json + +# mutation test suite (Node built-in runner; no external deps) +node --experimental-strip-types --test test/completeness.test.ts + +# generate a fixture / verify one +node --experimental-strip-types src/cli/generate.ts 8 +node --experimental-strip-types src/cli/generate.ts 8 | node --experimental-strip-types src/cli/verify.ts + +# two-run byte determinism +node --experimental-strip-types src/cli/generate.ts 8 > a.json +node --experimental-strip-types src/cli/generate.ts 8 > b.json +cmp a.json b.json && shasum -a 256 a.json b.json +``` + +## Verified results (at source commit `19892c1d`) + +- `tsc --noEmit`: clean. +- `node --test`: **15 pass / 0 fail** — one per material invariant, each mutated + artifact still parses so the failure is the targeted invariant. +- Two-run determinism (count=8): byte-identical, + `sha256 = b5bb0ad3c1b78b67dd19fa785ac7d59bf2f9d123975a8f065d0f8ba3d64b2892`. + +## Handoff + +To evaluate a real Gate 2, a future adapter feeds authored/received rows from the +live producer/receiver into `generateCompleteFixture`'s place and sets a +`productBoundary`/`gateEvaluation` state machine. Until that adapter exists, keep +the markers `not-connected` / `not-evaluated`; a green fixture is not a gate pass. diff --git a/devnet/rfc64-gate2-multi-asset-completeness/package.json b/devnet/rfc64-gate2-multi-asset-completeness/package.json new file mode 100644 index 0000000000..e225c760ee --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/package.json @@ -0,0 +1,13 @@ +{ + "name": "@devnet/rfc64-gate2-multi-asset-completeness", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Closed deterministic Gate 2 multi-asset completeness evidence contract. Fixture harness only: productBoundary is not-connected and gateEvaluation is not-evaluated until a real adapter is wired; output never asserts a real Gate 2 pass.", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "node --experimental-strip-types --test test/completeness.test.ts", + "generate": "node --experimental-strip-types src/cli/generate.ts", + "verify": "node --experimental-strip-types src/cli/verify.ts" + } +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/canonical.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/canonical.ts new file mode 100644 index 0000000000..fc142baccd --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/canonical.ts @@ -0,0 +1,78 @@ +import { createHash } from 'node:crypto'; + +// Deterministic, byte-stable encoding primitives. No timestamps, no host or +// environment input, no floats, no Date, no Math.random anywhere in this +// contract. Every value that reaches serialization must be an integer, a +// lowercase-hex string, a plain string, a boolean, null, an array, or a plain +// object; canonical order is imposed by sorting keys and by callers sorting any +// set-like array before it is serialized. + +/** Lowercase hex, even length, only 0-9a-f. */ +const LOWER_HEX = /^(?:[0-9a-f]{2})*$/; +const SHA256_HEX = /^[0-9a-f]{64}$/; + +export function isLowerHex(value: unknown): value is string { + return typeof value === 'string' && LOWER_HEX.test(value); +} + +export function isSha256Hex(value: unknown): value is string { + return typeof value === 'string' && SHA256_HEX.test(value); +} + +export function isSafeNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +/** SHA-256 over the given bytes, returned as lowercase hex. */ +export function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +export const UTF8 = new TextEncoder(); + +export type CanonicalValue = + | string + | number + | boolean + | null + | readonly CanonicalValue[] + | { readonly [key: string]: CanonicalValue }; + +/** + * Serialize a value to a single canonical UTF-8 string: object keys sorted + * ascending by code unit, no insignificant whitespace, integers only (never a + * float or exponent), standard JSON string escaping. Arrays keep their given + * order — callers impose canonical/sorted order on the data before calling. + * Throws on any non-integer number, non-finite number, undefined, function, + * bigint, or symbol so a nondeterministic value can never be silently emitted. + */ +export function canonicalize(value: CanonicalValue): string { + if (value === null) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') { + if (!Number.isInteger(value) || !Number.isFinite(value)) { + throw new TypeError(`canonical JSON permits only finite integers, got ${String(value)}`); + } + // Safe integers stringify without exponent; guard the boundary explicitly. + if (!Number.isSafeInteger(value)) { + throw new TypeError(`canonical JSON integer is outside the safe range: ${String(value)}`); + } + return String(value); + } + if (typeof value === 'string') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalize(item)).join(',')}]`; + } + if (typeof value === 'object') { + const entries = Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize((value as Record)[key]!)}`); + return `{${entries.join(',')}}`; + } + throw new TypeError(`canonical JSON cannot serialize value of type ${typeof value}`); +} + +/** Canonical document bytes: canonical JSON plus exactly one trailing LF. */ +export function canonicalDocument(value: CanonicalValue): string { + return `${canonicalize(value)}\n`; +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/cli/generate.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/cli/generate.ts new file mode 100644 index 0000000000..1df9c4dd95 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/cli/generate.ts @@ -0,0 +1,18 @@ +import { canonicalDocument } from '../canonical.ts'; +import { generateCompleteFixture } from '../generate.ts'; + +// Usage: node dist/cli/generate.js [count] +// Writes the canonical raw evidence document (single trailing LF) to stdout. +// Pure function of `count`: two invocations produce byte-identical output. +function main(argv: readonly string[]): void { + const countArg = argv[2] ?? '8'; + const count = Number(countArg); + if (!Number.isSafeInteger(count) || count < 1) { + process.stderr.write(`invalid count: ${countArg}\n`); + process.exit(2); + return; + } + process.stdout.write(canonicalDocument(generateCompleteFixture(count))); +} + +main(process.argv); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/cli/verify.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/cli/verify.ts new file mode 100644 index 0000000000..ca0a611b05 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/cli/verify.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { canonicalDocument } from '../canonical.ts'; +import { verify } from '../verify.ts'; + +// Usage: node dist/cli/verify.js (or omit the path to read stdin) +// Writes the canonical verdict document to stdout. Exit code 0 when the fixture +// is complete, 1 when it is rejected (fail-closed), 2 on unreadable input. +// A nonzero exit or a rejected verdict never means "Gate 2 passed". +function main(argv: readonly string[]): void { + const path = argv[2]; + let text: string; + try { + text = path === undefined ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8'); + } catch (cause) { + process.stderr.write(`cannot read raw evidence: ${String(cause)}\n`); + process.exit(2); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + const verdict = verify(parsed); + process.stdout.write(canonicalDocument(verdict)); + process.exit(verdict.fixtureComplete ? 0 : 1); +} + +main(process.argv); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/generate.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/generate.ts new file mode 100644 index 0000000000..ca5fa43075 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/generate.ts @@ -0,0 +1,59 @@ +import { sha256Hex, UTF8 } from './canonical.ts'; +import { + GATE_EVALUATION, + PRODUCT_BOUNDARY, + RAW_SCHEMA_ID, + type AssetRowV1, + type RawEvidenceV1, +} from './schema.ts'; +import { computeInventorySetRoot } from './set-root.ts'; + +// Fixed decimal width so the canonical ual string order matches numeric order. +const UAL_INDEX_WIDTH = 6; + +function deterministicRow(index: number): AssetRowV1 { + const id = String(index).padStart(UAL_INDEX_WIDTH, '0'); + const ual = `did:dkg:gate2-mac-fixture/${id}`; + // Content and bundle bytes are a pure function of the index: reproducible on + // any host with no clock, randomness, or environment input. + const content = UTF8.encode(`gate2-mac:content:${id}:${'ka'.repeat(index + 1)}`); + const bundle = UTF8.encode(`gate2-mac:bundle:${id}:${sha256Hex(content)}`); + return Object.freeze({ + ual, + contentDigest: sha256Hex(content), + contentLength: content.byteLength, + bundleDigest: sha256Hex(bundle), + bundleLength: bundle.byteLength, + }); +} + +function byUal(a: AssetRowV1, b: AssetRowV1): number { + if (a.ual < b.ual) return -1; + if (a.ual > b.ual) return 1; + return 0; +} + +/** + * Deterministically generate a COMPLETE multi-asset completeness fixture of + * `count` assets: authored and received are the identical canonical-ordered row + * set, `totalCount` is exact, and `inventorySetRoot` is computed from the + * authored set. Byte-identical across runs for the same `count`. + */ +export function generateCompleteFixture(count: number): RawEvidenceV1 { + if (!Number.isSafeInteger(count) || count < 1) { + throw new RangeError(`fixture asset count must be a positive safe integer, got ${String(count)}`); + } + const rows: AssetRowV1[] = []; + for (let index = 0; index < count; index += 1) rows.push(deterministicRow(index)); + const authored = Object.freeze([...rows].sort(byUal)); + const received = Object.freeze([...rows].sort(byUal)); + return Object.freeze({ + schema: RAW_SCHEMA_ID, + productBoundary: PRODUCT_BOUNDARY, + gateEvaluation: GATE_EVALUATION, + authored, + received, + totalCount: authored.length, + inventorySetRoot: computeInventorySetRoot(authored), + }); +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/schema.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/schema.ts new file mode 100644 index 0000000000..b0ecb8115f --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/schema.ts @@ -0,0 +1,136 @@ +import { isSafeNonNegativeInteger, isSha256Hex } from './canonical.ts'; + +export const RAW_SCHEMA_ID = 'rfc64-gate2-multi-asset-completeness/raw@1' as const; +export const VERDICT_SCHEMA_ID = 'rfc64-gate2-multi-asset-completeness/verdict@1' as const; + +// This contract is a closed fixture harness. It is NOT wired to any product +// runtime, so it can never assert that a real Gate 2 evaluation passed. These +// two markers are always present, on every raw artifact and every verdict, and +// are set from these constants by the verifier itself (never trusted from +// input) so a green fixture can never be read as a real gate pass. +export const PRODUCT_BOUNDARY = 'not-connected' as const; +export const GATE_EVALUATION = 'not-evaluated' as const; + +/** One asset's completeness row. `ual` is the primary identity/order key. */ +export type AssetRowV1 = { + readonly ual: string; + readonly contentDigest: string; + readonly contentLength: number; + readonly bundleDigest: string; + readonly bundleLength: number; +} + +export type RawEvidenceV1 = { + readonly schema: typeof RAW_SCHEMA_ID; + readonly productBoundary: typeof PRODUCT_BOUNDARY; + readonly gateEvaluation: typeof GATE_EVALUATION; + readonly authored: readonly AssetRowV1[]; + readonly received: readonly AssetRowV1[]; + readonly totalCount: number; + readonly inventorySetRoot: string; +} + +export type CompletenessChecksV1 = { + readonly schemaWellFormed: boolean; + readonly authoredRowsWellFormed: boolean; + readonly receivedRowsWellFormed: boolean; + readonly authoredUniqueUals: boolean; + readonly receivedUniqueUals: boolean; + readonly authoredCanonicalOrder: boolean; + readonly receivedCanonicalOrder: boolean; + readonly totalCountMatchesAuthored: boolean; + readonly noMissing: boolean; + readonly noExtra: boolean; + readonly perRowExactMatch: boolean; + readonly inventorySetRootMatches: boolean; +} + +export type VerdictV1 = { + readonly schema: typeof VERDICT_SCHEMA_ID; + readonly productBoundary: typeof PRODUCT_BOUNDARY; + readonly gateEvaluation: typeof GATE_EVALUATION; + /** + * True only when every completeness invariant holds for this fixture. This is + * a FIXTURE-level property, deliberately distinct from any gate disposition: + * `gateEvaluation` stays `not-evaluated` regardless of this value. + */ + readonly fixtureComplete: boolean; + readonly checks: CompletenessChecksV1; + readonly missing: readonly string[]; + readonly extra: readonly string[]; + readonly duplicateUals: readonly string[]; + readonly mismatchedUals: readonly string[]; + readonly recomputedInventorySetRoot: string; + readonly rejectReasons: readonly string[]; +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +const ROW_KEYS = ['bundleDigest', 'bundleLength', 'contentDigest', 'contentLength', 'ual']; +const RAW_KEYS = [ + 'authored', + 'gateEvaluation', + 'inventorySetRoot', + 'productBoundary', + 'received', + 'schema', + 'totalCount', +]; + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + return keys.length === expected.length && keys.every((key, index) => key === expected[index]); +} + +function parseRow(value: unknown): AssetRowV1 | undefined { + if (!isPlainObject(value) || !exactKeys(value, ROW_KEYS)) return undefined; + const { ual, contentDigest, contentLength, bundleDigest, bundleLength } = value; + if (typeof ual !== 'string' || ual.length === 0) return undefined; + if (!isSha256Hex(contentDigest) || !isSha256Hex(bundleDigest)) return undefined; + if (!isSafeNonNegativeInteger(contentLength) || !isSafeNonNegativeInteger(bundleLength)) { + return undefined; + } + return Object.freeze({ ual, contentDigest, contentLength, bundleDigest, bundleLength }); +} + +function parseRowArray(value: unknown): readonly AssetRowV1[] | undefined { + if (!Array.isArray(value)) return undefined; + const rows: AssetRowV1[] = []; + for (const entry of value) { + const row = parseRow(entry); + if (row === undefined) return undefined; + rows.push(row); + } + return Object.freeze(rows); +} + +/** + * Fail-closed structural parse of raw evidence. Returns `undefined` for any + * shape deviation — unknown or missing keys, wrong types, malformed hex, + * non-integer lengths, or the wrong schema/boundary literals — so the verifier + * can record a schema rejection rather than trusting or throwing on bad input. + */ +export function parseRawEvidence(value: unknown): RawEvidenceV1 | undefined { + if (!isPlainObject(value) || !exactKeys(value, RAW_KEYS)) return undefined; + if (value.schema !== RAW_SCHEMA_ID) return undefined; + if (value.productBoundary !== PRODUCT_BOUNDARY) return undefined; + if (value.gateEvaluation !== GATE_EVALUATION) return undefined; + if (!isSafeNonNegativeInteger(value.totalCount)) return undefined; + if (!isSha256Hex(value.inventorySetRoot)) return undefined; + const authored = parseRowArray(value.authored); + const received = parseRowArray(value.received); + if (authored === undefined || received === undefined) return undefined; + return Object.freeze({ + schema: RAW_SCHEMA_ID, + productBoundary: PRODUCT_BOUNDARY, + gateEvaluation: GATE_EVALUATION, + authored, + received, + totalCount: value.totalCount, + inventorySetRoot: value.inventorySetRoot, + }); +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/set-root.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/set-root.ts new file mode 100644 index 0000000000..db4e6e3b40 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/set-root.ts @@ -0,0 +1,32 @@ +import { canonicalize, sha256Hex, UTF8 } from './canonical.ts'; +import type { AssetRowV1 } from './schema.ts'; + +// Domain-separated so a set root can never collide with a bare content hash or +// a leaf hash. Versioned so the algorithm can evolve without ambiguity. +const LEAF_DOMAIN = 'rfc64-gate2-multi-asset-completeness:leaf:v1\n'; +const SET_ROOT_DOMAIN = 'rfc64-gate2-multi-asset-completeness:set-root:v1\n'; + +/** Leaf digest binding the FULL row (ual + both digests + both lengths). */ +function leafHex(row: AssetRowV1): string { + const canonicalRow = canonicalize({ + ual: row.ual, + contentDigest: row.contentDigest, + contentLength: row.contentLength, + bundleDigest: row.bundleDigest, + bundleLength: row.bundleLength, + }); + return sha256Hex(UTF8.encode(LEAF_DOMAIN + canonicalRow)); +} + +/** + * Order-independent commitment to a SET of rows: hash the sorted leaf digests + * under a domain tag and an explicit count prefix (so different partitions can + * never concatenate to the same preimage). Independent of input order — the + * verifier recomputes this from the received rows and compares it to the raw + * artifact's declared value; it never trusts the declared field. + */ +export function computeInventorySetRoot(rows: readonly AssetRowV1[]): string { + const sortedLeaves = rows.map(leafHex).sort(); + const preimage = `${SET_ROOT_DOMAIN}${sortedLeaves.length}\n${sortedLeaves.join('\n')}`; + return sha256Hex(UTF8.encode(preimage)); +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/verify.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/verify.ts new file mode 100644 index 0000000000..b80309c77e --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/verify.ts @@ -0,0 +1,192 @@ +import { isSafeNonNegativeInteger, isSha256Hex } from './canonical.ts'; +import { + GATE_EVALUATION, + PRODUCT_BOUNDARY, + VERDICT_SCHEMA_ID, + parseRawEvidence, + type AssetRowV1, + type RawEvidenceV1, + type VerdictV1, +} from './schema.ts'; +import { computeInventorySetRoot } from './set-root.ts'; + +function rowWellFormed(row: AssetRowV1): boolean { + return ( + typeof row.ual === 'string' + && row.ual.length > 0 + && isSha256Hex(row.contentDigest) + && isSha256Hex(row.bundleDigest) + && isSafeNonNegativeInteger(row.contentLength) + && isSafeNonNegativeInteger(row.bundleLength) + ); +} + +function uals(rows: readonly AssetRowV1[]): string[] { + return rows.map((row) => row.ual); +} + +function duplicatesInOrder(values: readonly string[]): string[] { + const seen = new Set(); + const duplicates: string[] = []; + for (const value of values) { + if (seen.has(value) && !duplicates.includes(value)) duplicates.push(value); + seen.add(value); + } + return duplicates.sort(); +} + +function isSortedAscending(values: readonly string[]): boolean { + for (let index = 1; index < values.length; index += 1) { + if (values[index - 1]! > values[index]!) return false; + } + return true; +} + +function rowsEqual(a: AssetRowV1, b: AssetRowV1): boolean { + return ( + a.ual === b.ual + && a.contentDigest === b.contentDigest + && a.contentLength === b.contentLength + && a.bundleDigest === b.bundleDigest + && a.bundleLength === b.bundleLength + ); +} + +/** First-wins index by ual (duplicates are reported separately, not merged away). */ +function indexByUal(rows: readonly AssetRowV1[]): Map { + const map = new Map(); + for (const row of rows) if (!map.has(row.ual)) map.set(row.ual, row); + return map; +} + +function schemaRejectVerdict(reason: string): VerdictV1 { + return Object.freeze({ + schema: VERDICT_SCHEMA_ID, + productBoundary: PRODUCT_BOUNDARY, + gateEvaluation: GATE_EVALUATION, + fixtureComplete: false, + checks: Object.freeze({ + schemaWellFormed: false, + authoredRowsWellFormed: false, + receivedRowsWellFormed: false, + authoredUniqueUals: false, + receivedUniqueUals: false, + authoredCanonicalOrder: false, + receivedCanonicalOrder: false, + totalCountMatchesAuthored: false, + noMissing: false, + noExtra: false, + perRowExactMatch: false, + inventorySetRootMatches: false, + }), + missing: Object.freeze([]), + extra: Object.freeze([]), + duplicateUals: Object.freeze([]), + mismatchedUals: Object.freeze([]), + recomputedInventorySetRoot: '', + rejectReasons: Object.freeze([reason]), + }); +} + +/** + * Fail-closed verification of a raw evidence artifact against the multi-asset + * completeness contract. Always returns a verdict (never throws on bad input); + * `fixtureComplete` is true only when every invariant holds. The gate + * disposition stays `not-evaluated` and the product boundary `not-connected` + * regardless — a complete fixture is not a Gate 2 pass. + */ +export function verify(rawInput: unknown): VerdictV1 { + const raw: RawEvidenceV1 | undefined = parseRawEvidence(rawInput); + if (raw === undefined) { + return schemaRejectVerdict('raw evidence failed fail-closed structural schema validation'); + } + + const authoredUals = uals(raw.authored); + const receivedUals = uals(raw.received); + const authoredSet = new Set(authoredUals); + const receivedSet = new Set(receivedUals); + + const authoredRowsWellFormed = raw.authored.every(rowWellFormed); + const receivedRowsWellFormed = raw.received.every(rowWellFormed); + + // Uniqueness and duplicates are read from the raw arrays, before any Map/Set + // collapse, so a duplicated ual cannot silently pass. + const authoredDuplicates = duplicatesInOrder(authoredUals); + const receivedDuplicates = duplicatesInOrder(receivedUals); + const authoredUniqueUals = authoredDuplicates.length === 0; + const receivedUniqueUals = receivedDuplicates.length === 0; + const duplicateUals = [...new Set([...authoredDuplicates, ...receivedDuplicates])].sort(); + + // Canonical ordering is an independent check on the array AS GIVEN, so a + // complete-but-misordered set is still rejected. + const authoredCanonicalOrder = isSortedAscending(authoredUals); + const receivedCanonicalOrder = isSortedAscending(receivedUals); + + const totalCountMatchesAuthored = raw.totalCount === raw.authored.length; + + const missing = [...authoredSet].filter((ual) => !receivedSet.has(ual)).sort(); + const extra = [...receivedSet].filter((ual) => !authoredSet.has(ual)).sort(); + const noMissing = missing.length === 0; + const noExtra = extra.length === 0; + + const authoredIndex = indexByUal(raw.authored); + const receivedIndex = indexByUal(raw.received); + const mismatchedUals: string[] = []; + for (const ual of authoredSet) { + const a = authoredIndex.get(ual); + const b = receivedIndex.get(ual); + if (a !== undefined && b !== undefined && !rowsEqual(a, b)) mismatchedUals.push(ual); + } + mismatchedUals.sort(); + const perRowExactMatch = mismatchedUals.length === 0; + + // Recomputed independently from the RECEIVED rows; the declared field is never + // trusted. Complete + correct data yields the authored-derived declared root. + const recomputedInventorySetRoot = computeInventorySetRoot(raw.received); + const inventorySetRootMatches = recomputedInventorySetRoot === raw.inventorySetRoot; + + const checks = Object.freeze({ + schemaWellFormed: true, + authoredRowsWellFormed, + receivedRowsWellFormed, + authoredUniqueUals, + receivedUniqueUals, + authoredCanonicalOrder, + receivedCanonicalOrder, + totalCountMatchesAuthored, + noMissing, + noExtra, + perRowExactMatch, + inventorySetRootMatches, + }); + + const rejectReasons: string[] = []; + if (!authoredRowsWellFormed) rejectReasons.push('one or more authored rows are malformed'); + if (!receivedRowsWellFormed) rejectReasons.push('one or more received rows are malformed'); + if (!authoredUniqueUals) rejectReasons.push(`authored contains duplicate uals: ${authoredDuplicates.join(', ')}`); + if (!receivedUniqueUals) rejectReasons.push(`received contains duplicate uals: ${receivedDuplicates.join(', ')}`); + if (!authoredCanonicalOrder) rejectReasons.push('authored rows are not in canonical ual order'); + if (!receivedCanonicalOrder) rejectReasons.push('received rows are not in canonical ual order'); + if (!totalCountMatchesAuthored) rejectReasons.push(`totalCount ${raw.totalCount} does not equal authored count ${raw.authored.length}`); + if (!noMissing) rejectReasons.push(`received is missing authored uals: ${missing.join(', ')}`); + if (!noExtra) rejectReasons.push(`received has extra uals not authored: ${extra.join(', ')}`); + if (!perRowExactMatch) rejectReasons.push(`row content/bundle digests or lengths differ for uals: ${mismatchedUals.join(', ')}`); + if (!inventorySetRootMatches) rejectReasons.push('recomputed inventorySetRoot does not match the declared value'); + + const fixtureComplete = rejectReasons.length === 0 + && Object.values(checks).every((value) => value === true); + + return Object.freeze({ + schema: VERDICT_SCHEMA_ID, + productBoundary: PRODUCT_BOUNDARY, + gateEvaluation: GATE_EVALUATION, + fixtureComplete, + checks, + missing: Object.freeze(missing), + extra: Object.freeze(extra), + duplicateUals: Object.freeze(duplicateUals), + mismatchedUals: Object.freeze(mismatchedUals), + recomputedInventorySetRoot, + rejectReasons: Object.freeze(rejectReasons), + }); +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts b/devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts new file mode 100644 index 0000000000..d21d8a7892 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { canonicalDocument, canonicalize } from '../src/canonical.ts'; +import { generateCompleteFixture } from '../src/generate.ts'; +import { verify } from '../src/verify.ts'; + +const FIXTURE_COUNT = 8; + +// Mutable deep clone of a frozen raw artifact; the mutated artifact must still +// parse so each test exercises the targeted invariant, not an incidental reject. +function clone(): any { + return JSON.parse(JSON.stringify(generateCompleteFixture(FIXTURE_COUNT))); +} + +const OTHER_DIGEST = 'c'.repeat(64); +const A_DIGEST = 'a'.repeat(64); + +function differentDigest(current: string): string { + return current === A_DIGEST ? OTHER_DIGEST : A_DIGEST; +} + +test('a complete fixture verifies with every invariant satisfied', () => { + const verdict = verify(generateCompleteFixture(FIXTURE_COUNT)); + assert.equal(verdict.fixtureComplete, true); + assert.deepEqual(verdict.missing, []); + assert.deepEqual(verdict.extra, []); + assert.deepEqual(verdict.duplicateUals, []); + assert.deepEqual(verdict.mismatchedUals, []); + assert.deepEqual(verdict.rejectReasons, []); + for (const [name, value] of Object.entries(verdict.checks)) { + assert.equal(value, true, `check ${name} must be true for a complete fixture`); + } + assert.equal( + verdict.recomputedInventorySetRoot, + generateCompleteFixture(FIXTURE_COUNT).inventorySetRoot, + 'verifier recomputes the same root it declares', + ); +}); + +test('the fixture is byte-deterministic across two generations', () => { + const a = canonicalDocument(generateCompleteFixture(FIXTURE_COUNT)); + const b = canonicalDocument(generateCompleteFixture(FIXTURE_COUNT)); + assert.equal(a, b); + assert.ok(a.endsWith('\n'), 'canonical document ends with exactly one LF'); +}); + +test('completeness is distinct from any gate pass, on complete and rejected verdicts', () => { + const complete = canonicalDocument(verify(generateCompleteFixture(FIXTURE_COUNT))); + const rejected = canonicalDocument(verify({ not: 'valid' })); + for (const doc of [complete, rejected]) { + assert.match(doc, /"productBoundary":"not-connected"/); + assert.match(doc, /"gateEvaluation":"not-evaluated"/); + // No token that could be read as a real Gate 2 pass. + assert.doesNotMatch(doc, /pass(ed)?/i); + assert.doesNotMatch(doc, /"gateEvaluation":"(passed|pass|ok|green|evaluated)"/); + } + // fixtureComplete may be true, but the gate disposition never changes. + assert.equal(verify(generateCompleteFixture(FIXTURE_COUNT)).gateEvaluation, 'not-evaluated'); +}); + +test('fail-closed schema rejection for structural deviations', () => { + const base = clone(); + const rejects: Array<[string, unknown]> = [ + ['unknown extra top-level field', { ...base, surprise: 1 }], + ['missing field', (() => { const r = clone(); delete r.totalCount; return r; })()], + ['wrong schema id', { ...clone(), schema: 'other' }], + ['tampered productBoundary marker', { ...clone(), productBoundary: 'connected' }], + ['tampered gateEvaluation marker', { ...clone(), gateEvaluation: 'passed' }], + ['non-hex inventorySetRoot', { ...clone(), inventorySetRoot: 'zz' }], + ['non-integer length in a row', (() => { const r = clone(); r.received[0].contentLength = 1.5; return r; })()], + ['empty ual in a row', (() => { const r = clone(); r.received[0].ual = ''; return r; })()], + ['non-hex digest in a row', (() => { const r = clone(); r.received[0].contentDigest = 'nothex'; return r; })()], + ['unknown field in a row', (() => { const r = clone(); r.received[0].extra = true; return r; })()], + ['authored is not an array', { ...clone(), authored: {} }], + ['non-parseable input', undefined], + ]; + for (const [name, input] of rejects) { + const verdict = verify(input); + assert.equal(verdict.fixtureComplete, false, `${name} must be rejected`); + assert.equal(verdict.checks.schemaWellFormed, false, `${name} must fail schemaWellFormed`); + assert.ok(verdict.rejectReasons.length > 0, `${name} must record a reject reason`); + assert.equal(verdict.gateEvaluation, 'not-evaluated'); + } +}); + +test('missing received row is rejected (noMissing)', () => { + const r = clone(); + const dropped = r.received[3].ual; + r.received.splice(3, 1); + const verdict = verify(r); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.noMissing, false); + assert.ok(verdict.missing.includes(dropped)); +}); + +test('extra received row is rejected (noExtra)', () => { + const r = clone(); + // A valid, well-formed row whose ual sorts after all fixture uals so canonical + // order stays intact and only the extra-set invariant is tripped. + const extraUal = 'did:dkg:gate2-mac-fixture/999999'; + r.received.push({ ual: extraUal, contentDigest: A_DIGEST, contentLength: 1, bundleDigest: A_DIGEST, bundleLength: 2 }); + const verdict = verify(r); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.noExtra, false); + assert.equal(verdict.checks.receivedCanonicalOrder, true, 'appended-after row keeps canonical order'); + assert.ok(verdict.extra.includes(extraUal)); +}); + +test('duplicate received ual is rejected (uniqueness), isolated from ordering', () => { + const r = clone(); + // Insert an adjacent clone so ual order stays non-decreasing; only uniqueness trips. + r.received.splice(1, 0, { ...r.received[0] }); + const verdict = verify(r); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.receivedUniqueUals, false); + assert.equal(verdict.checks.receivedCanonicalOrder, true, 'adjacent duplicate keeps non-decreasing order'); + assert.ok(verdict.duplicateUals.includes(r.received[0].ual)); +}); + +test('mis-ordered but complete set is rejected (canonicalOrder), isolated', () => { + const r = clone(); + [r.received[0], r.received[1]] = [r.received[1], r.received[0]]; + const verdict = verify(r); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.receivedCanonicalOrder, false); + // The set itself is unchanged, so completeness/root/count invariants still hold. + assert.equal(verdict.checks.noMissing, true); + assert.equal(verdict.checks.noExtra, true); + assert.equal(verdict.checks.inventorySetRootMatches, true); + assert.equal(verdict.checks.totalCountMatchesAuthored, true); +}); + +for (const field of ['contentDigest', 'bundleDigest'] as const) { + test(`per-row ${field} mismatch is rejected (perRowExactMatch + set root)`, () => { + const r = clone(); + r.received[2][field] = differentDigest(r.received[2][field]); + const verdict = verify(r); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.perRowExactMatch, false); + assert.equal(verdict.checks.inventorySetRootMatches, false, 'changing a row changes the received set root'); + assert.ok(verdict.mismatchedUals.includes(r.received[2].ual)); + }); +} + +for (const field of ['contentLength', 'bundleLength'] as const) { + test(`per-row ${field} mismatch is rejected (perRowExactMatch + set root)`, () => { + const r = clone(); + r.received[2][field] = r.received[2][field] + 1; + const verdict = verify(r); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.perRowExactMatch, false); + assert.equal(verdict.checks.inventorySetRootMatches, false); + assert.ok(verdict.mismatchedUals.includes(r.received[2].ual)); + }); +} + +test('wrong totalCount is rejected (totalCountMatchesAuthored), isolated', () => { + const r = clone(); + r.totalCount = r.authored.length + 1; + const verdict = verify(r); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.totalCountMatchesAuthored, false); + assert.equal(verdict.checks.noMissing, true); + assert.equal(verdict.checks.inventorySetRootMatches, true); +}); + +test('tampered declared inventorySetRoot is rejected, never trusted (isolated)', () => { + const r = clone(); + r.inventorySetRoot = differentDigest(r.inventorySetRoot); + const verdict = verify(r); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.inventorySetRootMatches, false); + // Every other invariant still holds: only the declared root was tampered. + assert.equal(verdict.checks.noMissing, true); + assert.equal(verdict.checks.noExtra, true); + assert.equal(verdict.checks.perRowExactMatch, true); + assert.notEqual(verdict.recomputedInventorySetRoot, r.inventorySetRoot); +}); + +test('canonical JSON rejects non-integer numbers', () => { + assert.throws(() => canonicalize({ n: 1.5 } as never)); + assert.throws(() => canonicalize({ n: Number.NaN } as never)); +}); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json b/devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json new file mode 100644 index 0000000000..fa82f7a466 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From fe3a314351a1518fc9dc012a85fe65f6d3392a21 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:40:37 +0200 Subject: [PATCH 150/292] fix(devnet): bind Gate 2 evidence to product inventory --- .../README.md | 154 ++++--- .../package.json | 2 +- .../src/canonical.ts | 256 ++++++++--- .../src/cli/generate.ts | 3 +- .../src/cli/verify.ts | 44 +- .../src/generate.ts | 96 +++-- .../src/product-digests.ts | 52 +++ .../src/schema.ts | 385 +++++++++++++---- .../src/set-root.ts | 32 -- .../src/verify.ts | 367 +++++++++------- .../test/completeness.test.ts | 407 ++++++++++++------ 11 files changed, 1210 insertions(+), 588 deletions(-) create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/product-digests.ts delete mode 100644 devnet/rfc64-gate2-multi-asset-completeness/src/set-root.ts diff --git a/devnet/rfc64-gate2-multi-asset-completeness/README.md b/devnet/rfc64-gate2-multi-asset-completeness/README.md index 06c576972c..8a4e9fe19a 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/README.md +++ b/devnet/rfc64-gate2-multi-asset-completeness/README.md @@ -1,74 +1,88 @@ -# RFC-64 Gate 2 — multi-asset completeness evidence contract - -A **closed, deterministic** evidence contract that proves multi-asset -completeness: that a receiver's applied row set exactly reproduces an authored -row set, with unique UALs, canonical ordering, matching content/bundle digests -and lengths, exact total count, and an independently recomputed inventory set -root — and that explicitly **rejects** missing, duplicate, and extra rows. - -> **Fixture harness only.** `productBoundary` is `not-connected` and -> `gateEvaluation` is `not-evaluated` on every artifact and verdict. This -> contract is not wired to any product runtime, so its output **never asserts a -> real Gate 2 pass**. `fixtureComplete` is a fixture-level property, deliberately -> distinct from any gate disposition. - -## Contract - -Raw evidence (`raw@1`) and verdict (`verdict@1`) schemas live in -[`src/schema.ts`](src/schema.ts). A row binds `ual`, `contentDigest`, -`contentLength`, `bundleDigest`, `bundleLength`. - -The verifier ([`src/verify.ts`](src/verify.ts)) is **fail-closed** — it always -returns a verdict, never throws on bad input — and checks: - -- `schemaWellFormed` — exact keys, types, lowercase sha-256 hex, non-negative - integer lengths; unknown/missing fields are rejected. -- `authored/receivedUniqueUals` — duplicates detected on the raw array, before - any Map/Set collapse. -- `authored/receivedCanonicalOrder` — the array **as given** equals its sorted - self (a complete-but-misordered set is still rejected). -- `totalCountMatchesAuthored`, `noMissing`, `noExtra`, `perRowExactMatch`. -- `inventorySetRootMatches` — the root is **recomputed from the received rows** - ([`src/set-root.ts`](src/set-root.ts), domain-tagged, full-row leaves, sorted) - and compared to the declared value; the declared field is never trusted. - -## Determinism - -The generator ([`src/generate.ts`](src/generate.ts)) is a pure function of the -asset count: no clock, randomness, host, or environment input. Output is -canonical ([`src/canonical.ts`](src/canonical.ts): sorted keys, integers only, -lowercase hex, single trailing LF). - -## Commands (run from the repo/worktree root toolchain) - -``` -# typecheck (erasable TS, noEmit) +# RFC-64 Gate 2 multi-asset completeness evidence contract + +A closed deterministic contract for the exact bounded product slice implemented +by the Gate 2 producer, receiver, and inventory-completeness helper: one +public/open author-catalog bucket containing **1..1,024 rows**. + +> Fixture harness only. `productBoundary` is always `not-connected` and +> `gateEvaluation` is always `not-evaluated`. A true `fixtureComplete` proves +> only that the contract and its generated fixture agree; it is not a Gate 2 +> result. A real two-process adapter still has to supply product observations. + +## What is bound + +The `raw@1` artifact carries an authored and a received observation. Its +fail-closed verifier checks: + +- the exact nine-field catalog scope and its independently recomputed + `dkg-author-catalog-scope-v1` digest; +- the receiver's exact authored catalog-head digest; +- head `totalRows`, signed bucket row count, receiver row count, and the actual + signed/activated array lengths; +- strict mathematical `kaId` order, independent duplicate-`kaId` and + duplicate-UAL rejection, and the 1..1,024 bucket bound; +- each UAL's network, author, canonical KA number, and packed `kaId`; +- exact per-row `catalogRowDigest`, projection/content digest, author-seal + digest, opaque-bundle digest, UAL, and activated triple count; +- missing, extra, duplicate, and mismatched received rows; and +- the receiver's declared applied-inventory digest, independently recomputed + with the production `dkg-rfc64-applied-inventory-v1` binary framing in numeric + `kaId` order. + +The legacy applied-inventory digest does not include `bundleDigest`. The +contract therefore keeps bundle equality as a separate mandatory invariant; +its mutation test proves that a wrong bundle is rejected even when the legacy +inventory digest remains unchanged. + +Serialization is bounded RFC 8785 JCS plus exactly one trailing LF. The JS +boundary accepts only exact data descriptors, rejects accessors without invoking +them, snapshots Proxies without property gets, caps rows before enumerating +their keys, and caps depth, nodes, strings, input bytes, and output bytes. + +## Real two-process adapter mapping + +The adapter should build one raw artifact from these product values. Entries +marked **gap** exist in the internal producer result but are not yet carried by +`PublishOpenAuthorCatalogExactSetSuccessorResultV1`; exposing detached read-only +copies is the minimal product-to-harness adapter seam. + +| Contract field | Product source | +| --- | --- | +| `authored.catalogScope` | **gap:** expose the exact `deriveAuthorCatalogScopeFromHeadV1(head.payload)` snapshot from `publishOpenAuthorCatalogExactSetSuccessorV1` | +| `authored.declaredCatalogScopeDigest` | **gap:** expose `result.assets[0].projection.catalogScopeDigest` (and retain the producer's all-assets equality check) | +| `authored.catalogHeadDigest` | existing public result `.headObjectDigest` | +| `authored.catalogHeadTotalRows` | existing public result `.inventoryRowCount` (assigned directly from `head.payload.totalRows`) | +| `authored.signedBucketRowCount` | **gap:** expose `produced.publication.bucket.payload.rows.length.toString()` independently of head count | +| `authored.signedRows[].kaId` | existing public result `.assets[].kaId` | +| `authored.signedRows[].catalogRowDigest` | existing public result `.assets[].catalogRowDigest` | +| `authored.signedRows[].contentDigest` | existing public result `.assets[].contentDigest` | +| `authored.signedRows[].sealDigest` | **gap:** expose internal `produced.assets[].sealBinding.sealDigest` | +| `authored.signedRows[].bundleDigest` | existing public result `.assets[].bundleDigest` | +| `authored.signedRows[].kaUal` | existing public result `.assets[].kaUal` | +| `authored.signedRows[].activatedTripleCount` | **gap:** expose a checked safe-integer conversion of internal `produced.assets[].projection.publicTripleCount` | +| `received.catalogHeadDigest` | `Rfc64PublicCatalogNativeMultiAssetActivationEvidenceV1.catalogHeadDigest` | +| `received.declaredInventoryDigest` | `.inventoryDigest`, also equal to the durable applied-head post-read | +| `received.inventoryRowCount` | `.inventoryRowCount` | +| `received.activatedRows` | `.rows`, projected to the seven contract row fields (omit `swmGraph` and `authorship`) | + +The receiver side is already sufficient. The author side needs the five +read-only gaps above; without them, a harness would have to synthesize scope, +bucket count, seal, or triple-count claims from its own request instead of +recording verified product output. After that small seam, orchestration can +expose the exact-set publish and multi-row synchronization calls through two real +DKGAgent child processes, carry these values verbatim, read the receiver's +durable applied head using the exposed scope digest, and use a separate connected +Gate 2 schema to make a real gate disposition. + +## Commands + +```sh +cd devnet/rfc64-gate2-multi-asset-completeness tsc --noEmit -p tsconfig.json - -# mutation test suite (Node built-in runner; no external deps) node --experimental-strip-types --test test/completeness.test.ts - -# generate a fixture / verify one -node --experimental-strip-types src/cli/generate.ts 8 -node --experimental-strip-types src/cli/generate.ts 8 | node --experimental-strip-types src/cli/verify.ts - -# two-run byte determinism -node --experimental-strip-types src/cli/generate.ts 8 > a.json -node --experimental-strip-types src/cli/generate.ts 8 > b.json -cmp a.json b.json && shasum -a 256 a.json b.json +node --experimental-strip-types src/cli/generate.ts 8 > raw.json +node --experimental-strip-types src/cli/verify.ts raw.json > verdict.json ``` -## Verified results (at source commit `19892c1d`) - -- `tsc --noEmit`: clean. -- `node --test`: **15 pass / 0 fail** — one per material invariant, each mutated - artifact still parses so the failure is the targeted invariant. -- Two-run determinism (count=8): byte-identical, - `sha256 = b5bb0ad3c1b78b67dd19fa785ac7d59bf2f9d123975a8f065d0f8ba3d64b2892`. - -## Handoff - -To evaluate a real Gate 2, a future adapter feeds authored/received rows from the -live producer/receiver into `generateCompleteFixture`'s place and sets a -`productBoundary`/`gateEvaluation` state machine. Until that adapter exists, keep -the markers `not-connected` / `not-evaluated`; a green fixture is not a gate pass. +Two identical generator invocations must produce byte-identical raw artifacts; +two verifier invocations over them must produce byte-identical verdicts. diff --git a/devnet/rfc64-gate2-multi-asset-completeness/package.json b/devnet/rfc64-gate2-multi-asset-completeness/package.json index e225c760ee..7447e191df 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/package.json +++ b/devnet/rfc64-gate2-multi-asset-completeness/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", - "description": "Closed deterministic Gate 2 multi-asset completeness evidence contract. Fixture harness only: productBoundary is not-connected and gateEvaluation is not-evaluated until a real adapter is wired; output never asserts a real Gate 2 pass.", + "description": "Closed product-shaped Gate 2 exact-set evidence contract with production-compatible scope and applied-inventory digests; never asserts a gate pass.", "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "test": "node --experimental-strip-types --test test/completeness.test.ts", diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/canonical.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/canonical.ts index fc142baccd..1ce957b21d 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/src/canonical.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/canonical.ts @@ -1,32 +1,17 @@ import { createHash } from 'node:crypto'; -// Deterministic, byte-stable encoding primitives. No timestamps, no host or -// environment input, no floats, no Date, no Math.random anywhere in this -// contract. Every value that reaches serialization must be an integer, a -// lowercase-hex string, a plain string, a boolean, null, an array, or a plain -// object; canonical order is imposed by sorting keys and by callers sorting any -// set-like array before it is serialized. - -/** Lowercase hex, even length, only 0-9a-f. */ -const LOWER_HEX = /^(?:[0-9a-f]{2})*$/; -const SHA256_HEX = /^[0-9a-f]{64}$/; - -export function isLowerHex(value: unknown): value is string { - return typeof value === 'string' && LOWER_HEX.test(value); -} - -export function isSha256Hex(value: unknown): value is string { - return typeof value === 'string' && SHA256_HEX.test(value); -} +/** Closed artifact ceiling, including the document's single trailing LF. */ +export const MAX_CANONICAL_DOCUMENT_BYTES = 2 * 1024 * 1024; +export const MAX_CANONICAL_DEPTH = 16; +export const MAX_CANONICAL_NODES = 32_768; -export function isSafeNonNegativeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; -} - -/** SHA-256 over the given bytes, returned as lowercase hex. */ -export function sha256Hex(bytes: Uint8Array): string { - return createHash('sha256').update(bytes).digest('hex'); -} +const MAX_STRING_CODE_UNITS = 1_048_576; +const DIGEST32 = /^0x[0-9a-f]{64}$/u; +const ADDRESS = /^0x[0-9a-f]{40}$/u; +const DECIMAL = /^(?:0|[1-9][0-9]*)$/u; +const MAX_U64 = 18_446_744_073_709_551_615n; +const MAX_U256 = + 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; export const UTF8 = new TextEncoder(); @@ -38,41 +23,204 @@ export type CanonicalValue = | readonly CanonicalValue[] | { readonly [key: string]: CanonicalValue }; +export function isDigest32(value: unknown): value is string { + return typeof value === 'string' && DIGEST32.test(value); +} + +export function isAddress(value: unknown): value is string { + return typeof value === 'string' + && ADDRESS.test(value) + && value !== `0x${'00'.repeat(20)}`; +} + +export function parseCanonicalU64(value: unknown): bigint | undefined { + return parseCanonicalDecimal(value, MAX_U64); +} + +export function parseCanonicalU256(value: unknown): bigint | undefined { + return parseCanonicalDecimal(value, MAX_U256); +} + +function parseCanonicalDecimal(value: unknown, maximum: bigint): bigint | undefined { + if (typeof value !== 'string' || !DECIMAL.test(value)) return undefined; + try { + const parsed = BigInt(value); + return parsed <= maximum ? parsed : undefined; + } catch { + return undefined; + } +} + +export function sha256Digest(...parts: readonly (string | Uint8Array)[]): string { + const hasher = createHash('sha256'); + for (const part of parts) hasher.update(part); + return `0x${hasher.digest('hex')}`; +} + +export function digestBytes(digest: string): Uint8Array { + if (!isDigest32(digest)) throw new TypeError('digest must be canonical 0x-prefixed sha-256'); + return Uint8Array.from(Buffer.from(digest.slice(2), 'hex')); +} + +export function encodeU64(value: bigint | number): Uint8Array { + let remaining = typeof value === 'number' ? BigInt(value) : value; + if (remaining < 0n || remaining > MAX_U64) throw new RangeError('value is outside u64'); + const result = new Uint8Array(8); + for (let index = result.length - 1; index >= 0; index -= 1) { + result[index] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return result; +} + /** - * Serialize a value to a single canonical UTF-8 string: object keys sorted - * ascending by code unit, no insignificant whitespace, integers only (never a - * float or exponent), standard JSON string escaping. Arrays keep their given - * order — callers impose canonical/sorted order on the data before calling. - * Throws on any non-integer number, non-finite number, undefined, function, - * bigint, or symbol so a nondeterministic value can never be silently emitted. + * RFC 8785 JCS for the deliberately narrower evidence model (safe integers + * only). Containers must be dense ordinary Arrays or plain data-only objects; + * accessors are rejected and never invoked. Depth, node, string, and output + * byte ceilings make this safe to use on a JavaScript trust boundary. */ export function canonicalize(value: CanonicalValue): string { - if (value === null) return 'null'; - if (typeof value === 'boolean') return value ? 'true' : 'false'; - if (typeof value === 'number') { - if (!Number.isInteger(value) || !Number.isFinite(value)) { - throw new TypeError(`canonical JSON permits only finite integers, got ${String(value)}`); + const ancestors = new Set(); + const budget = { nodes: 0, bytes: 0 }; + + const accountAscii = (text: string): void => { + budget.bytes += text.length; + if (budget.bytes + 1 > MAX_CANONICAL_DOCUMENT_BYTES) { + throw new RangeError('canonical document byte ceiling exceeded'); } - // Safe integers stringify without exponent; guard the boundary explicitly. - if (!Number.isSafeInteger(value)) { - throw new TypeError(`canonical JSON integer is outside the safe range: ${String(value)}`); + }; + const accountUtf8 = (text: string): void => { + budget.bytes += UTF8.encode(text).byteLength; + if (budget.bytes + 1 > MAX_CANONICAL_DOCUMENT_BYTES) { + throw new RangeError('canonical document byte ceiling exceeded'); } - return String(value); - } - if (typeof value === 'string') return JSON.stringify(value); - if (Array.isArray(value)) { - return `[${value.map((item) => canonicalize(item)).join(',')}]`; - } - if (typeof value === 'object') { - const entries = Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalize((value as Record)[key]!)}`); - return `{${entries.join(',')}}`; - } - throw new TypeError(`canonical JSON cannot serialize value of type ${typeof value}`); + }; + + const encode = (input: unknown, depth: number): string => { + budget.nodes += 1; + if (budget.nodes > MAX_CANONICAL_NODES) throw new TypeError('canonical JSON node ceiling exceeded'); + if (depth > MAX_CANONICAL_DEPTH) throw new TypeError('canonical JSON depth ceiling exceeded'); + + if (input === null) { + accountAscii('null'); + return 'null'; + } + if (typeof input === 'boolean') { + const encoded = input ? 'true' : 'false'; + accountAscii(encoded); + return encoded; + } + if (typeof input === 'number') { + if (!Number.isSafeInteger(input)) throw new TypeError('canonical evidence numbers must be safe integers'); + const encoded = JSON.stringify(input); + accountAscii(encoded); + return encoded; + } + if (typeof input === 'string') { + assertIJsonString(input); + const encoded = JSON.stringify(input); + accountUtf8(encoded); + return encoded; + } + if (typeof input !== 'object') throw new TypeError(`unsupported JSON value type: ${typeof input}`); + if (ancestors.has(input)) throw new TypeError('cyclic values are not JSON'); + ancestors.add(input); + try { + if (Array.isArray(input)) { + if (Object.getPrototypeOf(input) !== Array.prototype) { + throw new TypeError('only ordinary Arrays are accepted'); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); + if ( + lengthDescriptor === undefined + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + || lengthDescriptor.value > MAX_CANONICAL_NODES + ) { + throw new TypeError('array length is not an exact bounded data field'); + } + const length = lengthDescriptor.value; + const keys = Reflect.ownKeys(input); + if ( + keys.some((key) => typeof key !== 'string') + || keys.length !== length + 1 + || !keys.includes('length') + ) { + throw new TypeError('arrays must be dense and contain no custom properties'); + } + const entries: string[] = []; + accountAscii('[]'); + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError('array elements must be enumerable data properties'); + } + if (index !== 0) accountAscii(','); + entries.push(encode(descriptor.value, depth + 1)); + } + return `[${entries.join(',')}]`; + } + + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('only plain JSON objects are accepted'); + } + const keys = Reflect.ownKeys(input); + if (keys.length > MAX_CANONICAL_NODES || keys.some((key) => typeof key !== 'string')) { + throw new TypeError('object keys exceed the canonical JSON boundary'); + } + const stringKeys = keys as string[]; + for (const key of stringKeys) assertIJsonString(key); + stringKeys.sort(); // RFC 8785 property-name order is UTF-16 code-unit order. + const entries: string[] = []; + accountAscii('{}'); + for (let index = 0; index < stringKeys.length; index += 1) { + const key = stringKeys[index]!; + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError('object fields must be enumerable data properties'); + } + if (index !== 0) accountAscii(','); + const encodedKey = JSON.stringify(key); + accountUtf8(encodedKey); + accountAscii(':'); + entries.push(`${encodedKey}:${encode(descriptor.value, depth + 1)}`); + } + return `{${entries.join(',')}}`; + } finally { + ancestors.delete(input); + } + }; + + const result = encode(value, 0); + return result; } -/** Canonical document bytes: canonical JSON plus exactly one trailing LF. */ +/** Exact JCS document framing used by the contract: one and only one trailing LF. */ export function canonicalDocument(value: CanonicalValue): string { return `${canonicalize(value)}\n`; } + +function assertIJsonString(value: string): void { + if (value.length > MAX_STRING_CODE_UNITS) throw new RangeError('JSON string ceiling exceeded'); + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new TypeError('lone high surrogate is not I-JSON'); + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new TypeError('lone low surrogate is not I-JSON'); + } + } +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/cli/generate.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/cli/generate.ts index 1df9c4dd95..62d0b4c13a 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/src/cli/generate.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/cli/generate.ts @@ -1,5 +1,6 @@ import { canonicalDocument } from '../canonical.ts'; import { generateCompleteFixture } from '../generate.ts'; +import { MAX_GATE2_ROWS } from '../schema.ts'; // Usage: node dist/cli/generate.js [count] // Writes the canonical raw evidence document (single trailing LF) to stdout. @@ -7,7 +8,7 @@ import { generateCompleteFixture } from '../generate.ts'; function main(argv: readonly string[]): void { const countArg = argv[2] ?? '8'; const count = Number(countArg); - if (!Number.isSafeInteger(count) || count < 1) { + if (!Number.isSafeInteger(count) || count < 1 || count > MAX_GATE2_ROWS) { process.stderr.write(`invalid count: ${countArg}\n`); process.exit(2); return; diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/cli/verify.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/cli/verify.ts index ca0a611b05..7e1280bc75 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/src/cli/verify.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/cli/verify.ts @@ -1,24 +1,28 @@ -import { readFileSync } from 'node:fs'; -import { canonicalDocument } from '../canonical.ts'; +import { closeSync, openSync, readSync } from 'node:fs'; + +import { + MAX_CANONICAL_DOCUMENT_BYTES, + canonicalDocument, +} from '../canonical.ts'; import { verify } from '../verify.ts'; -// Usage: node dist/cli/verify.js (or omit the path to read stdin) -// Writes the canonical verdict document to stdout. Exit code 0 when the fixture -// is complete, 1 when it is rejected (fail-closed), 2 on unreadable input. -// A nonzero exit or a rejected verdict never means "Gate 2 passed". +const READ_CHUNK_BYTES = 64 * 1024; + +// Usage: node src/cli/verify.ts (or omit the path to read stdin). +// Input is byte-bounded before JSON parsing and must itself be exact JCS+LF. function main(argv: readonly string[]): void { - const path = argv[2]; let text: string; try { - text = path === undefined ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8'); + text = readBoundedUtf8(argv[2]); } catch (cause) { - process.stderr.write(`cannot read raw evidence: ${String(cause)}\n`); + process.stderr.write(`cannot read bounded raw evidence: ${String(cause)}\n`); process.exit(2); return; } let parsed: unknown; try { - parsed = JSON.parse(text); + parsed = JSON.parse(text) as unknown; + if (canonicalDocument(parsed as never) !== text) parsed = undefined; } catch { parsed = undefined; } @@ -27,4 +31,24 @@ function main(argv: readonly string[]): void { process.exit(verdict.fixtureComplete ? 0 : 1); } +function readBoundedUtf8(path: string | undefined): string { + const fd = path === undefined ? 0 : openSync(path, 'r'); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES); + const read = readSync(fd, chunk, 0, chunk.byteLength, null); + if (read === 0) break; + total += read; + if (total > MAX_CANONICAL_DOCUMENT_BYTES) throw new RangeError('raw evidence byte ceiling exceeded'); + chunks.push(chunk.subarray(0, read)); + } + } finally { + if (path !== undefined) closeSync(fd); + } + const bytes = Buffer.concat(chunks, total); + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); +} + main(process.argv); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/generate.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/generate.ts index ca5fa43075..ba99b8aeba 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/src/generate.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/generate.ts @@ -1,59 +1,81 @@ -import { sha256Hex, UTF8 } from './canonical.ts'; +import { sha256Digest } from './canonical.ts'; +import { + computeAppliedInventoryDigest, + computeCatalogScopeDigest, +} from './product-digests.ts'; import { GATE_EVALUATION, + MAX_GATE2_ROWS, PRODUCT_BOUNDARY, RAW_SCHEMA_ID, type AssetRowV1, + type CatalogScopeV1, type RawEvidenceV1, } from './schema.ts'; -import { computeInventorySetRoot } from './set-root.ts'; -// Fixed decimal width so the canonical ual string order matches numeric order. -const UAL_INDEX_WIDTH = 6; +const AUTHOR = '0x1111111111111111111111111111111111111111'; + +export const FIXTURE_SCOPE: CatalogScopeV1 = Object.freeze({ + networkId: 'otp:20430', + contextGraphId: 'gate2-multi-asset', + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', +}); function deterministicRow(index: number): AssetRowV1 { - const id = String(index).padStart(UAL_INDEX_WIDTH, '0'); - const ual = `did:dkg:gate2-mac-fixture/${id}`; - // Content and bundle bytes are a pure function of the index: reproducible on - // any host with no clock, randomness, or environment input. - const content = UTF8.encode(`gate2-mac:content:${id}:${'ka'.repeat(index + 1)}`); - const bundle = UTF8.encode(`gate2-mac:bundle:${id}:${sha256Hex(content)}`); + const kaNumber = BigInt(index + 1); + const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); + const seed = `gate2-multi-asset:${kaNumber.toString()}`; return Object.freeze({ - ual, - contentDigest: sha256Hex(content), - contentLength: content.byteLength, - bundleDigest: sha256Hex(bundle), - bundleLength: bundle.byteLength, + kaId, + catalogRowDigest: sha256Digest(`catalog-row:${seed}`), + contentDigest: sha256Digest(`projection:${seed}`), + sealDigest: sha256Digest(`author-seal:${seed}`), + bundleDigest: sha256Digest(`opaque-bundle:${seed}`), + kaUal: `did:dkg:${FIXTURE_SCOPE.networkId}/${AUTHOR}/${kaNumber.toString()}`, + activatedTripleCount: (index % 7) + 1, }); } -function byUal(a: AssetRowV1, b: AssetRowV1): number { - if (a.ual < b.ual) return -1; - if (a.ual > b.ual) return 1; - return 0; -} - -/** - * Deterministically generate a COMPLETE multi-asset completeness fixture of - * `count` assets: authored and received are the identical canonical-ordered row - * set, `totalCount` is exact, and `inventorySetRoot` is computed from the - * authored set. Byte-identical across runs for the same `count`. - */ +/** Pure, byte-stable 1..1,024-row fixture shaped exactly like the product adapter. */ export function generateCompleteFixture(count: number): RawEvidenceV1 { - if (!Number.isSafeInteger(count) || count < 1) { - throw new RangeError(`fixture asset count must be a positive safe integer, got ${String(count)}`); + if (!Number.isSafeInteger(count) || count < 1 || count > MAX_GATE2_ROWS) { + throw new RangeError(`fixture asset count must be within 1..${MAX_GATE2_ROWS}`); } - const rows: AssetRowV1[] = []; - for (let index = 0; index < count; index += 1) rows.push(deterministicRow(index)); - const authored = Object.freeze([...rows].sort(byUal)); - const received = Object.freeze([...rows].sort(byUal)); + const signedRows = Object.freeze( + Array.from({ length: count }, (_unused, index) => deterministicRow(index)), + ); + const activatedRows = Object.freeze(signedRows.map((row) => Object.freeze({ ...row }))); + const declaredCatalogScopeDigest = computeCatalogScopeDigest(FIXTURE_SCOPE); + const catalogHeadDigest = sha256Digest( + `catalog-head:${declaredCatalogScopeDigest}:${count.toString()}`, + ); + const declaredInventoryDigest = computeAppliedInventoryDigest( + declaredCatalogScopeDigest, + activatedRows, + ); return Object.freeze({ schema: RAW_SCHEMA_ID, productBoundary: PRODUCT_BOUNDARY, gateEvaluation: GATE_EVALUATION, - authored, - received, - totalCount: authored.length, - inventorySetRoot: computeInventorySetRoot(authored), + authored: Object.freeze({ + catalogScope: FIXTURE_SCOPE, + declaredCatalogScopeDigest, + catalogHeadDigest, + catalogHeadTotalRows: count.toString(), + signedBucketRowCount: count.toString(), + signedRows, + }), + received: Object.freeze({ + catalogHeadDigest, + declaredInventoryDigest, + inventoryRowCount: count, + activatedRows, + }), }); } diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/product-digests.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/product-digests.ts new file mode 100644 index 0000000000..fed5e645b8 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/product-digests.ts @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto'; + +import { + UTF8, + canonicalize, + digestBytes, + encodeU64, + sha256Digest, +} from './canonical.ts'; +import type { AssetRowV1, CatalogScopeV1 } from './schema.ts'; + +const CATALOG_SCOPE_DIGEST_DOMAIN = 'dkg-author-catalog-scope-v1\n'; +const APPLIED_INVENTORY_DIGEST_DOMAIN = 'dkg-rfc64-applied-inventory-v1\n'; + +/** Exact dkg-core `computeAuthorCatalogScopeDigestV1` framing. */ +export function computeCatalogScopeDigest(scope: CatalogScopeV1): string { + return sha256Digest(CATALOG_SCOPE_DIGEST_DOMAIN, canonicalize(scope)); +} + +/** + * Exact Gate-1-compatible production applied-inventory framing. Rows are in + * numeric KA-ID order. Bundle digests are deliberately not inside this legacy + * commitment, so the contract separately requires exact full-row equality. + */ +export function computeAppliedInventoryDigest( + catalogScopeDigest: string, + inputRows: readonly AssetRowV1[], +): string { + const rows = [...inputRows].sort(compareRowsByKaId); + const hasher = createHash('sha256'); + hasher.update(APPLIED_INVENTORY_DIGEST_DOMAIN); + hasher.update(digestBytes(catalogScopeDigest)); + hasher.update(encodeU64(BigInt(rows.length))); + for (const row of rows) { + hasher.update(digestBytes(row.catalogRowDigest)); + hasher.update(digestBytes(row.contentDigest)); + hasher.update(digestBytes(row.sealDigest)); + const ual = UTF8.encode(row.kaUal); + hasher.update(encodeU64(BigInt(ual.byteLength))); + hasher.update(ual); + hasher.update(encodeU64(BigInt(row.activatedTripleCount))); + } + return `0x${hasher.digest('hex')}`; +} + +export function compareRowsByKaId(left: AssetRowV1, right: AssetRowV1): -1 | 0 | 1 { + const leftId = BigInt(left.kaId); + const rightId = BigInt(right.kaId); + if (leftId < rightId) return -1; + if (leftId > rightId) return 1; + return 0; +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/schema.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/schema.ts index b0ecb8115f..459001dfcb 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/src/schema.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/schema.ts @@ -1,136 +1,335 @@ -import { isSafeNonNegativeInteger, isSha256Hex } from './canonical.ts'; +import { + UTF8, + isAddress, + isDigest32, + parseCanonicalU64, + parseCanonicalU256, +} from './canonical.ts'; export const RAW_SCHEMA_ID = 'rfc64-gate2-multi-asset-completeness/raw@1' as const; export const VERDICT_SCHEMA_ID = 'rfc64-gate2-multi-asset-completeness/verdict@1' as const; - -// This contract is a closed fixture harness. It is NOT wired to any product -// runtime, so it can never assert that a real Gate 2 evaluation passed. These -// two markers are always present, on every raw artifact and every verdict, and -// are set from these constants by the verifier itself (never trusted from -// input) so a green fixture can never be read as a real gate pass. export const PRODUCT_BOUNDARY = 'not-connected' as const; export const GATE_EVALUATION = 'not-evaluated' as const; +export const MAX_GATE2_ROWS = 1024; + +const NETWORK_ID = /^[A-Za-z0-9._:-]+$/u; +const CONTEXT_GRAPH_ID = /^[A-Za-z0-9_:/\.@-]+$/u; +const MAX_NETWORK_ID_BYTES = 128; +const MAX_CONTEXT_GRAPH_ID_BYTES = 256; +const MAX_UAL_BYTES = 1024; +const MAX_KA_NUMBER = (1n << 96n) - 1n; + +export type CatalogScopeV1 = { + readonly networkId: string; + readonly contextGraphId: string; + readonly governanceChainId: string | null; + readonly governanceContractAddress: string | null; + readonly ownershipTransitionDigest: string | null; + readonly subGraphName: null; + readonly authorAddress: string; + readonly era: string; + readonly bucketCount: '1'; +}; -/** One asset's completeness row. `ual` is the primary identity/order key. */ +/** Exact shape emitted by the Gate 2 completeness helper and native receiver. */ export type AssetRowV1 = { - readonly ual: string; + readonly kaId: string; + readonly catalogRowDigest: string; readonly contentDigest: string; - readonly contentLength: number; + readonly sealDigest: string; readonly bundleDigest: string; - readonly bundleLength: number; -} + readonly kaUal: string; + readonly activatedTripleCount: number; +}; + +export type AuthoredInventoryV1 = { + readonly catalogScope: CatalogScopeV1; + readonly declaredCatalogScopeDigest: string; + readonly catalogHeadDigest: string; + readonly catalogHeadTotalRows: string; + readonly signedBucketRowCount: string; + readonly signedRows: readonly AssetRowV1[]; +}; + +export type ReceivedInventoryV1 = { + readonly catalogHeadDigest: string; + readonly declaredInventoryDigest: string; + readonly inventoryRowCount: number; + readonly activatedRows: readonly AssetRowV1[]; +}; export type RawEvidenceV1 = { readonly schema: typeof RAW_SCHEMA_ID; readonly productBoundary: typeof PRODUCT_BOUNDARY; readonly gateEvaluation: typeof GATE_EVALUATION; - readonly authored: readonly AssetRowV1[]; - readonly received: readonly AssetRowV1[]; - readonly totalCount: number; - readonly inventorySetRoot: string; -} + readonly authored: AuthoredInventoryV1; + readonly received: ReceivedInventoryV1; +}; export type CompletenessChecksV1 = { readonly schemaWellFormed: boolean; - readonly authoredRowsWellFormed: boolean; - readonly receivedRowsWellFormed: boolean; - readonly authoredUniqueUals: boolean; - readonly receivedUniqueUals: boolean; - readonly authoredCanonicalOrder: boolean; - readonly receivedCanonicalOrder: boolean; - readonly totalCountMatchesAuthored: boolean; + readonly rowCountWithinBounds: boolean; + readonly catalogScopeDigestMatches: boolean; + readonly catalogHeadMatches: boolean; + readonly headCountMatchesSignedRows: boolean; + readonly bucketCountMatchesSignedRows: boolean; + readonly receivedCountMatchesSignedRows: boolean; + readonly signedRowsCanonicalOrder: boolean; + readonly activatedRowsCanonicalOrder: boolean; + readonly signedRowsUnique: boolean; + readonly activatedRowsUnique: boolean; + readonly rowsBindCatalogScope: boolean; readonly noMissing: boolean; readonly noExtra: boolean; readonly perRowExactMatch: boolean; - readonly inventorySetRootMatches: boolean; -} + readonly inventoryDigestMatches: boolean; +}; export type VerdictV1 = { readonly schema: typeof VERDICT_SCHEMA_ID; readonly productBoundary: typeof PRODUCT_BOUNDARY; readonly gateEvaluation: typeof GATE_EVALUATION; - /** - * True only when every completeness invariant holds for this fixture. This is - * a FIXTURE-level property, deliberately distinct from any gate disposition: - * `gateEvaluation` stays `not-evaluated` regardless of this value. - */ + /** Fixture-only result; it is never a Gate 2 disposition. */ readonly fixtureComplete: boolean; readonly checks: CompletenessChecksV1; - readonly missing: readonly string[]; - readonly extra: readonly string[]; + readonly missingKaIds: readonly string[]; + readonly extraKaIds: readonly string[]; + readonly duplicateKaIds: readonly string[]; readonly duplicateUals: readonly string[]; - readonly mismatchedUals: readonly string[]; - readonly recomputedInventorySetRoot: string; + readonly mismatchedKaIds: readonly string[]; + readonly recomputedCatalogScopeDigest: string; + readonly recomputedInventoryDigest: string; readonly rejectReasons: readonly string[]; -} +}; -function isPlainObject(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -} - -const ROW_KEYS = ['bundleDigest', 'bundleLength', 'contentDigest', 'contentLength', 'ual']; -const RAW_KEYS = [ - 'authored', - 'gateEvaluation', - 'inventorySetRoot', - 'productBoundary', - 'received', - 'schema', - 'totalCount', +const RAW_KEYS = ['authored', 'gateEvaluation', 'productBoundary', 'received', 'schema']; +const AUTHORED_KEYS = [ + 'catalogHeadDigest', + 'catalogHeadTotalRows', + 'catalogScope', + 'declaredCatalogScopeDigest', + 'signedBucketRowCount', + 'signedRows', +]; +const RECEIVED_KEYS = [ + 'activatedRows', + 'catalogHeadDigest', + 'declaredInventoryDigest', + 'inventoryRowCount', +]; +const SCOPE_KEYS = [ + 'authorAddress', + 'bucketCount', + 'contextGraphId', + 'era', + 'governanceChainId', + 'governanceContractAddress', + 'networkId', + 'ownershipTransitionDigest', + 'subGraphName', +]; +const ROW_KEYS = [ + 'activatedTripleCount', + 'bundleDigest', + 'catalogRowDigest', + 'contentDigest', + 'kaId', + 'kaUal', + 'sealDigest', ]; -function exactKeys(value: Record, expected: readonly string[]): boolean { - const keys = Object.keys(value).sort(); - return keys.length === expected.length && keys.every((key, index) => key === expected[index]); -} +/** + * Fail-closed, data-descriptor-only snapshot. It never performs property gets, + * rejects accessors, snapshots switching Proxies once, and checks the 1,024-row + * work ceiling before enumerating array keys or elements. + */ +export function parseRawEvidence(value: unknown): RawEvidenceV1 | undefined { + try { + const raw = snapshotExactRecord(value, RAW_KEYS); + if ( + raw.schema !== RAW_SCHEMA_ID + || raw.productBoundary !== PRODUCT_BOUNDARY + || raw.gateEvaluation !== GATE_EVALUATION + ) return undefined; + + const authoredInput = snapshotExactRecord(raw.authored, AUTHORED_KEYS); + const receivedInput = snapshotExactRecord(raw.received, RECEIVED_KEYS); + const catalogScope = parseScope(authoredInput.catalogScope); + if (catalogScope === undefined) return undefined; + if ( + !isDigest32(authoredInput.declaredCatalogScopeDigest) + || !isDigest32(authoredInput.catalogHeadDigest) + || parseCanonicalU64(authoredInput.catalogHeadTotalRows) === undefined + || parseCanonicalU64(authoredInput.signedBucketRowCount) === undefined + || !isDigest32(receivedInput.catalogHeadDigest) + || !isDigest32(receivedInput.declaredInventoryDigest) + || !Number.isSafeInteger(receivedInput.inventoryRowCount) + || (receivedInput.inventoryRowCount as number) < 0 + ) return undefined; -function parseRow(value: unknown): AssetRowV1 | undefined { - if (!isPlainObject(value) || !exactKeys(value, ROW_KEYS)) return undefined; - const { ual, contentDigest, contentLength, bundleDigest, bundleLength } = value; - if (typeof ual !== 'string' || ual.length === 0) return undefined; - if (!isSha256Hex(contentDigest) || !isSha256Hex(bundleDigest)) return undefined; - if (!isSafeNonNegativeInteger(contentLength) || !isSafeNonNegativeInteger(bundleLength)) { + const signedRows = parseRows(authoredInput.signedRows); + const activatedRows = parseRows(receivedInput.activatedRows); + if (signedRows === undefined || activatedRows === undefined) return undefined; + + return Object.freeze({ + schema: RAW_SCHEMA_ID, + productBoundary: PRODUCT_BOUNDARY, + gateEvaluation: GATE_EVALUATION, + authored: Object.freeze({ + catalogScope, + declaredCatalogScopeDigest: authoredInput.declaredCatalogScopeDigest, + catalogHeadDigest: authoredInput.catalogHeadDigest, + catalogHeadTotalRows: authoredInput.catalogHeadTotalRows, + signedBucketRowCount: authoredInput.signedBucketRowCount, + signedRows, + }) as AuthoredInventoryV1, + received: Object.freeze({ + catalogHeadDigest: receivedInput.catalogHeadDigest, + declaredInventoryDigest: receivedInput.declaredInventoryDigest, + inventoryRowCount: receivedInput.inventoryRowCount, + activatedRows, + }) as ReceivedInventoryV1, + }); + } catch { return undefined; } - return Object.freeze({ ual, contentDigest, contentLength, bundleDigest, bundleLength }); } -function parseRowArray(value: unknown): readonly AssetRowV1[] | undefined { - if (!Array.isArray(value)) return undefined; +function parseScope(value: unknown): CatalogScopeV1 | undefined { + const scope = snapshotExactRecord(value, SCOPE_KEYS); + if ( + !boundedIdentifier(scope.networkId, NETWORK_ID, MAX_NETWORK_ID_BYTES) + || !boundedIdentifier(scope.contextGraphId, CONTEXT_GRAPH_ID, MAX_CONTEXT_GRAPH_ID_BYTES) + || !isAddress(scope.authorAddress) + || parseCanonicalU64(scope.era) === undefined + || scope.bucketCount !== '1' + || scope.subGraphName !== null + || (scope.ownershipTransitionDigest !== null && !isDigest32(scope.ownershipTransitionDigest)) + ) return undefined; + const governanceNull = scope.governanceChainId === null + && scope.governanceContractAddress === null; + const governancePresent = parseCanonicalU256(scope.governanceChainId) !== undefined + && isAddress(scope.governanceContractAddress); + if (!governanceNull && !governancePresent) return undefined; + return Object.freeze({ + networkId: scope.networkId, + contextGraphId: scope.contextGraphId, + governanceChainId: scope.governanceChainId, + governanceContractAddress: scope.governanceContractAddress, + ownershipTransitionDigest: scope.ownershipTransitionDigest, + subGraphName: null, + authorAddress: scope.authorAddress, + era: scope.era, + bucketCount: '1', + }) as CatalogScopeV1; +} + +function parseRows(value: unknown): readonly AssetRowV1[] | undefined { + const inputs = snapshotDenseArray(value); const rows: AssetRowV1[] = []; - for (const entry of value) { - const row = parseRow(entry); - if (row === undefined) return undefined; - rows.push(row); + for (const input of inputs) { + const row = snapshotExactRecord(input, ROW_KEYS); + const kaId = parseCanonicalU256(row.kaId); + if ( + kaId === undefined + || !isDigest32(row.catalogRowDigest) + || !isDigest32(row.contentDigest) + || !isDigest32(row.sealDigest) + || !isDigest32(row.bundleDigest) + || typeof row.kaUal !== 'string' + || row.kaUal.length === 0 + || row.kaUal.normalize('NFC') !== row.kaUal + || UTF8.encode(row.kaUal).byteLength > MAX_UAL_BYTES + || !Number.isSafeInteger(row.activatedTripleCount) + || (row.activatedTripleCount as number) < 1 + ) return undefined; + rows.push(Object.freeze({ + kaId: row.kaId, + catalogRowDigest: row.catalogRowDigest, + contentDigest: row.contentDigest, + sealDigest: row.sealDigest, + bundleDigest: row.bundleDigest, + kaUal: row.kaUal, + activatedTripleCount: row.activatedTripleCount, + }) as AssetRowV1); } return Object.freeze(rows); } -/** - * Fail-closed structural parse of raw evidence. Returns `undefined` for any - * shape deviation — unknown or missing keys, wrong types, malformed hex, - * non-integer lengths, or the wrong schema/boundary literals — so the verifier - * can record a schema rejection rather than trusting or throwing on bad input. - */ -export function parseRawEvidence(value: unknown): RawEvidenceV1 | undefined { - if (!isPlainObject(value) || !exactKeys(value, RAW_KEYS)) return undefined; - if (value.schema !== RAW_SCHEMA_ID) return undefined; - if (value.productBoundary !== PRODUCT_BOUNDARY) return undefined; - if (value.gateEvaluation !== GATE_EVALUATION) return undefined; - if (!isSafeNonNegativeInteger(value.totalCount)) return undefined; - if (!isSha256Hex(value.inventorySetRoot)) return undefined; - const authored = parseRowArray(value.authored); - const received = parseRowArray(value.received); - if (authored === undefined || received === undefined) return undefined; - return Object.freeze({ - schema: RAW_SCHEMA_ID, - productBoundary: PRODUCT_BOUNDARY, - gateEvaluation: GATE_EVALUATION, - authored, - received, - totalCount: value.totalCount, - inventorySetRoot: value.inventorySetRoot, - }); +export function rowBindsToScope(row: AssetRowV1, scope: CatalogScopeV1): boolean { + const prefix = `did:dkg:${scope.networkId}/${scope.authorAddress}/`; + if (!row.kaUal.startsWith(prefix)) return false; + const numberText = row.kaUal.slice(prefix.length); + const kaNumber = parseCanonicalU256(numberText); + if (kaNumber === undefined || kaNumber > MAX_KA_NUMBER) return false; + const packed = (BigInt(scope.authorAddress) << 96n) | kaNumber; + return packed === BigInt(row.kaId) && row.kaUal === `${prefix}${kaNumber.toString()}`; +} + +function boundedIdentifier(value: unknown, pattern: RegExp, maxBytes: number): value is string { + return typeof value === 'string' + && value.length > 0 + && value.normalize('NFC') === value + && UTF8.encode(value).byteLength <= maxBytes + && pattern.test(value); +} + +function snapshotDenseArray(value: unknown): readonly unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { + throw new TypeError('rows must be an ordinary Array'); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if ( + lengthDescriptor === undefined + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + || lengthDescriptor.value > MAX_GATE2_ROWS + ) throw new TypeError('row array length is outside the closed 0..1024 parse bound'); + const length = lengthDescriptor.value; + const keys = Reflect.ownKeys(value); + if ( + keys.some((key) => typeof key !== 'string') + || keys.length !== length + 1 + || !keys.includes('length') + ) throw new TypeError('row array must be dense and property-free'); + const result: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) throw new TypeError('row array elements must be enumerable data fields'); + result.push(descriptor.value); + } + return Object.freeze(result); +} + +function snapshotExactRecord( + value: unknown, + expectedKeys: readonly string[], +): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('value must be a plain object'); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) throw new TypeError('non-plain object'); + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length + || keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) throw new TypeError('missing or extra fields'); + const result: Record = Object.create(null); + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) throw new TypeError('fields must be enumerable data properties'); + result[key] = descriptor.value; + } + return Object.freeze(result); } diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/set-root.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/set-root.ts deleted file mode 100644 index db4e6e3b40..0000000000 --- a/devnet/rfc64-gate2-multi-asset-completeness/src/set-root.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { canonicalize, sha256Hex, UTF8 } from './canonical.ts'; -import type { AssetRowV1 } from './schema.ts'; - -// Domain-separated so a set root can never collide with a bare content hash or -// a leaf hash. Versioned so the algorithm can evolve without ambiguity. -const LEAF_DOMAIN = 'rfc64-gate2-multi-asset-completeness:leaf:v1\n'; -const SET_ROOT_DOMAIN = 'rfc64-gate2-multi-asset-completeness:set-root:v1\n'; - -/** Leaf digest binding the FULL row (ual + both digests + both lengths). */ -function leafHex(row: AssetRowV1): string { - const canonicalRow = canonicalize({ - ual: row.ual, - contentDigest: row.contentDigest, - contentLength: row.contentLength, - bundleDigest: row.bundleDigest, - bundleLength: row.bundleLength, - }); - return sha256Hex(UTF8.encode(LEAF_DOMAIN + canonicalRow)); -} - -/** - * Order-independent commitment to a SET of rows: hash the sorted leaf digests - * under a domain tag and an explicit count prefix (so different partitions can - * never concatenate to the same preimage). Independent of input order — the - * verifier recomputes this from the received rows and compares it to the raw - * artifact's declared value; it never trusts the declared field. - */ -export function computeInventorySetRoot(rows: readonly AssetRowV1[]): string { - const sortedLeaves = rows.map(leafHex).sort(); - const preimage = `${SET_ROOT_DOMAIN}${sortedLeaves.length}\n${sortedLeaves.join('\n')}`; - return sha256Hex(UTF8.encode(preimage)); -} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/src/verify.ts b/devnet/rfc64-gate2-multi-asset-completeness/src/verify.ts index b80309c77e..3c9d20c9c2 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/src/verify.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/src/verify.ts @@ -1,192 +1,243 @@ -import { isSafeNonNegativeInteger, isSha256Hex } from './canonical.ts'; +import { + compareRowsByKaId, + computeAppliedInventoryDigest, + computeCatalogScopeDigest, +} from './product-digests.ts'; import { GATE_EVALUATION, + MAX_GATE2_ROWS, PRODUCT_BOUNDARY, VERDICT_SCHEMA_ID, parseRawEvidence, + rowBindsToScope, type AssetRowV1, + type CompletenessChecksV1, type RawEvidenceV1, type VerdictV1, } from './schema.ts'; -import { computeInventorySetRoot } from './set-root.ts'; - -function rowWellFormed(row: AssetRowV1): boolean { - return ( - typeof row.ual === 'string' - && row.ual.length > 0 - && isSha256Hex(row.contentDigest) - && isSha256Hex(row.bundleDigest) - && isSafeNonNegativeInteger(row.contentLength) - && isSafeNonNegativeInteger(row.bundleLength) - ); -} - -function uals(rows: readonly AssetRowV1[]): string[] { - return rows.map((row) => row.ual); -} - -function duplicatesInOrder(values: readonly string[]): string[] { - const seen = new Set(); - const duplicates: string[] = []; - for (const value of values) { - if (seen.has(value) && !duplicates.includes(value)) duplicates.push(value); - seen.add(value); - } - return duplicates.sort(); -} -function isSortedAscending(values: readonly string[]): boolean { - for (let index = 1; index < values.length; index += 1) { - if (values[index - 1]! > values[index]!) return false; +const CHECK_NAMES: readonly (keyof CompletenessChecksV1)[] = Object.freeze([ + 'schemaWellFormed', + 'rowCountWithinBounds', + 'catalogScopeDigestMatches', + 'catalogHeadMatches', + 'headCountMatchesSignedRows', + 'bucketCountMatchesSignedRows', + 'receivedCountMatchesSignedRows', + 'signedRowsCanonicalOrder', + 'activatedRowsCanonicalOrder', + 'signedRowsUnique', + 'activatedRowsUnique', + 'rowsBindCatalogScope', + 'noMissing', + 'noExtra', + 'perRowExactMatch', + 'inventoryDigestMatches', +]); + +const REASONS: Readonly> = Object.freeze({ + schemaWellFormed: 'raw evidence failed closed structural validation', + rowCountWithinBounds: `signed and activated inventories must each contain 1..${MAX_GATE2_ROWS} rows`, + catalogScopeDigestMatches: 'declared catalog scope digest differs from the canonical scope', + catalogHeadMatches: 'receiver catalog head differs from the authored head', + headCountMatchesSignedRows: 'catalog head totalRows differs from the signed row count', + bucketCountMatchesSignedRows: 'signed bucket rowCount differs from the signed row count', + receivedCountMatchesSignedRows: 'receiver inventoryRowCount differs from the signed row count', + signedRowsCanonicalOrder: 'signed rows are not strictly increasing by mathematical kaId', + activatedRowsCanonicalOrder: 'activated rows are not strictly increasing by mathematical kaId', + signedRowsUnique: 'signed rows contain duplicate kaIds or UALs', + activatedRowsUnique: 'activated rows contain duplicate kaIds or UALs', + rowsBindCatalogScope: 'one or more KA UALs do not bind network, author, and packed kaId', + noMissing: 'activated inventory is missing one or more signed kaIds', + noExtra: 'activated inventory contains one or more unsigned kaIds', + perRowExactMatch: 'activated row differs in UAL, content, bundle, seal, catalog-row digest, or triple count', + inventoryDigestMatches: 'declared applied-inventory digest differs from the exact activated inventory', +}); + +/** Always returns a deterministic fixture-only verdict and never promotes a gate. */ +export function verify(rawInput: unknown): VerdictV1 { + try { + const raw = parseRawEvidence(rawInput); + if (raw === undefined) return schemaRejectVerdict(); + return verifyParsed(raw); + } catch { + // A revoked or adversarial Proxy may throw from any internal operation. The + // verdict remains fixed and message-free instead of reflecting attacker text. + return schemaRejectVerdict(); } - return true; } -function rowsEqual(a: AssetRowV1, b: AssetRowV1): boolean { - return ( - a.ual === b.ual - && a.contentDigest === b.contentDigest - && a.contentLength === b.contentLength - && a.bundleDigest === b.bundleDigest - && a.bundleLength === b.bundleLength +function verifyParsed(raw: RawEvidenceV1): VerdictV1 { + const signed = raw.authored.signedRows; + const activated = raw.received.activatedRows; + const signedDuplicates = duplicates(signed); + const activatedDuplicates = duplicates(activated); + + const rowCountWithinBounds = inBounds(signed.length) && inBounds(activated.length); + const recomputedCatalogScopeDigest = computeCatalogScopeDigest(raw.authored.catalogScope); + const catalogScopeDigestMatches = + recomputedCatalogScopeDigest === raw.authored.declaredCatalogScopeDigest; + const catalogHeadMatches = + raw.authored.catalogHeadDigest === raw.received.catalogHeadDigest; + const headCountMatchesSignedRows = + BigInt(raw.authored.catalogHeadTotalRows) === BigInt(signed.length); + const bucketCountMatchesSignedRows = + BigInt(raw.authored.signedBucketRowCount) === BigInt(signed.length); + const receivedCountMatchesSignedRows = + raw.received.inventoryRowCount === signed.length; + const signedRowsCanonicalOrder = isStrictlyOrdered(signed); + const activatedRowsCanonicalOrder = isStrictlyOrdered(activated); + const signedRowsUnique = signedDuplicates.kaIds.length === 0 + && signedDuplicates.uals.length === 0; + const activatedRowsUnique = activatedDuplicates.kaIds.length === 0 + && activatedDuplicates.uals.length === 0; + const rowsBindCatalogScope = [...signed, ...activated] + .every((row) => rowBindsToScope(row, raw.authored.catalogScope)); + + const signedById = firstByKaId(signed); + const activatedById = firstByKaId(activated); + const missingKaIds = [...signedById.keys()] + .filter((kaId) => !activatedById.has(kaId)) + .sort(compareKaIdStrings); + const extraKaIds = [...activatedById.keys()] + .filter((kaId) => !signedById.has(kaId)) + .sort(compareKaIdStrings); + const mismatchedKaIds = [...signedById.entries()] + .filter(([kaId, row]) => { + const observed = activatedById.get(kaId); + return observed !== undefined && !sameRow(row, observed); + }) + .map(([kaId]) => kaId) + .sort(compareKaIdStrings); + const noMissing = missingKaIds.length === 0; + const noExtra = extraKaIds.length === 0; + const perRowExactMatch = mismatchedKaIds.length === 0; + const recomputedInventoryDigest = computeAppliedInventoryDigest( + recomputedCatalogScopeDigest, + activated, ); -} + const inventoryDigestMatches = + recomputedInventoryDigest === raw.received.declaredInventoryDigest; + + const checks = Object.freeze({ + schemaWellFormed: true, + rowCountWithinBounds, + catalogScopeDigestMatches, + catalogHeadMatches, + headCountMatchesSignedRows, + bucketCountMatchesSignedRows, + receivedCountMatchesSignedRows, + signedRowsCanonicalOrder, + activatedRowsCanonicalOrder, + signedRowsUnique, + activatedRowsUnique, + rowsBindCatalogScope, + noMissing, + noExtra, + perRowExactMatch, + inventoryDigestMatches, + }) satisfies CompletenessChecksV1; + const rejectReasons = CHECK_NAMES + .filter((name) => !checks[name]) + .map((name) => REASONS[name]); -/** First-wins index by ual (duplicates are reported separately, not merged away). */ -function indexByUal(rows: readonly AssetRowV1[]): Map { - const map = new Map(); - for (const row of rows) if (!map.has(row.ual)) map.set(row.ual, row); - return map; + return Object.freeze({ + schema: VERDICT_SCHEMA_ID, + productBoundary: PRODUCT_BOUNDARY, + gateEvaluation: GATE_EVALUATION, + fixtureComplete: CHECK_NAMES.every((name) => checks[name]), + checks, + missingKaIds: Object.freeze(missingKaIds), + extraKaIds: Object.freeze(extraKaIds), + duplicateKaIds: Object.freeze(uniqueSorted([ + ...signedDuplicates.kaIds, + ...activatedDuplicates.kaIds, + ], compareKaIdStrings)), + duplicateUals: Object.freeze(uniqueSorted([ + ...signedDuplicates.uals, + ...activatedDuplicates.uals, + ])), + mismatchedKaIds: Object.freeze(mismatchedKaIds), + recomputedCatalogScopeDigest, + recomputedInventoryDigest, + rejectReasons: Object.freeze(rejectReasons), + }); } -function schemaRejectVerdict(reason: string): VerdictV1 { +function schemaRejectVerdict(): VerdictV1 { + const checks = Object.freeze(Object.fromEntries( + CHECK_NAMES.map((name) => [name, false]), + )) as Readonly; return Object.freeze({ schema: VERDICT_SCHEMA_ID, productBoundary: PRODUCT_BOUNDARY, gateEvaluation: GATE_EVALUATION, fixtureComplete: false, - checks: Object.freeze({ - schemaWellFormed: false, - authoredRowsWellFormed: false, - receivedRowsWellFormed: false, - authoredUniqueUals: false, - receivedUniqueUals: false, - authoredCanonicalOrder: false, - receivedCanonicalOrder: false, - totalCountMatchesAuthored: false, - noMissing: false, - noExtra: false, - perRowExactMatch: false, - inventorySetRootMatches: false, - }), - missing: Object.freeze([]), - extra: Object.freeze([]), + checks, + missingKaIds: Object.freeze([]), + extraKaIds: Object.freeze([]), + duplicateKaIds: Object.freeze([]), duplicateUals: Object.freeze([]), - mismatchedUals: Object.freeze([]), - recomputedInventorySetRoot: '', - rejectReasons: Object.freeze([reason]), + mismatchedKaIds: Object.freeze([]), + recomputedCatalogScopeDigest: '', + recomputedInventoryDigest: '', + rejectReasons: Object.freeze([REASONS.schemaWellFormed]), }); } -/** - * Fail-closed verification of a raw evidence artifact against the multi-asset - * completeness contract. Always returns a verdict (never throws on bad input); - * `fixtureComplete` is true only when every invariant holds. The gate - * disposition stays `not-evaluated` and the product boundary `not-connected` - * regardless — a complete fixture is not a Gate 2 pass. - */ -export function verify(rawInput: unknown): VerdictV1 { - const raw: RawEvidenceV1 | undefined = parseRawEvidence(rawInput); - if (raw === undefined) { - return schemaRejectVerdict('raw evidence failed fail-closed structural schema validation'); +function inBounds(length: number): boolean { + return length >= 1 && length <= MAX_GATE2_ROWS; +} + +function isStrictlyOrdered(rows: readonly AssetRowV1[]): boolean { + for (let index = 1; index < rows.length; index += 1) { + if (compareRowsByKaId(rows[index - 1]!, rows[index]!) >= 0) return false; } + return true; +} - const authoredUals = uals(raw.authored); - const receivedUals = uals(raw.received); - const authoredSet = new Set(authoredUals); - const receivedSet = new Set(receivedUals); - - const authoredRowsWellFormed = raw.authored.every(rowWellFormed); - const receivedRowsWellFormed = raw.received.every(rowWellFormed); - - // Uniqueness and duplicates are read from the raw arrays, before any Map/Set - // collapse, so a duplicated ual cannot silently pass. - const authoredDuplicates = duplicatesInOrder(authoredUals); - const receivedDuplicates = duplicatesInOrder(receivedUals); - const authoredUniqueUals = authoredDuplicates.length === 0; - const receivedUniqueUals = receivedDuplicates.length === 0; - const duplicateUals = [...new Set([...authoredDuplicates, ...receivedDuplicates])].sort(); - - // Canonical ordering is an independent check on the array AS GIVEN, so a - // complete-but-misordered set is still rejected. - const authoredCanonicalOrder = isSortedAscending(authoredUals); - const receivedCanonicalOrder = isSortedAscending(receivedUals); - - const totalCountMatchesAuthored = raw.totalCount === raw.authored.length; - - const missing = [...authoredSet].filter((ual) => !receivedSet.has(ual)).sort(); - const extra = [...receivedSet].filter((ual) => !authoredSet.has(ual)).sort(); - const noMissing = missing.length === 0; - const noExtra = extra.length === 0; - - const authoredIndex = indexByUal(raw.authored); - const receivedIndex = indexByUal(raw.received); - const mismatchedUals: string[] = []; - for (const ual of authoredSet) { - const a = authoredIndex.get(ual); - const b = receivedIndex.get(ual); - if (a !== undefined && b !== undefined && !rowsEqual(a, b)) mismatchedUals.push(ual); +function duplicates(rows: readonly AssetRowV1[]): { kaIds: string[]; uals: string[] } { + const seenKaIds = new Set(); + const seenUals = new Set(); + const kaIds: string[] = []; + const uals: string[] = []; + for (const row of rows) { + if (seenKaIds.has(row.kaId)) kaIds.push(row.kaId); + if (seenUals.has(row.kaUal)) uals.push(row.kaUal); + seenKaIds.add(row.kaId); + seenUals.add(row.kaUal); } - mismatchedUals.sort(); - const perRowExactMatch = mismatchedUals.length === 0; + return { + kaIds: uniqueSorted(kaIds, compareKaIdStrings), + uals: uniqueSorted(uals), + }; +} - // Recomputed independently from the RECEIVED rows; the declared field is never - // trusted. Complete + correct data yields the authored-derived declared root. - const recomputedInventorySetRoot = computeInventorySetRoot(raw.received); - const inventorySetRootMatches = recomputedInventorySetRoot === raw.inventorySetRoot; +function firstByKaId(rows: readonly AssetRowV1[]): Map { + const result = new Map(); + for (const row of rows) if (!result.has(row.kaId)) result.set(row.kaId, row); + return result; +} - const checks = Object.freeze({ - schemaWellFormed: true, - authoredRowsWellFormed, - receivedRowsWellFormed, - authoredUniqueUals, - receivedUniqueUals, - authoredCanonicalOrder, - receivedCanonicalOrder, - totalCountMatchesAuthored, - noMissing, - noExtra, - perRowExactMatch, - inventorySetRootMatches, - }); +function sameRow(left: AssetRowV1, right: AssetRowV1): boolean { + return left.kaId === right.kaId + && left.catalogRowDigest === right.catalogRowDigest + && left.contentDigest === right.contentDigest + && left.sealDigest === right.sealDigest + && left.bundleDigest === right.bundleDigest + && left.kaUal === right.kaUal + && left.activatedTripleCount === right.activatedTripleCount; +} - const rejectReasons: string[] = []; - if (!authoredRowsWellFormed) rejectReasons.push('one or more authored rows are malformed'); - if (!receivedRowsWellFormed) rejectReasons.push('one or more received rows are malformed'); - if (!authoredUniqueUals) rejectReasons.push(`authored contains duplicate uals: ${authoredDuplicates.join(', ')}`); - if (!receivedUniqueUals) rejectReasons.push(`received contains duplicate uals: ${receivedDuplicates.join(', ')}`); - if (!authoredCanonicalOrder) rejectReasons.push('authored rows are not in canonical ual order'); - if (!receivedCanonicalOrder) rejectReasons.push('received rows are not in canonical ual order'); - if (!totalCountMatchesAuthored) rejectReasons.push(`totalCount ${raw.totalCount} does not equal authored count ${raw.authored.length}`); - if (!noMissing) rejectReasons.push(`received is missing authored uals: ${missing.join(', ')}`); - if (!noExtra) rejectReasons.push(`received has extra uals not authored: ${extra.join(', ')}`); - if (!perRowExactMatch) rejectReasons.push(`row content/bundle digests or lengths differ for uals: ${mismatchedUals.join(', ')}`); - if (!inventorySetRootMatches) rejectReasons.push('recomputed inventorySetRoot does not match the declared value'); - - const fixtureComplete = rejectReasons.length === 0 - && Object.values(checks).every((value) => value === true); +function compareKaIdStrings(left: string, right: string): number { + const a = BigInt(left); + const b = BigInt(right); + return a < b ? -1 : a > b ? 1 : 0; +} - return Object.freeze({ - schema: VERDICT_SCHEMA_ID, - productBoundary: PRODUCT_BOUNDARY, - gateEvaluation: GATE_EVALUATION, - fixtureComplete, - checks, - missing: Object.freeze(missing), - extra: Object.freeze(extra), - duplicateUals: Object.freeze(duplicateUals), - mismatchedUals: Object.freeze(mismatchedUals), - recomputedInventorySetRoot, - rejectReasons: Object.freeze(rejectReasons), - }); +function uniqueSorted( + values: readonly string[], + compare: (left: string, right: string) => number = (left, right) => left.localeCompare(right), +): string[] { + return [...new Set(values)].sort(compare); } diff --git a/devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts b/devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts index d21d8a7892..a63499d69b 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts @@ -2,183 +2,326 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { canonicalDocument, canonicalize } from '../src/canonical.ts'; -import { generateCompleteFixture } from '../src/generate.ts'; +import { FIXTURE_SCOPE, generateCompleteFixture } from '../src/generate.ts'; +import { + computeAppliedInventoryDigest, + computeCatalogScopeDigest, +} from '../src/product-digests.ts'; +import type { AssetRowV1, CatalogScopeV1 } from '../src/schema.ts'; import { verify } from '../src/verify.ts'; const FIXTURE_COUNT = 8; +const OTHER_DIGEST = `0x${'cc'.repeat(32)}`; +const AUTHOR = FIXTURE_SCOPE.authorAddress; -// Mutable deep clone of a frozen raw artifact; the mutated artifact must still -// parse so each test exercises the targeted invariant, not an incidental reject. -function clone(): any { - return JSON.parse(JSON.stringify(generateCompleteFixture(FIXTURE_COUNT))); +function clone(count = FIXTURE_COUNT): any { + return JSON.parse(JSON.stringify(generateCompleteFixture(count))); } -const OTHER_DIGEST = 'c'.repeat(64); -const A_DIGEST = 'a'.repeat(64); - -function differentDigest(current: string): string { - return current === A_DIGEST ? OTHER_DIGEST : A_DIGEST; +function mutateDigest(current: string): string { + return current === OTHER_DIGEST ? `0x${'dd'.repeat(32)}` : OTHER_DIGEST; } -test('a complete fixture verifies with every invariant satisfied', () => { - const verdict = verify(generateCompleteFixture(FIXTURE_COUNT)); +test('complete product-shaped evidence satisfies every fixture invariant', () => { + const raw = generateCompleteFixture(FIXTURE_COUNT); + const verdict = verify(raw); assert.equal(verdict.fixtureComplete, true); - assert.deepEqual(verdict.missing, []); - assert.deepEqual(verdict.extra, []); + assert.deepEqual(verdict.missingKaIds, []); + assert.deepEqual(verdict.extraKaIds, []); + assert.deepEqual(verdict.duplicateKaIds, []); assert.deepEqual(verdict.duplicateUals, []); - assert.deepEqual(verdict.mismatchedUals, []); + assert.deepEqual(verdict.mismatchedKaIds, []); assert.deepEqual(verdict.rejectReasons, []); for (const [name, value] of Object.entries(verdict.checks)) { - assert.equal(value, true, `check ${name} must be true for a complete fixture`); + assert.equal(value, true, `${name} must be true`); + } + assert.equal(verdict.recomputedCatalogScopeDigest, raw.authored.declaredCatalogScopeDigest); + assert.equal(verdict.recomputedInventoryDigest, raw.received.declaredInventoryDigest); +}); + +test('raw and verdict documents are two-run byte-identical RFC 8785 JCS plus one LF', () => { + const rawA = canonicalDocument(generateCompleteFixture(FIXTURE_COUNT)); + const rawB = canonicalDocument(generateCompleteFixture(FIXTURE_COUNT)); + const verdictA = canonicalDocument(verify(generateCompleteFixture(FIXTURE_COUNT))); + const verdictB = canonicalDocument(verify(generateCompleteFixture(FIXTURE_COUNT))); + assert.equal(rawA, rawB); + assert.equal(verdictA, verdictB); + for (const document of [rawA, verdictA]) { + assert.ok(document.endsWith('\n')); + assert.ok(!document.endsWith('\n\n')); + assert.equal(canonicalDocument(JSON.parse(document) as never), document); + } +}); + +test('fixture completeness cannot overstate a connected or evaluated Gate 2', () => { + for (const verdict of [verify(generateCompleteFixture(2)), verify({ invalid: true })]) { + const document = canonicalDocument(verdict); + assert.equal(verdict.productBoundary, 'not-connected'); + assert.equal(verdict.gateEvaluation, 'not-evaluated'); + assert.doesNotMatch(document, /"gateEvaluation":"(?:PASS|passed|green|evaluated)"/u); } +}); + +test('scope and applied-inventory digest framing matches the current product vector', () => { + const scope: CatalogScopeV1 = Object.freeze({ ...FIXTURE_SCOPE }); + const rows = [productVectorRow(2), productVectorRow(10), productVectorRow(100)]; + const scopeDigest = computeCatalogScopeDigest(scope); + assert.equal( + computeAppliedInventoryDigest(scopeDigest, rows), + '0x299d93f46a4baa1a0099f3ebfabb43f2070dc590313c4abfcf22f3541766a79e', + ); assert.equal( - verdict.recomputedInventorySetRoot, - generateCompleteFixture(FIXTURE_COUNT).inventorySetRoot, - 'verifier recomputes the same root it declares', + computeAppliedInventoryDigest(scopeDigest, [...rows].reverse()), + '0x299d93f46a4baa1a0099f3ebfabb43f2070dc590313c4abfcf22f3541766a79e', + 'digest sorts by mathematical kaId, not caller or lexical UAL order', ); }); -test('the fixture is byte-deterministic across two generations', () => { - const a = canonicalDocument(generateCompleteFixture(FIXTURE_COUNT)); - const b = canonicalDocument(generateCompleteFixture(FIXTURE_COUNT)); - assert.equal(a, b); - assert.ok(a.endsWith('\n'), 'canonical document ends with exactly one LF'); +test('declared catalog-scope and inventory roots are independently recomputed', () => { + const wrongScope = clone(); + wrongScope.authored.declaredCatalogScopeDigest = mutateDigest( + wrongScope.authored.declaredCatalogScopeDigest, + ); + const scopeVerdict = verify(wrongScope); + assert.equal(scopeVerdict.fixtureComplete, false); + assert.equal(scopeVerdict.checks.catalogScopeDigestMatches, false); + assert.notEqual( + scopeVerdict.recomputedCatalogScopeDigest, + wrongScope.authored.declaredCatalogScopeDigest, + ); + + const wrongInventory = clone(); + wrongInventory.received.declaredInventoryDigest = mutateDigest( + wrongInventory.received.declaredInventoryDigest, + ); + const inventoryVerdict = verify(wrongInventory); + assert.equal(inventoryVerdict.fixtureComplete, false); + assert.equal(inventoryVerdict.checks.inventoryDigestMatches, false); + assert.notEqual( + inventoryVerdict.recomputedInventoryDigest, + wrongInventory.received.declaredInventoryDigest, + ); }); -test('completeness is distinct from any gate pass, on complete and rejected verdicts', () => { - const complete = canonicalDocument(verify(generateCompleteFixture(FIXTURE_COUNT))); - const rejected = canonicalDocument(verify({ not: 'valid' })); - for (const doc of [complete, rejected]) { - assert.match(doc, /"productBoundary":"not-connected"/); - assert.match(doc, /"gateEvaluation":"not-evaluated"/); - // No token that could be read as a real Gate 2 pass. - assert.doesNotMatch(doc, /pass(ed)?/i); - assert.doesNotMatch(doc, /"gateEvaluation":"(passed|pass|ok|green|evaluated)"/); - } - // fixtureComplete may be true, but the gate disposition never changes. - assert.equal(verify(generateCompleteFixture(FIXTURE_COUNT)).gateEvaluation, 'not-evaluated'); +test('authored and receiver heads must be the same exact digest', () => { + const raw = clone(); + raw.received.catalogHeadDigest = mutateDigest(raw.received.catalogHeadDigest); + const verdict = verify(raw); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.catalogHeadMatches, false); }); -test('fail-closed schema rejection for structural deviations', () => { - const base = clone(); - const rejects: Array<[string, unknown]> = [ - ['unknown extra top-level field', { ...base, surprise: 1 }], - ['missing field', (() => { const r = clone(); delete r.totalCount; return r; })()], - ['wrong schema id', { ...clone(), schema: 'other' }], - ['tampered productBoundary marker', { ...clone(), productBoundary: 'connected' }], - ['tampered gateEvaluation marker', { ...clone(), gateEvaluation: 'passed' }], - ['non-hex inventorySetRoot', { ...clone(), inventorySetRoot: 'zz' }], - ['non-integer length in a row', (() => { const r = clone(); r.received[0].contentLength = 1.5; return r; })()], - ['empty ual in a row', (() => { const r = clone(); r.received[0].ual = ''; return r; })()], - ['non-hex digest in a row', (() => { const r = clone(); r.received[0].contentDigest = 'nothex'; return r; })()], - ['unknown field in a row', (() => { const r = clone(); r.received[0].extra = true; return r; })()], - ['authored is not an array', { ...clone(), authored: {} }], - ['non-parseable input', undefined], +test('head, signed bucket, and receiver counts each bind the exact row set', () => { + const mutations: Array<[string, (raw: any) => void]> = [ + ['head', (raw) => { raw.authored.catalogHeadTotalRows = '9'; }], + ['bucket', (raw) => { raw.authored.signedBucketRowCount = '9'; }], + ['receiver', (raw) => { raw.received.inventoryRowCount = 9; }], ]; - for (const [name, input] of rejects) { - const verdict = verify(input); - assert.equal(verdict.fixtureComplete, false, `${name} must be rejected`); - assert.equal(verdict.checks.schemaWellFormed, false, `${name} must fail schemaWellFormed`); - assert.ok(verdict.rejectReasons.length > 0, `${name} must record a reject reason`); - assert.equal(verdict.gateEvaluation, 'not-evaluated'); + for (const [label, mutate] of mutations) { + const raw = clone(); + mutate(raw); + const verdict = verify(raw); + assert.equal(verdict.fixtureComplete, false, label); } }); -test('missing received row is rejected (noMissing)', () => { - const r = clone(); - const dropped = r.received[3].ual; - r.received.splice(3, 1); - const verdict = verify(r); - assert.equal(verdict.fixtureComplete, false); +test('missing, extra, duplicate kaId, and duplicate UAL are rejected before set collapse', () => { + const missing = clone(); + const missingId = missing.received.activatedRows[3].kaId; + missing.received.activatedRows.splice(3, 1); + let verdict = verify(missing); assert.equal(verdict.checks.noMissing, false); - assert.ok(verdict.missing.includes(dropped)); -}); + assert.ok(verdict.missingKaIds.includes(missingId)); -test('extra received row is rejected (noExtra)', () => { - const r = clone(); - // A valid, well-formed row whose ual sorts after all fixture uals so canonical - // order stays intact and only the extra-set invariant is tripped. - const extraUal = 'did:dkg:gate2-mac-fixture/999999'; - r.received.push({ ual: extraUal, contentDigest: A_DIGEST, contentLength: 1, bundleDigest: A_DIGEST, bundleLength: 2 }); - const verdict = verify(r); - assert.equal(verdict.fixtureComplete, false); + const extra = clone(); + extra.received.activatedRows.push(clone(9).received.activatedRows[8]); + verdict = verify(extra); assert.equal(verdict.checks.noExtra, false); - assert.equal(verdict.checks.receivedCanonicalOrder, true, 'appended-after row keeps canonical order'); - assert.ok(verdict.extra.includes(extraUal)); -}); + assert.equal(verdict.extraKaIds.length, 1); -test('duplicate received ual is rejected (uniqueness), isolated from ordering', () => { - const r = clone(); - // Insert an adjacent clone so ual order stays non-decreasing; only uniqueness trips. - r.received.splice(1, 0, { ...r.received[0] }); - const verdict = verify(r); - assert.equal(verdict.fixtureComplete, false); - assert.equal(verdict.checks.receivedUniqueUals, false); - assert.equal(verdict.checks.receivedCanonicalOrder, true, 'adjacent duplicate keeps non-decreasing order'); - assert.ok(verdict.duplicateUals.includes(r.received[0].ual)); + const duplicate = clone(); + duplicate.received.activatedRows.splice(1, 0, { ...duplicate.received.activatedRows[0] }); + verdict = verify(duplicate); + assert.equal(verdict.checks.activatedRowsUnique, false); + assert.ok(verdict.duplicateKaIds.includes(duplicate.received.activatedRows[0].kaId)); + + const duplicateUal = clone(); + duplicateUal.received.activatedRows[1].kaUal = duplicateUal.received.activatedRows[0].kaUal; + verdict = verify(duplicateUal); + assert.equal(verdict.checks.activatedRowsUnique, false); + assert.ok(verdict.duplicateUals.includes(duplicateUal.received.activatedRows[0].kaUal)); }); -test('mis-ordered but complete set is rejected (canonicalOrder), isolated', () => { - const r = clone(); - [r.received[0], r.received[1]] = [r.received[1], r.received[0]]; - const verdict = verify(r); - assert.equal(verdict.fixtureComplete, false); - assert.equal(verdict.checks.receivedCanonicalOrder, false); - // The set itself is unchanged, so completeness/root/count invariants still hold. +test('signed and activated row order is mathematical kaId order, not lexical UAL order', () => { + const authored = clone(); + [authored.authored.signedRows[0], authored.authored.signedRows[1]] = [ + authored.authored.signedRows[1], + authored.authored.signedRows[0], + ]; + assert.equal(verify(authored).checks.signedRowsCanonicalOrder, false); + + const activated = clone(); + [activated.received.activatedRows[0], activated.received.activatedRows[1]] = [ + activated.received.activatedRows[1], + activated.received.activatedRows[0], + ]; + const verdict = verify(activated); + assert.equal(verdict.checks.activatedRowsCanonicalOrder, false); assert.equal(verdict.checks.noMissing, true); assert.equal(verdict.checks.noExtra, true); - assert.equal(verdict.checks.inventorySetRootMatches, true); - assert.equal(verdict.checks.totalCountMatchesAuthored, true); + assert.equal(verdict.checks.inventoryDigestMatches, true, 'inventory root is order-normalized'); }); -for (const field of ['contentDigest', 'bundleDigest'] as const) { - test(`per-row ${field} mismatch is rejected (perRowExactMatch + set root)`, () => { - const r = clone(); - r.received[2][field] = differentDigest(r.received[2][field]); - const verdict = verify(r); +for (const field of [ + 'catalogRowDigest', + 'contentDigest', + 'sealDigest', + 'bundleDigest', +] as const) { + test(`exact ${field} mismatch is rejected`, () => { + const raw = clone(); + raw.received.activatedRows[2][field] = mutateDigest(raw.received.activatedRows[2][field]); + const verdict = verify(raw); assert.equal(verdict.fixtureComplete, false); assert.equal(verdict.checks.perRowExactMatch, false); - assert.equal(verdict.checks.inventorySetRootMatches, false, 'changing a row changes the received set root'); - assert.ok(verdict.mismatchedUals.includes(r.received[2].ual)); + assert.ok(verdict.mismatchedKaIds.includes(raw.received.activatedRows[2].kaId)); + if (field === 'bundleDigest') { + assert.equal( + verdict.checks.inventoryDigestMatches, + true, + 'bundle is an exact equality check because the legacy inventory digest omits it', + ); + } }); } -for (const field of ['contentLength', 'bundleLength'] as const) { - test(`per-row ${field} mismatch is rejected (perRowExactMatch + set root)`, () => { - const r = clone(); - r.received[2][field] = r.received[2][field] + 1; - const verdict = verify(r); - assert.equal(verdict.fixtureComplete, false); - assert.equal(verdict.checks.perRowExactMatch, false); - assert.equal(verdict.checks.inventorySetRootMatches, false); - assert.ok(verdict.mismatchedUals.includes(r.received[2].ual)); +test('exact UAL and activated triple count mismatches are rejected', () => { + const wrongUal = clone(); + wrongUal.received.activatedRows[2].kaUal = + `did:dkg:${FIXTURE_SCOPE.networkId}/${AUTHOR}/999`; + let verdict = verify(wrongUal); + assert.equal(verdict.checks.rowsBindCatalogScope, false); + assert.equal(verdict.checks.perRowExactMatch, false); + + const wrongCount = clone(); + wrongCount.received.activatedRows[2].activatedTripleCount += 1; + verdict = verify(wrongCount); + assert.equal(verdict.checks.perRowExactMatch, false); + assert.equal(verdict.checks.inventoryDigestMatches, false); +}); + +test('UAL must encode the exact network, author, and packed kaId', () => { + const cases = [ + `did:dkg:otp:999/${AUTHOR}/1`, + `did:dkg:${FIXTURE_SCOPE.networkId}/0x2222222222222222222222222222222222222222/1`, + `did:dkg:${FIXTURE_SCOPE.networkId}/${AUTHOR}/01`, + `did:dkg:${FIXTURE_SCOPE.networkId}/${AUTHOR}/999`, + ]; + for (const kaUal of cases) { + const raw = clone(); + raw.authored.signedRows[0].kaUal = kaUal; + raw.received.activatedRows[0].kaUal = kaUal; + assert.equal(verify(raw).checks.rowsBindCatalogScope, false, kaUal); + } +}); + +test('the exact 1 and 1024 boundaries pass; 0 and 1025 fail closed', () => { + assert.equal(verify(generateCompleteFixture(1)).fixtureComplete, true); + assert.equal(verify(generateCompleteFixture(1024)).fixtureComplete, true); + assert.throws(() => generateCompleteFixture(0), RangeError); + assert.throws(() => generateCompleteFixture(1025), RangeError); + + const empty = clone(1); + empty.authored.catalogHeadTotalRows = '0'; + empty.authored.signedBucketRowCount = '0'; + empty.authored.signedRows = []; + empty.received.inventoryRowCount = 0; + empty.received.activatedRows = []; + assert.equal(verify(empty).checks.rowCountWithinBounds, false); + + let ownKeysInvoked = false; + const oversized = new Proxy(new Array(1025), { + ownKeys(target) { + ownKeysInvoked = true; + return Reflect.ownKeys(target); + }, }); -} + const raw = clone(1); + raw.authored.signedRows = oversized; + assert.equal(verify(raw).fixtureComplete, false); + assert.equal(ownKeysInvoked, false, 'length cap is checked before row-key enumeration'); +}); -test('wrong totalCount is rejected (totalCountMatchesAuthored), isolated', () => { - const r = clone(); - r.totalCount = r.authored.length + 1; - const verdict = verify(r); - assert.equal(verdict.fixtureComplete, false); - assert.equal(verdict.checks.totalCountMatchesAuthored, false); - assert.equal(verdict.checks.noMissing, true); - assert.equal(verdict.checks.inventorySetRootMatches, true); +test('schema boundary rejects unknown fields, malformed scalars, and accessors', () => { + const malformed: unknown[] = [ + { ...clone(), surprise: true }, + (() => { const raw = clone(); delete raw.received.inventoryRowCount; return raw; })(), + (() => { const raw = clone(); raw.authored.catalogHeadTotalRows = '01'; return raw; })(), + (() => { const raw = clone(); raw.received.activatedRows[0].sealDigest = 'bad'; return raw; })(), + (() => { const raw = clone(); raw.authored.catalogScope.bucketCount = '2'; return raw; })(), + ]; + for (const raw of malformed) { + const verdict = verify(raw); + assert.equal(verdict.fixtureComplete, false); + assert.equal(verdict.checks.schemaWellFormed, false); + assert.deepEqual(verdict.rejectReasons, ['raw evidence failed closed structural validation']); + } + + let getterInvoked = false; + const accessor = clone(); + Object.defineProperty(accessor, 'authored', { + enumerable: true, + get() { + getterInvoked = true; + return clone().authored; + }, + }); + assert.equal(verify(accessor).fixtureComplete, false); + assert.equal(getterInvoked, false); }); -test('tampered declared inventorySetRoot is rejected, never trusted (isolated)', () => { - const r = clone(); - r.inventorySetRoot = differentDigest(r.inventorySetRoot); - const verdict = verify(r); - assert.equal(verdict.fixtureComplete, false); - assert.equal(verdict.checks.inventorySetRootMatches, false); - // Every other invariant still holds: only the declared root was tampered. - assert.equal(verdict.checks.noMissing, true); - assert.equal(verdict.checks.noExtra, true); - assert.equal(verdict.checks.perRowExactMatch, true); - assert.notEqual(verdict.recomputedInventorySetRoot, r.inventorySetRoot); +test('switching Proxy get traps are never invoked at the JavaScript input boundary', () => { + let gets = 0; + const proxy = new Proxy(clone(), { + get(target, key, receiver) { + gets += 1; + return Reflect.get(target, key, receiver); + }, + }); + assert.equal(verify(proxy).fixtureComplete, true); + assert.equal(gets, 0); }); -test('canonical JSON rejects non-integer numbers', () => { +test('canonical JCS rejects accessors, sparse arrays, unsafe numbers, and lone surrogates', () => { + let getterInvoked = false; + const accessor = Object.defineProperty({}, 'x', { + enumerable: true, + get() { + getterInvoked = true; + return 1; + }, + }); + assert.throws(() => canonicalize(accessor as never)); + assert.equal(getterInvoked, false); + assert.throws(() => canonicalize(new Array(2) as never)); assert.throws(() => canonicalize({ n: 1.5 } as never)); - assert.throws(() => canonicalize({ n: Number.NaN } as never)); + assert.throws(() => canonicalize({ s: '\ud800' } as never)); + assert.equal(canonicalize({ '\u{1f600}': 1, '\ufffd': 2 }), '{"😀":1,"�":2}'); }); + +function productVectorRow(number: number): AssetRowV1 { + const kaId = ((BigInt(AUTHOR) << 96n) | BigInt(number)).toString(); + return Object.freeze({ + kaId, + catalogRowDigest: digest(number * 4), + contentDigest: digest((number * 4) + 1), + sealDigest: digest((number * 4) + 2), + bundleDigest: digest((number * 4) + 3), + kaUal: `did:dkg:otp:20430/${AUTHOR}/${number}`, + activatedTripleCount: number + 1, + }); +} + +function digest(seed: number): string { + return `0x${seed.toString(16).padStart(64, '0')}`; +} From 2295e8fce81e8a80f62ad9714f1cc6205ff42311 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:52:49 +0200 Subject: [PATCH 151/292] feat(agent): expose exact successor evidence --- packages/agent/src/dkg-agent-rfc64-catalog.ts | 39 +++++++++++++++++++ ...-successor-publication.integration.test.ts | 19 +++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 35364b283c..ec60387b85 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -25,6 +25,7 @@ import { assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, assertSignedAuthorCatalogHeadEnvelopeV1, assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, + computeAuthorCatalogScopeDigestV1, computeControlSignatureVariantDigestHex, deriveAuthorCatalogScopeFromHeadV1, type AssertionCoordinateV1, @@ -245,6 +246,10 @@ export interface PublishOpenAuthorCatalogSuccessorAssetResultV1 { readonly catalogRowDigest: Digest32V1; readonly bundleDigest: Digest32V1; readonly contentDigest: Digest32V1; + /** Exact verified graph-scoped author seal committed by the catalog row. */ + readonly sealDigest: Digest32V1; + /** Checked safe-integer form of the verified public projection triple count. */ + readonly activatedTripleCount: number; readonly contentByteLength: ByteLengthV1; readonly bundleByteLength: ByteLengthV1; readonly kaUal: string; @@ -254,6 +259,12 @@ export interface PublishOpenAuthorCatalogExactSetSuccessorResultV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; readonly headObjectDigest: Digest32V1; readonly signatureVariantDigest: Digest32V1; + /** Detached exact catalog scope derived from the signed successor head. */ + readonly catalogScope: Readonly; + /** Canonical digest independently recomputed from `catalogScope`. */ + readonly catalogScopeDigest: Digest32V1; + /** Exact signed bucket row count, sourced independently of head `totalRows`. */ + readonly signedBucketRowCount: CountV1; /** Strictly increasing by mathematical KA ID. */ readonly assets: readonly Readonly[]; readonly inventoryRowCount: CountV1; @@ -487,11 +498,28 @@ export class Rfc64CatalogMethods extends DKGAgentBase { announcement, peers, }); + const catalogScope = Object.freeze({ ...scope }) as Readonly; + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(catalogScope); + const signedBucketRowCount = produced.publication.bucket?.payload.rows.length.toString(); + if ( + signedBucketRowCount === undefined + || signedBucketRowCount !== head.payload.totalRows + || produced.assets.some( + (asset) => asset.projection.catalogScopeDigest !== catalogScopeDigest, + ) + ) { + throw new Error('RFC-64 successor evidence differs from its verified signed catalog scope'); + } const assets = produced.assets.map((asset) => Object.freeze({ kaId: asset.row.kaId, catalogRowDigest: asset.sealBinding.catalogRowDigest, bundleDigest: asset.bundleDigest, contentDigest: asset.projection.projectionDigest, + sealDigest: asset.sealBinding.sealDigest, + activatedTripleCount: countToSafeInteger( + asset.projection.publicTripleCount, + `RFC-64 successor asset ${asset.row.kaId} public triple count`, + ), contentByteLength: asset.projection.projectionByteLength, bundleByteLength: asset.row.transfer.byteLength, kaUal: asset.projection.kaUal, @@ -500,6 +528,9 @@ export class Rfc64CatalogMethods extends DKGAgentBase { announcement: delivery.announcement, headObjectDigest: headKeys.objectDigest, signatureVariantDigest: headKeys.signatureVariantDigest, + catalogScope, + catalogScopeDigest, + signedBucketRowCount: signedBucketRowCount as CountV1, assets: Object.freeze(assets), inventoryRowCount: head.payload.totalRows, announcedPeers: delivery.announcedPeers, @@ -763,6 +794,14 @@ export class Rfc64CatalogMethods extends DKGAgentBase { } } +function countToSafeInteger(value: CountV1, label: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed.toString() !== value) { + throw new Error(`${label} is outside the exact safe-integer evidence boundary`); + } + return parsed; +} + interface PublicOpenRootLaneHistoryV1 { readonly previousHead: SignedAuthorCatalogHeadEnvelopeV1; readonly previousDirectoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; diff --git a/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts index 31dc7df004..912d898e18 100644 --- a/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-successor-publication.integration.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { assertCanonicalGraphScopedAuthorSealV1, buildAuthorAttestationTypedData, + computeAuthorCatalogScopeDigestV1, decodeOpaqueKaBundleV1, type AuthorCatalogScopeV1, type CanonicalGraphScopedAuthorSealV1, @@ -197,11 +198,29 @@ describe('RFC-64 DKGAgent public/open successor publication', () => { expect(second.inventoryRowCount).toBe('2'); expect(third.announcement.catalogVersion).toBe('3'); expect(third.inventoryRowCount).toBe('3'); + expect(third.signedBucketRowCount).toBe('3'); + expect(third.catalogScope).toEqual({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + }); + expect(Object.isFrozen(third.catalogScope)).toBe(true); + expect(third.catalogScopeDigest).toBe(computeAuthorCatalogScopeDigestV1(third.catalogScope)); expect(third.assets.map(({ kaId }) => kaId)).toEqual(expectedKaIds); expect(third.assets.map(({ kaUal }) => kaUal)).toEqual( [KA_NUMBER, SECOND_KA_NUMBER, THIRD_KA_NUMBER] .map((number) => `did:dkg:${NETWORK_ID}/${AUTHOR}/${number}`), ); + expect(third.assets.map(({ activatedTripleCount }) => activatedTripleCount)).toEqual([2, 2, 2]); + expect(third.assets.every(({ sealDigest }) => /^0x[0-9a-f]{64}$/u.test(sealDigest))).toBe(true); + expect(Object.isFrozen(third.assets)).toBe(true); + expect(third.assets.every(Object.isFrozen)).toBe(true); expect(await agent.readRfc64StagedAuthorCatalogHeadV1({ objectDigest: third.headObjectDigest, signatureVariantDigest: third.signatureVariantDigest, From adcdf13532afebb4cba3c3058b1b875ee4afe462 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:05:28 +0200 Subject: [PATCH 152/292] fix(agent): derive evidence from signed successor --- packages/agent/src/dkg-agent-rfc64-catalog.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index ec60387b85..1318abcdc9 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -498,12 +498,16 @@ export class Rfc64CatalogMethods extends DKGAgentBase { announcement, peers, }); - const catalogScope = Object.freeze({ ...scope }) as Readonly; + const catalogScope = Object.freeze({ + ...deriveAuthorCatalogScopeFromHeadV1(head.payload), + }) as Readonly; const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(catalogScope); + const predecessorScopeDigest = computeAuthorCatalogScopeDigestV1(scope); const signedBucketRowCount = produced.publication.bucket?.payload.rows.length.toString(); if ( signedBucketRowCount === undefined || signedBucketRowCount !== head.payload.totalRows + || catalogScopeDigest !== predecessorScopeDigest || produced.assets.some( (asset) => asset.projection.catalogScopeDigest !== catalogScopeDigest, ) From c9168525d42f7828435f7355cf2dd60a1133f5b7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:05:28 +0200 Subject: [PATCH 153/292] test(devnet): connect Gate 2 real-process harness --- .../.gitignore | 1 + .../README.md | 30 +- .../adapter-process.ts | 680 ++++++++++ .../agent-child.ts | 210 +++ .../live-verifier.ts | 620 +++++++++ .../model.ts | 65 + .../package.json | 14 +- .../run.ts | 1128 +++++++++++++++++ .../test/live-verifier.test.ts | 233 ++++ .../tsconfig.json | 16 +- .../verify-live.ts | 24 + package.json | 5 + pnpm-lock.yaml | 21 + pnpm-workspace.yaml | 1 + 14 files changed, 3019 insertions(+), 29 deletions(-) create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/.gitignore create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/model.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/run.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts diff --git a/devnet/rfc64-gate2-multi-asset-completeness/.gitignore b/devnet/rfc64-gate2-multi-asset-completeness/.gitignore new file mode 100644 index 0000000000..d4f588edfe --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/.gitignore @@ -0,0 +1 @@ +artifacts/ diff --git a/devnet/rfc64-gate2-multi-asset-completeness/README.md b/devnet/rfc64-gate2-multi-asset-completeness/README.md index 8a4e9fe19a..e810d81f52 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/README.md +++ b/devnet/rfc64-gate2-multi-asset-completeness/README.md @@ -41,38 +41,34 @@ their keys, and caps depth, nodes, strings, input bytes, and output bytes. ## Real two-process adapter mapping -The adapter should build one raw artifact from these product values. Entries -marked **gap** exist in the internal producer result but are not yet carried by -`PublishOpenAuthorCatalogExactSetSuccessorResultV1`; exposing detached read-only -copies is the minimal product-to-harness adapter seam. +The connected adapter builds one raw artifact from these product values. The +author-side result fields are detached, read-only observations of the already +verified signed successor and its durable bundles. | Contract field | Product source | | --- | --- | -| `authored.catalogScope` | **gap:** expose the exact `deriveAuthorCatalogScopeFromHeadV1(head.payload)` snapshot from `publishOpenAuthorCatalogExactSetSuccessorV1` | -| `authored.declaredCatalogScopeDigest` | **gap:** expose `result.assets[0].projection.catalogScopeDigest` (and retain the producer's all-assets equality check) | +| `authored.catalogScope` | `PublishOpenAuthorCatalogExactSetSuccessorResultV1.catalogScope`, derived from the actual signed successor head | +| `authored.declaredCatalogScopeDigest` | `.catalogScopeDigest`, checked against predecessor scope and every verified asset projection | | `authored.catalogHeadDigest` | existing public result `.headObjectDigest` | | `authored.catalogHeadTotalRows` | existing public result `.inventoryRowCount` (assigned directly from `head.payload.totalRows`) | -| `authored.signedBucketRowCount` | **gap:** expose `produced.publication.bucket.payload.rows.length.toString()` independently of head count | +| `authored.signedBucketRowCount` | `.signedBucketRowCount`, sourced independently from the signed bucket | | `authored.signedRows[].kaId` | existing public result `.assets[].kaId` | | `authored.signedRows[].catalogRowDigest` | existing public result `.assets[].catalogRowDigest` | | `authored.signedRows[].contentDigest` | existing public result `.assets[].contentDigest` | -| `authored.signedRows[].sealDigest` | **gap:** expose internal `produced.assets[].sealBinding.sealDigest` | +| `authored.signedRows[].sealDigest` | `.assets[].sealDigest` | | `authored.signedRows[].bundleDigest` | existing public result `.assets[].bundleDigest` | | `authored.signedRows[].kaUal` | existing public result `.assets[].kaUal` | -| `authored.signedRows[].activatedTripleCount` | **gap:** expose a checked safe-integer conversion of internal `produced.assets[].projection.publicTripleCount` | +| `authored.signedRows[].activatedTripleCount` | `.assets[].activatedTripleCount`, a checked safe-integer conversion | | `received.catalogHeadDigest` | `Rfc64PublicCatalogNativeMultiAssetActivationEvidenceV1.catalogHeadDigest` | | `received.declaredInventoryDigest` | `.inventoryDigest`, also equal to the durable applied-head post-read | | `received.inventoryRowCount` | `.inventoryRowCount` | | `received.activatedRows` | `.rows`, projected to the seven contract row fields (omit `swmGraph` and `authorship`) | -The receiver side is already sufficient. The author side needs the five -read-only gaps above; without them, a harness would have to synthesize scope, -bucket count, seal, or triple-count claims from its own request instead of -recording verified product output. After that small seam, orchestration can -expose the exact-set publish and multi-row synchronization calls through two real -DKGAgent child processes, carry these values verbatim, read the receiver's -durable applied head using the exposed scope digest, and use a separate connected -Gate 2 schema to make a real gate disposition. +The receiver side and author side are now both sufficient for a connected +adapter. The orchestration exposes exact-set publication and multi-row +synchronization through two real DKGAgent child processes, carries these values +verbatim, and reads the receiver's durable applied head using the exposed scope +digest. A separate connected schema makes the real gate disposition. ## Commands diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts new file mode 100644 index 0000000000..b23e15ec54 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -0,0 +1,680 @@ +import { mkdir, open, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; +import { createInterface } from 'node:readline'; + +import { multiaddr } from '@multiformats/multiaddr'; +import { + DKGAgent, + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + produceDirectAuthorCatalogIssuerDelegationV1, + produceEmptyAuthorCatalogGenesisV1, +} from '@origintrail-official/dkg-agent'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { + computeControlSignatureVariantDigestHex, + type AuthorCatalogScopeV1, + type Digest32V1, + type EvmAddressV1, + type SignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + OxigraphStore, + quadsToNQuads, + type Quad, +} from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; + +import { + GATE2_ADAPTER_PROTOCOL_VERSION, + GATE2_AGENT_EVENT_PREFIX, + GATE2_REAL_DKG_AGENT_ADAPTER_ID, +} from './model.js'; + +const role = process.argv[2]; +const dataDirInput = process.env.DKG_RFC64_GATE2_ADAPTER_DATA_DIR; +const masterKeyHex = process.env.DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX; +if (role !== 'author' && role !== 'receiver') throw new Error('adapter role is required'); +if (!dataDirInput) throw new Error('DKG_RFC64_GATE2_ADAPTER_DATA_DIR is required'); +if (!masterKeyHex || !/^[0-9a-f]{64}$/u.test(masterKeyHex)) { + throw new Error('DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX must be 32 lowercase hex bytes'); +} + +const dataDir = resolve(dataDirInput); +const pinnedMasterKeyHex = masterKeyHex; +const RFC64_GATE2_DEPLOYMENT = Object.freeze({ + networkId: 'otp:20430', + assertedAtChainId: '20430', + assertedAtKav10Address: '0x4444444444444444444444444444444444444444', +}); +let agent: DKGAgent | undefined; +let stopping = false; +let commandTail = Promise.resolve(); + +interface Command { + readonly command: string; + readonly input?: unknown; + readonly requestId: string; +} + +function emit(event: Record): void { + const line = JSON.stringify({ role, ...event }); + if (Buffer.byteLength(line) > 1_000_000) { + throw new Error('Gate 2 adapter event exceeds the 1 MiB process-protocol bound'); + } + process.stdout.write(`${GATE2_AGENT_EVENT_PREFIX}${line}\n`); +} + +async function emitAndFlush(event: Record): Promise { + const line = JSON.stringify({ role, ...event }); + if (Buffer.byteLength(line) > 1_000_000) { + throw new Error('Gate 2 adapter event exceeds the 1 MiB process-protocol bound'); + } + await new Promise((resolveWrite, rejectWrite) => { + process.stdout.write(`${GATE2_AGENT_EVENT_PREFIX}${line}\n`, (error) => { + if (error === null || error === undefined) resolveWrite(); + else rejectWrite(error); + }); + }); +} + +async function ensureDeterministicAgentKey(): Promise { + await mkdir(dataDir, { recursive: true, mode: 0o700 }); + const keyPath = join(dataDir, 'agent-key.bin'); + const expected = Buffer.from(pinnedMasterKeyHex, 'hex'); + try { + const handle = await open(keyPath, 'wx', 0o600); + try { + await handle.writeFile(expected); + await handle.sync(); + } finally { + await handle.close(); + } + } catch (error) { + if (!isNodeError(error) || error.code !== 'EEXIST') throw error; + const existing = await readFile(keyPath); + if (!existing.equals(expected)) { + throw new Error('existing DKGAgent master key differs from the role-pinned harness key'); + } + } +} + +async function boot(): Promise { + await ensureDeterministicAgentKey(); + const created = await DKGAgent.create({ + name: `RFC64Gate2${role}`, + dataDir, + listenHost: '127.0.0.1', + listenPort: 0, + bootstrapPeers: [], + nodeRole: 'edge', + store: new OxigraphStore(join(dataDir, 'store.nq')), + syncSharedMemoryOnConnect: false, + syncReconcilerEnabled: false, + syncOnConnectEnabled: false, + durableSyncEnabled: false, + agentProfileHeartbeatMs: 0, + rfc64CatalogDeploymentProfile: RFC64_GATE2_DEPLOYMENT as never, + }); + agent = created; + await created.start(); + const tcp = created.multiaddrs.find((address) => address.includes('/tcp/')); + if (tcp === undefined) throw new Error('real DKGAgent exposed no TCP multiaddr'); + emit({ + adapterId: GATE2_REAL_DKG_AGENT_ADAPTER_ID, + agentClass: created.constructor.name, + capabilities: inspectGate2ProductCapabilities(created), + catalogServiceStarted: created.rfc64PublicCatalogStatsV1()?.started === true, + event: 'ready', + multiaddr: tcp, + peerId: created.peerId, + protocolVersion: GATE2_ADAPTER_PROTOCOL_VERSION, + startupRepair: null, + }); +} + +async function handle(command: Command): Promise { + if (typeof command.requestId !== 'string' || command.requestId.length === 0) { + throw new Error('requestId is required'); + } + const currentAgent = requireAgent(); + switch (command.command) { + case 'dial': { + const input = plainRecord(command.input, 'dial input'); + const address = requiredString(input.multiaddr, 'dial.multiaddr'); + const expectedPeerId = requiredString(input.peerId, 'dial.peerId'); + const connection = await currentAgent.node.libp2p.dial(multiaddr(address)); + const connectedPeerId = connection.remotePeer.toString(); + if (connectedPeerId !== expectedPeerId) { + throw new Error(`dial connected to ${connectedPeerId}, expected ${expectedPeerId}`); + } + emit({ event: 'dialed', peerId: connectedPeerId, requestId: command.requestId }); + return; + } + case 'acceptOpenPolicy': { + const accepted = currentAgent.acceptOpenContextGraphPolicyV1( + plainRecord(command.input, 'acceptOpenPolicy input') as never, + ); + emit({ + event: 'operation-completed', + operation: command.command, + output: accepted, + requestId: command.requestId, + }); + return; + } + case 'publishGenesis': { + requireRole('author'); + const input = plainRecord(command.input, 'publishGenesis input'); + const authorPrivateKey = requiredString( + input.authorPrivateKey, + 'publishGenesis.authorPrivateKey', + ); + const author = new ethers.Wallet(authorPrivateKey); + const forwarded: Record = { ...input, author, peers: [] }; + delete forwarded.authorPrivateKey; + const output = await currentAgent.publishOpenAuthorCatalogGenesisV1(forwarded as never); + emitOperationResult(command, output); + return; + } + case 'publishExactSetSuccessor': { + requireRole('author'); + const input = plainRecord(command.input, 'publishExactSetSuccessor input'); + const authorPrivateKey = requiredString( + input.authorPrivateKey, + 'publishExactSetSuccessor.authorPrivateKey', + ); + const assets = plainArray(input.assets, 'publishExactSetSuccessor.assets').map( + (value, index) => { + const asset = plainRecord(value, `publishExactSetSuccessor.assets[${index}]`); + const projectionNQuads = requiredString( + asset.projectionNQuads, + `publishExactSetSuccessor.assets[${index}].projectionNQuads`, + ); + const forwarded: Record = { + ...asset, + projectionBytes: new TextEncoder().encode(projectionNQuads), + }; + delete forwarded.projectionNQuads; + return forwarded; + }, + ); + const forwarded: Record = { + ...input, + author: new ethers.Wallet(authorPrivateKey), + assets, + peers: [], + }; + delete forwarded.authorPrivateKey; + const output = await currentAgent.publishOpenAuthorCatalogExactSetSuccessorV1( + forwarded as never, + ); + const result = plainRecord(output, 'publishExactSetSuccessor output'); + const outputAssets = plainArray(result.assets, 'publishExactSetSuccessor.output.assets'); + const assetsWithReceipts = await Promise.all(outputAssets.map(async (value, index) => { + const asset = plainRecord(value, `publishExactSetSuccessor.output.assets[${index}]`); + const bundleDigest = requiredDigest( + asset.bundleDigest, + `publishExactSetSuccessor.output.assets[${index}].bundleDigest`, + ); + const stagedBundle = await currentAgent.readRfc64StagedKaBundleV1(bundleDigest); + if (stagedBundle === null) { + throw new Error(`published exact-set bundle ${bundleDigest} is absent from durable storage`); + } + return Object.freeze({ + ...asset, + stagedBundleByteLength: stagedBundle.byteLength, + }); + })); + emitOperationResult(command, { ...result, assets: Object.freeze(assetsWithReceipts) }); + return; + } + case 'announce': { + requireRole('author'); + const output = await currentAgent.announceRfc64PublicCatalogHeadV1( + plainRecord(command.input, 'announce input') as never, + ); + emitOperationResult(command, output); + return; + } + case 'appliedHeadReadback': { + requireRole('receiver'); + const output = currentAgent.readRfc64AppliedCatalogHeadV1( + plainRecord(command.input, 'appliedHeadReadback input') as never, + ); + emitOperationResult(command, output); + return; + } + case 'exactInventoryReadback': { + requireRole('receiver'); + const input = plainRecord(command.input, 'exactInventoryReadback input'); + const catalogHeadDigest = requiredDigest( + input.catalogHeadDigest, + 'exactInventoryReadback.catalogHeadDigest', + ); + const output = currentAgent.readRfc64PublicCatalogSynchronizationEvidenceV1( + catalogHeadDigest, + ); + emitOperationResult(command, wireSynchronizationEvidence(output)); + return; + } + case 'prepareForgedAuthorizationGenesis': { + requireRole('author'); + const output = await prepareForgedAuthorizationGenesis( + currentAgent, + plainRecord(command.input, 'prepareForgedAuthorizationGenesis input'), + ); + emitOperationResult(command, output); + return; + } + case 'receiverStats': { + requireRole('receiver'); + emitOperationResult(command, currentAgent.rfc64PublicCatalogStatsV1()?.receiver ?? null); + return; + } + case 'semanticGraphReadback': { + requireRole('receiver'); + const input = plainRecord(command.input, 'semanticGraphReadback input'); + const swmGraph = requiredString(input.swmGraph, 'semanticGraphReadback.swmGraph'); + if (!/^did:dkg:context-graph:[A-Za-z0-9:._/-]+$/u.test(swmGraph)) { + throw new TypeError('semanticGraphReadback.swmGraph is not a safe production graph IRI'); + } + const result = await currentAgent.store.query( + `CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <${swmGraph}> { ?s ?p ?o } }`, + { source: 'rfc64-gate2-semantic-readback' }, + ); + if (result.type !== 'quads') { + throw new Error('semantic graph readback did not return quads'); + } + const quads = [...result.quads] + .map((quad): Quad => ({ ...quad, graph: '' })) + .sort(compareQuad); + emitOperationResult(command, { + activatedQuadCount: quads.length, + projectionNQuads: `${quadsToNQuads(quads)}\n`, + swmGraph, + }); + return; + } + case 'terminalFailureReadback': { + requireRole('receiver'); + const input = plainRecord(command.input, 'terminalFailureReadback input'); + const catalogHeadDigest = requiredDigest( + input.catalogHeadDigest, + 'terminalFailureReadback.catalogHeadDigest', + ); + const method = (currentAgent as unknown as Record)[ + 'readRfc64PublicCatalogReconciliationFailureV1' + ]; + if (typeof method !== 'function') { + throw new Error( + 'missing read-only product API: ' + + 'DKGAgent.readRfc64PublicCatalogReconciliationFailureV1(' + + 'catalogHeadDigest: Digest32V1): ' + + '{ catalogHeadDigest: Digest32V1; errorName: string; errorCode: ' + + 'Rfc64PublicCatalogNativeReceiverErrorCodeV1 | null } | null', + ); + } + const output = await (method as (digest: string) => unknown).call( + currentAgent, + catalogHeadDigest, + ); + emitOperationResult(command, output); + return; + } + case 'awaitReceiverIdle': + requireRole('receiver'); + await currentAgent.whenRfc64PublicCatalogReceiverIdleV1(); + emit({ event: 'receiver-idle', requestId: command.requestId }); + return; + case 'killRestart': + requireRole('receiver'); + // The parent process owns SIGKILL and replacement. This command only + // establishes an explicit process-protocol boundary; it creates no fake + // repair record and intentionally does not stop the DKGAgent. + emit({ event: 'kill-restart-ready', requestId: command.requestId }); + return; + case 'stop': + await stop(0, command.requestId); + return; + default: + throw new Error(`unknown adapter command: ${command.command}`); + } +} + +function compareQuad(left: Quad, right: Quad): number { + const leftKey = `${left.subject}\n${left.predicate}\n${left.object}\n${left.graph}`; + const rightKey = `${right.subject}\n${right.predicate}\n${right.object}\n${right.graph}`; + return leftKey.localeCompare(rightKey); +} + +function wireSynchronizationEvidence(output: unknown): unknown { + if (output === null) return null; + const evidence = plainRecord(output, 'exact synchronization evidence'); + if (evidence.inventoryRowCount === 0) return evidence; + const sourceRows = evidence.inventoryRowCount === 1 + ? [evidence] + : plainArray(evidence.rows, 'synchronization.rows'); + let verifiedControlObjectCount: number | undefined; + const rows = sourceRows.map((value, index) => { + const row = plainRecord(value, `synchronization.rows[${index}]`); + const authorship = plainRecord(row.authorship, `synchronization.rows[${index}].authorship`); + const path = plainArray( + authorship.directoryPathObjectDigests, + `synchronization.rows[${index}].authorship.directoryPathObjectDigests`, + ); + const variants = plainArray( + authorship.directoryPathSignatureVariantDigests, + `synchronization.rows[${index}].authorship.directoryPathSignatureVariantDigests`, + ); + if (path.length !== variants.length) { + throw new Error('synchronization authorship path evidence is incomplete'); + } + const rowControlObjectCount = 3 + path.length; + if ( + verifiedControlObjectCount !== undefined + && rowControlObjectCount !== verifiedControlObjectCount + ) { + throw new Error('synchronization rows disagree on the verified control-object closure'); + } + verifiedControlObjectCount = rowControlObjectCount; + return Object.freeze({ + kaId: row.kaId, + catalogRowDigest: row.catalogRowDigest, + contentDigest: row.contentDigest, + sealDigest: row.sealDigest, + bundleDigest: row.bundleDigest, + kaUal: row.kaUal, + activatedTripleCount: row.activatedTripleCount, + swmGraph: row.swmGraph, + }); + }); + if (verifiedControlObjectCount === undefined) { + throw new Error('non-empty synchronization evidence contains no exact rows'); + } + return Object.freeze({ + inventoryDigest: evidence.inventoryDigest, + catalogHeadDigest: evidence.catalogHeadDigest, + inventoryRowCount: evidence.inventoryRowCount, + activatedTripleCount: evidence.activatedTripleCount, + appliedHeadStatus: evidence.appliedHeadStatus, + rows: Object.freeze(rows), + verifiedControlObjectCount, + }); +} + +function inspectGate2ProductCapabilities(currentAgent: DKGAgent): Record { + const surface = currentAgent as unknown as Record; + return Object.freeze({ + acceptOpenPolicy: typeof surface.acceptOpenContextGraphPolicyV1 === 'function', + announce: typeof surface.announceRfc64PublicCatalogHeadV1 === 'function', + appliedHeadReadback: typeof surface.readRfc64AppliedCatalogHeadV1 === 'function', + exactInventoryReadback: + typeof surface.readRfc64PublicCatalogSynchronizationEvidenceV1 === 'function', + publishExactSetSuccessor: + typeof surface.publishOpenAuthorCatalogExactSetSuccessorV1 === 'function', + publishGenesis: typeof surface.publishOpenAuthorCatalogGenesisV1 === 'function', + terminalFailureReadback: + typeof surface.readRfc64PublicCatalogReconciliationFailureV1 === 'function', + }); +} + +interface HarnessControlObjectPersistenceV1 { + readonly controlObjects: { + stageVerifiedObjects(input: readonly HarnessVerifiedControlObjectV1[]): Promise<{ + readonly objects: ReadonlyArray<{ + readonly objectDigest: Digest32V1; + readonly signatureVariantDigest: Digest32V1; + }>; + }>; + }; +} + +interface HarnessVerifiedControlObjectV1 { + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: Awaited>; +} + +/** + * Harness-only adversarial setup. Both signatures are cryptographically valid, + * but the head claims the catalog author while naming an attacker-scoped + * direct-author delegation. The product receiver must reject that exact scope + * mismatch before activation or applied-head mutation. + */ +async function prepareForgedAuthorizationGenesis( + currentAgent: DKGAgent, + input: Record, +): Promise> { + const networkId = requiredString(input.networkId, 'forged.networkId'); + const contextGraphId = requiredString(input.contextGraphId, 'forged.contextGraphId'); + const policyDigest = requiredDigest(input.policyDigest, 'forged.policyDigest'); + const catalogAuthorPrivateKey = requiredString( + input.catalogAuthorPrivateKey, + 'forged.catalogAuthorPrivateKey', + ); + const attackerPrivateKey = requiredString( + input.attackerPrivateKey, + 'forged.attackerPrivateKey', + ); + const issuedAt = requiredString(input.issuedAt, 'forged.issuedAt'); + const delegationEffectiveAt = requiredString( + input.delegationEffectiveAt, + 'forged.delegationEffectiveAt', + ); + const delegationExpiresAt = requiredString( + input.delegationExpiresAt, + 'forged.delegationExpiresAt', + ); + const catalogAuthor = new ethers.Wallet(catalogAuthorPrivateKey); + const attacker = new ethers.Wallet(attackerPrivateKey); + const catalogAuthorAddress = catalogAuthor.address.toLowerCase() as EvmAddressV1; + const recoveredAuthorAddress = attacker.address.toLowerCase() as EvmAddressV1; + const attackerScope = catalogScope( + networkId, + contextGraphId, + recoveredAuthorAddress, + ); + const claimedCatalogScope = catalogScope( + networkId, + contextGraphId, + catalogAuthorAddress, + ); + const forgedDelegation = await produceDirectAuthorCatalogIssuerDelegationV1({ + scope: attackerScope, + signer: { + issuer: recoveredAuthorAddress, + signDigest: (digest) => attacker.signMessage(digest), + }, + effectiveAt: delegationEffectiveAt as never, + expiresAt: delegationExpiresAt as never, + catalogHeadIssuedAt: issuedAt as never, + }); + const forgedGenesis = await produceEmptyAuthorCatalogGenesisV1({ + scope: claimedCatalogScope, + catalogIssuerDelegationDigest: + forgedDelegation.authorization.catalogIssuerDelegation.objectDigest as Digest32V1, + issuedAt: issuedAt as never, + signer: { + issuer: catalogAuthorAddress, + signDigest: (digest) => catalogAuthor.signMessage(digest), + }, + }); + const persistence = ( + currentAgent as unknown as { rfc64PersistenceV1?: HarnessControlObjectPersistenceV1 } + ).rfc64PersistenceV1; + if (persistence === undefined) throw new Error('RFC-64 persistence is unavailable'); + await persistence.controlObjects.stageVerifiedObjects([{ + envelope: forgedDelegation.authorization.catalogIssuerDelegation, + issuerSignature: forgedDelegation.issuerSignature, + }]); + const verifiedObjects = await Promise.all(forgedGenesis.stagedObjects.map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }))); + const staged = await persistence.controlObjects.stageVerifiedObjects(verifiedObjects); + const head = forgedGenesis.head; + const headReceipt = staged.objects.find((entry) => entry.objectDigest === head.objectDigest); + if ( + headReceipt === undefined + || headReceipt.signatureVariantDigest !== computeControlSignatureVariantDigestHex( + head.objectDigest, + head.signature, + ) + ) { + throw new Error('forged authorization head did not receive an exact durable stage receipt'); + } + return Object.freeze({ + announcement: Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: head.payload.networkId, + contextGraphId: head.payload.contextGraphId, + subGraphName: head.payload.subGraphName, + authorAddress: head.payload.authorAddress, + catalogEra: head.payload.era, + catalogVersion: head.payload.version, + policyDigest, + catalogHeadObjectDigest: headReceipt.objectDigest, + signatureVariantDigest: headReceipt.signatureVariantDigest, + }), + attemptedCatalogHeadDigest: headReceipt.objectDigest, + catalogAuthorAddress, + recoveredAuthorAddress, + }); +} + +function catalogScope( + networkId: string, + contextGraphId: string, + authorAddress: EvmAddressV1, +): AuthorCatalogScopeV1 { + return Object.freeze({ + networkId, + contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress, + era: '0', + bucketCount: '1', + }) as AuthorCatalogScopeV1; +} + +function emitOperationResult(command: Command, output: unknown): void { + assertJsonWireValue(output, `${command.command} output`); + emit({ + event: 'operation-completed', + operation: command.command, + output, + requestId: command.requestId, + }); +} + +function assertJsonWireValue(value: unknown, path: string, depth = 0): void { + if (depth > 32) throw new Error(`${path} exceeds the adapter JSON depth bound`); + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number' && Number.isSafeInteger(value)) return; + if (Array.isArray(value)) { + if (value.length > 10_000) throw new Error(`${path} exceeds the adapter array bound`); + value.forEach((entry, index) => assertJsonWireValue(entry, `${path}[${index}]`, depth + 1)); + return; + } + if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + for (const [key, entry] of Object.entries(value as Record)) { + assertJsonWireValue(entry, `${path}.${key}`, depth + 1); + } + return; + } + throw new Error(`${path} is not a bounded plain JSON value`); +} + +async function stop(exitCode: number, requestId?: string): Promise { + if (stopping) return await new Promise(() => undefined); + stopping = true; + try { + await agent?.stop(); + if (requestId !== undefined) { + await emitAndFlush({ event: 'stopped', requestId }); + } + process.exit(exitCode); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exit(1); + } +} + +function requireAgent(): DKGAgent { + if (agent === undefined) throw new Error('real DKGAgent is not ready'); + return agent; +} + +function requireRole(expected: 'author' | 'receiver'): void { + if (role !== expected) throw new Error(`${role} cannot handle ${expected} operation`); +} + +function plainRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${label} must be a plain object`); + } + return value as Record; +} + +function plainArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value) || value.length > 1_024) { + throw new TypeError(`${label} must be a bounded Array`); + } + return value; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) { + throw new TypeError(`${label} must be a bounded non-empty string`); + } + return value; +} + +function requiredDigest(value: unknown, label: string): Digest32V1 { + if (typeof value !== 'string' || !/^0x[0-9a-f]{64}$/u.test(value)) { + throw new TypeError(`${label} must be a canonical digest`); + } + return value as Digest32V1; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +process.once('SIGTERM', () => { void stop(0); }); +process.once('SIGINT', () => { void stop(130); }); + +await boot().catch(async (error) => { + emit({ event: 'boot-failed', message: error instanceof Error ? error.message : String(error) }); + await stop(1); +}); + +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); +lines.on('line', (line) => { + if (Buffer.byteLength(line) > 1_000_000) { + emit({ event: 'error', message: 'command exceeds the 1 MiB process-protocol bound' }); + return; + } + let command: Command; + try { + command = JSON.parse(line) as Command; + } catch (error) { + emit({ event: 'error', message: `invalid command JSON: ${String(error)}` }); + return; + } + commandTail = commandTail.then(() => handle(command)).catch((error) => { + emit({ + event: 'error', + message: error instanceof Error ? error.message : String(error), + requestId: command.requestId, + }); + }); +}); +lines.once('close', () => { void stop(0); }); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts b/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts new file mode 100644 index 0000000000..69fbb76bc2 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts @@ -0,0 +1,210 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; + +import { + ChildProcessRegistry, + type ProcessExitEvidence, + type TrackedChildProcess, +} from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { GATE2_AGENT_EVENT_PREFIX } from './model.js'; + +const DEFAULT_EVENT_TIMEOUT_MS = 45_000; +const MAX_CAPTURE_BYTES = 256 * 1024; +const MAX_EVENT_LINE_BYTES = 1_000_000; + +export interface Gate2AgentEvent { + readonly event: string; + readonly requestId?: string; + readonly role: 'author' | 'receiver'; + readonly [key: string]: unknown; +} + +export interface Gate2AgentSpawnSpec { + readonly args: readonly string[]; + readonly command: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; +} + +export interface Gate2AgentChildOptions { + readonly eventTimeoutMs?: number; + readonly registry: ChildProcessRegistry; + readonly role: 'author' | 'receiver'; + readonly spawn: Gate2AgentSpawnSpec; +} + +interface PendingEvent { + readonly expectedEvent: string; + readonly reject: (error: Error) => void; + readonly requestId: string | null; + readonly resolve: (event: Gate2AgentEvent) => void; + readonly timer: NodeJS.Timeout; +} + +export class Gate2AgentChild { + readonly child: ChildProcessWithoutNullStreams; + readonly tracked: TrackedChildProcess; + readonly #eventTimeoutMs: number; + readonly #events: Gate2AgentEvent[] = []; + readonly #pending = new Set(); + #stderr = ''; + #stdout = ''; + #lineBuffer = ''; + + constructor(readonly options: Gate2AgentChildOptions) { + this.#eventTimeoutMs = options.eventTimeoutMs ?? DEFAULT_EVENT_TIMEOUT_MS; + if (!Number.isSafeInteger(this.#eventTimeoutMs) || this.#eventTimeoutMs < 1) { + throw new TypeError('eventTimeoutMs must be a positive safe integer'); + } + this.child = spawn(options.spawn.command, [...options.spawn.args], { + cwd: options.spawn.cwd, + env: options.spawn.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.tracked = options.registry.track(this.child); + this.child.stdout.setEncoding('utf8'); + this.child.stderr.setEncoding('utf8'); + this.child.stdout.on('data', (chunk: string) => this.consumeStdout(chunk)); + this.child.stderr.on('data', (chunk: string) => { + this.#stderr = appendBounded(this.#stderr, chunk); + }); + this.child.once('error', (error) => this.rejectAll(error)); + void this.tracked.closed.then((exit) => { + if (this.#pending.size === 0) return; + this.rejectAll(new Error( + `${this.options.role} DKGAgent closed before its expected event: ${JSON.stringify(exit)}\n` + + `stdout tail:\n${this.#stdout}\nstderr tail:\n${this.#stderr}`, + )); + }); + } + + get role(): 'author' | 'receiver' { + return this.options.role; + } + + waitFor(expectedEvent: string, requestId: string | null = null): Promise { + const existing = this.#events.find((event) => + event.event === expectedEvent && (requestId === null || event.requestId === requestId)); + if (existing !== undefined) return Promise.resolve(existing); + return new Promise((resolveEvent, rejectEvent) => { + const timer = setTimeout(() => { + this.#pending.delete(pending); + rejectEvent(new Error( + `${this.role} DKGAgent timed out waiting for ${expectedEvent}/${requestId ?? '*'}\n` + + `stdout tail:\n${this.#stdout}\nstderr tail:\n${this.#stderr}`, + )); + }, this.#eventTimeoutMs); + const pending: PendingEvent = { + expectedEvent, + reject: rejectEvent, + requestId, + resolve: resolveEvent, + timer, + }; + this.#pending.add(pending); + }); + } + + async request( + command: string, + requestId: string, + expectedEvent: string, + input?: unknown, + ): Promise { + const payload = input === undefined + ? { command, requestId } + : { command, input, requestId }; + const line = `${JSON.stringify(payload)}\n`; + if (Buffer.byteLength(line) > MAX_EVENT_LINE_BYTES) { + throw new Error('Gate 2 adapter command exceeds the 1 MiB process-protocol bound'); + } + const event = this.waitFor(expectedEvent, requestId); + try { + await new Promise((resolveWrite, rejectWrite) => { + this.child.stdin.write(line, (error) => { + if (error === null || error === undefined) resolveWrite(); + else rejectWrite(error); + }); + }); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + this.rejectAll(failure); + await event.catch(() => undefined); + throw failure; + } + return event; + } + + async stop(requestId: string): Promise { + if (this.child.exitCode !== null || this.child.signalCode !== null) { + return this.tracked.closed; + } + await this.request('stop', requestId, 'stopped'); + const exit = await this.tracked.closed; + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`${this.role} DKGAgent did not stop cleanly: ${JSON.stringify(exit)}`); + } + return exit; + } + + async killRestartBoundary(requestId: string): Promise { + await this.request('killRestart', requestId, 'kill-restart-ready'); + const exit = await this.options.registry.terminateAndWait(this.tracked, 'SIGKILL'); + if (exit.code !== null || exit.signal !== 'SIGKILL') { + throw new Error(`${this.role} crash boundary was not SIGKILL: ${JSON.stringify(exit)}`); + } + return exit; + } + + private consumeStdout(chunk: string): void { + this.#stdout = appendBounded(this.#stdout, chunk); + this.#lineBuffer += chunk; + if (Buffer.byteLength(this.#lineBuffer) > MAX_EVENT_LINE_BYTES) { + this.rejectAll(new Error(`${this.role} emitted an overlong unterminated event line`)); + this.#lineBuffer = ''; + return; + } + const lines = this.#lineBuffer.split('\n'); + this.#lineBuffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.startsWith(GATE2_AGENT_EVENT_PREFIX)) continue; + let event: Gate2AgentEvent; + try { + event = JSON.parse(line.slice(GATE2_AGENT_EVENT_PREFIX.length)) as Gate2AgentEvent; + } catch (error) { + this.rejectAll(new Error(`invalid Gate 2 adapter event JSON: ${String(error)}`)); + continue; + } + this.#events.push(event); + for (const pending of this.#pending) { + if ( + event.event !== pending.expectedEvent + || (pending.requestId !== null && event.requestId !== pending.requestId) + ) continue; + clearTimeout(pending.timer); + this.#pending.delete(pending); + pending.resolve(event); + } + if (event.event === 'error' || event.event === 'boot-failed') { + this.rejectAll(new Error( + `${this.role} DKGAgent ${event.event} for ${event.requestId ?? 'startup'}: ` + + `${String(event.message)}`, + )); + } + } + } + + private rejectAll(error: Error): void { + for (const pending of this.#pending) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.#pending.clear(); + } +} + +function appendBounded(previous: string, chunk: string): string { + const combined = previous + chunk; + if (Buffer.byteLength(combined) <= MAX_CAPTURE_BYTES) return combined; + const tail = Buffer.from(combined).subarray(-MAX_CAPTURE_BYTES).toString('utf8'); + return `[earlier output truncated]\n${tail}`; +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts b/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts new file mode 100644 index 0000000000..0c57d60dbf --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts @@ -0,0 +1,620 @@ +import { createHash } from 'node:crypto'; + +import { stableJson } from '../rfc64-persistence-lifecycle/evidence.js'; +import { + GATE2_ADAPTER_PROTOCOL_VERSION, + GATE2_RAW_SCHEMA_VERSION, + GATE2_REAL_DKG_AGENT_ADAPTER_ID, + GATE2_VERDICT_SCHEMA_VERSION, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + appliedReadBackFromInventories, + type Gate2AuthoredInventory, + type Gate2ReceivedInventory, +} from './model.js'; +import { sha256Digest } from './src/canonical.ts'; +import { + GATE_EVALUATION, + PRODUCT_BOUNDARY, + RAW_SCHEMA_ID, + type AssetRowV1, +} from './src/schema.ts'; +import { verify as verifyInventoryContract } from './src/verify.ts'; + +const COMMIT = /^[0-9a-f]{40,64}$/u; +const DIGEST = /^0x[0-9a-f]{64}$/u; +const ADDRESS = /^0x[0-9a-f]{40}$/u; +const DECIMAL = /^(0|[1-9][0-9]*)$/u; +const UAL = /^did:dkg:([^/]+)\/(0x[0-9a-f]{40})\/(0|[1-9][0-9]*)$/u; +const PRODUCTION_REASON = + 'two real DKGAgent processes completed production 1-to-2-to-3 exact-set publication, ' + + 'synchronization, authorization-negative, SIGKILL, same-head replay, and exact readback'; +const REPLACEMENT_CONTRACT = + 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence'; +const MAX_RAW_BYTES = 2 * 1024 * 1024; + +export interface Gate2VerifiedEvidence { + readonly rawArtifactSha256: string; + readonly sourceCommit: string; +} + +export interface Gate2PassVerdict { + readonly gate: 'OT-RFC-64 Gate 2 multi-asset completeness'; + readonly productBoundary: 'connected'; + readonly rawArtifactSha256: string; + readonly schemaVersion: typeof GATE2_VERDICT_SCHEMA_VERSION; + readonly sourceCommit: string; + readonly status: 'PASS'; +} + +export function buildGate2PassVerdict( + verified: Gate2VerifiedEvidence, +): Readonly { + return Object.freeze({ + gate: 'OT-RFC-64 Gate 2 multi-asset completeness', + productBoundary: 'connected', + rawArtifactSha256: verified.rawArtifactSha256, + schemaVersion: GATE2_VERDICT_SCHEMA_VERSION, + sourceCommit: verified.sourceCommit, + status: 'PASS', + }); +} + +export function verifyGate2ArtifactBytes( + rawBytes: Uint8Array, + expectedHead: string, +): Gate2VerifiedEvidence { + matchString(expectedHead, COMMIT, 'expected repository HEAD'); + if (rawBytes.byteLength < 1 || rawBytes.byteLength > MAX_RAW_BYTES) { + fail('$', 'raw artifact size is outside the closed verifier bound'); + } + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(rawBytes); + } catch (cause) { + throw new Error('Gate 2 artifact is not valid UTF-8', { cause }); + } + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch (cause) { + throw new Error('Gate 2 artifact is not valid JSON', { cause }); + } + if (stableJson(parsed) !== text) fail('$', 'raw artifact is not exact canonical stable JSON'); + verifyArtifact(parsed, expectedHead); + return Object.freeze({ + rawArtifactSha256: `0x${createHash('sha256').update(rawBytes).digest('hex')}`, + sourceCommit: expectedHead, + }); +} + +function verifyArtifact(value: unknown, expectedHead: string): void { + const raw = closedRecord(value, '$', [ + 'adapter', + 'authorizationNegative', + 'gate', + 'gateEvaluation', + 'harnessChecksPassed', + 'inventory', + 'invocation', + 'policy', + 'processBoundary', + 'ready', + 'repository', + 'restartReplay', + 'schemaVersion', + 'transitions', + 'transport', + ]); + exact(raw.schemaVersion, GATE2_RAW_SCHEMA_VERSION, '$.schemaVersion'); + exact(raw.gate, 'OT-RFC-64 Gate 2 multi-asset completeness', '$.gate'); + exact(raw.invocation, 'pnpm test:gate2:rfc64-multi-asset-harness', '$.invocation'); + exact(raw.harnessChecksPassed, true, '$.harnessChecksPassed'); + const evaluation = closedRecord(raw.gateEvaluation, '$.gateEvaluation', ['reason', 'status']); + exact(evaluation.status, 'PASS', '$.gateEvaluation.status'); + exact(evaluation.reason, PRODUCTION_REASON, '$.gateEvaluation.reason'); + + const adapter = closedRecord(raw.adapter, '$.adapter', [ + 'id', + 'inspectedProductCommits', + 'productBoundary', + 'protocolVersion', + 'replacementContract', + 'requiredProductionOperations', + ]); + exact(adapter.id, GATE2_REAL_DKG_AGENT_ADAPTER_ID, '$.adapter.id'); + exact(adapter.protocolVersion, GATE2_ADAPTER_PROTOCOL_VERSION, '$.adapter.protocolVersion'); + exact(adapter.productBoundary, 'connected', '$.adapter.productBoundary'); + exact(adapter.replacementContract, REPLACEMENT_CONTRACT, '$.adapter.replacementContract'); + exactJson( + adapter.requiredProductionOperations, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + '$.adapter.requiredProductionOperations', + ); + exactJson(adapter.inspectedProductCommits, [expectedHead], '$.adapter.inspectedProductCommits'); + + const repository = closedRecord(raw.repository, '$.repository', [ + 'testedHeadCommit', + 'trackedSourceCleanAfterProcesses', + 'trackedSourceCleanBeforeSpawn', + ]); + exact(repository.testedHeadCommit, expectedHead, '$.repository.testedHeadCommit'); + exact(repository.trackedSourceCleanBeforeSpawn, true, '$.repository.cleanBefore'); + exact(repository.trackedSourceCleanAfterProcesses, true, '$.repository.cleanAfter'); + + const peers = verifyReadyPair(raw.ready); + verifyProcessBoundary(raw.processBoundary); + const policy = verifyPolicy(raw.policy); + const inventories = verifyInventories(raw.inventory, policy); + const transitions = verifyTransitions(raw.transitions, inventories.authored.catalogHeadDigest); + const transport = verifyTransport(raw.transport, peers, policy.policyDigest, transitions); + const expectedApplied = appliedReadBackFromInventories( + inventories.authored, + inventories.received, + transitions.finalVersion, + ); + const positiveWire = verifyWireSynchronization( + closedRecord(raw.authorizationNegative, '$.authorizationNegative', [ + 'attemptedCatalogHeadDigest', + 'catalogAuthorAddress', + 'expectedFailureCode', + 'forgedAppliedHead', + 'forgedSynchronization', + 'positiveAppliedAfter', + 'positiveAppliedBefore', + 'positiveInventoryAfter', + 'positiveInventoryBefore', + 'recoveredAuthorAddress', + 'semanticAfter', + 'semanticBefore', + 'servedByPeerId', + 'testedByPeerId', + ]), + '$.authorizationNegative', + inventories, + expectedApplied, + peers, + ); + exact( + transport.verifiedControlObjectCount, + positiveWire.verifiedControlObjectCount, + '$.transport.verifiedControlObjectCount', + ); + verifyRestart( + raw.restartReplay, + inventories, + expectedApplied, + peers, + positiveWire.semantic, + ); +} + +function verifyInventories( + value: unknown, + policy: { networkId: string; contextGraphId: string }, +): { authored: Gate2AuthoredInventory; received: Gate2ReceivedInventory } { + const inventory = closedRecord(value, '$.inventory', ['authored', 'received']); + const contractRaw = { + schema: RAW_SCHEMA_ID, + productBoundary: PRODUCT_BOUNDARY, + gateEvaluation: GATE_EVALUATION, + authored: inventory.authored, + received: inventory.received, + }; + const verdict = verifyInventoryContract(contractRaw); + if (!verdict.fixtureComplete || Object.values(verdict.checks).some((check) => !check)) { + fail('$.inventory', `exact-set completeness rejected: ${verdict.rejectReasons.join('; ')}`); + } + const authored = inventory.authored as Gate2AuthoredInventory; + const received = inventory.received as Gate2ReceivedInventory; + exact(authored.signedRows.length, 3, '$.inventory.authored.signedRows.length'); + exact(received.activatedRows.length, 3, '$.inventory.received.activatedRows.length'); + exact(authored.catalogScope.networkId, policy.networkId, '$.inventory.authored.scope.networkId'); + exact( + authored.catalogScope.contextGraphId, + policy.contextGraphId, + '$.inventory.authored.scope.contextGraphId', + ); + return { authored, received }; +} + +function verifyTransitions( + value: unknown, + finalHead: string, +): { finalSignatureVariantDigest: string; finalVersion: string } { + const entries = closedArray(value, '$.transitions', 3); + let previousHead: string | undefined; + let finalSignatureVariantDigest = ''; + for (const [index, entry] of entries.entries()) { + const transition = closedRecord(entry, `$.transitions[${index}]`, [ + 'catalogHeadDigest', + 'catalogVersion', + 'inventoryRowCount', + 'previousCatalogHeadDigest', + 'signatureVariantDigest', + ]); + const head = digest(transition.catalogHeadDigest, `$.transitions[${index}].catalogHeadDigest`); + const predecessor = digest( + transition.previousCatalogHeadDigest, + `$.transitions[${index}].previousCatalogHeadDigest`, + ); + exact(transition.inventoryRowCount, index + 1, `$.transitions[${index}].inventoryRowCount`); + exact(transition.catalogVersion, String(index + 1), `$.transitions[${index}].catalogVersion`); + if (previousHead !== undefined) { + exact(predecessor, previousHead, `$.transitions[${index}].previousCatalogHeadDigest`); + } + previousHead = head; + finalSignatureVariantDigest = digest( + transition.signatureVariantDigest, + `$.transitions[${index}].signatureVariantDigest`, + ); + } + exact(previousHead, finalHead, '$.transitions final head'); + return { finalSignatureVariantDigest, finalVersion: '3' }; +} + +function verifyTransport( + value: unknown, + peers: { author: string; receiver: string }, + policyDigest: string, + transitions: { finalSignatureVariantDigest: string }, +): { verifiedControlObjectCount: number } { + const transport = closedRecord(value, '$.transport', [ + 'finalAnnouncementPolicyDigest', + 'finalSignatureVariantDigest', + 'receivedByPeerId', + 'servedByPeerId', + 'verifiedControlObjectCount', + ]); + exact(transport.servedByPeerId, peers.author, '$.transport.servedByPeerId'); + exact(transport.receivedByPeerId, peers.receiver, '$.transport.receivedByPeerId'); + exact( + transport.finalAnnouncementPolicyDigest, + policyDigest, + '$.transport.finalAnnouncementPolicyDigest', + ); + exact( + transport.finalSignatureVariantDigest, + transitions.finalSignatureVariantDigest, + '$.transport.finalSignatureVariantDigest', + ); + const verifiedControlObjectCount = exactInteger( + transport.verifiedControlObjectCount, + 4, + '$.transport.verifiedControlObjectCount', + ); + return { verifiedControlObjectCount }; +} + +function verifyWireSynchronization( + negative: Record, + path: string, + inventories: { authored: Gate2AuthoredInventory; received: Gate2ReceivedInventory }, + expectedApplied: unknown, + peers: { author: string; receiver: string }, +): { semantic: unknown; verifiedControlObjectCount: number } { + exact(negative.expectedFailureCode, 'catalog-native-receiver-authorization', `${path}.failureCode`); + digest(negative.attemptedCatalogHeadDigest, `${path}.attemptedCatalogHeadDigest`); + const author = address(negative.catalogAuthorAddress, `${path}.catalogAuthorAddress`); + exact(author, inventories.authored.catalogScope.authorAddress, `${path}.catalogAuthorAddress`); + const recovered = address(negative.recoveredAuthorAddress, `${path}.recoveredAuthorAddress`); + if (recovered === author) fail(`${path}.recoveredAuthorAddress`, 'must differ from author'); + exact(negative.forgedAppliedHead, null, `${path}.forgedAppliedHead`); + exact(negative.forgedSynchronization, null, `${path}.forgedSynchronization`); + exact(negative.servedByPeerId, peers.author, `${path}.servedByPeerId`); + exact(negative.testedByPeerId, peers.receiver, `${path}.testedByPeerId`); + exactJson(negative.positiveAppliedBefore, expectedApplied, `${path}.positiveAppliedBefore`); + exactJson(negative.positiveAppliedAfter, expectedApplied, `${path}.positiveAppliedAfter`); + exactJson( + negative.positiveInventoryAfter, + negative.positiveInventoryBefore, + `${path}.positiveInventoryAfter`, + ); + const wire = verifyPositiveWireInventory( + negative.positiveInventoryBefore, + `${path}.positiveInventoryBefore`, + inventories, + ); + exactJson(negative.semanticAfter, negative.semanticBefore, `${path}.semanticAfter`); + verifySemanticState(negative.semanticBefore, `${path}.semanticBefore`, wire.rows); + return { + semantic: negative.semanticBefore, + verifiedControlObjectCount: wire.verifiedControlObjectCount, + }; +} + +function verifyPositiveWireInventory( + value: unknown, + path: string, + inventories: { authored: Gate2AuthoredInventory; received: Gate2ReceivedInventory }, +): { rows: readonly WireRow[]; verifiedControlObjectCount: number } { + const wire = closedRecord(value, path, [ + 'activatedTripleCount', + 'appliedHeadStatus', + 'catalogHeadDigest', + 'inventoryDigest', + 'inventoryRowCount', + 'rows', + 'verifiedControlObjectCount', + ]); + exact(wire.appliedHeadStatus, 'applied', `${path}.appliedHeadStatus`); + exact(wire.catalogHeadDigest, inventories.authored.catalogHeadDigest, `${path}.catalogHeadDigest`); + exact(wire.inventoryDigest, inventories.received.declaredInventoryDigest, `${path}.inventoryDigest`); + exact(wire.inventoryRowCount, 3, `${path}.inventoryRowCount`); + const verifiedControlObjectCount = exactInteger( + wire.verifiedControlObjectCount, + 4, + `${path}.verifiedControlObjectCount`, + ); + const rows = closedArray(wire.rows, `${path}.rows`, 3).map((entry, index) => { + const row = closedRecord(entry, `${path}.rows[${index}]`, [ + 'activatedTripleCount', + 'bundleDigest', + 'catalogRowDigest', + 'contentDigest', + 'kaId', + 'kaUal', + 'sealDigest', + 'swmGraph', + ]); + const contractRow: AssetRowV1 = { + kaId: decimal(row.kaId, `${path}.rows[${index}].kaId`), + catalogRowDigest: digest(row.catalogRowDigest, `${path}.rows[${index}].catalogRowDigest`), + contentDigest: digest(row.contentDigest, `${path}.rows[${index}].contentDigest`), + sealDigest: digest(row.sealDigest, `${path}.rows[${index}].sealDigest`), + bundleDigest: digest(row.bundleDigest, `${path}.rows[${index}].bundleDigest`), + kaUal: boundedString(row.kaUal, `${path}.rows[${index}].kaUal`), + activatedTripleCount: positiveInteger( + row.activatedTripleCount, + `${path}.rows[${index}].activatedTripleCount`, + ), + }; + exactJson( + contractRow, + inventories.received.activatedRows[index], + `${path}.rows[${index}] product row`, + ); + return Object.freeze({ + ...contractRow, + swmGraph: boundedString(row.swmGraph, `${path}.rows[${index}].swmGraph`), + }); + }); + exact( + wire.activatedTripleCount, + rows.reduce((total, row) => total + row.activatedTripleCount, 0), + `${path}.activatedTripleCount`, + ); + return { rows: Object.freeze(rows), verifiedControlObjectCount }; +} + +interface WireRow extends AssetRowV1 { + readonly swmGraph: string; +} + +function verifySemanticState(value: unknown, path: string, rows: readonly WireRow[]): void { + const semantic = closedArray(value, path, 3); + semantic.forEach((entry, index) => { + const state = closedRecord(entry, `${path}[${index}]`, ['kaId', 'readBack']); + exact(state.kaId, rows[index]!.kaId, `${path}[${index}].kaId`); + const readBack = closedRecord(state.readBack, `${path}[${index}].readBack`, [ + 'activatedQuadCount', + 'projectionNQuads', + 'swmGraph', + ]); + exact( + readBack.activatedQuadCount, + rows[index]!.activatedTripleCount, + `${path}[${index}].activatedQuadCount`, + ); + exact(readBack.swmGraph, rows[index]!.swmGraph, `${path}[${index}].swmGraph`); + const projection = boundedString( + readBack.projectionNQuads, + `${path}[${index}].projectionNQuads`, + 256 * 1024, + ); + exact( + sha256Digest(projection), + rows[index]!.contentDigest, + `${path}[${index}].projection digest`, + ); + }); +} + +function verifyRestart( + value: unknown, + inventories: { authored: Gate2AuthoredInventory; received: Gate2ReceivedInventory }, + expectedApplied: unknown, + peers: { author: string; receiver: string }, + semanticBefore: unknown, +): void { + const restart = closedRecord(value, '$.restartReplay', [ + 'appliedReadBack', + 'crashExit', + 'processLocalSynchronization', + 'reannouncementAcknowledgedByPeerId', + 'receiverStats', + 'restartedReady', + 'semanticPostRead', + 'successorServedByPeerId', + ]); + exactJson(restart.appliedReadBack, expectedApplied, '$.restartReplay.appliedReadBack'); + verifyExit(restart.crashExit, '$.restartReplay.crashExit', null, 'SIGKILL'); + exact(restart.processLocalSynchronization, null, '$.restartReplay.processLocalSynchronization'); + exact( + restart.reannouncementAcknowledgedByPeerId, + peers.receiver, + '$.restartReplay.reannouncementAcknowledgedByPeerId', + ); + exact(restart.successorServedByPeerId, peers.author, '$.restartReplay.successorServedByPeerId'); + const stats = closedRecord(restart.receiverStats, '$.restartReplay.receiverStats', [ + 'applied', + 'dedupedAlreadyApplied', + ]); + exact(stats.applied, 0, '$.restartReplay.receiverStats.applied'); + exact(stats.dedupedAlreadyApplied, 1, '$.restartReplay.receiverStats.dedupedAlreadyApplied'); + const restartedPeer = verifyReadyEvent( + restart.restartedReady, + '$.restartReplay.restartedReady', + 'receiver', + ); + exact(restartedPeer, peers.receiver, '$.restartReplay.restartedReady.peerId'); + exactJson(restart.semanticPostRead, semanticBefore, '$.restartReplay.semanticPostRead'); + const rows = inventories.received.activatedRows.map((row, index) => ({ + ...row, + swmGraph: (closedRecord( + (closedArray(semanticBefore, '$.authorizationNegative.semanticBefore', 3)[index] as unknown), + `$.authorizationNegative.semanticBefore[${index}]`, + ['kaId', 'readBack'], + ).readBack as Record).swmGraph as string, + })); + verifySemanticState(restart.semanticPostRead, '$.restartReplay.semanticPostRead', rows); +} + +function verifyReadyPair(value: unknown): { author: string; receiver: string } { + const ready = closedRecord(value, '$.ready', ['author', 'receiver']); + const author = verifyReadyEvent(ready.author, '$.ready.author', 'author'); + const receiver = verifyReadyEvent(ready.receiver, '$.ready.receiver', 'receiver'); + if (author === receiver) fail('$.ready', 'author and receiver peers must be distinct'); + return { author, receiver }; +} + +function verifyReadyEvent(value: unknown, path: string, role: 'author' | 'receiver'): string { + const ready = closedRecord(value, path, [ + 'adapterId', + 'peerId', + 'protocolVersion', + 'role', + 'startupRepair', + ]); + exact(ready.adapterId, GATE2_REAL_DKG_AGENT_ADAPTER_ID, `${path}.adapterId`); + exact(ready.protocolVersion, GATE2_ADAPTER_PROTOCOL_VERSION, `${path}.protocolVersion`); + exact(ready.role, role, `${path}.role`); + exact(ready.startupRepair, null, `${path}.startupRepair`); + return boundedString(ready.peerId, `${path}.peerId`); +} + +function verifyProcessBoundary(value: unknown): void { + const boundary = closedRecord(value, '$.processBoundary', [ + 'authorInstances', + 'model', + 'receiverInstances', + 'stoppedExits', + ]); + exact(boundary.authorInstances, 1, '$.processBoundary.authorInstances'); + exact(boundary.receiverInstances, 2, '$.processBoundary.receiverInstances'); + exact( + boundary.model, + 'two real DKGAgent peer processes plus one receiver restart', + '$.processBoundary.model', + ); + const exits = closedRecord(boundary.stoppedExits, '$.processBoundary.stoppedExits', [ + 'author', + 'restartedReceiver', + ]); + verifyExit(exits.author, '$.processBoundary.stoppedExits.author', 0, null); + verifyExit(exits.restartedReceiver, '$.processBoundary.stoppedExits.restartedReceiver', 0, null); +} + +function verifyPolicy(value: unknown): { + contextGraphId: string; + networkId: string; + policyDigest: string; +} { + const policy = closedRecord(value, '$.policy', [ + 'authorPolicyDigest', + 'contextGraphId', + 'networkId', + 'receiverPolicyDigest', + ]); + const policyDigest = digest(policy.authorPolicyDigest, '$.policy.authorPolicyDigest'); + exact(policy.receiverPolicyDigest, policyDigest, '$.policy.receiverPolicyDigest'); + return { + contextGraphId: boundedString(policy.contextGraphId, '$.policy.contextGraphId'), + networkId: boundedString(policy.networkId, '$.policy.networkId'), + policyDigest, + }; +} + +function verifyExit( + value: unknown, + path: string, + expectedCode: number | null, + expectedSignal: string | null, +): void { + const exit = closedRecord(value, path, ['code', 'signal']); + exact(exit.code, expectedCode, `${path}.code`); + exact(exit.signal, expectedSignal, `${path}.signal`); +} + +function closedRecord( + value: unknown, + path: string, + expectedKeys: readonly string[], +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'must be a plain object'); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) fail(path, 'must be plain'); + const keys = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + if (stableJson(keys) !== stableJson(expected)) { + fail(path, `must contain exactly keys ${expected.join(', ')}`); + } + return value as Record; +} + +function closedArray(value: unknown, path: string, exactLength: number): unknown[] { + if (!Array.isArray(value) || value.length !== exactLength) { + fail(path, `must be an Array of exactly ${exactLength} entries`); + } + return value; +} + +function digest(value: unknown, path: string): string { + return matchString(value, DIGEST, path); +} + +function address(value: unknown, path: string): string { + return matchString(value, ADDRESS, path); +} + +function decimal(value: unknown, path: string): string { + return matchString(value, DECIMAL, path); +} + +function matchString(value: unknown, pattern: RegExp, path: string): string { + const text = boundedString(value, path); + if (!pattern.test(text)) fail(path, 'is malformed'); + return text; +} + +function boundedString(value: unknown, path: string, max = 4_096): string { + if (typeof value !== 'string' || value.length < 1 || value.length > max) { + fail(path, 'must be a bounded non-empty string'); + } + return value; +} + +function positiveInteger(value: unknown, path: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + fail(path, 'must be a positive safe integer'); + } + return value as number; +} + +function exactInteger(value: unknown, expected: number, path: string): number { + if (!Number.isSafeInteger(value) || value !== expected) fail(path, `must equal ${expected}`); + return value as number; +} + +function exact(actual: unknown, expected: unknown, path: string): void { + if (!Object.is(actual, expected)) fail(path, `must equal ${JSON.stringify(expected)}`); +} + +function exactJson(actual: unknown, expected: unknown, path: string): void { + if (stableJson(actual) !== stableJson(expected)) fail(path, 'must equal exact product evidence'); +} + +function fail(path: string, message: string): never { + throw new Error(`Gate 2 evidence verification failed at ${path}: ${message}`); +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/model.ts b/devnet/rfc64-gate2-multi-asset-completeness/model.ts new file mode 100644 index 0000000000..18124113af --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/model.ts @@ -0,0 +1,65 @@ +import type { + AssetRowV1, + CatalogScopeV1, +} from './src/schema.ts'; + +export const GATE2_RAW_SCHEMA_VERSION = + 'dkg-rfc64-gate2-multi-asset-evidence-v1' as const; +export const GATE2_VERDICT_SCHEMA_VERSION = + 'dkg-rfc64-gate2-multi-asset-verdict-v1' as const; +export const GATE2_ADAPTER_PROTOCOL_VERSION = + 'dkg-rfc64-gate2-adapter-protocol-v1' as const; +export const GATE2_REAL_DKG_AGENT_ADAPTER_ID = 'real-dkg-agent-gate2-v1' as const; +export const GATE2_AGENT_EVENT_PREFIX = 'RFC64_GATE2_ADAPTER_EVENT ' as const; + +export const REQUIRED_PRODUCTION_ADAPTER_OPERATIONS = Object.freeze([ + 'publishGenesis', + 'publishExactSetSuccessor', + 'announce', + 'appliedHeadReadback', + 'exactInventoryReadback', + 'terminalFailureReadback', + 'killRestart', +] as const); + +export interface Gate2AppliedHeadReadBack { + readonly appliedInventoryDigest: string; + readonly catalogVersion: string; + readonly currentCatalogHeadDigest: string; + readonly inventoryRowCount: number; +} + +export interface Gate2AuthoredInventory { + readonly catalogScope: CatalogScopeV1; + readonly declaredCatalogScopeDigest: string; + readonly catalogHeadDigest: string; + readonly catalogHeadTotalRows: string; + readonly signedBucketRowCount: string; + readonly signedRows: readonly AssetRowV1[]; +} + +export interface Gate2ReceivedInventory { + readonly catalogHeadDigest: string; + readonly declaredInventoryDigest: string; + readonly inventoryRowCount: number; + readonly activatedRows: readonly AssetRowV1[]; +} + +export interface Gate2SemanticReadBack { + readonly activatedQuadCount: number; + readonly projectionNQuads: string; + readonly swmGraph: string; +} + +export function appliedReadBackFromInventories( + authored: Gate2AuthoredInventory, + received: Gate2ReceivedInventory, + catalogVersion: string, +): Readonly { + return Object.freeze({ + appliedInventoryDigest: received.declaredInventoryDigest, + catalogVersion, + currentCatalogHeadDigest: authored.catalogHeadDigest, + inventoryRowCount: received.inventoryRowCount, + }); +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/package.json b/devnet/rfc64-gate2-multi-asset-completeness/package.json index 7447e191df..16934d9ac3 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/package.json +++ b/devnet/rfc64-gate2-multi-asset-completeness/package.json @@ -3,11 +3,21 @@ "private": true, "version": "0.0.0", "type": "module", - "description": "Closed product-shaped Gate 2 exact-set evidence contract with production-compatible scope and applied-inventory digests; never asserts a gate pass.", + "description": "Connected two-process Gate 2 exact-set harness plus its closed product-shaped completeness contract.", + "dependencies": { + "@multiformats/multiaddr": "^13.0.3", + "@origintrail-official/dkg-agent": "workspace:*", + "@origintrail-official/dkg-chain": "workspace:*", + "@origintrail-official/dkg-core": "workspace:*", + "@origintrail-official/dkg-storage": "workspace:*", + "ethers": "^6.16.0" + }, "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "test": "node --experimental-strip-types --test test/completeness.test.ts", "generate": "node --experimental-strip-types src/cli/generate.ts", - "verify": "node --experimental-strip-types src/cli/verify.ts" + "verify": "node --experimental-strip-types src/cli/verify.ts", + "live:generate": "node --import tsx run.ts", + "live:verify": "node --import tsx verify-live.ts" } } diff --git a/devnet/rfc64-gate2-multi-asset-completeness/run.ts b/devnet/rfc64-gate2-multi-asset-completeness/run.ts new file mode 100644 index 0000000000..3c7700d731 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/run.ts @@ -0,0 +1,1128 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; + +import { + assertCanonicalGraphScopedAuthorSealV1, + buildAuthorAttestationTypedData, + computeAuthorCatalogScopeDigestV1, + type CanonicalGraphScopedAuthorSealV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +import { + atomicWriteStableJson, + readCleanRepositoryHead, + stableJson, +} from '../rfc64-persistence-lifecycle/evidence.js'; +import { + ChildProcessRegistry, + cleanupPreservingPrimaryFailure, + type ProcessExitEvidence, +} from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { Gate2AgentChild, type Gate2AgentEvent } from './agent-child.js'; +import { + GATE2_ADAPTER_PROTOCOL_VERSION, + GATE2_RAW_SCHEMA_VERSION, + GATE2_REAL_DKG_AGENT_ADAPTER_ID, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + appliedReadBackFromInventories, + type Gate2AppliedHeadReadBack, + type Gate2AuthoredInventory, + type Gate2ReceivedInventory, + type Gate2SemanticReadBack, +} from './model.js'; +import { + GATE_EVALUATION, + PRODUCT_BOUNDARY, + RAW_SCHEMA_ID, + type AssetRowV1, + type CatalogScopeV1, +} from './src/schema.ts'; +import { verify as verifyInventoryContract } from './src/verify.ts'; + +const REPO_ROOT = resolve(import.meta.dirname, '../..'); +const ADAPTER_PROCESS = join(import.meta.dirname, 'adapter-process.ts'); +const DEFAULT_RAW_ARTIFACT = join(import.meta.dirname, 'artifacts/gate2-result.json'); +const DEFAULT_VERDICT_ARTIFACT = join(import.meta.dirname, 'artifacts/gate2-verdict.json'); +const PROCESS_TIMEOUT_MS = 90_000; + +const NETWORK_ID = 'otp:20430'; +const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/gate-2'; +const FORGED_CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/gate-2-forged-authorization'; +const AUTHOR_PRIVATE_KEY = `0x${'64'.repeat(32)}`; +const ATTACKER_PRIVATE_KEY = `0x${'65'.repeat(32)}`; +const AUTHOR_WALLET = new ethers.Wallet(AUTHOR_PRIVATE_KEY); +const AUTHOR_ADDRESS = AUTHOR_WALLET.address.toLowerCase(); +const ATTACKER_ADDRESS = new ethers.Wallet(ATTACKER_PRIVATE_KEY).address.toLowerCase(); +const KAV10_ADDRESS = '0x4444444444444444444444444444444444444444'; +const DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10_ADDRESS, +}); +const KA_NUMBERS = Object.freeze([7n, 8n, 9n]); +const ASSERTION_ROOTS = Object.freeze([ + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + '0x9d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609e', + '0xad7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609d', +]); +const PROJECTION_NQUADS = Object.freeze([ + ' "One" .\n', + ' "Two" .\n' + + ' "2" .\n', + ' "Three" .\n' + + ' "3" .\n' + + ' "v1" .\n', +]); +const GENESIS_ISSUED_AT = '1773900000000'; +const SUCCESSOR_ISSUED_AT = Object.freeze([ + '1773900001000', + '1773900002000', + '1773900003000', +]); +const FORGED_ISSUED_AT = '1773900004000'; +const DELEGATION_EFFECTIVE_AT = '1773899999000'; +const DELEGATION_EXPIRES_AT = '1774000000000'; +const ROLE_MASTER_KEYS = Object.freeze({ + author: '1a'.repeat(32), + receiver: '2b'.repeat(32), +}); + +async function execute(): Promise { + const headBefore = readCleanRepositoryHead(REPO_ROOT); + const rawArtifactPath = process.env.DKG_RFC64_GATE2_ARTIFACT ?? DEFAULT_RAW_ARTIFACT; + const verdictArtifactPath = process.env.DKG_RFC64_GATE2_VERDICT_ARTIFACT + ?? DEFAULT_VERDICT_ARTIFACT; + rmSync(rawArtifactPath, { force: true }); + rmSync(verdictArtifactPath, { force: true }); + + const authorDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate2-author-')); + const receiverDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate2-receiver-')); + const children = new ChildProcessRegistry(20_000); + let operationFailed = true; + let primaryFailure: unknown; + try { + const author = spawnAgent('author', authorDataDir, children); + const receiver = spawnAgent('receiver', receiverDataDir, children); + const [authorReady, receiverReady] = await Promise.all([ + author.waitFor('ready'), + receiver.waitFor('ready'), + ]); + requireRealReady(authorReady, 'author'); + requireRealReady(receiverReady, 'receiver'); + requireCondition(authorReady.peerId !== receiverReady.peerId, 'peer identities are not distinct'); + await connectBothWays(author, receiver, authorReady, receiverReady, 'initial'); + + const [authorPolicy, receiverPolicy] = await Promise.all([ + acceptPolicy(author, 'author-policy-v1', CONTEXT_GRAPH_ID), + acceptPolicy(receiver, 'receiver-policy-v1', CONTEXT_GRAPH_ID), + ]); + const authorPolicyDigest = requiredOutputDigest(authorPolicy, 'policyDigest'); + const receiverPolicyDigest = requiredOutputDigest(receiverPolicy, 'policyDigest'); + exact(authorPolicyDigest, receiverPolicyDigest, 'independently accepted policy digests'); + assertGate2ProductCapabilities(authorReady.capabilities, 'author'); + assertGate2ProductCapabilities(receiverReady.capabilities, 'receiver'); + + const genesis = outputRecord(await author.request( + 'publishGenesis', + 'publish-genesis-v1', + 'operation-completed', + { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + authorPrivateKey: AUTHOR_PRIVATE_KEY, + issuedAt: GENESIS_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, + }, + ), 'genesis'); + const genesisAnnouncement = outputRecordValue(genesis, 'announcement', 'genesis'); + exact( + requiredString(genesisAnnouncement.policyDigest, 'genesis.announcement.policyDigest'), + receiverPolicyDigest, + 'genesis policy digest', + ); + await announceAndDrain( + author, + receiver, + genesisAnnouncement, + requiredString(receiverReady.peerId, 'receiver peer ID'), + 'genesis', + ); + + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR_ADDRESS, + era: '0', + bucketCount: '1', + } as never); + const genesisApplied = await readApplied(receiver, catalogScopeDigest, AUTHOR_ADDRESS, 'genesis'); + exact(genesisApplied.catalogVersion, '0', 'genesis applied version'); + exact(genesisApplied.inventoryRowCount, 0, 'genesis applied row count'); + exact( + genesisApplied.currentCatalogHeadDigest, + requiredString(genesis.headObjectDigest, 'genesis.headObjectDigest'), + 'genesis applied head', + ); + + const catalogIssuerAuthorization = outputRecordValue( + genesis, + 'catalogIssuerAuthorization', + 'genesis', + ); + const allAssets = await Promise.all(KA_NUMBERS.map(async (kaNumber, index) => ({ + assertionCoordinate: `gate-2-object-${index + 1}`, + projectionNQuads: PROJECTION_NQUADS[index]!, + seal: await authorSeal(kaNumber, index + 1, ASSERTION_ROOTS[index]!), + }))); + let previousHead = stagedHeadRef(genesis, 'genesis'); + let previousHeadDigest = requiredString(genesis.headObjectDigest, 'genesis.headObjectDigest'); + const transitions: Record[] = []; + let finalPublication: Record | undefined; + let finalSynchronization: Record | undefined; + for (let index = 0; index < allAssets.length; index += 1) { + const liveSet = allAssets.slice(0, index + 1); + const inputOrder = index === 2 + ? [liveSet[2]!, liveSet[0]!, liveSet[1]!] + : [...liveSet].reverse(); + const publication = await publishExactSetSuccessor(author, { + requestId: `publish-exact-set-${index + 1}-v1`, + previousHead, + catalogIssuerAuthorization, + assets: inputOrder, + issuedAt: SUCCESSOR_ISSUED_AT[index]!, + }); + const announcement = outputRecordValue( + publication, + 'announcement', + `exact-set-${index + 1} publication`, + ); + exact( + requiredString(announcement.policyDigest, `exact-set-${index + 1}.policyDigest`), + receiverPolicyDigest, + `exact-set-${index + 1} policy digest`, + ); + await announceAndDrain( + author, + receiver, + announcement, + requiredString(receiverReady.peerId, 'receiver peer ID'), + `exact-set-${index + 1}`, + ); + const headDigest = requiredDigest( + publication.headObjectDigest, + `exact-set-${index + 1}.headObjectDigest`, + ); + const synchronization = await readSynchronization( + receiver, + headDigest, + `exact-set-${index + 1}`, + ); + exact( + requiredSafeInteger( + synchronization.inventoryRowCount, + `exact-set-${index + 1}.sync.inventoryRowCount`, + ), + index + 1, + `exact-set-${index + 1} synchronized row count`, + ); + exact( + requiredString( + synchronization.appliedHeadStatus, + `exact-set-${index + 1}.sync.appliedHeadStatus`, + ), + 'applied', + `exact-set-${index + 1} applied status`, + ); + transitions.push(Object.freeze({ + catalogHeadDigest: headDigest, + catalogVersion: requiredString( + announcement.catalogVersion, + `exact-set-${index + 1}.catalogVersion`, + ), + inventoryRowCount: index + 1, + previousCatalogHeadDigest: previousHeadDigest, + signatureVariantDigest: requiredDigest( + publication.signatureVariantDigest, + `exact-set-${index + 1}.signatureVariantDigest`, + ), + })); + previousHead = stagedHeadRef(publication, `exact-set-${index + 1}`); + previousHeadDigest = headDigest; + finalPublication = publication; + finalSynchronization = synchronization; + } + if (finalPublication === undefined || finalSynchronization === undefined) { + throw new Error('Gate 2 produced no final three-row successor evidence'); + } + + const finalAnnouncement = outputRecordValue( + finalPublication, + 'announcement', + 'final publication', + ); + const authored = authoredInventoryFromPublication(finalPublication); + const received = receivedInventoryFromSynchronization(finalSynchronization); + exact(authored.catalogHeadDigest, received.catalogHeadDigest, 'authored/received head'); + exact(authored.declaredCatalogScopeDigest, catalogScopeDigest, 'product catalog scope digest'); + exact( + verifiedControlObjectCount(finalSynchronization, 'final synchronization'), + 4, + 'final verified control-object count', + ); + const contractVerdict = verifyInventoryContract({ + schema: RAW_SCHEMA_ID, + productBoundary: PRODUCT_BOUNDARY, + gateEvaluation: GATE_EVALUATION, + authored, + received, + }); + exact(contractVerdict.fixtureComplete, true, 'reviewed exact-set evidence contract'); + const expectedApplied = appliedReadBackFromInventories( + authored, + received, + requiredString(finalAnnouncement.catalogVersion, 'final catalog version'), + ); + const positiveApplied = await readApplied( + receiver, + authored.declaredCatalogScopeDigest, + AUTHOR_ADDRESS, + 'final-positive', + ); + exactJson(positiveApplied, expectedApplied, 'final positive durable applied head'); + const semanticBeforeNegative = await readExactSemanticState( + receiver, + finalSynchronization, + 'before-negative', + ); + + const [authorForgedPolicy, receiverForgedPolicy] = await Promise.all([ + acceptPolicy(author, 'author-forged-policy-v1', FORGED_CONTEXT_GRAPH_ID), + acceptPolicy(receiver, 'receiver-forged-policy-v1', FORGED_CONTEXT_GRAPH_ID), + ]); + const forgedPolicyDigest = requiredOutputDigest(authorForgedPolicy, 'policyDigest'); + exact( + requiredOutputDigest(receiverForgedPolicy, 'policyDigest'), + forgedPolicyDigest, + 'forged-CG independently accepted policy digest', + ); + const forgedPrepared = outputRecord(await author.request( + 'prepareForgedAuthorizationGenesis', + 'prepare-forged-authorization-v1', + 'operation-completed', + { + networkId: NETWORK_ID, + contextGraphId: FORGED_CONTEXT_GRAPH_ID, + policyDigest: forgedPolicyDigest, + catalogAuthorPrivateKey: AUTHOR_PRIVATE_KEY, + attackerPrivateKey: ATTACKER_PRIVATE_KEY, + issuedAt: FORGED_ISSUED_AT, + delegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + delegationExpiresAt: DELEGATION_EXPIRES_AT, + }, + ), 'forged setup'); + const forgedAnnouncement = outputRecordValue(forgedPrepared, 'announcement', 'forged setup'); + const forgedHeadDigest = requiredDigest( + forgedPrepared.attemptedCatalogHeadDigest, + 'forged.attemptedCatalogHeadDigest', + ); + exact(forgedPrepared.catalogAuthorAddress, AUTHOR_ADDRESS, 'forged claimed author'); + exact(forgedPrepared.recoveredAuthorAddress, ATTACKER_ADDRESS, 'forged recovered author'); + const statsBeforeForged = await readReceiverStats(receiver, 'before-forged'); + await announceAndDrain( + author, + receiver, + forgedAnnouncement, + requiredString(receiverReady.peerId, 'receiver peer ID'), + 'forged', + ); + const statsAfterForged = await readReceiverStats(receiver, 'after-forged'); + exact( + requiredSafeInteger(statsAfterForged.failed, 'statsAfterForged.failed'), + requiredSafeInteger(statsBeforeForged.failed, 'statsBeforeForged.failed') + 1, + 'forged terminal failure count', + ); + const forgedScopeDigest = computeAuthorCatalogScopeDigestV1({ + networkId: NETWORK_ID, + contextGraphId: FORGED_CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR_ADDRESS, + era: '0', + bucketCount: '1', + } as never); + const forgedApplied = await readAppliedNullable( + receiver, + forgedScopeDigest, + AUTHOR_ADDRESS, + 'forged', + ); + exact(forgedApplied, null, 'forged applied head'); + const forgedSync = await readSynchronizationNullable(receiver, forgedHeadDigest, 'forged'); + exact(forgedSync, null, 'forged synchronization evidence'); + const appliedAfterForged = await readApplied( + receiver, + authored.declaredCatalogScopeDigest, + AUTHOR_ADDRESS, + 'after-forged', + ); + const inventoryAfterForged = await readSynchronization( + receiver, + authored.catalogHeadDigest, + 'after-forged', + ); + exactJson(appliedAfterForged, positiveApplied, 'positive applied state after forged attempt'); + exactJson( + inventoryAfterForged, + finalSynchronization, + 'positive exact inventory after forged attempt', + ); + const semanticAfterNegative = await readExactSemanticState( + receiver, + finalSynchronization, + 'after-negative', + ); + exactJson( + semanticAfterNegative, + semanticBeforeNegative, + 'exact semantic state after forged attempt', + ); + const terminalFailure = outputRecord(await receiver.request( + 'terminalFailureReadback', + 'forged-terminal-failure-v1', + 'operation-completed', + { catalogHeadDigest: forgedHeadDigest }, + ), 'forged terminal failure'); + const failureCode = requiredString(terminalFailure.errorCode, 'terminalFailure.errorCode'); + requiredString(terminalFailure.errorName, 'terminalFailure.errorName'); + exact( + requiredDigest(terminalFailure.catalogHeadDigest, 'terminalFailure.catalogHeadDigest'), + forgedHeadDigest, + 'terminal failure head digest', + ); + exact(failureCode, 'catalog-native-receiver-authorization', 'terminal failure code'); + + const receiverCrashExit = await receiver.killRestartBoundary('receiver-crash-v1'); + const restartedReceiver = spawnAgent('receiver', receiverDataDir, children); + const restartedReady = await restartedReceiver.waitFor('ready'); + requireRealReady(restartedReady, 'receiver'); + exact(restartedReady.peerId, receiverReady.peerId, 'receiver peer ID after restart'); + await connectBothWays(author, restartedReceiver, authorReady, restartedReady, 'restart'); + await acceptPolicy(restartedReceiver, 'restarted-receiver-policy-v1', CONTEXT_GRAPH_ID); + await announceAndDrain( + author, + restartedReceiver, + finalAnnouncement, + requiredString(restartedReady.peerId, 'restarted receiver peer ID'), + 'replay', + ); + const replayApplied = await readApplied( + restartedReceiver, + authored.declaredCatalogScopeDigest, + AUTHOR_ADDRESS, + 'replay', + ); + const replayedSynchronization = await readSynchronizationNullable( + restartedReceiver, + authored.catalogHeadDigest, + 'replay', + ); + exact(replayedSynchronization, null, 'already-applied replay process-local evidence'); + const restartStats = await readReceiverStats(restartedReceiver, 'restart-replay'); + exact( + requiredSafeInteger(restartStats.dedupedAlreadyApplied, 'restart dedupedAlreadyApplied'), + 1, + 'restart durable applied-head dedupe', + ); + exact( + requiredSafeInteger(restartStats.applied, 'restart applied'), + 0, + 'restart duplicate activation count', + ); + const semanticAfterRestart = await readExactSemanticState( + restartedReceiver, + finalSynchronization, + 'after-restart', + ); + exactJson( + semanticAfterRestart, + semanticBeforeNegative, + 'exact three-row SWM state across SIGKILL/restart/reannouncement', + ); + exactJson(replayApplied, expectedApplied, 'replay durable applied readback'); + + const restartedReceiverExit = await restartedReceiver.stop('receiver-stop-v1'); + const authorExit = await author.stop('author-stop-v1'); + const headAfter = readCleanRepositoryHead(REPO_ROOT); + exact(headAfter, headBefore, 'tracked source commit after process run'); + + const artifact = { + adapter: { + id: GATE2_REAL_DKG_AGENT_ADAPTER_ID, + inspectedProductCommits: [headBefore], + productBoundary: 'connected', + protocolVersion: GATE2_ADAPTER_PROTOCOL_VERSION, + requiredProductionOperations: REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + replacementContract: + 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence', + }, + authorizationNegative: { + attemptedCatalogHeadDigest: forgedHeadDigest, + catalogAuthorAddress: AUTHOR_ADDRESS, + expectedFailureCode: failureCode, + forgedAppliedHead: forgedApplied, + forgedSynchronization: forgedSync, + positiveAppliedAfter: structuredClone(appliedAfterForged), + positiveAppliedBefore: structuredClone(positiveApplied), + positiveInventoryAfter: structuredClone(inventoryAfterForged), + positiveInventoryBefore: structuredClone(finalSynchronization), + recoveredAuthorAddress: ATTACKER_ADDRESS, + semanticAfter: structuredClone(semanticAfterNegative), + semanticBefore: structuredClone(semanticBeforeNegative), + servedByPeerId: authorReady.peerId, + testedByPeerId: receiverReady.peerId, + }, + gate: 'OT-RFC-64 Gate 2 multi-asset completeness', + gateEvaluation: { + reason: + 'two real DKGAgent processes completed production 1-to-2-to-3 exact-set publication, ' + + 'synchronization, authorization-negative, SIGKILL, same-head replay, and exact readback', + status: 'PASS', + }, + harnessChecksPassed: true, + inventory: { + authored: structuredClone(authored), + received: structuredClone(received), + }, + invocation: 'pnpm test:gate2:rfc64-multi-asset-harness', + policy: { + authorPolicyDigest, + contextGraphId: CONTEXT_GRAPH_ID, + networkId: NETWORK_ID, + receiverPolicyDigest, + }, + processBoundary: { + authorInstances: 1, + model: 'two real DKGAgent peer processes plus one receiver restart', + receiverInstances: 2, + stoppedExits: { + author: selectExit(authorExit), + restartedReceiver: selectExit(restartedReceiverExit), + }, + }, + ready: { + author: selectReady(authorReady), + receiver: selectReady(receiverReady), + }, + repository: { + testedHeadCommit: headBefore, + trackedSourceCleanAfterProcesses: true, + trackedSourceCleanBeforeSpawn: true, + }, + restartReplay: { + appliedReadBack: structuredClone(replayApplied), + crashExit: receiverCrashExit, + processLocalSynchronization: replayedSynchronization, + reannouncementAcknowledgedByPeerId: restartedReady.peerId, + receiverStats: { + applied: requiredSafeInteger(restartStats.applied, 'restart stats applied'), + dedupedAlreadyApplied: requiredSafeInteger( + restartStats.dedupedAlreadyApplied, + 'restart stats dedupedAlreadyApplied', + ), + }, + restartedReady: selectReady(restartedReady), + semanticPostRead: structuredClone(semanticAfterRestart), + successorServedByPeerId: authorReady.peerId, + }, + schemaVersion: GATE2_RAW_SCHEMA_VERSION, + transitions: structuredClone(transitions), + transport: { + finalAnnouncementPolicyDigest: requiredDigest( + finalAnnouncement.policyDigest, + 'final announcement policy digest', + ), + finalSignatureVariantDigest: requiredDigest( + finalPublication.signatureVariantDigest, + 'final signature variant digest', + ), + receivedByPeerId: receiverReady.peerId, + servedByPeerId: authorReady.peerId, + verifiedControlObjectCount: verifiedControlObjectCount( + finalSynchronization, + 'final synchronization', + ), + }, + }; + const publication = atomicWriteStableJson(rawArtifactPath, artifact); + process.stdout.write( + `[rfc64-gate2-harness] wrote ${rawArtifactPath} sha256=${publication.sha256}\n`, + ); + operationFailed = false; + } catch (error) { + primaryFailure = error; + } finally { + await cleanupPreservingPrimaryFailure({ + operationFailed, + primaryFailure, + cleanup: () => children.terminateAllThenCleanup(() => { + rmSync(authorDataDir, { force: true, recursive: true }); + rmSync(receiverDataDir, { force: true, recursive: true }); + }), + reportSecondaryFailure: (primary, secondary) => { + process.stderr.write( + `[rfc64-gate2-harness] cleanup failure after ${String(primary)}: ${String(secondary)}\n`, + ); + }, + }); + } +} + +async function publishExactSetSuccessor( + author: Gate2AgentChild, + input: { + readonly requestId: string; + readonly previousHead: Record; + readonly catalogIssuerAuthorization: Record; + readonly assets: readonly Record[]; + readonly issuedAt: string; + }, +): Promise> { + const event = await author.request( + 'publishExactSetSuccessor', + input.requestId, + 'operation-completed', + { + previousHead: input.previousHead, + authorPrivateKey: AUTHOR_PRIVATE_KEY, + catalogIssuerAuthorization: input.catalogIssuerAuthorization, + assets: input.assets, + deployment: DEPLOYMENT, + issuedAt: input.issuedAt, + peers: [], + }, + ); + return outputRecord(event, input.requestId); +} + +async function announceAndDrain( + author: Gate2AgentChild, + receiver: Gate2AgentChild, + announcement: Record, + receiverPeerId: string, + label: string, +): Promise { + const announced = await author.request('announce', `${label}-announce-v1`, 'operation-completed', { + announcement, + peers: [receiverPeerId], + }); + const output = outputRecord(announced, `${label} announce`); + exactJson(output.announcedPeers, [receiverPeerId], `${label} announced peers`); + exactJson(output.failedPeers, [], `${label} failed peers`); + await receiver.request('awaitReceiverIdle', `${label}-receiver-idle-v1`, 'receiver-idle'); +} + +async function acceptPolicy( + child: Gate2AgentChild, + requestId: string, + contextGraphId: string, +): Promise { + return child.request('acceptOpenPolicy', requestId, 'operation-completed', { + networkId: NETWORK_ID, + contextGraphId, + ownerAddress: AUTHOR_ADDRESS, + }); +} + +async function connectBothWays( + author: Gate2AgentChild, + receiver: Gate2AgentChild, + authorReady: Gate2AgentEvent, + receiverReady: Gate2AgentEvent, + label: string, +): Promise { + await Promise.all([ + receiver.request('dial', `${label}-receiver-dial-author-v1`, 'dialed', { + multiaddr: authorReady.multiaddr, + peerId: authorReady.peerId, + }), + author.request('dial', `${label}-author-dial-receiver-v1`, 'dialed', { + multiaddr: receiverReady.multiaddr, + peerId: receiverReady.peerId, + }), + ]); +} + +async function readApplied( + receiver: Gate2AgentChild, + catalogScopeDigest: string, + authorAddress: string, + label: string, +): Promise { + const value = await readAppliedNullable(receiver, catalogScopeDigest, authorAddress, label); + if (value === null) throw new Error(`${label} applied-head readback is missing`); + return value; +} + +async function readAppliedNullable( + receiver: Gate2AgentChild, + catalogScopeDigest: string, + authorAddress: string, + label: string, +): Promise { + const event = await receiver.request( + 'appliedHeadReadback', + `${label}-applied-head-v1`, + 'operation-completed', + { catalogScopeDigest, authorAddress }, + ); + if (event.output === null) return null; + const output = outputRecord(event, `${label} applied head`); + return Object.freeze({ + appliedInventoryDigest: requiredDigest( + output.appliedInventoryDigest, + `${label}.appliedInventoryDigest`, + ), + catalogVersion: requiredDecimal(output.catalogVersion, `${label}.catalogVersion`), + currentCatalogHeadDigest: requiredDigest( + output.currentCatalogHeadDigest, + `${label}.currentCatalogHeadDigest`, + ), + inventoryRowCount: decimalToSafeInteger( + output.inventoryRowCount, + `${label}.inventoryRowCount`, + ), + }); +} + +async function readSynchronization( + receiver: Gate2AgentChild, + catalogHeadDigest: string, + label: string, +): Promise> { + const value = await readSynchronizationNullable(receiver, catalogHeadDigest, label); + if (value === null) throw new Error(`${label} synchronization evidence is missing`); + return value; +} + +async function readSynchronizationNullable( + receiver: Gate2AgentChild, + catalogHeadDigest: string, + label: string, +): Promise | null> { + const event = await receiver.request( + 'exactInventoryReadback', + `${label}-exact-inventory-v1`, + 'operation-completed', + { catalogHeadDigest }, + ); + return event.output === null ? null : outputRecord(event, `${label} synchronization`); +} + +async function readReceiverStats( + receiver: Gate2AgentChild, + label: string, +): Promise> { + return outputRecord( + await receiver.request('receiverStats', `${label}-receiver-stats-v1`, 'operation-completed'), + `${label} receiver stats`, + ); +} + +async function readSemanticGraph( + receiver: Gate2AgentChild, + swmGraph: string, + label: string, +): Promise { + const output = outputRecord(await receiver.request( + 'semanticGraphReadback', + `${label}-semantic-graph-v1`, + 'operation-completed', + { swmGraph }, + ), `${label} semantic graph`); + return Object.freeze({ + activatedQuadCount: requiredSafeInteger( + output.activatedQuadCount, + `${label}.activatedQuadCount`, + ), + projectionNQuads: requiredString(output.projectionNQuads, `${label}.projectionNQuads`), + swmGraph: requiredString(output.swmGraph, `${label}.swmGraph`), + }); +} + +async function readExactSemanticState( + receiver: Gate2AgentChild, + synchronization: Record, + label: string, +): Promise[]> { + const rows = plainArray(synchronization.rows, `${label}.rows`).map((value, index) => + outputRecordValue({ row: value }, 'row', `${label}.rows[${index}]`)); + const result: Record[] = []; + for (const [index, row] of rows.entries()) { + const kaUal = requiredString(row.kaUal, `${label}.rows[${index}].kaUal`); + const kaNumber = Number(kaUal.slice(kaUal.lastIndexOf('/') + 1)); + const expectedIndex = KA_NUMBERS.findIndex((candidate) => candidate === BigInt(kaNumber)); + if (expectedIndex < 0) throw new Error(`${label} row UAL is outside the exact Gate 2 set`); + const readBack = await readSemanticGraph( + receiver, + requiredString(row.swmGraph, `${label}.rows[${index}].swmGraph`), + `${label}-${index}`, + ); + exact( + readBack.activatedQuadCount, + requiredSafeInteger(row.activatedTripleCount, `${label}.rows[${index}].tripleCount`), + `${label} semantic triple count ${index}`, + ); + exact( + readBack.projectionNQuads, + PROJECTION_NQUADS[expectedIndex], + `${label} semantic projection ${index}`, + ); + result.push(Object.freeze({ + kaId: requiredDecimal(row.kaId, `${label}.rows[${index}].kaId`), + readBack, + })); + } + return Object.freeze(result); +} + +function authoredInventoryFromPublication( + publication: Record, +): Gate2AuthoredInventory { + const scope = outputRecordValue(publication, 'catalogScope', 'publication'); + exact(scope.bucketCount, '1', 'catalog scope bucketCount'); + exact(scope.subGraphName, null, 'catalog scope subGraphName'); + const catalogScope = Object.freeze({ + networkId: requiredString(scope.networkId, 'catalogScope.networkId'), + contextGraphId: requiredString(scope.contextGraphId, 'catalogScope.contextGraphId'), + governanceChainId: nullableString(scope.governanceChainId, 'catalogScope.governanceChainId'), + governanceContractAddress: nullableString( + scope.governanceContractAddress, + 'catalogScope.governanceContractAddress', + ), + ownershipTransitionDigest: nullableString( + scope.ownershipTransitionDigest, + 'catalogScope.ownershipTransitionDigest', + ), + subGraphName: null, + authorAddress: requiredString(scope.authorAddress, 'catalogScope.authorAddress'), + era: requiredDecimal(scope.era, 'catalogScope.era'), + bucketCount: '1', + }) satisfies CatalogScopeV1; + const signedRows = Object.freeze(plainArray(publication.assets, 'publication.assets').map( + (value, index) => { + const asset = outputRecordValue({ asset: value }, 'asset', `publication.assets[${index}]`); + exact( + requiredSafeInteger( + asset.stagedBundleByteLength, + `publication.assets[${index}].stagedBundleByteLength`, + ), + decimalToSafeInteger( + asset.bundleByteLength, + `publication.assets[${index}].bundleByteLength`, + ), + `publication.assets[${index}] durable bundle byte length`, + ); + decimalToSafeInteger( + asset.contentByteLength, + `publication.assets[${index}].contentByteLength`, + ); + return assetRow(asset, `publication.assets[${index}]`); + }, + )); + return Object.freeze({ + catalogScope, + declaredCatalogScopeDigest: requiredDigest( + publication.catalogScopeDigest, + 'publication.catalogScopeDigest', + ), + catalogHeadDigest: requiredDigest(publication.headObjectDigest, 'publication.headObjectDigest'), + catalogHeadTotalRows: requiredDecimal( + publication.inventoryRowCount, + 'publication.inventoryRowCount', + ), + signedBucketRowCount: requiredDecimal( + publication.signedBucketRowCount, + 'publication.signedBucketRowCount', + ), + signedRows, + }); +} + +function receivedInventoryFromSynchronization( + synchronization: Record, +): Gate2ReceivedInventory { + const activatedRows = Object.freeze(plainArray( + synchronization.rows, + 'synchronization.rows', + ).map((value, index) => assetRow( + outputRecordValue({ row: value }, 'row', `synchronization.rows[${index}]`), + `synchronization.rows[${index}]`, + ))); + const inventoryRowCount = requiredSafeInteger( + synchronization.inventoryRowCount, + 'synchronization.inventoryRowCount', + ); + exact(inventoryRowCount, activatedRows.length, 'synchronization row count'); + exact( + requiredSafeInteger( + synchronization.activatedTripleCount, + 'synchronization.activatedTripleCount', + ), + activatedRows.reduce((total, row) => total + row.activatedTripleCount, 0), + 'synchronization total triple count', + ); + return Object.freeze({ + catalogHeadDigest: requiredDigest( + synchronization.catalogHeadDigest, + 'synchronization.catalogHeadDigest', + ), + declaredInventoryDigest: requiredDigest( + synchronization.inventoryDigest, + 'synchronization.inventoryDigest', + ), + inventoryRowCount, + activatedRows, + }); +} + +function assetRow(value: Record, label: string): AssetRowV1 { + return Object.freeze({ + kaId: requiredDecimal(value.kaId, `${label}.kaId`), + catalogRowDigest: requiredDigest(value.catalogRowDigest, `${label}.catalogRowDigest`), + contentDigest: requiredDigest(value.contentDigest, `${label}.contentDigest`), + sealDigest: requiredDigest(value.sealDigest, `${label}.sealDigest`), + bundleDigest: requiredDigest(value.bundleDigest, `${label}.bundleDigest`), + kaUal: requiredString(value.kaUal, `${label}.kaUal`), + activatedTripleCount: requiredSafeInteger( + value.activatedTripleCount, + `${label}.activatedTripleCount`, + ), + }); +} + +function verifiedControlObjectCount( + synchronization: Record, + label: string, +): number { + return requiredSafeInteger( + synchronization.verifiedControlObjectCount, + `${label}.verifiedControlObjectCount`, + ); +} + +async function authorSeal( + kaNumber: bigint, + publicTripleCount: number, + assertionRoot: string, +): Promise { + const kaId = ((BigInt(AUTHOR_ADDRESS) << 96n) | kaNumber).toString(); + const kaUal = `did:dkg:${NETWORK_ID}/${AUTHOR_ADDRESS}/${kaNumber}`; + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(DEPLOYMENT.assertedAtChainId), + kav10Address: DEPLOYMENT.assertedAtKav10Address, + merkleRoot: ethers.getBytes(assertionRoot), + authorAddress: AUTHOR_ADDRESS, + reservedKaId: BigInt(kaId), + }); + const signature = ethers.Signature.from(await AUTHOR_WALLET.signTypedData( + typedData.domain, + typedData.types, + typedData.message, + )); + const seal = { + assertionMerkleRoot: assertionRoot, + authorAddress: AUTHOR_ADDRESS, + authorAttestationR: signature.r, + authorAttestationVS: signature.yParityAndS, + authorSchemeVersion: '1', + assertedAtChainId: DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10_ADDRESS, + reservedKaId: kaId, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal, + assertionVersion: '1', + publicTripleCount: publicTripleCount.toString(), + privateTripleCount: '0', + privateMerkleRoot: null, + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(seal); + return seal; +} + +function stagedHeadRef(output: Record, label: string): Record { + return { + objectDigest: requiredDigest(output.headObjectDigest, `${label}.headObjectDigest`), + signatureVariantDigest: requiredDigest( + output.signatureVariantDigest, + `${label}.signatureVariantDigest`, + ), + }; +} + +function spawnAgent( + role: 'author' | 'receiver', + dataDir: string, + registry: ChildProcessRegistry, +): Gate2AgentChild { + return new Gate2AgentChild({ + eventTimeoutMs: PROCESS_TIMEOUT_MS, + registry, + role, + spawn: { + command: process.execPath, + args: ['--import', 'tsx', ADAPTER_PROCESS, role], + cwd: REPO_ROOT, + env: { + ...process.env, + DKG_RFC64_GATE2_ADAPTER_DATA_DIR: dataDir, + DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX: ROLE_MASTER_KEYS[role], + NODE_ENV: 'production', + }, + }, + }); +} + +function requireRealReady(event: Gate2AgentEvent, expectedRole: 'author' | 'receiver'): void { + requireCondition(event.role === expectedRole, 'ready role differs from the spawned role'); + requireCondition( + event.adapterId === GATE2_REAL_DKG_AGENT_ADAPTER_ID, + 'adapter did not identify the real DKGAgent boundary', + ); + requireCondition( + event.protocolVersion === GATE2_ADAPTER_PROTOCOL_VERSION, + 'adapter protocol version changed', + ); + requireCondition(event.agentClass === 'DKGAgent', 'child did not boot a real DKGAgent'); + requireCondition(event.catalogServiceStarted === true, 'production catalog service did not start'); + requireCondition(event.startupRepair === null, 'adapter claimed nonexistent automatic startup repair'); + requireCondition(typeof event.peerId === 'string' && event.peerId.length > 0, 'peer ID is missing'); + requireCondition( + typeof event.multiaddr === 'string' && event.multiaddr.includes('/tcp/'), + 'TCP multiaddr is missing', + ); +} + +function assertGate2ProductCapabilities(value: unknown, role: string): void { + const capabilities = outputRecordValue({ capabilities: value }, 'capabilities', role); + for (const name of [ + 'acceptOpenPolicy', + 'announce', + 'appliedHeadReadback', + 'exactInventoryReadback', + 'publishExactSetSuccessor', + 'publishGenesis', + 'terminalFailureReadback', + ]) { + exact(capabilities[name], true, `${role} capability ${name}`); + } +} + +function selectReady(event: Gate2AgentEvent): Record { + return { + adapterId: event.adapterId, + peerId: event.peerId, + protocolVersion: event.protocolVersion, + role: event.role, + startupRepair: event.startupRepair, + }; +} + +function selectExit(exit: ProcessExitEvidence): Record { + return { code: exit.code, signal: exit.signal }; +} + +function outputRecord(event: Gate2AgentEvent, label: string): Record { + if (event.output === null || typeof event.output !== 'object' || Array.isArray(event.output)) { + throw new Error(`${label} output is not an object`); + } + return event.output as Record; +} + +function outputRecordValue( + record: Record, + key: string, + label: string, +): Record { + const value = record[key]; + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label}.${key} is not an object`); + } + return value as Record; +} + +function plainArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value) || value.length > 1_024) { + throw new Error(`${label} is not a bounded Array`); + } + return value; +} + +function requiredOutputDigest(event: Gate2AgentEvent, key: string): string { + return requiredDigest(outputRecord(event, `${event.role}/${event.event}`)[key], key); +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 16_384) { + throw new Error(`${label} must be a bounded non-empty string`); + } + return value; +} + +function nullableString(value: unknown, label: string): string | null { + return value === null ? null : requiredString(value, label); +} + +function requiredDigest(value: unknown, label: string): string { + const digest = requiredString(value, label); + if (!/^0x[0-9a-f]{64}$/u.test(digest)) throw new Error(`${label} is not a canonical digest`); + return digest; +} + +function requiredDecimal(value: unknown, label: string): string { + const decimal = requiredString(value, label); + if (!/^(0|[1-9][0-9]*)$/u.test(decimal)) throw new Error(`${label} is not canonical decimal`); + return decimal; +} + +function requiredSafeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`${label} must be a non-negative safe integer`); + } + return value as number; +} + +function decimalToSafeInteger(value: unknown, label: string): number { + const number = Number(requiredDecimal(value, label)); + if (!Number.isSafeInteger(number)) throw new Error(`${label} exceeds safe integer range`); + return number; +} + +function exact(actual: unknown, expected: unknown, label: string): void { + if (!Object.is(actual, expected)) { + throw new Error(`${label} differed: ${JSON.stringify(actual)} !== ${JSON.stringify(expected)}`); + } +} + +function exactJson(actual: unknown, expected: unknown, label: string): void { + if (stableJson(actual) !== stableJson(expected)) { + throw new Error(`${label} differed from exact production evidence`); + } +} + +function requireCondition(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +await execute(); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts b/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts new file mode 100644 index 0000000000..6d733a3682 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { stableJson } from '../../rfc64-persistence-lifecycle/evidence.ts'; +import { + GATE2_ADAPTER_PROTOCOL_VERSION, + GATE2_RAW_SCHEMA_VERSION, + GATE2_REAL_DKG_AGENT_ADAPTER_ID, + REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + appliedReadBackFromInventories, +} from '../model.ts'; +import { + buildGate2PassVerdict, + verifyGate2ArtifactBytes, +} from '../live-verifier.ts'; +import { sha256Digest } from '../src/canonical.ts'; +import { generateCompleteFixture } from '../src/generate.ts'; +import { computeAppliedInventoryDigest } from '../src/product-digests.ts'; + +const SOURCE_COMMIT = 'a'.repeat(40); +const AUTHOR_PEER = '12D3KooWGate2Author'; +const RECEIVER_PEER = '12D3KooWGate2Receiver'; +const PROJECTIONS = [ + ' "One" .\n', + ' "Two" .\n', + ' "Three" .\n', +]; + +function sample(): any { + const fixture: any = JSON.parse(JSON.stringify(generateCompleteFixture(3))); + fixture.authored.signedRows.forEach((row: any, index: number) => { + row.contentDigest = sha256Digest(PROJECTIONS[index]!); + fixture.received.activatedRows[index].contentDigest = row.contentDigest; + }); + fixture.received.declaredInventoryDigest = computeAppliedInventoryDigest( + fixture.authored.declaredCatalogScopeDigest, + fixture.received.activatedRows, + ); + const authored = fixture.authored; + const received = fixture.received; + const expectedApplied = appliedReadBackFromInventories(authored, received, '3'); + const wireRows = received.activatedRows.map((row: any) => ({ + ...row, + swmGraph: `did:dkg:context-graph:${authored.catalogScope.contextGraphId}` + + `/_shared_memory/${authored.catalogScope.authorAddress}/${row.kaUal.split('/').at(-1)}`, + })); + const wire = { + activatedTripleCount: wireRows.reduce( + (total: number, row: any) => total + row.activatedTripleCount, + 0, + ), + appliedHeadStatus: 'applied', + catalogHeadDigest: authored.catalogHeadDigest, + inventoryDigest: received.declaredInventoryDigest, + inventoryRowCount: 3, + rows: wireRows, + verifiedControlObjectCount: 4, + }; + const semantic = wireRows.map((row: any, index: number) => ({ + kaId: row.kaId, + readBack: { + activatedQuadCount: row.activatedTripleCount, + projectionNQuads: PROJECTIONS[index], + swmGraph: row.swmGraph, + }, + })); + const ready = (role: 'author' | 'receiver', peerId: string) => ({ + adapterId: GATE2_REAL_DKG_AGENT_ADAPTER_ID, + peerId, + protocolVersion: GATE2_ADAPTER_PROTOCOL_VERSION, + role, + startupRepair: null, + }); + const digest = (byte: string) => `0x${byte.repeat(64)}`; + const policyDigest = digest('9'); + return { + adapter: { + id: GATE2_REAL_DKG_AGENT_ADAPTER_ID, + inspectedProductCommits: [SOURCE_COMMIT], + productBoundary: 'connected', + protocolVersion: GATE2_ADAPTER_PROTOCOL_VERSION, + requiredProductionOperations: REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, + replacementContract: + 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence', + }, + authorizationNegative: { + attemptedCatalogHeadDigest: digest('8'), + catalogAuthorAddress: authored.catalogScope.authorAddress, + expectedFailureCode: 'catalog-native-receiver-authorization', + forgedAppliedHead: null, + forgedSynchronization: null, + positiveAppliedAfter: expectedApplied, + positiveAppliedBefore: expectedApplied, + positiveInventoryAfter: wire, + positiveInventoryBefore: wire, + recoveredAuthorAddress: '0x2222222222222222222222222222222222222222', + semanticAfter: semantic, + semanticBefore: semantic, + servedByPeerId: AUTHOR_PEER, + testedByPeerId: RECEIVER_PEER, + }, + gate: 'OT-RFC-64 Gate 2 multi-asset completeness', + gateEvaluation: { + reason: + 'two real DKGAgent processes completed production 1-to-2-to-3 exact-set publication, ' + + 'synchronization, authorization-negative, SIGKILL, same-head replay, and exact readback', + status: 'PASS', + }, + harnessChecksPassed: true, + inventory: { authored, received }, + invocation: 'pnpm test:gate2:rfc64-multi-asset-harness', + policy: { + authorPolicyDigest: policyDigest, + contextGraphId: authored.catalogScope.contextGraphId, + networkId: authored.catalogScope.networkId, + receiverPolicyDigest: policyDigest, + }, + processBoundary: { + authorInstances: 1, + model: 'two real DKGAgent peer processes plus one receiver restart', + receiverInstances: 2, + stoppedExits: { + author: { code: 0, signal: null }, + restartedReceiver: { code: 0, signal: null }, + }, + }, + ready: { + author: ready('author', AUTHOR_PEER), + receiver: ready('receiver', RECEIVER_PEER), + }, + repository: { + testedHeadCommit: SOURCE_COMMIT, + trackedSourceCleanAfterProcesses: true, + trackedSourceCleanBeforeSpawn: true, + }, + restartReplay: { + appliedReadBack: expectedApplied, + crashExit: { code: null, signal: 'SIGKILL' }, + processLocalSynchronization: null, + reannouncementAcknowledgedByPeerId: RECEIVER_PEER, + receiverStats: { applied: 0, dedupedAlreadyApplied: 1 }, + restartedReady: ready('receiver', RECEIVER_PEER), + semanticPostRead: semantic, + successorServedByPeerId: AUTHOR_PEER, + }, + schemaVersion: GATE2_RAW_SCHEMA_VERSION, + transitions: [ + { + catalogHeadDigest: digest('1'), + catalogVersion: '1', + inventoryRowCount: 1, + previousCatalogHeadDigest: digest('0'), + signatureVariantDigest: digest('4'), + }, + { + catalogHeadDigest: digest('2'), + catalogVersion: '2', + inventoryRowCount: 2, + previousCatalogHeadDigest: digest('1'), + signatureVariantDigest: digest('5'), + }, + { + catalogHeadDigest: authored.catalogHeadDigest, + catalogVersion: '3', + inventoryRowCount: 3, + previousCatalogHeadDigest: digest('2'), + signatureVariantDigest: digest('6'), + }, + ], + transport: { + finalAnnouncementPolicyDigest: policyDigest, + finalSignatureVariantDigest: digest('6'), + receivedByPeerId: RECEIVER_PEER, + servedByPeerId: AUTHOR_PEER, + verifiedControlObjectCount: 4, + }, + }; +} + +function bytes(value: unknown): Buffer { + return Buffer.from(stableJson(JSON.parse(JSON.stringify(value)) as unknown), 'utf8'); +} + +test('connected artifact verifies and raw/verdict schemas are two-run byte-identical', () => { + const firstRaw = bytes(sample()); + const secondRaw = bytes(sample()); + assert.deepEqual(firstRaw, secondRaw); + const firstVerified = verifyGate2ArtifactBytes(firstRaw, SOURCE_COMMIT); + const secondVerified = verifyGate2ArtifactBytes(secondRaw, SOURCE_COMMIT); + assert.deepEqual(firstVerified, secondVerified); + assert.equal( + stableJson(buildGate2PassVerdict(firstVerified)), + stableJson(buildGate2PassVerdict(secondVerified)), + ); + assert.match(firstVerified.rawArtifactSha256, /^0x[0-9a-f]{64}$/u); +}); + +for (const [label, mutate] of [ + ['missing', (raw: any) => { raw.inventory.received.activatedRows.pop(); }], + ['extra', (raw: any) => { + raw.inventory.received.activatedRows.push( + JSON.parse(JSON.stringify(generateCompleteFixture(4))).received.activatedRows[3], + ); + }], + ['duplicate', (raw: any) => { + raw.inventory.received.activatedRows.splice( + 1, + 0, + { ...raw.inventory.received.activatedRows[0] }, + ); + }], + ['mismatch', (raw: any) => { + raw.inventory.received.activatedRows[1].bundleDigest = `0x${'f'.repeat(64)}`; + }], +] as const) { + test(`${label} inventory mutation is rejected by the connected verifier`, () => { + const raw = sample(); + mutate(raw); + assert.throws( + () => verifyGate2ArtifactBytes(bytes(raw), SOURCE_COMMIT), + /Gate 2 evidence verification failed at \$\.inventory/u, + ); + }); +} + +test('a fixture boundary cannot masquerade as a connected Gate 2 pass', () => { + const raw = sample(); + raw.adapter.productBoundary = 'not-connected'; + assert.throws( + () => verifyGate2ArtifactBytes(bytes(raw), SOURCE_COMMIT), + /\$\.adapter\.productBoundary/u, + ); +}); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json b/devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json index fa82f7a466..34a88560eb 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json +++ b/devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json @@ -1,18 +1,14 @@ { + "extends": "../../tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "lib": ["ES2023"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "rootDir": ".", - "strict": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - "verbatimModuleSyntax": true, + "declaration": false, + "declarationMap": false, "allowImportingTsExtensions": true, + "rootDir": "../..", "noEmit": true, + "sourceMap": false, "skipLibCheck": true, "types": ["node"] }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["*.ts", "src/**/*.ts", "test/**/*.ts"] } diff --git a/devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts b/devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts new file mode 100644 index 0000000000..fb06e3901c --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; + +import { + atomicWriteStableJson, + readCleanRepositoryHead, +} from '../rfc64-persistence-lifecycle/evidence.js'; +import { + buildGate2PassVerdict, + verifyGate2ArtifactBytes, +} from './live-verifier.js'; + +const REPO_ROOT = resolve(import.meta.dirname, '../..'); +const artifactPath = process.env.DKG_RFC64_GATE2_ARTIFACT + ?? join(import.meta.dirname, 'artifacts/gate2-result.json'); +const verdictPath = process.env.DKG_RFC64_GATE2_VERDICT_ARTIFACT + ?? join(import.meta.dirname, 'artifacts/gate2-verdict.json'); +const expectedHead = readCleanRepositoryHead(REPO_ROOT); +const verified = verifyGate2ArtifactBytes(readFileSync(artifactPath), expectedHead); +const publication = atomicWriteStableJson(verdictPath, buildGate2PassVerdict(verified)); +process.stdout.write( + `[rfc64-gate2-harness] PASS verdict=${verdictPath} sha256=${publication.sha256}\n`, +); diff --git a/package.json b/package.json index c93d4707e2..b3354bcb3a 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,11 @@ "test:gate1:rfc64-public-open-harness:verify": "node --import tsx devnet/rfc64-gate1-public-open/verify.ts", "test:gate1:rfc64-public-open-harness:unit": "node --import tsx --test devnet/rfc64-gate1-public-open/agent-child.test.ts devnet/rfc64-gate1-public-open/model.test.ts devnet/rfc64-gate1-public-open/product-capabilities.test.ts devnet/rfc64-gate1-public-open/verifier.test.ts", "typecheck:gate1:rfc64-public-open-harness": "tsc --project devnet/rfc64-gate1-public-open/tsconfig.json", + "test:gate2:rfc64-multi-asset-harness": "pnpm run test:gate2:rfc64-multi-asset-harness:generate && pnpm run test:gate2:rfc64-multi-asset-harness:verify", + "test:gate2:rfc64-multi-asset-harness:generate": "node --import tsx devnet/rfc64-gate2-multi-asset-completeness/run.ts", + "test:gate2:rfc64-multi-asset-harness:verify": "node --import tsx devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts", + "test:gate2:rfc64-multi-asset-harness:unit": "node --import tsx --test devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts", + "typecheck:gate2:rfc64-multi-asset-harness": "tsc --project devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", "test:devnet:v10-stress": "vitest run --config devnet/v10-stress/vitest.config.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0b2d43b9c..8e35a18918 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,6 +319,27 @@ importers: specifier: ^6.16.0 version: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + devnet/rfc64-gate2-multi-asset-completeness: + dependencies: + '@multiformats/multiaddr': + specifier: ^13.0.3 + version: 13.0.3 + '@origintrail-official/dkg-agent': + specifier: workspace:* + version: link:../../packages/agent + '@origintrail-official/dkg-chain': + specifier: workspace:* + version: link:../../packages/chain + '@origintrail-official/dkg-core': + specifier: workspace:* + version: link:../../packages/core + '@origintrail-official/dkg-storage': + specifier: workspace:* + version: link:../../packages/storage + ethers: + specifier: ^6.16.0 + version: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + devnet/rfc64-persistence-lifecycle: dependencies: '@origintrail-official/dkg-agent': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f3aece375d..841a4ebb3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,6 +22,7 @@ packages: - "devnet/_bootstrap" - "devnet/rfc64-persistence-lifecycle" - "devnet/rfc64-gate1-public-open" + - "devnet/rfc64-gate2-multi-asset-completeness" - "devnet/pr1386-term-canon" - "devnet/pr1385-subgraph-rs" - "devnet/pr1388-okf-integration" From 463b0afa96288fafc7c68e04dc18f8e4fce481eb Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:06:48 +0200 Subject: [PATCH 154/292] fix(devnet): bind Gate 2 canonical projections --- .../run.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/devnet/rfc64-gate2-multi-asset-completeness/run.ts b/devnet/rfc64-gate2-multi-asset-completeness/run.ts index 3c7700d731..d45347bfcb 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/run.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/run.ts @@ -66,16 +66,17 @@ const DEPLOYMENT = Object.freeze({ const KA_NUMBERS = Object.freeze([7n, 8n, 9n]); const ASSERTION_ROOTS = Object.freeze([ '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', - '0x9d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609e', - '0xad7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609d', + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', ]); +const CANONICAL_TWO_TRIPLE_PROJECTION = + ' ' + + '"42"^^ .\n' + + ' "Alice" .\n'; const PROJECTION_NQUADS = Object.freeze([ - ' "One" .\n', - ' "Two" .\n' - + ' "2" .\n', - ' "Three" .\n' - + ' "3" .\n' - + ' "v1" .\n', + CANONICAL_TWO_TRIPLE_PROJECTION, + CANONICAL_TWO_TRIPLE_PROJECTION, + CANONICAL_TWO_TRIPLE_PROJECTION, ]); const GENESIS_ISSUED_AT = '1773900000000'; const SUCCESSOR_ISSUED_AT = Object.freeze([ @@ -181,7 +182,7 @@ async function execute(): Promise { const allAssets = await Promise.all(KA_NUMBERS.map(async (kaNumber, index) => ({ assertionCoordinate: `gate-2-object-${index + 1}`, projectionNQuads: PROJECTION_NQUADS[index]!, - seal: await authorSeal(kaNumber, index + 1, ASSERTION_ROOTS[index]!), + seal: await authorSeal(kaNumber, 2, ASSERTION_ROOTS[index]!), }))); let previousHead = stagedHeadRef(genesis, 'genesis'); let previousHeadDigest = requiredString(genesis.headObjectDigest, 'genesis.headObjectDigest'); From 9405499e04269ac6529b01fbdd5f948d5609f503 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:07:31 +0200 Subject: [PATCH 155/292] fix(devnet): serialize receiver KA identifiers --- .../adapter-process.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts index b23e15ec54..91f41d9e1d 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -379,7 +379,7 @@ function wireSynchronizationEvidence(output: unknown): unknown { } verifiedControlObjectCount = rowControlObjectCount; return Object.freeze({ - kaId: row.kaId, + kaId: canonicalDecimalWire(row.kaId, `synchronization.rows[${index}].kaId`), catalogRowDigest: row.catalogRowDigest, contentDigest: row.contentDigest, sealDigest: row.sealDigest, @@ -403,6 +403,12 @@ function wireSynchronizationEvidence(output: unknown): unknown { }); } +function canonicalDecimalWire(value: unknown, label: string): string { + if (typeof value === 'bigint' && value >= 0n) return value.toString(); + if (typeof value === 'string' && /^(0|[1-9][0-9]*)$/u.test(value)) return value; + throw new TypeError(`${label} is not a canonical non-negative integer`); +} + function inspectGate2ProductCapabilities(currentAgent: DKGAgent): Record { const surface = currentAgent as unknown as Record; return Object.freeze({ From 3d75c3a0dee7f9826d6b228c74b1dc02772aec91 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:08:53 +0200 Subject: [PATCH 156/292] fix(devnet): prove one-row transition durably --- .../run.ts | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/devnet/rfc64-gate2-multi-asset-completeness/run.ts b/devnet/rfc64-gate2-multi-asset-completeness/run.ts index d45347bfcb..4a4644bc8e 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/run.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/run.ts @@ -222,27 +222,40 @@ async function execute(): Promise { publication.headObjectDigest, `exact-set-${index + 1}.headObjectDigest`, ); - const synchronization = await readSynchronization( - receiver, - headDigest, - `exact-set-${index + 1}`, - ); - exact( - requiredSafeInteger( - synchronization.inventoryRowCount, - `exact-set-${index + 1}.sync.inventoryRowCount`, - ), - index + 1, - `exact-set-${index + 1} synchronized row count`, - ); - exact( - requiredString( - synchronization.appliedHeadStatus, - `exact-set-${index + 1}.sync.appliedHeadStatus`, - ), - 'applied', - `exact-set-${index + 1} applied status`, - ); + let synchronization: Record | undefined; + if (index === 0) { + const applied = await readApplied( + receiver, + catalogScopeDigest, + AUTHOR_ADDRESS, + 'exact-set-1', + ); + exact(applied.currentCatalogHeadDigest, headDigest, 'exact-set-1 durable head'); + exact(applied.catalogVersion, '1', 'exact-set-1 durable version'); + exact(applied.inventoryRowCount, 1, 'exact-set-1 durable row count'); + } else { + synchronization = await readSynchronization( + receiver, + headDigest, + `exact-set-${index + 1}`, + ); + exact( + requiredSafeInteger( + synchronization.inventoryRowCount, + `exact-set-${index + 1}.sync.inventoryRowCount`, + ), + index + 1, + `exact-set-${index + 1} synchronized row count`, + ); + exact( + requiredString( + synchronization.appliedHeadStatus, + `exact-set-${index + 1}.sync.appliedHeadStatus`, + ), + 'applied', + `exact-set-${index + 1} applied status`, + ); + } transitions.push(Object.freeze({ catalogHeadDigest: headDigest, catalogVersion: requiredString( @@ -259,7 +272,7 @@ async function execute(): Promise { previousHead = stagedHeadRef(publication, `exact-set-${index + 1}`); previousHeadDigest = headDigest; finalPublication = publication; - finalSynchronization = synchronization; + if (synchronization !== undefined) finalSynchronization = synchronization; } if (finalPublication === undefined || finalSynchronization === undefined) { throw new Error('Gate 2 produced no final three-row successor evidence'); From 152b6ad8736b0bc3e0895349db0285ae4d5ebff2 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:10:43 +0200 Subject: [PATCH 157/292] fix(devnet): verify domain-bound projection digests --- .../rfc64-gate2-multi-asset-completeness/live-verifier.ts | 4 +++- .../test/live-verifier.test.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts b/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts index 0c57d60dbf..701a868c4c 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts @@ -390,6 +390,8 @@ interface WireRow extends AssetRowV1 { readonly swmGraph: string; } +const KA_PROJECTION_DIGEST_DOMAIN_V1 = 'dkg-ka-projection-v1\n'; + function verifySemanticState(value: unknown, path: string, rows: readonly WireRow[]): void { const semantic = closedArray(value, path, 3); semantic.forEach((entry, index) => { @@ -412,7 +414,7 @@ function verifySemanticState(value: unknown, path: string, rows: readonly WireRo 256 * 1024, ); exact( - sha256Digest(projection), + sha256Digest(KA_PROJECTION_DIGEST_DOMAIN_V1, projection), rows[index]!.contentDigest, `${path}[${index}].projection digest`, ); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts b/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts index 6d733a3682..1c29d7aa6a 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts @@ -25,11 +25,15 @@ const PROJECTIONS = [ ' "Two" .\n', ' "Three" .\n', ]; +const KA_PROJECTION_DIGEST_DOMAIN_V1 = 'dkg-ka-projection-v1\n'; function sample(): any { const fixture: any = JSON.parse(JSON.stringify(generateCompleteFixture(3))); fixture.authored.signedRows.forEach((row: any, index: number) => { - row.contentDigest = sha256Digest(PROJECTIONS[index]!); + row.contentDigest = sha256Digest( + KA_PROJECTION_DIGEST_DOMAIN_V1, + PROJECTIONS[index]!, + ); fixture.received.activatedRows[index].contentDigest = row.contentDigest; }); fixture.received.declaredInventoryDigest = computeAppliedInventoryDigest( From 97245b8782d86360d518e9164e372f1d475e749f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:20:54 +0200 Subject: [PATCH 158/292] fix(agent): bound RFC-64 exact-set bundle memory --- .../public-catalog-native-receiver-v1.ts | 12 ++ .../public-catalog-native-transport-v1.ts | 41 +++++++ .../public-catalog-successor-producer-v1.ts | 108 ++++++++++++------ ...c-catalog-native-gate1.integration.test.ts | 66 +++++++++++ ...public-catalog-native-transport-v1.test.ts | 14 +++ ...blic-catalog-successor-producer-v1.test.ts | 76 ++++++++++++ 6 files changed, 283 insertions(+), 34 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 82be0debcb..71436a47e0 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -86,6 +86,7 @@ import { import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + assertRfc64PublicCatalogExactSetBundleBytesV1, type FetchedRfc64PublicCatalogObjectV1, type Rfc64PublicCatalogNativeFetchScopeV1, type Rfc64PublicCatalogNativeTransportV1, @@ -601,6 +602,17 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } catch (cause) { fail('catalog-native-receiver-catalog', 'catalog bucket is not bound to its directory', cause); } + try { + assertRfc64PublicCatalogExactSetBundleBytesV1( + bucket.payload.rows.map((row) => row.transfer.byteLength), + ); + } catch (cause) { + fail( + 'catalog-native-receiver-slice', + 'signed catalog exact set exceeds the V1 aggregate bundle-byte ceiling', + cause, + ); + } const preparedRows: Array<{ readonly row: AuthorCatalogRowV1; readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; diff --git a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts index 95aea135c8..61e8776666 100644 --- a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts @@ -26,6 +26,7 @@ import { canonicalizeSignedControlEnvelopeBytes, computeControlSignatureVariantDigestHex, decodeOpaqueKaBundleV1, + parseCanonicalDecimalU64, parseCanonicalSignedControlEnvelope, type ContextGraphAccessPolicyV1, type ContextGraphIdV1, @@ -58,6 +59,46 @@ export const RFC64_PUBLIC_CATALOG_OBJECT_FETCH_RESPONSE_MAX_BYTES_V1 = /** First vertical slice resource ceiling; protocol descriptors may advertise more. */ export const RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1 = 8 * 1024 * 1024; +/** + * Maximum logical bytes in every complete bundle committed by one exact-set + * successor. This keeps the 1..1,024-row slice useful for small assets while + * bounding a producer/receiver batch to 64 MiB (eight maximum-size bundles, + * or 1,024 bundles averaging 64 KiB). + */ +export const RFC64_PUBLIC_CATALOG_EXACT_SET_BUNDLE_BYTES_MAX_V1 = + 64 * 1024 * 1024; + +/** Add one canonical signed-row byte length to the shared V1 exact-set budget. */ +export function addRfc64PublicCatalogExactSetBundleBytesV1( + currentTotal: bigint, + byteLength: DecimalU64V1, +): bigint { + const ceiling = BigInt(RFC64_PUBLIC_CATALOG_EXACT_SET_BUNDLE_BYTES_MAX_V1); + if (typeof currentTotal !== 'bigint' || currentTotal < 0n || currentTotal > ceiling) { + throw new RangeError('current exact-set bundle-byte total is outside the V1 ceiling'); + } + const nextTotal = currentTotal + parseCanonicalDecimalU64( + byteLength, + 'exact-set bundle byteLength', + ); + if (nextTotal > ceiling) { + throw new RangeError( + `exact-set bundle bytes exceed the V1 ${RFC64_PUBLIC_CATALOG_EXACT_SET_BUNDLE_BYTES_MAX_V1}-byte ceiling`, + ); + } + return nextTotal; +} + +/** Validate canonical row byte lengths before a receiver fetches any bundle. */ +export function assertRfc64PublicCatalogExactSetBundleBytesV1( + byteLengths: readonly DecimalU64V1[], +): bigint { + let total = 0n; + for (const byteLength of byteLengths) { + total = addRfc64PublicCatalogExactSetBundleBytesV1(total, byteLength); + } + return total; +} const FETCH_NOT_FOUND = 0; const FETCH_FOUND = 1; diff --git a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts index d36687b2d0..ec13527f12 100644 --- a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts @@ -68,7 +68,10 @@ import type { StageVerifiedControlObjectsResultV1, } from './control-object-store-v1.js'; import { assertRecoverableAuthorAttestationV1 } from './public-catalog-native-receiver-v1.js'; -import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1 } from './public-catalog-native-transport-v1.js'; +import { + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1, + addRfc64PublicCatalogExactSetBundleBytesV1, +} from './public-catalog-native-transport-v1.js'; export type Rfc64PublicCatalogSuccessorProducerErrorCodeV1 = | 'catalog-successor-producer-input' @@ -494,6 +497,8 @@ function prepareExactSet(input: ProduceAndStagePublicOpenExactSetSuccessorInputV `assets must contain 1..${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1} entries`, ); } + const snapshots: PreparedRfc64PublicCatalogSuccessorAssetSnapshotV1[] = []; + let aggregateBundleBytes = 0n; for (let index = 0; index < assets.length; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(assets, String(index)); if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { @@ -502,9 +507,57 @@ function prepareExactSet(input: ProduceAndStagePublicOpenExactSetSuccessorInputV 'assets must contain only enumerable data elements', ); } + const asset = descriptor.value as Rfc64PublicCatalogSuccessorAssetInputV1; + try { + // Snapshot each caller-owned field exactly once. In particular, no + // stateful projectionBytes getter can change validation vs encoding. + const assertionCoordinate = asset?.assertionCoordinate; + const projectionInput = asset?.projectionBytes; + const sealInput = asset?.seal; + if (!(projectionInput instanceof Uint8Array)) { + throw new TypeError('projectionBytes must be a Uint8Array'); + } + // Reject a projection that cannot possibly fit the Gate-1 transport + // before allocating its detached snapshot. + if ( + BigInt(projectionInput.byteLength) + > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1) + - MIN_KA_BUNDLE_BYTES_V1 + ) { + throw new RangeError('projection exceeds the Gate-1 complete-bundle ceiling'); + } + const projectionBytes = new Uint8Array(projectionInput); + const sealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(sealInput); + const seal = parseCanonicalGraphScopedAuthorSealV1(sealBytes); + const bundleByteLength = calculateOpaqueKaBundleByteLengthV1( + BigInt(projectionBytes.byteLength), + BigInt(sealBytes.byteLength), + ); + if (bundleByteLength > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1)) { + throw new RangeError('bundle exceeds the Gate-1 complete-bundle ceiling'); + } + const canonicalBundleByteLength = bundleByteLength.toString() as ByteLengthV1; + aggregateBundleBytes = addRfc64PublicCatalogExactSetBundleBytesV1( + aggregateBundleBytes, + canonicalBundleByteLength, + ); + snapshots.push(Object.freeze({ + assertionCoordinate, + projectionBytes, + seal, + sealBytes, + bundleByteLength, + })); + } catch (cause) { + fail( + 'catalog-successor-producer-input', + 'projection, seal, or exact-set bundle-byte budget is not canonical', + cause, + ); + } } - const prepared = assets.map((asset) => prepareRowAndBundle( - asset, + const prepared = snapshots.map((snapshot) => prepareRowAndBundle( + snapshot, input.previousHead, input.deployment, )); @@ -525,48 +578,35 @@ function prepareExactSet(input: ProduceAndStagePublicOpenExactSetSuccessorInputV return Object.freeze(prepared); } +interface PreparedRfc64PublicCatalogSuccessorAssetSnapshotV1 { + readonly assertionCoordinate: AssertionCoordinateV1; + readonly projectionBytes: Uint8Array; + readonly seal: CanonicalGraphScopedAuthorSealV1; + readonly sealBytes: Uint8Array; + readonly bundleByteLength: bigint; +} + function prepareRowAndBundle( - asset: Rfc64PublicCatalogSuccessorAssetInputV1, + asset: PreparedRfc64PublicCatalogSuccessorAssetSnapshotV1, previousHead: SignedAuthorCatalogHeadEnvelopeV1, deploymentInput: CatalogSealDeploymentProfileV1, ) { - let sealBytes: Uint8Array; - let seal: CanonicalGraphScopedAuthorSealV1; let encoded: ReturnType; let row: AuthorCatalogRowV1; try { - if (!(asset?.projectionBytes instanceof Uint8Array)) { - throw new TypeError('projectionBytes must be a Uint8Array'); - } - // Reject a projection that cannot possibly fit the Gate-1 transport before - // canonicalizing the seal or allocating/copying a complete bundle. - if ( - BigInt(asset.projectionBytes.byteLength) - > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1) - - MIN_KA_BUNDLE_BYTES_V1 - ) { - throw new RangeError('projection exceeds the Gate-1 complete-bundle ceiling'); - } - const assertionCoordinate = asset?.assertionCoordinate; - sealBytes = canonicalizeCanonicalGraphScopedAuthorSealBytesV1(asset.seal); - seal = parseCanonicalGraphScopedAuthorSealV1(sealBytes); - const bundleByteLength = calculateOpaqueKaBundleByteLengthV1( - BigInt(asset.projectionBytes.byteLength), - BigInt(sealBytes.byteLength), - ); - if (bundleByteLength > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1)) { - throw new RangeError('bundle exceeds the Gate-1 complete-bundle ceiling'); - } - encoded = encodeOpaqueKaBundleV1(asset.projectionBytes, sealBytes); + encoded = encodeOpaqueKaBundleV1(asset.projectionBytes, asset.sealBytes); const byteLength = BigInt(encoded.bundleBytes.byteLength); + if (byteLength !== asset.bundleByteLength) { + throw new Error('encoded bundle length differs from its exact-set budget snapshot'); + } const chunkCount = ((byteLength - 1n) / KA_TRANSFER_CHUNK_SIZE_BYTES_V1) + 1n; row = parseCanonicalAuthorCatalogRowV1(canonicalizeAuthorCatalogRowV1({ - kaId: seal.reservedKaId, - assertionCoordinate, - assertionVersion: seal.assertionVersion, + kaId: asset.seal.reservedKaId, + assertionCoordinate: asset.assertionCoordinate, + assertionVersion: asset.seal.assertionVersion, projectionId: KA_TRANSFER_PROJECTION_V1, projectionDigest: encoded.projectionDigest, - sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(asset.seal), transfer: { codec: KA_TRANSFER_CODEC_V1, projectionId: KA_TRANSFER_PROJECTION_V1, @@ -598,7 +638,7 @@ function prepareRowAndBundle( deployment, scope, row: Object.freeze(row), - sealBytes, + sealBytes: asset.sealBytes, encoded, bundleBytes: new Uint8Array(encoded.bundleBytes), }); diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 9622aebe27..e95fc2149f 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -48,11 +48,13 @@ import { import { computeRfc64AppliedInventoryDigestV1, Rfc64PublicCatalogNativeReceiverV1, + rfc64CatalogSignatureVariantDigestV1, } from '../src/rfc64/public-catalog-native-receiver-v1.js'; import { verifyRfc64PublicCatalogInventoryCompletenessV1, } from '../src/rfc64/public-catalog-inventory-completeness-v1.js'; import { + RFC64_PUBLIC_CATALOG_EXACT_SET_BUNDLE_BYTES_MAX_V1, Rfc64PublicCatalogNativeTransportV1, } from '../src/rfc64/public-catalog-native-transport-v1.js'; import { @@ -551,6 +553,70 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); }, 30_000); + it('rejects an over-budget signed exact set before fetching its first bundle', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + const declaredBundleByteLength = + BigInt(RFC64_PUBLIC_CATALOG_EXACT_SET_BUNDLE_BYTES_MAX_V1); + const built = await buildRowBundle(AUTHOR_WALLET, { + kaNumber: 100n, + assertionCoordinate: 'budget-row', + }); + const row = { + ...built.row, + transfer: { + ...built.row.transfer, + byteLength: declaredBundleByteLength.toString(), + chunkCount: ( + ((declaredBundleByteLength - 1n) / 262_144n) + 1n + ).toString(), + }, + } as AuthorCatalogRowV1; + assertAuthorCatalogRowV1(row); + const successor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: fixture.successor.head, + previousDirectoryPath: fixture.successor.directoryPath, + previousBucket: fixture.successor.bucket, + selectedBucketId: '0' as never, + nextRows: [fixture.rowBundle.row, row], + issuedAt: '1773900001003' as never, + signer: { + issuer: AUTHOR, + signDigest: async (digest) => AUTHOR_WALLET.signMessage(digest), + }, + }); + for (const envelope of successor.stagedObjects) { + fixture.authorObjects.set(envelope.objectDigest, envelope); + } + const headSignature = await verifyControlEnvelopeIssuerSignatureV1(successor.head); + fixture.receiverHeadFetch.mockImplementationOnce(async () => Object.freeze({ + envelope: successor.head, + issuerSignature: headSignature, + })); + const announcement = Object.freeze({ + ...fixture.announcement, + catalogVersion: successor.head.payload.version, + catalogHeadObjectDigest: successor.head.objectDigest, + signatureVariantDigest: rfc64CatalogSignatureVariantDigestV1(successor.head), + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + fixture.receiverBundleFetch.mockClear(); + const observed = fixture.createCasObservedReceiver(); + + await expect(fixture.synchronizeAny( + announcement, + observed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-slice' }); + expect(fixture.receiverBundleFetch).not.toHaveBeenCalled(); + expect(observed.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + }, 30_000); + it('keeps the one-row compatibility entrypoint fail-closed for a multi-row head', async () => { const fixture = await setupLiveReceiver(); await fixture.bootstrap(); diff --git a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts index 1a4ec9d6b8..b968b105f0 100644 --- a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts @@ -19,9 +19,11 @@ import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog- import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_EXACT_SET_BUNDLE_BYTES_MAX_V1, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, Rfc64PublicCatalogNativeTransportV1, + assertRfc64PublicCatalogExactSetBundleBytesV1, type Rfc64PublicCatalogNativeFetchScopeV1, Rfc64PublicCatalogNativeTransportErrorV1, } from '../src/rfc64/public-catalog-native-transport-v1.js'; @@ -59,6 +61,18 @@ async function connect(from: DKGNode, to: DKGNode): Promise { } describe('RFC-64 public catalog native content transport v1', () => { + it('accepts the exact-set bundle-byte ceiling and rejects one byte over it', () => { + const ceiling = BigInt(RFC64_PUBLIC_CATALOG_EXACT_SET_BUNDLE_BYTES_MAX_V1); + expect(assertRfc64PublicCatalogExactSetBundleBytesV1([ + (ceiling - 1n).toString() as never, + '1' as never, + ])).toBe(ceiling); + expect(() => assertRfc64PublicCatalogExactSetBundleBytesV1([ + ceiling.toString() as never, + '1' as never, + ])).toThrow(/exceed.*ceiling/); + }); + it('fetches exact directory and bundle digests across two live libp2p nodes', async () => { const [authorNode, receiverNode] = await Promise.all([startNode(), startNode()]); await connect(receiverNode, authorNode); diff --git a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts index bb89b12f55..003b7c9c36 100644 --- a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts @@ -245,6 +245,39 @@ describe('RFC-64 public/open one-row successor producer', () => { expect(stageVerifiedObjects).not.toHaveBeenCalled(); }); + it('rejects an aggregate over-budget exact set before signing or staging', async () => { + const { genesis, authorization } = await producerHistory(); + const signDigest = vi.fn(async (digest: Uint8Array) => AUTHOR_WALLET.signMessage(digest)); + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => undefined as never); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + const asset = { + assertionCoordinate: 'gate-1-object' as never, + projectionBytes: new Uint8Array( + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1 - 4_096, + ), + seal: await authorSeal(AUTHOR_WALLET), + }; + + await expect(producer.produceAndStageExactSet({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assets: Array.from({ length: 9 }, () => asset), + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: { issuer: AUTHOR, signDigest }, + catalogIssuerAuthorization: authorization, + })).rejects.toMatchObject({ code: 'catalog-successor-producer-input' }); + + expect(signDigest).not.toHaveBeenCalled(); + expect(stageKaBundle).not.toHaveBeenCalled(); + expect(stageVerifiedObjects).not.toHaveBeenCalled(); + }); + it('rejects a non-author attestation before either durable staging callback', async () => { const { genesis, authorization } = await producerHistory(); const stageKaBundle = vi.fn(durableBundleReceipt); @@ -436,6 +469,49 @@ describe('RFC-64 public/open one-row successor producer', () => { expect(stageVerifiedObjects).not.toHaveBeenCalled(); }); + it('snapshots a stateful projection getter once before validation and encoding', async () => { + const { genesis, authorization } = await producerHistory(); + const stageKaBundle = vi.fn(durableBundleReceipt); + const stageVerifiedObjects = vi.fn(async () => Object.freeze({ + durable: true as const, + namespaceDurability: 'test-exact-durable' as never, + objects: Object.freeze([]), + })); + const producer = new Rfc64PublicCatalogSuccessorProducerV1({ + controlObjects: { stageVerifiedObjects } as never, + stageKaBundle, + }); + let projectionReads = 0; + const asset = { + assertionCoordinate: 'gate-1-object' as never, + get projectionBytes() { + projectionReads += 1; + return projectionReads === 1 + ? PROJECTION + : new Uint8Array(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1); + }, + seal: await authorSeal(AUTHOR_WALLET), + }; + + const result = await producer.produceAndStageExactSet({ + previousHead: genesis.head, + previousDirectoryPath: genesis.directoryPath, + previousBucket: null, + assets: [asset], + deployment: DEPLOYMENT, + issuedAt: '1773900001000' as never, + catalogSigner: catalogSigner(), + catalogIssuerAuthorization: authorization, + }); + + expect(projectionReads).toBe(1); + expect(decodeOpaqueKaBundleV1(result.assets[0]!.bundleBytes).projectionBytes).toEqual( + PROJECTION, + ); + expect(stageKaBundle).toHaveBeenCalledOnce(); + expect(stageVerifiedObjects).toHaveBeenCalledOnce(); + }); + it('fails control-object staging only after the bundle is durably staged first', async () => { const { genesis, authorization } = await producerHistory(); const events: string[] = []; From 1be230ed3fa8b8b5682e9724b86dd383684dca4f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:24:37 +0200 Subject: [PATCH 159/292] fix(agent): unify RFC-64 inventory digests --- packages/agent/scripts/test-package-root.mjs | 18 ++ packages/agent/src/index.ts | 5 + ...ublic-catalog-inventory-completeness-v1.ts | 180 ++++++++++++++++-- .../public-catalog-native-receiver-v1.ts | 46 +---- ...d-inventory-digest-public-api.typecheck.ts | 25 +++ ...-catalog-inventory-completeness-v1.test.ts | 43 +++-- ...c-catalog-native-gate1.integration.test.ts | 10 +- 7 files changed, 250 insertions(+), 77 deletions(-) create mode 100644 packages/agent/test/rfc64-applied-inventory-digest-public-api.typecheck.ts diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 632498c814..596fb5bbc3 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -12,12 +12,30 @@ if ( typeof root.DKGAgent !== 'function' || typeof legacyAgent.DKGAgent !== 'function' || typeof root.Rfc64PublicCatalogSuccessorProducerV1 !== 'function' + || typeof root.computeRfc64AppliedInventoryDigestV1 !== 'function' ) { throw new Error('published agent entry points did not expose required root APIs'); } if (packageManifest.name !== '@origintrail-official/dkg-agent') { throw new Error('historical package.json subpath no longer resolves'); } + +const digestAuthor = '0x1111111111111111111111111111111111111111'; +const digestRows = [10, 2].map((number) => ({ + kaId: ((BigInt(digestAuthor) << 96n) | BigInt(number)).toString(), + catalogRowDigest: `0x${(number * 4).toString(16).padStart(64, '0')}`, + contentDigest: `0x${((number * 4) + 1).toString(16).padStart(64, '0')}`, + sealDigest: `0x${((number * 4) + 2).toString(16).padStart(64, '0')}`, + kaUal: `did:dkg:otp:20430/${digestAuthor}/${number}`, + activatedTripleCount: number + 1, +})); +const publicDigest = root.computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: '0x6287b105b87dbacce48f7702a54e9410ba4a5d52475b986288042eb75464bbe2', + rows: digestRows, +}); +if (publicDigest !== '0x6d273d5f5fc1acbfe6836168c7159b747fe3df549d2d5bddcd8bf409ff58ea01') { + throw new Error('package-root applied-inventory digest did not use numeric KA-ID order'); +} const publicRfc64Modules = [ 'author-catalog-producer.js', 'catalog-row-authorship.js', diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 54f5379fd8..a38d6bf0fc 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -43,6 +43,11 @@ export * from './rfc64/public-catalog-service-v1.js'; export * from './rfc64/public-catalog-issuer-delegation-v1.js'; export * from './rfc64/public-catalog-native-transport-v1.js'; export * from './rfc64/public-catalog-native-receiver-v1.js'; +export { + computeRfc64AppliedInventoryDigestV1, + type ComputeRfc64AppliedInventoryDigestInputV1, + type Rfc64AppliedInventoryDigestRowV1, +} from './rfc64/public-catalog-inventory-completeness-v1.js'; export * from './rfc64/public-catalog-successor-producer-v1.js'; export * from './rfc64/public-catalog-native-reconciler-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; diff --git a/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts b/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts index 551946e182..cf6e542475 100644 --- a/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-inventory-completeness-v1.ts @@ -65,6 +65,29 @@ export interface Rfc64PublicCatalogInventoryEvidenceRowV1 { readonly activatedTripleCount: number; } +/** + * Exact row fields committed by the durable applied-inventory digest. + * + * `kaId` is an ordering key and is not itself hashed. It is nevertheless + * required so every caller uses the signed catalog's mathematical KA-ID order; + * deriving order from lexical UAL text is not equivalent for IDs such as 2 and + * 10. `bundleDigest` remains operator evidence outside this legacy commitment, + * so callers may pass richer receiver evidence rows without affecting the hash. + */ +export interface Rfc64AppliedInventoryDigestRowV1 { + readonly kaId: KaIdV1; + readonly catalogRowDigest: Digest32V1; + readonly contentDigest: Digest32V1; + readonly sealDigest: Digest32V1; + readonly kaUal: string; + readonly activatedTripleCount: number; +} + +export interface ComputeRfc64AppliedInventoryDigestInputV1 { + readonly catalogScopeDigest: Digest32V1; + readonly rows: readonly Rfc64AppliedInventoryDigestRowV1[]; +} + /** Deterministic evidence for one complete bounded catalog live set. */ export interface Rfc64PublicCatalogInventoryCompletenessEvidenceV1 { readonly catalogScopeDigest: Digest32V1; @@ -84,6 +107,47 @@ export interface VerifyRfc64PublicCatalogInventoryCompletenessInputV1 { readonly observedRows: readonly Rfc64PublicCatalogInventoryEvidenceRowV1[]; } +/** + * Compute the one canonical applied-inventory commitment for empty, one-row, + * and bounded multi-row catalogs. + * + * Input rows are snapshotted and validated, then sorted by mathematical + * `kaId`. Caller order is irrelevant. This preserves the Gate-1 empty/one-row + * framing while making the previously unobservable multi-row order explicit. + */ +export function computeRfc64AppliedInventoryDigestV1( + input: ComputeRfc64AppliedInventoryDigestInputV1, +): Digest32V1 { + const boundary = snapshotExactDataRecord(input, [ + 'catalogScopeDigest', + 'rows', + ], 'applied inventory digest input'); + try { + assertCanonicalDigest(boundary.catalogScopeDigest, 'catalogScopeDigest'); + } catch (cause) { + fail( + 'catalog-inventory-completeness-input', + 'catalogScopeDigest is not a canonical digest', + cause, + ); + } + const rows = snapshotAppliedInventoryDigestRows(boundary.rows); + const hasher = sha256.create(); + hasher.update(UTF8.encode(APPLIED_INVENTORY_DIGEST_DOMAIN_V1)); + hasher.update(ethers.getBytes(boundary.catalogScopeDigest as Digest32V1)); + hasher.update(encodeU64(BigInt(rows.length), 'inventory row count')); + for (const row of rows) { + hasher.update(ethers.getBytes(row.catalogRowDigest)); + hasher.update(ethers.getBytes(row.contentDigest)); + hasher.update(ethers.getBytes(row.sealDigest)); + const ual = UTF8.encode(row.kaUal); + hasher.update(encodeU64(BigInt(ual.byteLength), 'KA UAL byte length')); + hasher.update(ual); + hasher.update(encodeU64(BigInt(row.activatedTripleCount), 'activated triple count')); + } + return ethers.hexlify(hasher.digest()) as Digest32V1; +} + /** * Verify exact bounded set equality and return deterministic completion evidence. * @@ -165,7 +229,10 @@ export function verifyRfc64PublicCatalogInventoryCompletenessV1( return Object.freeze({ catalogScopeDigest, inventoryRowCount: boundary.expectedTotalRows, - inventoryDigest: computeInventoryDigest(catalogScopeDigest, expected), + inventoryDigest: computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest, + rows: expected, + }), rows: expected, }); } @@ -326,24 +393,60 @@ function snapshotRow( return snapshot; } -function computeInventoryDigest( - catalogScopeDigest: Digest32V1, - rows: readonly Readonly[], -): Digest32V1 { - const hasher = sha256.create(); - hasher.update(UTF8.encode(APPLIED_INVENTORY_DIGEST_DOMAIN_V1)); - hasher.update(ethers.getBytes(catalogScopeDigest)); - hasher.update(encodeU64(BigInt(rows.length), 'inventory row count')); - for (const row of rows) { - hasher.update(ethers.getBytes(row.catalogRowDigest)); - hasher.update(ethers.getBytes(row.contentDigest)); - hasher.update(ethers.getBytes(row.sealDigest)); - const ual = UTF8.encode(row.kaUal); - hasher.update(encodeU64(BigInt(ual.byteLength), 'KA UAL byte length')); - hasher.update(ual); - hasher.update(encodeU64(BigInt(row.activatedTripleCount), 'activated triple count')); +function snapshotAppliedInventoryDigestRows( + input: unknown, +): readonly Readonly[] { + const values = snapshotDenseBoundedOrdinaryArray(input, 'applied inventory digest rows'); + const rows = values.map((value, index) => { + const label = `applied inventory digest rows[${index}]`; + const fields = snapshotRequiredDataRecord(value, [ + 'activatedTripleCount', + 'catalogRowDigest', + 'contentDigest', + 'kaId', + 'kaUal', + 'sealDigest', + ], label); + const row = Object.freeze({ + kaId: fields.kaId, + catalogRowDigest: fields.catalogRowDigest, + contentDigest: fields.contentDigest, + sealDigest: fields.sealDigest, + kaUal: fields.kaUal, + activatedTripleCount: fields.activatedTripleCount, + }) as unknown as Rfc64AppliedInventoryDigestRowV1; + try { + assertCanonicalKaId(row.kaId, `${label}.kaId`); + assertCanonicalDigest(row.catalogRowDigest, `${label}.catalogRowDigest`); + assertCanonicalDigest(row.contentDigest, `${label}.contentDigest`); + assertCanonicalDigest(row.sealDigest, `${label}.sealDigest`); + if (!Number.isSafeInteger(row.activatedTripleCount) || row.activatedTripleCount < 1) { + throw new Error(`${label}.activatedTripleCount must be a positive safe integer`); + } + const parsedUal = parseDeterministicKnowledgeAssetUal(row.kaUal); + const packedKaId = (BigInt(parsedUal.agentAddress) << 96n) | BigInt(parsedUal.kaNumber); + if (parsedUal.ual !== row.kaUal || packedKaId !== BigInt(row.kaId)) { + throw new Error(`${label}.kaUal does not canonically encode kaId`); + } + } catch (cause) { + fail( + 'catalog-inventory-completeness-input', + `${label} is not exact canonical digest evidence`, + cause, + ); + } + return row; + }); + rows.sort((left, right) => compareAuthorCatalogKaIdsV1(left.kaId, right.kaId)); + for (let index = 1; index < rows.length; index += 1) { + if (rows[index - 1]!.kaId === rows[index]!.kaId) { + fail( + 'catalog-inventory-completeness-duplicate', + `applied inventory digest rows contain duplicate kaId ${rows[index]!.kaId}`, + ); + } } - return ethers.hexlify(hasher.digest()) as Digest32V1; + return Object.freeze(rows); } function encodeU64(value: bigint, label: string): Uint8Array { @@ -374,7 +477,7 @@ function sameRow( function snapshotDenseBoundedOrdinaryArray( value: unknown, - label: 'expectedRows' | 'observedRows', + label: string, ): readonly unknown[] { try { if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) { @@ -477,6 +580,45 @@ function snapshotExactDataRecord( } } +/** + * Snapshot only the fields committed by a projection. Richer product evidence + * may carry additional data, but it is neither enumerated nor read here. + */ +function snapshotRequiredDataRecord( + value: unknown, + requiredKeys: readonly string[], + label: string, +): Readonly> { + try { + if (!isPlainRecord(value)) { + fail('catalog-inventory-completeness-input', `${label} must be a plain object`); + } + const snapshot: Record = Object.create(null); + for (const key of requiredKeys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + fail( + 'catalog-inventory-completeness-input', + `${label}.${key} must be an enumerable data property`, + ); + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot); + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogInventoryCompletenessErrorV1) throw cause; + fail( + 'catalog-inventory-completeness-input', + `${label} could not be snapshotted exactly`, + cause, + ); + } +} + function isPlainRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 71436a47e0..6cc7d63715 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -58,7 +58,6 @@ import { type VerifiedCatalogSealBindingSnapshotV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; -import { sha256 } from '@noble/hashes/sha2.js'; import { quadsToNQuads, readExactGraphPaged, @@ -80,6 +79,7 @@ import type { Rfc64InventoryV1OperationsV1, } from './inventory-v1/index.js'; import { + computeRfc64AppliedInventoryDigestV1, verifyRfc64PublicCatalogInventoryCompletenessV1, type Rfc64PublicCatalogInventoryEvidenceRowV1, } from './public-catalog-inventory-completeness-v1.js'; @@ -98,8 +98,6 @@ import type { } from './public-catalog-transport-v1.js'; const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); -const UTF8_ENCODER = new TextEncoder(); -const APPLIED_INVENTORY_DIGEST_DOMAIN_V1 = 'dkg-rfc64-applied-inventory-v1\n'; export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { /** Fetch-only capability; lifecycle ownership remains with the catalog service. */ @@ -1567,48 +1565,6 @@ async function activateExactPublicProjection( }; } -export interface Rfc64AppliedInventoryDigestRowV1 { - readonly catalogRowDigest: Digest32V1; - readonly contentDigest: Digest32V1; - readonly sealDigest: Digest32V1; - readonly kaUal: string; - readonly activatedTripleCount: number; -} - -/** Compute an applied inventory commitment from exact semantic post-read evidence. */ -export function computeRfc64AppliedInventoryDigestV1(input: { - readonly catalogScopeDigest: Digest32V1; - readonly rows: readonly Rfc64AppliedInventoryDigestRowV1[]; -}): Digest32V1 { - const hasher = sha256.create(); - hasher.update(UTF8_ENCODER.encode(APPLIED_INVENTORY_DIGEST_DOMAIN_V1)); - hasher.update(ethers.getBytes(input.catalogScopeDigest)); - hasher.update(encodeU64(input.rows.length, 'inventory row count')); - for (const row of [...input.rows].sort((left, right) => left.kaUal.localeCompare(right.kaUal))) { - hasher.update(ethers.getBytes(row.catalogRowDigest)); - hasher.update(ethers.getBytes(row.contentDigest)); - hasher.update(ethers.getBytes(row.sealDigest)); - const ual = UTF8_ENCODER.encode(row.kaUal); - hasher.update(encodeU64(ual.byteLength, 'KA UAL byte length')); - hasher.update(ual); - hasher.update(encodeU64(row.activatedTripleCount, 'activated triple count')); - } - return ethers.hexlify(hasher.digest()) as Digest32V1; -} - -function encodeU64(value: number, label: string): Uint8Array { - if (!Number.isSafeInteger(value) || value < 0) { - fail('catalog-native-receiver-activation', `${label} is not a safe unsigned integer`); - } - const result = new Uint8Array(8); - let remaining = BigInt(value); - for (let index = result.length - 1; index >= 0; index -= 1) { - result[index] = Number(remaining & 0xffn); - remaining >>= 8n; - } - return result; -} - /** * Require the transferred v1 AuthorAttestation to recover the catalog author. * This first receiver slice intentionally supports the recoverable EOA scheme; diff --git a/packages/agent/test/rfc64-applied-inventory-digest-public-api.typecheck.ts b/packages/agent/test/rfc64-applied-inventory-digest-public-api.typecheck.ts new file mode 100644 index 0000000000..8b051e62eb --- /dev/null +++ b/packages/agent/test/rfc64-applied-inventory-digest-public-api.typecheck.ts @@ -0,0 +1,25 @@ +import { + computeRfc64AppliedInventoryDigestV1, + type ComputeRfc64AppliedInventoryDigestInputV1, + type Rfc64AppliedInventoryDigestRowV1, +} from '@origintrail-official/dkg-agent'; +import type { Digest32V1, KaIdV1 } from '@origintrail-official/dkg-core'; + +declare const digest: Digest32V1; +declare const kaId: KaIdV1; + +const row: Rfc64AppliedInventoryDigestRowV1 = { + kaId, + catalogRowDigest: digest, + contentDigest: digest, + sealDigest: digest, + kaUal: 'did:dkg:otp:20430/0x1111111111111111111111111111111111111111/2', + activatedTripleCount: 1, +}; +const input: ComputeRfc64AppliedInventoryDigestInputV1 = { + catalogScopeDigest: digest, + rows: [row], +}; +const result: Digest32V1 = computeRfc64AppliedInventoryDigestV1(input); + +void result; diff --git a/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts b/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts index 9ecb3fb365..159934f3a2 100644 --- a/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-inventory-completeness-v1.test.ts @@ -9,8 +9,8 @@ import { type KaIdV1, } from '@origintrail-official/dkg-core'; -import { computeRfc64AppliedInventoryDigestV1 } from '../src/rfc64/public-catalog-native-receiver-v1.js'; import { + computeRfc64AppliedInventoryDigestV1, Rfc64PublicCatalogInventoryCompletenessErrorV1, verifyRfc64PublicCatalogInventoryCompletenessV1, type Rfc64PublicCatalogInventoryEvidenceRowV1, @@ -59,7 +59,13 @@ describe('RFC-64 public catalog bounded inventory completeness', () => { expect(repeated).toEqual(evidence); }); - it('preserves the exact Gate-1 digest framing for a one-row set', () => { + it('preserves the exact Gate-1 empty and one-row digest vectors', () => { + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(SCOPE); + expect(computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest, + rows: [], + })).toBe('0xcd36c729c972b50de1b2e562fa7e2200513d8ba282af29ebf4e403a806605aee'); + const only = row(7); const evidence = verifyRfc64PublicCatalogInventoryCompletenessV1({ catalogScope: SCOPE, @@ -67,16 +73,31 @@ describe('RFC-64 public catalog bounded inventory completeness', () => { expectedRows: [only], observedRows: [only], }); - expect(evidence.inventoryDigest).toBe(computeRfc64AppliedInventoryDigestV1({ + const recomputed = computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest, + rows: [only], + }); + expect(recomputed).toBe('0x6b258dc6f104ad042aec38da7837d9ba53f292af96eecd70f4e71dfb9a53e2f2'); + expect(evidence.inventoryDigest).toBe(recomputed); + }); + + it('uses numeric KA-ID order for 2 vs 10 and matches the completeness digest', () => { + const two = row(2); + const ten = row(10); + const evidence = verifyRfc64PublicCatalogInventoryCompletenessV1({ + catalogScope: SCOPE, + expectedTotalRows: '2' as CountV1, + expectedRows: [two, ten], + observedRows: [ten, two], + }); + const recomputed = computeRfc64AppliedInventoryDigestV1({ catalogScopeDigest: computeAuthorCatalogScopeDigestV1(SCOPE), - rows: [{ - catalogRowDigest: only.catalogRowDigest, - contentDigest: only.contentDigest, - sealDigest: only.sealDigest, - kaUal: only.kaUal, - activatedTripleCount: only.activatedTripleCount, - }], - })); + rows: [ten, two], + }); + + expect(recomputed).toBe('0x6d273d5f5fc1acbfe6836168c7159b747fe3df549d2d5bddcd8bf409ff58ea01'); + expect(recomputed).not.toBe('0x7fdb1bfd439ccde258f1061d728cc555337ee1764b1188cf36c519ece1d7abb5'); + expect(recomputed).toBe(evidence.inventoryDigest); }); it.each([ diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index e95fc2149f..0937d55873 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -46,11 +46,11 @@ import { produceSparseAuthorCatalogSuccessorV1, } from '../src/rfc64/author-catalog-producer.js'; import { - computeRfc64AppliedInventoryDigestV1, Rfc64PublicCatalogNativeReceiverV1, rfc64CatalogSignatureVariantDigestV1, } from '../src/rfc64/public-catalog-native-receiver-v1.js'; import { + computeRfc64AppliedInventoryDigestV1, verifyRfc64PublicCatalogInventoryCompletenessV1, } from '../src/rfc64/public-catalog-inventory-completeness-v1.js'; import { @@ -175,6 +175,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { deriveAuthorCatalogScopeFromHeadV1(fixture.successor.head.payload), ), rows: [{ + kaId: fixture.rowBundle.row.kaId, catalogRowDigest: expectedRowDigest, contentDigest: fixture.rowBundle.row.projectionDigest, sealDigest: fixture.rowBundle.row.sealDigest, @@ -283,14 +284,19 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { expectedRows, observedRows: expectedRows, }); + const independentlyRecomputedDigest = computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: fixture.scopeDigest, + rows: [...evidence.rows].reverse(), + }); expect(evidence).toMatchObject({ - inventoryDigest: expected.inventoryDigest, + inventoryDigest: independentlyRecomputedDigest, catalogHeadDigest: fixture.multiAssetSuccessor.head.objectDigest, inventoryRowCount: 2, activatedTripleCount: 4, appliedHeadStatus: 'applied', }); + expect(independentlyRecomputedDigest).toBe(expected.inventoryDigest); expect(evidence.rows.map((row) => ({ kaId: row.kaId, kaUal: row.kaUal, From bb9cfeef0b8a07d5365bacfe69d3cca2a42dbdcd Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:32:22 +0200 Subject: [PATCH 160/292] feat(agent): roll back failed RFC-64 transitions --- .../public-catalog-native-receiver-v1.ts | 273 ++++++++++++++++-- packages/storage/src/index.ts | 1 + 2 files changed, 242 insertions(+), 32 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 6cc7d63715..27d0419ae8 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -17,6 +17,7 @@ import { AUTHOR_SCHEME_VERSION_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, ZERO_DIGEST32_V1, assertAuthorCatalogHeadScopeBindingV1, @@ -61,7 +62,9 @@ import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dk import { quadsToNQuads, readExactGraphPaged, + readExactGraphPagedWithDiscoveredCount, tryReplaceGraphAndSubjectAtomically, + type Quad, type TripleStore, } from '@origintrail-official/dkg-storage'; import { ethers } from 'ethers'; @@ -98,6 +101,13 @@ import type { } from './public-catalog-transport-v1.js'; const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); +const MAX_TRANSITION_JOURNAL_ENTRIES_V1 = MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 * 2; +const MAX_TRANSITION_GRAPH_QUADS_V1 = + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1.maxPublicTriples; +// Includes the graph IRI repeated on every materialized N-Quad, so it must be +// wider than the verified projection-byte ceiling while remaining finite. +const MAX_TRANSITION_GRAPH_NQUADS_BYTES_V1 = 256 * 1024 * 1024; +const MAX_TRANSITION_SEAL_SUBJECT_ROWS_V1 = 15; export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { /** Fetch-only capability; lifecycle ownership remains with the catalog service. */ @@ -760,33 +770,47 @@ export class Rfc64PublicCatalogNativeReceiverV1 { fail('catalog-native-receiver-catalog', 'verified catalog objects could not be staged', cause); } + const transitionJournal = await snapshotSemanticTransitionV1( + this.options.store, + [ + ...plannedRemovals.map((removal) => transitionLocationFromRemoval(removal)), + ...preparedRows.map((prepared) => transitionLocationFromTarget( + head, + prepared.row, + prepared.sealBinding, + )), + ], + ); const removedRows: Rfc64PublicCatalogNativeRemovedRowEvidenceV1[] = []; - for (const removal of plannedRemovals) { - throwIfAborted(signal); - await deactivateExactOwnedPublicProjection(this.options.store, removal); - removedRows.push(removal); - } - const activatedRows: Rfc64PublicCatalogNativeActivatedRowEvidenceV1[] = []; - for (const prepared of preparedRows) { - throwIfAborted(signal); - const activation = await activateExactPublicProjection( - this.options.store, - head, - prepared.row, - prepared.projectionMetadata.kaUal, - prepared.projectionBytes, - Number(prepared.projectionMetadata.publicTripleCount), - prepared.sealBinding, - ); - activatedRows.push(Object.freeze({ - ...activation.evidence, - swmGraph: activation.swmGraph, - authorship: prepared.authorship, - })); - } - let completion: ReturnType; + let completion!: ReturnType; + let activatedTripleCount = 0; + let semanticMutationAttempted = false; try { + for (const removal of plannedRemovals) { + throwIfAborted(signal); + semanticMutationAttempted = true; + await deactivateExactOwnedPublicProjection(this.options.store, removal); + removedRows.push(removal); + } + for (const prepared of preparedRows) { + throwIfAborted(signal); + semanticMutationAttempted = true; + const activation = await activateExactPublicProjection( + this.options.store, + head, + prepared.row, + prepared.projectionMetadata.kaUal, + prepared.projectionBytes, + Number(prepared.projectionMetadata.publicTripleCount), + prepared.sealBinding, + ); + activatedRows.push(Object.freeze({ + ...activation.evidence, + swmGraph: activation.swmGraph, + authorship: prepared.authorship, + })); + } completion = verifyRfc64PublicCatalogInventoryCompletenessV1({ catalogScope: trustedCatalogScope, expectedTotalRows: head.payload.totalRows as CountV1, @@ -801,20 +825,36 @@ export class Rfc64PublicCatalogNativeReceiverV1 { activatedTripleCount: row.activatedTripleCount, })), }); + activatedTripleCount = activatedRows.reduce( + (total, row) => total + row.activatedTripleCount, + 0, + ); + if (!Number.isSafeInteger(activatedTripleCount)) { + fail( + 'catalog-native-receiver-activation', + 'total activated triple count is not a safe integer', + ); + } } catch (cause) { + if (semanticMutationAttempted) { + try { + await restoreSemanticTransitionV1(this.options.store, transitionJournal); + } catch (rollbackCause) { + fail( + 'catalog-native-receiver-activation', + 'semantic transition failed and its exact predecessor rollback also failed', + new AggregateError([cause, rollbackCause]), + ); + } + } + if (signal?.aborted && cause === signal.reason) throw cause; + if (cause instanceof Rfc64PublicCatalogNativeReceiverErrorV1) throw cause; fail( 'catalog-native-receiver-activation', - 'semantic post-reads do not equal the exact signed inventory', + 'semantic transition failed after mutation began', cause, ); } - const activatedTripleCount = activatedRows.reduce( - (total, row) => total + row.activatedTripleCount, - 0, - ); - if (!Number.isSafeInteger(activatedTripleCount)) { - fail('catalog-native-receiver-activation', 'total activated triple count is not a safe integer'); - } let appliedHeadStatus: 'applied' | 'existing'; if (replay) { @@ -1414,6 +1454,175 @@ function planOwnedRowRemoval( }); } +interface Rfc64SemanticTransitionLocationV1 { + readonly swmGraph: string; + readonly sealMetaGraph: string; + readonly sealSubject: string; +} + +interface Rfc64SemanticTransitionPreimageV1 + extends Rfc64SemanticTransitionLocationV1 { + readonly graphQuads: readonly Readonly[]; + readonly sealQuads: readonly Readonly[]; +} + +function transitionLocationFromRemoval( + removal: Readonly, +): Readonly { + return Object.freeze({ + swmGraph: removal.swmGraph, + sealMetaGraph: removal.sealMetaGraph, + sealSubject: removal.sealSubject, + }); +} + +function transitionLocationFromTarget( + head: SignedAuthorCatalogHeadEnvelopeV1, + row: Readonly, + binding: VerifiedCatalogSealBindingSnapshotV1, +): Readonly { + return Object.freeze({ + swmGraph: derivePublicSwmGraph(head.payload.contextGraphId, row.kaId), + sealMetaGraph: binding.placement.metaGraph, + sealSubject: binding.placement.subject, + }); +} + +/** + * Capture only the exact graph/subject pairs this verified transition may + * mutate. The journal is deliberately in-memory: it closes returned-failure + * consistency, while process-death recovery remains a Gate-4 durable protocol. + */ +async function snapshotSemanticTransitionV1( + store: TripleStore, + locations: readonly Readonly[], +): Promise[]> { + if (locations.length > MAX_TRANSITION_JOURNAL_ENTRIES_V1) { + fail( + 'catalog-native-receiver-activation', + `semantic transition exceeds ${MAX_TRANSITION_JOURNAL_ENTRIES_V1} exact preimages`, + ); + } + const journal: Rfc64SemanticTransitionPreimageV1[] = []; + try { + for (const location of locations) { + const graphQuads = await readExactGraphPagedWithDiscoveredCount( + store, + location.swmGraph, + { + maxQuadCount: MAX_TRANSITION_GRAPH_QUADS_V1, + maxNQuadsBytes: MAX_TRANSITION_GRAPH_NQUADS_BYTES_V1, + outputGraph: location.swmGraph, + queryOptions: { source: 'rfc64-public-catalog-transition-snapshot' }, + }, + ); + const sealQuads = await readExactSealSubjectRowsV1( + store, + location.sealMetaGraph, + location.sealSubject, + 'rfc64-public-catalog-transition-snapshot', + ); + journal.push(Object.freeze({ + ...location, + graphQuads: Object.freeze(graphQuads.map((quad) => Object.freeze({ ...quad }))), + sealQuads, + })); + } + } catch (cause) { + fail( + 'catalog-native-receiver-activation', + 'bounded exact semantic transition snapshot failed before mutation', + cause, + ); + } + return Object.freeze(journal); +} + +async function restoreSemanticTransitionV1( + store: TripleStore, + journal: readonly Readonly[], +): Promise { + for (let index = journal.length - 1; index >= 0; index -= 1) { + const preimage = journal[index]; + if (preimage === undefined) continue; + const restored = await tryReplaceGraphAndSubjectAtomically( + store, + preimage.swmGraph, + preimage.graphQuads.map((quad) => ({ ...quad })), + preimage.sealMetaGraph, + preimage.sealSubject, + preimage.sealQuads.map((quad) => ({ ...quad })), + { source: 'rfc64-public-catalog-transition-rollback' }, + ); + if (!restored) { + throw new Error('store lacks atomic graph/subject replacement during semantic rollback'); + } + await assertExactSemanticTransitionPreimageV1(store, preimage); + } +} + +async function assertExactSemanticTransitionPreimageV1( + store: TripleStore, + preimage: Readonly, +): Promise { + const [graphQuads, sealQuads] = await Promise.all([ + readExactGraphPaged(store, preimage.swmGraph, { + expectedQuadCount: preimage.graphQuads.length, + maxQuadCount: MAX_TRANSITION_GRAPH_QUADS_V1, + maxNQuadsBytes: MAX_TRANSITION_GRAPH_NQUADS_BYTES_V1, + outputGraph: preimage.swmGraph, + queryOptions: { source: 'rfc64-public-catalog-transition-rollback-post-read' }, + }), + readExactSealSubjectRowsV1( + store, + preimage.sealMetaGraph, + preimage.sealSubject, + 'rfc64-public-catalog-transition-rollback-post-read', + ), + ]); + if ( + canonicalQuadSetV1(graphQuads) !== canonicalQuadSetV1(preimage.graphQuads) + || canonicalQuadSetV1(sealQuads) !== canonicalQuadSetV1(preimage.sealQuads) + ) { + throw new Error('semantic transition rollback post-read differs from its exact preimage'); + } +} + +async function readExactSealSubjectRowsV1( + store: TripleStore, + metaGraph: string, + subject: string, + source: string, +): Promise[]> { + const result = await store.query( + `SELECT ?p ?o WHERE { GRAPH <${metaGraph}> { <${subject}> ?p ?o } } ` + + `ORDER BY ?p ?o LIMIT ${MAX_TRANSITION_SEAL_SUBJECT_ROWS_V1 + 1}`, + { source, maxResponseBytes: 64 * 1024 }, + ); + if ( + result.type !== 'bindings' + || result.bindings.length > MAX_TRANSITION_SEAL_SUBJECT_ROWS_V1 + ) { + throw new Error('exact transition seal subject exceeds its bounded row contract'); + } + const quads = result.bindings.map((row) => { + if (typeof row.p !== 'string' || typeof row.o !== 'string') { + throw new Error('exact transition seal subject row is incomplete'); + } + return Object.freeze({ + subject, + predicate: row.p, + object: row.o, + graph: metaGraph, + }); + }); + return Object.freeze(quads); +} + +function canonicalQuadSetV1(quads: readonly Readonly[]): string { + return quadsToNQuads([...quads].sort(compareQuads)); +} + async function deactivateExactOwnedPublicProjection( store: TripleStore, removal: Readonly, diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 755675dac5..140056b897 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -86,6 +86,7 @@ export { quadToNQuad, quadsToNQuads, readExactGraphPaged, + readExactGraphPagedWithDiscoveredCount, type ExactGraphReadErrorCode, type ExactGraphReadErrorKind, type ReadExactGraphPagedOptions, From 97a3c1bd17509e8ba9f2072f29c6680cd071dda3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:32:29 +0200 Subject: [PATCH 161/292] test(agent): prove RFC-64 transition rollback --- ...c-catalog-native-gate1.integration.test.ts | 262 +++++++++++++++++- 1 file changed, 261 insertions(+), 1 deletion(-) diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 0937d55873..fc3557c543 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -83,6 +83,7 @@ const UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${KA_NUMBER}`; const SECOND_KA_NUMBER = 8n; const SECOND_KA_ID = ((BigInt(AUTHOR) << 96n) | SECOND_KA_NUMBER).toString(); const SECOND_UAL = `did:dkg:${NETWORK_ID}/${AUTHOR}/${SECOND_KA_NUMBER}`; +const THIRD_KA_NUMBER = 9n; const PROJECTION = ' "42"^^ .\n' + ' "Alice" .\n'; @@ -499,7 +500,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { fixture.scopeDigest, AUTHOR, )?.currentCatalogHeadDigest).toBe(fixture.multiAssetSuccessor.head.objectDigest); - await expect(fixture.receiverStore.hasGraph(omittedSwmGraph)).resolves.toBe(false); + await expect(fixture.receiverStore.hasGraph(omittedSwmGraph)).resolves.toBe(true); const restartedReceiver = fixture.createReceiver(fixture.receiverPersistence.inventory); const repaired = await fixture.synchronizeAny( @@ -522,6 +523,186 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { }); }, 30_000); + it('rolls back an omitted row and earlier target activations when a later target post-read fails', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + await fixture.synchronize(); + await fixture.synchronizeAny(fixture.multiAssetAnnouncement); + await fixture.synchronizeAny(fixture.threeAssetAnnouncement); + const firstGraph = + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${KA_NUMBER}`; + const omittedGraph = + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${SECOND_KA_NUMBER}`; + const laterGraph = + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${AUTHOR}/${THIRD_KA_NUMBER}`; + const firstSeal = deriveCanonicalGraphScopedAuthorSealPlacementV1({ + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + assertionCoordinate: 'gate-1-object' as never, + }); + const omittedSeal = deriveCanonicalGraphScopedAuthorSealPlacementV1({ + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + assertionCoordinate: 'gate-2-object' as never, + }); + const laterSeal = deriveCanonicalGraphScopedAuthorSealPlacementV1({ + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + assertionCoordinate: 'gate-2-replacement-object' as never, + }); + const foreignAuthor = '0x9999999999999999999999999999999999999999' as EvmAddressV1; + const foreignGraph = + `did:dkg:context-graph:${CONTEXT_GRAPH_ID}/_shared_memory/${foreignAuthor}/77`; + const foreignSeal = deriveCanonicalGraphScopedAuthorSealPlacementV1({ + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: foreignAuthor, + assertionCoordinate: 'transition-foreign-object' as never, + }); + await fixture.receiverStore.insert([ + { + subject: 'https://example.org/predecessor-only', + predicate: 'https://schema.org/name', + object: '"Exact predecessor sentinel"', + graph: firstGraph, + }, + { + subject: 'https://example.org/transition-foreign', + predicate: 'https://schema.org/name', + object: '"Foreign transition sentinel"', + graph: foreignGraph, + }, + { + subject: foreignSeal.subject, + predicate: 'https://example.org/foreignTransitionSeal', + object: '"owned elsewhere"', + graph: foreignSeal.metaGraph, + }, + ]); + const predecessorFirst = await readExactSemanticPairForTest( + fixture.receiverStore, + firstGraph, + firstSeal.metaGraph, + firstSeal.subject, + ); + const predecessorOmitted = await readExactSemanticPairForTest( + fixture.receiverStore, + omittedGraph, + omittedSeal.metaGraph, + omittedSeal.subject, + ); + const predecessorLater = await readExactSemanticPairForTest( + fixture.receiverStore, + laterGraph, + laterSeal.metaGraph, + laterSeal.subject, + ); + const foreignBefore = await readExactSemanticPairForTest( + fixture.receiverStore, + foreignGraph, + foreignSeal.metaGraph, + foreignSeal.subject, + ); + expect(predecessorFirst.graph).toHaveLength(3); + const activatedGraphs: string[] = []; + let failLaterPostRead = true; + const faultStore = new Proxy(fixture.receiverStore, { + get(target, property) { + if (property === 'replaceGraphAndSubject') { + return async (...args: Parameters>) => { + await target.replaceGraphAndSubject!(...args); + if (args[1].length > 0) activatedGraphs.push(args[0]); + }; + } + if (property === 'query') { + return async (...args: Parameters) => { + if ( + failLaterPostRead + && args[1]?.source === 'rfc64-public-catalog-native-post-read' + && args[0].includes(`<${laterGraph}>`) + ) { + failLaterPostRead = false; + throw new Error('injected later-row exact post-read failure'); + } + return target.query(...args); + }; + } + const value = Reflect.get(target, property, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as TripleStore; + const failed = fixture.createCasObservedReceiver(undefined, faultStore); + + await expect(fixture.synchronizeAny( + fixture.replacementAnnouncement, + failed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-activation' }); + + expect(activatedGraphs.slice(0, 2)).toEqual([firstGraph, laterGraph]); + expect(failed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.threeAssetSuccessor.head.objectDigest); + await expect(readExactSemanticPairForTest( + fixture.receiverStore, + firstGraph, + firstSeal.metaGraph, + firstSeal.subject, + )).resolves.toEqual(predecessorFirst); + await expect(readExactSemanticPairForTest( + fixture.receiverStore, + omittedGraph, + omittedSeal.metaGraph, + omittedSeal.subject, + )).resolves.toEqual(predecessorOmitted); + await expect(readExactSemanticPairForTest( + fixture.receiverStore, + laterGraph, + laterSeal.metaGraph, + laterSeal.subject, + )).resolves.toEqual(predecessorLater); + await expect(readExactSemanticPairForTest( + fixture.receiverStore, + foreignGraph, + foreignSeal.metaGraph, + foreignSeal.subject, + )).resolves.toEqual(foreignBefore); + + const retried = await fixture.synchronizeAny(fixture.replacementAnnouncement); + expect(retried).toMatchObject({ + catalogHeadDigest: fixture.replacementSuccessor.head.objectDigest, + inventoryRowCount: 2, + removedRowCount: 1, + appliedHeadStatus: 'applied', + }); + await expect(fixture.receiverStore.hasGraph(firstGraph)).resolves.toBe(true); + await expect(fixture.receiverStore.hasGraph(omittedGraph)).resolves.toBe(false); + await expect(fixture.receiverStore.hasGraph(laterGraph)).resolves.toBe(true); + await expect(readExactSemanticPairForTest( + fixture.receiverStore, + firstGraph, + firstSeal.metaGraph, + firstSeal.subject, + )).resolves.not.toEqual(predecessorFirst); + await expect(readExactSemanticPairForTest( + fixture.receiverStore, + foreignGraph, + foreignSeal.metaGraph, + foreignSeal.subject, + )).resolves.toEqual(foreignBefore); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toMatchObject({ + currentCatalogHeadDigest: fixture.replacementSuccessor.head.objectDigest, + inventoryRowCount: '2', + }); + }, 30_000); + it('verifies all multi-asset bundles before staging, SWM mutation, or applied-head CAS', async () => { const fixture = await setupLiveReceiver(); await fixture.bootstrap(); @@ -1075,6 +1256,10 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { kaNumber: SECOND_KA_NUMBER, assertionCoordinate: 'gate-2-object', }); + const thirdRowBundle = await buildRowBundle(signingWallet, { + kaNumber: THIRD_KA_NUMBER, + assertionCoordinate: 'gate-2-replacement-object', + }); const genesis = await produceEmptyAuthorCatalogGenesisV1({ scope, catalogIssuerDelegationDigest: catalogIssuerDelegation.objectDigest, @@ -1115,6 +1300,24 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { issuedAt: '1773900001003' as never, signer, }); + const threeAssetSuccessor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: multiAssetSuccessor.head, + previousDirectoryPath: multiAssetSuccessor.directoryPath, + previousBucket: multiAssetSuccessor.bucket, + selectedBucketId: '0' as never, + nextRows: [rowBundle.row, secondRowBundle.row, thirdRowBundle.row], + issuedAt: '1773900001004' as never, + signer, + }); + const replacementSuccessor = await produceSparseAuthorCatalogSuccessorV1({ + previousHead: threeAssetSuccessor.head, + previousDirectoryPath: threeAssetSuccessor.directoryPath, + previousBucket: threeAssetSuccessor.bucket, + selectedBucketId: '0' as never, + nextRows: [rowBundle.row, thirdRowBundle.row], + issuedAt: '1773900001005' as never, + signer, + }); const governedSuccessor = await produceSparseAuthorCatalogSuccessorV1({ previousHead: governedGenesis.head, previousDirectoryPath: governedGenesis.directoryPath, @@ -1161,6 +1364,8 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ...successor.stagedObjects, ...multiAssetSuccessor.stagedObjects, ...removalSuccessor.stagedObjects, + ...threeAssetSuccessor.stagedObjects, + ...replacementSuccessor.stagedObjects, ...governedSuccessor.stagedObjects, ...competingSuccessor.stagedObjects, crossLaneHead, @@ -1174,6 +1379,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const bundleBytesByDigest = new Map([ [rowBundle.row.transfer.blobDigest, rowBundle.bundleBytes], [secondRowBundle.row.transfer.blobDigest, secondRowBundle.bundleBytes], + [thirdRowBundle.row.transfer.blobDigest, thirdRowBundle.bundleBytes], ]); const authorBundleRead = vi.fn(async (digest: Digest32V1) => bundleBytesByDigest.get(digest) ?? null); @@ -1189,6 +1395,8 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { ...successor.stagedObjects, ...multiAssetSuccessor.stagedObjects, ...removalSuccessor.stagedObjects, + ...threeAssetSuccessor.stagedObjects, + ...replacementSuccessor.stagedObjects, ...governedSuccessor.stagedObjects, ...competingSuccessor.stagedObjects, crossLaneHead, @@ -1223,6 +1431,12 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { const removalHeadKeys = staged.objects.find( (keys) => keys.objectDigest === removalSuccessor.head.objectDigest, ); + const replacementHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === replacementSuccessor.head.objectDigest, + ); + const threeAssetHeadKeys = staged.objects.find( + (keys) => keys.objectDigest === threeAssetSuccessor.head.objectDigest, + ); const governedGenesisHeadKeys = staged.objects.find( (keys) => keys.objectDigest === governedGenesis.head.objectDigest, ); @@ -1248,6 +1462,8 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { if (genesisHeadKeys === undefined) throw new Error('genesis head was not staged'); if (multiAssetHeadKeys === undefined) throw new Error('multi-asset successor head was not staged'); if (removalHeadKeys === undefined) throw new Error('removal successor head was not staged'); + if (replacementHeadKeys === undefined) throw new Error('replacement successor head was not staged'); + if (threeAssetHeadKeys === undefined) throw new Error('three-asset successor head was not staged'); if (governedGenesisHeadKeys === undefined) throw new Error('governed genesis head was not staged'); if (governedSuccessorHeadKeys === undefined) throw new Error('governed successor head was not staged'); if (invalidGenesisHeadKeys === undefined) throw new Error('invalid genesis head was not staged'); @@ -1334,6 +1550,18 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { catalogHeadObjectDigest: removalHeadKeys.objectDigest, signatureVariantDigest: removalHeadKeys.signatureVariantDigest, }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const replacementAnnouncement = Object.freeze({ + ...announcement, + catalogVersion: replacementSuccessor.head.payload.version, + catalogHeadObjectDigest: replacementHeadKeys.objectDigest, + signatureVariantDigest: replacementHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; + const threeAssetAnnouncement = Object.freeze({ + ...announcement, + catalogVersion: threeAssetSuccessor.head.payload.version, + catalogHeadObjectDigest: threeAssetHeadKeys.objectDigest, + signatureVariantDigest: threeAssetHeadKeys.signatureVariantDigest, + }) satisfies Rfc64PublicCatalogHeadAnnouncementV1; const competingAnnouncement = Object.freeze({ ...announcement, catalogHeadObjectDigest: competingHeadKeys.objectDigest, @@ -1462,6 +1690,8 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { multiAssetSuccessor, removalAnnouncement, removalSuccessor, + replacementAnnouncement, + replacementSuccessor, receiverDirectory: receiverOpened.directory, receiverHeadFetch, receiverObjectFetch, @@ -1469,6 +1699,9 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { receiverStore, rowBundle, secondRowBundle, + thirdRowBundle, + threeAssetAnnouncement, + threeAssetSuccessor, scope, scopeDigest, successor, @@ -1508,6 +1741,33 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { }; } +async function readExactSemanticPairForTest( + store: TripleStore, + graph: string, + sealMetaGraph: string, + sealSubject: string, +): Promise<{ + readonly graph: readonly Readonly>[]; + readonly seal: readonly Readonly>[]; +}> { + const [graphResult, sealResult] = await Promise.all([ + store.query( + `SELECT ?s ?p ?o WHERE { GRAPH <${graph}> { ?s ?p ?o } } ORDER BY ?s ?p ?o`, + ), + store.query( + `SELECT ?p ?o WHERE { GRAPH <${sealMetaGraph}> { ` + + `<${sealSubject}> ?p ?o } } ORDER BY ?p ?o`, + ), + ]); + if (graphResult.type !== 'bindings' || sealResult.type !== 'bindings') { + throw new Error('exact semantic pair test read did not return bindings'); + } + return Object.freeze({ + graph: Object.freeze(graphResult.bindings.map((row) => Object.freeze({ ...row }))), + seal: Object.freeze(sealResult.bindings.map((row) => Object.freeze({ ...row }))), + }); +} + async function buildDirectCatalogIssuerDelegation(options: { readonly scope?: AuthorCatalogScopeV1; readonly contextGraphId?: ContextGraphIdV1; From d2bc11deab1587ea4648c1cff0cc43977817d420 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:35:13 +0200 Subject: [PATCH 162/292] test(rfc64): harden Gate 2 live evidence --- .../adapter-process.ts | 18 +- .../agent-child.ts | 19 +- .../launch-live.ts | 19 + .../live-verifier.ts | 103 ++++- .../run.ts | 135 +++++-- .../runtime-load-hook.ts | 56 +++ .../runtime-provenance.ts | 381 ++++++++++++++++++ .../test/live-verifier.test.ts | 97 ++++- .../test/runtime-provenance.test.ts | 70 ++++ .../verify-live.ts | 18 +- .../rfc64-persistence-lifecycle/evidence.ts | 13 +- package.json | 4 +- 12 files changed, 876 insertions(+), 57 deletions(-) create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/launch-live.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/runtime-load-hook.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/runtime-provenance.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/test/runtime-provenance.test.ts diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts index 91f41d9e1d..02cad8e9f5 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -30,15 +30,20 @@ import { GATE2_AGENT_EVENT_PREFIX, GATE2_REAL_DKG_AGENT_ADAPTER_ID, } from './model.js'; +import { sealGate2ExecutedRuntimeManifestV1 } from './runtime-load-hook.ts'; const role = process.argv[2]; const dataDirInput = process.env.DKG_RFC64_GATE2_ADAPTER_DATA_DIR; const masterKeyHex = process.env.DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX; +const runtimeBuildManifestDigest = process.env.DKG_RFC64_GATE2_RUNTIME_MANIFEST_DIGEST; if (role !== 'author' && role !== 'receiver') throw new Error('adapter role is required'); if (!dataDirInput) throw new Error('DKG_RFC64_GATE2_ADAPTER_DATA_DIR is required'); if (!masterKeyHex || !/^[0-9a-f]{64}$/u.test(masterKeyHex)) { throw new Error('DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX must be 32 lowercase hex bytes'); } +if (!runtimeBuildManifestDigest || !/^0x[0-9a-f]{64}$/u.test(runtimeBuildManifestDigest)) { + throw new Error('DKG_RFC64_GATE2_RUNTIME_MANIFEST_DIGEST must be a canonical digest'); +} const dataDir = resolve(dataDirInput); const pinnedMasterKeyHex = masterKeyHex; @@ -129,6 +134,7 @@ async function boot(): Promise { multiaddr: tcp, peerId: created.peerId, protocolVersion: GATE2_ADAPTER_PROTOCOL_VERSION, + runtimeBuildManifestDigest, startupRepair: null, }); } @@ -332,7 +338,11 @@ async function handle(command: Command): Promise { // The parent process owns SIGKILL and replacement. This command only // establishes an explicit process-protocol boundary; it creates no fake // repair record and intentionally does not stop the DKGAgent. - emit({ event: 'kill-restart-ready', requestId: command.requestId }); + emit({ + event: 'kill-restart-ready', + executedRuntimeManifest: sealGate2ExecutedRuntimeManifestV1(), + requestId: command.requestId, + }); return; case 'stop': await stop(0, command.requestId); @@ -600,7 +610,11 @@ async function stop(exitCode: number, requestId?: string): Promise { try { await agent?.stop(); if (requestId !== undefined) { - await emitAndFlush({ event: 'stopped', requestId }); + await emitAndFlush({ + event: 'stopped', + executedRuntimeManifest: sealGate2ExecutedRuntimeManifestV1(), + requestId, + }); } process.exit(exitCode); } catch (error) { diff --git a/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts b/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts index 69fbb76bc2..622bbca9db 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts @@ -25,6 +25,11 @@ export interface Gate2AgentSpawnSpec { readonly env: NodeJS.ProcessEnv; } +export interface Gate2ProcessBoundaryEvidence { + readonly event: Gate2AgentEvent; + readonly exit: ProcessExitEvidence; +} + export interface Gate2AgentChildOptions { readonly eventTimeoutMs?: number; readonly registry: ChildProcessRegistry; @@ -134,25 +139,25 @@ export class Gate2AgentChild { return event; } - async stop(requestId: string): Promise { + async stop(requestId: string): Promise { if (this.child.exitCode !== null || this.child.signalCode !== null) { - return this.tracked.closed; + throw new Error(`${this.role} DKGAgent was already closed before the stop boundary`); } - await this.request('stop', requestId, 'stopped'); + const event = await this.request('stop', requestId, 'stopped'); const exit = await this.tracked.closed; if (exit.code !== 0 || exit.signal !== null) { throw new Error(`${this.role} DKGAgent did not stop cleanly: ${JSON.stringify(exit)}`); } - return exit; + return { event, exit }; } - async killRestartBoundary(requestId: string): Promise { - await this.request('killRestart', requestId, 'kill-restart-ready'); + async killRestartBoundary(requestId: string): Promise { + const event = await this.request('killRestart', requestId, 'kill-restart-ready'); const exit = await this.options.registry.terminateAndWait(this.tracked, 'SIGKILL'); if (exit.code !== null || exit.signal !== 'SIGKILL') { throw new Error(`${this.role} crash boundary was not SIGKILL: ${JSON.stringify(exit)}`); } - return exit; + return { event, exit }; } private consumeStdout(chunk: string): void { diff --git a/devnet/rfc64-gate2-multi-asset-completeness/launch-live.ts b/devnet/rfc64-gate2-multi-asset-completeness/launch-live.ts new file mode 100644 index 0000000000..8440b32714 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/launch-live.ts @@ -0,0 +1,19 @@ +import { resolve } from 'node:path'; + +import { readCleanRepositoryHead } from '../rfc64-persistence-lifecycle/evidence.js'; +import { + buildGate2RuntimeManifestV1, + installGate2RuntimeLaunchReceiptV1, + runGate2CleanRuntimeBuildV1, +} from './runtime-provenance.ts'; + +const repoRoot = resolve(import.meta.dirname, '../..'); +const sourceCommit = readCleanRepositoryHead(repoRoot); +runGate2CleanRuntimeBuildV1(repoRoot); +const headAfterBuild = readCleanRepositoryHead(repoRoot); +if (headAfterBuild !== sourceCommit) { + throw new Error('Gate 2 source HEAD changed during the clean runtime build'); +} +const manifest = buildGate2RuntimeManifestV1(repoRoot, sourceCommit); +installGate2RuntimeLaunchReceiptV1({ manifest, sourceCommit }); +await import('./run.ts'); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts b/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts index 701a868c4c..527c2f3455 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/live-verifier.ts @@ -11,7 +11,7 @@ import { type Gate2AuthoredInventory, type Gate2ReceivedInventory, } from './model.js'; -import { sha256Digest } from './src/canonical.ts'; +import { canonicalDocument, sha256Digest, type CanonicalValue } from './src/canonical.ts'; import { GATE_EVALUATION, PRODUCT_BOUNDARY, @@ -19,6 +19,12 @@ import { type AssetRowV1, } from './src/schema.ts'; import { verify as verifyInventoryContract } from './src/verify.ts'; +import { + buildGate2RuntimeProvenanceV1, + type Gate2RuntimeManifestV1, + type Gate2RuntimeProcessEvidenceV1, + type Gate2RuntimeProvenanceV1, +} from './runtime-provenance.ts'; const COMMIT = /^[0-9a-f]{40,64}$/u; const DIGEST = /^0x[0-9a-f]{64}$/u; @@ -34,6 +40,7 @@ const MAX_RAW_BYTES = 2 * 1024 * 1024; export interface Gate2VerifiedEvidence { readonly rawArtifactSha256: string; + readonly runtimeProvenanceDigest: string; readonly sourceCommit: string; } @@ -41,6 +48,7 @@ export interface Gate2PassVerdict { readonly gate: 'OT-RFC-64 Gate 2 multi-asset completeness'; readonly productBoundary: 'connected'; readonly rawArtifactSha256: string; + readonly runtimeProvenanceDigest: string; readonly schemaVersion: typeof GATE2_VERDICT_SCHEMA_VERSION; readonly sourceCommit: string; readonly status: 'PASS'; @@ -53,6 +61,7 @@ export function buildGate2PassVerdict( gate: 'OT-RFC-64 Gate 2 multi-asset completeness', productBoundary: 'connected', rawArtifactSha256: verified.rawArtifactSha256, + runtimeProvenanceDigest: verified.runtimeProvenanceDigest, schemaVersion: GATE2_VERDICT_SCHEMA_VERSION, sourceCommit: verified.sourceCommit, status: 'PASS', @@ -62,6 +71,7 @@ export function buildGate2PassVerdict( export function verifyGate2ArtifactBytes( rawBytes: Uint8Array, expectedHead: string, + expectedRuntimeManifest: Gate2RuntimeManifestV1, ): Gate2VerifiedEvidence { matchString(expectedHead, COMMIT, 'expected repository HEAD'); if (rawBytes.byteLength < 1 || rawBytes.byteLength > MAX_RAW_BYTES) { @@ -79,15 +89,26 @@ export function verifyGate2ArtifactBytes( } catch (cause) { throw new Error('Gate 2 artifact is not valid JSON', { cause }); } - if (stableJson(parsed) !== text) fail('$', 'raw artifact is not exact canonical stable JSON'); - verifyArtifact(parsed, expectedHead); + if (canonicalDocument(parsed as CanonicalValue) !== text) { + fail('$', 'raw artifact is not an exact RFC 8785 canonical document with one trailing LF'); + } + const runtimeProvenanceDigest = verifyArtifact( + parsed, + expectedHead, + expectedRuntimeManifest, + ); return Object.freeze({ rawArtifactSha256: `0x${createHash('sha256').update(rawBytes).digest('hex')}`, + runtimeProvenanceDigest, sourceCommit: expectedHead, }); } -function verifyArtifact(value: unknown, expectedHead: string): void { +function verifyArtifact( + value: unknown, + expectedHead: string, + expectedRuntimeManifest: Gate2RuntimeManifestV1, +): string { const raw = closedRecord(value, '$', [ 'adapter', 'authorizationNegative', @@ -101,6 +122,7 @@ function verifyArtifact(value: unknown, expectedHead: string): void { 'ready', 'repository', 'restartReplay', + 'runtimeProvenance', 'schemaVersion', 'transitions', 'transport', @@ -120,6 +142,7 @@ function verifyArtifact(value: unknown, expectedHead: string): void { 'protocolVersion', 'replacementContract', 'requiredProductionOperations', + 'runtimeBuildManifestDigest', ]); exact(adapter.id, GATE2_REAL_DKG_AGENT_ADAPTER_ID, '$.adapter.id'); exact(adapter.protocolVersion, GATE2_ADAPTER_PROTOCOL_VERSION, '$.adapter.protocolVersion'); @@ -132,6 +155,16 @@ function verifyArtifact(value: unknown, expectedHead: string): void { ); exactJson(adapter.inspectedProductCommits, [expectedHead], '$.adapter.inspectedProductCommits'); + const runtimeProvenanceDigest = verifyRuntimeProvenance( + raw.runtimeProvenance, + expectedRuntimeManifest, + ); + exact( + adapter.runtimeBuildManifestDigest, + expectedRuntimeManifest.manifestDigest, + '$.adapter.runtimeBuildManifestDigest', + ); + const repository = closedRecord(raw.repository, '$.repository', [ 'testedHeadCommit', 'trackedSourceCleanAfterProcesses', @@ -141,7 +174,7 @@ function verifyArtifact(value: unknown, expectedHead: string): void { exact(repository.trackedSourceCleanBeforeSpawn, true, '$.repository.cleanBefore'); exact(repository.trackedSourceCleanAfterProcesses, true, '$.repository.cleanAfter'); - const peers = verifyReadyPair(raw.ready); + const peers = verifyReadyPair(raw.ready, expectedRuntimeManifest.manifestDigest); verifyProcessBoundary(raw.processBoundary); const policy = verifyPolicy(raw.policy); const inventories = verifyInventories(raw.inventory, policy); @@ -185,7 +218,30 @@ function verifyArtifact(value: unknown, expectedHead: string): void { expectedApplied, peers, positiveWire.semantic, + expectedRuntimeManifest.manifestDigest, ); + return runtimeProvenanceDigest; +} + +function verifyRuntimeProvenance( + value: unknown, + expectedRuntimeManifest: Gate2RuntimeManifestV1, +): string { + try { + const raw = value as Gate2RuntimeProvenanceV1; + exactJson(raw.sourceBuild, expectedRuntimeManifest, '$.runtimeProvenance.sourceBuild'); + const rebuilt = buildGate2RuntimeProvenanceV1( + raw.sourceBuild, + raw.processes as readonly Gate2RuntimeProcessEvidenceV1[], + ); + exactJson(raw, rebuilt, '$.runtimeProvenance'); + return digest(raw.provenanceDigest, '$.runtimeProvenance.provenanceDigest'); + } catch (cause) { + if (cause instanceof Error && cause.message.startsWith('Gate 2 evidence verification failed')) { + throw cause; + } + fail('$.runtimeProvenance', cause instanceof Error ? cause.message : String(cause)); + } } function verifyInventories( @@ -208,6 +264,12 @@ function verifyInventories( const received = inventory.received as Gate2ReceivedInventory; exact(authored.signedRows.length, 3, '$.inventory.authored.signedRows.length'); exact(received.activatedRows.length, 3, '$.inventory.received.activatedRows.length'); + if (new Set(authored.signedRows.map((row) => row.contentDigest)).size !== 3) { + fail('$.inventory.authored.signedRows', 'content digests must be distinct per KA'); + } + if (new Set(received.activatedRows.map((row) => row.contentDigest)).size !== 3) { + fail('$.inventory.received.activatedRows', 'content digests must be distinct per KA'); + } exact(authored.catalogScope.networkId, policy.networkId, '$.inventory.authored.scope.networkId'); exact( authored.catalogScope.contextGraphId, @@ -383,6 +445,9 @@ function verifyPositiveWireInventory( rows.reduce((total, row) => total + row.activatedTripleCount, 0), `${path}.activatedTripleCount`, ); + if (new Set(rows.map((row) => row.swmGraph)).size !== rows.length) { + fail(`${path}.rows`, 'SWM graphs must be distinct per KA'); + } return { rows: Object.freeze(rows), verifiedControlObjectCount }; } @@ -394,6 +459,7 @@ const KA_PROJECTION_DIGEST_DOMAIN_V1 = 'dkg-ka-projection-v1\n'; function verifySemanticState(value: unknown, path: string, rows: readonly WireRow[]): void { const semantic = closedArray(value, path, 3); + const projections = new Set(); semantic.forEach((entry, index) => { const state = closedRecord(entry, `${path}[${index}]`, ['kaId', 'readBack']); exact(state.kaId, rows[index]!.kaId, `${path}[${index}].kaId`); @@ -413,12 +479,14 @@ function verifySemanticState(value: unknown, path: string, rows: readonly WireRo `${path}[${index}].projectionNQuads`, 256 * 1024, ); + projections.add(projection); exact( sha256Digest(KA_PROJECTION_DIGEST_DOMAIN_V1, projection), rows[index]!.contentDigest, `${path}[${index}].projection digest`, ); }); + if (projections.size !== rows.length) fail(path, 'semantic projections must be distinct per KA'); } function verifyRestart( @@ -427,6 +495,7 @@ function verifyRestart( expectedApplied: unknown, peers: { author: string; receiver: string }, semanticBefore: unknown, + runtimeManifestDigest: string, ): void { const restart = closedRecord(value, '$.restartReplay', [ 'appliedReadBack', @@ -457,6 +526,7 @@ function verifyRestart( restart.restartedReady, '$.restartReplay.restartedReady', 'receiver', + runtimeManifestDigest, ); exact(restartedPeer, peers.receiver, '$.restartReplay.restartedReady.peerId'); exactJson(restart.semanticPostRead, semanticBefore, '$.restartReplay.semanticPostRead'); @@ -471,25 +541,40 @@ function verifyRestart( verifySemanticState(restart.semanticPostRead, '$.restartReplay.semanticPostRead', rows); } -function verifyReadyPair(value: unknown): { author: string; receiver: string } { +function verifyReadyPair( + value: unknown, + runtimeManifestDigest: string, +): { author: string; receiver: string } { const ready = closedRecord(value, '$.ready', ['author', 'receiver']); - const author = verifyReadyEvent(ready.author, '$.ready.author', 'author'); - const receiver = verifyReadyEvent(ready.receiver, '$.ready.receiver', 'receiver'); + const author = verifyReadyEvent(ready.author, '$.ready.author', 'author', runtimeManifestDigest); + const receiver = verifyReadyEvent( + ready.receiver, + '$.ready.receiver', + 'receiver', + runtimeManifestDigest, + ); if (author === receiver) fail('$.ready', 'author and receiver peers must be distinct'); return { author, receiver }; } -function verifyReadyEvent(value: unknown, path: string, role: 'author' | 'receiver'): string { +function verifyReadyEvent( + value: unknown, + path: string, + role: 'author' | 'receiver', + runtimeManifestDigest: string, +): string { const ready = closedRecord(value, path, [ 'adapterId', 'peerId', 'protocolVersion', 'role', + 'runtimeBuildManifestDigest', 'startupRepair', ]); exact(ready.adapterId, GATE2_REAL_DKG_AGENT_ADAPTER_ID, `${path}.adapterId`); exact(ready.protocolVersion, GATE2_ADAPTER_PROTOCOL_VERSION, `${path}.protocolVersion`); exact(ready.role, role, `${path}.role`); + exact(ready.runtimeBuildManifestDigest, runtimeManifestDigest, `${path}.runtimeBuildManifestDigest`); exact(ready.startupRepair, null, `${path}.startupRepair`); return boundedString(ready.peerId, `${path}.peerId`); } diff --git a/devnet/rfc64-gate2-multi-asset-completeness/run.ts b/devnet/rfc64-gate2-multi-asset-completeness/run.ts index 4a4644bc8e..7bd9fa1c91 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/run.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/run.ts @@ -12,7 +12,7 @@ import { import { ethers } from 'ethers'; import { - atomicWriteStableJson, + atomicWriteExactBytes, readCleanRepositoryHead, stableJson, } from '../rfc64-persistence-lifecycle/evidence.js'; @@ -41,9 +41,18 @@ import { type CatalogScopeV1, } from './src/schema.ts'; import { verify as verifyInventoryContract } from './src/verify.ts'; +import { canonicalDocument, type CanonicalValue } from './src/canonical.ts'; +import { + assertGate2RuntimeManifestEqualV1, + buildGate2RuntimeManifestV1, + buildGate2RuntimeProvenanceV1, + consumeGate2RuntimeLaunchReceiptV1, + type Gate2ExecutedRuntimeManifestV1, +} from './runtime-provenance.ts'; const REPO_ROOT = resolve(import.meta.dirname, '../..'); const ADAPTER_PROCESS = join(import.meta.dirname, 'adapter-process.ts'); +const RUNTIME_LOAD_HOOK = join(import.meta.dirname, 'runtime-load-hook.ts'); const DEFAULT_RAW_ARTIFACT = join(import.meta.dirname, 'artifacts/gate2-result.json'); const DEFAULT_VERDICT_ARTIFACT = join(import.meta.dirname, 'artifacts/gate2-verdict.json'); const PROCESS_TIMEOUT_MS = 90_000; @@ -66,17 +75,19 @@ const DEPLOYMENT = Object.freeze({ const KA_NUMBERS = Object.freeze([7n, 8n, 9n]); const ASSERTION_ROOTS = Object.freeze([ '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', - '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', - '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f', + '0x3f3c55f18e4bf87221b0f51de96594fc94496961cc7b71a1c6b9823ee10e1f30', + '0xa3bae85ecbcd93e7673b01492a36c8104cab9d0f391a5dd9923dcc7e09a4b9b9', ]); -const CANONICAL_TWO_TRIPLE_PROJECTION = +const PROJECTION_NQUADS = Object.freeze([ ' ' + '"42"^^ .\n' - + ' "Alice" .\n'; -const PROJECTION_NQUADS = Object.freeze([ - CANONICAL_TWO_TRIPLE_PROJECTION, - CANONICAL_TWO_TRIPLE_PROJECTION, - CANONICAL_TWO_TRIPLE_PROJECTION, + + ' "Alice" .\n', + ' ' + + '"43"^^ .\n' + + ' "Bob" .\n', + ' ' + + '"44"^^ .\n' + + ' "Carol" .\n', ]); const GENESIS_ISSUED_AT = '1773900000000'; const SUCCESSOR_ISSUED_AT = Object.freeze([ @@ -93,7 +104,15 @@ const ROLE_MASTER_KEYS = Object.freeze({ }); async function execute(): Promise { + const launchReceipt = consumeGate2RuntimeLaunchReceiptV1(); const headBefore = readCleanRepositoryHead(REPO_ROOT); + exact(headBefore, launchReceipt.sourceCommit, 'clean-build launch source commit'); + assertGate2RuntimeManifestEqualV1( + buildGate2RuntimeManifestV1(REPO_ROOT, headBefore), + launchReceipt.manifest, + ); + exact(new Set(PROJECTION_NQUADS).size, 3, 'distinct per-KA projections before spawn'); + exact(new Set(ASSERTION_ROOTS).size, 3, 'distinct per-KA assertion roots before spawn'); const rawArtifactPath = process.env.DKG_RFC64_GATE2_ARTIFACT ?? DEFAULT_RAW_ARTIFACT; const verdictArtifactPath = process.env.DKG_RFC64_GATE2_VERDICT_ARTIFACT ?? DEFAULT_VERDICT_ARTIFACT; @@ -106,14 +125,18 @@ async function execute(): Promise { let operationFailed = true; let primaryFailure: unknown; try { - const author = spawnAgent('author', authorDataDir, children); - const receiver = spawnAgent('receiver', receiverDataDir, children); + const author = spawnAgent( + 'author', authorDataDir, children, launchReceipt.manifest.manifestDigest, headBefore, + ); + const receiver = spawnAgent( + 'receiver', receiverDataDir, children, launchReceipt.manifest.manifestDigest, headBefore, + ); const [authorReady, receiverReady] = await Promise.all([ author.waitFor('ready'), receiver.waitFor('ready'), ]); - requireRealReady(authorReady, 'author'); - requireRealReady(receiverReady, 'receiver'); + requireRealReady(authorReady, 'author', launchReceipt.manifest.manifestDigest); + requireRealReady(receiverReady, 'receiver', launchReceipt.manifest.manifestDigest); requireCondition(authorReady.peerId !== receiverReady.peerId, 'peer identities are not distinct'); await connectBothWays(author, receiver, authorReady, receiverReady, 'initial'); @@ -426,10 +449,12 @@ async function execute(): Promise { ); exact(failureCode, 'catalog-native-receiver-authorization', 'terminal failure code'); - const receiverCrashExit = await receiver.killRestartBoundary('receiver-crash-v1'); - const restartedReceiver = spawnAgent('receiver', receiverDataDir, children); + const receiverCrashBoundary = await receiver.killRestartBoundary('receiver-crash-v1'); + const restartedReceiver = spawnAgent( + 'receiver', receiverDataDir, children, launchReceipt.manifest.manifestDigest, headBefore, + ); const restartedReady = await restartedReceiver.waitFor('ready'); - requireRealReady(restartedReady, 'receiver'); + requireRealReady(restartedReady, 'receiver', launchReceipt.manifest.manifestDigest); exact(restartedReady.peerId, receiverReady.peerId, 'receiver peer ID after restart'); await connectBothWays(author, restartedReceiver, authorReady, restartedReady, 'restart'); await acceptPolicy(restartedReceiver, 'restarted-receiver-policy-v1', CONTEXT_GRAPH_ID); @@ -475,10 +500,37 @@ async function execute(): Promise { ); exactJson(replayApplied, expectedApplied, 'replay durable applied readback'); - const restartedReceiverExit = await restartedReceiver.stop('receiver-stop-v1'); - const authorExit = await author.stop('author-stop-v1'); + const restartedReceiverBoundary = await restartedReceiver.stop('receiver-stop-v1'); + const authorBoundary = await author.stop('author-stop-v1'); const headAfter = readCleanRepositoryHead(REPO_ROOT); exact(headAfter, headBefore, 'tracked source commit after process run'); + assertGate2RuntimeManifestEqualV1( + buildGate2RuntimeManifestV1(REPO_ROOT, headAfter), + launchReceipt.manifest, + ); + const runtimeProvenance = buildGate2RuntimeProvenanceV1( + launchReceipt.manifest, + [ + { + id: 'author', + loaded: requiredExecutedRuntimeManifest(authorBoundary.event, 'author stop'), + }, + { + id: 'receiverBeforeCrash', + loaded: requiredExecutedRuntimeManifest( + receiverCrashBoundary.event, + 'receiver pre-SIGKILL', + ), + }, + { + id: 'receiverAfterRestart', + loaded: requiredExecutedRuntimeManifest( + restartedReceiverBoundary.event, + 'restarted receiver stop', + ), + }, + ], + ); const artifact = { adapter: { @@ -486,6 +538,7 @@ async function execute(): Promise { inspectedProductCommits: [headBefore], productBoundary: 'connected', protocolVersion: GATE2_ADAPTER_PROTOCOL_VERSION, + runtimeBuildManifestDigest: launchReceipt.manifest.manifestDigest, requiredProductionOperations: REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, replacementContract: 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence', @@ -530,8 +583,8 @@ async function execute(): Promise { model: 'two real DKGAgent peer processes plus one receiver restart', receiverInstances: 2, stoppedExits: { - author: selectExit(authorExit), - restartedReceiver: selectExit(restartedReceiverExit), + author: selectExit(authorBoundary.exit), + restartedReceiver: selectExit(restartedReceiverBoundary.exit), }, }, ready: { @@ -545,7 +598,7 @@ async function execute(): Promise { }, restartReplay: { appliedReadBack: structuredClone(replayApplied), - crashExit: receiverCrashExit, + crashExit: receiverCrashBoundary.exit, processLocalSynchronization: replayedSynchronization, reannouncementAcknowledgedByPeerId: restartedReady.peerId, receiverStats: { @@ -560,6 +613,7 @@ async function execute(): Promise { successorServedByPeerId: authorReady.peerId, }, schemaVersion: GATE2_RAW_SCHEMA_VERSION, + runtimeProvenance: structuredClone(runtimeProvenance), transitions: structuredClone(transitions), transport: { finalAnnouncementPolicyDigest: requiredDigest( @@ -578,7 +632,10 @@ async function execute(): Promise { ), }, }; - const publication = atomicWriteStableJson(rawArtifactPath, artifact); + const publication = atomicWriteExactBytes( + rawArtifactPath, + new TextEncoder().encode(canonicalDocument(artifact as unknown as CanonicalValue)), + ); process.stdout.write( `[rfc64-gate2-harness] wrote ${rawArtifactPath} sha256=${publication.sha256}\n`, ); @@ -989,26 +1046,38 @@ function spawnAgent( role: 'author' | 'receiver', dataDir: string, registry: ChildProcessRegistry, + runtimeManifestDigest: string, + sourceCommit: string, ): Gate2AgentChild { + const childEnv = { ...process.env }; + delete childEnv.NODE_OPTIONS; + delete childEnv.NODE_PATH; + delete childEnv.TSX_TSCONFIG_PATH; return new Gate2AgentChild({ eventTimeoutMs: PROCESS_TIMEOUT_MS, registry, role, spawn: { command: process.execPath, - args: ['--import', 'tsx', ADAPTER_PROCESS, role], + args: ['--import', 'tsx', '--import', RUNTIME_LOAD_HOOK, ADAPTER_PROCESS, role], cwd: REPO_ROOT, env: { - ...process.env, + ...childEnv, DKG_RFC64_GATE2_ADAPTER_DATA_DIR: dataDir, DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX: ROLE_MASTER_KEYS[role], + DKG_RFC64_GATE2_RUNTIME_MANIFEST_DIGEST: runtimeManifestDigest, + DKG_RFC64_GATE2_RUNTIME_SOURCE_COMMIT: sourceCommit, NODE_ENV: 'production', }, }, }); } -function requireRealReady(event: Gate2AgentEvent, expectedRole: 'author' | 'receiver'): void { +function requireRealReady( + event: Gate2AgentEvent, + expectedRole: 'author' | 'receiver', + runtimeManifestDigest: string, +): void { requireCondition(event.role === expectedRole, 'ready role differs from the spawned role'); requireCondition( event.adapterId === GATE2_REAL_DKG_AGENT_ADAPTER_ID, @@ -1021,6 +1090,7 @@ function requireRealReady(event: Gate2AgentEvent, expectedRole: 'author' | 'rece requireCondition(event.agentClass === 'DKGAgent', 'child did not boot a real DKGAgent'); requireCondition(event.catalogServiceStarted === true, 'production catalog service did not start'); requireCondition(event.startupRepair === null, 'adapter claimed nonexistent automatic startup repair'); + exact(event.runtimeBuildManifestDigest, runtimeManifestDigest, 'child runtime build manifest digest'); requireCondition(typeof event.peerId === 'string' && event.peerId.length > 0, 'peer ID is missing'); requireCondition( typeof event.multiaddr === 'string' && event.multiaddr.includes('/tcp/'), @@ -1049,10 +1119,25 @@ function selectReady(event: Gate2AgentEvent): Record { peerId: event.peerId, protocolVersion: event.protocolVersion, role: event.role, + runtimeBuildManifestDigest: event.runtimeBuildManifestDigest, startupRepair: event.startupRepair, }; } +function requiredExecutedRuntimeManifest( + event: Gate2AgentEvent, + label: string, +): Gate2ExecutedRuntimeManifestV1 { + if ( + event.executedRuntimeManifest === null + || typeof event.executedRuntimeManifest !== 'object' + || Array.isArray(event.executedRuntimeManifest) + ) { + throw new Error(`${label} omitted its executed runtime manifest`); + } + return event.executedRuntimeManifest as Gate2ExecutedRuntimeManifestV1; +} + function selectExit(exit: ProcessExitEvidence): Record { return { code: exit.code, signal: exit.signal }; } diff --git a/devnet/rfc64-gate2-multi-asset-completeness/runtime-load-hook.ts b/devnet/rfc64-gate2-multi-asset-completeness/runtime-load-hook.ts new file mode 100644 index 0000000000..4815cc375a --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/runtime-load-hook.ts @@ -0,0 +1,56 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, realpathSync } from 'node:fs'; +import { registerHooks } from 'node:module'; +import { relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + buildGate2ExecutedRuntimeManifestV1, + type Gate2ExecutedRuntimeManifestV1, + type Gate2RuntimeFileEvidenceV1, +} from './runtime-provenance.ts'; + +const REPO_ROOT = realpathSync.native(resolve(import.meta.dirname, '../..')); +const sourceCommitInput = process.env.DKG_RFC64_GATE2_RUNTIME_SOURCE_COMMIT; +if (sourceCommitInput === undefined || !/^[0-9a-f]{40,64}$/u.test(sourceCommitInput)) { + throw new Error('DKG_RFC64_GATE2_RUNTIME_SOURCE_COMMIT is required by the runtime load hook'); +} +const SOURCE_COMMIT: string = sourceCommitInput; + +const RUNTIME_PATH = /^packages\/[^/]+\/dist\/.+\.(?:js|json|node|wasm)$/u; +const loaded = new Map(); +let sealed = false; + +registerHooks({ + resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + const url = result.url; + if (!url.startsWith('file:')) return result; + const absolutePath = realpathSync.native(fileURLToPath(url)); + const path = relative(REPO_ROOT, absolutePath).split(sep).join('/'); + if (!RUNTIME_PATH.test(path)) return result; + if (sealed) throw new Error(`workspace runtime artifact resolved after provenance seal: ${path}`); + const bytes = readFileSync(absolutePath); + const entry = Object.freeze({ + byteLength: bytes.byteLength, + path, + sha256: `0x${createHash('sha256').update(bytes).digest('hex')}`, + }); + const previous = loaded.get(path); + if ( + previous !== undefined + && (previous.byteLength !== entry.byteLength || previous.sha256 !== entry.sha256) + ) { + throw new Error(`workspace runtime artifact changed between module resolutions: ${path}`); + } + loaded.set(path, entry); + return result; + }, +}); + +export function sealGate2ExecutedRuntimeManifestV1(): Readonly { + if (sealed) throw new Error('runtime loader manifest was already sealed'); + sealed = true; + if (loaded.size < 1) throw new Error('runtime loader hook observed no workspace dist artifacts'); + return buildGate2ExecutedRuntimeManifestV1(SOURCE_COMMIT, [...loaded.values()]); +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/runtime-provenance.ts b/devnet/rfc64-gate2-multi-asset-completeness/runtime-provenance.ts new file mode 100644 index 0000000000..79b5993fd5 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/runtime-provenance.ts @@ -0,0 +1,381 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { relative, resolve, sep } from 'node:path'; + +import { canonicalize, type CanonicalValue } from './src/canonical.ts'; + +export const GATE2_RUNTIME_MANIFEST_SCHEMA_VERSION = + 'dkg-rfc64-gate2-runtime-manifest-v1' as const; +export const GATE2_RUNTIME_MANIFEST_DIGEST_DOMAIN = + 'dkg-rfc64-gate2-runtime-manifest-v1\n' as const; +export const GATE2_EXECUTED_RUNTIME_MANIFEST_SCHEMA_VERSION = + 'dkg-rfc64-gate2-executed-runtime-manifest-v1' as const; +export const GATE2_EXECUTED_RUNTIME_MANIFEST_DIGEST_DOMAIN = + 'dkg-rfc64-gate2-executed-runtime-manifest-v1\n' as const; +export const GATE2_RUNTIME_PROVENANCE_SCHEMA_VERSION = + 'dkg-rfc64-gate2-runtime-provenance-v1' as const; +export const GATE2_RUNTIME_PROVENANCE_DIGEST_DOMAIN = + 'dkg-rfc64-gate2-runtime-provenance-v1\n' as const; + +export const GATE2_RUNTIME_PACKAGE_CLOSURE = Object.freeze([ + Object.freeze({ name: '@origintrail-official/dkg-agent', path: 'packages/agent/dist' }), + Object.freeze({ name: '@origintrail-official/dkg-chain', path: 'packages/chain/dist' }), + Object.freeze({ name: '@origintrail-official/dkg-core', path: 'packages/core/dist' }), + Object.freeze({ name: '@origintrail-official/dkg-publisher', path: 'packages/publisher/dist' }), + Object.freeze({ name: '@origintrail-official/dkg-query', path: 'packages/query/dist' }), + Object.freeze({ + name: '@origintrail-official/dkg-random-sampling', + path: 'packages/random-sampling/dist', + }), + Object.freeze({ name: '@origintrail-official/dkg-rdf-utils', path: 'packages/rdf-utils/dist' }), + Object.freeze({ name: '@origintrail-official/dkg-storage', path: 'packages/storage/dist' }), +] as const); + +export const GATE2_RUNTIME_CLEAN_ARGS = Object.freeze([ + '-r', + '--filter', + '@origintrail-official/dkg-agent...', + '--filter', + '!@origintrail-official/dkg-evm-module', + 'run', + 'clean', +] as const); + +export const GATE2_RUNTIME_BUILD_ARGS = Object.freeze([ + '-r', + '--filter', + '@origintrail-official/dkg-agent...', + '--filter', + '!@origintrail-official/dkg-evm-module', + 'run', + 'build', +] as const); + +const SOURCE_COMMIT = /^[0-9a-f]{40,64}$/u; +const DIGEST = /^0x[0-9a-f]{64}$/u; +const RUNTIME_FILE = /\.(?:js|json|node|wasm)$/u; +const MAX_RUNTIME_FILES = 4_096; +const MAX_RUNTIME_FILE_BYTES = 64 * 1024 * 1024; +const MAX_RUNTIME_CLOSURE_BYTES = 512 * 1024 * 1024; + +export interface Gate2RuntimeFileEvidenceV1 { + readonly byteLength: number; + readonly path: string; + readonly sha256: string; +} + +export interface Gate2RuntimeManifestV1 { + readonly build: { + readonly buildArgs: readonly string[]; + readonly cleanArgs: readonly string[]; + readonly command: 'pnpm'; + }; + readonly manifestDigest: string; + readonly packageClosure: readonly { + readonly name: string; + readonly path: string; + }[]; + readonly runtimeFiles: readonly Gate2RuntimeFileEvidenceV1[]; + readonly schemaVersion: typeof GATE2_RUNTIME_MANIFEST_SCHEMA_VERSION; + readonly sourceCommit: string; +} + +export interface Gate2RuntimeLaunchReceiptV1 { + readonly manifest: Readonly; + readonly sourceCommit: string; +} + +export interface Gate2ExecutedRuntimeManifestV1 { + readonly manifestDigest: string; + readonly runtimeFiles: readonly Gate2RuntimeFileEvidenceV1[]; + readonly schemaVersion: typeof GATE2_EXECUTED_RUNTIME_MANIFEST_SCHEMA_VERSION; + readonly sourceCommit: string; +} + +export type Gate2RuntimeProcessIdV1 = + | 'author' + | 'receiverBeforeCrash' + | 'receiverAfterRestart'; + +export interface Gate2RuntimeProcessEvidenceV1 { + readonly id: Gate2RuntimeProcessIdV1; + readonly loaded: Gate2ExecutedRuntimeManifestV1; +} + +export interface Gate2RuntimeProvenanceV1 { + readonly processes: readonly Gate2RuntimeProcessEvidenceV1[]; + readonly provenanceDigest: string; + readonly schemaVersion: typeof GATE2_RUNTIME_PROVENANCE_SCHEMA_VERSION; + readonly sourceBuild: Gate2RuntimeManifestV1; +} + +let pendingLaunchReceipt: Readonly | undefined; + +/** Remove ignored outputs and rebuild the complete workspace runtime dependency closure. */ +export function runGate2CleanRuntimeBuildV1(repoRootInput: string): void { + const repoRoot = resolve(repoRootInput); + for (const args of [GATE2_RUNTIME_CLEAN_ARGS, GATE2_RUNTIME_BUILD_ARGS]) { + execFileSync('pnpm', [...args], { + cwd: repoRoot, + env: process.env, + stdio: 'inherit', + }); + } +} + +/** Hash every executable/data artifact in the freshly built workspace package closure. */ +export function buildGate2RuntimeManifestV1( + repoRootInput: string, + sourceCommit: string, +): Readonly { + const repoRoot = resolve(repoRootInput); + const entries: Gate2RuntimeFileEvidenceV1[] = []; + for (const pkg of GATE2_RUNTIME_PACKAGE_CLOSURE) { + collectRuntimeFiles(repoRoot, resolve(repoRoot, pkg.path), entries); + } + return buildGate2RuntimeManifestFromEntriesV1(sourceCommit, entries); +} + +/** Deterministic constructor exposed for adversarial manifest tests. */ +export function buildGate2RuntimeManifestFromEntriesV1( + sourceCommit: string, + inputEntries: readonly Gate2RuntimeFileEvidenceV1[], +): Readonly { + if (!SOURCE_COMMIT.test(sourceCommit)) throw new TypeError('runtime source commit is malformed'); + if (inputEntries.length < 1 || inputEntries.length > MAX_RUNTIME_FILES) { + throw new RangeError('runtime manifest file count is outside the closed bound'); + } + let totalBytes = 0; + const paths = new Set(); + const runtimeFiles = inputEntries.map((input) => { + if ( + typeof input.path !== 'string' + || input.path.length < 1 + || input.path.length > 4_096 + || input.path.startsWith('/') + || input.path.split('/').includes('..') + || !RUNTIME_FILE.test(input.path) + ) { + throw new TypeError('runtime manifest path is not a bounded relative runtime artifact'); + } + if (paths.has(input.path)) throw new TypeError(`duplicate runtime manifest path: ${input.path}`); + paths.add(input.path); + if ( + !Number.isSafeInteger(input.byteLength) + || input.byteLength < 0 + || input.byteLength > MAX_RUNTIME_FILE_BYTES + ) { + throw new RangeError(`runtime artifact byte length is invalid: ${input.path}`); + } + if (!DIGEST.test(input.sha256)) { + throw new TypeError(`runtime artifact digest is invalid: ${input.path}`); + } + totalBytes += input.byteLength; + if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_RUNTIME_CLOSURE_BYTES) { + throw new RangeError('runtime artifact closure exceeds the byte bound'); + } + return Object.freeze({ + byteLength: input.byteLength, + path: input.path, + sha256: input.sha256, + }); + }).sort((left, right) => compareText(left.path, right.path)); + const packageClosure = Object.freeze(GATE2_RUNTIME_PACKAGE_CLOSURE.map((pkg) => + Object.freeze({ name: pkg.name, path: pkg.path }))); + const build = Object.freeze({ + buildArgs: GATE2_RUNTIME_BUILD_ARGS, + cleanArgs: GATE2_RUNTIME_CLEAN_ARGS, + command: 'pnpm' as const, + }); + const payload = Object.freeze({ + build, + packageClosure, + runtimeFiles: Object.freeze(runtimeFiles), + schemaVersion: GATE2_RUNTIME_MANIFEST_SCHEMA_VERSION, + sourceCommit, + }); + const manifestDigest = sha256( + GATE2_RUNTIME_MANIFEST_DIGEST_DOMAIN, + canonicalize(payload as unknown as CanonicalValue), + ); + return Object.freeze({ ...payload, manifestDigest }); +} + +export function assertGate2RuntimeManifestEqualV1( + actual: Gate2RuntimeManifestV1, + expected: Gate2RuntimeManifestV1, +): void { + if ( + canonicalize(actual as unknown as CanonicalValue) + !== canonicalize(expected as unknown as CanonicalValue) + ) { + throw new Error('Gate 2 runtime manifest differs from the clean source build'); + } +} + +/** Deterministic manifest of the exact workspace dist files observed by a child loader hook. */ +export function buildGate2ExecutedRuntimeManifestV1( + sourceCommit: string, + inputEntries: readonly Gate2RuntimeFileEvidenceV1[], +): Readonly { + const validated = buildGate2RuntimeManifestFromEntriesV1(sourceCommit, inputEntries); + const payload = Object.freeze({ + runtimeFiles: validated.runtimeFiles, + schemaVersion: GATE2_EXECUTED_RUNTIME_MANIFEST_SCHEMA_VERSION, + sourceCommit, + }); + return Object.freeze({ + ...payload, + manifestDigest: sha256( + GATE2_EXECUTED_RUNTIME_MANIFEST_DIGEST_DOMAIN, + canonicalize(payload as unknown as CanonicalValue), + ), + }); +} + +/** Fail closed unless every child-observed byte is present in the clean-build snapshot. */ +export function assertGate2ExecutedRuntimeMatchesBuildV1( + executed: Gate2ExecutedRuntimeManifestV1, + cleanBuild: Gate2RuntimeManifestV1, +): void { + const rebuilt = buildGate2ExecutedRuntimeManifestV1( + executed.sourceCommit, + executed.runtimeFiles, + ); + if (canonicalize(rebuilt as unknown as CanonicalValue) + !== canonicalize(executed as unknown as CanonicalValue)) { + throw new Error('Gate 2 executed runtime manifest is not internally canonical'); + } + if (executed.sourceCommit !== cleanBuild.sourceCommit) { + throw new Error('Gate 2 executed runtime manifest names a different source commit'); + } + const expectedByPath = new Map(cleanBuild.runtimeFiles.map((entry) => [entry.path, entry])); + const allowedPrefixes = GATE2_RUNTIME_PACKAGE_CLOSURE.map((entry) => `${entry.path}/`); + for (const entry of executed.runtimeFiles) { + if (!allowedPrefixes.some((prefix) => entry.path.startsWith(prefix))) { + throw new Error(`Gate 2 child loaded an undeclared workspace package: ${entry.path}`); + } + const expected = expectedByPath.get(entry.path); + if ( + expected === undefined + || expected.byteLength !== entry.byteLength + || expected.sha256 !== entry.sha256 + ) { + throw new Error(`Gate 2 child loaded bytes outside the clean-build snapshot: ${entry.path}`); + } + } + for (const mandatory of [ + 'packages/agent/dist/index.js', + 'packages/chain/dist/index.js', + 'packages/core/dist/index.js', + 'packages/storage/dist/index.js', + ]) { + if (!executed.runtimeFiles.some((entry) => entry.path === mandatory)) { + throw new Error(`Gate 2 child did not load mandatory runtime entrypoint: ${mandatory}`); + } + } +} + +export function buildGate2RuntimeProvenanceV1( + sourceBuild: Gate2RuntimeManifestV1, + inputProcesses: readonly Gate2RuntimeProcessEvidenceV1[], +): Readonly { + const expectedIds: readonly Gate2RuntimeProcessIdV1[] = Object.freeze([ + 'author', + 'receiverBeforeCrash', + 'receiverAfterRestart', + ]); + if (inputProcesses.length !== expectedIds.length) { + throw new Error('Gate 2 runtime provenance must contain exactly three process boundaries'); + } + const processes = inputProcesses.map((processEvidence, index) => { + if (processEvidence.id !== expectedIds[index]) { + throw new Error(`Gate 2 runtime process ${index} must be ${expectedIds[index]}`); + } + assertGate2ExecutedRuntimeMatchesBuildV1(processEvidence.loaded, sourceBuild); + return Object.freeze({ id: processEvidence.id, loaded: processEvidence.loaded }); + }); + const payload = Object.freeze({ + processes: Object.freeze(processes), + schemaVersion: GATE2_RUNTIME_PROVENANCE_SCHEMA_VERSION, + sourceBuild, + }); + return Object.freeze({ + ...payload, + provenanceDigest: sha256( + GATE2_RUNTIME_PROVENANCE_DIGEST_DOMAIN, + canonicalize(payload as unknown as CanonicalValue), + ), + }); +} + +export function installGate2RuntimeLaunchReceiptV1( + receipt: Gate2RuntimeLaunchReceiptV1, +): void { + if (pendingLaunchReceipt !== undefined) { + throw new Error('Gate 2 runtime launch receipt is already installed'); + } + if (receipt.manifest.sourceCommit !== receipt.sourceCommit) { + throw new Error('Gate 2 runtime launch receipt does not bind its source commit'); + } + pendingLaunchReceipt = Object.freeze({ + manifest: receipt.manifest, + sourceCommit: receipt.sourceCommit, + }); +} + +export function consumeGate2RuntimeLaunchReceiptV1(): Readonly { + const receipt = pendingLaunchReceipt; + pendingLaunchReceipt = undefined; + if (receipt === undefined) { + throw new Error( + 'Gate 2 live harness requires its clean-build launcher; direct run.ts execution is forbidden', + ); + } + return receipt; +} + +function collectRuntimeFiles( + repoRoot: string, + directory: string, + entries: Gate2RuntimeFileEvidenceV1[], +): void { + const children = readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => compareText(left.name, right.name)); + for (const child of children) { + const absolutePath = resolve(directory, child.name); + if (child.isSymbolicLink()) { + throw new Error(`runtime artifact closure contains a symbolic link: ${absolutePath}`); + } + if (child.isDirectory()) { + collectRuntimeFiles(repoRoot, absolutePath, entries); + continue; + } + if (!child.isFile() || !RUNTIME_FILE.test(child.name)) continue; + if (entries.length >= MAX_RUNTIME_FILES) { + throw new RangeError('runtime artifact closure exceeds the file-count bound'); + } + const bytes = readFileSync(absolutePath); + if (bytes.byteLength > MAX_RUNTIME_FILE_BYTES) { + throw new RangeError(`runtime artifact exceeds the per-file byte bound: ${absolutePath}`); + } + const path = relative(repoRoot, absolutePath).split(sep).join('/'); + if (path.startsWith('../') || path === '..') { + throw new Error(`runtime artifact escaped the repository root: ${absolutePath}`); + } + entries.push(Object.freeze({ + byteLength: bytes.byteLength, + path, + sha256: `0x${createHash('sha256').update(bytes).digest('hex')}`, + })); + } +} + +function sha256(domain: string, payload: string): string { + return `0x${createHash('sha256').update(domain).update(payload).digest('hex')}`; +} + +function compareText(left: string, right: string): -1 | 0 | 1 { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts b/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts index 1c29d7aa6a..4e29a23a7d 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts @@ -13,9 +13,14 @@ import { buildGate2PassVerdict, verifyGate2ArtifactBytes, } from '../live-verifier.ts'; -import { sha256Digest } from '../src/canonical.ts'; +import { canonicalDocument, sha256Digest, type CanonicalValue } from '../src/canonical.ts'; import { generateCompleteFixture } from '../src/generate.ts'; import { computeAppliedInventoryDigest } from '../src/product-digests.ts'; +import { + buildGate2ExecutedRuntimeManifestV1, + buildGate2RuntimeManifestFromEntriesV1, + buildGate2RuntimeProvenanceV1, +} from '../runtime-provenance.ts'; const SOURCE_COMMIT = 'a'.repeat(40); const AUTHOR_PEER = '12D3KooWGate2Author'; @@ -26,6 +31,19 @@ const PROJECTIONS = [ ' "Three" .\n', ]; const KA_PROJECTION_DIGEST_DOMAIN_V1 = 'dkg-ka-projection-v1\n'; +const RUNTIME_FILES = [ + { path: 'packages/agent/dist/index.js', byteLength: 1, sha256: `0x${'1'.repeat(64)}` }, + { path: 'packages/chain/dist/index.js', byteLength: 2, sha256: `0x${'2'.repeat(64)}` }, + { path: 'packages/core/dist/index.js', byteLength: 3, sha256: `0x${'3'.repeat(64)}` }, + { path: 'packages/storage/dist/index.js', byteLength: 4, sha256: `0x${'4'.repeat(64)}` }, +] as const; +const RUNTIME_MANIFEST = buildGate2RuntimeManifestFromEntriesV1(SOURCE_COMMIT, RUNTIME_FILES); +const EXECUTED_RUNTIME = buildGate2ExecutedRuntimeManifestV1(SOURCE_COMMIT, RUNTIME_FILES); +const RUNTIME_PROVENANCE = buildGate2RuntimeProvenanceV1(RUNTIME_MANIFEST, [ + { id: 'author', loaded: EXECUTED_RUNTIME }, + { id: 'receiverBeforeCrash', loaded: EXECUTED_RUNTIME }, + { id: 'receiverAfterRestart', loaded: EXECUTED_RUNTIME }, +]); function sample(): any { const fixture: any = JSON.parse(JSON.stringify(generateCompleteFixture(3))); @@ -73,6 +91,7 @@ function sample(): any { peerId, protocolVersion: GATE2_ADAPTER_PROTOCOL_VERSION, role, + runtimeBuildManifestDigest: RUNTIME_MANIFEST.manifestDigest, startupRepair: null, }); const digest = (byte: string) => `0x${byte.repeat(64)}`; @@ -86,6 +105,7 @@ function sample(): any { requiredProductionOperations: REQUIRED_PRODUCTION_ADAPTER_OPERATIONS, replacementContract: 'real DKGAgent production APIs only; no fixture adapter or synthesized product evidence', + runtimeBuildManifestDigest: RUNTIME_MANIFEST.manifestDigest, }, authorizationNegative: { attemptedCatalogHeadDigest: digest('8'), @@ -147,6 +167,7 @@ function sample(): any { semanticPostRead: semantic, successorServedByPeerId: AUTHOR_PEER, }, + runtimeProvenance: RUNTIME_PROVENANCE, schemaVersion: GATE2_RAW_SCHEMA_VERSION, transitions: [ { @@ -182,19 +203,22 @@ function sample(): any { } function bytes(value: unknown): Buffer { - return Buffer.from(stableJson(JSON.parse(JSON.stringify(value)) as unknown), 'utf8'); + return Buffer.from( + canonicalDocument(JSON.parse(JSON.stringify(value)) as CanonicalValue), + 'utf8', + ); } test('connected artifact verifies and raw/verdict schemas are two-run byte-identical', () => { const firstRaw = bytes(sample()); const secondRaw = bytes(sample()); assert.deepEqual(firstRaw, secondRaw); - const firstVerified = verifyGate2ArtifactBytes(firstRaw, SOURCE_COMMIT); - const secondVerified = verifyGate2ArtifactBytes(secondRaw, SOURCE_COMMIT); + const firstVerified = verifyGate2ArtifactBytes(firstRaw, SOURCE_COMMIT, RUNTIME_MANIFEST); + const secondVerified = verifyGate2ArtifactBytes(secondRaw, SOURCE_COMMIT, RUNTIME_MANIFEST); assert.deepEqual(firstVerified, secondVerified); assert.equal( - stableJson(buildGate2PassVerdict(firstVerified)), - stableJson(buildGate2PassVerdict(secondVerified)), + canonicalDocument(buildGate2PassVerdict(firstVerified) as unknown as CanonicalValue), + canonicalDocument(buildGate2PassVerdict(secondVerified) as unknown as CanonicalValue), ); assert.match(firstVerified.rawArtifactSha256, /^0x[0-9a-f]{64}$/u); }); @@ -221,7 +245,7 @@ for (const [label, mutate] of [ const raw = sample(); mutate(raw); assert.throws( - () => verifyGate2ArtifactBytes(bytes(raw), SOURCE_COMMIT), + () => verifyGate2ArtifactBytes(bytes(raw), SOURCE_COMMIT, RUNTIME_MANIFEST), /Gate 2 evidence verification failed at \$\.inventory/u, ); }); @@ -231,7 +255,64 @@ test('a fixture boundary cannot masquerade as a connected Gate 2 pass', () => { const raw = sample(); raw.adapter.productBoundary = 'not-connected'; assert.throws( - () => verifyGate2ArtifactBytes(bytes(raw), SOURCE_COMMIT), + () => verifyGate2ArtifactBytes(bytes(raw), SOURCE_COMMIT, RUNTIME_MANIFEST), /\$\.adapter\.productBoundary/u, ); }); + +test('only exact RFC 8785 JSON with one trailing LF is accepted', () => { + const raw = sample(); + const exactBytes = bytes(raw); + assert.doesNotThrow(() => verifyGate2ArtifactBytes( + exactBytes, + SOURCE_COMMIT, + RUNTIME_MANIFEST, + )); + const canonical = exactBytes.toString('utf8'); + for (const invalid of [ + Buffer.from(stableJson(JSON.parse(JSON.stringify(raw))), 'utf8'), + Buffer.from(canonical.slice(0, -1), 'utf8'), + Buffer.from(`${canonical}\n`, 'utf8'), + Buffer.from(`${canonical.slice(0, -1)}\r\n`, 'utf8'), + ]) { + assert.throws( + () => verifyGate2ArtifactBytes(invalid, SOURCE_COMMIT, RUNTIME_MANIFEST), + /canonical document/u, + ); + } + const verdict = canonicalDocument( + buildGate2PassVerdict(verifyGate2ArtifactBytes( + exactBytes, + SOURCE_COMMIT, + RUNTIME_MANIFEST, + )) as unknown as CanonicalValue, + ); + assert.equal((verdict.match(/\n/gu) ?? []).length, 1); +}); + +test('conflated per-KA content digests are rejected even with a recomputed inventory digest', () => { + const raw = sample(); + raw.inventory.authored.signedRows[1].contentDigest = + raw.inventory.authored.signedRows[0].contentDigest; + raw.inventory.received.activatedRows[1].contentDigest = + raw.inventory.received.activatedRows[0].contentDigest; + raw.inventory.received.declaredInventoryDigest = computeAppliedInventoryDigest( + raw.inventory.authored.declaredCatalogScopeDigest, + raw.inventory.received.activatedRows, + ); + assert.throws( + () => verifyGate2ArtifactBytes(bytes(raw), SOURCE_COMMIT, RUNTIME_MANIFEST), + /content digests must be distinct per KA/u, + ); +}); + +test('copied semantic projections cannot masquerade as three complete assets', () => { + const raw = sample(); + raw.authorizationNegative.semanticBefore[1].readBack.projectionNQuads = PROJECTIONS[0]; + raw.authorizationNegative.semanticAfter[1].readBack.projectionNQuads = PROJECTIONS[0]; + raw.restartReplay.semanticPostRead[1].readBack.projectionNQuads = PROJECTIONS[0]; + assert.throws( + () => verifyGate2ArtifactBytes(bytes(raw), SOURCE_COMMIT, RUNTIME_MANIFEST), + /projection/u, + ); +}); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/test/runtime-provenance.test.ts b/devnet/rfc64-gate2-multi-asset-completeness/test/runtime-provenance.test.ts new file mode 100644 index 0000000000..08233a2b55 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/test/runtime-provenance.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { test } from 'node:test'; + +import { + assertGate2ExecutedRuntimeMatchesBuildV1, + buildGate2ExecutedRuntimeManifestV1, + buildGate2RuntimeManifestFromEntriesV1, + buildGate2RuntimeProvenanceV1, +} from '../runtime-provenance.ts'; + +const SOURCE_COMMIT = 'b'.repeat(40); +const FILES = [ + { path: 'packages/agent/dist/index.js', byteLength: 1, sha256: `0x${'1'.repeat(64)}` }, + { path: 'packages/chain/dist/index.js', byteLength: 2, sha256: `0x${'2'.repeat(64)}` }, + { path: 'packages/core/dist/index.js', byteLength: 3, sha256: `0x${'3'.repeat(64)}` }, + { path: 'packages/storage/dist/index.js', byteLength: 4, sha256: `0x${'4'.repeat(64)}` }, +] as const; + +test('runtime manifests are deterministic and bind exact loaded bytes', () => { + const build = buildGate2RuntimeManifestFromEntriesV1(SOURCE_COMMIT, FILES); + const reordered = buildGate2RuntimeManifestFromEntriesV1(SOURCE_COMMIT, [...FILES].reverse()); + assert.deepEqual(build, reordered); + const loaded = buildGate2ExecutedRuntimeManifestV1(SOURCE_COMMIT, FILES); + assert.doesNotThrow(() => assertGate2ExecutedRuntimeMatchesBuildV1(loaded, build)); + + const changed = buildGate2ExecutedRuntimeManifestV1(SOURCE_COMMIT, [ + { ...FILES[0], sha256: `0x${'f'.repeat(64)}` }, + ...FILES.slice(1), + ]); + assert.throws( + () => assertGate2ExecutedRuntimeMatchesBuildV1(changed, build), + /outside the clean-build snapshot/u, + ); +}); + +test('runtime provenance rejects missing entrypoints and process substitution', () => { + const build = buildGate2RuntimeManifestFromEntriesV1(SOURCE_COMMIT, FILES); + const incomplete = buildGate2ExecutedRuntimeManifestV1(SOURCE_COMMIT, FILES.slice(1)); + assert.throws( + () => assertGate2ExecutedRuntimeMatchesBuildV1(incomplete, build), + /mandatory runtime entrypoint/u, + ); + const loaded = buildGate2ExecutedRuntimeManifestV1(SOURCE_COMMIT, FILES); + assert.throws( + () => buildGate2RuntimeProvenanceV1(build, [ + { id: 'receiverBeforeCrash', loaded }, + { id: 'author', loaded }, + { id: 'receiverAfterRestart', loaded }, + ]), + /process 0 must be author/u, + ); +}); + +test('direct run.ts execution fails closed without the clean-build launch receipt', () => { + const repoRoot = resolve(import.meta.dirname, '../../..'); + const runPath = resolve(import.meta.dirname, '../run.ts'); + const result = spawnSync(process.execPath, ['--import', 'tsx', runPath], { + cwd: repoRoot, + encoding: 'utf8', + env: process.env, + timeout: 30_000, + }); + assert.notEqual(result.status, 0); + assert.match( + `${result.stdout}${result.stderr}`, + /requires its clean-build launcher; direct run\.ts execution is forbidden/u, + ); +}); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts b/devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts index fb06e3901c..3cff8bb5b3 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts @@ -3,13 +3,15 @@ import { join, resolve } from 'node:path'; import process from 'node:process'; import { - atomicWriteStableJson, + atomicWriteExactBytes, readCleanRepositoryHead, } from '../rfc64-persistence-lifecycle/evidence.js'; import { buildGate2PassVerdict, verifyGate2ArtifactBytes, } from './live-verifier.js'; +import { canonicalDocument, type CanonicalValue } from './src/canonical.ts'; +import { buildGate2RuntimeManifestV1 } from './runtime-provenance.ts'; const REPO_ROOT = resolve(import.meta.dirname, '../..'); const artifactPath = process.env.DKG_RFC64_GATE2_ARTIFACT @@ -17,8 +19,18 @@ const artifactPath = process.env.DKG_RFC64_GATE2_ARTIFACT const verdictPath = process.env.DKG_RFC64_GATE2_VERDICT_ARTIFACT ?? join(import.meta.dirname, 'artifacts/gate2-verdict.json'); const expectedHead = readCleanRepositoryHead(REPO_ROOT); -const verified = verifyGate2ArtifactBytes(readFileSync(artifactPath), expectedHead); -const publication = atomicWriteStableJson(verdictPath, buildGate2PassVerdict(verified)); +const expectedRuntimeManifest = buildGate2RuntimeManifestV1(REPO_ROOT, expectedHead); +const verified = verifyGate2ArtifactBytes( + readFileSync(artifactPath), + expectedHead, + expectedRuntimeManifest, +); +const publication = atomicWriteExactBytes( + verdictPath, + new TextEncoder().encode( + canonicalDocument(buildGate2PassVerdict(verified) as unknown as CanonicalValue), + ), +); process.stdout.write( `[rfc64-gate2-harness] PASS verdict=${verdictPath} sha256=${publication.sha256}\n`, ); diff --git a/devnet/rfc64-persistence-lifecycle/evidence.ts b/devnet/rfc64-persistence-lifecycle/evidence.ts index 700ee634d4..74d513f463 100644 --- a/devnet/rfc64-persistence-lifecycle/evidence.ts +++ b/devnet/rfc64-persistence-lifecycle/evidence.ts @@ -65,6 +65,17 @@ export function stableJson(value: unknown): string { export function atomicWriteStableJson( artifactPathInput: string, value: unknown, +): AtomicArtifactWriteResult { + return atomicWriteExactBytes( + artifactPathInput, + Buffer.from(stableJson(value), 'utf8'), + ); +} + +/** Atomically publish caller-canonicalized artifact bytes without re-encoding them. */ +export function atomicWriteExactBytes( + artifactPathInput: string, + bytesInput: Uint8Array, ): AtomicArtifactWriteResult { const artifactPath = resolve(artifactPathInput); const parentPath = dirname(artifactPath); @@ -72,7 +83,7 @@ export function atomicWriteStableJson( const parentIdentity = inspectDirectory(parentPath, 'artifact parent directory'); assertReplaceableArtifactTarget(artifactPath); - const bytes = Buffer.from(stableJson(value), 'utf8'); + const bytes = Buffer.from(bytesInput); const intendedSha256 = sha256(bytes); const tempPath = resolve( parentPath, diff --git a/package.json b/package.json index b3354bcb3a..7f337d4aad 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,9 @@ "test:gate1:rfc64-public-open-harness:unit": "node --import tsx --test devnet/rfc64-gate1-public-open/agent-child.test.ts devnet/rfc64-gate1-public-open/model.test.ts devnet/rfc64-gate1-public-open/product-capabilities.test.ts devnet/rfc64-gate1-public-open/verifier.test.ts", "typecheck:gate1:rfc64-public-open-harness": "tsc --project devnet/rfc64-gate1-public-open/tsconfig.json", "test:gate2:rfc64-multi-asset-harness": "pnpm run test:gate2:rfc64-multi-asset-harness:generate && pnpm run test:gate2:rfc64-multi-asset-harness:verify", - "test:gate2:rfc64-multi-asset-harness:generate": "node --import tsx devnet/rfc64-gate2-multi-asset-completeness/run.ts", + "test:gate2:rfc64-multi-asset-harness:generate": "node --import tsx devnet/rfc64-gate2-multi-asset-completeness/launch-live.ts", "test:gate2:rfc64-multi-asset-harness:verify": "node --import tsx devnet/rfc64-gate2-multi-asset-completeness/verify-live.ts", - "test:gate2:rfc64-multi-asset-harness:unit": "node --import tsx --test devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts", + "test:gate2:rfc64-multi-asset-harness:unit": "node --import tsx --test devnet/rfc64-gate2-multi-asset-completeness/test/completeness.test.ts devnet/rfc64-gate2-multi-asset-completeness/test/live-verifier.test.ts devnet/rfc64-gate2-multi-asset-completeness/test/runtime-provenance.test.ts", "typecheck:gate2:rfc64-multi-asset-harness": "tsc --project devnet/rfc64-gate2-multi-asset-completeness/tsconfig.json", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", From d8cad4ea06a5c1526b0d2a0055087472fe5e7e72 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:07:28 +0200 Subject: [PATCH 163/292] feat(agent): classify RFC-64 policy cells --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/index.ts | 1 + packages/agent/src/rfc64/policy-cell-v1.ts | 121 ++++++++++++++++++ .../agent/test/rfc64-policy-cell-v1.test.ts | 121 ++++++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 6 files changed, 246 insertions(+) create mode 100644 packages/agent/src/rfc64/policy-cell-v1.ts create mode 100644 packages/agent/test/rfc64-policy-cell-v1.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index c9a5447a31..88ef99fbe3 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -29,6 +29,7 @@ "./dist/rfc64/persistence-layout-v1.js": null, "./dist/rfc64/persistence-root-ownership-v1-internal.js": null, "./dist/rfc64/persistence-v1.js": null, + "./dist/rfc64/policy-cell-v1.js": null, "./dist/rfc64/public-catalog-inventory-completeness-v1.js": null, "./dist/rfc64/public-catalog-native-receiver-v1.js": null, "./dist/rfc64/public-catalog-native-reconciler-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 596fb5bbc3..4fb440017e 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -57,6 +57,7 @@ const blockedRfc64Modules = [ 'persistence-layout-v1.js', 'persistence-root-ownership-v1-internal.js', 'persistence-v1.js', + 'policy-cell-v1.js', 'public-catalog-inventory-completeness-v1.js', 'public-catalog-native-reconciler-v1.js', 'public-catalog-native-receiver-v1.js', diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index a38d6bf0fc..9bf1035b5c 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -50,6 +50,7 @@ export { } from './rfc64/public-catalog-inventory-completeness-v1.js'; export * from './rfc64/public-catalog-successor-producer-v1.js'; export * from './rfc64/public-catalog-native-reconciler-v1.js'; +export * from './rfc64/policy-cell-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; export { MessageHandler, type SkillRequest, type SkillResponse, type SkillHandler, type ChatHandler, type ChatAclCheck } from './messaging.js'; export { diff --git a/packages/agent/src/rfc64/policy-cell-v1.ts b/packages/agent/src/rfc64/policy-cell-v1.ts new file mode 100644 index 0000000000..f16d9b820e --- /dev/null +++ b/packages/agent/src/rfc64/policy-cell-v1.ts @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Pure OT-RFC-64 D26 policy-cell classification. + * + * This helper deliberately does not authorize a principal. It only converts one + * already-canonical ContextGraphPolicyV1 into the closed operation modes that + * policy-aware transports, reconcilers, and evidence collectors must implement. + * Signatures, policy history/finality, roster freshness, peer-to-agent binding, + * VM inclusion-time authority, and ingress capabilities remain separate proofs. + */ + +import { + assertContextGraphPolicyV1, + canonicalizeContextGraphPolicyPayloadV1, + parseCanonicalContextGraphPolicyPayloadV1, + type ContextGraphPolicyV1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +export const RFC64_POLICY_CELLS_V1 = Object.freeze([ + 'public-open', + 'public-curated', + 'private-open', + 'private-curated', +] as const); + +export type Rfc64PolicyCellV1 = (typeof RFC64_POLICY_CELLS_V1)[number]; + +export type Rfc64VmPublisherAuthorizationModeV1 = + | 'any-wallet' + | 'direct-eoa-or-safe' + | 'pca-owner-or-registered-agent'; + +export interface Rfc64PolicyCellDescriptorV1 { + readonly cell: Rfc64PolicyCellV1; + readonly sharing: 'open' | 'invite-only'; + readonly contribution: 'open' | 'curated'; + /** Open sharing forbids an exhaustive member roster; invite-only requires one. */ + readonly rosterMode: 'forbidden' | 'required'; + readonly catalogDisclosure: 'open-authenticated' | 'current-member-only'; + readonly swmSubmission: 'open-authenticated' | 'current-member-only'; + readonly ordinaryPayloadFetch: 'open-authenticated' | 'current-member-only'; + readonly providerEligibility: + | 'authenticated-exact-bundle-holder' + | 'current-member-with-provider-role'; + readonly vmPublisherAuthorization: Rfc64VmPublisherAuthorizationModeV1; + /** Null for open contribution; exact policy value for curated contribution. */ + readonly publishAuthority: EvmAddressV1 | null; + readonly publishAuthorityAccountId: ContextGraphPolicyV1['publishAuthorityAccountId']; + /** Unregistered policy has no VM expected set; a registered policy follows chain ordinals. */ + readonly vmExpectedSet: 'none-unregistered' | 'finalized-chain-ordinals'; + /** + * The exceptional nonmember upload path. Inclusion-time policy still decides + * eligibility, so current curated policy cannot reject a historical ordinal + * that was finalized while contribution was open. + */ + readonly writeOnlyVmIngress: + | 'not-applicable-open-sharing' + | 'not-applicable-unregistered' + | 'finalized-open-inclusion-only' + | 'historical-open-inclusion-only'; +} + +/** + * Snapshot and classify both D26 axes without inferring either from the other. + * The canonical round trip owns the snapshot before any field is consulted. + */ +export function classifyRfc64PolicyCellV1( + policyInput: ContextGraphPolicyV1, +): Readonly { + assertContextGraphPolicyV1(policyInput); + const policy = parseCanonicalContextGraphPolicyPayloadV1( + canonicalizeContextGraphPolicyPayloadV1(policyInput), + ); + + const openSharing = policy.accessPolicy === 0; + const openContribution = policy.publishPolicy === 1; + const registered = policy.source.kind === 'finalized-chain'; + const vmPublisherAuthorization: Rfc64VmPublisherAuthorizationModeV1 = + openContribution + ? 'any-wallet' + : policy.publishAuthorityAccountId === '0' + ? 'direct-eoa-or-safe' + : 'pca-owner-or-registered-agent'; + + const writeOnlyVmIngress: Rfc64PolicyCellDescriptorV1['writeOnlyVmIngress'] = + openSharing + ? 'not-applicable-open-sharing' + : !registered + ? 'not-applicable-unregistered' + : openContribution + ? 'finalized-open-inclusion-only' + : 'historical-open-inclusion-only'; + + return Object.freeze({ + cell: policyCell(policy.accessPolicy, policy.publishPolicy), + sharing: openSharing ? 'open' : 'invite-only', + contribution: openContribution ? 'open' : 'curated', + rosterMode: openSharing ? 'forbidden' : 'required', + catalogDisclosure: openSharing ? 'open-authenticated' : 'current-member-only', + swmSubmission: openSharing ? 'open-authenticated' : 'current-member-only', + ordinaryPayloadFetch: openSharing ? 'open-authenticated' : 'current-member-only', + providerEligibility: openSharing + ? 'authenticated-exact-bundle-holder' + : 'current-member-with-provider-role', + vmPublisherAuthorization, + publishAuthority: policy.publishAuthority, + publishAuthorityAccountId: policy.publishAuthorityAccountId, + vmExpectedSet: registered ? 'finalized-chain-ordinals' : 'none-unregistered', + writeOnlyVmIngress, + }); +} + +function policyCell( + accessPolicy: ContextGraphPolicyV1['accessPolicy'], + publishPolicy: ContextGraphPolicyV1['publishPolicy'], +): Rfc64PolicyCellV1 { + if (accessPolicy === 0) return publishPolicy === 1 ? 'public-open' : 'public-curated'; + return publishPolicy === 1 ? 'private-open' : 'private-curated'; +} diff --git a/packages/agent/test/rfc64-policy-cell-v1.test.ts b/packages/agent/test/rfc64-policy-cell-v1.test.ts new file mode 100644 index 0000000000..d0a76494b9 --- /dev/null +++ b/packages/agent/test/rfc64-policy-cell-v1.test.ts @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import type { ContextGraphPolicyV1 } from '@origintrail-official/dkg-core'; + +import { + RFC64_POLICY_CELLS_V1, + classifyRfc64PolicyCellV1, +} from '../src/rfc64/policy-cell-v1.js'; + +const OWNER = '0x1111111111111111111111111111111111111111' as const; +const CURATOR = '0x2222222222222222222222222222222222222222' as const; +const GOVERNANCE = '0x3333333333333333333333333333333333333333' as const; + +function policy( + accessPolicy: 0 | 1, + publishPolicy: 0 | 1, + options: { registered?: boolean; pca?: boolean } = {}, +): ContextGraphPolicyV1 { + const registered = options.registered ?? true; + return { + networkId: 'otp:20430', + contextGraphId: '0x1111111111111111111111111111111111111111/policy-cell', + governanceChainId: registered ? '20430' : null, + governanceContractAddress: registered ? GOVERNANCE : null, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy, + publishPolicy, + publishAuthority: publishPolicy === 0 ? CURATOR : null, + publishAuthorityAccountId: publishPolicy === 0 && options.pca ? '7' : '0', + projectionId: 'cg-shared-v1', + administrativeDelegationDigest: null, + source: registered + ? { + kind: 'finalized-chain', + chainId: '20430', + contractAddress: GOVERNANCE, + blockNumber: '42', + blockHash: `0x${'44'.repeat(32)}`, + } + : { + kind: 'owner-signed-unregistered', + ownerAddress: OWNER, + ownerAuthorityEra: '0', + }, + effectiveAt: '1700000000000', + issuedAt: '1700000000000', + }; +} + +describe('RFC-64 D26 policy cell classification', () => { + it('keeps the four sharing/contribution cells closed and orthogonal', () => { + expect(RFC64_POLICY_CELLS_V1).toEqual([ + 'public-open', + 'public-curated', + 'private-open', + 'private-curated', + ]); + const cells = [ + classifyRfc64PolicyCellV1(policy(0, 1)), + classifyRfc64PolicyCellV1(policy(0, 0)), + classifyRfc64PolicyCellV1(policy(1, 1)), + classifyRfc64PolicyCellV1(policy(1, 0)), + ]; + expect(cells.map((cell) => cell.cell)).toEqual(RFC64_POLICY_CELLS_V1); + expect(cells.map((cell) => cell.rosterMode)).toEqual([ + 'forbidden', + 'forbidden', + 'required', + 'required', + ]); + expect(cells.map((cell) => cell.vmPublisherAuthorization)).toEqual([ + 'any-wallet', + 'direct-eoa-or-safe', + 'any-wallet', + 'direct-eoa-or-safe', + ]); + expect(cells.map((cell) => cell.catalogDisclosure)).toEqual([ + 'open-authenticated', + 'open-authenticated', + 'current-member-only', + 'current-member-only', + ]); + }); + + it('classifies direct and PCA curator domains without granting authority', () => { + expect(classifyRfc64PolicyCellV1(policy(0, 0)).vmPublisherAuthorization) + .toBe('direct-eoa-or-safe'); + expect(classifyRfc64PolicyCellV1(policy(0, 0, { pca: true })).vmPublisherAuthorization) + .toBe('pca-owner-or-registered-agent'); + }); + + it('makes write-only ingress inclusion-time and registration aware', () => { + expect(classifyRfc64PolicyCellV1(policy(1, 1)).writeOnlyVmIngress) + .toBe('finalized-open-inclusion-only'); + expect(classifyRfc64PolicyCellV1(policy(1, 0)).writeOnlyVmIngress) + .toBe('historical-open-inclusion-only'); + expect(classifyRfc64PolicyCellV1(policy(1, 1, { registered: false })).writeOnlyVmIngress) + .toBe('not-applicable-unregistered'); + expect(classifyRfc64PolicyCellV1(policy(0, 1)).writeOnlyVmIngress) + .toBe('not-applicable-open-sharing'); + }); + + it('returns a detached immutable descriptor', () => { + const source = policy(1, 0, { pca: true }); + const descriptor = classifyRfc64PolicyCellV1(source); + (source as { accessPolicy: 0 | 1 }).accessPolicy = 0; + expect(descriptor.cell).toBe('private-curated'); + expect(Object.isFrozen(descriptor)).toBe(true); + }); + + it('rejects malformed policy input instead of inferring a permissive cell', () => { + expect(() => classifyRfc64PolicyCellV1({ + ...policy(1, 0), + publishAuthority: null, + })).toThrow(/cg-policy-publish-domain/); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 7906d19759..6bea721429 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -134,6 +134,7 @@ export default defineConfig({ "test/rfc64-persistent-catalog-provider-v1.test.ts", "test/rfc64-public-catalog-successor-producer-v1.test.ts", "test/rfc64-dkg-agent-successor-publication.integration.test.ts", + "test/rfc64-policy-cell-v1.test.ts", ], testTimeout: 60_000, maxWorkers: 1, From 0470e6b7d5055b9c292d0a4e324a33095d8dd1ba Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:04:01 +0200 Subject: [PATCH 164/292] fix(agent): close RFC-64 policy cell contract --- packages/agent/scripts/test-package-root.mjs | 17 +++ packages/agent/src/rfc64/policy-cell-v1.ts | 127 ++++++++++++----- .../agent/test/rfc64-policy-cell-v1.test.ts | 131 ++++++++++++++---- 3 files changed, 213 insertions(+), 62 deletions(-) diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 4fb440017e..621ae2f33a 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -7,15 +7,32 @@ const root = await import('@origintrail-official/dkg-agent'); const legacyAgent = await import('@origintrail-official/dkg-agent/dist/dkg-agent.js'); const require = createRequire(import.meta.url); const packageManifest = require('@origintrail-official/dkg-agent/package.json'); +const expectedRfc64PolicyCells = [ + 'public-open', + 'public-curated', + 'private-open', + 'private-curated', +]; if ( typeof root.DKGAgent !== 'function' || typeof legacyAgent.DKGAgent !== 'function' || typeof root.Rfc64PublicCatalogSuccessorProducerV1 !== 'function' || typeof root.computeRfc64AppliedInventoryDigestV1 !== 'function' + || typeof root.classifyRfc64PolicyCellV1 !== 'function' ) { throw new Error('published agent entry points did not expose required root APIs'); } +if ( + !Array.isArray(root.RFC64_POLICY_CELLS_V1) + || !Object.isFrozen(root.RFC64_POLICY_CELLS_V1) + || root.RFC64_POLICY_CELLS_V1.length !== expectedRfc64PolicyCells.length + || root.RFC64_POLICY_CELLS_V1.some( + (cell, index) => cell !== expectedRfc64PolicyCells[index] + ) +) { + throw new Error('package root did not expose the closed RFC-64 policy-cell list'); +} if (packageManifest.name !== '@origintrail-official/dkg-agent') { throw new Error('historical package.json subpath no longer resolves'); } diff --git a/packages/agent/src/rfc64/policy-cell-v1.ts b/packages/agent/src/rfc64/policy-cell-v1.ts index f16d9b820e..eaf61a9a41 100644 --- a/packages/agent/src/rfc64/policy-cell-v1.ts +++ b/packages/agent/src/rfc64/policy-cell-v1.ts @@ -18,22 +18,12 @@ import { type EvmAddressV1, } from '@origintrail-official/dkg-core'; -export const RFC64_POLICY_CELLS_V1 = Object.freeze([ - 'public-open', - 'public-curated', - 'private-open', - 'private-curated', -] as const); - -export type Rfc64PolicyCellV1 = (typeof RFC64_POLICY_CELLS_V1)[number]; - export type Rfc64VmPublisherAuthorizationModeV1 = | 'any-wallet' | 'direct-eoa-or-safe' | 'pca-owner-or-registered-agent'; -export interface Rfc64PolicyCellDescriptorV1 { - readonly cell: Rfc64PolicyCellV1; +interface Rfc64PolicyCellStaticDescriptorV1 { readonly sharing: 'open' | 'invite-only'; readonly contribution: 'open' | 'curated'; /** Open sharing forbids an exhaustive member roster; invite-only requires one. */ @@ -44,6 +34,78 @@ export interface Rfc64PolicyCellDescriptorV1 { readonly providerEligibility: | 'authenticated-exact-bundle-holder' | 'current-member-with-provider-role'; +} + +interface Rfc64PolicyCellTableEntryV1 { + readonly accessPolicy: ContextGraphPolicyV1['accessPolicy']; + readonly publishPolicy: ContextGraphPolicyV1['publishPolicy']; + readonly descriptor: Readonly; +} + +function definePolicyCellV1( + accessPolicy: ContextGraphPolicyV1['accessPolicy'], + publishPolicy: ContextGraphPolicyV1['publishPolicy'], + descriptor: Rfc64PolicyCellStaticDescriptorV1, +): Readonly { + return Object.freeze({ + accessPolicy, + publishPolicy, + descriptor: Object.freeze({ ...descriptor }), + }); +} + +/** One closed source of truth for both D26 axes and every cell-static mode. */ +const RFC64_POLICY_CELL_TABLE_V1 = Object.freeze({ + 'public-open': definePolicyCellV1(0, 1, { + sharing: 'open', + contribution: 'open', + rosterMode: 'forbidden', + catalogDisclosure: 'open-authenticated', + swmSubmission: 'open-authenticated', + ordinaryPayloadFetch: 'open-authenticated', + providerEligibility: 'authenticated-exact-bundle-holder', + }), + 'public-curated': definePolicyCellV1(0, 0, { + sharing: 'open', + contribution: 'curated', + rosterMode: 'forbidden', + catalogDisclosure: 'open-authenticated', + swmSubmission: 'open-authenticated', + ordinaryPayloadFetch: 'open-authenticated', + providerEligibility: 'authenticated-exact-bundle-holder', + }), + 'private-open': definePolicyCellV1(1, 1, { + sharing: 'invite-only', + contribution: 'open', + rosterMode: 'required', + catalogDisclosure: 'current-member-only', + swmSubmission: 'current-member-only', + ordinaryPayloadFetch: 'current-member-only', + providerEligibility: 'current-member-with-provider-role', + }), + 'private-curated': definePolicyCellV1(1, 0, { + sharing: 'invite-only', + contribution: 'curated', + rosterMode: 'required', + catalogDisclosure: 'current-member-only', + swmSubmission: 'current-member-only', + ordinaryPayloadFetch: 'current-member-only', + providerEligibility: 'current-member-with-provider-role', + }), +} satisfies Record); + +export type Rfc64PolicyCellV1 = keyof typeof RFC64_POLICY_CELL_TABLE_V1; + +export const RFC64_POLICY_CELLS_V1: readonly Rfc64PolicyCellV1[] = Object.freeze( + Object.keys(RFC64_POLICY_CELL_TABLE_V1) as Rfc64PolicyCellV1[], +); + +type Rfc64PolicyAxesKeyV1 = `${ContextGraphPolicyV1['accessPolicy']}:${ContextGraphPolicyV1['publishPolicy']}`; + +const RFC64_POLICY_CELL_BY_AXES_V1 = buildPolicyCellLookupV1(); + +export interface Rfc64PolicyCellDescriptorV1 extends Rfc64PolicyCellStaticDescriptorV1 { + readonly cell: Rfc64PolicyCellV1; readonly vmPublisherAuthorization: Rfc64VmPublisherAuthorizationModeV1; /** Null for open contribution; exact policy value for curated contribution. */ readonly publishAuthority: EvmAddressV1 | null; @@ -74,36 +136,30 @@ export function classifyRfc64PolicyCellV1( canonicalizeContextGraphPolicyPayloadV1(policyInput), ); - const openSharing = policy.accessPolicy === 0; - const openContribution = policy.publishPolicy === 1; + const cell = RFC64_POLICY_CELL_BY_AXES_V1[ + `${policy.accessPolicy}:${policy.publishPolicy}` as Rfc64PolicyAxesKeyV1 + ]; + const cellDescriptor = RFC64_POLICY_CELL_TABLE_V1[cell].descriptor; const registered = policy.source.kind === 'finalized-chain'; const vmPublisherAuthorization: Rfc64VmPublisherAuthorizationModeV1 = - openContribution + cellDescriptor.contribution === 'open' ? 'any-wallet' : policy.publishAuthorityAccountId === '0' ? 'direct-eoa-or-safe' : 'pca-owner-or-registered-agent'; const writeOnlyVmIngress: Rfc64PolicyCellDescriptorV1['writeOnlyVmIngress'] = - openSharing + cellDescriptor.sharing === 'open' ? 'not-applicable-open-sharing' : !registered ? 'not-applicable-unregistered' - : openContribution + : cellDescriptor.contribution === 'open' ? 'finalized-open-inclusion-only' : 'historical-open-inclusion-only'; return Object.freeze({ - cell: policyCell(policy.accessPolicy, policy.publishPolicy), - sharing: openSharing ? 'open' : 'invite-only', - contribution: openContribution ? 'open' : 'curated', - rosterMode: openSharing ? 'forbidden' : 'required', - catalogDisclosure: openSharing ? 'open-authenticated' : 'current-member-only', - swmSubmission: openSharing ? 'open-authenticated' : 'current-member-only', - ordinaryPayloadFetch: openSharing ? 'open-authenticated' : 'current-member-only', - providerEligibility: openSharing - ? 'authenticated-exact-bundle-holder' - : 'current-member-with-provider-role', + cell, + ...cellDescriptor, vmPublisherAuthorization, publishAuthority: policy.publishAuthority, publishAuthorityAccountId: policy.publishAuthorityAccountId, @@ -112,10 +168,17 @@ export function classifyRfc64PolicyCellV1( }); } -function policyCell( - accessPolicy: ContextGraphPolicyV1['accessPolicy'], - publishPolicy: ContextGraphPolicyV1['publishPolicy'], -): Rfc64PolicyCellV1 { - if (accessPolicy === 0) return publishPolicy === 1 ? 'public-open' : 'public-curated'; - return publishPolicy === 1 ? 'private-open' : 'private-curated'; +function buildPolicyCellLookupV1(): Readonly> { + const lookup = Object.create(null) as Record; + for (const cell of RFC64_POLICY_CELLS_V1) { + const entry = RFC64_POLICY_CELL_TABLE_V1[cell]; + const key = `${entry.accessPolicy}:${entry.publishPolicy}`; + if (Object.hasOwn(lookup, key)) { + throw new Error(`duplicate RFC-64 policy axes in canonical table: ${key}`); + } + lookup[key] = cell; + } + return Object.freeze(lookup) as Readonly< + Record + >; } diff --git a/packages/agent/test/rfc64-policy-cell-v1.test.ts b/packages/agent/test/rfc64-policy-cell-v1.test.ts index d0a76494b9..8673ccf9d6 100644 --- a/packages/agent/test/rfc64-policy-cell-v1.test.ts +++ b/packages/agent/test/rfc64-policy-cell-v1.test.ts @@ -65,43 +65,114 @@ describe('RFC-64 D26 policy cell classification', () => { classifyRfc64PolicyCellV1(policy(1, 1)), classifyRfc64PolicyCellV1(policy(1, 0)), ]; - expect(cells.map((cell) => cell.cell)).toEqual(RFC64_POLICY_CELLS_V1); - expect(cells.map((cell) => cell.rosterMode)).toEqual([ - 'forbidden', - 'forbidden', - 'required', - 'required', - ]); - expect(cells.map((cell) => cell.vmPublisherAuthorization)).toEqual([ - 'any-wallet', - 'direct-eoa-or-safe', - 'any-wallet', - 'direct-eoa-or-safe', - ]); - expect(cells.map((cell) => cell.catalogDisclosure)).toEqual([ - 'open-authenticated', - 'open-authenticated', - 'current-member-only', - 'current-member-only', + expect(cells).toEqual([ + { + cell: 'public-open', + sharing: 'open', + contribution: 'open', + rosterMode: 'forbidden', + catalogDisclosure: 'open-authenticated', + swmSubmission: 'open-authenticated', + ordinaryPayloadFetch: 'open-authenticated', + providerEligibility: 'authenticated-exact-bundle-holder', + vmPublisherAuthorization: 'any-wallet', + publishAuthority: null, + publishAuthorityAccountId: '0', + vmExpectedSet: 'finalized-chain-ordinals', + writeOnlyVmIngress: 'not-applicable-open-sharing', + }, + { + cell: 'public-curated', + sharing: 'open', + contribution: 'curated', + rosterMode: 'forbidden', + catalogDisclosure: 'open-authenticated', + swmSubmission: 'open-authenticated', + ordinaryPayloadFetch: 'open-authenticated', + providerEligibility: 'authenticated-exact-bundle-holder', + vmPublisherAuthorization: 'direct-eoa-or-safe', + publishAuthority: CURATOR, + publishAuthorityAccountId: '0', + vmExpectedSet: 'finalized-chain-ordinals', + writeOnlyVmIngress: 'not-applicable-open-sharing', + }, + { + cell: 'private-open', + sharing: 'invite-only', + contribution: 'open', + rosterMode: 'required', + catalogDisclosure: 'current-member-only', + swmSubmission: 'current-member-only', + ordinaryPayloadFetch: 'current-member-only', + providerEligibility: 'current-member-with-provider-role', + vmPublisherAuthorization: 'any-wallet', + publishAuthority: null, + publishAuthorityAccountId: '0', + vmExpectedSet: 'finalized-chain-ordinals', + writeOnlyVmIngress: 'finalized-open-inclusion-only', + }, + { + cell: 'private-curated', + sharing: 'invite-only', + contribution: 'curated', + rosterMode: 'required', + catalogDisclosure: 'current-member-only', + swmSubmission: 'current-member-only', + ordinaryPayloadFetch: 'current-member-only', + providerEligibility: 'current-member-with-provider-role', + vmPublisherAuthorization: 'direct-eoa-or-safe', + publishAuthority: CURATOR, + publishAuthorityAccountId: '0', + vmExpectedSet: 'finalized-chain-ordinals', + writeOnlyVmIngress: 'historical-open-inclusion-only', + }, ]); + expect(Object.isFrozen(RFC64_POLICY_CELLS_V1)).toBe(true); + expect(cells.every(Object.isFrozen)).toBe(true); }); it('classifies direct and PCA curator domains without granting authority', () => { - expect(classifyRfc64PolicyCellV1(policy(0, 0)).vmPublisherAuthorization) - .toBe('direct-eoa-or-safe'); - expect(classifyRfc64PolicyCellV1(policy(0, 0, { pca: true })).vmPublisherAuthorization) - .toBe('pca-owner-or-registered-agent'); + expect(classifyRfc64PolicyCellV1(policy(1, 0, { pca: true }))).toEqual({ + cell: 'private-curated', + sharing: 'invite-only', + contribution: 'curated', + rosterMode: 'required', + catalogDisclosure: 'current-member-only', + swmSubmission: 'current-member-only', + ordinaryPayloadFetch: 'current-member-only', + providerEligibility: 'current-member-with-provider-role', + vmPublisherAuthorization: 'pca-owner-or-registered-agent', + publishAuthority: CURATOR, + publishAuthorityAccountId: '7', + vmExpectedSet: 'finalized-chain-ordinals', + writeOnlyVmIngress: 'historical-open-inclusion-only', + }); }); it('makes write-only ingress inclusion-time and registration aware', () => { - expect(classifyRfc64PolicyCellV1(policy(1, 1)).writeOnlyVmIngress) - .toBe('finalized-open-inclusion-only'); - expect(classifyRfc64PolicyCellV1(policy(1, 0)).writeOnlyVmIngress) - .toBe('historical-open-inclusion-only'); - expect(classifyRfc64PolicyCellV1(policy(1, 1, { registered: false })).writeOnlyVmIngress) - .toBe('not-applicable-unregistered'); - expect(classifyRfc64PolicyCellV1(policy(0, 1)).writeOnlyVmIngress) - .toBe('not-applicable-open-sharing'); + expect(classifyRfc64PolicyCellV1(policy(1, 0, { + registered: false, + pca: true, + }))).toEqual({ + cell: 'private-curated', + sharing: 'invite-only', + contribution: 'curated', + rosterMode: 'required', + catalogDisclosure: 'current-member-only', + swmSubmission: 'current-member-only', + ordinaryPayloadFetch: 'current-member-only', + providerEligibility: 'current-member-with-provider-role', + vmPublisherAuthorization: 'pca-owner-or-registered-agent', + publishAuthority: CURATOR, + publishAuthorityAccountId: '7', + vmExpectedSet: 'none-unregistered', + writeOnlyVmIngress: 'not-applicable-unregistered', + }); + expect(classifyRfc64PolicyCellV1(policy(0, 1, { registered: false }))) + .toMatchObject({ + vmExpectedSet: 'none-unregistered', + writeOnlyVmIngress: 'not-applicable-open-sharing', + }); }); it('returns a detached immutable descriptor', () => { From d2fd9031b2958ff3d6ec61d0f6352269f7eec761 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 23:10:43 +0200 Subject: [PATCH 165/292] feat(agent): authorize RFC-64 catalog policy cells --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + .../src/rfc64/catalog-access-policy-v1.ts | 330 ++++++++++++++++++ .../rfc64-catalog-access-policy-v1.test.ts | 278 +++++++++++++++ packages/agent/vitest.unit.config.ts | 1 + 5 files changed, 611 insertions(+) create mode 100644 packages/agent/src/rfc64/catalog-access-policy-v1.ts create mode 100644 packages/agent/test/rfc64-catalog-access-policy-v1.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 88ef99fbe3..548ea609ee 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -22,6 +22,7 @@ "./dist/rfc64/inventory-v1/statements.js": "./dist/rfc64/inventory-v1/statements.js", "./dist/rfc64/control-object-store-v1-internal.js": null, "./dist/rfc64/control-object-store-v1.js": null, + "./dist/rfc64/catalog-access-policy-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, "./dist/rfc64/ka-bundle-store-v1-internal.js": null, "./dist/rfc64/ka-bundle-store-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 621ae2f33a..4c4685a7af 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -65,6 +65,7 @@ const publicRfc64Modules = [ 'inventory-v1/statements.js', ]; const blockedRfc64Modules = [ + 'catalog-access-policy-v1.js', 'control-object-store-v1-internal.js', 'control-object-store-v1.js', 'durable-file-store-v1.js', diff --git a/packages/agent/src/rfc64/catalog-access-policy-v1.ts b/packages/agent/src/rfc64/catalog-access-policy-v1.ts new file mode 100644 index 0000000000..91373b4197 --- /dev/null +++ b/packages/agent/src/rfc64/catalog-access-policy-v1.ts @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Accepted-current D26 access decisions for the RFC-64 catalog data plane. + * + * This registry is deliberately an authorization consumer, not an authority + * verifier. Callers may insert only a policy/roster snapshot that has already + * passed the RFC-64 administrative/finality boundary. The registry snapshots + * those values once, binds a private roster to the exact policy digest, and + * refuses in-place policy replacement so a stale caller cannot roll current + * authorization backwards. + * + * SWM read, submission, discovery, and payload transfer follow accessPolicy. + * publishPolicy is retained in the accepted policy snapshot but never consulted + * here: it governs VM transaction admission, not SWM synchronization. + */ + +import { + assertCanonicalDigest, + canonicalizeContextGraphPolicyPayloadV1, + canonicalizeMemberRosterPayloadV1, + parseCanonicalContextGraphPolicyPayloadV1, + parseCanonicalMemberRosterPayloadV1, + type ContextGraphIdV1, + type ContextGraphPolicyV1, + type Digest32V1, + type EvmAddressV1, + type MemberRosterEntryV1, + type MemberRosterV1, + type NetworkIdV1, +} from '@origintrail-official/dkg-core'; + +import { classifyRfc64PolicyCellV1 } from './policy-cell-v1.js'; + +export type Rfc64CatalogAccessOperationV1 = + | 'announce-outbound' + | 'announce-inbound' + | 'fetch-outbound' + | 'fetch-inbound' + | 'catalog-object-fetch-outbound' + | 'catalog-object-fetch-inbound' + | 'ka-bundle-fetch-outbound' + | 'ka-bundle-fetch-inbound'; + +export interface Rfc64CatalogAccessAuthorizationInputV1 { + readonly operation: Rfc64CatalogAccessOperationV1; + readonly remotePeerId: string; + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly policyDigest: Digest32V1; +} + +export interface Rfc64CatalogAccessAuthorizationV1 { + readonly accessPolicy: ContextGraphPolicyV1['accessPolicy']; + readonly policyDigest: Digest32V1; +} + +export interface AcceptRfc64CatalogAccessSnapshotInputV1 { + readonly policy: ContextGraphPolicyV1; + readonly policyDigest: Digest32V1; + readonly roster?: MemberRosterV1 | null; +} + +export interface AcceptedRfc64CatalogAccessSnapshotV1 { + readonly policy: Readonly; + readonly policyDigest: Digest32V1; + readonly roster: Readonly | null; +} + +export interface Rfc64CatalogAccessPolicyRegistryOptionsV1 { + readonly localAgentAddress: EvmAddressV1; + /** Exact authenticated libp2p-peer to agent-wallet binding. */ + readonly resolveRemoteAgentAddress: ( + remotePeerId: string, + ) => Promise; +} + +interface HeldCatalogAccessSnapshotV1 extends AcceptedRfc64CatalogAccessSnapshotV1 { + readonly members: ReadonlyMap> | null; +} + +const EVM_ADDRESS = /^0x[0-9a-f]{40}$/u; +const ZERO_ADDRESS = `0x${'0'.repeat(40)}`; +const MAX_PEER_ID_BYTES = 256; +const UTF8 = new TextEncoder(); + +/** + * Fail-closed current-policy registry shared by announcement, catalog-object, + * and opaque-bundle transports. + */ +export class Rfc64CatalogAccessPolicyRegistryV1 { + readonly #localAgentAddress: EvmAddressV1; + readonly #resolveRemoteAgentAddress: ( + remotePeerId: string, + ) => Promise; + readonly #byKey = new Map(); + + constructor(options: Rfc64CatalogAccessPolicyRegistryOptionsV1) { + this.#localAgentAddress = snapshotAgentAddress( + options?.localAgentAddress, + 'localAgentAddress', + ); + if (typeof options?.resolveRemoteAgentAddress !== 'function') { + throw new TypeError('resolveRemoteAgentAddress must be a function'); + } + this.#resolveRemoteAgentAddress = options.resolveRemoteAgentAddress; + } + + /** Accept one already-authoritative current snapshot. Exact replay is idempotent. */ + accept( + input: AcceptRfc64CatalogAccessSnapshotInputV1, + ): AcceptedRfc64CatalogAccessSnapshotV1 { + const policy = snapshotPolicy(input?.policy); + const policyDigest = snapshotDigest(input?.policyDigest, 'policyDigest'); + const descriptor = classifyRfc64PolicyCellV1(policy); + const rosterInput = input?.roster ?? null; + let roster: Readonly | null = null; + let members: ReadonlyMap> | null = null; + + if (descriptor.rosterMode === 'forbidden') { + if (rosterInput !== null) { + throw new Error('open RFC-64 catalog policy forbids an exhaustive member roster'); + } + } else { + if (rosterInput === null) { + throw new Error('invite-only RFC-64 catalog policy requires a current member roster'); + } + roster = snapshotRoster(rosterInput); + assertRosterMatchesPolicy(roster, policy, policyDigest); + members = new Map(roster.members.map((entry) => [entry.agentAddress, entry])); + } + + const key = policyKey(policy.networkId, policy.contextGraphId); + const current = this.#byKey.get(key); + if (current !== undefined) { + if ( + current.policyDigest !== policyDigest + || canonicalizeContextGraphPolicyPayloadV1(current.policy) + !== canonicalizeContextGraphPolicyPayloadV1(policy) + || canonicalRoster(current.roster) !== canonicalRoster(roster) + ) { + throw new Error( + 'RFC-64 current policy replacement requires the verified transition/high-water path', + ); + } + return publicSnapshot(current); + } + + const held = Object.freeze({ policy, policyDigest, roster, members }); + this.#byKey.set(key, held); + return publicSnapshot(held); + } + + lookup( + networkId: NetworkIdV1, + contextGraphId: ContextGraphIdV1, + ): AcceptedRfc64CatalogAccessSnapshotV1 | null { + const held = this.#byKey.get(policyKey(networkId, contextGraphId)); + return held === undefined ? null : publicSnapshot(held); + } + + get size(): number { + return this.#byKey.size; + } + + /** + * Authorize one live transport operation from accepted local state only. + * Returning null is the sole denial shape; transports still compare the + * returned digest with the untrusted wire digest independently. + */ + readonly authorize = async ( + input: Rfc64CatalogAccessAuthorizationInputV1, + ): Promise => { + const held = this.#byKey.get(policyKey(input.networkId, input.contextGraphId)); + if (held === undefined || held.policyDigest !== input.policyDigest) return null; + const descriptor = classifyRfc64PolicyCellV1(held.policy); + if (descriptor.catalogDisclosure === 'open-authenticated') { + return authorization(held); + } + if (held.members === null) return null; + + const remotePeerId = snapshotPeerId(input.remotePeerId); + const remoteAgentAddress = await this.#resolveRemoteMemberAddress(remotePeerId); + if (remoteAgentAddress === null) return null; + const localMember = held.members.get(this.#localAgentAddress); + const remoteMember = held.members.get(remoteAgentAddress); + if (localMember === undefined || remoteMember === undefined) return null; + + const servingMember = servingSide(input.operation) === 'local' + ? localMember + : remoteMember; + if (!servingMember.roles.includes('provider')) return null; + return authorization(held); + }; + + /** SWM authorship follows accessPolicy and is intentionally publishPolicy-neutral. */ + isSwmAuthorAuthorized(input: { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly policyDigest: Digest32V1; + readonly authorAddress: EvmAddressV1; + }): boolean { + const held = this.#byKey.get(policyKey(input.networkId, input.contextGraphId)); + if (held === undefined || held.policyDigest !== input.policyDigest) return false; + let authorAddress: EvmAddressV1; + try { + authorAddress = snapshotAgentAddress(input.authorAddress, 'authorAddress'); + } catch { + return false; + } + return held.policy.accessPolicy === 0 + || held.members?.has(authorAddress) === true; + } + + async #resolveRemoteMemberAddress(remotePeerId: string): Promise { + try { + const resolved = await this.#resolveRemoteAgentAddress(remotePeerId); + return resolved === null + ? null + : snapshotAgentAddress(resolved, 'resolved remote agent address'); + } catch { + return null; + } + } +} + +function authorization( + held: HeldCatalogAccessSnapshotV1, +): Rfc64CatalogAccessAuthorizationV1 { + return Object.freeze({ + accessPolicy: held.policy.accessPolicy, + policyDigest: held.policyDigest, + }); +} + +function servingSide(operation: Rfc64CatalogAccessOperationV1): 'local' | 'remote' { + switch (operation) { + case 'announce-outbound': + case 'fetch-inbound': + case 'catalog-object-fetch-inbound': + case 'ka-bundle-fetch-inbound': + return 'local'; + case 'announce-inbound': + case 'fetch-outbound': + case 'catalog-object-fetch-outbound': + case 'ka-bundle-fetch-outbound': + return 'remote'; + } +} + +function assertRosterMatchesPolicy( + roster: Readonly, + policy: Readonly, + policyDigest: Digest32V1, +): void { + if ( + roster.networkId !== policy.networkId + || roster.contextGraphId !== policy.contextGraphId + || roster.ownershipTransitionDigest !== policy.ownershipTransitionDigest + || roster.era !== policy.era + || roster.policyDigest !== policyDigest + || roster.administrativeDelegationDigest !== policy.administrativeDelegationDigest + ) { + throw new Error('RFC-64 member roster is not bound to the exact accepted policy'); + } +} + +function publicSnapshot( + held: HeldCatalogAccessSnapshotV1, +): AcceptedRfc64CatalogAccessSnapshotV1 { + return Object.freeze({ + policy: held.policy, + policyDigest: held.policyDigest, + roster: held.roster, + }); +} + +function snapshotPolicy(input: ContextGraphPolicyV1): Readonly { + return deepFreeze(parseCanonicalContextGraphPolicyPayloadV1( + canonicalizeContextGraphPolicyPayloadV1(input), + )); +} + +function snapshotRoster(input: MemberRosterV1): Readonly { + return deepFreeze(parseCanonicalMemberRosterPayloadV1( + canonicalizeMemberRosterPayloadV1(input), + )); +} + +function snapshotDigest(input: Digest32V1, label: string): Digest32V1 { + assertCanonicalDigest(input, label); + return input; +} + +function snapshotAgentAddress(input: EvmAddressV1, label: string): EvmAddressV1 { + if (typeof input !== 'string' || !EVM_ADDRESS.test(input) || input === ZERO_ADDRESS) { + throw new TypeError(`${label} must be a canonical nonzero EVM address`); + } + return input; +} + +function snapshotPeerId(input: string): string { + if ( + typeof input !== 'string' + || input.length === 0 + || input.trim() !== input + || UTF8.encode(input).byteLength > MAX_PEER_ID_BYTES + ) { + throw new TypeError('remotePeerId must be a bounded canonical peer identifier'); + } + return input; +} + +function canonicalRoster(roster: Readonly | null): string | null { + return roster === null ? null : canonicalizeMemberRosterPayloadV1(roster); +} + +function policyKey(networkId: NetworkIdV1, contextGraphId: ContextGraphIdV1): string { + return `${networkId}\n${contextGraphId}`; +} + +function deepFreeze(value: T): Readonly { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value as Record)) { + deepFreeze(child); + } + Object.freeze(value); + } + return value as Readonly; +} diff --git a/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts b/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts new file mode 100644 index 0000000000..c7f118d940 --- /dev/null +++ b/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from 'vitest'; +import { + CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + computeContextGraphPolicyObjectDigestV1, + type ContextGraphPolicyV1, + type Digest32V1, + type EvmAddressV1, + type MemberRosterV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; + +import { + Rfc64CatalogAccessPolicyRegistryV1, + type Rfc64CatalogAccessOperationV1, +} from '../src/rfc64/catalog-access-policy-v1.js'; + +const OWNER = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const LOCAL = '0x2222222222222222222222222222222222222222' as EvmAddressV1; +const REMOTE = '0x3333333333333333333333333333333333333333' as EvmAddressV1; +const OUTSIDER = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const CURATOR = '0x5555555555555555555555555555555555555555' as EvmAddressV1; +const NETWORK = 'otp:20430' as const; +const CG = '0x1111111111111111111111111111111111111111/v2-policy' as const; + +const OPERATIONS: readonly Rfc64CatalogAccessOperationV1[] = Object.freeze([ + 'announce-outbound', + 'announce-inbound', + 'fetch-outbound', + 'fetch-inbound', + 'catalog-object-fetch-outbound', + 'catalog-object-fetch-inbound', + 'ka-bundle-fetch-outbound', + 'ka-bundle-fetch-inbound', +]); + +function policy(accessPolicy: 0 | 1, publishPolicy: 0 | 1): ContextGraphPolicyV1 { + return { + networkId: NETWORK, + contextGraphId: CG, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy, + publishPolicy, + publishAuthority: publishPolicy === 0 ? CURATOR : null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: OWNER, + ownerAuthorityEra: '0', + }, + effectiveAt: '0', + issuedAt: '0', + }; +} + +function digestFor(input: ContextGraphPolicyV1): Digest32V1 { + return computeContextGraphPolicyObjectDigestV1({ + issuer: OWNER, + objectType: CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + payload: input, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1); +} + +function roster( + policyDigest: Digest32V1, + options: { localProvider?: boolean; remoteProvider?: boolean } = {}, +): MemberRosterV1 { + return { + networkId: NETWORK, + contextGraphId: CG, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousRosterDigest: null, + policyDigest, + administrativeDelegationDigest: null, + members: [ + { + agentAddress: LOCAL, + roles: options.localProvider === false ? ['holder'] : ['holder', 'provider'], + }, + { + agentAddress: REMOTE, + roles: options.remoteProvider === false ? ['holder'] : ['holder', 'provider'], + }, + ], + issuedAt: '0', + }; +} + +function registry( + remoteAddress: EvmAddressV1 | null = REMOTE, +): Rfc64CatalogAccessPolicyRegistryV1 { + return new Rfc64CatalogAccessPolicyRegistryV1({ + localAgentAddress: LOCAL, + resolveRemoteAgentAddress: async () => remoteAddress, + }); +} + +function authInput(operation: Rfc64CatalogAccessOperationV1, policyDigest: Digest32V1) { + return { + operation, + remotePeerId: '12D3KooRemote', + networkId: NETWORK, + contextGraphId: CG, + policyDigest, + } as const; +} + +describe('RFC-64 D26 catalog access authorization', () => { + it('keeps both open-sharing cells SWM-equivalent without resolving a roster identity', async () => { + for (const publishPolicy of [0, 1] as const) { + let resolverCalls = 0; + const acceptedPolicy = policy(0, publishPolicy); + const policyDigest = digestFor(acceptedPolicy); + const subject = new Rfc64CatalogAccessPolicyRegistryV1({ + localAgentAddress: LOCAL, + resolveRemoteAgentAddress: async () => { + resolverCalls += 1; + return null; + }, + }); + subject.accept({ policy: acceptedPolicy, policyDigest }); + for (const operation of OPERATIONS) { + await expect(subject.authorize(authInput(operation, policyDigest))).resolves.toEqual({ + accessPolicy: 0, + policyDigest, + }); + } + expect(subject.isSwmAuthorAuthorized({ + networkId: NETWORK, + contextGraphId: CG, + policyDigest, + authorAddress: OUTSIDER, + })).toBe(true); + expect(resolverCalls).toBe(0); + } + }); + + it('keeps both invite-only cells SWM-equivalent and requires current members', async () => { + for (const publishPolicy of [0, 1] as const) { + const acceptedPolicy = policy(1, publishPolicy); + const policyDigest = digestFor(acceptedPolicy); + const subject = registry(); + subject.accept({ policy: acceptedPolicy, policyDigest, roster: roster(policyDigest) }); + for (const operation of OPERATIONS) { + await expect(subject.authorize(authInput(operation, policyDigest))).resolves.toEqual({ + accessPolicy: 1, + policyDigest, + }); + } + expect(subject.isSwmAuthorAuthorized({ + networkId: NETWORK, + contextGraphId: CG, + policyDigest, + authorAddress: REMOTE, + })).toBe(true); + expect(subject.isSwmAuthorAuthorized({ + networkId: NETWORK, + contextGraphId: CG, + policyDigest, + authorAddress: OUTSIDER, + })).toBe(false); + } + }); + + it('requires the serving side to hold the provider role', async () => { + const acceptedPolicy = policy(1, 1); + const policyDigest = digestFor(acceptedPolicy); + + const localNotProvider = registry(); + localNotProvider.accept({ + policy: acceptedPolicy, + policyDigest, + roster: roster(policyDigest, { localProvider: false }), + }); + for (const operation of [ + 'announce-outbound', + 'fetch-inbound', + 'catalog-object-fetch-inbound', + 'ka-bundle-fetch-inbound', + ] as const) { + await expect(localNotProvider.authorize(authInput(operation, policyDigest))) + .resolves.toBeNull(); + } + + const remoteNotProvider = registry(); + remoteNotProvider.accept({ + policy: acceptedPolicy, + policyDigest, + roster: roster(policyDigest, { remoteProvider: false }), + }); + for (const operation of [ + 'announce-inbound', + 'fetch-outbound', + 'catalog-object-fetch-outbound', + 'ka-bundle-fetch-outbound', + ] as const) { + await expect(remoteNotProvider.authorize(authInput(operation, policyDigest))) + .resolves.toBeNull(); + } + }); + + it('denies unknown peers, stale digests, and missing private membership', async () => { + const acceptedPolicy = policy(1, 0); + const policyDigest = digestFor(acceptedPolicy); + const outsider = registry(OUTSIDER); + outsider.accept({ policy: acceptedPolicy, policyDigest, roster: roster(policyDigest) }); + await expect(outsider.authorize(authInput('fetch-inbound', policyDigest))) + .resolves.toBeNull(); + await expect(outsider.authorize(authInput( + 'fetch-inbound', + `0x${'ab'.repeat(32)}` as Digest32V1, + ))).resolves.toBeNull(); + const unresolved = registry(null); + unresolved.accept({ policy: acceptedPolicy, policyDigest, roster: roster(policyDigest) }); + await expect(unresolved.authorize(authInput('fetch-inbound', policyDigest))) + .resolves.toBeNull(); + }); + + it('binds the conditional roster exactly and snapshots mutable caller input', async () => { + const acceptedPolicy = policy(1, 1); + const policyDigest = digestFor(acceptedPolicy); + const acceptedRoster = roster(policyDigest); + const subject = registry(); + const accepted = subject.accept({ + policy: acceptedPolicy, + policyDigest, + roster: acceptedRoster, + }); + acceptedPolicy.accessPolicy = 0; + acceptedRoster.members.splice(0, acceptedRoster.members.length); + expect(accepted.policy.accessPolicy).toBe(1); + expect(accepted.roster?.members).toHaveLength(2); + await expect(subject.authorize(authInput('fetch-outbound', policyDigest))) + .resolves.toEqual({ accessPolicy: 1, policyDigest }); + + expect(() => registry().accept({ + policy: policy(1, 1), + policyDigest, + })).toThrow(/requires a current member roster/u); + expect(() => registry().accept({ + policy: policy(0, 1), + policyDigest: digestFor(policy(0, 1)), + roster: roster(policyDigest), + })).toThrow(/forbids an exhaustive member roster/u); + expect(() => registry().accept({ + policy: policy(1, 1), + policyDigest, + roster: { ...roster(policyDigest), policyDigest: `0x${'cd'.repeat(32)}` as Digest32V1 }, + })).toThrow(/not bound to the exact accepted policy/u); + }); + + it('allows exact replay but refuses unverified current-policy replacement', () => { + const initial = policy(0, 1); + const initialDigest = digestFor(initial); + const subject = registry(); + subject.accept({ policy: initial, policyDigest: initialDigest }); + expect(subject.accept({ policy: initial, policyDigest: initialDigest }).policyDigest) + .toBe(initialDigest); + const replacement = { ...policy(0, 0), version: '1', previousPolicyDigest: initialDigest }; + expect(() => subject.accept({ + policy: replacement, + policyDigest: digestFor(replacement), + })).toThrow(/verified transition\/high-water path/u); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 6bea721429..95f7040a64 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -134,6 +134,7 @@ export default defineConfig({ "test/rfc64-persistent-catalog-provider-v1.test.ts", "test/rfc64-public-catalog-successor-producer-v1.test.ts", "test/rfc64-dkg-agent-successor-publication.integration.test.ts", + "test/rfc64-catalog-access-policy-v1.test.ts", "test/rfc64-policy-cell-v1.test.ts", ], testTimeout: 60_000, From 9edec445363a4f669137e28f2fa24f340558f966 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 23:19:02 +0200 Subject: [PATCH 166/292] fix(agent): fail closed at RFC-64 catalog access boundary --- .../src/rfc64/catalog-access-policy-v1.ts | 77 ++++++++++++++++--- .../rfc64-catalog-access-policy-v1.test.ts | 48 ++++++++++++ 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/rfc64/catalog-access-policy-v1.ts b/packages/agent/src/rfc64/catalog-access-policy-v1.ts index 91373b4197..e80890f029 100644 --- a/packages/agent/src/rfc64/catalog-access-policy-v1.ts +++ b/packages/agent/src/rfc64/catalog-access-policy-v1.ts @@ -17,6 +17,8 @@ import { assertCanonicalDigest, + assertContextGraphIdV1, + assertNetworkIdV1, canonicalizeContextGraphPolicyPayloadV1, canonicalizeMemberRosterPayloadV1, parseCanonicalContextGraphPolicyPayloadV1, @@ -171,22 +173,33 @@ export class Rfc64CatalogAccessPolicyRegistryV1 { readonly authorize = async ( input: Rfc64CatalogAccessAuthorizationInputV1, ): Promise => { - const held = this.#byKey.get(policyKey(input.networkId, input.contextGraphId)); - if (held === undefined || held.policyDigest !== input.policyDigest) return null; + let boundary: Readonly; + try { + boundary = snapshotAuthorizationInput(input); + } catch { + return null; + } + + const key = policyKey(boundary.networkId, boundary.contextGraphId); + const held = this.#byKey.get(key); + if (held === undefined || held.policyDigest !== boundary.policyDigest) return null; const descriptor = classifyRfc64PolicyCellV1(held.policy); if (descriptor.catalogDisclosure === 'open-authenticated') { return authorization(held); } if (held.members === null) return null; - const remotePeerId = snapshotPeerId(input.remotePeerId); - const remoteAgentAddress = await this.#resolveRemoteMemberAddress(remotePeerId); + const remoteAgentAddress = await this.#resolveRemoteMemberAddress(boundary.remotePeerId); if (remoteAgentAddress === null) return null; + // A future verified transition path may replace the held snapshot while the + // authenticated peer binding is being resolved. Never authorize against a + // snapshot that stopped being current during that await. + if (this.#byKey.get(key) !== held) return null; const localMember = held.members.get(this.#localAgentAddress); const remoteMember = held.members.get(remoteAgentAddress); if (localMember === undefined || remoteMember === undefined) return null; - const servingMember = servingSide(input.operation) === 'local' + const servingMember = servingSide(boundary.operation) === 'local' ? localMember : remoteMember; if (!servingMember.roles.includes('provider')) return null; @@ -200,16 +213,20 @@ export class Rfc64CatalogAccessPolicyRegistryV1 { readonly policyDigest: Digest32V1; readonly authorAddress: EvmAddressV1; }): boolean { - const held = this.#byKey.get(policyKey(input.networkId, input.contextGraphId)); - if (held === undefined || held.policyDigest !== input.policyDigest) return false; - let authorAddress: EvmAddressV1; try { - authorAddress = snapshotAgentAddress(input.authorAddress, 'authorAddress'); + const networkId = input.networkId; + const contextGraphId = input.contextGraphId; + const policyDigest = snapshotDigest(input.policyDigest, 'policyDigest'); + const authorAddress = snapshotAgentAddress(input.authorAddress, 'authorAddress'); + assertNetworkIdV1(networkId); + assertContextGraphIdV1(contextGraphId); + const held = this.#byKey.get(policyKey(networkId, contextGraphId)); + if (held === undefined || held.policyDigest !== policyDigest) return false; + return held.policy.accessPolicy === 0 + || held.members?.has(authorAddress) === true; } catch { return false; } - return held.policy.accessPolicy === 0 - || held.members?.has(authorAddress) === true; } async #resolveRemoteMemberAddress(remotePeerId: string): Promise { @@ -248,6 +265,44 @@ function servingSide(operation: Rfc64CatalogAccessOperationV1): 'local' | 'remot } } +function snapshotAuthorizationInput( + input: Rfc64CatalogAccessAuthorizationInputV1, +): Readonly { + if (input === null || typeof input !== 'object') { + throw new TypeError('catalog access authorization input must be an object'); + } + const operation = snapshotOperation(input.operation); + const remotePeerId = snapshotPeerId(input.remotePeerId); + const networkId = input.networkId; + const contextGraphId = input.contextGraphId; + assertNetworkIdV1(networkId); + assertContextGraphIdV1(contextGraphId); + const policyDigest = snapshotDigest(input.policyDigest, 'policyDigest'); + return Object.freeze({ + operation, + remotePeerId, + networkId, + contextGraphId, + policyDigest, + }); +} + +function snapshotOperation(input: unknown): Rfc64CatalogAccessOperationV1 { + switch (input) { + case 'announce-outbound': + case 'announce-inbound': + case 'fetch-outbound': + case 'fetch-inbound': + case 'catalog-object-fetch-outbound': + case 'catalog-object-fetch-inbound': + case 'ka-bundle-fetch-outbound': + case 'ka-bundle-fetch-inbound': + return input; + default: + throw new TypeError('operation must be a supported RFC-64 catalog access operation'); + } +} + function assertRosterMatchesPolicy( roster: Readonly, policy: Readonly, diff --git a/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts b/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts index c7f118d940..528c88e187 100644 --- a/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts +++ b/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts @@ -229,6 +229,54 @@ describe('RFC-64 D26 catalog access authorization', () => { .resolves.toBeNull(); }); + it('fails closed for malformed runtime operations and peer identifiers', async () => { + for (const accessPolicy of [0, 1] as const) { + const acceptedPolicy = policy(accessPolicy, 1); + const policyDigest = digestFor(acceptedPolicy); + const subject = registry(); + subject.accept({ + policy: acceptedPolicy, + policyDigest, + roster: accessPolicy === 1 ? roster(policyDigest) : null, + }); + + await expect(subject.authorize({ + ...authInput('fetch-inbound', policyDigest), + operation: 'not-a-catalog-operation', + } as never)).resolves.toBeNull(); + await expect(subject.authorize({ + ...authInput('fetch-inbound', policyDigest), + remotePeerId: '', + })).resolves.toBeNull(); + await expect(subject.authorize(null as never)).resolves.toBeNull(); + expect(subject.isSwmAuthorAuthorized(null as never)).toBe(false); + } + }); + + it('snapshots the operation before awaiting peer-to-agent resolution', async () => { + let finishResolution: ((address: EvmAddressV1) => void) | undefined; + const acceptedPolicy = policy(1, 1); + const policyDigest = digestFor(acceptedPolicy); + const subject = new Rfc64CatalogAccessPolicyRegistryV1({ + localAgentAddress: LOCAL, + resolveRemoteAgentAddress: async () => new Promise((resolve) => { + finishResolution = resolve; + }), + }); + subject.accept({ + policy: acceptedPolicy, + policyDigest, + roster: roster(policyDigest, { remoteProvider: false }), + }); + + const input = { ...authInput('fetch-outbound', policyDigest) }; + const authorization = subject.authorize(input); + input.operation = 'fetch-inbound'; + expect(finishResolution).toBeTypeOf('function'); + finishResolution!(REMOTE); + await expect(authorization).resolves.toBeNull(); + }); + it('binds the conditional roster exactly and snapshots mutable caller input', async () => { const acceptedPolicy = policy(1, 1); const policyDigest = digestFor(acceptedPolicy); From 4a24aed79b197d5a6ea0bb87bb4435377aac94f0 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 21 Jul 2026 13:11:44 +0200 Subject: [PATCH 167/292] feat(agent): authorize RFC-64 catalog transports by policy Replace the ad-hoc open-policy check on the RFC-64 catalog transports with the registry-backed access-policy authorizer from #1835: - exactly one authorizer must be configured (current or legacy compatibility), - authorization is re-checked after each await on the inbound and outbound paths, so a policy revoked mid-operation cannot be raced, - head requests remain bound to the authorized scope. The two native CONTENT protocols stay restricted to publicly-readable cells (accessPolicy === 0). They resolve objects and bundles purely by the digest carried in the request, out of a store shared by every context graph on the node, and nothing on the serve path binds what is served back to the graph that authorized the request. Admitting a private cell here would therefore let any peer authorized under any public cell the node happens to hold read another graph's control objects and KA bundles by digest alone, and would leave a removed member's remembered digests a permanent read capability -- inverting the fail-closed requirement this commit exists to enforce. Serving private content needs the serve path scope-bound first (heads carry networkId/contextGraphId, buckets carry catalogScopeDigest, bundles bind only via row membership in the announced head); that is a design slice for the private-CG milestone, not a widening of this gate. Tests pin the boundary rather than the hole: the live two-node fetch runs across both public cells (publishPolicy governs finalized-VM admission only and must not affect read authorization), and a private-policy provider is asserted to deny before provider lookup on BOTH content protocols. Co-Authored-By: Claude Opus 4.8 --- .../public-catalog-native-transport-v1.ts | 112 +++++-- .../src/rfc64/public-catalog-transport-v1.ts | 94 ++++-- ...public-catalog-native-transport-v1.test.ts | 309 +++++++++++++++++- .../rfc64-public-catalog-transport-v1.test.ts | 192 ++++++++++- 4 files changed, 620 insertions(+), 87 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts index 61e8776666..aebdef389a 100644 --- a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 /** - * RFC-64 public/open catalog content transport. + * RFC-64 catalog content transport. * * This is the digest-following half of the Gate-1 transport. A catalog-head * announcement still travels through `Rfc64PublicCatalogTransportV1`; after the @@ -28,7 +28,6 @@ import { decodeOpaqueKaBundleV1, parseCanonicalDecimalU64, parseCanonicalSignedControlEnvelope, - type ContextGraphAccessPolicyV1, type ContextGraphIdV1, type DecimalU64V1, type Digest32V1, @@ -43,6 +42,12 @@ import { type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; +import type { + Rfc64CatalogAccessAuthorizationInputV1, + Rfc64CatalogAccessAuthorizationV1, + Rfc64CatalogAccessPolicyRegistryV1, +} from './catalog-access-policy-v1.js'; + export const RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1 = '/dkg/catalog/1/control-object/by-digest' as const; export const RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1 = @@ -170,10 +175,8 @@ export interface Rfc64PublicCatalogNativeAuthorizationInputV1 { readonly catalogHeadObjectDigest: Digest32V1; } -export interface Rfc64PublicCatalogNativeAuthorizationV1 { - readonly accessPolicy: ContextGraphAccessPolicyV1; - readonly policyDigest: Digest32V1; -} +export type Rfc64PublicCatalogNativeAuthorizationV1 = + Rfc64CatalogAccessAuthorizationV1; export interface FetchedRfc64PublicCatalogObjectV1 { readonly envelope: SignedControlEnvelopeV1; @@ -189,8 +192,10 @@ export interface Rfc64PublicCatalogNativeTransportOptionsV1 { readonly readKaBundleByDigest: ( blobDigest: Digest32V1, ) => Promise; - /** Must consult accepted current policy state, never the untrusted request. */ - readonly authorizeOpenCatalogOperation: ( + /** Preferred V2 contract: the accepted-current access-policy registry. */ + readonly authorizeCatalogOperation?: Rfc64CatalogAccessPolicyRegistryV1['authorize']; + /** @deprecated Gate-1 compatibility until the service wiring migrates. */ + readonly authorizeOpenCatalogOperation?: ( input: Rfc64PublicCatalogNativeAuthorizationInputV1, ) => Promise; readonly verifyIssuerSignature: ( @@ -220,6 +225,9 @@ export class Rfc64PublicCatalogNativeTransportErrorV1 extends Error { export class Rfc64PublicCatalogNativeTransportV1 { #started = false; + readonly #authorizeCatalogOperation: ( + input: Rfc64PublicCatalogNativeAuthorizationInputV1, + ) => Promise; constructor( private readonly router: ProtocolRouter, @@ -231,9 +239,20 @@ export class Rfc64PublicCatalogNativeTransportV1 { if (typeof options.readKaBundleByDigest !== 'function') { fail('catalog-native-input', 'readKaBundleByDigest must be a function'); } - if (typeof options.authorizeOpenCatalogOperation !== 'function') { - fail('catalog-native-input', 'authorizeOpenCatalogOperation must be a function'); + const currentAuthorizer = options.authorizeCatalogOperation; + const legacyAuthorizer = options.authorizeOpenCatalogOperation; + if ( + (typeof currentAuthorizer !== 'function' && typeof legacyAuthorizer !== 'function') + || (typeof currentAuthorizer === 'function' && typeof legacyAuthorizer === 'function') + ) { + fail( + 'catalog-native-input', + 'exactly one catalog access-policy authorizer must be configured', + ); } + this.#authorizeCatalogOperation = typeof currentAuthorizer === 'function' + ? (input) => currentAuthorizer(toCatalogAccessAuthorizationInput(input)) + : (input) => legacyAuthorizer!(input); if (typeof options.verifyIssuerSignature !== 'function') { fail('catalog-native-input', 'verifyIssuerSignature must be a function'); } @@ -280,7 +299,7 @@ export class Rfc64PublicCatalogNativeTransportV1 { this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const request = parseCatalogObjectRequest(encodeRequest(requestInput)); - await this.requireOpenPolicy('catalog-object-fetch-outbound', remotePeerId, request); + await this.requireCatalogPolicy('catalog-object-fetch-outbound', remotePeerId, request); const response = await this.router.send( remotePeerId, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, @@ -288,10 +307,11 @@ export class Rfc64PublicCatalogNativeTransportV1 { sendOptions, ); const envelope = parseCatalogObjectResponse(response); - await this.requireOpenPolicy('catalog-object-fetch-outbound', remotePeerId, request); + await this.requireCatalogPolicy('catalog-object-fetch-outbound', remotePeerId, request); if (envelope === null) return null; assertCatalogObjectMatchesRequest(envelope, request); const issuerSignature = await this.verifyExactIssuerSignature(envelope); + await this.requireCatalogPolicy('catalog-object-fetch-outbound', remotePeerId, request); return Object.freeze({ envelope: deepFreeze(envelope), issuerSignature }); } @@ -310,7 +330,7 @@ export class Rfc64PublicCatalogNativeTransportV1 { 'advertised KA bundle exceeds this receiver transport resource ceiling', ); } - await this.requireOpenPolicy('ka-bundle-fetch-outbound', remotePeerId, request); + await this.requireCatalogPolicy('ka-bundle-fetch-outbound', remotePeerId, request); const response = await this.router.send( remotePeerId, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, @@ -318,7 +338,7 @@ export class Rfc64PublicCatalogNativeTransportV1 { sendOptions, ); const bundle = parseBundleResponse(response, request); - await this.requireOpenPolicy('ka-bundle-fetch-outbound', remotePeerId, request); + await this.requireCatalogPolicy('ka-bundle-fetch-outbound', remotePeerId, request); return bundle; } @@ -329,14 +349,23 @@ export class Rfc64PublicCatalogNativeTransportV1 { this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const request = parseCatalogObjectRequest(data); - if (!await this.isOpenPolicy('catalog-object-fetch-inbound', remotePeerId, request)) { + if (!await this.isCatalogPolicyAuthorized('catalog-object-fetch-inbound', remotePeerId, request)) { return Uint8Array.of(FETCH_DENIED); } const envelope = await this.options.readCatalogObjectByDigest(request.targetObjectDigest); - if (envelope === null) return Uint8Array.of(FETCH_NOT_FOUND); + if (envelope === null) { + if (!await this.isCatalogPolicyAuthorized( + 'catalog-object-fetch-inbound', + remotePeerId, + request, + )) { + return Uint8Array.of(FETCH_DENIED); + } + return Uint8Array.of(FETCH_NOT_FOUND); + } assertCatalogObjectMatchesRequest(envelope, request); await this.verifyExactIssuerSignature(envelope); - if (!await this.isOpenPolicy('catalog-object-fetch-inbound', remotePeerId, request)) { + if (!await this.isCatalogPolicyAuthorized('catalog-object-fetch-inbound', remotePeerId, request)) { return Uint8Array.of(FETCH_DENIED); } const bytes = canonicalizeSignedControlEnvelopeBytes(envelope); @@ -357,26 +386,35 @@ export class Rfc64PublicCatalogNativeTransportV1 { > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1)) { fail('catalog-native-resource-refused', 'requested KA bundle exceeds the response ceiling'); } - if (!await this.isOpenPolicy('ka-bundle-fetch-inbound', remotePeerId, request)) { + if (!await this.isCatalogPolicyAuthorized('ka-bundle-fetch-inbound', remotePeerId, request)) { return Uint8Array.of(FETCH_DENIED); } const bundle = await this.options.readKaBundleByDigest(request.blobDigest); - if (bundle === null) return Uint8Array.of(FETCH_NOT_FOUND); + if (bundle === null) { + if (!await this.isCatalogPolicyAuthorized( + 'ka-bundle-fetch-inbound', + remotePeerId, + request, + )) { + return Uint8Array.of(FETCH_DENIED); + } + return Uint8Array.of(FETCH_NOT_FOUND); + } assertExactBundle(bundle, request); - if (!await this.isOpenPolicy('ka-bundle-fetch-inbound', remotePeerId, request)) { + if (!await this.isCatalogPolicyAuthorized('ka-bundle-fetch-inbound', remotePeerId, request)) { return Uint8Array.of(FETCH_DENIED); } return foundResponse(bundle); } - private async isOpenPolicy( + private async isCatalogPolicyAuthorized( operation: Rfc64PublicCatalogNativeOperationV1, remotePeerId: string, request: Rfc64PublicCatalogObjectFetchRequestV1 | Rfc64PublicCatalogBundleFetchRequestV1, ): Promise { try { - await this.requireOpenPolicy(operation, remotePeerId, request); + await this.requireCatalogPolicy(operation, remotePeerId, request); return true; } catch (cause) { if ( @@ -387,7 +425,7 @@ export class Rfc64PublicCatalogNativeTransportV1 { } } - private async requireOpenPolicy( + private async requireCatalogPolicy( operation: Rfc64PublicCatalogNativeOperationV1, remotePeerId: string, request: Rfc64PublicCatalogObjectFetchRequestV1 @@ -395,7 +433,7 @@ export class Rfc64PublicCatalogNativeTransportV1 { ): Promise { let authorization: Rfc64PublicCatalogNativeAuthorizationV1 | null; try { - authorization = await this.options.authorizeOpenCatalogOperation(Object.freeze({ + authorization = await this.#authorizeCatalogOperation(Object.freeze({ operation, remotePeerId, networkId: request.networkId, @@ -405,10 +443,20 @@ export class Rfc64PublicCatalogNativeTransportV1 { catalogHeadObjectDigest: request.catalogHeadObjectDigest, })); } catch (cause) { - fail('catalog-native-policy-denied', 'open catalog policy authorization failed', cause); + fail('catalog-native-policy-denied', 'catalog access-policy authorization failed', cause); } + // The native content protocols resolve objects and bundles purely by the digest carried in the + // request, out of a store that is shared by every context graph on this node, and nothing on the + // serve path binds what is served back to the context graph that authorized the request. Admitting + // a private cell (accessPolicy === 1) here would therefore let any peer authorized under ANY public + // cell this node happens to hold read another graph's control objects and KA bundles by digest + // alone, and would leave a removed member's remembered digests a permanent read capability. + // Public cells are safe because their content is open by definition. Serving private content needs + // the serve path to be scope-bound first (heads carry networkId/contextGraphId, buckets carry + // catalogScopeDigest, bundles bind only via row membership in the announced head) — that is a + // design slice for the private-CG milestone, not a widening of this gate. if (authorization === null || authorization.accessPolicy !== 0) { - fail('catalog-native-policy-denied', 'catalog content fetch is not open-policy authorized'); + fail('catalog-native-policy-denied', 'catalog content fetch is not access-policy authorized'); } try { assertCanonicalDigest(authorization.policyDigest, 'authorized policyDigest'); @@ -448,6 +496,18 @@ export class Rfc64PublicCatalogNativeTransportV1 { } } +function toCatalogAccessAuthorizationInput( + input: Rfc64PublicCatalogNativeAuthorizationInputV1, +): Rfc64CatalogAccessAuthorizationInputV1 { + return Object.freeze({ + operation: input.operation, + remotePeerId: input.remotePeerId, + networkId: input.networkId, + contextGraphId: input.contextGraphId, + policyDigest: input.policyDigest, + }); +} + export function encodeRfc64PublicCatalogObjectFetchRequestV1( input: Rfc64PublicCatalogObjectFetchRequestV1, ): Uint8Array { diff --git a/packages/agent/src/rfc64/public-catalog-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-transport-v1.ts index c30abcf7a4..a6f66cc59d 100644 --- a/packages/agent/src/rfc64/public-catalog-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-transport-v1.ts @@ -10,7 +10,6 @@ import { canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, computeControlSignatureVariantDigestHex, parseCanonicalSignedAuthorCatalogHeadEnvelopeV1, - type ContextGraphAccessPolicyV1, type ContextGraphIdV1, type DecimalU64V1, type Digest32V1, @@ -26,6 +25,12 @@ import { type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; +import type { + Rfc64CatalogAccessAuthorizationInputV1, + Rfc64CatalogAccessAuthorizationV1, + Rfc64CatalogAccessPolicyRegistryV1, +} from './catalog-access-policy-v1.js'; + /** * Additive RFC-64 protocol IDs. Their `/catalog/1` component is the wire * compatibility boundary; neither protocol is negotiated under a legacy sync ID. @@ -113,14 +118,11 @@ export interface Rfc64PublicCatalogAuthorizationInputV1 { } /** - * A caller-minted current-policy decision. The transport independently requires - * `accessPolicy === 0` and an exact digest match, so returning a private policy or - * a different policy generation always fails closed. + * A caller-minted current-policy decision. The transport independently validates + * the access-policy cell and exact digest match; the registry decides whether + * the authenticated remote is authorized for an open or invite-only cell. */ -export interface Rfc64PublicCatalogAuthorizationV1 { - readonly accessPolicy: ContextGraphAccessPolicyV1; - readonly policyDigest: Digest32V1; -} +export type Rfc64PublicCatalogAuthorizationV1 = Rfc64CatalogAccessAuthorizationV1; export interface Rfc64PublicCatalogControlObjectReaderV1 { getVerifiedObject(input: { @@ -142,8 +144,10 @@ export interface FetchedRfc64PublicCatalogHeadV1 { export interface Rfc64PublicCatalogTransportOptionsV1 { readonly controlObjects: Rfc64PublicCatalogControlObjectReaderV1; - /** Must consult accepted current policy state, not the untrusted wire hint. */ - readonly authorizeOpenCatalogOperation: ( + /** Preferred V2 contract: the accepted-current access-policy registry. */ + readonly authorizeCatalogOperation?: Rfc64CatalogAccessPolicyRegistryV1['authorize']; + /** @deprecated Gate-1 compatibility until the service wiring migrates. */ + readonly authorizeOpenCatalogOperation?: ( input: Rfc64PublicCatalogAuthorizationInputV1, ) => Promise; /** Generic envelope cryptography only; object-specific head/scope binding is local. */ @@ -181,15 +185,18 @@ export class Rfc64PublicCatalogTransportErrorV1 extends Error { } /** - * Small public/open RFC-64 transport slice. + * Small RFC-64 author-catalog transport slice. * * It deliberately does not select peers, activate catalog state, admit candidate - * rows, or support invite-only policy. Announcements are only untrusted hints; + * rows. Announcements are only untrusted hints; * every served and received head is fetched by both exact digests, structurally * bound to the hint, and reverified with the generic signature verifier. */ export class Rfc64PublicCatalogTransportV1 { #started = false; + readonly #authorizeCatalogOperation: ( + input: Rfc64PublicCatalogAuthorizationInputV1, + ) => Promise; constructor( private readonly router: ProtocolRouter, @@ -198,9 +205,20 @@ export class Rfc64PublicCatalogTransportV1 { if (typeof options?.controlObjects?.getVerifiedObject !== 'function') { fail('catalog-transport-input', 'controlObjects.getVerifiedObject must be a function'); } - if (typeof options.authorizeOpenCatalogOperation !== 'function') { - fail('catalog-transport-input', 'authorizeOpenCatalogOperation must be a function'); + const currentAuthorizer = options.authorizeCatalogOperation; + const legacyAuthorizer = options.authorizeOpenCatalogOperation; + if ( + (typeof currentAuthorizer !== 'function' && typeof legacyAuthorizer !== 'function') + || (typeof currentAuthorizer === 'function' && typeof legacyAuthorizer === 'function') + ) { + fail( + 'catalog-transport-input', + 'exactly one catalog access-policy authorizer must be configured', + ); } + this.#authorizeCatalogOperation = typeof currentAuthorizer === 'function' + ? (input) => currentAuthorizer(toCatalogAccessAuthorizationInput(input)) + : (input) => legacyAuthorizer!(input); if (typeof options.verifyIssuerSignature !== 'function') { fail('catalog-transport-input', 'verifyIssuerSignature must be a function'); } @@ -250,7 +268,7 @@ export class Rfc64PublicCatalogTransportV1 { this.requireStarted(); const peerId = snapshotPeerId(remotePeerId); const announcement = parseAnnouncement(encodeAnnouncement(announcementInput)); - await this.requireOpenPolicy('announce-outbound', peerId, announcement); + await this.requireCatalogPolicy('announce-outbound', peerId, announcement); const response = await this.router.send( peerId, RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, @@ -263,7 +281,7 @@ export class Rfc64PublicCatalogTransportV1 { if (response.byteLength !== 1 || response[0] !== ACK[0]) { fail('catalog-transport-wire', 'catalog-head announcement returned an invalid acknowledgement'); } - await this.requireOpenPolicy('announce-outbound', peerId, announcement); + await this.requireCatalogPolicy('announce-outbound', peerId, announcement); } async fetchCatalogHead( @@ -274,7 +292,7 @@ export class Rfc64PublicCatalogTransportV1 { this.requireStarted(); const peerId = snapshotPeerId(remotePeerId); const announcement = parseAnnouncement(encodeAnnouncement(announcementInput)); - await this.requireOpenPolicy('fetch-outbound', peerId, announcement); + await this.requireCatalogPolicy('fetch-outbound', peerId, announcement); const request = requestFromAnnouncement(announcement); const response = await this.router.send( peerId, @@ -283,10 +301,11 @@ export class Rfc64PublicCatalogTransportV1 { sendOptions, ); const envelope = parseFetchResponse(response); - await this.requireOpenPolicy('fetch-outbound', peerId, announcement); + await this.requireCatalogPolicy('fetch-outbound', peerId, announcement); if (envelope === null) return null; assertHeadMatchesAnnouncement(envelope, announcement); const issuerSignature = await this.verifyExactIssuerSignature(envelope); + await this.requireCatalogPolicy('fetch-outbound', peerId, announcement); return Object.freeze({ envelope: deepFreeze(envelope), issuerSignature, @@ -300,11 +319,11 @@ export class Rfc64PublicCatalogTransportV1 { this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const announcement = parseAnnouncement(data); - if (!await this.isOpenPolicy('announce-inbound', remotePeerId, announcement)) { + if (!await this.isCatalogPolicyAuthorized('announce-inbound', remotePeerId, announcement)) { return Uint8Array.of(ANNOUNCEMENT_DENIED); } await this.options.onCatalogHeadAvailable(announcement, remotePeerId); - if (!await this.isOpenPolicy('announce-inbound', remotePeerId, announcement)) { + if (!await this.isCatalogPolicyAuthorized('announce-inbound', remotePeerId, announcement)) { return Uint8Array.of(ANNOUNCEMENT_DENIED); } return ACK; @@ -317,7 +336,7 @@ export class Rfc64PublicCatalogTransportV1 { this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const request = parseFetchRequest(data); - if (!await this.isOpenPolicy('fetch-inbound', remotePeerId, request)) { + if (!await this.isCatalogPolicyAuthorized('fetch-inbound', remotePeerId, request)) { return Uint8Array.of(FETCH_DENIED); } const stored = await this.options.controlObjects.getVerifiedObject({ @@ -326,7 +345,7 @@ export class Rfc64PublicCatalogTransportV1 { verifyIssuerSignature: this.options.verifyIssuerSignature, }); if (stored === null) { - if (!await this.isOpenPolicy('fetch-inbound', remotePeerId, request)) { + if (!await this.isCatalogPolicyAuthorized('fetch-inbound', remotePeerId, request)) { return Uint8Array.of(FETCH_DENIED); } return Uint8Array.of(FETCH_NOT_FOUND); @@ -351,19 +370,19 @@ export class Rfc64PublicCatalogTransportV1 { if (response.byteLength > RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1) { fail('catalog-transport-wire', 'author-catalog head exceeds the v1 fetch response cap'); } - if (!await this.isOpenPolicy('fetch-inbound', remotePeerId, request)) { + if (!await this.isCatalogPolicyAuthorized('fetch-inbound', remotePeerId, request)) { return Uint8Array.of(FETCH_DENIED); } return response; } - private async isOpenPolicy( + private async isCatalogPolicyAuthorized( operation: Rfc64PublicCatalogOperationV1, remotePeerId: string, scope: Rfc64PublicCatalogHeadAnnouncementV1 | Rfc64PublicCatalogHeadFetchRequestV1, ): Promise { try { - await this.requireOpenPolicy(operation, remotePeerId, scope); + await this.requireCatalogPolicy(operation, remotePeerId, scope); return true; } catch (cause) { if ( @@ -374,7 +393,7 @@ export class Rfc64PublicCatalogTransportV1 { } } - private async requireOpenPolicy( + private async requireCatalogPolicy( operation: Rfc64PublicCatalogOperationV1, remotePeerId: string, scope: Rfc64PublicCatalogHeadAnnouncementV1 | Rfc64PublicCatalogHeadFetchRequestV1, @@ -390,12 +409,15 @@ export class Rfc64PublicCatalogTransportV1 { }) satisfies Rfc64PublicCatalogAuthorizationInputV1; let authorization: Rfc64PublicCatalogAuthorizationV1 | null; try { - authorization = await this.options.authorizeOpenCatalogOperation(input); + authorization = await this.#authorizeCatalogOperation(input); } catch (cause) { - fail('catalog-transport-policy-denied', 'open catalog policy authorization failed', cause); + fail('catalog-transport-policy-denied', 'catalog access-policy authorization failed', cause); } - if (authorization === null || authorization.accessPolicy !== 0) { - fail('catalog-transport-policy-denied', 'catalog operation is not authorized by open access policy'); + if ( + authorization === null + || (authorization.accessPolicy !== 0 && authorization.accessPolicy !== 1) + ) { + fail('catalog-transport-policy-denied', 'catalog operation is not access-policy authorized'); } try { assertCanonicalDigest(authorization.policyDigest, 'authorized policyDigest'); @@ -638,6 +660,18 @@ function assertExactIssuerSignatureProof( } } +function toCatalogAccessAuthorizationInput( + input: Rfc64PublicCatalogAuthorizationInputV1, +): Rfc64CatalogAccessAuthorizationInputV1 { + return Object.freeze({ + operation: input.operation, + remotePeerId: input.remotePeerId, + networkId: input.networkId, + contextGraphId: input.contextGraphId, + policyDigest: input.policyDigest, + }); +} + function encodeFlatCanonicalJson( value: object, maxBytes: number, diff --git a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts index b968b105f0..4aaf1f666a 100644 --- a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts @@ -1,14 +1,18 @@ import { multiaddr } from '@multiformats/multiaddr'; import { AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, DKGNode, ProtocolRouter, canonicalizeSignedControlEnvelopeBytes, computeControlSignatureVariantDigestHex, encodeOpaqueKaBundleV1, type AuthorCatalogScopeV1, + type ContextGraphIdV1, + type ContextGraphPolicyV1, type Digest32V1, type EvmAddressV1, + type MemberRosterV1, type SignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; @@ -16,6 +20,7 @@ import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { Rfc64CatalogAccessPolicyRegistryV1 } from '../src/rfc64/catalog-access-policy-v1.js'; import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, @@ -32,6 +37,9 @@ const AUTHOR_WALLET = new ethers.Wallet(`0x${'65'.repeat(32)}`); const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; const POLICY_DIGEST = `0x${'73'.repeat(32)}` as Digest32V1; const DELEGATION_DIGEST = `0x${'74'.repeat(32)}` as Digest32V1; +const LOCAL_MEMBER = '0x3333333333333333333333333333333333333333' as EvmAddressV1; +const REMOTE_MEMBER = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const CURATOR = '0x5555555555555555555555555555555555555555' as EvmAddressV1; const UTF8 = new TextEncoder(); const nodes: DKGNode[] = []; @@ -60,6 +68,59 @@ async function connect(from: DKGNode, to: DKGNode): Promise { await from.libp2p.dial(multiaddr(address)); } +function policyRegistry( + localAgentAddress: EvmAddressV1, + remoteAgentAddress: EvmAddressV1, + contextGraphId: ContextGraphIdV1, + accessPolicy: 0 | 1, + publishPolicy: 0 | 1, +): Rfc64CatalogAccessPolicyRegistryV1 { + const registry = new Rfc64CatalogAccessPolicyRegistryV1({ + localAgentAddress, + resolveRemoteAgentAddress: async () => remoteAgentAddress, + }); + const policy = { + networkId: 'otp:20430', + contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy, + publishPolicy, + publishAuthority: publishPolicy === 0 ? CURATOR : null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: AUTHOR, + ownerAuthorityEra: '0', + }, + effectiveAt: '0', + issuedAt: '0', + } satisfies ContextGraphPolicyV1; + const roster = accessPolicy === 0 ? null : { + networkId: policy.networkId, + contextGraphId: policy.contextGraphId, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousRosterDigest: null, + policyDigest: POLICY_DIGEST, + administrativeDelegationDigest: null, + members: [ + { agentAddress: LOCAL_MEMBER, roles: ['holder', 'provider'] }, + { agentAddress: REMOTE_MEMBER, roles: ['holder', 'provider'] }, + ], + issuedAt: '0', + } satisfies MemberRosterV1; + registry.accept({ policy, policyDigest: POLICY_DIGEST, roster }); + return registry; +} + describe('RFC-64 public catalog native content transport v1', () => { it('accepts the exact-set bundle-byte ceiling and rejects one byte over it', () => { const ceiling = BigInt(RFC64_PUBLIC_CATALOG_EXACT_SET_BUNDLE_BYTES_MAX_V1); @@ -73,7 +134,16 @@ describe('RFC-64 public catalog native content transport v1', () => { ])).toThrow(/exceed.*ceiling/); }); - it('fetches exact directory and bundle digests across two live libp2p nodes', async () => { + // Only the publicly-readable cells are servable over the native content protocols: the serve path + // resolves by digest out of an agent-wide store and is not bound back to the authorizing graph, so + // private content stays denied (see requireCatalogPolicy). publishPolicy must not affect this — + // it governs finalized-VM admission only, never read/SWM authorization. + it.each([ + { accessPolicy: 0 as const, publishPolicy: 0 as const }, + { accessPolicy: 0 as const, publishPolicy: 1 as const }, + ])( + 'fetches exact directory and bundle digests for accessPolicy=$accessPolicy publishPolicy=$publishPolicy', + async ({ accessPolicy, publishPolicy }) => { const [authorNode, receiverNode] = await Promise.all([startNode(), startNode()]); await connect(receiverNode, authorNode); @@ -108,16 +178,29 @@ describe('RFC-64 public catalog native content transport v1', () => { const authorBundleReads = vi.fn(async (digest: Digest32V1) => bundles.get(digest) ?? null); const authorAuthorizations: string[] = []; const receiverAuthorizations: string[] = []; - const openPolicy = () => Object.freeze({ accessPolicy: 0 as const, policyDigest: POLICY_DIGEST }); + const authorPolicy = policyRegistry( + LOCAL_MEMBER, + REMOTE_MEMBER, + produced.head.payload.contextGraphId, + accessPolicy, + publishPolicy, + ); + const receiverPolicy = policyRegistry( + REMOTE_MEMBER, + LOCAL_MEMBER, + produced.head.payload.contextGraphId, + accessPolicy, + publishPolicy, + ); const authorTransport = new Rfc64PublicCatalogNativeTransportV1( new ProtocolRouter(authorNode), { readCatalogObjectByDigest: authorCatalogReads, readKaBundleByDigest: authorBundleReads, - authorizeOpenCatalogOperation: async (input) => { + authorizeCatalogOperation: async (input) => { authorAuthorizations.push(input.operation); - return openPolicy(); + return authorPolicy.authorize(input); }, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }, @@ -127,9 +210,9 @@ describe('RFC-64 public catalog native content transport v1', () => { { readCatalogObjectByDigest: async () => null, readKaBundleByDigest: async () => null, - authorizeOpenCatalogOperation: async (input) => { + authorizeCatalogOperation: async (input) => { receiverAuthorizations.push(input.operation); - return openPolicy(); + return receiverPolicy.authorize(input); }, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }, @@ -179,14 +262,17 @@ describe('RFC-64 public catalog native content transport v1', () => { 'ka-bundle-fetch-inbound', ]); expect(receiverAuthorizations).toEqual([ + 'catalog-object-fetch-outbound', 'catalog-object-fetch-outbound', 'catalog-object-fetch-outbound', 'ka-bundle-fetch-outbound', 'ka-bundle-fetch-outbound', ]); - }, 30_000); + }, + 30_000, + ); - it('denies a private-policy request before provider lookup', async () => { + it('denies an unauthorized request before provider lookup', async () => { const [authorNode, receiverNode] = await Promise.all([startNode(), startNode()]); await connect(receiverNode, authorNode); const providerRead = vi.fn(async () => null); @@ -195,10 +281,7 @@ describe('RFC-64 public catalog native content transport v1', () => { { readCatalogObjectByDigest: providerRead, readKaBundleByDigest: async () => null, - authorizeOpenCatalogOperation: async () => ({ - accessPolicy: 1, - policyDigest: POLICY_DIGEST, - }), + authorizeCatalogOperation: async () => null, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }, ); @@ -207,7 +290,7 @@ describe('RFC-64 public catalog native content transport v1', () => { { readCatalogObjectByDigest: async () => null, readKaBundleByDigest: async () => null, - authorizeOpenCatalogOperation: async () => ({ + authorizeCatalogOperation: async () => ({ accessPolicy: 0, policyDigest: POLICY_DIGEST, }), @@ -234,6 +317,204 @@ describe('RFC-64 public catalog native content transport v1', () => { expect(providerRead).not.toHaveBeenCalled(); }, 15_000); + it('denies a private-policy request before provider lookup on both content protocols', async () => { + const [authorNode, receiverNode] = await Promise.all([startNode(), startNode()]); + await connect(receiverNode, authorNode); + const providerObjectRead = vi.fn(async () => null); + const providerBundleRead = vi.fn(async () => null); + const authorTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(authorNode), + { + readCatalogObjectByDigest: providerObjectRead, + readKaBundleByDigest: providerBundleRead, + // A provider that has itself accepted a private cell must STILL refuse to serve that cell's + // content over the native protocols. The serve path resolves purely by the digest in the + // request, out of a store shared by every graph on the node, so serving private content here + // would hand it to anyone authorized under any public cell the node happens to hold. + authorizeCatalogOperation: async () => ({ + accessPolicy: 1, + policyDigest: POLICY_DIGEST, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + const receiverTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(receiverNode), + { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeCatalogOperation: async () => ({ + accessPolicy: 0, + policyDigest: POLICY_DIGEST, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + transports.push(authorTransport, receiverTransport); + authorTransport.start(); + receiverTransport.start(); + + const scope = { + networkId: 'otp:20430' as never, + contextGraphId: '0x1111111111111111111111111111111111111111/private-denied' as never, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0' as never, + catalogVersion: '1' as never, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}` as Digest32V1, + }; + + await expect(receiverTransport.fetchCatalogObject(authorNode.peerId, { + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + ...scope, + targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + targetObjectDigest: `0x${'82'.repeat(32)}` as Digest32V1, + }, { timeoutMs: 4_000 })).rejects.toThrow(/policy/); + expect(providerObjectRead).not.toHaveBeenCalled(); + + const encoded = encodeOpaqueKaBundleV1(UTF8.encode('private'), new Uint8Array()); + await expect(receiverTransport.fetchKaBundle(authorNode.peerId, { + kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + ...scope, + blobDigest: encoded.blobDigest, + byteLength: encoded.bundleBytes.byteLength.toString() as never, + }, { timeoutMs: 4_000 })).rejects.toThrow(/policy/); + expect(providerBundleRead).not.toHaveBeenCalled(); + }, 20_000); + + it('rechecks provider authorization after an awaited catalog-object miss', async () => { + const [authorNode, receiverNode] = await Promise.all([startNode(), startNode()]); + await connect(receiverNode, authorNode); + const providerRead = vi.fn(async () => null); + let providerAuthorizationChecks = 0; + const authorTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(authorNode), + { + readCatalogObjectByDigest: providerRead, + readKaBundleByDigest: async () => null, + authorizeCatalogOperation: async () => { + providerAuthorizationChecks += 1; + return providerAuthorizationChecks === 1 ? { + accessPolicy: 0, + policyDigest: POLICY_DIGEST, + } : null; + }, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + const receiverTransport = new Rfc64PublicCatalogNativeTransportV1( + new ProtocolRouter(receiverNode), + { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeCatalogOperation: async () => ({ + accessPolicy: 0, + policyDigest: POLICY_DIGEST, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }, + ); + transports.push(authorTransport, receiverTransport); + authorTransport.start(); + receiverTransport.start(); + + await expect(receiverTransport.fetchCatalogObject(authorNode.peerId, { + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + networkId: 'otp:20430' as never, + contextGraphId: '0x1111111111111111111111111111111111111111/revoked' as never, + subGraphName: 'member-subgraph' as never, + authorAddress: AUTHOR, + catalogEra: '0' as never, + catalogVersion: '1' as never, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}` as Digest32V1, + targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + targetObjectDigest: `0x${'82'.repeat(32)}` as Digest32V1, + }, { timeoutMs: 4_000 })).rejects.toMatchObject({ + code: 'catalog-native-policy-denied', + }); + expect(providerRead).toHaveBeenCalledOnce(); + expect(providerAuthorizationChecks).toBe(2); + }, 15_000); + + it('rechecks current authorization after the outbound bundle-fetch await', async () => { + const bundle = encodeOpaqueKaBundleV1(UTF8.encode('x'), new Uint8Array()).bundleBytes; + const response = new Uint8Array(bundle.byteLength + 1); + response[0] = 1; + response.set(bundle, 1); + const send = vi.fn(async () => response); + let authorizationChecks = 0; + const transport = new Rfc64PublicCatalogNativeTransportV1({ + register: () => {}, + unregister: () => {}, + send, + } as unknown as ProtocolRouter, { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeCatalogOperation: async () => { + authorizationChecks += 1; + return authorizationChecks === 1 ? { + accessPolicy: 0, + policyDigest: POLICY_DIGEST, + } : null; + }, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + transports.push(transport); + transport.start(); + + await expect(transport.fetchKaBundle('remote-peer', { + kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, + networkId: 'otp:20430' as never, + contextGraphId: '0x1111111111111111111111111111111111111111/recheck' as never, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0' as never, + catalogVersion: '1' as never, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}` as Digest32V1, + blobDigest: encodeOpaqueKaBundleV1(UTF8.encode('x'), new Uint8Array()).blobDigest, + byteLength: bundle.byteLength.toString() as never, + })).rejects.toMatchObject({ code: 'catalog-native-policy-denied' }); + expect(send).toHaveBeenCalledOnce(); + expect(authorizationChecks).toBe(2); + }); + + it('rejects a stale registry digest before sending a native fetch request', async () => { + const send = vi.fn(async () => Uint8Array.of(0)); + const transport = new Rfc64PublicCatalogNativeTransportV1({ + register: () => {}, + unregister: () => {}, + send, + } as unknown as ProtocolRouter, { + readCatalogObjectByDigest: async () => null, + readKaBundleByDigest: async () => null, + authorizeCatalogOperation: async () => ({ + accessPolicy: 0, + policyDigest: `0x${'99'.repeat(32)}` as Digest32V1, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + transports.push(transport); + transport.start(); + + await expect(transport.fetchCatalogObject('remote-peer', { + kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, + networkId: 'otp:20430' as never, + contextGraphId: '0x1111111111111111111111111111111111111111/stale' as never, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0' as never, + catalogVersion: '1' as never, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}` as Digest32V1, + targetObjectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + targetObjectDigest: `0x${'82'.repeat(32)}` as Digest32V1, + })).rejects.toMatchObject({ code: 'catalog-native-policy-denied' }); + expect(send).not.toHaveBeenCalled(); + }); + it('rejects a generic signature proof minted for another exact signature variant', async () => { const produced = await produceEmptyAuthorCatalogGenesisV1({ scope: { @@ -285,7 +566,7 @@ describe('RFC-64 public catalog native content transport v1', () => { const transport = new Rfc64PublicCatalogNativeTransportV1(router, { readCatalogObjectByDigest: async () => null, readKaBundleByDigest: async () => null, - authorizeOpenCatalogOperation: async () => ({ + authorizeCatalogOperation: async () => ({ accessPolicy: 0, policyDigest: POLICY_DIGEST, }), diff --git a/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts b/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts index 3e043aeacf..82213ea4ce 100644 --- a/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts @@ -4,17 +4,24 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, DKGNode, ProtocolRouter, type AuthorCatalogScopeV1, + type ContextGraphPolicyV1, type Digest32V1, type EvmAddressV1, + type MemberRosterV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { + Rfc64CatalogAccessPolicyRegistryV1, + type Rfc64CatalogAccessAuthorizationInputV1, +} from '../src/rfc64/catalog-access-policy-v1.js'; import { openRfc64PersistenceV1, type Rfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; import { RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, @@ -23,7 +30,6 @@ import { Rfc64PublicCatalogTransportV1, encodeRfc64PublicCatalogHeadAnnouncementV1, parseRfc64PublicCatalogHeadAnnouncementV1, - type Rfc64PublicCatalogAuthorizationInputV1, type Rfc64PublicCatalogHeadAnnouncementV1, } from '../src/rfc64/public-catalog-transport-v1.js'; @@ -31,6 +37,11 @@ const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; const POLICY_DIGEST = `0x${'71'.repeat(32)}` as Digest32V1; const DELEGATION_DIGEST = `0x${'72'.repeat(32)}` as Digest32V1; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/gate-1' as const; +const LOCAL_MEMBER = '0x3333333333333333333333333333333333333333' as EvmAddressV1; +const REMOTE_MEMBER = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const CURATOR = '0x5555555555555555555555555555555555555555' as EvmAddressV1; const temporaryDirectories: string[] = []; const nodes: DKGNode[] = []; @@ -86,7 +97,7 @@ async function stageOpenCatalogHead( ): Promise { const scope = { networkId: 'otp:20430', - contextGraphId: '0x1111111111111111111111111111111111111111/gate-1', + contextGraphId: CONTEXT_GRAPH_ID, governanceChainId: '20430', governanceContractAddress: '0x2222222222222222222222222222222222222222', ownershipTransitionDigest: null, @@ -130,8 +141,67 @@ const OPEN_POLICY = async () => Object.freeze({ policyDigest: POLICY_DIGEST, }); -describe('RFC-64 public/open author catalog transport v1', () => { - it('announces and fetches one exact signed head across two live libp2p nodes', async () => { +function policyRegistry( + localAgentAddress: EvmAddressV1, + remoteAgentAddress: EvmAddressV1, + accessPolicy: 0 | 1, + publishPolicy: 0 | 1, +): Rfc64CatalogAccessPolicyRegistryV1 { + const registry = new Rfc64CatalogAccessPolicyRegistryV1({ + localAgentAddress, + resolveRemoteAgentAddress: async () => remoteAgentAddress, + }); + const policy = { + networkId: 'otp:20430', + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy, + publishPolicy, + publishAuthority: publishPolicy === 0 ? CURATOR : null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: AUTHOR, + ownerAuthorityEra: '0', + }, + effectiveAt: '0', + issuedAt: '0', + } satisfies ContextGraphPolicyV1; + const roster = accessPolicy === 0 ? null : { + networkId: policy.networkId, + contextGraphId: policy.contextGraphId, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousRosterDigest: null, + policyDigest: POLICY_DIGEST, + administrativeDelegationDigest: null, + members: [ + { agentAddress: LOCAL_MEMBER, roles: ['holder', 'provider'] }, + { agentAddress: REMOTE_MEMBER, roles: ['holder', 'provider'] }, + ], + issuedAt: '0', + } satisfies MemberRosterV1; + registry.accept({ policy, policyDigest: POLICY_DIGEST, roster }); + return registry; +} + +describe('RFC-64 author catalog transport v1', () => { + it.each([ + { accessPolicy: 0 as const, publishPolicy: 0 as const }, + { accessPolicy: 0 as const, publishPolicy: 1 as const }, + { accessPolicy: 1 as const, publishPolicy: 0 as const }, + { accessPolicy: 1 as const, publishPolicy: 1 as const }, + ])( + 'announces and fetches one exact signed head for accessPolicy=$accessPolicy publishPolicy=$publishPolicy', + async ({ accessPolicy, publishPolicy }) => { const [authorNode, receiverNode, authorPersistence, receiverPersistence] = await Promise.all([ startNode(), startNode(), @@ -145,16 +215,28 @@ describe('RFC-64 public/open author catalog transport v1', () => { announcement: Rfc64PublicCatalogHeadAnnouncementV1; remotePeerId: string; }> = []; - const authorAuthorizations: Rfc64PublicCatalogAuthorizationInputV1[] = []; - const receiverAuthorizations: Rfc64PublicCatalogAuthorizationInputV1[] = []; + const authorAuthorizations: Rfc64CatalogAccessAuthorizationInputV1[] = []; + const receiverAuthorizations: Rfc64CatalogAccessAuthorizationInputV1[] = []; + const authorPolicy = policyRegistry( + LOCAL_MEMBER, + REMOTE_MEMBER, + accessPolicy, + publishPolicy, + ); + const receiverPolicy = policyRegistry( + REMOTE_MEMBER, + LOCAL_MEMBER, + accessPolicy, + publishPolicy, + ); const authorTransport = new Rfc64PublicCatalogTransportV1( new ProtocolRouter(authorNode), { controlObjects: authorPersistence.controlObjects, - authorizeOpenCatalogOperation: async (input) => { + authorizeCatalogOperation: async (input) => { authorAuthorizations.push(input); - return OPEN_POLICY(); + return authorPolicy.authorize(input); }, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, onCatalogHeadAvailable: async () => {}, @@ -164,9 +246,9 @@ describe('RFC-64 public/open author catalog transport v1', () => { new ProtocolRouter(receiverNode), { controlObjects: receiverPersistence.controlObjects, - authorizeOpenCatalogOperation: async (input) => { + authorizeCatalogOperation: async (input) => { receiverAuthorizations.push(input); - return OPEN_POLICY(); + return receiverPolicy.authorize(input); }, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, onCatalogHeadAvailable: async (received, remotePeerId) => { @@ -221,10 +303,13 @@ describe('RFC-64 public/open author catalog transport v1', () => { 'announce-inbound', 'fetch-outbound', 'fetch-outbound', + 'fetch-outbound', ]); - }, 30_000); + }, + 30_000, + ); - it('denies private-policy fetch before revealing cache hit or miss state', async () => { + it('denies an unauthorized fetch before revealing cache hit or miss state', async () => { const [providerNode, requesterNode] = await Promise.all([startNode(), startNode()]); await connect(requesterNode, providerNode); const getVerifiedObject = vi.fn(async () => null); @@ -245,10 +330,7 @@ describe('RFC-64 public/open author catalog transport v1', () => { new ProtocolRouter(providerNode), { controlObjects: { getVerifiedObject }, - authorizeOpenCatalogOperation: async () => ({ - accessPolicy: 1, - policyDigest: POLICY_DIGEST, - }), + authorizeCatalogOperation: async () => null, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, onCatalogHeadAvailable: async () => {}, }, @@ -257,7 +339,7 @@ describe('RFC-64 public/open author catalog transport v1', () => { new ProtocolRouter(requesterNode), { controlObjects: { getVerifiedObject: vi.fn(async () => null) }, - authorizeOpenCatalogOperation: OPEN_POLICY, + authorizeCatalogOperation: OPEN_POLICY, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, onCatalogHeadAvailable: async () => {}, }, @@ -274,6 +356,82 @@ describe('RFC-64 public/open author catalog transport v1', () => { expect(getVerifiedObject).not.toHaveBeenCalled(); }, 15_000); + it('rechecks current authorization after the outbound fetch await', async () => { + const send = vi.fn(async () => Uint8Array.of(0)); + const router = { + register: () => {}, + unregister: () => {}, + send, + } as unknown as ProtocolRouter; + let authorizationChecks = 0; + const transport = new Rfc64PublicCatalogTransportV1(router, { + controlObjects: { getVerifiedObject: async () => null }, + authorizeCatalogOperation: async () => { + authorizationChecks += 1; + return authorizationChecks === 1 ? { + accessPolicy: 1, + policyDigest: POLICY_DIGEST, + } : null; + }, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }); + transports.push(transport); + transport.start(); + const announcement = { + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: 'otp:20430', + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + catalogVersion: '0', + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}`, + signatureVariantDigest: `0x${'82'.repeat(32)}`, + } as Rfc64PublicCatalogHeadAnnouncementV1; + + await expect(transport.fetchCatalogHead('remote-peer', announcement)) + .rejects.toMatchObject({ code: 'catalog-transport-policy-denied' }); + expect(send).toHaveBeenCalledOnce(); + expect(authorizationChecks).toBe(2); + }); + + it('rejects a stale registry digest before sending any wire request', async () => { + const send = vi.fn(async () => Uint8Array.of(0)); + const transport = new Rfc64PublicCatalogTransportV1({ + register: () => {}, + unregister: () => {}, + send, + } as unknown as ProtocolRouter, { + controlObjects: { getVerifiedObject: async () => null }, + authorizeCatalogOperation: async () => ({ + accessPolicy: 1, + policyDigest: `0x${'99'.repeat(32)}` as Digest32V1, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }); + transports.push(transport); + transport.start(); + const announcement = { + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: 'otp:20430', + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + catalogVersion: '0', + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}`, + signatureVariantDigest: `0x${'82'.repeat(32)}`, + } as Rfc64PublicCatalogHeadAnnouncementV1; + + await expect(transport.fetchCatalogHead('remote-peer', announcement)) + .rejects.toMatchObject({ code: 'catalog-transport-policy-denied' }); + expect(send).not.toHaveBeenCalled(); + }); + it('round-trips only exact canonical announcement fields', () => { const announcement = Object.freeze({ kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, From ea05841e182af0a19abd1c7e0b90d6a87ec465f5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 21 Jul 2026 13:12:23 +0200 Subject: [PATCH 168/292] feat(agent): compose RFC-64 generic catalog authoring service/API (CP1) Compose the service/API generic-authoring slice onto the authorized transport stack. Adds the policy-neutral public authoring surface used by both publicly readable cells: - acceptRfc64CatalogAccessSnapshotV1 (accepts exact public/open and public/curated policy snapshots with no roster; private requires a current member roster and fails closed without one), - publishAuthorCatalogGenesisV1 / publishAuthorCatalogExactSetSuccessorV1 (generic author/subgraph/catalog-era scope; root + named subgraphs; not routed through the legacy open/root-only assertions), - rfc64CatalogAccessPolicyAuthority create-option, package-root + type exports. Two test-coverage gaps found while reviewing this slice are closed here, because both were mutation-dead -- they passed whether or not the product behaved: - The private branch of isSwmAuthorAuthorized was unfalsifiable: the suite's own memberRoster() helper enrols every wallet the tests author with, so deleting the roster clause at either call site left the whole suite green. Adds an off-roster private-cell author negative covering both the author side (assertAcceptedPolicyMatchesCatalogScope) and the receive side (announceCatalogHead -> resolveTrustedCatalogScope), with a positive control on an enrolled roster so the denial is attributable to the roster check. - The new public-API typecheck introduced each params value as `declare const x: T` and passed it to a method taking exactly T -- a T-assignable-to-T no-op that proves only the export name exists. A rename or a newly required field on the published signatures compiled clean. Rebuilt around real object literals (matching the sibling applied-inventory-digest typecheck), so field names are load-bearing. Validation on this exact tree: agent build (tsc + test:types + test:package-root) green; full RFC-64 unit regression green. Land as small reviewable PRs on integration/rfc64-devnet; do NOT merge to main. Co-Authored-By: Claude Opus 4.8 --- packages/agent/scripts/test-package-root.mjs | 13 + packages/agent/src/dkg-agent-rfc64-catalog.ts | 158 +++++++++- packages/agent/src/dkg-agent-types.ts | 14 + packages/agent/src/dkg-agent.ts | 7 + packages/agent/src/index.ts | 12 + .../src/rfc64/public-catalog-service-v1.ts | 163 ++++++++-- .../public-catalog-successor-producer-v1.ts | 14 +- ...c64-catalog-policy-api-public.typecheck.ts | 93 ++++++ ...kg-agent-native-wiring.integration.test.ts | 154 +++++++++- .../rfc64-public-catalog-service-v1.test.ts | 285 +++++++++++++++++- ...blic-catalog-successor-producer-v1.test.ts | 31 +- 11 files changed, 891 insertions(+), 53 deletions(-) create mode 100644 packages/agent/test/rfc64-catalog-policy-api-public.typecheck.ts diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 4c4685a7af..5a1c58c755 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -23,6 +23,19 @@ if ( ) { throw new Error('published agent entry points did not expose required root APIs'); } +const requiredCatalogMethods = [ + 'acceptRfc64CatalogAccessSnapshotV1', + 'publishAuthorCatalogGenesisV1', + 'publishAuthorCatalogExactSetSuccessorV1', +]; +for (const method of requiredCatalogMethods) { + if ( + typeof root.DKGAgent.prototype[method] !== 'function' + || typeof legacyAgent.DKGAgent.prototype[method] !== 'function' + ) { + throw new Error(`published DKGAgent entry points did not expose ${method}`); + } +} if ( !Array.isArray(root.RFC64_POLICY_CELLS_V1) || !Object.isFrozen(root.RFC64_POLICY_CELLS_V1) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 1318abcdc9..8d4bfcd093 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -54,8 +54,13 @@ import { ethers } from 'ethers'; import { DKGAgentBase } from './dkg-agent-base.js'; import type { DKGAgent } from './dkg-agent.js'; +import type { Rfc64CatalogAccessPolicyAuthorityConfigV1 } from './dkg-agent-types.js'; import type { Rfc64AuthorCatalogEip191SignerV1 } from './rfc64/author-catalog-producer.js'; import type { AcceptedOpenCatalogPolicyV1 } from './rfc64/open-catalog-policy-v1.js'; +import type { + AcceptRfc64CatalogAccessSnapshotInputV1, + AcceptedRfc64CatalogAccessSnapshotV1, +} from './rfc64/catalog-access-policy-v1.js'; import type { Rfc64PublicCatalogReceiverReconcilerV1 } from './rfc64/public-catalog-receiver-v1.js'; import type { Rfc64PersistenceV1 } from './rfc64/persistence-v1.js'; import { @@ -97,6 +102,12 @@ export interface Rfc64OpenCatalogAuthorSignerV1 { signMessage(message: Uint8Array): Promise; } +const LEGACY_OPEN_ONLY_LOCAL_AGENT_ADDRESS = + '0x0000000000000000000000000000000000000001' as EvmAddressV1; + +/** Policy-neutral catalog author signer; legacy open APIs use the same shape. */ +export type Rfc64CatalogAuthorSignerV1 = Rfc64OpenCatalogAuthorSignerV1; + export interface AcceptOpenContextGraphPolicyInputV1 { readonly networkId: NetworkIdV1; readonly contextGraphId: ContextGraphIdV1; @@ -106,6 +117,10 @@ export interface AcceptOpenContextGraphPolicyInputV1 { readonly effectiveAt?: TimestampMsV1; } +/** Already-authoritative current policy snapshot supplied to the catalog plane. */ +export type AcceptRfc64CatalogAccessSnapshotParamsV1 = + AcceptRfc64CatalogAccessSnapshotInputV1; + export interface PublishOpenAuthorCatalogGenesisParamsV1 { readonly networkId: NetworkIdV1; readonly contextGraphId: ContextGraphIdV1; @@ -125,6 +140,16 @@ export interface PublishOpenAuthorCatalogGenesisParamsV1 { readonly ownerAuthorityEra?: DecimalU64V1; } +export interface PublishAuthorCatalogGenesisParamsV1 { + /** Exact accepted policy-bound author catalog scope. */ + readonly scope: AuthorCatalogScopeV1; + readonly author: Rfc64CatalogAuthorSignerV1; + readonly peers: readonly string[]; + readonly issuedAt?: TimestampMsV1; + readonly catalogIssuerDelegationEffectiveAt: TimestampMsV1; + readonly catalogIssuerDelegationExpiresAt: TimestampMsV1; +} + export interface Rfc64StagedAuthorCatalogHeadRefV1 { readonly objectDigest: Digest32V1; readonly signatureVariantDigest: Digest32V1; @@ -193,6 +218,44 @@ export function snapshotRfc64CatalogDeploymentProfileV1( }); } +/** Snapshot the function-bearing create-time authority without trusting caller mutation. */ +export function snapshotRfc64CatalogAccessPolicyAuthorityV1( + input: Rfc64CatalogAccessPolicyAuthorityConfigV1 | undefined, +): Readonly | undefined { + if (input === undefined) return undefined; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('rfc64CatalogAccessPolicyAuthority must be a plain object'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('rfc64CatalogAccessPolicyAuthority must be a plain object'); + } + const keys = Object.keys(input).sort(); + if ( + keys.length !== 2 + || keys[0] !== 'localAgentAddress' + || keys[1] !== 'resolveRemoteAgentAddress' + ) { + throw new TypeError('rfc64CatalogAccessPolicyAuthority has unknown or missing fields'); + } + if ( + typeof input.localAgentAddress !== 'string' + || !ethers.isAddress(input.localAgentAddress) + || input.localAgentAddress === ethers.ZeroAddress + ) { + throw new TypeError('rfc64CatalogAccessPolicyAuthority.localAgentAddress is invalid'); + } + if (typeof input.resolveRemoteAgentAddress !== 'function') { + throw new TypeError( + 'rfc64CatalogAccessPolicyAuthority.resolveRemoteAgentAddress must be a function', + ); + } + return Object.freeze({ + localAgentAddress: input.localAgentAddress.toLowerCase() as EvmAddressV1, + resolveRemoteAgentAddress: input.resolveRemoteAgentAddress, + }); +} + export interface PublishOpenAuthorCatalogSuccessorParamsV1 { /** Exact durable predecessor returned by genesis or a prior successor. */ readonly previousHead: Rfc64StagedAuthorCatalogHeadRefV1; @@ -226,6 +289,12 @@ export interface PublishOpenAuthorCatalogExactSetSuccessorParamsV1 { readonly peers: readonly string[]; } +export type PublishAuthorCatalogExactSetSuccessorParamsV1 = + PublishOpenAuthorCatalogExactSetSuccessorParamsV1; + +export type PublishAuthorCatalogGenesisResultV1 = + PublishOpenAuthorCatalogGenesisResultV1; + export interface PublishOpenAuthorCatalogSuccessorResultV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; readonly headObjectDigest: Digest32V1; @@ -272,6 +341,9 @@ export interface PublishOpenAuthorCatalogExactSetSuccessorResultV1 { readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; } +export type PublishAuthorCatalogExactSetSuccessorResultV1 = + PublishOpenAuthorCatalogExactSetSuccessorResultV1; + export class Rfc64CatalogMethods extends DKGAgentBase { /** * Construct + start the public catalog service on the production router. @@ -281,9 +353,24 @@ export class Rfc64CatalogMethods extends DKGAgentBase { if (this.rfc64PublicCatalogServiceV1 !== undefined) return; const persistence = this.rfc64PersistenceV1; if (persistence === undefined) return; + const configuredAuthority = this.config.rfc64CatalogAccessPolicyAuthority; + const defaultAgentAddress = this.defaultAgentAddress?.toLowerCase(); + const fallbackLocalAgentAddress = defaultAgentAddress !== undefined + && ethers.isAddress(defaultAgentAddress) + && defaultAgentAddress !== ethers.ZeroAddress + ? defaultAgentAddress as EvmAddressV1 + : LEGACY_OPEN_ONLY_LOCAL_AGENT_ADDRESS; + const accessPolicyAuthority = configuredAuthority ?? Object.freeze({ + localAgentAddress: fallbackLocalAgentAddress, + // Legacy-open compatibility only. Private snapshot acceptance is denied + // below unless the caller supplied an explicit authenticated resolver. + resolveRemoteAgentAddress: async () => null, + }); const service = new Rfc64PublicCatalogServiceV1({ router: this.router, controlObjects: persistence.controlObjects, + accessPolicyAuthority, + privatePolicyAuthorityConfigured: configuredAuthority !== undefined, native: this.createRfc64PublicCatalogNativeOptionsV1(), receiver: { onError: (announcement, error) => { @@ -323,6 +410,40 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return this.requireRfc64PublicCatalogServiceV1().acceptOpenPolicy(input); } + /** + * Accept an independently verified current ContextGraphPolicyV1 snapshot and + * its conditional MemberRosterV1 into the catalog data-plane authorizer. + * This method does not verify administrative/finality authority itself. + */ + acceptRfc64CatalogAccessSnapshotV1( + this: DKGAgent, + input: AcceptRfc64CatalogAccessSnapshotParamsV1, + ): AcceptedRfc64CatalogAccessSnapshotV1 { + return this.requireRfc64PublicCatalogServiceV1().acceptPolicySnapshot(input); + } + + /** Author path for a previously accepted policy snapshot in any V1 cell. */ + async publishAuthorCatalogGenesisV1( + this: DKGAgent, + params: PublishAuthorCatalogGenesisParamsV1, + ): Promise { + const authorAddress = params.author.address.toLowerCase() as EvmAddressV1; + if (authorAddress !== params.scope.authorAddress) { + throw new Error('RFC-64 genesis author must equal the exact catalog scope author'); + } + return this.requireRfc64PublicCatalogServiceV1().publishAuthorCatalogGenesis({ + scope: params.scope, + signer: { + issuer: authorAddress, + signDigest: (objectDigest) => params.author.signMessage(objectDigest), + }, + issuedAt: params.issuedAt ?? (Date.now().toString() as TimestampMsV1), + catalogIssuerDelegationEffectiveAt: params.catalogIssuerDelegationEffectiveAt, + catalogIssuerDelegationExpiresAt: params.catalogIssuerDelegationExpiresAt, + peers: params.peers, + }); + } + /** * Author path: accept the CG's open policy, durably stage its signed direct- * author issuer delegation, durably stage the bound genesis, then best-effort @@ -429,15 +550,37 @@ export class Rfc64CatalogMethods extends DKGAgentBase { this: DKGAgent, params: PublishOpenAuthorCatalogExactSetSuccessorParamsV1, ): Promise { + const service = this.requireRfc64PublicCatalogServiceV1(); + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) { + throw new Error('RFC-64 persistence is not available'); + } + const history = await loadBoundedAuthorCatalogHistoryV1( + persistence, + params.previousHead, + ); + const scope = deriveAuthorCatalogScopeFromHeadV1(history.previousHead.payload); + if (scope.subGraphName !== null) { + throw new Error('RFC-64 public/open compatibility successor requires the root lane'); + } + service.acceptedOpenPolicyDigestForCatalogScope(scope); + return this.publishAuthorCatalogExactSetSuccessorV1(params); + } + + /** Exact-set successor path for any locally accepted policy cell. */ + async publishAuthorCatalogExactSetSuccessorV1( + this: DKGAgent, + params: PublishAuthorCatalogExactSetSuccessorParamsV1, + ): Promise { const service = this.requireRfc64PublicCatalogServiceV1(); const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(params.peers); const persistence = this.rfc64PersistenceV1; if (persistence === undefined) { throw new Error('RFC-64 persistence is not available'); } - const history = await loadPublicOpenRootLaneHistoryV1(persistence, params.previousHead); + const history = await loadBoundedAuthorCatalogHistoryV1(persistence, params.previousHead); const scope = deriveAuthorCatalogScopeFromHeadV1(history.previousHead.payload); - const policyDigest = service.acceptedOpenPolicyDigestForCatalogScope(scope); + const policyDigest = service.acceptedPolicyDigestForCatalogScope(scope); const authorAddress = params.author.address.toLowerCase() as EvmAddressV1; if (authorAddress !== scope.authorAddress) { throw new Error('RFC-64 successor author must equal the exact predecessor author'); @@ -806,16 +949,16 @@ function countToSafeInteger(value: CountV1, label: string): number { return parsed; } -interface PublicOpenRootLaneHistoryV1 { +interface BoundedAuthorCatalogHistoryV1 { readonly previousHead: SignedAuthorCatalogHeadEnvelopeV1; readonly previousDirectoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; readonly previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null; } -async function loadPublicOpenRootLaneHistoryV1( +async function loadBoundedAuthorCatalogHistoryV1( persistence: Rfc64PersistenceV1, ref: Rfc64StagedAuthorCatalogHeadRefV1, -): Promise { +): Promise { const storedHead = await persistence.controlObjects.getVerifiedObject({ objectDigest: ref.objectDigest, signatureVariantDigest: ref.signatureVariantDigest, @@ -825,12 +968,11 @@ async function loadPublicOpenRootLaneHistoryV1( assertSignedAuthorCatalogHeadEnvelopeV1(storedHead.envelope); const previousHead = storedHead.envelope; if ( - previousHead.payload.subGraphName !== null - || previousHead.payload.bucketCount !== '1' + previousHead.payload.bucketCount !== '1' || previousHead.payload.directoryHeight !== '0' || BigInt(previousHead.payload.totalRows) > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) ) { - throw new Error('RFC-64 predecessor is outside the public/open root-lane slice'); + throw new Error('RFC-64 predecessor is outside the bounded one-bucket successor slice'); } const storedRoot = await persistence.controlObjects.getVerifiedObjectByDigest({ diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index dd419e1ef7..a9a2d0b002 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -31,6 +31,7 @@ import type { ContextGraphJoinPolicyMode as CoreContextGraphJoinPolicyMode, ContextGraphJoinPolicyRecord as CoreContextGraphJoinPolicyRecord, CatalogSealDeploymentProfileV1, + EvmAddressV1, } from '@origintrail-official/dkg-core'; import type { PhaseCallback, @@ -1041,6 +1042,14 @@ export interface ReplicationEvent { export type ReplicationEventSink = (event: ReplicationEvent) => void; +export interface Rfc64CatalogAccessPolicyAuthorityConfigV1 { + readonly localAgentAddress: EvmAddressV1; + /** Exact authenticated libp2p-peer to agent-wallet binding. */ + readonly resolveRemoteAgentAddress: ( + remotePeerId: string, + ) => Promise; +} + export interface DKGAgentConfig { name: string; /** Selected genesis document. Defaults to the compatibility Base testnet genesis. */ @@ -1055,6 +1064,11 @@ export interface DKGAgentConfig { * announcement wire data. */ rfc64CatalogDeploymentProfile?: CatalogSealDeploymentProfileV1; + /** + * Explicit agent-identity authority required before accepting a private + * RFC-64 catalog policy. Omission preserves the legacy open-only lane. + */ + rfc64CatalogAccessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1; /** * public-projection enable flag. When set, a private CG's confirmed VM * publishes emit/refresh a verifiable public projection (the floor: existence, diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 55e3170640..5b88cba5b0 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -357,6 +357,7 @@ import { type DurableSyncResult, type SharedMemorySyncResult, type DKGAgentConfig, + type Rfc64CatalogAccessPolicyAuthorityConfigV1, type DKGAgentACKTransportOptions, type ImportedArtifactByteStore, type ReplicationEvent, @@ -402,6 +403,7 @@ import { CclPolicyMethods } from './dkg-agent-ccl.js'; import { EndorseVerifyMethods } from './dkg-agent-endorse.js'; import { Rfc64CatalogMethods, + snapshotRfc64CatalogAccessPolicyAuthorityV1, snapshotRfc64CatalogDeploymentProfileV1, } from './dkg-agent-rfc64-catalog.js'; import { ContextGraphRegistryMethods } from './dkg-agent-cg-registry.js'; @@ -457,6 +459,7 @@ export type { SharedMemorySyncDiagnostics, CatchupSyncDiagnostics, DKGAgentConfig, + Rfc64CatalogAccessPolicyAuthorityConfigV1, DKGAgentACKTransportOptions, ImportedArtifactByteStore, }; @@ -698,6 +701,9 @@ export class DKGAgent extends DKGAgentBase { const rfc64CatalogDeploymentProfile = snapshotRfc64CatalogDeploymentProfileV1( config.rfc64CatalogDeploymentProfile, ); + const rfc64CatalogAccessPolicyAuthority = snapshotRfc64CatalogAccessPolicyAuthorityV1( + config.rfc64CatalogAccessPolicyAuthority, + ); let wallet: DKGAgentWallet; if (config.dataDir) { try { @@ -802,6 +808,7 @@ export class DKGAgent extends DKGAgentBase { ...config, genesisId, networkIdentity, + rfc64CatalogAccessPolicyAuthority, rfc64CatalogDeploymentProfile, }; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 9bf1035b5c..56d19a353c 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -110,6 +110,17 @@ export { type PolicyApprovalBinding, } from './ccl-policy.js'; export { DKGAgent } from './dkg-agent.js'; +export type { + AcceptRfc64CatalogAccessSnapshotParamsV1, + PublishAuthorCatalogExactSetSuccessorParamsV1, + PublishAuthorCatalogExactSetSuccessorResultV1, + PublishAuthorCatalogGenesisParamsV1, + PublishAuthorCatalogGenesisResultV1, + Rfc64CatalogAuthorSignerV1, +} from './dkg-agent-rfc64-catalog.js'; +export type { + AcceptedRfc64CatalogAccessSnapshotV1, +} from './rfc64/catalog-access-policy-v1.js'; export { contextGraphPriority, countSyncPriorityClasses, @@ -175,6 +186,7 @@ export { InvalidContentError, StaleSenderKeyTargetError, type DKGAgentConfig, + type Rfc64CatalogAccessPolicyAuthorityConfigV1, type DKGAgentACKTransportOptions, type ContextGraphSub, type ContextGraphDiscoveryMetadata, diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 77f58a02c5..de7f6bf129 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -29,7 +29,9 @@ import { type SendOptions, type SignedControlEnvelopeV1, type AuthorCatalogScopeV1, + type ContextGraphIdV1, type Digest32V1, + type NetworkIdV1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; import { @@ -47,12 +49,17 @@ import type { StageVerifiedControlObjectsResultV1, } from './control-object-store-v1.js'; import { - Rfc64AcceptedOpenCatalogPolicyRegistryV1, buildOpenOwnerContextGraphPolicyV1, computeOpenContextGraphPolicyDigestV1, type AcceptedOpenCatalogPolicyV1, type BuildOpenOwnerContextGraphPolicyInputV1, } from './open-catalog-policy-v1.js'; +import { + Rfc64CatalogAccessPolicyRegistryV1, + type AcceptRfc64CatalogAccessSnapshotInputV1, + type AcceptedRfc64CatalogAccessSnapshotV1, + type Rfc64CatalogAccessPolicyRegistryOptionsV1, +} from './catalog-access-policy-v1.js'; import { Rfc64PublicCatalogReceiverV1, type Rfc64PublicCatalogReceiverReconcilerV1, @@ -69,7 +76,6 @@ import { produceDirectAuthorCatalogIssuerDelegationV1, } from './public-catalog-issuer-delegation-v1.js'; import { - deriveRfc64PublicOpenCatalogScopeV1, type Rfc64PublicOpenCatalogTrustedScopeResolverV1, } from './public-catalog-native-reconciler-v1.js'; import type { @@ -93,6 +99,10 @@ const UTF8 = new TextEncoder(); export interface Rfc64PublicCatalogServiceOptionsV1 { readonly router: ProtocolRouter; readonly controlObjects: Rfc64ControlObjectOperationsV1; + /** Explicit local/remote agent-identity authority for private catalog decisions. */ + readonly accessPolicyAuthority: Rfc64CatalogAccessPolicyRegistryOptionsV1; + /** False only for the DKGAgent legacy-open compatibility authority. */ + readonly privatePolicyAuthorityConfigured?: boolean; readonly receiver?: Rfc64PublicCatalogReceiverOptionsV1; /** Full production native content/reconciliation path. Omission is diagnostic-only. */ readonly native?: Rfc64PublicCatalogServiceNativeOptionsV1; @@ -150,6 +160,11 @@ export interface PublishOpenAuthorCatalogGenesisInputV1 { readonly peers: readonly string[]; } +export type PublishAuthorCatalogGenesisInputV1 = Omit< + PublishOpenAuthorCatalogGenesisInputV1, + 'policy' +>; + export interface PublishOpenAuthorCatalogGenesisResultV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; readonly headObjectDigest: Digest32V1; @@ -190,16 +205,19 @@ export class Rfc64PublicCatalogServiceV1 { readonly #verifyIssuerSignature: ( envelope: SignedControlEnvelopeV1, ) => Promise; - readonly #policies = new Rfc64AcceptedOpenCatalogPolicyRegistryV1(); + readonly #policies: Rfc64CatalogAccessPolicyRegistryV1; readonly #receiver: Rfc64PublicCatalogReceiverV1; readonly #transport: Rfc64PublicCatalogTransportV1; readonly #nativeTransport: Rfc64PublicCatalogNativeTransportV1 | undefined; readonly #transportTimeoutMs: number; + readonly #privatePolicyAuthorityConfigured: boolean; #started = false; #closed = false; constructor(options: Rfc64PublicCatalogServiceOptionsV1) { this.#controlObjects = options.controlObjects; + this.#policies = new Rfc64CatalogAccessPolicyRegistryV1(options.accessPolicyAuthority); + this.#privatePolicyAuthorityConfigured = options.privatePolicyAuthorityConfigured ?? true; this.#verifyIssuerSignature = options.verifyIssuerSignature ?? verifyControlEnvelopeIssuerSignatureV1; this.#transportTimeoutMs = options.transportTimeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS; @@ -257,10 +275,49 @@ export class Rfc64PublicCatalogServiceV1 { acceptOpenPolicy( input: BuildOpenOwnerContextGraphPolicyInputV1, ): AcceptedOpenCatalogPolicyV1 { - return this.#policies.accept(buildOpenOwnerContextGraphPolicyV1(input)); + const policy = buildOpenOwnerContextGraphPolicyV1(input); + const accepted = this.acceptPolicySnapshot({ + policy, + policyDigest: computeOpenContextGraphPolicyDigestV1(policy), + }); + return Object.freeze({ policy: accepted.policy, policyDigest: accepted.policyDigest }); + } + + /** + * Accept one policy/optional-roster snapshot that already crossed the + * administrative/finality authority boundary. All four access/publish cells + * are retained; a roster is required exactly when accessPolicy is private. + */ + acceptPolicySnapshot( + input: AcceptRfc64CatalogAccessSnapshotInputV1, + ): AcceptedRfc64CatalogAccessSnapshotV1 { + if (input?.policy?.accessPolicy === 1 && !this.#privatePolicyAuthorityConfigured) { + throw new Error( + 'RFC-64 private catalog policy requires explicit access-policy authority configuration', + ); + } + return this.#policies.accept(input); + } + + acceptedPolicySnapshot( + networkId: NetworkIdV1, + contextGraphId: ContextGraphIdV1, + ): AcceptedRfc64CatalogAccessSnapshotV1 | null { + return this.#policies.lookup(networkId, contextGraphId); } - /** Resolve the locally accepted open-policy digest for one exact catalog scope. */ + /** Resolve the locally accepted policy digest for one exact catalog scope. */ + acceptedPolicyDigestForCatalogScope(scopeInput: AuthorCatalogScopeV1): Digest32V1 { + const scope = snapshotCatalogScope(scopeInput); + const held = this.#policies.lookup(scope.networkId, scope.contextGraphId); + if (held === null) { + throw new Error('RFC-64 catalog scope has no locally accepted policy snapshot'); + } + assertAcceptedPolicyMatchesCatalogScope(this.#policies, held, scope); + return held.policyDigest; + } + + /** Compatibility alias for the original public/open authoring surface. */ acceptedOpenPolicyDigestForCatalogScope(scopeInput: AuthorCatalogScopeV1): Digest32V1 { const scope = snapshotCatalogScope(scopeInput); const held = this.#policies.lookup(scope.networkId, scope.contextGraphId); @@ -309,6 +366,29 @@ export class Rfc64PublicCatalogServiceV1 { */ async publishOpenAuthorCatalogGenesis( input: PublishOpenAuthorCatalogGenesisInputV1, + ): Promise { + const scope = snapshotCatalogScope(input.scope); + const heldPolicy = this.#policies.lookup(scope.networkId, scope.contextGraphId); + assertOpenPolicyMatchesCatalogScope(input.policy, heldPolicy, scope); + return this.#publishAuthorCatalogGenesis(input, heldPolicy!); + } + + /** Author path for any already-accepted RFC-64 catalog access-policy cell. */ + async publishAuthorCatalogGenesis( + input: PublishAuthorCatalogGenesisInputV1, + ): Promise { + const scope = snapshotCatalogScope(input.scope); + const heldPolicy = this.#policies.lookup(scope.networkId, scope.contextGraphId); + if (heldPolicy === null) { + throw new Error('RFC-64 catalog scope has no locally accepted policy snapshot'); + } + assertAcceptedPolicyMatchesCatalogScope(this.#policies, heldPolicy, scope); + return this.#publishAuthorCatalogGenesis(input, heldPolicy); + } + + async #publishAuthorCatalogGenesis( + input: PublishAuthorCatalogGenesisInputV1, + heldPolicy: AcceptedRfc64CatalogAccessSnapshotV1, ): Promise { this.#requireStarted(); const scope = snapshotCatalogScope(input.scope); @@ -320,9 +400,8 @@ export class Rfc64PublicCatalogServiceV1 { const effectiveAt = input.catalogIssuerDelegationEffectiveAt; const expiresAt = input.catalogIssuerDelegationExpiresAt; const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); - const heldPolicy = this.#policies.lookup(scope.networkId, scope.contextGraphId); - assertOpenPolicyMatchesCatalogScope(input.policy, heldPolicy, scope); - const policyDigest = heldPolicy!.policyDigest; + assertAcceptedPolicyMatchesCatalogScope(this.#policies, heldPolicy, scope); + const policyDigest = heldPolicy.policyDigest; const delegation = await produceDirectAuthorCatalogIssuerDelegationV1({ scope, signer, @@ -406,7 +485,7 @@ export class Rfc64PublicCatalogServiceV1 { encodeRfc64PublicCatalogHeadAnnouncementV1(input.announcement), ); const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); - this.#assertAcceptedOpenAnnouncement(announcement); + this.#assertAcceptedCatalogAnnouncement(announcement); return this.#announceCatalogHeadSnapshot(announcement, peers); } @@ -453,45 +532,50 @@ export class Rfc64PublicCatalogServiceV1 { }); } - #assertAcceptedOpenAnnouncement( + #assertAcceptedCatalogAnnouncement( announcement: Rfc64PublicCatalogHeadAnnouncementV1, ): void { const held = this.#policies.lookup(announcement.networkId, announcement.contextGraphId); if ( held === null - || held.policy.accessPolicy !== 0 || held.policyDigest !== announcement.policyDigest - || held.policy.source.kind !== 'owner-signed-unregistered' - || held.policy.source.ownerAddress !== announcement.authorAddress + || !this.#policies.isSwmAuthorAuthorized({ + networkId: announcement.networkId, + contextGraphId: announcement.contextGraphId, + policyDigest: announcement.policyDigest, + authorAddress: announcement.authorAddress, + }) ) { throw new Error( - 'RFC-64 catalog announcement is not bound to the locally accepted open policy', + 'RFC-64 catalog announcement is not bound to the locally accepted policy snapshot', ); } } async #authorizeNativeOperation( input: Rfc64PublicCatalogNativeAuthorizationInputV1, ): Promise { - const record = this.#policies.lookup(input.networkId, input.contextGraphId); - if (record === null || record.policy.accessPolicy !== 0) return null; - return Object.freeze({ - accessPolicy: 0, - policyDigest: record.policyDigest, - }); + return this.#policies.authorize(input); } #resolveTrustedCatalogScope( announcement: Rfc64PublicCatalogHeadAnnouncementV1, ): Readonly { const record = this.#policies.lookup(announcement.networkId, announcement.contextGraphId); - if ( - record === null - || record.policyDigest !== announcement.policyDigest - || record.policy.accessPolicy !== 0 - ) { - throw new Error('RFC-64 announcement has no matching accepted open policy generation'); + if (record === null || record.policyDigest !== announcement.policyDigest) { + throw new Error('RFC-64 announcement has no matching accepted policy generation'); } - return deriveRfc64PublicOpenCatalogScopeV1(announcement, record.policy); + this.#assertAcceptedCatalogAnnouncement(announcement); + return Object.freeze({ + networkId: record.policy.networkId, + contextGraphId: record.policy.contextGraphId, + governanceChainId: record.policy.governanceChainId, + governanceContractAddress: record.policy.governanceContractAddress, + ownershipTransitionDigest: record.policy.ownershipTransitionDigest, + subGraphName: announcement.subGraphName, + authorAddress: announcement.authorAddress, + era: announcement.catalogEra, + bucketCount: '1', + }) as Readonly; } async #stageHeadOnly( @@ -606,6 +690,31 @@ function assertOpenPolicyMatchesCatalogScope( } } +function assertAcceptedPolicyMatchesCatalogScope( + registry: Rfc64CatalogAccessPolicyRegistryV1, + held: AcceptedRfc64CatalogAccessSnapshotV1, + scope: AuthorCatalogScopeV1, +): void { + const policy = held.policy; + if ( + policy.networkId !== scope.networkId + || policy.contextGraphId !== scope.contextGraphId + || policy.governanceChainId !== scope.governanceChainId + || policy.governanceContractAddress !== scope.governanceContractAddress + || policy.ownershipTransitionDigest !== scope.ownershipTransitionDigest + || !registry.isSwmAuthorAuthorized({ + networkId: scope.networkId, + contextGraphId: scope.contextGraphId, + policyDigest: held.policyDigest, + authorAddress: scope.authorAddress, + }) + ) { + throw new Error( + 'RFC-64 policy snapshot is not bound to the exact catalog network, CG, governance scope, and author', + ); + } +} + function assertExactDurableStageReceipt( receipt: StageVerifiedControlObjectsResultV1, expected: readonly SignedControlEnvelopeV1[], diff --git a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts index ec13527f12..a5feca7b5c 100644 --- a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Production boundary for the bounded public/open successor slice. + * Production boundary for the bounded one-bucket catalog successor slice. * * The adapter deliberately delegates catalog construction and every bundle, * seal, and projection codec check to the canonical RFC-64 helpers. Neither @@ -188,7 +188,7 @@ export interface ProducedAndStagedPublicOpenExactSetSuccessorV1 { } /** - * Build, authenticate, fully verify, and durably stage one public/open + * Build, authenticate, fully verify, and durably stage one * root-lane successor. Bundle-first staging prevents a served head from * referring to an unavailable bundle; a later control-stage failure can leave * only an unreferenced immutable bundle. @@ -297,8 +297,7 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { const producedBucket = publication.bucket; if ( - publication.head.payload.subGraphName !== null - || publication.head.payload.bucketCount !== '1' + publication.head.payload.bucketCount !== '1' || publication.head.payload.directoryHeight !== '0' || publication.head.payload.totalRows !== String(preparedAssets.length) || publication.head.payload.version === '0' @@ -308,7 +307,7 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { ) { fail( 'catalog-successor-producer-verification', - 'produced catalog is outside the bounded public/open exact-set successor slice', + 'produced catalog is outside the bounded exact-set successor slice', ); } @@ -686,8 +685,7 @@ function assertSupportedPreviousSlice( bucket: SignedAuthorCatalogBucketEnvelopeV1 | null, ): void { if ( - head.payload.subGraphName !== null - || head.payload.bucketCount !== '1' + head.payload.bucketCount !== '1' || head.payload.directoryHeight !== '0' || BigInt(head.payload.totalRows) > BigInt(MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1) || (head.payload.totalRows === '0' && bucket !== null) @@ -697,7 +695,7 @@ function assertSupportedPreviousSlice( ) { fail( 'catalog-successor-producer-history', - 'previous catalog is outside the bounded public/open root-lane slice', + 'previous catalog is outside the bounded one-bucket successor slice', ); } } diff --git a/packages/agent/test/rfc64-catalog-policy-api-public.typecheck.ts b/packages/agent/test/rfc64-catalog-policy-api-public.typecheck.ts new file mode 100644 index 0000000000..84def2694d --- /dev/null +++ b/packages/agent/test/rfc64-catalog-policy-api-public.typecheck.ts @@ -0,0 +1,93 @@ +import { + DKGAgent, + type AcceptRfc64CatalogAccessSnapshotParamsV1, + type AcceptedRfc64CatalogAccessSnapshotV1, + type DKGAgentConfig, + type PublishAuthorCatalogExactSetSuccessorParamsV1, + type PublishAuthorCatalogExactSetSuccessorResultV1, + type PublishAuthorCatalogGenesisParamsV1, + type PublishAuthorCatalogGenesisResultV1, + type Rfc64CatalogAccessPolicyAuthorityConfigV1, +} from '@origintrail-official/dkg-agent'; + +// Every params value below is built as a real object literal rather than passed through as a +// `declare const params: T`. That distinction is the whole point of this file: `T` assignable to `T` +// compiles no matter how T's fields are renamed, so it proves only that the export NAME exists. +// Real literals additionally fail to compile when a published field is renamed, removed, or newly +// required — which is what an external consumer of this surface would actually hit. +// +// Nested/leaf values are pulled through indexed access on the published types themselves, so this +// file stays free of incidental imports while keeping the field names load-bearing: renaming +// `scope` breaks both the indexed access and the literal key. + +declare const agent: DKGAgent; + +declare const policy: AcceptRfc64CatalogAccessSnapshotParamsV1['policy']; +declare const policyDigest: AcceptRfc64CatalogAccessSnapshotParamsV1['policyDigest']; +declare const roster: NonNullable; + +const snapshot: AcceptRfc64CatalogAccessSnapshotParamsV1 = { + policy, + policyDigest, + roster, +}; +const accepted: AcceptedRfc64CatalogAccessSnapshotV1 = + agent.acceptRfc64CatalogAccessSnapshotV1(snapshot); + +declare const genesisScope: PublishAuthorCatalogGenesisParamsV1['scope']; +declare const genesisAuthor: PublishAuthorCatalogGenesisParamsV1['author']; +declare const genesisIssuedAt: NonNullable; +declare const delegationEffectiveAt: + PublishAuthorCatalogGenesisParamsV1['catalogIssuerDelegationEffectiveAt']; +declare const delegationExpiresAt: + PublishAuthorCatalogGenesisParamsV1['catalogIssuerDelegationExpiresAt']; + +const genesis: PublishAuthorCatalogGenesisParamsV1 = { + scope: genesisScope, + author: genesisAuthor, + peers: ['12D3KooWExamplePeer'], + issuedAt: genesisIssuedAt, + catalogIssuerDelegationEffectiveAt: delegationEffectiveAt, + catalogIssuerDelegationExpiresAt: delegationExpiresAt, +}; +const genesisResult: Promise = + agent.publishAuthorCatalogGenesisV1(genesis); + +declare const previousHead: PublishAuthorCatalogExactSetSuccessorParamsV1['previousHead']; +declare const successorAuthor: PublishAuthorCatalogExactSetSuccessorParamsV1['author']; +declare const catalogIssuerAuthorization: + PublishAuthorCatalogExactSetSuccessorParamsV1['catalogIssuerAuthorization']; +declare const assets: PublishAuthorCatalogExactSetSuccessorParamsV1['assets']; +declare const deployment: PublishAuthorCatalogExactSetSuccessorParamsV1['deployment']; +declare const successorIssuedAt: + NonNullable; + +const successor: PublishAuthorCatalogExactSetSuccessorParamsV1 = { + previousHead, + author: successorAuthor, + catalogIssuerAuthorization, + assets, + deployment, + issuedAt: successorIssuedAt, + peers: ['12D3KooWExamplePeer'], +}; +const successorResult: Promise = + agent.publishAuthorCatalogExactSetSuccessorV1(successor); + +declare const localAgentAddress: Rfc64CatalogAccessPolicyAuthorityConfigV1['localAgentAddress']; +declare const resolveRemoteAgentAddress: + Rfc64CatalogAccessPolicyAuthorityConfigV1['resolveRemoteAgentAddress']; + +const authority: Rfc64CatalogAccessPolicyAuthorityConfigV1 = { + localAgentAddress, + resolveRemoteAgentAddress, +}; +// The authority is create-time configuration, so pin its config field name too. +const authorityConfig: Pick = { + rfc64CatalogAccessPolicyAuthority: authority, +}; + +void accepted; +void genesisResult; +void successorResult; +void authorityConfig; diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index ae07d45f4d..bb3bc24190 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -4,14 +4,17 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, assertCanonicalGraphScopedAuthorSealV1, buildAuthorAttestationTypedData, computeAuthorCatalogScopeDigestV1, type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, type ContextGraphIdV1, + type ContextGraphPolicyV1, type Digest32V1, type EvmAddressV1, + type MemberRosterV1, type NetworkIdV1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; @@ -20,7 +23,11 @@ import { ethers } from 'ethers'; import { afterEach, describe, expect, it } from 'vitest'; import { DKGAgent } from '../src/dkg-agent.js'; -import { snapshotRfc64CatalogDeploymentProfileV1 } from '../src/dkg-agent-rfc64-catalog.js'; +import { + snapshotRfc64CatalogAccessPolicyAuthorityV1, + snapshotRfc64CatalogDeploymentProfileV1, +} from '../src/dkg-agent-rfc64-catalog.js'; +import type { Rfc64CatalogAccessPolicyAuthorityConfigV1 } from '../src/dkg-agent-types.js'; const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const NETWORK_ID = 'otp:20430' as NetworkIdV1; @@ -60,6 +67,7 @@ async function startNativeAgent( name: string, deployment: CatalogSealDeploymentProfileV1 = NATIVE_DEPLOYMENT, existingDataDir?: string, + accessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1, ): Promise { const dataDir = existingDataDir ?? await mkdtemp(join(tmpdir(), `dkg-rfc64-native-${name}-`)); @@ -78,6 +86,7 @@ async function startNativeAgent( durableSyncEnabled: false, agentProfileHeartbeatMs: 0, rfc64CatalogDeploymentProfile: deployment, + rfc64CatalogAccessPolicyAuthority: accessPolicyAuthority, }); agents.push(agent); await agent.start(); @@ -109,6 +118,50 @@ function catalogScopeDigest() { }); } +function privateCatalogPolicy(): ContextGraphPolicyV1 { + return { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: '7', + version: '0', + previousPolicyDigest: null, + accessPolicy: 1, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: AUTHOR, + ownerAuthorityEra: '0', + }, + effectiveAt: '0', + issuedAt: '0', + }; +} + +function privateCatalogRoster( + policy: ContextGraphPolicyV1, + policyDigest: Digest32V1, +): MemberRosterV1 { + return { + networkId: policy.networkId, + contextGraphId: policy.contextGraphId, + ownershipTransitionDigest: policy.ownershipTransitionDigest, + era: policy.era, + version: '0', + previousRosterDigest: null, + policyDigest, + administrativeDelegationDigest: policy.administrativeDelegationDigest, + members: [{ agentAddress: AUTHOR, roles: ['holder', 'provider'] }], + issuedAt: '0', + }; +} + describe('RFC-64 DKGAgent production native catalog wiring', () => { it('snapshots and canonicalizes the deterministic local deployment override', () => { const callerOwned = { @@ -128,6 +181,103 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { })).toThrow(/non-zero EVM address/); }); + it('snapshots the explicit access authority and fails closed before private activation', async () => { + const resolver = async () => AUTHOR; + const callerOwned = { + localAgentAddress: ethers.getAddress(AUTHOR) as EvmAddressV1, + resolveRemoteAgentAddress: resolver, + }; + const snapshot = snapshotRfc64CatalogAccessPolicyAuthorityV1(callerOwned)!; + callerOwned.localAgentAddress = ethers.ZeroAddress as EvmAddressV1; + expect(snapshot).toEqual({ + localAgentAddress: AUTHOR, + resolveRemoteAgentAddress: resolver, + }); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(() => snapshotRfc64CatalogAccessPolicyAuthorityV1({ + localAgentAddress: ethers.ZeroAddress as EvmAddressV1, + resolveRemoteAgentAddress: resolver, + })).toThrow(/localAgentAddress is invalid/); + + const policy = privateCatalogPolicy(); + const policyDigest = `0x${'ab'.repeat(32)}` as Digest32V1; + const roster = privateCatalogRoster(policy, policyDigest); + const legacyOpenOnly = await startNativeAgent('private-denied'); + expect(() => legacyOpenOnly.acceptRfc64CatalogAccessSnapshotV1({ + policy, + policyDigest, + roster, + })).toThrow(/requires explicit access-policy authority/); + + const configured = await startNativeAgent( + 'private-configured', + NATIVE_DEPLOYMENT, + undefined, + { + localAgentAddress: AUTHOR, + resolveRemoteAgentAddress: resolver, + }, + ); + expect(configured.acceptRfc64CatalogAccessSnapshotV1({ + policy, + policyDigest, + roster, + })).toMatchObject({ policyDigest, roster: { policyDigest } }); + + const published = await configured.publishAuthorCatalogGenesisV1({ + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: 'service-lane', + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + }, + author: AUTHOR_WALLET, + peers: [], + issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: MULTI_DELEGATION_EXPIRES_AT, + }); + expect(published.announcement).toMatchObject({ + policyDigest, + subGraphName: 'service-lane', + catalogEra: '0', + }); + + const successor = await configured.publishAuthorCatalogExactSetSuccessorV1({ + previousHead: { + objectDigest: published.headObjectDigest, + signatureVariantDigest: published.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: published.catalogIssuerAuthorization, + assets: [{ + assertionCoordinate: 'private-subgraph-object' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(7n), + }], + deployment: NATIVE_DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [], + }); + expect(successor).toMatchObject({ + announcement: { + policyDigest, + subGraphName: 'service-lane', + catalogEra: '0', + }, + catalogScope: { + subGraphName: 'service-lane', + era: '0', + }, + inventoryRowCount: '1', + }); + }, 60_000); + it('uses the trusted override to fetch provider content and durably apply native genesis', async () => { const [author, receiver] = await Promise.all([ startNativeAgent('author'), @@ -244,7 +394,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); await receiver.whenRfc64PublicCatalogReceiverIdleV1(); - const successor = await author.publishOpenAuthorCatalogExactSetSuccessorV1({ + const successor = await author.publishAuthorCatalogExactSetSuccessorV1({ previousHead: { objectDigest: firstSuccessor.headObjectDigest, signatureVariantDigest: firstSuccessor.signatureVariantDigest, diff --git a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts index ecac5dca4b..882bca95c4 100644 --- a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts @@ -2,11 +2,14 @@ import { AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1, computeControlSignatureVariantDigestHex, type AuthorCatalogScopeV1, type Digest32V1, + type ContextGraphPolicyV1, type EvmAddressV1, + type MemberRosterV1, type ProtocolRouter, type TimestampMsV1, } from '@origintrail-official/dkg-core'; @@ -14,6 +17,10 @@ import { ethers } from 'ethers'; import { describe, expect, it, vi } from 'vitest'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { + buildOpenOwnerContextGraphPolicyV1, + computeOpenContextGraphPolicyDigestV1, +} from '../src/rfc64/open-catalog-policy-v1.js'; import type { Rfc64ControlObjectOperationsV1 } from '../src/rfc64/control-object-store-v1.js'; import { RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1, @@ -145,6 +152,79 @@ function nativeOptions( } as const; } +function accessPolicyAuthority() { + return { + localAgentAddress: AUTHOR, + resolveRemoteAgentAddress: async () => null, + } as const; +} + +function catalogPolicy( + contextGraphId: string, + accessPolicy: 0 | 1, + publishPolicy: 0 | 1, +): ContextGraphPolicyV1 { + return { + networkId: NETWORK_ID, + contextGraphId: contextGraphId as ContextGraphPolicyV1['contextGraphId'], + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy, + publishPolicy, + publishAuthority: publishPolicy === 0 + ? OTHER_WALLET.address.toLowerCase() as EvmAddressV1 + : null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { kind: 'owner-signed-unregistered', ownerAddress: AUTHOR, ownerAuthorityEra: '0' }, + effectiveAt: '0', + issuedAt: '0', + }; +} + +function memberRoster( + policy: ContextGraphPolicyV1, + policyDigest: Digest32V1, +): MemberRosterV1 { + const members = [AUTHOR, OTHER_WALLET.address.toLowerCase() as EvmAddressV1] + .sort() + .map((agentAddress) => ({ agentAddress, roles: ['holder', 'provider'] as const })); + return { + networkId: policy.networkId, + contextGraphId: policy.contextGraphId, + ownershipTransitionDigest: policy.ownershipTransitionDigest, + era: policy.era, + version: '0', + previousRosterDigest: null, + policyDigest, + administrativeDelegationDigest: policy.administrativeDelegationDigest, + members, + issuedAt: '0', + }; +} + +/** + * `memberRoster` enrols every wallet these tests author with, which makes the private branch of + * `isSwmAuthorAuthorized` unfalsifiable. This builds the same roster minus one address so the + * off-roster author case can actually be asserted. + */ +function rosterExcluding( + policy: ContextGraphPolicyV1, + policyDigest: Digest32V1, + excluded: EvmAddressV1, +): MemberRosterV1 { + const base = memberRoster(policy, policyDigest); + return { + ...base, + members: base.members.filter((member) => member.agentAddress !== excluded), + }; +} + function acceptPolicy(service: Rfc64PublicCatalogServiceV1) { return service.acceptOpenPolicy({ networkId: NETWORK_ID, @@ -208,6 +288,176 @@ function countEvent(router: RecordingRouter, event: string): number { } describe('RFC-64 public catalog service v1 lifecycle ownership', () => { + it('accepts all four policy cells and requires a roster only for private access', async () => { + const service = new Rfc64PublicCatalogServiceV1({ + router: new RecordingRouter().asProtocolRouter(), + controlObjects: controlObjects(), + accessPolicyAuthority: accessPolicyAuthority(), + }); + let index = 0; + for (const accessPolicy of [0, 1] as const) { + for (const publishPolicy of [0, 1] as const) { + index += 1; + const policy = catalogPolicy( + `${CONTEXT_GRAPH_ID}-${accessPolicy}-${publishPolicy}`, + accessPolicy, + publishPolicy, + ); + const policyDigest = `0x${index.toString(16).padStart(64, '0')}` as Digest32V1; + const accepted = service.acceptPolicySnapshot({ + policy, + policyDigest, + roster: accessPolicy === 1 ? memberRoster(policy, policyDigest) : undefined, + }); + expect(accepted.policy.accessPolicy).toBe(accessPolicy); + expect(accepted.policy.publishPolicy).toBe(publishPolicy); + expect(accepted.roster === null).toBe(accessPolicy === 0); + expect(service.acceptedPolicyDigestForCatalogScope({ + networkId: policy.networkId, + contextGraphId: policy.contextGraphId, + governanceChainId: policy.governanceChainId, + governanceContractAddress: policy.governanceContractAddress, + ownershipTransitionDigest: policy.ownershipTransitionDigest, + subGraphName: 'service-lane', + authorAddress: OTHER_WALLET.address.toLowerCase() as EvmAddressV1, + era: '7', + bucketCount: '1', + })).toBe(policyDigest); + } + } + expect(service.stats().acceptedPolicies).toBe(4); + + const privatePolicy = catalogPolicy(`${CONTEXT_GRAPH_ID}-missing-roster`, 1, 1); + await expect(Promise.resolve().then(() => service.acceptPolicySnapshot({ + policy: privatePolicy, + policyDigest: `0x${'f'.repeat(64)}` as Digest32V1, + }))).rejects.toThrow(/requires a current member roster/); + await service.close(); + }); + + it('denies a private-cell author that is absent from the current member roster', async () => { + const service = new Rfc64PublicCatalogServiceV1({ + router: new RecordingRouter().asProtocolRouter(), + controlObjects: controlObjects(), + accessPolicyAuthority: accessPolicyAuthority(), + }); + service.start(); + const outsider = OTHER_WALLET.address.toLowerCase() as EvmAddressV1; + + const closedPolicy = catalogPolicy(`${CONTEXT_GRAPH_ID}-private-closed`, 1, 1); + const closedDigest = `0x${'2c'.repeat(32)}` as Digest32V1; + service.acceptPolicySnapshot({ + policy: closedPolicy, + policyDigest: closedDigest, + roster: rosterExcluding(closedPolicy, closedDigest, outsider), + }); + + // Author side (assertAcceptedPolicyMatchesCatalogScope): an accepted private snapshot must not + // bind a catalog scope whose author is off-roster. + await expect(service.publishAuthorCatalogGenesis({ + scope: { + networkId: closedPolicy.networkId, + contextGraphId: closedPolicy.contextGraphId, + governanceChainId: closedPolicy.governanceChainId, + governanceContractAddress: closedPolicy.governanceContractAddress, + ownershipTransitionDigest: closedPolicy.ownershipTransitionDigest, + subGraphName: 'service-lane', + authorAddress: outsider, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1, + signer: { + issuer: outsider, + signDigest: (digest: Uint8Array) => OTHER_WALLET.signMessage(digest), + }, + issuedAt: HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, + peers: [], + })).rejects.toThrow(/not bound to the exact catalog network, CG, governance scope, and author/); + + // Receive side (#assertAcceptedCatalogAnnouncement): an announcement naming an off-roster author + // must not resolve a trusted catalog scope. + await expect(service.announceCatalogHead({ + announcement: { + ...announcement(closedDigest), + contextGraphId: closedPolicy.contextGraphId, + authorAddress: outsider, + } as Rfc64PublicCatalogHeadAnnouncementV1, + peers: [], + })).rejects.toThrow(/not bound to the locally accepted policy snapshot/); + + // Control: the identical announcement is accepted for a private cell whose roster DOES enrol the + // same author, proving both denials above come from the roster check rather than from an + // unrelated scope mismatch. + const openRosterPolicy = catalogPolicy(`${CONTEXT_GRAPH_ID}-private-enrolled`, 1, 1); + const openRosterDigest = `0x${'2d'.repeat(32)}` as Digest32V1; + service.acceptPolicySnapshot({ + policy: openRosterPolicy, + policyDigest: openRosterDigest, + roster: memberRoster(openRosterPolicy, openRosterDigest), + }); + await expect(service.announceCatalogHead({ + announcement: { + ...announcement(openRosterDigest), + contextGraphId: openRosterPolicy.contextGraphId, + authorAddress: outsider, + } as Rfc64PublicCatalogHeadAnnouncementV1, + peers: [], + })).resolves.toMatchObject({ announcedPeers: [] }); + + await service.close(); + }); + + it('publishes exact subgraph genesis under both public policy cells', async () => { + for (const publishPolicy of [0, 1] as const) { + const service = new Rfc64PublicCatalogServiceV1({ + router: new RecordingRouter().asProtocolRouter(), + controlObjects: controlObjects(), + accessPolicyAuthority: accessPolicyAuthority(), + }); + service.start(); + const policy = catalogPolicy( + `${CONTEXT_GRAPH_ID}-public-${publishPolicy}`, + 0, + publishPolicy, + ); + const policyDigest = ( + `0x${(10 + publishPolicy).toString(16).padStart(64, '0')}` + ) as Digest32V1; + service.acceptPolicySnapshot({ policy, policyDigest }); + + const authorAddress = OTHER_WALLET.address.toLowerCase() as EvmAddressV1; + const result = await service.publishAuthorCatalogGenesis({ + scope: { + networkId: policy.networkId, + contextGraphId: policy.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: 'service-lane', + authorAddress, + era: '0', + bucketCount: '1', + }, + signer: { + issuer: authorAddress, + signDigest: (digest) => OTHER_WALLET.signMessage(digest), + }, + issuedAt: HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: DELEGATION_EXPIRES_AT, + peers: [], + }); + expect(result.announcement).toMatchObject({ + policyDigest, + subGraphName: 'service-lane', + authorAddress, + }); + await service.close(); + } + }); + it('durably stages the exact signed delegation before genesis and only then announces', async () => { const router = new RecordingRouter(); const store = controlObjects(); @@ -219,6 +469,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: store, + accessPolicyAuthority: accessPolicyAuthority(), }); service.start(); @@ -259,6 +510,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: store, + accessPolicyAuthority: accessPolicyAuthority(), }); service.start(); @@ -269,11 +521,15 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { }, }) as never)).rejects.toThrow(/signer must equal the exact catalog author/); - const wrongPolicy = service.acceptOpenPolicy({ + const wrongPolicyPayload = buildOpenOwnerContextGraphPolicyV1({ networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, ownerAddress: OTHER_WALLET.address.toLowerCase() as EvmAddressV1, }); + const wrongPolicy = { + policy: wrongPolicyPayload, + policyDigest: computeOpenContextGraphPolicyDigestV1(wrongPolicyPayload), + }; await expect(service.publishOpenAuthorCatalogGenesis(genesisInput(service, { policy: wrongPolicy, }) as never)).rejects.toThrow(/not bound to the exact catalog/); @@ -289,6 +545,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: store, + accessPolicyAuthority: accessPolicyAuthority(), }); service.start(); @@ -311,6 +568,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: store, + accessPolicyAuthority: accessPolicyAuthority(), }); service.start(); @@ -330,6 +588,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: store, + accessPolicyAuthority: accessPolicyAuthority(), }); service.start(); @@ -345,6 +604,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: controlObjects(), + accessPolicyAuthority: accessPolicyAuthority(), }); const policy = acceptPolicy(service); const head = announcement(policy.policyDigest); @@ -405,6 +665,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: controlObjects(), + accessPolicyAuthority: accessPolicyAuthority(), transportTimeoutMs: 4_321, native: nativeOptions(createReconciler), }); @@ -445,13 +706,24 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { era: '0', bucketCount: '1', }); + expect(clients!.resolveTrustedCatalogScope({ + ...announcement(policy.policyDigest), + subGraphName: 'service-lane', + catalogEra: '7', + } as Rfc64PublicCatalogHeadAnnouncementV1)).toMatchObject({ + subGraphName: 'service-lane', + authorAddress: AUTHOR, + era: '7', + }); expect(() => clients!.resolveTrustedCatalogScope(announcement( `0x${'cc'.repeat(32)}` as Digest32V1, - ))).toThrow('no matching accepted open policy generation'); - expect(() => clients!.resolveTrustedCatalogScope({ + ))).toThrow('no matching accepted policy generation'); + expect(clients!.resolveTrustedCatalogScope({ ...announcement(policy.policyDigest), authorAddress: OTHER_WALLET.address.toLowerCase() as EvmAddressV1, - })).toThrow('accepted null-governance owner policy'); + })).toMatchObject({ + authorAddress: OTHER_WALLET.address.toLowerCase(), + }); service.start(); service.start(); @@ -464,6 +736,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: controlObjects(), + accessPolicyAuthority: accessPolicyAuthority(), native: nativeOptions(() => inertReconciler()), }); @@ -483,6 +756,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: controlObjects(), + accessPolicyAuthority: accessPolicyAuthority(), native: nativeOptions(() => inertReconciler()), }); @@ -508,6 +782,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: controlObjects(), + accessPolicyAuthority: accessPolicyAuthority(), receiver: { retryBackoffMs: 0 }, native: nativeOptions((input) => { clients = input; @@ -582,6 +857,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: store, + accessPolicyAuthority: accessPolicyAuthority(), onHeadStaged, }); const policy = acceptPolicy(service); @@ -651,6 +927,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { const service = new Rfc64PublicCatalogServiceV1({ router: router.asProtocolRouter(), controlObjects: store, + accessPolicyAuthority: accessPolicyAuthority(), receiver: { maxAttempts: 1, retryBackoffMs: 0, onError }, }); const policy = acceptPolicy(service); diff --git a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts index 003b7c9c36..42f0a17121 100644 --- a/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-successor-producer-v1.test.ts @@ -131,6 +131,22 @@ describe('RFC-64 public/open one-row successor producer', () => { }); }); + it('preserves an exact non-root subgraph lane across a bounded successor', async () => { + const subGraphName = 'service-lane' as AuthorCatalogScopeV1['subGraphName']; + const { genesis, authorization } = await producerHistory(subGraphName); + const result = await stageOne( + { head: genesis.head, directoryPath: genesis.directoryPath, bucket: null }, + authorization, + ); + + expect(result.publication.head.payload).toMatchObject({ + subGraphName, + version: '1', + previousHeadDigest: genesis.head.objectDigest, + }); + expect(result.publication.bucket?.payload.rows).toHaveLength(1); + }); + it('canonicalizes an unordered two-row exact set and produces the same signed head', async () => { const { genesis, authorization } = await producerHistory(); const stageKaBundle = vi.fn(durableBundleReceipt); @@ -622,8 +638,14 @@ type BundleStageInput = { readonly bundleBytes: Uint8Array; }; -async function producerHistory() { - const authorization = await directCatalogAuthorization(AUTHOR_WALLET, CONTEXT_GRAPH_ID); +async function producerHistory( + subGraphName: AuthorCatalogScopeV1['subGraphName'] = null, +) { + const authorization = await directCatalogAuthorization( + AUTHOR_WALLET, + CONTEXT_GRAPH_ID, + subGraphName, + ); const genesis = await produceEmptyAuthorCatalogGenesisV1({ scope: { networkId: NETWORK_ID, @@ -631,7 +653,7 @@ async function producerHistory() { governanceChainId: '20430', governanceContractAddress: GOVERNANCE, ownershipTransitionDigest: null, - subGraphName: null, + subGraphName, authorAddress: AUTHOR, era: '0', bucketCount: '1', @@ -685,6 +707,7 @@ async function stageOne( async function directCatalogAuthorization( wallet: ethers.Wallet, contextGraphId: ContextGraphIdV1, + subGraphName: AuthorCatalogScopeV1['subGraphName'] = null, ) { const authorAddress = wallet.address.toLowerCase() as EvmAddressV1; const produced = await produceDirectAuthorCatalogIssuerDelegationV1({ @@ -694,7 +717,7 @@ async function directCatalogAuthorization( governanceChainId: '20430', governanceContractAddress: GOVERNANCE, ownershipTransitionDigest: null, - subGraphName: null, + subGraphName, authorAddress, era: '0', bucketCount: '1', From d0e23c5147abc0be07161ee9a19416f3fbfbf9c6 Mon Sep 17 00:00:00 2001 From: branarakic Date: Tue, 21 Jul 2026 17:47:21 +0200 Subject: [PATCH 169/292] fix(sync): retry foreground catchup under backpressure --- packages/agent/src/dkg-agent-lifecycle.ts | 76 ++++++++++- packages/agent/src/index.ts | 6 + .../src/sync/catchup-backpressure-retry.ts | 39 ++++++ .../test/catchup-backpressure-retry.test.ts | 44 ++++++ .../test/sync-requester-priority.test.ts | 41 +++++- .../cli/src/catchup-runner-worker-impl.ts | 21 ++- packages/cli/src/catchup-runner.ts | 18 ++- .../test/catchup-runner-worker-impl.test.ts | 125 +++++++++++++++--- 8 files changed, 339 insertions(+), 31 deletions(-) create mode 100644 packages/agent/src/sync/catchup-backpressure-retry.ts create mode 100644 packages/agent/test/catchup-backpressure-retry.test.ts diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 28860b2c2b..723138fda9 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -257,6 +257,7 @@ import { import { runSyncOnConnect, SyncOnConnectPostSyncError, type SyncOnConnectOutcome, type SyncOnConnectPeerOutcome } from './sync/on-connect/sync-on-connect.js'; import { mapWithConcurrency } from './map-with-concurrency.js'; import { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from './sync/catchup-concurrency.js'; +import { retryCatchupPlaneOnBackpressure } from './sync/catchup-backpressure-retry.js'; import { classifyDurableProgress } from './sync/durable-progress.js'; import { getSyncBackpressureSnapshot, @@ -607,12 +608,16 @@ function contextGraphCatchupSingleFlightKey(params: { includeSharedMemory: boolean; maxPeers?: number; peerRotationKey?: string; + priority?: number; + retryDeferredBackpressure?: boolean; }): string { return syncSingleFlightKey('context-graph-catchup', { contextGraphId: params.contextGraphId, includeSharedMemory: params.includeSharedMemory, maxPeers: normalizedCatchupMaxPeers(params.maxPeers), peerRotationKey: params.peerRotationKey ?? null, + priority: params.priority ?? null, + retryDeferredBackpressure: params.retryDeferredBackpressure === true, }); } @@ -626,6 +631,7 @@ function durableSyncSingleFlightKey(params: { hasAccessDeniedCallback: boolean; hasSinceBatchIdResolver: boolean; exactAssetUals?: readonly string[]; + priority?: number; }): string | null { if (params.hasPhaseCallback || params.hasAccessDeniedCallback || params.hasSinceBatchIdResolver) { return null; @@ -637,6 +643,7 @@ function durableSyncSingleFlightKey(params: { totalTimeoutMs: params.totalTimeoutMs, syncAgentsMeta: params.syncAgentsMeta, exactAssetUals: params.exactAssetUals ?? null, + priority: params.priority ?? null, }); } @@ -646,6 +653,7 @@ function sharedMemorySyncSingleFlightKey(params: { stopOnBackoffWorthyFailure?: boolean; publicContextGraphIds: readonly string[]; privateRecoverFromCurator: readonly string[]; + priority?: number; }): string { return syncSingleFlightKey('shared-memory-sync', { remotePeerId: params.remotePeerId, @@ -653,6 +661,7 @@ function sharedMemorySyncSingleFlightKey(params: { stopOnBackoffWorthyFailure: params.stopOnBackoffWorthyFailure === true, publicContextGraphIds: params.publicContextGraphIds, privateRecoverFromCurator: params.privateRecoverFromCurator, + priority: params.priority ?? null, }); } @@ -4042,7 +4051,13 @@ export class LifecycleSyncMethods extends DKGAgentBase { // the same flag that makes it a responder. Same signal SC4 uses to advertise the protocol. if (asChangelogReader(this.store) !== null && contextGraphIds.length > 0) { try { - const lane = await this.runChangelogLane(ctx, remotePeerId, contextGraphIds, onAccessDenied); + const lane = await this.runChangelogLane( + ctx, + remotePeerId, + contextGraphIds, + onAccessDenied, + options?.priority, + ); changelogResult = lane.result; legacyContextGraphIds = lane.remainingLegacyCgs; if ((changelogResult.deferredBackpressure ?? 0) > 0) return changelogResult; @@ -4142,6 +4157,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { hasAccessDeniedCallback: Boolean(onAccessDenied), hasSinceBatchIdResolver: Boolean(sinceBatchIdFor), exactAssetUals: options?.exactAssetUals, + priority: options?.priority, }); return singleFlightKey ? runSyncSingleFlight(this, singleFlightKey, runSync) : runSync(); } @@ -4293,6 +4309,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { remotePeerId: string, contextGraphIds: string[], onAccessDenied?: (contextGraphId: string) => void, + priority?: number, ): Promise<{ result: DurableSyncResult; remainingLegacyCgs: string[] }> { const peerProtocols = await this.getPeerProtocols(remotePeerId); if (!peerProtocols.includes(PROTOCOL_SYNC_CHANGELOG)) { @@ -4333,6 +4350,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { item.lane, item.operationId, run, + priority, ), merge: mergeDurableSyncResults, markDeferred: (summary) => ({ @@ -4761,6 +4779,8 @@ export class LifecycleSyncMethods extends DKGAgentBase { options?: { stopOnBackoffWorthyFailure?: boolean; sharedMemorySyncPlan?: SharedMemorySyncContextGraphPlan; + /** Admission override for foreground catch-up. */ + priority?: number; }, ): Promise { const ctx = createOperationContext('sync'); @@ -4827,6 +4847,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { stopOnBackoffWorthyFailure, publicContextGraphIds, privateRecoverFromCurator, + priority: options?.priority, }); const runSync = async (): Promise => { @@ -4980,6 +5001,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { item.lane, item.operationId, run, + options?.priority, ), merge: mergeSharedMemorySyncResults, markDeferred: (summary) => ({ @@ -5084,7 +5106,15 @@ export class LifecycleSyncMethods extends DKGAgentBase { */ async syncContextGraphFromConnectedPeers(this: DKGAgent, contextGraphId: string, - options?: { includeSharedMemory?: boolean; maxPeers?: number; peerRotationKey?: string }, + options?: { + includeSharedMemory?: boolean; + maxPeers?: number; + peerRotationKey?: string; + /** Admission override used by explicit foreground catch-up callers. */ + priority?: number; + /** Retry only locally-deferred planes; completed planes are not rerun. */ + retryDeferredBackpressure?: boolean; + }, ): Promise<{ /** Ordered connected peers before optional maxPeers windowing. */ connectedPeers: number; @@ -5137,6 +5167,8 @@ export class LifecycleSyncMethods extends DKGAgentBase { includeSharedMemory, maxPeers: options?.maxPeers, peerRotationKey: options?.peerRotationKey, + priority: options?.priority, + retryDeferredBackpressure: options?.retryDeferredBackpressure, }); return runSyncSingleFlight(this, singleFlightKey, async (): Promise => { @@ -5186,6 +5218,8 @@ export class LifecycleSyncMethods extends DKGAgentBase { ); return this.runCatchupOverPeers(contextGraphId, includeSharedMemory, peers, { totalPeers: orderedPeers.length, + priority: options?.priority, + retryDeferredBackpressure: options?.retryDeferredBackpressure, }); }); } @@ -5276,7 +5310,11 @@ export class LifecycleSyncMethods extends DKGAgentBase { contextGraphId: string, includeSharedMemory: boolean, peers: Array<{ toString(): string }>, - stats?: { totalPeers?: number }, + stats?: { + totalPeers?: number; + priority?: number; + retryDeferredBackpressure?: boolean; + }, ): Promise<{ /** Ordered connected peers before optional caller windowing. */ connectedPeers: number; @@ -5432,13 +5470,37 @@ export class LifecycleSyncMethods extends DKGAgentBase { syncCapable, CATCHUP_MAX_CONCURRENT_PEER_SYNCS, async (remotePeerId) => { - const durable = await this.syncFromPeerDetailed( + const runDurable = () => this.syncFromPeerDetailed( remotePeerId, [contextGraphId], + undefined, + undefined, + undefined, + stats?.priority === undefined ? undefined : { priority: stats.priority }, + ); + const durable = await ( + stats?.retryDeferredBackpressure + ? retryCatchupPlaneOnBackpressure(runDurable) + : runDurable() ).catch(emptyDurable); - const shared = includeSharedMemory - ? await this.syncSharedMemoryFromPeerDetailed(remotePeerId, [contextGraphId]).catch(emptyShared) - : null; + + // SWM authorization/materialization depends on durable metadata. If + // durable admission remains deferred, do not manufacture a premature + // SWM denial. Once durable completes, retry only SWM; a successful VM + // plane is never fetched again just because SWM hit local pressure. + let shared: SharedMemorySyncResult | null = null; + if (includeSharedMemory && (durable.deferredBackpressure ?? 0) === 0) { + const runShared = () => this.syncSharedMemoryFromPeerDetailed( + remotePeerId, + [contextGraphId], + stats?.priority === undefined ? undefined : { priority: stats.priority }, + ); + shared = await ( + stats?.retryDeferredBackpressure + ? retryCatchupPlaneOnBackpressure(runShared) + : runShared() + ).catch(emptyShared); + } return { durable, shared }; }, ); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index a8496bf5ea..61c80cab4f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -269,6 +269,12 @@ export { // deep-importing the compiled `dist/` module. export { mapWithConcurrency } from './map-with-concurrency.js'; export { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from './sync/catchup-concurrency.js'; +export { + CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS, + FOREGROUND_CATCHUP_SYNC_PRIORITY, + retryCatchupPlaneOnBackpressure, + type CatchupBackpressureResult, +} from './sync/catchup-backpressure-retry.js'; export { classifyDurableProgress, type DurableProgressClassification, diff --git a/packages/agent/src/sync/catchup-backpressure-retry.ts b/packages/agent/src/sync/catchup-backpressure-retry.ts new file mode 100644 index 0000000000..7b345a1b8d --- /dev/null +++ b/packages/agent/src/sync/catchup-backpressure-retry.ts @@ -0,0 +1,39 @@ +/** + * User-requested catch-up must outrank autonomous exact-VM repair (priority + * 1_000) and ordinary background sync (priority 0). This lets a subscribe or + * explicit catch-up displace queued background work instead of being marked + * deferred before it has fetched a byte. + */ +export const FOREGROUND_CATCHUP_SYNC_PRIORITY = 2_000; + +/** + * Admission can still race another foreground catch-up. Retry that local-only + * outcome briefly; transport, authorization, timeout, and integrity failures + * are deliberately not retried here. + */ +export const CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS = [100, 250, 500] as const; + +export interface CatchupBackpressureResult { + deferredBackpressure?: number; +} + +export async function retryCatchupPlaneOnBackpressure( + run: () => Promise, + options?: { + delaysMs?: readonly number[]; + wait?: (delayMs: number) => Promise; + }, +): Promise { + const delaysMs = options?.delaysMs ?? CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS; + const wait = options?.wait ?? ((delayMs: number) => new Promise((resolve) => { + setTimeout(resolve, delayMs); + })); + + let result = await run(); + for (const delayMs of delaysMs) { + if ((result.deferredBackpressure ?? 0) === 0) break; + await wait(delayMs); + result = await run(); + } + return result; +} diff --git a/packages/agent/test/catchup-backpressure-retry.test.ts b/packages/agent/test/catchup-backpressure-retry.test.ts new file mode 100644 index 0000000000..311811f434 --- /dev/null +++ b/packages/agent/test/catchup-backpressure-retry.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS, + retryCatchupPlaneOnBackpressure, +} from '../src/sync/catchup-backpressure-retry.js'; + +describe('retryCatchupPlaneOnBackpressure', () => { + it('retries only the local scheduler deferral result', async () => { + const run = vi.fn() + .mockResolvedValueOnce({ deferredBackpressure: 1, marker: 'deferred' }) + .mockResolvedValueOnce({ deferredBackpressure: 0, marker: 'complete' }); + const waits: number[] = []; + + const result = await retryCatchupPlaneOnBackpressure(run, { + delaysMs: [3, 5], + wait: async (delayMs) => { waits.push(delayMs); }, + }); + + expect(result).toEqual({ deferredBackpressure: 0, marker: 'complete' }); + expect(run).toHaveBeenCalledTimes(2); + expect(waits).toEqual([3]); + }); + + it('returns the final deferred result after the bounded retry budget', async () => { + const run = vi.fn(async () => ({ deferredBackpressure: 1 })); + + const result = await retryCatchupPlaneOnBackpressure(run, { + wait: async () => {}, + }); + + expect(result.deferredBackpressure).toBe(1); + expect(run).toHaveBeenCalledTimes(CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS.length + 1); + }); + + it('does not retry a clean result', async () => { + const run = vi.fn(async () => ({ deferredBackpressure: 0 })); + + await retryCatchupPlaneOnBackpressure(run, { + wait: async () => { throw new Error('must not wait'); }, + }); + + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/agent/test/sync-requester-priority.test.ts b/packages/agent/test/sync-requester-priority.test.ts index 712507dbc8..43c451a50b 100644 --- a/packages/agent/test/sync-requester-priority.test.ts +++ b/packages/agent/test/sync-requester-priority.test.ts @@ -54,7 +54,11 @@ function durableContext(contextGraphIds: string[]) { function lifecycleAgent( priorities: Record, - runAdmission: (contextGraphId: string, work: () => Promise) => Promise, + runAdmission: ( + contextGraphId: string, + work: () => Promise, + priorityOverride?: number, + ) => Promise, ) { return { config: { syncContextGraphPriorities: priorities }, @@ -95,7 +99,8 @@ function lifecycleAgent( _lane: string, _label: string, work: () => Promise, - ) => runAdmission(contextGraphId, work), + priorityOverride?: number, + ) => runAdmission(contextGraphId, work, priorityOverride), log: { info: noop, warn: noop, debug: noop }, }; } @@ -121,6 +126,27 @@ describe('requester per-CG priority admission', () => { expect(admissions).toEqual(['high', 'default', 'low']); }); + it('passes a foreground durable priority override through admission', async () => { + const priorityOverrides: Array = []; + const agent = lifecycleAgent({}, async (_contextGraphId, work, priorityOverride) => { + priorityOverrides.push(priorityOverride); + return work(); + }); + + await (LifecycleSyncMethods.prototype.runLegacyDurableSync as any).call( + agent, + ctx, + 'peer', + ['foreground'], + undefined, + undefined, + undefined, + { priority: 2_000 }, + ); + + expect(priorityOverrides).toEqual([2_000]); + }); + it('preserves completed durable progress when a later admission is deferred', async () => { const admissions: string[] = []; const agent = lifecycleAgent({}, async (contextGraphId, work) => { @@ -148,6 +174,7 @@ describe('requester per-CG priority admission', () => { it('marks changelog admission pressure deferred without routing that graph to legacy', async () => { const admissions: string[] = []; + const priorityOverrides: Array = []; const emptyResult = { insertedTriples: 0, fetchedMetaTriples: 0, @@ -179,8 +206,10 @@ describe('requester per-CG priority admission', () => { _lane: string, _label: string, work: () => Promise, + priorityOverride?: number, ) => { admissions.push(contextGraphId); + priorityOverrides.push(priorityOverride); if (contextGraphId === 'second') { throw new SyncBackpressureBusyError('queue full'); } @@ -195,16 +224,20 @@ describe('requester per-CG priority admission', () => { ctx, 'peer', ['first', 'second', 'third'], + undefined, + 2_000, ); expect(admissions).toEqual(['first', 'second']); expect(lane.result.completedPhases).toBe(1); expect(lane.result.deferredBackpressure).toBe(1); expect(lane.remainingLegacyCgs).toEqual([]); + expect(priorityOverrides).toEqual([2_000, 2_000]); }); it('preserves completed shared-memory progress when a later admission is deferred', async () => { const admissions: string[] = []; + const priorityOverrides: Array = []; const warnings: string[] = []; const contextGraphIds = ['first', 'second', 'third']; const agent = { @@ -239,8 +272,10 @@ describe('requester per-CG priority admission', () => { _lane: string, _label: string, work: () => Promise, + priorityOverride?: number, ) => { admissions.push(contextGraphId); + priorityOverrides.push(priorityOverride); if (contextGraphId === 'second') { throw new SyncBackpressureBusyError('queue full'); } @@ -263,6 +298,7 @@ describe('requester per-CG priority admission', () => { privateRecoverFromCurator: [], eligibleContextGraphIds: contextGraphIds, }, + priority: 2_000, }); expect(admissions).toEqual(['first', 'second']); @@ -272,6 +308,7 @@ describe('requester per-CG priority admission', () => { expect(summary.deferredBackpressure).toBe(1); expect(summary.failedPeers).toBe(0); expect(summary.backoffWorthyFailures).toBe(0); + expect(priorityOverrides).toEqual([2_000, 2_000]); }); it('counts several failed Context Graphs from one remote as one failed peer', async () => { diff --git a/packages/cli/src/catchup-runner-worker-impl.ts b/packages/cli/src/catchup-runner-worker-impl.ts index af66fb1d70..013783ddbf 100644 --- a/packages/cli/src/catchup-runner-worker-impl.ts +++ b/packages/cli/src/catchup-runner-worker-impl.ts @@ -1,5 +1,9 @@ import { parentPort } from 'node:worker_threads'; -import { CATCHUP_MAX_CONCURRENT_PEER_SYNCS, mapWithConcurrency } from '@origintrail-official/dkg-agent'; +import { + CATCHUP_MAX_CONCURRENT_PEER_SYNCS, + mapWithConcurrency, + retryCatchupPlaneOnBackpressure, +} from '@origintrail-official/dkg-agent'; import { catchupPeerResponded, catchupPeerSucceeded, @@ -192,13 +196,22 @@ async function runCatchup(request: CatchupRunRequest): Promise syncCapable, CATCHUP_MAX_CONCURRENT_PEER_SYNCS, async (peerId) => { - const rawDurable = await invoke('syncDurable', peerId, request.contextGraphId).catch(() => emptyDurable()); + const rawDurable = await retryCatchupPlaneOnBackpressure( + () => invoke('syncDurable', peerId, request.contextGraphId), + ).catch(() => emptyDurable()); const durable = { ...rawDurable, verifiedPrivateOnlyResponses: rawDurable.verifiedPrivateOnlyResponses ?? 0, }; - const shared = request.includeSharedMemory - ? await invoke('syncSharedMemory', peerId, request.contextGraphId).catch(() => emptyShared()) + + // Durable metadata is required to authorize/materialize SWM. If VM is + // still locally deferred after bounded retries, leave SWM untouched for + // this peer. If only SWM is deferred, retry just SWM so the already- + // completed durable plane is never fetched a second time. + const shared = request.includeSharedMemory && (durable.deferredBackpressure ?? 0) === 0 + ? await retryCatchupPlaneOnBackpressure( + () => invoke('syncSharedMemory', peerId, request.contextGraphId), + ).catch(() => emptyShared()) : null; return { durable, shared }; }, diff --git a/packages/cli/src/catchup-runner.ts b/packages/cli/src/catchup-runner.ts index 9be274fab7..62dbec4a3d 100644 --- a/packages/cli/src/catchup-runner.ts +++ b/packages/cli/src/catchup-runner.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { classifyDurableProgress, + FOREGROUND_CATCHUP_SYNC_PRIORITY, type DKGAgent, type DurableProgressSummary, } from '@origintrail-official/dkg-agent'; @@ -291,11 +292,22 @@ class WorkerCatchupRunner implements CatchupRunner { } case 'syncDurable': { const [peerId, contextGraphId] = args as [string, string]; - return agent.syncFromPeerDetailed(peerId, [contextGraphId]); + return agent.syncFromPeerDetailed( + peerId, + [contextGraphId], + undefined, + undefined, + undefined, + { priority: FOREGROUND_CATCHUP_SYNC_PRIORITY }, + ); } case 'syncSharedMemory': { const [peerId, contextGraphId] = args as [string, string]; - return agent.syncSharedMemoryFromPeerDetailed(peerId, [contextGraphId]); + return agent.syncSharedMemoryFromPeerDetailed( + peerId, + [contextGraphId], + { priority: FOREGROUND_CATCHUP_SYNC_PRIORITY }, + ); } case 'finalizeCatchup': { const [contextGraphId] = args as [string, number, number]; @@ -318,6 +330,8 @@ class InlineCatchupRunner implements CatchupRunner { run(request: CatchupRunRequest): Promise { return this.agent.syncContextGraphFromConnectedPeers(request.contextGraphId, { includeSharedMemory: request.includeSharedMemory, + priority: FOREGROUND_CATCHUP_SYNC_PRIORITY, + retryDeferredBackpressure: true, }) as Promise; } diff --git a/packages/cli/test/catchup-runner-worker-impl.test.ts b/packages/cli/test/catchup-runner-worker-impl.test.ts index dd68c49b4e..2a68311727 100644 --- a/packages/cli/test/catchup-runner-worker-impl.test.ts +++ b/packages/cli/test/catchup-runner-worker-impl.test.ts @@ -9,7 +9,10 @@ // aggregation keeps its one-result-per-peer input-order shape, and one peer's // failure stays isolated instead of failing the whole run. import { describe, expect, it, vi } from 'vitest'; -import { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from '@origintrail-official/dkg-agent'; +import { + CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS, + CATCHUP_MAX_CONCURRENT_PEER_SYNCS, +} from '@origintrail-official/dkg-agent'; import type { CatchupJobResult, CatchupRunRequest } from '../src/catchup-runner.js'; // The worker impl wires itself to `parentPort` at module load, so a @@ -232,8 +235,10 @@ describe('catchup-runner-worker-impl bounded fan-out (sync-storm mitigation C-1) expect(result.diagnostics?.durable.failedPeers).toBe(1); }); - it('surfaces partial progress followed by local deferral without finalizing the catch-up', async () => { + it('retries only SWM after durable progress and finalizes when local pressure clears', async () => { const finalizeCalls: unknown[][] = []; + let durableCalls = 0; + let sharedCalls = 0; const result = await runWorkerCatchup({ contextGraphId: 'cg-deferred', includeSharedMemory: true }, async (method) => { switch (method) { case 'prepareCatchup': @@ -241,17 +246,22 @@ describe('catchup-runner-worker-impl bounded fan-out (sync-storm mitigation C-1) case 'waitForSyncProtocol': return true; case 'syncDurable': + durableCalls += 1; return durableResult(); - case 'syncSharedMemory': - return { - ...sharedResult(), - insertedTriples: 0, - fetchedDataTriples: 0, - insertedDataTriples: 0, - bytesReceived: 0, - completedPhases: 0, - deferredBackpressure: 1, - }; + case 'syncSharedMemory': { + sharedCalls += 1; + return sharedCalls === 1 + ? { + ...sharedResult(), + insertedTriples: 0, + fetchedDataTriples: 0, + insertedDataTriples: 0, + bytesReceived: 0, + completedPhases: 0, + deferredBackpressure: 1, + } + : sharedResult(); + } case 'finalizeCatchup': finalizeCalls.push([]); return null; @@ -261,11 +271,94 @@ describe('catchup-runner-worker-impl bounded fan-out (sync-storm mitigation C-1) }); expect(result.peersResponded).toBe(1); - expect(result.peersSucceeded).toBe(0); - expect(result.deferredBackpressure).toBe(1); + expect(result.peersSucceeded).toBe(1); + expect(result.deferredBackpressure).toBe(0); expect(result.dataSynced).toBe(1); - expect(result.sharedMemorySynced).toBe(0); - expect(result.diagnostics?.sharedMemory.deferredBackpressure).toBe(1); + expect(result.sharedMemorySynced).toBe(1); + expect(result.diagnostics?.sharedMemory.deferredBackpressure).toBe(0); + expect(durableCalls).toBe(1); + expect(sharedCalls).toBe(2); + expect(finalizeCalls).toEqual([[]]); + }); + + it('finishes deferred durable sync before starting SWM', async () => { + let durableCalls = 0; + let sharedCalls = 0; + const callOrder: string[] = []; + + const result = await runWorkerCatchup( + { contextGraphId: 'cg-durable-deferred', includeSharedMemory: true }, + async (method) => { + switch (method) { + case 'prepareCatchup': + return { preferredPeerId: undefined, isPrivateContextGraph: false, peerIds: ['peer-1'], connectedPeers: 1 }; + case 'waitForSyncProtocol': + return true; + case 'syncDurable': + durableCalls += 1; + callOrder.push(`durable-${durableCalls}`); + return durableCalls === 1 + ? { ...durableResult(), insertedTriples: 0, insertedDataTriples: 0, completedPhases: 0, deferredBackpressure: 1 } + : durableResult(); + case 'syncSharedMemory': + sharedCalls += 1; + callOrder.push('shared'); + return sharedResult(); + case 'finalizeCatchup': + return null; + default: + throw new Error(`unexpected invoke: ${method}`); + } + }, + ); + + expect(result.deferredBackpressure).toBe(0); + expect(durableCalls).toBe(2); + expect(sharedCalls).toBe(1); + expect(callOrder).toEqual(['durable-1', 'durable-2', 'shared']); + }); + + it('returns deferred after a bounded durable retry budget and never starts dependent SWM', async () => { + let durableCalls = 0; + let sharedCalls = 0; + const finalizeCalls: unknown[][] = []; + + const result = await runWorkerCatchup( + { contextGraphId: 'cg-persistently-deferred', includeSharedMemory: true }, + async (method) => { + switch (method) { + case 'prepareCatchup': + return { preferredPeerId: undefined, isPrivateContextGraph: false, peerIds: ['peer-1'], connectedPeers: 1 }; + case 'waitForSyncProtocol': + return true; + case 'syncDurable': + durableCalls += 1; + return { + ...durableResult(), + insertedTriples: 0, + fetchedDataTriples: 0, + insertedDataTriples: 0, + bytesReceived: 0, + completedPhases: 0, + deferredBackpressure: 1, + }; + case 'syncSharedMemory': + sharedCalls += 1; + return sharedResult(); + case 'finalizeCatchup': + finalizeCalls.push([]); + return null; + default: + throw new Error(`unexpected invoke: ${method}`); + } + }, + ); + + expect(durableCalls).toBe(CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS.length + 1); + expect(sharedCalls).toBe(0); + expect(result.deferredBackpressure).toBe(1); + expect(result.peersResponded).toBe(0); + expect(result.peersSucceeded).toBe(0); expect(finalizeCalls).toEqual([]); }); From 10ff3cf3980fe1e2a0ec98023f974bfd40000a42 Mon Sep 17 00:00:00 2001 From: Jurij89 <138491694+Jurij89@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:27:18 -0400 Subject: [PATCH 170/292] feat(publisher): job-scoped terminal cleanup for publisher + SWM share queues (#1837) (#1883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(publisher): job-scoped terminal cleanup for publisher + SWM share queues (#1837) Adds two atomic, idempotent, by-exact-jobId TERMINAL-clear operations (epic PR4). Each uses the node's native terminal state as the sole authority, rejects nonterminal/unknown/ malformed without mutation, never broadens to other jobs, and returns a bounded TerminalJobClearOutcome { cleared | already_absent | rejected(nonterminal|unknown|malformed) }. Publisher (lift): - clearTerminalJob(jobId) on a segregated VmPublishTerminalJobClearer interface. Runs INSIDE withClaimLock and — the anti-sweep fix — retry() is now ALSO wrapped in withClaimLock (retry() is the only lock-free terminal→active transition; without this a concurrent by-id clear could sweep a just-reaccepted active job). Terminal authority = new shared isClearableTerminalLiftJob (finalized|failed, excluding retry_recovery-failed which may still land an on-chain tx); bulk clear() refactored to reuse it (behavior identical). Ordered defensive read splits already_absent/malformed/unknown/nonterminal without throwing. Preserves the #1829 journal by construction (deleteJob is subject-scoped to the control-plane graph; a regression test asserts the lineage survives a clear). Promote (SWM share): - clearTerminalJob(jobId) on a segregated PromoteTerminalJobClearer, inside the existing withMutationLock (already serializes every transition, incl. recover()), so it is race-safe with no new locking. New subject-scoped deleteJob primitive. Terminal = {succeeded, failed} with NO carve-out (nothing background re-drives a terminal promote row). Bare-state probe splits already_absent/unknown/malformed. Surface: POST /api/publisher/clear-job and POST /api/knowledge-assets/swm/share-jobs/:id/clear (both DISTINCT from cancel; already_absent→200 not 404); agent.assertion.clearPromoteAsync pass-through; api-client publisherClearJob + knowledgeAssetClearShareJob. Base contracts stay frozen; factory/getter intersections widened. Tests: 19 unit (lift 11: all outcomes, retry_recovery injection, no-sweep vs concurrent retry(), journal preservation, concurrent determinism; promote 8) + 3 route (outcome→HTTP) + existing lift/promote regression 67 unchanged. Publisher/agent/cli tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(publisher): address #1883 local-review findings (clear-job route + promote parse + shared responder) - 🔴 POST /api/publisher/clear-job: `const { jobId } = JSON.parse(body)` threw on a literal `null` body (JSON.parse succeeds, destructure of null → TypeError → 500). Optional-chain the parsed body so a null/primitive falls through to the bounded 400 malformed guard. Regression: a `null` body returns 400 rejected(malformed), not 500. - 🟡 promote clearTerminalJob: the state-triple parse (`parseLiteral` = JSON.parse) was unguarded and could throw out of the method, contradicting its "never throws" contract and diverging from the lift sibling. Wrap it in try/catch → rejected(unknown). Regression: a subject with an unrecognized state literal returns rejected(unknown) without throwing. - 💡 extract respondTerminalClearOutcome(res, outcome, jobId) into http-utils, used by BOTH the publisher and SWM share-job clear handlers, so the TerminalJobClearOutcome→HTTP contract is single-sourced and can't drift. Publisher/agent/cli tsc clean; clear tests 20 (lift 11 + promote 9) + route 4 green. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(publisher): address #1883 review round 2 — validate clear jobId + neutral shared type (#1837) Combined remote (otReviewAgent) + local multi-angle review findings: - 🔴 (remote): clearTerminalJob interpolated an unvalidated jobId into the control-plane SPARQL IRI (jobSubject(jobId) in `<…>`); the new clear-job route accepts arbitrary jobId from a JSON body, so a value with a space / '>' / '{' could break the query out of the IRI → 500 / injection instead of the bounded outcome. Add isSafeClearJobId (the producer grammar /^[A-Za-z0-9][A-Za-z0-9._:-]*$/, ≤256 — IRI-safe) and reject an empty OR unsafe jobId as `malformed` BEFORE building the query, in BOTH clearers (lift + promote). - 🟡 (remote) + 🔵 (local, corroborated): TerminalJobClearOutcome was parked in async-lift-publisher-types.ts and imported by the promote queue (cross-family coupling). Move it to a neutral `terminal-job-clear.ts` (which also owns isSafeClearJobId); both queue type modules + impls import from there; barrels re-export from the neutral owner. - 🟡 (remote): the SWM share-job clear endpoint had no route-level test. Add route tests in promote-async-routes.test.ts (terminal→200 cleared, repeat/absent→200 already_absent, nonterminal→409, unsafe path→400 before queue lookup) + the clearPromoteAsync mock facade. Also add unsafe-jobId cases to the lift/promote clear unit tests and the publisher clear-job route test (bad id / bad>id → 400 malformed, no 500/query error). Deferred (reasoned replies on-thread): the capability-intersection→named-contract refactor and the per-route JSON-parsing-island helper — both follow the established #1828/#1829 convention and are cross-cutting cleanups best done on their own, not correctness gaps. Publisher/agent/cli tsc clean; publisher clear unit 20 + route 41 green. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(publisher): address bot round-3 maintainability findings on #1837 terminal clear Follow-up to the round-2 fixes on PR #1883; resolves the four maintainability comments the reviewer raised against those fixes. No behaviour change. - Type the promote-queue capability at the ownership boundary instead of casting it at the getter: `DKGAgentBase._promoteQueue` is now `AsyncPromoteQueue & PromoteTerminalJobClearer`, so a substituted queue must satisfy the clearer at compile time (closes the runtime type hole). - Move `respondTerminalClearOutcome` out of the generic `http-utils.ts` into a route-owned `daemon/routes/terminal-clear-response.ts`; both clear routes import it there. `http-utils.ts` stays protocol-level. - Make the safe job-id grammar a single authoritative contract: the publisher exports `SAFE_CLEAR_JOB_ID_PATTERN`/`SAFE_CLEAR_JOB_ID_MAX_LENGTH`, and the CLI `validatePromoteJobId` imports them instead of re-declaring the regex, so route-level and control-plane job-id acceptance cannot drift. - Add a production DKGAgent facade regression (`clear-promote-async-facade.test.ts`) that drives a REAL promote queue through `agent.assertion.clearPromoteAsync`, asserting exact-row removal, idempotent repeat, and nonterminal rejection — a mis-delegation to `cancel()` (which retains the row) would fail it. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cli): cover the #1837 clear-job ApiClient wrappers Follow-up to the round-4 review on PR #1883. The new thin client wrappers `knowledgeAssetClearShareJob` and `publisherClearJob` were exercised only indirectly; a wrong URL/method/encoding/body would break callers while the route + queue tests stayed green. Adds a focused block in api-client.test.ts (beside the existing share-job helper assertions) asserting: - knowledgeAssetClearShareJob('share/job 1') → POST /api/knowledge-assets/swm/share-jobs/share%2Fjob%201/clear with {} (method, percent-encoding, empty body). - publisherClearJob('lift job 7') → POST /api/publisher/clear-job with { jobId: 'lift job 7' } (jobId travels in the body, not the path). Co-Authored-By: Claude Opus 4.8 (1M context) * chore(publisher): round-5 review touch-ups on #1883 (no behaviour change) Small follow-ups to the round-4 review on PR #1883: - Make TERMINAL_PROMOTE_JOB_STATES the single source of truth: isTerminalPromoteJobState now returns TERMINAL_PROMOTE_JOB_STATES.includes(state) instead of duplicating ['succeeded','failed'] inline (removes the dead constant). - Wire the #1837 DKGAgent facade regression into the curated agent unit suite: add clear-promote-async-facade.test.ts to vitest.unit.config.ts, beside the sibling promote-async-default-agent.test.ts. (It already ran in CI, which globs the full test/ dir via ci-shard-agent.mjs; this is a test:unit consistency add.) - Lock the shared terminal-clear HTTP contract: add terminal-clear-response.test.ts asserting respondTerminalClearOutcome for every branch, incl. the previously untested rejected(unknown) -> 409 mapping. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/agent/src/dkg-agent-base.ts | 6 +- packages/agent/src/dkg-agent.ts | 8 +- .../test/clear-promote-async-facade.test.ts | 81 ++++++++ packages/agent/vitest.unit.config.ts | 1 + packages/cli/src/api-client.ts | 15 ++ .../routes/knowledge-assets-async-share.ts | 12 ++ .../cli/src/daemon/routes/knowledge-assets.ts | 16 ++ packages/cli/src/daemon/routes/publisher.ts | 24 +++ .../daemon/routes/shared-assertion-helpers.ts | 14 +- .../daemon/routes/terminal-clear-response.ts | 28 +++ packages/cli/src/publisher-runner.ts | 3 +- packages/cli/test/api-client.test.ts | 16 ++ .../cli/test/promote-async-routes.test.ts | 48 +++++ .../test/publisher-clear-job-route.test.ts | 163 +++++++++++++++ .../cli/test/terminal-clear-response.test.ts | 55 +++++ packages/cli/vitest.unit.config.ts | 1 + .../src/async-lift-publisher-impl.ts | 80 ++++++-- .../src/async-lift-publisher-types.ts | 13 ++ .../src/async-lift-publisher-utils.ts | 13 ++ .../publisher/src/async-lift-publisher.ts | 2 + .../publisher/src/async-promote-queue-impl.ts | 57 ++++- .../src/async-promote-queue-types.ts | 14 ++ .../src/async-promote-queue-utils.ts | 14 ++ packages/publisher/src/async-promote-queue.ts | 1 + packages/publisher/src/index.ts | 7 + packages/publisher/src/terminal-job-clear.ts | 36 ++++ .../test/async-lift-terminal-clear.test.ts | 194 ++++++++++++++++++ .../test/async-promote-terminal-clear.test.ts | 139 +++++++++++++ packages/publisher/vitest.unit.config.ts | 2 + 29 files changed, 1041 insertions(+), 22 deletions(-) create mode 100644 packages/agent/test/clear-promote-async-facade.test.ts create mode 100644 packages/cli/src/daemon/routes/terminal-clear-response.ts create mode 100644 packages/cli/test/publisher-clear-job-route.test.ts create mode 100644 packages/cli/test/terminal-clear-response.test.ts create mode 100644 packages/publisher/src/terminal-job-clear.ts create mode 100644 packages/publisher/test/async-lift-terminal-clear.test.ts create mode 100644 packages/publisher/test/async-promote-terminal-clear.test.ts diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index 96c25adf20..c0fd71ce63 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -115,6 +115,7 @@ import { FileWorkspacePublicSnapshotStore, parseWorkspacePublicSnapshotNQuads, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, + type PromoteTerminalJobClearer, type PromoteJob, type PromoteListFilter, wrapAsRpcPreconditionIfApplicable, type PublishOptions, type PublishResult, type PhaseCallback, type KAMetadata, type CASCondition, @@ -554,7 +555,10 @@ export class DKGAgentBase { * getter so the worker (a daemon-side concern) and tests can drive * the queue directly without going through the assertion subsurface. */ - protected _promoteQueue?: AsyncPromoteQueue; + // Typed with the terminal-clear capability at the ownership boundary (not cast at the + // getter): the only assigned value is `TripleStoreAsyncPromoteQueue`, which implements it, + // and any test/subclass substituting a queue must now satisfy the clearer at compile time. + protected _promoteQueue?: AsyncPromoteQueue & PromoteTerminalJobClearer; /** * Override for tests / future operator config. When set before * `promoteQueue` is first accessed, the queue is constructed with diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 812b51f617..cf228a8335 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -107,6 +107,7 @@ import { FileWorkspacePublicSnapshotStore, parseWorkspacePublicSnapshotNQuads, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, + type PromoteTerminalJobClearer, type TerminalJobClearOutcome, type PromoteJob, type PromoteListFilter, wrapAsRpcPreconditionIfApplicable, resolveStorageAckTiming, @@ -2968,6 +2969,11 @@ export class DKGAgent extends DKGAgentBase { async recoverPromoteAsync(jobId: string): Promise { return agent.promoteQueue.recover(jobId); }, + // #1837 — atomic by-jobId terminal clear (record removal). Distinct from + // cancelPromoteAsync (queued abort, retains the row). + async clearPromoteAsync(jobId: string): Promise { + return agent.promoteQueue.clearTerminalJob(jobId); + }, }; } @@ -2983,7 +2989,7 @@ export class DKGAgent extends DKGAgentBase { * `recordCommitMarker` / `recoverOnStartup`) without the assertion * subsurface having to leak those methods to user-facing callers. */ - get promoteQueue(): AsyncPromoteQueue { + get promoteQueue(): AsyncPromoteQueue & PromoteTerminalJobClearer { if (!this._promoteQueue) { this._promoteQueue = new TripleStoreAsyncPromoteQueue(this.store, this._promoteQueueConfig ?? {}); } diff --git a/packages/agent/test/clear-promote-async-facade.test.ts b/packages/agent/test/clear-promote-async-facade.test.ts new file mode 100644 index 0000000000..4f83cb7cb1 --- /dev/null +++ b/packages/agent/test/clear-promote-async-facade.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { + TripleStoreAsyncPromoteQueue, + type PromoteRequest, +} from '@origintrail-official/dkg-publisher'; +import { DKGAgent } from '../src/dkg-agent.js'; + +// #1837 — verifies the PRODUCTION DKGAgent facade wiring between +// `agent.assertion.clearPromoteAsync` and `promoteQueue.clearTerminalJob`, driving a REAL +// promote queue. The SWM route tests stub `clearPromoteAsync` onto a fake agent, so they +// cover the route + queue contract but NOT this delegation: a regression that pointed the +// facade at `cancel()` (which retains the row) or any other queue method would leave those +// route tests green yet be caught here. +describe('DKGAgent assertion.clearPromoteAsync facade wiring (#1837)', () => { + const stores: OxigraphStore[] = []; + afterEach(async () => { + await Promise.all(stores.splice(0).map((s) => s.close().catch(() => {}))); + }); + + function makeRequest(overrides: Partial = {}): PromoteRequest { + return { contextGraphId: 'graphify', subGraphName: 'code', assertionName: 'shard-1', entities: 'all', ...overrides }; + } + + function newQueue(): TripleStoreAsyncPromoteQueue { + const store = new OxigraphStore(); + stores.push(store); + let id = 0; + return new TripleStoreAsyncPromoteQueue(store, { now: () => 1_000_000, idGenerator: () => `job-${++id}` }); + } + + // Real DKGAgent facade over an injected real queue — `agent.assertion.clearPromoteAsync` + // routes through the public `promoteQueue` getter, exactly as production does. + // `defaultAgentAddress` is set so the `assertion` getter's `this.defaultAgentAddress ?? + // this.peerId` resolves without touching the unbuilt libp2p `node` (mirrors the sibling + // promote-async-default-agent facade test). + function agentFor(queue: TripleStoreAsyncPromoteQueue): { assertion: { clearPromoteAsync(jobId: string): Promise } } { + const agent = Object.create(DKGAgent.prototype) as { + _promoteQueue: TripleStoreAsyncPromoteQueue; + defaultAgentAddress: string; + }; + agent.defaultAgentAddress = `0x${'11'.repeat(20)}`; + agent._promoteQueue = queue; + return agent as unknown as { assertion: { clearPromoteAsync(jobId: string): Promise } }; + } + + async function driveToSucceeded(queue: TripleStoreAsyncPromoteQueue): Promise { + const jobId = await queue.enqueue(makeRequest()); + const claimed = await queue.claimNext('worker-1'); + const token = claimed!.lease!.claimToken; + for (const marker of ['swmInserted', 'wmCleaned', 'lifecycleStamped', 'gossiped'] as const) { + await queue.recordCommitMarker(jobId, token, marker); + } + await queue.succeed(jobId, token, { promotedCount: 1, succeededAt: 1_000_000 }); + return jobId; + } + + it('clears a terminal job through the real facade, removes only that row, and is idempotent', async () => { + const queue = newQueue(); + const target = await driveToSucceeded(queue); + const other = await driveToSucceeded(queue); + const agent = agentFor(queue); + + // cleared — and the row is actually gone (a mis-delegation to cancel() would retain it). + expect(await agent.assertion.clearPromoteAsync(target)).toEqual({ outcome: 'cleared' }); + expect(await queue.getStatus(target)).toBeNull(); + expect((await queue.getStatus(other))?.state).toBe('succeeded'); // only the exact row cleared + + // Idempotent repeat via the facade. + expect(await agent.assertion.clearPromoteAsync(target)).toEqual({ outcome: 'already_absent' }); + }); + + it('rejects a nonterminal (queued) job through the facade without mutation', async () => { + const queue = newQueue(); + const queued = await queue.enqueue(makeRequest()); + const agent = agentFor(queue); + + expect(await agent.assertion.clearPromoteAsync(queued)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(queued))?.state).toBe('queued'); // untouched + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 107e88a784..ae68dfc21d 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "test/publish-foreign-author-resolution.test.ts", "test/durable-integrity-seal-assertion-version.test.ts", "test/promote-async-default-agent.test.ts", + "test/clear-promote-async-facade.test.ts", "test/query-min-trust-alias.test.ts", "test/sync-envelope-cursor.test.ts", "test/exact-assets.test.ts", diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index 0c02b3b9e0..167f63a0b9 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -798,6 +798,13 @@ export class ApiClient { return this.post(`/api/knowledge-assets/swm/share-jobs/${encodeURIComponent(jobId)}/recover`, {}); } + // #1837 — atomic by-exact-jobId TERMINAL record removal (idempotent). Distinct from + // knowledgeAssetCancelShareJob (DELETE = queued cancellation that retains the row). + // cleared / already_absent resolve normally (200); rejected throws via post(). + async knowledgeAssetClearShareJob(jobId: string): Promise<{ outcome: 'cleared' | 'already_absent'; jobId: string }> { + return this.post(`/api/knowledge-assets/swm/share-jobs/${encodeURIComponent(jobId)}/clear`, {}); + } + /** Publish to VM (mint or update on chain; git push origin main). */ async knowledgeAssetPublish( contextGraphId: string, @@ -1279,6 +1286,14 @@ export class ApiClient { return this.post('/api/publisher/clear', { status }); } + // #1837 — atomic by-exact-jobId TERMINAL clear (distinct from publisherCancel and the + // status-scoped publisherClear). cleared / already_absent resolve normally (200); + // rejected (nonterminal/unknown → 409, malformed → 400) throws via the post() helper + // carrying { outcome:'rejected', reason } in the response body. + async publisherClearJob(jobId: string): Promise<{ outcome: 'cleared' | 'already_absent'; jobId: string }> { + return this.post('/api/publisher/clear-job', { jobId }); + } + // ------------------------- EPCIS ------------------------------------- async captureEpcis(request: { diff --git a/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts b/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts index d34173b7c4..1de92087bd 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts @@ -23,6 +23,7 @@ // Shared logic lives in `./shared-assertion-helpers.js`. import type { RequestContext } from "./context.js"; +import { respondTerminalClearOutcome } from "./terminal-clear-response.js"; import { jsonResponse, readBody, @@ -238,3 +239,14 @@ export async function handleKaShareJobRecover(ctx: RequestContext, jobId: string throw err; } } + +// ── POST /api/knowledge-assets/swm/share-jobs/:jobId/clear ──────────────────── +// +// #1837 — atomic by-exact-jobId TERMINAL record removal. DISTINCT from the DELETE +// cancellation above (which rewrites a queued job to failed+cancelled and RETAINS the +// row): this REMOVES a native-terminal (succeeded|failed) job record and is idempotent +// (already_absent = 200, not 404). The caller passes the already url-decoded jobId. +export async function handleKaShareJobClear(ctx: RequestContext, jobId: string): Promise { + const { res, agent } = ctx; + return respondTerminalClearOutcome(res, await agent.assertion.clearPromoteAsync(jobId), jobId); +} diff --git a/packages/cli/src/daemon/routes/knowledge-assets.ts b/packages/cli/src/daemon/routes/knowledge-assets.ts index cde6495b35..c15f3b7d95 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets.ts @@ -62,6 +62,7 @@ import { handleKaShareJobsList, handleKaShareJobStatus, handleKaShareJobCancel, + handleKaShareJobClear, handleKaShareJobRecover, } from "./knowledge-assets-async-share.js"; import { @@ -667,6 +668,21 @@ export async function handleKnowledgeAssetsRoutes(ctx: RequestContext): Promise< if (jobId === null) return; return handleKaShareJobRecover(ctx, jobId); } + // POST /api/knowledge-assets/swm/share-jobs/:jobId/clear — #1837 atomic terminal + // record removal (idempotent). DISTINCT from the DELETE cancellation below (which + // rewrites a queued job to failed+cancelled and RETAINS the row). + if ( + method === "POST" && + path.startsWith(`${SHARE_JOBS_PREFIX}/`) && + path.endsWith("/clear") + ) { + const jobId = decodePromoteJobId( + path.slice(`${SHARE_JOBS_PREFIX}/`.length, -"/clear".length), + res, + ); + if (jobId === null) return; + return handleKaShareJobClear(ctx, jobId); + } // GET /api/knowledge-assets/swm/share-jobs/:jobId — status (#3) if ( method === "GET" && diff --git a/packages/cli/src/daemon/routes/publisher.ts b/packages/cli/src/daemon/routes/publisher.ts index 72c5b9f8f3..7d868146c4 100644 --- a/packages/cli/src/daemon/routes/publisher.ts +++ b/packages/cli/src/daemon/routes/publisher.ts @@ -186,6 +186,7 @@ import { bindingValue, carryForwardBundledMarkItDownBinary, } from '../manifest.js'; +import { respondTerminalClearOutcome } from './terminal-clear-response.js'; import { resolveNameToPeerId, jsonResponse, @@ -545,4 +546,27 @@ export async function handlePublisherRoutes(ctx: RequestContext): Promise const count = await publisherControl.clear(status); return jsonResponse(res, 200, { cleared: count, status }); } + + // POST /api/publisher/clear-job { jobId } + // #1837 — atomic by-exact-jobId TERMINAL clear. DISTINCT from cancel (which aborts an + // ACCEPTED job) and from bulk /clear (status-scoped): clears exactly one job iff it is + // in a native terminal state, is idempotent for an absent job (already_absent = 200, + // NOT 404), and never touches another job. Preserves the #1829 journal (subject-scoped). + if (req.method === "POST" && path === "/api/publisher/clear-job") { + const body = await readBody(req, SMALL_BODY_BYTES); + let clearJobParsed: any; + try { + clearJobParsed = JSON.parse(body || "{}"); + } catch { + return jsonResponse(res, 400, { error: "Invalid JSON body" }); + } + // `JSON.parse("null")` etc. succeeds but yields a non-object; optional-chain so a + // `null`/primitive body falls through to the malformed guard (400), never a + // destructure TypeError → 500. + const jobId = clearJobParsed && typeof clearJobParsed === "object" ? clearJobParsed.jobId : undefined; + if (typeof jobId !== "string" || jobId.trim().length === 0) { + return jsonResponse(res, 400, { outcome: "rejected", reason: "malformed", error: "Missing jobId" }); + } + return respondTerminalClearOutcome(res, await publisherControl.clearTerminalJob(jobId), jobId); + } } diff --git a/packages/cli/src/daemon/routes/shared-assertion-helpers.ts b/packages/cli/src/daemon/routes/shared-assertion-helpers.ts index 76a287b5c2..f0a42d2d6a 100644 --- a/packages/cli/src/daemon/routes/shared-assertion-helpers.ts +++ b/packages/cli/src/daemon/routes/shared-assertion-helpers.ts @@ -20,7 +20,12 @@ import { resolveImportedArtifactMetadata, assertRdfLiteralMutf8Safe, } from '@origintrail-official/dkg-core'; -import { type PromoteJob, type PromoteJobState } from '@origintrail-official/dkg-publisher'; +import { + type PromoteJob, + type PromoteJobState, + SAFE_CLEAR_JOB_ID_PATTERN, + SAFE_CLEAR_JOB_ID_MAX_LENGTH, +} from '@origintrail-official/dkg-publisher'; import { daemonState } from '../state.js'; import { jsonResponse, @@ -116,9 +121,12 @@ export class ImportArtifactRouteError extends Error { } export function validatePromoteJobId(jobId: string): { valid: true } | { valid: false; reason: string } { + // Grammar + length bound are imported from the publisher (the single authoritative + // job-id contract, also enforced control-plane-side by `isSafeClearJobId`) so route + // acceptance and control-plane acceptance cannot drift. if (!jobId) return { valid: false, reason: "jobId is required" }; - if (jobId.length > 256) return { valid: false, reason: "jobId is too long" }; - if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(jobId)) { + if (jobId.length > SAFE_CLEAR_JOB_ID_MAX_LENGTH) return { valid: false, reason: "jobId is too long" }; + if (!SAFE_CLEAR_JOB_ID_PATTERN.test(jobId)) { return { valid: false, reason: "jobId may only contain letters, numbers, '.', '_', ':', and '-'", diff --git a/packages/cli/src/daemon/routes/terminal-clear-response.ts b/packages/cli/src/daemon/routes/terminal-clear-response.ts new file mode 100644 index 0000000000..4bfe8c0274 --- /dev/null +++ b/packages/cli/src/daemon/routes/terminal-clear-response.ts @@ -0,0 +1,28 @@ +// daemon/routes/terminal-clear-response.ts +// +// #1837 — single source for the TerminalJobClearOutcome → HTTP projection, shared by the +// publisher (`POST /api/publisher/clear-job`) and SWM share-job +// (`POST /api/knowledge-assets/swm/share-jobs/:jobId/clear`) clear routes so the response +// contract cannot drift. This is route-owned policy (both clear routes are the only +// callers), so it lives beside the routes rather than in the generic `http-utils.ts` +// bucket, which stays focused on protocol-level helpers (JSON responses, body parsing). +// +// Mapping: `cleared` / `already_absent` are SUCCESS (200, idempotent — NOT 404); +// `rejected(malformed)` is a client input error (400); `rejected(nonterminal|unknown)` is a +// server-side state condition (409). + +import type { ServerResponse } from "node:http"; +import type { TerminalJobClearOutcome } from "@origintrail-official/dkg-publisher"; +import { jsonResponse } from "../http-utils.js"; + +export function respondTerminalClearOutcome( + res: ServerResponse, + outcome: TerminalJobClearOutcome, + jobId: string, +): void { + if (outcome.outcome === "cleared" || outcome.outcome === "already_absent") { + jsonResponse(res, 200, { ...outcome, jobId }); + return; + } + jsonResponse(res, outcome.reason === "malformed" ? 400 : 409, { ...outcome, jobId }); +} diff --git a/packages/cli/src/publisher-runner.ts b/packages/cli/src/publisher-runner.ts index 866afebdba..d23e4f1b51 100644 --- a/packages/cli/src/publisher-runner.ts +++ b/packages/cli/src/publisher-runner.ts @@ -33,6 +33,7 @@ import { type VmPublishIntentRecoveryPublisher, type VmPublishIntentIndexBackfiller, type VmPublishAdmissionJournalReader, + type VmPublishTerminalJobClearer, type LiftJobBroadcast, type LiftJobHex, type LiftJobIncluded, @@ -374,7 +375,7 @@ export function createPublisherInspectorFromStore( export function createPublisherControlFromStore( store: TripleStore, options: { publicSnapshotStore?: WorkspacePublicSnapshotStore; maxRetries?: number } = {}, -): VmPublishIntentRecoveryPublisher & VmPublishIntentIndexBackfiller & VmPublishAdmissionJournalReader { +): VmPublishIntentRecoveryPublisher & VmPublishIntentIndexBackfiller & VmPublishAdmissionJournalReader & VmPublishTerminalJobClearer { // The daemon admission instance also serves the #1828 recovery lookup (route) // and the boot index backfill — segregated capabilities the base // AsyncLiftPublisher runtime contract intentionally does NOT carry. diff --git a/packages/cli/test/api-client.test.ts b/packages/cli/test/api-client.test.ts index e0f822665b..18211caeb2 100644 --- a/packages/cli/test/api-client.test.ts +++ b/packages/cli/test/api-client.test.ts @@ -1120,6 +1120,22 @@ describe('ApiClient — GitHub-shaped knowledge-assets SDK (OT-RFC-43 §10.5)', expect(calls[0].opts.method).toBe('POST'); }); + it('#1837 clear-job helpers POST the terminal-clear routes with correct encoding and body', async () => { + // SWM share-job clear: jobId is percent-encoded in the path, empty JSON body. + let calls = track({ outcome: 'cleared', jobId: 'share/job 1' }); + await client.knowledgeAssetClearShareJob('share/job 1'); + expect(calls[0].url).toBe(`${base}/api/knowledge-assets/swm/share-jobs/share%2Fjob%201/clear`); + expect(calls[0].opts.method).toBe('POST'); + expect(JSON.parse(calls[0].opts.body as string)).toEqual({}); + + // Publisher clear-job: jobId travels in the body, not the path. + calls = track({ outcome: 'already_absent', jobId: 'lift job 7' }); + await client.publisherClearJob('lift job 7'); + expect(calls[0].url).toBe(`${base}/api/publisher/clear-job`); + expect(calls[0].opts.method).toBe('POST'); + expect(JSON.parse(calls[0].opts.body as string)).toEqual({ jobId: 'lift job 7' }); + }); + it('knowledgeAssetShareAsync rejects unsupported sync-only options before HTTP serialization', async () => { const calls = track({ jobId: 'should-not-reach', state: 'queued' }); await expect(client.knowledgeAssetShareAsync('cg', 'f', { diff --git a/packages/cli/test/promote-async-routes.test.ts b/packages/cli/test/promote-async-routes.test.ts index 3cf2247b98..f2e3215f72 100644 --- a/packages/cli/test/promote-async-routes.test.ts +++ b/packages/cli/test/promote-async-routes.test.ts @@ -118,6 +118,9 @@ describe('async SWM-share queue daemon routes', () => { async recoverPromoteAsync(jobId: string): Promise { return queue.recover(jobId); }, + async clearPromoteAsync(jobId: string) { + return (queue as TripleStoreAsyncPromoteQueue).clearTerminalJob(jobId); + }, }, }; } @@ -561,6 +564,51 @@ describe('async SWM-share queue daemon routes', () => { expect(r.body.error).toMatch(/Invalid promote jobId/); }); + // --------------------------------------------------------------------------- + // POST /api/knowledge-assets/swm/share-jobs/:jobId/clear (#1837) + // Atomic terminal record removal — DISTINCT from DELETE (cancel). Idempotent: + // already_absent is 200, not 404. + // --------------------------------------------------------------------------- + + it('POST /swm/share-jobs/:jobId/clear clears a terminal job → 200 cleared; repeat → 200 already_absent', async () => { + await startRoutes(makeAgent()); + const enq = await post('/api/knowledge-assets/clearable/swm/share-async', { contextGraphId: 'cg' }); + await del(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}`); // cancel → terminal 'failed' + expect((await queue.getStatus(enq.body.jobId))?.state).toBe('failed'); + + const r = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ outcome: 'cleared' }); + expect(await queue.getStatus(enq.body.jobId)).toBeNull(); + + const again = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); + expect(again.status).toBe(200); + expect(again.body).toMatchObject({ outcome: 'already_absent' }); + }); + + it('POST /swm/share-jobs/:jobId/clear returns 409 for a nonterminal (queued) job, without mutation', async () => { + await startRoutes(makeAgent()); + const enq = await post('/api/knowledge-assets/queued2/swm/share-async', { contextGraphId: 'cg' }); + const r = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); + expect(r.status).toBe(409); + expect(r.body).toMatchObject({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(enq.body.jobId))?.state).toBe('queued'); + }); + + it('POST /swm/share-jobs/:jobId/clear returns 200 already_absent for an unknown job (idempotent)', async () => { + await startRoutes(makeAgent()); + const r = await post('/api/knowledge-assets/swm/share-jobs/non-existent/clear', {}); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ outcome: 'already_absent' }); + }); + + it('POST /swm/share-jobs/:jobId/clear rejects an unsafe path value before queue lookup (400)', async () => { + await startRoutes(makeAgent()); + const r = await post('/api/knowledge-assets/swm/share-jobs/job%3Ebad/clear', {}); + expect(r.status).toBe(400); + expect(r.body.error).toMatch(/Invalid promote jobId/); + }); + // --------------------------------------------------------------------------- // Routing precedence — the list route (`/swm/share-jobs`) must not be // claimed by the per-job route (`/swm/share-jobs/:jobId`). diff --git a/packages/cli/test/publisher-clear-job-route.test.ts b/packages/cli/test/publisher-clear-job-route.test.ts new file mode 100644 index 0000000000..975ca69793 --- /dev/null +++ b/packages/cli/test/publisher-clear-job-route.test.ts @@ -0,0 +1,163 @@ +import type { ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { afterEach, describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { createPublisherControlFromStore } from '../src/publisher-runner.js'; +import { handlePublisherRoutes } from '../src/daemon/routes/publisher.js'; +import type { RequestContext } from '../src/daemon/routes/context.js'; + +// #1837 — POST /api/publisher/clear-job outcome → HTTP mapping. +describe('#1837 POST /api/publisher/clear-job', () => { + const stores: OxigraphStore[] = []; + let ids = 0; + let now = 1_000; + afterEach(async () => { + await Promise.all(stores.splice(0).map((s) => s.close().catch(() => {}))); + }); + + function kaVmPublishRequest() { + const authorAddress = '0x1111111111111111111111111111111111111111'; + const kaNumber = 7n; + const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; + return { + contextGraphId: 'music-social', name: 'albums', agentAddress: '0x0', shareOperationId: 'share-op-1', + roots: [] as string[], contentScopeVersion: 2 as const, kaUal, assertionVersion: '1', + publicTripleCount: 2, privateTripleCount: 0, + seal: { + merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, authorAddress: authorAddress as `0x${string}`, + signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, + schemeVersion: 1, reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, + }, + sealChainId: '31337' as `${bigint}`, sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, + sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, + intentKey: `sha256:${'ab'.repeat(32)}`, wmCurrentAssertion: '12'.repeat(32), swmCurrentAssertion: '12'.repeat(32), + kaNumber: kaNumber.toString(), reservedUal: kaUal, + }; + } + + function newControl() { + const store = new OxigraphStore(); + stores.push(store); + return createPublisherControlFromStore(store, {}); + } + + async function finalizedJob(control: ReturnType): Promise { + const bx = { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }; + const inc = { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }; + const jobId = await control.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + await control.claimNext('wallet-1'); + await control.update(jobId, 'validated', { validation: { canonicalRoots: [], canonicalRootMap: {}, swmQuadCount: 2, authorityProofRef: 'knowledge-asset-lifecycle', transitionType: 'CREATE' } }); + await control.update(jobId, 'broadcast', { broadcast: bx }); + await control.update(jobId, 'included', { broadcast: bx, inclusion: inc }); + await control.update(jobId, 'finalized', { broadcast: bx, inclusion: inc, finalization: { mode: 'local' } }); + return jobId; + } + + it('clears a terminal job → 200 cleared; repeat → 200 already_absent', async () => { + const control = newControl(); + const jobId = await finalizedJob(control); + const ctx1 = postClearJob(control, { jobId }); + await handlePublisherRoutes(ctx1); + expect(responseStatus(ctx1)).toBe(200); + expect(responseBody(ctx1)).toMatchObject({ outcome: 'cleared', jobId }); + + const ctx2 = postClearJob(control, { jobId }); + await handlePublisherRoutes(ctx2); + expect(responseStatus(ctx2)).toBe(200); + expect(responseBody(ctx2)).toMatchObject({ outcome: 'already_absent', jobId }); + }); + + it('rejects a nonterminal (accepted) job → 409 nonterminal', async () => { + const control = newControl(); + const jobId = await control.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + const ctx = postClearJob(control, { jobId }); + await handlePublisherRoutes(ctx); + expect(responseStatus(ctx)).toBe(409); + expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'nonterminal' }); + }); + + it('rejects a missing/empty jobId → 400 malformed', async () => { + const control = newControl(); + const ctx = postClearJob(control, {}); + await handlePublisherRoutes(ctx); + expect(responseStatus(ctx)).toBe(400); + }); + + // #1883 review (🔴): a literal `null` body parses fine but must not TypeError on + // destructure (→ 500). It falls through to the malformed guard as a bounded 400. + it('rejects a literal null body → 400 malformed (no 500)', async () => { + const control = newControl(); + const ctx = postClearJobRaw(control, 'null'); + await handlePublisherRoutes(ctx); + expect(responseStatus(ctx)).toBe(400); + expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'malformed' }); + }); + + // #1883 review (🔴): a SPARQL-unsafe jobId must be a bounded 400, never a query-error 500. + it('rejects a SPARQL-unsafe jobId → 400 malformed (no 500)', async () => { + const control = newControl(); + for (const jobId of ['bad id', 'bad>id']) { + const ctx = postClearJob(control, { jobId }); + await handlePublisherRoutes(ctx); + expect(responseStatus(ctx)).toBe(400); + expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'malformed' }); + } + }); + + function postClearJob(publisherControl: RequestContext['publisherControl'], body: Record): RequestContext { + return postClearJobRaw(publisherControl, JSON.stringify(body)); + } + + function postClearJobRaw(publisherControl: RequestContext['publisherControl'], rawBody: string): RequestContext { + const path = '/api/publisher/clear-job'; + const url = new URL(`http://127.0.0.1${path}`); + const req = Readable.from([]); + // readBody() resolves synchronously from a prebuffered body (as httpAuthGuard's + // eager drain leaves it) — avoids driving a mock stream in the unit harness. + Object.assign(req, { + method: 'POST', url: path, headers: { host: '127.0.0.1' }, + __dkgPrebufferedBody: Buffer.from(rawBody, 'utf8'), + }); + return { + req: req as RequestContext['req'], + res: createResponse() as unknown as ServerResponse, + agent: {} as RequestContext['agent'], + publisherControl, + publisherState: { runtime: null, availability: { available: false, reason: 'publisher_disabled', retryable: false, operatorActionRequired: true } }, + config: {} as RequestContext['config'], + startedAt: 0, + dashDb: {} as RequestContext['dashDb'], + opWallets: { adminWallet: { address: '0x0', privateKey: '0x0' }, wallets: [] } as RequestContext['opWallets'], + network: null as RequestContext['network'], + tracker: {} as RequestContext['tracker'], + memoryManager: {} as RequestContext['memoryManager'], + bridgeAuthToken: undefined, + nodeVersion: 'test', + nodeCommit: 'test', + catchupTracker: {} as RequestContext['catchupTracker'], + extractionRegistry: {} as RequestContext['extractionRegistry'], + fileStore: {} as RequestContext['fileStore'], + extractionStatus: new Map(), + assertionImportLocks: new Map(), + vectorStore: {} as RequestContext['vectorStore'], + embeddingProvider: null, + validTokens: new Set(), + apiHost: '127.0.0.1', + apiPortRef: { value: 0 }, + url, + path: url.pathname, + requestToken: undefined, + requestAgentAddress: '0x0', + }; + } + + function createResponse() { + return { + statusCode: 0, headers: undefined as Record | undefined, body: '', writableEnded: false, + writeHead(status: number, headers: Record) { this.statusCode = status; this.headers = headers; return this; }, + end(body?: string) { this.body = body ?? ''; this.writableEnded = true; return this; }, + }; + } + function responseStatus(ctx: RequestContext): number { return (ctx.res as unknown as { statusCode: number }).statusCode; } + function responseBody(ctx: RequestContext): Record { return JSON.parse((ctx.res as unknown as { body: string }).body) as Record; } +}); diff --git a/packages/cli/test/terminal-clear-response.test.ts b/packages/cli/test/terminal-clear-response.test.ts new file mode 100644 index 0000000000..277e7e7330 --- /dev/null +++ b/packages/cli/test/terminal-clear-response.test.ts @@ -0,0 +1,55 @@ +import type { ServerResponse } from 'node:http'; +import { describe, expect, it } from 'vitest'; +import type { TerminalJobClearOutcome } from '@origintrail-official/dkg-publisher'; +import { respondTerminalClearOutcome } from '../src/daemon/routes/terminal-clear-response.js'; + +// #1837 — locks the shared TerminalJobClearOutcome → HTTP contract that BOTH the publisher +// (/api/publisher/clear-job) and SWM (/api/knowledge-assets/swm/share-jobs/:id/clear) clear +// routes project through, so a future mapping change (wrong status, dropped jobId, or the +// `unknown` branch diverging) cannot silently alter the clear-route contract. +describe('respondTerminalClearOutcome mapping', () => { + function fakeRes() { + return { + statusCode: 0, + body: '', + headers: undefined as Record | undefined, + writableEnded: false, + writeHead(status: number, headers: Record) { + this.statusCode = status; + this.headers = headers; + return this; + }, + end(body?: string) { + this.body = body ?? ''; + this.writableEnded = true; + return this; + }, + }; + } + + function run(outcome: TerminalJobClearOutcome, jobId: string) { + const res = fakeRes(); + respondTerminalClearOutcome(res as unknown as ServerResponse, outcome, jobId); + return { status: res.statusCode, body: JSON.parse(res.body) as Record }; + } + + it('cleared → 200 with echoed jobId', () => { + expect(run({ outcome: 'cleared' }, 'job-1')).toEqual({ status: 200, body: { outcome: 'cleared', jobId: 'job-1' } }); + }); + + it('already_absent → 200 (idempotent success, NOT 404)', () => { + expect(run({ outcome: 'already_absent' }, 'job-2')).toEqual({ status: 200, body: { outcome: 'already_absent', jobId: 'job-2' } }); + }); + + it('rejected malformed → 400 (client input error)', () => { + expect(run({ outcome: 'rejected', reason: 'malformed' }, 'bad id')).toEqual({ status: 400, body: { outcome: 'rejected', reason: 'malformed', jobId: 'bad id' } }); + }); + + it('rejected nonterminal → 409 (server-side state condition)', () => { + expect(run({ outcome: 'rejected', reason: 'nonterminal' }, 'job-3')).toEqual({ status: 409, body: { outcome: 'rejected', reason: 'nonterminal', jobId: 'job-3' } }); + }); + + it('rejected unknown → 409 with echoed jobId', () => { + expect(run({ outcome: 'rejected', reason: 'unknown' }, 'bogus-1')).toEqual({ status: 409, body: { outcome: 'rejected', reason: 'unknown', jobId: 'bogus-1' } }); + }); +}); diff --git a/packages/cli/vitest.unit.config.ts b/packages/cli/vitest.unit.config.ts index 1b11d0d923..5ca8fced20 100644 --- a/packages/cli/vitest.unit.config.ts +++ b/packages/cli/vitest.unit.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ // #1828 — durable-admission recovery lookup route (pure handler, no hardhat). 'test/publisher-job-by-intent-route.test.ts', 'test/publisher-journal-route.test.ts', + 'test/publisher-clear-job-route.test.ts', // #1828 — daemon-boot intent-index backfill wiring (fail-open contract). 'test/vm-publish-intent-backfill.test.ts', 'test/agent-connect-routes.test.ts', diff --git a/packages/publisher/src/async-lift-publisher-impl.ts b/packages/publisher/src/async-lift-publisher-impl.ts index 3bfc1e9c7b..f60b4edd84 100644 --- a/packages/publisher/src/async-lift-publisher-impl.ts +++ b/packages/publisher/src/async-lift-publisher-impl.ts @@ -40,8 +40,10 @@ import type { VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, + VmPublishTerminalJobClearer, } from './async-lift-publisher-types.js'; import { AsyncLiftJobConflictError } from './async-lift-publisher-types.js'; +import { isSafeClearJobId, type TerminalJobClearOutcome } from './terminal-job-clear.js'; import { mapPublishExceptionToLiftJobFailure, mapPublishResultToLiftJobSuccess, @@ -80,6 +82,7 @@ import { getRecoveryTxHash, isKnowledgeAssetVmPublishJobRequest, isFailedJob, + isClearableTerminalLiftJob, isOccupyingLifecycleJob, normalizePersistedLiftJobRequest, rawLiftRequestFromJobRequest, @@ -192,7 +195,7 @@ function resolveKnowledgeAssetVmPublishHandler( } export class TripleStoreAsyncLiftPublisher - implements VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader { + implements VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, VmPublishTerminalJobClearer { private static readonly claimQueues = new Map>(); // #1829 — dedicated per-lineageKey journal mutex, SEPARATE from claimQueues, so the // read-modify-write seq allocation is atomic without touching the claim lock (lock @@ -992,17 +995,24 @@ export class TripleStoreAsyncLiftPublisher await this.ensureGraph(); if (filter.status && filter.status !== 'failed') return 0; - let retried = 0; - for (const job of (await this.list({ status: 'failed' })).filter(isFailedJob)) { - if (!job.failure.retryable || job.retries.retryCount >= job.retries.maxRetries) continue; - // Jobs that failed with a recovery-phase resolution must go through recover(), - // not retry(), to avoid double-publishing if the original tx eventually lands. - if (job.failure.resolution === 'retry_recovery') continue; - - await this.reacceptFailedJob(job); - retried += 1; - } - return retried; + // #1837 — reaccept (failed→accepted) is a terminal→active transition; it MUST be + // serialized with claimNext/enqueue AND with clearTerminalJob (which also runs under + // withClaimLock) so a by-id clear that read a job as clearable-failed cannot be swept + // after retry() flips it active. Without this lock the "a transitioning job cannot be + // swept" guarantee does not hold. + return this.withClaimLock(async () => { + let retried = 0; + for (const job of (await this.list({ status: 'failed' })).filter(isFailedJob)) { + if (!job.failure.retryable || job.retries.retryCount >= job.retries.maxRetries) continue; + // Jobs that failed with a recovery-phase resolution must go through recover(), + // not retry(), to avoid double-publishing if the original tx eventually lands. + if (job.failure.resolution === 'retry_recovery') continue; + + await this.reacceptFailedJob(job); + retried += 1; + } + return retried; + }); } async clear(status: 'finalized' | 'failed'): Promise { @@ -1010,9 +1020,10 @@ export class TripleStoreAsyncLiftPublisher const jobs = await this.list({ status }); let cleared = 0; for (const job of jobs) { - // Protect retry_recovery jobs — they may still have a pending on-chain tx - // that periodic recovery will finalize. Only explicit cancel can remove them. - if (status === 'failed' && isFailedJob(job) && job.failure.resolution === 'retry_recovery') continue; + // #1837 — single terminal-clear authority shared with clearTerminalJob: skips + // retry_recovery-failed jobs (a pending on-chain tx may still land). Behavior is + // identical to the prior inline `resolution === 'retry_recovery'` guard. + if (!isClearableTerminalLiftJob(job)) continue; await this.releaseWalletLockForJob(job); await this.deleteJob(job.jobId); cleared += 1; @@ -1020,6 +1031,45 @@ export class TripleStoreAsyncLiftPublisher return cleared; } + /** + * #1837 — atomic by-exact-jobId terminal clear. Runs INSIDE withClaimLock so it is + * serialized against claimNext/enqueue/reaccept and retry() (the only terminal→active + * transitions) — a job transitioning cannot be swept, and concurrent clears are + * deterministic (exactly one 'cleared', the rest 'already_absent'). deleteJob is + * subject-scoped to the control-plane graph, so it never touches another job or the + * #1829 journal. Never throws / never mutates on a reject. + */ + async clearTerminalJob(jobId: string): Promise { + // Reject an empty OR SPARQL-unsafe jobId as malformed BEFORE building the jobSubject + // IRI — otherwise an attacker-controlled jobId (from the clear-job HTTP body) with a + // space/'>'/'{' could break the query out of `<…>` and surface as a 500/injection + // instead of the bounded outcome. + if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; + return this.withClaimLock(async () => { + await this.ensureGraph(); + const rows = expectBindings( + await this.store.query( + `SELECT ?payload WHERE { GRAPH <${this.graphUri}> { <${jobSubject(jobId)}> <${PAYLOAD_PREDICATE}> ?payload } }`, + ), + ); + if (rows.length === 0) return { outcome: 'already_absent' }; + // Parse defensively — a corrupt persisted payload must surface as rejected(malformed), + // never throw (parseJobPayload does an unguarded JSON.parse). + let job: LiftJob | null; + try { + job = this.parseJobPayload(rows[0]?.['payload']); + } catch { + return { outcome: 'rejected', reason: 'malformed' }; + } + if (job === null) return { outcome: 'rejected', reason: 'malformed' }; + if (!LIFT_JOB_STATES.includes(job.status)) return { outcome: 'rejected', reason: 'unknown' }; + if (!isClearableTerminalLiftJob(job)) return { outcome: 'rejected', reason: 'nonterminal' }; + await this.releaseWalletLockForJob(job); + await this.deleteJob(jobId); + return { outcome: 'cleared' }; + }); + } + private async ensureGraph(): Promise { if (this.graphEnsured) return; await this.store.createGraph(this.graphUri); diff --git a/packages/publisher/src/async-lift-publisher-types.ts b/packages/publisher/src/async-lift-publisher-types.ts index ba46c59d9a..84d8b118a2 100644 --- a/packages/publisher/src/async-lift-publisher-types.ts +++ b/packages/publisher/src/async-lift-publisher-types.ts @@ -17,6 +17,7 @@ import type { PublishOptions, PublishResult } from './publisher.js'; import type { AsyncLiftPublishFailureInput } from './async-lift-publish-result.js'; import type { AsyncPreparedPublishPayload, LiftResolvedPublishSlice } from './async-lift-publish-options.js'; import type { WorkspacePublicSnapshotStore } from './workspace-snapshot-store.js'; +import type { TerminalJobClearOutcome } from './terminal-job-clear.js'; export class AsyncLiftJobConflictError extends Error { readonly code = 'ASYNC_LIFT_JOB_CONFLICT'; @@ -124,6 +125,18 @@ export interface VmPublishAdmissionJournalReader { readJournalByJob(jobId: string): Promise; } +/** + * #1837 — atomic by-jobId terminal cleanup. Segregated off the base contract (like the + * #1828/#1829 capabilities); a MUTATION/admin capability, not a query. Clears the exact + * job ONLY when it is in a native terminal state, rejects otherwise without mutation, + * and is idempotent for an absent job. Never broadens to other jobs. On the lift side + * this preserves the #1829 append-only journal by construction (subject-scoped delete + * in the control-plane graph only). + */ +export interface VmPublishTerminalJobClearer { + clearTerminalJob(jobId: string): Promise; +} + /** * #1828 — one-shot storage maintenance: (re)build the ephemeral intent index for * VM-publish jobs admitted before it existed. This is a boot-time repair, not a diff --git a/packages/publisher/src/async-lift-publisher-utils.ts b/packages/publisher/src/async-lift-publisher-utils.ts index a21671ccc9..5866a51a3f 100644 --- a/packages/publisher/src/async-lift-publisher-utils.ts +++ b/packages/publisher/src/async-lift-publisher-utils.ts @@ -71,6 +71,19 @@ export function isFailedJob(job: LiftJob): job is PersistedFailedJob { return job.status === 'failed' && 'failure' in job; } +/** + * #1837 — the single terminal-clear authority, reused by both `clear(status)` (bulk) and + * `clearTerminalJob(jobId)` so they cannot drift. A job is clearable iff it is in a native + * terminal state (finalized|failed) AND is not a `retry_recovery`-failed job — those may + * still carry a pending on-chain tx that periodic recovery will finalize, so only explicit + * cancel removes them. A `retry_recovery`-failed job is therefore treated as + * NONTERMINAL-for-cleanup. + */ +export function isClearableTerminalLiftJob(job: LiftJob): boolean { + return isTerminalLiftJobState(job.status) + && !(isFailedJob(job) && job.failure.resolution === 'retry_recovery'); +} + /** * #1828 — whether a job still OCCUPIES its lifecycle subject: any non-terminal * state, or a failed job admission would still reaccept (retryable with retries diff --git a/packages/publisher/src/async-lift-publisher.ts b/packages/publisher/src/async-lift-publisher.ts index 46c09b8978..e9b71e1738 100644 --- a/packages/publisher/src/async-lift-publisher.ts +++ b/packages/publisher/src/async-lift-publisher.ts @@ -14,10 +14,12 @@ export type { VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, + VmPublishTerminalJobClearer, IntentLookupInput, IntentLookupResult, JournalReadInput, JournalReadResult, } from './async-lift-publisher-types.js'; export { AsyncLiftJobConflictError } from './async-lift-publisher-types.js'; +export type { TerminalJobClearOutcome } from './terminal-job-clear.js'; export { TripleStoreAsyncLiftPublisher } from './async-lift-publisher-impl.js'; diff --git a/packages/publisher/src/async-promote-queue-impl.ts b/packages/publisher/src/async-promote-queue-impl.ts index 8da0c91f16..27cd1d6dec 100644 --- a/packages/publisher/src/async-promote-queue-impl.ts +++ b/packages/publisher/src/async-promote-queue-impl.ts @@ -18,6 +18,7 @@ */ import type { TripleStore } from '@origintrail-official/dkg-storage'; +import { isSafeClearJobId, type TerminalJobClearOutcome } from './terminal-job-clear.js'; import { ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, ASYNC_PROMOTE_QUEUE_MIN_AUTO_RECOVERABLE_FORMAT_VERSION, @@ -26,6 +27,7 @@ import { PROMOTE_JOB_STATES, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, + type PromoteTerminalJobClearer, type PromoteAttemptError, type PromoteCommitMarker, type PromoteCommitMarkerStep, @@ -48,10 +50,12 @@ import { comparePromoteJobs, defaultBackoffMs, expectBindings, + isTerminalPromoteJobState, jobSubject, literal, normalizePromoteAgentLane, parseJobPayload, + parseLiteral, promoteLaneConflictScope, promoteLaneScopesConflict, serializeJob, @@ -68,7 +72,7 @@ type PromoteConflictLookup = { laneScope: ReturnType; }; -export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue { +export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue, PromoteTerminalJobClearer { /** * Per-graph-URI mutex map. Serialises uniqueness-affecting mutations * callers can't both observe stale state and then persist conflicting @@ -599,6 +603,57 @@ export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue { await this.store.flush?.(); } + // #1837 — subject-scoped record removal (the delete half of writeJob, no re-insert). + // All of a job's triples live under jobSubject(jobId) in the single control-plane + // graph, so this provably cannot touch another job or another graph. + private async deleteJob(jobId: string): Promise { + await this.store.deleteByPattern({ subject: jobSubject(jobId), graph: this.graphUri }); + await this.store.flush?.(); + } + + /** + * #1837 — atomic by-exact-jobId terminal clear. Runs INSIDE withMutationLock, which + * already serializes EVERY transition (incl. the only terminal→active path, recover() + * failed→queued), so a transitioning job cannot be swept and concurrent clears are + * deterministic — no new lock needed. Reads the denormalized state triple to split + * already_absent / unknown / malformed without a JSON.parse that collapses them. Never + * throws / never mutates on a reject. + */ + async clearTerminalJob(jobId: string): Promise { + // Reject an empty OR SPARQL-unsafe jobId as malformed before building the jobSubject + // IRI (defense-in-depth; the SWM route already pre-validates via decodePromoteJobId, + // but a direct agent.assertion.clearPromoteAsync caller must be bounded too). + if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; + return this.withMutationLock(async () => { + await this.ensureGraph(); + const stateRows = expectBindings( + await this.store.query( + `SELECT ?state WHERE { GRAPH <${this.graphUri}> { <${jobSubject(jobId)}> <${PROMOTE_STATE}> ?state } }`, + ), + ); + if (stateRows.length === 0) return { outcome: 'already_absent' }; + const rawState = stateRows[0]?.['state']; + // parseLiteral is JSON.parse — a corrupt/non-JSON state literal must become a + // bounded reject, never throw out of the method (matches the lift sibling's guard). + let state: string | undefined; + try { + state = rawState === undefined ? undefined : String(parseLiteral(rawState)); + } catch { + return { outcome: 'rejected', reason: 'unknown' }; + } + if (state === undefined || !(PROMOTE_JOB_STATES as readonly string[]).includes(state)) { + return { outcome: 'rejected', reason: 'unknown' }; + } + // State literal is a known enum value; the full payload must also parse (a corrupt + // payload with a valid state triple is malformed, not unknown). + const job = await this.readJob(jobId); + if (job === null) return { outcome: 'rejected', reason: 'malformed' }; + if (!isTerminalPromoteJobState(job.state)) return { outcome: 'rejected', reason: 'nonterminal' }; + await this.deleteJob(jobId); + return { outcome: 'cleared' }; + }); + } + private assertLeaseHeld(job: PromoteJob, claimToken: string): void { if (job.state !== 'running') { throw new PromoteJobLeaseError(job.jobId, `job is in state '${job.state}', not 'running'`); diff --git a/packages/publisher/src/async-promote-queue-types.ts b/packages/publisher/src/async-promote-queue-types.ts index e7d53e6916..438b04c55a 100644 --- a/packages/publisher/src/async-promote-queue-types.ts +++ b/packages/publisher/src/async-promote-queue-types.ts @@ -14,6 +14,8 @@ * differences from `AsyncLiftPublisher`. */ +import type { TerminalJobClearOutcome } from './terminal-job-clear.js'; + export const PROMOTE_JOB_STATES = [ 'queued', 'running', @@ -219,6 +221,18 @@ export interface AsyncPromoteQueue { getStats(): Promise; } +/** + * #1837 — atomic by-exact-jobId terminal cleanup for the SWM share (promote) queue. + * Segregated off the base contract (mirrors the publisher-side VmPublishTerminalJobClearer); + * a mutation/admin capability. Clears the exact job ONLY when in a native terminal state + * ({succeeded, failed}, no carve-out — nothing background re-drives a terminal promote row), + * rejects otherwise without mutation, idempotent for an absent job, never broadens to other + * jobs. Reuses the shared TerminalJobClearOutcome for symmetry with the lift clearer. + */ +export interface PromoteTerminalJobClearer { + clearTerminalJob(jobId: string): Promise; +} + export interface AsyncPromoteQueueConfig { /** Defaults to `urn:dkg:promote-queue:control-plane` per RFC §4.1. */ graphUri?: string; diff --git a/packages/publisher/src/async-promote-queue-utils.ts b/packages/publisher/src/async-promote-queue-utils.ts index 457269c05a..a9e98ed288 100644 --- a/packages/publisher/src/async-promote-queue-utils.ts +++ b/packages/publisher/src/async-promote-queue-utils.ts @@ -58,6 +58,20 @@ export const ACTIVE_PROMOTE_STATES: readonly PromoteJobState[] = [ 'failed_retrying', ]; +/** + * #1837 — native terminal states for a by-jobId terminal clear. Unlike the lift queue + * there is NO carve-out: nothing background re-drives a terminal promote row (no on-chain + * tx; recover() failed→queued is manual-only + locked; reconcileExpiredRunning touches + * only 'running'; conflict detection gates on ACTIVE states), so both terminal states are + * uniformly clearable — a `requiresManualInspection` (partial-ambiguity) failed row + * included (data-safe: nothing reads a removed row). + */ +export const TERMINAL_PROMOTE_JOB_STATES: readonly PromoteJobState[] = ['succeeded', 'failed']; + +export function isTerminalPromoteJobState(state: PromoteJobState): boolean { + return TERMINAL_PROMOTE_JOB_STATES.includes(state); +} + export function jobSubject(jobId: string): string { return `urn:dkg:promote-queue:job:${jobId}`; } diff --git a/packages/publisher/src/async-promote-queue.ts b/packages/publisher/src/async-promote-queue.ts index f878cbd494..7554ddcc73 100644 --- a/packages/publisher/src/async-promote-queue.ts +++ b/packages/publisher/src/async-promote-queue.ts @@ -14,6 +14,7 @@ export type { PromoteRequest, PromoteResult, PromoteStats, + PromoteTerminalJobClearer, } from './async-promote-queue-types.js'; export { ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index c44350641c..c917e5f5fe 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -267,11 +267,17 @@ export { type VmPublishIntentRecoveryPublisher, type VmPublishIntentIndexBackfiller, type VmPublishAdmissionJournalReader, + type VmPublishTerminalJobClearer, + type TerminalJobClearOutcome, type IntentLookupInput, type IntentLookupResult, type JournalReadInput, type JournalReadResult, } from './async-lift-publisher.js'; +export { + SAFE_CLEAR_JOB_ID_PATTERN, + SAFE_CLEAR_JOB_ID_MAX_LENGTH, +} from './terminal-job-clear.js'; export { TripleStoreAsyncPromoteQueue, ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, @@ -294,6 +300,7 @@ export { type PromoteRequest, type PromoteResult, type PromoteStats, + type PromoteTerminalJobClearer, } from './async-promote-queue.js'; export { AsyncLiftRunner, diff --git a/packages/publisher/src/terminal-job-clear.ts b/packages/publisher/src/terminal-job-clear.ts new file mode 100644 index 0000000000..50c11cc6be --- /dev/null +++ b/packages/publisher/src/terminal-job-clear.ts @@ -0,0 +1,36 @@ +// #1837 — shared contract for the atomic by-exact-jobId TERMINAL clear, owned by NEITHER +// queue (lift nor promote) so a generic admin-clear result is not coupled to one +// implementation family's type module. + +/** + * Bounded outcome of a terminal clear, shared by the lift publisher and the SWM promote + * queue. The control method owns every reason INCLUDING `malformed` (a corrupt persisted + * payload — or an unsafe jobId — is only detectable inside the method, never at the HTTP + * route). Never throws and never mutates on a reject. `already_absent` is a SUCCESS + * (idempotent repeat), distinct from a rejection. + */ +export type TerminalJobClearOutcome = + | { readonly outcome: 'cleared' } + | { readonly outcome: 'already_absent' } + | { readonly outcome: 'rejected'; readonly reason: 'nonterminal' | 'unknown' | 'malformed' }; + +// Producer grammar for a queue jobId (crypto.randomUUID(), or test 'job-N'): starts +// alphanumeric, then alnum/'.'/'_'/':'/'-'. This is IRI-safe — it excludes every character +// that could break out of the `<…>` IRI in a control-plane SPARQL query (spaces, '<' '>' +// '"' '{' '}' '|' '^' '`', control chars). This is the SINGLE authoritative job-id grammar: +// the CLI route validator (`validatePromoteJobId`) imports it rather than re-declaring the +// regex, so route-level and control-plane job-id acceptance cannot drift. +export const SAFE_CLEAR_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +export const SAFE_CLEAR_JOB_ID_MAX_LENGTH = 256; + +/** + * True iff `jobId` is safe to interpolate into a control-plane SPARQL IRI. A by-id clear + * MUST reject an unsafe jobId as `malformed` BEFORE building the query, so an + * attacker-controlled jobId (from the clear-job HTTP body) yields a bounded reject rather + * than a query syntax error / injection / 500. + */ +export function isSafeClearJobId(jobId: string): boolean { + return ( + jobId.length > 0 && jobId.length <= SAFE_CLEAR_JOB_ID_MAX_LENGTH && SAFE_CLEAR_JOB_ID_PATTERN.test(jobId) + ); +} diff --git a/packages/publisher/test/async-lift-terminal-clear.test.ts b/packages/publisher/test/async-lift-terminal-clear.test.ts new file mode 100644 index 0000000000..2c347f1c9d --- /dev/null +++ b/packages/publisher/test/async-lift-terminal-clear.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { TripleStoreAsyncLiftPublisher, type AsyncLiftPublisherConfig } from '../src/index.js'; +import { DEFAULT_CONTROL_GRAPH_URI, jobSubject, serializeJob } from '../src/async-lift-control-plane.js'; + +// #1837 — atomic by-exact-jobId TERMINAL clear for the async publisher (lift) queue. +describe('#1837 lift publisher clearTerminalJob', () => { + let now = 1_000; + let ids = 0; + let store: OxigraphStore; + + beforeEach(() => { + now = 1_000; + ids = 0; + store = new OxigraphStore(); + }); + + function createPublisher(config: Omit = {}): TripleStoreAsyncLiftPublisher { + return new TripleStoreAsyncLiftPublisher(store, { + now: () => ++now, + idGenerator: () => `job-${++ids}`, + journalWrites: true, + ...config, + }); + } + + function kaVmPublishRequest(overrides: Record = {}) { + const authorAddress = '0x1111111111111111111111111111111111111111'; + const kaNumber = 7n; + const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; + return { + contextGraphId: 'music-social', name: 'albums', shareOperationId: 'share-op-1', + roots: [] as string[], contentScopeVersion: 2 as const, kaUal, assertionVersion: '1', + publicTripleCount: 2, privateTripleCount: 0, + seal: { + merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, + authorAddress: authorAddress as `0x${string}`, + signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, + schemeVersion: 1, + reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, + }, + sealChainId: '31337' as `${bigint}`, sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, + sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, + intentKey: `sha256:${'ab'.repeat(32)}`, wmCurrentAssertion: '12'.repeat(32), swmCurrentAssertion: '12'.repeat(32), + kaNumber: kaNumber.toString(), reservedUal: kaUal, ...overrides, + }; + } + const bx = { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }; + const inc = { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }; + + async function driveToValidated(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + const jobId = await p.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest(o)); + await p.claimNext('wallet-1'); + await p.update(jobId, 'validated', { + validation: { canonicalRoots: [], canonicalRootMap: {}, swmQuadCount: 2, authorityProofRef: 'knowledge-asset-lifecycle', transitionType: 'CREATE' }, + }); + return jobId; + } + async function driveToFinalized(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + const jobId = await driveToValidated(p, o); + await p.update(jobId, 'broadcast', { broadcast: bx }); + await p.update(jobId, 'included', { broadcast: bx, inclusion: inc }); + await p.update(jobId, 'finalized', { broadcast: bx, inclusion: inc, finalization: { mode: 'local' } }); + return jobId; + } + // Terminal, non-retryable (tx_reverted → fail_job): clearable, retry() won't touch it. + async function driveToTerminalFailed(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + const jobId = await driveToValidated(p, o); + await p.update(jobId, 'broadcast', { broadcast: bx }); + await p.recordPublishFailure(jobId, { error: new Error('tx reverted on chain'), failedFromState: 'broadcast', errorPayloadRef: 'urn:err:1' }); + return jobId; + } + it('clears an exact finalized job (cleared); no other job changes', async () => { + const p = createPublisher(); + const target = await driveToFinalized(p, { name: 'a' }); + const other = await driveToFinalized(p, { name: 'b' }); + expect(await p.clearTerminalJob(target)).toEqual({ outcome: 'cleared' }); + expect(await p.getStatus(target)).toBeNull(); + expect((await p.getStatus(other))?.status).toBe('finalized'); // untouched + }); + + it('clears an exact terminal (non-retryable) failed job', async () => { + const p = createPublisher(); + const jobId = await driveToTerminalFailed(p); + expect((await p.getStatus(jobId))?.status).toBe('failed'); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await p.getStatus(jobId)).toBeNull(); + }); + + it('rejects an accepted (queued) job as nonterminal without mutation', async () => { + const p = createPublisher(); + const accepted = await p.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + expect(await p.clearTerminalJob(accepted)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await p.getStatus(accepted))?.status).toBe('accepted'); + }); + + it('rejects a validated job as nonterminal without mutation', async () => { + const p = createPublisher(); + const validated = await driveToValidated(p); + expect(await p.clearTerminalJob(validated)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await p.getStatus(validated))?.status).toBe('validated'); + }); + + it('rejects a broadcast job as nonterminal without mutation', async () => { + const p = createPublisher(); + const broadcast = await driveToValidated(p); + await p.update(broadcast, 'broadcast', { broadcast: bx }); + expect(await p.clearTerminalJob(broadcast)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await p.getStatus(broadcast))?.status).toBe('broadcast'); + }); + + it('rejects a retry_recovery-protected failed job as nonterminal (a pending tx may still land)', async () => { + // retry_recovery is a raw-lift-only recovery resolution (KA-VM canRetryFailedRecovery + // is false), so inject a synthetic retry_recovery-failed job to exercise the guard the + // clearer shares with bulk clear(). Start from a real terminal-failed job and rewrite + // its persisted resolution. + const p = createPublisher(); + const jobId = await driveToTerminalFailed(p); + const job = await p.getStatus(jobId); + if (!job || !('failure' in job)) throw new Error('expected a failed job'); + const mutated = { ...job, failure: { ...job.failure, resolution: 'retry_recovery' } }; + await store.deleteByPattern({ subject: jobSubject(jobId), graph: DEFAULT_CONTROL_GRAPH_URI }); + await store.insert(serializeJob(mutated as typeof job, DEFAULT_CONTROL_GRAPH_URI)); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await p.getStatus(jobId))?.status).toBe('failed'); // unchanged + }); + + it('is idempotent: absent / already-cleared → already_absent', async () => { + const p = createPublisher(); + expect(await p.clearTerminalJob('never-existed')).toEqual({ outcome: 'already_absent' }); + const jobId = await driveToFinalized(p); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'already_absent' }); + }); + + it('rejects an empty or SPARQL-unsafe jobId as malformed without querying/mutating', async () => { + const p = createPublisher(); + expect(await p.clearTerminalJob('')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await p.clearTerminalJob(' ')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + // #1883 review (🔴): a jobId that would break out of the <…> SPARQL IRI must be a + // bounded malformed reject, never a query error / injection. + expect(await p.clearTerminalJob('bad id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await p.clearTerminalJob('bad>id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await p.clearTerminalJob('a{ b')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + }); + + it('preserves the #1829 journal: clearing a terminal job leaves its journal lineage readable', async () => { + const p = createPublisher(); + const jobId = await driveToFinalized(p); + expect((await p.readJournalByJob(jobId)).entries.length).toBeGreaterThan(0); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await p.getStatus(jobId)).toBeNull(); // gone from control plane + const journal = await p.readJournalByJob(jobId); + expect(journal.entries.length).toBeGreaterThan(0); // lineage survives the clear + expect(journal.entries.some((e) => e.kind === 'finalized')).toBe(true); + }); + + it('no-sweep: a clearable-failed job reaccepted by concurrent retry() is never deleted while active', async () => { + // maxRetries:1 + a retryable failure (rpc_unavailable/reset_to_accepted) → the job is + // BOTH clearable (terminal failed, not retry_recovery) AND reacceptable by retry(). + // clearTerminalJob and retry() both run under withClaimLock, so they serialize: + // whichever wins, an ACTIVE job is never swept. + const p = createPublisher({ maxRetries: 1 }); + const jobId = await driveToValidated(p, { name: 'race' }); + await p.update(jobId, 'broadcast', { broadcast: bx }); + await p.recordPublishFailure(jobId, { error: new Error('rpc temporarily down'), failedFromState: 'broadcast', errorPayloadRef: 'urn:err:2' }); + const failed = await p.getStatus(jobId); + expect(failed?.status).toBe('failed'); + expect(failed && 'failure' in failed && failed.failure.retryable).toBe(true); + + const [clearOutcome, retried] = await Promise.all([p.clearTerminalJob(jobId), p.retry({ status: 'failed' })]); + const after = await p.getStatus(jobId); + if (clearOutcome.outcome === 'cleared') { + // clear won: job gone, retry could not have reaccepted it. + expect(after).toBeNull(); + expect(retried).toBe(0); + } else { + // retry won: job is active ('accepted'); clear must have rejected it, NOT deleted it. + expect(clearOutcome).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect(after?.status).toBe('accepted'); + expect(retried).toBe(1); + } + }); + + it('concurrent clears of one terminal job are deterministic: one cleared, rest already_absent', async () => { + const p = createPublisher(); + const target = await driveToFinalized(p, { name: 'a' }); + const other = await driveToFinalized(p, { name: 'b' }); + const results = await Promise.all([p.clearTerminalJob(target), p.clearTerminalJob(target), p.clearTerminalJob(target)]); + expect(results.filter((r) => r.outcome === 'cleared')).toHaveLength(1); + expect(results.filter((r) => r.outcome === 'already_absent')).toHaveLength(2); + expect((await p.getStatus(other))?.status).toBe('finalized'); // never affected + }); +}); diff --git a/packages/publisher/test/async-promote-terminal-clear.test.ts b/packages/publisher/test/async-promote-terminal-clear.test.ts new file mode 100644 index 0000000000..2d77f74a88 --- /dev/null +++ b/packages/publisher/test/async-promote-terminal-clear.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { + type AsyncPromoteQueue, + type AsyncPromoteQueueConfig, + type PromoteRequest, + type PromoteTerminalJobClearer, +} from '../src/async-promote-queue-types.js'; +import { TripleStoreAsyncPromoteQueue } from '../src/async-promote-queue-impl.js'; +import { DEFAULT_PROMOTE_CONTROL_GRAPH_URI, PROMOTE_STATE, jobSubject, literal } from '../src/async-promote-queue-utils.js'; + +// #1837 — atomic by-exact-jobId TERMINAL clear for the SWM promote queue. +describe('#1837 promote queue clearTerminalJob', () => { + let store: OxigraphStore; + let now: number; + let idCounter: number; + + beforeEach(() => { + store = new OxigraphStore(); + now = 1_000_000; + idCounter = 0; + }); + + function createQueue(overrides: Partial = {}): AsyncPromoteQueue & PromoteTerminalJobClearer { + return new TripleStoreAsyncPromoteQueue(store, { + now: () => now, + idGenerator: () => `job-${++idCounter}`, + ...overrides, + }) as TripleStoreAsyncPromoteQueue; + } + + function makeRequest(overrides: Partial = {}): PromoteRequest { + return { contextGraphId: 'graphify', subGraphName: 'code', assertionName: 'shard-1', entities: 'all', ...overrides }; + } + + async function enqueueSucceeded(queue: AsyncPromoteQueue, req?: Partial): Promise { + const jobId = await queue.enqueue(makeRequest(req)); + const claimed = await queue.claimNext('worker-1'); + const token = claimed!.lease!.claimToken; + // Worker records commit progress — required before succeed(). + await queue.recordCommitMarker(jobId, token, 'swmInserted'); + await queue.recordCommitMarker(jobId, token, 'wmCleaned'); + await queue.recordCommitMarker(jobId, token, 'lifecycleStamped'); + await queue.recordCommitMarker(jobId, token, 'gossiped'); + await queue.succeed(jobId, token, { promotedCount: 1, succeededAt: now }); + return jobId; + } + + async function enqueueTerminalFailed(queue: AsyncPromoteQueue, req?: Partial): Promise { + const jobId = await queue.enqueue(makeRequest(req)); + const claimed = await queue.claimNext('worker-1'); + await queue.fail(jobId, claimed!.lease!.claimToken, { + message: 'permanent', retryable: false, classification: 'permanent', recordedAt: now, + }); + return jobId; + } + + it('clears an exact succeeded job (cleared); no other job changes', async () => { + const queue = createQueue(); + const target = await enqueueSucceeded(queue, { assertionName: 'a' }); + const other = await enqueueSucceeded(queue, { assertionName: 'b' }); + expect(await queue.clearTerminalJob(target)).toEqual({ outcome: 'cleared' }); + expect(await queue.getStatus(target)).toBeNull(); + expect((await queue.getStatus(other))?.state).toBe('succeeded'); // untouched + }); + + it('clears an exact terminal-failed job (incl. no retry_recovery carve-out)', async () => { + const queue = createQueue(); + const jobId = await enqueueTerminalFailed(queue); + expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await queue.getStatus(jobId)).toBeNull(); + }); + + it('rejects a queued job as nonterminal without mutation', async () => { + const queue = createQueue(); + const queued = await queue.enqueue(makeRequest()); + expect(await queue.clearTerminalJob(queued)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(queued))?.state).toBe('queued'); + }); + + it('rejects a running job as nonterminal without mutation', async () => { + const queue = createQueue(); + const runningId = await queue.enqueue(makeRequest()); + await queue.claimNext('worker-1'); + expect((await queue.getStatus(runningId))?.state).toBe('running'); + expect(await queue.clearTerminalJob(runningId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(runningId))?.state).toBe('running'); + }); + + it('rejects a failed_retrying job as nonterminal without mutation', async () => { + const queue = createQueue({ backoff: () => 10_000 }); + const retryingId = await queue.enqueue(makeRequest()); + const claimed = await queue.claimNext('worker-1'); + await queue.fail(retryingId, claimed!.lease!.claimToken, { + message: 'transient', retryable: true, classification: 'transient', recordedAt: now, + }); + expect((await queue.getStatus(retryingId))?.state).toBe('failed_retrying'); + expect(await queue.clearTerminalJob(retryingId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(retryingId))?.state).toBe('failed_retrying'); + }); + + it('is idempotent: an absent / already-cleared job returns already_absent', async () => { + const queue = createQueue(); + expect(await queue.clearTerminalJob('never-existed')).toEqual({ outcome: 'already_absent' }); + const jobId = await enqueueSucceeded(queue); + expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'already_absent' }); // repeat + }); + + it('rejects an empty or SPARQL-unsafe jobId as malformed without querying/mutating', async () => { + const queue = createQueue(); + expect(await queue.clearTerminalJob('')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await queue.clearTerminalJob(' ')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await queue.clearTerminalJob('bad id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await queue.clearTerminalJob('bad>id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + }); + + // #1883 review (🟡): a state triple present but not a recognized enum value must be a + // bounded reject (unknown), never throw — and the parse itself is now try/catch-guarded. + it('rejects a subject whose state is not a known enum value as unknown, without throwing', async () => { + const queue = createQueue(); + await store.insert([ + { subject: jobSubject('bogus-1'), predicate: PROMOTE_STATE, object: literal('bogus_state'), graph: DEFAULT_PROMOTE_CONTROL_GRAPH_URI }, + ]); + await expect(queue.clearTerminalJob('bogus-1')).resolves.toEqual({ outcome: 'rejected', reason: 'unknown' }); + }); + + it('concurrent clears of one terminal job are deterministic: one cleared, rest already_absent, no other job affected', async () => { + const queue = createQueue(); + const target = await enqueueSucceeded(queue, { assertionName: 'a' }); + const other = await enqueueSucceeded(queue, { assertionName: 'b' }); + const results = await Promise.all([ + queue.clearTerminalJob(target), queue.clearTerminalJob(target), queue.clearTerminalJob(target), + ]); + expect(results.filter((r) => r.outcome === 'cleared')).toHaveLength(1); + expect(results.filter((r) => r.outcome === 'already_absent')).toHaveLength(2); + expect((await queue.getStatus(other))?.state).toBe('succeeded'); // never affected + }); +}); diff --git a/packages/publisher/vitest.unit.config.ts b/packages/publisher/vitest.unit.config.ts index 5eb6e0ec07..c8f3e3c822 100644 --- a/packages/publisher/vitest.unit.config.ts +++ b/packages/publisher/vitest.unit.config.ts @@ -22,6 +22,8 @@ export default defineConfig({ 'test/async-lift-intent-lookup.test.ts', 'test/async-lift-publish-options.test.ts', 'test/async-promote-queue.test.ts', + 'test/async-lift-terminal-clear.test.ts', + 'test/async-promote-terminal-clear.test.ts', 'test/lift-job-types.test.ts', 'test/multi-root-token-rows.test.ts', 'test/access-verification.test.ts', From 47b760f644b4e553a09817b6d4d55e8a2378a990 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:12:43 +0200 Subject: [PATCH 171/292] fix(rfc64): harden catalog transport authorization --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + .../catalog-transport-authorization-v1.ts | 93 +++++++++ .../public-catalog-native-transport-v1.ts | 171 ++++++++-------- .../src/rfc64/public-catalog-transport-v1.ts | 183 ++++++++++-------- ...public-catalog-native-transport-v1.test.ts | 51 +---- .../rfc64-public-catalog-transport-v1.test.ts | 100 +++++----- .../rfc64-catalog-access-policy-fixture.ts | 69 +++++++ 8 files changed, 419 insertions(+), 250 deletions(-) create mode 100644 packages/agent/src/rfc64/catalog-transport-authorization-v1.ts create mode 100644 packages/agent/test/support/rfc64-catalog-access-policy-fixture.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 548ea609ee..a7bed011d6 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -23,6 +23,7 @@ "./dist/rfc64/control-object-store-v1-internal.js": null, "./dist/rfc64/control-object-store-v1.js": null, "./dist/rfc64/catalog-access-policy-v1.js": null, + "./dist/rfc64/catalog-transport-authorization-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, "./dist/rfc64/ka-bundle-store-v1-internal.js": null, "./dist/rfc64/ka-bundle-store-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 4c4685a7af..b470d6bbe2 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -66,6 +66,7 @@ const publicRfc64Modules = [ ]; const blockedRfc64Modules = [ 'catalog-access-policy-v1.js', + 'catalog-transport-authorization-v1.js', 'control-object-store-v1-internal.js', 'control-object-store-v1.js', 'durable-file-store-v1.js', diff --git a/packages/agent/src/rfc64/catalog-transport-authorization-v1.ts b/packages/agent/src/rfc64/catalog-transport-authorization-v1.ts new file mode 100644 index 0000000000..0c853c1ef9 --- /dev/null +++ b/packages/agent/src/rfc64/catalog-transport-authorization-v1.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { + Rfc64CatalogAccessAuthorizationInputV1, + Rfc64CatalogAccessAuthorizationV1, + Rfc64CatalogAccessPolicyRegistryV1, +} from './catalog-access-policy-v1.js'; + +export type Rfc64LegacyOpenCatalogAuthorizerV1< + Input extends Rfc64CatalogAccessAuthorizationInputV1, +> = (input: Input) => Promise; + +/** + * Collapse the V2 registry and the deprecated open-only callback into one + * transport-facing authorizer. The legacy callback is deliberately incapable + * of authorizing a private cell: private access requires the current registry's + * authenticated peer-to-wallet and member-role checks. + */ +export function normalizeRfc64CatalogTransportAuthorizerV1< + Input extends Rfc64CatalogAccessAuthorizationInputV1, +>(options: { + readonly current?: Rfc64CatalogAccessPolicyRegistryV1['authorize']; + readonly legacyOpen?: Rfc64LegacyOpenCatalogAuthorizerV1; + readonly invalidConfiguration: (message: string) => never; +}): Rfc64LegacyOpenCatalogAuthorizerV1 { + const current = options.current; + const legacyOpen = options.legacyOpen; + if ( + (typeof current !== 'function' && typeof legacyOpen !== 'function') + || (typeof current === 'function' && typeof legacyOpen === 'function') + ) { + return options.invalidConfiguration( + 'exactly one catalog access-policy authorizer must be configured', + ); + } + if (typeof current === 'function') { + return (input) => current(projectCatalogAccessAuthorizationInput(input)); + } + return async (input) => { + const authorization = await legacyOpen!(input); + return authorization?.accessPolicy === 0 ? authorization : null; + }; +} + +/** + * Apply the same accepted-current policy before and after an awaited boundary. + * This closes policy-generation and membership TOCTOU windows without making + * each transport flow reproduce the checkpoint choreography. + */ +export async function withCurrentRfc64CatalogPolicyV1( + requireCurrentPolicy: () => Promise, + work: () => Value | Promise, +): Promise { + await requireCurrentPolicy(); + const value = await work(); + await requireCurrentPolicy(); + return value; +} + +export async function recheckCurrentRfc64CatalogPolicyAfterAwaitV1( + requireCurrentPolicy: () => Promise, + work: () => Value | Promise, +): Promise { + const value = await work(); + await requireCurrentPolicy(); + return value; +} + +export type Rfc64AuthorizedCatalogWorkResultV1 = + | { readonly authorized: true; readonly value: Value } + | { readonly authorized: false }; + +export async function withAuthorizedCurrentRfc64CatalogPolicyV1( + isCurrentPolicyAuthorized: () => Promise, + work: () => Value | Promise, +): Promise> { + if (!await isCurrentPolicyAuthorized()) return Object.freeze({ authorized: false }); + const value = await work(); + if (!await isCurrentPolicyAuthorized()) return Object.freeze({ authorized: false }); + return Object.freeze({ authorized: true, value }); +} + +function projectCatalogAccessAuthorizationInput( + input: Rfc64CatalogAccessAuthorizationInputV1, +): Rfc64CatalogAccessAuthorizationInputV1 { + return Object.freeze({ + operation: input.operation, + remotePeerId: input.remotePeerId, + networkId: input.networkId, + contextGraphId: input.contextGraphId, + policyDigest: input.policyDigest, + }); +} diff --git a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts index aebdef389a..a9c6eee9dc 100644 --- a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts @@ -43,10 +43,16 @@ import { } from '@origintrail-official/dkg-chain'; import type { - Rfc64CatalogAccessAuthorizationInputV1, Rfc64CatalogAccessAuthorizationV1, Rfc64CatalogAccessPolicyRegistryV1, } from './catalog-access-policy-v1.js'; +import { + normalizeRfc64CatalogTransportAuthorizerV1, + recheckCurrentRfc64CatalogPolicyAfterAwaitV1, + withAuthorizedCurrentRfc64CatalogPolicyV1, + withCurrentRfc64CatalogPolicyV1, +} from './catalog-transport-authorization-v1.js'; +import type { Rfc64AuthorizedCatalogWorkResultV1 } from './catalog-transport-authorization-v1.js'; export const RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1 = '/dkg/catalog/1/control-object/by-digest' as const; @@ -239,20 +245,11 @@ export class Rfc64PublicCatalogNativeTransportV1 { if (typeof options.readKaBundleByDigest !== 'function') { fail('catalog-native-input', 'readKaBundleByDigest must be a function'); } - const currentAuthorizer = options.authorizeCatalogOperation; - const legacyAuthorizer = options.authorizeOpenCatalogOperation; - if ( - (typeof currentAuthorizer !== 'function' && typeof legacyAuthorizer !== 'function') - || (typeof currentAuthorizer === 'function' && typeof legacyAuthorizer === 'function') - ) { - fail( - 'catalog-native-input', - 'exactly one catalog access-policy authorizer must be configured', - ); - } - this.#authorizeCatalogOperation = typeof currentAuthorizer === 'function' - ? (input) => currentAuthorizer(toCatalogAccessAuthorizationInput(input)) - : (input) => legacyAuthorizer!(input); + this.#authorizeCatalogOperation = normalizeRfc64CatalogTransportAuthorizerV1({ + current: options.authorizeCatalogOperation, + legacyOpen: options.authorizeOpenCatalogOperation, + invalidConfiguration: (message) => fail('catalog-native-input', message), + }); if (typeof options.verifyIssuerSignature !== 'function') { fail('catalog-native-input', 'verifyIssuerSignature must be a function'); } @@ -299,19 +296,24 @@ export class Rfc64PublicCatalogNativeTransportV1 { this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const request = parseCatalogObjectRequest(encodeRequest(requestInput)); - await this.requireCatalogPolicy('catalog-object-fetch-outbound', remotePeerId, request); - const response = await this.router.send( + const response = await this.withCurrentCatalogPolicy( + 'catalog-object-fetch-outbound', remotePeerId, - RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, - encodeRequest(request), - sendOptions, + request, + () => this.router.send( + remotePeerId, + RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1, + encodeRequest(request), + sendOptions, + ), ); const envelope = parseCatalogObjectResponse(response); - await this.requireCatalogPolicy('catalog-object-fetch-outbound', remotePeerId, request); if (envelope === null) return null; assertCatalogObjectMatchesRequest(envelope, request); - const issuerSignature = await this.verifyExactIssuerSignature(envelope); - await this.requireCatalogPolicy('catalog-object-fetch-outbound', remotePeerId, request); + const issuerSignature = await recheckCurrentRfc64CatalogPolicyAfterAwaitV1( + () => this.requireCatalogPolicy('catalog-object-fetch-outbound', remotePeerId, request), + () => this.verifyExactIssuerSignature(envelope), + ); return Object.freeze({ envelope: deepFreeze(envelope), issuerSignature }); } @@ -330,15 +332,18 @@ export class Rfc64PublicCatalogNativeTransportV1 { 'advertised KA bundle exceeds this receiver transport resource ceiling', ); } - await this.requireCatalogPolicy('ka-bundle-fetch-outbound', remotePeerId, request); - const response = await this.router.send( + const response = await this.withCurrentCatalogPolicy( + 'ka-bundle-fetch-outbound', remotePeerId, - RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, - encodeRequest(request), - sendOptions, + request, + () => this.router.send( + remotePeerId, + RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, + encodeRequest(request), + sendOptions, + ), ); const bundle = parseBundleResponse(response, request); - await this.requireCatalogPolicy('ka-bundle-fetch-outbound', remotePeerId, request); return bundle; } @@ -349,30 +354,26 @@ export class Rfc64PublicCatalogNativeTransportV1 { this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const request = parseCatalogObjectRequest(data); - if (!await this.isCatalogPolicyAuthorized('catalog-object-fetch-inbound', remotePeerId, request)) { - return Uint8Array.of(FETCH_DENIED); - } - const envelope = await this.options.readCatalogObjectByDigest(request.targetObjectDigest); - if (envelope === null) { - if (!await this.isCatalogPolicyAuthorized( - 'catalog-object-fetch-inbound', - remotePeerId, - request, - )) { - return Uint8Array.of(FETCH_DENIED); - } - return Uint8Array.of(FETCH_NOT_FOUND); - } - assertCatalogObjectMatchesRequest(envelope, request); - await this.verifyExactIssuerSignature(envelope); - if (!await this.isCatalogPolicyAuthorized('catalog-object-fetch-inbound', remotePeerId, request)) { + const served = await this.withAuthorizedCurrentCatalogPolicy( + 'catalog-object-fetch-inbound', + remotePeerId, + request, + async () => { + const envelope = await this.options.readCatalogObjectByDigest(request.targetObjectDigest); + if (envelope === null) return null; + assertCatalogObjectMatchesRequest(envelope, request); + await this.verifyExactIssuerSignature(envelope); + const bytes = canonicalizeSignedControlEnvelopeBytes(envelope); + if (bytes.byteLength + 1 > RFC64_PUBLIC_CATALOG_OBJECT_FETCH_RESPONSE_MAX_BYTES_V1) { + fail('catalog-native-resource-refused', 'catalog object exceeds the response ceiling'); + } + return foundResponse(bytes); + }, + ); + if (!served.authorized) { return Uint8Array.of(FETCH_DENIED); } - const bytes = canonicalizeSignedControlEnvelopeBytes(envelope); - if (bytes.byteLength + 1 > RFC64_PUBLIC_CATALOG_OBJECT_FETCH_RESPONSE_MAX_BYTES_V1) { - fail('catalog-native-resource-refused', 'catalog object exceeds the response ceiling'); - } - return foundResponse(bytes); + return served.value ?? Uint8Array.of(FETCH_NOT_FOUND); } private async handleBundleFetch( @@ -386,25 +387,34 @@ export class Rfc64PublicCatalogNativeTransportV1 { > BigInt(RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1)) { fail('catalog-native-resource-refused', 'requested KA bundle exceeds the response ceiling'); } - if (!await this.isCatalogPolicyAuthorized('ka-bundle-fetch-inbound', remotePeerId, request)) { - return Uint8Array.of(FETCH_DENIED); - } - const bundle = await this.options.readKaBundleByDigest(request.blobDigest); - if (bundle === null) { - if (!await this.isCatalogPolicyAuthorized( - 'ka-bundle-fetch-inbound', - remotePeerId, - request, - )) { - return Uint8Array.of(FETCH_DENIED); - } - return Uint8Array.of(FETCH_NOT_FOUND); - } - assertExactBundle(bundle, request); - if (!await this.isCatalogPolicyAuthorized('ka-bundle-fetch-inbound', remotePeerId, request)) { + const served = await this.withAuthorizedCurrentCatalogPolicy( + 'ka-bundle-fetch-inbound', + remotePeerId, + request, + async () => { + const bundle = await this.options.readKaBundleByDigest(request.blobDigest); + if (bundle === null) return null; + assertExactBundle(bundle, request); + return foundResponse(bundle); + }, + ); + if (!served.authorized) { return Uint8Array.of(FETCH_DENIED); } - return foundResponse(bundle); + return served.value ?? Uint8Array.of(FETCH_NOT_FOUND); + } + + private async withAuthorizedCurrentCatalogPolicy( + operation: Rfc64PublicCatalogNativeOperationV1, + remotePeerId: string, + request: Rfc64PublicCatalogObjectFetchRequestV1 + | Rfc64PublicCatalogBundleFetchRequestV1, + work: () => Value | Promise, + ): Promise> { + return withAuthorizedCurrentRfc64CatalogPolicyV1( + () => this.isCatalogPolicyAuthorized(operation, remotePeerId, request), + work, + ); } private async isCatalogPolicyAuthorized( @@ -425,6 +435,19 @@ export class Rfc64PublicCatalogNativeTransportV1 { } } + private withCurrentCatalogPolicy( + operation: Rfc64PublicCatalogNativeOperationV1, + remotePeerId: string, + request: Rfc64PublicCatalogObjectFetchRequestV1 + | Rfc64PublicCatalogBundleFetchRequestV1, + work: () => Value | Promise, + ): Promise { + return withCurrentRfc64CatalogPolicyV1( + () => this.requireCatalogPolicy(operation, remotePeerId, request), + work, + ); + } + private async requireCatalogPolicy( operation: Rfc64PublicCatalogNativeOperationV1, remotePeerId: string, @@ -496,18 +519,6 @@ export class Rfc64PublicCatalogNativeTransportV1 { } } -function toCatalogAccessAuthorizationInput( - input: Rfc64PublicCatalogNativeAuthorizationInputV1, -): Rfc64CatalogAccessAuthorizationInputV1 { - return Object.freeze({ - operation: input.operation, - remotePeerId: input.remotePeerId, - networkId: input.networkId, - contextGraphId: input.contextGraphId, - policyDigest: input.policyDigest, - }); -} - export function encodeRfc64PublicCatalogObjectFetchRequestV1( input: Rfc64PublicCatalogObjectFetchRequestV1, ): Uint8Array { diff --git a/packages/agent/src/rfc64/public-catalog-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-transport-v1.ts index a6f66cc59d..e0af149434 100644 --- a/packages/agent/src/rfc64/public-catalog-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-transport-v1.ts @@ -26,10 +26,16 @@ import { } from '@origintrail-official/dkg-chain'; import type { - Rfc64CatalogAccessAuthorizationInputV1, Rfc64CatalogAccessAuthorizationV1, Rfc64CatalogAccessPolicyRegistryV1, } from './catalog-access-policy-v1.js'; +import { + normalizeRfc64CatalogTransportAuthorizerV1, + recheckCurrentRfc64CatalogPolicyAfterAwaitV1, + withAuthorizedCurrentRfc64CatalogPolicyV1, + withCurrentRfc64CatalogPolicyV1, +} from './catalog-transport-authorization-v1.js'; +import type { Rfc64AuthorizedCatalogWorkResultV1 } from './catalog-transport-authorization-v1.js'; /** * Additive RFC-64 protocol IDs. Their `/catalog/1` component is the wire @@ -205,20 +211,11 @@ export class Rfc64PublicCatalogTransportV1 { if (typeof options?.controlObjects?.getVerifiedObject !== 'function') { fail('catalog-transport-input', 'controlObjects.getVerifiedObject must be a function'); } - const currentAuthorizer = options.authorizeCatalogOperation; - const legacyAuthorizer = options.authorizeOpenCatalogOperation; - if ( - (typeof currentAuthorizer !== 'function' && typeof legacyAuthorizer !== 'function') - || (typeof currentAuthorizer === 'function' && typeof legacyAuthorizer === 'function') - ) { - fail( - 'catalog-transport-input', - 'exactly one catalog access-policy authorizer must be configured', - ); - } - this.#authorizeCatalogOperation = typeof currentAuthorizer === 'function' - ? (input) => currentAuthorizer(toCatalogAccessAuthorizationInput(input)) - : (input) => legacyAuthorizer!(input); + this.#authorizeCatalogOperation = normalizeRfc64CatalogTransportAuthorizerV1({ + current: options.authorizeCatalogOperation, + legacyOpen: options.authorizeOpenCatalogOperation, + invalidConfiguration: (message) => fail('catalog-transport-input', message), + }); if (typeof options.verifyIssuerSignature !== 'function') { fail('catalog-transport-input', 'verifyIssuerSignature must be a function'); } @@ -268,12 +265,16 @@ export class Rfc64PublicCatalogTransportV1 { this.requireStarted(); const peerId = snapshotPeerId(remotePeerId); const announcement = parseAnnouncement(encodeAnnouncement(announcementInput)); - await this.requireCatalogPolicy('announce-outbound', peerId, announcement); - const response = await this.router.send( + const response = await this.withCurrentCatalogPolicy( + 'announce-outbound', peerId, - RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, - encodeAnnouncement(announcement), - sendOptions, + announcement, + () => this.router.send( + peerId, + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_PROTOCOL_V1, + encodeAnnouncement(announcement), + sendOptions, + ), ); if (response.byteLength === 1 && response[0] === ANNOUNCEMENT_DENIED) { fail('catalog-transport-policy-denied', 'remote peer denied the catalog-head announcement'); @@ -281,7 +282,6 @@ export class Rfc64PublicCatalogTransportV1 { if (response.byteLength !== 1 || response[0] !== ACK[0]) { fail('catalog-transport-wire', 'catalog-head announcement returned an invalid acknowledgement'); } - await this.requireCatalogPolicy('announce-outbound', peerId, announcement); } async fetchCatalogHead( @@ -292,20 +292,25 @@ export class Rfc64PublicCatalogTransportV1 { this.requireStarted(); const peerId = snapshotPeerId(remotePeerId); const announcement = parseAnnouncement(encodeAnnouncement(announcementInput)); - await this.requireCatalogPolicy('fetch-outbound', peerId, announcement); const request = requestFromAnnouncement(announcement); - const response = await this.router.send( + const response = await this.withCurrentCatalogPolicy( + 'fetch-outbound', peerId, - RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1, - encodeFetchRequest(request), - sendOptions, + announcement, + () => this.router.send( + peerId, + RFC64_PUBLIC_CATALOG_HEAD_FETCH_PROTOCOL_V1, + encodeFetchRequest(request), + sendOptions, + ), ); const envelope = parseFetchResponse(response); - await this.requireCatalogPolicy('fetch-outbound', peerId, announcement); if (envelope === null) return null; assertHeadMatchesAnnouncement(envelope, announcement); - const issuerSignature = await this.verifyExactIssuerSignature(envelope); - await this.requireCatalogPolicy('fetch-outbound', peerId, announcement); + const issuerSignature = await recheckCurrentRfc64CatalogPolicyAfterAwaitV1( + () => this.requireCatalogPolicy('fetch-outbound', peerId, announcement), + () => this.verifyExactIssuerSignature(envelope), + ); return Object.freeze({ envelope: deepFreeze(envelope), issuerSignature, @@ -319,11 +324,13 @@ export class Rfc64PublicCatalogTransportV1 { this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const announcement = parseAnnouncement(data); - if (!await this.isCatalogPolicyAuthorized('announce-inbound', remotePeerId, announcement)) { - return Uint8Array.of(ANNOUNCEMENT_DENIED); - } - await this.options.onCatalogHeadAvailable(announcement, remotePeerId); - if (!await this.isCatalogPolicyAuthorized('announce-inbound', remotePeerId, announcement)) { + const admitted = await this.withAuthorizedCurrentCatalogPolicy( + 'announce-inbound', + remotePeerId, + announcement, + () => this.options.onCatalogHeadAvailable(announcement, remotePeerId), + ); + if (!admitted.authorized) { return Uint8Array.of(ANNOUNCEMENT_DENIED); } return ACK; @@ -336,44 +343,56 @@ export class Rfc64PublicCatalogTransportV1 { this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const request = parseFetchRequest(data); - if (!await this.isCatalogPolicyAuthorized('fetch-inbound', remotePeerId, request)) { - return Uint8Array.of(FETCH_DENIED); - } - const stored = await this.options.controlObjects.getVerifiedObject({ - objectDigest: request.catalogHeadObjectDigest, - signatureVariantDigest: request.signatureVariantDigest, - verifyIssuerSignature: this.options.verifyIssuerSignature, - }); - if (stored === null) { - if (!await this.isCatalogPolicyAuthorized('fetch-inbound', remotePeerId, request)) { - return Uint8Array.of(FETCH_DENIED); - } - return Uint8Array.of(FETCH_NOT_FOUND); - } - let envelope: SignedAuthorCatalogHeadEnvelopeV1; - try { - assertSignedAuthorCatalogHeadEnvelopeV1(stored.envelope); - envelope = stored.envelope; - assertHeadMatchesRequest(envelope, request); - assertExactIssuerSignatureProof(envelope, stored.issuerSignature); - } catch (cause) { - fail( - 'catalog-transport-object-mismatch', - 'stored object is not the exact requested author-catalog head', - cause, - ); - } - const envelopeBytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(envelope); - const response = new Uint8Array(1 + envelopeBytes.byteLength); - response[0] = FETCH_FOUND; - response.set(envelopeBytes, 1); - if (response.byteLength > RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1) { - fail('catalog-transport-wire', 'author-catalog head exceeds the v1 fetch response cap'); - } - if (!await this.isCatalogPolicyAuthorized('fetch-inbound', remotePeerId, request)) { + const served = await this.withAuthorizedCurrentCatalogPolicy( + 'fetch-inbound', + remotePeerId, + request, + async () => { + const stored = await this.options.controlObjects.getVerifiedObject({ + objectDigest: request.catalogHeadObjectDigest, + signatureVariantDigest: request.signatureVariantDigest, + verifyIssuerSignature: this.options.verifyIssuerSignature, + }); + if (stored === null) return null; + let envelope: SignedAuthorCatalogHeadEnvelopeV1; + try { + assertSignedAuthorCatalogHeadEnvelopeV1(stored.envelope); + envelope = stored.envelope; + assertHeadMatchesRequest(envelope, request); + assertExactIssuerSignatureProof(envelope, stored.issuerSignature); + } catch (cause) { + fail( + 'catalog-transport-object-mismatch', + 'stored object is not the exact requested author-catalog head', + cause, + ); + } + const envelopeBytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(envelope); + const response = new Uint8Array(1 + envelopeBytes.byteLength); + response[0] = FETCH_FOUND; + response.set(envelopeBytes, 1); + if (response.byteLength > RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1) { + fail('catalog-transport-wire', 'author-catalog head exceeds the v1 fetch response cap'); + } + return response; + }, + ); + if (!served.authorized) { return Uint8Array.of(FETCH_DENIED); } - return response; + return served.value ?? Uint8Array.of(FETCH_NOT_FOUND); + } + + private async withAuthorizedCurrentCatalogPolicy( + operation: Rfc64PublicCatalogOperationV1, + remotePeerId: string, + scope: Rfc64PublicCatalogHeadAnnouncementV1 | Rfc64PublicCatalogHeadFetchRequestV1, + work: () => Value | Promise, + ): Promise> { + return withAuthorizedCurrentRfc64CatalogPolicyV1( + () => this.isCatalogPolicyAuthorized(operation, remotePeerId, scope), + work, + ); } private async isCatalogPolicyAuthorized( @@ -393,6 +412,18 @@ export class Rfc64PublicCatalogTransportV1 { } } + private withCurrentCatalogPolicy( + operation: Rfc64PublicCatalogOperationV1, + remotePeerId: string, + scope: Rfc64PublicCatalogHeadAnnouncementV1 | Rfc64PublicCatalogHeadFetchRequestV1, + work: () => Value | Promise, + ): Promise { + return withCurrentRfc64CatalogPolicyV1( + () => this.requireCatalogPolicy(operation, remotePeerId, scope), + work, + ); + } + private async requireCatalogPolicy( operation: Rfc64PublicCatalogOperationV1, remotePeerId: string, @@ -660,18 +691,6 @@ function assertExactIssuerSignatureProof( } } -function toCatalogAccessAuthorizationInput( - input: Rfc64PublicCatalogAuthorizationInputV1, -): Rfc64CatalogAccessAuthorizationInputV1 { - return Object.freeze({ - operation: input.operation, - remotePeerId: input.remotePeerId, - networkId: input.networkId, - contextGraphId: input.contextGraphId, - policyDigest: input.policyDigest, - }); -} - function encodeFlatCanonicalJson( value: object, maxBytes: number, diff --git a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts index 4aaf1f666a..e1ebaf88d9 100644 --- a/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-transport-v1.test.ts @@ -1,7 +1,6 @@ import { multiaddr } from '@multiformats/multiaddr'; import { AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, - CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, DKGNode, ProtocolRouter, canonicalizeSignedControlEnvelopeBytes, @@ -9,10 +8,8 @@ import { encodeOpaqueKaBundleV1, type AuthorCatalogScopeV1, type ContextGraphIdV1, - type ContextGraphPolicyV1, type Digest32V1, type EvmAddressV1, - type MemberRosterV1, type SignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; @@ -20,7 +17,6 @@ import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; -import { Rfc64CatalogAccessPolicyRegistryV1 } from '../src/rfc64/catalog-access-policy-v1.js'; import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, @@ -32,6 +28,7 @@ import { type Rfc64PublicCatalogNativeFetchScopeV1, Rfc64PublicCatalogNativeTransportErrorV1, } from '../src/rfc64/public-catalog-native-transport-v1.js'; +import { createRfc64CatalogAccessPolicyRegistryFixture } from './support/rfc64-catalog-access-policy-fixture.js'; const AUTHOR_WALLET = new ethers.Wallet(`0x${'65'.repeat(32)}`); const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; @@ -74,51 +71,17 @@ function policyRegistry( contextGraphId: ContextGraphIdV1, accessPolicy: 0 | 1, publishPolicy: 0 | 1, -): Rfc64CatalogAccessPolicyRegistryV1 { - const registry = new Rfc64CatalogAccessPolicyRegistryV1({ +) { + return createRfc64CatalogAccessPolicyRegistryFixture({ localAgentAddress, - resolveRemoteAgentAddress: async () => remoteAgentAddress, - }); - const policy = { - networkId: 'otp:20430', + remoteAgentAddress, contextGraphId, - governanceChainId: null, - governanceContractAddress: null, - ownershipTransitionDigest: null, - era: '0', - version: '0', - previousPolicyDigest: null, accessPolicy, publishPolicy, - publishAuthority: publishPolicy === 0 ? CURATOR : null, - publishAuthorityAccountId: '0', - projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, - administrativeDelegationDigest: null, - source: { - kind: 'owner-signed-unregistered', - ownerAddress: AUTHOR, - ownerAuthorityEra: '0', - }, - effectiveAt: '0', - issuedAt: '0', - } satisfies ContextGraphPolicyV1; - const roster = accessPolicy === 0 ? null : { - networkId: policy.networkId, - contextGraphId: policy.contextGraphId, - ownershipTransitionDigest: null, - era: '0', - version: '0', - previousRosterDigest: null, policyDigest: POLICY_DIGEST, - administrativeDelegationDigest: null, - members: [ - { agentAddress: LOCAL_MEMBER, roles: ['holder', 'provider'] }, - { agentAddress: REMOTE_MEMBER, roles: ['holder', 'provider'] }, - ], - issuedAt: '0', - } satisfies MemberRosterV1; - registry.accept({ policy, policyDigest: POLICY_DIGEST, roster }); - return registry; + ownerAddress: AUTHOR, + curatorAddress: CURATOR, + }); } describe('RFC-64 public catalog native content transport v1', () => { diff --git a/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts b/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts index 82213ea4ce..db07faaf69 100644 --- a/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-transport-v1.test.ts @@ -4,14 +4,11 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { - CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, DKGNode, ProtocolRouter, type AuthorCatalogScopeV1, - type ContextGraphPolicyV1, type Digest32V1, type EvmAddressV1, - type MemberRosterV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; import { ethers } from 'ethers'; @@ -19,7 +16,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; import { - Rfc64CatalogAccessPolicyRegistryV1, type Rfc64CatalogAccessAuthorizationInputV1, } from '../src/rfc64/catalog-access-policy-v1.js'; import { openRfc64PersistenceV1, type Rfc64PersistenceV1 } from '../src/rfc64/persistence-v1.js'; @@ -32,6 +28,7 @@ import { parseRfc64PublicCatalogHeadAnnouncementV1, type Rfc64PublicCatalogHeadAnnouncementV1, } from '../src/rfc64/public-catalog-transport-v1.js'; +import { createRfc64CatalogAccessPolicyRegistryFixture } from './support/rfc64-catalog-access-policy-fixture.js'; const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; @@ -146,51 +143,17 @@ function policyRegistry( remoteAgentAddress: EvmAddressV1, accessPolicy: 0 | 1, publishPolicy: 0 | 1, -): Rfc64CatalogAccessPolicyRegistryV1 { - const registry = new Rfc64CatalogAccessPolicyRegistryV1({ +) { + return createRfc64CatalogAccessPolicyRegistryFixture({ localAgentAddress, - resolveRemoteAgentAddress: async () => remoteAgentAddress, - }); - const policy = { - networkId: 'otp:20430', + remoteAgentAddress, contextGraphId: CONTEXT_GRAPH_ID, - governanceChainId: null, - governanceContractAddress: null, - ownershipTransitionDigest: null, - era: '0', - version: '0', - previousPolicyDigest: null, accessPolicy, publishPolicy, - publishAuthority: publishPolicy === 0 ? CURATOR : null, - publishAuthorityAccountId: '0', - projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, - administrativeDelegationDigest: null, - source: { - kind: 'owner-signed-unregistered', - ownerAddress: AUTHOR, - ownerAuthorityEra: '0', - }, - effectiveAt: '0', - issuedAt: '0', - } satisfies ContextGraphPolicyV1; - const roster = accessPolicy === 0 ? null : { - networkId: policy.networkId, - contextGraphId: policy.contextGraphId, - ownershipTransitionDigest: null, - era: '0', - version: '0', - previousRosterDigest: null, policyDigest: POLICY_DIGEST, - administrativeDelegationDigest: null, - members: [ - { agentAddress: LOCAL_MEMBER, roles: ['holder', 'provider'] }, - { agentAddress: REMOTE_MEMBER, roles: ['holder', 'provider'] }, - ], - issuedAt: '0', - } satisfies MemberRosterV1; - registry.accept({ policy, policyDigest: POLICY_DIGEST, roster }); - return registry; + ownerAddress: AUTHOR, + curatorAddress: CURATOR, + }); } describe('RFC-64 author catalog transport v1', () => { @@ -356,6 +319,55 @@ describe('RFC-64 author catalog transport v1', () => { expect(getVerifiedObject).not.toHaveBeenCalled(); }, 15_000); + it('keeps the deprecated open authorizer fail-closed for private policy', async () => { + const [providerNode, requesterNode] = await Promise.all([startNode(), startNode()]); + await connect(requesterNode, providerNode); + const getVerifiedObject = vi.fn(async () => null); + const announcement = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: 'otp:20430', + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + catalogVersion: '0', + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: `0x${'81'.repeat(32)}`, + signatureVariantDigest: `0x${'82'.repeat(32)}`, + }) as Rfc64PublicCatalogHeadAnnouncementV1; + const providerTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(providerNode), + { + controlObjects: { getVerifiedObject }, + authorizeOpenCatalogOperation: async () => ({ + accessPolicy: 1, + policyDigest: POLICY_DIGEST, + }), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }, + ); + const requesterTransport = new Rfc64PublicCatalogTransportV1( + new ProtocolRouter(requesterNode), + { + controlObjects: { getVerifiedObject: async () => null }, + authorizeCatalogOperation: OPEN_POLICY, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + onCatalogHeadAvailable: async () => {}, + }, + ); + transports.push(providerTransport, requesterTransport); + providerTransport.start(); + requesterTransport.start(); + + await expect(requesterTransport.fetchCatalogHead( + providerNode.peerId, + announcement, + { timeoutMs: 4_000 }, + )).rejects.toMatchObject({ code: 'catalog-transport-policy-denied' }); + expect(getVerifiedObject).not.toHaveBeenCalled(); + }, 15_000); + it('rechecks current authorization after the outbound fetch await', async () => { const send = vi.fn(async () => Uint8Array.of(0)); const router = { diff --git a/packages/agent/test/support/rfc64-catalog-access-policy-fixture.ts b/packages/agent/test/support/rfc64-catalog-access-policy-fixture.ts new file mode 100644 index 0000000000..7c34f96b7e --- /dev/null +++ b/packages/agent/test/support/rfc64-catalog-access-policy-fixture.ts @@ -0,0 +1,69 @@ +import { + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + type ContextGraphIdV1, + type ContextGraphPolicyV1, + type Digest32V1, + type EvmAddressV1, + type MemberRosterV1, + type NetworkIdV1, +} from '@origintrail-official/dkg-core'; + +import { Rfc64CatalogAccessPolicyRegistryV1 } from '../../src/rfc64/catalog-access-policy-v1.js'; + +export function createRfc64CatalogAccessPolicyRegistryFixture(options: { + readonly localAgentAddress: EvmAddressV1; + readonly remoteAgentAddress: EvmAddressV1; + readonly networkId?: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly accessPolicy: 0 | 1; + readonly publishPolicy: 0 | 1; + readonly policyDigest: Digest32V1; + readonly ownerAddress: EvmAddressV1; + readonly curatorAddress: EvmAddressV1; +}): Rfc64CatalogAccessPolicyRegistryV1 { + const networkId = options.networkId ?? 'otp:20430'; + const registry = new Rfc64CatalogAccessPolicyRegistryV1({ + localAgentAddress: options.localAgentAddress, + resolveRemoteAgentAddress: async () => options.remoteAgentAddress, + }); + const policy = { + networkId, + contextGraphId: options.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: options.accessPolicy, + publishPolicy: options.publishPolicy, + publishAuthority: options.publishPolicy === 0 ? options.curatorAddress : null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: options.ownerAddress, + ownerAuthorityEra: '0', + }, + effectiveAt: '0', + issuedAt: '0', + } satisfies ContextGraphPolicyV1; + const roster = options.accessPolicy === 0 ? null : { + networkId: policy.networkId, + contextGraphId: policy.contextGraphId, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousRosterDigest: null, + policyDigest: options.policyDigest, + administrativeDelegationDigest: null, + members: [ + { agentAddress: options.localAgentAddress, roles: ['holder', 'provider'] }, + { agentAddress: options.remoteAgentAddress, roles: ['holder', 'provider'] }, + ].sort((left, right) => left.agentAddress.localeCompare(right.agentAddress)), + issuedAt: '0', + } satisfies MemberRosterV1; + registry.accept({ policy, policyDigest: options.policyDigest, roster }); + return registry; +} From 8d9fc2130961b49daadcb761fc11445450faf443 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:15:12 +0200 Subject: [PATCH 172/292] Revert "feat(publisher): job-scoped terminal cleanup for publisher + SWM share queues (#1837) (#1883)" This reverts commit 10ff3cf3980fe1e2a0ec98023f974bfd40000a42. --- packages/agent/src/dkg-agent-base.ts | 6 +- packages/agent/src/dkg-agent.ts | 8 +- .../test/clear-promote-async-facade.test.ts | 81 -------- packages/agent/vitest.unit.config.ts | 1 - packages/cli/src/api-client.ts | 15 -- .../routes/knowledge-assets-async-share.ts | 12 -- .../cli/src/daemon/routes/knowledge-assets.ts | 16 -- packages/cli/src/daemon/routes/publisher.ts | 24 --- .../daemon/routes/shared-assertion-helpers.ts | 14 +- .../daemon/routes/terminal-clear-response.ts | 28 --- packages/cli/src/publisher-runner.ts | 3 +- packages/cli/test/api-client.test.ts | 16 -- .../cli/test/promote-async-routes.test.ts | 48 ----- .../test/publisher-clear-job-route.test.ts | 163 --------------- .../cli/test/terminal-clear-response.test.ts | 55 ----- packages/cli/vitest.unit.config.ts | 1 - .../src/async-lift-publisher-impl.ts | 80 ++------ .../src/async-lift-publisher-types.ts | 13 -- .../src/async-lift-publisher-utils.ts | 13 -- .../publisher/src/async-lift-publisher.ts | 2 - .../publisher/src/async-promote-queue-impl.ts | 57 +---- .../src/async-promote-queue-types.ts | 14 -- .../src/async-promote-queue-utils.ts | 14 -- packages/publisher/src/async-promote-queue.ts | 1 - packages/publisher/src/index.ts | 7 - packages/publisher/src/terminal-job-clear.ts | 36 ---- .../test/async-lift-terminal-clear.test.ts | 194 ------------------ .../test/async-promote-terminal-clear.test.ts | 139 ------------- packages/publisher/vitest.unit.config.ts | 2 - 29 files changed, 22 insertions(+), 1041 deletions(-) delete mode 100644 packages/agent/test/clear-promote-async-facade.test.ts delete mode 100644 packages/cli/src/daemon/routes/terminal-clear-response.ts delete mode 100644 packages/cli/test/publisher-clear-job-route.test.ts delete mode 100644 packages/cli/test/terminal-clear-response.test.ts delete mode 100644 packages/publisher/src/terminal-job-clear.ts delete mode 100644 packages/publisher/test/async-lift-terminal-clear.test.ts delete mode 100644 packages/publisher/test/async-promote-terminal-clear.test.ts diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index c0fd71ce63..96c25adf20 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -115,7 +115,6 @@ import { FileWorkspacePublicSnapshotStore, parseWorkspacePublicSnapshotNQuads, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, - type PromoteTerminalJobClearer, type PromoteJob, type PromoteListFilter, wrapAsRpcPreconditionIfApplicable, type PublishOptions, type PublishResult, type PhaseCallback, type KAMetadata, type CASCondition, @@ -555,10 +554,7 @@ export class DKGAgentBase { * getter so the worker (a daemon-side concern) and tests can drive * the queue directly without going through the assertion subsurface. */ - // Typed with the terminal-clear capability at the ownership boundary (not cast at the - // getter): the only assigned value is `TripleStoreAsyncPromoteQueue`, which implements it, - // and any test/subclass substituting a queue must now satisfy the clearer at compile time. - protected _promoteQueue?: AsyncPromoteQueue & PromoteTerminalJobClearer; + protected _promoteQueue?: AsyncPromoteQueue; /** * Override for tests / future operator config. When set before * `promoteQueue` is first accessed, the queue is constructed with diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index cf228a8335..812b51f617 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -107,7 +107,6 @@ import { FileWorkspacePublicSnapshotStore, parseWorkspacePublicSnapshotNQuads, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, - type PromoteTerminalJobClearer, type TerminalJobClearOutcome, type PromoteJob, type PromoteListFilter, wrapAsRpcPreconditionIfApplicable, resolveStorageAckTiming, @@ -2969,11 +2968,6 @@ export class DKGAgent extends DKGAgentBase { async recoverPromoteAsync(jobId: string): Promise { return agent.promoteQueue.recover(jobId); }, - // #1837 — atomic by-jobId terminal clear (record removal). Distinct from - // cancelPromoteAsync (queued abort, retains the row). - async clearPromoteAsync(jobId: string): Promise { - return agent.promoteQueue.clearTerminalJob(jobId); - }, }; } @@ -2989,7 +2983,7 @@ export class DKGAgent extends DKGAgentBase { * `recordCommitMarker` / `recoverOnStartup`) without the assertion * subsurface having to leak those methods to user-facing callers. */ - get promoteQueue(): AsyncPromoteQueue & PromoteTerminalJobClearer { + get promoteQueue(): AsyncPromoteQueue { if (!this._promoteQueue) { this._promoteQueue = new TripleStoreAsyncPromoteQueue(this.store, this._promoteQueueConfig ?? {}); } diff --git a/packages/agent/test/clear-promote-async-facade.test.ts b/packages/agent/test/clear-promote-async-facade.test.ts deleted file mode 100644 index 4f83cb7cb1..0000000000 --- a/packages/agent/test/clear-promote-async-facade.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; -import { - TripleStoreAsyncPromoteQueue, - type PromoteRequest, -} from '@origintrail-official/dkg-publisher'; -import { DKGAgent } from '../src/dkg-agent.js'; - -// #1837 — verifies the PRODUCTION DKGAgent facade wiring between -// `agent.assertion.clearPromoteAsync` and `promoteQueue.clearTerminalJob`, driving a REAL -// promote queue. The SWM route tests stub `clearPromoteAsync` onto a fake agent, so they -// cover the route + queue contract but NOT this delegation: a regression that pointed the -// facade at `cancel()` (which retains the row) or any other queue method would leave those -// route tests green yet be caught here. -describe('DKGAgent assertion.clearPromoteAsync facade wiring (#1837)', () => { - const stores: OxigraphStore[] = []; - afterEach(async () => { - await Promise.all(stores.splice(0).map((s) => s.close().catch(() => {}))); - }); - - function makeRequest(overrides: Partial = {}): PromoteRequest { - return { contextGraphId: 'graphify', subGraphName: 'code', assertionName: 'shard-1', entities: 'all', ...overrides }; - } - - function newQueue(): TripleStoreAsyncPromoteQueue { - const store = new OxigraphStore(); - stores.push(store); - let id = 0; - return new TripleStoreAsyncPromoteQueue(store, { now: () => 1_000_000, idGenerator: () => `job-${++id}` }); - } - - // Real DKGAgent facade over an injected real queue — `agent.assertion.clearPromoteAsync` - // routes through the public `promoteQueue` getter, exactly as production does. - // `defaultAgentAddress` is set so the `assertion` getter's `this.defaultAgentAddress ?? - // this.peerId` resolves without touching the unbuilt libp2p `node` (mirrors the sibling - // promote-async-default-agent facade test). - function agentFor(queue: TripleStoreAsyncPromoteQueue): { assertion: { clearPromoteAsync(jobId: string): Promise } } { - const agent = Object.create(DKGAgent.prototype) as { - _promoteQueue: TripleStoreAsyncPromoteQueue; - defaultAgentAddress: string; - }; - agent.defaultAgentAddress = `0x${'11'.repeat(20)}`; - agent._promoteQueue = queue; - return agent as unknown as { assertion: { clearPromoteAsync(jobId: string): Promise } }; - } - - async function driveToSucceeded(queue: TripleStoreAsyncPromoteQueue): Promise { - const jobId = await queue.enqueue(makeRequest()); - const claimed = await queue.claimNext('worker-1'); - const token = claimed!.lease!.claimToken; - for (const marker of ['swmInserted', 'wmCleaned', 'lifecycleStamped', 'gossiped'] as const) { - await queue.recordCommitMarker(jobId, token, marker); - } - await queue.succeed(jobId, token, { promotedCount: 1, succeededAt: 1_000_000 }); - return jobId; - } - - it('clears a terminal job through the real facade, removes only that row, and is idempotent', async () => { - const queue = newQueue(); - const target = await driveToSucceeded(queue); - const other = await driveToSucceeded(queue); - const agent = agentFor(queue); - - // cleared — and the row is actually gone (a mis-delegation to cancel() would retain it). - expect(await agent.assertion.clearPromoteAsync(target)).toEqual({ outcome: 'cleared' }); - expect(await queue.getStatus(target)).toBeNull(); - expect((await queue.getStatus(other))?.state).toBe('succeeded'); // only the exact row cleared - - // Idempotent repeat via the facade. - expect(await agent.assertion.clearPromoteAsync(target)).toEqual({ outcome: 'already_absent' }); - }); - - it('rejects a nonterminal (queued) job through the facade without mutation', async () => { - const queue = newQueue(); - const queued = await queue.enqueue(makeRequest()); - const agent = agentFor(queue); - - expect(await agent.assertion.clearPromoteAsync(queued)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await queue.getStatus(queued))?.state).toBe('queued'); // untouched - }); -}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index ae68dfc21d..107e88a784 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -22,7 +22,6 @@ export default defineConfig({ "test/publish-foreign-author-resolution.test.ts", "test/durable-integrity-seal-assertion-version.test.ts", "test/promote-async-default-agent.test.ts", - "test/clear-promote-async-facade.test.ts", "test/query-min-trust-alias.test.ts", "test/sync-envelope-cursor.test.ts", "test/exact-assets.test.ts", diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index 167f63a0b9..0c02b3b9e0 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -798,13 +798,6 @@ export class ApiClient { return this.post(`/api/knowledge-assets/swm/share-jobs/${encodeURIComponent(jobId)}/recover`, {}); } - // #1837 — atomic by-exact-jobId TERMINAL record removal (idempotent). Distinct from - // knowledgeAssetCancelShareJob (DELETE = queued cancellation that retains the row). - // cleared / already_absent resolve normally (200); rejected throws via post(). - async knowledgeAssetClearShareJob(jobId: string): Promise<{ outcome: 'cleared' | 'already_absent'; jobId: string }> { - return this.post(`/api/knowledge-assets/swm/share-jobs/${encodeURIComponent(jobId)}/clear`, {}); - } - /** Publish to VM (mint or update on chain; git push origin main). */ async knowledgeAssetPublish( contextGraphId: string, @@ -1286,14 +1279,6 @@ export class ApiClient { return this.post('/api/publisher/clear', { status }); } - // #1837 — atomic by-exact-jobId TERMINAL clear (distinct from publisherCancel and the - // status-scoped publisherClear). cleared / already_absent resolve normally (200); - // rejected (nonterminal/unknown → 409, malformed → 400) throws via the post() helper - // carrying { outcome:'rejected', reason } in the response body. - async publisherClearJob(jobId: string): Promise<{ outcome: 'cleared' | 'already_absent'; jobId: string }> { - return this.post('/api/publisher/clear-job', { jobId }); - } - // ------------------------- EPCIS ------------------------------------- async captureEpcis(request: { diff --git a/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts b/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts index 1de92087bd..d34173b7c4 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts @@ -23,7 +23,6 @@ // Shared logic lives in `./shared-assertion-helpers.js`. import type { RequestContext } from "./context.js"; -import { respondTerminalClearOutcome } from "./terminal-clear-response.js"; import { jsonResponse, readBody, @@ -239,14 +238,3 @@ export async function handleKaShareJobRecover(ctx: RequestContext, jobId: string throw err; } } - -// ── POST /api/knowledge-assets/swm/share-jobs/:jobId/clear ──────────────────── -// -// #1837 — atomic by-exact-jobId TERMINAL record removal. DISTINCT from the DELETE -// cancellation above (which rewrites a queued job to failed+cancelled and RETAINS the -// row): this REMOVES a native-terminal (succeeded|failed) job record and is idempotent -// (already_absent = 200, not 404). The caller passes the already url-decoded jobId. -export async function handleKaShareJobClear(ctx: RequestContext, jobId: string): Promise { - const { res, agent } = ctx; - return respondTerminalClearOutcome(res, await agent.assertion.clearPromoteAsync(jobId), jobId); -} diff --git a/packages/cli/src/daemon/routes/knowledge-assets.ts b/packages/cli/src/daemon/routes/knowledge-assets.ts index c15f3b7d95..cde6495b35 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets.ts @@ -62,7 +62,6 @@ import { handleKaShareJobsList, handleKaShareJobStatus, handleKaShareJobCancel, - handleKaShareJobClear, handleKaShareJobRecover, } from "./knowledge-assets-async-share.js"; import { @@ -668,21 +667,6 @@ export async function handleKnowledgeAssetsRoutes(ctx: RequestContext): Promise< if (jobId === null) return; return handleKaShareJobRecover(ctx, jobId); } - // POST /api/knowledge-assets/swm/share-jobs/:jobId/clear — #1837 atomic terminal - // record removal (idempotent). DISTINCT from the DELETE cancellation below (which - // rewrites a queued job to failed+cancelled and RETAINS the row). - if ( - method === "POST" && - path.startsWith(`${SHARE_JOBS_PREFIX}/`) && - path.endsWith("/clear") - ) { - const jobId = decodePromoteJobId( - path.slice(`${SHARE_JOBS_PREFIX}/`.length, -"/clear".length), - res, - ); - if (jobId === null) return; - return handleKaShareJobClear(ctx, jobId); - } // GET /api/knowledge-assets/swm/share-jobs/:jobId — status (#3) if ( method === "GET" && diff --git a/packages/cli/src/daemon/routes/publisher.ts b/packages/cli/src/daemon/routes/publisher.ts index 7d868146c4..72c5b9f8f3 100644 --- a/packages/cli/src/daemon/routes/publisher.ts +++ b/packages/cli/src/daemon/routes/publisher.ts @@ -186,7 +186,6 @@ import { bindingValue, carryForwardBundledMarkItDownBinary, } from '../manifest.js'; -import { respondTerminalClearOutcome } from './terminal-clear-response.js'; import { resolveNameToPeerId, jsonResponse, @@ -546,27 +545,4 @@ export async function handlePublisherRoutes(ctx: RequestContext): Promise const count = await publisherControl.clear(status); return jsonResponse(res, 200, { cleared: count, status }); } - - // POST /api/publisher/clear-job { jobId } - // #1837 — atomic by-exact-jobId TERMINAL clear. DISTINCT from cancel (which aborts an - // ACCEPTED job) and from bulk /clear (status-scoped): clears exactly one job iff it is - // in a native terminal state, is idempotent for an absent job (already_absent = 200, - // NOT 404), and never touches another job. Preserves the #1829 journal (subject-scoped). - if (req.method === "POST" && path === "/api/publisher/clear-job") { - const body = await readBody(req, SMALL_BODY_BYTES); - let clearJobParsed: any; - try { - clearJobParsed = JSON.parse(body || "{}"); - } catch { - return jsonResponse(res, 400, { error: "Invalid JSON body" }); - } - // `JSON.parse("null")` etc. succeeds but yields a non-object; optional-chain so a - // `null`/primitive body falls through to the malformed guard (400), never a - // destructure TypeError → 500. - const jobId = clearJobParsed && typeof clearJobParsed === "object" ? clearJobParsed.jobId : undefined; - if (typeof jobId !== "string" || jobId.trim().length === 0) { - return jsonResponse(res, 400, { outcome: "rejected", reason: "malformed", error: "Missing jobId" }); - } - return respondTerminalClearOutcome(res, await publisherControl.clearTerminalJob(jobId), jobId); - } } diff --git a/packages/cli/src/daemon/routes/shared-assertion-helpers.ts b/packages/cli/src/daemon/routes/shared-assertion-helpers.ts index f0a42d2d6a..76a287b5c2 100644 --- a/packages/cli/src/daemon/routes/shared-assertion-helpers.ts +++ b/packages/cli/src/daemon/routes/shared-assertion-helpers.ts @@ -20,12 +20,7 @@ import { resolveImportedArtifactMetadata, assertRdfLiteralMutf8Safe, } from '@origintrail-official/dkg-core'; -import { - type PromoteJob, - type PromoteJobState, - SAFE_CLEAR_JOB_ID_PATTERN, - SAFE_CLEAR_JOB_ID_MAX_LENGTH, -} from '@origintrail-official/dkg-publisher'; +import { type PromoteJob, type PromoteJobState } from '@origintrail-official/dkg-publisher'; import { daemonState } from '../state.js'; import { jsonResponse, @@ -121,12 +116,9 @@ export class ImportArtifactRouteError extends Error { } export function validatePromoteJobId(jobId: string): { valid: true } | { valid: false; reason: string } { - // Grammar + length bound are imported from the publisher (the single authoritative - // job-id contract, also enforced control-plane-side by `isSafeClearJobId`) so route - // acceptance and control-plane acceptance cannot drift. if (!jobId) return { valid: false, reason: "jobId is required" }; - if (jobId.length > SAFE_CLEAR_JOB_ID_MAX_LENGTH) return { valid: false, reason: "jobId is too long" }; - if (!SAFE_CLEAR_JOB_ID_PATTERN.test(jobId)) { + if (jobId.length > 256) return { valid: false, reason: "jobId is too long" }; + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(jobId)) { return { valid: false, reason: "jobId may only contain letters, numbers, '.', '_', ':', and '-'", diff --git a/packages/cli/src/daemon/routes/terminal-clear-response.ts b/packages/cli/src/daemon/routes/terminal-clear-response.ts deleted file mode 100644 index 4bfe8c0274..0000000000 --- a/packages/cli/src/daemon/routes/terminal-clear-response.ts +++ /dev/null @@ -1,28 +0,0 @@ -// daemon/routes/terminal-clear-response.ts -// -// #1837 — single source for the TerminalJobClearOutcome → HTTP projection, shared by the -// publisher (`POST /api/publisher/clear-job`) and SWM share-job -// (`POST /api/knowledge-assets/swm/share-jobs/:jobId/clear`) clear routes so the response -// contract cannot drift. This is route-owned policy (both clear routes are the only -// callers), so it lives beside the routes rather than in the generic `http-utils.ts` -// bucket, which stays focused on protocol-level helpers (JSON responses, body parsing). -// -// Mapping: `cleared` / `already_absent` are SUCCESS (200, idempotent — NOT 404); -// `rejected(malformed)` is a client input error (400); `rejected(nonterminal|unknown)` is a -// server-side state condition (409). - -import type { ServerResponse } from "node:http"; -import type { TerminalJobClearOutcome } from "@origintrail-official/dkg-publisher"; -import { jsonResponse } from "../http-utils.js"; - -export function respondTerminalClearOutcome( - res: ServerResponse, - outcome: TerminalJobClearOutcome, - jobId: string, -): void { - if (outcome.outcome === "cleared" || outcome.outcome === "already_absent") { - jsonResponse(res, 200, { ...outcome, jobId }); - return; - } - jsonResponse(res, outcome.reason === "malformed" ? 400 : 409, { ...outcome, jobId }); -} diff --git a/packages/cli/src/publisher-runner.ts b/packages/cli/src/publisher-runner.ts index d23e4f1b51..866afebdba 100644 --- a/packages/cli/src/publisher-runner.ts +++ b/packages/cli/src/publisher-runner.ts @@ -33,7 +33,6 @@ import { type VmPublishIntentRecoveryPublisher, type VmPublishIntentIndexBackfiller, type VmPublishAdmissionJournalReader, - type VmPublishTerminalJobClearer, type LiftJobBroadcast, type LiftJobHex, type LiftJobIncluded, @@ -375,7 +374,7 @@ export function createPublisherInspectorFromStore( export function createPublisherControlFromStore( store: TripleStore, options: { publicSnapshotStore?: WorkspacePublicSnapshotStore; maxRetries?: number } = {}, -): VmPublishIntentRecoveryPublisher & VmPublishIntentIndexBackfiller & VmPublishAdmissionJournalReader & VmPublishTerminalJobClearer { +): VmPublishIntentRecoveryPublisher & VmPublishIntentIndexBackfiller & VmPublishAdmissionJournalReader { // The daemon admission instance also serves the #1828 recovery lookup (route) // and the boot index backfill — segregated capabilities the base // AsyncLiftPublisher runtime contract intentionally does NOT carry. diff --git a/packages/cli/test/api-client.test.ts b/packages/cli/test/api-client.test.ts index 18211caeb2..e0f822665b 100644 --- a/packages/cli/test/api-client.test.ts +++ b/packages/cli/test/api-client.test.ts @@ -1120,22 +1120,6 @@ describe('ApiClient — GitHub-shaped knowledge-assets SDK (OT-RFC-43 §10.5)', expect(calls[0].opts.method).toBe('POST'); }); - it('#1837 clear-job helpers POST the terminal-clear routes with correct encoding and body', async () => { - // SWM share-job clear: jobId is percent-encoded in the path, empty JSON body. - let calls = track({ outcome: 'cleared', jobId: 'share/job 1' }); - await client.knowledgeAssetClearShareJob('share/job 1'); - expect(calls[0].url).toBe(`${base}/api/knowledge-assets/swm/share-jobs/share%2Fjob%201/clear`); - expect(calls[0].opts.method).toBe('POST'); - expect(JSON.parse(calls[0].opts.body as string)).toEqual({}); - - // Publisher clear-job: jobId travels in the body, not the path. - calls = track({ outcome: 'already_absent', jobId: 'lift job 7' }); - await client.publisherClearJob('lift job 7'); - expect(calls[0].url).toBe(`${base}/api/publisher/clear-job`); - expect(calls[0].opts.method).toBe('POST'); - expect(JSON.parse(calls[0].opts.body as string)).toEqual({ jobId: 'lift job 7' }); - }); - it('knowledgeAssetShareAsync rejects unsupported sync-only options before HTTP serialization', async () => { const calls = track({ jobId: 'should-not-reach', state: 'queued' }); await expect(client.knowledgeAssetShareAsync('cg', 'f', { diff --git a/packages/cli/test/promote-async-routes.test.ts b/packages/cli/test/promote-async-routes.test.ts index f2e3215f72..3cf2247b98 100644 --- a/packages/cli/test/promote-async-routes.test.ts +++ b/packages/cli/test/promote-async-routes.test.ts @@ -118,9 +118,6 @@ describe('async SWM-share queue daemon routes', () => { async recoverPromoteAsync(jobId: string): Promise { return queue.recover(jobId); }, - async clearPromoteAsync(jobId: string) { - return (queue as TripleStoreAsyncPromoteQueue).clearTerminalJob(jobId); - }, }, }; } @@ -564,51 +561,6 @@ describe('async SWM-share queue daemon routes', () => { expect(r.body.error).toMatch(/Invalid promote jobId/); }); - // --------------------------------------------------------------------------- - // POST /api/knowledge-assets/swm/share-jobs/:jobId/clear (#1837) - // Atomic terminal record removal — DISTINCT from DELETE (cancel). Idempotent: - // already_absent is 200, not 404. - // --------------------------------------------------------------------------- - - it('POST /swm/share-jobs/:jobId/clear clears a terminal job → 200 cleared; repeat → 200 already_absent', async () => { - await startRoutes(makeAgent()); - const enq = await post('/api/knowledge-assets/clearable/swm/share-async', { contextGraphId: 'cg' }); - await del(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}`); // cancel → terminal 'failed' - expect((await queue.getStatus(enq.body.jobId))?.state).toBe('failed'); - - const r = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ outcome: 'cleared' }); - expect(await queue.getStatus(enq.body.jobId)).toBeNull(); - - const again = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); - expect(again.status).toBe(200); - expect(again.body).toMatchObject({ outcome: 'already_absent' }); - }); - - it('POST /swm/share-jobs/:jobId/clear returns 409 for a nonterminal (queued) job, without mutation', async () => { - await startRoutes(makeAgent()); - const enq = await post('/api/knowledge-assets/queued2/swm/share-async', { contextGraphId: 'cg' }); - const r = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); - expect(r.status).toBe(409); - expect(r.body).toMatchObject({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await queue.getStatus(enq.body.jobId))?.state).toBe('queued'); - }); - - it('POST /swm/share-jobs/:jobId/clear returns 200 already_absent for an unknown job (idempotent)', async () => { - await startRoutes(makeAgent()); - const r = await post('/api/knowledge-assets/swm/share-jobs/non-existent/clear', {}); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ outcome: 'already_absent' }); - }); - - it('POST /swm/share-jobs/:jobId/clear rejects an unsafe path value before queue lookup (400)', async () => { - await startRoutes(makeAgent()); - const r = await post('/api/knowledge-assets/swm/share-jobs/job%3Ebad/clear', {}); - expect(r.status).toBe(400); - expect(r.body.error).toMatch(/Invalid promote jobId/); - }); - // --------------------------------------------------------------------------- // Routing precedence — the list route (`/swm/share-jobs`) must not be // claimed by the per-job route (`/swm/share-jobs/:jobId`). diff --git a/packages/cli/test/publisher-clear-job-route.test.ts b/packages/cli/test/publisher-clear-job-route.test.ts deleted file mode 100644 index 975ca69793..0000000000 --- a/packages/cli/test/publisher-clear-job-route.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { ServerResponse } from 'node:http'; -import { Readable } from 'node:stream'; -import { afterEach, describe, expect, it } from 'vitest'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; -import { createPublisherControlFromStore } from '../src/publisher-runner.js'; -import { handlePublisherRoutes } from '../src/daemon/routes/publisher.js'; -import type { RequestContext } from '../src/daemon/routes/context.js'; - -// #1837 — POST /api/publisher/clear-job outcome → HTTP mapping. -describe('#1837 POST /api/publisher/clear-job', () => { - const stores: OxigraphStore[] = []; - let ids = 0; - let now = 1_000; - afterEach(async () => { - await Promise.all(stores.splice(0).map((s) => s.close().catch(() => {}))); - }); - - function kaVmPublishRequest() { - const authorAddress = '0x1111111111111111111111111111111111111111'; - const kaNumber = 7n; - const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; - return { - contextGraphId: 'music-social', name: 'albums', agentAddress: '0x0', shareOperationId: 'share-op-1', - roots: [] as string[], contentScopeVersion: 2 as const, kaUal, assertionVersion: '1', - publicTripleCount: 2, privateTripleCount: 0, - seal: { - merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, authorAddress: authorAddress as `0x${string}`, - signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, - schemeVersion: 1, reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, - }, - sealChainId: '31337' as `${bigint}`, sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, - sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - intentKey: `sha256:${'ab'.repeat(32)}`, wmCurrentAssertion: '12'.repeat(32), swmCurrentAssertion: '12'.repeat(32), - kaNumber: kaNumber.toString(), reservedUal: kaUal, - }; - } - - function newControl() { - const store = new OxigraphStore(); - stores.push(store); - return createPublisherControlFromStore(store, {}); - } - - async function finalizedJob(control: ReturnType): Promise { - const bx = { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }; - const inc = { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }; - const jobId = await control.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); - await control.claimNext('wallet-1'); - await control.update(jobId, 'validated', { validation: { canonicalRoots: [], canonicalRootMap: {}, swmQuadCount: 2, authorityProofRef: 'knowledge-asset-lifecycle', transitionType: 'CREATE' } }); - await control.update(jobId, 'broadcast', { broadcast: bx }); - await control.update(jobId, 'included', { broadcast: bx, inclusion: inc }); - await control.update(jobId, 'finalized', { broadcast: bx, inclusion: inc, finalization: { mode: 'local' } }); - return jobId; - } - - it('clears a terminal job → 200 cleared; repeat → 200 already_absent', async () => { - const control = newControl(); - const jobId = await finalizedJob(control); - const ctx1 = postClearJob(control, { jobId }); - await handlePublisherRoutes(ctx1); - expect(responseStatus(ctx1)).toBe(200); - expect(responseBody(ctx1)).toMatchObject({ outcome: 'cleared', jobId }); - - const ctx2 = postClearJob(control, { jobId }); - await handlePublisherRoutes(ctx2); - expect(responseStatus(ctx2)).toBe(200); - expect(responseBody(ctx2)).toMatchObject({ outcome: 'already_absent', jobId }); - }); - - it('rejects a nonterminal (accepted) job → 409 nonterminal', async () => { - const control = newControl(); - const jobId = await control.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); - const ctx = postClearJob(control, { jobId }); - await handlePublisherRoutes(ctx); - expect(responseStatus(ctx)).toBe(409); - expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'nonterminal' }); - }); - - it('rejects a missing/empty jobId → 400 malformed', async () => { - const control = newControl(); - const ctx = postClearJob(control, {}); - await handlePublisherRoutes(ctx); - expect(responseStatus(ctx)).toBe(400); - }); - - // #1883 review (🔴): a literal `null` body parses fine but must not TypeError on - // destructure (→ 500). It falls through to the malformed guard as a bounded 400. - it('rejects a literal null body → 400 malformed (no 500)', async () => { - const control = newControl(); - const ctx = postClearJobRaw(control, 'null'); - await handlePublisherRoutes(ctx); - expect(responseStatus(ctx)).toBe(400); - expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'malformed' }); - }); - - // #1883 review (🔴): a SPARQL-unsafe jobId must be a bounded 400, never a query-error 500. - it('rejects a SPARQL-unsafe jobId → 400 malformed (no 500)', async () => { - const control = newControl(); - for (const jobId of ['bad id', 'bad>id']) { - const ctx = postClearJob(control, { jobId }); - await handlePublisherRoutes(ctx); - expect(responseStatus(ctx)).toBe(400); - expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'malformed' }); - } - }); - - function postClearJob(publisherControl: RequestContext['publisherControl'], body: Record): RequestContext { - return postClearJobRaw(publisherControl, JSON.stringify(body)); - } - - function postClearJobRaw(publisherControl: RequestContext['publisherControl'], rawBody: string): RequestContext { - const path = '/api/publisher/clear-job'; - const url = new URL(`http://127.0.0.1${path}`); - const req = Readable.from([]); - // readBody() resolves synchronously from a prebuffered body (as httpAuthGuard's - // eager drain leaves it) — avoids driving a mock stream in the unit harness. - Object.assign(req, { - method: 'POST', url: path, headers: { host: '127.0.0.1' }, - __dkgPrebufferedBody: Buffer.from(rawBody, 'utf8'), - }); - return { - req: req as RequestContext['req'], - res: createResponse() as unknown as ServerResponse, - agent: {} as RequestContext['agent'], - publisherControl, - publisherState: { runtime: null, availability: { available: false, reason: 'publisher_disabled', retryable: false, operatorActionRequired: true } }, - config: {} as RequestContext['config'], - startedAt: 0, - dashDb: {} as RequestContext['dashDb'], - opWallets: { adminWallet: { address: '0x0', privateKey: '0x0' }, wallets: [] } as RequestContext['opWallets'], - network: null as RequestContext['network'], - tracker: {} as RequestContext['tracker'], - memoryManager: {} as RequestContext['memoryManager'], - bridgeAuthToken: undefined, - nodeVersion: 'test', - nodeCommit: 'test', - catchupTracker: {} as RequestContext['catchupTracker'], - extractionRegistry: {} as RequestContext['extractionRegistry'], - fileStore: {} as RequestContext['fileStore'], - extractionStatus: new Map(), - assertionImportLocks: new Map(), - vectorStore: {} as RequestContext['vectorStore'], - embeddingProvider: null, - validTokens: new Set(), - apiHost: '127.0.0.1', - apiPortRef: { value: 0 }, - url, - path: url.pathname, - requestToken: undefined, - requestAgentAddress: '0x0', - }; - } - - function createResponse() { - return { - statusCode: 0, headers: undefined as Record | undefined, body: '', writableEnded: false, - writeHead(status: number, headers: Record) { this.statusCode = status; this.headers = headers; return this; }, - end(body?: string) { this.body = body ?? ''; this.writableEnded = true; return this; }, - }; - } - function responseStatus(ctx: RequestContext): number { return (ctx.res as unknown as { statusCode: number }).statusCode; } - function responseBody(ctx: RequestContext): Record { return JSON.parse((ctx.res as unknown as { body: string }).body) as Record; } -}); diff --git a/packages/cli/test/terminal-clear-response.test.ts b/packages/cli/test/terminal-clear-response.test.ts deleted file mode 100644 index 277e7e7330..0000000000 --- a/packages/cli/test/terminal-clear-response.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ServerResponse } from 'node:http'; -import { describe, expect, it } from 'vitest'; -import type { TerminalJobClearOutcome } from '@origintrail-official/dkg-publisher'; -import { respondTerminalClearOutcome } from '../src/daemon/routes/terminal-clear-response.js'; - -// #1837 — locks the shared TerminalJobClearOutcome → HTTP contract that BOTH the publisher -// (/api/publisher/clear-job) and SWM (/api/knowledge-assets/swm/share-jobs/:id/clear) clear -// routes project through, so a future mapping change (wrong status, dropped jobId, or the -// `unknown` branch diverging) cannot silently alter the clear-route contract. -describe('respondTerminalClearOutcome mapping', () => { - function fakeRes() { - return { - statusCode: 0, - body: '', - headers: undefined as Record | undefined, - writableEnded: false, - writeHead(status: number, headers: Record) { - this.statusCode = status; - this.headers = headers; - return this; - }, - end(body?: string) { - this.body = body ?? ''; - this.writableEnded = true; - return this; - }, - }; - } - - function run(outcome: TerminalJobClearOutcome, jobId: string) { - const res = fakeRes(); - respondTerminalClearOutcome(res as unknown as ServerResponse, outcome, jobId); - return { status: res.statusCode, body: JSON.parse(res.body) as Record }; - } - - it('cleared → 200 with echoed jobId', () => { - expect(run({ outcome: 'cleared' }, 'job-1')).toEqual({ status: 200, body: { outcome: 'cleared', jobId: 'job-1' } }); - }); - - it('already_absent → 200 (idempotent success, NOT 404)', () => { - expect(run({ outcome: 'already_absent' }, 'job-2')).toEqual({ status: 200, body: { outcome: 'already_absent', jobId: 'job-2' } }); - }); - - it('rejected malformed → 400 (client input error)', () => { - expect(run({ outcome: 'rejected', reason: 'malformed' }, 'bad id')).toEqual({ status: 400, body: { outcome: 'rejected', reason: 'malformed', jobId: 'bad id' } }); - }); - - it('rejected nonterminal → 409 (server-side state condition)', () => { - expect(run({ outcome: 'rejected', reason: 'nonterminal' }, 'job-3')).toEqual({ status: 409, body: { outcome: 'rejected', reason: 'nonterminal', jobId: 'job-3' } }); - }); - - it('rejected unknown → 409 with echoed jobId', () => { - expect(run({ outcome: 'rejected', reason: 'unknown' }, 'bogus-1')).toEqual({ status: 409, body: { outcome: 'rejected', reason: 'unknown', jobId: 'bogus-1' } }); - }); -}); diff --git a/packages/cli/vitest.unit.config.ts b/packages/cli/vitest.unit.config.ts index 5ca8fced20..1b11d0d923 100644 --- a/packages/cli/vitest.unit.config.ts +++ b/packages/cli/vitest.unit.config.ts @@ -18,7 +18,6 @@ export default defineConfig({ // #1828 — durable-admission recovery lookup route (pure handler, no hardhat). 'test/publisher-job-by-intent-route.test.ts', 'test/publisher-journal-route.test.ts', - 'test/publisher-clear-job-route.test.ts', // #1828 — daemon-boot intent-index backfill wiring (fail-open contract). 'test/vm-publish-intent-backfill.test.ts', 'test/agent-connect-routes.test.ts', diff --git a/packages/publisher/src/async-lift-publisher-impl.ts b/packages/publisher/src/async-lift-publisher-impl.ts index f60b4edd84..3bfc1e9c7b 100644 --- a/packages/publisher/src/async-lift-publisher-impl.ts +++ b/packages/publisher/src/async-lift-publisher-impl.ts @@ -40,10 +40,8 @@ import type { VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, - VmPublishTerminalJobClearer, } from './async-lift-publisher-types.js'; import { AsyncLiftJobConflictError } from './async-lift-publisher-types.js'; -import { isSafeClearJobId, type TerminalJobClearOutcome } from './terminal-job-clear.js'; import { mapPublishExceptionToLiftJobFailure, mapPublishResultToLiftJobSuccess, @@ -82,7 +80,6 @@ import { getRecoveryTxHash, isKnowledgeAssetVmPublishJobRequest, isFailedJob, - isClearableTerminalLiftJob, isOccupyingLifecycleJob, normalizePersistedLiftJobRequest, rawLiftRequestFromJobRequest, @@ -195,7 +192,7 @@ function resolveKnowledgeAssetVmPublishHandler( } export class TripleStoreAsyncLiftPublisher - implements VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, VmPublishTerminalJobClearer { + implements VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader { private static readonly claimQueues = new Map>(); // #1829 — dedicated per-lineageKey journal mutex, SEPARATE from claimQueues, so the // read-modify-write seq allocation is atomic without touching the claim lock (lock @@ -995,24 +992,17 @@ export class TripleStoreAsyncLiftPublisher await this.ensureGraph(); if (filter.status && filter.status !== 'failed') return 0; - // #1837 — reaccept (failed→accepted) is a terminal→active transition; it MUST be - // serialized with claimNext/enqueue AND with clearTerminalJob (which also runs under - // withClaimLock) so a by-id clear that read a job as clearable-failed cannot be swept - // after retry() flips it active. Without this lock the "a transitioning job cannot be - // swept" guarantee does not hold. - return this.withClaimLock(async () => { - let retried = 0; - for (const job of (await this.list({ status: 'failed' })).filter(isFailedJob)) { - if (!job.failure.retryable || job.retries.retryCount >= job.retries.maxRetries) continue; - // Jobs that failed with a recovery-phase resolution must go through recover(), - // not retry(), to avoid double-publishing if the original tx eventually lands. - if (job.failure.resolution === 'retry_recovery') continue; - - await this.reacceptFailedJob(job); - retried += 1; - } - return retried; - }); + let retried = 0; + for (const job of (await this.list({ status: 'failed' })).filter(isFailedJob)) { + if (!job.failure.retryable || job.retries.retryCount >= job.retries.maxRetries) continue; + // Jobs that failed with a recovery-phase resolution must go through recover(), + // not retry(), to avoid double-publishing if the original tx eventually lands. + if (job.failure.resolution === 'retry_recovery') continue; + + await this.reacceptFailedJob(job); + retried += 1; + } + return retried; } async clear(status: 'finalized' | 'failed'): Promise { @@ -1020,10 +1010,9 @@ export class TripleStoreAsyncLiftPublisher const jobs = await this.list({ status }); let cleared = 0; for (const job of jobs) { - // #1837 — single terminal-clear authority shared with clearTerminalJob: skips - // retry_recovery-failed jobs (a pending on-chain tx may still land). Behavior is - // identical to the prior inline `resolution === 'retry_recovery'` guard. - if (!isClearableTerminalLiftJob(job)) continue; + // Protect retry_recovery jobs — they may still have a pending on-chain tx + // that periodic recovery will finalize. Only explicit cancel can remove them. + if (status === 'failed' && isFailedJob(job) && job.failure.resolution === 'retry_recovery') continue; await this.releaseWalletLockForJob(job); await this.deleteJob(job.jobId); cleared += 1; @@ -1031,45 +1020,6 @@ export class TripleStoreAsyncLiftPublisher return cleared; } - /** - * #1837 — atomic by-exact-jobId terminal clear. Runs INSIDE withClaimLock so it is - * serialized against claimNext/enqueue/reaccept and retry() (the only terminal→active - * transitions) — a job transitioning cannot be swept, and concurrent clears are - * deterministic (exactly one 'cleared', the rest 'already_absent'). deleteJob is - * subject-scoped to the control-plane graph, so it never touches another job or the - * #1829 journal. Never throws / never mutates on a reject. - */ - async clearTerminalJob(jobId: string): Promise { - // Reject an empty OR SPARQL-unsafe jobId as malformed BEFORE building the jobSubject - // IRI — otherwise an attacker-controlled jobId (from the clear-job HTTP body) with a - // space/'>'/'{' could break the query out of `<…>` and surface as a 500/injection - // instead of the bounded outcome. - if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; - return this.withClaimLock(async () => { - await this.ensureGraph(); - const rows = expectBindings( - await this.store.query( - `SELECT ?payload WHERE { GRAPH <${this.graphUri}> { <${jobSubject(jobId)}> <${PAYLOAD_PREDICATE}> ?payload } }`, - ), - ); - if (rows.length === 0) return { outcome: 'already_absent' }; - // Parse defensively — a corrupt persisted payload must surface as rejected(malformed), - // never throw (parseJobPayload does an unguarded JSON.parse). - let job: LiftJob | null; - try { - job = this.parseJobPayload(rows[0]?.['payload']); - } catch { - return { outcome: 'rejected', reason: 'malformed' }; - } - if (job === null) return { outcome: 'rejected', reason: 'malformed' }; - if (!LIFT_JOB_STATES.includes(job.status)) return { outcome: 'rejected', reason: 'unknown' }; - if (!isClearableTerminalLiftJob(job)) return { outcome: 'rejected', reason: 'nonterminal' }; - await this.releaseWalletLockForJob(job); - await this.deleteJob(jobId); - return { outcome: 'cleared' }; - }); - } - private async ensureGraph(): Promise { if (this.graphEnsured) return; await this.store.createGraph(this.graphUri); diff --git a/packages/publisher/src/async-lift-publisher-types.ts b/packages/publisher/src/async-lift-publisher-types.ts index 84d8b118a2..ba46c59d9a 100644 --- a/packages/publisher/src/async-lift-publisher-types.ts +++ b/packages/publisher/src/async-lift-publisher-types.ts @@ -17,7 +17,6 @@ import type { PublishOptions, PublishResult } from './publisher.js'; import type { AsyncLiftPublishFailureInput } from './async-lift-publish-result.js'; import type { AsyncPreparedPublishPayload, LiftResolvedPublishSlice } from './async-lift-publish-options.js'; import type { WorkspacePublicSnapshotStore } from './workspace-snapshot-store.js'; -import type { TerminalJobClearOutcome } from './terminal-job-clear.js'; export class AsyncLiftJobConflictError extends Error { readonly code = 'ASYNC_LIFT_JOB_CONFLICT'; @@ -125,18 +124,6 @@ export interface VmPublishAdmissionJournalReader { readJournalByJob(jobId: string): Promise; } -/** - * #1837 — atomic by-jobId terminal cleanup. Segregated off the base contract (like the - * #1828/#1829 capabilities); a MUTATION/admin capability, not a query. Clears the exact - * job ONLY when it is in a native terminal state, rejects otherwise without mutation, - * and is idempotent for an absent job. Never broadens to other jobs. On the lift side - * this preserves the #1829 append-only journal by construction (subject-scoped delete - * in the control-plane graph only). - */ -export interface VmPublishTerminalJobClearer { - clearTerminalJob(jobId: string): Promise; -} - /** * #1828 — one-shot storage maintenance: (re)build the ephemeral intent index for * VM-publish jobs admitted before it existed. This is a boot-time repair, not a diff --git a/packages/publisher/src/async-lift-publisher-utils.ts b/packages/publisher/src/async-lift-publisher-utils.ts index 5866a51a3f..a21671ccc9 100644 --- a/packages/publisher/src/async-lift-publisher-utils.ts +++ b/packages/publisher/src/async-lift-publisher-utils.ts @@ -71,19 +71,6 @@ export function isFailedJob(job: LiftJob): job is PersistedFailedJob { return job.status === 'failed' && 'failure' in job; } -/** - * #1837 — the single terminal-clear authority, reused by both `clear(status)` (bulk) and - * `clearTerminalJob(jobId)` so they cannot drift. A job is clearable iff it is in a native - * terminal state (finalized|failed) AND is not a `retry_recovery`-failed job — those may - * still carry a pending on-chain tx that periodic recovery will finalize, so only explicit - * cancel removes them. A `retry_recovery`-failed job is therefore treated as - * NONTERMINAL-for-cleanup. - */ -export function isClearableTerminalLiftJob(job: LiftJob): boolean { - return isTerminalLiftJobState(job.status) - && !(isFailedJob(job) && job.failure.resolution === 'retry_recovery'); -} - /** * #1828 — whether a job still OCCUPIES its lifecycle subject: any non-terminal * state, or a failed job admission would still reaccept (retryable with retries diff --git a/packages/publisher/src/async-lift-publisher.ts b/packages/publisher/src/async-lift-publisher.ts index e9b71e1738..46c09b8978 100644 --- a/packages/publisher/src/async-lift-publisher.ts +++ b/packages/publisher/src/async-lift-publisher.ts @@ -14,12 +14,10 @@ export type { VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, - VmPublishTerminalJobClearer, IntentLookupInput, IntentLookupResult, JournalReadInput, JournalReadResult, } from './async-lift-publisher-types.js'; export { AsyncLiftJobConflictError } from './async-lift-publisher-types.js'; -export type { TerminalJobClearOutcome } from './terminal-job-clear.js'; export { TripleStoreAsyncLiftPublisher } from './async-lift-publisher-impl.js'; diff --git a/packages/publisher/src/async-promote-queue-impl.ts b/packages/publisher/src/async-promote-queue-impl.ts index 27cd1d6dec..8da0c91f16 100644 --- a/packages/publisher/src/async-promote-queue-impl.ts +++ b/packages/publisher/src/async-promote-queue-impl.ts @@ -18,7 +18,6 @@ */ import type { TripleStore } from '@origintrail-official/dkg-storage'; -import { isSafeClearJobId, type TerminalJobClearOutcome } from './terminal-job-clear.js'; import { ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, ASYNC_PROMOTE_QUEUE_MIN_AUTO_RECOVERABLE_FORMAT_VERSION, @@ -27,7 +26,6 @@ import { PROMOTE_JOB_STATES, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, - type PromoteTerminalJobClearer, type PromoteAttemptError, type PromoteCommitMarker, type PromoteCommitMarkerStep, @@ -50,12 +48,10 @@ import { comparePromoteJobs, defaultBackoffMs, expectBindings, - isTerminalPromoteJobState, jobSubject, literal, normalizePromoteAgentLane, parseJobPayload, - parseLiteral, promoteLaneConflictScope, promoteLaneScopesConflict, serializeJob, @@ -72,7 +68,7 @@ type PromoteConflictLookup = { laneScope: ReturnType; }; -export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue, PromoteTerminalJobClearer { +export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue { /** * Per-graph-URI mutex map. Serialises uniqueness-affecting mutations * callers can't both observe stale state and then persist conflicting @@ -603,57 +599,6 @@ export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue, PromoteT await this.store.flush?.(); } - // #1837 — subject-scoped record removal (the delete half of writeJob, no re-insert). - // All of a job's triples live under jobSubject(jobId) in the single control-plane - // graph, so this provably cannot touch another job or another graph. - private async deleteJob(jobId: string): Promise { - await this.store.deleteByPattern({ subject: jobSubject(jobId), graph: this.graphUri }); - await this.store.flush?.(); - } - - /** - * #1837 — atomic by-exact-jobId terminal clear. Runs INSIDE withMutationLock, which - * already serializes EVERY transition (incl. the only terminal→active path, recover() - * failed→queued), so a transitioning job cannot be swept and concurrent clears are - * deterministic — no new lock needed. Reads the denormalized state triple to split - * already_absent / unknown / malformed without a JSON.parse that collapses them. Never - * throws / never mutates on a reject. - */ - async clearTerminalJob(jobId: string): Promise { - // Reject an empty OR SPARQL-unsafe jobId as malformed before building the jobSubject - // IRI (defense-in-depth; the SWM route already pre-validates via decodePromoteJobId, - // but a direct agent.assertion.clearPromoteAsync caller must be bounded too). - if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; - return this.withMutationLock(async () => { - await this.ensureGraph(); - const stateRows = expectBindings( - await this.store.query( - `SELECT ?state WHERE { GRAPH <${this.graphUri}> { <${jobSubject(jobId)}> <${PROMOTE_STATE}> ?state } }`, - ), - ); - if (stateRows.length === 0) return { outcome: 'already_absent' }; - const rawState = stateRows[0]?.['state']; - // parseLiteral is JSON.parse — a corrupt/non-JSON state literal must become a - // bounded reject, never throw out of the method (matches the lift sibling's guard). - let state: string | undefined; - try { - state = rawState === undefined ? undefined : String(parseLiteral(rawState)); - } catch { - return { outcome: 'rejected', reason: 'unknown' }; - } - if (state === undefined || !(PROMOTE_JOB_STATES as readonly string[]).includes(state)) { - return { outcome: 'rejected', reason: 'unknown' }; - } - // State literal is a known enum value; the full payload must also parse (a corrupt - // payload with a valid state triple is malformed, not unknown). - const job = await this.readJob(jobId); - if (job === null) return { outcome: 'rejected', reason: 'malformed' }; - if (!isTerminalPromoteJobState(job.state)) return { outcome: 'rejected', reason: 'nonterminal' }; - await this.deleteJob(jobId); - return { outcome: 'cleared' }; - }); - } - private assertLeaseHeld(job: PromoteJob, claimToken: string): void { if (job.state !== 'running') { throw new PromoteJobLeaseError(job.jobId, `job is in state '${job.state}', not 'running'`); diff --git a/packages/publisher/src/async-promote-queue-types.ts b/packages/publisher/src/async-promote-queue-types.ts index 438b04c55a..e7d53e6916 100644 --- a/packages/publisher/src/async-promote-queue-types.ts +++ b/packages/publisher/src/async-promote-queue-types.ts @@ -14,8 +14,6 @@ * differences from `AsyncLiftPublisher`. */ -import type { TerminalJobClearOutcome } from './terminal-job-clear.js'; - export const PROMOTE_JOB_STATES = [ 'queued', 'running', @@ -221,18 +219,6 @@ export interface AsyncPromoteQueue { getStats(): Promise; } -/** - * #1837 — atomic by-exact-jobId terminal cleanup for the SWM share (promote) queue. - * Segregated off the base contract (mirrors the publisher-side VmPublishTerminalJobClearer); - * a mutation/admin capability. Clears the exact job ONLY when in a native terminal state - * ({succeeded, failed}, no carve-out — nothing background re-drives a terminal promote row), - * rejects otherwise without mutation, idempotent for an absent job, never broadens to other - * jobs. Reuses the shared TerminalJobClearOutcome for symmetry with the lift clearer. - */ -export interface PromoteTerminalJobClearer { - clearTerminalJob(jobId: string): Promise; -} - export interface AsyncPromoteQueueConfig { /** Defaults to `urn:dkg:promote-queue:control-plane` per RFC §4.1. */ graphUri?: string; diff --git a/packages/publisher/src/async-promote-queue-utils.ts b/packages/publisher/src/async-promote-queue-utils.ts index a9e98ed288..457269c05a 100644 --- a/packages/publisher/src/async-promote-queue-utils.ts +++ b/packages/publisher/src/async-promote-queue-utils.ts @@ -58,20 +58,6 @@ export const ACTIVE_PROMOTE_STATES: readonly PromoteJobState[] = [ 'failed_retrying', ]; -/** - * #1837 — native terminal states for a by-jobId terminal clear. Unlike the lift queue - * there is NO carve-out: nothing background re-drives a terminal promote row (no on-chain - * tx; recover() failed→queued is manual-only + locked; reconcileExpiredRunning touches - * only 'running'; conflict detection gates on ACTIVE states), so both terminal states are - * uniformly clearable — a `requiresManualInspection` (partial-ambiguity) failed row - * included (data-safe: nothing reads a removed row). - */ -export const TERMINAL_PROMOTE_JOB_STATES: readonly PromoteJobState[] = ['succeeded', 'failed']; - -export function isTerminalPromoteJobState(state: PromoteJobState): boolean { - return TERMINAL_PROMOTE_JOB_STATES.includes(state); -} - export function jobSubject(jobId: string): string { return `urn:dkg:promote-queue:job:${jobId}`; } diff --git a/packages/publisher/src/async-promote-queue.ts b/packages/publisher/src/async-promote-queue.ts index 7554ddcc73..f878cbd494 100644 --- a/packages/publisher/src/async-promote-queue.ts +++ b/packages/publisher/src/async-promote-queue.ts @@ -14,7 +14,6 @@ export type { PromoteRequest, PromoteResult, PromoteStats, - PromoteTerminalJobClearer, } from './async-promote-queue-types.js'; export { ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index c917e5f5fe..c44350641c 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -267,17 +267,11 @@ export { type VmPublishIntentRecoveryPublisher, type VmPublishIntentIndexBackfiller, type VmPublishAdmissionJournalReader, - type VmPublishTerminalJobClearer, - type TerminalJobClearOutcome, type IntentLookupInput, type IntentLookupResult, type JournalReadInput, type JournalReadResult, } from './async-lift-publisher.js'; -export { - SAFE_CLEAR_JOB_ID_PATTERN, - SAFE_CLEAR_JOB_ID_MAX_LENGTH, -} from './terminal-job-clear.js'; export { TripleStoreAsyncPromoteQueue, ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, @@ -300,7 +294,6 @@ export { type PromoteRequest, type PromoteResult, type PromoteStats, - type PromoteTerminalJobClearer, } from './async-promote-queue.js'; export { AsyncLiftRunner, diff --git a/packages/publisher/src/terminal-job-clear.ts b/packages/publisher/src/terminal-job-clear.ts deleted file mode 100644 index 50c11cc6be..0000000000 --- a/packages/publisher/src/terminal-job-clear.ts +++ /dev/null @@ -1,36 +0,0 @@ -// #1837 — shared contract for the atomic by-exact-jobId TERMINAL clear, owned by NEITHER -// queue (lift nor promote) so a generic admin-clear result is not coupled to one -// implementation family's type module. - -/** - * Bounded outcome of a terminal clear, shared by the lift publisher and the SWM promote - * queue. The control method owns every reason INCLUDING `malformed` (a corrupt persisted - * payload — or an unsafe jobId — is only detectable inside the method, never at the HTTP - * route). Never throws and never mutates on a reject. `already_absent` is a SUCCESS - * (idempotent repeat), distinct from a rejection. - */ -export type TerminalJobClearOutcome = - | { readonly outcome: 'cleared' } - | { readonly outcome: 'already_absent' } - | { readonly outcome: 'rejected'; readonly reason: 'nonterminal' | 'unknown' | 'malformed' }; - -// Producer grammar for a queue jobId (crypto.randomUUID(), or test 'job-N'): starts -// alphanumeric, then alnum/'.'/'_'/':'/'-'. This is IRI-safe — it excludes every character -// that could break out of the `<…>` IRI in a control-plane SPARQL query (spaces, '<' '>' -// '"' '{' '}' '|' '^' '`', control chars). This is the SINGLE authoritative job-id grammar: -// the CLI route validator (`validatePromoteJobId`) imports it rather than re-declaring the -// regex, so route-level and control-plane job-id acceptance cannot drift. -export const SAFE_CLEAR_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; -export const SAFE_CLEAR_JOB_ID_MAX_LENGTH = 256; - -/** - * True iff `jobId` is safe to interpolate into a control-plane SPARQL IRI. A by-id clear - * MUST reject an unsafe jobId as `malformed` BEFORE building the query, so an - * attacker-controlled jobId (from the clear-job HTTP body) yields a bounded reject rather - * than a query syntax error / injection / 500. - */ -export function isSafeClearJobId(jobId: string): boolean { - return ( - jobId.length > 0 && jobId.length <= SAFE_CLEAR_JOB_ID_MAX_LENGTH && SAFE_CLEAR_JOB_ID_PATTERN.test(jobId) - ); -} diff --git a/packages/publisher/test/async-lift-terminal-clear.test.ts b/packages/publisher/test/async-lift-terminal-clear.test.ts deleted file mode 100644 index 2c347f1c9d..0000000000 --- a/packages/publisher/test/async-lift-terminal-clear.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; -import { TripleStoreAsyncLiftPublisher, type AsyncLiftPublisherConfig } from '../src/index.js'; -import { DEFAULT_CONTROL_GRAPH_URI, jobSubject, serializeJob } from '../src/async-lift-control-plane.js'; - -// #1837 — atomic by-exact-jobId TERMINAL clear for the async publisher (lift) queue. -describe('#1837 lift publisher clearTerminalJob', () => { - let now = 1_000; - let ids = 0; - let store: OxigraphStore; - - beforeEach(() => { - now = 1_000; - ids = 0; - store = new OxigraphStore(); - }); - - function createPublisher(config: Omit = {}): TripleStoreAsyncLiftPublisher { - return new TripleStoreAsyncLiftPublisher(store, { - now: () => ++now, - idGenerator: () => `job-${++ids}`, - journalWrites: true, - ...config, - }); - } - - function kaVmPublishRequest(overrides: Record = {}) { - const authorAddress = '0x1111111111111111111111111111111111111111'; - const kaNumber = 7n; - const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; - return { - contextGraphId: 'music-social', name: 'albums', shareOperationId: 'share-op-1', - roots: [] as string[], contentScopeVersion: 2 as const, kaUal, assertionVersion: '1', - publicTripleCount: 2, privateTripleCount: 0, - seal: { - merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - authorAddress: authorAddress as `0x${string}`, - signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, - schemeVersion: 1, - reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, - }, - sealChainId: '31337' as `${bigint}`, sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, - sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - intentKey: `sha256:${'ab'.repeat(32)}`, wmCurrentAssertion: '12'.repeat(32), swmCurrentAssertion: '12'.repeat(32), - kaNumber: kaNumber.toString(), reservedUal: kaUal, ...overrides, - }; - } - const bx = { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }; - const inc = { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }; - - async function driveToValidated(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { - const jobId = await p.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest(o)); - await p.claimNext('wallet-1'); - await p.update(jobId, 'validated', { - validation: { canonicalRoots: [], canonicalRootMap: {}, swmQuadCount: 2, authorityProofRef: 'knowledge-asset-lifecycle', transitionType: 'CREATE' }, - }); - return jobId; - } - async function driveToFinalized(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { - const jobId = await driveToValidated(p, o); - await p.update(jobId, 'broadcast', { broadcast: bx }); - await p.update(jobId, 'included', { broadcast: bx, inclusion: inc }); - await p.update(jobId, 'finalized', { broadcast: bx, inclusion: inc, finalization: { mode: 'local' } }); - return jobId; - } - // Terminal, non-retryable (tx_reverted → fail_job): clearable, retry() won't touch it. - async function driveToTerminalFailed(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { - const jobId = await driveToValidated(p, o); - await p.update(jobId, 'broadcast', { broadcast: bx }); - await p.recordPublishFailure(jobId, { error: new Error('tx reverted on chain'), failedFromState: 'broadcast', errorPayloadRef: 'urn:err:1' }); - return jobId; - } - it('clears an exact finalized job (cleared); no other job changes', async () => { - const p = createPublisher(); - const target = await driveToFinalized(p, { name: 'a' }); - const other = await driveToFinalized(p, { name: 'b' }); - expect(await p.clearTerminalJob(target)).toEqual({ outcome: 'cleared' }); - expect(await p.getStatus(target)).toBeNull(); - expect((await p.getStatus(other))?.status).toBe('finalized'); // untouched - }); - - it('clears an exact terminal (non-retryable) failed job', async () => { - const p = createPublisher(); - const jobId = await driveToTerminalFailed(p); - expect((await p.getStatus(jobId))?.status).toBe('failed'); - expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); - expect(await p.getStatus(jobId)).toBeNull(); - }); - - it('rejects an accepted (queued) job as nonterminal without mutation', async () => { - const p = createPublisher(); - const accepted = await p.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); - expect(await p.clearTerminalJob(accepted)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await p.getStatus(accepted))?.status).toBe('accepted'); - }); - - it('rejects a validated job as nonterminal without mutation', async () => { - const p = createPublisher(); - const validated = await driveToValidated(p); - expect(await p.clearTerminalJob(validated)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await p.getStatus(validated))?.status).toBe('validated'); - }); - - it('rejects a broadcast job as nonterminal without mutation', async () => { - const p = createPublisher(); - const broadcast = await driveToValidated(p); - await p.update(broadcast, 'broadcast', { broadcast: bx }); - expect(await p.clearTerminalJob(broadcast)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await p.getStatus(broadcast))?.status).toBe('broadcast'); - }); - - it('rejects a retry_recovery-protected failed job as nonterminal (a pending tx may still land)', async () => { - // retry_recovery is a raw-lift-only recovery resolution (KA-VM canRetryFailedRecovery - // is false), so inject a synthetic retry_recovery-failed job to exercise the guard the - // clearer shares with bulk clear(). Start from a real terminal-failed job and rewrite - // its persisted resolution. - const p = createPublisher(); - const jobId = await driveToTerminalFailed(p); - const job = await p.getStatus(jobId); - if (!job || !('failure' in job)) throw new Error('expected a failed job'); - const mutated = { ...job, failure: { ...job.failure, resolution: 'retry_recovery' } }; - await store.deleteByPattern({ subject: jobSubject(jobId), graph: DEFAULT_CONTROL_GRAPH_URI }); - await store.insert(serializeJob(mutated as typeof job, DEFAULT_CONTROL_GRAPH_URI)); - expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await p.getStatus(jobId))?.status).toBe('failed'); // unchanged - }); - - it('is idempotent: absent / already-cleared → already_absent', async () => { - const p = createPublisher(); - expect(await p.clearTerminalJob('never-existed')).toEqual({ outcome: 'already_absent' }); - const jobId = await driveToFinalized(p); - expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); - expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'already_absent' }); - }); - - it('rejects an empty or SPARQL-unsafe jobId as malformed without querying/mutating', async () => { - const p = createPublisher(); - expect(await p.clearTerminalJob('')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - expect(await p.clearTerminalJob(' ')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - // #1883 review (🔴): a jobId that would break out of the <…> SPARQL IRI must be a - // bounded malformed reject, never a query error / injection. - expect(await p.clearTerminalJob('bad id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - expect(await p.clearTerminalJob('bad>id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - expect(await p.clearTerminalJob('a{ b')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - }); - - it('preserves the #1829 journal: clearing a terminal job leaves its journal lineage readable', async () => { - const p = createPublisher(); - const jobId = await driveToFinalized(p); - expect((await p.readJournalByJob(jobId)).entries.length).toBeGreaterThan(0); - expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); - expect(await p.getStatus(jobId)).toBeNull(); // gone from control plane - const journal = await p.readJournalByJob(jobId); - expect(journal.entries.length).toBeGreaterThan(0); // lineage survives the clear - expect(journal.entries.some((e) => e.kind === 'finalized')).toBe(true); - }); - - it('no-sweep: a clearable-failed job reaccepted by concurrent retry() is never deleted while active', async () => { - // maxRetries:1 + a retryable failure (rpc_unavailable/reset_to_accepted) → the job is - // BOTH clearable (terminal failed, not retry_recovery) AND reacceptable by retry(). - // clearTerminalJob and retry() both run under withClaimLock, so they serialize: - // whichever wins, an ACTIVE job is never swept. - const p = createPublisher({ maxRetries: 1 }); - const jobId = await driveToValidated(p, { name: 'race' }); - await p.update(jobId, 'broadcast', { broadcast: bx }); - await p.recordPublishFailure(jobId, { error: new Error('rpc temporarily down'), failedFromState: 'broadcast', errorPayloadRef: 'urn:err:2' }); - const failed = await p.getStatus(jobId); - expect(failed?.status).toBe('failed'); - expect(failed && 'failure' in failed && failed.failure.retryable).toBe(true); - - const [clearOutcome, retried] = await Promise.all([p.clearTerminalJob(jobId), p.retry({ status: 'failed' })]); - const after = await p.getStatus(jobId); - if (clearOutcome.outcome === 'cleared') { - // clear won: job gone, retry could not have reaccepted it. - expect(after).toBeNull(); - expect(retried).toBe(0); - } else { - // retry won: job is active ('accepted'); clear must have rejected it, NOT deleted it. - expect(clearOutcome).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect(after?.status).toBe('accepted'); - expect(retried).toBe(1); - } - }); - - it('concurrent clears of one terminal job are deterministic: one cleared, rest already_absent', async () => { - const p = createPublisher(); - const target = await driveToFinalized(p, { name: 'a' }); - const other = await driveToFinalized(p, { name: 'b' }); - const results = await Promise.all([p.clearTerminalJob(target), p.clearTerminalJob(target), p.clearTerminalJob(target)]); - expect(results.filter((r) => r.outcome === 'cleared')).toHaveLength(1); - expect(results.filter((r) => r.outcome === 'already_absent')).toHaveLength(2); - expect((await p.getStatus(other))?.status).toBe('finalized'); // never affected - }); -}); diff --git a/packages/publisher/test/async-promote-terminal-clear.test.ts b/packages/publisher/test/async-promote-terminal-clear.test.ts deleted file mode 100644 index 2d77f74a88..0000000000 --- a/packages/publisher/test/async-promote-terminal-clear.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; -import { - type AsyncPromoteQueue, - type AsyncPromoteQueueConfig, - type PromoteRequest, - type PromoteTerminalJobClearer, -} from '../src/async-promote-queue-types.js'; -import { TripleStoreAsyncPromoteQueue } from '../src/async-promote-queue-impl.js'; -import { DEFAULT_PROMOTE_CONTROL_GRAPH_URI, PROMOTE_STATE, jobSubject, literal } from '../src/async-promote-queue-utils.js'; - -// #1837 — atomic by-exact-jobId TERMINAL clear for the SWM promote queue. -describe('#1837 promote queue clearTerminalJob', () => { - let store: OxigraphStore; - let now: number; - let idCounter: number; - - beforeEach(() => { - store = new OxigraphStore(); - now = 1_000_000; - idCounter = 0; - }); - - function createQueue(overrides: Partial = {}): AsyncPromoteQueue & PromoteTerminalJobClearer { - return new TripleStoreAsyncPromoteQueue(store, { - now: () => now, - idGenerator: () => `job-${++idCounter}`, - ...overrides, - }) as TripleStoreAsyncPromoteQueue; - } - - function makeRequest(overrides: Partial = {}): PromoteRequest { - return { contextGraphId: 'graphify', subGraphName: 'code', assertionName: 'shard-1', entities: 'all', ...overrides }; - } - - async function enqueueSucceeded(queue: AsyncPromoteQueue, req?: Partial): Promise { - const jobId = await queue.enqueue(makeRequest(req)); - const claimed = await queue.claimNext('worker-1'); - const token = claimed!.lease!.claimToken; - // Worker records commit progress — required before succeed(). - await queue.recordCommitMarker(jobId, token, 'swmInserted'); - await queue.recordCommitMarker(jobId, token, 'wmCleaned'); - await queue.recordCommitMarker(jobId, token, 'lifecycleStamped'); - await queue.recordCommitMarker(jobId, token, 'gossiped'); - await queue.succeed(jobId, token, { promotedCount: 1, succeededAt: now }); - return jobId; - } - - async function enqueueTerminalFailed(queue: AsyncPromoteQueue, req?: Partial): Promise { - const jobId = await queue.enqueue(makeRequest(req)); - const claimed = await queue.claimNext('worker-1'); - await queue.fail(jobId, claimed!.lease!.claimToken, { - message: 'permanent', retryable: false, classification: 'permanent', recordedAt: now, - }); - return jobId; - } - - it('clears an exact succeeded job (cleared); no other job changes', async () => { - const queue = createQueue(); - const target = await enqueueSucceeded(queue, { assertionName: 'a' }); - const other = await enqueueSucceeded(queue, { assertionName: 'b' }); - expect(await queue.clearTerminalJob(target)).toEqual({ outcome: 'cleared' }); - expect(await queue.getStatus(target)).toBeNull(); - expect((await queue.getStatus(other))?.state).toBe('succeeded'); // untouched - }); - - it('clears an exact terminal-failed job (incl. no retry_recovery carve-out)', async () => { - const queue = createQueue(); - const jobId = await enqueueTerminalFailed(queue); - expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); - expect(await queue.getStatus(jobId)).toBeNull(); - }); - - it('rejects a queued job as nonterminal without mutation', async () => { - const queue = createQueue(); - const queued = await queue.enqueue(makeRequest()); - expect(await queue.clearTerminalJob(queued)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await queue.getStatus(queued))?.state).toBe('queued'); - }); - - it('rejects a running job as nonterminal without mutation', async () => { - const queue = createQueue(); - const runningId = await queue.enqueue(makeRequest()); - await queue.claimNext('worker-1'); - expect((await queue.getStatus(runningId))?.state).toBe('running'); - expect(await queue.clearTerminalJob(runningId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await queue.getStatus(runningId))?.state).toBe('running'); - }); - - it('rejects a failed_retrying job as nonterminal without mutation', async () => { - const queue = createQueue({ backoff: () => 10_000 }); - const retryingId = await queue.enqueue(makeRequest()); - const claimed = await queue.claimNext('worker-1'); - await queue.fail(retryingId, claimed!.lease!.claimToken, { - message: 'transient', retryable: true, classification: 'transient', recordedAt: now, - }); - expect((await queue.getStatus(retryingId))?.state).toBe('failed_retrying'); - expect(await queue.clearTerminalJob(retryingId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); - expect((await queue.getStatus(retryingId))?.state).toBe('failed_retrying'); - }); - - it('is idempotent: an absent / already-cleared job returns already_absent', async () => { - const queue = createQueue(); - expect(await queue.clearTerminalJob('never-existed')).toEqual({ outcome: 'already_absent' }); - const jobId = await enqueueSucceeded(queue); - expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); - expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'already_absent' }); // repeat - }); - - it('rejects an empty or SPARQL-unsafe jobId as malformed without querying/mutating', async () => { - const queue = createQueue(); - expect(await queue.clearTerminalJob('')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - expect(await queue.clearTerminalJob(' ')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - expect(await queue.clearTerminalJob('bad id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - expect(await queue.clearTerminalJob('bad>id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); - }); - - // #1883 review (🟡): a state triple present but not a recognized enum value must be a - // bounded reject (unknown), never throw — and the parse itself is now try/catch-guarded. - it('rejects a subject whose state is not a known enum value as unknown, without throwing', async () => { - const queue = createQueue(); - await store.insert([ - { subject: jobSubject('bogus-1'), predicate: PROMOTE_STATE, object: literal('bogus_state'), graph: DEFAULT_PROMOTE_CONTROL_GRAPH_URI }, - ]); - await expect(queue.clearTerminalJob('bogus-1')).resolves.toEqual({ outcome: 'rejected', reason: 'unknown' }); - }); - - it('concurrent clears of one terminal job are deterministic: one cleared, rest already_absent, no other job affected', async () => { - const queue = createQueue(); - const target = await enqueueSucceeded(queue, { assertionName: 'a' }); - const other = await enqueueSucceeded(queue, { assertionName: 'b' }); - const results = await Promise.all([ - queue.clearTerminalJob(target), queue.clearTerminalJob(target), queue.clearTerminalJob(target), - ]); - expect(results.filter((r) => r.outcome === 'cleared')).toHaveLength(1); - expect(results.filter((r) => r.outcome === 'already_absent')).toHaveLength(2); - expect((await queue.getStatus(other))?.state).toBe('succeeded'); // never affected - }); -}); diff --git a/packages/publisher/vitest.unit.config.ts b/packages/publisher/vitest.unit.config.ts index c8f3e3c822..5eb6e0ec07 100644 --- a/packages/publisher/vitest.unit.config.ts +++ b/packages/publisher/vitest.unit.config.ts @@ -22,8 +22,6 @@ export default defineConfig({ 'test/async-lift-intent-lookup.test.ts', 'test/async-lift-publish-options.test.ts', 'test/async-promote-queue.test.ts', - 'test/async-lift-terminal-clear.test.ts', - 'test/async-promote-terminal-clear.test.ts', 'test/lift-job-types.test.ts', 'test/multi-root-token-rows.test.ts', 'test/access-verification.test.ts', From ae48423aa025d2fedc019a841d8e5409f91add31 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:15:13 +0200 Subject: [PATCH 173/292] Revert "Merge pull request #1895 from OriginTrail/fix/catchup-backpressure-retry" This reverts commit 2bdc5fd5f10514cc03f087f1809f2099c3f80452, reversing changes made to 2e53f74ae38fe89021c30e4476d70dfa73477a05. --- packages/agent/src/dkg-agent-lifecycle.ts | 76 +---------- packages/agent/src/index.ts | 6 - .../src/sync/catchup-backpressure-retry.ts | 39 ------ .../test/catchup-backpressure-retry.test.ts | 44 ------ .../test/sync-requester-priority.test.ts | 41 +----- .../cli/src/catchup-runner-worker-impl.ts | 21 +-- packages/cli/src/catchup-runner.ts | 18 +-- .../test/catchup-runner-worker-impl.test.ts | 125 +++--------------- 8 files changed, 31 insertions(+), 339 deletions(-) delete mode 100644 packages/agent/src/sync/catchup-backpressure-retry.ts delete mode 100644 packages/agent/test/catchup-backpressure-retry.test.ts diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 723138fda9..28860b2c2b 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -257,7 +257,6 @@ import { import { runSyncOnConnect, SyncOnConnectPostSyncError, type SyncOnConnectOutcome, type SyncOnConnectPeerOutcome } from './sync/on-connect/sync-on-connect.js'; import { mapWithConcurrency } from './map-with-concurrency.js'; import { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from './sync/catchup-concurrency.js'; -import { retryCatchupPlaneOnBackpressure } from './sync/catchup-backpressure-retry.js'; import { classifyDurableProgress } from './sync/durable-progress.js'; import { getSyncBackpressureSnapshot, @@ -608,16 +607,12 @@ function contextGraphCatchupSingleFlightKey(params: { includeSharedMemory: boolean; maxPeers?: number; peerRotationKey?: string; - priority?: number; - retryDeferredBackpressure?: boolean; }): string { return syncSingleFlightKey('context-graph-catchup', { contextGraphId: params.contextGraphId, includeSharedMemory: params.includeSharedMemory, maxPeers: normalizedCatchupMaxPeers(params.maxPeers), peerRotationKey: params.peerRotationKey ?? null, - priority: params.priority ?? null, - retryDeferredBackpressure: params.retryDeferredBackpressure === true, }); } @@ -631,7 +626,6 @@ function durableSyncSingleFlightKey(params: { hasAccessDeniedCallback: boolean; hasSinceBatchIdResolver: boolean; exactAssetUals?: readonly string[]; - priority?: number; }): string | null { if (params.hasPhaseCallback || params.hasAccessDeniedCallback || params.hasSinceBatchIdResolver) { return null; @@ -643,7 +637,6 @@ function durableSyncSingleFlightKey(params: { totalTimeoutMs: params.totalTimeoutMs, syncAgentsMeta: params.syncAgentsMeta, exactAssetUals: params.exactAssetUals ?? null, - priority: params.priority ?? null, }); } @@ -653,7 +646,6 @@ function sharedMemorySyncSingleFlightKey(params: { stopOnBackoffWorthyFailure?: boolean; publicContextGraphIds: readonly string[]; privateRecoverFromCurator: readonly string[]; - priority?: number; }): string { return syncSingleFlightKey('shared-memory-sync', { remotePeerId: params.remotePeerId, @@ -661,7 +653,6 @@ function sharedMemorySyncSingleFlightKey(params: { stopOnBackoffWorthyFailure: params.stopOnBackoffWorthyFailure === true, publicContextGraphIds: params.publicContextGraphIds, privateRecoverFromCurator: params.privateRecoverFromCurator, - priority: params.priority ?? null, }); } @@ -4051,13 +4042,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { // the same flag that makes it a responder. Same signal SC4 uses to advertise the protocol. if (asChangelogReader(this.store) !== null && contextGraphIds.length > 0) { try { - const lane = await this.runChangelogLane( - ctx, - remotePeerId, - contextGraphIds, - onAccessDenied, - options?.priority, - ); + const lane = await this.runChangelogLane(ctx, remotePeerId, contextGraphIds, onAccessDenied); changelogResult = lane.result; legacyContextGraphIds = lane.remainingLegacyCgs; if ((changelogResult.deferredBackpressure ?? 0) > 0) return changelogResult; @@ -4157,7 +4142,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { hasAccessDeniedCallback: Boolean(onAccessDenied), hasSinceBatchIdResolver: Boolean(sinceBatchIdFor), exactAssetUals: options?.exactAssetUals, - priority: options?.priority, }); return singleFlightKey ? runSyncSingleFlight(this, singleFlightKey, runSync) : runSync(); } @@ -4309,7 +4293,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { remotePeerId: string, contextGraphIds: string[], onAccessDenied?: (contextGraphId: string) => void, - priority?: number, ): Promise<{ result: DurableSyncResult; remainingLegacyCgs: string[] }> { const peerProtocols = await this.getPeerProtocols(remotePeerId); if (!peerProtocols.includes(PROTOCOL_SYNC_CHANGELOG)) { @@ -4350,7 +4333,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { item.lane, item.operationId, run, - priority, ), merge: mergeDurableSyncResults, markDeferred: (summary) => ({ @@ -4779,8 +4761,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { options?: { stopOnBackoffWorthyFailure?: boolean; sharedMemorySyncPlan?: SharedMemorySyncContextGraphPlan; - /** Admission override for foreground catch-up. */ - priority?: number; }, ): Promise { const ctx = createOperationContext('sync'); @@ -4847,7 +4827,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { stopOnBackoffWorthyFailure, publicContextGraphIds, privateRecoverFromCurator, - priority: options?.priority, }); const runSync = async (): Promise => { @@ -5001,7 +4980,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { item.lane, item.operationId, run, - options?.priority, ), merge: mergeSharedMemorySyncResults, markDeferred: (summary) => ({ @@ -5106,15 +5084,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { */ async syncContextGraphFromConnectedPeers(this: DKGAgent, contextGraphId: string, - options?: { - includeSharedMemory?: boolean; - maxPeers?: number; - peerRotationKey?: string; - /** Admission override used by explicit foreground catch-up callers. */ - priority?: number; - /** Retry only locally-deferred planes; completed planes are not rerun. */ - retryDeferredBackpressure?: boolean; - }, + options?: { includeSharedMemory?: boolean; maxPeers?: number; peerRotationKey?: string }, ): Promise<{ /** Ordered connected peers before optional maxPeers windowing. */ connectedPeers: number; @@ -5167,8 +5137,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { includeSharedMemory, maxPeers: options?.maxPeers, peerRotationKey: options?.peerRotationKey, - priority: options?.priority, - retryDeferredBackpressure: options?.retryDeferredBackpressure, }); return runSyncSingleFlight(this, singleFlightKey, async (): Promise => { @@ -5218,8 +5186,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { ); return this.runCatchupOverPeers(contextGraphId, includeSharedMemory, peers, { totalPeers: orderedPeers.length, - priority: options?.priority, - retryDeferredBackpressure: options?.retryDeferredBackpressure, }); }); } @@ -5310,11 +5276,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { contextGraphId: string, includeSharedMemory: boolean, peers: Array<{ toString(): string }>, - stats?: { - totalPeers?: number; - priority?: number; - retryDeferredBackpressure?: boolean; - }, + stats?: { totalPeers?: number }, ): Promise<{ /** Ordered connected peers before optional caller windowing. */ connectedPeers: number; @@ -5470,37 +5432,13 @@ export class LifecycleSyncMethods extends DKGAgentBase { syncCapable, CATCHUP_MAX_CONCURRENT_PEER_SYNCS, async (remotePeerId) => { - const runDurable = () => this.syncFromPeerDetailed( + const durable = await this.syncFromPeerDetailed( remotePeerId, [contextGraphId], - undefined, - undefined, - undefined, - stats?.priority === undefined ? undefined : { priority: stats.priority }, - ); - const durable = await ( - stats?.retryDeferredBackpressure - ? retryCatchupPlaneOnBackpressure(runDurable) - : runDurable() ).catch(emptyDurable); - - // SWM authorization/materialization depends on durable metadata. If - // durable admission remains deferred, do not manufacture a premature - // SWM denial. Once durable completes, retry only SWM; a successful VM - // plane is never fetched again just because SWM hit local pressure. - let shared: SharedMemorySyncResult | null = null; - if (includeSharedMemory && (durable.deferredBackpressure ?? 0) === 0) { - const runShared = () => this.syncSharedMemoryFromPeerDetailed( - remotePeerId, - [contextGraphId], - stats?.priority === undefined ? undefined : { priority: stats.priority }, - ); - shared = await ( - stats?.retryDeferredBackpressure - ? retryCatchupPlaneOnBackpressure(runShared) - : runShared() - ).catch(emptyShared); - } + const shared = includeSharedMemory + ? await this.syncSharedMemoryFromPeerDetailed(remotePeerId, [contextGraphId]).catch(emptyShared) + : null; return { durable, shared }; }, ); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 61c80cab4f..a8496bf5ea 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -269,12 +269,6 @@ export { // deep-importing the compiled `dist/` module. export { mapWithConcurrency } from './map-with-concurrency.js'; export { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from './sync/catchup-concurrency.js'; -export { - CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS, - FOREGROUND_CATCHUP_SYNC_PRIORITY, - retryCatchupPlaneOnBackpressure, - type CatchupBackpressureResult, -} from './sync/catchup-backpressure-retry.js'; export { classifyDurableProgress, type DurableProgressClassification, diff --git a/packages/agent/src/sync/catchup-backpressure-retry.ts b/packages/agent/src/sync/catchup-backpressure-retry.ts deleted file mode 100644 index 7b345a1b8d..0000000000 --- a/packages/agent/src/sync/catchup-backpressure-retry.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * User-requested catch-up must outrank autonomous exact-VM repair (priority - * 1_000) and ordinary background sync (priority 0). This lets a subscribe or - * explicit catch-up displace queued background work instead of being marked - * deferred before it has fetched a byte. - */ -export const FOREGROUND_CATCHUP_SYNC_PRIORITY = 2_000; - -/** - * Admission can still race another foreground catch-up. Retry that local-only - * outcome briefly; transport, authorization, timeout, and integrity failures - * are deliberately not retried here. - */ -export const CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS = [100, 250, 500] as const; - -export interface CatchupBackpressureResult { - deferredBackpressure?: number; -} - -export async function retryCatchupPlaneOnBackpressure( - run: () => Promise, - options?: { - delaysMs?: readonly number[]; - wait?: (delayMs: number) => Promise; - }, -): Promise { - const delaysMs = options?.delaysMs ?? CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS; - const wait = options?.wait ?? ((delayMs: number) => new Promise((resolve) => { - setTimeout(resolve, delayMs); - })); - - let result = await run(); - for (const delayMs of delaysMs) { - if ((result.deferredBackpressure ?? 0) === 0) break; - await wait(delayMs); - result = await run(); - } - return result; -} diff --git a/packages/agent/test/catchup-backpressure-retry.test.ts b/packages/agent/test/catchup-backpressure-retry.test.ts deleted file mode 100644 index 311811f434..0000000000 --- a/packages/agent/test/catchup-backpressure-retry.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS, - retryCatchupPlaneOnBackpressure, -} from '../src/sync/catchup-backpressure-retry.js'; - -describe('retryCatchupPlaneOnBackpressure', () => { - it('retries only the local scheduler deferral result', async () => { - const run = vi.fn() - .mockResolvedValueOnce({ deferredBackpressure: 1, marker: 'deferred' }) - .mockResolvedValueOnce({ deferredBackpressure: 0, marker: 'complete' }); - const waits: number[] = []; - - const result = await retryCatchupPlaneOnBackpressure(run, { - delaysMs: [3, 5], - wait: async (delayMs) => { waits.push(delayMs); }, - }); - - expect(result).toEqual({ deferredBackpressure: 0, marker: 'complete' }); - expect(run).toHaveBeenCalledTimes(2); - expect(waits).toEqual([3]); - }); - - it('returns the final deferred result after the bounded retry budget', async () => { - const run = vi.fn(async () => ({ deferredBackpressure: 1 })); - - const result = await retryCatchupPlaneOnBackpressure(run, { - wait: async () => {}, - }); - - expect(result.deferredBackpressure).toBe(1); - expect(run).toHaveBeenCalledTimes(CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS.length + 1); - }); - - it('does not retry a clean result', async () => { - const run = vi.fn(async () => ({ deferredBackpressure: 0 })); - - await retryCatchupPlaneOnBackpressure(run, { - wait: async () => { throw new Error('must not wait'); }, - }); - - expect(run).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/agent/test/sync-requester-priority.test.ts b/packages/agent/test/sync-requester-priority.test.ts index 43c451a50b..712507dbc8 100644 --- a/packages/agent/test/sync-requester-priority.test.ts +++ b/packages/agent/test/sync-requester-priority.test.ts @@ -54,11 +54,7 @@ function durableContext(contextGraphIds: string[]) { function lifecycleAgent( priorities: Record, - runAdmission: ( - contextGraphId: string, - work: () => Promise, - priorityOverride?: number, - ) => Promise, + runAdmission: (contextGraphId: string, work: () => Promise) => Promise, ) { return { config: { syncContextGraphPriorities: priorities }, @@ -99,8 +95,7 @@ function lifecycleAgent( _lane: string, _label: string, work: () => Promise, - priorityOverride?: number, - ) => runAdmission(contextGraphId, work, priorityOverride), + ) => runAdmission(contextGraphId, work), log: { info: noop, warn: noop, debug: noop }, }; } @@ -126,27 +121,6 @@ describe('requester per-CG priority admission', () => { expect(admissions).toEqual(['high', 'default', 'low']); }); - it('passes a foreground durable priority override through admission', async () => { - const priorityOverrides: Array = []; - const agent = lifecycleAgent({}, async (_contextGraphId, work, priorityOverride) => { - priorityOverrides.push(priorityOverride); - return work(); - }); - - await (LifecycleSyncMethods.prototype.runLegacyDurableSync as any).call( - agent, - ctx, - 'peer', - ['foreground'], - undefined, - undefined, - undefined, - { priority: 2_000 }, - ); - - expect(priorityOverrides).toEqual([2_000]); - }); - it('preserves completed durable progress when a later admission is deferred', async () => { const admissions: string[] = []; const agent = lifecycleAgent({}, async (contextGraphId, work) => { @@ -174,7 +148,6 @@ describe('requester per-CG priority admission', () => { it('marks changelog admission pressure deferred without routing that graph to legacy', async () => { const admissions: string[] = []; - const priorityOverrides: Array = []; const emptyResult = { insertedTriples: 0, fetchedMetaTriples: 0, @@ -206,10 +179,8 @@ describe('requester per-CG priority admission', () => { _lane: string, _label: string, work: () => Promise, - priorityOverride?: number, ) => { admissions.push(contextGraphId); - priorityOverrides.push(priorityOverride); if (contextGraphId === 'second') { throw new SyncBackpressureBusyError('queue full'); } @@ -224,20 +195,16 @@ describe('requester per-CG priority admission', () => { ctx, 'peer', ['first', 'second', 'third'], - undefined, - 2_000, ); expect(admissions).toEqual(['first', 'second']); expect(lane.result.completedPhases).toBe(1); expect(lane.result.deferredBackpressure).toBe(1); expect(lane.remainingLegacyCgs).toEqual([]); - expect(priorityOverrides).toEqual([2_000, 2_000]); }); it('preserves completed shared-memory progress when a later admission is deferred', async () => { const admissions: string[] = []; - const priorityOverrides: Array = []; const warnings: string[] = []; const contextGraphIds = ['first', 'second', 'third']; const agent = { @@ -272,10 +239,8 @@ describe('requester per-CG priority admission', () => { _lane: string, _label: string, work: () => Promise, - priorityOverride?: number, ) => { admissions.push(contextGraphId); - priorityOverrides.push(priorityOverride); if (contextGraphId === 'second') { throw new SyncBackpressureBusyError('queue full'); } @@ -298,7 +263,6 @@ describe('requester per-CG priority admission', () => { privateRecoverFromCurator: [], eligibleContextGraphIds: contextGraphIds, }, - priority: 2_000, }); expect(admissions).toEqual(['first', 'second']); @@ -308,7 +272,6 @@ describe('requester per-CG priority admission', () => { expect(summary.deferredBackpressure).toBe(1); expect(summary.failedPeers).toBe(0); expect(summary.backoffWorthyFailures).toBe(0); - expect(priorityOverrides).toEqual([2_000, 2_000]); }); it('counts several failed Context Graphs from one remote as one failed peer', async () => { diff --git a/packages/cli/src/catchup-runner-worker-impl.ts b/packages/cli/src/catchup-runner-worker-impl.ts index 013783ddbf..af66fb1d70 100644 --- a/packages/cli/src/catchup-runner-worker-impl.ts +++ b/packages/cli/src/catchup-runner-worker-impl.ts @@ -1,9 +1,5 @@ import { parentPort } from 'node:worker_threads'; -import { - CATCHUP_MAX_CONCURRENT_PEER_SYNCS, - mapWithConcurrency, - retryCatchupPlaneOnBackpressure, -} from '@origintrail-official/dkg-agent'; +import { CATCHUP_MAX_CONCURRENT_PEER_SYNCS, mapWithConcurrency } from '@origintrail-official/dkg-agent'; import { catchupPeerResponded, catchupPeerSucceeded, @@ -196,22 +192,13 @@ async function runCatchup(request: CatchupRunRequest): Promise syncCapable, CATCHUP_MAX_CONCURRENT_PEER_SYNCS, async (peerId) => { - const rawDurable = await retryCatchupPlaneOnBackpressure( - () => invoke('syncDurable', peerId, request.contextGraphId), - ).catch(() => emptyDurable()); + const rawDurable = await invoke('syncDurable', peerId, request.contextGraphId).catch(() => emptyDurable()); const durable = { ...rawDurable, verifiedPrivateOnlyResponses: rawDurable.verifiedPrivateOnlyResponses ?? 0, }; - - // Durable metadata is required to authorize/materialize SWM. If VM is - // still locally deferred after bounded retries, leave SWM untouched for - // this peer. If only SWM is deferred, retry just SWM so the already- - // completed durable plane is never fetched a second time. - const shared = request.includeSharedMemory && (durable.deferredBackpressure ?? 0) === 0 - ? await retryCatchupPlaneOnBackpressure( - () => invoke('syncSharedMemory', peerId, request.contextGraphId), - ).catch(() => emptyShared()) + const shared = request.includeSharedMemory + ? await invoke('syncSharedMemory', peerId, request.contextGraphId).catch(() => emptyShared()) : null; return { durable, shared }; }, diff --git a/packages/cli/src/catchup-runner.ts b/packages/cli/src/catchup-runner.ts index 62dbec4a3d..9be274fab7 100644 --- a/packages/cli/src/catchup-runner.ts +++ b/packages/cli/src/catchup-runner.ts @@ -3,7 +3,6 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { classifyDurableProgress, - FOREGROUND_CATCHUP_SYNC_PRIORITY, type DKGAgent, type DurableProgressSummary, } from '@origintrail-official/dkg-agent'; @@ -292,22 +291,11 @@ class WorkerCatchupRunner implements CatchupRunner { } case 'syncDurable': { const [peerId, contextGraphId] = args as [string, string]; - return agent.syncFromPeerDetailed( - peerId, - [contextGraphId], - undefined, - undefined, - undefined, - { priority: FOREGROUND_CATCHUP_SYNC_PRIORITY }, - ); + return agent.syncFromPeerDetailed(peerId, [contextGraphId]); } case 'syncSharedMemory': { const [peerId, contextGraphId] = args as [string, string]; - return agent.syncSharedMemoryFromPeerDetailed( - peerId, - [contextGraphId], - { priority: FOREGROUND_CATCHUP_SYNC_PRIORITY }, - ); + return agent.syncSharedMemoryFromPeerDetailed(peerId, [contextGraphId]); } case 'finalizeCatchup': { const [contextGraphId] = args as [string, number, number]; @@ -330,8 +318,6 @@ class InlineCatchupRunner implements CatchupRunner { run(request: CatchupRunRequest): Promise { return this.agent.syncContextGraphFromConnectedPeers(request.contextGraphId, { includeSharedMemory: request.includeSharedMemory, - priority: FOREGROUND_CATCHUP_SYNC_PRIORITY, - retryDeferredBackpressure: true, }) as Promise; } diff --git a/packages/cli/test/catchup-runner-worker-impl.test.ts b/packages/cli/test/catchup-runner-worker-impl.test.ts index 2a68311727..dd68c49b4e 100644 --- a/packages/cli/test/catchup-runner-worker-impl.test.ts +++ b/packages/cli/test/catchup-runner-worker-impl.test.ts @@ -9,10 +9,7 @@ // aggregation keeps its one-result-per-peer input-order shape, and one peer's // failure stays isolated instead of failing the whole run. import { describe, expect, it, vi } from 'vitest'; -import { - CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS, - CATCHUP_MAX_CONCURRENT_PEER_SYNCS, -} from '@origintrail-official/dkg-agent'; +import { CATCHUP_MAX_CONCURRENT_PEER_SYNCS } from '@origintrail-official/dkg-agent'; import type { CatchupJobResult, CatchupRunRequest } from '../src/catchup-runner.js'; // The worker impl wires itself to `parentPort` at module load, so a @@ -235,10 +232,8 @@ describe('catchup-runner-worker-impl bounded fan-out (sync-storm mitigation C-1) expect(result.diagnostics?.durable.failedPeers).toBe(1); }); - it('retries only SWM after durable progress and finalizes when local pressure clears', async () => { + it('surfaces partial progress followed by local deferral without finalizing the catch-up', async () => { const finalizeCalls: unknown[][] = []; - let durableCalls = 0; - let sharedCalls = 0; const result = await runWorkerCatchup({ contextGraphId: 'cg-deferred', includeSharedMemory: true }, async (method) => { switch (method) { case 'prepareCatchup': @@ -246,22 +241,17 @@ describe('catchup-runner-worker-impl bounded fan-out (sync-storm mitigation C-1) case 'waitForSyncProtocol': return true; case 'syncDurable': - durableCalls += 1; return durableResult(); - case 'syncSharedMemory': { - sharedCalls += 1; - return sharedCalls === 1 - ? { - ...sharedResult(), - insertedTriples: 0, - fetchedDataTriples: 0, - insertedDataTriples: 0, - bytesReceived: 0, - completedPhases: 0, - deferredBackpressure: 1, - } - : sharedResult(); - } + case 'syncSharedMemory': + return { + ...sharedResult(), + insertedTriples: 0, + fetchedDataTriples: 0, + insertedDataTriples: 0, + bytesReceived: 0, + completedPhases: 0, + deferredBackpressure: 1, + }; case 'finalizeCatchup': finalizeCalls.push([]); return null; @@ -271,94 +261,11 @@ describe('catchup-runner-worker-impl bounded fan-out (sync-storm mitigation C-1) }); expect(result.peersResponded).toBe(1); - expect(result.peersSucceeded).toBe(1); - expect(result.deferredBackpressure).toBe(0); - expect(result.dataSynced).toBe(1); - expect(result.sharedMemorySynced).toBe(1); - expect(result.diagnostics?.sharedMemory.deferredBackpressure).toBe(0); - expect(durableCalls).toBe(1); - expect(sharedCalls).toBe(2); - expect(finalizeCalls).toEqual([[]]); - }); - - it('finishes deferred durable sync before starting SWM', async () => { - let durableCalls = 0; - let sharedCalls = 0; - const callOrder: string[] = []; - - const result = await runWorkerCatchup( - { contextGraphId: 'cg-durable-deferred', includeSharedMemory: true }, - async (method) => { - switch (method) { - case 'prepareCatchup': - return { preferredPeerId: undefined, isPrivateContextGraph: false, peerIds: ['peer-1'], connectedPeers: 1 }; - case 'waitForSyncProtocol': - return true; - case 'syncDurable': - durableCalls += 1; - callOrder.push(`durable-${durableCalls}`); - return durableCalls === 1 - ? { ...durableResult(), insertedTriples: 0, insertedDataTriples: 0, completedPhases: 0, deferredBackpressure: 1 } - : durableResult(); - case 'syncSharedMemory': - sharedCalls += 1; - callOrder.push('shared'); - return sharedResult(); - case 'finalizeCatchup': - return null; - default: - throw new Error(`unexpected invoke: ${method}`); - } - }, - ); - - expect(result.deferredBackpressure).toBe(0); - expect(durableCalls).toBe(2); - expect(sharedCalls).toBe(1); - expect(callOrder).toEqual(['durable-1', 'durable-2', 'shared']); - }); - - it('returns deferred after a bounded durable retry budget and never starts dependent SWM', async () => { - let durableCalls = 0; - let sharedCalls = 0; - const finalizeCalls: unknown[][] = []; - - const result = await runWorkerCatchup( - { contextGraphId: 'cg-persistently-deferred', includeSharedMemory: true }, - async (method) => { - switch (method) { - case 'prepareCatchup': - return { preferredPeerId: undefined, isPrivateContextGraph: false, peerIds: ['peer-1'], connectedPeers: 1 }; - case 'waitForSyncProtocol': - return true; - case 'syncDurable': - durableCalls += 1; - return { - ...durableResult(), - insertedTriples: 0, - fetchedDataTriples: 0, - insertedDataTriples: 0, - bytesReceived: 0, - completedPhases: 0, - deferredBackpressure: 1, - }; - case 'syncSharedMemory': - sharedCalls += 1; - return sharedResult(); - case 'finalizeCatchup': - finalizeCalls.push([]); - return null; - default: - throw new Error(`unexpected invoke: ${method}`); - } - }, - ); - - expect(durableCalls).toBe(CATCHUP_BACKPRESSURE_RETRY_DELAYS_MS.length + 1); - expect(sharedCalls).toBe(0); - expect(result.deferredBackpressure).toBe(1); - expect(result.peersResponded).toBe(0); expect(result.peersSucceeded).toBe(0); + expect(result.deferredBackpressure).toBe(1); + expect(result.dataSynced).toBe(1); + expect(result.sharedMemorySynced).toBe(0); + expect(result.diagnostics?.sharedMemory.deferredBackpressure).toBe(1); expect(finalizeCalls).toEqual([]); }); From 99d71835921666c65bc5480fd39f2746df52b80d Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:21:29 +0200 Subject: [PATCH 174/292] fix(rfc64): harden policy-bound catalog authoring --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/dkg-agent-rfc64-catalog.ts | 176 +++++------------- packages/agent/src/index.ts | 1 - .../src/rfc64/catalog-access-policy-v1.ts | 109 +++++++++-- .../src/rfc64/catalog-authority-config-v1.ts | 92 +++++++++ .../src/rfc64/public-catalog-service-v1.ts | 71 ++++--- .../rfc64-catalog-access-policy-v1.test.ts | 48 +++++ ...kg-agent-native-wiring.integration.test.ts | 20 +- .../rfc64-public-catalog-service-v1.test.ts | 51 +++++ 10 files changed, 392 insertions(+), 178 deletions(-) create mode 100644 packages/agent/src/rfc64/catalog-authority-config-v1.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index a7bed011d6..f428b3c907 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -23,6 +23,7 @@ "./dist/rfc64/control-object-store-v1-internal.js": null, "./dist/rfc64/control-object-store-v1.js": null, "./dist/rfc64/catalog-access-policy-v1.js": null, + "./dist/rfc64/catalog-authority-config-v1.js": null, "./dist/rfc64/catalog-transport-authorization-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, "./dist/rfc64/ka-bundle-store-v1-internal.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 330d3dc85a..2db6284cc7 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -79,6 +79,7 @@ const publicRfc64Modules = [ ]; const blockedRfc64Modules = [ 'catalog-access-policy-v1.js', + 'catalog-authority-config-v1.js', 'catalog-transport-authorization-v1.js', 'control-object-store-v1-internal.js', 'control-object-store-v1.js', diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 8d4bfcd093..60bf2984d0 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -43,19 +43,18 @@ import { type DecimalU64V1, type AuthorCatalogScopeV1, type CountV1, - assertCanonicalChainId, - assertNetworkIdV1, type SignedAuthorCatalogBucketEnvelopeV1, type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; -import { ethers } from 'ethers'; - import { DKGAgentBase } from './dkg-agent-base.js'; import type { DKGAgent } from './dkg-agent.js'; -import type { Rfc64CatalogAccessPolicyAuthorityConfigV1 } from './dkg-agent-types.js'; import type { Rfc64AuthorCatalogEip191SignerV1 } from './rfc64/author-catalog-producer.js'; +import { + snapshotRfc64CatalogAccessPolicyAuthorityV1, + snapshotRfc64CatalogDeploymentProfileV1, +} from './rfc64/catalog-authority-config-v1.js'; import type { AcceptedOpenCatalogPolicyV1 } from './rfc64/open-catalog-policy-v1.js'; import type { AcceptRfc64CatalogAccessSnapshotInputV1, @@ -66,6 +65,7 @@ import type { Rfc64PersistenceV1 } from './rfc64/persistence-v1.js'; import { Rfc64PublicCatalogServiceV1, snapshotRfc64PublicCatalogAnnouncementPeersV1, + type PublishAuthorCatalogGenesisResultV1, type PublishOpenAuthorCatalogGenesisResultV1, type AnnounceRfc64PublicCatalogHeadInputV1, type AnnounceRfc64PublicCatalogHeadResultV1, @@ -97,16 +97,13 @@ import { } from './rfc64/public-catalog-transport-v1.js'; /** Minimal EIP-191 EOA signer (ethers.Wallet-compatible) for author-catalog objects. */ -export interface Rfc64OpenCatalogAuthorSignerV1 { +export interface Rfc64CatalogAuthorSignerV1 { readonly address: string; signMessage(message: Uint8Array): Promise; } -const LEGACY_OPEN_ONLY_LOCAL_AGENT_ADDRESS = - '0x0000000000000000000000000000000000000001' as EvmAddressV1; - -/** Policy-neutral catalog author signer; legacy open APIs use the same shape. */ -export type Rfc64CatalogAuthorSignerV1 = Rfc64OpenCatalogAuthorSignerV1; +/** Compatibility name retained for the legacy public/open authoring surface. */ +export type Rfc64OpenCatalogAuthorSignerV1 = Rfc64CatalogAuthorSignerV1; export interface AcceptOpenContextGraphPolicyInputV1 { readonly networkId: NetworkIdV1; @@ -171,90 +168,10 @@ export { type Rfc64PublicCatalogReconciliationFailureV1, } from './rfc64/public-catalog-reconciliation-failure-v1.js'; -/** - * Validate and detach a locally configured deployment tuple from caller-owned - * state. Exported so `DKGAgent.create()` can snapshot the override before any - * asynchronous startup work begins. - */ -export function snapshotRfc64CatalogDeploymentProfileV1( - input: CatalogSealDeploymentProfileV1 | undefined, -): Readonly | undefined { - if (input === undefined) return undefined; - if (input === null || typeof input !== 'object' || Array.isArray(input)) { - throw new TypeError('rfc64CatalogDeploymentProfile must be a plain object'); - } - const prototype = Object.getPrototypeOf(input); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError('rfc64CatalogDeploymentProfile must be a plain object'); - } - const keys = Object.keys(input).sort(); - const expectedKeys = [ - 'assertedAtChainId', - 'assertedAtKav10Address', - 'networkId', - ]; - if ( - keys.length !== expectedKeys.length - || keys.some((key, index) => key !== expectedKeys[index]) - ) { - throw new TypeError( - 'rfc64CatalogDeploymentProfile must contain exactly networkId, ' - + 'assertedAtChainId, and assertedAtKav10Address', - ); - } - assertNetworkIdV1(input.networkId); - assertCanonicalChainId(input.assertedAtChainId, 'assertedAtChainId'); - if (!ethers.isAddress(input.assertedAtKav10Address)) { - throw new TypeError('assertedAtKav10Address must be a non-zero EVM address'); - } - const assertedAtKav10Address = input.assertedAtKav10Address.toLowerCase() as EvmAddressV1; - if (assertedAtKav10Address === `0x${'00'.repeat(20)}`) { - throw new TypeError('assertedAtKav10Address must be a non-zero EVM address'); - } - return Object.freeze({ - networkId: input.networkId, - assertedAtChainId: input.assertedAtChainId, - assertedAtKav10Address, - }); -} - -/** Snapshot the function-bearing create-time authority without trusting caller mutation. */ -export function snapshotRfc64CatalogAccessPolicyAuthorityV1( - input: Rfc64CatalogAccessPolicyAuthorityConfigV1 | undefined, -): Readonly | undefined { - if (input === undefined) return undefined; - if (input === null || typeof input !== 'object' || Array.isArray(input)) { - throw new TypeError('rfc64CatalogAccessPolicyAuthority must be a plain object'); - } - const prototype = Object.getPrototypeOf(input); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError('rfc64CatalogAccessPolicyAuthority must be a plain object'); - } - const keys = Object.keys(input).sort(); - if ( - keys.length !== 2 - || keys[0] !== 'localAgentAddress' - || keys[1] !== 'resolveRemoteAgentAddress' - ) { - throw new TypeError('rfc64CatalogAccessPolicyAuthority has unknown or missing fields'); - } - if ( - typeof input.localAgentAddress !== 'string' - || !ethers.isAddress(input.localAgentAddress) - || input.localAgentAddress === ethers.ZeroAddress - ) { - throw new TypeError('rfc64CatalogAccessPolicyAuthority.localAgentAddress is invalid'); - } - if (typeof input.resolveRemoteAgentAddress !== 'function') { - throw new TypeError( - 'rfc64CatalogAccessPolicyAuthority.resolveRemoteAgentAddress must be a function', - ); - } - return Object.freeze({ - localAgentAddress: input.localAgentAddress.toLowerCase() as EvmAddressV1, - resolveRemoteAgentAddress: input.resolveRemoteAgentAddress, - }); -} +export { + snapshotRfc64CatalogAccessPolicyAuthorityV1, + snapshotRfc64CatalogDeploymentProfileV1, +} from './rfc64/catalog-authority-config-v1.js'; export interface PublishOpenAuthorCatalogSuccessorParamsV1 { /** Exact durable predecessor returned by genesis or a prior successor. */ @@ -274,26 +191,25 @@ export interface PublishOpenAuthorCatalogSuccessorParamsV1 { } /** One member of the complete live set supplied to an ordinary successor. */ -export type Rfc64OpenCatalogSuccessorAssetInputV1 = +export type Rfc64CatalogSuccessorAssetInputV1 = Rfc64PublicCatalogSuccessorAssetInputV1; -export interface PublishOpenAuthorCatalogExactSetSuccessorParamsV1 { +export interface PublishAuthorCatalogExactSetSuccessorParamsV1 { /** Exact durable predecessor returned by genesis or a prior successor. */ readonly previousHead: Rfc64StagedAuthorCatalogHeadRefV1; - readonly author: Rfc64OpenCatalogAuthorSignerV1; + readonly author: Rfc64CatalogAuthorSignerV1; readonly catalogIssuerAuthorization: Rfc64PublicCatalogIssuerAuthorizationV1; /** Complete 1..1024-row live set; input order does not affect the signed head. */ - readonly assets: readonly Rfc64OpenCatalogSuccessorAssetInputV1[]; + readonly assets: readonly Rfc64CatalogSuccessorAssetInputV1[]; readonly deployment: CatalogSealDeploymentProfileV1; readonly issuedAt?: TimestampMsV1; readonly peers: readonly string[]; } -export type PublishAuthorCatalogExactSetSuccessorParamsV1 = - PublishOpenAuthorCatalogExactSetSuccessorParamsV1; - -export type PublishAuthorCatalogGenesisResultV1 = - PublishOpenAuthorCatalogGenesisResultV1; +export type Rfc64OpenCatalogSuccessorAssetInputV1 = + Rfc64CatalogSuccessorAssetInputV1; +export type PublishOpenAuthorCatalogExactSetSuccessorParamsV1 = + PublishAuthorCatalogExactSetSuccessorParamsV1; export interface PublishOpenAuthorCatalogSuccessorResultV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; @@ -310,7 +226,7 @@ export interface PublishOpenAuthorCatalogSuccessorResultV1 { readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; } -export interface PublishOpenAuthorCatalogSuccessorAssetResultV1 { +export interface PublishAuthorCatalogSuccessorAssetResultV1 { readonly kaId: KaIdV1; readonly catalogRowDigest: Digest32V1; readonly bundleDigest: Digest32V1; @@ -324,7 +240,7 @@ export interface PublishOpenAuthorCatalogSuccessorAssetResultV1 { readonly kaUal: string; } -export interface PublishOpenAuthorCatalogExactSetSuccessorResultV1 { +export interface PublishAuthorCatalogExactSetSuccessorResultV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; readonly headObjectDigest: Digest32V1; readonly signatureVariantDigest: Digest32V1; @@ -335,14 +251,16 @@ export interface PublishOpenAuthorCatalogExactSetSuccessorResultV1 { /** Exact signed bucket row count, sourced independently of head `totalRows`. */ readonly signedBucketRowCount: CountV1; /** Strictly increasing by mathematical KA ID. */ - readonly assets: readonly Readonly[]; + readonly assets: readonly Readonly[]; readonly inventoryRowCount: CountV1; readonly announcedPeers: readonly string[]; readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; } -export type PublishAuthorCatalogExactSetSuccessorResultV1 = - PublishOpenAuthorCatalogExactSetSuccessorResultV1; +export type PublishOpenAuthorCatalogSuccessorAssetResultV1 = + PublishAuthorCatalogSuccessorAssetResultV1; +export type PublishOpenAuthorCatalogExactSetSuccessorResultV1 = + PublishAuthorCatalogExactSetSuccessorResultV1; export class Rfc64CatalogMethods extends DKGAgentBase { /** @@ -353,24 +271,10 @@ export class Rfc64CatalogMethods extends DKGAgentBase { if (this.rfc64PublicCatalogServiceV1 !== undefined) return; const persistence = this.rfc64PersistenceV1; if (persistence === undefined) return; - const configuredAuthority = this.config.rfc64CatalogAccessPolicyAuthority; - const defaultAgentAddress = this.defaultAgentAddress?.toLowerCase(); - const fallbackLocalAgentAddress = defaultAgentAddress !== undefined - && ethers.isAddress(defaultAgentAddress) - && defaultAgentAddress !== ethers.ZeroAddress - ? defaultAgentAddress as EvmAddressV1 - : LEGACY_OPEN_ONLY_LOCAL_AGENT_ADDRESS; - const accessPolicyAuthority = configuredAuthority ?? Object.freeze({ - localAgentAddress: fallbackLocalAgentAddress, - // Legacy-open compatibility only. Private snapshot acceptance is denied - // below unless the caller supplied an explicit authenticated resolver. - resolveRemoteAgentAddress: async () => null, - }); const service = new Rfc64PublicCatalogServiceV1({ router: this.router, controlObjects: persistence.controlObjects, - accessPolicyAuthority, - privatePolicyAuthorityConfigured: configuredAuthority !== undefined, + accessPolicyAuthority: this.config.rfc64CatalogAccessPolicyAuthority, native: this.createRfc64PublicCatalogNativeOptionsV1(), receiver: { onError: (announcement, error) => { @@ -564,13 +468,26 @@ export class Rfc64CatalogMethods extends DKGAgentBase { throw new Error('RFC-64 public/open compatibility successor requires the root lane'); } service.acceptedOpenPolicyDigestForCatalogScope(scope); - return this.publishAuthorCatalogExactSetSuccessorV1(params); + return this.publishAuthorCatalogExactSetSuccessorFromHistoryV1(params, history); } /** Exact-set successor path for any locally accepted policy cell. */ async publishAuthorCatalogExactSetSuccessorV1( this: DKGAgent, params: PublishAuthorCatalogExactSetSuccessorParamsV1, + ): Promise { + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) { + throw new Error('RFC-64 persistence is not available'); + } + const history = await loadBoundedAuthorCatalogHistoryV1(persistence, params.previousHead); + return this.publishAuthorCatalogExactSetSuccessorFromHistoryV1(params, history); + } + + private async publishAuthorCatalogExactSetSuccessorFromHistoryV1( + this: DKGAgent, + params: PublishAuthorCatalogExactSetSuccessorParamsV1, + history: BoundedAuthorCatalogHistoryV1, ): Promise { const service = this.requireRfc64PublicCatalogServiceV1(); const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(params.peers); @@ -578,9 +495,14 @@ export class Rfc64CatalogMethods extends DKGAgentBase { if (persistence === undefined) { throw new Error('RFC-64 persistence is not available'); } - const history = await loadBoundedAuthorCatalogHistoryV1(persistence, params.previousHead); const scope = deriveAuthorCatalogScopeFromHeadV1(history.previousHead.payload); - const policyDigest = service.acceptedPolicyDigestForCatalogScope(scope); + const heldPolicy = service.acceptedPolicySnapshotForCatalogScope(scope); + const policyDigest = heldPolicy.policyDigest; + if (heldPolicy.policy.accessPolicy === 1 && peers.length > 0) { + throw new Error( + 'RFC-64 private catalog peer fan-out requires scope-bound private content transport', + ); + } const authorAddress = params.author.address.toLowerCase() as EvmAddressV1; if (authorAddress !== scope.authorAddress) { throw new Error('RFC-64 successor author must equal the exact predecessor author'); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 56d19a353c..73242508d4 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -115,7 +115,6 @@ export type { PublishAuthorCatalogExactSetSuccessorParamsV1, PublishAuthorCatalogExactSetSuccessorResultV1, PublishAuthorCatalogGenesisParamsV1, - PublishAuthorCatalogGenesisResultV1, Rfc64CatalogAuthorSignerV1, } from './dkg-agent-rfc64-catalog.js'; export type { diff --git a/packages/agent/src/rfc64/catalog-access-policy-v1.ts b/packages/agent/src/rfc64/catalog-access-policy-v1.ts index e80890f029..571cf480aa 100644 --- a/packages/agent/src/rfc64/catalog-access-policy-v1.ts +++ b/packages/agent/src/rfc64/catalog-access-policy-v1.ts @@ -91,18 +91,23 @@ const UTF8 = new TextEncoder(); * and opaque-bundle transports. */ export class Rfc64CatalogAccessPolicyRegistryV1 { - readonly #localAgentAddress: EvmAddressV1; - readonly #resolveRemoteAgentAddress: ( + readonly #localAgentAddress: EvmAddressV1 | null; + readonly #resolveRemoteAgentAddress: (( remotePeerId: string, - ) => Promise; + ) => Promise) | null; readonly #byKey = new Map(); - constructor(options: Rfc64CatalogAccessPolicyRegistryOptionsV1) { + constructor(options?: Rfc64CatalogAccessPolicyRegistryOptionsV1) { + if (options === undefined) { + this.#localAgentAddress = null; + this.#resolveRemoteAgentAddress = null; + return; + } this.#localAgentAddress = snapshotAgentAddress( - options?.localAgentAddress, + options.localAgentAddress, 'localAgentAddress', ); - if (typeof options?.resolveRemoteAgentAddress !== 'function') { + if (typeof options.resolveRemoteAgentAddress !== 'function') { throw new TypeError('resolveRemoteAgentAddress must be a function'); } this.#resolveRemoteAgentAddress = options.resolveRemoteAgentAddress; @@ -111,6 +116,24 @@ export class Rfc64CatalogAccessPolicyRegistryV1 { /** Accept one already-authoritative current snapshot. Exact replay is idempotent. */ accept( input: AcceptRfc64CatalogAccessSnapshotInputV1, + ): AcceptedRfc64CatalogAccessSnapshotV1 { + return this.#accept(input, false); + } + + /** + * Advance accepted-current state across one already-verified direct policy + * transition. The predecessor digest and monotonic era/version high-water are + * rechecked locally before the old authorization snapshot is replaced. + */ + acceptCurrent( + input: AcceptRfc64CatalogAccessSnapshotInputV1, + ): AcceptedRfc64CatalogAccessSnapshotV1 { + return this.#accept(input, true); + } + + #accept( + input: AcceptRfc64CatalogAccessSnapshotInputV1, + allowVerifiedTransition: boolean, ): AcceptedRfc64CatalogAccessSnapshotV1 { const policy = snapshotPolicy(input?.policy); const policyDigest = snapshotDigest(input?.policyDigest, 'policyDigest'); @@ -124,6 +147,11 @@ export class Rfc64CatalogAccessPolicyRegistryV1 { throw new Error('open RFC-64 catalog policy forbids an exhaustive member roster'); } } else { + if (!this.privatePolicyAuthorityConfigured) { + throw new Error( + 'RFC-64 private catalog policy requires explicit access-policy authority configuration', + ); + } if (rosterInput === null) { throw new Error('invite-only RFC-64 catalog policy requires a current member roster'); } @@ -133,26 +161,30 @@ export class Rfc64CatalogAccessPolicyRegistryV1 { } const key = policyKey(policy.networkId, policy.contextGraphId); + const held = Object.freeze({ policy, policyDigest, roster, members }); const current = this.#byKey.get(key); if (current !== undefined) { - if ( - current.policyDigest !== policyDigest - || canonicalizeContextGraphPolicyPayloadV1(current.policy) - !== canonicalizeContextGraphPolicyPayloadV1(policy) - || canonicalRoster(current.roster) !== canonicalRoster(roster) - ) { - throw new Error( - 'RFC-64 current policy replacement requires the verified transition/high-water path', - ); + if (!sameSnapshot(current, held)) { + if (!allowVerifiedTransition) { + throw new Error( + 'RFC-64 current policy replacement requires the verified transition/high-water path', + ); + } + assertDirectMonotonicPolicyTransition(current, held); + this.#byKey.set(key, held); + return publicSnapshot(held); } return publicSnapshot(current); } - const held = Object.freeze({ policy, policyDigest, roster, members }); this.#byKey.set(key, held); return publicSnapshot(held); } + get privatePolicyAuthorityConfigured(): boolean { + return this.#localAgentAddress !== null && this.#resolveRemoteAgentAddress !== null; + } + lookup( networkId: NetworkIdV1, contextGraphId: ContextGraphIdV1, @@ -187,7 +219,11 @@ export class Rfc64CatalogAccessPolicyRegistryV1 { if (descriptor.catalogDisclosure === 'open-authenticated') { return authorization(held); } - if (held.members === null) return null; + if ( + held.members === null + || this.#localAgentAddress === null + || this.#resolveRemoteAgentAddress === null + ) return null; const remoteAgentAddress = await this.#resolveRemoteMemberAddress(boundary.remotePeerId); if (remoteAgentAddress === null) return null; @@ -223,13 +259,17 @@ export class Rfc64CatalogAccessPolicyRegistryV1 { const held = this.#byKey.get(policyKey(networkId, contextGraphId)); if (held === undefined || held.policyDigest !== policyDigest) return false; return held.policy.accessPolicy === 0 - || held.members?.has(authorAddress) === true; + || ( + this.privatePolicyAuthorityConfigured + && held.members?.has(authorAddress) === true + ); } catch { return false; } } async #resolveRemoteMemberAddress(remotePeerId: string): Promise { + if (this.#resolveRemoteAgentAddress === null) return null; try { const resolved = await this.#resolveRemoteAgentAddress(remotePeerId); return resolved === null @@ -370,6 +410,39 @@ function canonicalRoster(roster: Readonly | null): string | null return roster === null ? null : canonicalizeMemberRosterPayloadV1(roster); } +function sameSnapshot( + left: HeldCatalogAccessSnapshotV1, + right: HeldCatalogAccessSnapshotV1, +): boolean { + return left.policyDigest === right.policyDigest + && canonicalizeContextGraphPolicyPayloadV1(left.policy) + === canonicalizeContextGraphPolicyPayloadV1(right.policy) + && canonicalRoster(left.roster) === canonicalRoster(right.roster); +} + +function assertDirectMonotonicPolicyTransition( + current: HeldCatalogAccessSnapshotV1, + successor: HeldCatalogAccessSnapshotV1, +): void { + if (successor.policy.previousPolicyDigest !== current.policyDigest) { + throw new Error( + 'RFC-64 accepted-current policy transition is not linked to the exact predecessor digest', + ); + } + const currentEra = BigInt(current.policy.era); + const successorEra = BigInt(successor.policy.era); + const currentVersion = BigInt(current.policy.version); + const successorVersion = BigInt(successor.policy.version); + if ( + successorEra < currentEra + || (successorEra === currentEra && successorVersion <= currentVersion) + ) { + throw new Error( + 'RFC-64 accepted-current policy transition does not advance the era/version high-water', + ); + } +} + function policyKey(networkId: NetworkIdV1, contextGraphId: ContextGraphIdV1): string { return `${networkId}\n${contextGraphId}`; } diff --git a/packages/agent/src/rfc64/catalog-authority-config-v1.ts b/packages/agent/src/rfc64/catalog-authority-config-v1.ts new file mode 100644 index 0000000000..268e16668b --- /dev/null +++ b/packages/agent/src/rfc64/catalog-authority-config-v1.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + assertCanonicalChainId, + assertNetworkIdV1, + type CatalogSealDeploymentProfileV1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +import type { Rfc64CatalogAccessPolicyAuthorityConfigV1 } from '../dkg-agent-types.js'; + +/** Detach a locally configured deployment tuple from caller-owned state. */ +export function snapshotRfc64CatalogDeploymentProfileV1( + input: CatalogSealDeploymentProfileV1 | undefined, +): Readonly | undefined { + if (input === undefined) return undefined; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('rfc64CatalogDeploymentProfile must be a plain object'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('rfc64CatalogDeploymentProfile must be a plain object'); + } + const keys = Object.keys(input).sort(); + const expectedKeys = [ + 'assertedAtChainId', + 'assertedAtKav10Address', + 'networkId', + ]; + if ( + keys.length !== expectedKeys.length + || keys.some((key, index) => key !== expectedKeys[index]) + ) { + throw new TypeError( + 'rfc64CatalogDeploymentProfile must contain exactly networkId, ' + + 'assertedAtChainId, and assertedAtKav10Address', + ); + } + assertNetworkIdV1(input.networkId); + assertCanonicalChainId(input.assertedAtChainId, 'assertedAtChainId'); + if (!ethers.isAddress(input.assertedAtKav10Address)) { + throw new TypeError('assertedAtKav10Address must be a non-zero EVM address'); + } + const assertedAtKav10Address = input.assertedAtKav10Address.toLowerCase() as EvmAddressV1; + if (assertedAtKav10Address === `0x${'00'.repeat(20)}`) { + throw new TypeError('assertedAtKav10Address must be a non-zero EVM address'); + } + return Object.freeze({ + networkId: input.networkId, + assertedAtChainId: input.assertedAtChainId, + assertedAtKav10Address, + }); +} + +/** Snapshot the function-bearing private-policy authority at create time. */ +export function snapshotRfc64CatalogAccessPolicyAuthorityV1( + input: Rfc64CatalogAccessPolicyAuthorityConfigV1 | undefined, +): Readonly | undefined { + if (input === undefined) return undefined; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('rfc64CatalogAccessPolicyAuthority must be a plain object'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('rfc64CatalogAccessPolicyAuthority must be a plain object'); + } + const keys = Object.keys(input).sort(); + if ( + keys.length !== 2 + || keys[0] !== 'localAgentAddress' + || keys[1] !== 'resolveRemoteAgentAddress' + ) { + throw new TypeError('rfc64CatalogAccessPolicyAuthority has unknown or missing fields'); + } + if ( + typeof input.localAgentAddress !== 'string' + || !ethers.isAddress(input.localAgentAddress) + || input.localAgentAddress === ethers.ZeroAddress + ) { + throw new TypeError('rfc64CatalogAccessPolicyAuthority.localAgentAddress is invalid'); + } + if (typeof input.resolveRemoteAgentAddress !== 'function') { + throw new TypeError( + 'rfc64CatalogAccessPolicyAuthority.resolveRemoteAgentAddress must be a function', + ); + } + return Object.freeze({ + localAgentAddress: input.localAgentAddress.toLowerCase() as EvmAddressV1, + resolveRemoteAgentAddress: input.resolveRemoteAgentAddress, + }); +} diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index de7f6bf129..760ee55c12 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -99,10 +99,8 @@ const UTF8 = new TextEncoder(); export interface Rfc64PublicCatalogServiceOptionsV1 { readonly router: ProtocolRouter; readonly controlObjects: Rfc64ControlObjectOperationsV1; - /** Explicit local/remote agent-identity authority for private catalog decisions. */ - readonly accessPolicyAuthority: Rfc64CatalogAccessPolicyRegistryOptionsV1; - /** False only for the DKGAgent legacy-open compatibility authority. */ - readonly privatePolicyAuthorityConfigured?: boolean; + /** Omit for an explicit open-only service; required before accepting private policy. */ + readonly accessPolicyAuthority?: Rfc64CatalogAccessPolicyRegistryOptionsV1; readonly receiver?: Rfc64PublicCatalogReceiverOptionsV1; /** Full production native content/reconciliation path. Omission is diagnostic-only. */ readonly native?: Rfc64PublicCatalogServiceNativeOptionsV1; @@ -148,24 +146,23 @@ export interface Rfc64PublicCatalogServiceNativeOptionsV1 extends Pick< ) => Rfc64PublicCatalogReceiverReconcilerV1; } -export interface PublishOpenAuthorCatalogGenesisInputV1 { +export interface PublishAuthorCatalogGenesisInputV1 { readonly scope: AuthorCatalogScopeV1; readonly signer: Rfc64AuthorCatalogEip191SignerV1; readonly issuedAt: TimestampMsV1; readonly catalogIssuerDelegationEffectiveAt: TimestampMsV1; readonly catalogIssuerDelegationExpiresAt: TimestampMsV1; - /** The accepted open policy for the CG; its digest stamps the announcement. */ - readonly policy: AcceptedOpenCatalogPolicyV1; /** Peers to announce availability to. Announcements are best-effort hints. */ readonly peers: readonly string[]; } -export type PublishAuthorCatalogGenesisInputV1 = Omit< - PublishOpenAuthorCatalogGenesisInputV1, - 'policy' ->; +export interface PublishOpenAuthorCatalogGenesisInputV1 + extends PublishAuthorCatalogGenesisInputV1 { + /** The accepted open policy for the CG; its digest stamps the announcement. */ + readonly policy: AcceptedOpenCatalogPolicyV1; +} -export interface PublishOpenAuthorCatalogGenesisResultV1 { +export interface PublishAuthorCatalogGenesisResultV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; readonly headObjectDigest: Digest32V1; readonly signatureVariantDigest: Digest32V1; @@ -179,6 +176,9 @@ export interface PublishOpenAuthorCatalogGenesisResultV1 { readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; } +export type PublishOpenAuthorCatalogGenesisResultV1 = + PublishAuthorCatalogGenesisResultV1; + export interface AnnounceRfc64PublicCatalogHeadInputV1 { readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; /** Unique peer IDs; at most RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1. */ @@ -210,14 +210,12 @@ export class Rfc64PublicCatalogServiceV1 { readonly #transport: Rfc64PublicCatalogTransportV1; readonly #nativeTransport: Rfc64PublicCatalogNativeTransportV1 | undefined; readonly #transportTimeoutMs: number; - readonly #privatePolicyAuthorityConfigured: boolean; #started = false; #closed = false; constructor(options: Rfc64PublicCatalogServiceOptionsV1) { this.#controlObjects = options.controlObjects; this.#policies = new Rfc64CatalogAccessPolicyRegistryV1(options.accessPolicyAuthority); - this.#privatePolicyAuthorityConfigured = options.privatePolicyAuthorityConfigured ?? true; this.#verifyIssuerSignature = options.verifyIssuerSignature ?? verifyControlEnvelopeIssuerSignatureV1; this.#transportTimeoutMs = options.transportTimeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS; @@ -291,12 +289,7 @@ export class Rfc64PublicCatalogServiceV1 { acceptPolicySnapshot( input: AcceptRfc64CatalogAccessSnapshotInputV1, ): AcceptedRfc64CatalogAccessSnapshotV1 { - if (input?.policy?.accessPolicy === 1 && !this.#privatePolicyAuthorityConfigured) { - throw new Error( - 'RFC-64 private catalog policy requires explicit access-policy authority configuration', - ); - } - return this.#policies.accept(input); + return this.#policies.acceptCurrent(input); } acceptedPolicySnapshot( @@ -308,13 +301,19 @@ export class Rfc64PublicCatalogServiceV1 { /** Resolve the locally accepted policy digest for one exact catalog scope. */ acceptedPolicyDigestForCatalogScope(scopeInput: AuthorCatalogScopeV1): Digest32V1 { + return this.acceptedPolicySnapshotForCatalogScope(scopeInput).policyDigest; + } + + acceptedPolicySnapshotForCatalogScope( + scopeInput: AuthorCatalogScopeV1, + ): AcceptedRfc64CatalogAccessSnapshotV1 { const scope = snapshotCatalogScope(scopeInput); const held = this.#policies.lookup(scope.networkId, scope.contextGraphId); if (held === null) { throw new Error('RFC-64 catalog scope has no locally accepted policy snapshot'); } assertAcceptedPolicyMatchesCatalogScope(this.#policies, held, scope); - return held.policyDigest; + return held; } /** Compatibility alias for the original public/open authoring surface. */ @@ -370,26 +369,30 @@ export class Rfc64PublicCatalogServiceV1 { const scope = snapshotCatalogScope(input.scope); const heldPolicy = this.#policies.lookup(scope.networkId, scope.contextGraphId); assertOpenPolicyMatchesCatalogScope(input.policy, heldPolicy, scope); - return this.#publishAuthorCatalogGenesis(input, heldPolicy!); + const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); + return this.#publishAuthorCatalogGenesis(input, heldPolicy!, peers); } /** Author path for any already-accepted RFC-64 catalog access-policy cell. */ async publishAuthorCatalogGenesis( input: PublishAuthorCatalogGenesisInputV1, - ): Promise { + ): Promise { const scope = snapshotCatalogScope(input.scope); const heldPolicy = this.#policies.lookup(scope.networkId, scope.contextGraphId); if (heldPolicy === null) { throw new Error('RFC-64 catalog scope has no locally accepted policy snapshot'); } assertAcceptedPolicyMatchesCatalogScope(this.#policies, heldPolicy, scope); - return this.#publishAuthorCatalogGenesis(input, heldPolicy); + const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); + assertSupportedCatalogFanout(heldPolicy, peers); + return this.#publishAuthorCatalogGenesis(input, heldPolicy, peers); } async #publishAuthorCatalogGenesis( input: PublishAuthorCatalogGenesisInputV1, heldPolicy: AcceptedRfc64CatalogAccessSnapshotV1, - ): Promise { + peers: readonly string[], + ): Promise { this.#requireStarted(); const scope = snapshotCatalogScope(input.scope); const signer = Object.freeze({ @@ -399,7 +402,6 @@ export class Rfc64PublicCatalogServiceV1 { const issuedAt = input.issuedAt; const effectiveAt = input.catalogIssuerDelegationEffectiveAt; const expiresAt = input.catalogIssuerDelegationExpiresAt; - const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); assertAcceptedPolicyMatchesCatalogScope(this.#policies, heldPolicy, scope); const policyDigest = heldPolicy.policyDigest; const delegation = await produceDirectAuthorCatalogIssuerDelegationV1({ @@ -485,7 +487,8 @@ export class Rfc64PublicCatalogServiceV1 { encodeRfc64PublicCatalogHeadAnnouncementV1(input.announcement), ); const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); - this.#assertAcceptedCatalogAnnouncement(announcement); + const heldPolicy = this.#assertAcceptedCatalogAnnouncement(announcement); + assertSupportedCatalogFanout(heldPolicy, peers); return this.#announceCatalogHeadSnapshot(announcement, peers); } @@ -534,7 +537,7 @@ export class Rfc64PublicCatalogServiceV1 { #assertAcceptedCatalogAnnouncement( announcement: Rfc64PublicCatalogHeadAnnouncementV1, - ): void { + ): AcceptedRfc64CatalogAccessSnapshotV1 { const held = this.#policies.lookup(announcement.networkId, announcement.contextGraphId); if ( held === null @@ -550,6 +553,7 @@ export class Rfc64PublicCatalogServiceV1 { 'RFC-64 catalog announcement is not bound to the locally accepted policy snapshot', ); } + return held; } async #authorizeNativeOperation( input: Rfc64PublicCatalogNativeAuthorizationInputV1, @@ -649,6 +653,17 @@ export function snapshotRfc64PublicCatalogAnnouncementPeersV1( return Object.freeze(peers); } +function assertSupportedCatalogFanout( + heldPolicy: AcceptedRfc64CatalogAccessSnapshotV1, + peers: readonly string[], +): void { + if (heldPolicy.policy.accessPolicy === 1 && peers.length > 0) { + throw new Error( + 'RFC-64 private catalog peer fan-out requires scope-bound private content transport', + ); + } +} + function snapshotCatalogScope(input: AuthorCatalogScopeV1): Readonly { const scope = Object.freeze({ networkId: input.networkId, diff --git a/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts b/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts index 528c88e187..a972a7beb5 100644 --- a/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts +++ b/packages/agent/test/rfc64-catalog-access-policy-v1.test.ts @@ -119,6 +119,23 @@ function authInput(operation: Rfc64CatalogAccessOperationV1, policyDigest: Diges } describe('RFC-64 D26 catalog access authorization', () => { + it('supports an explicit open-only registry without a dummy identity authority', async () => { + const subject = new Rfc64CatalogAccessPolicyRegistryV1(); + const openPolicy = policy(0, 1); + const openDigest = digestFor(openPolicy); + subject.accept({ policy: openPolicy, policyDigest: openDigest }); + await expect(subject.authorize(authInput('fetch-inbound', openDigest))) + .resolves.toEqual({ accessPolicy: 0, policyDigest: openDigest }); + + const privatePolicy = policy(1, 1); + const privateDigest = digestFor(privatePolicy); + expect(() => subject.accept({ + policy: privatePolicy, + policyDigest: privateDigest, + roster: roster(privateDigest), + })).toThrow(/requires explicit access-policy authority/u); + }); + it('keeps both open-sharing cells SWM-equivalent without resolving a roster identity', async () => { for (const publishPolicy of [0, 1] as const) { let resolverCalls = 0; @@ -323,4 +340,35 @@ describe('RFC-64 D26 catalog access authorization', () => { policyDigest: digestFor(replacement), })).toThrow(/verified transition\/high-water path/u); }); + + it('advances only across a linked monotonic accepted-current policy transition', async () => { + const initial = policy(0, 1); + const initialDigest = digestFor(initial); + const subject = registry(); + subject.acceptCurrent({ policy: initial, policyDigest: initialDigest }); + const successor = { + ...policy(0, 0), + version: '1', + previousPolicyDigest: initialDigest, + } satisfies ContextGraphPolicyV1; + const successorDigest = digestFor(successor); + subject.acceptCurrent({ policy: successor, policyDigest: successorDigest }); + + expect(subject.lookup(NETWORK, CG)?.policyDigest).toBe(successorDigest); + await expect(subject.authorize(authInput('fetch-inbound', initialDigest))) + .resolves.toBeNull(); + await expect(subject.authorize(authInput('fetch-inbound', successorDigest))) + .resolves.toEqual({ accessPolicy: 0, policyDigest: successorDigest }); + + const unlinked = { + ...policy(0, 1), + version: '2', + previousPolicyDigest: initialDigest, + } satisfies ContextGraphPolicyV1; + expect(() => subject.acceptCurrent({ + policy: unlinked, + policyDigest: digestFor(unlinked), + })).toThrow(/exact predecessor digest/u); + expect(subject.lookup(NETWORK, CG)?.policyDigest).toBe(successorDigest); + }); }); diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index bb3bc24190..e057cac94a 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -224,7 +224,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { roster, })).toMatchObject({ policyDigest, roster: { policyDigest } }); - const published = await configured.publishAuthorCatalogGenesisV1({ + const privateGenesis = { scope: { networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, @@ -241,14 +241,19 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { issuedAt: FIXED_HEAD_ISSUED_AT, catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, catalogIssuerDelegationExpiresAt: MULTI_DELEGATION_EXPIRES_AT, - }); + } as const; + await expect(configured.publishAuthorCatalogGenesisV1({ + ...privateGenesis, + peers: ['12D3KooPrivateReceiver'], + })).rejects.toThrow(/private catalog peer fan-out requires scope-bound/u); + const published = await configured.publishAuthorCatalogGenesisV1(privateGenesis); expect(published.announcement).toMatchObject({ policyDigest, subGraphName: 'service-lane', catalogEra: '0', }); - const successor = await configured.publishAuthorCatalogExactSetSuccessorV1({ + const privateSuccessor = { previousHead: { objectDigest: published.headObjectDigest, signatureVariantDigest: published.signatureVariantDigest, @@ -263,7 +268,14 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { deployment: NATIVE_DEPLOYMENT, issuedAt: SUCCESSOR_ISSUED_AT, peers: [], - }); + } as const; + await expect(configured.publishOpenAuthorCatalogExactSetSuccessorV1(privateSuccessor)) + .rejects.toThrow(/public\/open compatibility successor requires the root lane/u); + await expect(configured.publishAuthorCatalogExactSetSuccessorV1({ + ...privateSuccessor, + peers: ['12D3KooPrivateReceiver'], + })).rejects.toThrow(/private catalog peer fan-out requires scope-bound/u); + const successor = await configured.publishAuthorCatalogExactSetSuccessorV1(privateSuccessor); expect(successor).toMatchObject({ announcement: { policyDigest, diff --git a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts index 882bca95c4..7daab1300c 100644 --- a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts @@ -288,6 +288,57 @@ function countEvent(router: RecordingRouter, event: string): number { } describe('RFC-64 public catalog service v1 lifecycle ownership', () => { + it('preserves direct open-only construction and rejects private snapshots', async () => { + const service = new Rfc64PublicCatalogServiceV1({ + router: new RecordingRouter().asProtocolRouter(), + controlObjects: controlObjects(), + }); + service.start(); + const accepted = service.acceptOpenPolicy({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + expect(accepted.policy.accessPolicy).toBe(0); + + const privatePolicy = catalogPolicy(`${CONTEXT_GRAPH_ID}-private`, 1, 1); + const privateDigest = `0x${'31'.repeat(32)}` as Digest32V1; + expect(() => service.acceptPolicySnapshot({ + policy: privatePolicy, + policyDigest: privateDigest, + roster: memberRoster(privatePolicy, privateDigest), + })).toThrow(/requires explicit access-policy authority/u); + await service.close(); + }); + + it('advances a verified current policy and rejects the old digest immediately', async () => { + const service = new Rfc64PublicCatalogServiceV1({ + router: new RecordingRouter().asProtocolRouter(), + controlObjects: controlObjects(), + }); + service.start(); + const initial = catalogPolicy(CONTEXT_GRAPH_ID, 0, 1); + const initialDigest = `0x${'32'.repeat(32)}` as Digest32V1; + service.acceptPolicySnapshot({ policy: initial, policyDigest: initialDigest }); + const successor = { + ...catalogPolicy(CONTEXT_GRAPH_ID, 0, 0), + version: '1', + previousPolicyDigest: initialDigest, + } satisfies ContextGraphPolicyV1; + const successorDigest = `0x${'33'.repeat(32)}` as Digest32V1; + service.acceptPolicySnapshot({ policy: successor, policyDigest: successorDigest }); + + await expect(service.announceCatalogHead({ + announcement: announcement(initialDigest), + peers: [], + })).rejects.toThrow(/not bound to the locally accepted policy/u); + await expect(service.announceCatalogHead({ + announcement: announcement(successorDigest), + peers: [], + })).resolves.toMatchObject({ announcedPeers: [], failedPeers: [] }); + await service.close(); + }); + it('accepts all four policy cells and requires a roster only for private access', async () => { const service = new Rfc64PublicCatalogServiceV1({ router: new RecordingRouter().asProtocolRouter(), From 7d82e6b4ea52c7c823831f21895a33a3b8840323 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Tue, 21 Jul 2026 23:49:33 +0200 Subject: [PATCH 175/292] test(devnet): checkpoint RFC-64 policy matrix harness --- devnet/_bootstrap/harness.ts | 15 + devnet/_bootstrap/rfc64-evidence.test.ts | 1 + .../rfc64-v2-swm-policy-matrix.test.ts | 166 ++++++++ .../_bootstrap/rfc64-v2-swm-policy-matrix.ts | 369 ++++++++++++++++++ devnet/_bootstrap/tsconfig.evidence.json | 4 +- devnet/_bootstrap/vitest.evidence.config.ts | 5 +- devnet/rich-scenario/automated.test.ts | 265 ++++++++++++- 7 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts create mode 100644 devnet/_bootstrap/rfc64-v2-swm-policy-matrix.ts diff --git a/devnet/_bootstrap/harness.ts b/devnet/_bootstrap/harness.ts index 72baaf28bf..f4baa4e372 100644 --- a/devnet/_bootstrap/harness.ts +++ b/devnet/_bootstrap/harness.ts @@ -32,6 +32,21 @@ import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; import { ethers } from 'ethers'; +export { + RFC64_V2_FUTURE_TRANSITION_CAPABILITY, + RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES, + RFC64_V2_SWM_POLICY_CELLS, + digestRfc64V2SwmBindings, + inspectRfc64V2ProductCapabilities, + probeBuiltRfc64V2ProductCapabilities, + requireRfc64V2ProductCapabilities, + verifyRfc64V2SwmPolicyMatrix, + type Rfc64V2ProductCapabilityInspection, + type Rfc64V2SwmCellObservation, + type Rfc64V2SwmReadObservation, + type VerifiedRfc64V2SwmPolicyMatrix, +} from './rfc64-v2-swm-policy-matrix.js'; + export const REPO_ROOT = resolve(import.meta.dirname, '../..'); export const RPC = process.env.DEVNET_RPC ?? 'http://127.0.0.1:8545'; export const DEVNET_DIR = join(REPO_ROOT, '.devnet'); diff --git a/devnet/_bootstrap/rfc64-evidence.test.ts b/devnet/_bootstrap/rfc64-evidence.test.ts index fd0d061334..51b456e786 100644 --- a/devnet/_bootstrap/rfc64-evidence.test.ts +++ b/devnet/_bootstrap/rfc64-evidence.test.ts @@ -70,6 +70,7 @@ describe('RFC-64 evidence Vitest discovery', () => { expect(config.root).toBe(resolve(import.meta.dirname, '../..')); expect(config.test?.include).toEqual([ 'devnet/_bootstrap/rfc64-evidence.test.ts', + 'devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts', ]); expect(include).toBeDefined(); expect(isAbsolute(include!)).toBe(false); diff --git a/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts b/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts new file mode 100644 index 0000000000..bea895113f --- /dev/null +++ b/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; + +import { + RFC64_V2_FUTURE_TRANSITION_CAPABILITY, + RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES, + digestRfc64V2SwmBindings, + inspectRfc64V2ProductCapabilities, + requireRfc64V2ProductCapabilities, + verifyRfc64V2SwmPolicyMatrix, + type Rfc64V2SwmCellObservation, +} from './rfc64-v2-swm-policy-matrix.js'; + +const MEMBER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const OUTSIDER = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + +function digest(byte: string, prefix = 'sha256:'): string { + return `${prefix}${byte.repeat(64)}`; +} + +function cell( + name: Rfc64V2SwmCellObservation['cell'], + index: number, + accessPolicy: 0 | 1, + publishPolicy: 0 | 1, +): Rfc64V2SwmCellObservation { + const authorAgentAddress = `0x${String(index + 1).padStart(40, '0')}`; + const number = BigInt(index + 11); + const kaId = ((BigInt(authorAgentAddress) << 96n) | number).toString(); + const contentSha256 = digest(String(index + 1)); + const positive = { + agentAddress: MEMBER, + bindingCount: 2, + contentSha256, + httpStatus: 200, + leakedMarker: false, + nodeNumber: 6, + outcome: 'applied' as const, + }; + return { + accessPolicy, + assertionUri: `did:dkg:context-graph:matrix-${index}/assertion/${authorAgentAddress}/asset`, + authorAgentAddress, + cell: name, + contentSha256, + contextGraphId: `matrix-${index}`, + kaId, + memberRead: positive, + merkleRoot: digest(String(index + 5), '0x'), + outsiderRead: accessPolicy === 0 + ? { ...positive, agentAddress: OUTSIDER, nodeNumber: 4 } + : { + agentAddress: OUTSIDER, + bindingCount: 0, + contentSha256: null, + httpStatus: 403, + leakedMarker: false, + nodeNumber: 4, + outcome: 'denied', + }, + policyLifecycle: 'immutable-per-cell-snapshot', + publishPolicy, + tripleCount: 2, + txHash: digest(((index + 9) % 16).toString(16), '0x'), + ual: `did:dkg:otp:20430/${authorAgentAddress}/${number}`, + }; +} + +function fixture(): Rfc64V2SwmCellObservation[] { + return [ + cell('public-open', 0, 0, 1), + cell('public-curated', 1, 0, 0), + cell('private-open', 2, 1, 1), + cell('private-curated', 3, 1, 0), + ]; +} + +describe('RFC-64 V2 rich-scenario SWM policy matrix contract', () => { + it('accepts the exact four cells and proves publishPolicy-neutral SWM behavior', () => { + const verified = verifyRfc64V2SwmPolicyMatrix(fixture()); + expect(verified.status).toBe('PASS'); + expect(verified.publishPolicyConsultedBySwm).toBe(false); + expect(verified.policyLifecycle).toBe('four-independent-immutable-snapshots'); + expect(verified.publishPolicyParity).toHaveLength(2); + expect(verified.publishPolicyParity[0]!.vector).toEqual( + verified.publishPolicyParity[0]!.vector, + ); + }); + + it('rejects missing cells, wrong axes, UAL/kaId mismatch, and content mismatch', () => { + expect(() => verifyRfc64V2SwmPolicyMatrix(fixture().slice(0, 3))) + .toThrow(/exactly four/); + + const wrongAxes = fixture(); + wrongAxes[1] = { ...wrongAxes[1]!, publishPolicy: 1 }; + expect(() => verifyRfc64V2SwmPolicyMatrix(wrongAxes)).toThrow(/publishPolicy/); + + const wrongKaId = fixture(); + wrongKaId[0] = { ...wrongKaId[0]!, kaId: '1' }; + expect(() => verifyRfc64V2SwmPolicyMatrix(wrongKaId)).toThrow(/packed UAL identity/); + + const mismatch = fixture(); + mismatch[0] = { + ...mismatch[0]!, + memberRead: { ...mismatch[0]!.memberRead, contentSha256: digest('f') }, + }; + expect(() => verifyRfc64V2SwmPolicyMatrix(mismatch)).toThrow(/contentSha256/); + }); + + it('rejects a private outsider leak and publish-axis-dependent behavior', () => { + const leak = fixture(); + leak[2] = { + ...leak[2]!, + outsiderRead: { + ...leak[2]!.outsiderRead, + bindingCount: 2, + contentSha256: leak[2]!.contentSha256, + httpStatus: 200, + leakedMarker: true, + outcome: 'applied', + }, + }; + expect(() => verifyRfc64V2SwmPolicyMatrix(leak)).toThrow(/outsiderRead\.outcome/); + + const drift = fixture(); + drift[1] = { + ...drift[1]!, + outsiderRead: { ...drift[1]!.outsiderRead, bindingCount: 1 }, + }; + expect(() => verifyRfc64V2SwmPolicyMatrix(drift)).toThrow(/bindingCount/); + }); + + it('normalizes SPARQL bindings before hashing', () => { + const left = digestRfc64V2SwmBindings([ + { p: { value: 'urn:p:2' }, o: 'two' }, + { o: { value: 'one' }, p: 'urn:p:1' }, + ]); + const right = digestRfc64V2SwmBindings([ + { p: 'urn:p:1', o: 'one' }, + { o: 'two', p: 'urn:p:2' }, + ]); + expect(left).toBe(right); + }); + + it('reports missing product seams deterministically and keeps transitions future-scoped', () => { + const partial = inspectRfc64V2ProductCapabilities({ + acceptRfc64CatalogAccessSnapshotV1() {}, + }); + expect(partial.missing).toEqual([ + 'publishAuthorCatalogGenesisV1', + 'publishAuthorCatalogExactSetSuccessorV1', + ]); + expect(() => requireRfc64V2ProductCapabilities(partial)).toThrow( + 'RFC64_V2_PRODUCT_CAPABILITIES_UNAVAILABLE: ' + + 'publishAuthorCatalogGenesisV1, publishAuthorCatalogExactSetSuccessorV1', + ); + expect(partial.futureTransitionCapability).toBe(false); + + const complete = Object.fromEntries([ + ...RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES, + RFC64_V2_FUTURE_TRANSITION_CAPABILITY, + ].map((name) => [name, () => undefined])); + const inspection = inspectRfc64V2ProductCapabilities(complete); + expect(inspection.missing).toEqual([]); + expect(inspection.futureTransitionCapability).toBe(true); + }); +}); diff --git a/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.ts b/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.ts new file mode 100644 index 0000000000..48219bc873 --- /dev/null +++ b/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.ts @@ -0,0 +1,369 @@ +import { createHash } from 'node:crypto'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { parseDeterministicKnowledgeAssetUal } from '../../packages/core/src/ka-content-scope.js'; + +export const RFC64_V2_SWM_POLICY_CELLS = Object.freeze([ + 'public-open', + 'public-curated', + 'private-open', + 'private-curated', +] as const); + +export type Rfc64V2SwmPolicyCell = typeof RFC64_V2_SWM_POLICY_CELLS[number]; + +export const RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES = Object.freeze([ + 'acceptRfc64CatalogAccessSnapshotV1', + 'publishAuthorCatalogGenesisV1', + 'publishAuthorCatalogExactSetSuccessorV1', +] as const); + +/** + * Deliberately not required by the immutable four-cell V2 matrix. Once the + * product owns verified high-water policy/roster activation, the same rich + * scenario can add open↔invite and roster-removal transitions without + * pretending that repeated immutable snapshot acceptance is a transition. + */ +export const RFC64_V2_FUTURE_TRANSITION_CAPABILITY = + 'activateRfc64CatalogAccessTransitionV1' as const; + +export type Rfc64V2RequiredProductCapability = + typeof RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES[number]; + +export interface Rfc64V2ProductCapabilityInspection { + readonly futureTransitionCapability: boolean; + readonly missing: readonly Rfc64V2RequiredProductCapability[]; + readonly observed: Readonly>; +} + +export interface Rfc64V2SwmReadObservation { + readonly agentAddress: string; + readonly bindingCount: number; + readonly contentSha256: string | null; + readonly httpStatus: number; + readonly leakedMarker: boolean; + readonly nodeNumber: number; + readonly outcome: 'applied' | 'denied'; +} + +export interface Rfc64V2SwmCellObservation { + readonly accessPolicy: 0 | 1; + readonly assertionUri: string; + readonly authorAgentAddress: string; + readonly cell: Rfc64V2SwmPolicyCell; + readonly contentSha256: string; + readonly contextGraphId: string; + readonly kaId: string; + readonly memberRead: Rfc64V2SwmReadObservation; + readonly merkleRoot: string; + readonly outsiderRead: Rfc64V2SwmReadObservation; + /** V2 proves four independent current snapshots, not live policy replacement. */ + readonly policyLifecycle: 'immutable-per-cell-snapshot'; + readonly publishPolicy: 0 | 1; + readonly tripleCount: number; + readonly txHash: string; + readonly ual: string; +} + +export interface Rfc64V2SwmBehaviorVector { + readonly accessPolicy: 0 | 1; + readonly memberBindingCount: number; + readonly memberOutcome: 'applied'; + readonly outsiderBindingCount: number; + readonly outsiderOutcome: 'applied' | 'denied'; +} + +export interface Rfc64V2SwmPublishPolicyParity { + readonly behaviorDigest: string; + readonly curatedCell: 'public-curated' | 'private-curated'; + readonly openCell: 'public-open' | 'private-open'; + readonly vector: Rfc64V2SwmBehaviorVector; +} + +export interface VerifiedRfc64V2SwmPolicyMatrix { + readonly cells: readonly Rfc64V2SwmCellObservation[]; + readonly policyLifecycle: 'four-independent-immutable-snapshots'; + readonly publishPolicyConsultedBySwm: false; + readonly publishPolicyParity: readonly Rfc64V2SwmPublishPolicyParity[]; + readonly status: 'PASS'; +} + +const AXES: Readonly< + Record +> = Object.freeze({ + 'public-open': [0, 1], + 'public-curated': [0, 0], + 'private-open': [1, 1], + 'private-curated': [1, 0], +}); + +const ADDRESS = /^0x[0-9a-f]{40}$/u; +const DECIMAL = /^(?:0|[1-9][0-9]*)$/u; +const DIGEST = /^(?:0x|sha256:)[0-9a-f]{64}$/u; +const TX_HASH = /^0x[0-9a-f]{64}$/u; +const BEHAVIOR_DOMAIN = 'dkg-rfc64-v2-swm-behavior-v1\n'; + +export function inspectRfc64V2ProductCapabilities( + value: unknown, +): Rfc64V2ProductCapabilityInspection { + const candidate = value !== null && (typeof value === 'object' || typeof value === 'function') + ? value as Record + : null; + const observed = Object.freeze(Object.fromEntries( + RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES.map((name) => [ + name, + candidate !== null && typeof candidate[name] === 'function', + ]), + )) as Readonly>; + const missing = Object.freeze( + RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES.filter((name) => !observed[name]), + ); + return Object.freeze({ + futureTransitionCapability: + candidate !== null + && typeof candidate[RFC64_V2_FUTURE_TRANSITION_CAPABILITY] === 'function', + missing, + observed, + }); +} + +/** Inspect the same built DKGAgent class loaded by current-repo devnet nodes. */ +export async function probeBuiltRfc64V2ProductCapabilities( + repositoryRoot = resolve(import.meta.dirname, '../..'), +): Promise { + const entry = join(repositoryRoot, 'packages/agent/dist/index.js'); + let loaded: Record; + try { + loaded = await import(pathToFileURL(entry).href) as Record; + } catch (cause) { + throw new Error( + `RFC64_V2_PRODUCT_BUILD_UNAVAILABLE: ${entry}: ${errorMessage(cause)}`, + ); + } + const dkgAgent = loaded.DKGAgent; + if (typeof dkgAgent !== 'function') { + throw new Error(`RFC64_V2_PRODUCT_BUILD_UNAVAILABLE: ${entry} exports no DKGAgent class`); + } + return inspectRfc64V2ProductCapabilities(dkgAgent.prototype); +} + +export function requireRfc64V2ProductCapabilities( + inspection: Rfc64V2ProductCapabilityInspection, +): void { + if (inspection.missing.length === 0) return; + throw new Error( + `RFC64_V2_PRODUCT_CAPABILITIES_UNAVAILABLE: ${inspection.missing.join(', ')}`, + ); +} + +export function verifyRfc64V2SwmPolicyMatrix( + observations: readonly Rfc64V2SwmCellObservation[], +): VerifiedRfc64V2SwmPolicyMatrix { + if (!Array.isArray(observations) || observations.length !== 4) { + fail('matrix', 'must contain exactly four policy-cell observations'); + } + const cells = observations.map((input, index) => verifyCell( + input, + RFC64_V2_SWM_POLICY_CELLS[index]!, + `matrix[${index}]`, + )); + if (new Set(cells.map((cell) => cell.contextGraphId)).size !== 4) { + fail('matrix', 'contextGraphId values must be distinct'); + } + if (new Set(cells.map((cell) => cell.ual)).size !== 4) { + fail('matrix', 'UAL values must be distinct'); + } + if (new Set(cells.map((cell) => cell.contentSha256)).size !== 4) { + fail('matrix', 'content digests must be distinct'); + } + + const publishPolicyParity = Object.freeze([ + verifyParity(cells, 'public-open', 'public-curated'), + verifyParity(cells, 'private-open', 'private-curated'), + ]); + return Object.freeze({ + cells: Object.freeze(cells), + policyLifecycle: 'four-independent-immutable-snapshots', + publishPolicyConsultedBySwm: false, + publishPolicyParity, + status: 'PASS', + }); +} + +export function digestRfc64V2SwmBindings( + bindings: readonly Record[], +): string { + const normalized = bindings.map((binding) => Object.freeze(Object.fromEntries( + Object.entries(binding) + .map(([key, value]) => [key, normalizeBindingCell(value)] as const) + .sort(([left], [right]) => compareCodePoints(left, right)), + ))).sort((left, right) => compareCodePoints( + stableJson(left), + stableJson(right), + )); + return `sha256:${createHash('sha256').update(stableJson(normalized)).digest('hex')}`; +} + +function verifyCell( + input: Rfc64V2SwmCellObservation, + expectedCell: Rfc64V2SwmPolicyCell, + path: string, +): Rfc64V2SwmCellObservation { + if (!isPlainRecord(input)) fail(path, 'must be a plain observation object'); + exact(input.cell, expectedCell, `${path}.cell`); + const [accessPolicy, publishPolicy] = AXES[expectedCell]; + exact(input.accessPolicy, accessPolicy, `${path}.accessPolicy`); + exact(input.publishPolicy, publishPolicy, `${path}.publishPolicy`); + exact( + input.policyLifecycle, + 'immutable-per-cell-snapshot', + `${path}.policyLifecycle`, + ); + nonempty(input.contextGraphId, `${path}.contextGraphId`); + match(input.authorAgentAddress, ADDRESS, `${path}.authorAgentAddress`); + match(input.kaId, DECIMAL, `${path}.kaId`); + match(input.merkleRoot, DIGEST, `${path}.merkleRoot`); + match(input.contentSha256, /^sha256:[0-9a-f]{64}$/u, `${path}.contentSha256`); + match(input.txHash, TX_HASH, `${path}.txHash`); + nonempty(input.assertionUri, `${path}.assertionUri`); + if (!Number.isSafeInteger(input.tripleCount) || input.tripleCount < 1) { + fail(`${path}.tripleCount`, 'must be a positive safe integer'); + } + const ual = parseDeterministicKnowledgeAssetUal(input.ual); + exact(ual.ual, input.ual, `${path}.ual canonical form`); + exact(ual.agentAddress, input.authorAgentAddress, `${path}.ual author`); + const packedKaId = (BigInt(ual.agentAddress) << 96n) | BigInt(ual.kaNumber); + exact(packedKaId.toString(), input.kaId, `${path}.kaId packed UAL identity`); + + verifyPositiveRead(input.memberRead, input, `${path}.memberRead`); + if (accessPolicy === 0) { + verifyPositiveRead(input.outsiderRead, input, `${path}.outsiderRead`); + } else { + verifyDeniedRead(input.outsiderRead, `${path}.outsiderRead`); + } + return Object.freeze({ ...input }); +} + +function verifyPositiveRead( + read: Rfc64V2SwmReadObservation, + cell: Rfc64V2SwmCellObservation, + path: string, +): void { + verifyReadBase(read, path); + exact(read.outcome, 'applied', `${path}.outcome`); + exact(read.httpStatus, 200, `${path}.httpStatus`); + exact(read.bindingCount, cell.tripleCount, `${path}.bindingCount`); + exact(read.contentSha256, cell.contentSha256, `${path}.contentSha256`); + exact(read.leakedMarker, false, `${path}.leakedMarker`); +} + +function verifyDeniedRead(read: Rfc64V2SwmReadObservation, path: string): void { + verifyReadBase(read, path); + exact(read.outcome, 'denied', `${path}.outcome`); + if (read.httpStatus !== 200 && read.httpStatus !== 403 && read.httpStatus !== 404) { + fail(`${path}.httpStatus`, 'private denial must be empty-200, 403, or 404'); + } + exact(read.bindingCount, 0, `${path}.bindingCount`); + exact(read.contentSha256, null, `${path}.contentSha256`); + exact(read.leakedMarker, false, `${path}.leakedMarker`); +} + +function verifyReadBase(read: Rfc64V2SwmReadObservation, path: string): void { + if (!isPlainRecord(read)) fail(path, 'must be a plain read observation'); + match(read.agentAddress, ADDRESS, `${path}.agentAddress`); + if (!Number.isSafeInteger(read.nodeNumber) || read.nodeNumber < 1) { + fail(`${path}.nodeNumber`, 'must be a positive safe integer'); + } + if (!Number.isSafeInteger(read.httpStatus) || read.httpStatus < 100 || read.httpStatus > 599) { + fail(`${path}.httpStatus`, 'must be an HTTP status'); + } + if (!Number.isSafeInteger(read.bindingCount) || read.bindingCount < 0) { + fail(`${path}.bindingCount`, 'must be a nonnegative safe integer'); + } +} + +function verifyParity( + cells: readonly Rfc64V2SwmCellObservation[], + openCell: 'public-open' | 'private-open', + curatedCell: 'public-curated' | 'private-curated', +): Rfc64V2SwmPublishPolicyParity { + const open = cells.find((cell) => cell.cell === openCell)!; + const curated = cells.find((cell) => cell.cell === curatedCell)!; + const openVector = behaviorVector(open); + const curatedVector = behaviorVector(curated); + exact( + stableJson(openVector), + stableJson(curatedVector), + `${openCell}/${curatedCell} SWM behavior`, + ); + return Object.freeze({ + behaviorDigest: `sha256:${createHash('sha256') + .update(BEHAVIOR_DOMAIN) + .update(stableJson(openVector)) + .digest('hex')}`, + curatedCell, + openCell, + vector: openVector, + }); +} + +function behaviorVector(cell: Rfc64V2SwmCellObservation): Rfc64V2SwmBehaviorVector { + return Object.freeze({ + accessPolicy: cell.accessPolicy, + memberBindingCount: cell.memberRead.bindingCount, + memberOutcome: 'applied', + outsiderBindingCount: cell.outsiderRead.bindingCount, + outsiderOutcome: cell.outsiderRead.outcome, + }); +} + +function normalizeBindingCell(value: unknown): string { + if (typeof value === 'string') return value; + if (isPlainRecord(value) && typeof value.value === 'string') return value.value; + throw new TypeError(`SPARQL binding cell must be a string or {value}: ${String(value)}`); +} + +function stableJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((entry) => stableJson(entry)).join(',')}]`; + if (!isPlainRecord(value)) throw new TypeError('stable JSON input must be plain data'); + return `{${Object.keys(value).sort(compareCodePoints).map( + (key) => `${JSON.stringify(key)}:${stableJson(value[key])}`, + ).join(',')}}`; +} + +function compareCodePoints(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function nonempty(value: unknown, path: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) { + fail(path, 'must be a bounded nonempty string'); + } +} + +function match(value: unknown, pattern: RegExp, path: string): asserts value is string { + nonempty(value, path); + if (!pattern.test(value)) fail(path, `must match ${pattern}`); +} + +function exact(actual: unknown, expected: unknown, path: string): void { + if (!Object.is(actual, expected)) { + fail(path, `must equal ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); + } +} + +function errorMessage(value: unknown): string { + return value instanceof Error ? value.message : String(value); +} + +function fail(path: string, message: string): never { + throw new Error(`RFC64_V2_SWM_POLICY_MATRIX_INVALID at ${path}: ${message}`); +} diff --git a/devnet/_bootstrap/tsconfig.evidence.json b/devnet/_bootstrap/tsconfig.evidence.json index b4a4ee9afb..4a432a6ebd 100644 --- a/devnet/_bootstrap/tsconfig.evidence.json +++ b/devnet/_bootstrap/tsconfig.evidence.json @@ -11,6 +11,8 @@ "include": [ "rdf-canonize.d.ts", "rfc64-evidence.ts", - "rfc64-evidence.test.ts" + "rfc64-evidence.test.ts", + "rfc64-v2-swm-policy-matrix.ts", + "rfc64-v2-swm-policy-matrix.test.ts" ] } diff --git a/devnet/_bootstrap/vitest.evidence.config.ts b/devnet/_bootstrap/vitest.evidence.config.ts index 76c2d2b6d4..a9c43536d2 100644 --- a/devnet/_bootstrap/vitest.evidence.config.ts +++ b/devnet/_bootstrap/vitest.evidence.config.ts @@ -7,7 +7,10 @@ const repositoryRoot = resolve(import.meta.dirname, '../..'); export default defineConfig({ root: repositoryRoot, test: { - include: ['devnet/_bootstrap/rfc64-evidence.test.ts'], + include: [ + 'devnet/_bootstrap/rfc64-evidence.test.ts', + 'devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts', + ], pool: 'forks', sequence: { concurrent: false }, globals: false, diff --git a/devnet/rich-scenario/automated.test.ts b/devnet/rich-scenario/automated.test.ts index 00ac0f9735..89ed69c954 100644 --- a/devnet/rich-scenario/automated.test.ts +++ b/devnet/rich-scenario/automated.test.ts @@ -6,10 +6,19 @@ * pnpm test:devnet:rich-scenario */ import { describe, it, expect, beforeAll } from 'vitest'; -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { ethers, Wallet } from 'ethers'; import { buildUpdateSeal } from '../../packages/publisher/test/_helpers/seal.js'; +import { + digestRfc64V2SwmBindings, + probeBuiltRfc64V2ProductCapabilities, + requireRfc64V2ProductCapabilities, + verifyRfc64V2SwmPolicyMatrix, + type Rfc64V2SwmCellObservation, + type Rfc64V2SwmPolicyCell, + type Rfc64V2SwmReadObservation, +} from '../_bootstrap/harness.js'; const REPO_ROOT = resolve(__dirname, '../..'); const RPC = 'http://127.0.0.1:8545'; @@ -25,6 +34,8 @@ const RS_TIMEOUT_S = Number(process.env.RS_TIMEOUT ?? 90); const MINE_BLOCKS = Number(process.env.MINE_BLOCKS ?? 120); const SKIP_STAKE = process.env.SKIP_STAKE === '1'; const PUBLISH_PACE_MS = Number(process.env.PUBLISH_PACE_MS ?? 800); +const V2_SWM_POLICY_MATRIX = process.env.RICH_V2_SWM_POLICY_MATRIX === '1'; +const V2_SWM_SYNC_TIMEOUT_MS = Number(process.env.RICH_V2_SWM_SYNC_TIMEOUT_MS ?? 90_000); const TOTAL_PUBLISHES = WM_COUNT + SWM_COUNT + VM_COUNT; @@ -72,17 +83,34 @@ interface VmPublishRecord { rootSubject: string; } +interface MatrixAssetPublication { + readonly assertionUri: string; + readonly authorAgentAddress: string; + readonly contentSha256: string; + readonly kaId: string; + readonly marker: string; + readonly merkleRoot: string; + readonly subject: string; + readonly tripleCount: number; + readonly txHash: string; + readonly ual: string; +} + const state: { v: DevnetState | null } = { v: null }; const run: { stamp: number; edgeAgents: Record; cgs: CgRef[]; + outsiderAgent: AgentRef | null; + v2Matrix: Rfc64V2SwmCellObservation[]; vmPublishes: VmPublishRecord[]; updatesDone: number; } = { stamp: Date.now(), edgeAgents: {}, cgs: [], + outsiderAgent: null, + v2Matrix: [], vmPublishes: [], updatesDone: 0, }; @@ -330,6 +358,241 @@ async function assertionCreate( return rootSubject; } +function bindingLexicalValue(value: string): string { + if (value.startsWith('"') && value.endsWith('"')) { + try { + const parsed = JSON.parse(value); + if (typeof parsed === 'string') return parsed; + } catch { + // Keep the exact input below; malformed literals are rejected by create. + } + } + return value; +} + +function matrixBindings( + quads: readonly { predicate: string; object: string }[], +): Array> { + return quads.map((quad) => ({ + o: bindingLexicalValue(quad.object), + p: quad.predicate, + })); +} + +async function createAndPublishMatrixAsset( + node: DevnetNode, + agent: AgentRef, + cg: CgRef, + cell: Rfc64V2SwmPolicyCell, + index: number, +): Promise { + const name = `v2-matrix-${cell}-${run.stamp}`; + const marker = `v2-matrix-marker-${cell}-${run.stamp}`; + const subject = `urn:devnet:rfc64:v2:${cell}:${run.stamp}`; + const graph = `did:dkg:context-graph:${cg.localId}`; + const quads = [ + { + subject, + predicate: 'https://schema.org/name', + object: `"${marker}"`, + graph, + }, + { + subject, + predicate: 'https://schema.org/position', + object: `"${index + 1}"^^`, + graph, + }, + ]; + const created = await apiFetch(node, '/api/knowledge-assets', { + method: 'POST', + bearer: agent.authToken, + body: JSON.stringify({ + alsoShareSwm: true, + awaitCuratorAck: cgIsCurated(cg), + contextGraphId: cg.localId, + name, + quads, + }), + }); + const createBody = (await created.json().catch(() => null)) as { + assertionUri?: string; + authorAddress?: string; + errors?: unknown; + merkleRoot?: string; + promotedCount?: number; + status?: string; + } | null; + if ( + !created.ok + || createBody?.status !== 'swm-shared' + || createBody.promotedCount !== quads.length + || typeof createBody.assertionUri !== 'string' + || typeof createBody.authorAddress !== 'string' + || typeof createBody.merkleRoot !== 'string' + ) { + throw new Error( + `V2 matrix ${cell} create/share failed: HTTP ${created.status} ${JSON.stringify(createBody)}`, + ); + } + + const publishOnce = async () => { + const response = await apiFetch( + node, + `/api/knowledge-assets/${encodeURIComponent(name)}/vm/publish`, + { + method: 'POST', + bearer: agent.authToken, + body: JSON.stringify({ contextGraphId: cg.localId }), + }, + ); + const body = (await response.json().catch(() => null)) as { + authorAddress?: string; + kaId?: string; + merkleRoot?: string; + status?: string; + txHash?: string; + ual?: string; + } | null; + const tentative = body?.status === 'tentative' || body?.kaId === '0'; + if (!response.ok && !tentative) { + throw new Error( + `V2 matrix ${cell} VM publish failed: HTTP ${response.status} ${JSON.stringify(body)}`, + ); + } + return body; + }; + let published = await publishOnce(); + if (published?.status === 'tentative' || published?.kaId === '0') { + await new Promise((resolveWait) => setTimeout(resolveWait, 2_000)); + published = await publishOnce(); + } + if ( + published?.status !== 'confirmed' + || typeof published.kaId !== 'string' + || typeof published.ual !== 'string' + || typeof published.txHash !== 'string' + || typeof published.merkleRoot !== 'string' + || typeof published.authorAddress !== 'string' + ) { + throw new Error(`V2 matrix ${cell} VM publish was not exact: ${JSON.stringify(published)}`); + } + expect(published.merkleRoot).toBe(createBody.merkleRoot); + expect(published.authorAddress.toLowerCase()).toBe(agent.agentAddress.toLowerCase()); + expect(createBody.authorAddress.toLowerCase()).toBe(agent.agentAddress.toLowerCase()); + return { + assertionUri: createBody.assertionUri, + authorAgentAddress: published.authorAddress.toLowerCase(), + contentSha256: digestRfc64V2SwmBindings(matrixBindings(quads)), + kaId: published.kaId, + marker, + merkleRoot: published.merkleRoot.toLowerCase(), + subject, + tripleCount: quads.length, + txHash: published.txHash.toLowerCase(), + ual: published.ual.toLowerCase(), + }; +} + +function extractQueryBindings(value: unknown): Array> { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return []; + const root = value as Record; + const result = root.result; + if (result === null || typeof result !== 'object' || Array.isArray(result)) return []; + const bindings = (result as Record).bindings; + return Array.isArray(bindings) + ? bindings.filter( + (entry): entry is Record => + entry !== null && typeof entry === 'object' && !Array.isArray(entry), + ) + : []; +} + +async function queryMatrixAsset( + node: DevnetNode, + agent: AgentRef, + cg: CgRef, + asset: MatrixAssetPublication, +): Promise { + const response = await apiFetch(node, '/api/query', { + method: 'POST', + bearer: agent.authToken, + body: JSON.stringify({ + contextGraphId: cg.localId, + sparql: `SELECT ?p ?o WHERE { <${asset.subject}> ?p ?o } ORDER BY ?p ?o`, + view: 'shared-working-memory', + }), + }); + const bodyText = await response.text(); + let parsed: unknown = null; + try { + parsed = JSON.parse(bodyText); + } catch { + // A non-JSON denial remains a denial and is still privacy-scanned below. + } + const bindings = response.ok ? extractQueryBindings(parsed) : []; + const applied = response.status === 200 && bindings.length > 0; + return { + agentAddress: agent.agentAddress.toLowerCase(), + bindingCount: bindings.length, + contentSha256: applied ? digestRfc64V2SwmBindings(bindings) : null, + httpStatus: response.status, + leakedMarker: !applied && ( + bodyText.includes(asset.marker) + || bodyText.includes(asset.subject) + || bodyText.includes(asset.contentSha256) + ), + nodeNumber: node.num, + outcome: applied ? 'applied' : 'denied', + }; +} + +async function waitForMatrixAsset( + node: DevnetNode, + agent: AgentRef, + cg: CgRef, + asset: MatrixAssetPublication, +): Promise { + const deadline = Date.now() + V2_SWM_SYNC_TIMEOUT_MS; + let last: Rfc64V2SwmReadObservation | null = null; + do { + last = await queryMatrixAsset(node, agent, cg, asset); + if ( + last.outcome === 'applied' + && last.bindingCount === asset.tripleCount + && last.contentSha256 === asset.contentSha256 + ) return last; + await new Promise((resolveWait) => setTimeout(resolveWait, 1_000)); + } while (Date.now() < deadline); + throw new Error( + `V2 matrix ${cg.key} did not synchronize exact content to node${node.num}: ${JSON.stringify(last)}`, + ); +} + +async function proveStablePrivateDenial( + node: DevnetNode, + agent: AgentRef, + cg: CgRef, + asset: MatrixAssetPublication, +): Promise { + let last: Rfc64V2SwmReadObservation | null = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + last = await queryMatrixAsset(node, agent, cg, asset); + if ( + last.outcome !== 'denied' + || last.bindingCount !== 0 + || last.contentSha256 !== null + || last.leakedMarker + ) { + throw new Error( + `V2 matrix private outsider observed content on attempt ${attempt + 1}: ${JSON.stringify(last)}`, + ); + } + if (attempt < 2) await new Promise((resolveWait) => setTimeout(resolveWait, 750)); + } + return last!; +} + async function publishAssertionVm( node: DevnetNode, cgId: string, From 5219669ac4cf850418fe78d643958f19994be766 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:35:33 +0200 Subject: [PATCH 176/292] test(rfc64): prove public SWM policy parity --- devnet/rfc64-cp1-public-swm-parity/.gitignore | 2 + devnet/rfc64-cp1-public-swm-parity/README.md | 23 + .../launch-live.ts | 19 + .../rfc64-cp1-public-swm-parity/package.json | 18 + devnet/rfc64-cp1-public-swm-parity/run.ts | 545 ++++++++++++++++++ .../rfc64-cp1-public-swm-parity/tsconfig.json | 21 + .../verifier.test.ts | 68 +++ .../rfc64-cp1-public-swm-parity/verifier.ts | 102 ++++ .../verify-live.ts | 10 + .../adapter-process.ts | 108 +++- package.json | 5 + pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 1 + 13 files changed, 928 insertions(+), 3 deletions(-) create mode 100644 devnet/rfc64-cp1-public-swm-parity/.gitignore create mode 100644 devnet/rfc64-cp1-public-swm-parity/README.md create mode 100644 devnet/rfc64-cp1-public-swm-parity/launch-live.ts create mode 100644 devnet/rfc64-cp1-public-swm-parity/package.json create mode 100644 devnet/rfc64-cp1-public-swm-parity/run.ts create mode 100644 devnet/rfc64-cp1-public-swm-parity/tsconfig.json create mode 100644 devnet/rfc64-cp1-public-swm-parity/verifier.test.ts create mode 100644 devnet/rfc64-cp1-public-swm-parity/verifier.ts create mode 100644 devnet/rfc64-cp1-public-swm-parity/verify-live.ts diff --git a/devnet/rfc64-cp1-public-swm-parity/.gitignore b/devnet/rfc64-cp1-public-swm-parity/.gitignore new file mode 100644 index 0000000000..4318334edb --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/.gitignore @@ -0,0 +1,2 @@ +artifacts/ + diff --git a/devnet/rfc64-cp1-public-swm-parity/README.md b/devnet/rfc64-cp1-public-swm-parity/README.md new file mode 100644 index 0000000000..320ebf8279 --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/README.md @@ -0,0 +1,23 @@ +# RFC-64 CP1 public SWM parity + +This is the strict M1/CP1 gate. It boots one author and one receiver as separate +OS processes, each running the built production `DKGAgent`. Across the same two +processes it publishes one identical exact-set asset under both publicly +readable policy cells: + +- public/open: `accessPolicy=0`, `publishPolicy=1` +- public/curated: `accessPolicy=0`, `publishPolicy=0` + +The gate passes only when each receiver-side durable inventory is applied and +the exact activated N-Quads, content digest, and bundle digest are byte-equal +both to the authored corpus and across the two policy cells. + +Run from the repository root: + +```sh +pnpm test:m1:rfc64-public-swm-parity +``` + +The command performs a clean runtime build, runs the two-process proof, writes +`artifacts/cp1-public-swm-parity.json`, and verifies the closed evidence shape. + diff --git a/devnet/rfc64-cp1-public-swm-parity/launch-live.ts b/devnet/rfc64-cp1-public-swm-parity/launch-live.ts new file mode 100644 index 0000000000..123765913b --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/launch-live.ts @@ -0,0 +1,19 @@ +import { resolve } from 'node:path'; + +import { readCleanRepositoryHead } from '../rfc64-persistence-lifecycle/evidence.js'; +import { + buildGate2RuntimeManifestV1, + installGate2RuntimeLaunchReceiptV1, + runGate2CleanRuntimeBuildV1, +} from '../rfc64-gate2-multi-asset-completeness/runtime-provenance.ts'; + +const repoRoot = resolve(import.meta.dirname, '../..'); +const sourceCommit = readCleanRepositoryHead(repoRoot); +runGate2CleanRuntimeBuildV1(repoRoot); +if (readCleanRepositoryHead(repoRoot) !== sourceCommit) { + throw new Error('CP1 source HEAD changed during the clean runtime build'); +} +const manifest = buildGate2RuntimeManifestV1(repoRoot, sourceCommit); +installGate2RuntimeLaunchReceiptV1({ manifest, sourceCommit }); +await import('./run.ts'); + diff --git a/devnet/rfc64-cp1-public-swm-parity/package.json b/devnet/rfc64-cp1-public-swm-parity/package.json new file mode 100644 index 0000000000..25c88c87bb --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/package.json @@ -0,0 +1,18 @@ +{ + "name": "@devnet/rfc64-cp1-public-swm-parity", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Two-process RFC-64 CP1 public/open and public/curated exact SWM parity harness.", + "dependencies": { + "@origintrail-official/dkg-core": "workspace:*", + "ethers": "^6.16.0" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "node --import tsx --test verifier.test.ts", + "live": "node --import tsx launch-live.ts", + "verify": "node --import tsx verify-live.ts" + } +} + diff --git a/devnet/rfc64-cp1-public-swm-parity/run.ts b/devnet/rfc64-cp1-public-swm-parity/run.ts new file mode 100644 index 0000000000..201b9c64ae --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/run.ts @@ -0,0 +1,545 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; + +import { + CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + assertCanonicalGraphScopedAuthorSealV1, + buildAuthorAttestationTypedData, + computeContextGraphPolicyObjectDigestV1, + type CanonicalGraphScopedAuthorSealV1, + type ContextGraphPolicyV1, + type Digest32V1, + type EvmAddressV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +import { + atomicWriteExactBytes, + readCleanRepositoryHead, +} from '../rfc64-persistence-lifecycle/evidence.js'; +import { + ChildProcessRegistry, + cleanupPreservingPrimaryFailure, +} from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { + Gate2AgentChild, + type Gate2AgentEvent, +} from '../rfc64-gate2-multi-asset-completeness/agent-child.js'; +import { + GATE2_ADAPTER_PROTOCOL_VERSION, + GATE2_REAL_DKG_AGENT_ADAPTER_ID, +} from '../rfc64-gate2-multi-asset-completeness/model.js'; +import { + assertGate2RuntimeManifestEqualV1, + buildGate2RuntimeManifestV1, + consumeGate2RuntimeLaunchReceiptV1, +} from '../rfc64-gate2-multi-asset-completeness/runtime-provenance.ts'; +import { canonicalDocument, type CanonicalValue } from + '../rfc64-gate2-multi-asset-completeness/src/canonical.ts'; +import { + CP1_PUBLIC_SWM_PARITY_SCHEMA, + semanticSha256, + verifyCp1PublicSwmParity, +} from './verifier.ts'; + +const REPO_ROOT = resolve(import.meta.dirname, '../..'); +const GATE2_DIR = resolve(import.meta.dirname, '../rfc64-gate2-multi-asset-completeness'); +const ADAPTER_PROCESS = join(GATE2_DIR, 'adapter-process.ts'); +const RUNTIME_LOAD_HOOK = join(GATE2_DIR, 'runtime-load-hook.ts'); +const ARTIFACT = process.env.DKG_RFC64_CP1_ARTIFACT + ?? join(import.meta.dirname, 'artifacts/cp1-public-swm-parity.json'); +const PROCESS_TIMEOUT_MS = 90_000; +const NETWORK_ID = 'otp:20430'; +const OWNER_PRIVATE_KEY = `0x${'64'.repeat(32)}`; +const OWNER_WALLET = new ethers.Wallet(OWNER_PRIVATE_KEY); +const OWNER = OWNER_WALLET.address.toLowerCase() as EvmAddressV1; +const KAV10 = '0x4444444444444444444444444444444444444444'; +const DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: KAV10, +}); +// This is the exact two-triple corpus committed by ASSERTION_ROOT below. The +// same signed KA bundle is deliberately reused in both public policy cells. +const PROJECTION_NQUADS = + ' ' + + '"42"^^ .\n' + + ' "Alice" .\n'; +const ASSERTION_ROOT = + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f'; +const ROLE_MASTER_KEYS = Object.freeze({ author: '1a'.repeat(32), receiver: '2b'.repeat(32) }); + +interface CellSpec { + readonly cell: 'public-open' | 'public-curated'; + readonly contextGraphId: string; + readonly publishPolicy: 0 | 1; +} + +const CELLS: readonly CellSpec[] = Object.freeze([ + Object.freeze({ + cell: 'public-open', + contextGraphId: '0x1111111111111111111111111111111111111111/cp1-public-open', + publishPolicy: 1, + }), + Object.freeze({ + cell: 'public-curated', + contextGraphId: '0x1111111111111111111111111111111111111111/cp1-public-curated', + publishPolicy: 0, + }), +]); + +async function execute(): Promise { + const launch = consumeGate2RuntimeLaunchReceiptV1(); + const head = readCleanRepositoryHead(REPO_ROOT); + exact(head, launch.sourceCommit, 'launch source commit'); + assertGate2RuntimeManifestEqualV1( + buildGate2RuntimeManifestV1(REPO_ROOT, head), + launch.manifest, + ); + rmSync(ARTIFACT, { force: true }); + + const authorDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-cp1-author-')); + const receiverDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-cp1-receiver-')); + const registry = new ChildProcessRegistry(20_000); + let primaryFailure: unknown; + let operationFailed = true; + try { + const author = spawnAgent('author', authorDataDir, registry, launch.manifest.manifestDigest, head); + const receiver = spawnAgent( + 'receiver', + receiverDataDir, + registry, + launch.manifest.manifestDigest, + head, + ); + const [authorReady, receiverReady] = await Promise.all([ + author.waitFor('ready'), + receiver.waitFor('ready'), + ]); + requireReady(authorReady, 'author', launch.manifest.manifestDigest); + requireReady(receiverReady, 'receiver', launch.manifest.manifestDigest); + const authorPeerId = requiredString(authorReady.peerId, 'author peer ID'); + const receiverPeerId = requiredString(receiverReady.peerId, 'receiver peer ID'); + if (authorPeerId === receiverPeerId) throw new Error('CP1 peer identities are not distinct'); + const authorPid = requiredPid(author.child.pid, 'author PID'); + const receiverPid = requiredPid(receiver.child.pid, 'receiver PID'); + if (authorPid === receiverPid) throw new Error('CP1 process identities are not distinct'); + await connectBothWays(author, receiver, authorReady, receiverReady); + + const seal = await authorSeal(70n); + const cellEvidence: Record[] = []; + for (const [index, spec] of CELLS.entries()) { + const policy = publicPolicy(spec); + const policyDigest = digestForPolicy(policy); + const [authorAccepted, receiverAccepted] = await Promise.all([ + acceptPolicy(author, `author-${spec.cell}`, policy, policyDigest), + acceptPolicy(receiver, `receiver-${spec.cell}`, policy, policyDigest), + ]); + exact(authorAccepted, policyDigest, `${spec.cell} author policy digest`); + exact(receiverAccepted, policyDigest, `${spec.cell} receiver policy digest`); + + const genesis = output(await author.request( + 'publishCatalogGenesis', + `${spec.cell}-genesis`, + 'operation-completed', + { + scope: catalogScope(spec.contextGraphId), + authorPrivateKey: OWNER_PRIVATE_KEY, + issuedAt: String(1773900000000 + index * 10_000), + catalogIssuerDelegationEffectiveAt: '1773899999000', + catalogIssuerDelegationExpiresAt: '1774000000000', + }, + ), `${spec.cell} genesis`); + const genesisAnnouncement = record(genesis.announcement, `${spec.cell} genesis announcement`); + exact(genesisAnnouncement.policyDigest, policyDigest, `${spec.cell} genesis policy digest`); + await announceAndDrain( + author, + receiver, + genesisAnnouncement, + receiverPeerId, + `${spec.cell}-genesis`, + ); + + const publication = output(await author.request( + 'publishCatalogExactSetSuccessor', + `${spec.cell}-successor`, + 'operation-completed', + { + previousHead: stagedHead(genesis, `${spec.cell} genesis`), + authorPrivateKey: OWNER_PRIVATE_KEY, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + assets: [{ + assertionCoordinate: 'cp1-byte-identical-public-corpus', + projectionNQuads: PROJECTION_NQUADS, + seal, + }], + deployment: DEPLOYMENT, + issuedAt: String(1773900001000 + index * 10_000), + }, + ), `${spec.cell} successor`); + const announcement = record(publication.announcement, `${spec.cell} successor announcement`); + exact(announcement.policyDigest, policyDigest, `${spec.cell} successor policy digest`); + await announceAndDrain( + author, + receiver, + announcement, + receiverPeerId, + `${spec.cell}-successor`, + ); + const headDigest = requiredDigest(publication.headObjectDigest, `${spec.cell} head digest`); + const synchronization = output(await receiver.request( + 'exactInventoryReadback', + `${spec.cell}-inventory`, + 'operation-completed', + { catalogHeadDigest: headDigest }, + ), `${spec.cell} inventory`); + const rows = array(synchronization.rows, `${spec.cell} inventory rows`); + if (rows.length !== 1) throw new Error(`${spec.cell} did not apply exactly one row`); + const row = record(rows[0], `${spec.cell} inventory row`); + const semantic = output(await receiver.request( + 'semanticGraphReadback', + `${spec.cell}-semantic`, + 'operation-completed', + { swmGraph: requiredString(row.swmGraph, `${spec.cell} SWM graph`) }, + ), `${spec.cell} semantic readback`); + const projectionNQuads = requiredString( + semantic.projectionNQuads, + `${spec.cell} projection N-Quads`, + ); + exact(projectionNQuads, PROJECTION_NQUADS, `${spec.cell} exact semantic bytes`); + const publishedAssets = array(publication.assets, `${spec.cell} published assets`); + if (publishedAssets.length !== 1) throw new Error(`${spec.cell} publication has wrong row count`); + const publishedAsset = record(publishedAssets[0], `${spec.cell} published asset`); + exact(publishedAsset.bundleDigest, row.bundleDigest, `${spec.cell} bundle transfer digest`); + exact(publishedAsset.contentDigest, row.contentDigest, `${spec.cell} content transfer digest`); + cellEvidence.push({ + accessPolicy: 0, + activatedTripleCount: requiredInteger( + synchronization.activatedTripleCount, + `${spec.cell} activated triple count`, + ), + announcementPolicyDigest: requiredDigest( + announcement.policyDigest, + `${spec.cell} announcement policy digest`, + ), + announcedPeerId: receiverPeerId, + appliedHeadStatus: requiredString( + synchronization.appliedHeadStatus, + `${spec.cell} applied status`, + ), + authorPolicyDigest: authorAccepted, + bundleDigest: requiredDigest(row.bundleDigest, `${spec.cell} bundle digest`), + cell: spec.cell, + contentDigest: requiredDigest(row.contentDigest, `${spec.cell} content digest`), + contextGraphId: spec.contextGraphId, + inventoryRowCount: requiredInteger( + synchronization.inventoryRowCount, + `${spec.cell} inventory row count`, + ), + projectionNQuads, + publishPolicy: spec.publishPolicy, + receiverPolicyDigest: receiverAccepted, + semanticSha256: semanticSha256(projectionNQuads), + }); + } + + const [authorStopped, receiverStopped] = await Promise.all([ + author.stop('cp1-author-stop'), + receiver.stop('cp1-receiver-stop'), + ]); + const artifact = { + cells: cellEvidence, + expectedProjectionNQuads: PROJECTION_NQUADS, + expectedSemanticSha256: semanticSha256(PROJECTION_NQUADS), + peers: { authorPeerId, receiverPeerId }, + processBoundary: { + authorExitCode: authorStopped.exit.code, + authorPid, + receiverExitCode: receiverStopped.exit.code, + receiverPid, + }, + repository: { testedHeadCommit: head, trackedSourceClean: true }, + runtimeManifestDigest: launch.manifest.manifestDigest, + schemaVersion: CP1_PUBLIC_SWM_PARITY_SCHEMA, + status: 'PASS', + }; + verifyCp1PublicSwmParity(artifact); + const publication = atomicWriteExactBytes( + ARTIFACT, + new TextEncoder().encode(canonicalDocument(artifact as unknown as CanonicalValue)), + ); + process.stdout.write( + `[rfc64-cp1] PASS artifact=${ARTIFACT} sha256=${publication.sha256}\n`, + ); + operationFailed = false; + } catch (error) { + primaryFailure = error; + } finally { + await cleanupPreservingPrimaryFailure({ + operationFailed, + primaryFailure, + cleanup: () => registry.terminateAllThenCleanup(() => { + rmSync(authorDataDir, { recursive: true, force: true }); + rmSync(receiverDataDir, { recursive: true, force: true }); + }), + reportSecondaryFailure: (primary, secondary) => { + process.stderr.write(`[rfc64-cp1] cleanup failure after ${String(primary)}: ${String(secondary)}\n`); + }, + }); + } +} + +function publicPolicy(spec: CellSpec): ContextGraphPolicyV1 { + return { + networkId: NETWORK_ID as never, + contextGraphId: spec.contextGraphId as never, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: spec.publishPolicy, + publishAuthority: spec.publishPolicy === 0 ? OWNER : null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: OWNER, + ownerAuthorityEra: '0', + }, + effectiveAt: '0', + issuedAt: '0', + } as unknown as ContextGraphPolicyV1; +} + +function digestForPolicy(policy: ContextGraphPolicyV1): Digest32V1 { + return computeContextGraphPolicyObjectDigestV1({ + issuer: OWNER, + objectType: CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + payload: policy, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1); +} + +function catalogScope(contextGraphId: string): Record { + return { + networkId: NETWORK_ID, + contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: OWNER, + era: '0', + bucketCount: '1', + }; +} + +async function acceptPolicy( + child: Gate2AgentChild, + requestId: string, + policy: ContextGraphPolicyV1, + policyDigest: Digest32V1, +): Promise { + const accepted = output(await child.request( + 'acceptPolicySnapshot', + requestId, + 'operation-completed', + { policy, policyDigest }, + ), `${requestId} accepted policy`); + return requiredDigest(accepted.policyDigest, `${requestId} policy digest`); +} + +async function announceAndDrain( + author: Gate2AgentChild, + receiver: Gate2AgentChild, + announcement: Record, + receiverPeerId: string, + requestId: string, +): Promise { + const delivery = output(await author.request( + 'announce', + `${requestId}-announce`, + 'operation-completed', + { announcement, peers: [receiverPeerId] }, + ), `${requestId} delivery`); + exact(delivery.announcedPeers, [receiverPeerId], `${requestId} acknowledged peer`); + exact(delivery.failedPeers, [], `${requestId} failed peers`); + await receiver.request( + 'awaitReceiverIdle', + `${requestId}-idle`, + 'receiver-idle', + ); +} + +async function connectBothWays( + author: Gate2AgentChild, + receiver: Gate2AgentChild, + authorReady: Gate2AgentEvent, + receiverReady: Gate2AgentEvent, +): Promise { + await Promise.all([ + author.request('dial', 'cp1-dial-author', 'dialed', { + multiaddr: receiverReady.multiaddr, + peerId: receiverReady.peerId, + }), + receiver.request('dial', 'cp1-dial-receiver', 'dialed', { + multiaddr: authorReady.multiaddr, + peerId: authorReady.peerId, + }), + ]); +} + +function spawnAgent( + role: 'author' | 'receiver', + dataDir: string, + registry: ChildProcessRegistry, + runtimeManifestDigest: string, + sourceCommit: string, +): Gate2AgentChild { + const childEnv = { ...process.env }; + delete childEnv.NODE_OPTIONS; + delete childEnv.NODE_PATH; + delete childEnv.TSX_TSCONFIG_PATH; + return new Gate2AgentChild({ + eventTimeoutMs: PROCESS_TIMEOUT_MS, + registry, + role, + spawn: { + command: process.execPath, + args: ['--import', 'tsx', '--import', RUNTIME_LOAD_HOOK, ADAPTER_PROCESS, role], + cwd: REPO_ROOT, + env: { + ...childEnv, + DKG_RFC64_GATE2_ADAPTER_DATA_DIR: dataDir, + DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX: ROLE_MASTER_KEYS[role], + DKG_RFC64_GATE2_RUNTIME_MANIFEST_DIGEST: runtimeManifestDigest, + DKG_RFC64_GATE2_RUNTIME_SOURCE_COMMIT: sourceCommit, + NODE_ENV: 'production', + }, + }, + }); +} + +function requireReady( + event: Gate2AgentEvent, + role: 'author' | 'receiver', + manifestDigest: string, +): void { + exact(event.role, role, `${role} ready role`); + exact(event.adapterId, GATE2_REAL_DKG_AGENT_ADAPTER_ID, `${role} adapter`); + exact(event.protocolVersion, GATE2_ADAPTER_PROTOCOL_VERSION, `${role} protocol`); + exact(event.agentClass, 'DKGAgent', `${role} agent class`); + exact(event.catalogServiceStarted, true, `${role} catalog service`); + exact(event.runtimeBuildManifestDigest, manifestDigest, `${role} runtime manifest`); + const capabilities = record(event.capabilities, `${role} capabilities`); + for (const capability of [ + 'acceptPolicySnapshot', + 'announce', + 'exactInventoryReadback', + 'publishPolicyBoundExactSetSuccessor', + 'publishPolicyBoundGenesis', + ]) exact(capabilities[capability], true, `${role} capability ${capability}`); + requiredString(event.peerId, `${role} peer ID`); + requiredString(event.multiaddr, `${role} multiaddr`); +} + +async function authorSeal(kaNumber: bigint): Promise { + const kaId = ((BigInt(OWNER) << 96n) | kaNumber).toString(); + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(DEPLOYMENT.assertedAtChainId), + kav10Address: DEPLOYMENT.assertedAtKav10Address as never, + merkleRoot: ethers.getBytes(ASSERTION_ROOT), + authorAddress: OWNER, + reservedKaId: BigInt(kaId), + }); + const signature = ethers.Signature.from(await OWNER_WALLET.signTypedData( + typedData.domain, + typedData.types, + typedData.message, + )); + const seal = { + assertionMerkleRoot: ASSERTION_ROOT, + authorAddress: OWNER, + authorAttestationR: signature.r, + authorAttestationVS: signature.yParityAndS, + authorSchemeVersion: '1', + assertedAtChainId: DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10, + reservedKaId: kaId, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: `did:dkg:${NETWORK_ID}/${OWNER}/${kaNumber}`, + assertionVersion: '1', + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(seal); + return seal; +} + +function stagedHead(value: Record, label: string): Record { + return { + objectDigest: requiredDigest(value.headObjectDigest, `${label} object digest`), + signatureVariantDigest: requiredDigest( + value.signatureVariantDigest, + `${label} signature variant digest`, + ), + }; +} + +function output(event: Gate2AgentEvent, label: string): Record { + return record(event.output, `${label} output`); +} + +function record(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value as Record; +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`); + return value; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${label} is missing`); + return value; +} + +function requiredDigest(value: unknown, label: string): string { + const result = requiredString(value, label); + if (!/^0x[0-9a-f]{64}$/u.test(result)) throw new TypeError(`${label} is not a digest`); + return result; +} + +function requiredInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new TypeError(`${label} is not a non-negative integer`); + } + return value as number; +} + +function requiredPid(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) throw new TypeError(`${label} missing`); + return value as number; +} + +function exact(actual: unknown, expected: unknown, label: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${label} mismatch: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`); + } +} + +await execute(); diff --git a/devnet/rfc64-cp1-public-swm-parity/tsconfig.json b/devnet/rfc64-cp1-public-swm-parity/tsconfig.json new file mode 100644 index 0000000000..05541208d1 --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "allowImportingTsExtensions": true, + "noEmit": true, + "rootDir": "../..", + "skipLibCheck": true, + "sourceMap": false, + "types": ["node"] + }, + "include": [ + "*.ts", + "../rfc64-gate2-multi-asset-completeness/agent-child.ts", + "../rfc64-gate2-multi-asset-completeness/model.ts", + "../rfc64-gate2-multi-asset-completeness/runtime-provenance.ts", + "../rfc64-persistence-lifecycle/evidence.ts", + "../rfc64-persistence-lifecycle/process-lifecycle.ts" + ] +} diff --git a/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts b/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts new file mode 100644 index 0000000000..0a3bc4518d --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + CP1_PUBLIC_SWM_PARITY_SCHEMA, + semanticSha256, + verifyCp1PublicSwmParity, +} from './verifier.ts'; + +const projection = ' "RFC-64 CP1" .\n' + + ' "1" .\n'; +const digest = (byte: string) => `0x${byte.repeat(64)}`; + +function fixture(): Record { + const cell = (name: string, publishPolicy: number, policyByte: string) => ({ + accessPolicy: 0, + activatedTripleCount: 2, + announcementPolicyDigest: digest(policyByte), + announcedPeerId: 'receiver-peer', + appliedHeadStatus: 'applied', + authorPolicyDigest: digest(policyByte), + bundleDigest: digest('a'), + cell: name, + contentDigest: digest('b'), + contextGraphId: `cg-${name}`, + inventoryRowCount: 1, + projectionNQuads: projection, + publishPolicy, + receiverPolicyDigest: digest(policyByte), + semanticSha256: semanticSha256(projection), + }); + return { + cells: [cell('public-open', 1, '1'), cell('public-curated', 0, '2')], + expectedProjectionNQuads: projection, + expectedSemanticSha256: semanticSha256(projection), + peers: { authorPeerId: 'author-peer', receiverPeerId: 'receiver-peer' }, + processBoundary: { + authorExitCode: 0, + authorPid: 100, + receiverExitCode: 0, + receiverPid: 101, + }, + repository: { testedHeadCommit: 'a'.repeat(40), trackedSourceClean: true }, + schemaVersion: CP1_PUBLIC_SWM_PARITY_SCHEMA, + status: 'PASS', + }; +} + +test('accepts exact two-cell public SWM parity', () => { + assert.equal(verifyCp1PublicSwmParity(fixture()).status, 'PASS'); +}); + +test('rejects publish-axis, process-boundary, and byte-parity drift', () => { + const wrongPolicy = structuredClone(fixture()) as { cells: Array> }; + wrongPolicy.cells[1]!.publishPolicy = 1; + assert.throws(() => verifyCp1PublicSwmParity(wrongPolicy), /publishPolicy/); + + const samePid = structuredClone(fixture()) as { + processBoundary: Record; + }; + samePid.processBoundary.receiverPid = 100; + assert.throws(() => verifyCp1PublicSwmParity(samePid), /PIDs must differ/); + + const changedBytes = structuredClone(fixture()) as { cells: Array> }; + changedBytes.cells[1]!.projectionNQuads = `${projection}# drift`; + assert.throws(() => verifyCp1PublicSwmParity(changedBytes), /projectionNQuads/); +}); + diff --git a/devnet/rfc64-cp1-public-swm-parity/verifier.ts b/devnet/rfc64-cp1-public-swm-parity/verifier.ts new file mode 100644 index 0000000000..b4a7a3be35 --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/verifier.ts @@ -0,0 +1,102 @@ +import { createHash } from 'node:crypto'; + +export const CP1_PUBLIC_SWM_PARITY_SCHEMA = + 'dkg-rfc64-cp1-public-swm-parity-v1' as const; + +const DIGEST = /^0x[0-9a-f]{64}$/u; +const SHA256 = /^sha256:[0-9a-f]{64}$/u; +const CELLS = ['public-open', 'public-curated'] as const; + +export function semanticSha256(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +export function verifyCp1PublicSwmParity(value: unknown): Record { + const root = record(value, '$'); + exact(root.schemaVersion, CP1_PUBLIC_SWM_PARITY_SCHEMA, '$.schemaVersion'); + exact(root.status, 'PASS', '$.status'); + const repository = record(root.repository, '$.repository'); + string(repository.testedHeadCommit, '$.repository.testedHeadCommit'); + exact(repository.trackedSourceClean, true, '$.repository.trackedSourceClean'); + const boundary = record(root.processBoundary, '$.processBoundary'); + integer(boundary.authorPid, '$.processBoundary.authorPid'); + integer(boundary.receiverPid, '$.processBoundary.receiverPid'); + if (boundary.authorPid === boundary.receiverPid) fail('$.processBoundary', 'PIDs must differ'); + exact(boundary.authorExitCode, 0, '$.processBoundary.authorExitCode'); + exact(boundary.receiverExitCode, 0, '$.processBoundary.receiverExitCode'); + const peers = record(root.peers, '$.peers'); + const authorPeerId = string(peers.authorPeerId, '$.peers.authorPeerId'); + const receiverPeerId = string(peers.receiverPeerId, '$.peers.receiverPeerId'); + if (authorPeerId === receiverPeerId) fail('$.peers', 'peer IDs must differ'); + const expectedProjection = string(root.expectedProjectionNQuads, '$.expectedProjectionNQuads'); + const expectedSemanticDigest = semanticSha256(expectedProjection); + exact(root.expectedSemanticSha256, expectedSemanticDigest, '$.expectedSemanticSha256'); + + if (!Array.isArray(root.cells) || root.cells.length !== 2) { + fail('$.cells', 'must contain exactly public-open and public-curated'); + } + const cells = root.cells.map((entry, index) => { + const path = `$.cells[${index}]`; + const cell = record(entry, path); + exact(cell.cell, CELLS[index], `${path}.cell`); + exact(cell.accessPolicy, 0, `${path}.accessPolicy`); + exact(cell.publishPolicy, index === 0 ? 1 : 0, `${path}.publishPolicy`); + string(cell.contextGraphId, `${path}.contextGraphId`); + const policyDigest = digest(cell.authorPolicyDigest, `${path}.authorPolicyDigest`); + exact(cell.receiverPolicyDigest, policyDigest, `${path}.receiverPolicyDigest`); + exact(cell.announcementPolicyDigest, policyDigest, `${path}.announcementPolicyDigest`); + exact(cell.announcedPeerId, receiverPeerId, `${path}.announcedPeerId`); + exact(cell.inventoryRowCount, 1, `${path}.inventoryRowCount`); + exact(cell.activatedTripleCount, 2, `${path}.activatedTripleCount`); + exact(cell.appliedHeadStatus, 'applied', `${path}.appliedHeadStatus`); + const bundleDigest = digest(cell.bundleDigest, `${path}.bundleDigest`); + const contentDigest = digest(cell.contentDigest, `${path}.contentDigest`); + const projection = string(cell.projectionNQuads, `${path}.projectionNQuads`); + exact(projection, expectedProjection, `${path}.projectionNQuads`); + const semanticDigest = sha256(cell.semanticSha256, `${path}.semanticSha256`); + exact(semanticDigest, expectedSemanticDigest, `${path}.semanticSha256`); + return { bundleDigest, contentDigest, semanticDigest }; + }); + exact(cells[1]!.bundleDigest, cells[0]!.bundleDigest, '$.cells bundle parity'); + exact(cells[1]!.contentDigest, cells[0]!.contentDigest, '$.cells content parity'); + exact(cells[1]!.semanticDigest, cells[0]!.semanticDigest, '$.cells semantic parity'); + return root; +} + +function record(value: unknown, path: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'must be an object'); + } + return value as Record; +} + +function string(value: unknown, path: string): string { + if (typeof value !== 'string' || value.length === 0) fail(path, 'must be a non-empty string'); + return value; +} + +function integer(value: unknown, path: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) fail(path, 'must be a positive PID'); + return value as number; +} + +function digest(value: unknown, path: string): string { + const result = string(value, path); + if (!DIGEST.test(result)) fail(path, 'must be a canonical digest'); + return result; +} + +function sha256(value: unknown, path: string): string { + const result = string(value, path); + if (!SHA256.test(result)) fail(path, 'must be a canonical sha256 digest'); + return result; +} + +function exact(actual: unknown, expected: unknown, path: string): void { + if (actual !== expected) fail(path, `expected ${JSON.stringify(expected)}`); +} + +function fail(path: string, message: string): never { + throw new Error(`RFC64_CP1_PUBLIC_SWM_PARITY_INVALID at ${path}: ${message}`); +} + diff --git a/devnet/rfc64-cp1-public-swm-parity/verify-live.ts b/devnet/rfc64-cp1-public-swm-parity/verify-live.ts new file mode 100644 index 0000000000..caa4eabf9c --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/verify-live.ts @@ -0,0 +1,10 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { verifyCp1PublicSwmParity } from './verifier.ts'; + +const path = process.env.DKG_RFC64_CP1_ARTIFACT + ?? join(import.meta.dirname, 'artifacts/cp1-public-swm-parity.json'); +verifyCp1PublicSwmParity(JSON.parse(readFileSync(path, 'utf8'))); +process.stdout.write(`[rfc64-cp1] PASS ${path}\n`); + diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts index 02cad8e9f5..0ed8979c1a 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -13,6 +13,7 @@ import { import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; import { computeControlSignatureVariantDigestHex, + parseDeterministicKnowledgeAssetUal, type AuthorCatalogScopeV1, type Digest32V1, type EvmAddressV1, @@ -169,6 +170,13 @@ async function handle(command: Command): Promise { }); return; } + case 'acceptPolicySnapshot': { + const accepted = currentAgent.acceptRfc64CatalogAccessSnapshotV1( + plainRecord(command.input, 'acceptPolicySnapshot input') as never, + ); + emitOperationResult(command, accepted); + return; + } case 'publishGenesis': { requireRole('author'); const input = plainRecord(command.input, 'publishGenesis input'); @@ -183,6 +191,23 @@ async function handle(command: Command): Promise { emitOperationResult(command, output); return; } + case 'publishCatalogGenesis': { + requireRole('author'); + const input = plainRecord(command.input, 'publishCatalogGenesis input'); + const authorPrivateKey = requiredString( + input.authorPrivateKey, + 'publishCatalogGenesis.authorPrivateKey', + ); + const forwarded: Record = { + ...input, + author: new ethers.Wallet(authorPrivateKey), + peers: [], + }; + delete forwarded.authorPrivateKey; + const output = await currentAgent.publishAuthorCatalogGenesisV1(forwarded as never); + emitOperationResult(command, output); + return; + } case 'publishExactSetSuccessor': { requireRole('author'); const input = plainRecord(command.input, 'publishExactSetSuccessor input'); @@ -235,6 +260,65 @@ async function handle(command: Command): Promise { emitOperationResult(command, { ...result, assets: Object.freeze(assetsWithReceipts) }); return; } + case 'publishCatalogExactSetSuccessor': { + requireRole('author'); + const input = plainRecord(command.input, 'publishCatalogExactSetSuccessor input'); + const authorPrivateKey = requiredString( + input.authorPrivateKey, + 'publishCatalogExactSetSuccessor.authorPrivateKey', + ); + const assets = plainArray( + input.assets, + 'publishCatalogExactSetSuccessor.assets', + ).map((value, index) => { + const asset = plainRecord(value, `publishCatalogExactSetSuccessor.assets[${index}]`); + const projectionNQuads = requiredString( + asset.projectionNQuads, + `publishCatalogExactSetSuccessor.assets[${index}].projectionNQuads`, + ); + const forwarded: Record = { + ...asset, + projectionBytes: new TextEncoder().encode(projectionNQuads), + }; + delete forwarded.projectionNQuads; + return forwarded; + }); + const forwarded: Record = { + ...input, + author: new ethers.Wallet(authorPrivateKey), + assets, + peers: [], + }; + delete forwarded.authorPrivateKey; + const output = await currentAgent.publishAuthorCatalogExactSetSuccessorV1( + forwarded as never, + ); + const result = plainRecord(output, 'publishCatalogExactSetSuccessor output'); + const outputAssets = plainArray( + result.assets, + 'publishCatalogExactSetSuccessor.output.assets', + ); + const assetsWithReceipts = await Promise.all(outputAssets.map(async (value, index) => { + const asset = plainRecord( + value, + `publishCatalogExactSetSuccessor.output.assets[${index}]`, + ); + const bundleDigest = requiredDigest( + asset.bundleDigest, + `publishCatalogExactSetSuccessor.output.assets[${index}].bundleDigest`, + ); + const stagedBundle = await currentAgent.readRfc64StagedKaBundleV1(bundleDigest); + if (stagedBundle === null) { + throw new Error(`published catalog bundle ${bundleDigest} is absent from durable storage`); + } + return Object.freeze({ + ...asset, + stagedBundleByteLength: stagedBundle.byteLength, + }); + })); + emitOperationResult(command, { ...result, assets: Object.freeze(assetsWithReceipts) }); + return; + } case 'announce': { requireRole('author'); const output = await currentAgent.announceRfc64PublicCatalogHeadV1( @@ -388,13 +472,20 @@ function wireSynchronizationEvidence(output: unknown): unknown { throw new Error('synchronization rows disagree on the verified control-object closure'); } verifiedControlObjectCount = rowControlObjectCount; + const kaUal = requiredString(row.kaUal, `synchronization.rows[${index}].kaUal`); return Object.freeze({ - kaId: canonicalDecimalWire(row.kaId, `synchronization.rows[${index}].kaId`), + kaId: canonicalDecimalWire( + row.kaId ?? packedKaIdFromUal(kaUal), + `synchronization.rows[${index}].kaId`, + ), catalogRowDigest: row.catalogRowDigest, contentDigest: row.contentDigest, - sealDigest: row.sealDigest, + // Legacy one-row evidence predates sealDigest on this readback surface. + // Multi-row evidence always carries it; keep the absence explicit on the + // adapter wire instead of emitting `undefined` or inventing a digest. + sealDigest: row.sealDigest ?? null, bundleDigest: row.bundleDigest, - kaUal: row.kaUal, + kaUal, activatedTripleCount: row.activatedTripleCount, swmGraph: row.swmGraph, }); @@ -413,6 +504,11 @@ function wireSynchronizationEvidence(output: unknown): unknown { }); } +function packedKaIdFromUal(kaUal: string): string { + const parsed = parseDeterministicKnowledgeAssetUal(kaUal); + return ((BigInt(parsed.agentAddress) << 96n) | BigInt(parsed.kaNumber)).toString(); +} + function canonicalDecimalWire(value: unknown, label: string): string { if (typeof value === 'bigint' && value >= 0n) return value.toString(); if (typeof value === 'string' && /^(0|[1-9][0-9]*)$/u.test(value)) return value; @@ -422,6 +518,8 @@ function canonicalDecimalWire(value: unknown, label: string): string { function inspectGate2ProductCapabilities(currentAgent: DKGAgent): Record { const surface = currentAgent as unknown as Record; return Object.freeze({ + acceptPolicySnapshot: + typeof surface.acceptRfc64CatalogAccessSnapshotV1 === 'function', acceptOpenPolicy: typeof surface.acceptOpenContextGraphPolicyV1 === 'function', announce: typeof surface.announceRfc64PublicCatalogHeadV1 === 'function', appliedHeadReadback: typeof surface.readRfc64AppliedCatalogHeadV1 === 'function', @@ -430,6 +528,10 @@ function inspectGate2ProductCapabilities(currentAgent: DKGAgent): Record Date: Wed, 22 Jul 2026 00:38:57 +0200 Subject: [PATCH 177/292] Revert "test(devnet): checkpoint RFC-64 policy matrix harness" This reverts commit 7d82e6b4ea52c7c823831f21895a33a3b8840323. --- devnet/_bootstrap/harness.ts | 15 - devnet/_bootstrap/rfc64-evidence.test.ts | 1 - .../rfc64-v2-swm-policy-matrix.test.ts | 166 -------- .../_bootstrap/rfc64-v2-swm-policy-matrix.ts | 369 ------------------ devnet/_bootstrap/tsconfig.evidence.json | 4 +- devnet/_bootstrap/vitest.evidence.config.ts | 5 +- devnet/rich-scenario/automated.test.ts | 265 +------------ 7 files changed, 3 insertions(+), 822 deletions(-) delete mode 100644 devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts delete mode 100644 devnet/_bootstrap/rfc64-v2-swm-policy-matrix.ts diff --git a/devnet/_bootstrap/harness.ts b/devnet/_bootstrap/harness.ts index f4baa4e372..72baaf28bf 100644 --- a/devnet/_bootstrap/harness.ts +++ b/devnet/_bootstrap/harness.ts @@ -32,21 +32,6 @@ import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; import { ethers } from 'ethers'; -export { - RFC64_V2_FUTURE_TRANSITION_CAPABILITY, - RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES, - RFC64_V2_SWM_POLICY_CELLS, - digestRfc64V2SwmBindings, - inspectRfc64V2ProductCapabilities, - probeBuiltRfc64V2ProductCapabilities, - requireRfc64V2ProductCapabilities, - verifyRfc64V2SwmPolicyMatrix, - type Rfc64V2ProductCapabilityInspection, - type Rfc64V2SwmCellObservation, - type Rfc64V2SwmReadObservation, - type VerifiedRfc64V2SwmPolicyMatrix, -} from './rfc64-v2-swm-policy-matrix.js'; - export const REPO_ROOT = resolve(import.meta.dirname, '../..'); export const RPC = process.env.DEVNET_RPC ?? 'http://127.0.0.1:8545'; export const DEVNET_DIR = join(REPO_ROOT, '.devnet'); diff --git a/devnet/_bootstrap/rfc64-evidence.test.ts b/devnet/_bootstrap/rfc64-evidence.test.ts index 51b456e786..fd0d061334 100644 --- a/devnet/_bootstrap/rfc64-evidence.test.ts +++ b/devnet/_bootstrap/rfc64-evidence.test.ts @@ -70,7 +70,6 @@ describe('RFC-64 evidence Vitest discovery', () => { expect(config.root).toBe(resolve(import.meta.dirname, '../..')); expect(config.test?.include).toEqual([ 'devnet/_bootstrap/rfc64-evidence.test.ts', - 'devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts', ]); expect(include).toBeDefined(); expect(isAbsolute(include!)).toBe(false); diff --git a/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts b/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts deleted file mode 100644 index bea895113f..0000000000 --- a/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - RFC64_V2_FUTURE_TRANSITION_CAPABILITY, - RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES, - digestRfc64V2SwmBindings, - inspectRfc64V2ProductCapabilities, - requireRfc64V2ProductCapabilities, - verifyRfc64V2SwmPolicyMatrix, - type Rfc64V2SwmCellObservation, -} from './rfc64-v2-swm-policy-matrix.js'; - -const MEMBER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; -const OUTSIDER = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; - -function digest(byte: string, prefix = 'sha256:'): string { - return `${prefix}${byte.repeat(64)}`; -} - -function cell( - name: Rfc64V2SwmCellObservation['cell'], - index: number, - accessPolicy: 0 | 1, - publishPolicy: 0 | 1, -): Rfc64V2SwmCellObservation { - const authorAgentAddress = `0x${String(index + 1).padStart(40, '0')}`; - const number = BigInt(index + 11); - const kaId = ((BigInt(authorAgentAddress) << 96n) | number).toString(); - const contentSha256 = digest(String(index + 1)); - const positive = { - agentAddress: MEMBER, - bindingCount: 2, - contentSha256, - httpStatus: 200, - leakedMarker: false, - nodeNumber: 6, - outcome: 'applied' as const, - }; - return { - accessPolicy, - assertionUri: `did:dkg:context-graph:matrix-${index}/assertion/${authorAgentAddress}/asset`, - authorAgentAddress, - cell: name, - contentSha256, - contextGraphId: `matrix-${index}`, - kaId, - memberRead: positive, - merkleRoot: digest(String(index + 5), '0x'), - outsiderRead: accessPolicy === 0 - ? { ...positive, agentAddress: OUTSIDER, nodeNumber: 4 } - : { - agentAddress: OUTSIDER, - bindingCount: 0, - contentSha256: null, - httpStatus: 403, - leakedMarker: false, - nodeNumber: 4, - outcome: 'denied', - }, - policyLifecycle: 'immutable-per-cell-snapshot', - publishPolicy, - tripleCount: 2, - txHash: digest(((index + 9) % 16).toString(16), '0x'), - ual: `did:dkg:otp:20430/${authorAgentAddress}/${number}`, - }; -} - -function fixture(): Rfc64V2SwmCellObservation[] { - return [ - cell('public-open', 0, 0, 1), - cell('public-curated', 1, 0, 0), - cell('private-open', 2, 1, 1), - cell('private-curated', 3, 1, 0), - ]; -} - -describe('RFC-64 V2 rich-scenario SWM policy matrix contract', () => { - it('accepts the exact four cells and proves publishPolicy-neutral SWM behavior', () => { - const verified = verifyRfc64V2SwmPolicyMatrix(fixture()); - expect(verified.status).toBe('PASS'); - expect(verified.publishPolicyConsultedBySwm).toBe(false); - expect(verified.policyLifecycle).toBe('four-independent-immutable-snapshots'); - expect(verified.publishPolicyParity).toHaveLength(2); - expect(verified.publishPolicyParity[0]!.vector).toEqual( - verified.publishPolicyParity[0]!.vector, - ); - }); - - it('rejects missing cells, wrong axes, UAL/kaId mismatch, and content mismatch', () => { - expect(() => verifyRfc64V2SwmPolicyMatrix(fixture().slice(0, 3))) - .toThrow(/exactly four/); - - const wrongAxes = fixture(); - wrongAxes[1] = { ...wrongAxes[1]!, publishPolicy: 1 }; - expect(() => verifyRfc64V2SwmPolicyMatrix(wrongAxes)).toThrow(/publishPolicy/); - - const wrongKaId = fixture(); - wrongKaId[0] = { ...wrongKaId[0]!, kaId: '1' }; - expect(() => verifyRfc64V2SwmPolicyMatrix(wrongKaId)).toThrow(/packed UAL identity/); - - const mismatch = fixture(); - mismatch[0] = { - ...mismatch[0]!, - memberRead: { ...mismatch[0]!.memberRead, contentSha256: digest('f') }, - }; - expect(() => verifyRfc64V2SwmPolicyMatrix(mismatch)).toThrow(/contentSha256/); - }); - - it('rejects a private outsider leak and publish-axis-dependent behavior', () => { - const leak = fixture(); - leak[2] = { - ...leak[2]!, - outsiderRead: { - ...leak[2]!.outsiderRead, - bindingCount: 2, - contentSha256: leak[2]!.contentSha256, - httpStatus: 200, - leakedMarker: true, - outcome: 'applied', - }, - }; - expect(() => verifyRfc64V2SwmPolicyMatrix(leak)).toThrow(/outsiderRead\.outcome/); - - const drift = fixture(); - drift[1] = { - ...drift[1]!, - outsiderRead: { ...drift[1]!.outsiderRead, bindingCount: 1 }, - }; - expect(() => verifyRfc64V2SwmPolicyMatrix(drift)).toThrow(/bindingCount/); - }); - - it('normalizes SPARQL bindings before hashing', () => { - const left = digestRfc64V2SwmBindings([ - { p: { value: 'urn:p:2' }, o: 'two' }, - { o: { value: 'one' }, p: 'urn:p:1' }, - ]); - const right = digestRfc64V2SwmBindings([ - { p: 'urn:p:1', o: 'one' }, - { o: 'two', p: 'urn:p:2' }, - ]); - expect(left).toBe(right); - }); - - it('reports missing product seams deterministically and keeps transitions future-scoped', () => { - const partial = inspectRfc64V2ProductCapabilities({ - acceptRfc64CatalogAccessSnapshotV1() {}, - }); - expect(partial.missing).toEqual([ - 'publishAuthorCatalogGenesisV1', - 'publishAuthorCatalogExactSetSuccessorV1', - ]); - expect(() => requireRfc64V2ProductCapabilities(partial)).toThrow( - 'RFC64_V2_PRODUCT_CAPABILITIES_UNAVAILABLE: ' - + 'publishAuthorCatalogGenesisV1, publishAuthorCatalogExactSetSuccessorV1', - ); - expect(partial.futureTransitionCapability).toBe(false); - - const complete = Object.fromEntries([ - ...RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES, - RFC64_V2_FUTURE_TRANSITION_CAPABILITY, - ].map((name) => [name, () => undefined])); - const inspection = inspectRfc64V2ProductCapabilities(complete); - expect(inspection.missing).toEqual([]); - expect(inspection.futureTransitionCapability).toBe(true); - }); -}); diff --git a/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.ts b/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.ts deleted file mode 100644 index 48219bc873..0000000000 --- a/devnet/_bootstrap/rfc64-v2-swm-policy-matrix.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { createHash } from 'node:crypto'; -import { join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -import { parseDeterministicKnowledgeAssetUal } from '../../packages/core/src/ka-content-scope.js'; - -export const RFC64_V2_SWM_POLICY_CELLS = Object.freeze([ - 'public-open', - 'public-curated', - 'private-open', - 'private-curated', -] as const); - -export type Rfc64V2SwmPolicyCell = typeof RFC64_V2_SWM_POLICY_CELLS[number]; - -export const RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES = Object.freeze([ - 'acceptRfc64CatalogAccessSnapshotV1', - 'publishAuthorCatalogGenesisV1', - 'publishAuthorCatalogExactSetSuccessorV1', -] as const); - -/** - * Deliberately not required by the immutable four-cell V2 matrix. Once the - * product owns verified high-water policy/roster activation, the same rich - * scenario can add open↔invite and roster-removal transitions without - * pretending that repeated immutable snapshot acceptance is a transition. - */ -export const RFC64_V2_FUTURE_TRANSITION_CAPABILITY = - 'activateRfc64CatalogAccessTransitionV1' as const; - -export type Rfc64V2RequiredProductCapability = - typeof RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES[number]; - -export interface Rfc64V2ProductCapabilityInspection { - readonly futureTransitionCapability: boolean; - readonly missing: readonly Rfc64V2RequiredProductCapability[]; - readonly observed: Readonly>; -} - -export interface Rfc64V2SwmReadObservation { - readonly agentAddress: string; - readonly bindingCount: number; - readonly contentSha256: string | null; - readonly httpStatus: number; - readonly leakedMarker: boolean; - readonly nodeNumber: number; - readonly outcome: 'applied' | 'denied'; -} - -export interface Rfc64V2SwmCellObservation { - readonly accessPolicy: 0 | 1; - readonly assertionUri: string; - readonly authorAgentAddress: string; - readonly cell: Rfc64V2SwmPolicyCell; - readonly contentSha256: string; - readonly contextGraphId: string; - readonly kaId: string; - readonly memberRead: Rfc64V2SwmReadObservation; - readonly merkleRoot: string; - readonly outsiderRead: Rfc64V2SwmReadObservation; - /** V2 proves four independent current snapshots, not live policy replacement. */ - readonly policyLifecycle: 'immutable-per-cell-snapshot'; - readonly publishPolicy: 0 | 1; - readonly tripleCount: number; - readonly txHash: string; - readonly ual: string; -} - -export interface Rfc64V2SwmBehaviorVector { - readonly accessPolicy: 0 | 1; - readonly memberBindingCount: number; - readonly memberOutcome: 'applied'; - readonly outsiderBindingCount: number; - readonly outsiderOutcome: 'applied' | 'denied'; -} - -export interface Rfc64V2SwmPublishPolicyParity { - readonly behaviorDigest: string; - readonly curatedCell: 'public-curated' | 'private-curated'; - readonly openCell: 'public-open' | 'private-open'; - readonly vector: Rfc64V2SwmBehaviorVector; -} - -export interface VerifiedRfc64V2SwmPolicyMatrix { - readonly cells: readonly Rfc64V2SwmCellObservation[]; - readonly policyLifecycle: 'four-independent-immutable-snapshots'; - readonly publishPolicyConsultedBySwm: false; - readonly publishPolicyParity: readonly Rfc64V2SwmPublishPolicyParity[]; - readonly status: 'PASS'; -} - -const AXES: Readonly< - Record -> = Object.freeze({ - 'public-open': [0, 1], - 'public-curated': [0, 0], - 'private-open': [1, 1], - 'private-curated': [1, 0], -}); - -const ADDRESS = /^0x[0-9a-f]{40}$/u; -const DECIMAL = /^(?:0|[1-9][0-9]*)$/u; -const DIGEST = /^(?:0x|sha256:)[0-9a-f]{64}$/u; -const TX_HASH = /^0x[0-9a-f]{64}$/u; -const BEHAVIOR_DOMAIN = 'dkg-rfc64-v2-swm-behavior-v1\n'; - -export function inspectRfc64V2ProductCapabilities( - value: unknown, -): Rfc64V2ProductCapabilityInspection { - const candidate = value !== null && (typeof value === 'object' || typeof value === 'function') - ? value as Record - : null; - const observed = Object.freeze(Object.fromEntries( - RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES.map((name) => [ - name, - candidate !== null && typeof candidate[name] === 'function', - ]), - )) as Readonly>; - const missing = Object.freeze( - RFC64_V2_REQUIRED_PRODUCT_CAPABILITIES.filter((name) => !observed[name]), - ); - return Object.freeze({ - futureTransitionCapability: - candidate !== null - && typeof candidate[RFC64_V2_FUTURE_TRANSITION_CAPABILITY] === 'function', - missing, - observed, - }); -} - -/** Inspect the same built DKGAgent class loaded by current-repo devnet nodes. */ -export async function probeBuiltRfc64V2ProductCapabilities( - repositoryRoot = resolve(import.meta.dirname, '../..'), -): Promise { - const entry = join(repositoryRoot, 'packages/agent/dist/index.js'); - let loaded: Record; - try { - loaded = await import(pathToFileURL(entry).href) as Record; - } catch (cause) { - throw new Error( - `RFC64_V2_PRODUCT_BUILD_UNAVAILABLE: ${entry}: ${errorMessage(cause)}`, - ); - } - const dkgAgent = loaded.DKGAgent; - if (typeof dkgAgent !== 'function') { - throw new Error(`RFC64_V2_PRODUCT_BUILD_UNAVAILABLE: ${entry} exports no DKGAgent class`); - } - return inspectRfc64V2ProductCapabilities(dkgAgent.prototype); -} - -export function requireRfc64V2ProductCapabilities( - inspection: Rfc64V2ProductCapabilityInspection, -): void { - if (inspection.missing.length === 0) return; - throw new Error( - `RFC64_V2_PRODUCT_CAPABILITIES_UNAVAILABLE: ${inspection.missing.join(', ')}`, - ); -} - -export function verifyRfc64V2SwmPolicyMatrix( - observations: readonly Rfc64V2SwmCellObservation[], -): VerifiedRfc64V2SwmPolicyMatrix { - if (!Array.isArray(observations) || observations.length !== 4) { - fail('matrix', 'must contain exactly four policy-cell observations'); - } - const cells = observations.map((input, index) => verifyCell( - input, - RFC64_V2_SWM_POLICY_CELLS[index]!, - `matrix[${index}]`, - )); - if (new Set(cells.map((cell) => cell.contextGraphId)).size !== 4) { - fail('matrix', 'contextGraphId values must be distinct'); - } - if (new Set(cells.map((cell) => cell.ual)).size !== 4) { - fail('matrix', 'UAL values must be distinct'); - } - if (new Set(cells.map((cell) => cell.contentSha256)).size !== 4) { - fail('matrix', 'content digests must be distinct'); - } - - const publishPolicyParity = Object.freeze([ - verifyParity(cells, 'public-open', 'public-curated'), - verifyParity(cells, 'private-open', 'private-curated'), - ]); - return Object.freeze({ - cells: Object.freeze(cells), - policyLifecycle: 'four-independent-immutable-snapshots', - publishPolicyConsultedBySwm: false, - publishPolicyParity, - status: 'PASS', - }); -} - -export function digestRfc64V2SwmBindings( - bindings: readonly Record[], -): string { - const normalized = bindings.map((binding) => Object.freeze(Object.fromEntries( - Object.entries(binding) - .map(([key, value]) => [key, normalizeBindingCell(value)] as const) - .sort(([left], [right]) => compareCodePoints(left, right)), - ))).sort((left, right) => compareCodePoints( - stableJson(left), - stableJson(right), - )); - return `sha256:${createHash('sha256').update(stableJson(normalized)).digest('hex')}`; -} - -function verifyCell( - input: Rfc64V2SwmCellObservation, - expectedCell: Rfc64V2SwmPolicyCell, - path: string, -): Rfc64V2SwmCellObservation { - if (!isPlainRecord(input)) fail(path, 'must be a plain observation object'); - exact(input.cell, expectedCell, `${path}.cell`); - const [accessPolicy, publishPolicy] = AXES[expectedCell]; - exact(input.accessPolicy, accessPolicy, `${path}.accessPolicy`); - exact(input.publishPolicy, publishPolicy, `${path}.publishPolicy`); - exact( - input.policyLifecycle, - 'immutable-per-cell-snapshot', - `${path}.policyLifecycle`, - ); - nonempty(input.contextGraphId, `${path}.contextGraphId`); - match(input.authorAgentAddress, ADDRESS, `${path}.authorAgentAddress`); - match(input.kaId, DECIMAL, `${path}.kaId`); - match(input.merkleRoot, DIGEST, `${path}.merkleRoot`); - match(input.contentSha256, /^sha256:[0-9a-f]{64}$/u, `${path}.contentSha256`); - match(input.txHash, TX_HASH, `${path}.txHash`); - nonempty(input.assertionUri, `${path}.assertionUri`); - if (!Number.isSafeInteger(input.tripleCount) || input.tripleCount < 1) { - fail(`${path}.tripleCount`, 'must be a positive safe integer'); - } - const ual = parseDeterministicKnowledgeAssetUal(input.ual); - exact(ual.ual, input.ual, `${path}.ual canonical form`); - exact(ual.agentAddress, input.authorAgentAddress, `${path}.ual author`); - const packedKaId = (BigInt(ual.agentAddress) << 96n) | BigInt(ual.kaNumber); - exact(packedKaId.toString(), input.kaId, `${path}.kaId packed UAL identity`); - - verifyPositiveRead(input.memberRead, input, `${path}.memberRead`); - if (accessPolicy === 0) { - verifyPositiveRead(input.outsiderRead, input, `${path}.outsiderRead`); - } else { - verifyDeniedRead(input.outsiderRead, `${path}.outsiderRead`); - } - return Object.freeze({ ...input }); -} - -function verifyPositiveRead( - read: Rfc64V2SwmReadObservation, - cell: Rfc64V2SwmCellObservation, - path: string, -): void { - verifyReadBase(read, path); - exact(read.outcome, 'applied', `${path}.outcome`); - exact(read.httpStatus, 200, `${path}.httpStatus`); - exact(read.bindingCount, cell.tripleCount, `${path}.bindingCount`); - exact(read.contentSha256, cell.contentSha256, `${path}.contentSha256`); - exact(read.leakedMarker, false, `${path}.leakedMarker`); -} - -function verifyDeniedRead(read: Rfc64V2SwmReadObservation, path: string): void { - verifyReadBase(read, path); - exact(read.outcome, 'denied', `${path}.outcome`); - if (read.httpStatus !== 200 && read.httpStatus !== 403 && read.httpStatus !== 404) { - fail(`${path}.httpStatus`, 'private denial must be empty-200, 403, or 404'); - } - exact(read.bindingCount, 0, `${path}.bindingCount`); - exact(read.contentSha256, null, `${path}.contentSha256`); - exact(read.leakedMarker, false, `${path}.leakedMarker`); -} - -function verifyReadBase(read: Rfc64V2SwmReadObservation, path: string): void { - if (!isPlainRecord(read)) fail(path, 'must be a plain read observation'); - match(read.agentAddress, ADDRESS, `${path}.agentAddress`); - if (!Number.isSafeInteger(read.nodeNumber) || read.nodeNumber < 1) { - fail(`${path}.nodeNumber`, 'must be a positive safe integer'); - } - if (!Number.isSafeInteger(read.httpStatus) || read.httpStatus < 100 || read.httpStatus > 599) { - fail(`${path}.httpStatus`, 'must be an HTTP status'); - } - if (!Number.isSafeInteger(read.bindingCount) || read.bindingCount < 0) { - fail(`${path}.bindingCount`, 'must be a nonnegative safe integer'); - } -} - -function verifyParity( - cells: readonly Rfc64V2SwmCellObservation[], - openCell: 'public-open' | 'private-open', - curatedCell: 'public-curated' | 'private-curated', -): Rfc64V2SwmPublishPolicyParity { - const open = cells.find((cell) => cell.cell === openCell)!; - const curated = cells.find((cell) => cell.cell === curatedCell)!; - const openVector = behaviorVector(open); - const curatedVector = behaviorVector(curated); - exact( - stableJson(openVector), - stableJson(curatedVector), - `${openCell}/${curatedCell} SWM behavior`, - ); - return Object.freeze({ - behaviorDigest: `sha256:${createHash('sha256') - .update(BEHAVIOR_DOMAIN) - .update(stableJson(openVector)) - .digest('hex')}`, - curatedCell, - openCell, - vector: openVector, - }); -} - -function behaviorVector(cell: Rfc64V2SwmCellObservation): Rfc64V2SwmBehaviorVector { - return Object.freeze({ - accessPolicy: cell.accessPolicy, - memberBindingCount: cell.memberRead.bindingCount, - memberOutcome: 'applied', - outsiderBindingCount: cell.outsiderRead.bindingCount, - outsiderOutcome: cell.outsiderRead.outcome, - }); -} - -function normalizeBindingCell(value: unknown): string { - if (typeof value === 'string') return value; - if (isPlainRecord(value) && typeof value.value === 'string') return value.value; - throw new TypeError(`SPARQL binding cell must be a string or {value}: ${String(value)}`); -} - -function stableJson(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map((entry) => stableJson(entry)).join(',')}]`; - if (!isPlainRecord(value)) throw new TypeError('stable JSON input must be plain data'); - return `{${Object.keys(value).sort(compareCodePoints).map( - (key) => `${JSON.stringify(key)}:${stableJson(value[key])}`, - ).join(',')}}`; -} - -function compareCodePoints(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0; -} - -function isPlainRecord(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function nonempty(value: unknown, path: string): asserts value is string { - if (typeof value !== 'string' || value.length === 0 || value.length > 4096) { - fail(path, 'must be a bounded nonempty string'); - } -} - -function match(value: unknown, pattern: RegExp, path: string): asserts value is string { - nonempty(value, path); - if (!pattern.test(value)) fail(path, `must match ${pattern}`); -} - -function exact(actual: unknown, expected: unknown, path: string): void { - if (!Object.is(actual, expected)) { - fail(path, `must equal ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); - } -} - -function errorMessage(value: unknown): string { - return value instanceof Error ? value.message : String(value); -} - -function fail(path: string, message: string): never { - throw new Error(`RFC64_V2_SWM_POLICY_MATRIX_INVALID at ${path}: ${message}`); -} diff --git a/devnet/_bootstrap/tsconfig.evidence.json b/devnet/_bootstrap/tsconfig.evidence.json index 4a432a6ebd..b4a4ee9afb 100644 --- a/devnet/_bootstrap/tsconfig.evidence.json +++ b/devnet/_bootstrap/tsconfig.evidence.json @@ -11,8 +11,6 @@ "include": [ "rdf-canonize.d.ts", "rfc64-evidence.ts", - "rfc64-evidence.test.ts", - "rfc64-v2-swm-policy-matrix.ts", - "rfc64-v2-swm-policy-matrix.test.ts" + "rfc64-evidence.test.ts" ] } diff --git a/devnet/_bootstrap/vitest.evidence.config.ts b/devnet/_bootstrap/vitest.evidence.config.ts index a9c43536d2..76c2d2b6d4 100644 --- a/devnet/_bootstrap/vitest.evidence.config.ts +++ b/devnet/_bootstrap/vitest.evidence.config.ts @@ -7,10 +7,7 @@ const repositoryRoot = resolve(import.meta.dirname, '../..'); export default defineConfig({ root: repositoryRoot, test: { - include: [ - 'devnet/_bootstrap/rfc64-evidence.test.ts', - 'devnet/_bootstrap/rfc64-v2-swm-policy-matrix.test.ts', - ], + include: ['devnet/_bootstrap/rfc64-evidence.test.ts'], pool: 'forks', sequence: { concurrent: false }, globals: false, diff --git a/devnet/rich-scenario/automated.test.ts b/devnet/rich-scenario/automated.test.ts index 89ed69c954..00ac0f9735 100644 --- a/devnet/rich-scenario/automated.test.ts +++ b/devnet/rich-scenario/automated.test.ts @@ -6,19 +6,10 @@ * pnpm test:devnet:rich-scenario */ import { describe, it, expect, beforeAll } from 'vitest'; -import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { ethers, Wallet } from 'ethers'; import { buildUpdateSeal } from '../../packages/publisher/test/_helpers/seal.js'; -import { - digestRfc64V2SwmBindings, - probeBuiltRfc64V2ProductCapabilities, - requireRfc64V2ProductCapabilities, - verifyRfc64V2SwmPolicyMatrix, - type Rfc64V2SwmCellObservation, - type Rfc64V2SwmPolicyCell, - type Rfc64V2SwmReadObservation, -} from '../_bootstrap/harness.js'; const REPO_ROOT = resolve(__dirname, '../..'); const RPC = 'http://127.0.0.1:8545'; @@ -34,8 +25,6 @@ const RS_TIMEOUT_S = Number(process.env.RS_TIMEOUT ?? 90); const MINE_BLOCKS = Number(process.env.MINE_BLOCKS ?? 120); const SKIP_STAKE = process.env.SKIP_STAKE === '1'; const PUBLISH_PACE_MS = Number(process.env.PUBLISH_PACE_MS ?? 800); -const V2_SWM_POLICY_MATRIX = process.env.RICH_V2_SWM_POLICY_MATRIX === '1'; -const V2_SWM_SYNC_TIMEOUT_MS = Number(process.env.RICH_V2_SWM_SYNC_TIMEOUT_MS ?? 90_000); const TOTAL_PUBLISHES = WM_COUNT + SWM_COUNT + VM_COUNT; @@ -83,34 +72,17 @@ interface VmPublishRecord { rootSubject: string; } -interface MatrixAssetPublication { - readonly assertionUri: string; - readonly authorAgentAddress: string; - readonly contentSha256: string; - readonly kaId: string; - readonly marker: string; - readonly merkleRoot: string; - readonly subject: string; - readonly tripleCount: number; - readonly txHash: string; - readonly ual: string; -} - const state: { v: DevnetState | null } = { v: null }; const run: { stamp: number; edgeAgents: Record; cgs: CgRef[]; - outsiderAgent: AgentRef | null; - v2Matrix: Rfc64V2SwmCellObservation[]; vmPublishes: VmPublishRecord[]; updatesDone: number; } = { stamp: Date.now(), edgeAgents: {}, cgs: [], - outsiderAgent: null, - v2Matrix: [], vmPublishes: [], updatesDone: 0, }; @@ -358,241 +330,6 @@ async function assertionCreate( return rootSubject; } -function bindingLexicalValue(value: string): string { - if (value.startsWith('"') && value.endsWith('"')) { - try { - const parsed = JSON.parse(value); - if (typeof parsed === 'string') return parsed; - } catch { - // Keep the exact input below; malformed literals are rejected by create. - } - } - return value; -} - -function matrixBindings( - quads: readonly { predicate: string; object: string }[], -): Array> { - return quads.map((quad) => ({ - o: bindingLexicalValue(quad.object), - p: quad.predicate, - })); -} - -async function createAndPublishMatrixAsset( - node: DevnetNode, - agent: AgentRef, - cg: CgRef, - cell: Rfc64V2SwmPolicyCell, - index: number, -): Promise { - const name = `v2-matrix-${cell}-${run.stamp}`; - const marker = `v2-matrix-marker-${cell}-${run.stamp}`; - const subject = `urn:devnet:rfc64:v2:${cell}:${run.stamp}`; - const graph = `did:dkg:context-graph:${cg.localId}`; - const quads = [ - { - subject, - predicate: 'https://schema.org/name', - object: `"${marker}"`, - graph, - }, - { - subject, - predicate: 'https://schema.org/position', - object: `"${index + 1}"^^`, - graph, - }, - ]; - const created = await apiFetch(node, '/api/knowledge-assets', { - method: 'POST', - bearer: agent.authToken, - body: JSON.stringify({ - alsoShareSwm: true, - awaitCuratorAck: cgIsCurated(cg), - contextGraphId: cg.localId, - name, - quads, - }), - }); - const createBody = (await created.json().catch(() => null)) as { - assertionUri?: string; - authorAddress?: string; - errors?: unknown; - merkleRoot?: string; - promotedCount?: number; - status?: string; - } | null; - if ( - !created.ok - || createBody?.status !== 'swm-shared' - || createBody.promotedCount !== quads.length - || typeof createBody.assertionUri !== 'string' - || typeof createBody.authorAddress !== 'string' - || typeof createBody.merkleRoot !== 'string' - ) { - throw new Error( - `V2 matrix ${cell} create/share failed: HTTP ${created.status} ${JSON.stringify(createBody)}`, - ); - } - - const publishOnce = async () => { - const response = await apiFetch( - node, - `/api/knowledge-assets/${encodeURIComponent(name)}/vm/publish`, - { - method: 'POST', - bearer: agent.authToken, - body: JSON.stringify({ contextGraphId: cg.localId }), - }, - ); - const body = (await response.json().catch(() => null)) as { - authorAddress?: string; - kaId?: string; - merkleRoot?: string; - status?: string; - txHash?: string; - ual?: string; - } | null; - const tentative = body?.status === 'tentative' || body?.kaId === '0'; - if (!response.ok && !tentative) { - throw new Error( - `V2 matrix ${cell} VM publish failed: HTTP ${response.status} ${JSON.stringify(body)}`, - ); - } - return body; - }; - let published = await publishOnce(); - if (published?.status === 'tentative' || published?.kaId === '0') { - await new Promise((resolveWait) => setTimeout(resolveWait, 2_000)); - published = await publishOnce(); - } - if ( - published?.status !== 'confirmed' - || typeof published.kaId !== 'string' - || typeof published.ual !== 'string' - || typeof published.txHash !== 'string' - || typeof published.merkleRoot !== 'string' - || typeof published.authorAddress !== 'string' - ) { - throw new Error(`V2 matrix ${cell} VM publish was not exact: ${JSON.stringify(published)}`); - } - expect(published.merkleRoot).toBe(createBody.merkleRoot); - expect(published.authorAddress.toLowerCase()).toBe(agent.agentAddress.toLowerCase()); - expect(createBody.authorAddress.toLowerCase()).toBe(agent.agentAddress.toLowerCase()); - return { - assertionUri: createBody.assertionUri, - authorAgentAddress: published.authorAddress.toLowerCase(), - contentSha256: digestRfc64V2SwmBindings(matrixBindings(quads)), - kaId: published.kaId, - marker, - merkleRoot: published.merkleRoot.toLowerCase(), - subject, - tripleCount: quads.length, - txHash: published.txHash.toLowerCase(), - ual: published.ual.toLowerCase(), - }; -} - -function extractQueryBindings(value: unknown): Array> { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return []; - const root = value as Record; - const result = root.result; - if (result === null || typeof result !== 'object' || Array.isArray(result)) return []; - const bindings = (result as Record).bindings; - return Array.isArray(bindings) - ? bindings.filter( - (entry): entry is Record => - entry !== null && typeof entry === 'object' && !Array.isArray(entry), - ) - : []; -} - -async function queryMatrixAsset( - node: DevnetNode, - agent: AgentRef, - cg: CgRef, - asset: MatrixAssetPublication, -): Promise { - const response = await apiFetch(node, '/api/query', { - method: 'POST', - bearer: agent.authToken, - body: JSON.stringify({ - contextGraphId: cg.localId, - sparql: `SELECT ?p ?o WHERE { <${asset.subject}> ?p ?o } ORDER BY ?p ?o`, - view: 'shared-working-memory', - }), - }); - const bodyText = await response.text(); - let parsed: unknown = null; - try { - parsed = JSON.parse(bodyText); - } catch { - // A non-JSON denial remains a denial and is still privacy-scanned below. - } - const bindings = response.ok ? extractQueryBindings(parsed) : []; - const applied = response.status === 200 && bindings.length > 0; - return { - agentAddress: agent.agentAddress.toLowerCase(), - bindingCount: bindings.length, - contentSha256: applied ? digestRfc64V2SwmBindings(bindings) : null, - httpStatus: response.status, - leakedMarker: !applied && ( - bodyText.includes(asset.marker) - || bodyText.includes(asset.subject) - || bodyText.includes(asset.contentSha256) - ), - nodeNumber: node.num, - outcome: applied ? 'applied' : 'denied', - }; -} - -async function waitForMatrixAsset( - node: DevnetNode, - agent: AgentRef, - cg: CgRef, - asset: MatrixAssetPublication, -): Promise { - const deadline = Date.now() + V2_SWM_SYNC_TIMEOUT_MS; - let last: Rfc64V2SwmReadObservation | null = null; - do { - last = await queryMatrixAsset(node, agent, cg, asset); - if ( - last.outcome === 'applied' - && last.bindingCount === asset.tripleCount - && last.contentSha256 === asset.contentSha256 - ) return last; - await new Promise((resolveWait) => setTimeout(resolveWait, 1_000)); - } while (Date.now() < deadline); - throw new Error( - `V2 matrix ${cg.key} did not synchronize exact content to node${node.num}: ${JSON.stringify(last)}`, - ); -} - -async function proveStablePrivateDenial( - node: DevnetNode, - agent: AgentRef, - cg: CgRef, - asset: MatrixAssetPublication, -): Promise { - let last: Rfc64V2SwmReadObservation | null = null; - for (let attempt = 0; attempt < 3; attempt += 1) { - last = await queryMatrixAsset(node, agent, cg, asset); - if ( - last.outcome !== 'denied' - || last.bindingCount !== 0 - || last.contentSha256 !== null - || last.leakedMarker - ) { - throw new Error( - `V2 matrix private outsider observed content on attempt ${attempt + 1}: ${JSON.stringify(last)}`, - ); - } - if (attempt < 2) await new Promise((resolveWait) => setTimeout(resolveWait, 750)); - } - return last!; -} - async function publishAssertionVm( node: DevnetNode, cgId: string, From 5fa0f459986a76fdf926bd02292230a28cd9c2d9 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:39:55 +0200 Subject: [PATCH 178/292] fix(sync): probe explicitly selected catchup peer --- packages/cli/src/daemon/routes/memory.ts | 9 +- .../shared-memory-catchup-durable.test.ts | 105 ++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/daemon/routes/memory.ts b/packages/cli/src/daemon/routes/memory.ts index 9e8671b9eb..b87ae7a638 100644 --- a/packages/cli/src/daemon/routes/memory.ts +++ b/packages/cli/src/daemon/routes/memory.ts @@ -864,7 +864,14 @@ WHERE { continue; } const baseCandidatePeers = await candidatePeersForContextGraph(cgId); - const unsupportedPeers = await unsupportedPeersForContextGraph(cgId, baseCandidatePeers); + // An explicit peerId is an operator-directed bounded probe. libp2p's + // identify-backed peerStore can lag a live connection, so an absent + // protocol advertisement must not suppress the wire negotiation the + // operator requested. Default peer enumeration remains conservative and + // filters peers that are known not to advertise the current sync wire. + const unsupportedPeers = peerIdParam + ? new Set() + : await unsupportedPeersForContextGraph(cgId, baseCandidatePeers); const privateCuratorPeerIds = await privateCuratorPeersForContextGraph(cgId); const swmSelectedPeers = canUseSharedMemory ? swmCatchupPeerSelector.select({ diff --git a/packages/cli/test/shared-memory-catchup-durable.test.ts b/packages/cli/test/shared-memory-catchup-durable.test.ts index 91607857f2..70266a0ae2 100644 --- a/packages/cli/test/shared-memory-catchup-durable.test.ts +++ b/packages/cli/test/shared-memory-catchup-durable.test.ts @@ -99,6 +99,111 @@ describe('POST /api/shared-memory/catchup durable leg', () => { expect(agent.getPeerProtocols).not.toHaveBeenCalled(); }); + it('probes an explicit durable peer even when its protocol advertisement is absent', async () => { + const cgId = 'explicit-durable-probe-cg'; + const peerId = 'peer-live-with-stale-identify'; + const syncFromPeerDetailed = vi.fn(async () => detailedDurableResult({ + insertedTriples: 23, + insertedDataTriples: 20, + insertedMetaTriples: 3, + complete: true, + })); + const agent = { + peerId: 'self-peer', + getPeerProtocols: vi.fn(async () => []), + isPrivateContextGraph: vi.fn(async () => false), + syncFromPeerDetailed, + }; + const { ctx, res } = buildCatchupCtx( + { + contextGraphId: cgId, + peerId, + includeSharedMemory: false, + includeDurable: true, + hostCatchupFallback: false, + }, + agent, + ); + + await handleMemoryRoutes(ctx); + + expect(res.statusCode).toBe(200); + expect(agent.getPeerProtocols).not.toHaveBeenCalled(); + expect(syncFromPeerDetailed).toHaveBeenCalledWith( + peerId, + [cgId], + undefined, + undefined, + undefined, + { totalTimeoutMs: 109_000 }, + ); + expect(JSON.parse(res.body)).toMatchObject({ + ok: true, + durableComplete: true, + peersAttempted: 1, + totalDurableInsertedTriples: 23, + results: [{ + peerId, + durableInsertedTriples: 23, + durableComplete: true, + }], + perContextGraph: [{ + contextGraphId: cgId, + durableComplete: true, + perPeer: [{ + peerId, + durableInsertedTriples: 23, + durableComplete: true, + durableDiagnostics: { + insertedDataTriples: 20, + insertedMetaTriples: 3, + }, + }], + }], + }); + }); + + it('still filters an implicitly enumerated durable peer without sync advertisement', async () => { + const cgId = 'implicit-unsupported-durable-cg'; + const peerId = 'peer-legacy'; + const syncFromPeerDetailed = vi.fn(); + const agent = { + peerId: 'self-peer', + node: { + libp2p: { + getConnections: () => [{ + remotePeer: { toString: () => peerId }, + }], + }, + }, + getPeerProtocols: vi.fn(async () => []), + isPrivateContextGraph: vi.fn(async () => false), + syncFromPeerDetailed, + }; + const { ctx, res } = buildCatchupCtx( + { + contextGraphId: cgId, + includeSharedMemory: false, + includeDurable: true, + hostCatchupFallback: false, + }, + agent, + ); + + await handleMemoryRoutes(ctx); + + expect(res.statusCode).toBe(503); + expect(agent.getPeerProtocols).toHaveBeenCalledWith(peerId); + expect(syncFromPeerDetailed).not.toHaveBeenCalled(); + expect(JSON.parse(res.body)).toMatchObject({ + ok: false, + retryable: true, + errorCode: 'DURABLE_CATCHUP_NO_ELIGIBLE_PEERS', + durableComplete: false, + peersAttempted: 0, + }); + }); + it('supports durable-only recovery without starting either SWM catchup path', async () => { const cgId = 'private-durable-only-cg'; const peerId = 'peer-curator'; From dd2d461fd96076203fc02cfee86cc86cb9ec676f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:41:14 +0200 Subject: [PATCH 179/292] test(rfc64): close CP1 evidence schema --- .../verifier.test.ts | 6 +- .../rfc64-cp1-public-swm-parity/verifier.ts | 65 +++++++++++++++++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts b/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts index 0a3bc4518d..2659325705 100644 --- a/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts +++ b/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts @@ -41,6 +41,7 @@ function fixture(): Record { receiverPid: 101, }, repository: { testedHeadCommit: 'a'.repeat(40), trackedSourceClean: true }, + runtimeManifestDigest: digest('c'), schemaVersion: CP1_PUBLIC_SWM_PARITY_SCHEMA, status: 'PASS', }; @@ -64,5 +65,8 @@ test('rejects publish-axis, process-boundary, and byte-parity drift', () => { const changedBytes = structuredClone(fixture()) as { cells: Array> }; changedBytes.cells[1]!.projectionNQuads = `${projection}# drift`; assert.throws(() => verifyCp1PublicSwmParity(changedBytes), /projectionNQuads/); -}); + const extraKey = structuredClone(fixture()) as Record; + extraKey.unverified = true; + assert.throws(() => verifyCp1PublicSwmParity(extraKey), /keys differ/); +}); diff --git a/devnet/rfc64-cp1-public-swm-parity/verifier.ts b/devnet/rfc64-cp1-public-swm-parity/verifier.ts index b4a7a3be35..153c81bd89 100644 --- a/devnet/rfc64-cp1-public-swm-parity/verifier.ts +++ b/devnet/rfc64-cp1-public-swm-parity/verifier.ts @@ -12,19 +12,41 @@ export function semanticSha256(value: string): string { } export function verifyCp1PublicSwmParity(value: unknown): Record { - const root = record(value, '$'); + const root = closedRecord(value, '$', [ + 'cells', + 'expectedProjectionNQuads', + 'expectedSemanticSha256', + 'peers', + 'processBoundary', + 'repository', + 'runtimeManifestDigest', + 'schemaVersion', + 'status', + ]); exact(root.schemaVersion, CP1_PUBLIC_SWM_PARITY_SCHEMA, '$.schemaVersion'); exact(root.status, 'PASS', '$.status'); - const repository = record(root.repository, '$.repository'); - string(repository.testedHeadCommit, '$.repository.testedHeadCommit'); + digest(root.runtimeManifestDigest, '$.runtimeManifestDigest'); + const repository = closedRecord(root.repository, '$.repository', [ + 'testedHeadCommit', + 'trackedSourceClean', + ]); + const testedHeadCommit = string(repository.testedHeadCommit, '$.repository.testedHeadCommit'); + if (!/^[0-9a-f]{40}$/u.test(testedHeadCommit)) { + fail('$.repository.testedHeadCommit', 'must be a full lowercase Git commit'); + } exact(repository.trackedSourceClean, true, '$.repository.trackedSourceClean'); - const boundary = record(root.processBoundary, '$.processBoundary'); + const boundary = closedRecord(root.processBoundary, '$.processBoundary', [ + 'authorExitCode', + 'authorPid', + 'receiverExitCode', + 'receiverPid', + ]); integer(boundary.authorPid, '$.processBoundary.authorPid'); integer(boundary.receiverPid, '$.processBoundary.receiverPid'); if (boundary.authorPid === boundary.receiverPid) fail('$.processBoundary', 'PIDs must differ'); exact(boundary.authorExitCode, 0, '$.processBoundary.authorExitCode'); exact(boundary.receiverExitCode, 0, '$.processBoundary.receiverExitCode'); - const peers = record(root.peers, '$.peers'); + const peers = closedRecord(root.peers, '$.peers', ['authorPeerId', 'receiverPeerId']); const authorPeerId = string(peers.authorPeerId, '$.peers.authorPeerId'); const receiverPeerId = string(peers.receiverPeerId, '$.peers.receiverPeerId'); if (authorPeerId === receiverPeerId) fail('$.peers', 'peer IDs must differ'); @@ -37,7 +59,23 @@ export function verifyCp1PublicSwmParity(value: unknown): Record { const path = `$.cells[${index}]`; - const cell = record(entry, path); + const cell = closedRecord(entry, path, [ + 'accessPolicy', + 'activatedTripleCount', + 'announcementPolicyDigest', + 'announcedPeerId', + 'appliedHeadStatus', + 'authorPolicyDigest', + 'bundleDigest', + 'cell', + 'contentDigest', + 'contextGraphId', + 'inventoryRowCount', + 'projectionNQuads', + 'publishPolicy', + 'receiverPolicyDigest', + 'semanticSha256', + ]); exact(cell.cell, CELLS[index], `${path}.cell`); exact(cell.accessPolicy, 0, `${path}.accessPolicy`); exact(cell.publishPolicy, index === 0 ? 1 : 0, `${path}.publishPolicy`); @@ -70,6 +108,20 @@ function record(value: unknown, path: string): Record { return value as Record; } +function closedRecord( + value: unknown, + path: string, + expectedKeys: readonly string[], +): Record { + const result = record(value, path); + const actual = Object.keys(result).sort(); + const expected = [...expectedKeys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail(path, `keys differ: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`); + } + return result; +} + function string(value: unknown, path: string): string { if (typeof value !== 'string' || value.length === 0) fail(path, 'must be a non-empty string'); return value; @@ -99,4 +151,3 @@ function exact(actual: unknown, expected: unknown, path: string): void { function fail(path: string, message: string): never { throw new Error(`RFC64_CP1_PUBLIC_SWM_PARITY_INVALID at ${path}: ${message}`); } - From 0a92c91443c4ce47493dcbd09cecd3a2d80ee1bf Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:56:12 +0200 Subject: [PATCH 180/292] fix(sync): keep incomplete reconnect progress non-fresh --- .../src/sync/on-connect/sync-on-connect.ts | 9 +++- .../agent/test/sync-on-connect-retry.test.ts | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/sync/on-connect/sync-on-connect.ts b/packages/agent/src/sync/on-connect/sync-on-connect.ts index a63f4bdc98..70f7de4999 100644 --- a/packages/agent/src/sync/on-connect/sync-on-connect.ts +++ b/packages/agent/src/sync/on-connect/sync-on-connect.ts @@ -155,7 +155,14 @@ export async function runSyncOnConnect(context: SyncOnConnectContext): Promise { expect(synced).toEqual([{ peerId: remotePeer, fresh: true, progress: true }]); }); + it('keeps explicit incomplete durable progress non-fresh and retryable', async () => { + const remotePeer = freshPeerIdString(); + const synced: Array<{ + peerId: string; + fresh: boolean | undefined; + progress: boolean | undefined; + }> = []; + + const outcome = await runSyncOnConnect({ + remotePeer, + syncingPeers: new Set(), + getPeerProtocols: async () => [PROTOCOL_SYNC], + knownCorePeerIds: new Set(), + getSyncContextGraphs: () => [], + syncFromPeer: async () => ({ + complete: false, + insertedTriples: 40_000, + insertedDataTriples: 40_000, + insertedMetaTriples: 0, + metaOnlyResponses: 0, + verifiedPrivateOnlyResponses: 0, + completedPhases: 1, + checkpointAdvances: 1, + timedOutPhases: 0, + failedPeers: 0, + failedPhases: 0, + deniedPhases: 0, + dataRejectedMissingMeta: 0, + rejectedKcs: 0, + }), + refreshMetaSyncedFlags: async () => {}, + discoverContextGraphsFromStore: async () => 0, + syncSharedMemoryFromPeer: async () => ({ + insertedTriples: 0, + timedOutPhases: 0, + failedPeers: 0, + deniedPhases: 0, + }), + logInfo: noopLog, + onPeerSynced: (peerId, syncOutcome) => synced.push({ + peerId, + fresh: syncOutcome?.fresh, + progress: syncOutcome?.progress, + }), + }); + + expect(outcome).toBe('synced'); + expect(synced).toEqual([{ peerId: remotePeer, fresh: false, progress: true }]); + }); + it.each([ ['timed out', { timedOutPhases: 1 }], ['failed integrity verification', { rejectedKcs: 1 }], From 028eabe76ad5a5ae7d05dd765a975e63c6bcb6cd Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 00:59:33 +0200 Subject: [PATCH 181/292] fix(sync): preserve incomplete durable aggregate --- .../src/sync/on-connect/sync-on-connect.ts | 6 +- .../agent/test/sync-on-connect-retry.test.ts | 59 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/sync/on-connect/sync-on-connect.ts b/packages/agent/src/sync/on-connect/sync-on-connect.ts index 70f7de4999..f3ee88d5ea 100644 --- a/packages/agent/src/sync/on-connect/sync-on-connect.ts +++ b/packages/agent/src/sync/on-connect/sync-on-connect.ts @@ -136,6 +136,7 @@ export async function runSyncOnConnect(context: SyncOnConnectContext): Promise { - const cleanDurableRound = cleanDurableDetailedRound && !sawDurableMetadataOnlyDetailedSync; + const cleanDurableRound = cleanDurableDetailedRound + && !sawDurableMetadataOnlyDetailedSync + && !sawExplicitIncompleteDurableResult; if (sawBackpressureDeferral) { if (madeProgress) { context.onPeerSynced?.(remotePeer, { fresh: false, progress: true }); diff --git a/packages/agent/test/sync-on-connect-retry.test.ts b/packages/agent/test/sync-on-connect-retry.test.ts index 5bc5a4ed66..0d51b533bb 100644 --- a/packages/agent/test/sync-on-connect-retry.test.ts +++ b/packages/agent/test/sync-on-connect-retry.test.ts @@ -742,6 +742,65 @@ describe('runSyncOnConnect callbacks', () => { expect(synced).toEqual([{ peerId: remotePeer, fresh: false, progress: true }]); }); + it('keeps an incomplete first durable leg non-fresh after a clean discovered-CG leg', async () => { + const remotePeer = freshPeerIdString(); + let contextGraphs = ['cg-a']; + const synced: Array<{ + peerId: string; + fresh: boolean | undefined; + progress: boolean | undefined; + }> = []; + const syncFromPeer = recorder(async (_peerId: string, contextGraphIds?: string[]) => ({ + complete: contextGraphIds !== undefined, + insertedTriples: contextGraphIds === undefined ? 40_000 : 1, + insertedDataTriples: contextGraphIds === undefined ? 40_000 : 1, + insertedMetaTriples: 0, + metaOnlyResponses: 0, + verifiedPrivateOnlyResponses: 0, + completedPhases: 1, + checkpointAdvances: 1, + timedOutPhases: 0, + failedPeers: 0, + failedPhases: 0, + deniedPhases: 0, + dataRejectedMissingMeta: 0, + rejectedKcs: 0, + })); + + const outcome = await runSyncOnConnect({ + remotePeer, + syncingPeers: new Set(), + getPeerProtocols: async () => [PROTOCOL_SYNC], + knownCorePeerIds: new Set(), + getSyncContextGraphs: () => contextGraphs, + syncFromPeer, + refreshMetaSyncedFlags: async () => {}, + discoverContextGraphsFromStore: async () => { + contextGraphs = ['cg-a', 'cg-b']; + return 1; + }, + syncSharedMemoryFromPeer: async () => ({ + insertedTriples: 0, + timedOutPhases: 0, + failedPeers: 0, + deniedPhases: 0, + }), + logInfo: noopLog, + onPeerSynced: (peerId, syncOutcome) => synced.push({ + peerId, + fresh: syncOutcome?.fresh, + progress: syncOutcome?.progress, + }), + }); + + expect(outcome).toBe('synced'); + expect(syncFromPeer.calls).toEqual([ + [remotePeer], + [remotePeer, ['cg-b']], + ]); + expect(synced).toEqual([{ peerId: remotePeer, fresh: false, progress: true }]); + }); + it.each([ ['timed out', { timedOutPhases: 1 }], ['failed integrity verification', { rejectedKcs: 1 }], From f0d00a389bf9d9b47f9acaf1ffbfa6e3075273c3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 08:05:56 +0200 Subject: [PATCH 182/292] test(rfc64): bind CP1 evidence to policy cells --- .../policy-cells.ts | 99 +++++ devnet/rfc64-cp1-public-swm-parity/run.ts | 371 +++++++++--------- .../verifier.test.ts | 82 +++- .../rfc64-cp1-public-swm-parity/verifier.ts | 28 +- .../adapter-process.ts | 209 +++++----- 5 files changed, 468 insertions(+), 321 deletions(-) create mode 100644 devnet/rfc64-cp1-public-swm-parity/policy-cells.ts diff --git a/devnet/rfc64-cp1-public-swm-parity/policy-cells.ts b/devnet/rfc64-cp1-public-swm-parity/policy-cells.ts new file mode 100644 index 0000000000..d171765d40 --- /dev/null +++ b/devnet/rfc64-cp1-public-swm-parity/policy-cells.ts @@ -0,0 +1,99 @@ +import { + CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + computeContextGraphPolicyObjectDigestV1, + type AuthorCatalogScopeV1, + type ContextGraphIdV1, + type ContextGraphPolicyV1, + type CountV1, + type DecimalU256V1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type TimestampMsV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; + +export type Cp1PublicCellName = 'public-open' | 'public-curated'; + +export interface Cp1PublicCellSpec { + readonly cell: Cp1PublicCellName; + readonly contextGraphId: ContextGraphIdV1; + readonly publishPolicy: 0 | 1; +} + +export const CP1_NETWORK_ID = 'otp:20430' as NetworkIdV1; +export const CP1_OWNER_ADDRESS = + '0x1c5e6f6f6c7866ef146b0c0220d857d12a9058f0' as EvmAddressV1; +const ZERO_U64 = '0' as DecimalU64V1; +const ZERO_U256 = '0' as DecimalU256V1; +const ZERO_TIMESTAMP = '0' as TimestampMsV1; +const ONE_COUNT = '1' as CountV1; + +export const CP1_PUBLIC_CELL_SPECS: readonly Cp1PublicCellSpec[] = Object.freeze([ + Object.freeze({ + cell: 'public-open', + contextGraphId: + '0x1111111111111111111111111111111111111111/cp1-public-open' as ContextGraphIdV1, + publishPolicy: 1, + }), + Object.freeze({ + cell: 'public-curated', + contextGraphId: + '0x1111111111111111111111111111111111111111/cp1-public-curated' as ContextGraphIdV1, + publishPolicy: 0, + }), +]); + +export function cp1PublicPolicy(spec: Cp1PublicCellSpec): ContextGraphPolicyV1 { + const policy = { + networkId: CP1_NETWORK_ID, + contextGraphId: spec.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + era: ZERO_U64, + version: ZERO_U64, + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: spec.publishPolicy, + publishAuthority: spec.publishPolicy === 0 ? CP1_OWNER_ADDRESS : null, + publishAuthorityAccountId: ZERO_U256, + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'owner-signed-unregistered', + ownerAddress: CP1_OWNER_ADDRESS, + ownerAuthorityEra: ZERO_U64, + }, + effectiveAt: ZERO_TIMESTAMP, + issuedAt: ZERO_TIMESTAMP, + } satisfies ContextGraphPolicyV1; + return Object.freeze(policy); +} + +export function cp1PolicyDigest(spec: Cp1PublicCellSpec): Digest32V1 { + return computeContextGraphPolicyObjectDigestV1({ + issuer: CP1_OWNER_ADDRESS, + objectType: CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + payload: cp1PublicPolicy(spec), + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as unknown as UnsignedControlEnvelopeV1); +} + +export function cp1CatalogScope(spec: Cp1PublicCellSpec): AuthorCatalogScopeV1 { + const scope = { + networkId: CP1_NETWORK_ID, + contextGraphId: spec.contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: CP1_OWNER_ADDRESS, + era: ZERO_U64, + bucketCount: ONE_COUNT, + } satisfies AuthorCatalogScopeV1; + return Object.freeze(scope); +} diff --git a/devnet/rfc64-cp1-public-swm-parity/run.ts b/devnet/rfc64-cp1-public-swm-parity/run.ts index 201b9c64ae..0ba9ff4dc3 100644 --- a/devnet/rfc64-cp1-public-swm-parity/run.ts +++ b/devnet/rfc64-cp1-public-swm-parity/run.ts @@ -4,16 +4,12 @@ import { join, resolve } from 'node:path'; import process from 'node:process'; import { - CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, - CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, assertCanonicalGraphScopedAuthorSealV1, buildAuthorAttestationTypedData, - computeContextGraphPolicyObjectDigestV1, type CanonicalGraphScopedAuthorSealV1, type ContextGraphPolicyV1, type Digest32V1, type EvmAddressV1, - type UnsignedControlEnvelopeV1, } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; @@ -45,6 +41,16 @@ import { semanticSha256, verifyCp1PublicSwmParity, } from './verifier.ts'; +import { + CP1_NETWORK_ID, + CP1_OWNER_ADDRESS, + CP1_PUBLIC_CELL_SPECS, + cp1CatalogScope, + cp1PolicyDigest, + cp1PublicPolicy, + type Cp1PublicCellName, + type Cp1PublicCellSpec, +} from './policy-cells.ts'; const REPO_ROOT = resolve(import.meta.dirname, '../..'); const GATE2_DIR = resolve(import.meta.dirname, '../rfc64-gate2-multi-asset-completeness'); @@ -53,10 +59,13 @@ const RUNTIME_LOAD_HOOK = join(GATE2_DIR, 'runtime-load-hook.ts'); const ARTIFACT = process.env.DKG_RFC64_CP1_ARTIFACT ?? join(import.meta.dirname, 'artifacts/cp1-public-swm-parity.json'); const PROCESS_TIMEOUT_MS = 90_000; -const NETWORK_ID = 'otp:20430'; +const NETWORK_ID = CP1_NETWORK_ID; const OWNER_PRIVATE_KEY = `0x${'64'.repeat(32)}`; const OWNER_WALLET = new ethers.Wallet(OWNER_PRIVATE_KEY); const OWNER = OWNER_WALLET.address.toLowerCase() as EvmAddressV1; +if (OWNER !== CP1_OWNER_ADDRESS) { + throw new Error('CP1 owner fixture does not match its pinned private key'); +} const KAV10 = '0x4444444444444444444444444444444444444444'; const DEPLOYMENT = Object.freeze({ networkId: NETWORK_ID, @@ -73,24 +82,41 @@ const ASSERTION_ROOT = '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f'; const ROLE_MASTER_KEYS = Object.freeze({ author: '1a'.repeat(32), receiver: '2b'.repeat(32) }); -interface CellSpec { - readonly cell: 'public-open' | 'public-curated'; +interface Cp1PublicationAsset { + readonly bundleDigest: Digest32V1; + readonly contentDigest: Digest32V1; +} + +interface Cp1SynchronizationRow extends Cp1PublicationAsset { + readonly swmGraph: string; +} + +interface Cp1SemanticReadback { + readonly projectionNQuads: string; +} + +interface Cp1CellEvidence extends Cp1PublicationAsset { + readonly accessPolicy: 0; + readonly activatedTripleCount: number; + readonly announcementPolicyDigest: Digest32V1; + readonly announcedPeerId: string; + readonly appliedHeadStatus: string; + readonly authorPolicyDigest: Digest32V1; + readonly cell: Cp1PublicCellName; readonly contextGraphId: string; + readonly inventoryRowCount: number; + readonly projectionNQuads: string; readonly publishPolicy: 0 | 1; + readonly receiverPolicyDigest: Digest32V1; + readonly semanticSha256: string; } -const CELLS: readonly CellSpec[] = Object.freeze([ - Object.freeze({ - cell: 'public-open', - contextGraphId: '0x1111111111111111111111111111111111111111/cp1-public-open', - publishPolicy: 1, - }), - Object.freeze({ - cell: 'public-curated', - contextGraphId: '0x1111111111111111111111111111111111111111/cp1-public-curated', - publishPolicy: 0, - }), -]); +interface RunPolicyCellContext { + readonly author: Gate2AgentChild; + readonly receiver: Gate2AgentChild; + readonly receiverPeerId: string; + readonly seal: CanonicalGraphScopedAuthorSealV1; +} async function execute(): Promise { const launch = consumeGate2RuntimeLaunchReceiptV1(); @@ -130,121 +156,15 @@ async function execute(): Promise { if (authorPid === receiverPid) throw new Error('CP1 process identities are not distinct'); await connectBothWays(author, receiver, authorReady, receiverReady); - const seal = await authorSeal(70n); - const cellEvidence: Record[] = []; - for (const [index, spec] of CELLS.entries()) { - const policy = publicPolicy(spec); - const policyDigest = digestForPolicy(policy); - const [authorAccepted, receiverAccepted] = await Promise.all([ - acceptPolicy(author, `author-${spec.cell}`, policy, policyDigest), - acceptPolicy(receiver, `receiver-${spec.cell}`, policy, policyDigest), - ]); - exact(authorAccepted, policyDigest, `${spec.cell} author policy digest`); - exact(receiverAccepted, policyDigest, `${spec.cell} receiver policy digest`); - - const genesis = output(await author.request( - 'publishCatalogGenesis', - `${spec.cell}-genesis`, - 'operation-completed', - { - scope: catalogScope(spec.contextGraphId), - authorPrivateKey: OWNER_PRIVATE_KEY, - issuedAt: String(1773900000000 + index * 10_000), - catalogIssuerDelegationEffectiveAt: '1773899999000', - catalogIssuerDelegationExpiresAt: '1774000000000', - }, - ), `${spec.cell} genesis`); - const genesisAnnouncement = record(genesis.announcement, `${spec.cell} genesis announcement`); - exact(genesisAnnouncement.policyDigest, policyDigest, `${spec.cell} genesis policy digest`); - await announceAndDrain( - author, - receiver, - genesisAnnouncement, - receiverPeerId, - `${spec.cell}-genesis`, - ); - - const publication = output(await author.request( - 'publishCatalogExactSetSuccessor', - `${spec.cell}-successor`, - 'operation-completed', - { - previousHead: stagedHead(genesis, `${spec.cell} genesis`), - authorPrivateKey: OWNER_PRIVATE_KEY, - catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, - assets: [{ - assertionCoordinate: 'cp1-byte-identical-public-corpus', - projectionNQuads: PROJECTION_NQUADS, - seal, - }], - deployment: DEPLOYMENT, - issuedAt: String(1773900001000 + index * 10_000), - }, - ), `${spec.cell} successor`); - const announcement = record(publication.announcement, `${spec.cell} successor announcement`); - exact(announcement.policyDigest, policyDigest, `${spec.cell} successor policy digest`); - await announceAndDrain( - author, - receiver, - announcement, - receiverPeerId, - `${spec.cell}-successor`, - ); - const headDigest = requiredDigest(publication.headObjectDigest, `${spec.cell} head digest`); - const synchronization = output(await receiver.request( - 'exactInventoryReadback', - `${spec.cell}-inventory`, - 'operation-completed', - { catalogHeadDigest: headDigest }, - ), `${spec.cell} inventory`); - const rows = array(synchronization.rows, `${spec.cell} inventory rows`); - if (rows.length !== 1) throw new Error(`${spec.cell} did not apply exactly one row`); - const row = record(rows[0], `${spec.cell} inventory row`); - const semantic = output(await receiver.request( - 'semanticGraphReadback', - `${spec.cell}-semantic`, - 'operation-completed', - { swmGraph: requiredString(row.swmGraph, `${spec.cell} SWM graph`) }, - ), `${spec.cell} semantic readback`); - const projectionNQuads = requiredString( - semantic.projectionNQuads, - `${spec.cell} projection N-Quads`, - ); - exact(projectionNQuads, PROJECTION_NQUADS, `${spec.cell} exact semantic bytes`); - const publishedAssets = array(publication.assets, `${spec.cell} published assets`); - if (publishedAssets.length !== 1) throw new Error(`${spec.cell} publication has wrong row count`); - const publishedAsset = record(publishedAssets[0], `${spec.cell} published asset`); - exact(publishedAsset.bundleDigest, row.bundleDigest, `${spec.cell} bundle transfer digest`); - exact(publishedAsset.contentDigest, row.contentDigest, `${spec.cell} content transfer digest`); - cellEvidence.push({ - accessPolicy: 0, - activatedTripleCount: requiredInteger( - synchronization.activatedTripleCount, - `${spec.cell} activated triple count`, - ), - announcementPolicyDigest: requiredDigest( - announcement.policyDigest, - `${spec.cell} announcement policy digest`, - ), - announcedPeerId: receiverPeerId, - appliedHeadStatus: requiredString( - synchronization.appliedHeadStatus, - `${spec.cell} applied status`, - ), - authorPolicyDigest: authorAccepted, - bundleDigest: requiredDigest(row.bundleDigest, `${spec.cell} bundle digest`), - cell: spec.cell, - contentDigest: requiredDigest(row.contentDigest, `${spec.cell} content digest`), - contextGraphId: spec.contextGraphId, - inventoryRowCount: requiredInteger( - synchronization.inventoryRowCount, - `${spec.cell} inventory row count`, - ), - projectionNQuads, - publishPolicy: spec.publishPolicy, - receiverPolicyDigest: receiverAccepted, - semanticSha256: semanticSha256(projectionNQuads), - }); + const context: RunPolicyCellContext = { + author, + receiver, + receiverPeerId, + seal: await authorSeal(70n), + }; + const cellEvidence: Cp1CellEvidence[] = []; + for (const [index, spec] of CP1_PUBLIC_CELL_SPECS.entries()) { + cellEvidence.push(await runPolicyCell(context, spec, index)); } const [authorStopped, receiverStopped] = await Promise.all([ @@ -293,54 +213,149 @@ async function execute(): Promise { } } -function publicPolicy(spec: CellSpec): ContextGraphPolicyV1 { +async function runPolicyCell( + context: RunPolicyCellContext, + spec: Cp1PublicCellSpec, + index: number, +): Promise { + const { author, receiver, receiverPeerId, seal } = context; + const policy = cp1PublicPolicy(spec); + const policyDigest = cp1PolicyDigest(spec); + const [authorAccepted, receiverAccepted] = await Promise.all([ + acceptPolicy(author, `author-${spec.cell}`, policy, policyDigest), + acceptPolicy(receiver, `receiver-${spec.cell}`, policy, policyDigest), + ]); + exact(authorAccepted, policyDigest, `${spec.cell} author policy digest`); + exact(receiverAccepted, policyDigest, `${spec.cell} receiver policy digest`); + + const genesis = output(await author.request( + 'publishCatalogGenesis', + `${spec.cell}-genesis`, + 'operation-completed', + { + scope: cp1CatalogScope(spec), + authorPrivateKey: OWNER_PRIVATE_KEY, + issuedAt: String(1773900000000 + index * 10_000), + catalogIssuerDelegationEffectiveAt: '1773899999000', + catalogIssuerDelegationExpiresAt: '1774000000000', + }, + ), `${spec.cell} genesis`); + const genesisAnnouncement = record(genesis.announcement, `${spec.cell} genesis announcement`); + exact(genesisAnnouncement.policyDigest, policyDigest, `${spec.cell} genesis policy digest`); + await announceAndDrain( + author, + receiver, + genesisAnnouncement, + receiverPeerId, + `${spec.cell}-genesis`, + ); + + const publication = output(await author.request( + 'publishCatalogExactSetSuccessor', + `${spec.cell}-successor`, + 'operation-completed', + { + previousHead: stagedHead(genesis, `${spec.cell} genesis`), + authorPrivateKey: OWNER_PRIVATE_KEY, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + assets: [{ + assertionCoordinate: 'cp1-byte-identical-public-corpus', + projectionNQuads: PROJECTION_NQUADS, + seal, + }], + deployment: DEPLOYMENT, + issuedAt: String(1773900001000 + index * 10_000), + }, + ), `${spec.cell} successor`); + const announcement = record(publication.announcement, `${spec.cell} successor announcement`); + exact(announcement.policyDigest, policyDigest, `${spec.cell} successor policy digest`); + await announceAndDrain( + author, + receiver, + announcement, + receiverPeerId, + `${spec.cell}-successor`, + ); + + const headDigest = requiredDigest(publication.headObjectDigest, `${spec.cell} head digest`); + const synchronization = output(await receiver.request( + 'exactInventoryReadback', + `${spec.cell}-inventory`, + 'operation-completed', + { catalogHeadDigest: headDigest }, + ), `${spec.cell} inventory`); + const rows = array(synchronization.rows, `${spec.cell} inventory rows`); + if (rows.length !== 1) throw new Error(`${spec.cell} did not apply exactly one row`); + const row = synchronizationRow(rows[0], `${spec.cell} inventory row`); + const semantic = semanticReadback(output(await receiver.request( + 'semanticGraphReadback', + `${spec.cell}-semantic`, + 'operation-completed', + { swmGraph: row.swmGraph }, + ), `${spec.cell} semantic readback`), `${spec.cell} semantic readback`); + exact(semantic.projectionNQuads, PROJECTION_NQUADS, `${spec.cell} exact semantic bytes`); + + const publishedAssets = array(publication.assets, `${spec.cell} published assets`); + if (publishedAssets.length !== 1) throw new Error(`${spec.cell} publication has wrong row count`); + const publishedAsset = publicationAsset(publishedAssets[0], `${spec.cell} published asset`); + exact(publishedAsset.bundleDigest, row.bundleDigest, `${spec.cell} bundle transfer digest`); + exact(publishedAsset.contentDigest, row.contentDigest, `${spec.cell} content transfer digest`); + return { - networkId: NETWORK_ID as never, - contextGraphId: spec.contextGraphId as never, - governanceChainId: null, - governanceContractAddress: null, - ownershipTransitionDigest: null, - era: '0', - version: '0', - previousPolicyDigest: null, accessPolicy: 0, + activatedTripleCount: requiredInteger( + synchronization.activatedTripleCount, + `${spec.cell} activated triple count`, + ), + announcementPolicyDigest: requiredDigest( + announcement.policyDigest, + `${spec.cell} announcement policy digest`, + ), + announcedPeerId: receiverPeerId, + appliedHeadStatus: requiredString( + synchronization.appliedHeadStatus, + `${spec.cell} applied status`, + ), + authorPolicyDigest: authorAccepted, + bundleDigest: row.bundleDigest, + cell: spec.cell, + contentDigest: row.contentDigest, + contextGraphId: spec.contextGraphId, + inventoryRowCount: requiredInteger( + synchronization.inventoryRowCount, + `${spec.cell} inventory row count`, + ), + projectionNQuads: semantic.projectionNQuads, publishPolicy: spec.publishPolicy, - publishAuthority: spec.publishPolicy === 0 ? OWNER : null, - publishAuthorityAccountId: '0', - projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, - administrativeDelegationDigest: null, - source: { - kind: 'owner-signed-unregistered', - ownerAddress: OWNER, - ownerAuthorityEra: '0', - }, - effectiveAt: '0', - issuedAt: '0', - } as unknown as ContextGraphPolicyV1; + receiverPolicyDigest: receiverAccepted, + semanticSha256: semanticSha256(semantic.projectionNQuads), + }; } -function digestForPolicy(policy: ContextGraphPolicyV1): Digest32V1 { - return computeContextGraphPolicyObjectDigestV1({ - issuer: OWNER, - objectType: CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, - payload: policy, - signatureEvidence: { kind: 'none' }, - signatureSuite: 'eip191-personal-sign-digest-v1', - } as unknown as UnsignedControlEnvelopeV1); +function publicationAsset(value: unknown, label: string): Cp1PublicationAsset { + const asset = record(value, label); + return Object.freeze({ + bundleDigest: requiredDigest(asset.bundleDigest, `${label}.bundleDigest`), + contentDigest: requiredDigest(asset.contentDigest, `${label}.contentDigest`), + }); } -function catalogScope(contextGraphId: string): Record { - return { - networkId: NETWORK_ID, - contextGraphId, - governanceChainId: null, - governanceContractAddress: null, - ownershipTransitionDigest: null, - subGraphName: null, - authorAddress: OWNER, - era: '0', - bucketCount: '1', - }; +function synchronizationRow(value: unknown, label: string): Cp1SynchronizationRow { + const row = record(value, label); + return Object.freeze({ + ...publicationAsset(row, label), + swmGraph: requiredString(row.swmGraph, `${label}.swmGraph`), + }); +} + +function semanticReadback(value: unknown, label: string): Cp1SemanticReadback { + const semantic = record(value, label); + return Object.freeze({ + projectionNQuads: requiredString( + semantic.projectionNQuads, + `${label}.projectionNQuads`, + ), + }); } async function acceptPolicy( @@ -348,7 +363,7 @@ async function acceptPolicy( requestId: string, policy: ContextGraphPolicyV1, policyDigest: Digest32V1, -): Promise { +): Promise { const accepted = output(await child.request( 'acceptPolicySnapshot', requestId, @@ -518,10 +533,10 @@ function requiredString(value: unknown, label: string): string { return value; } -function requiredDigest(value: unknown, label: string): string { +function requiredDigest(value: unknown, label: string): Digest32V1 { const result = requiredString(value, label); if (!/^0x[0-9a-f]{64}$/u.test(result)) throw new TypeError(`${label} is not a digest`); - return result; + return result as Digest32V1; } function requiredInteger(value: unknown, label: string): number { diff --git a/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts b/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts index 2659325705..4ef3fdcb6e 100644 --- a/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts +++ b/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts @@ -6,31 +6,39 @@ import { semanticSha256, verifyCp1PublicSwmParity, } from './verifier.ts'; +import { + CP1_PUBLIC_CELL_SPECS, + cp1PolicyDigest, +} from './policy-cells.ts'; const projection = ' "RFC-64 CP1" .\n' + ' "1" .\n'; const digest = (byte: string) => `0x${byte.repeat(64)}`; function fixture(): Record { - const cell = (name: string, publishPolicy: number, policyByte: string) => ({ - accessPolicy: 0, - activatedTripleCount: 2, - announcementPolicyDigest: digest(policyByte), - announcedPeerId: 'receiver-peer', - appliedHeadStatus: 'applied', - authorPolicyDigest: digest(policyByte), - bundleDigest: digest('a'), - cell: name, - contentDigest: digest('b'), - contextGraphId: `cg-${name}`, - inventoryRowCount: 1, - projectionNQuads: projection, - publishPolicy, - receiverPolicyDigest: digest(policyByte), - semanticSha256: semanticSha256(projection), - }); + const cell = (index: number) => { + const spec = CP1_PUBLIC_CELL_SPECS[index]!; + const policyDigest = cp1PolicyDigest(spec); + return { + accessPolicy: 0, + activatedTripleCount: 2, + announcementPolicyDigest: policyDigest, + announcedPeerId: 'receiver-peer', + appliedHeadStatus: 'applied', + authorPolicyDigest: policyDigest, + bundleDigest: digest('a'), + cell: spec.cell, + contentDigest: digest('b'), + contextGraphId: spec.contextGraphId, + inventoryRowCount: 1, + projectionNQuads: projection, + publishPolicy: spec.publishPolicy, + receiverPolicyDigest: policyDigest, + semanticSha256: semanticSha256(projection), + }; + }; return { - cells: [cell('public-open', 1, '1'), cell('public-curated', 0, '2')], + cells: [cell(0), cell(1)], expectedProjectionNQuads: projection, expectedSemanticSha256: semanticSha256(projection), peers: { authorPeerId: 'author-peer', receiverPeerId: 'receiver-peer' }, @@ -70,3 +78,41 @@ test('rejects publish-axis, process-boundary, and byte-parity drift', () => { extraKey.unverified = true; assert.throws(() => verifyCp1PublicSwmParity(extraKey), /keys differ/); }); + +test('rejects wrong or duplicate policy-cell identity', () => { + const wrongContextGraph = structuredClone(fixture()) as { + cells: Array>; + }; + wrongContextGraph.cells[1]!.contextGraphId = 'cg-unrelated'; + assert.throws(() => verifyCp1PublicSwmParity(wrongContextGraph), /contextGraphId/); + + const duplicateContextGraph = structuredClone(fixture()) as { + cells: Array>; + }; + duplicateContextGraph.cells[1]!.contextGraphId = duplicateContextGraph.cells[0]!.contextGraphId; + assert.throws(() => verifyCp1PublicSwmParity(duplicateContextGraph), /contextGraphId/); + + const wrongPolicyDigest = structuredClone(fixture()) as { + cells: Array>; + }; + for (const field of [ + 'announcementPolicyDigest', + 'authorPolicyDigest', + 'receiverPolicyDigest', + ]) { + wrongPolicyDigest.cells[1]![field] = digest('9'); + } + assert.throws(() => verifyCp1PublicSwmParity(wrongPolicyDigest), /authorPolicyDigest/); + + const duplicatePolicyDigest = structuredClone(fixture()) as { + cells: Array>; + }; + for (const field of [ + 'announcementPolicyDigest', + 'authorPolicyDigest', + 'receiverPolicyDigest', + ]) { + duplicatePolicyDigest.cells[1]![field] = duplicatePolicyDigest.cells[0]![field]; + } + assert.throws(() => verifyCp1PublicSwmParity(duplicatePolicyDigest), /authorPolicyDigest/); +}); diff --git a/devnet/rfc64-cp1-public-swm-parity/verifier.ts b/devnet/rfc64-cp1-public-swm-parity/verifier.ts index 153c81bd89..59df0f67a4 100644 --- a/devnet/rfc64-cp1-public-swm-parity/verifier.ts +++ b/devnet/rfc64-cp1-public-swm-parity/verifier.ts @@ -1,11 +1,15 @@ import { createHash } from 'node:crypto'; +import { + CP1_PUBLIC_CELL_SPECS, + cp1PolicyDigest, +} from './policy-cells.ts'; + export const CP1_PUBLIC_SWM_PARITY_SCHEMA = 'dkg-rfc64-cp1-public-swm-parity-v1' as const; const DIGEST = /^0x[0-9a-f]{64}$/u; const SHA256 = /^sha256:[0-9a-f]{64}$/u; -const CELLS = ['public-open', 'public-curated'] as const; export function semanticSha256(value: string): string { return `sha256:${createHash('sha256').update(value).digest('hex')}`; @@ -76,11 +80,13 @@ export function verifyCp1PublicSwmParity(value: unknown): Record { } case 'publishGenesis': { requireRole('author'); - const input = plainRecord(command.input, 'publishGenesis input'); - const authorPrivateKey = requiredString( - input.authorPrivateKey, - 'publishGenesis.authorPrivateKey', + const output = await publishGenesisVia( + command, + (input) => currentAgent.publishOpenAuthorCatalogGenesisV1(input), ); - const author = new ethers.Wallet(authorPrivateKey); - const forwarded: Record = { ...input, author, peers: [] }; - delete forwarded.authorPrivateKey; - const output = await currentAgent.publishOpenAuthorCatalogGenesisV1(forwarded as never); emitOperationResult(command, output); return; } case 'publishCatalogGenesis': { requireRole('author'); - const input = plainRecord(command.input, 'publishCatalogGenesis input'); - const authorPrivateKey = requiredString( - input.authorPrivateKey, - 'publishCatalogGenesis.authorPrivateKey', + const output = await publishGenesisVia( + command, + (input) => currentAgent.publishAuthorCatalogGenesisV1(input), ); - const forwarded: Record = { - ...input, - author: new ethers.Wallet(authorPrivateKey), - peers: [], - }; - delete forwarded.authorPrivateKey; - const output = await currentAgent.publishAuthorCatalogGenesisV1(forwarded as never); emitOperationResult(command, output); return; } case 'publishExactSetSuccessor': { requireRole('author'); - const input = plainRecord(command.input, 'publishExactSetSuccessor input'); - const authorPrivateKey = requiredString( - input.authorPrivateKey, - 'publishExactSetSuccessor.authorPrivateKey', - ); - const assets = plainArray(input.assets, 'publishExactSetSuccessor.assets').map( - (value, index) => { - const asset = plainRecord(value, `publishExactSetSuccessor.assets[${index}]`); - const projectionNQuads = requiredString( - asset.projectionNQuads, - `publishExactSetSuccessor.assets[${index}].projectionNQuads`, - ); - const forwarded: Record = { - ...asset, - projectionBytes: new TextEncoder().encode(projectionNQuads), - }; - delete forwarded.projectionNQuads; - return forwarded; - }, - ); - const forwarded: Record = { - ...input, - author: new ethers.Wallet(authorPrivateKey), - assets, - peers: [], - }; - delete forwarded.authorPrivateKey; - const output = await currentAgent.publishOpenAuthorCatalogExactSetSuccessorV1( - forwarded as never, + const output = await publishExactSetSuccessorVia( + currentAgent, + command, + (input) => currentAgent.publishOpenAuthorCatalogExactSetSuccessorV1(input), ); - const result = plainRecord(output, 'publishExactSetSuccessor output'); - const outputAssets = plainArray(result.assets, 'publishExactSetSuccessor.output.assets'); - const assetsWithReceipts = await Promise.all(outputAssets.map(async (value, index) => { - const asset = plainRecord(value, `publishExactSetSuccessor.output.assets[${index}]`); - const bundleDigest = requiredDigest( - asset.bundleDigest, - `publishExactSetSuccessor.output.assets[${index}].bundleDigest`, - ); - const stagedBundle = await currentAgent.readRfc64StagedKaBundleV1(bundleDigest); - if (stagedBundle === null) { - throw new Error(`published exact-set bundle ${bundleDigest} is absent from durable storage`); - } - return Object.freeze({ - ...asset, - stagedBundleByteLength: stagedBundle.byteLength, - }); - })); - emitOperationResult(command, { ...result, assets: Object.freeze(assetsWithReceipts) }); + emitOperationResult(command, output); return; } case 'publishCatalogExactSetSuccessor': { requireRole('author'); - const input = plainRecord(command.input, 'publishCatalogExactSetSuccessor input'); - const authorPrivateKey = requiredString( - input.authorPrivateKey, - 'publishCatalogExactSetSuccessor.authorPrivateKey', - ); - const assets = plainArray( - input.assets, - 'publishCatalogExactSetSuccessor.assets', - ).map((value, index) => { - const asset = plainRecord(value, `publishCatalogExactSetSuccessor.assets[${index}]`); - const projectionNQuads = requiredString( - asset.projectionNQuads, - `publishCatalogExactSetSuccessor.assets[${index}].projectionNQuads`, - ); - const forwarded: Record = { - ...asset, - projectionBytes: new TextEncoder().encode(projectionNQuads), - }; - delete forwarded.projectionNQuads; - return forwarded; - }); - const forwarded: Record = { - ...input, - author: new ethers.Wallet(authorPrivateKey), - assets, - peers: [], - }; - delete forwarded.authorPrivateKey; - const output = await currentAgent.publishAuthorCatalogExactSetSuccessorV1( - forwarded as never, - ); - const result = plainRecord(output, 'publishCatalogExactSetSuccessor output'); - const outputAssets = plainArray( - result.assets, - 'publishCatalogExactSetSuccessor.output.assets', + const output = await publishExactSetSuccessorVia( + currentAgent, + command, + (input) => currentAgent.publishAuthorCatalogExactSetSuccessorV1(input), ); - const assetsWithReceipts = await Promise.all(outputAssets.map(async (value, index) => { - const asset = plainRecord( - value, - `publishCatalogExactSetSuccessor.output.assets[${index}]`, - ); - const bundleDigest = requiredDigest( - asset.bundleDigest, - `publishCatalogExactSetSuccessor.output.assets[${index}].bundleDigest`, - ); - const stagedBundle = await currentAgent.readRfc64StagedKaBundleV1(bundleDigest); - if (stagedBundle === null) { - throw new Error(`published catalog bundle ${bundleDigest} is absent from durable storage`); - } - return Object.freeze({ - ...asset, - stagedBundleByteLength: stagedBundle.byteLength, - }); - })); - emitOperationResult(command, { ...result, assets: Object.freeze(assetsWithReceipts) }); + emitOperationResult(command, output); return; } case 'announce': { @@ -436,6 +332,79 @@ async function handle(command: Command): Promise { } } +type HarnessGenesisPublisher = (input: never) => Promise; +type HarnessExactSetPublisher = (input: never) => Promise; + +async function publishGenesisVia( + command: Command, + publish: HarnessGenesisPublisher, +): Promise { + const label = command.command; + const input = plainRecord(command.input, `${label} input`); + const authorPrivateKey = requiredString( + input.authorPrivateKey, + `${label}.authorPrivateKey`, + ); + const forwarded: Record = { + ...input, + author: new ethers.Wallet(authorPrivateKey), + peers: [], + }; + delete forwarded.authorPrivateKey; + return publish(forwarded as never); +} + +async function publishExactSetSuccessorVia( + currentAgent: DKGAgent, + command: Command, + publish: HarnessExactSetPublisher, +): Promise> { + const label = command.command; + const input = plainRecord(command.input, `${label} input`); + const authorPrivateKey = requiredString( + input.authorPrivateKey, + `${label}.authorPrivateKey`, + ); + const assets = plainArray(input.assets, `${label}.assets`).map((value, index) => { + const assetLabel = `${label}.assets[${index}]`; + const asset = plainRecord(value, assetLabel); + const projectionNQuads = requiredString( + asset.projectionNQuads, + `${assetLabel}.projectionNQuads`, + ); + const forwarded: Record = { + ...asset, + projectionBytes: new TextEncoder().encode(projectionNQuads), + }; + delete forwarded.projectionNQuads; + return forwarded; + }); + const forwarded: Record = { + ...input, + author: new ethers.Wallet(authorPrivateKey), + assets, + peers: [], + }; + delete forwarded.authorPrivateKey; + + const result = plainRecord(await publish(forwarded as never), `${label} output`); + const outputAssets = plainArray(result.assets, `${label}.output.assets`); + const assetsWithReceipts = await Promise.all(outputAssets.map(async (value, index) => { + const assetLabel = `${label}.output.assets[${index}]`; + const asset = plainRecord(value, assetLabel); + const bundleDigest = requiredDigest(asset.bundleDigest, `${assetLabel}.bundleDigest`); + const stagedBundle = await currentAgent.readRfc64StagedKaBundleV1(bundleDigest); + if (stagedBundle === null) { + throw new Error(`published bundle ${bundleDigest} is absent from durable storage`); + } + return Object.freeze({ + ...asset, + stagedBundleByteLength: stagedBundle.byteLength, + }); + })); + return Object.freeze({ ...result, assets: Object.freeze(assetsWithReceipts) }); +} + function compareQuad(left: Quad, right: Quad): number { const leftKey = `${left.subject}\n${left.predicate}\n${left.object}\n${left.graph}`; const rightKey = `${right.subject}\n${right.predicate}\n${right.object}\n${right.graph}`; From 71d6692480fd5ce6ddb91cd242e3ab07b59a57c4 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 08:13:11 +0200 Subject: [PATCH 183/292] refactor(core): separate finalized VM row and frontier --- packages/core/src/finalized-vm-set-v1.ts | 134 ++++++++++++------ .../core/test/finalized-vm-set-v1.test.ts | 6 +- 2 files changed, 92 insertions(+), 48 deletions(-) diff --git a/packages/core/src/finalized-vm-set-v1.ts b/packages/core/src/finalized-vm-set-v1.ts index 5651622c63..b71c0bfa6a 100644 --- a/packages/core/src/finalized-vm-set-v1.ts +++ b/packages/core/src/finalized-vm-set-v1.ts @@ -82,6 +82,15 @@ export interface FinalizedVmSetEvidenceV1 { readonly highestFinalizedOrdinal: DecimalU64V1 | null; } +type CanonicalFinalizedVmSetRowV1 = Readonly + & Readonly>; + +interface ValidatedFinalizedVmSetRowV1 { + readonly row: CanonicalFinalizedVmSetRowV1; + readonly ordinalValue: bigint; + readonly assertionVersionValue: bigint; +} + export type FinalizedVmSetV1ErrorCode = | 'finalized-vm-set-schema' | 'finalized-vm-set-scalar' @@ -151,21 +160,28 @@ export function snapshotFinalizedVmSetRowV1( input: FinalizedVmSetRowV1, ): Readonly { const scope = snapshotFinalizedVmSetScopeV1(scopeInput); - return snapshotFinalizedVmSetRowForScopeV1(scope, input); + return snapshotFinalizedVmSetRowForScopeV1(scope, input).row; } function snapshotFinalizedVmSetRowForScopeV1( scope: Readonly, input: FinalizedVmSetRowV1, -): Readonly { +): ValidatedFinalizedVmSetRowV1 { const record = snapshotRecord(input, FINALIZED_VM_SET_ROW_KEYS, 'finalized VM row'); + let ordinalValue: bigint; + let assertionVersionValue: bigint; try { assertCanonicalChainId(record.chainId, 'row.chainId'); assertCanonicalEvmAddress(record.contractAddress, 'row.contractAddress'); assertCanonicalDecimalU64(record.ordinal, 'row.ordinal'); + ordinalValue = parseCanonicalDecimalU64(record.ordinal, 'row.ordinal'); assertCanonicalEvmAddress(record.authorAddress, 'row.authorAddress'); assertCanonicalDecimalU64(record.assertionVersion, 'row.assertionVersion'); - if (parseCanonicalDecimalU64(record.assertionVersion, 'row.assertionVersion') === 0n) { + assertionVersionValue = parseCanonicalDecimalU64( + record.assertionVersion, + 'row.assertionVersion', + ); + if (assertionVersionValue === 0n) { fail('finalized-vm-set-scalar', 'row.assertionVersion must be positive'); } assertCanonicalDigest(record.assertionRoot, 'row.assertionRoot'); @@ -206,7 +222,7 @@ function snapshotFinalizedVmSetRowForScopeV1( fail('finalized-vm-set-lane', 'finalized VM row differs from the trusted scope lane'); } - return Object.freeze({ + const row = Object.freeze({ chainId: record.chainId, contractAddress: record.contractAddress, ordinal: record.ordinal, @@ -217,7 +233,8 @@ function snapshotFinalizedVmSetRowForScopeV1( finalizedBlockNumber: record.finalizedBlockNumber, finalizedBlockHash: record.finalizedBlockHash, placementEvidenceDigest: record.placementEvidenceDigest, - }) as Readonly; + }) as CanonicalFinalizedVmSetRowV1; + return Object.freeze({ row, ordinalValue, assertionVersionValue }); } /** Compute the exact domain-separated RFC-64 leaf digest for one placed row. */ @@ -226,8 +243,8 @@ export function computeFinalizedVmSetLeafDigestV1( row: FinalizedVmSetRowV1, ): Digest32V1 { const trustedScope = snapshotFinalizedVmSetScopeV1(scope); - const snapshot = snapshotFinalizedVmSetRowForScopeV1(trustedScope, row); - return digestBytesToLowerHex(computeFinalizedVmSetLeafDigestBytesV1(snapshot)); + const validated = snapshotFinalizedVmSetRowForScopeV1(trustedScope, row); + return digestBytesToLowerHex(computeFinalizedVmSetLeafDigestBytesV1(validated.row)); } /** The canonical finalized-VM empty accumulator root. */ @@ -244,7 +261,11 @@ export function computeEmptyFinalizedVmSetRootV1(): Digest32V1 { */ export class FinalizedVmSetAccumulatorV1 { readonly #scope: Readonly; - readonly #levels: Array = []; + readonly #frontier = new DomainSeparatedMerkleFrontier( + NODE_DOMAIN_BYTES, + ODD_DOMAIN_BYTES, + EMPTY_DOMAIN_BYTES, + ); #rowCount = 0n; #highestFinalizedOrdinal: DecimalU64V1 | null = null; #highestOrdinalValue: bigint | null = null; @@ -268,23 +289,18 @@ export class FinalizedVmSetAccumulatorV1 { fail('finalized-vm-set-state', 'finalized VM row count exceeds the u64 range'); } - const row = snapshotFinalizedVmSetRowForScopeV1(this.#scope, rowInput); - const ordinal = parseCanonicalDecimalU64(row.ordinal, 'row.ordinal'); - if (this.#highestOrdinalValue !== null && ordinal <= this.#highestOrdinalValue) { + const validated = snapshotFinalizedVmSetRowForScopeV1(this.#scope, rowInput); + if ( + this.#highestOrdinalValue !== null + && validated.ordinalValue <= this.#highestOrdinalValue + ) { fail('finalized-vm-set-order', 'finalized VM rows must have unique increasing ordinals'); } - let current = computeFinalizedVmSetLeafDigestBytesV1(row); - let level = 0; - while (this.#levels[level] !== undefined) { - current = digestBytes(NODE_DOMAIN_BYTES, this.#levels[level]!, current); - this.#levels[level] = undefined; - level += 1; - } - this.#levels[level] = current; + this.#frontier.append(computeFinalizedVmSetLeafDigestBytesV1(validated.row)); this.#rowCount += 1n; - this.#highestOrdinalValue = ordinal; - this.#highestFinalizedOrdinal = row.ordinal; + this.#highestOrdinalValue = validated.ordinalValue; + this.#highestFinalizedOrdinal = validated.row.ordinal; } finally { this.#appendInProgress = false; } @@ -296,30 +312,9 @@ export class FinalizedVmSetAccumulatorV1 { } if (this.#finalEvidence !== undefined) return this.#finalEvidence; - let pending: Uint8Array | undefined; - let pendingLevel = -1; - for (let level = 0; level < this.#levels.length; level += 1) { - const left = this.#levels[level]; - if (left === undefined) continue; - if (pending === undefined) { - pending = left; - pendingLevel = level; - continue; - } - while (pendingLevel < level) { - pending = digestBytes(ODD_DOMAIN_BYTES, pending); - pendingLevel += 1; - } - pending = digestBytes(NODE_DOMAIN_BYTES, left, pending); - pendingLevel = level + 1; - } - - const rootDigest = pending === undefined - ? computeEmptyFinalizedVmSetRootV1() - : digestBytesToLowerHex(pending); this.#finalEvidence = Object.freeze({ scope: this.#scope, - rootDigest, + rootDigest: digestBytesToLowerHex(this.#frontier.finalizeRoot()), rowCount: this.#rowCount.toString() as DecimalU64V1, highestFinalizedOrdinal: this.#highestFinalizedOrdinal, }); @@ -338,17 +333,66 @@ export function computeFinalizedVmSetEvidenceV1( } function computeFinalizedVmSetLeafDigestBytesV1( - row: Readonly, + row: CanonicalFinalizedVmSetRowV1, ): Uint8Array { return digestBytes( LEAF_DOMAIN_BYTES, - canonicalizeJsonBytes(row as unknown as CanonicalJsonValue, { + canonicalizeJsonBytes(row, { maxBytes: 2 * 1024, maxDepth: 2, }), ); } +class DomainSeparatedMerkleFrontier { + readonly #levels: Array = []; + #finalRoot: Uint8Array | undefined; + + constructor( + private readonly nodeDomain: Uint8Array, + private readonly oddDomain: Uint8Array, + private readonly emptyDomain: Uint8Array, + ) {} + + append(leafDigest: Uint8Array): void { + if (this.#finalRoot !== undefined) { + fail('finalized-vm-set-state', 'cannot append to a finalized Merkle frontier'); + } + let current = leafDigest; + let level = 0; + while (this.#levels[level] !== undefined) { + current = digestBytes(this.nodeDomain, this.#levels[level]!, current); + this.#levels[level] = undefined; + level += 1; + } + this.#levels[level] = current; + } + + finalizeRoot(): Uint8Array { + if (this.#finalRoot !== undefined) return this.#finalRoot; + + let pending: Uint8Array | undefined; + let pendingLevel = -1; + for (let level = 0; level < this.#levels.length; level += 1) { + const left = this.#levels[level]; + if (left === undefined) continue; + if (pending === undefined) { + pending = left; + pendingLevel = level; + continue; + } + while (pendingLevel < level) { + pending = digestBytes(this.oddDomain, pending); + pendingLevel += 1; + } + pending = digestBytes(this.nodeDomain, left, pending); + pendingLevel = level + 1; + } + this.#finalRoot = pending ?? digestBytes(this.emptyDomain); + return this.#finalRoot; + } +} + function snapshotRecord( input: unknown, expectedKeys: Keys, diff --git a/packages/core/test/finalized-vm-set-v1.test.ts b/packages/core/test/finalized-vm-set-v1.test.ts index 7377a6e3b1..a963bce52a 100644 --- a/packages/core/test/finalized-vm-set-v1.test.ts +++ b/packages/core/test/finalized-vm-set-v1.test.ts @@ -72,11 +72,11 @@ describe('RFC-64 finalized VM-set accumulator', () => { }); it('streams ordered rows with logarithmic retained tree state and finalizes idempotently', () => { + const rows = [row(0), row(3), row(9)]; const accumulator = new FinalizedVmSetAccumulatorV1(SCOPE); - accumulator.append(row(0)); - accumulator.append(row(3)); - accumulator.append(row(9)); + for (const value of rows) accumulator.append(value); const first = accumulator.finalize(); + expect(first.rootDigest).toBe(computeReferenceRoot(rows)); expect(first.rowCount).toBe('3'); expect(first.highestFinalizedOrdinal).toBe('9'); expect(accumulator.finalize()).toBe(first); From 7e0544485dc1a44127552084828d1e5cc6220265 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 08:28:33 +0200 Subject: [PATCH 184/292] test(rfc64): bind CP1 evidence to current runtime --- devnet/rfc64-cp1-public-swm-parity/run.ts | 130 ++----- .../verifier.test.ts | 63 ++- .../rfc64-cp1-public-swm-parity/verifier.ts | 22 +- .../verify-live.ts | 17 +- .../adapter-process.ts | 360 ++++++++++++++---- .../run.ts | 152 ++------ .../two-agent-harness.ts | 132 +++++++ 7 files changed, 579 insertions(+), 297 deletions(-) create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/two-agent-harness.ts diff --git a/devnet/rfc64-cp1-public-swm-parity/run.ts b/devnet/rfc64-cp1-public-swm-parity/run.ts index 0ba9ff4dc3..7b96b4a9f7 100644 --- a/devnet/rfc64-cp1-public-swm-parity/run.ts +++ b/devnet/rfc64-cp1-public-swm-parity/run.ts @@ -1,5 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; import process from 'node:process'; @@ -15,7 +14,6 @@ import { ethers } from 'ethers'; import { atomicWriteExactBytes, - readCleanRepositoryHead, } from '../rfc64-persistence-lifecycle/evidence.js'; import { ChildProcessRegistry, @@ -26,14 +24,15 @@ import { type Gate2AgentEvent, } from '../rfc64-gate2-multi-asset-completeness/agent-child.js'; import { - GATE2_ADAPTER_PROTOCOL_VERSION, - GATE2_REAL_DKG_AGENT_ADAPTER_ID, -} from '../rfc64-gate2-multi-asset-completeness/model.js'; -import { - assertGate2RuntimeManifestEqualV1, - buildGate2RuntimeManifestV1, consumeGate2RuntimeLaunchReceiptV1, } from '../rfc64-gate2-multi-asset-completeness/runtime-provenance.ts'; +import { + assertGate2HarnessReadyV1, + assertGate2HarnessSourceStateV1, + connectGate2HarnessAgentsV1, + createGate2TwoAgentDataDirsV1, + spawnGate2HarnessAgentV1, +} from '../rfc64-gate2-multi-asset-completeness/two-agent-harness.ts'; import { canonicalDocument, type CanonicalValue } from '../rfc64-gate2-multi-asset-completeness/src/canonical.ts'; import { @@ -53,12 +52,8 @@ import { } from './policy-cells.ts'; const REPO_ROOT = resolve(import.meta.dirname, '../..'); -const GATE2_DIR = resolve(import.meta.dirname, '../rfc64-gate2-multi-asset-completeness'); -const ADAPTER_PROCESS = join(GATE2_DIR, 'adapter-process.ts'); -const RUNTIME_LOAD_HOOK = join(GATE2_DIR, 'runtime-load-hook.ts'); const ARTIFACT = process.env.DKG_RFC64_CP1_ARTIFACT ?? join(import.meta.dirname, 'artifacts/cp1-public-swm-parity.json'); -const PROCESS_TIMEOUT_MS = 90_000; const NETWORK_ID = CP1_NETWORK_ID; const OWNER_PRIVATE_KEY = `0x${'64'.repeat(32)}`; const OWNER_WALLET = new ethers.Wallet(OWNER_PRIVATE_KEY); @@ -80,7 +75,6 @@ const PROJECTION_NQUADS = + ' "Alice" .\n'; const ASSERTION_ROOT = '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f'; -const ROLE_MASTER_KEYS = Object.freeze({ author: '1a'.repeat(32), receiver: '2b'.repeat(32) }); interface Cp1PublicationAsset { readonly bundleDigest: Digest32V1; @@ -120,41 +114,43 @@ interface RunPolicyCellContext { async function execute(): Promise { const launch = consumeGate2RuntimeLaunchReceiptV1(); - const head = readCleanRepositoryHead(REPO_ROOT); - exact(head, launch.sourceCommit, 'launch source commit'); - assertGate2RuntimeManifestEqualV1( - buildGate2RuntimeManifestV1(REPO_ROOT, head), + const head = assertGate2HarnessSourceStateV1( + REPO_ROOT, + launch.sourceCommit, launch.manifest, ); rmSync(ARTIFACT, { force: true }); - const authorDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-cp1-author-')); - const receiverDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-cp1-receiver-')); + const dataDirs = createGate2TwoAgentDataDirsV1('cp1'); + const authorDataDir = dataDirs.author; + const receiverDataDir = dataDirs.receiver; const registry = new ChildProcessRegistry(20_000); let primaryFailure: unknown; let operationFailed = true; try { - const author = spawnAgent('author', authorDataDir, registry, launch.manifest.manifestDigest, head); - const receiver = spawnAgent( - 'receiver', - receiverDataDir, - registry, - launch.manifest.manifestDigest, - head, - ); + const author = spawnGate2HarnessAgentV1({ + role: 'author', dataDir: authorDataDir, registry, repoRoot: REPO_ROOT, + runtimeManifestDigest: launch.manifest.manifestDigest, sourceCommit: head, + }); + const receiver = spawnGate2HarnessAgentV1({ + role: 'receiver', dataDir: receiverDataDir, registry, repoRoot: REPO_ROOT, + runtimeManifestDigest: launch.manifest.manifestDigest, sourceCommit: head, + }); const [authorReady, receiverReady] = await Promise.all([ author.waitFor('ready'), receiver.waitFor('ready'), ]); - requireReady(authorReady, 'author', launch.manifest.manifestDigest); - requireReady(receiverReady, 'receiver', launch.manifest.manifestDigest); + assertGate2HarnessReadyV1(authorReady, 'author', launch.manifest.manifestDigest); + assertGate2HarnessReadyV1(receiverReady, 'receiver', launch.manifest.manifestDigest); + requireCp1Capabilities(authorReady, 'author'); + requireCp1Capabilities(receiverReady, 'receiver'); const authorPeerId = requiredString(authorReady.peerId, 'author peer ID'); const receiverPeerId = requiredString(receiverReady.peerId, 'receiver peer ID'); if (authorPeerId === receiverPeerId) throw new Error('CP1 peer identities are not distinct'); const authorPid = requiredPid(author.child.pid, 'author PID'); const receiverPid = requiredPid(receiver.child.pid, 'receiver PID'); if (authorPid === receiverPid) throw new Error('CP1 process identities are not distinct'); - await connectBothWays(author, receiver, authorReady, receiverReady); + await connectGate2HarnessAgentsV1(author, receiver, authorReady, receiverReady, 'cp1'); const context: RunPolicyCellContext = { author, @@ -171,6 +167,11 @@ async function execute(): Promise { author.stop('cp1-author-stop'), receiver.stop('cp1-receiver-stop'), ]); + const headAfter = assertGate2HarnessSourceStateV1( + REPO_ROOT, + head, + launch.manifest, + ); const artifact = { cells: cellEvidence, expectedProjectionNQuads: PROJECTION_NQUADS, @@ -182,12 +183,15 @@ async function execute(): Promise { receiverExitCode: receiverStopped.exit.code, receiverPid, }, - repository: { testedHeadCommit: head, trackedSourceClean: true }, + repository: { testedHeadCommit: headAfter, trackedSourceClean: true }, runtimeManifestDigest: launch.manifest.manifestDigest, schemaVersion: CP1_PUBLIC_SWM_PARITY_SCHEMA, status: 'PASS', }; - verifyCp1PublicSwmParity(artifact); + verifyCp1PublicSwmParity(artifact, { + runtimeManifestDigest: launch.manifest.manifestDigest, + testedHeadCommit: headAfter, + }); const publication = atomicWriteExactBytes( ARTIFACT, new TextEncoder().encode(canonicalDocument(artifact as unknown as CanonicalValue)), @@ -395,66 +399,10 @@ async function announceAndDrain( ); } -async function connectBothWays( - author: Gate2AgentChild, - receiver: Gate2AgentChild, - authorReady: Gate2AgentEvent, - receiverReady: Gate2AgentEvent, -): Promise { - await Promise.all([ - author.request('dial', 'cp1-dial-author', 'dialed', { - multiaddr: receiverReady.multiaddr, - peerId: receiverReady.peerId, - }), - receiver.request('dial', 'cp1-dial-receiver', 'dialed', { - multiaddr: authorReady.multiaddr, - peerId: authorReady.peerId, - }), - ]); -} - -function spawnAgent( - role: 'author' | 'receiver', - dataDir: string, - registry: ChildProcessRegistry, - runtimeManifestDigest: string, - sourceCommit: string, -): Gate2AgentChild { - const childEnv = { ...process.env }; - delete childEnv.NODE_OPTIONS; - delete childEnv.NODE_PATH; - delete childEnv.TSX_TSCONFIG_PATH; - return new Gate2AgentChild({ - eventTimeoutMs: PROCESS_TIMEOUT_MS, - registry, - role, - spawn: { - command: process.execPath, - args: ['--import', 'tsx', '--import', RUNTIME_LOAD_HOOK, ADAPTER_PROCESS, role], - cwd: REPO_ROOT, - env: { - ...childEnv, - DKG_RFC64_GATE2_ADAPTER_DATA_DIR: dataDir, - DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX: ROLE_MASTER_KEYS[role], - DKG_RFC64_GATE2_RUNTIME_MANIFEST_DIGEST: runtimeManifestDigest, - DKG_RFC64_GATE2_RUNTIME_SOURCE_COMMIT: sourceCommit, - NODE_ENV: 'production', - }, - }, - }); -} - -function requireReady( +function requireCp1Capabilities( event: Gate2AgentEvent, role: 'author' | 'receiver', - manifestDigest: string, ): void { - exact(event.role, role, `${role} ready role`); - exact(event.adapterId, GATE2_REAL_DKG_AGENT_ADAPTER_ID, `${role} adapter`); - exact(event.protocolVersion, GATE2_ADAPTER_PROTOCOL_VERSION, `${role} protocol`); - exact(event.agentClass, 'DKGAgent', `${role} agent class`); - exact(event.catalogServiceStarted, true, `${role} catalog service`); - exact(event.runtimeBuildManifestDigest, manifestDigest, `${role} runtime manifest`); const capabilities = record(event.capabilities, `${role} capabilities`); for (const capability of [ 'acceptPolicySnapshot', @@ -463,8 +411,6 @@ function requireReady( 'publishPolicyBoundExactSetSuccessor', 'publishPolicyBoundGenesis', ]) exact(capabilities[capability], true, `${role} capability ${capability}`); - requiredString(event.peerId, `${role} peer ID`); - requiredString(event.multiaddr, `${role} multiaddr`); } async function authorSeal(kaNumber: bigint): Promise { diff --git a/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts b/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts index 4ef3fdcb6e..acdbeda32b 100644 --- a/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts +++ b/devnet/rfc64-cp1-public-swm-parity/verifier.test.ts @@ -14,6 +14,12 @@ import { const projection = ' "RFC-64 CP1" .\n' + ' "1" .\n'; const digest = (byte: string) => `0x${byte.repeat(64)}`; +const TESTED_HEAD = 'a'.repeat(40); +const RUNTIME_MANIFEST_DIGEST = digest('c'); +const EXPECTED_PROVENANCE = Object.freeze({ + runtimeManifestDigest: RUNTIME_MANIFEST_DIGEST, + testedHeadCommit: TESTED_HEAD, +}); function fixture(): Record { const cell = (index: number) => { @@ -48,35 +54,70 @@ function fixture(): Record { receiverExitCode: 0, receiverPid: 101, }, - repository: { testedHeadCommit: 'a'.repeat(40), trackedSourceClean: true }, - runtimeManifestDigest: digest('c'), + repository: { testedHeadCommit: TESTED_HEAD, trackedSourceClean: true }, + runtimeManifestDigest: RUNTIME_MANIFEST_DIGEST, schemaVersion: CP1_PUBLIC_SWM_PARITY_SCHEMA, status: 'PASS', }; } test('accepts exact two-cell public SWM parity', () => { - assert.equal(verifyCp1PublicSwmParity(fixture()).status, 'PASS'); + assert.equal(verifyCp1PublicSwmParity(fixture(), EXPECTED_PROVENANCE).status, 'PASS'); }); test('rejects publish-axis, process-boundary, and byte-parity drift', () => { const wrongPolicy = structuredClone(fixture()) as { cells: Array> }; wrongPolicy.cells[1]!.publishPolicy = 1; - assert.throws(() => verifyCp1PublicSwmParity(wrongPolicy), /publishPolicy/); + assert.throws(() => verifyCp1PublicSwmParity(wrongPolicy, EXPECTED_PROVENANCE), /publishPolicy/); const samePid = structuredClone(fixture()) as { processBoundary: Record; }; samePid.processBoundary.receiverPid = 100; - assert.throws(() => verifyCp1PublicSwmParity(samePid), /PIDs must differ/); + assert.throws(() => verifyCp1PublicSwmParity(samePid, EXPECTED_PROVENANCE), /PIDs must differ/); const changedBytes = structuredClone(fixture()) as { cells: Array> }; changedBytes.cells[1]!.projectionNQuads = `${projection}# drift`; - assert.throws(() => verifyCp1PublicSwmParity(changedBytes), /projectionNQuads/); + assert.throws(() => verifyCp1PublicSwmParity(changedBytes, EXPECTED_PROVENANCE), /projectionNQuads/); + + const changedBundle = structuredClone(fixture()) as { + cells: Array>; + }; + changedBundle.cells[1]!.bundleDigest = digest('d'); + assert.throws( + () => verifyCp1PublicSwmParity(changedBundle, EXPECTED_PROVENANCE), + /bundle parity/, + ); + + const changedContent = structuredClone(fixture()) as { + cells: Array>; + }; + changedContent.cells[1]!.contentDigest = digest('e'); + assert.throws( + () => verifyCp1PublicSwmParity(changedContent, EXPECTED_PROVENANCE), + /content parity/, + ); const extraKey = structuredClone(fixture()) as Record; extraKey.unverified = true; - assert.throws(() => verifyCp1PublicSwmParity(extraKey), /keys differ/); + assert.throws(() => verifyCp1PublicSwmParity(extraKey, EXPECTED_PROVENANCE), /keys differ/); +}); + +test('rejects stale repository and runtime provenance', () => { + assert.throws( + () => verifyCp1PublicSwmParity(fixture(), { + ...EXPECTED_PROVENANCE, + testedHeadCommit: 'b'.repeat(40), + }), + /testedHeadCommit/, + ); + assert.throws( + () => verifyCp1PublicSwmParity(fixture(), { + ...EXPECTED_PROVENANCE, + runtimeManifestDigest: digest('f'), + }), + /runtimeManifestDigest/, + ); }); test('rejects wrong or duplicate policy-cell identity', () => { @@ -84,13 +125,13 @@ test('rejects wrong or duplicate policy-cell identity', () => { cells: Array>; }; wrongContextGraph.cells[1]!.contextGraphId = 'cg-unrelated'; - assert.throws(() => verifyCp1PublicSwmParity(wrongContextGraph), /contextGraphId/); + assert.throws(() => verifyCp1PublicSwmParity(wrongContextGraph, EXPECTED_PROVENANCE), /contextGraphId/); const duplicateContextGraph = structuredClone(fixture()) as { cells: Array>; }; duplicateContextGraph.cells[1]!.contextGraphId = duplicateContextGraph.cells[0]!.contextGraphId; - assert.throws(() => verifyCp1PublicSwmParity(duplicateContextGraph), /contextGraphId/); + assert.throws(() => verifyCp1PublicSwmParity(duplicateContextGraph, EXPECTED_PROVENANCE), /contextGraphId/); const wrongPolicyDigest = structuredClone(fixture()) as { cells: Array>; @@ -102,7 +143,7 @@ test('rejects wrong or duplicate policy-cell identity', () => { ]) { wrongPolicyDigest.cells[1]![field] = digest('9'); } - assert.throws(() => verifyCp1PublicSwmParity(wrongPolicyDigest), /authorPolicyDigest/); + assert.throws(() => verifyCp1PublicSwmParity(wrongPolicyDigest, EXPECTED_PROVENANCE), /authorPolicyDigest/); const duplicatePolicyDigest = structuredClone(fixture()) as { cells: Array>; @@ -114,5 +155,5 @@ test('rejects wrong or duplicate policy-cell identity', () => { ]) { duplicatePolicyDigest.cells[1]![field] = duplicatePolicyDigest.cells[0]![field]; } - assert.throws(() => verifyCp1PublicSwmParity(duplicatePolicyDigest), /authorPolicyDigest/); + assert.throws(() => verifyCp1PublicSwmParity(duplicatePolicyDigest, EXPECTED_PROVENANCE), /authorPolicyDigest/); }); diff --git a/devnet/rfc64-cp1-public-swm-parity/verifier.ts b/devnet/rfc64-cp1-public-swm-parity/verifier.ts index 59df0f67a4..bbc6957891 100644 --- a/devnet/rfc64-cp1-public-swm-parity/verifier.ts +++ b/devnet/rfc64-cp1-public-swm-parity/verifier.ts @@ -11,11 +11,19 @@ export const CP1_PUBLIC_SWM_PARITY_SCHEMA = const DIGEST = /^0x[0-9a-f]{64}$/u; const SHA256 = /^sha256:[0-9a-f]{64}$/u; +export interface Cp1ExpectedProvenanceV1 { + readonly runtimeManifestDigest: string; + readonly testedHeadCommit: string; +} + export function semanticSha256(value: string): string { return `sha256:${createHash('sha256').update(value).digest('hex')}`; } -export function verifyCp1PublicSwmParity(value: unknown): Record { +export function verifyCp1PublicSwmParity( + value: unknown, + expectedProvenance: Cp1ExpectedProvenanceV1, +): Record { const root = closedRecord(value, '$', [ 'cells', 'expectedProjectionNQuads', @@ -29,7 +37,12 @@ export function verifyCp1PublicSwmParity(value: unknown): Record { } case 'publishGenesis': { requireRole('author'); - const output = await publishGenesisVia( - command, - (input) => currentAgent.publishOpenAuthorCatalogGenesisV1(input), + const output = await currentAgent.publishOpenAuthorCatalogGenesisV1( + normalizeOpenGenesisInput(command), ); emitOperationResult(command, output); return; } case 'publishCatalogGenesis': { requireRole('author'); - const output = await publishGenesisVia( - command, - (input) => currentAgent.publishAuthorCatalogGenesisV1(input), + const output = await currentAgent.publishAuthorCatalogGenesisV1( + normalizePolicyBoundGenesisInput(command), ); emitOperationResult(command, output); return; @@ -200,7 +208,7 @@ async function handle(command: Command): Promise { const output = await publishExactSetSuccessorVia( currentAgent, command, - (input) => currentAgent.publishOpenAuthorCatalogExactSetSuccessorV1(input), + 'open', ); emitOperationResult(command, output); return; @@ -210,7 +218,7 @@ async function handle(command: Command): Promise { const output = await publishExactSetSuccessorVia( currentAgent, command, - (input) => currentAgent.publishAuthorCatalogExactSetSuccessorV1(input), + 'policy-bound', ); emitOperationResult(command, output); return; @@ -332,62 +340,181 @@ async function handle(command: Command): Promise { } } -type HarnessGenesisPublisher = (input: never) => Promise; -type HarnessExactSetPublisher = (input: never) => Promise; +type OpenGenesisInput = Parameters[0]; +type PolicyBoundGenesisInput = Parameters[0]; +type ExactSetSuccessorInput = Parameters[0]; -async function publishGenesisVia( +function normalizeOpenGenesisInput( command: Command, - publish: HarnessGenesisPublisher, -): Promise { +): OpenGenesisInput { const label = command.command; const input = plainRecord(command.input, `${label} input`); const authorPrivateKey = requiredString( input.authorPrivateKey, `${label}.authorPrivateKey`, ); - const forwarded: Record = { - ...input, + const networkId = requiredString(input.networkId, `${label}.networkId`); + assertNetworkIdV1(networkId); + const contextGraphId = requiredString(input.contextGraphId, `${label}.contextGraphId`); + assertContextGraphIdV1(contextGraphId); + const issuedAt = optionalTimestamp(input.issuedAt, `${label}.issuedAt`); + const catalogIssuerDelegationEffectiveAt = canonicalTimestamp( + input.catalogIssuerDelegationEffectiveAt, + `${label}.catalogIssuerDelegationEffectiveAt`, + ); + const catalogIssuerDelegationExpiresAt = canonicalTimestamp( + input.catalogIssuerDelegationExpiresAt, + `${label}.catalogIssuerDelegationExpiresAt`, + ); + return { + networkId, + contextGraphId, author: new ethers.Wallet(authorPrivateKey), peers: [], - }; - delete forwarded.authorPrivateKey; - return publish(forwarded as never); + ...(issuedAt === undefined ? {} : { issuedAt }), + ...(input.subGraphName === undefined + ? {} + : { subGraphName: nullableSubGraphName(input.subGraphName, `${label}.subGraphName`) }), + ...(input.policyIssuedAt === undefined + ? {} + : { policyIssuedAt: canonicalTimestamp(input.policyIssuedAt, `${label}.policyIssuedAt`) }), + ...(input.policyEffectiveAt === undefined + ? {} + : { + policyEffectiveAt: canonicalTimestamp( + input.policyEffectiveAt, + `${label}.policyEffectiveAt`, + ), + }), + ...(input.ownerAuthorityEra === undefined + ? {} + : { + ownerAuthorityEra: canonicalDecimalU64( + input.ownerAuthorityEra, + `${label}.ownerAuthorityEra`, + ), + }), + catalogIssuerDelegationEffectiveAt, + catalogIssuerDelegationExpiresAt, + } satisfies OpenGenesisInput; +} + +function normalizePolicyBoundGenesisInput( + command: Command, +): PolicyBoundGenesisInput { + const label = command.command; + const input = plainRecord(command.input, `${label} input`); + const authorPrivateKey = requiredString( + input.authorPrivateKey, + `${label}.authorPrivateKey`, + ); + assertAuthorCatalogScopeV1(input.scope); + return { + scope: input.scope, + author: new ethers.Wallet(authorPrivateKey), + peers: [], + ...(input.issuedAt === undefined + ? {} + : { issuedAt: canonicalTimestamp(input.issuedAt, `${label}.issuedAt`) }), + catalogIssuerDelegationEffectiveAt: canonicalTimestamp( + input.catalogIssuerDelegationEffectiveAt, + `${label}.catalogIssuerDelegationEffectiveAt`, + ), + catalogIssuerDelegationExpiresAt: canonicalTimestamp( + input.catalogIssuerDelegationExpiresAt, + `${label}.catalogIssuerDelegationExpiresAt`, + ), + } satisfies PolicyBoundGenesisInput; } async function publishExactSetSuccessorVia( currentAgent: DKGAgent, command: Command, - publish: HarnessExactSetPublisher, + mode: 'open' | 'policy-bound', ): Promise> { + const forwarded = normalizeExactSetSuccessorInput(command); + const published = mode === 'open' + ? await currentAgent.publishOpenAuthorCatalogExactSetSuccessorV1(forwarded) + : await currentAgent.publishAuthorCatalogExactSetSuccessorV1(forwarded); + return addDurableBundleReceipts(currentAgent, published, command.command); +} + +function normalizeExactSetSuccessorInput(command: Command): ExactSetSuccessorInput { const label = command.command; const input = plainRecord(command.input, `${label} input`); const authorPrivateKey = requiredString( input.authorPrivateKey, `${label}.authorPrivateKey`, ); - const assets = plainArray(input.assets, `${label}.assets`).map((value, index) => { + const assets: ExactSetSuccessorInput['assets'] = plainArray( + input.assets, + `${label}.assets`, + ).map((value, index) => { const assetLabel = `${label}.assets[${index}]`; const asset = plainRecord(value, assetLabel); + assertAssertionCoordinateV1(asset.assertionCoordinate, `${assetLabel}.assertionCoordinate`); + assertCanonicalGraphScopedAuthorSealV1(asset.seal); const projectionNQuads = requiredString( asset.projectionNQuads, `${assetLabel}.projectionNQuads`, ); - const forwarded: Record = { - ...asset, + return Object.freeze({ + assertionCoordinate: asset.assertionCoordinate, projectionBytes: new TextEncoder().encode(projectionNQuads), - }; - delete forwarded.projectionNQuads; - return forwarded; + seal: asset.seal, + }); }); - const forwarded: Record = { - ...input, + const previousHead = plainRecord(input.previousHead, `${label}.previousHead`); + const authorization = plainRecord( + input.catalogIssuerAuthorization, + `${label}.catalogIssuerAuthorization`, + ); + const catalogIssuerDelegation = + authorization.catalogIssuerDelegation as SignedControlEnvelopeV1; + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(catalogIssuerDelegation); + if (authorization.parentAuthorAgentEvidence !== null) { + throw new TypeError(`${label}.catalogIssuerAuthorization must use direct-author evidence`); + } + const deployment = plainRecord(input.deployment, `${label}.deployment`); + const networkId = requiredString(deployment.networkId, `${label}.deployment.networkId`); + assertNetworkIdV1(networkId); + const assertedAtChainId = requiredString( + deployment.assertedAtChainId, + `${label}.deployment.assertedAtChainId`, + ); + assertCanonicalChainId(assertedAtChainId, `${label}.deployment.assertedAtChainId`); + const assertedAtKav10Address = canonicalEvmAddress( + deployment.assertedAtKav10Address, + `${label}.deployment.assertedAtKav10Address`, + ); + return { + previousHead: { + objectDigest: requiredDigest(previousHead.objectDigest, `${label}.previousHead.objectDigest`), + signatureVariantDigest: requiredDigest( + previousHead.signatureVariantDigest, + `${label}.previousHead.signatureVariantDigest`, + ), + }, author: new ethers.Wallet(authorPrivateKey), + catalogIssuerAuthorization: { + catalogIssuerDelegation, + parentAuthorAgentEvidence: null, + }, assets, + deployment: { networkId, assertedAtChainId, assertedAtKav10Address }, + ...(input.issuedAt === undefined + ? {} + : { issuedAt: canonicalTimestamp(input.issuedAt, `${label}.issuedAt`) }), peers: [], - }; - delete forwarded.authorPrivateKey; + } satisfies ExactSetSuccessorInput; +} - const result = plainRecord(await publish(forwarded as never), `${label} output`); +async function addDurableBundleReceipts( + currentAgent: DKGAgent, + published: unknown, + label: string, +): Promise> { + const result = plainRecord(published, `${label} output`); const outputAssets = plainArray(result.assets, `${label}.output.assets`); const assetsWithReceipts = await Promise.all(outputAssets.map(async (value, index) => { const assetLabel = `${label}.output.assets[${index}]`; @@ -405,6 +532,46 @@ async function publishExactSetSuccessorVia( return Object.freeze({ ...result, assets: Object.freeze(assetsWithReceipts) }); } +function canonicalTimestamp( + value: unknown, + label: string, +): NonNullable { + assertCanonicalTimestampMs(value, label); + return value; +} + +function optionalTimestamp( + value: unknown, + label: string, +): NonNullable | undefined { + return value === undefined ? undefined : canonicalTimestamp(value, label); +} + +function canonicalDecimalU64( + value: unknown, + label: string, +): NonNullable { + assertCanonicalDecimalU64(value, label); + return value; +} + +function nullableSubGraphName( + value: unknown, + label: string, +): OpenGenesisInput['subGraphName'] { + if (value === null) return null; + assertSubGraphNameV1(value, label); + return value; +} + +function canonicalEvmAddress(value: unknown, label: string): EvmAddressV1 { + const address = requiredString(value, label); + if (!/^0x[0-9a-f]{40}$/u.test(address) || address === `0x${'0'.repeat(40)}`) { + throw new TypeError(`${label} is not a canonical non-zero EVM address`); + } + return address as EvmAddressV1; +} + function compareQuad(left: Quad, right: Quad): number { const leftKey = `${left.subject}\n${left.predicate}\n${left.object}\n${left.graph}`; const rightKey = `${right.subject}\n${right.predicate}\n${right.object}\n${right.graph}`; @@ -415,64 +582,103 @@ function wireSynchronizationEvidence(output: unknown): unknown { if (output === null) return null; const evidence = plainRecord(output, 'exact synchronization evidence'); if (evidence.inventoryRowCount === 0) return evidence; - const sourceRows = evidence.inventoryRowCount === 1 - ? [evidence] - : plainArray(evidence.rows, 'synchronization.rows'); - let verifiedControlObjectCount: number | undefined; - const rows = sourceRows.map((value, index) => { - const row = plainRecord(value, `synchronization.rows[${index}]`); - const authorship = plainRecord(row.authorship, `synchronization.rows[${index}].authorship`); - const path = plainArray( - authorship.directoryPathObjectDigests, - `synchronization.rows[${index}].authorship.directoryPathObjectDigests`, - ); - const variants = plainArray( - authorship.directoryPathSignatureVariantDigests, - `synchronization.rows[${index}].authorship.directoryPathSignatureVariantDigests`, + const wired = evidence.inventoryRowCount === 1 + ? [wireLegacySingleRowSynchronizationEvidence(evidence)] + : plainArray(evidence.rows, 'synchronization.rows').map( + (value, index) => wireMultiRowSynchronizationEvidence(value, index), ); - if (path.length !== variants.length) { - throw new Error('synchronization authorship path evidence is incomplete'); - } - const rowControlObjectCount = 3 + path.length; - if ( - verifiedControlObjectCount !== undefined - && rowControlObjectCount !== verifiedControlObjectCount - ) { - throw new Error('synchronization rows disagree on the verified control-object closure'); - } - verifiedControlObjectCount = rowControlObjectCount; - const kaUal = requiredString(row.kaUal, `synchronization.rows[${index}].kaUal`); - return Object.freeze({ - kaId: canonicalDecimalWire( - row.kaId ?? packedKaIdFromUal(kaUal), - `synchronization.rows[${index}].kaId`, - ), - catalogRowDigest: row.catalogRowDigest, - contentDigest: row.contentDigest, - // Legacy one-row evidence predates sealDigest on this readback surface. - // Multi-row evidence always carries it; keep the absence explicit on the - // adapter wire instead of emitting `undefined` or inventing a digest. - sealDigest: row.sealDigest ?? null, - bundleDigest: row.bundleDigest, - kaUal, - activatedTripleCount: row.activatedTripleCount, - swmGraph: row.swmGraph, - }); - }); - if (verifiedControlObjectCount === undefined) { - throw new Error('non-empty synchronization evidence contains no exact rows'); - } + const verifiedControlObjectCount = requireUniformControlObjectCount(wired); return Object.freeze({ inventoryDigest: evidence.inventoryDigest, catalogHeadDigest: evidence.catalogHeadDigest, inventoryRowCount: evidence.inventoryRowCount, activatedTripleCount: evidence.activatedTripleCount, appliedHeadStatus: evidence.appliedHeadStatus, - rows: Object.freeze(rows), + rows: Object.freeze(wired.map((entry) => entry.row)), verifiedControlObjectCount, }); } +interface WiredSynchronizationRow { + readonly row: Readonly>; + readonly verifiedControlObjectCount: number; +} + +function wireLegacySingleRowSynchronizationEvidence( + evidence: Record, +): WiredSynchronizationRow { + const label = 'synchronization.legacySingleRow'; + const kaUal = requiredString(evidence.kaUal, `${label}.kaUal`); + return wireSynchronizationRow( + evidence, + label, + canonicalDecimalWire(packedKaIdFromUal(kaUal), `${label}.kaId`), + null, + ); +} + +function wireMultiRowSynchronizationEvidence( + value: unknown, + index: number, +): WiredSynchronizationRow { + const label = `synchronization.rows[${index}]`; + const row = plainRecord(value, label); + return wireSynchronizationRow( + row, + label, + canonicalDecimalWire(row.kaId, `${label}.kaId`), + requiredDigest(row.sealDigest, `${label}.sealDigest`), + ); +} + +function wireSynchronizationRow( + row: Record, + label: string, + kaId: string, + sealDigest: Digest32V1 | null, +): WiredSynchronizationRow { + const authorship = plainRecord(row.authorship, `${label}.authorship`); + const path = plainArray( + authorship.directoryPathObjectDigests, + `${label}.authorship.directoryPathObjectDigests`, + ); + const variants = plainArray( + authorship.directoryPathSignatureVariantDigests, + `${label}.authorship.directoryPathSignatureVariantDigests`, + ); + if (path.length !== variants.length) { + throw new Error('synchronization authorship path evidence is incomplete'); + } + return Object.freeze({ + row: Object.freeze({ + kaId, + catalogRowDigest: row.catalogRowDigest, + contentDigest: row.contentDigest, + sealDigest, + bundleDigest: row.bundleDigest, + kaUal: requiredString(row.kaUal, `${label}.kaUal`), + activatedTripleCount: row.activatedTripleCount, + swmGraph: row.swmGraph, + }), + verifiedControlObjectCount: 3 + path.length, + }); +} + +function requireUniformControlObjectCount( + rows: readonly WiredSynchronizationRow[], +): number { + const first = rows[0]; + if (first === undefined) { + throw new Error('non-empty synchronization evidence contains no exact rows'); + } + for (const row of rows.slice(1)) { + if (row.verifiedControlObjectCount !== first.verifiedControlObjectCount) { + throw new Error('synchronization rows disagree on the verified control-object closure'); + } + } + return first.verifiedControlObjectCount; +} + function packedKaIdFromUal(kaUal: string): string { const parsed = parseDeterministicKnowledgeAssetUal(kaUal); return ((BigInt(parsed.agentAddress) << 96n) | BigInt(parsed.kaNumber)).toString(); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/run.ts b/devnet/rfc64-gate2-multi-asset-completeness/run.ts index 7bd9fa1c91..3bfef1fee6 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/run.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/run.ts @@ -1,5 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; import process from 'node:process'; @@ -13,7 +12,6 @@ import { ethers } from 'ethers'; import { atomicWriteExactBytes, - readCleanRepositoryHead, stableJson, } from '../rfc64-persistence-lifecycle/evidence.js'; import { @@ -43,19 +41,21 @@ import { import { verify as verifyInventoryContract } from './src/verify.ts'; import { canonicalDocument, type CanonicalValue } from './src/canonical.ts'; import { - assertGate2RuntimeManifestEqualV1, - buildGate2RuntimeManifestV1, buildGate2RuntimeProvenanceV1, consumeGate2RuntimeLaunchReceiptV1, type Gate2ExecutedRuntimeManifestV1, } from './runtime-provenance.ts'; +import { + assertGate2HarnessReadyV1, + assertGate2HarnessSourceStateV1, + connectGate2HarnessAgentsV1, + createGate2TwoAgentDataDirsV1, + spawnGate2HarnessAgentV1, +} from './two-agent-harness.ts'; const REPO_ROOT = resolve(import.meta.dirname, '../..'); -const ADAPTER_PROCESS = join(import.meta.dirname, 'adapter-process.ts'); -const RUNTIME_LOAD_HOOK = join(import.meta.dirname, 'runtime-load-hook.ts'); const DEFAULT_RAW_ARTIFACT = join(import.meta.dirname, 'artifacts/gate2-result.json'); const DEFAULT_VERDICT_ARTIFACT = join(import.meta.dirname, 'artifacts/gate2-verdict.json'); -const PROCESS_TIMEOUT_MS = 90_000; const NETWORK_ID = 'otp:20430'; const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/gate-2'; @@ -98,17 +98,11 @@ const SUCCESSOR_ISSUED_AT = Object.freeze([ const FORGED_ISSUED_AT = '1773900004000'; const DELEGATION_EFFECTIVE_AT = '1773899999000'; const DELEGATION_EXPIRES_AT = '1774000000000'; -const ROLE_MASTER_KEYS = Object.freeze({ - author: '1a'.repeat(32), - receiver: '2b'.repeat(32), -}); - async function execute(): Promise { const launchReceipt = consumeGate2RuntimeLaunchReceiptV1(); - const headBefore = readCleanRepositoryHead(REPO_ROOT); - exact(headBefore, launchReceipt.sourceCommit, 'clean-build launch source commit'); - assertGate2RuntimeManifestEqualV1( - buildGate2RuntimeManifestV1(REPO_ROOT, headBefore), + const headBefore = assertGate2HarnessSourceStateV1( + REPO_ROOT, + launchReceipt.sourceCommit, launchReceipt.manifest, ); exact(new Set(PROJECTION_NQUADS).size, 3, 'distinct per-KA projections before spawn'); @@ -119,26 +113,29 @@ async function execute(): Promise { rmSync(rawArtifactPath, { force: true }); rmSync(verdictArtifactPath, { force: true }); - const authorDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate2-author-')); - const receiverDataDir = mkdtempSync(join(tmpdir(), 'dkg-rfc64-gate2-receiver-')); + const dataDirs = createGate2TwoAgentDataDirsV1('gate2'); + const authorDataDir = dataDirs.author; + const receiverDataDir = dataDirs.receiver; const children = new ChildProcessRegistry(20_000); let operationFailed = true; let primaryFailure: unknown; try { - const author = spawnAgent( - 'author', authorDataDir, children, launchReceipt.manifest.manifestDigest, headBefore, - ); - const receiver = spawnAgent( - 'receiver', receiverDataDir, children, launchReceipt.manifest.manifestDigest, headBefore, - ); + const author = spawnGate2HarnessAgentV1({ + role: 'author', dataDir: authorDataDir, registry: children, repoRoot: REPO_ROOT, + runtimeManifestDigest: launchReceipt.manifest.manifestDigest, sourceCommit: headBefore, + }); + const receiver = spawnGate2HarnessAgentV1({ + role: 'receiver', dataDir: receiverDataDir, registry: children, repoRoot: REPO_ROOT, + runtimeManifestDigest: launchReceipt.manifest.manifestDigest, sourceCommit: headBefore, + }); const [authorReady, receiverReady] = await Promise.all([ author.waitFor('ready'), receiver.waitFor('ready'), ]); - requireRealReady(authorReady, 'author', launchReceipt.manifest.manifestDigest); - requireRealReady(receiverReady, 'receiver', launchReceipt.manifest.manifestDigest); + assertGate2HarnessReadyV1(authorReady, 'author', launchReceipt.manifest.manifestDigest); + assertGate2HarnessReadyV1(receiverReady, 'receiver', launchReceipt.manifest.manifestDigest); requireCondition(authorReady.peerId !== receiverReady.peerId, 'peer identities are not distinct'); - await connectBothWays(author, receiver, authorReady, receiverReady, 'initial'); + await connectGate2HarnessAgentsV1(author, receiver, authorReady, receiverReady, 'initial'); const [authorPolicy, receiverPolicy] = await Promise.all([ acceptPolicy(author, 'author-policy-v1', CONTEXT_GRAPH_ID), @@ -450,13 +447,20 @@ async function execute(): Promise { exact(failureCode, 'catalog-native-receiver-authorization', 'terminal failure code'); const receiverCrashBoundary = await receiver.killRestartBoundary('receiver-crash-v1'); - const restartedReceiver = spawnAgent( - 'receiver', receiverDataDir, children, launchReceipt.manifest.manifestDigest, headBefore, - ); + const restartedReceiver = spawnGate2HarnessAgentV1({ + role: 'receiver', dataDir: receiverDataDir, registry: children, repoRoot: REPO_ROOT, + runtimeManifestDigest: launchReceipt.manifest.manifestDigest, sourceCommit: headBefore, + }); const restartedReady = await restartedReceiver.waitFor('ready'); - requireRealReady(restartedReady, 'receiver', launchReceipt.manifest.manifestDigest); + assertGate2HarnessReadyV1(restartedReady, 'receiver', launchReceipt.manifest.manifestDigest); exact(restartedReady.peerId, receiverReady.peerId, 'receiver peer ID after restart'); - await connectBothWays(author, restartedReceiver, authorReady, restartedReady, 'restart'); + await connectGate2HarnessAgentsV1( + author, + restartedReceiver, + authorReady, + restartedReady, + 'restart', + ); await acceptPolicy(restartedReceiver, 'restarted-receiver-policy-v1', CONTEXT_GRAPH_ID); await announceAndDrain( author, @@ -502,10 +506,9 @@ async function execute(): Promise { const restartedReceiverBoundary = await restartedReceiver.stop('receiver-stop-v1'); const authorBoundary = await author.stop('author-stop-v1'); - const headAfter = readCleanRepositoryHead(REPO_ROOT); - exact(headAfter, headBefore, 'tracked source commit after process run'); - assertGate2RuntimeManifestEqualV1( - buildGate2RuntimeManifestV1(REPO_ROOT, headAfter), + const headAfter = assertGate2HarnessSourceStateV1( + REPO_ROOT, + headBefore, launchReceipt.manifest, ); const runtimeProvenance = buildGate2RuntimeProvenanceV1( @@ -715,25 +718,6 @@ async function acceptPolicy( }); } -async function connectBothWays( - author: Gate2AgentChild, - receiver: Gate2AgentChild, - authorReady: Gate2AgentEvent, - receiverReady: Gate2AgentEvent, - label: string, -): Promise { - await Promise.all([ - receiver.request('dial', `${label}-receiver-dial-author-v1`, 'dialed', { - multiaddr: authorReady.multiaddr, - peerId: authorReady.peerId, - }), - author.request('dial', `${label}-author-dial-receiver-v1`, 'dialed', { - multiaddr: receiverReady.multiaddr, - peerId: receiverReady.peerId, - }), - ]); -} - async function readApplied( receiver: Gate2AgentChild, catalogScopeDigest: string, @@ -1042,62 +1026,6 @@ function stagedHeadRef(output: Record, label: string): Record 0, 'peer ID is missing'); - requireCondition( - typeof event.multiaddr === 'string' && event.multiaddr.includes('/tcp/'), - 'TCP multiaddr is missing', - ); -} - function assertGate2ProductCapabilities(value: unknown, role: string): void { const capabilities = outputRecordValue({ capabilities: value }, 'capabilities', role); for (const name of [ diff --git a/devnet/rfc64-gate2-multi-asset-completeness/two-agent-harness.ts b/devnet/rfc64-gate2-multi-asset-completeness/two-agent-harness.ts new file mode 100644 index 0000000000..1b478d6254 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/two-agent-harness.ts @@ -0,0 +1,132 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { readCleanRepositoryHead } from '../rfc64-persistence-lifecycle/evidence.js'; +import { ChildProcessRegistry } from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { Gate2AgentChild, type Gate2AgentEvent } from './agent-child.js'; +import { + GATE2_ADAPTER_PROTOCOL_VERSION, + GATE2_REAL_DKG_AGENT_ADAPTER_ID, +} from './model.js'; +import { + assertGate2RuntimeManifestEqualV1, + buildGate2RuntimeManifestV1, + type Gate2RuntimeManifestV1, +} from './runtime-provenance.ts'; + +const ADAPTER_PROCESS = join(import.meta.dirname, 'adapter-process.ts'); +const RUNTIME_LOAD_HOOK = join(import.meta.dirname, 'runtime-load-hook.ts'); +const PROCESS_TIMEOUT_MS = 90_000; +const ROLE_MASTER_KEYS = Object.freeze({ + author: '1a'.repeat(32), + receiver: '2b'.repeat(32), +}); + +export type Gate2HarnessAgentRoleV1 = 'author' | 'receiver'; + +export interface Gate2TwoAgentDataDirsV1 { + readonly author: string; + readonly receiver: string; +} + +export function createGate2TwoAgentDataDirsV1(label: string): Gate2TwoAgentDataDirsV1 { + if (!/^[a-z0-9-]+$/u.test(label)) throw new TypeError('harness label is not safe'); + return Object.freeze({ + author: mkdtempSync(join(tmpdir(), `dkg-rfc64-${label}-author-`)), + receiver: mkdtempSync(join(tmpdir(), `dkg-rfc64-${label}-receiver-`)), + }); +} + +/** Verify one clean source/runtime boundary and return the exact current HEAD. */ +export function assertGate2HarnessSourceStateV1( + repoRoot: string, + expectedSourceCommit: string, + expectedManifest: Gate2RuntimeManifestV1, +): string { + const head = readCleanRepositoryHead(repoRoot); + exact(head, expectedSourceCommit, 'clean harness source commit'); + assertGate2RuntimeManifestEqualV1( + buildGate2RuntimeManifestV1(repoRoot, head), + expectedManifest, + ); + return head; +} + +export function spawnGate2HarnessAgentV1(input: { + readonly dataDir: string; + readonly registry: ChildProcessRegistry; + readonly repoRoot: string; + readonly role: Gate2HarnessAgentRoleV1; + readonly runtimeManifestDigest: string; + readonly sourceCommit: string; +}): Gate2AgentChild { + const childEnv = { ...process.env }; + delete childEnv.NODE_OPTIONS; + delete childEnv.NODE_PATH; + delete childEnv.TSX_TSCONFIG_PATH; + return new Gate2AgentChild({ + eventTimeoutMs: PROCESS_TIMEOUT_MS, + registry: input.registry, + role: input.role, + spawn: { + command: process.execPath, + args: ['--import', 'tsx', '--import', RUNTIME_LOAD_HOOK, ADAPTER_PROCESS, input.role], + cwd: input.repoRoot, + env: { + ...childEnv, + DKG_RFC64_GATE2_ADAPTER_DATA_DIR: input.dataDir, + DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX: ROLE_MASTER_KEYS[input.role], + DKG_RFC64_GATE2_RUNTIME_MANIFEST_DIGEST: input.runtimeManifestDigest, + DKG_RFC64_GATE2_RUNTIME_SOURCE_COMMIT: input.sourceCommit, + NODE_ENV: 'production', + }, + }, + }); +} + +export function assertGate2HarnessReadyV1( + event: Gate2AgentEvent, + expectedRole: Gate2HarnessAgentRoleV1, + runtimeManifestDigest: string, +): void { + exact(event.role, expectedRole, 'ready role'); + exact(event.adapterId, GATE2_REAL_DKG_AGENT_ADAPTER_ID, 'ready adapter'); + exact(event.protocolVersion, GATE2_ADAPTER_PROTOCOL_VERSION, 'ready protocol'); + exact(event.agentClass, 'DKGAgent', 'ready agent class'); + exact(event.catalogServiceStarted, true, 'ready catalog service'); + exact(event.startupRepair, null, 'ready startup repair'); + exact(event.runtimeBuildManifestDigest, runtimeManifestDigest, 'ready runtime manifest'); + requiredString(event.peerId, 'ready peer ID'); + const multiaddr = requiredString(event.multiaddr, 'ready multiaddr'); + if (!multiaddr.includes('/tcp/')) throw new Error('ready multiaddr is not TCP'); +} + +export async function connectGate2HarnessAgentsV1( + author: Gate2AgentChild, + receiver: Gate2AgentChild, + authorReady: Gate2AgentEvent, + receiverReady: Gate2AgentEvent, + label: string, +): Promise { + await Promise.all([ + receiver.request('dial', `${label}-receiver-dial-author-v1`, 'dialed', { + multiaddr: authorReady.multiaddr, + peerId: authorReady.peerId, + }), + author.request('dial', `${label}-author-dial-receiver-v1`, 'dialed', { + multiaddr: receiverReady.multiaddr, + peerId: receiverReady.peerId, + }), + ]); +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${label} is missing`); + return value; +} + +function exact(actual: unknown, expected: unknown, label: string): void { + if (actual !== expected) throw new Error(`${label} differs from expected`); +} From 99555b6ac4b2b9a2e7d682b80c391fdaa0b8f006 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 08:32:37 +0200 Subject: [PATCH 185/292] refactor(core): narrow finalized VM set boundary --- packages/core/src/finalized-vm-set-v1.ts | 26 ++++++++---------------- packages/core/src/index.ts | 12 ++++++++++- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/core/src/finalized-vm-set-v1.ts b/packages/core/src/finalized-vm-set-v1.ts index b71c0bfa6a..f2b8969210 100644 --- a/packages/core/src/finalized-vm-set-v1.ts +++ b/packages/core/src/finalized-vm-set-v1.ts @@ -88,7 +88,6 @@ type CanonicalFinalizedVmSetRowV1 = Readonly interface ValidatedFinalizedVmSetRowV1 { readonly row: CanonicalFinalizedVmSetRowV1; readonly ordinalValue: bigint; - readonly assertionVersionValue: bigint; } export type FinalizedVmSetV1ErrorCode = @@ -234,7 +233,7 @@ function snapshotFinalizedVmSetRowForScopeV1( finalizedBlockHash: record.finalizedBlockHash, placementEvidenceDigest: record.placementEvidenceDigest, }) as CanonicalFinalizedVmSetRowV1; - return Object.freeze({ row, ordinalValue, assertionVersionValue }); + return Object.freeze({ row, ordinalValue }); } /** Compute the exact domain-separated RFC-64 leaf digest for one placed row. */ @@ -261,11 +260,7 @@ export function computeEmptyFinalizedVmSetRootV1(): Digest32V1 { */ export class FinalizedVmSetAccumulatorV1 { readonly #scope: Readonly; - readonly #frontier = new DomainSeparatedMerkleFrontier( - NODE_DOMAIN_BYTES, - ODD_DOMAIN_BYTES, - EMPTY_DOMAIN_BYTES, - ); + readonly #frontier = new FinalizedVmSetMerkleFrontier(); #rowCount = 0n; #highestFinalizedOrdinal: DecimalU64V1 | null = null; #highestOrdinalValue: bigint | null = null; @@ -344,16 +339,11 @@ function computeFinalizedVmSetLeafDigestBytesV1( ); } -class DomainSeparatedMerkleFrontier { +/** Feature-local frontier for the fixed finalized-VM set digest domains. */ +class FinalizedVmSetMerkleFrontier { readonly #levels: Array = []; #finalRoot: Uint8Array | undefined; - constructor( - private readonly nodeDomain: Uint8Array, - private readonly oddDomain: Uint8Array, - private readonly emptyDomain: Uint8Array, - ) {} - append(leafDigest: Uint8Array): void { if (this.#finalRoot !== undefined) { fail('finalized-vm-set-state', 'cannot append to a finalized Merkle frontier'); @@ -361,7 +351,7 @@ class DomainSeparatedMerkleFrontier { let current = leafDigest; let level = 0; while (this.#levels[level] !== undefined) { - current = digestBytes(this.nodeDomain, this.#levels[level]!, current); + current = digestBytes(NODE_DOMAIN_BYTES, this.#levels[level]!, current); this.#levels[level] = undefined; level += 1; } @@ -382,13 +372,13 @@ class DomainSeparatedMerkleFrontier { continue; } while (pendingLevel < level) { - pending = digestBytes(this.oddDomain, pending); + pending = digestBytes(ODD_DOMAIN_BYTES, pending); pendingLevel += 1; } - pending = digestBytes(this.nodeDomain, left, pending); + pending = digestBytes(NODE_DOMAIN_BYTES, left, pending); pendingLevel = level + 1; } - this.#finalRoot = pending ?? digestBytes(this.emptyDomain); + this.#finalRoot = pending ?? digestBytes(EMPTY_DOMAIN_BYTES); return this.#finalRoot; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dcb0d651cc..fd9c9790a9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -52,7 +52,17 @@ export * from './ka-bundle-v1.js'; export * from './ka-chunk-tree.js'; export * from './ka-chunk-proof.js'; export * from './cg-shared-projection.js'; -export * from './finalized-vm-set-v1.js'; +export { + FinalizedVmSetAccumulatorV1, + FinalizedVmSetV1Error, + computeFinalizedVmSetEvidenceV1, + computeFinalizedVmSetLeafDigestV1, + type FinalizedVmLaneV1, + type FinalizedVmSetEvidenceV1, + type FinalizedVmSetRowV1, + type FinalizedVmSetScopeV1, + type FinalizedVmSetV1ErrorCode, +} from './finalized-vm-set-v1.js'; export * from './author-catalog-codec.js'; export * from './author-catalog-objects.js'; export * from './author-catalog-directory.js'; From 35e55a529361b879f7c71ded19be21e9d9ea15df Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 08:42:13 +0200 Subject: [PATCH 186/292] test(rfc64): centralize CP1 policy envelope --- .../policy-cells.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/devnet/rfc64-cp1-public-swm-parity/policy-cells.ts b/devnet/rfc64-cp1-public-swm-parity/policy-cells.ts index d171765d40..90b61c00dc 100644 --- a/devnet/rfc64-cp1-public-swm-parity/policy-cells.ts +++ b/devnet/rfc64-cp1-public-swm-parity/policy-cells.ts @@ -74,13 +74,31 @@ export function cp1PublicPolicy(spec: Cp1PublicCellSpec): ContextGraphPolicyV1 { } export function cp1PolicyDigest(spec: Cp1PublicCellSpec): Digest32V1 { - return computeContextGraphPolicyObjectDigestV1({ - issuer: CP1_OWNER_ADDRESS, + return computeContextGraphPolicyObjectDigestV1( + cp1UnsignedOwnerPolicyEnvelope(cp1PublicPolicy(spec)), + ); +} + +/** + * Assemble the canonical owner-signed policy envelope at one explicit runtime + * validation boundary. ContextGraphPolicyV1 is JSON-safe by construction, but + * its nominal interface does not structurally extend CanonicalJsonValue; the + * core digest codec revalidates the exact envelope and payload before hashing. + */ +function cp1UnsignedOwnerPolicyEnvelope( + policy: ContextGraphPolicyV1, +): UnsignedControlEnvelopeV1 { + if (policy.source.kind !== 'owner-signed-unregistered') { + throw new Error('CP1 policy envelope requires an owner-signed source'); + } + const envelope = Object.freeze({ + issuer: policy.source.ownerAddress, objectType: CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, - payload: cp1PublicPolicy(spec), - signatureEvidence: { kind: 'none' }, + payload: policy, + signatureEvidence: Object.freeze({ kind: 'none' as const }), signatureSuite: 'eip191-personal-sign-digest-v1', - } as unknown as UnsignedControlEnvelopeV1); + } as const); + return envelope as unknown as UnsignedControlEnvelopeV1; } export function cp1CatalogScope(spec: Cp1PublicCellSpec): AuthorCatalogScopeV1 { From 38ee8f4d1eaa3ccf63877da249c44b1a6655f3e1 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 08:48:32 +0200 Subject: [PATCH 187/292] refactor(core): share RFC64 network identifiers --- packages/core/src/author-catalog-codec.ts | 25 +++++++++-------- packages/core/src/finalized-vm-set-v1.ts | 5 ++-- packages/core/src/sync-wire-identifiers.ts | 27 +++++++++++++++++++ .../core/test/sync-wire-identifiers.test.ts | 18 +++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/sync-wire-identifiers.ts create mode 100644 packages/core/test/sync-wire-identifiers.test.ts diff --git a/packages/core/src/author-catalog-codec.ts b/packages/core/src/author-catalog-codec.ts index def95e3682..907b18114e 100644 --- a/packages/core/src/author-catalog-codec.ts +++ b/packages/core/src/author-catalog-codec.ts @@ -25,16 +25,20 @@ import { type EvmAddressV1, type KaIdV1, } from './sync-wire-scalars.js'; +import { + MAX_NETWORK_ID_BYTES_V1, + assertNetworkIdV1 as assertSharedNetworkIdV1, + type NetworkIdV1, +} from './sync-wire-identifiers.js'; import { assertExactKeys, isPlainRecord } from './sync-wire-objects.js'; -declare const NETWORK_ID_V1_BRAND: unique symbol; declare const CONTEXT_GRAPH_ID_V1_BRAND: unique symbol; declare const SUBGRAPH_NAME_V1_BRAND: unique symbol; declare const ASSERTION_COORDINATE_V1_BRAND: unique symbol; declare const CATALOG_ASSERTION_SCOPE_V1_BRAND: unique symbol; declare const CATALOG_ASSERTION_SUBJECT_V1_BRAND: unique symbol; -export type NetworkIdV1 = string & { readonly [NETWORK_ID_V1_BRAND]: true }; +export type { NetworkIdV1 } from './sync-wire-identifiers.js'; export type ContextGraphIdV1 = string & { readonly [CONTEXT_GRAPH_ID_V1_BRAND]: true }; export type SubGraphNameV1 = string & { readonly [SUBGRAPH_NAME_V1_BRAND]: true }; export type AssertionCoordinateV1 = string & { @@ -54,7 +58,7 @@ export const AUTHOR_CATALOG_KEY_DIGEST_DOMAIN_V1 = export const AUTHOR_CATALOG_ROW_DIGEST_DOMAIN_V1 = 'dkg-author-catalog-row-v1\n' as const; -export const MAX_AUTHOR_CATALOG_NETWORK_ID_BYTES_V1 = 128; +export const MAX_AUTHOR_CATALOG_NETWORK_ID_BYTES_V1 = MAX_NETWORK_ID_BYTES_V1; export const MAX_AUTHOR_CATALOG_IDENTIFIER_BYTES_V1 = 256; export const MAX_AUTHOR_CATALOG_SCOPE_BYTES_V1 = 2048; export const MAX_AUTHOR_CATALOG_ROW_BYTES_V1 = 2048; @@ -62,7 +66,6 @@ export const MAX_AUTHOR_CATALOG_ROW_DIGEST_INPUT_BYTES_V1 = 4096; export const MAX_AUTHOR_CATALOG_BUCKET_COUNT_V1 = 9_223_372_036_854_775_808n; export const PACKED_KA_NUMBER_BITS_V1 = 96n; -const NETWORK_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; const CONTEXT_GRAPH_ID_PATTERN = /^[A-Za-z0-9_:/\.@-]+$/; const CATALOG_IDENTIFIER_ASCII_FORBIDDEN = new Set([ '<', @@ -133,13 +136,13 @@ export function assertNetworkIdV1( value: unknown, label = 'networkId', ): asserts value is NetworkIdV1 { - const identifier = assertNfcUtf8Identifier( - value, - label, - MAX_AUTHOR_CATALOG_NETWORK_ID_BYTES_V1, - ); - if (!NETWORK_ID_PATTERN.test(identifier)) { - fail('catalog-identifier', `${label} contains a character outside the networkId grammar`); + try { + assertSharedNetworkIdV1(value, label); + } catch (cause) { + const message = cause instanceof Error + ? cause.message + : `${label} is not a canonical networkId`; + fail('catalog-identifier', message, cause); } } diff --git a/packages/core/src/finalized-vm-set-v1.ts b/packages/core/src/finalized-vm-set-v1.ts index f2b8969210..09b21d9c2f 100644 --- a/packages/core/src/finalized-vm-set-v1.ts +++ b/packages/core/src/finalized-vm-set-v1.ts @@ -1,8 +1,8 @@ import { sha256 } from '@noble/hashes/sha2.js'; -import { assertNetworkIdV1, type NetworkIdV1 } from './author-catalog-codec.js'; import { canonicalizeJsonBytes, type CanonicalJsonValue } from './canonical-json.js'; import { parseDeterministicKnowledgeAssetUal } from './ka-content-scope.js'; +import { assertNetworkIdV1, type NetworkIdV1 } from './sync-wire-identifiers.js'; import { snapshotExactDataRecord } from './sync-wire-objects.js'; import { MAX_DECIMAL_U64, @@ -209,7 +209,8 @@ function snapshotFinalizedVmSetRowForScopeV1( if (parsed.agentAddress !== record.authorAddress) { fail('finalized-vm-set-ual', 'row.ual author differs from row.authorAddress'); } - if (parsed.chainId !== scope.networkId) { + const ualNetworkId = parsed.chainId; + if (ualNetworkId !== scope.networkId) { fail('finalized-vm-set-ual', 'row.ual namespace differs from the trusted network profile'); } } catch (cause) { diff --git a/packages/core/src/sync-wire-identifiers.ts b/packages/core/src/sync-wire-identifiers.ts new file mode 100644 index 0000000000..b402e844ff --- /dev/null +++ b/packages/core/src/sync-wire-identifiers.ts @@ -0,0 +1,27 @@ +/** Cross-protocol network identifier used by RFC-64 wire objects. */ +declare const NETWORK_ID_V1_BRAND: unique symbol; + +export type NetworkIdV1 = string & { readonly [NETWORK_ID_V1_BRAND]: true }; + +export const MAX_NETWORK_ID_BYTES_V1 = 128; + +const NETWORK_ID_PATTERN = /^[A-Za-z0-9._:-]+$/; +const UTF8 = new TextEncoder(); + +export function assertNetworkIdV1( + value: unknown, + label = 'networkId', +): asserts value is NetworkIdV1 { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + if ( + value.length > MAX_NETWORK_ID_BYTES_V1 + || UTF8.encode(value).byteLength > MAX_NETWORK_ID_BYTES_V1 + ) { + throw new Error(`${label} exceeds ${MAX_NETWORK_ID_BYTES_V1} UTF-8 bytes`); + } + if (!NETWORK_ID_PATTERN.test(value)) { + throw new Error(`${label} contains a character outside the networkId grammar`); + } +} diff --git a/packages/core/test/sync-wire-identifiers.test.ts b/packages/core/test/sync-wire-identifiers.test.ts new file mode 100644 index 0000000000..2936b3b3d9 --- /dev/null +++ b/packages/core/test/sync-wire-identifiers.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { + MAX_NETWORK_ID_BYTES_V1, + assertNetworkIdV1, +} from '../src/sync-wire-identifiers.js'; + +describe('RFC-64 shared wire identifiers', () => { + it('preserves the canonical network-id grammar and byte ceiling', () => { + expect(() => assertNetworkIdV1('otp:20430')).not.toThrow(); + expect(() => assertNetworkIdV1('otp/20430')).toThrow(/networkId grammar/); + expect(() => assertNetworkIdV1('')).toThrow(/non-empty string/); + expect(() => assertNetworkIdV1('a'.repeat(MAX_NETWORK_ID_BYTES_V1))).not.toThrow(); + expect(() => assertNetworkIdV1('a'.repeat(MAX_NETWORK_ID_BYTES_V1 + 1))).toThrow( + /128 UTF-8 bytes/, + ); + }); +}); From dbbdf8d42a85de117b37e966ef90f1c72383e55a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:43:21 +0200 Subject: [PATCH 188/292] feat(chain): add fail-closed finalized Context Graph policy snapshot seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smallest additive Gate 5 seam: an immutable FinalizedContextGraphPolicySnapshotV1 that pins a Context Graph's on-chain policy at a finalized block, reusing the strict finalized RPC anchor types (chainId + blockNumber + blockHash) and the ContextGraphStorage.getContextGraph(uint256) / getNameHash(uint256) authoritative surface. Includes a fail-closed composer (snapshotFinalizedContextGraphPolicyV1) that validates the request binding and raw fields — canonical chainId, decimal uint256 contextGraphId/accountId, lowercase EVM addresses, non-zero owner + governance contract, accessPolicy in {0,1}, publishPolicy in {0,1}, 32-byte anchor/name digests — and rejects an unregistered CG (zero owner) or any out-of-range/ malformed field instead of substituting a default; a resolver seam (FinalizedContextGraphPolicyResolverV1) where the production finalized read plugs in; and a fixed mock resolver for parity. Does not wire DKGAgent policy enforcement or a live resolver (that finalized getContextGraph read is the remaining resolver seam). Product runtime is otherwise untouched; only the package barrel gains the additive export. Evidence at source commit 1bb183f8: chain tsc build clean; vitest 15 pass / 0 fail (mock-adapter parity, zero-authority accepted, inactive accepted, and fail-closed on unregistered/binding/policy/anchor/name cases). No push; no Gate 2/3, devnet harness, or integration/rfc64-devnet edits. Co-Authored-By: Claude Opus 4.8 --- ...finalized-context-graph-policy-snapshot.ts | 221 ++++++++++++++++++ packages/chain/src/index.ts | 14 ++ ...context-graph-policy-snapshot.unit.test.ts | 122 ++++++++++ 3 files changed, 357 insertions(+) create mode 100644 packages/chain/src/finalized-context-graph-policy-snapshot.ts create mode 100644 packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts diff --git a/packages/chain/src/finalized-context-graph-policy-snapshot.ts b/packages/chain/src/finalized-context-graph-policy-snapshot.ts new file mode 100644 index 0000000000..3defe45a8b --- /dev/null +++ b/packages/chain/src/finalized-context-graph-policy-snapshot.ts @@ -0,0 +1,221 @@ +import { + assertCanonicalChainId, + type BlockNumberV1, + type ChainIdV1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +// Smallest additive, fail-closed seam that pins a Context Graph's on-chain +// policy at a finalized block. It reuses the strict finalized RPC anchor shape +// (chainId + blockNumber + blockHash, as produced by the current-finalized EVM +// primitives) and the ContextGraphStorage.getContextGraph(uint256) authoritative +// surface, but does NOT wire enforcement or a live resolver here: the actual +// finalized on-chain read is provided through the resolver seam below (a mock +// resolver gives parity for tests). The composer never invents a field — any +// value the surface cannot authoritatively supply fails closed. + +export const FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1 = + 'rfc64-gate5-finalized-cg-policy-snapshot@1' as const; + +// ContextGraphStorage enum ranges (see evm-adapter-context-graph.ts): +// accessPolicy: 0 = open, 1 = invite-only (private) +// publishPolicy: 0 = curators-only, 1 = open +export const CG_ACCESS_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); +export const CG_PUBLISH_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); + +const CANONICAL_UINT256_DECIMAL = /^(?:0|[1-9][0-9]*)$/; +const MAX_UINT256 = + 115792089237316195423570985008687907853269984665640564039457584007913129639935n; +const CANONICAL_LOWER_ADDRESS = /^0x[0-9a-f]{40}$/; +const ZERO_ADDRESS = `0x${'0'.repeat(40)}`; +const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; + +export type FinalizedContextGraphPolicyErrorCodeV1 = + | 'request-binding' + | 'unregistered-context-graph' + | 'malformed-owner' + | 'malformed-authority' + | 'unsupported-access-policy' + | 'unsupported-publish-policy' + | 'malformed-anchor' + | 'malformed-name-binding'; + +export class FinalizedContextGraphPolicyErrorV1 extends Error { + constructor( + readonly code: FinalizedContextGraphPolicyErrorCodeV1, + message: string, + ) { + super(`[${code}] ${message}`); + this.name = 'FinalizedContextGraphPolicyErrorV1'; + } +} + +/** Exact Context Graph identity + governance surface a snapshot is bound to. */ +export type FinalizedContextGraphPolicyRequestV1 = { + readonly chainId: ChainIdV1; + readonly contextGraphId: string; + /** The governing ContextGraphStorage contract address (lowercase). */ + readonly governanceContract: EvmAddressV1; +}; + +/** + * Raw fields a resolver reads from `ContextGraphStorage.getContextGraph(uint256)` + * (and `getNameHash`) at a finalized block. The seam that produces these is + * responsible for the finalized read; the composer validates and freezes them. + */ +export type RawFinalizedContextGraphPolicyFieldsV1 = { + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly owner: EvmAddressV1; + readonly active: boolean; + readonly accessPolicy: number; + readonly publishPolicy: number; + /** Curator / PCA publish authority (may be the zero address when unset). */ + readonly publishAuthority: EvmAddressV1; + readonly publishAuthorityAccountId: string; + /** Name binding from `getNameHash(uint256)` — the exact CG/network name commitment. */ + readonly nameHash: Digest32V1; +}; + +/** Immutable finalized Context Graph policy snapshot. */ +export type FinalizedContextGraphPolicySnapshotV1 = { + readonly schema: typeof FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1; + readonly chainId: ChainIdV1; + readonly contextGraphId: string; + readonly governanceContract: EvmAddressV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly owner: EvmAddressV1; + readonly active: boolean; + readonly accessPolicy: number; + readonly publishPolicy: number; + readonly publishAuthority: EvmAddressV1; + readonly publishAuthorityAccountId: string; + readonly nameHash: Digest32V1; +}; + +/** + * The resolver seam: given a bound request, read the raw CG policy fields at a + * finalized block. The production resolver wires the strict finalized RPC + * primitives to `getContextGraph`/`getNameHash`; a mock resolver gives parity. + */ +export interface FinalizedContextGraphPolicyResolverV1 { + (request: FinalizedContextGraphPolicyRequestV1): Promise; +} + +function assertCanonicalUint256(value: unknown, label: string): string { + if (typeof value !== 'string' || !CANONICAL_UINT256_DECIMAL.test(value) || BigInt(value) > MAX_UINT256) { + throw new FinalizedContextGraphPolicyErrorV1('request-binding', `${label} must be a canonical decimal uint256`); + } + return value; +} + +function assertLowerAddress( + value: unknown, + code: FinalizedContextGraphPolicyErrorCodeV1, + label: string, + allowZero: boolean, +): EvmAddressV1 { + if (typeof value !== 'string' || !CANONICAL_LOWER_ADDRESS.test(value)) { + throw new FinalizedContextGraphPolicyErrorV1(code, `${label} must be a canonical lowercase EVM address`); + } + if (!allowZero && value === ZERO_ADDRESS) { + throw new FinalizedContextGraphPolicyErrorV1(code, `${label} must not be the zero address`); + } + return value as EvmAddressV1; +} + +function assertDigest32(value: unknown, code: FinalizedContextGraphPolicyErrorCodeV1, label: string): Digest32V1 { + if (typeof value !== 'string' || !CANONICAL_DIGEST_32.test(value)) { + throw new FinalizedContextGraphPolicyErrorV1(code, `${label} must be a 0x-prefixed lowercase 32-byte hex digest`); + } + return value as Digest32V1; +} + +/** + * Fail-closed composer: validate the request binding and the raw finalized + * fields and freeze them into an immutable snapshot. Rejects an unregistered CG + * (zero owner), out-of-range policy enums, a malformed anchor/name/authority, or + * an inconsistent request binding — never substituting a default for a field the + * surface did not authoritatively provide. + */ +export function snapshotFinalizedContextGraphPolicyV1( + request: FinalizedContextGraphPolicyRequestV1, + raw: RawFinalizedContextGraphPolicyFieldsV1, +): FinalizedContextGraphPolicySnapshotV1 { + let chainId: ChainIdV1; + try { + assertCanonicalChainId(request.chainId, 'finalized CG policy chainId'); + chainId = request.chainId; + } catch { + throw new FinalizedContextGraphPolicyErrorV1('request-binding', 'request chainId is not canonical'); + } + const contextGraphId = assertCanonicalUint256(request.contextGraphId, 'contextGraphId'); + const governanceContract = assertLowerAddress( + request.governanceContract, 'request-binding', 'governanceContract', false, + ); + + const owner = assertLowerAddress(raw.owner, 'malformed-owner', 'owner', true); + if (owner === ZERO_ADDRESS) { + // getContextGraph returns a zero owner for unregistered ids: fail closed + // rather than emit a policy snapshot for a Context Graph that does not exist. + throw new FinalizedContextGraphPolicyErrorV1( + 'unregistered-context-graph', + 'ContextGraphStorage returned a zero owner (unregistered context graph)', + ); + } + const publishAuthority = assertLowerAddress(raw.publishAuthority, 'malformed-authority', 'publishAuthority', true); + const publishAuthorityAccountId = assertCanonicalUint256(raw.publishAuthorityAccountId, 'publishAuthorityAccountId'); + + if (!CG_ACCESS_POLICY_VALUES_V1.includes(raw.accessPolicy as 0 | 1)) { + throw new FinalizedContextGraphPolicyErrorV1('unsupported-access-policy', `unsupported accessPolicy ${String(raw.accessPolicy)}`); + } + if (!CG_PUBLISH_POLICY_VALUES_V1.includes(raw.publishPolicy as 0 | 1)) { + throw new FinalizedContextGraphPolicyErrorV1('unsupported-publish-policy', `unsupported publishPolicy ${String(raw.publishPolicy)}`); + } + if (typeof raw.active !== 'boolean') { + throw new FinalizedContextGraphPolicyErrorV1('malformed-anchor', 'active must be a boolean'); + } + + const blockHash = assertDigest32(raw.blockHash, 'malformed-anchor', 'blockHash'); + const blockNumber = assertCanonicalUint256(raw.blockNumber, 'blockNumber') as unknown as BlockNumberV1; + const nameHash = assertDigest32(raw.nameHash, 'malformed-name-binding', 'nameHash'); + + return Object.freeze({ + schema: FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, + chainId, + contextGraphId, + governanceContract, + blockNumber, + blockHash, + owner, + active: raw.active, + accessPolicy: raw.accessPolicy, + publishPolicy: raw.publishPolicy, + publishAuthority, + publishAuthorityAccountId, + nameHash, + }); +} + +/** Resolve raw finalized fields through the seam, then fail-closed compose them. */ +export async function resolveFinalizedContextGraphPolicySnapshotV1( + resolver: FinalizedContextGraphPolicyResolverV1, + request: FinalizedContextGraphPolicyRequestV1, +): Promise { + const raw = await resolver(request); + return snapshotFinalizedContextGraphPolicyV1(request, raw); +} + +/** + * A fixed mock resolver for parity tests. Returns exactly the supplied raw + * fields regardless of request; production resolvers instead issue the finalized + * `getContextGraph`/`getNameHash` reads through the strict RPC primitives. + */ +export function createFixedFinalizedContextGraphPolicyResolverV1( + raw: RawFinalizedContextGraphPolicyFieldsV1, +): FinalizedContextGraphPolicyResolverV1 { + const frozen = Object.freeze({ ...raw }); + return () => Promise.resolve(frozen); +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index ae90769c9e..404578c4a5 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -40,6 +40,20 @@ export { type CurrentFinalizedEvmBlockReferenceProfileV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from './strict-current-finalized-evm-rpc.js'; +export { + CG_ACCESS_POLICY_VALUES_V1, + CG_PUBLISH_POLICY_VALUES_V1, + FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, + FinalizedContextGraphPolicyErrorV1, + createFixedFinalizedContextGraphPolicyResolverV1, + resolveFinalizedContextGraphPolicySnapshotV1, + snapshotFinalizedContextGraphPolicyV1, + type FinalizedContextGraphPolicyErrorCodeV1, + type FinalizedContextGraphPolicyRequestV1, + type FinalizedContextGraphPolicyResolverV1, + type FinalizedContextGraphPolicySnapshotV1, + type RawFinalizedContextGraphPolicyFieldsV1, +} from './finalized-context-graph-policy-snapshot.js'; export { resolveQuotedPublisherCandidatePricing, resolveLegacyPublisherCandidatePricing, diff --git a/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts b/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts new file mode 100644 index 0000000000..862c49ce16 --- /dev/null +++ b/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import type { + BlockNumberV1, + ChainIdV1, + Digest32V1, + EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +import { + FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, + FinalizedContextGraphPolicyErrorV1, + createFixedFinalizedContextGraphPolicyResolverV1, + resolveFinalizedContextGraphPolicySnapshotV1, + snapshotFinalizedContextGraphPolicyV1, + type FinalizedContextGraphPolicyErrorCodeV1, + type FinalizedContextGraphPolicyRequestV1, + type RawFinalizedContextGraphPolicyFieldsV1, +} from '../src/finalized-context-graph-policy-snapshot.js'; + +const CHAIN_ID = '20430' as ChainIdV1; +const CGS = `0x${'11'.repeat(20)}` as EvmAddressV1; +const OWNER = `0x${'22'.repeat(20)}` as EvmAddressV1; +const AUTHORITY = `0x${'33'.repeat(20)}` as EvmAddressV1; +const ZERO = `0x${'0'.repeat(40)}` as EvmAddressV1; +const BLOCK_HASH = `0x${'44'.repeat(32)}` as Digest32V1; +const NAME_HASH = `0x${'55'.repeat(32)}` as Digest32V1; + +function validRequest(): FinalizedContextGraphPolicyRequestV1 { + return { chainId: CHAIN_ID, contextGraphId: '42', governanceContract: CGS }; +} + +function validRaw(): RawFinalizedContextGraphPolicyFieldsV1 { + return { + blockNumber: '123' as BlockNumberV1, + blockHash: BLOCK_HASH, + owner: OWNER, + active: true, + accessPolicy: 1, + publishPolicy: 0, + publishAuthority: AUTHORITY, + publishAuthorityAccountId: '7', + nameHash: NAME_HASH, + }; +} + +function expectFailure(operation: () => unknown, code: FinalizedContextGraphPolicyErrorCodeV1): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(FinalizedContextGraphPolicyErrorV1); + expect((error as FinalizedContextGraphPolicyErrorV1).code).toBe(code); + return; + } + throw new Error(`expected failure ${code}`); +} + +describe('RFC-64 Gate 5 finalized Context Graph policy snapshot', () => { + it('composes an immutable snapshot binding anchor, identity, and every policy field', () => { + const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), validRaw()); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(snapshot).toEqual({ + schema: FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, + chainId: CHAIN_ID, + contextGraphId: '42', + governanceContract: CGS, + blockNumber: '123', + blockHash: BLOCK_HASH, + owner: OWNER, + active: true, + accessPolicy: 1, + publishPolicy: 0, + publishAuthority: AUTHORITY, + publishAuthorityAccountId: '7', + nameHash: NAME_HASH, + }); + }); + + it('reaches the same snapshot through the resolver seam (mock-adapter parity)', async () => { + const resolver = createFixedFinalizedContextGraphPolicyResolverV1(validRaw()); + const viaSeam = await resolveFinalizedContextGraphPolicySnapshotV1(resolver, validRequest()); + const direct = snapshotFinalizedContextGraphPolicyV1(validRequest(), validRaw()); + expect(viaSeam).toEqual(direct); + }); + + it('accepts a zero publish authority (curator/PCA unset) without inventing one', () => { + const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), { + ...validRaw(), + publishAuthority: ZERO, + publishAuthorityAccountId: '0', + }); + expect(snapshot.publishAuthority).toBe(ZERO); + expect(snapshot.publishAuthorityAccountId).toBe('0'); + }); + + it('accepts a registered-but-inactive Context Graph', () => { + const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), active: false }); + expect(snapshot.active).toBe(false); + }); + + it('fails closed on an unregistered context graph (zero owner)', () => { + expectFailure( + () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), owner: ZERO }), + 'unregistered-context-graph', + ); + }); + + it.each([ + ['non-canonical chainId', () => snapshotFinalizedContextGraphPolicyV1({ ...validRequest(), chainId: '020430' as ChainIdV1 }, validRaw()), 'request-binding'], + ['non-decimal contextGraphId', () => snapshotFinalizedContextGraphPolicyV1({ ...validRequest(), contextGraphId: '0x2a' }, validRaw()), 'request-binding'], + ['zero governanceContract', () => snapshotFinalizedContextGraphPolicyV1({ ...validRequest(), governanceContract: ZERO }, validRaw()), 'request-binding'], + ['checksummed (non-lowercase) owner', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), owner: `0x${'Ab'.repeat(20)}` as EvmAddressV1 }), 'malformed-owner'], + ['malformed publishAuthority', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishAuthority: '0xdeadbeef' as EvmAddressV1 }), 'malformed-authority'], + ['out-of-range accessPolicy', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), accessPolicy: 2 }), 'unsupported-access-policy'], + ['out-of-range publishPolicy', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishPolicy: 9 }), 'unsupported-publish-policy'], + ['malformed blockHash', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), blockHash: '0xdead' as Digest32V1 }), 'malformed-anchor'], + ['non-decimal blockNumber', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), blockNumber: 'latest' as unknown as BlockNumberV1 }), 'request-binding'], + ['malformed nameHash', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), nameHash: '0x55' as Digest32V1 }), 'malformed-name-binding'], + ] as const)('fails closed on %s', (_name, operation, code) => { + expectFailure(operation, code); + }); +}); From 26a7abab1a8e3061b7cb4184d178ab2e2fb9c5c7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 22:13:30 +0200 Subject: [PATCH 189/292] fix(chain): harden finalized policy snapshot --- ...finalized-context-graph-policy-snapshot.ts | 107 ++++++++++++------ packages/chain/src/index.ts | 1 - ...context-graph-policy-snapshot.unit.test.ts | 57 +++++++++- 3 files changed, 125 insertions(+), 40 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-policy-snapshot.ts b/packages/chain/src/finalized-context-graph-policy-snapshot.ts index 3defe45a8b..3659f9af19 100644 --- a/packages/chain/src/finalized-context-graph-policy-snapshot.ts +++ b/packages/chain/src/finalized-context-graph-policy-snapshot.ts @@ -1,7 +1,13 @@ import { assertCanonicalChainId, + assertCanonicalDecimalU64, + assertCanonicalDecimalU256, + assertCanonicalDigest, type BlockNumberV1, type ChainIdV1, + type ContextGraphAccessPolicyV1, + type ContextGraphPublishPolicyV1, + type DecimalU256V1, type Digest32V1, type EvmAddressV1, } from '@origintrail-official/dkg-core'; @@ -24,12 +30,8 @@ export const FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1 = export const CG_ACCESS_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); export const CG_PUBLISH_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); -const CANONICAL_UINT256_DECIMAL = /^(?:0|[1-9][0-9]*)$/; -const MAX_UINT256 = - 115792089237316195423570985008687907853269984665640564039457584007913129639935n; const CANONICAL_LOWER_ADDRESS = /^0x[0-9a-f]{40}$/; const ZERO_ADDRESS = `0x${'0'.repeat(40)}`; -const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; export type FinalizedContextGraphPolicyErrorCodeV1 = | 'request-binding' @@ -82,16 +84,16 @@ export type RawFinalizedContextGraphPolicyFieldsV1 = { export type FinalizedContextGraphPolicySnapshotV1 = { readonly schema: typeof FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1; readonly chainId: ChainIdV1; - readonly contextGraphId: string; + readonly contextGraphId: DecimalU256V1; readonly governanceContract: EvmAddressV1; readonly blockNumber: BlockNumberV1; readonly blockHash: Digest32V1; readonly owner: EvmAddressV1; readonly active: boolean; - readonly accessPolicy: number; - readonly publishPolicy: number; + readonly accessPolicy: ContextGraphAccessPolicyV1; + readonly publishPolicy: ContextGraphPublishPolicyV1; readonly publishAuthority: EvmAddressV1; - readonly publishAuthorityAccountId: string; + readonly publishAuthorityAccountId: DecimalU256V1; readonly nameHash: Digest32V1; }; @@ -104,11 +106,35 @@ export interface FinalizedContextGraphPolicyResolverV1 { (request: FinalizedContextGraphPolicyRequestV1): Promise; } -function assertCanonicalUint256(value: unknown, label: string): string { - if (typeof value !== 'string' || !CANONICAL_UINT256_DECIMAL.test(value) || BigInt(value) > MAX_UINT256) { - throw new FinalizedContextGraphPolicyErrorV1('request-binding', `${label} must be a canonical decimal uint256`); +function canonicalDecimalU256( + value: unknown, + code: FinalizedContextGraphPolicyErrorCodeV1, + label: string, +): DecimalU256V1 { + try { + assertCanonicalDecimalU256(value, label); + return value; + } catch { + throw new FinalizedContextGraphPolicyErrorV1( + code, + `${label} must be a canonical decimal uint256`, + ); + } +} + +function canonicalBlockNumber( + value: unknown, + code: FinalizedContextGraphPolicyErrorCodeV1, +): BlockNumberV1 { + try { + assertCanonicalDecimalU64(value, 'blockNumber'); + return value; + } catch { + throw new FinalizedContextGraphPolicyErrorV1( + code, + 'blockNumber must be a canonical decimal uint64', + ); } - return value; } function assertLowerAddress( @@ -126,11 +152,20 @@ function assertLowerAddress( return value as EvmAddressV1; } -function assertDigest32(value: unknown, code: FinalizedContextGraphPolicyErrorCodeV1, label: string): Digest32V1 { - if (typeof value !== 'string' || !CANONICAL_DIGEST_32.test(value)) { - throw new FinalizedContextGraphPolicyErrorV1(code, `${label} must be a 0x-prefixed lowercase 32-byte hex digest`); +function canonicalDigest32( + value: unknown, + code: FinalizedContextGraphPolicyErrorCodeV1, + label: string, +): Digest32V1 { + try { + assertCanonicalDigest(value, label); + return value; + } catch { + throw new FinalizedContextGraphPolicyErrorV1( + code, + `${label} must be a 0x-prefixed lowercase 32-byte hex digest`, + ); } - return value as Digest32V1; } /** @@ -151,7 +186,11 @@ export function snapshotFinalizedContextGraphPolicyV1( } catch { throw new FinalizedContextGraphPolicyErrorV1('request-binding', 'request chainId is not canonical'); } - const contextGraphId = assertCanonicalUint256(request.contextGraphId, 'contextGraphId'); + const contextGraphId = canonicalDecimalU256( + request.contextGraphId, + 'request-binding', + 'contextGraphId', + ); const governanceContract = assertLowerAddress( request.governanceContract, 'request-binding', 'governanceContract', false, ); @@ -166,21 +205,27 @@ export function snapshotFinalizedContextGraphPolicyV1( ); } const publishAuthority = assertLowerAddress(raw.publishAuthority, 'malformed-authority', 'publishAuthority', true); - const publishAuthorityAccountId = assertCanonicalUint256(raw.publishAuthorityAccountId, 'publishAuthorityAccountId'); + const publishAuthorityAccountId = canonicalDecimalU256( + raw.publishAuthorityAccountId, + 'request-binding', + 'publishAuthorityAccountId', + ); - if (!CG_ACCESS_POLICY_VALUES_V1.includes(raw.accessPolicy as 0 | 1)) { + if (raw.accessPolicy !== 0 && raw.accessPolicy !== 1) { throw new FinalizedContextGraphPolicyErrorV1('unsupported-access-policy', `unsupported accessPolicy ${String(raw.accessPolicy)}`); } - if (!CG_PUBLISH_POLICY_VALUES_V1.includes(raw.publishPolicy as 0 | 1)) { + const accessPolicy: ContextGraphAccessPolicyV1 = raw.accessPolicy; + if (raw.publishPolicy !== 0 && raw.publishPolicy !== 1) { throw new FinalizedContextGraphPolicyErrorV1('unsupported-publish-policy', `unsupported publishPolicy ${String(raw.publishPolicy)}`); } + const publishPolicy: ContextGraphPublishPolicyV1 = raw.publishPolicy; if (typeof raw.active !== 'boolean') { throw new FinalizedContextGraphPolicyErrorV1('malformed-anchor', 'active must be a boolean'); } - const blockHash = assertDigest32(raw.blockHash, 'malformed-anchor', 'blockHash'); - const blockNumber = assertCanonicalUint256(raw.blockNumber, 'blockNumber') as unknown as BlockNumberV1; - const nameHash = assertDigest32(raw.nameHash, 'malformed-name-binding', 'nameHash'); + const blockHash = canonicalDigest32(raw.blockHash, 'malformed-anchor', 'blockHash'); + const blockNumber = canonicalBlockNumber(raw.blockNumber, 'request-binding'); + const nameHash = canonicalDigest32(raw.nameHash, 'malformed-name-binding', 'nameHash'); return Object.freeze({ schema: FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, @@ -191,8 +236,8 @@ export function snapshotFinalizedContextGraphPolicyV1( blockHash, owner, active: raw.active, - accessPolicy: raw.accessPolicy, - publishPolicy: raw.publishPolicy, + accessPolicy, + publishPolicy, publishAuthority, publishAuthorityAccountId, nameHash, @@ -207,15 +252,3 @@ export async function resolveFinalizedContextGraphPolicySnapshotV1( const raw = await resolver(request); return snapshotFinalizedContextGraphPolicyV1(request, raw); } - -/** - * A fixed mock resolver for parity tests. Returns exactly the supplied raw - * fields regardless of request; production resolvers instead issue the finalized - * `getContextGraph`/`getNameHash` reads through the strict RPC primitives. - */ -export function createFixedFinalizedContextGraphPolicyResolverV1( - raw: RawFinalizedContextGraphPolicyFieldsV1, -): FinalizedContextGraphPolicyResolverV1 { - const frozen = Object.freeze({ ...raw }); - return () => Promise.resolve(frozen); -} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 404578c4a5..01bcf3b5da 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -45,7 +45,6 @@ export { CG_PUBLISH_POLICY_VALUES_V1, FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, FinalizedContextGraphPolicyErrorV1, - createFixedFinalizedContextGraphPolicyResolverV1, resolveFinalizedContextGraphPolicySnapshotV1, snapshotFinalizedContextGraphPolicyV1, type FinalizedContextGraphPolicyErrorCodeV1, diff --git a/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts b/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts index 862c49ce16..0b47187ab5 100644 --- a/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'; import type { BlockNumberV1, ChainIdV1, + ContextGraphAccessPolicyV1, + ContextGraphPublishPolicyV1, Digest32V1, EvmAddressV1, } from '@origintrail-official/dkg-core'; @@ -10,7 +12,6 @@ import type { import { FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, FinalizedContextGraphPolicyErrorV1, - createFixedFinalizedContextGraphPolicyResolverV1, resolveFinalizedContextGraphPolicySnapshotV1, snapshotFinalizedContextGraphPolicyV1, type FinalizedContextGraphPolicyErrorCodeV1, @@ -25,6 +26,12 @@ const AUTHORITY = `0x${'33'.repeat(20)}` as EvmAddressV1; const ZERO = `0x${'0'.repeat(40)}` as EvmAddressV1; const BLOCK_HASH = `0x${'44'.repeat(32)}` as Digest32V1; const NAME_HASH = `0x${'55'.repeat(32)}` as Digest32V1; +const MAX_U64 = '18446744073709551615'; +const ABOVE_MAX_U64 = '18446744073709551616'; +const MAX_U256 = + '115792089237316195423570985008687907853269984665640564039457584007913129639935'; +const ABOVE_MAX_U256 = + '115792089237316195423570985008687907853269984665640564039457584007913129639936'; function validRequest(): FinalizedContextGraphPolicyRequestV1 { return { chainId: CHAIN_ID, contextGraphId: '42', governanceContract: CGS }; @@ -58,7 +65,11 @@ function expectFailure(operation: () => unknown, code: FinalizedContextGraphPoli describe('RFC-64 Gate 5 finalized Context Graph policy snapshot', () => { it('composes an immutable snapshot binding anchor, identity, and every policy field', () => { const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), validRaw()); + const accessPolicy: ContextGraphAccessPolicyV1 = snapshot.accessPolicy; + const publishPolicy: ContextGraphPublishPolicyV1 = snapshot.publishPolicy; expect(Object.isFrozen(snapshot)).toBe(true); + expect(accessPolicy).toBe(1); + expect(publishPolicy).toBe(0); expect(snapshot).toEqual({ schema: FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, chainId: CHAIN_ID, @@ -77,7 +88,8 @@ describe('RFC-64 Gate 5 finalized Context Graph policy snapshot', () => { }); it('reaches the same snapshot through the resolver seam (mock-adapter parity)', async () => { - const resolver = createFixedFinalizedContextGraphPolicyResolverV1(validRaw()); + const raw = Object.freeze(validRaw()); + const resolver = () => Promise.resolve(raw); const viaSeam = await resolveFinalizedContextGraphPolicySnapshotV1(resolver, validRequest()); const direct = snapshotFinalizedContextGraphPolicyV1(validRequest(), validRaw()); expect(viaSeam).toEqual(direct); @@ -98,6 +110,45 @@ describe('RFC-64 Gate 5 finalized Context Graph policy snapshot', () => { expect(snapshot.active).toBe(false); }); + it('accepts the canonical u64 block-number maximum and rejects MAX_U64 + 1', () => { + const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), { + ...validRaw(), + blockNumber: MAX_U64 as BlockNumberV1, + }); + expect(snapshot.blockNumber).toBe(MAX_U64); + expectFailure( + () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { + ...validRaw(), + blockNumber: ABOVE_MAX_U64 as BlockNumberV1, + }), + 'request-binding', + ); + }); + + it('accepts canonical u256 identity boundaries and rejects values above them', () => { + const snapshot = snapshotFinalizedContextGraphPolicyV1( + { ...validRequest(), contextGraphId: MAX_U256 }, + { ...validRaw(), publishAuthorityAccountId: MAX_U256 }, + ); + expect(snapshot.contextGraphId).toBe(MAX_U256); + expect(snapshot.publishAuthorityAccountId).toBe(MAX_U256); + + expectFailure( + () => snapshotFinalizedContextGraphPolicyV1( + { ...validRequest(), contextGraphId: ABOVE_MAX_U256 }, + validRaw(), + ), + 'request-binding', + ); + expectFailure( + () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { + ...validRaw(), + publishAuthorityAccountId: ABOVE_MAX_U256, + }), + 'request-binding', + ); + }); + it('fails closed on an unregistered context graph (zero owner)', () => { expectFailure( () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), owner: ZERO }), @@ -111,6 +162,8 @@ describe('RFC-64 Gate 5 finalized Context Graph policy snapshot', () => { ['zero governanceContract', () => snapshotFinalizedContextGraphPolicyV1({ ...validRequest(), governanceContract: ZERO }, validRaw()), 'request-binding'], ['checksummed (non-lowercase) owner', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), owner: `0x${'Ab'.repeat(20)}` as EvmAddressV1 }), 'malformed-owner'], ['malformed publishAuthority', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishAuthority: '0xdeadbeef' as EvmAddressV1 }), 'malformed-authority'], + ['hex publishAuthorityAccountId', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishAuthorityAccountId: '0x7' }), 'request-binding'], + ['non-canonical publishAuthorityAccountId', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishAuthorityAccountId: '07' }), 'request-binding'], ['out-of-range accessPolicy', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), accessPolicy: 2 }), 'unsupported-access-policy'], ['out-of-range publishPolicy', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishPolicy: 9 }), 'unsupported-publish-policy'], ['malformed blockHash', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), blockHash: '0xdead' as Digest32V1 }), 'malformed-anchor'], From 08a13a024f859614a06af8ebf3b9329e5f7e74b1 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 08:19:36 +0200 Subject: [PATCH 190/292] refactor(chain): model finalized policy as chain read --- ...finalized-context-graph-policy-snapshot.ts | 254 ---------------- .../chain/src/finalized-context-graph-read.ts | 273 ++++++++++++++++++ packages/chain/src/index.ts | 19 +- ...context-graph-policy-snapshot.unit.test.ts | 175 ----------- .../finalized-context-graph-read.unit.test.ts | 204 +++++++++++++ packages/core/src/index.ts | 2 + packages/core/src/sync-wire-scalars.ts | 7 +- packages/core/test/sync-wire-scalars.test.ts | 3 + 8 files changed, 497 insertions(+), 440 deletions(-) delete mode 100644 packages/chain/src/finalized-context-graph-policy-snapshot.ts create mode 100644 packages/chain/src/finalized-context-graph-read.ts delete mode 100644 packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts create mode 100644 packages/chain/test/finalized-context-graph-read.unit.test.ts diff --git a/packages/chain/src/finalized-context-graph-policy-snapshot.ts b/packages/chain/src/finalized-context-graph-policy-snapshot.ts deleted file mode 100644 index 3659f9af19..0000000000 --- a/packages/chain/src/finalized-context-graph-policy-snapshot.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { - assertCanonicalChainId, - assertCanonicalDecimalU64, - assertCanonicalDecimalU256, - assertCanonicalDigest, - type BlockNumberV1, - type ChainIdV1, - type ContextGraphAccessPolicyV1, - type ContextGraphPublishPolicyV1, - type DecimalU256V1, - type Digest32V1, - type EvmAddressV1, -} from '@origintrail-official/dkg-core'; - -// Smallest additive, fail-closed seam that pins a Context Graph's on-chain -// policy at a finalized block. It reuses the strict finalized RPC anchor shape -// (chainId + blockNumber + blockHash, as produced by the current-finalized EVM -// primitives) and the ContextGraphStorage.getContextGraph(uint256) authoritative -// surface, but does NOT wire enforcement or a live resolver here: the actual -// finalized on-chain read is provided through the resolver seam below (a mock -// resolver gives parity for tests). The composer never invents a field — any -// value the surface cannot authoritatively supply fails closed. - -export const FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1 = - 'rfc64-gate5-finalized-cg-policy-snapshot@1' as const; - -// ContextGraphStorage enum ranges (see evm-adapter-context-graph.ts): -// accessPolicy: 0 = open, 1 = invite-only (private) -// publishPolicy: 0 = curators-only, 1 = open -export const CG_ACCESS_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); -export const CG_PUBLISH_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); - -const CANONICAL_LOWER_ADDRESS = /^0x[0-9a-f]{40}$/; -const ZERO_ADDRESS = `0x${'0'.repeat(40)}`; - -export type FinalizedContextGraphPolicyErrorCodeV1 = - | 'request-binding' - | 'unregistered-context-graph' - | 'malformed-owner' - | 'malformed-authority' - | 'unsupported-access-policy' - | 'unsupported-publish-policy' - | 'malformed-anchor' - | 'malformed-name-binding'; - -export class FinalizedContextGraphPolicyErrorV1 extends Error { - constructor( - readonly code: FinalizedContextGraphPolicyErrorCodeV1, - message: string, - ) { - super(`[${code}] ${message}`); - this.name = 'FinalizedContextGraphPolicyErrorV1'; - } -} - -/** Exact Context Graph identity + governance surface a snapshot is bound to. */ -export type FinalizedContextGraphPolicyRequestV1 = { - readonly chainId: ChainIdV1; - readonly contextGraphId: string; - /** The governing ContextGraphStorage contract address (lowercase). */ - readonly governanceContract: EvmAddressV1; -}; - -/** - * Raw fields a resolver reads from `ContextGraphStorage.getContextGraph(uint256)` - * (and `getNameHash`) at a finalized block. The seam that produces these is - * responsible for the finalized read; the composer validates and freezes them. - */ -export type RawFinalizedContextGraphPolicyFieldsV1 = { - readonly blockNumber: BlockNumberV1; - readonly blockHash: Digest32V1; - readonly owner: EvmAddressV1; - readonly active: boolean; - readonly accessPolicy: number; - readonly publishPolicy: number; - /** Curator / PCA publish authority (may be the zero address when unset). */ - readonly publishAuthority: EvmAddressV1; - readonly publishAuthorityAccountId: string; - /** Name binding from `getNameHash(uint256)` — the exact CG/network name commitment. */ - readonly nameHash: Digest32V1; -}; - -/** Immutable finalized Context Graph policy snapshot. */ -export type FinalizedContextGraphPolicySnapshotV1 = { - readonly schema: typeof FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1; - readonly chainId: ChainIdV1; - readonly contextGraphId: DecimalU256V1; - readonly governanceContract: EvmAddressV1; - readonly blockNumber: BlockNumberV1; - readonly blockHash: Digest32V1; - readonly owner: EvmAddressV1; - readonly active: boolean; - readonly accessPolicy: ContextGraphAccessPolicyV1; - readonly publishPolicy: ContextGraphPublishPolicyV1; - readonly publishAuthority: EvmAddressV1; - readonly publishAuthorityAccountId: DecimalU256V1; - readonly nameHash: Digest32V1; -}; - -/** - * The resolver seam: given a bound request, read the raw CG policy fields at a - * finalized block. The production resolver wires the strict finalized RPC - * primitives to `getContextGraph`/`getNameHash`; a mock resolver gives parity. - */ -export interface FinalizedContextGraphPolicyResolverV1 { - (request: FinalizedContextGraphPolicyRequestV1): Promise; -} - -function canonicalDecimalU256( - value: unknown, - code: FinalizedContextGraphPolicyErrorCodeV1, - label: string, -): DecimalU256V1 { - try { - assertCanonicalDecimalU256(value, label); - return value; - } catch { - throw new FinalizedContextGraphPolicyErrorV1( - code, - `${label} must be a canonical decimal uint256`, - ); - } -} - -function canonicalBlockNumber( - value: unknown, - code: FinalizedContextGraphPolicyErrorCodeV1, -): BlockNumberV1 { - try { - assertCanonicalDecimalU64(value, 'blockNumber'); - return value; - } catch { - throw new FinalizedContextGraphPolicyErrorV1( - code, - 'blockNumber must be a canonical decimal uint64', - ); - } -} - -function assertLowerAddress( - value: unknown, - code: FinalizedContextGraphPolicyErrorCodeV1, - label: string, - allowZero: boolean, -): EvmAddressV1 { - if (typeof value !== 'string' || !CANONICAL_LOWER_ADDRESS.test(value)) { - throw new FinalizedContextGraphPolicyErrorV1(code, `${label} must be a canonical lowercase EVM address`); - } - if (!allowZero && value === ZERO_ADDRESS) { - throw new FinalizedContextGraphPolicyErrorV1(code, `${label} must not be the zero address`); - } - return value as EvmAddressV1; -} - -function canonicalDigest32( - value: unknown, - code: FinalizedContextGraphPolicyErrorCodeV1, - label: string, -): Digest32V1 { - try { - assertCanonicalDigest(value, label); - return value; - } catch { - throw new FinalizedContextGraphPolicyErrorV1( - code, - `${label} must be a 0x-prefixed lowercase 32-byte hex digest`, - ); - } -} - -/** - * Fail-closed composer: validate the request binding and the raw finalized - * fields and freeze them into an immutable snapshot. Rejects an unregistered CG - * (zero owner), out-of-range policy enums, a malformed anchor/name/authority, or - * an inconsistent request binding — never substituting a default for a field the - * surface did not authoritatively provide. - */ -export function snapshotFinalizedContextGraphPolicyV1( - request: FinalizedContextGraphPolicyRequestV1, - raw: RawFinalizedContextGraphPolicyFieldsV1, -): FinalizedContextGraphPolicySnapshotV1 { - let chainId: ChainIdV1; - try { - assertCanonicalChainId(request.chainId, 'finalized CG policy chainId'); - chainId = request.chainId; - } catch { - throw new FinalizedContextGraphPolicyErrorV1('request-binding', 'request chainId is not canonical'); - } - const contextGraphId = canonicalDecimalU256( - request.contextGraphId, - 'request-binding', - 'contextGraphId', - ); - const governanceContract = assertLowerAddress( - request.governanceContract, 'request-binding', 'governanceContract', false, - ); - - const owner = assertLowerAddress(raw.owner, 'malformed-owner', 'owner', true); - if (owner === ZERO_ADDRESS) { - // getContextGraph returns a zero owner for unregistered ids: fail closed - // rather than emit a policy snapshot for a Context Graph that does not exist. - throw new FinalizedContextGraphPolicyErrorV1( - 'unregistered-context-graph', - 'ContextGraphStorage returned a zero owner (unregistered context graph)', - ); - } - const publishAuthority = assertLowerAddress(raw.publishAuthority, 'malformed-authority', 'publishAuthority', true); - const publishAuthorityAccountId = canonicalDecimalU256( - raw.publishAuthorityAccountId, - 'request-binding', - 'publishAuthorityAccountId', - ); - - if (raw.accessPolicy !== 0 && raw.accessPolicy !== 1) { - throw new FinalizedContextGraphPolicyErrorV1('unsupported-access-policy', `unsupported accessPolicy ${String(raw.accessPolicy)}`); - } - const accessPolicy: ContextGraphAccessPolicyV1 = raw.accessPolicy; - if (raw.publishPolicy !== 0 && raw.publishPolicy !== 1) { - throw new FinalizedContextGraphPolicyErrorV1('unsupported-publish-policy', `unsupported publishPolicy ${String(raw.publishPolicy)}`); - } - const publishPolicy: ContextGraphPublishPolicyV1 = raw.publishPolicy; - if (typeof raw.active !== 'boolean') { - throw new FinalizedContextGraphPolicyErrorV1('malformed-anchor', 'active must be a boolean'); - } - - const blockHash = canonicalDigest32(raw.blockHash, 'malformed-anchor', 'blockHash'); - const blockNumber = canonicalBlockNumber(raw.blockNumber, 'request-binding'); - const nameHash = canonicalDigest32(raw.nameHash, 'malformed-name-binding', 'nameHash'); - - return Object.freeze({ - schema: FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, - chainId, - contextGraphId, - governanceContract, - blockNumber, - blockHash, - owner, - active: raw.active, - accessPolicy, - publishPolicy, - publishAuthority, - publishAuthorityAccountId, - nameHash, - }); -} - -/** Resolve raw finalized fields through the seam, then fail-closed compose them. */ -export async function resolveFinalizedContextGraphPolicySnapshotV1( - resolver: FinalizedContextGraphPolicyResolverV1, - request: FinalizedContextGraphPolicyRequestV1, -): Promise { - const raw = await resolver(request); - return snapshotFinalizedContextGraphPolicyV1(request, raw); -} diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts new file mode 100644 index 0000000000..a9c176fe27 --- /dev/null +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -0,0 +1,273 @@ +import { + assertCanonicalChainId, + assertCanonicalDecimalU64, + assertCanonicalDecimalU256, + assertCanonicalDigest, + assertCanonicalEvmAddress, + isCanonicalEvmAddressShapeV1, + type BlockNumberV1, + type ChainIdV1, + type ContextGraphAccessPolicyV1, + type ContextGraphPublishPolicyV1, + type DecimalU256V1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +// This module validates one finalized ContextGraphStorage read. It deliberately +// does not define another RFC-64 policy object: the chain surface cannot supply +// the network/name identifier, era, version, predecessor, or timestamps needed +// by ContextGraphPolicyV1. Runtime policy composition must add those trusted +// inputs once and validate the result with the canonical core policy codec. + +export const CG_ACCESS_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); +export const CG_PUBLISH_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); + +const ZERO_ADDRESS = `0x${'0'.repeat(40)}`; + +export type FinalizedContextGraphReadErrorCodeV1 = + | 'request-binding' + | 'unregistered-context-graph' + | 'malformed-owner' + | 'malformed-authority' + | 'unsupported-access-policy' + | 'unsupported-publish-policy' + | 'inconsistent-publish-policy' + | 'malformed-anchor' + | 'malformed-name-binding'; + +export class FinalizedContextGraphReadErrorV1 extends Error { + constructor( + readonly code: FinalizedContextGraphReadErrorCodeV1, + message: string, + ) { + super(`[${code}] ${message}`); + this.name = 'FinalizedContextGraphReadErrorV1'; + } +} + +/** Untrusted identity supplied to the finalized chain resolver. */ +export type FinalizedContextGraphReadRequestV1 = { + readonly chainId: unknown; + readonly contextGraphId: unknown; + readonly governanceContract: unknown; +}; + +/** Untrusted RPC output from getContextGraph(uint256) and getNameHash(uint256). */ +export type UntrustedFinalizedContextGraphFieldsV1 = { + readonly blockNumber: unknown; + readonly blockHash: unknown; + readonly owner: unknown; + readonly active: unknown; + readonly accessPolicy: unknown; + readonly publishPolicy: unknown; + readonly publishAuthority: unknown; + readonly publishAuthorityAccountId: unknown; + readonly nameHash: unknown; +}; + +/** Validated, immutable adapter read. This is not a ContextGraphPolicyV1. */ +export type FinalizedContextGraphReadV1 = { + readonly chainId: ChainIdV1; + readonly contextGraphId: DecimalU256V1; + readonly governanceContract: EvmAddressV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly owner: EvmAddressV1; + readonly active: boolean; + readonly accessPolicy: ContextGraphAccessPolicyV1; + readonly publishPolicy: ContextGraphPublishPolicyV1; + readonly publishAuthority: EvmAddressV1 | null; + readonly publishAuthorityAccountId: DecimalU256V1; + readonly nameHash: Digest32V1; +}; + +export interface FinalizedContextGraphReadResolverV1 { + (request: FinalizedContextGraphReadRequestV1): Promise; +} + +function canonicalDecimalU256( + value: unknown, + code: FinalizedContextGraphReadErrorCodeV1, + label: string, +): DecimalU256V1 { + try { + assertCanonicalDecimalU256(value, label); + return value; + } catch { + throw new FinalizedContextGraphReadErrorV1( + code, + `${label} must be a canonical decimal uint256`, + ); + } +} + +function canonicalBlockNumber( + value: unknown, + code: FinalizedContextGraphReadErrorCodeV1, +): BlockNumberV1 { + try { + assertCanonicalDecimalU64(value, 'blockNumber'); + return value; + } catch { + throw new FinalizedContextGraphReadErrorV1( + code, + 'blockNumber must be a canonical decimal uint64', + ); + } +} + +function canonicalNonZeroAddress( + value: unknown, + code: FinalizedContextGraphReadErrorCodeV1, + label: string, +): EvmAddressV1 { + try { + assertCanonicalEvmAddress(value, label); + return value; + } catch { + throw new FinalizedContextGraphReadErrorV1( + code, + `${label} must be a canonical lowercase non-zero EVM address`, + ); + } +} + +function canonicalNullableAddress( + value: unknown, + code: FinalizedContextGraphReadErrorCodeV1, + label: string, +): EvmAddressV1 | null { + if (!isCanonicalEvmAddressShapeV1(value)) { + throw new FinalizedContextGraphReadErrorV1( + code, + `${label} must be a canonical lowercase EVM address`, + ); + } + if (value === ZERO_ADDRESS) return null; + return canonicalNonZeroAddress(value, code, label); +} + +function canonicalDigest32( + value: unknown, + code: FinalizedContextGraphReadErrorCodeV1, + label: string, +): Digest32V1 { + try { + assertCanonicalDigest(value, label); + return value; + } catch { + throw new FinalizedContextGraphReadErrorV1( + code, + `${label} must be a 0x-prefixed lowercase 32-byte hex digest`, + ); + } +} + +/** Validate and snapshot one request-bound finalized chain read. */ +export function composeFinalizedContextGraphReadV1( + request: FinalizedContextGraphReadRequestV1, + raw: UntrustedFinalizedContextGraphFieldsV1, +): FinalizedContextGraphReadV1 { + let chainId: ChainIdV1; + try { + assertCanonicalChainId(request.chainId, 'finalized Context Graph chainId'); + chainId = request.chainId; + } catch { + throw new FinalizedContextGraphReadErrorV1( + 'request-binding', + 'request chainId is not canonical', + ); + } + const contextGraphId = canonicalDecimalU256( + request.contextGraphId, + 'request-binding', + 'contextGraphId', + ); + const governanceContract = canonicalNonZeroAddress( + request.governanceContract, + 'request-binding', + 'governanceContract', + ); + + let owner: EvmAddressV1; + if (raw.owner === ZERO_ADDRESS) { + throw new FinalizedContextGraphReadErrorV1( + 'unregistered-context-graph', + 'ContextGraphStorage returned a zero owner (unregistered context graph)', + ); + } + owner = canonicalNonZeroAddress(raw.owner, 'malformed-owner', 'owner'); + + const publishAuthority = canonicalNullableAddress( + raw.publishAuthority, + 'malformed-authority', + 'publishAuthority', + ); + const publishAuthorityAccountId = canonicalDecimalU256( + raw.publishAuthorityAccountId, + 'request-binding', + 'publishAuthorityAccountId', + ); + + if (raw.accessPolicy !== 0 && raw.accessPolicy !== 1) { + throw new FinalizedContextGraphReadErrorV1( + 'unsupported-access-policy', + `unsupported accessPolicy ${String(raw.accessPolicy)}`, + ); + } + const accessPolicy: ContextGraphAccessPolicyV1 = raw.accessPolicy; + if (raw.publishPolicy !== 0 && raw.publishPolicy !== 1) { + throw new FinalizedContextGraphReadErrorV1( + 'unsupported-publish-policy', + `unsupported publishPolicy ${String(raw.publishPolicy)}`, + ); + } + const publishPolicy: ContextGraphPublishPolicyV1 = raw.publishPolicy; + if ( + publishPolicy === 1 + && (publishAuthority !== null || publishAuthorityAccountId !== '0') + ) { + throw new FinalizedContextGraphReadErrorV1( + 'inconsistent-publish-policy', + 'open contribution requires zero publish authority and account ID zero', + ); + } + if (publishPolicy === 0 && publishAuthority === null) { + throw new FinalizedContextGraphReadErrorV1( + 'inconsistent-publish-policy', + 'curated contribution requires a non-zero publish authority', + ); + } + if (typeof raw.active !== 'boolean') { + throw new FinalizedContextGraphReadErrorV1('malformed-anchor', 'active must be a boolean'); + } + + const blockHash = canonicalDigest32(raw.blockHash, 'malformed-anchor', 'blockHash'); + const blockNumber = canonicalBlockNumber(raw.blockNumber, 'request-binding'); + const nameHash = canonicalDigest32(raw.nameHash, 'malformed-name-binding', 'nameHash'); + + return Object.freeze({ + chainId, + contextGraphId, + governanceContract, + blockNumber, + blockHash, + owner, + active: raw.active, + accessPolicy, + publishPolicy, + publishAuthority, + publishAuthorityAccountId, + nameHash, + }); +} + +/** Resolve untrusted finalized fields, forwarding the exact bound request once. */ +export async function resolveFinalizedContextGraphReadV1( + resolver: FinalizedContextGraphReadResolverV1, + request: FinalizedContextGraphReadRequestV1, +): Promise { + const raw = await resolver(request); + return composeFinalizedContextGraphReadV1(request, raw); +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 01bcf3b5da..9f98bf9b48 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -43,16 +43,15 @@ export { export { CG_ACCESS_POLICY_VALUES_V1, CG_PUBLISH_POLICY_VALUES_V1, - FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, - FinalizedContextGraphPolicyErrorV1, - resolveFinalizedContextGraphPolicySnapshotV1, - snapshotFinalizedContextGraphPolicyV1, - type FinalizedContextGraphPolicyErrorCodeV1, - type FinalizedContextGraphPolicyRequestV1, - type FinalizedContextGraphPolicyResolverV1, - type FinalizedContextGraphPolicySnapshotV1, - type RawFinalizedContextGraphPolicyFieldsV1, -} from './finalized-context-graph-policy-snapshot.js'; + FinalizedContextGraphReadErrorV1, + composeFinalizedContextGraphReadV1, + resolveFinalizedContextGraphReadV1, + type FinalizedContextGraphReadErrorCodeV1, + type FinalizedContextGraphReadRequestV1, + type FinalizedContextGraphReadResolverV1, + type FinalizedContextGraphReadV1, + type UntrustedFinalizedContextGraphFieldsV1, +} from './finalized-context-graph-read.js'; export { resolveQuotedPublisherCandidatePricing, resolveLegacyPublisherCandidatePricing, diff --git a/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts b/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts deleted file mode 100644 index 0b47187ab5..0000000000 --- a/packages/chain/test/finalized-context-graph-policy-snapshot.unit.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { - BlockNumberV1, - ChainIdV1, - ContextGraphAccessPolicyV1, - ContextGraphPublishPolicyV1, - Digest32V1, - EvmAddressV1, -} from '@origintrail-official/dkg-core'; - -import { - FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, - FinalizedContextGraphPolicyErrorV1, - resolveFinalizedContextGraphPolicySnapshotV1, - snapshotFinalizedContextGraphPolicyV1, - type FinalizedContextGraphPolicyErrorCodeV1, - type FinalizedContextGraphPolicyRequestV1, - type RawFinalizedContextGraphPolicyFieldsV1, -} from '../src/finalized-context-graph-policy-snapshot.js'; - -const CHAIN_ID = '20430' as ChainIdV1; -const CGS = `0x${'11'.repeat(20)}` as EvmAddressV1; -const OWNER = `0x${'22'.repeat(20)}` as EvmAddressV1; -const AUTHORITY = `0x${'33'.repeat(20)}` as EvmAddressV1; -const ZERO = `0x${'0'.repeat(40)}` as EvmAddressV1; -const BLOCK_HASH = `0x${'44'.repeat(32)}` as Digest32V1; -const NAME_HASH = `0x${'55'.repeat(32)}` as Digest32V1; -const MAX_U64 = '18446744073709551615'; -const ABOVE_MAX_U64 = '18446744073709551616'; -const MAX_U256 = - '115792089237316195423570985008687907853269984665640564039457584007913129639935'; -const ABOVE_MAX_U256 = - '115792089237316195423570985008687907853269984665640564039457584007913129639936'; - -function validRequest(): FinalizedContextGraphPolicyRequestV1 { - return { chainId: CHAIN_ID, contextGraphId: '42', governanceContract: CGS }; -} - -function validRaw(): RawFinalizedContextGraphPolicyFieldsV1 { - return { - blockNumber: '123' as BlockNumberV1, - blockHash: BLOCK_HASH, - owner: OWNER, - active: true, - accessPolicy: 1, - publishPolicy: 0, - publishAuthority: AUTHORITY, - publishAuthorityAccountId: '7', - nameHash: NAME_HASH, - }; -} - -function expectFailure(operation: () => unknown, code: FinalizedContextGraphPolicyErrorCodeV1): void { - try { - operation(); - } catch (error) { - expect(error).toBeInstanceOf(FinalizedContextGraphPolicyErrorV1); - expect((error as FinalizedContextGraphPolicyErrorV1).code).toBe(code); - return; - } - throw new Error(`expected failure ${code}`); -} - -describe('RFC-64 Gate 5 finalized Context Graph policy snapshot', () => { - it('composes an immutable snapshot binding anchor, identity, and every policy field', () => { - const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), validRaw()); - const accessPolicy: ContextGraphAccessPolicyV1 = snapshot.accessPolicy; - const publishPolicy: ContextGraphPublishPolicyV1 = snapshot.publishPolicy; - expect(Object.isFrozen(snapshot)).toBe(true); - expect(accessPolicy).toBe(1); - expect(publishPolicy).toBe(0); - expect(snapshot).toEqual({ - schema: FINALIZED_CG_POLICY_SNAPSHOT_SCHEMA_V1, - chainId: CHAIN_ID, - contextGraphId: '42', - governanceContract: CGS, - blockNumber: '123', - blockHash: BLOCK_HASH, - owner: OWNER, - active: true, - accessPolicy: 1, - publishPolicy: 0, - publishAuthority: AUTHORITY, - publishAuthorityAccountId: '7', - nameHash: NAME_HASH, - }); - }); - - it('reaches the same snapshot through the resolver seam (mock-adapter parity)', async () => { - const raw = Object.freeze(validRaw()); - const resolver = () => Promise.resolve(raw); - const viaSeam = await resolveFinalizedContextGraphPolicySnapshotV1(resolver, validRequest()); - const direct = snapshotFinalizedContextGraphPolicyV1(validRequest(), validRaw()); - expect(viaSeam).toEqual(direct); - }); - - it('accepts a zero publish authority (curator/PCA unset) without inventing one', () => { - const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), { - ...validRaw(), - publishAuthority: ZERO, - publishAuthorityAccountId: '0', - }); - expect(snapshot.publishAuthority).toBe(ZERO); - expect(snapshot.publishAuthorityAccountId).toBe('0'); - }); - - it('accepts a registered-but-inactive Context Graph', () => { - const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), active: false }); - expect(snapshot.active).toBe(false); - }); - - it('accepts the canonical u64 block-number maximum and rejects MAX_U64 + 1', () => { - const snapshot = snapshotFinalizedContextGraphPolicyV1(validRequest(), { - ...validRaw(), - blockNumber: MAX_U64 as BlockNumberV1, - }); - expect(snapshot.blockNumber).toBe(MAX_U64); - expectFailure( - () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { - ...validRaw(), - blockNumber: ABOVE_MAX_U64 as BlockNumberV1, - }), - 'request-binding', - ); - }); - - it('accepts canonical u256 identity boundaries and rejects values above them', () => { - const snapshot = snapshotFinalizedContextGraphPolicyV1( - { ...validRequest(), contextGraphId: MAX_U256 }, - { ...validRaw(), publishAuthorityAccountId: MAX_U256 }, - ); - expect(snapshot.contextGraphId).toBe(MAX_U256); - expect(snapshot.publishAuthorityAccountId).toBe(MAX_U256); - - expectFailure( - () => snapshotFinalizedContextGraphPolicyV1( - { ...validRequest(), contextGraphId: ABOVE_MAX_U256 }, - validRaw(), - ), - 'request-binding', - ); - expectFailure( - () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { - ...validRaw(), - publishAuthorityAccountId: ABOVE_MAX_U256, - }), - 'request-binding', - ); - }); - - it('fails closed on an unregistered context graph (zero owner)', () => { - expectFailure( - () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), owner: ZERO }), - 'unregistered-context-graph', - ); - }); - - it.each([ - ['non-canonical chainId', () => snapshotFinalizedContextGraphPolicyV1({ ...validRequest(), chainId: '020430' as ChainIdV1 }, validRaw()), 'request-binding'], - ['non-decimal contextGraphId', () => snapshotFinalizedContextGraphPolicyV1({ ...validRequest(), contextGraphId: '0x2a' }, validRaw()), 'request-binding'], - ['zero governanceContract', () => snapshotFinalizedContextGraphPolicyV1({ ...validRequest(), governanceContract: ZERO }, validRaw()), 'request-binding'], - ['checksummed (non-lowercase) owner', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), owner: `0x${'Ab'.repeat(20)}` as EvmAddressV1 }), 'malformed-owner'], - ['malformed publishAuthority', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishAuthority: '0xdeadbeef' as EvmAddressV1 }), 'malformed-authority'], - ['hex publishAuthorityAccountId', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishAuthorityAccountId: '0x7' }), 'request-binding'], - ['non-canonical publishAuthorityAccountId', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishAuthorityAccountId: '07' }), 'request-binding'], - ['out-of-range accessPolicy', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), accessPolicy: 2 }), 'unsupported-access-policy'], - ['out-of-range publishPolicy', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), publishPolicy: 9 }), 'unsupported-publish-policy'], - ['malformed blockHash', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), blockHash: '0xdead' as Digest32V1 }), 'malformed-anchor'], - ['non-decimal blockNumber', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), blockNumber: 'latest' as unknown as BlockNumberV1 }), 'request-binding'], - ['malformed nameHash', () => snapshotFinalizedContextGraphPolicyV1(validRequest(), { ...validRaw(), nameHash: '0x55' as Digest32V1 }), 'malformed-name-binding'], - ] as const)('fails closed on %s', (_name, operation, code) => { - expectFailure(operation, code); - }); -}); diff --git a/packages/chain/test/finalized-context-graph-read.unit.test.ts b/packages/chain/test/finalized-context-graph-read.unit.test.ts new file mode 100644 index 0000000000..0f3947852b --- /dev/null +++ b/packages/chain/test/finalized-context-graph-read.unit.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + ContextGraphAccessPolicyV1, + ContextGraphPublishPolicyV1, + EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +import { + FinalizedContextGraphReadErrorV1, + composeFinalizedContextGraphReadV1, + resolveFinalizedContextGraphReadV1, + type FinalizedContextGraphReadErrorCodeV1, + type FinalizedContextGraphReadRequestV1, + type UntrustedFinalizedContextGraphFieldsV1, +} from '../src/finalized-context-graph-read.js'; + +const CHAIN_ID = '20430'; +const CGS = `0x${'11'.repeat(20)}`; +const OWNER = `0x${'22'.repeat(20)}`; +const AUTHORITY = `0x${'33'.repeat(20)}`; +const ZERO = `0x${'0'.repeat(40)}`; +const BLOCK_HASH = `0x${'44'.repeat(32)}`; +const NAME_HASH = `0x${'55'.repeat(32)}`; +const MAX_U64 = '18446744073709551615'; +const ABOVE_MAX_U64 = '18446744073709551616'; +const MAX_U256 = + '115792089237316195423570985008687907853269984665640564039457584007913129639935'; +const ABOVE_MAX_U256 = + '115792089237316195423570985008687907853269984665640564039457584007913129639936'; + +function validRequest(): FinalizedContextGraphReadRequestV1 { + return { chainId: CHAIN_ID, contextGraphId: '42', governanceContract: CGS }; +} + +function validRaw(): UntrustedFinalizedContextGraphFieldsV1 { + return { + blockNumber: '123', + blockHash: BLOCK_HASH, + owner: OWNER, + active: true, + accessPolicy: 1, + publishPolicy: 0, + publishAuthority: AUTHORITY, + publishAuthorityAccountId: '7', + nameHash: NAME_HASH, + }; +} + +function expectFailure( + operation: () => unknown, + code: FinalizedContextGraphReadErrorCodeV1, +): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(FinalizedContextGraphReadErrorV1); + expect((error as FinalizedContextGraphReadErrorV1).code).toBe(code); + return; + } + throw new Error(`expected failure ${code}`); +} + +describe('RFC-64 finalized Context Graph chain read', () => { + it('validates and snapshots the complete request-bound chain result', () => { + const read = composeFinalizedContextGraphReadV1(validRequest(), validRaw()); + const accessPolicy: ContextGraphAccessPolicyV1 = read.accessPolicy; + const publishPolicy: ContextGraphPublishPolicyV1 = read.publishPolicy; + const publishAuthority: EvmAddressV1 | null = read.publishAuthority; + expect(Object.isFrozen(read)).toBe(true); + expect(accessPolicy).toBe(1); + expect(publishPolicy).toBe(0); + expect(publishAuthority).toBe(AUTHORITY); + expect(read).toEqual({ + chainId: CHAIN_ID, + contextGraphId: '42', + governanceContract: CGS, + blockNumber: '123', + blockHash: BLOCK_HASH, + owner: OWNER, + active: true, + accessPolicy: 1, + publishPolicy: 0, + publishAuthority: AUTHORITY, + publishAuthorityAccountId: '7', + nameHash: NAME_HASH, + }); + }); + + it('forwards the exact request object through the resolver seam once', async () => { + const request = validRequest(); + const raw = Object.freeze(validRaw()); + const resolver = vi.fn((received: FinalizedContextGraphReadRequestV1) => { + expect(received).toBe(request); + return Promise.resolve(raw); + }); + const viaSeam = await resolveFinalizedContextGraphReadV1(resolver, request); + const direct = composeFinalizedContextGraphReadV1(request, validRaw()); + expect(resolver).toHaveBeenCalledOnce(); + expect(resolver).toHaveBeenCalledWith(request); + expect(viaSeam).toEqual(direct); + }); + + it('maps the valid open-policy zero authority to the canonical null domain', () => { + const read = composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + publishPolicy: 1, + publishAuthority: ZERO, + publishAuthorityAccountId: '0', + }); + expect(read.publishAuthority).toBeNull(); + expect(read.publishAuthorityAccountId).toBe('0'); + }); + + it('rejects impossible curated and open publish-authority combinations', () => { + expectFailure( + () => composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + publishAuthority: ZERO, + publishAuthorityAccountId: '0', + }), + 'inconsistent-publish-policy', + ); + expectFailure( + () => composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + publishPolicy: 1, + }), + 'inconsistent-publish-policy', + ); + }); + + it('preserves a registered-but-inactive Context Graph as chain-read state', () => { + const read = composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + active: false, + }); + expect(read.active).toBe(false); + }); + + it('accepts the canonical u64 block-number maximum and rejects MAX_U64 + 1', () => { + const read = composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + blockNumber: MAX_U64, + }); + expect(read.blockNumber).toBe(MAX_U64); + expectFailure( + () => composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + blockNumber: ABOVE_MAX_U64, + }), + 'request-binding', + ); + }); + + it('accepts canonical u256 identity boundaries and rejects values above them', () => { + const read = composeFinalizedContextGraphReadV1( + { ...validRequest(), contextGraphId: MAX_U256 }, + { ...validRaw(), publishAuthorityAccountId: MAX_U256 }, + ); + expect(read.contextGraphId).toBe(MAX_U256); + expect(read.publishAuthorityAccountId).toBe(MAX_U256); + + expectFailure( + () => composeFinalizedContextGraphReadV1( + { ...validRequest(), contextGraphId: ABOVE_MAX_U256 }, + validRaw(), + ), + 'request-binding', + ); + expectFailure( + () => composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + publishAuthorityAccountId: ABOVE_MAX_U256, + }), + 'request-binding', + ); + }); + + it('fails closed on an unregistered context graph (zero owner)', () => { + expectFailure( + () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), owner: ZERO }), + 'unregistered-context-graph', + ); + }); + + it.each([ + ['non-canonical chainId', () => composeFinalizedContextGraphReadV1({ ...validRequest(), chainId: '020430' }, validRaw()), 'request-binding'], + ['non-decimal contextGraphId', () => composeFinalizedContextGraphReadV1({ ...validRequest(), contextGraphId: '0x2a' }, validRaw()), 'request-binding'], + ['zero governanceContract', () => composeFinalizedContextGraphReadV1({ ...validRequest(), governanceContract: ZERO }, validRaw()), 'request-binding'], + ['checksummed owner', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), owner: `0x${'Ab'.repeat(20)}` }), 'malformed-owner'], + ['malformed publishAuthority', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), publishAuthority: '0xdeadbeef' }), 'malformed-authority'], + ['hex publishAuthorityAccountId', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), publishAuthorityAccountId: '0x7' }), 'request-binding'], + ['non-canonical publishAuthorityAccountId', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), publishAuthorityAccountId: '07' }), 'request-binding'], + ['out-of-range accessPolicy', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), accessPolicy: 2 }), 'unsupported-access-policy'], + ['out-of-range publishPolicy', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), publishPolicy: 9 }), 'unsupported-publish-policy'], + ['malformed blockHash', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), blockHash: '0xdead' }), 'malformed-anchor'], + ['non-decimal blockNumber', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), blockNumber: 'latest' }), 'request-binding'], + ['malformed active flag', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), active: 1 }), 'malformed-anchor'], + ['malformed nameHash', () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), nameHash: '0x55' }), 'malformed-name-binding'], + ] as const)('fails closed on %s', (_name, operation, code) => { + expectFailure(operation, code); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fd9c9790a9..d7e9474f0e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,11 +24,13 @@ export { assertCanonicalDecimalU64, assertCanonicalDecimalU256, assertCanonicalDigest, + assertCanonicalEvmAddress, assertCanonicalHexBytes, assertCanonicalKaId, assertCanonicalTimestampMs, parseCanonicalDecimalU64, parseCanonicalDecimalU256, + isCanonicalEvmAddressShapeV1, } from './sync-wire-scalars.js'; export type { BatchIdV1, diff --git a/packages/core/src/sync-wire-scalars.ts b/packages/core/src/sync-wire-scalars.ts index 0de4a754a6..4304505cee 100644 --- a/packages/core/src/sync-wire-scalars.ts +++ b/packages/core/src/sync-wire-scalars.ts @@ -110,7 +110,7 @@ export function assertCanonicalEvmAddress( value: unknown, label = 'address', ): asserts value is EvmAddressV1 { - if (typeof value !== 'string' || !EVM_ADDRESS.test(value)) { + if (!isCanonicalEvmAddressShapeV1(value)) { throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); } if (value === ZERO_EVM_ADDRESS) { @@ -118,6 +118,11 @@ export function assertCanonicalEvmAddress( } } +/** Structural EVM-address check for RPC fields whose domain also permits zero. */ +export function isCanonicalEvmAddressShapeV1(value: unknown): value is string { + return typeof value === 'string' && EVM_ADDRESS.test(value); +} + export function assertCanonicalHexBytes( value: unknown, label: string, diff --git a/packages/core/test/sync-wire-scalars.test.ts b/packages/core/test/sync-wire-scalars.test.ts index 640c3a67ef..d91daf71e4 100644 --- a/packages/core/test/sync-wire-scalars.test.ts +++ b/packages/core/test/sync-wire-scalars.test.ts @@ -10,6 +10,7 @@ import { assertCanonicalHexBytes, assertCanonicalKaId, assertCanonicalTimestampMs, + isCanonicalEvmAddressShapeV1, parseCanonicalDecimalU256, } from '../src/sync-wire-scalars.js'; @@ -60,6 +61,8 @@ describe('RFC-64 sync wire scalar profile', () => { }); it('accepts only lowercase nonzero fixed-width EVM addresses', () => { + expect(isCanonicalEvmAddressShapeV1(`0x${'00'.repeat(20)}`)).toBe(true); + expect(isCanonicalEvmAddressShapeV1(`0x${'AA'.repeat(20)}`)).toBe(false); expect(() => assertCanonicalEvmAddress(`0x${'11'.repeat(20)}`)).not.toThrow(); expect(() => assertCanonicalEvmAddress(`0x${'AA'.repeat(20)}`)).toThrow( /lowercase 20-byte/, From 1cba82ded06af8c8310dcfde97e12edadc763fa5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 08:45:26 +0200 Subject: [PATCH 191/292] refactor(chain): validate finalized CG bindings --- .../chain/src/finalized-context-graph-read.ts | 82 +++++++++++++------ packages/chain/src/index.ts | 4 +- .../finalized-context-graph-read.unit.test.ts | 37 ++++++++- packages/core/src/cg-policy-objects.ts | 20 ++++- packages/core/src/index.ts | 2 +- packages/core/src/sync-wire-scalars.ts | 19 ++++- packages/core/test/sync-wire-scalars.test.ts | 11 ++- 7 files changed, 136 insertions(+), 39 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts index a9c176fe27..7dbfb4eecd 100644 --- a/packages/chain/src/finalized-context-graph-read.ts +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -4,7 +4,9 @@ import { assertCanonicalDecimalU256, assertCanonicalDigest, assertCanonicalEvmAddress, - isCanonicalEvmAddressShapeV1, + assertContextGraphAccessPolicyV1, + assertContextGraphPublishPolicyV1, + parseCanonicalNullableEvmAddressV1, type BlockNumberV1, type ChainIdV1, type ContextGraphAccessPolicyV1, @@ -20,10 +22,7 @@ import { // by ContextGraphPolicyV1. Runtime policy composition must add those trusted // inputs once and validate the result with the canonical core policy codec. -export const CG_ACCESS_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); -export const CG_PUBLISH_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); - -const ZERO_ADDRESS = `0x${'0'.repeat(40)}`; +const ZERO_DIGEST_32 = `0x${'0'.repeat(64)}`; export type FinalizedContextGraphReadErrorCodeV1 = | 'request-binding' @@ -53,6 +52,13 @@ export type FinalizedContextGraphReadRequestV1 = { readonly governanceContract: unknown; }; +/** Canonical request binding passed across the finalized RPC boundary. */ +export type FinalizedContextGraphBindingV1 = { + readonly chainId: ChainIdV1; + readonly contextGraphId: DecimalU256V1; + readonly governanceContract: EvmAddressV1; +}; + /** Untrusted RPC output from getContextGraph(uint256) and getNameHash(uint256). */ export type UntrustedFinalizedContextGraphFieldsV1 = { readonly blockNumber: unknown; @@ -79,11 +85,11 @@ export type FinalizedContextGraphReadV1 = { readonly publishPolicy: ContextGraphPublishPolicyV1; readonly publishAuthority: EvmAddressV1 | null; readonly publishAuthorityAccountId: DecimalU256V1; - readonly nameHash: Digest32V1; + readonly nameHash: Digest32V1 | null; }; export interface FinalizedContextGraphReadResolverV1 { - (request: FinalizedContextGraphReadRequestV1): Promise; + (binding: FinalizedContextGraphBindingV1): Promise; } function canonicalDecimalU256( @@ -138,14 +144,14 @@ function canonicalNullableAddress( code: FinalizedContextGraphReadErrorCodeV1, label: string, ): EvmAddressV1 | null { - if (!isCanonicalEvmAddressShapeV1(value)) { + try { + return parseCanonicalNullableEvmAddressV1(value, label); + } catch { throw new FinalizedContextGraphReadErrorV1( code, `${label} must be a canonical lowercase EVM address`, ); } - if (value === ZERO_ADDRESS) return null; - return canonicalNonZeroAddress(value, code, label); } function canonicalDigest32( @@ -164,15 +170,20 @@ function canonicalDigest32( } } -/** Validate and snapshot one request-bound finalized chain read. */ -export function composeFinalizedContextGraphReadV1( +function canonicalNullableNameHash(value: unknown): Digest32V1 | null { + if (value === null || value === ZERO_DIGEST_32) return null; + return canonicalDigest32(value, 'malformed-name-binding', 'nameHash'); +} + +/** Validate and freeze the identity before any resolver can consume it. */ +export function validateFinalizedContextGraphReadRequestV1( request: FinalizedContextGraphReadRequestV1, - raw: UntrustedFinalizedContextGraphFieldsV1, -): FinalizedContextGraphReadV1 { +): FinalizedContextGraphBindingV1 { + const chainIdValue = request.chainId; let chainId: ChainIdV1; try { - assertCanonicalChainId(request.chainId, 'finalized Context Graph chainId'); - chainId = request.chainId; + assertCanonicalChainId(chainIdValue, 'finalized Context Graph chainId'); + chainId = chainIdValue; } catch { throw new FinalizedContextGraphReadErrorV1( 'request-binding', @@ -189,15 +200,33 @@ export function composeFinalizedContextGraphReadV1( 'request-binding', 'governanceContract', ); + return Object.freeze({ chainId, contextGraphId, governanceContract }); +} + +/** Validate and snapshot one request-bound finalized chain read. */ +export function composeFinalizedContextGraphReadV1( + request: FinalizedContextGraphReadRequestV1, + raw: UntrustedFinalizedContextGraphFieldsV1, +): FinalizedContextGraphReadV1 { + return composeValidatedFinalizedContextGraphReadV1( + validateFinalizedContextGraphReadRequestV1(request), + raw, + ); +} - let owner: EvmAddressV1; - if (raw.owner === ZERO_ADDRESS) { +function composeValidatedFinalizedContextGraphReadV1( + binding: FinalizedContextGraphBindingV1, + raw: UntrustedFinalizedContextGraphFieldsV1, +): FinalizedContextGraphReadV1 { + const { chainId, contextGraphId, governanceContract } = binding; + + const owner = canonicalNullableAddress(raw.owner, 'malformed-owner', 'owner'); + if (owner === null) { throw new FinalizedContextGraphReadErrorV1( 'unregistered-context-graph', 'ContextGraphStorage returned a zero owner (unregistered context graph)', ); } - owner = canonicalNonZeroAddress(raw.owner, 'malformed-owner', 'owner'); const publishAuthority = canonicalNullableAddress( raw.publishAuthority, @@ -210,14 +239,18 @@ export function composeFinalizedContextGraphReadV1( 'publishAuthorityAccountId', ); - if (raw.accessPolicy !== 0 && raw.accessPolicy !== 1) { + try { + assertContextGraphAccessPolicyV1(raw.accessPolicy); + } catch { throw new FinalizedContextGraphReadErrorV1( 'unsupported-access-policy', `unsupported accessPolicy ${String(raw.accessPolicy)}`, ); } const accessPolicy: ContextGraphAccessPolicyV1 = raw.accessPolicy; - if (raw.publishPolicy !== 0 && raw.publishPolicy !== 1) { + try { + assertContextGraphPublishPolicyV1(raw.publishPolicy); + } catch { throw new FinalizedContextGraphReadErrorV1( 'unsupported-publish-policy', `unsupported publishPolicy ${String(raw.publishPolicy)}`, @@ -245,7 +278,7 @@ export function composeFinalizedContextGraphReadV1( const blockHash = canonicalDigest32(raw.blockHash, 'malformed-anchor', 'blockHash'); const blockNumber = canonicalBlockNumber(raw.blockNumber, 'request-binding'); - const nameHash = canonicalDigest32(raw.nameHash, 'malformed-name-binding', 'nameHash'); + const nameHash = canonicalNullableNameHash(raw.nameHash); return Object.freeze({ chainId, @@ -268,6 +301,7 @@ export async function resolveFinalizedContextGraphReadV1( resolver: FinalizedContextGraphReadResolverV1, request: FinalizedContextGraphReadRequestV1, ): Promise { - const raw = await resolver(request); - return composeFinalizedContextGraphReadV1(request, raw); + const binding = validateFinalizedContextGraphReadRequestV1(request); + const raw = await resolver(binding); + return composeValidatedFinalizedContextGraphReadV1(binding, raw); } diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 9f98bf9b48..c64f3de653 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -41,11 +41,11 @@ export { type StrictCurrentFinalizedEvmRpcConfigV1, } from './strict-current-finalized-evm-rpc.js'; export { - CG_ACCESS_POLICY_VALUES_V1, - CG_PUBLISH_POLICY_VALUES_V1, FinalizedContextGraphReadErrorV1, composeFinalizedContextGraphReadV1, resolveFinalizedContextGraphReadV1, + validateFinalizedContextGraphReadRequestV1, + type FinalizedContextGraphBindingV1, type FinalizedContextGraphReadErrorCodeV1, type FinalizedContextGraphReadRequestV1, type FinalizedContextGraphReadResolverV1, diff --git a/packages/chain/test/finalized-context-graph-read.unit.test.ts b/packages/chain/test/finalized-context-graph-read.unit.test.ts index 0f3947852b..b51b98eb91 100644 --- a/packages/chain/test/finalized-context-graph-read.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-read.unit.test.ts @@ -10,6 +10,7 @@ import { FinalizedContextGraphReadErrorV1, composeFinalizedContextGraphReadV1, resolveFinalizedContextGraphReadV1, + type FinalizedContextGraphBindingV1, type FinalizedContextGraphReadErrorCodeV1, type FinalizedContextGraphReadRequestV1, type UntrustedFinalizedContextGraphFieldsV1, @@ -87,20 +88,35 @@ describe('RFC-64 finalized Context Graph chain read', () => { }); }); - it('forwards the exact request object through the resolver seam once', async () => { + it('passes one frozen canonical binding through the resolver seam', async () => { const request = validRequest(); const raw = Object.freeze(validRaw()); - const resolver = vi.fn((received: FinalizedContextGraphReadRequestV1) => { - expect(received).toBe(request); + const resolver = vi.fn((received: FinalizedContextGraphBindingV1) => { + expect(received).not.toBe(request); + expect(Object.isFrozen(received)).toBe(true); + expect(received).toEqual(request); return Promise.resolve(raw); }); const viaSeam = await resolveFinalizedContextGraphReadV1(resolver, request); const direct = composeFinalizedContextGraphReadV1(request, validRaw()); expect(resolver).toHaveBeenCalledOnce(); - expect(resolver).toHaveBeenCalledWith(request); + expect(resolver).toHaveBeenCalledWith(Object.freeze({ + chainId: CHAIN_ID, + contextGraphId: '42', + governanceContract: CGS, + })); expect(viaSeam).toEqual(direct); }); + it('rejects malformed identity before invoking the resolver', async () => { + const resolver = vi.fn(() => Promise.resolve(validRaw())); + await expect(resolveFinalizedContextGraphReadV1(resolver, { + ...validRequest(), + contextGraphId: '0x2a', + })).rejects.toMatchObject({ code: 'request-binding' }); + expect(resolver).not.toHaveBeenCalled(); + }); + it('maps the valid open-policy zero authority to the canonical null domain', () => { const read = composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), @@ -138,6 +154,19 @@ describe('RFC-64 finalized Context Graph chain read', () => { expect(read.active).toBe(false); }); + it('maps both finalized name-hash opt-out representations to null', () => { + const fromAdapter = composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + nameHash: null, + }); + const fromRawRpc = composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + nameHash: `0x${'0'.repeat(64)}`, + }); + expect(fromAdapter.nameHash).toBeNull(); + expect(fromRawRpc.nameHash).toBeNull(); + }); + it('accepts the canonical u64 block-number maximum and rejects MAX_U64 + 1', () => { const read = composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), diff --git a/packages/core/src/cg-policy-objects.ts b/packages/core/src/cg-policy-objects.ts index a91efcb130..099731974c 100644 --- a/packages/core/src/cg-policy-objects.ts +++ b/packages/core/src/cg-policy-objects.ts @@ -48,6 +48,8 @@ export const MEMBER_ROSTER_ROLES_V1 = Object.freeze([ 'ingress-host', 'provider', ] as const); +export const CONTEXT_GRAPH_ACCESS_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); +export const CONTEXT_GRAPH_PUBLISH_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); export type ContextGraphAccessPolicyV1 = 0 | 1; export type ContextGraphPublishPolicyV1 = 0 | 1; @@ -155,6 +157,20 @@ export function assertContextGraphPolicyV1( validateContextGraphPolicySnapshotV1(value); } +export function assertContextGraphAccessPolicyV1( + value: unknown, + label = 'accessPolicy', +): asserts value is ContextGraphAccessPolicyV1 { + assertPolicyEnum(value, label); +} + +export function assertContextGraphPublishPolicyV1( + value: unknown, + label = 'publishPolicy', +): asserts value is ContextGraphPublishPolicyV1 { + assertPolicyEnum(value, label); +} + export function canonicalizeContextGraphPolicyPayloadV1( policy: ContextGraphPolicyV1, ): string { @@ -411,8 +427,8 @@ function assertContextGraphPolicyStructureV1( u64(value.era, 'era'); u64(value.version, 'version'); optionalDigest(value.previousPolicyDigest, 'previousPolicyDigest'); - assertPolicyEnum(value.accessPolicy, 'accessPolicy'); - assertPolicyEnum(value.publishPolicy, 'publishPolicy'); + assertContextGraphAccessPolicyV1(value.accessPolicy); + assertContextGraphPublishPolicyV1(value.publishPolicy); if (value.publishAuthority !== null) { scalar(() => assertCanonicalEvmAddress(value.publishAuthority, 'publishAuthority')); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d7e9474f0e..f9f5bc22f5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,7 +30,7 @@ export { assertCanonicalTimestampMs, parseCanonicalDecimalU64, parseCanonicalDecimalU256, - isCanonicalEvmAddressShapeV1, + parseCanonicalNullableEvmAddressV1, } from './sync-wire-scalars.js'; export type { BatchIdV1, diff --git a/packages/core/src/sync-wire-scalars.ts b/packages/core/src/sync-wire-scalars.ts index 4304505cee..dcef535827 100644 --- a/packages/core/src/sync-wire-scalars.ts +++ b/packages/core/src/sync-wire-scalars.ts @@ -110,7 +110,7 @@ export function assertCanonicalEvmAddress( value: unknown, label = 'address', ): asserts value is EvmAddressV1 { - if (!isCanonicalEvmAddressShapeV1(value)) { + if (!isCanonicalEvmAddressShape(value)) { throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); } if (value === ZERO_EVM_ADDRESS) { @@ -118,8 +118,21 @@ export function assertCanonicalEvmAddress( } } -/** Structural EVM-address check for RPC fields whose domain also permits zero. */ -export function isCanonicalEvmAddressShapeV1(value: unknown): value is string { +/** + * Parse a canonical EVM-address slot whose zero sentinel means no address. + * Non-zero values are returned in the ordinary EvmAddressV1 domain. + */ +export function parseCanonicalNullableEvmAddressV1( + value: unknown, + label = 'address', +): EvmAddressV1 | null { + if (!isCanonicalEvmAddressShape(value)) { + throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); + } + return value === ZERO_EVM_ADDRESS ? null : value as EvmAddressV1; +} + +function isCanonicalEvmAddressShape(value: unknown): value is string { return typeof value === 'string' && EVM_ADDRESS.test(value); } diff --git a/packages/core/test/sync-wire-scalars.test.ts b/packages/core/test/sync-wire-scalars.test.ts index d91daf71e4..a52c70a0fe 100644 --- a/packages/core/test/sync-wire-scalars.test.ts +++ b/packages/core/test/sync-wire-scalars.test.ts @@ -10,8 +10,8 @@ import { assertCanonicalHexBytes, assertCanonicalKaId, assertCanonicalTimestampMs, - isCanonicalEvmAddressShapeV1, parseCanonicalDecimalU256, + parseCanonicalNullableEvmAddressV1, } from '../src/sync-wire-scalars.js'; describe('RFC-64 sync wire scalar profile', () => { @@ -61,8 +61,13 @@ describe('RFC-64 sync wire scalar profile', () => { }); it('accepts only lowercase nonzero fixed-width EVM addresses', () => { - expect(isCanonicalEvmAddressShapeV1(`0x${'00'.repeat(20)}`)).toBe(true); - expect(isCanonicalEvmAddressShapeV1(`0x${'AA'.repeat(20)}`)).toBe(false); + expect(parseCanonicalNullableEvmAddressV1(`0x${'00'.repeat(20)}`)).toBeNull(); + expect(parseCanonicalNullableEvmAddressV1(`0x${'11'.repeat(20)}`)).toBe( + `0x${'11'.repeat(20)}`, + ); + expect(() => parseCanonicalNullableEvmAddressV1(`0x${'AA'.repeat(20)}`)).toThrow( + /lowercase 20-byte/, + ); expect(() => assertCanonicalEvmAddress(`0x${'11'.repeat(20)}`)).not.toThrow(); expect(() => assertCanonicalEvmAddress(`0x${'AA'.repeat(20)}`)).toThrow( /lowercase 20-byte/, From 487cd9ccd3ef8f2dcf57dc5bc6d0f5f97d44b69a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 09:03:10 +0200 Subject: [PATCH 192/292] test(chain): isolate finalized policy invariants --- .../finalized-context-graph-read.unit.test.ts | 14 ++++++++++++- packages/core/src/cg-policy-objects.ts | 20 +++++++++++++------ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/chain/test/finalized-context-graph-read.unit.test.ts b/packages/chain/test/finalized-context-graph-read.unit.test.ts index b51b98eb91..0e0221a81b 100644 --- a/packages/chain/test/finalized-context-graph-read.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-read.unit.test.ts @@ -128,7 +128,7 @@ describe('RFC-64 finalized Context Graph chain read', () => { expect(read.publishAuthorityAccountId).toBe('0'); }); - it('rejects impossible curated and open publish-authority combinations', () => { + it('rejects a curated policy without a non-zero authority', () => { expectFailure( () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), @@ -137,10 +137,22 @@ describe('RFC-64 finalized Context Graph chain read', () => { }), 'inconsistent-publish-policy', ); + }); + + it('independently rejects both invalid fields in an open policy', () => { expectFailure( () => composeFinalizedContextGraphReadV1(validRequest(), { ...validRaw(), publishPolicy: 1, + publishAuthorityAccountId: '0', + }), + 'inconsistent-publish-policy', + ); + expectFailure( + () => composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + publishPolicy: 1, + publishAuthority: ZERO, }), 'inconsistent-publish-policy', ); diff --git a/packages/core/src/cg-policy-objects.ts b/packages/core/src/cg-policy-objects.ts index 099731974c..942f2db0a9 100644 --- a/packages/core/src/cg-policy-objects.ts +++ b/packages/core/src/cg-policy-objects.ts @@ -51,8 +51,10 @@ export const MEMBER_ROSTER_ROLES_V1 = Object.freeze([ export const CONTEXT_GRAPH_ACCESS_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); export const CONTEXT_GRAPH_PUBLISH_POLICY_VALUES_V1 = Object.freeze([0, 1] as const); -export type ContextGraphAccessPolicyV1 = 0 | 1; -export type ContextGraphPublishPolicyV1 = 0 | 1; +export type ContextGraphAccessPolicyV1 = + (typeof CONTEXT_GRAPH_ACCESS_POLICY_VALUES_V1)[number]; +export type ContextGraphPublishPolicyV1 = + (typeof CONTEXT_GRAPH_PUBLISH_POLICY_VALUES_V1)[number]; export type MemberRosterRoleV1 = (typeof MEMBER_ROSTER_ROLES_V1)[number]; export interface FinalizedChainPolicySourceV1 { @@ -161,14 +163,14 @@ export function assertContextGraphAccessPolicyV1( value: unknown, label = 'accessPolicy', ): asserts value is ContextGraphAccessPolicyV1 { - assertPolicyEnum(value, label); + assertPolicyEnum(value, label, CONTEXT_GRAPH_ACCESS_POLICY_VALUES_V1); } export function assertContextGraphPublishPolicyV1( value: unknown, label = 'publishPolicy', ): asserts value is ContextGraphPublishPolicyV1 { - assertPolicyEnum(value, label); + assertPolicyEnum(value, label, CONTEXT_GRAPH_PUBLISH_POLICY_VALUES_V1); } export function canonicalizeContextGraphPolicyPayloadV1( @@ -709,8 +711,14 @@ function assertGovernancePair(chainId: unknown, contractAddress: unknown): void } } -function assertPolicyEnum(value: unknown, label: string): asserts value is 0 | 1 { - if (value !== 0 && value !== 1) fail('cg-policy-scalar', `${label} must be JSON number 0 or 1`); +function assertPolicyEnum( + value: unknown, + label: string, + allowed: Values, +): asserts value is Values[number] { + if (typeof value !== 'number' || !(allowed as readonly number[]).includes(value)) { + fail('cg-policy-scalar', `${label} must be JSON number ${allowed.join(' or ')}`); + } } function optionalDigest(value: unknown, label: string): void { From 99727740ab975227e0180a0af1160921be08a455 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 09:14:20 +0200 Subject: [PATCH 193/292] fix(rfc64): centralize publish policy domain --- .../chain/src/finalized-context-graph-read.ts | 27 +++++----- packages/core/src/cg-policy-objects.ts | 54 +++++++++++++------ packages/core/src/index.ts | 1 - packages/core/src/sync-wire-scalars.ts | 20 +------ packages/core/test/cg-policy-objects.test.ts | 16 ++++++ packages/core/test/sync-wire-scalars.test.ts | 8 --- 6 files changed, 67 insertions(+), 59 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts index 7dbfb4eecd..c6f9ede261 100644 --- a/packages/chain/src/finalized-context-graph-read.ts +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -5,8 +5,8 @@ import { assertCanonicalDigest, assertCanonicalEvmAddress, assertContextGraphAccessPolicyV1, + assertContextGraphPublishDomainV1, assertContextGraphPublishPolicyV1, - parseCanonicalNullableEvmAddressV1, type BlockNumberV1, type ChainIdV1, type ContextGraphAccessPolicyV1, @@ -23,6 +23,8 @@ import { // inputs once and validate the result with the canonical core policy codec. const ZERO_DIGEST_32 = `0x${'0'.repeat(64)}`; +const ZERO_ADDRESS = `0x${'0'.repeat(40)}`; +const CANONICAL_LOWER_EVM_ADDRESS = /^0x[0-9a-f]{40}$/; export type FinalizedContextGraphReadErrorCodeV1 = | 'request-binding' @@ -144,14 +146,14 @@ function canonicalNullableAddress( code: FinalizedContextGraphReadErrorCodeV1, label: string, ): EvmAddressV1 | null { - try { - return parseCanonicalNullableEvmAddressV1(value, label); - } catch { + if (typeof value !== 'string' || !CANONICAL_LOWER_EVM_ADDRESS.test(value)) { throw new FinalizedContextGraphReadErrorV1( code, `${label} must be a canonical lowercase EVM address`, ); } + if (value === ZERO_ADDRESS) return null; + return canonicalNonZeroAddress(value, code, label); } function canonicalDigest32( @@ -257,19 +259,16 @@ function composeValidatedFinalizedContextGraphReadV1( ); } const publishPolicy: ContextGraphPublishPolicyV1 = raw.publishPolicy; - if ( - publishPolicy === 1 - && (publishAuthority !== null || publishAuthorityAccountId !== '0') - ) { - throw new FinalizedContextGraphReadErrorV1( - 'inconsistent-publish-policy', - 'open contribution requires zero publish authority and account ID zero', + try { + assertContextGraphPublishDomainV1( + publishPolicy, + publishAuthority, + publishAuthorityAccountId, ); - } - if (publishPolicy === 0 && publishAuthority === null) { + } catch { throw new FinalizedContextGraphReadErrorV1( 'inconsistent-publish-policy', - 'curated contribution requires a non-zero publish authority', + 'publish policy disagrees with its normalized authority tuple', ); } if (typeof raw.active !== 'boolean') { diff --git a/packages/core/src/cg-policy-objects.ts b/packages/core/src/cg-policy-objects.ts index 942f2db0a9..4a8f3eae7d 100644 --- a/packages/core/src/cg-policy-objects.ts +++ b/packages/core/src/cg-policy-objects.ts @@ -173,6 +173,39 @@ export function assertContextGraphPublishPolicyV1( assertPolicyEnum(value, label, CONTEXT_GRAPH_PUBLISH_POLICY_VALUES_V1); } +/** + * Validate the complete normalized contribution-policy tuple. + * + * Chain adapters normalize the EVM zero-address sentinel to null before + * crossing this boundary. Keeping the tuple invariant here gives every + * producer of ContextGraphPolicyV1 the same fail-closed domain check without + * making core aware of a storage-specific sentinel representation. + */ +export function assertContextGraphPublishDomainV1( + publishPolicy: unknown, + publishAuthority: unknown, + publishAuthorityAccountId: unknown, +): asserts publishPolicy is ContextGraphPublishPolicyV1 { + assertContextGraphPublishPolicyV1(publishPolicy); + if (publishAuthority !== null) { + scalar(() => assertCanonicalEvmAddress(publishAuthority, 'publishAuthority')); + } + scalar(() => assertCanonicalDecimalU256( + publishAuthorityAccountId, + 'publishAuthorityAccountId', + )); + if (publishPolicy === 1) { + if (publishAuthority !== null || publishAuthorityAccountId !== '0') { + fail( + 'cg-policy-publish-domain', + 'open contribution requires null publishAuthority and account ID zero', + ); + } + } else if (publishAuthority === null) { + fail('cg-policy-publish-domain', 'curated contribution requires publishAuthority'); + } +} + export function canonicalizeContextGraphPolicyPayloadV1( policy: ContextGraphPolicyV1, ): string { @@ -430,24 +463,11 @@ function assertContextGraphPolicyStructureV1( u64(value.version, 'version'); optionalDigest(value.previousPolicyDigest, 'previousPolicyDigest'); assertContextGraphAccessPolicyV1(value.accessPolicy); - assertContextGraphPublishPolicyV1(value.publishPolicy); - if (value.publishAuthority !== null) { - scalar(() => assertCanonicalEvmAddress(value.publishAuthority, 'publishAuthority')); - } - scalar(() => assertCanonicalDecimalU256( + assertContextGraphPublishDomainV1( + value.publishPolicy, + value.publishAuthority, value.publishAuthorityAccountId, - 'publishAuthorityAccountId', - )); - if (value.publishPolicy === 1) { - if (value.publishAuthority !== null || value.publishAuthorityAccountId !== '0') { - fail( - 'cg-policy-publish-domain', - 'open contribution requires null publishAuthority and account ID zero', - ); - } - } else if (value.publishAuthority === null) { - fail('cg-policy-publish-domain', 'curated contribution requires publishAuthority'); - } + ); if (value.projectionId !== CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1) { fail('cg-policy-scalar', 'projectionId must be cg-shared-v1'); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9f5bc22f5..c8ef6ed1ee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,7 +30,6 @@ export { assertCanonicalTimestampMs, parseCanonicalDecimalU64, parseCanonicalDecimalU256, - parseCanonicalNullableEvmAddressV1, } from './sync-wire-scalars.js'; export type { BatchIdV1, diff --git a/packages/core/src/sync-wire-scalars.ts b/packages/core/src/sync-wire-scalars.ts index dcef535827..0de4a754a6 100644 --- a/packages/core/src/sync-wire-scalars.ts +++ b/packages/core/src/sync-wire-scalars.ts @@ -110,7 +110,7 @@ export function assertCanonicalEvmAddress( value: unknown, label = 'address', ): asserts value is EvmAddressV1 { - if (!isCanonicalEvmAddressShape(value)) { + if (typeof value !== 'string' || !EVM_ADDRESS.test(value)) { throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); } if (value === ZERO_EVM_ADDRESS) { @@ -118,24 +118,6 @@ export function assertCanonicalEvmAddress( } } -/** - * Parse a canonical EVM-address slot whose zero sentinel means no address. - * Non-zero values are returned in the ordinary EvmAddressV1 domain. - */ -export function parseCanonicalNullableEvmAddressV1( - value: unknown, - label = 'address', -): EvmAddressV1 | null { - if (!isCanonicalEvmAddressShape(value)) { - throw new Error(`${label} must be a lowercase 20-byte 0x EVM address`); - } - return value === ZERO_EVM_ADDRESS ? null : value as EvmAddressV1; -} - -function isCanonicalEvmAddressShape(value: unknown): value is string { - return typeof value === 'string' && EVM_ADDRESS.test(value); -} - export function assertCanonicalHexBytes( value: unknown, label: string, diff --git a/packages/core/test/cg-policy-objects.test.ts b/packages/core/test/cg-policy-objects.test.ts index ba9c40d651..a6bb287b9f 100644 --- a/packages/core/test/cg-policy-objects.test.ts +++ b/packages/core/test/cg-policy-objects.test.ts @@ -7,6 +7,7 @@ import { MAX_MEMBER_ROSTER_ENTRIES_V1, MEMBER_ROSTER_ROLES_V1, MEMBER_ROSTER_OBJECT_TYPE_V1, + assertContextGraphPublishDomainV1, assertContextGraphPolicyV1, assertMemberRosterV1, assertSignedContextGraphPolicyEnvelopeV1, @@ -172,6 +173,21 @@ describe('ContextGraphPolicyV1 codec', () => { .toThrow(/cg-policy-scalar/); }); + it('owns the normalized contribution-policy tuple invariant', () => { + expect(() => assertContextGraphPublishDomainV1(1, null, '0')).not.toThrow(); + expect(() => assertContextGraphPublishDomainV1(0, ISSUER, '0')).not.toThrow(); + expect(() => assertContextGraphPublishDomainV1(0, ISSUER, '7')).not.toThrow(); + + expect(() => assertContextGraphPublishDomainV1(1, ISSUER, '0')) + .toThrow(/cg-policy-publish-domain/); + expect(() => assertContextGraphPublishDomainV1(1, null, '7')) + .toThrow(/cg-policy-publish-domain/); + expect(() => assertContextGraphPublishDomainV1(0, null, '0')) + .toThrow(/cg-policy-publish-domain/); + expect(() => assertContextGraphPublishDomainV1(0, `0x${'00'.repeat(20)}`, '0')) + .toThrow(/cg-policy-scalar/); + }); + it('rejects unknown fields, wrong projection, malformed scalars, and noncanonical wire', () => { expect(() => assertContextGraphPolicyV1({ ...POLICY, subGraphName: null })) .toThrow(/cg-policy-schema/); diff --git a/packages/core/test/sync-wire-scalars.test.ts b/packages/core/test/sync-wire-scalars.test.ts index a52c70a0fe..640c3a67ef 100644 --- a/packages/core/test/sync-wire-scalars.test.ts +++ b/packages/core/test/sync-wire-scalars.test.ts @@ -11,7 +11,6 @@ import { assertCanonicalKaId, assertCanonicalTimestampMs, parseCanonicalDecimalU256, - parseCanonicalNullableEvmAddressV1, } from '../src/sync-wire-scalars.js'; describe('RFC-64 sync wire scalar profile', () => { @@ -61,13 +60,6 @@ describe('RFC-64 sync wire scalar profile', () => { }); it('accepts only lowercase nonzero fixed-width EVM addresses', () => { - expect(parseCanonicalNullableEvmAddressV1(`0x${'00'.repeat(20)}`)).toBeNull(); - expect(parseCanonicalNullableEvmAddressV1(`0x${'11'.repeat(20)}`)).toBe( - `0x${'11'.repeat(20)}`, - ); - expect(() => parseCanonicalNullableEvmAddressV1(`0x${'AA'.repeat(20)}`)).toThrow( - /lowercase 20-byte/, - ); expect(() => assertCanonicalEvmAddress(`0x${'11'.repeat(20)}`)).not.toThrow(); expect(() => assertCanonicalEvmAddress(`0x${'AA'.repeat(20)}`)).toThrow( /lowercase 20-byte/, From 3d1e3abf81932b8787060bb0e7766fcf849df5cc Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 09:30:01 +0200 Subject: [PATCH 194/292] refactor(rfc64): model publish domain as value --- .../chain/src/finalized-context-graph-read.ts | 12 ++-- packages/core/src/cg-policy-objects.ts | 59 ++++++++++++++----- packages/core/test/cg-policy-objects.test.ts | 28 ++++++--- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts index c6f9ede261..dd602337f8 100644 --- a/packages/chain/src/finalized-context-graph-read.ts +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -5,11 +5,12 @@ import { assertCanonicalDigest, assertCanonicalEvmAddress, assertContextGraphAccessPolicyV1, - assertContextGraphPublishDomainV1, assertContextGraphPublishPolicyV1, + snapshotContextGraphPublishDomainV1, type BlockNumberV1, type ChainIdV1, type ContextGraphAccessPolicyV1, + type ContextGraphPublishDomainV1, type ContextGraphPublishPolicyV1, type DecimalU256V1, type Digest32V1, @@ -259,8 +260,9 @@ function composeValidatedFinalizedContextGraphReadV1( ); } const publishPolicy: ContextGraphPublishPolicyV1 = raw.publishPolicy; + let publishDomain: ContextGraphPublishDomainV1; try { - assertContextGraphPublishDomainV1( + publishDomain = snapshotContextGraphPublishDomainV1( publishPolicy, publishAuthority, publishAuthorityAccountId, @@ -288,9 +290,9 @@ function composeValidatedFinalizedContextGraphReadV1( owner, active: raw.active, accessPolicy, - publishPolicy, - publishAuthority, - publishAuthorityAccountId, + publishPolicy: publishDomain.publishPolicy, + publishAuthority: publishDomain.publishAuthority, + publishAuthorityAccountId: publishDomain.publishAuthorityAccountId, nameHash, }); } diff --git a/packages/core/src/cg-policy-objects.ts b/packages/core/src/cg-policy-objects.ts index 4a8f3eae7d..46757ec980 100644 --- a/packages/core/src/cg-policy-objects.ts +++ b/packages/core/src/cg-policy-objects.ts @@ -55,6 +55,17 @@ export type ContextGraphAccessPolicyV1 = (typeof CONTEXT_GRAPH_ACCESS_POLICY_VALUES_V1)[number]; export type ContextGraphPublishPolicyV1 = (typeof CONTEXT_GRAPH_PUBLISH_POLICY_VALUES_V1)[number]; +export type ContextGraphPublishDomainV1 = + | { + readonly publishPolicy: 1; + readonly publishAuthority: null; + readonly publishAuthorityAccountId: DecimalU256V1 & '0'; + } + | { + readonly publishPolicy: 0; + readonly publishAuthority: EvmAddressV1; + readonly publishAuthorityAccountId: DecimalU256V1; + }; export type MemberRosterRoleV1 = (typeof MEMBER_ROSTER_ROLES_V1)[number]; export interface FinalizedChainPolicySourceV1 { @@ -181,29 +192,49 @@ export function assertContextGraphPublishPolicyV1( * producer of ContextGraphPolicyV1 the same fail-closed domain check without * making core aware of a storage-specific sentinel representation. */ -export function assertContextGraphPublishDomainV1( +export function snapshotContextGraphPublishDomainV1( publishPolicy: unknown, publishAuthority: unknown, publishAuthorityAccountId: unknown, -): asserts publishPolicy is ContextGraphPublishPolicyV1 { +): ContextGraphPublishDomainV1 { assertContextGraphPublishPolicyV1(publishPolicy); - if (publishAuthority !== null) { - scalar(() => assertCanonicalEvmAddress(publishAuthority, 'publishAuthority')); - } - scalar(() => assertCanonicalDecimalU256( - publishAuthorityAccountId, - 'publishAuthorityAccountId', - )); + const authority = publishAuthority === null + ? null + : scalar(() => { + assertCanonicalEvmAddress(publishAuthority, 'publishAuthority'); + return publishAuthority; + }); + const authorityAccountId = scalar(() => { + assertCanonicalDecimalU256(publishAuthorityAccountId, 'publishAuthorityAccountId'); + return publishAuthorityAccountId; + }); if (publishPolicy === 1) { - if (publishAuthority !== null || publishAuthorityAccountId !== '0') { + if (authority !== null || !isCanonicalZeroDecimalU256(authorityAccountId)) { fail( 'cg-policy-publish-domain', 'open contribution requires null publishAuthority and account ID zero', ); } - } else if (publishAuthority === null) { + return Object.freeze({ + publishPolicy, + publishAuthority: null, + publishAuthorityAccountId: authorityAccountId, + }); + } + if (authority === null) { fail('cg-policy-publish-domain', 'curated contribution requires publishAuthority'); } + return Object.freeze({ + publishPolicy, + publishAuthority: authority, + publishAuthorityAccountId: authorityAccountId, + }); +} + +function isCanonicalZeroDecimalU256( + value: DecimalU256V1, +): value is DecimalU256V1 & '0' { + return value === '0'; } export function canonicalizeContextGraphPolicyPayloadV1( @@ -463,7 +494,7 @@ function assertContextGraphPolicyStructureV1( u64(value.version, 'version'); optionalDigest(value.previousPolicyDigest, 'previousPolicyDigest'); assertContextGraphAccessPolicyV1(value.accessPolicy); - assertContextGraphPublishDomainV1( + snapshotContextGraphPublishDomainV1( value.publishPolicy, value.publishAuthority, value.publishAuthorityAccountId, @@ -753,9 +784,9 @@ function u64(value: unknown, label: string): bigint { } } -function scalar(operation: () => void): void { +function scalar(operation: () => T): T { try { - operation(); + return operation(); } catch (cause) { fail('cg-policy-scalar', 'policy/roster scalar is not canonical', cause); } diff --git a/packages/core/test/cg-policy-objects.test.ts b/packages/core/test/cg-policy-objects.test.ts index a6bb287b9f..901290e494 100644 --- a/packages/core/test/cg-policy-objects.test.ts +++ b/packages/core/test/cg-policy-objects.test.ts @@ -7,7 +7,6 @@ import { MAX_MEMBER_ROSTER_ENTRIES_V1, MEMBER_ROSTER_ROLES_V1, MEMBER_ROSTER_OBJECT_TYPE_V1, - assertContextGraphPublishDomainV1, assertContextGraphPolicyV1, assertMemberRosterV1, assertSignedContextGraphPolicyEnvelopeV1, @@ -28,6 +27,7 @@ import { parseCanonicalSignedMemberRosterEnvelopeV1, parseCanonicalUnsignedContextGraphPolicyEnvelopeV1, parseCanonicalUnsignedMemberRosterEnvelopeV1, + snapshotContextGraphPublishDomainV1, type ContextGraphPolicyV1, type MemberRosterV1, } from '../src/cg-policy-objects.js'; @@ -174,17 +174,29 @@ describe('ContextGraphPolicyV1 codec', () => { }); it('owns the normalized contribution-policy tuple invariant', () => { - expect(() => assertContextGraphPublishDomainV1(1, null, '0')).not.toThrow(); - expect(() => assertContextGraphPublishDomainV1(0, ISSUER, '0')).not.toThrow(); - expect(() => assertContextGraphPublishDomainV1(0, ISSUER, '7')).not.toThrow(); + const open = snapshotContextGraphPublishDomainV1(1, null, '0'); + const curated = snapshotContextGraphPublishDomainV1(0, ISSUER, '7'); + expect(open).toEqual({ + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + }); + expect(curated).toEqual({ + publishPolicy: 0, + publishAuthority: ISSUER, + publishAuthorityAccountId: '7', + }); + expect(Object.isFrozen(open)).toBe(true); + expect(Object.isFrozen(curated)).toBe(true); + expect(() => snapshotContextGraphPublishDomainV1(0, ISSUER, '0')).not.toThrow(); - expect(() => assertContextGraphPublishDomainV1(1, ISSUER, '0')) + expect(() => snapshotContextGraphPublishDomainV1(1, ISSUER, '0')) .toThrow(/cg-policy-publish-domain/); - expect(() => assertContextGraphPublishDomainV1(1, null, '7')) + expect(() => snapshotContextGraphPublishDomainV1(1, null, '7')) .toThrow(/cg-policy-publish-domain/); - expect(() => assertContextGraphPublishDomainV1(0, null, '0')) + expect(() => snapshotContextGraphPublishDomainV1(0, null, '0')) .toThrow(/cg-policy-publish-domain/); - expect(() => assertContextGraphPublishDomainV1(0, `0x${'00'.repeat(20)}`, '0')) + expect(() => snapshotContextGraphPublishDomainV1(0, `0x${'00'.repeat(20)}`, '0')) .toThrow(/cg-policy-scalar/); }); From 5f1221ca54c3572c07af05f7b1af6d299c86534c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 09:06:25 +0200 Subject: [PATCH 195/292] feat(chain): add same-anchor finalized reads --- packages/chain/src/index.ts | 7 + .../src/strict-current-finalized-evm-rpc.ts | 305 +++++++++++++++--- ...ict-current-finalized-evm-rpc.unit.test.ts | 122 +++++++ 3 files changed, 391 insertions(+), 43 deletions(-) diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index c64f3de653..95776a8dce 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -36,8 +36,15 @@ export { } from './current-finalized-evm-call.js'; export { CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, + STRICT_CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + STRICT_CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, createStrictCurrentFinalizedEvmChainAdapterV1, + createStrictCurrentFinalizedEvmReadV1, type CurrentFinalizedEvmBlockReferenceProfileV1, + type StrictCurrentFinalizedEvmReadCallV1, + type StrictCurrentFinalizedEvmReadRequestV1, + type StrictCurrentFinalizedEvmReadResultV1, + type StrictCurrentFinalizedEvmReadV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from './strict-current-finalized-evm-rpc.js'; export { diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 7ca127b35d..676998478f 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -3,16 +3,19 @@ import { type BlockNumberV1, type ChainIdV1, type Digest32V1, + type EvmAddressV1, } from '@origintrail-official/dkg-core'; import { CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, CONTROL_EIP1271_GAS_LIMIT_V1, CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, CurrentFinalizedEvmCallErrorV1, - type CurrentFinalizedEvmCallRequestV1, - type CurrentFinalizedEvmCallResultV1, } from './control-object-signature-verifier.js'; import { snapshotCurrentFinalizedEvmCallRequestV1, @@ -39,6 +42,36 @@ export interface StrictCurrentFinalizedEvmRpcConfigV1 { readonly blockReferenceProfile?: CurrentFinalizedEvmBlockReferenceProfileV1; } +export const STRICT_CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 = 4; +// ContextGraphStorage.getContextGraph includes a participant array capped at +// 256 entries on chain; its maximal canonical ABI result is about 8.5 KiB. +export const STRICT_CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 = 16 * 1024; + +/** One trusted-local ABI call in a same-finalized-anchor read. */ +export interface StrictCurrentFinalizedEvmReadCallV1 { + readonly to: EvmAddressV1; + readonly data: string; + readonly maxReturnBytes: number; +} + +export interface StrictCurrentFinalizedEvmReadRequestV1 { + readonly chainId: ChainIdV1; + readonly calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; + readonly signal: AbortSignal; +} + +export interface StrictCurrentFinalizedEvmReadResultV1 { + readonly chainId: ChainIdV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly returnData: readonly string[]; +} + +export interface StrictCurrentFinalizedEvmReadV1 { + (request: StrictCurrentFinalizedEvmReadRequestV1): + Promise; +} + interface StrictRpcConfigSnapshotV1 { readonly chainId: ChainIdV1; readonly endpoints: readonly string[]; @@ -67,11 +100,14 @@ const CONFIG_OPTIONAL_KEYS = Object.freeze(['blockReferenceProfile'] as const); const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; const CANONICAL_LOWER_QUANTITY = /^0x(?:0|[1-9a-f][0-9a-f]*)$/; const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; +const CANONICAL_NONZERO_EVM_ADDRESS = /^0x(?!0{40}$)[0-9a-f]{40}$/; const MAX_U64 = 18_446_744_073_709_551_615n; const MAX_U256 = 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; const RPC_CALL_GAS_QUANTITY = `0x${CONTROL_EIP1271_GAS_LIMIT_V1.toString(16)}`; const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); +const READ_REQUEST_KEYS = Object.freeze(['calls', 'chainId', 'signal'] as const); +const READ_CALL_KEYS = Object.freeze(['data', 'maxReturnBytes', 'to'] as const); /** * Build one strict raw-JSON-RPC adapter from trusted local chain configuration. @@ -84,10 +120,46 @@ const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); export function createStrictCurrentFinalizedEvmChainAdapterV1( input: StrictCurrentFinalizedEvmRpcConfigV1, ): CurrentFinalizedEvmChainAdapterV1 { - const config = snapshotConfig(input); + const read = createStrictCurrentFinalizedEvmReadV1(input); const adapter: CurrentFinalizedEvmChainAdapterV1 = async (inputRequest) => { const request = snapshotCurrentFinalizedEvmCallRequestV1(inputRequest); + const result = await read({ + chainId: request.chainId, + calls: Object.freeze([Object.freeze({ + to: request.to, + data: request.data, + maxReturnBytes: request.maxReturnBytes, + })]), + signal: request.signal, + }); + const returnData = assertEip1271ReturnData(result.returnData[0]); + return Object.freeze({ + chainId: result.chainId, + blockNumber: result.blockNumber, + blockHash: result.blockHash, + returnData, + }); + }; + + return Object.freeze(adapter); +} + +/** + * Build a bounded, non-queueing same-finalized-anchor read primitive. + * + * Calls, destinations, and configured endpoints are trusted local runtime + * inputs. The primitive still snapshots them strictly, fixes gas/deadline/body + * caps, and never accepts a block selector from its caller. + */ +export function createStrictCurrentFinalizedEvmReadV1( + input: StrictCurrentFinalizedEvmRpcConfigV1, +): StrictCurrentFinalizedEvmReadV1 { + const config = snapshotConfig(input); + let activeReads = 0; + + const read: StrictCurrentFinalizedEvmReadV1 = async (inputRequest) => { + const request = snapshotReadRequest(inputRequest); if (request.chainId !== config.chainId) { throw new CurrentFinalizedEvmCallErrorV1( 'chain-mismatch', @@ -97,10 +169,17 @@ export function createStrictCurrentFinalizedEvmChainAdapterV1( if (request.signal.aborted) { throw cancelled('Current-finalized EVM call was cancelled before transport admission'); } + if (activeReads >= CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1) { + throw new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + `Chain ${request.chainId} already has ${activeReads} finalized reads in flight`, + ); + } + activeReads += 1; const totalDeadline = createDeadlineScope( request.signal, - request.totalDeadlineMs, + CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, 'current-finalized total deadline', ); let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; @@ -111,12 +190,14 @@ export function createStrictCurrentFinalizedEvmChainAdapterV1( throw cancelled('Current-finalized EVM call was cancelled'); } if (totalDeadline.timedOut()) { - throw timedOut(`Current-finalized total deadline exceeded ${request.totalDeadlineMs}ms`); + throw timedOut( + `Current-finalized total deadline exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`, + ); } const attemptDeadline = createDeadlineScope( totalDeadline.signal, - request.attemptTimeoutMs, + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, `current-finalized endpoint attempt ${index + 1}`, ); try { @@ -134,12 +215,12 @@ export function createStrictCurrentFinalizedEvmChainAdapterV1( } if (totalDeadline.timedOut()) { throw timedOut( - `Current-finalized total deadline exceeded ${request.totalDeadlineMs}ms`, + `Current-finalized total deadline exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`, ); } if (attemptDeadline.timedOut()) { throw timedOut( - `Current-finalized endpoint attempt exceeded ${request.attemptTimeoutMs}ms`, + `Current-finalized endpoint attempt exceeded ${CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1}ms`, ); } return result; @@ -161,27 +242,37 @@ export function createStrictCurrentFinalizedEvmChainAdapterV1( throw cancelled('Current-finalized EVM call was cancelled'); } if (totalDeadline.timedOut()) { - throw timedOut(`Current-finalized total deadline exceeded ${request.totalDeadlineMs}ms`); + throw timedOut( + `Current-finalized total deadline exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`, + ); } throw lastRetryableFailure ?? unavailable('No configured current-finalized endpoint succeeded'); } finally { totalDeadline.close(); + activeReads = Math.max(0, activeReads - 1); } }; - return Object.freeze(adapter); + return Object.freeze(read); } async function executeEndpointAttempt( config: StrictRpcConfigSnapshotV1, endpoint: string, - request: CurrentFinalizedEvmCallRequestV1, + request: StrictCurrentFinalizedEvmReadRequestV1, signal: AbortSignal, -): Promise { +): Promise { let requestId = 0; const rpc = async (method: string, params: readonly unknown[]): Promise => { requestId += 1; - return postJsonRpc(endpoint, requestId, method, params, request.maxRpcResponseBytes, signal); + return postJsonRpc( + endpoint, + requestId, + method, + params, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + signal, + ); }; const remoteChainId = parseChainId(await rpc('eth_chainId', Object.freeze([]))); @@ -196,42 +287,45 @@ async function executeEndpointAttempt( await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), 'current finalized header', ); - const callObject = Object.freeze({ - from: request.from, - to: request.to, - data: request.data, - gas: RPC_CALL_GAS_QUANTITY, - }); + const executeCallsAt = async (blockReference: unknown): Promise => { + const codeChecked = new Set(); + const results: string[] = []; + for (const call of request.calls) { + if (!codeChecked.has(call.to)) { + const code = await rpc('eth_getCode', Object.freeze([call.to, blockReference])); + assertDeployedCode(code); + codeChecked.add(call.to); + } + const callObject = Object.freeze({ + from: CONTROL_EIP1271_CALL_FROM_V1, + to: call.to, + data: call.data, + gas: RPC_CALL_GAS_QUANTITY, + }); + results.push(parseContractReturn( + await rpc('eth_call', Object.freeze([callObject, blockReference])), + call.maxReturnBytes, + )); + } + return Object.freeze(results); + }; - let returnData: string; + let returnData: readonly string[]; if (config.blockReferenceProfile === 'eip1898') { const blockReference = Object.freeze({ blockHash: anchor.blockHash, requireCanonical: true as const, }); - const code = await rpc('eth_getCode', Object.freeze([request.to, blockReference])); - assertDeployedCode(code); - returnData = parseContractReturn( - await rpc('eth_call', Object.freeze([callObject, blockReference])), - request.maxReturnBytes, - ); + returnData = await executeCallsAt(blockReference); } else { // Number-selected code/call evidence is not deterministic until the // same-endpoint post-read closes the hash sandwich. Hold anchor-dependent // outcomes until then, so a reorg cannot manufacture no-code/revert, a // malformed return, or an execution-cap failure and poison admission. let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - let provisionalReturnData: string | undefined; + let provisionalReturnData: readonly string[] | undefined; try { - const code = await rpc( - 'eth_getCode', - Object.freeze([request.to, anchor.blockNumberQuantity]), - ); - assertDeployedCode(code); - provisionalReturnData = parseContractReturn( - await rpc('eth_call', Object.freeze([callObject, anchor.blockNumberQuantity])), - request.maxReturnBytes, - ); + provisionalReturnData = await executeCallsAt(anchor.blockNumberQuantity); } catch (cause) { if ( cause instanceof CurrentFinalizedEvmCallErrorV1 @@ -263,7 +357,7 @@ async function executeEndpointAttempt( } if (anchorDependentFailure !== undefined) throw anchorDependentFailure; if (provisionalReturnData === undefined) { - throw unavailable('Block-number fallback produced no contract result'); + throw unavailable('Block-number fallback produced no contract results'); } returnData = provisionalReturnData; } @@ -429,7 +523,7 @@ function assertDeployedCode(input: unknown): void { if (input === '0x') { throw new CurrentFinalizedEvmCallErrorV1( 'no-code', - 'EIP-1271 issuer has no deployed code at the resolved finalized anchor', + 'Finalized-read target has no deployed code at the resolved anchor', ); } } @@ -438,11 +532,22 @@ function parseContractReturn(input: unknown, maxBytes: number): string { if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { throw new CurrentFinalizedEvmCallErrorV1( 'malformed-return', - 'EIP-1271 eth_call returned malformed bytes', + 'Finalized eth_call returned malformed bytes', ); } const byteLength = (input.length - 2) / 2; - if (byteLength > maxBytes || byteLength !== 32) { + if (byteLength > maxBytes) { + throw new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + `Finalized eth_call returned ${byteLength} bytes; limit ${maxBytes}`, + ); + } + return input; +} + +function assertEip1271ReturnData(input: string | undefined): string { + if (input === undefined || (input.length - 2) / 2 !== CONTROL_EIP1271_MAX_RETURN_BYTES_V1) { + const byteLength = input === undefined ? 0 : (input.length - 2) / 2; throw new CurrentFinalizedEvmCallErrorV1( 'malformed-return', `EIP-1271 eth_call returned ${byteLength} bytes; exactly 32 are required`, @@ -481,12 +586,12 @@ function classifyJsonRpcError( // the message with gas-related text (for example, code 3 plus // "execution reverted: out of gas"). A fixed-cap exhaustion that did not // execute a REVERT remains a resource refusal below, but a proven REVERT - // must win so callers cannot misclassify invalid EIP-1271 evidence as merely + // must win so callers cannot misclassify invalid execution evidence as merely // unsupported. if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { return new CurrentFinalizedEvmCallErrorV1( 'revert', - 'EIP-1271 contract call reverted at the resolved finalized anchor', + 'Contract call reverted at the resolved finalized anchor', ); } if ( @@ -500,7 +605,7 @@ function classifyJsonRpcError( ) ) { return anchorDependentResourceLimited( - 'EIP-1271 execution could not complete within the fixed gas cap', + 'Finalized contract execution could not complete within the fixed gas cap', ); } if (message.includes('timeout') || message.includes('timed out')) { @@ -572,6 +677,120 @@ function createDeadlineScope( }); } +function snapshotReadRequest(input: unknown): StrictCurrentFinalizedEvmReadRequestV1 { + try { + const record = snapshotExactDataRecord(input, READ_REQUEST_KEYS); + assertCanonicalChainId(record.chainId, 'strict finalized-read chainId'); + if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); + return Object.freeze({ + chainId: record.chainId, + calls: snapshotReadCalls(record.calls), + signal: record.signal, + }); + } catch { + throw unavailable('Strict current-finalized read request failed the fixed local profile'); + } +} + +function snapshotReadCalls(input: unknown): readonly StrictCurrentFinalizedEvmReadCallV1[] { + if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { + throw new Error('finalized read calls must be an ordinary array'); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); + if ( + lengthDescriptor === undefined + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 1 + || lengthDescriptor.value > STRICT_CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 + ) { + throw new Error('finalized read call count is outside the fixed profile'); + } + const length = lengthDescriptor.value; + const keys = Reflect.ownKeys(input); + if ( + keys.length !== length + 1 + || keys.some((key) => ( + typeof key !== 'string' + || (key !== 'length' && !isCanonicalArrayIndex(key, length)) + )) + ) { + throw new Error('finalized read calls must be a dense ordinary array'); + } + + const calls: StrictCurrentFinalizedEvmReadCallV1[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('finalized read calls must contain data properties'); + } + const record = snapshotExactDataRecord(descriptor.value, READ_CALL_KEYS); + if ( + typeof record.to !== 'string' + || !CANONICAL_NONZERO_EVM_ADDRESS.test(record.to) + ) { + throw new Error(`finalized read calls[${index}].to is not canonical`); + } + if ( + typeof record.data !== 'string' + || !/^0x[0-9a-f]{8}(?:[0-9a-f]{2})*$/.test(record.data) + ) { + throw new Error(`finalized read calls[${index}].data is not canonical ABI calldata`); + } + if ( + typeof record.maxReturnBytes !== 'number' + || !Number.isSafeInteger(record.maxReturnBytes) + || record.maxReturnBytes < 1 + || record.maxReturnBytes > STRICT_CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 + ) { + throw new Error(`finalized read calls[${index}].maxReturnBytes is outside the fixed cap`); + } + calls.push(Object.freeze({ + to: record.to as EvmAddressV1, + data: record.data, + maxReturnBytes: record.maxReturnBytes, + })); + } + return Object.freeze(calls); +} + +function snapshotExactDataRecord( + input: unknown, + expectedKeys: readonly string[], +): Record { + if (!isPlainRecord(input)) throw new Error('not a plain record'); + const actualKeys = Reflect.ownKeys(input); + if ( + actualKeys.some((key) => typeof key !== 'string') + || actualKeys.length !== expectedKeys.length + || (actualKeys as string[]).sort().some((key, index) => key !== expectedKeys[index]) + ) { + throw new Error('unknown or missing fields'); + } + const snapshot: Record = Object.create(null) as Record; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('fields must be enumerable data properties'); + } + snapshot[key] = descriptor.value; + } + return snapshot; +} + +function isAbortSignal(value: unknown): value is AbortSignal { + if (value === null || typeof value !== 'object') return false; + try { + const getter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + if (getter === undefined) return false; + getter.call(value); + return true; + } catch { + return false; + } +} + function snapshotConfig(input: StrictCurrentFinalizedEvmRpcConfigV1): StrictRpcConfigSnapshotV1 { if (!isPlainRecord(input)) { throw new TypeError('Strict current-finalized RPC config must be a plain data record'); diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 5f7255c6cd..28cd343240 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -21,6 +21,7 @@ import { } from '../src/control-object-signature-verifier.js'; import { createStrictCurrentFinalizedEvmChainAdapterV1, + createStrictCurrentFinalizedEvmReadV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from '../src/strict-current-finalized-evm-rpc.js'; @@ -33,6 +34,8 @@ const OBJECT_DIGEST = `${'33'.repeat(32)}`; const CANONICAL_CALL_DATA = `0x1626ba7e${OBJECT_DIGEST}${'0'.repeat(62)}40${'0'.repeat(63)}1aa${'0'.repeat(62)}`; const MAGIC_RETURN = `0x1626ba7e${'00'.repeat(28)}`; const WRONG_MAGIC_RETURN = `0xffffffff${'00'.repeat(28)}`; +const FIRST_READ_DATA = '0x11111111'; +const SECOND_READ_DATA = '0x22222222'; interface JsonRpcRequest { readonly jsonrpc: '2.0'; @@ -60,6 +63,125 @@ afterEach(async () => { }); describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { + it('executes multiple ABI reads at one EIP-1898 anchor and checks shared code once', async () => { + const server = await startRpcServer((call, response) => { + switch (call.method) { + case 'eth_chainId': + sendResult(response, call, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': + sendResult(response, call, { number: '0x7b', hash: BLOCK_HASH }); + return; + case 'eth_getCode': + sendResult(response, call, '0x6000'); + return; + case 'eth_call': { + const callObject = call.params[0] as { readonly data?: unknown }; + sendResult( + response, + call, + callObject.data === FIRST_READ_DATA ? '0xaaaa' : '0xbbbbcc', + ); + return; + } + default: + sendError(response, call, -32601, 'method not found'); + } + }); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + + const result = await read({ + chainId: CHAIN_ID, + calls: [ + { to: TO, data: FIRST_READ_DATA, maxReturnBytes: 2 }, + { to: TO, data: SECOND_READ_DATA, maxReturnBytes: 3 }, + ], + signal: new AbortController().signal, + }); + + expect(Object.isFrozen(read)).toBe(true); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.returnData)).toBe(true); + expect(result).toEqual({ + chainId: CHAIN_ID, + blockNumber: '123', + blockHash: BLOCK_HASH, + returnData: ['0xaaaa', '0xbbbbcc'], + }); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_call', + ]); + const hashReference = { blockHash: BLOCK_HASH, requireCanonical: true }; + expect(server.calls[2]!.params).toEqual([TO, hashReference]); + expect(server.calls[3]!.params[1]).toEqual(hashReference); + expect(server.calls[4]!.params[1]).toEqual(hashReference); + }); + + it('closes one hash sandwich after every call in a multi-read fallback', async () => { + const server = await startRpcServer(successfulHandler()); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(read({ + chainId: CHAIN_ID, + calls: [ + { to: TO, data: FIRST_READ_DATA, maxReturnBytes: 32 }, + { to: TO, data: SECOND_READ_DATA, maxReturnBytes: 32 }, + ], + signal: new AbortController().signal, + })).resolves.toMatchObject({ + blockNumber: '123', + blockHash: BLOCK_HASH, + returnData: [MAGIC_RETURN, MAGIC_RETURN], + }); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_call', + 'eth_getBlockByNumber', + ]); + expect(server.calls[3]!.params[1]).toBe('0x7b'); + expect(server.calls[4]!.params[1]).toBe('0x7b'); + expect(server.calls[5]!.params).toEqual(['0x7b', false]); + }); + + it('rejects a fifth concurrent generic read without queueing it', async () => { + const gate = deferred(); + const baseHandler = successfulHandler(); + const server = await startRpcServer(async (call, response, request) => { + if (call.method === 'eth_chainId') await gate.promise; + await baseHandler(call, response, request); + }); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + const request = () => ({ + chainId: CHAIN_ID, + calls: [{ to: TO, data: FIRST_READ_DATA, maxReturnBytes: 32 }], + signal: new AbortController().signal, + }); + const active = Array.from({ length: 4 }, () => read(request())); + + await expect(read(request())).rejects.toMatchObject({ + code: 'concurrency-saturated', + }); + gate.resolve(undefined); + await expect(Promise.all(active)).resolves.toHaveLength(4); + }); + it('uses the configured endpoint once, in exact EIP-1898 request order and shape', async () => { const server = await startRpcServer(successfulHandler()); const configuredEndpoints = [server.url]; From 06c3eac5d4d436199d768df75a5e5e340defadf1 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 09:32:24 +0200 Subject: [PATCH 196/292] fix(rfc64): harden finalized RPC transport --- .../src/control-object-signature-verifier.ts | 76 ++++---- .../chain/src/current-finalized-evm-call.ts | 111 ++--------- .../src/current-finalized-evm-read-profile.ts | 56 ++++++ packages/chain/src/index.ts | 14 +- .../src/strict-current-finalized-evm-rpc.ts | 181 +++++++----------- packages/chain/src/strict-local-data.ts | 120 ++++++++++++ ...ict-current-finalized-evm-rpc.unit.test.ts | 119 ++++++++++++ 7 files changed, 420 insertions(+), 257 deletions(-) create mode 100644 packages/chain/src/current-finalized-evm-read-profile.ts create mode 100644 packages/chain/src/strict-local-data.ts diff --git a/packages/chain/src/control-object-signature-verifier.ts b/packages/chain/src/control-object-signature-verifier.ts index 895f99111a..34b21d40ab 100644 --- a/packages/chain/src/control-object-signature-verifier.ts +++ b/packages/chain/src/control-object-signature-verifier.ts @@ -14,20 +14,43 @@ import { } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; +import { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, + CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, + CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, + CurrentFinalizedEvmCallErrorV1, + type CurrentFinalizedEvmCallErrorCodeV1, +} from './current-finalized-evm-read-profile.js'; + +export { + CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, + CurrentFinalizedEvmCallErrorV1, + type CurrentFinalizedEvmCallErrorCodeV1, +} from './current-finalized-evm-read-profile.js'; + export const EIP1271_MAGIC_VALUE_V1 = '0x1626ba7e' as const; export const EIP1271_CANONICAL_ABI_RETURN_V1 = `0x${EIP1271_MAGIC_VALUE_V1.slice(2)}${'00'.repeat(28)}` as const; -export const CONTROL_EIP1271_GAS_LIMIT_V1 = 1_000_000n; +export const CONTROL_EIP1271_GAS_LIMIT_V1 = CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1; export const CONTROL_EIP1271_MAX_RETURN_BYTES_V1 = 32; -export const CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1 = 64 * 1024; -export const CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 = 4_000; -export const CONTROL_EIP1271_MAX_ATTEMPTS_V1 = 2; -export const CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1 = 10_000; -export const CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 = 4; +export const CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1 = + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1; +export const CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 = + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1; +export const CONTROL_EIP1271_MAX_ATTEMPTS_V1 = CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1; +export const CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1 = + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1; +export const CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 = + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1; export const CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1 = - 'distinct-configured-endpoints-no-same-endpoint-retry' as const; -export const CONTROL_EIP1271_CALL_FROM_V1 = - '0x0000000000000000000000000000000000000000' as const; + CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1; +export const CONTROL_EIP1271_CALL_FROM_V1 = CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1; const SECP256K1_N = BigInt( '0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', @@ -40,41 +63,6 @@ const EIP1271_INTERFACE = new ethers.Interface([ ]); declare const VERIFIED_CONTROL_ENVELOPE_ISSUER_SIGNATURE_BRAND_V1: unique symbol; -export const CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1 = Object.freeze([ - 'unsupported-chain', - 'chain-mismatch', - 'finalized-state-unavailable', - 'rpc-unavailable', - 'rpc-timeout', - 'concurrency-saturated', - 'resource-limit', - 'revert', - 'no-code', - 'malformed-return', -] as const); - -export type CurrentFinalizedEvmCallErrorCodeV1 = - (typeof CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1)[number]; - -/** Closed failure vocabulary implemented by the finalized-state RPC gateway. */ -export class CurrentFinalizedEvmCallErrorV1 extends Error { - readonly code: CurrentFinalizedEvmCallErrorCodeV1; - - constructor( - code: CurrentFinalizedEvmCallErrorCodeV1, - message: string, - options: { readonly cause?: unknown } = {}, - ) { - if (!CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1.includes(code)) { - throw new TypeError(`Unsupported current-finalized EVM call error code: ${String(code)}`); - } - super(message, options.cause === undefined ? undefined : { cause: options.cause }); - this.name = 'CurrentFinalizedEvmCallErrorV1'; - this.code = code; - Object.freeze(this); - } -} - export interface CurrentFinalizedEvmCallRequestV1 { readonly chainId: ChainIdV1; readonly to: EvmAddressV1; diff --git a/packages/chain/src/current-finalized-evm-call.ts b/packages/chain/src/current-finalized-evm-call.ts index 3a68047859..a30c81289a 100644 --- a/packages/chain/src/current-finalized-evm-call.ts +++ b/packages/chain/src/current-finalized-evm-call.ts @@ -13,12 +13,20 @@ import { CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, - CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, - CurrentFinalizedEvmCallErrorV1, type CurrentFinalizedEvmCallRequestV1, type CurrentFinalizedEvmCallResultV1, type CurrentFinalizedEvmCallV1, } from './control-object-signature-verifier.js'; +import { + CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import { + assertCanonicalNonzeroEvmAddress, + isAbortSignal, + snapshotDenseDataArray, + snapshotExactDataRecord, +} from './strict-local-data.js'; const REQUEST_KEYS = Object.freeze([ 'attemptTimeoutMs', @@ -36,7 +44,6 @@ const REQUEST_KEYS = Object.freeze([ 'to', 'totalDeadlineMs', ] as const); -const CANONICAL_NONZERO_EVM_ADDRESS = /^0x(?!0{40}$)[0-9a-f]{40}$/; /** * Per-chain trusted-local adapter seam. A later transport implementation owns @@ -123,54 +130,14 @@ function snapshotAdapterRegistry( function snapshotDenseArray(input: unknown): readonly unknown[] { try { - if (!Array.isArray(input)) throw new Error('not an array'); - const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); - if ( - lengthDescriptor === undefined - || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') - || typeof lengthDescriptor.value !== 'number' - || !Number.isSafeInteger(lengthDescriptor.value) - || lengthDescriptor.value < 0 - ) { - throw new Error('invalid array length'); - } - - const length = lengthDescriptor.value; - const ownKeys = Reflect.ownKeys(input); - if ( - ownKeys.length !== length + 1 - || ownKeys.some((key) => ( - typeof key !== 'string' - || (key !== 'length' && !isCanonicalArrayIndex(key, length)) - )) - ) { - throw new Error('registrations must be a dense ordinary array'); - } - - const snapshot: unknown[] = []; - for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); - if ( - descriptor === undefined - || !descriptor.enumerable - || !Object.prototype.hasOwnProperty.call(descriptor, 'value') - ) { - throw new Error('registration entry must be an enumerable data property'); - } - snapshot.push(descriptor.value); - } - return Object.freeze(snapshot); + return snapshotDenseDataArray(input, { + label: 'Current-finalized adapter registrations', + }); } catch { throw new TypeError('Current-finalized adapter registrations must be a dense data-only array'); } } -function isCanonicalArrayIndex(key: string, length: number): boolean { - if (!/^(?:0|[1-9][0-9]*)$/.test(key)) return false; - const index = Number(key); - return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key; -} - function snapshotRegistration(input: unknown): Readonly { let chainId: unknown; let adapter: unknown; @@ -203,12 +170,7 @@ export function snapshotCurrentFinalizedEvmCallRequestV1( try { const record = snapshotExactDataRecord(input, REQUEST_KEYS); assertCanonicalChainId(record.chainId, 'current-finalized request chainId'); - if ( - typeof record.to !== 'string' - || !CANONICAL_NONZERO_EVM_ADDRESS.test(record.to) - ) { - throw new Error('to is not a canonical nonzero EVM address'); - } + assertCanonicalNonzeroEvmAddress(record.to, 'current-finalized request to'); if (record.from !== CONTROL_EIP1271_CALL_FROM_V1) throw new Error('wrong from'); if (typeof record.data !== 'string') throw new Error('call data is not a string'); assertCanonicalEip1271CallData(record.data); @@ -300,51 +262,6 @@ function assertCanonicalEip1271CallData(data: string): void { } } -function snapshotExactDataRecord( - input: unknown, - expectedKeys: readonly string[], -): Record { - if (input === null || typeof input !== 'object' || Array.isArray(input)) { - throw new Error('not a record'); - } - const prototype = Object.getPrototypeOf(input); - if (prototype !== Object.prototype && prototype !== null) throw new Error('not plain'); - const actualKeys = Reflect.ownKeys(input); - if ( - actualKeys.some((key) => typeof key !== 'string') - || actualKeys.length !== expectedKeys.length - || (actualKeys as string[]).sort().some((key, index) => key !== expectedKeys[index]) - ) { - throw new Error('unknown or missing fields'); - } - - const snapshot: Record = Object.create(null) as Record; - for (const key of expectedKeys) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if ( - descriptor === undefined - || !descriptor.enumerable - || !Object.prototype.hasOwnProperty.call(descriptor, 'value') - ) { - throw new Error('fields must be enumerable data properties'); - } - snapshot[key] = descriptor.value; - } - return snapshot; -} - -function isAbortSignal(value: unknown): value is AbortSignal { - if (value === null || typeof value !== 'object') return false; - try { - const abortedGetter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; - if (abortedGetter === undefined) return false; - abortedGetter.call(value); - return true; - } catch { - return false; - } -} - function snapshotAdapterFailure(cause: unknown): CurrentFinalizedEvmCallErrorV1 { try { if (cause instanceof CurrentFinalizedEvmCallErrorV1) { diff --git a/packages/chain/src/current-finalized-evm-read-profile.ts b/packages/chain/src/current-finalized-evm-read-profile.ts new file mode 100644 index 0000000000..bc5b3485a4 --- /dev/null +++ b/packages/chain/src/current-finalized-evm-read-profile.ts @@ -0,0 +1,56 @@ +/** + * Canonical trusted-local transport profile for current-finalized EVM reads. + * + * These limits belong to the generic finalized-read boundary. EIP-1271 is one + * specialization of this profile; changing signature-verification policy must + * not implicitly redefine unrelated finalized reads. + */ +export const CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1 = + '0x0000000000000000000000000000000000000000' as const; +export const CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1 = 1_000_000n; +export const CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1 = 64 * 1024; +export const CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1 = 4_000; +export const CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1 = 2; +export const CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1 = 10_000; +export const CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1 = 4; +export const CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1 = + 'distinct-configured-endpoints-no-same-endpoint-retry' as const; +export const CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 = 4; +// ContextGraphStorage.getContextGraph includes a participant array capped at +// 256 entries on chain; its maximal canonical ABI result is about 8.5 KiB. +export const CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 = 16 * 1024; + +export const CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1 = Object.freeze([ + 'unsupported-chain', + 'chain-mismatch', + 'finalized-state-unavailable', + 'rpc-unavailable', + 'rpc-timeout', + 'concurrency-saturated', + 'resource-limit', + 'revert', + 'no-code', + 'malformed-return', +] as const); + +export type CurrentFinalizedEvmCallErrorCodeV1 = + (typeof CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1)[number]; + +/** Closed failure vocabulary implemented by the finalized-state RPC gateway. */ +export class CurrentFinalizedEvmCallErrorV1 extends Error { + readonly code: CurrentFinalizedEvmCallErrorCodeV1; + + constructor( + code: CurrentFinalizedEvmCallErrorCodeV1, + message: string, + options: { readonly cause?: unknown } = {}, + ) { + if (!CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1.includes(code)) { + throw new TypeError(`Unsupported current-finalized EVM call error code: ${String(code)}`); + } + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'CurrentFinalizedEvmCallErrorV1'; + this.code = code; + Object.freeze(this); + } +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 95776a8dce..08ec48599a 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -29,6 +29,18 @@ export { type VerifiedControlEnvelopeIssuerSignatureSnapshotV1, type VerifyControlEnvelopeIssuerSignatureOptionsV1, } from './control-object-signature-verifier.js'; +export { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, + CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, +} from './current-finalized-evm-read-profile.js'; export { createCurrentFinalizedEvmCallRouterV1, type CurrentFinalizedEvmChainAdapterRegistrationV1, @@ -36,8 +48,6 @@ export { } from './current-finalized-evm-call.js'; export { CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, - STRICT_CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - STRICT_CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, type CurrentFinalizedEvmBlockReferenceProfileV1, diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 676998478f..15acab7354 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -7,20 +7,30 @@ import { } from '@origintrail-official/dkg-core'; import { - CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, - CONTROL_EIP1271_CALL_FROM_V1, - CONTROL_EIP1271_GAS_LIMIT_V1, - CONTROL_EIP1271_MAX_ATTEMPTS_V1, - CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, - CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, - CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, - CurrentFinalizedEvmCallErrorV1, } from './control-object-signature-verifier.js'; +import { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, + CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; import { snapshotCurrentFinalizedEvmCallRequestV1, type CurrentFinalizedEvmChainAdapterV1, } from './current-finalized-evm-call.js'; +import { + assertCanonicalNonzeroEvmAddress, + isAbortSignal, + snapshotDenseDataArray, + snapshotExactDataRecord, +} from './strict-local-data.js'; export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ 'eip1898', @@ -42,11 +52,6 @@ export interface StrictCurrentFinalizedEvmRpcConfigV1 { readonly blockReferenceProfile?: CurrentFinalizedEvmBlockReferenceProfileV1; } -export const STRICT_CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 = 4; -// ContextGraphStorage.getContextGraph includes a participant array capped at -// 256 entries on chain; its maximal canonical ABI result is about 8.5 KiB. -export const STRICT_CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 = 16 * 1024; - /** One trusted-local ABI call in a same-finalized-anchor read. */ export interface StrictCurrentFinalizedEvmReadCallV1 { readonly to: EvmAddressV1; @@ -100,11 +105,10 @@ const CONFIG_OPTIONAL_KEYS = Object.freeze(['blockReferenceProfile'] as const); const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; const CANONICAL_LOWER_QUANTITY = /^0x(?:0|[1-9a-f][0-9a-f]*)$/; const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; -const CANONICAL_NONZERO_EVM_ADDRESS = /^0x(?!0{40}$)[0-9a-f]{40}$/; const MAX_U64 = 18_446_744_073_709_551_615n; const MAX_U256 = 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; -const RPC_CALL_GAS_QUANTITY = `0x${CONTROL_EIP1271_GAS_LIMIT_V1.toString(16)}`; +const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); const READ_REQUEST_KEYS = Object.freeze(['calls', 'chainId', 'signal'] as const); const READ_CALL_KEYS = Object.freeze(['data', 'maxReturnBytes', 'to'] as const); @@ -169,7 +173,7 @@ export function createStrictCurrentFinalizedEvmReadV1( if (request.signal.aborted) { throw cancelled('Current-finalized EVM call was cancelled before transport admission'); } - if (activeReads >= CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1) { + if (activeReads >= CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1) { throw new CurrentFinalizedEvmCallErrorV1( 'concurrency-saturated', `Chain ${request.chainId} already has ${activeReads} finalized reads in flight`, @@ -179,7 +183,7 @@ export function createStrictCurrentFinalizedEvmReadV1( const totalDeadline = createDeadlineScope( request.signal, - CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, 'current-finalized total deadline', ); let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; @@ -191,13 +195,13 @@ export function createStrictCurrentFinalizedEvmReadV1( } if (totalDeadline.timedOut()) { throw timedOut( - `Current-finalized total deadline exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`, + `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, ); } const attemptDeadline = createDeadlineScope( totalDeadline.signal, - CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, `current-finalized endpoint attempt ${index + 1}`, ); try { @@ -215,12 +219,12 @@ export function createStrictCurrentFinalizedEvmReadV1( } if (totalDeadline.timedOut()) { throw timedOut( - `Current-finalized total deadline exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`, + `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, ); } if (attemptDeadline.timedOut()) { throw timedOut( - `Current-finalized endpoint attempt exceeded ${CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1}ms`, + `Current-finalized endpoint attempt exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, ); } return result; @@ -243,7 +247,7 @@ export function createStrictCurrentFinalizedEvmReadV1( } if (totalDeadline.timedOut()) { throw timedOut( - `Current-finalized total deadline exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`, + `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, ); } throw lastRetryableFailure ?? unavailable('No configured current-finalized endpoint succeeded'); @@ -270,7 +274,7 @@ async function executeEndpointAttempt( requestId, method, params, - CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, signal, ); }; @@ -288,26 +292,24 @@ async function executeEndpointAttempt( 'current finalized header', ); const executeCallsAt = async (blockReference: unknown): Promise => { - const codeChecked = new Set(); - const results: string[] = []; - for (const call of request.calls) { - if (!codeChecked.has(call.to)) { - const code = await rpc('eth_getCode', Object.freeze([call.to, blockReference])); - assertDeployedCode(code); - codeChecked.add(call.to); - } + const uniqueTargets = [...new Set(request.calls.map(({ to }) => to))]; + await settleParallelBatch(uniqueTargets.map(async (to) => { + const code = await rpc('eth_getCode', Object.freeze([to, blockReference])); + assertDeployedCode(code); + })); + + return settleParallelBatch(request.calls.map(async (call) => { const callObject = Object.freeze({ - from: CONTROL_EIP1271_CALL_FROM_V1, + from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, to: call.to, data: call.data, gas: RPC_CALL_GAS_QUANTITY, }); - results.push(parseContractReturn( + return parseContractReturn( await rpc('eth_call', Object.freeze([callObject, blockReference])), call.maxReturnBytes, - )); - } - return Object.freeze(results); + ); + })); }; let returnData: readonly string[]; @@ -370,6 +372,22 @@ async function executeEndpointAttempt( }); } +/** + * Execute one bounded phase concurrently but retain the permit until every + * started operation settles. This prevents an early rejection from leaving a + * sibling fetch alive after the finalized-read concurrency slot is released. + */ +async function settleParallelBatch(operations: readonly Promise[]): Promise { + const settled = await Promise.allSettled(operations); + const values: T[] = []; + for (let index = 0; index < settled.length; index += 1) { + const result = settled[index]!; + if (result.status === 'rejected') throw result.reason; + values.push(result.value); + } + return Object.freeze(values); +} + async function postJsonRpc( endpoint: string, id: number, @@ -634,10 +652,10 @@ function classifyAttemptFailure( ): CurrentFinalizedEvmCallErrorV1 { if (callerSignal.aborted) return cancelled('Current-finalized EVM call was cancelled'); if (totalDeadline.timedOut()) { - return timedOut(`Current-finalized total deadline exceeded ${CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1}ms`); + return timedOut(`Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`); } if (attemptDeadline.timedOut()) { - return timedOut(`Current-finalized endpoint attempt exceeded ${CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1}ms`); + return timedOut(`Current-finalized endpoint attempt exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`); } if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; return unavailable('Current-finalized endpoint attempt failed closed', cause); @@ -693,45 +711,16 @@ function snapshotReadRequest(input: unknown): StrictCurrentFinalizedEvmReadReque } function snapshotReadCalls(input: unknown): readonly StrictCurrentFinalizedEvmReadCallV1[] { - if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { - throw new Error('finalized read calls must be an ordinary array'); - } - const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); - if ( - lengthDescriptor === undefined - || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') - || typeof lengthDescriptor.value !== 'number' - || !Number.isSafeInteger(lengthDescriptor.value) - || lengthDescriptor.value < 1 - || lengthDescriptor.value > STRICT_CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 - ) { - throw new Error('finalized read call count is outside the fixed profile'); - } - const length = lengthDescriptor.value; - const keys = Reflect.ownKeys(input); - if ( - keys.length !== length + 1 - || keys.some((key) => ( - typeof key !== 'string' - || (key !== 'length' && !isCanonicalArrayIndex(key, length)) - )) - ) { - throw new Error('finalized read calls must be a dense ordinary array'); - } + const entries = snapshotDenseDataArray(input, { + label: 'Current-finalized read calls', + minLength: 1, + maxLength: CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + }); const calls: StrictCurrentFinalizedEvmReadCallV1[] = []; - for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - throw new Error('finalized read calls must contain data properties'); - } - const record = snapshotExactDataRecord(descriptor.value, READ_CALL_KEYS); - if ( - typeof record.to !== 'string' - || !CANONICAL_NONZERO_EVM_ADDRESS.test(record.to) - ) { - throw new Error(`finalized read calls[${index}].to is not canonical`); - } + for (let index = 0; index < entries.length; index += 1) { + const record = snapshotExactDataRecord(entries[index], READ_CALL_KEYS); + assertCanonicalNonzeroEvmAddress(record.to, `current-finalized read calls[${index}].to`); if ( typeof record.data !== 'string' || !/^0x[0-9a-f]{8}(?:[0-9a-f]{2})*$/.test(record.data) @@ -742,7 +731,7 @@ function snapshotReadCalls(input: unknown): readonly StrictCurrentFinalizedEvmRe typeof record.maxReturnBytes !== 'number' || !Number.isSafeInteger(record.maxReturnBytes) || record.maxReturnBytes < 1 - || record.maxReturnBytes > STRICT_CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 + || record.maxReturnBytes > CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 ) { throw new Error(`finalized read calls[${index}].maxReturnBytes is outside the fixed cap`); } @@ -755,42 +744,6 @@ function snapshotReadCalls(input: unknown): readonly StrictCurrentFinalizedEvmRe return Object.freeze(calls); } -function snapshotExactDataRecord( - input: unknown, - expectedKeys: readonly string[], -): Record { - if (!isPlainRecord(input)) throw new Error('not a plain record'); - const actualKeys = Reflect.ownKeys(input); - if ( - actualKeys.some((key) => typeof key !== 'string') - || actualKeys.length !== expectedKeys.length - || (actualKeys as string[]).sort().some((key, index) => key !== expectedKeys[index]) - ) { - throw new Error('unknown or missing fields'); - } - const snapshot: Record = Object.create(null) as Record; - for (const key of expectedKeys) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - throw new Error('fields must be enumerable data properties'); - } - snapshot[key] = descriptor.value; - } - return snapshot; -} - -function isAbortSignal(value: unknown): value is AbortSignal { - if (value === null || typeof value !== 'object') return false; - try { - const getter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; - if (getter === undefined) return false; - getter.call(value); - return true; - } catch { - return false; - } -} - function snapshotConfig(input: StrictCurrentFinalizedEvmRpcConfigV1): StrictRpcConfigSnapshotV1 { if (!isPlainRecord(input)) { throw new TypeError('Strict current-finalized RPC config must be a plain data record'); @@ -871,9 +824,9 @@ function snapshotNormalizedEndpoints(input: unknown): readonly string[] { if (cause instanceof TypeError) throw cause; throw new TypeError('Strict current-finalized endpoints must be a dense data-only array'); } - if (normalized.length === 0 || normalized.length > CONTROL_EIP1271_MAX_ATTEMPTS_V1) { + if (normalized.length === 0 || normalized.length > CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { throw new TypeError( - `Strict current-finalized RPC requires 1..${CONTROL_EIP1271_MAX_ATTEMPTS_V1} distinct endpoints`, + `Strict current-finalized RPC requires 1..${CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1} distinct endpoints`, ); } return Object.freeze(normalized); diff --git a/packages/chain/src/strict-local-data.ts b/packages/chain/src/strict-local-data.ts new file mode 100644 index 0000000000..95da2a43b9 --- /dev/null +++ b/packages/chain/src/strict-local-data.ts @@ -0,0 +1,120 @@ +import { type EvmAddressV1 } from '@origintrail-official/dkg-core'; + +const CANONICAL_NONZERO_EVM_ADDRESS = /^0x(?!0{40}$)[0-9a-f]{40}$/; + +export interface DenseDataArraySnapshotOptions { + readonly label: string; + readonly minLength?: number; + readonly maxLength?: number; +} + +/** Snapshot an exact, plain, enumerable data-only record without invoking accessors. */ +export function snapshotExactDataRecord( + input: unknown, + expectedKeys: readonly string[], +): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('not a record'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) throw new Error('not plain'); + const actualKeys = Reflect.ownKeys(input); + if ( + actualKeys.some((key) => typeof key !== 'string') + || actualKeys.length !== expectedKeys.length + || (actualKeys as string[]).sort().some((key, index) => key !== expectedKeys[index]) + ) { + throw new Error('unknown or missing fields'); + } + + const snapshot: Record = Object.create(null) as Record; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new Error('fields must be enumerable data properties'); + } + snapshot[key] = descriptor.value; + } + return snapshot; +} + +/** Snapshot a dense ordinary array without iteration or inherited property reads. */ +export function snapshotDenseDataArray( + input: unknown, + options: DenseDataArraySnapshotOptions, +): readonly unknown[] { + if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { + throw new Error(`${options.label} must be an ordinary array`); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); + if ( + lengthDescriptor === undefined + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < (options.minLength ?? 0) + || ( + options.maxLength !== undefined + && lengthDescriptor.value > options.maxLength + ) + ) { + throw new Error(`${options.label} length is outside the accepted range`); + } + + const length = lengthDescriptor.value; + const ownKeys = Reflect.ownKeys(input); + if ( + ownKeys.length !== length + 1 + || ownKeys.some((key) => ( + typeof key !== 'string' + || (key !== 'length' && !isCanonicalArrayIndex(key, length)) + )) + ) { + throw new Error(`${options.label} must be dense and data-only`); + } + + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new Error(`${options.label} entries must be enumerable data properties`); + } + snapshot.push(descriptor.value); + } + return Object.freeze(snapshot); +} + +export function isAbortSignal(value: unknown): value is AbortSignal { + if (value === null || typeof value !== 'object') return false; + try { + const abortedGetter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + if (abortedGetter === undefined) return false; + abortedGetter.call(value); + return true; + } catch { + return false; + } +} + +export function assertCanonicalNonzeroEvmAddress( + value: unknown, + label: string, +): asserts value is EvmAddressV1 { + if (typeof value !== 'string' || !CANONICAL_NONZERO_EVM_ADDRESS.test(value)) { + throw new Error(`${label} must be a canonical lowercase nonzero EVM address`); + } +} + +function isCanonicalArrayIndex(key: string, length: number): boolean { + if (!/^(?:0|[1-9][0-9]*)$/.test(key)) return false; + const index = Number(key); + return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key; +} diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 28cd343240..a0508a3067 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -19,6 +19,16 @@ import { CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, type CurrentFinalizedEvmCallRequestV1, } from '../src/control-object-signature-verifier.js'; +import { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, + CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, +} from '../src/current-finalized-evm-read-profile.js'; import { createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, @@ -28,6 +38,7 @@ import { const CHAIN_ID = '20430' as ChainIdV1; const CHAIN_QUANTITY = '0x4fce'; const TO = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const OTHER_TO = '0x2222222222222222222222222222222222222222' as EvmAddressV1; const BLOCK_HASH = `0x${'22'.repeat(32)}`; const OTHER_BLOCK_HASH = `0x${'23'.repeat(32)}`; const OBJECT_DIGEST = `${'33'.repeat(32)}`; @@ -63,6 +74,23 @@ afterEach(async () => { }); describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { + it('keeps the EIP-1271 specialization pinned to the generic finalized-read profile', () => { + expect(CONTROL_EIP1271_CALL_FROM_V1).toBe(CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1); + expect(CONTROL_EIP1271_GAS_LIMIT_V1).toBe(CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1); + expect(CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1) + .toBe(CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1); + expect(CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1) + .toBe(CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1); + expect(CONTROL_EIP1271_MAX_ATTEMPTS_V1) + .toBe(CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1); + expect(CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1) + .toBe(CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1); + expect(CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1) + .toBe(CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1); + expect(CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1) + .toBe(CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1); + }); + it('executes multiple ABI reads at one EIP-1898 anchor and checks shared code once', async () => { const server = await startRpcServer((call, response) => { switch (call.method) { @@ -157,6 +185,97 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { expect(server.calls[5]!.params).toEqual(['0x7b', false]); }); + it('checks every distinct target before executing any call', async () => { + const server = await startRpcServer((call, response) => { + switch (call.method) { + case 'eth_chainId': + sendResult(response, call, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': + sendResult(response, call, { number: '0x7b', hash: BLOCK_HASH }); + return; + case 'eth_getCode': + sendResult(response, call, call.params[0] === TO ? '0x6000' : '0x'); + return; + case 'eth_call': + sendResult(response, call, MAGIC_RETURN); + return; + default: + sendError(response, call, -32601, 'method not found'); + } + }); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + + await expect(read({ + chainId: CHAIN_ID, + calls: [ + { to: TO, data: FIRST_READ_DATA, maxReturnBytes: 32 }, + { to: OTHER_TO, data: SECOND_READ_DATA, maxReturnBytes: 32 }, + ], + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'no-code' }); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_getCode', + ]); + expect(server.calls[2]!.params[0]).toBe(TO); + expect(server.calls[3]!.params[0]).toBe(OTHER_TO); + }); + + it('rejects an oversized generic return only after a stable fallback sandwich', async () => { + const server = await startRpcServer(successfulHandler({ returnData: '0xaaaaaa' })); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(read({ + chainId: CHAIN_ID, + calls: [{ to: TO, data: FIRST_READ_DATA, maxReturnBytes: 2 }], + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'malformed-return' }); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getBlockByNumber', + ]); + }); + + it('fails hostile generic read shapes closed before transport', async () => { + const server = await startRpcServer(successfulHandler()); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + const validCall = { to: TO, data: FIRST_READ_DATA, maxReturnBytes: 32 }; + const sparse = new Array(1); + const accessor = { to: TO, data: FIRST_READ_DATA } as Record; + Object.defineProperty(accessor, 'maxReturnBytes', { + enumerable: true, + get() { + throw new Error('must not execute'); + }, + }); + + for (const request of [ + { chainId: CHAIN_ID, calls: sparse, signal: new AbortController().signal }, + { chainId: CHAIN_ID, calls: [accessor], signal: new AbortController().signal }, + { chainId: CHAIN_ID, calls: [validCall], signal: {} }, + { chainId: CHAIN_ID, calls: [validCall], signal: new AbortController().signal, rpcUrl: server.url }, + ]) { + await expect(read(request as never)).rejects.toMatchObject({ code: 'rpc-unavailable' }); + } + expect(server.calls).toHaveLength(0); + }); + it('rejects a fifth concurrent generic read without queueing it', async () => { const gate = deferred(); const baseHandler = successfulHandler(); From 074f7e61259fcef7f02a92c028c262942cd9c0af Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 09:42:29 +0200 Subject: [PATCH 197/292] fix(chain): make exact record keys order-independent --- packages/chain/src/strict-local-data.ts | 6 +++++- .../chain/test/strict-local-data.unit.test.ts | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/chain/test/strict-local-data.unit.test.ts diff --git a/packages/chain/src/strict-local-data.ts b/packages/chain/src/strict-local-data.ts index 95da2a43b9..81fe53483f 100644 --- a/packages/chain/src/strict-local-data.ts +++ b/packages/chain/src/strict-local-data.ts @@ -18,11 +18,15 @@ export function snapshotExactDataRecord( } const prototype = Object.getPrototypeOf(input); if (prototype !== Object.prototype && prototype !== null) throw new Error('not plain'); + const expectedKeySet = new Set(expectedKeys); + if (expectedKeySet.size !== expectedKeys.length) { + throw new Error('expected keys must be unique'); + } const actualKeys = Reflect.ownKeys(input); if ( actualKeys.some((key) => typeof key !== 'string') || actualKeys.length !== expectedKeys.length - || (actualKeys as string[]).sort().some((key, index) => key !== expectedKeys[index]) + || (actualKeys as string[]).some((key) => !expectedKeySet.has(key)) ) { throw new Error('unknown or missing fields'); } diff --git a/packages/chain/test/strict-local-data.unit.test.ts b/packages/chain/test/strict-local-data.unit.test.ts new file mode 100644 index 0000000000..3cfcabc171 --- /dev/null +++ b/packages/chain/test/strict-local-data.unit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { snapshotExactDataRecord } from '../src/strict-local-data.js'; + +describe('strict local data helpers', () => { + it('matches an exact record independently of expected-key order', () => { + const snapshot = snapshotExactDataRecord({ a: 1, b: 2 }, ['b', 'a']); + expect(Object.getPrototypeOf(snapshot)).toBeNull(); + expect(snapshot).toEqual({ a: 1, b: 2 }); + }); + + it('rejects duplicate expected keys and still rejects missing or unknown fields', () => { + expect(() => snapshotExactDataRecord({ a: 1 }, ['a', 'a'])) + .toThrow(/expected keys must be unique/); + expect(() => snapshotExactDataRecord({ a: 1 }, ['a', 'b'])) + .toThrow(/unknown or missing fields/); + expect(() => snapshotExactDataRecord({ a: 1, b: 2 }, ['a'])) + .toThrow(/unknown or missing fields/); + }); +}); From a33634e1426cc32dd25ebfa81b6edc790cc76664 Mon Sep 17 00:00:00 2001 From: Jurij89 <138491694+Jurij89@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:48:08 -0400 Subject: [PATCH 198/292] Re-land #1883: job-scoped terminal cleanup for publisher + SWM share queues (#1837) (#1910) Reverts commit 8d9fc2130, which was itself a revert of #1883's merge (10ff3cf39), restoring the fully-converged #1837 terminal-cleanup work. #1883 was reverted while staging the 10.0.10 Day-One canary candidate (#1903), in the same batch as unrelated SYNC reverts (e.g. #1895 catchup-backpressure-retry). #1837 is publisher-only and has nothing to do with the sync work that batch targeted, so its revert appears collateral. This restores the reviewed + CI-green capability to clear one terminal async-publisher (lift) or SWM share (promote) job by exact job ID. Restores 29 files (+1041/-22); the revert-of-revert applies with zero conflicts on current testnet-canary. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/agent/src/dkg-agent-base.ts | 6 +- packages/agent/src/dkg-agent.ts | 8 +- .../test/clear-promote-async-facade.test.ts | 81 ++++++++ packages/agent/vitest.unit.config.ts | 1 + packages/cli/src/api-client.ts | 15 ++ .../routes/knowledge-assets-async-share.ts | 12 ++ .../cli/src/daemon/routes/knowledge-assets.ts | 16 ++ packages/cli/src/daemon/routes/publisher.ts | 24 +++ .../daemon/routes/shared-assertion-helpers.ts | 14 +- .../daemon/routes/terminal-clear-response.ts | 28 +++ packages/cli/src/publisher-runner.ts | 3 +- packages/cli/test/api-client.test.ts | 16 ++ .../cli/test/promote-async-routes.test.ts | 48 +++++ .../test/publisher-clear-job-route.test.ts | 163 +++++++++++++++ .../cli/test/terminal-clear-response.test.ts | 55 +++++ packages/cli/vitest.unit.config.ts | 1 + .../src/async-lift-publisher-impl.ts | 80 ++++++-- .../src/async-lift-publisher-types.ts | 13 ++ .../src/async-lift-publisher-utils.ts | 13 ++ .../publisher/src/async-lift-publisher.ts | 2 + .../publisher/src/async-promote-queue-impl.ts | 57 ++++- .../src/async-promote-queue-types.ts | 14 ++ .../src/async-promote-queue-utils.ts | 14 ++ packages/publisher/src/async-promote-queue.ts | 1 + packages/publisher/src/index.ts | 7 + packages/publisher/src/terminal-job-clear.ts | 36 ++++ .../test/async-lift-terminal-clear.test.ts | 194 ++++++++++++++++++ .../test/async-promote-terminal-clear.test.ts | 139 +++++++++++++ packages/publisher/vitest.unit.config.ts | 2 + 29 files changed, 1041 insertions(+), 22 deletions(-) create mode 100644 packages/agent/test/clear-promote-async-facade.test.ts create mode 100644 packages/cli/src/daemon/routes/terminal-clear-response.ts create mode 100644 packages/cli/test/publisher-clear-job-route.test.ts create mode 100644 packages/cli/test/terminal-clear-response.test.ts create mode 100644 packages/publisher/src/terminal-job-clear.ts create mode 100644 packages/publisher/test/async-lift-terminal-clear.test.ts create mode 100644 packages/publisher/test/async-promote-terminal-clear.test.ts diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index 96c25adf20..c0fd71ce63 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -115,6 +115,7 @@ import { FileWorkspacePublicSnapshotStore, parseWorkspacePublicSnapshotNQuads, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, + type PromoteTerminalJobClearer, type PromoteJob, type PromoteListFilter, wrapAsRpcPreconditionIfApplicable, type PublishOptions, type PublishResult, type PhaseCallback, type KAMetadata, type CASCondition, @@ -554,7 +555,10 @@ export class DKGAgentBase { * getter so the worker (a daemon-side concern) and tests can drive * the queue directly without going through the assertion subsurface. */ - protected _promoteQueue?: AsyncPromoteQueue; + // Typed with the terminal-clear capability at the ownership boundary (not cast at the + // getter): the only assigned value is `TripleStoreAsyncPromoteQueue`, which implements it, + // and any test/subclass substituting a queue must now satisfy the clearer at compile time. + protected _promoteQueue?: AsyncPromoteQueue & PromoteTerminalJobClearer; /** * Override for tests / future operator config. When set before * `promoteQueue` is first accessed, the queue is constructed with diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 812b51f617..cf228a8335 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -107,6 +107,7 @@ import { FileWorkspacePublicSnapshotStore, parseWorkspacePublicSnapshotNQuads, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, + type PromoteTerminalJobClearer, type TerminalJobClearOutcome, type PromoteJob, type PromoteListFilter, wrapAsRpcPreconditionIfApplicable, resolveStorageAckTiming, @@ -2968,6 +2969,11 @@ export class DKGAgent extends DKGAgentBase { async recoverPromoteAsync(jobId: string): Promise { return agent.promoteQueue.recover(jobId); }, + // #1837 — atomic by-jobId terminal clear (record removal). Distinct from + // cancelPromoteAsync (queued abort, retains the row). + async clearPromoteAsync(jobId: string): Promise { + return agent.promoteQueue.clearTerminalJob(jobId); + }, }; } @@ -2983,7 +2989,7 @@ export class DKGAgent extends DKGAgentBase { * `recordCommitMarker` / `recoverOnStartup`) without the assertion * subsurface having to leak those methods to user-facing callers. */ - get promoteQueue(): AsyncPromoteQueue { + get promoteQueue(): AsyncPromoteQueue & PromoteTerminalJobClearer { if (!this._promoteQueue) { this._promoteQueue = new TripleStoreAsyncPromoteQueue(this.store, this._promoteQueueConfig ?? {}); } diff --git a/packages/agent/test/clear-promote-async-facade.test.ts b/packages/agent/test/clear-promote-async-facade.test.ts new file mode 100644 index 0000000000..4f83cb7cb1 --- /dev/null +++ b/packages/agent/test/clear-promote-async-facade.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { + TripleStoreAsyncPromoteQueue, + type PromoteRequest, +} from '@origintrail-official/dkg-publisher'; +import { DKGAgent } from '../src/dkg-agent.js'; + +// #1837 — verifies the PRODUCTION DKGAgent facade wiring between +// `agent.assertion.clearPromoteAsync` and `promoteQueue.clearTerminalJob`, driving a REAL +// promote queue. The SWM route tests stub `clearPromoteAsync` onto a fake agent, so they +// cover the route + queue contract but NOT this delegation: a regression that pointed the +// facade at `cancel()` (which retains the row) or any other queue method would leave those +// route tests green yet be caught here. +describe('DKGAgent assertion.clearPromoteAsync facade wiring (#1837)', () => { + const stores: OxigraphStore[] = []; + afterEach(async () => { + await Promise.all(stores.splice(0).map((s) => s.close().catch(() => {}))); + }); + + function makeRequest(overrides: Partial = {}): PromoteRequest { + return { contextGraphId: 'graphify', subGraphName: 'code', assertionName: 'shard-1', entities: 'all', ...overrides }; + } + + function newQueue(): TripleStoreAsyncPromoteQueue { + const store = new OxigraphStore(); + stores.push(store); + let id = 0; + return new TripleStoreAsyncPromoteQueue(store, { now: () => 1_000_000, idGenerator: () => `job-${++id}` }); + } + + // Real DKGAgent facade over an injected real queue — `agent.assertion.clearPromoteAsync` + // routes through the public `promoteQueue` getter, exactly as production does. + // `defaultAgentAddress` is set so the `assertion` getter's `this.defaultAgentAddress ?? + // this.peerId` resolves without touching the unbuilt libp2p `node` (mirrors the sibling + // promote-async-default-agent facade test). + function agentFor(queue: TripleStoreAsyncPromoteQueue): { assertion: { clearPromoteAsync(jobId: string): Promise } } { + const agent = Object.create(DKGAgent.prototype) as { + _promoteQueue: TripleStoreAsyncPromoteQueue; + defaultAgentAddress: string; + }; + agent.defaultAgentAddress = `0x${'11'.repeat(20)}`; + agent._promoteQueue = queue; + return agent as unknown as { assertion: { clearPromoteAsync(jobId: string): Promise } }; + } + + async function driveToSucceeded(queue: TripleStoreAsyncPromoteQueue): Promise { + const jobId = await queue.enqueue(makeRequest()); + const claimed = await queue.claimNext('worker-1'); + const token = claimed!.lease!.claimToken; + for (const marker of ['swmInserted', 'wmCleaned', 'lifecycleStamped', 'gossiped'] as const) { + await queue.recordCommitMarker(jobId, token, marker); + } + await queue.succeed(jobId, token, { promotedCount: 1, succeededAt: 1_000_000 }); + return jobId; + } + + it('clears a terminal job through the real facade, removes only that row, and is idempotent', async () => { + const queue = newQueue(); + const target = await driveToSucceeded(queue); + const other = await driveToSucceeded(queue); + const agent = agentFor(queue); + + // cleared — and the row is actually gone (a mis-delegation to cancel() would retain it). + expect(await agent.assertion.clearPromoteAsync(target)).toEqual({ outcome: 'cleared' }); + expect(await queue.getStatus(target)).toBeNull(); + expect((await queue.getStatus(other))?.state).toBe('succeeded'); // only the exact row cleared + + // Idempotent repeat via the facade. + expect(await agent.assertion.clearPromoteAsync(target)).toEqual({ outcome: 'already_absent' }); + }); + + it('rejects a nonterminal (queued) job through the facade without mutation', async () => { + const queue = newQueue(); + const queued = await queue.enqueue(makeRequest()); + const agent = agentFor(queue); + + expect(await agent.assertion.clearPromoteAsync(queued)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(queued))?.state).toBe('queued'); // untouched + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index 107e88a784..ae68dfc21d 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "test/publish-foreign-author-resolution.test.ts", "test/durable-integrity-seal-assertion-version.test.ts", "test/promote-async-default-agent.test.ts", + "test/clear-promote-async-facade.test.ts", "test/query-min-trust-alias.test.ts", "test/sync-envelope-cursor.test.ts", "test/exact-assets.test.ts", diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index 0c02b3b9e0..167f63a0b9 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -798,6 +798,13 @@ export class ApiClient { return this.post(`/api/knowledge-assets/swm/share-jobs/${encodeURIComponent(jobId)}/recover`, {}); } + // #1837 — atomic by-exact-jobId TERMINAL record removal (idempotent). Distinct from + // knowledgeAssetCancelShareJob (DELETE = queued cancellation that retains the row). + // cleared / already_absent resolve normally (200); rejected throws via post(). + async knowledgeAssetClearShareJob(jobId: string): Promise<{ outcome: 'cleared' | 'already_absent'; jobId: string }> { + return this.post(`/api/knowledge-assets/swm/share-jobs/${encodeURIComponent(jobId)}/clear`, {}); + } + /** Publish to VM (mint or update on chain; git push origin main). */ async knowledgeAssetPublish( contextGraphId: string, @@ -1279,6 +1286,14 @@ export class ApiClient { return this.post('/api/publisher/clear', { status }); } + // #1837 — atomic by-exact-jobId TERMINAL clear (distinct from publisherCancel and the + // status-scoped publisherClear). cleared / already_absent resolve normally (200); + // rejected (nonterminal/unknown → 409, malformed → 400) throws via the post() helper + // carrying { outcome:'rejected', reason } in the response body. + async publisherClearJob(jobId: string): Promise<{ outcome: 'cleared' | 'already_absent'; jobId: string }> { + return this.post('/api/publisher/clear-job', { jobId }); + } + // ------------------------- EPCIS ------------------------------------- async captureEpcis(request: { diff --git a/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts b/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts index d34173b7c4..1de92087bd 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets-async-share.ts @@ -23,6 +23,7 @@ // Shared logic lives in `./shared-assertion-helpers.js`. import type { RequestContext } from "./context.js"; +import { respondTerminalClearOutcome } from "./terminal-clear-response.js"; import { jsonResponse, readBody, @@ -238,3 +239,14 @@ export async function handleKaShareJobRecover(ctx: RequestContext, jobId: string throw err; } } + +// ── POST /api/knowledge-assets/swm/share-jobs/:jobId/clear ──────────────────── +// +// #1837 — atomic by-exact-jobId TERMINAL record removal. DISTINCT from the DELETE +// cancellation above (which rewrites a queued job to failed+cancelled and RETAINS the +// row): this REMOVES a native-terminal (succeeded|failed) job record and is idempotent +// (already_absent = 200, not 404). The caller passes the already url-decoded jobId. +export async function handleKaShareJobClear(ctx: RequestContext, jobId: string): Promise { + const { res, agent } = ctx; + return respondTerminalClearOutcome(res, await agent.assertion.clearPromoteAsync(jobId), jobId); +} diff --git a/packages/cli/src/daemon/routes/knowledge-assets.ts b/packages/cli/src/daemon/routes/knowledge-assets.ts index cde6495b35..c15f3b7d95 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets.ts @@ -62,6 +62,7 @@ import { handleKaShareJobsList, handleKaShareJobStatus, handleKaShareJobCancel, + handleKaShareJobClear, handleKaShareJobRecover, } from "./knowledge-assets-async-share.js"; import { @@ -667,6 +668,21 @@ export async function handleKnowledgeAssetsRoutes(ctx: RequestContext): Promise< if (jobId === null) return; return handleKaShareJobRecover(ctx, jobId); } + // POST /api/knowledge-assets/swm/share-jobs/:jobId/clear — #1837 atomic terminal + // record removal (idempotent). DISTINCT from the DELETE cancellation below (which + // rewrites a queued job to failed+cancelled and RETAINS the row). + if ( + method === "POST" && + path.startsWith(`${SHARE_JOBS_PREFIX}/`) && + path.endsWith("/clear") + ) { + const jobId = decodePromoteJobId( + path.slice(`${SHARE_JOBS_PREFIX}/`.length, -"/clear".length), + res, + ); + if (jobId === null) return; + return handleKaShareJobClear(ctx, jobId); + } // GET /api/knowledge-assets/swm/share-jobs/:jobId — status (#3) if ( method === "GET" && diff --git a/packages/cli/src/daemon/routes/publisher.ts b/packages/cli/src/daemon/routes/publisher.ts index 72c5b9f8f3..7d868146c4 100644 --- a/packages/cli/src/daemon/routes/publisher.ts +++ b/packages/cli/src/daemon/routes/publisher.ts @@ -186,6 +186,7 @@ import { bindingValue, carryForwardBundledMarkItDownBinary, } from '../manifest.js'; +import { respondTerminalClearOutcome } from './terminal-clear-response.js'; import { resolveNameToPeerId, jsonResponse, @@ -545,4 +546,27 @@ export async function handlePublisherRoutes(ctx: RequestContext): Promise const count = await publisherControl.clear(status); return jsonResponse(res, 200, { cleared: count, status }); } + + // POST /api/publisher/clear-job { jobId } + // #1837 — atomic by-exact-jobId TERMINAL clear. DISTINCT from cancel (which aborts an + // ACCEPTED job) and from bulk /clear (status-scoped): clears exactly one job iff it is + // in a native terminal state, is idempotent for an absent job (already_absent = 200, + // NOT 404), and never touches another job. Preserves the #1829 journal (subject-scoped). + if (req.method === "POST" && path === "/api/publisher/clear-job") { + const body = await readBody(req, SMALL_BODY_BYTES); + let clearJobParsed: any; + try { + clearJobParsed = JSON.parse(body || "{}"); + } catch { + return jsonResponse(res, 400, { error: "Invalid JSON body" }); + } + // `JSON.parse("null")` etc. succeeds but yields a non-object; optional-chain so a + // `null`/primitive body falls through to the malformed guard (400), never a + // destructure TypeError → 500. + const jobId = clearJobParsed && typeof clearJobParsed === "object" ? clearJobParsed.jobId : undefined; + if (typeof jobId !== "string" || jobId.trim().length === 0) { + return jsonResponse(res, 400, { outcome: "rejected", reason: "malformed", error: "Missing jobId" }); + } + return respondTerminalClearOutcome(res, await publisherControl.clearTerminalJob(jobId), jobId); + } } diff --git a/packages/cli/src/daemon/routes/shared-assertion-helpers.ts b/packages/cli/src/daemon/routes/shared-assertion-helpers.ts index 76a287b5c2..f0a42d2d6a 100644 --- a/packages/cli/src/daemon/routes/shared-assertion-helpers.ts +++ b/packages/cli/src/daemon/routes/shared-assertion-helpers.ts @@ -20,7 +20,12 @@ import { resolveImportedArtifactMetadata, assertRdfLiteralMutf8Safe, } from '@origintrail-official/dkg-core'; -import { type PromoteJob, type PromoteJobState } from '@origintrail-official/dkg-publisher'; +import { + type PromoteJob, + type PromoteJobState, + SAFE_CLEAR_JOB_ID_PATTERN, + SAFE_CLEAR_JOB_ID_MAX_LENGTH, +} from '@origintrail-official/dkg-publisher'; import { daemonState } from '../state.js'; import { jsonResponse, @@ -116,9 +121,12 @@ export class ImportArtifactRouteError extends Error { } export function validatePromoteJobId(jobId: string): { valid: true } | { valid: false; reason: string } { + // Grammar + length bound are imported from the publisher (the single authoritative + // job-id contract, also enforced control-plane-side by `isSafeClearJobId`) so route + // acceptance and control-plane acceptance cannot drift. if (!jobId) return { valid: false, reason: "jobId is required" }; - if (jobId.length > 256) return { valid: false, reason: "jobId is too long" }; - if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(jobId)) { + if (jobId.length > SAFE_CLEAR_JOB_ID_MAX_LENGTH) return { valid: false, reason: "jobId is too long" }; + if (!SAFE_CLEAR_JOB_ID_PATTERN.test(jobId)) { return { valid: false, reason: "jobId may only contain letters, numbers, '.', '_', ':', and '-'", diff --git a/packages/cli/src/daemon/routes/terminal-clear-response.ts b/packages/cli/src/daemon/routes/terminal-clear-response.ts new file mode 100644 index 0000000000..4bfe8c0274 --- /dev/null +++ b/packages/cli/src/daemon/routes/terminal-clear-response.ts @@ -0,0 +1,28 @@ +// daemon/routes/terminal-clear-response.ts +// +// #1837 — single source for the TerminalJobClearOutcome → HTTP projection, shared by the +// publisher (`POST /api/publisher/clear-job`) and SWM share-job +// (`POST /api/knowledge-assets/swm/share-jobs/:jobId/clear`) clear routes so the response +// contract cannot drift. This is route-owned policy (both clear routes are the only +// callers), so it lives beside the routes rather than in the generic `http-utils.ts` +// bucket, which stays focused on protocol-level helpers (JSON responses, body parsing). +// +// Mapping: `cleared` / `already_absent` are SUCCESS (200, idempotent — NOT 404); +// `rejected(malformed)` is a client input error (400); `rejected(nonterminal|unknown)` is a +// server-side state condition (409). + +import type { ServerResponse } from "node:http"; +import type { TerminalJobClearOutcome } from "@origintrail-official/dkg-publisher"; +import { jsonResponse } from "../http-utils.js"; + +export function respondTerminalClearOutcome( + res: ServerResponse, + outcome: TerminalJobClearOutcome, + jobId: string, +): void { + if (outcome.outcome === "cleared" || outcome.outcome === "already_absent") { + jsonResponse(res, 200, { ...outcome, jobId }); + return; + } + jsonResponse(res, outcome.reason === "malformed" ? 400 : 409, { ...outcome, jobId }); +} diff --git a/packages/cli/src/publisher-runner.ts b/packages/cli/src/publisher-runner.ts index 866afebdba..d23e4f1b51 100644 --- a/packages/cli/src/publisher-runner.ts +++ b/packages/cli/src/publisher-runner.ts @@ -33,6 +33,7 @@ import { type VmPublishIntentRecoveryPublisher, type VmPublishIntentIndexBackfiller, type VmPublishAdmissionJournalReader, + type VmPublishTerminalJobClearer, type LiftJobBroadcast, type LiftJobHex, type LiftJobIncluded, @@ -374,7 +375,7 @@ export function createPublisherInspectorFromStore( export function createPublisherControlFromStore( store: TripleStore, options: { publicSnapshotStore?: WorkspacePublicSnapshotStore; maxRetries?: number } = {}, -): VmPublishIntentRecoveryPublisher & VmPublishIntentIndexBackfiller & VmPublishAdmissionJournalReader { +): VmPublishIntentRecoveryPublisher & VmPublishIntentIndexBackfiller & VmPublishAdmissionJournalReader & VmPublishTerminalJobClearer { // The daemon admission instance also serves the #1828 recovery lookup (route) // and the boot index backfill — segregated capabilities the base // AsyncLiftPublisher runtime contract intentionally does NOT carry. diff --git a/packages/cli/test/api-client.test.ts b/packages/cli/test/api-client.test.ts index e0f822665b..18211caeb2 100644 --- a/packages/cli/test/api-client.test.ts +++ b/packages/cli/test/api-client.test.ts @@ -1120,6 +1120,22 @@ describe('ApiClient — GitHub-shaped knowledge-assets SDK (OT-RFC-43 §10.5)', expect(calls[0].opts.method).toBe('POST'); }); + it('#1837 clear-job helpers POST the terminal-clear routes with correct encoding and body', async () => { + // SWM share-job clear: jobId is percent-encoded in the path, empty JSON body. + let calls = track({ outcome: 'cleared', jobId: 'share/job 1' }); + await client.knowledgeAssetClearShareJob('share/job 1'); + expect(calls[0].url).toBe(`${base}/api/knowledge-assets/swm/share-jobs/share%2Fjob%201/clear`); + expect(calls[0].opts.method).toBe('POST'); + expect(JSON.parse(calls[0].opts.body as string)).toEqual({}); + + // Publisher clear-job: jobId travels in the body, not the path. + calls = track({ outcome: 'already_absent', jobId: 'lift job 7' }); + await client.publisherClearJob('lift job 7'); + expect(calls[0].url).toBe(`${base}/api/publisher/clear-job`); + expect(calls[0].opts.method).toBe('POST'); + expect(JSON.parse(calls[0].opts.body as string)).toEqual({ jobId: 'lift job 7' }); + }); + it('knowledgeAssetShareAsync rejects unsupported sync-only options before HTTP serialization', async () => { const calls = track({ jobId: 'should-not-reach', state: 'queued' }); await expect(client.knowledgeAssetShareAsync('cg', 'f', { diff --git a/packages/cli/test/promote-async-routes.test.ts b/packages/cli/test/promote-async-routes.test.ts index 3cf2247b98..f2e3215f72 100644 --- a/packages/cli/test/promote-async-routes.test.ts +++ b/packages/cli/test/promote-async-routes.test.ts @@ -118,6 +118,9 @@ describe('async SWM-share queue daemon routes', () => { async recoverPromoteAsync(jobId: string): Promise { return queue.recover(jobId); }, + async clearPromoteAsync(jobId: string) { + return (queue as TripleStoreAsyncPromoteQueue).clearTerminalJob(jobId); + }, }, }; } @@ -561,6 +564,51 @@ describe('async SWM-share queue daemon routes', () => { expect(r.body.error).toMatch(/Invalid promote jobId/); }); + // --------------------------------------------------------------------------- + // POST /api/knowledge-assets/swm/share-jobs/:jobId/clear (#1837) + // Atomic terminal record removal — DISTINCT from DELETE (cancel). Idempotent: + // already_absent is 200, not 404. + // --------------------------------------------------------------------------- + + it('POST /swm/share-jobs/:jobId/clear clears a terminal job → 200 cleared; repeat → 200 already_absent', async () => { + await startRoutes(makeAgent()); + const enq = await post('/api/knowledge-assets/clearable/swm/share-async', { contextGraphId: 'cg' }); + await del(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}`); // cancel → terminal 'failed' + expect((await queue.getStatus(enq.body.jobId))?.state).toBe('failed'); + + const r = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ outcome: 'cleared' }); + expect(await queue.getStatus(enq.body.jobId)).toBeNull(); + + const again = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); + expect(again.status).toBe(200); + expect(again.body).toMatchObject({ outcome: 'already_absent' }); + }); + + it('POST /swm/share-jobs/:jobId/clear returns 409 for a nonterminal (queued) job, without mutation', async () => { + await startRoutes(makeAgent()); + const enq = await post('/api/knowledge-assets/queued2/swm/share-async', { contextGraphId: 'cg' }); + const r = await post(`/api/knowledge-assets/swm/share-jobs/${enq.body.jobId}/clear`, {}); + expect(r.status).toBe(409); + expect(r.body).toMatchObject({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(enq.body.jobId))?.state).toBe('queued'); + }); + + it('POST /swm/share-jobs/:jobId/clear returns 200 already_absent for an unknown job (idempotent)', async () => { + await startRoutes(makeAgent()); + const r = await post('/api/knowledge-assets/swm/share-jobs/non-existent/clear', {}); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ outcome: 'already_absent' }); + }); + + it('POST /swm/share-jobs/:jobId/clear rejects an unsafe path value before queue lookup (400)', async () => { + await startRoutes(makeAgent()); + const r = await post('/api/knowledge-assets/swm/share-jobs/job%3Ebad/clear', {}); + expect(r.status).toBe(400); + expect(r.body.error).toMatch(/Invalid promote jobId/); + }); + // --------------------------------------------------------------------------- // Routing precedence — the list route (`/swm/share-jobs`) must not be // claimed by the per-job route (`/swm/share-jobs/:jobId`). diff --git a/packages/cli/test/publisher-clear-job-route.test.ts b/packages/cli/test/publisher-clear-job-route.test.ts new file mode 100644 index 0000000000..975ca69793 --- /dev/null +++ b/packages/cli/test/publisher-clear-job-route.test.ts @@ -0,0 +1,163 @@ +import type { ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { afterEach, describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { createPublisherControlFromStore } from '../src/publisher-runner.js'; +import { handlePublisherRoutes } from '../src/daemon/routes/publisher.js'; +import type { RequestContext } from '../src/daemon/routes/context.js'; + +// #1837 — POST /api/publisher/clear-job outcome → HTTP mapping. +describe('#1837 POST /api/publisher/clear-job', () => { + const stores: OxigraphStore[] = []; + let ids = 0; + let now = 1_000; + afterEach(async () => { + await Promise.all(stores.splice(0).map((s) => s.close().catch(() => {}))); + }); + + function kaVmPublishRequest() { + const authorAddress = '0x1111111111111111111111111111111111111111'; + const kaNumber = 7n; + const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; + return { + contextGraphId: 'music-social', name: 'albums', agentAddress: '0x0', shareOperationId: 'share-op-1', + roots: [] as string[], contentScopeVersion: 2 as const, kaUal, assertionVersion: '1', + publicTripleCount: 2, privateTripleCount: 0, + seal: { + merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, authorAddress: authorAddress as `0x${string}`, + signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, + schemeVersion: 1, reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, + }, + sealChainId: '31337' as `${bigint}`, sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, + sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, + intentKey: `sha256:${'ab'.repeat(32)}`, wmCurrentAssertion: '12'.repeat(32), swmCurrentAssertion: '12'.repeat(32), + kaNumber: kaNumber.toString(), reservedUal: kaUal, + }; + } + + function newControl() { + const store = new OxigraphStore(); + stores.push(store); + return createPublisherControlFromStore(store, {}); + } + + async function finalizedJob(control: ReturnType): Promise { + const bx = { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }; + const inc = { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }; + const jobId = await control.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + await control.claimNext('wallet-1'); + await control.update(jobId, 'validated', { validation: { canonicalRoots: [], canonicalRootMap: {}, swmQuadCount: 2, authorityProofRef: 'knowledge-asset-lifecycle', transitionType: 'CREATE' } }); + await control.update(jobId, 'broadcast', { broadcast: bx }); + await control.update(jobId, 'included', { broadcast: bx, inclusion: inc }); + await control.update(jobId, 'finalized', { broadcast: bx, inclusion: inc, finalization: { mode: 'local' } }); + return jobId; + } + + it('clears a terminal job → 200 cleared; repeat → 200 already_absent', async () => { + const control = newControl(); + const jobId = await finalizedJob(control); + const ctx1 = postClearJob(control, { jobId }); + await handlePublisherRoutes(ctx1); + expect(responseStatus(ctx1)).toBe(200); + expect(responseBody(ctx1)).toMatchObject({ outcome: 'cleared', jobId }); + + const ctx2 = postClearJob(control, { jobId }); + await handlePublisherRoutes(ctx2); + expect(responseStatus(ctx2)).toBe(200); + expect(responseBody(ctx2)).toMatchObject({ outcome: 'already_absent', jobId }); + }); + + it('rejects a nonterminal (accepted) job → 409 nonterminal', async () => { + const control = newControl(); + const jobId = await control.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + const ctx = postClearJob(control, { jobId }); + await handlePublisherRoutes(ctx); + expect(responseStatus(ctx)).toBe(409); + expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'nonterminal' }); + }); + + it('rejects a missing/empty jobId → 400 malformed', async () => { + const control = newControl(); + const ctx = postClearJob(control, {}); + await handlePublisherRoutes(ctx); + expect(responseStatus(ctx)).toBe(400); + }); + + // #1883 review (🔴): a literal `null` body parses fine but must not TypeError on + // destructure (→ 500). It falls through to the malformed guard as a bounded 400. + it('rejects a literal null body → 400 malformed (no 500)', async () => { + const control = newControl(); + const ctx = postClearJobRaw(control, 'null'); + await handlePublisherRoutes(ctx); + expect(responseStatus(ctx)).toBe(400); + expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'malformed' }); + }); + + // #1883 review (🔴): a SPARQL-unsafe jobId must be a bounded 400, never a query-error 500. + it('rejects a SPARQL-unsafe jobId → 400 malformed (no 500)', async () => { + const control = newControl(); + for (const jobId of ['bad id', 'bad>id']) { + const ctx = postClearJob(control, { jobId }); + await handlePublisherRoutes(ctx); + expect(responseStatus(ctx)).toBe(400); + expect(responseBody(ctx)).toMatchObject({ outcome: 'rejected', reason: 'malformed' }); + } + }); + + function postClearJob(publisherControl: RequestContext['publisherControl'], body: Record): RequestContext { + return postClearJobRaw(publisherControl, JSON.stringify(body)); + } + + function postClearJobRaw(publisherControl: RequestContext['publisherControl'], rawBody: string): RequestContext { + const path = '/api/publisher/clear-job'; + const url = new URL(`http://127.0.0.1${path}`); + const req = Readable.from([]); + // readBody() resolves synchronously from a prebuffered body (as httpAuthGuard's + // eager drain leaves it) — avoids driving a mock stream in the unit harness. + Object.assign(req, { + method: 'POST', url: path, headers: { host: '127.0.0.1' }, + __dkgPrebufferedBody: Buffer.from(rawBody, 'utf8'), + }); + return { + req: req as RequestContext['req'], + res: createResponse() as unknown as ServerResponse, + agent: {} as RequestContext['agent'], + publisherControl, + publisherState: { runtime: null, availability: { available: false, reason: 'publisher_disabled', retryable: false, operatorActionRequired: true } }, + config: {} as RequestContext['config'], + startedAt: 0, + dashDb: {} as RequestContext['dashDb'], + opWallets: { adminWallet: { address: '0x0', privateKey: '0x0' }, wallets: [] } as RequestContext['opWallets'], + network: null as RequestContext['network'], + tracker: {} as RequestContext['tracker'], + memoryManager: {} as RequestContext['memoryManager'], + bridgeAuthToken: undefined, + nodeVersion: 'test', + nodeCommit: 'test', + catchupTracker: {} as RequestContext['catchupTracker'], + extractionRegistry: {} as RequestContext['extractionRegistry'], + fileStore: {} as RequestContext['fileStore'], + extractionStatus: new Map(), + assertionImportLocks: new Map(), + vectorStore: {} as RequestContext['vectorStore'], + embeddingProvider: null, + validTokens: new Set(), + apiHost: '127.0.0.1', + apiPortRef: { value: 0 }, + url, + path: url.pathname, + requestToken: undefined, + requestAgentAddress: '0x0', + }; + } + + function createResponse() { + return { + statusCode: 0, headers: undefined as Record | undefined, body: '', writableEnded: false, + writeHead(status: number, headers: Record) { this.statusCode = status; this.headers = headers; return this; }, + end(body?: string) { this.body = body ?? ''; this.writableEnded = true; return this; }, + }; + } + function responseStatus(ctx: RequestContext): number { return (ctx.res as unknown as { statusCode: number }).statusCode; } + function responseBody(ctx: RequestContext): Record { return JSON.parse((ctx.res as unknown as { body: string }).body) as Record; } +}); diff --git a/packages/cli/test/terminal-clear-response.test.ts b/packages/cli/test/terminal-clear-response.test.ts new file mode 100644 index 0000000000..277e7e7330 --- /dev/null +++ b/packages/cli/test/terminal-clear-response.test.ts @@ -0,0 +1,55 @@ +import type { ServerResponse } from 'node:http'; +import { describe, expect, it } from 'vitest'; +import type { TerminalJobClearOutcome } from '@origintrail-official/dkg-publisher'; +import { respondTerminalClearOutcome } from '../src/daemon/routes/terminal-clear-response.js'; + +// #1837 — locks the shared TerminalJobClearOutcome → HTTP contract that BOTH the publisher +// (/api/publisher/clear-job) and SWM (/api/knowledge-assets/swm/share-jobs/:id/clear) clear +// routes project through, so a future mapping change (wrong status, dropped jobId, or the +// `unknown` branch diverging) cannot silently alter the clear-route contract. +describe('respondTerminalClearOutcome mapping', () => { + function fakeRes() { + return { + statusCode: 0, + body: '', + headers: undefined as Record | undefined, + writableEnded: false, + writeHead(status: number, headers: Record) { + this.statusCode = status; + this.headers = headers; + return this; + }, + end(body?: string) { + this.body = body ?? ''; + this.writableEnded = true; + return this; + }, + }; + } + + function run(outcome: TerminalJobClearOutcome, jobId: string) { + const res = fakeRes(); + respondTerminalClearOutcome(res as unknown as ServerResponse, outcome, jobId); + return { status: res.statusCode, body: JSON.parse(res.body) as Record }; + } + + it('cleared → 200 with echoed jobId', () => { + expect(run({ outcome: 'cleared' }, 'job-1')).toEqual({ status: 200, body: { outcome: 'cleared', jobId: 'job-1' } }); + }); + + it('already_absent → 200 (idempotent success, NOT 404)', () => { + expect(run({ outcome: 'already_absent' }, 'job-2')).toEqual({ status: 200, body: { outcome: 'already_absent', jobId: 'job-2' } }); + }); + + it('rejected malformed → 400 (client input error)', () => { + expect(run({ outcome: 'rejected', reason: 'malformed' }, 'bad id')).toEqual({ status: 400, body: { outcome: 'rejected', reason: 'malformed', jobId: 'bad id' } }); + }); + + it('rejected nonterminal → 409 (server-side state condition)', () => { + expect(run({ outcome: 'rejected', reason: 'nonterminal' }, 'job-3')).toEqual({ status: 409, body: { outcome: 'rejected', reason: 'nonterminal', jobId: 'job-3' } }); + }); + + it('rejected unknown → 409 with echoed jobId', () => { + expect(run({ outcome: 'rejected', reason: 'unknown' }, 'bogus-1')).toEqual({ status: 409, body: { outcome: 'rejected', reason: 'unknown', jobId: 'bogus-1' } }); + }); +}); diff --git a/packages/cli/vitest.unit.config.ts b/packages/cli/vitest.unit.config.ts index 1b11d0d923..5ca8fced20 100644 --- a/packages/cli/vitest.unit.config.ts +++ b/packages/cli/vitest.unit.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ // #1828 — durable-admission recovery lookup route (pure handler, no hardhat). 'test/publisher-job-by-intent-route.test.ts', 'test/publisher-journal-route.test.ts', + 'test/publisher-clear-job-route.test.ts', // #1828 — daemon-boot intent-index backfill wiring (fail-open contract). 'test/vm-publish-intent-backfill.test.ts', 'test/agent-connect-routes.test.ts', diff --git a/packages/publisher/src/async-lift-publisher-impl.ts b/packages/publisher/src/async-lift-publisher-impl.ts index 3bfc1e9c7b..f60b4edd84 100644 --- a/packages/publisher/src/async-lift-publisher-impl.ts +++ b/packages/publisher/src/async-lift-publisher-impl.ts @@ -40,8 +40,10 @@ import type { VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, + VmPublishTerminalJobClearer, } from './async-lift-publisher-types.js'; import { AsyncLiftJobConflictError } from './async-lift-publisher-types.js'; +import { isSafeClearJobId, type TerminalJobClearOutcome } from './terminal-job-clear.js'; import { mapPublishExceptionToLiftJobFailure, mapPublishResultToLiftJobSuccess, @@ -80,6 +82,7 @@ import { getRecoveryTxHash, isKnowledgeAssetVmPublishJobRequest, isFailedJob, + isClearableTerminalLiftJob, isOccupyingLifecycleJob, normalizePersistedLiftJobRequest, rawLiftRequestFromJobRequest, @@ -192,7 +195,7 @@ function resolveKnowledgeAssetVmPublishHandler( } export class TripleStoreAsyncLiftPublisher - implements VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader { + implements VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, VmPublishTerminalJobClearer { private static readonly claimQueues = new Map>(); // #1829 — dedicated per-lineageKey journal mutex, SEPARATE from claimQueues, so the // read-modify-write seq allocation is atomic without touching the claim lock (lock @@ -992,17 +995,24 @@ export class TripleStoreAsyncLiftPublisher await this.ensureGraph(); if (filter.status && filter.status !== 'failed') return 0; - let retried = 0; - for (const job of (await this.list({ status: 'failed' })).filter(isFailedJob)) { - if (!job.failure.retryable || job.retries.retryCount >= job.retries.maxRetries) continue; - // Jobs that failed with a recovery-phase resolution must go through recover(), - // not retry(), to avoid double-publishing if the original tx eventually lands. - if (job.failure.resolution === 'retry_recovery') continue; - - await this.reacceptFailedJob(job); - retried += 1; - } - return retried; + // #1837 — reaccept (failed→accepted) is a terminal→active transition; it MUST be + // serialized with claimNext/enqueue AND with clearTerminalJob (which also runs under + // withClaimLock) so a by-id clear that read a job as clearable-failed cannot be swept + // after retry() flips it active. Without this lock the "a transitioning job cannot be + // swept" guarantee does not hold. + return this.withClaimLock(async () => { + let retried = 0; + for (const job of (await this.list({ status: 'failed' })).filter(isFailedJob)) { + if (!job.failure.retryable || job.retries.retryCount >= job.retries.maxRetries) continue; + // Jobs that failed with a recovery-phase resolution must go through recover(), + // not retry(), to avoid double-publishing if the original tx eventually lands. + if (job.failure.resolution === 'retry_recovery') continue; + + await this.reacceptFailedJob(job); + retried += 1; + } + return retried; + }); } async clear(status: 'finalized' | 'failed'): Promise { @@ -1010,9 +1020,10 @@ export class TripleStoreAsyncLiftPublisher const jobs = await this.list({ status }); let cleared = 0; for (const job of jobs) { - // Protect retry_recovery jobs — they may still have a pending on-chain tx - // that periodic recovery will finalize. Only explicit cancel can remove them. - if (status === 'failed' && isFailedJob(job) && job.failure.resolution === 'retry_recovery') continue; + // #1837 — single terminal-clear authority shared with clearTerminalJob: skips + // retry_recovery-failed jobs (a pending on-chain tx may still land). Behavior is + // identical to the prior inline `resolution === 'retry_recovery'` guard. + if (!isClearableTerminalLiftJob(job)) continue; await this.releaseWalletLockForJob(job); await this.deleteJob(job.jobId); cleared += 1; @@ -1020,6 +1031,45 @@ export class TripleStoreAsyncLiftPublisher return cleared; } + /** + * #1837 — atomic by-exact-jobId terminal clear. Runs INSIDE withClaimLock so it is + * serialized against claimNext/enqueue/reaccept and retry() (the only terminal→active + * transitions) — a job transitioning cannot be swept, and concurrent clears are + * deterministic (exactly one 'cleared', the rest 'already_absent'). deleteJob is + * subject-scoped to the control-plane graph, so it never touches another job or the + * #1829 journal. Never throws / never mutates on a reject. + */ + async clearTerminalJob(jobId: string): Promise { + // Reject an empty OR SPARQL-unsafe jobId as malformed BEFORE building the jobSubject + // IRI — otherwise an attacker-controlled jobId (from the clear-job HTTP body) with a + // space/'>'/'{' could break the query out of `<…>` and surface as a 500/injection + // instead of the bounded outcome. + if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; + return this.withClaimLock(async () => { + await this.ensureGraph(); + const rows = expectBindings( + await this.store.query( + `SELECT ?payload WHERE { GRAPH <${this.graphUri}> { <${jobSubject(jobId)}> <${PAYLOAD_PREDICATE}> ?payload } }`, + ), + ); + if (rows.length === 0) return { outcome: 'already_absent' }; + // Parse defensively — a corrupt persisted payload must surface as rejected(malformed), + // never throw (parseJobPayload does an unguarded JSON.parse). + let job: LiftJob | null; + try { + job = this.parseJobPayload(rows[0]?.['payload']); + } catch { + return { outcome: 'rejected', reason: 'malformed' }; + } + if (job === null) return { outcome: 'rejected', reason: 'malformed' }; + if (!LIFT_JOB_STATES.includes(job.status)) return { outcome: 'rejected', reason: 'unknown' }; + if (!isClearableTerminalLiftJob(job)) return { outcome: 'rejected', reason: 'nonterminal' }; + await this.releaseWalletLockForJob(job); + await this.deleteJob(jobId); + return { outcome: 'cleared' }; + }); + } + private async ensureGraph(): Promise { if (this.graphEnsured) return; await this.store.createGraph(this.graphUri); diff --git a/packages/publisher/src/async-lift-publisher-types.ts b/packages/publisher/src/async-lift-publisher-types.ts index ba46c59d9a..84d8b118a2 100644 --- a/packages/publisher/src/async-lift-publisher-types.ts +++ b/packages/publisher/src/async-lift-publisher-types.ts @@ -17,6 +17,7 @@ import type { PublishOptions, PublishResult } from './publisher.js'; import type { AsyncLiftPublishFailureInput } from './async-lift-publish-result.js'; import type { AsyncPreparedPublishPayload, LiftResolvedPublishSlice } from './async-lift-publish-options.js'; import type { WorkspacePublicSnapshotStore } from './workspace-snapshot-store.js'; +import type { TerminalJobClearOutcome } from './terminal-job-clear.js'; export class AsyncLiftJobConflictError extends Error { readonly code = 'ASYNC_LIFT_JOB_CONFLICT'; @@ -124,6 +125,18 @@ export interface VmPublishAdmissionJournalReader { readJournalByJob(jobId: string): Promise; } +/** + * #1837 — atomic by-jobId terminal cleanup. Segregated off the base contract (like the + * #1828/#1829 capabilities); a MUTATION/admin capability, not a query. Clears the exact + * job ONLY when it is in a native terminal state, rejects otherwise without mutation, + * and is idempotent for an absent job. Never broadens to other jobs. On the lift side + * this preserves the #1829 append-only journal by construction (subject-scoped delete + * in the control-plane graph only). + */ +export interface VmPublishTerminalJobClearer { + clearTerminalJob(jobId: string): Promise; +} + /** * #1828 — one-shot storage maintenance: (re)build the ephemeral intent index for * VM-publish jobs admitted before it existed. This is a boot-time repair, not a diff --git a/packages/publisher/src/async-lift-publisher-utils.ts b/packages/publisher/src/async-lift-publisher-utils.ts index a21671ccc9..5866a51a3f 100644 --- a/packages/publisher/src/async-lift-publisher-utils.ts +++ b/packages/publisher/src/async-lift-publisher-utils.ts @@ -71,6 +71,19 @@ export function isFailedJob(job: LiftJob): job is PersistedFailedJob { return job.status === 'failed' && 'failure' in job; } +/** + * #1837 — the single terminal-clear authority, reused by both `clear(status)` (bulk) and + * `clearTerminalJob(jobId)` so they cannot drift. A job is clearable iff it is in a native + * terminal state (finalized|failed) AND is not a `retry_recovery`-failed job — those may + * still carry a pending on-chain tx that periodic recovery will finalize, so only explicit + * cancel removes them. A `retry_recovery`-failed job is therefore treated as + * NONTERMINAL-for-cleanup. + */ +export function isClearableTerminalLiftJob(job: LiftJob): boolean { + return isTerminalLiftJobState(job.status) + && !(isFailedJob(job) && job.failure.resolution === 'retry_recovery'); +} + /** * #1828 — whether a job still OCCUPIES its lifecycle subject: any non-terminal * state, or a failed job admission would still reaccept (retryable with retries diff --git a/packages/publisher/src/async-lift-publisher.ts b/packages/publisher/src/async-lift-publisher.ts index 46c09b8978..e9b71e1738 100644 --- a/packages/publisher/src/async-lift-publisher.ts +++ b/packages/publisher/src/async-lift-publisher.ts @@ -14,10 +14,12 @@ export type { VmPublishIntentRecoveryPublisher, VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, + VmPublishTerminalJobClearer, IntentLookupInput, IntentLookupResult, JournalReadInput, JournalReadResult, } from './async-lift-publisher-types.js'; export { AsyncLiftJobConflictError } from './async-lift-publisher-types.js'; +export type { TerminalJobClearOutcome } from './terminal-job-clear.js'; export { TripleStoreAsyncLiftPublisher } from './async-lift-publisher-impl.js'; diff --git a/packages/publisher/src/async-promote-queue-impl.ts b/packages/publisher/src/async-promote-queue-impl.ts index 8da0c91f16..27cd1d6dec 100644 --- a/packages/publisher/src/async-promote-queue-impl.ts +++ b/packages/publisher/src/async-promote-queue-impl.ts @@ -18,6 +18,7 @@ */ import type { TripleStore } from '@origintrail-official/dkg-storage'; +import { isSafeClearJobId, type TerminalJobClearOutcome } from './terminal-job-clear.js'; import { ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, ASYNC_PROMOTE_QUEUE_MIN_AUTO_RECOVERABLE_FORMAT_VERSION, @@ -26,6 +27,7 @@ import { PROMOTE_JOB_STATES, type AsyncPromoteQueue, type AsyncPromoteQueueConfig, + type PromoteTerminalJobClearer, type PromoteAttemptError, type PromoteCommitMarker, type PromoteCommitMarkerStep, @@ -48,10 +50,12 @@ import { comparePromoteJobs, defaultBackoffMs, expectBindings, + isTerminalPromoteJobState, jobSubject, literal, normalizePromoteAgentLane, parseJobPayload, + parseLiteral, promoteLaneConflictScope, promoteLaneScopesConflict, serializeJob, @@ -68,7 +72,7 @@ type PromoteConflictLookup = { laneScope: ReturnType; }; -export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue { +export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue, PromoteTerminalJobClearer { /** * Per-graph-URI mutex map. Serialises uniqueness-affecting mutations * callers can't both observe stale state and then persist conflicting @@ -599,6 +603,57 @@ export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue { await this.store.flush?.(); } + // #1837 — subject-scoped record removal (the delete half of writeJob, no re-insert). + // All of a job's triples live under jobSubject(jobId) in the single control-plane + // graph, so this provably cannot touch another job or another graph. + private async deleteJob(jobId: string): Promise { + await this.store.deleteByPattern({ subject: jobSubject(jobId), graph: this.graphUri }); + await this.store.flush?.(); + } + + /** + * #1837 — atomic by-exact-jobId terminal clear. Runs INSIDE withMutationLock, which + * already serializes EVERY transition (incl. the only terminal→active path, recover() + * failed→queued), so a transitioning job cannot be swept and concurrent clears are + * deterministic — no new lock needed. Reads the denormalized state triple to split + * already_absent / unknown / malformed without a JSON.parse that collapses them. Never + * throws / never mutates on a reject. + */ + async clearTerminalJob(jobId: string): Promise { + // Reject an empty OR SPARQL-unsafe jobId as malformed before building the jobSubject + // IRI (defense-in-depth; the SWM route already pre-validates via decodePromoteJobId, + // but a direct agent.assertion.clearPromoteAsync caller must be bounded too). + if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; + return this.withMutationLock(async () => { + await this.ensureGraph(); + const stateRows = expectBindings( + await this.store.query( + `SELECT ?state WHERE { GRAPH <${this.graphUri}> { <${jobSubject(jobId)}> <${PROMOTE_STATE}> ?state } }`, + ), + ); + if (stateRows.length === 0) return { outcome: 'already_absent' }; + const rawState = stateRows[0]?.['state']; + // parseLiteral is JSON.parse — a corrupt/non-JSON state literal must become a + // bounded reject, never throw out of the method (matches the lift sibling's guard). + let state: string | undefined; + try { + state = rawState === undefined ? undefined : String(parseLiteral(rawState)); + } catch { + return { outcome: 'rejected', reason: 'unknown' }; + } + if (state === undefined || !(PROMOTE_JOB_STATES as readonly string[]).includes(state)) { + return { outcome: 'rejected', reason: 'unknown' }; + } + // State literal is a known enum value; the full payload must also parse (a corrupt + // payload with a valid state triple is malformed, not unknown). + const job = await this.readJob(jobId); + if (job === null) return { outcome: 'rejected', reason: 'malformed' }; + if (!isTerminalPromoteJobState(job.state)) return { outcome: 'rejected', reason: 'nonterminal' }; + await this.deleteJob(jobId); + return { outcome: 'cleared' }; + }); + } + private assertLeaseHeld(job: PromoteJob, claimToken: string): void { if (job.state !== 'running') { throw new PromoteJobLeaseError(job.jobId, `job is in state '${job.state}', not 'running'`); diff --git a/packages/publisher/src/async-promote-queue-types.ts b/packages/publisher/src/async-promote-queue-types.ts index e7d53e6916..438b04c55a 100644 --- a/packages/publisher/src/async-promote-queue-types.ts +++ b/packages/publisher/src/async-promote-queue-types.ts @@ -14,6 +14,8 @@ * differences from `AsyncLiftPublisher`. */ +import type { TerminalJobClearOutcome } from './terminal-job-clear.js'; + export const PROMOTE_JOB_STATES = [ 'queued', 'running', @@ -219,6 +221,18 @@ export interface AsyncPromoteQueue { getStats(): Promise; } +/** + * #1837 — atomic by-exact-jobId terminal cleanup for the SWM share (promote) queue. + * Segregated off the base contract (mirrors the publisher-side VmPublishTerminalJobClearer); + * a mutation/admin capability. Clears the exact job ONLY when in a native terminal state + * ({succeeded, failed}, no carve-out — nothing background re-drives a terminal promote row), + * rejects otherwise without mutation, idempotent for an absent job, never broadens to other + * jobs. Reuses the shared TerminalJobClearOutcome for symmetry with the lift clearer. + */ +export interface PromoteTerminalJobClearer { + clearTerminalJob(jobId: string): Promise; +} + export interface AsyncPromoteQueueConfig { /** Defaults to `urn:dkg:promote-queue:control-plane` per RFC §4.1. */ graphUri?: string; diff --git a/packages/publisher/src/async-promote-queue-utils.ts b/packages/publisher/src/async-promote-queue-utils.ts index 457269c05a..a9e98ed288 100644 --- a/packages/publisher/src/async-promote-queue-utils.ts +++ b/packages/publisher/src/async-promote-queue-utils.ts @@ -58,6 +58,20 @@ export const ACTIVE_PROMOTE_STATES: readonly PromoteJobState[] = [ 'failed_retrying', ]; +/** + * #1837 — native terminal states for a by-jobId terminal clear. Unlike the lift queue + * there is NO carve-out: nothing background re-drives a terminal promote row (no on-chain + * tx; recover() failed→queued is manual-only + locked; reconcileExpiredRunning touches + * only 'running'; conflict detection gates on ACTIVE states), so both terminal states are + * uniformly clearable — a `requiresManualInspection` (partial-ambiguity) failed row + * included (data-safe: nothing reads a removed row). + */ +export const TERMINAL_PROMOTE_JOB_STATES: readonly PromoteJobState[] = ['succeeded', 'failed']; + +export function isTerminalPromoteJobState(state: PromoteJobState): boolean { + return TERMINAL_PROMOTE_JOB_STATES.includes(state); +} + export function jobSubject(jobId: string): string { return `urn:dkg:promote-queue:job:${jobId}`; } diff --git a/packages/publisher/src/async-promote-queue.ts b/packages/publisher/src/async-promote-queue.ts index f878cbd494..7554ddcc73 100644 --- a/packages/publisher/src/async-promote-queue.ts +++ b/packages/publisher/src/async-promote-queue.ts @@ -14,6 +14,7 @@ export type { PromoteRequest, PromoteResult, PromoteStats, + PromoteTerminalJobClearer, } from './async-promote-queue-types.js'; export { ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index c44350641c..c917e5f5fe 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -267,11 +267,17 @@ export { type VmPublishIntentRecoveryPublisher, type VmPublishIntentIndexBackfiller, type VmPublishAdmissionJournalReader, + type VmPublishTerminalJobClearer, + type TerminalJobClearOutcome, type IntentLookupInput, type IntentLookupResult, type JournalReadInput, type JournalReadResult, } from './async-lift-publisher.js'; +export { + SAFE_CLEAR_JOB_ID_PATTERN, + SAFE_CLEAR_JOB_ID_MAX_LENGTH, +} from './terminal-job-clear.js'; export { TripleStoreAsyncPromoteQueue, ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, @@ -294,6 +300,7 @@ export { type PromoteRequest, type PromoteResult, type PromoteStats, + type PromoteTerminalJobClearer, } from './async-promote-queue.js'; export { AsyncLiftRunner, diff --git a/packages/publisher/src/terminal-job-clear.ts b/packages/publisher/src/terminal-job-clear.ts new file mode 100644 index 0000000000..50c11cc6be --- /dev/null +++ b/packages/publisher/src/terminal-job-clear.ts @@ -0,0 +1,36 @@ +// #1837 — shared contract for the atomic by-exact-jobId TERMINAL clear, owned by NEITHER +// queue (lift nor promote) so a generic admin-clear result is not coupled to one +// implementation family's type module. + +/** + * Bounded outcome of a terminal clear, shared by the lift publisher and the SWM promote + * queue. The control method owns every reason INCLUDING `malformed` (a corrupt persisted + * payload — or an unsafe jobId — is only detectable inside the method, never at the HTTP + * route). Never throws and never mutates on a reject. `already_absent` is a SUCCESS + * (idempotent repeat), distinct from a rejection. + */ +export type TerminalJobClearOutcome = + | { readonly outcome: 'cleared' } + | { readonly outcome: 'already_absent' } + | { readonly outcome: 'rejected'; readonly reason: 'nonterminal' | 'unknown' | 'malformed' }; + +// Producer grammar for a queue jobId (crypto.randomUUID(), or test 'job-N'): starts +// alphanumeric, then alnum/'.'/'_'/':'/'-'. This is IRI-safe — it excludes every character +// that could break out of the `<…>` IRI in a control-plane SPARQL query (spaces, '<' '>' +// '"' '{' '}' '|' '^' '`', control chars). This is the SINGLE authoritative job-id grammar: +// the CLI route validator (`validatePromoteJobId`) imports it rather than re-declaring the +// regex, so route-level and control-plane job-id acceptance cannot drift. +export const SAFE_CLEAR_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +export const SAFE_CLEAR_JOB_ID_MAX_LENGTH = 256; + +/** + * True iff `jobId` is safe to interpolate into a control-plane SPARQL IRI. A by-id clear + * MUST reject an unsafe jobId as `malformed` BEFORE building the query, so an + * attacker-controlled jobId (from the clear-job HTTP body) yields a bounded reject rather + * than a query syntax error / injection / 500. + */ +export function isSafeClearJobId(jobId: string): boolean { + return ( + jobId.length > 0 && jobId.length <= SAFE_CLEAR_JOB_ID_MAX_LENGTH && SAFE_CLEAR_JOB_ID_PATTERN.test(jobId) + ); +} diff --git a/packages/publisher/test/async-lift-terminal-clear.test.ts b/packages/publisher/test/async-lift-terminal-clear.test.ts new file mode 100644 index 0000000000..2c347f1c9d --- /dev/null +++ b/packages/publisher/test/async-lift-terminal-clear.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { TripleStoreAsyncLiftPublisher, type AsyncLiftPublisherConfig } from '../src/index.js'; +import { DEFAULT_CONTROL_GRAPH_URI, jobSubject, serializeJob } from '../src/async-lift-control-plane.js'; + +// #1837 — atomic by-exact-jobId TERMINAL clear for the async publisher (lift) queue. +describe('#1837 lift publisher clearTerminalJob', () => { + let now = 1_000; + let ids = 0; + let store: OxigraphStore; + + beforeEach(() => { + now = 1_000; + ids = 0; + store = new OxigraphStore(); + }); + + function createPublisher(config: Omit = {}): TripleStoreAsyncLiftPublisher { + return new TripleStoreAsyncLiftPublisher(store, { + now: () => ++now, + idGenerator: () => `job-${++ids}`, + journalWrites: true, + ...config, + }); + } + + function kaVmPublishRequest(overrides: Record = {}) { + const authorAddress = '0x1111111111111111111111111111111111111111'; + const kaNumber = 7n; + const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; + return { + contextGraphId: 'music-social', name: 'albums', shareOperationId: 'share-op-1', + roots: [] as string[], contentScopeVersion: 2 as const, kaUal, assertionVersion: '1', + publicTripleCount: 2, privateTripleCount: 0, + seal: { + merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, + authorAddress: authorAddress as `0x${string}`, + signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, + schemeVersion: 1, + reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, + }, + sealChainId: '31337' as `${bigint}`, sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, + sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, + intentKey: `sha256:${'ab'.repeat(32)}`, wmCurrentAssertion: '12'.repeat(32), swmCurrentAssertion: '12'.repeat(32), + kaNumber: kaNumber.toString(), reservedUal: kaUal, ...overrides, + }; + } + const bx = { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }; + const inc = { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }; + + async function driveToValidated(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + const jobId = await p.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest(o)); + await p.claimNext('wallet-1'); + await p.update(jobId, 'validated', { + validation: { canonicalRoots: [], canonicalRootMap: {}, swmQuadCount: 2, authorityProofRef: 'knowledge-asset-lifecycle', transitionType: 'CREATE' }, + }); + return jobId; + } + async function driveToFinalized(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + const jobId = await driveToValidated(p, o); + await p.update(jobId, 'broadcast', { broadcast: bx }); + await p.update(jobId, 'included', { broadcast: bx, inclusion: inc }); + await p.update(jobId, 'finalized', { broadcast: bx, inclusion: inc, finalization: { mode: 'local' } }); + return jobId; + } + // Terminal, non-retryable (tx_reverted → fail_job): clearable, retry() won't touch it. + async function driveToTerminalFailed(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + const jobId = await driveToValidated(p, o); + await p.update(jobId, 'broadcast', { broadcast: bx }); + await p.recordPublishFailure(jobId, { error: new Error('tx reverted on chain'), failedFromState: 'broadcast', errorPayloadRef: 'urn:err:1' }); + return jobId; + } + it('clears an exact finalized job (cleared); no other job changes', async () => { + const p = createPublisher(); + const target = await driveToFinalized(p, { name: 'a' }); + const other = await driveToFinalized(p, { name: 'b' }); + expect(await p.clearTerminalJob(target)).toEqual({ outcome: 'cleared' }); + expect(await p.getStatus(target)).toBeNull(); + expect((await p.getStatus(other))?.status).toBe('finalized'); // untouched + }); + + it('clears an exact terminal (non-retryable) failed job', async () => { + const p = createPublisher(); + const jobId = await driveToTerminalFailed(p); + expect((await p.getStatus(jobId))?.status).toBe('failed'); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await p.getStatus(jobId)).toBeNull(); + }); + + it('rejects an accepted (queued) job as nonterminal without mutation', async () => { + const p = createPublisher(); + const accepted = await p.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + expect(await p.clearTerminalJob(accepted)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await p.getStatus(accepted))?.status).toBe('accepted'); + }); + + it('rejects a validated job as nonterminal without mutation', async () => { + const p = createPublisher(); + const validated = await driveToValidated(p); + expect(await p.clearTerminalJob(validated)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await p.getStatus(validated))?.status).toBe('validated'); + }); + + it('rejects a broadcast job as nonterminal without mutation', async () => { + const p = createPublisher(); + const broadcast = await driveToValidated(p); + await p.update(broadcast, 'broadcast', { broadcast: bx }); + expect(await p.clearTerminalJob(broadcast)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await p.getStatus(broadcast))?.status).toBe('broadcast'); + }); + + it('rejects a retry_recovery-protected failed job as nonterminal (a pending tx may still land)', async () => { + // retry_recovery is a raw-lift-only recovery resolution (KA-VM canRetryFailedRecovery + // is false), so inject a synthetic retry_recovery-failed job to exercise the guard the + // clearer shares with bulk clear(). Start from a real terminal-failed job and rewrite + // its persisted resolution. + const p = createPublisher(); + const jobId = await driveToTerminalFailed(p); + const job = await p.getStatus(jobId); + if (!job || !('failure' in job)) throw new Error('expected a failed job'); + const mutated = { ...job, failure: { ...job.failure, resolution: 'retry_recovery' } }; + await store.deleteByPattern({ subject: jobSubject(jobId), graph: DEFAULT_CONTROL_GRAPH_URI }); + await store.insert(serializeJob(mutated as typeof job, DEFAULT_CONTROL_GRAPH_URI)); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await p.getStatus(jobId))?.status).toBe('failed'); // unchanged + }); + + it('is idempotent: absent / already-cleared → already_absent', async () => { + const p = createPublisher(); + expect(await p.clearTerminalJob('never-existed')).toEqual({ outcome: 'already_absent' }); + const jobId = await driveToFinalized(p); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'already_absent' }); + }); + + it('rejects an empty or SPARQL-unsafe jobId as malformed without querying/mutating', async () => { + const p = createPublisher(); + expect(await p.clearTerminalJob('')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await p.clearTerminalJob(' ')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + // #1883 review (🔴): a jobId that would break out of the <…> SPARQL IRI must be a + // bounded malformed reject, never a query error / injection. + expect(await p.clearTerminalJob('bad id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await p.clearTerminalJob('bad>id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await p.clearTerminalJob('a{ b')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + }); + + it('preserves the #1829 journal: clearing a terminal job leaves its journal lineage readable', async () => { + const p = createPublisher(); + const jobId = await driveToFinalized(p); + expect((await p.readJournalByJob(jobId)).entries.length).toBeGreaterThan(0); + expect(await p.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await p.getStatus(jobId)).toBeNull(); // gone from control plane + const journal = await p.readJournalByJob(jobId); + expect(journal.entries.length).toBeGreaterThan(0); // lineage survives the clear + expect(journal.entries.some((e) => e.kind === 'finalized')).toBe(true); + }); + + it('no-sweep: a clearable-failed job reaccepted by concurrent retry() is never deleted while active', async () => { + // maxRetries:1 + a retryable failure (rpc_unavailable/reset_to_accepted) → the job is + // BOTH clearable (terminal failed, not retry_recovery) AND reacceptable by retry(). + // clearTerminalJob and retry() both run under withClaimLock, so they serialize: + // whichever wins, an ACTIVE job is never swept. + const p = createPublisher({ maxRetries: 1 }); + const jobId = await driveToValidated(p, { name: 'race' }); + await p.update(jobId, 'broadcast', { broadcast: bx }); + await p.recordPublishFailure(jobId, { error: new Error('rpc temporarily down'), failedFromState: 'broadcast', errorPayloadRef: 'urn:err:2' }); + const failed = await p.getStatus(jobId); + expect(failed?.status).toBe('failed'); + expect(failed && 'failure' in failed && failed.failure.retryable).toBe(true); + + const [clearOutcome, retried] = await Promise.all([p.clearTerminalJob(jobId), p.retry({ status: 'failed' })]); + const after = await p.getStatus(jobId); + if (clearOutcome.outcome === 'cleared') { + // clear won: job gone, retry could not have reaccepted it. + expect(after).toBeNull(); + expect(retried).toBe(0); + } else { + // retry won: job is active ('accepted'); clear must have rejected it, NOT deleted it. + expect(clearOutcome).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect(after?.status).toBe('accepted'); + expect(retried).toBe(1); + } + }); + + it('concurrent clears of one terminal job are deterministic: one cleared, rest already_absent', async () => { + const p = createPublisher(); + const target = await driveToFinalized(p, { name: 'a' }); + const other = await driveToFinalized(p, { name: 'b' }); + const results = await Promise.all([p.clearTerminalJob(target), p.clearTerminalJob(target), p.clearTerminalJob(target)]); + expect(results.filter((r) => r.outcome === 'cleared')).toHaveLength(1); + expect(results.filter((r) => r.outcome === 'already_absent')).toHaveLength(2); + expect((await p.getStatus(other))?.status).toBe('finalized'); // never affected + }); +}); diff --git a/packages/publisher/test/async-promote-terminal-clear.test.ts b/packages/publisher/test/async-promote-terminal-clear.test.ts new file mode 100644 index 0000000000..2d77f74a88 --- /dev/null +++ b/packages/publisher/test/async-promote-terminal-clear.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { + type AsyncPromoteQueue, + type AsyncPromoteQueueConfig, + type PromoteRequest, + type PromoteTerminalJobClearer, +} from '../src/async-promote-queue-types.js'; +import { TripleStoreAsyncPromoteQueue } from '../src/async-promote-queue-impl.js'; +import { DEFAULT_PROMOTE_CONTROL_GRAPH_URI, PROMOTE_STATE, jobSubject, literal } from '../src/async-promote-queue-utils.js'; + +// #1837 — atomic by-exact-jobId TERMINAL clear for the SWM promote queue. +describe('#1837 promote queue clearTerminalJob', () => { + let store: OxigraphStore; + let now: number; + let idCounter: number; + + beforeEach(() => { + store = new OxigraphStore(); + now = 1_000_000; + idCounter = 0; + }); + + function createQueue(overrides: Partial = {}): AsyncPromoteQueue & PromoteTerminalJobClearer { + return new TripleStoreAsyncPromoteQueue(store, { + now: () => now, + idGenerator: () => `job-${++idCounter}`, + ...overrides, + }) as TripleStoreAsyncPromoteQueue; + } + + function makeRequest(overrides: Partial = {}): PromoteRequest { + return { contextGraphId: 'graphify', subGraphName: 'code', assertionName: 'shard-1', entities: 'all', ...overrides }; + } + + async function enqueueSucceeded(queue: AsyncPromoteQueue, req?: Partial): Promise { + const jobId = await queue.enqueue(makeRequest(req)); + const claimed = await queue.claimNext('worker-1'); + const token = claimed!.lease!.claimToken; + // Worker records commit progress — required before succeed(). + await queue.recordCommitMarker(jobId, token, 'swmInserted'); + await queue.recordCommitMarker(jobId, token, 'wmCleaned'); + await queue.recordCommitMarker(jobId, token, 'lifecycleStamped'); + await queue.recordCommitMarker(jobId, token, 'gossiped'); + await queue.succeed(jobId, token, { promotedCount: 1, succeededAt: now }); + return jobId; + } + + async function enqueueTerminalFailed(queue: AsyncPromoteQueue, req?: Partial): Promise { + const jobId = await queue.enqueue(makeRequest(req)); + const claimed = await queue.claimNext('worker-1'); + await queue.fail(jobId, claimed!.lease!.claimToken, { + message: 'permanent', retryable: false, classification: 'permanent', recordedAt: now, + }); + return jobId; + } + + it('clears an exact succeeded job (cleared); no other job changes', async () => { + const queue = createQueue(); + const target = await enqueueSucceeded(queue, { assertionName: 'a' }); + const other = await enqueueSucceeded(queue, { assertionName: 'b' }); + expect(await queue.clearTerminalJob(target)).toEqual({ outcome: 'cleared' }); + expect(await queue.getStatus(target)).toBeNull(); + expect((await queue.getStatus(other))?.state).toBe('succeeded'); // untouched + }); + + it('clears an exact terminal-failed job (incl. no retry_recovery carve-out)', async () => { + const queue = createQueue(); + const jobId = await enqueueTerminalFailed(queue); + expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await queue.getStatus(jobId)).toBeNull(); + }); + + it('rejects a queued job as nonterminal without mutation', async () => { + const queue = createQueue(); + const queued = await queue.enqueue(makeRequest()); + expect(await queue.clearTerminalJob(queued)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(queued))?.state).toBe('queued'); + }); + + it('rejects a running job as nonterminal without mutation', async () => { + const queue = createQueue(); + const runningId = await queue.enqueue(makeRequest()); + await queue.claimNext('worker-1'); + expect((await queue.getStatus(runningId))?.state).toBe('running'); + expect(await queue.clearTerminalJob(runningId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(runningId))?.state).toBe('running'); + }); + + it('rejects a failed_retrying job as nonterminal without mutation', async () => { + const queue = createQueue({ backoff: () => 10_000 }); + const retryingId = await queue.enqueue(makeRequest()); + const claimed = await queue.claimNext('worker-1'); + await queue.fail(retryingId, claimed!.lease!.claimToken, { + message: 'transient', retryable: true, classification: 'transient', recordedAt: now, + }); + expect((await queue.getStatus(retryingId))?.state).toBe('failed_retrying'); + expect(await queue.clearTerminalJob(retryingId)).toEqual({ outcome: 'rejected', reason: 'nonterminal' }); + expect((await queue.getStatus(retryingId))?.state).toBe('failed_retrying'); + }); + + it('is idempotent: an absent / already-cleared job returns already_absent', async () => { + const queue = createQueue(); + expect(await queue.clearTerminalJob('never-existed')).toEqual({ outcome: 'already_absent' }); + const jobId = await enqueueSucceeded(queue); + expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); + expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'already_absent' }); // repeat + }); + + it('rejects an empty or SPARQL-unsafe jobId as malformed without querying/mutating', async () => { + const queue = createQueue(); + expect(await queue.clearTerminalJob('')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await queue.clearTerminalJob(' ')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await queue.clearTerminalJob('bad id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await queue.clearTerminalJob('bad>id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); + }); + + // #1883 review (🟡): a state triple present but not a recognized enum value must be a + // bounded reject (unknown), never throw — and the parse itself is now try/catch-guarded. + it('rejects a subject whose state is not a known enum value as unknown, without throwing', async () => { + const queue = createQueue(); + await store.insert([ + { subject: jobSubject('bogus-1'), predicate: PROMOTE_STATE, object: literal('bogus_state'), graph: DEFAULT_PROMOTE_CONTROL_GRAPH_URI }, + ]); + await expect(queue.clearTerminalJob('bogus-1')).resolves.toEqual({ outcome: 'rejected', reason: 'unknown' }); + }); + + it('concurrent clears of one terminal job are deterministic: one cleared, rest already_absent, no other job affected', async () => { + const queue = createQueue(); + const target = await enqueueSucceeded(queue, { assertionName: 'a' }); + const other = await enqueueSucceeded(queue, { assertionName: 'b' }); + const results = await Promise.all([ + queue.clearTerminalJob(target), queue.clearTerminalJob(target), queue.clearTerminalJob(target), + ]); + expect(results.filter((r) => r.outcome === 'cleared')).toHaveLength(1); + expect(results.filter((r) => r.outcome === 'already_absent')).toHaveLength(2); + expect((await queue.getStatus(other))?.state).toBe('succeeded'); // never affected + }); +}); diff --git a/packages/publisher/vitest.unit.config.ts b/packages/publisher/vitest.unit.config.ts index 5eb6e0ec07..c8f3e3c822 100644 --- a/packages/publisher/vitest.unit.config.ts +++ b/packages/publisher/vitest.unit.config.ts @@ -22,6 +22,8 @@ export default defineConfig({ 'test/async-lift-intent-lookup.test.ts', 'test/async-lift-publish-options.test.ts', 'test/async-promote-queue.test.ts', + 'test/async-lift-terminal-clear.test.ts', + 'test/async-promote-terminal-clear.test.ts', 'test/lift-job-types.test.ts', 'test/multi-root-token-rows.test.ts', 'test/access-verification.test.ts', From 06b6580181ef5c683b8ed85fb2bd0ea08db9e2cb Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:05:50 +0200 Subject: [PATCH 199/292] refactor(chain): consolidate finalized read boundary --- .../src/control-object-signature-verifier.ts | 43 +--- .../src/current-finalized-evm-call-model.ts | 44 ++++ .../chain/src/current-finalized-evm-call.ts | 122 ++++------- .../src/current-finalized-evm-read-profile.ts | 3 +- packages/chain/src/nonqueueing-admission.ts | 35 ++++ .../src/strict-current-finalized-evm-rpc.ts | 197 +++++++----------- .../current-finalized-evm-call.unit.test.ts | 34 ++- ...ict-current-finalized-evm-rpc.unit.test.ts | 84 +++++++- 8 files changed, 323 insertions(+), 239 deletions(-) create mode 100644 packages/chain/src/current-finalized-evm-call-model.ts create mode 100644 packages/chain/src/nonqueueing-admission.ts diff --git a/packages/chain/src/control-object-signature-verifier.ts b/packages/chain/src/control-object-signature-verifier.ts index 34b21d40ab..e227da4b69 100644 --- a/packages/chain/src/control-object-signature-verifier.ts +++ b/packages/chain/src/control-object-signature-verifier.ts @@ -14,6 +14,11 @@ import { } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; +import { + type CurrentFinalizedEvmCallRequestV1, + type CurrentFinalizedEvmCallResultV1, + type CurrentFinalizedEvmCallV1, +} from './current-finalized-evm-call-model.js'; import { CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, @@ -33,6 +38,11 @@ export { CurrentFinalizedEvmCallErrorV1, type CurrentFinalizedEvmCallErrorCodeV1, } from './current-finalized-evm-read-profile.js'; +export { + type CurrentFinalizedEvmCallRequestV1, + type CurrentFinalizedEvmCallResultV1, + type CurrentFinalizedEvmCallV1, +} from './current-finalized-evm-call-model.js'; export const EIP1271_MAGIC_VALUE_V1 = '0x1626ba7e' as const; export const EIP1271_CANONICAL_ABI_RETURN_V1 = @@ -63,39 +73,6 @@ const EIP1271_INTERFACE = new ethers.Interface([ ]); declare const VERIFIED_CONTROL_ENVELOPE_ISSUER_SIGNATURE_BRAND_V1: unique symbol; -export interface CurrentFinalizedEvmCallRequestV1 { - readonly chainId: ChainIdV1; - readonly to: EvmAddressV1; - readonly from: typeof CONTROL_EIP1271_CALL_FROM_V1; - readonly data: string; - readonly gasLimit: bigint; - readonly maxReturnBytes: number; - readonly maxRpcResponseBytes: number; - readonly attemptTimeoutMs: number; - readonly maxAttempts: number; - readonly endpointAttemptPolicy: typeof CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1; - readonly maxConcurrentCallsPerChain: number; - readonly totalDeadlineMs: number; - readonly ccipReadEnabled: false; - readonly signal: AbortSignal; -} - -export interface CurrentFinalizedEvmCallResultV1 { - readonly chainId: ChainIdV1; - readonly blockNumber: BlockNumberV1; - readonly blockHash: Digest32V1; - readonly returnData: string; -} - -/** - * Trusted local adapter boundary. Implementations select the receiver's current - * finalized block and execute the exact request at that block; peer data never - * supplies an RPC URL or block selector. - */ -export interface CurrentFinalizedEvmCallV1 { - (request: CurrentFinalizedEvmCallRequestV1): Promise; -} - export const CONTROL_SIGNATURE_VERIFICATION_ERROR_CODES_V1 = Object.freeze([ 'CONTROL_SIGNATURE_ENVELOPE_INVALID', 'CONTROL_SIGNATURE_EIP191_NON_CANONICAL', diff --git a/packages/chain/src/current-finalized-evm-call-model.ts b/packages/chain/src/current-finalized-evm-call-model.ts new file mode 100644 index 0000000000..159b792827 --- /dev/null +++ b/packages/chain/src/current-finalized-evm-call-model.ts @@ -0,0 +1,44 @@ +import { + type BlockNumberV1, + type ChainIdV1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, +} from './current-finalized-evm-read-profile.js'; + +/** Canonical single-call projection over the generic finalized-read transport. */ +export interface CurrentFinalizedEvmCallRequestV1 { + readonly chainId: ChainIdV1; + readonly to: EvmAddressV1; + readonly from: typeof CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1; + readonly data: string; + readonly gasLimit: bigint; + readonly maxReturnBytes: number; + readonly maxRpcResponseBytes: number; + readonly attemptTimeoutMs: number; + readonly maxAttempts: number; + readonly endpointAttemptPolicy: typeof CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1; + readonly maxConcurrentCallsPerChain: number; + readonly totalDeadlineMs: number; + readonly ccipReadEnabled: false; + readonly signal: AbortSignal; +} + +export interface CurrentFinalizedEvmCallResultV1 { + readonly chainId: ChainIdV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly returnData: string; +} + +/** + * Trusted local single-call seam. EIP-1271 is one ABI specialization; its + * exact calldata and 32-byte magic-result rules stay in the verifier. + */ +export interface CurrentFinalizedEvmCallV1 { + (request: CurrentFinalizedEvmCallRequestV1): Promise; +} diff --git a/packages/chain/src/current-finalized-evm-call.ts b/packages/chain/src/current-finalized-evm-call.ts index a30c81289a..bb683e0893 100644 --- a/packages/chain/src/current-finalized-evm-call.ts +++ b/packages/chain/src/current-finalized-evm-call.ts @@ -4,23 +4,24 @@ import { } from '@origintrail-official/dkg-core'; import { - CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, - CONTROL_EIP1271_CALL_FROM_V1, - CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, - CONTROL_EIP1271_GAS_LIMIT_V1, - CONTROL_EIP1271_MAX_ATTEMPTS_V1, - CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, - CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, - CONTROL_EIP1271_MAX_RETURN_BYTES_V1, - CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, type CurrentFinalizedEvmCallRequestV1, type CurrentFinalizedEvmCallResultV1, type CurrentFinalizedEvmCallV1, -} from './control-object-signature-verifier.js'; +} from './current-finalized-evm-call-model.js'; import { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, + CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; +import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; import { assertCanonicalNonzeroEvmAddress, isAbortSignal, @@ -69,8 +70,9 @@ export function createCurrentFinalizedEvmCallRouterV1( registrations: readonly CurrentFinalizedEvmChainAdapterRegistrationV1[], ): CurrentFinalizedEvmCallV1 { const adapters = snapshotAdapterRegistry(registrations); - const inFlightByChain = new Map(); - for (const chainId of adapters.keys()) inFlightByChain.set(chainId, 0); + const admission = createNonqueueingAdmissionGateV1( + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + ); const route: CurrentFinalizedEvmCallV1 = async (input) => { const request = snapshotCurrentFinalizedEvmCallRequestV1(input); @@ -82,31 +84,22 @@ export function createCurrentFinalizedEvmCallRouterV1( ); } - const active = inFlightByChain.get(request.chainId) ?? 0; - if (active >= CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1) { - throw new CurrentFinalizedEvmCallErrorV1( - 'concurrency-saturated', - `Chain ${request.chainId} already has ${active} current-finalized calls in flight`, - ); - } - inFlightByChain.set(request.chainId, active + 1); - // Do not race this operation with request.signal. The verifier may stop // awaiting after caller cancellation, but this permit remains held until // the actual adapter operation settles (including adapters that ignore // abort), so abandoned work cannot evade the four-call ceiling. - const operation = Promise.resolve() - .then(() => adapter(request)) - .catch((cause: unknown) => { - throw snapshotAdapterFailure(cause); - }); - - try { - return await operation; - } finally { - const remaining = (inFlightByChain.get(request.chainId) ?? 1) - 1; - inFlightByChain.set(request.chainId, Math.max(0, remaining)); - } + return admission.run( + request.chainId, + () => Promise.resolve() + .then(() => adapter(request)) + .catch((cause: unknown) => { + throw snapshotAdapterFailure(cause); + }), + (active) => new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + `Chain ${request.chainId} already has ${active} current-finalized calls in flight`, + ), + ); }; return Object.freeze(route); @@ -171,32 +164,39 @@ export function snapshotCurrentFinalizedEvmCallRequestV1( const record = snapshotExactDataRecord(input, REQUEST_KEYS); assertCanonicalChainId(record.chainId, 'current-finalized request chainId'); assertCanonicalNonzeroEvmAddress(record.to, 'current-finalized request to'); - if (record.from !== CONTROL_EIP1271_CALL_FROM_V1) throw new Error('wrong from'); + if (record.from !== CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1) throw new Error('wrong from'); if (typeof record.data !== 'string') throw new Error('call data is not a string'); - assertCanonicalEip1271CallData(record.data); - if (record.gasLimit !== CONTROL_EIP1271_GAS_LIMIT_V1) throw new Error('wrong gas limit'); - if (record.maxReturnBytes !== CONTROL_EIP1271_MAX_RETURN_BYTES_V1) { + assertCanonicalAbiCallData(record.data); + if (record.gasLimit !== CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1) { + throw new Error('wrong gas limit'); + } + if ( + typeof record.maxReturnBytes !== 'number' + || !Number.isSafeInteger(record.maxReturnBytes) + || record.maxReturnBytes < 1 + || record.maxReturnBytes > CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 + ) { throw new Error('wrong return cap'); } - if (record.maxRpcResponseBytes !== CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1) { + if (record.maxRpcResponseBytes !== CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1) { throw new Error('wrong RPC response cap'); } - if (record.attemptTimeoutMs !== CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1) { + if (record.attemptTimeoutMs !== CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1) { throw new Error('wrong attempt timeout'); } - if (record.maxAttempts !== CONTROL_EIP1271_MAX_ATTEMPTS_V1) { + if (record.maxAttempts !== CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { throw new Error('wrong attempt count'); } - if (record.endpointAttemptPolicy !== CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1) { + if (record.endpointAttemptPolicy !== CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1) { throw new Error('wrong endpoint policy'); } if ( record.maxConcurrentCallsPerChain - !== CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 + !== CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1 ) { throw new Error('wrong concurrency ceiling'); } - if (record.totalDeadlineMs !== CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1) { + if (record.totalDeadlineMs !== CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1) { throw new Error('wrong total deadline'); } if (record.ccipReadEnabled !== false) throw new Error('CCIP Read must be disabled'); @@ -226,39 +226,9 @@ export function snapshotCurrentFinalizedEvmCallRequestV1( } } -function assertCanonicalEip1271CallData(data: string): void { - if (!/^0x[0-9a-f]+$/.test(data)) { - throw new Error('call data is not canonical lowercase hex'); - } - const bytes = data.slice(2); - const selectorLength = 8; - const wordLength = 64; - const fixedLength = selectorLength + (wordLength * 3); - if ( - bytes.slice(0, selectorLength) !== '1626ba7e' - || bytes.length < fixedLength - || bytes.slice(selectorLength + wordLength, selectorLength + (wordLength * 2)) - !== `${'0'.repeat(62)}40` - ) { - throw new Error('call data is not canonical isValidSignature(bytes32,bytes) ABI'); - } - - const signatureLengthWord = bytes.slice( - selectorLength + (wordLength * 2), - fixedLength, - ); - const signatureLength = BigInt(`0x${signatureLengthWord}`); - if (signatureLength < 1n || signatureLength > 4096n) { - throw new Error('EIP-1271 signature length is outside 1..4096 bytes'); - } - const paddedSignatureHexLength = Math.ceil(Number(signatureLength) / 32) * wordLength; - const tail = bytes.slice(fixedLength); - if (tail.length !== paddedSignatureHexLength) { - throw new Error('EIP-1271 signature tail has noncanonical length'); - } - const signatureHexLength = Number(signatureLength) * 2; - if (!/^0*$/.test(tail.slice(signatureHexLength))) { - throw new Error('EIP-1271 signature tail has nonzero ABI padding'); +function assertCanonicalAbiCallData(data: string): void { + if (!/^0x[0-9a-f]{8}(?:[0-9a-f]{2})*$/.test(data)) { + throw new Error('call data is not canonical ABI calldata'); } } diff --git a/packages/chain/src/current-finalized-evm-read-profile.ts b/packages/chain/src/current-finalized-evm-read-profile.ts index bc5b3485a4..609f89a6ce 100644 --- a/packages/chain/src/current-finalized-evm-read-profile.ts +++ b/packages/chain/src/current-finalized-evm-read-profile.ts @@ -16,8 +16,7 @@ export const CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1 = 4; export const CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1 = 'distinct-configured-endpoints-no-same-endpoint-retry' as const; export const CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 = 4; -// ContextGraphStorage.getContextGraph includes a participant array capped at -// 256 entries on chain; its maximal canonical ABI result is about 8.5 KiB. +// Absolute transport ceiling. Domain readers own tighter ABI-specific caps. export const CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 = 16 * 1024; export const CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1 = Object.freeze([ diff --git a/packages/chain/src/nonqueueing-admission.ts b/packages/chain/src/nonqueueing-admission.ts new file mode 100644 index 0000000000..fe56630949 --- /dev/null +++ b/packages/chain/src/nonqueueing-admission.ts @@ -0,0 +1,35 @@ +export interface NonqueueingAdmissionGateV1 { + run( + key: K, + operation: () => Promise, + saturated: (active: number) => Error, + ): Promise; +} + +/** Shared non-queueing permit state used by finalized routers and transports. */ +export function createNonqueueingAdmissionGateV1( + limit: number, +): NonqueueingAdmissionGateV1 { + if (!Number.isSafeInteger(limit) || limit < 1) { + throw new TypeError('Non-queueing admission limit must be a positive safe integer'); + } + const activeByKey = new Map(); + const gate: NonqueueingAdmissionGateV1 = Object.freeze({ + async run( + key: K, + operation: () => Promise, + saturated: (active: number) => Error, + ): Promise { + const active = activeByKey.get(key) ?? 0; + if (active >= limit) throw saturated(active); + activeByKey.set(key, active + 1); + try { + return await operation(); + } finally { + const remaining = (activeByKey.get(key) ?? 1) - 1; + activeByKey.set(key, Math.max(0, remaining)); + } + }, + }); + return gate; +} diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 15acab7354..1778266887 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -6,9 +6,6 @@ import { type EvmAddressV1, } from '@origintrail-official/dkg-core'; -import { - CONTROL_EIP1271_MAX_RETURN_BYTES_V1, -} from './control-object-signature-verifier.js'; import { CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, @@ -31,6 +28,7 @@ import { snapshotDenseDataArray, snapshotExactDataRecord, } from './strict-local-data.js'; +import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ 'eip1898', @@ -137,7 +135,10 @@ export function createStrictCurrentFinalizedEvmChainAdapterV1( })]), signal: request.signal, }); - const returnData = assertEip1271ReturnData(result.returnData[0]); + const returnData = result.returnData[0]; + if (returnData === undefined) { + throw unavailable('Single-call finalized read produced no contract result'); + } return Object.freeze({ chainId: result.chainId, blockNumber: result.blockNumber, @@ -160,7 +161,9 @@ export function createStrictCurrentFinalizedEvmReadV1( input: StrictCurrentFinalizedEvmRpcConfigV1, ): StrictCurrentFinalizedEvmReadV1 { const config = snapshotConfig(input); - let activeReads = 0; + const admission = createNonqueueingAdmissionGateV1( + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + ); const read: StrictCurrentFinalizedEvmReadV1 = async (inputRequest) => { const request = snapshotReadRequest(inputRequest); @@ -173,47 +176,15 @@ export function createStrictCurrentFinalizedEvmReadV1( if (request.signal.aborted) { throw cancelled('Current-finalized EVM call was cancelled before transport admission'); } - if (activeReads >= CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1) { - throw new CurrentFinalizedEvmCallErrorV1( - 'concurrency-saturated', - `Chain ${request.chainId} already has ${activeReads} finalized reads in flight`, + return admission.run(request.chainId, async () => { + const totalDeadline = createDeadlineScope( + request.signal, + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, + 'current-finalized total deadline', ); - } - activeReads += 1; - - const totalDeadline = createDeadlineScope( - request.signal, - CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, - 'current-finalized total deadline', - ); - let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - - try { - for (let index = 0; index < config.endpoints.length; index += 1) { - if (request.signal.aborted) { - throw cancelled('Current-finalized EVM call was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - - const attemptDeadline = createDeadlineScope( - totalDeadline.signal, - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - `current-finalized endpoint attempt ${index + 1}`, - ); - try { - const result = await executeEndpointAttempt( - config, - config.endpoints[index]!, - request, - attemptDeadline.signal, - ); - // Close races where transport completion and abort/deadline become - // observable in the same turn. A late response never escapes merely - // because its promise callback ran before the timer callback. + let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + try { + for (let index = 0; index < config.endpoints.length; index += 1) { if (request.signal.aborted) { throw cancelled('Current-finalized EVM call was cancelled'); } @@ -222,39 +193,67 @@ export function createStrictCurrentFinalizedEvmReadV1( `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, ); } - if (attemptDeadline.timedOut()) { - throw timedOut( - `Current-finalized endpoint attempt exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, + + const attemptDeadline = createDeadlineScope( + totalDeadline.signal, + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + `current-finalized endpoint attempt ${index + 1}`, + ); + try { + const result = await executeEndpointAttempt( + config, + config.endpoints[index]!, + request, + attemptDeadline.signal, + ); + // Close races where transport completion and abort/deadline become + // observable in the same turn. A late response never escapes merely + // because its promise callback ran before the timer callback. + if (request.signal.aborted) { + throw cancelled('Current-finalized EVM call was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + if (attemptDeadline.timedOut()) { + throw timedOut( + `Current-finalized endpoint attempt exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, + ); + } + return result; + } catch (cause) { + const failure = classifyAttemptFailure( + cause, + request.signal, + totalDeadline, + attemptDeadline, ); + if (isTerminalAttemptFailure(failure)) throw failure; + lastRetryableFailure = failure; + } finally { + attemptDeadline.close(); } - return result; - } catch (cause) { - const failure = classifyAttemptFailure( - cause, - request.signal, - totalDeadline, - attemptDeadline, - ); - if (isTerminalAttemptFailure(failure)) throw failure; - lastRetryableFailure = failure; - } finally { - attemptDeadline.close(); } - } - if (request.signal.aborted) { - throw cancelled('Current-finalized EVM call was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, - ); + if (request.signal.aborted) { + throw cancelled('Current-finalized EVM call was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + throw lastRetryableFailure + ?? unavailable('No configured current-finalized endpoint succeeded'); + } finally { + totalDeadline.close(); } - throw lastRetryableFailure ?? unavailable('No configured current-finalized endpoint succeeded'); - } finally { - totalDeadline.close(); - activeReads = Math.max(0, activeReads - 1); - } + }, (active) => new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + `Chain ${request.chainId} already has ${active} finalized reads in flight`, + )); }; return Object.freeze(read); @@ -563,17 +562,6 @@ function parseContractReturn(input: unknown, maxBytes: number): string { return input; } -function assertEip1271ReturnData(input: string | undefined): string { - if (input === undefined || (input.length - 2) / 2 !== CONTROL_EIP1271_MAX_RETURN_BYTES_V1) { - const byteLength = input === undefined ? 0 : (input.length - 2) / 2; - throw new CurrentFinalizedEvmCallErrorV1( - 'malformed-return', - `EIP-1271 eth_call returned ${byteLength} bytes; exactly 32 are required`, - ); - } - return input; -} - function parseCanonicalQuantity(input: unknown, maximum: bigint): bigint { if (typeof input !== 'string' || !CANONICAL_LOWER_QUANTITY.test(input)) { throw new Error('not a canonical lowercase JSON-RPC quantity'); @@ -790,34 +778,13 @@ function assertConfigDataProperties(input: Record): void { function snapshotNormalizedEndpoints(input: unknown): readonly string[] { const normalized: string[] = []; try { - if (!Array.isArray(input)) throw new Error('not an array'); - const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); - if ( - lengthDescriptor === undefined - || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') - || typeof lengthDescriptor.value !== 'number' - || !Number.isSafeInteger(lengthDescriptor.value) - || lengthDescriptor.value < 0 - ) { - throw new Error('invalid length'); - } - const length = lengthDescriptor.value; - const ownKeys = Reflect.ownKeys(input); - if ( - ownKeys.length !== length + 1 - || ownKeys.some((key) => ( - typeof key !== 'string' - || (key !== 'length' && !isCanonicalArrayIndex(key, length)) - )) - ) { - throw new Error('not a dense ordinary array'); - } - for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - throw new Error('entry is not a data property'); - } - const endpoint = normalizeEndpoint(descriptor.value); + const endpoints = snapshotDenseDataArray(input, { + label: 'Strict current-finalized RPC endpoints', + minLength: 1, + maxLength: CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, + }); + for (const entry of endpoints) { + const endpoint = normalizeEndpoint(entry); if (!normalized.includes(endpoint)) normalized.push(endpoint); } } catch (cause) { @@ -832,12 +799,6 @@ function snapshotNormalizedEndpoints(input: unknown): readonly string[] { return Object.freeze(normalized); } -function isCanonicalArrayIndex(key: string, length: number): boolean { - if (!/^(?:0|[1-9][0-9]*)$/.test(key)) return false; - const index = Number(key); - return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key; -} - function normalizeEndpoint(input: unknown): string { if (typeof input !== 'string' || input.trim() === '') { throw new TypeError('Strict current-finalized RPC endpoint must be a nonempty URL string'); diff --git a/packages/chain/test/current-finalized-evm-call.unit.test.ts b/packages/chain/test/current-finalized-evm-call.unit.test.ts index 5da0a0e10b..5e8aa688fd 100644 --- a/packages/chain/test/current-finalized-evm-call.unit.test.ts +++ b/packages/chain/test/current-finalized-evm-call.unit.test.ts @@ -23,6 +23,9 @@ import { type CurrentFinalizedEvmChainAdapterRegistrationV1, type CurrentFinalizedEvmChainAdapterV1, } from '../src/current-finalized-evm-call.js'; +import { + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, +} from '../src/current-finalized-evm-read-profile.js'; const CHAIN_A = '20430' as ChainIdV1; const CHAIN_B = '100' as ChainIdV1; @@ -125,7 +128,6 @@ describe('RFC-64 current-finalized EVM call router', () => { it.each([ ['from', '0x2222222222222222222222222222222222222222'], ['gasLimit', CONTROL_EIP1271_GAS_LIMIT_V1 + 1n], - ['maxReturnBytes', CONTROL_EIP1271_MAX_RETURN_BYTES_V1 + 1], ['maxRpcResponseBytes', CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1 + 1], ['attemptTimeoutMs', CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 + 1], ['maxAttempts', CONTROL_EIP1271_MAX_ATTEMPTS_V1 + 1], @@ -143,6 +145,19 @@ describe('RFC-64 current-finalized EVM call router', () => { expect(adapter).not.toHaveBeenCalled(); }); + it.each([ + 0, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 + 1, + ])('rejects maxReturnBytes=%s outside the generic transport envelope', async (value) => { + const adapter = vi.fn(async () => RESULT_A); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + + await expect(router(request({ maxReturnBytes: value }))).rejects.toMatchObject({ + code: 'rpc-unavailable', + }); + expect(adapter).not.toHaveBeenCalled(); + }); + it('rejects malformed routing fields and any peer URL or block selector', async () => { const adapter = vi.fn(async () => RESULT_A); const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); @@ -152,9 +167,6 @@ describe('RFC-64 current-finalized EVM call router', () => { request({ to: '0xABC' as EvmAddressV1 }), request({ data: '0xABC' }), request({ data: '0x1234' }), - request({ data: CANONICAL_CALL_DATA.replace('1626ba7e', 'ffffffff') }), - request({ data: `${CANONICAL_CALL_DATA}00` }), - request({ data: `${CANONICAL_CALL_DATA.slice(0, -2)}01` }), request({ signal: {} as AbortSignal }), { ...request(), rpcUrl: 'https://peer.invalid' }, { ...request(), blockTag: 'latest' }, @@ -186,6 +198,20 @@ describe('RFC-64 current-finalized EVM call router', () => { expect(adapter).not.toHaveBeenCalled(); }); + it('keeps the routed call seam ABI-generic while EIP-1271 owns its specialization', async () => { + const adapter = vi.fn(async () => RESULT_A); + const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); + + await expect(router(request({ + data: '0x12345678', + maxReturnBytes: 64, + }))).resolves.toBe(RESULT_A); + expect(adapter).toHaveBeenCalledWith(expect.objectContaining({ + data: '0x12345678', + maxReturnBytes: 64, + })); + }); + it('admits four calls per chain and rejects the fifth immediately without queueing', async () => { const pending = Array.from({ length: 5 }, () => deferred()); let callIndex = 0; diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index a0508a3067..9934fb2229 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -25,7 +25,9 @@ import { CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, } from '../src/current-finalized-evm-read-profile.js'; @@ -266,6 +268,28 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { }); for (const request of [ + { chainId: CHAIN_ID, calls: [], signal: new AbortController().signal }, + { + chainId: CHAIN_ID, + calls: Array.from( + { length: CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 + 1 }, + () => validCall, + ), + signal: new AbortController().signal, + }, + { + chainId: CHAIN_ID, + calls: [{ ...validCall, maxReturnBytes: 0 }], + signal: new AbortController().signal, + }, + { + chainId: CHAIN_ID, + calls: [{ + ...validCall, + maxReturnBytes: CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 + 1, + }], + signal: new AbortController().signal, + }, { chainId: CHAIN_ID, calls: sparse, signal: new AbortController().signal }, { chainId: CHAIN_ID, calls: [accessor], signal: new AbortController().signal }, { chainId: CHAIN_ID, calls: [validCall], signal: {} }, @@ -301,6 +325,53 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { await expect(Promise.all(active)).resolves.toHaveLength(4); }); + it('holds each read permit until every started parallel code check settles', async () => { + const siblingGate = deferred(); + const allSiblingsStarted = deferred(); + let siblingsStarted = 0; + const baseHandler = successfulHandler(); + const server = await startRpcServer(async (call, response, request) => { + if (call.method !== 'eth_getCode') { + await baseHandler(call, response, request); + return; + } + if (call.params[0] === TO) { + sendResult(response, call, '0x'); + return; + } + siblingsStarted += 1; + if (siblingsStarted === CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1) { + allSiblingsStarted.resolve(undefined); + } + await siblingGate.promise; + sendResult(response, call, '0x6000'); + }); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + const request = () => ({ + chainId: CHAIN_ID, + calls: [ + { to: TO, data: FIRST_READ_DATA, maxReturnBytes: 32 }, + { to: OTHER_TO, data: SECOND_READ_DATA, maxReturnBytes: 32 }, + ], + signal: new AbortController().signal, + }); + const active = Array.from( + { length: CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1 }, + () => read(request()), + ); + + await allSiblingsStarted.promise; + await expect(read(request())).rejects.toMatchObject({ code: 'concurrency-saturated' }); + siblingGate.resolve(undefined); + await Promise.all(active.map(async (operation) => { + await expect(operation).rejects.toMatchObject({ code: 'no-code' }); + })); + await expect(read(request())).rejects.toMatchObject({ code: 'no-code' }); + }); + it('uses the configured endpoint once, in exact EIP-1898 request order and shape', async () => { const server = await startRpcServer(successfulHandler()); const configuredEndpoints = [server.url]; @@ -580,14 +651,15 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { expect(second.calls).toHaveLength(0); }); - it('rejects malformed contract returns but preserves exact wrong magic for the verifier', async () => { - const malformed = await startRpcServer(successfulHandler({ returnData: '0x1626ba7e' })); - const malformedAdapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + it('preserves bounded ABI returns, including EIP-1271 short and wrong magic, for the verifier', async () => { + const short = await startRpcServer(successfulHandler({ returnData: '0x1626ba7e' })); + const shortAdapter = createStrictCurrentFinalizedEvmChainAdapterV1({ chainId: CHAIN_ID, - endpoints: [malformed.url], + endpoints: [short.url], + }); + await expect(shortAdapter(fixedRequest())).resolves.toMatchObject({ + returnData: '0x1626ba7e', }); - await expect(malformedAdapter(fixedRequest())) - .rejects.toMatchObject({ code: 'malformed-return' }); const wrong = await startRpcServer(successfulHandler({ returnData: WRONG_MAGIC_RETURN })); const wrongAdapter = createStrictCurrentFinalizedEvmChainAdapterV1({ From bc4f14c8f76212d642b35646e9a34cbac72c46d7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:12:12 +0200 Subject: [PATCH 200/292] feat(chain): retain authenticated finalized revert data --- .../src/strict-current-finalized-evm-rpc.ts | 34 +++++++++++++--- ...ict-current-finalized-evm-rpc.unit.test.ts | 40 ++++++++++++++++--- 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 1778266887..420ddcb28d 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -96,6 +96,7 @@ interface DeadlineScope { interface RpcErrorEnvelopeV1 { readonly code: number; readonly message: string; + readonly data?: string; } const CONFIG_REQUIRED_KEYS = Object.freeze(['chainId', 'endpoints'] as const); @@ -108,9 +109,18 @@ const MAX_U256 = 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); +const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); const READ_REQUEST_KEYS = Object.freeze(['calls', 'chainId', 'signal'] as const); const READ_CALL_KEYS = Object.freeze(['data', 'maxReturnBytes', 'to'] as const); +/** Package-internal evidence available only for errors minted by this transport. */ +export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { + if ((typeof error !== 'object' && typeof error !== 'function') || error === null) { + return undefined; + } + return AUTHENTICATED_REVERT_DATA_V1.get(error); +} + /** * Build one strict raw-JSON-RPC adapter from trusted local chain configuration. * @@ -580,7 +590,15 @@ function parseRpcError(input: unknown): RpcErrorEnvelopeV1 | undefined { ) { return undefined; } - return Object.freeze({ code: input.code, message: input.message }); + const data = typeof input.data === 'string' + && CANONICAL_LOWER_HEX_BYTES.test(input.data) + ? input.data + : undefined; + return Object.freeze({ + code: input.code, + message: input.message, + ...(data === undefined ? {} : { data }), + }); } function classifyJsonRpcError( @@ -595,10 +613,7 @@ function classifyJsonRpcError( // must win so callers cannot misclassify invalid execution evidence as merely // unsupported. if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { - return new CurrentFinalizedEvmCallErrorV1( - 'revert', - 'Contract call reverted at the resolved finalized anchor', - ); + return revertedAtFinalizedAnchor(error.data); } if ( method === 'eth_call' @@ -843,6 +858,15 @@ function anchorDependentResourceLimited(message: string): CurrentFinalizedEvmCal return error; } +function revertedAtFinalizedAnchor(data?: string): CurrentFinalizedEvmCallErrorV1 { + const error = new CurrentFinalizedEvmCallErrorV1( + 'revert', + 'Contract call reverted at the resolved finalized anchor', + ); + if (data !== undefined) AUTHENTICATED_REVERT_DATA_V1.set(error, data); + return error; +} + function isAnchorDependentResourceLimit( error: CurrentFinalizedEvmCallErrorV1, ): boolean { diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 9934fb2229..ee3ceebc66 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -17,6 +17,7 @@ import { CONTROL_EIP1271_MAX_RETURN_BYTES_V1, CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + CurrentFinalizedEvmCallErrorV1, type CurrentFinalizedEvmCallRequestV1, } from '../src/control-object-signature-verifier.js'; import { @@ -34,6 +35,7 @@ import { import { createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, + readStrictCurrentFinalizedEvmRevertDataV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from '../src/strict-current-finalized-evm-rpc.js'; @@ -598,14 +600,27 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { }); it('stops on a deterministic contract revert without contacting a later endpoint', async () => { - const first = await startRpcServer(successfulHandler({ revert: true })); + const revertData = `0x7e273289${'0'.repeat(63)}1`; + const first = await startRpcServer(successfulHandler({ + callError: { code: 3, message: 'execution reverted', data: revertData }, + })); const second = await startRpcServer(successfulHandler()); const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ chainId: CHAIN_ID, endpoints: [first.url, second.url], }); - await expect(adapter(fixedRequest())).rejects.toMatchObject({ code: 'revert' }); + let caught: unknown; + try { + await adapter(fixedRequest()); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ code: 'revert' }); + expect(readStrictCurrentFinalizedEvmRevertDataV1(caught)).toBe(revertData); + expect(readStrictCurrentFinalizedEvmRevertDataV1( + new CurrentFinalizedEvmCallErrorV1('revert', 'forged'), + )).toBeUndefined(); expect(second.calls).toHaveLength(0); }); @@ -766,7 +781,11 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { interface SuccessfulHandlerOptions { readonly blockHash?: string | (() => string); - readonly callError?: { readonly code: number; readonly message: string }; + readonly callError?: { + readonly code: number; + readonly message: string; + readonly data?: string; + }; readonly code?: string; readonly outOfGas?: boolean; readonly returnData?: string; @@ -791,7 +810,13 @@ function successfulHandler(options: SuccessfulHandlerOptions = {}): LoopbackHand return; case 'eth_call': if (options.callError) { - sendError(response, call, options.callError.code, options.callError.message); + sendError( + response, + call, + options.callError.code, + options.callError.message, + options.callError.data, + ); } else if (options.outOfGas) { sendError(response, call, -32000, 'out of gas'); } else if (options.revert) { @@ -857,9 +882,14 @@ function sendError( request: JsonRpcRequest, code: number, message: string, + data?: string, ): void { response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code, message } })); + response.end(JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + error: { code, message, ...(data === undefined ? {} : { data }) }, + })); } async function closeServer(server: Server): Promise { From 24857516a3e5561afd36ea6a3adb4db6932bf273 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:25:01 +0200 Subject: [PATCH 201/292] refactor(chain): make finalized call policy transport-owned --- .../src/control-object-signature-verifier.ts | 9 ---- .../src/current-finalized-evm-call-model.ts | 14 ----- .../chain/src/current-finalized-evm-call.ts | 51 ------------------- .../src/current-finalized-evm-read-profile.ts | 3 +- .../src/strict-current-finalized-evm-rpc.ts | 23 +++++---- ...rol-object-signature-verifier.unit.test.ts | 23 +++------ .../current-finalized-evm-call.unit.test.ts | 40 ++++----------- ...ict-current-finalized-evm-rpc.unit.test.ts | 40 ++++++++------- 8 files changed, 56 insertions(+), 147 deletions(-) diff --git a/packages/chain/src/control-object-signature-verifier.ts b/packages/chain/src/control-object-signature-verifier.ts index e227da4b69..56f442b1ad 100644 --- a/packages/chain/src/control-object-signature-verifier.ts +++ b/packages/chain/src/control-object-signature-verifier.ts @@ -214,20 +214,11 @@ export async function verifyControlEnvelopeIssuerSignatureV1( const request = Object.freeze({ chainId: envelope.signatureEvidence.chainId as ChainIdV1, to: envelope.signatureEvidence.contractAddress as EvmAddressV1, - from: CONTROL_EIP1271_CALL_FROM_V1, data: EIP1271_INTERFACE.encodeFunctionData('isValidSignature', [ envelope.objectDigest, envelope.signature, ]).toLowerCase(), - gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, - maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, - attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, - maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, - endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, - maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, - totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, - ccipReadEnabled: false, signal: controller.signal, } satisfies CurrentFinalizedEvmCallRequestV1); diff --git a/packages/chain/src/current-finalized-evm-call-model.ts b/packages/chain/src/current-finalized-evm-call-model.ts index 159b792827..b11310dfd9 100644 --- a/packages/chain/src/current-finalized-evm-call-model.ts +++ b/packages/chain/src/current-finalized-evm-call-model.ts @@ -5,26 +5,12 @@ import { type EvmAddressV1, } from '@origintrail-official/dkg-core'; -import { - CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, -} from './current-finalized-evm-read-profile.js'; - /** Canonical single-call projection over the generic finalized-read transport. */ export interface CurrentFinalizedEvmCallRequestV1 { readonly chainId: ChainIdV1; readonly to: EvmAddressV1; - readonly from: typeof CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1; readonly data: string; - readonly gasLimit: bigint; readonly maxReturnBytes: number; - readonly maxRpcResponseBytes: number; - readonly attemptTimeoutMs: number; - readonly maxAttempts: number; - readonly endpointAttemptPolicy: typeof CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1; - readonly maxConcurrentCallsPerChain: number; - readonly totalDeadlineMs: number; - readonly ccipReadEnabled: false; readonly signal: AbortSignal; } diff --git a/packages/chain/src/current-finalized-evm-call.ts b/packages/chain/src/current-finalized-evm-call.ts index bb683e0893..04c6ff254f 100644 --- a/packages/chain/src/current-finalized-evm-call.ts +++ b/packages/chain/src/current-finalized-evm-call.ts @@ -9,15 +9,8 @@ import { type CurrentFinalizedEvmCallV1, } from './current-finalized-evm-call-model.js'; import { - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, - CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, - CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; @@ -30,20 +23,11 @@ import { } from './strict-local-data.js'; const REQUEST_KEYS = Object.freeze([ - 'attemptTimeoutMs', - 'ccipReadEnabled', 'chainId', 'data', - 'endpointAttemptPolicy', - 'from', - 'gasLimit', - 'maxAttempts', - 'maxConcurrentCallsPerChain', 'maxReturnBytes', - 'maxRpcResponseBytes', 'signal', 'to', - 'totalDeadlineMs', ] as const); /** @@ -164,12 +148,8 @@ export function snapshotCurrentFinalizedEvmCallRequestV1( const record = snapshotExactDataRecord(input, REQUEST_KEYS); assertCanonicalChainId(record.chainId, 'current-finalized request chainId'); assertCanonicalNonzeroEvmAddress(record.to, 'current-finalized request to'); - if (record.from !== CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1) throw new Error('wrong from'); if (typeof record.data !== 'string') throw new Error('call data is not a string'); assertCanonicalAbiCallData(record.data); - if (record.gasLimit !== CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1) { - throw new Error('wrong gas limit'); - } if ( typeof record.maxReturnBytes !== 'number' || !Number.isSafeInteger(record.maxReturnBytes) @@ -178,44 +158,13 @@ export function snapshotCurrentFinalizedEvmCallRequestV1( ) { throw new Error('wrong return cap'); } - if (record.maxRpcResponseBytes !== CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1) { - throw new Error('wrong RPC response cap'); - } - if (record.attemptTimeoutMs !== CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1) { - throw new Error('wrong attempt timeout'); - } - if (record.maxAttempts !== CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { - throw new Error('wrong attempt count'); - } - if (record.endpointAttemptPolicy !== CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1) { - throw new Error('wrong endpoint policy'); - } - if ( - record.maxConcurrentCallsPerChain - !== CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1 - ) { - throw new Error('wrong concurrency ceiling'); - } - if (record.totalDeadlineMs !== CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1) { - throw new Error('wrong total deadline'); - } - if (record.ccipReadEnabled !== false) throw new Error('CCIP Read must be disabled'); if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); return Object.freeze({ chainId: record.chainId, to: record.to, - from: record.from, data: record.data, - gasLimit: record.gasLimit, maxReturnBytes: record.maxReturnBytes, - maxRpcResponseBytes: record.maxRpcResponseBytes, - attemptTimeoutMs: record.attemptTimeoutMs, - maxAttempts: record.maxAttempts, - endpointAttemptPolicy: record.endpointAttemptPolicy, - maxConcurrentCallsPerChain: record.maxConcurrentCallsPerChain, - totalDeadlineMs: record.totalDeadlineMs, - ccipReadEnabled: record.ccipReadEnabled, signal: record.signal, }) as CurrentFinalizedEvmCallRequestV1; } catch { diff --git a/packages/chain/src/current-finalized-evm-read-profile.ts b/packages/chain/src/current-finalized-evm-read-profile.ts index 609f89a6ce..5d5f210f40 100644 --- a/packages/chain/src/current-finalized-evm-read-profile.ts +++ b/packages/chain/src/current-finalized-evm-read-profile.ts @@ -50,6 +50,7 @@ export class CurrentFinalizedEvmCallErrorV1 extends Error { super(message, options.cause === undefined ? undefined : { cause: options.cause }); this.name = 'CurrentFinalizedEvmCallErrorV1'; this.code = code; - Object.freeze(this); + // Package-internal typed failures finish their own fields before freezing. + if (new.target === CurrentFinalizedEvmCallErrorV1) Object.freeze(this); } } diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 420ddcb28d..3795229177 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -109,16 +109,21 @@ const MAX_U256 = 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); -const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); const READ_REQUEST_KEYS = Object.freeze(['calls', 'chainId', 'signal'] as const); const READ_CALL_KEYS = Object.freeze(['data', 'maxReturnBytes', 'to'] as const); +class AuthenticatedFinalizedEvmRevertErrorV1 extends CurrentFinalizedEvmCallErrorV1 { + constructor(readonly revertData: string) { + super('revert', 'Contract call reverted at the resolved finalized anchor'); + Object.freeze(this); + } +} + /** Package-internal evidence available only for errors minted by this transport. */ export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { - if ((typeof error !== 'object' && typeof error !== 'function') || error === null) { - return undefined; - } - return AUTHENTICATED_REVERT_DATA_V1.get(error); + return error instanceof AuthenticatedFinalizedEvmRevertErrorV1 + ? error.revertData + : undefined; } /** @@ -859,12 +864,12 @@ function anchorDependentResourceLimited(message: string): CurrentFinalizedEvmCal } function revertedAtFinalizedAnchor(data?: string): CurrentFinalizedEvmCallErrorV1 { - const error = new CurrentFinalizedEvmCallErrorV1( + return data === undefined + ? new CurrentFinalizedEvmCallErrorV1( 'revert', 'Contract call reverted at the resolved finalized anchor', - ); - if (data !== undefined) AUTHENTICATED_REVERT_DATA_V1.set(error, data); - return error; + ) + : new AuthenticatedFinalizedEvmRevertErrorV1(data); } function isAnchorDependentResourceLimit( diff --git a/packages/chain/test/control-object-signature-verifier.unit.test.ts b/packages/chain/test/control-object-signature-verifier.unit.test.ts index d3e297dd42..48470f6239 100644 --- a/packages/chain/test/control-object-signature-verifier.unit.test.ts +++ b/packages/chain/test/control-object-signature-verifier.unit.test.ts @@ -7,13 +7,6 @@ import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { - CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, - CONTROL_EIP1271_CALL_FROM_V1, - CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, - CONTROL_EIP1271_GAS_LIMIT_V1, - CONTROL_EIP1271_MAX_ATTEMPTS_V1, - CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, - CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, EIP1271_CANONICAL_ABI_RETURN_V1, @@ -197,19 +190,15 @@ describe('RFC-64 control-object issuer signature verifier', () => { expect(call).toHaveBeenCalledTimes(1); const request = call.mock.calls[0]![0]; - expect(request).toMatchObject({ + expect(request).toEqual({ chainId: '20430', to: SAFE, - from: CONTROL_EIP1271_CALL_FROM_V1, - gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, + data: EIP1271_INTERFACE.encodeFunctionData('isValidSignature', [ + envelope.objectDigest, + envelope.signature, + ]).toLowerCase(), maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, - maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, - attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, - maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, - endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, - maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, - totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, - ccipReadEnabled: false, + signal: expect.any(AbortSignal), }); const decoded = EIP1271_INTERFACE.decodeFunctionData('isValidSignature', request.data); expect(decoded[0]).toBe(envelope.objectDigest); diff --git a/packages/chain/test/current-finalized-evm-call.unit.test.ts b/packages/chain/test/current-finalized-evm-call.unit.test.ts index 5e8aa688fd..c7816265ac 100644 --- a/packages/chain/test/current-finalized-evm-call.unit.test.ts +++ b/packages/chain/test/current-finalized-evm-call.unit.test.ts @@ -5,15 +5,7 @@ import { import { describe, expect, it, vi } from 'vitest'; import { - CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, - CONTROL_EIP1271_CALL_FROM_V1, - CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, - CONTROL_EIP1271_GAS_LIMIT_V1, - CONTROL_EIP1271_MAX_ATTEMPTS_V1, - CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, - CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, - CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, CurrentFinalizedEvmCallErrorV1, type CurrentFinalizedEvmCallRequestV1, type CurrentFinalizedEvmCallResultV1, @@ -127,21 +119,20 @@ describe('RFC-64 current-finalized EVM call router', () => { it.each([ ['from', '0x2222222222222222222222222222222222222222'], - ['gasLimit', CONTROL_EIP1271_GAS_LIMIT_V1 + 1n], - ['maxRpcResponseBytes', CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1 + 1], - ['attemptTimeoutMs', CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 + 1], - ['maxAttempts', CONTROL_EIP1271_MAX_ATTEMPTS_V1 + 1], - ['endpointAttemptPolicy', 'retry-same-peer-endpoint'], - ['maxConcurrentCallsPerChain', CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 + 1], - ['totalDeadlineMs', CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1 + 1], - ['ccipReadEnabled', true], - ] as const)('rejects a request with a non-frozen %s profile field', async (key, value) => { + ['gasLimit', 1_000_000n], + ['maxRpcResponseBytes', 65_536], + ['attemptTimeoutMs', 4_000], + ['maxAttempts', 2], + ['endpointAttemptPolicy', 'each-configured-endpoint-once'], + ['maxConcurrentCallsPerChain', 4], + ['totalDeadlineMs', 9_000], + ['ccipReadEnabled', false], + ] as const)('rejects obsolete caller-owned %s transport policy', async (key, value) => { const adapter = vi.fn(async () => RESULT_A); const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); - await expect(router(request({ [key]: value }))).rejects.toMatchObject({ - code: 'rpc-unavailable', - }); + await expect(router({ ...request(), [key]: value } as CurrentFinalizedEvmCallRequestV1)) + .rejects.toMatchObject({ code: 'rpc-unavailable' }); expect(adapter).not.toHaveBeenCalled(); }); @@ -333,17 +324,8 @@ function request( return { chainId: CHAIN_A, to: TO, - from: CONTROL_EIP1271_CALL_FROM_V1, data: CANONICAL_CALL_DATA, - gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, - maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, - attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, - maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, - endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, - maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, - totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, - ccipReadEnabled: false, signal: new AbortController().signal, ...overrides, }; diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index ee3ceebc66..b1f0311ce5 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -109,11 +109,9 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { return; case 'eth_call': { const callObject = call.params[0] as { readonly data?: unknown }; - sendResult( - response, - call, - callObject.data === FIRST_READ_DATA ? '0xaaaa' : '0xbbbbcc', - ); + if (callObject.data === FIRST_READ_DATA) sendResult(response, call, '0xaaaa'); + else if (callObject.data === SECOND_READ_DATA) sendResult(response, call, '0xbbbbcc'); + else sendError(response, call, -32602, 'unexpected call payload'); return; } default: @@ -152,8 +150,24 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { ]); const hashReference = { blockHash: BLOCK_HASH, requireCanonical: true }; expect(server.calls[2]!.params).toEqual([TO, hashReference]); - expect(server.calls[3]!.params[1]).toEqual(hashReference); - expect(server.calls[4]!.params[1]).toEqual(hashReference); + const ethCallParams = server.calls + .filter(({ method }) => method === 'eth_call') + .map(({ params }) => params); + expect(ethCallParams).toHaveLength(2); + expect(ethCallParams).toEqual(expect.arrayContaining([ + [{ + from: CONTROL_EIP1271_CALL_FROM_V1, + to: TO, + data: FIRST_READ_DATA, + gas: '0xf4240', + }, hashReference], + [{ + from: CONTROL_EIP1271_CALL_FROM_V1, + to: TO, + data: SECOND_READ_DATA, + gas: '0xf4240', + }, hashReference], + ])); }); it('closes one hash sandwich after every call in a multi-read fallback', async () => { @@ -616,7 +630,8 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { } catch (error) { caught = error; } - expect(caught).toMatchObject({ code: 'revert' }); + expect(caught).toMatchObject({ code: 'revert', revertData }); + expect(Object.isFrozen(caught)).toBe(true); expect(readStrictCurrentFinalizedEvmRevertDataV1(caught)).toBe(revertData); expect(readStrictCurrentFinalizedEvmRevertDataV1( new CurrentFinalizedEvmCallErrorV1('revert', 'forged'), @@ -905,17 +920,8 @@ function fixedRequest( return { chainId: CHAIN_ID, to: TO, - from: CONTROL_EIP1271_CALL_FROM_V1, data: CANONICAL_CALL_DATA, - gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, - maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, - attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, - maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, - endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, - maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, - totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, - ccipReadEnabled: false, signal: new AbortController().signal, ...overrides, }; From 3d35b7b3dbf601e9f49f10b2ad362d5de7788b30 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:31:35 +0200 Subject: [PATCH 202/292] test(chain): share loopback JSON-RPC harness --- .../test/loopback-json-rpc-test-helpers.ts | 111 ++++++++++++++++++ ...ict-current-finalized-evm-rpc.unit.test.ts | 101 ++-------------- 2 files changed, 120 insertions(+), 92 deletions(-) create mode 100644 packages/chain/test/loopback-json-rpc-test-helpers.ts diff --git a/packages/chain/test/loopback-json-rpc-test-helpers.ts b/packages/chain/test/loopback-json-rpc-test-helpers.ts new file mode 100644 index 0000000000..14beae2d62 --- /dev/null +++ b/packages/chain/test/loopback-json-rpc-test-helpers.ts @@ -0,0 +1,111 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export interface LoopbackJsonRpcRequest { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly method: string; + readonly params: readonly unknown[]; +} + +export interface LoopbackJsonRpcServer { + readonly url: string; + readonly calls: LoopbackJsonRpcRequest[]; + readonly stop: () => Promise; +} + +export type LoopbackJsonRpcHandler = ( + call: LoopbackJsonRpcRequest, + response: ServerResponse, + request: IncomingMessage, +) => void | Promise; + +export interface LoopbackJsonRpcTestHarness { + readonly start: (handler: LoopbackJsonRpcHandler) => Promise; + readonly stopAll: () => Promise; +} + +/** Suite-local loopback JSON-RPC lifecycle with deterministic teardown. */ +export function createLoopbackJsonRpcTestHarness(): LoopbackJsonRpcTestHarness { + const activeServers: LoopbackJsonRpcServer[] = []; + + const start = async (handler: LoopbackJsonRpcHandler): Promise => { + const calls: LoopbackJsonRpcRequest[] = []; + const server = createServer(async (request, response) => { + try { + const parsed = JSON.parse(await readRequestBody(request)) as LoopbackJsonRpcRequest; + calls.push(parsed); + await handler(parsed, response, request); + } catch (cause) { + if (!response.headersSent) response.writeHead(500, { 'content-type': 'text/plain' }); + if (!response.writableEnded) { + response.end(cause instanceof Error ? cause.message : 'failure'); + } + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + let stopped = false; + const loopback = Object.freeze({ + url: `http://127.0.0.1:${address.port}`, + calls, + stop: async () => { + if (stopped) return; + stopped = true; + await closeServer(server); + }, + }); + activeServers.push(loopback); + return loopback; + }; + + return Object.freeze({ + start, + stopAll: async () => { + await Promise.all(activeServers.splice(0).map((server) => server.stop())); + }, + }); +} + +export function sendJsonRpcResult( + response: ServerResponse, + request: LoopbackJsonRpcRequest, + result: unknown, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); +} + +export function sendJsonRpcError( + response: ServerResponse, + request: LoopbackJsonRpcRequest, + code: number, + message: string, + data?: string, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + error: { code, message, ...(data === undefined ? {} : { data }) }, + })); +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); +} diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index b1f0311ce5..1470116306 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -1,6 +1,3 @@ -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import type { AddressInfo } from 'node:net'; - import { type ChainIdV1, type EvmAddressV1, @@ -38,6 +35,12 @@ import { readStrictCurrentFinalizedEvmRevertDataV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from '../src/strict-current-finalized-evm-rpc.js'; +import { + createLoopbackJsonRpcTestHarness, + sendJsonRpcError as sendError, + sendJsonRpcResult as sendResult, + type LoopbackJsonRpcHandler as LoopbackHandler, +} from './loopback-json-rpc-test-helpers.js'; const CHAIN_ID = '20430' as ChainIdV1; const CHAIN_QUANTITY = '0x4fce'; @@ -52,29 +55,11 @@ const WRONG_MAGIC_RETURN = `0xffffffff${'00'.repeat(28)}`; const FIRST_READ_DATA = '0x11111111'; const SECOND_READ_DATA = '0x22222222'; -interface JsonRpcRequest { - readonly jsonrpc: '2.0'; - readonly id: number; - readonly method: string; - readonly params: readonly unknown[]; -} - -interface LoopbackRpcServer { - readonly url: string; - readonly calls: JsonRpcRequest[]; - readonly stop: () => Promise; -} - -type LoopbackHandler = ( - call: JsonRpcRequest, - response: ServerResponse, - request: IncomingMessage, -) => void | Promise; - -const activeServers: LoopbackRpcServer[] = []; +const rpcHarness = createLoopbackJsonRpcTestHarness(); +const startRpcServer = rpcHarness.start; afterEach(async () => { - await Promise.all(activeServers.splice(0).map((server) => server.stop())); + await rpcHarness.stopAll(); }); describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { @@ -846,74 +831,6 @@ function successfulHandler(options: SuccessfulHandlerOptions = {}): LoopbackHand }; } -async function startRpcServer(handler: LoopbackHandler): Promise { - const calls: JsonRpcRequest[] = []; - const server = createServer(async (request, response) => { - try { - const body = await readRequestBody(request); - const parsed = JSON.parse(body) as JsonRpcRequest; - calls.push(parsed); - await handler(parsed, response, request); - } catch (cause) { - if (!response.headersSent) response.writeHead(500, { 'content-type': 'text/plain' }); - if (!response.writableEnded) response.end(cause instanceof Error ? cause.message : 'failure'); - } - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.off('error', reject); - resolve(); - }); - }); - const address = server.address() as AddressInfo; - let stopped = false; - const loopback = Object.freeze({ - url: `http://127.0.0.1:${address.port}`, - calls, - stop: async () => { - if (stopped) return; - stopped = true; - await closeServer(server); - }, - }); - activeServers.push(loopback); - return loopback; -} - -async function readRequestBody(request: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of request) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString('utf8'); -} - -function sendResult(response: ServerResponse, request: JsonRpcRequest, result: unknown): void { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); -} - -function sendError( - response: ServerResponse, - request: JsonRpcRequest, - code: number, - message: string, - data?: string, -): void { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ - jsonrpc: '2.0', - id: request.id, - error: { code, message, ...(data === undefined ? {} : { data }) }, - })); -} - -async function closeServer(server: Server): Promise { - await new Promise((resolve) => { - server.close(() => resolve()); - server.closeAllConnections(); - }); -} - function fixedRequest( overrides: Partial = {}, ): CurrentFinalizedEvmCallRequestV1 { From 5c6bb0fe4d8282a5be48001587c686459205008b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:52:21 +0200 Subject: [PATCH 203/292] fix(chain): preserve finalized gateway compatibility --- .../src/control-object-signature-verifier.ts | 9 ++ .../src/current-finalized-evm-call-model.ts | 18 ++- .../chain/src/current-finalized-evm-call.ts | 96 ++++++++++-- packages/chain/src/index.ts | 12 -- .../src/strict-current-finalized-evm-rpc.ts | 1 - ...rol-object-signature-verifier.unit.test.ts | 16 ++ .../current-finalized-evm-call.unit.test.ts | 72 ++++----- .../test/loopback-json-rpc-test-helpers.ts | 111 -------------- packages/chain/test/loopback-rpc-harness.ts | 140 ++++++++++++++++-- ...ict-current-finalized-evm-rpc.unit.test.ts | 31 +++- 10 files changed, 316 insertions(+), 190 deletions(-) delete mode 100644 packages/chain/test/loopback-json-rpc-test-helpers.ts diff --git a/packages/chain/src/control-object-signature-verifier.ts b/packages/chain/src/control-object-signature-verifier.ts index 56f442b1ad..e227da4b69 100644 --- a/packages/chain/src/control-object-signature-verifier.ts +++ b/packages/chain/src/control-object-signature-verifier.ts @@ -214,11 +214,20 @@ export async function verifyControlEnvelopeIssuerSignatureV1( const request = Object.freeze({ chainId: envelope.signatureEvidence.chainId as ChainIdV1, to: envelope.signatureEvidence.contractAddress as EvmAddressV1, + from: CONTROL_EIP1271_CALL_FROM_V1, data: EIP1271_INTERFACE.encodeFunctionData('isValidSignature', [ envelope.objectDigest, envelope.signature, ]).toLowerCase(), + gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, + endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + ccipReadEnabled: false, signal: controller.signal, } satisfies CurrentFinalizedEvmCallRequestV1); diff --git a/packages/chain/src/current-finalized-evm-call-model.ts b/packages/chain/src/current-finalized-evm-call-model.ts index b11310dfd9..4d057487ab 100644 --- a/packages/chain/src/current-finalized-evm-call-model.ts +++ b/packages/chain/src/current-finalized-evm-call-model.ts @@ -5,12 +5,25 @@ import { type EvmAddressV1, } from '@origintrail-official/dkg-core'; -/** Canonical single-call projection over the generic finalized-read transport. */ +/** + * Existing V1 EIP-1271 gateway contract. Keep the fixed transport fields on + * this public seam even though the built-in transport owns and revalidates + * their values; external gateways may still inspect them. + */ export interface CurrentFinalizedEvmCallRequestV1 { readonly chainId: ChainIdV1; readonly to: EvmAddressV1; + readonly from: '0x0000000000000000000000000000000000000000'; readonly data: string; + readonly gasLimit: bigint; readonly maxReturnBytes: number; + readonly maxRpcResponseBytes: number; + readonly attemptTimeoutMs: number; + readonly maxAttempts: number; + readonly endpointAttemptPolicy: 'distinct-configured-endpoints-no-same-endpoint-retry'; + readonly maxConcurrentCallsPerChain: number; + readonly totalDeadlineMs: number; + readonly ccipReadEnabled: false; readonly signal: AbortSignal; } @@ -22,8 +35,7 @@ export interface CurrentFinalizedEvmCallResultV1 { } /** - * Trusted local single-call seam. EIP-1271 is one ABI specialization; its - * exact calldata and 32-byte magic-result rules stay in the verifier. + * Trusted local current-finalized EIP-1271 seam retained for V1 compatibility. */ export interface CurrentFinalizedEvmCallV1 { (request: CurrentFinalizedEvmCallRequestV1): Promise; diff --git a/packages/chain/src/current-finalized-evm-call.ts b/packages/chain/src/current-finalized-evm-call.ts index 04c6ff254f..1bac14a2f9 100644 --- a/packages/chain/src/current-finalized-evm-call.ts +++ b/packages/chain/src/current-finalized-evm-call.ts @@ -3,6 +3,17 @@ import { type ChainIdV1, } from '@origintrail-official/dkg-core'; +import { + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, + CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + CONTROL_EIP1271_GAS_LIMIT_V1, + CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, +} from './control-object-signature-verifier.js'; import { type CurrentFinalizedEvmCallRequestV1, type CurrentFinalizedEvmCallResultV1, @@ -10,7 +21,6 @@ import { } from './current-finalized-evm-call-model.js'; import { CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, - CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; @@ -23,11 +33,20 @@ import { } from './strict-local-data.js'; const REQUEST_KEYS = Object.freeze([ + 'attemptTimeoutMs', + 'ccipReadEnabled', 'chainId', 'data', + 'endpointAttemptPolicy', + 'from', + 'gasLimit', + 'maxAttempts', + 'maxConcurrentCallsPerChain', 'maxReturnBytes', + 'maxRpcResponseBytes', 'signal', 'to', + 'totalDeadlineMs', ] as const); /** @@ -148,23 +167,49 @@ export function snapshotCurrentFinalizedEvmCallRequestV1( const record = snapshotExactDataRecord(input, REQUEST_KEYS); assertCanonicalChainId(record.chainId, 'current-finalized request chainId'); assertCanonicalNonzeroEvmAddress(record.to, 'current-finalized request to'); + if (record.from !== CONTROL_EIP1271_CALL_FROM_V1) throw new Error('wrong from'); if (typeof record.data !== 'string') throw new Error('call data is not a string'); - assertCanonicalAbiCallData(record.data); - if ( - typeof record.maxReturnBytes !== 'number' - || !Number.isSafeInteger(record.maxReturnBytes) - || record.maxReturnBytes < 1 - || record.maxReturnBytes > CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 - ) { + assertCanonicalEip1271CallData(record.data); + if (record.gasLimit !== CONTROL_EIP1271_GAS_LIMIT_V1) throw new Error('wrong gas limit'); + if (record.maxReturnBytes !== CONTROL_EIP1271_MAX_RETURN_BYTES_V1) { throw new Error('wrong return cap'); } + if (record.maxRpcResponseBytes !== CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1) { + throw new Error('wrong RPC response cap'); + } + if (record.attemptTimeoutMs !== CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1) { + throw new Error('wrong attempt timeout'); + } + if (record.maxAttempts !== CONTROL_EIP1271_MAX_ATTEMPTS_V1) { + throw new Error('wrong attempt count'); + } + if (record.endpointAttemptPolicy !== CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1) { + throw new Error('wrong endpoint policy'); + } + if ( + record.maxConcurrentCallsPerChain + !== CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 + ) throw new Error('wrong concurrency ceiling'); + if (record.totalDeadlineMs !== CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1) { + throw new Error('wrong total deadline'); + } + if (record.ccipReadEnabled !== false) throw new Error('CCIP Read must be disabled'); if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); return Object.freeze({ chainId: record.chainId, to: record.to, + from: record.from, data: record.data, + gasLimit: record.gasLimit, maxReturnBytes: record.maxReturnBytes, + maxRpcResponseBytes: record.maxRpcResponseBytes, + attemptTimeoutMs: record.attemptTimeoutMs, + maxAttempts: record.maxAttempts, + endpointAttemptPolicy: record.endpointAttemptPolicy, + maxConcurrentCallsPerChain: record.maxConcurrentCallsPerChain, + totalDeadlineMs: record.totalDeadlineMs, + ccipReadEnabled: record.ccipReadEnabled, signal: record.signal, }) as CurrentFinalizedEvmCallRequestV1; } catch { @@ -175,9 +220,38 @@ export function snapshotCurrentFinalizedEvmCallRequestV1( } } -function assertCanonicalAbiCallData(data: string): void { - if (!/^0x[0-9a-f]{8}(?:[0-9a-f]{2})*$/.test(data)) { - throw new Error('call data is not canonical ABI calldata'); +function assertCanonicalEip1271CallData(data: string): void { + if (!/^0x[0-9a-f]+$/.test(data)) { + throw new Error('call data is not canonical lowercase hex'); + } + const bytes = data.slice(2); + const selectorLength = 8; + const wordLength = 64; + const fixedLength = selectorLength + (wordLength * 3); + if ( + bytes.slice(0, selectorLength) !== '1626ba7e' + || bytes.length < fixedLength + || bytes.slice(selectorLength + wordLength, selectorLength + (wordLength * 2)) + !== `${'0'.repeat(62)}40` + ) { + throw new Error('call data is not canonical isValidSignature(bytes32,bytes) ABI'); + } + const signatureLengthWord = bytes.slice( + selectorLength + (wordLength * 2), + fixedLength, + ); + const signatureLength = BigInt(`0x${signatureLengthWord}`); + if (signatureLength < 1n || signatureLength > 4096n) { + throw new Error('EIP-1271 signature length is outside 1..4096 bytes'); + } + const paddedSignatureHexLength = Math.ceil(Number(signatureLength) / 32) * wordLength; + const tail = bytes.slice(fixedLength); + if (tail.length !== paddedSignatureHexLength) { + throw new Error('EIP-1271 signature tail has noncanonical length'); + } + const signatureHexLength = Number(signatureLength) * 2; + if (!/^0*$/.test(tail.slice(signatureHexLength))) { + throw new Error('EIP-1271 signature tail has nonzero ABI padding'); } } diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 08ec48599a..a10334554d 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -29,18 +29,6 @@ export { type VerifiedControlEnvelopeIssuerSignatureSnapshotV1, type VerifyControlEnvelopeIssuerSignatureOptionsV1, } from './control-object-signature-verifier.js'; -export { - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1, - CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, - CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, - CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, - CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, -} from './current-finalized-evm-read-profile.js'; export { createCurrentFinalizedEvmCallRouterV1, type CurrentFinalizedEvmChainAdapterRegistrationV1, diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 3795229177..9fd6cea2d7 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -801,7 +801,6 @@ function snapshotNormalizedEndpoints(input: unknown): readonly string[] { const endpoints = snapshotDenseDataArray(input, { label: 'Strict current-finalized RPC endpoints', minLength: 1, - maxLength: CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, }); for (const entry of endpoints) { const endpoint = normalizeEndpoint(entry); diff --git a/packages/chain/test/control-object-signature-verifier.unit.test.ts b/packages/chain/test/control-object-signature-verifier.unit.test.ts index 48470f6239..bb10033e7f 100644 --- a/packages/chain/test/control-object-signature-verifier.unit.test.ts +++ b/packages/chain/test/control-object-signature-verifier.unit.test.ts @@ -7,6 +7,13 @@ import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, + CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + CONTROL_EIP1271_GAS_LIMIT_V1, + CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, EIP1271_CANONICAL_ABI_RETURN_V1, @@ -193,11 +200,20 @@ describe('RFC-64 control-object issuer signature verifier', () => { expect(request).toEqual({ chainId: '20430', to: SAFE, + from: CONTROL_EIP1271_CALL_FROM_V1, data: EIP1271_INTERFACE.encodeFunctionData('isValidSignature', [ envelope.objectDigest, envelope.signature, ]).toLowerCase(), + gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, + endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + ccipReadEnabled: false, signal: expect.any(AbortSignal), }); const decoded = EIP1271_INTERFACE.decodeFunctionData('isValidSignature', request.data); diff --git a/packages/chain/test/current-finalized-evm-call.unit.test.ts b/packages/chain/test/current-finalized-evm-call.unit.test.ts index c7816265ac..de8cf5fa94 100644 --- a/packages/chain/test/current-finalized-evm-call.unit.test.ts +++ b/packages/chain/test/current-finalized-evm-call.unit.test.ts @@ -5,7 +5,15 @@ import { import { describe, expect, it, vi } from 'vitest'; import { + CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + CONTROL_EIP1271_CALL_FROM_V1, + CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + CONTROL_EIP1271_GAS_LIMIT_V1, + CONTROL_EIP1271_MAX_ATTEMPTS_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, CurrentFinalizedEvmCallErrorV1, type CurrentFinalizedEvmCallRequestV1, type CurrentFinalizedEvmCallResultV1, @@ -15,9 +23,6 @@ import { type CurrentFinalizedEvmChainAdapterRegistrationV1, type CurrentFinalizedEvmChainAdapterV1, } from '../src/current-finalized-evm-call.js'; -import { - CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, -} from '../src/current-finalized-evm-read-profile.js'; const CHAIN_A = '20430' as ChainIdV1; const CHAIN_B = '100' as ChainIdV1; @@ -119,33 +124,20 @@ describe('RFC-64 current-finalized EVM call router', () => { it.each([ ['from', '0x2222222222222222222222222222222222222222'], - ['gasLimit', 1_000_000n], - ['maxRpcResponseBytes', 65_536], - ['attemptTimeoutMs', 4_000], - ['maxAttempts', 2], - ['endpointAttemptPolicy', 'each-configured-endpoint-once'], - ['maxConcurrentCallsPerChain', 4], - ['totalDeadlineMs', 9_000], - ['ccipReadEnabled', false], - ] as const)('rejects obsolete caller-owned %s transport policy', async (key, value) => { - const adapter = vi.fn(async () => RESULT_A); - const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); - - await expect(router({ ...request(), [key]: value } as CurrentFinalizedEvmCallRequestV1)) - .rejects.toMatchObject({ code: 'rpc-unavailable' }); - expect(adapter).not.toHaveBeenCalled(); - }); - - it.each([ - 0, - CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 + 1, - ])('rejects maxReturnBytes=%s outside the generic transport envelope', async (value) => { + ['gasLimit', CONTROL_EIP1271_GAS_LIMIT_V1 + 1n], + ['maxReturnBytes', CONTROL_EIP1271_MAX_RETURN_BYTES_V1 + 1], + ['maxRpcResponseBytes', CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1 + 1], + ['attemptTimeoutMs', CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1 + 1], + ['maxAttempts', CONTROL_EIP1271_MAX_ATTEMPTS_V1 + 1], + ['endpointAttemptPolicy', 'retry-same-peer-endpoint'], + ['maxConcurrentCallsPerChain', CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1 + 1], + ['totalDeadlineMs', CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1 + 1], + ['ccipReadEnabled', true], + ] as const)('rejects a request with a non-frozen %s profile field', async (key, value) => { const adapter = vi.fn(async () => RESULT_A); const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); - await expect(router(request({ maxReturnBytes: value }))).rejects.toMatchObject({ - code: 'rpc-unavailable', - }); + await expect(router(request({ [key]: value }))).rejects.toMatchObject({ code: 'rpc-unavailable' }); expect(adapter).not.toHaveBeenCalled(); }); @@ -158,6 +150,9 @@ describe('RFC-64 current-finalized EVM call router', () => { request({ to: '0xABC' as EvmAddressV1 }), request({ data: '0xABC' }), request({ data: '0x1234' }), + request({ data: CANONICAL_CALL_DATA.replace('1626ba7e', 'ffffffff') }), + request({ data: `${CANONICAL_CALL_DATA}00` }), + request({ data: `${CANONICAL_CALL_DATA.slice(0, -2)}01` }), request({ signal: {} as AbortSignal }), { ...request(), rpcUrl: 'https://peer.invalid' }, { ...request(), blockTag: 'latest' }, @@ -189,20 +184,6 @@ describe('RFC-64 current-finalized EVM call router', () => { expect(adapter).not.toHaveBeenCalled(); }); - it('keeps the routed call seam ABI-generic while EIP-1271 owns its specialization', async () => { - const adapter = vi.fn(async () => RESULT_A); - const router = createCurrentFinalizedEvmCallRouterV1([{ chainId: CHAIN_A, adapter }]); - - await expect(router(request({ - data: '0x12345678', - maxReturnBytes: 64, - }))).resolves.toBe(RESULT_A); - expect(adapter).toHaveBeenCalledWith(expect.objectContaining({ - data: '0x12345678', - maxReturnBytes: 64, - })); - }); - it('admits four calls per chain and rejects the fifth immediately without queueing', async () => { const pending = Array.from({ length: 5 }, () => deferred()); let callIndex = 0; @@ -324,8 +305,17 @@ function request( return { chainId: CHAIN_A, to: TO, + from: CONTROL_EIP1271_CALL_FROM_V1, data: CANONICAL_CALL_DATA, + gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, + endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + ccipReadEnabled: false, signal: new AbortController().signal, ...overrides, }; diff --git a/packages/chain/test/loopback-json-rpc-test-helpers.ts b/packages/chain/test/loopback-json-rpc-test-helpers.ts deleted file mode 100644 index 14beae2d62..0000000000 --- a/packages/chain/test/loopback-json-rpc-test-helpers.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import type { AddressInfo } from 'node:net'; - -export interface LoopbackJsonRpcRequest { - readonly jsonrpc: '2.0'; - readonly id: number; - readonly method: string; - readonly params: readonly unknown[]; -} - -export interface LoopbackJsonRpcServer { - readonly url: string; - readonly calls: LoopbackJsonRpcRequest[]; - readonly stop: () => Promise; -} - -export type LoopbackJsonRpcHandler = ( - call: LoopbackJsonRpcRequest, - response: ServerResponse, - request: IncomingMessage, -) => void | Promise; - -export interface LoopbackJsonRpcTestHarness { - readonly start: (handler: LoopbackJsonRpcHandler) => Promise; - readonly stopAll: () => Promise; -} - -/** Suite-local loopback JSON-RPC lifecycle with deterministic teardown. */ -export function createLoopbackJsonRpcTestHarness(): LoopbackJsonRpcTestHarness { - const activeServers: LoopbackJsonRpcServer[] = []; - - const start = async (handler: LoopbackJsonRpcHandler): Promise => { - const calls: LoopbackJsonRpcRequest[] = []; - const server = createServer(async (request, response) => { - try { - const parsed = JSON.parse(await readRequestBody(request)) as LoopbackJsonRpcRequest; - calls.push(parsed); - await handler(parsed, response, request); - } catch (cause) { - if (!response.headersSent) response.writeHead(500, { 'content-type': 'text/plain' }); - if (!response.writableEnded) { - response.end(cause instanceof Error ? cause.message : 'failure'); - } - } - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.off('error', reject); - resolve(); - }); - }); - const address = server.address() as AddressInfo; - let stopped = false; - const loopback = Object.freeze({ - url: `http://127.0.0.1:${address.port}`, - calls, - stop: async () => { - if (stopped) return; - stopped = true; - await closeServer(server); - }, - }); - activeServers.push(loopback); - return loopback; - }; - - return Object.freeze({ - start, - stopAll: async () => { - await Promise.all(activeServers.splice(0).map((server) => server.stop())); - }, - }); -} - -export function sendJsonRpcResult( - response: ServerResponse, - request: LoopbackJsonRpcRequest, - result: unknown, -): void { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); -} - -export function sendJsonRpcError( - response: ServerResponse, - request: LoopbackJsonRpcRequest, - code: number, - message: string, - data?: string, -): void { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ - jsonrpc: '2.0', - id: request.id, - error: { code, message, ...(data === undefined ? {} : { data }) }, - })); -} - -async function readRequestBody(request: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of request) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString('utf8'); -} - -async function closeServer(server: Server): Promise { - await new Promise((resolve) => { - server.close(() => resolve()); - server.closeAllConnections(); - }); -} diff --git a/packages/chain/test/loopback-rpc-harness.ts b/packages/chain/test/loopback-rpc-harness.ts index cf0bf942e4..6f76d79c37 100644 --- a/packages/chain/test/loopback-rpc-harness.ts +++ b/packages/chain/test/loopback-rpc-harness.ts @@ -15,7 +15,12 @@ * in afterEach, or the hook hangs past vitest's timeout — the known flaky-CI * failure mode (see evm-adapter.unit.test.ts:1549). */ -import { createServer, type Server } from 'node:http'; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; import type { AddressInfo } from 'node:net'; /** chainId 31337 (matches the tests' `chainId: 'evm:31337'`). */ @@ -38,6 +43,30 @@ export interface LoopbackOptions { results?: Record; } +export interface LoopbackJsonRpcRequest { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly method: string; + readonly params: readonly unknown[]; +} + +export interface LoopbackJsonRpcServer { + readonly url: string; + readonly calls: LoopbackJsonRpcRequest[]; + readonly stop: () => Promise; +} + +export type LoopbackJsonRpcHandler = ( + call: LoopbackJsonRpcRequest, + response: ServerResponse, + request: IncomingMessage, +) => void | Promise; + +export interface LoopbackJsonRpcTestHarness { + readonly start: (handler: LoopbackJsonRpcHandler) => Promise; + readonly stopAll: () => Promise; +} + const DEFAULT_RESULTS: Record = { eth_chainId: CHAIN_ID_HEX, eth_blockNumber: '0x10', @@ -58,7 +87,7 @@ export async function startLoopbackRpc(options: LoopbackOptions = {}): Promise(); - const server = createServer((req, res) => { + const loopback = await startLoopbackHttpServer((req, res) => { let raw = ''; req.on('data', (c) => { raw += c; }); req.on('end', () => { @@ -87,16 +116,107 @@ export async function startLoopbackRpc(options: LoopbackOptions = {}): Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); - const addr = server.address() as AddressInfo; return { - url: `http://127.0.0.1:${addr.port}`, - server, + url: loopback.url, + server: loopback.server, hits: (method) => counts.get(method) ?? 0, totalHits: () => [...counts.values()].reduce((a, b) => a + b, 0), - close: async () => { - server.closeAllConnections?.(); - await new Promise((resolve) => server.close(() => resolve())); - }, + close: loopback.close, }; } + +/** Handler-driven variant sharing the canonical loopback server lifecycle. */ +export function createLoopbackJsonRpcTestHarness(): LoopbackJsonRpcTestHarness { + const activeServers: LoopbackJsonRpcServer[] = []; + + const start = async (handler: LoopbackJsonRpcHandler): Promise => { + const calls: LoopbackJsonRpcRequest[] = []; + const started = await startLoopbackHttpServer(async (request, response) => { + try { + const parsed = JSON.parse(await readRequestBody(request)) as LoopbackJsonRpcRequest; + calls.push(parsed); + await handler(parsed, response, request); + } catch (cause) { + if (!response.headersSent) response.writeHead(500, { 'content-type': 'text/plain' }); + if (!response.writableEnded) { + response.end(cause instanceof Error ? cause.message : 'failure'); + } + } + }); + let stopped = false; + const loopback = Object.freeze({ + url: started.url, + calls, + stop: async () => { + if (stopped) return; + stopped = true; + await started.close(); + }, + }); + activeServers.push(loopback); + return loopback; + }; + + return Object.freeze({ + start, + stopAll: async () => { + await Promise.all(activeServers.splice(0).map((server) => server.stop())); + }, + }); +} + +export function sendJsonRpcResult( + response: ServerResponse, + request: LoopbackJsonRpcRequest, + result: unknown, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); +} + +export function sendJsonRpcError( + response: ServerResponse, + request: LoopbackJsonRpcRequest, + code: number, + message: string, + data?: string, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + error: { code, message, ...(data === undefined ? {} : { data }) }, + })); +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +async function startLoopbackHttpServer( + handler: (request: IncomingMessage, response: ServerResponse) => void | Promise, +): Promise Promise }>> { + const server = createServer(handler); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + return Object.freeze({ + url: `http://127.0.0.1:${address.port}`, + server, + close: () => closeServer(server), + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); +} diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 1470116306..60fb39da74 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -40,7 +40,7 @@ import { sendJsonRpcError as sendError, sendJsonRpcResult as sendResult, type LoopbackJsonRpcHandler as LoopbackHandler, -} from './loopback-json-rpc-test-helpers.js'; +} from './loopback-rpc-harness.js'; const CHAIN_ID = '20430' as ChainIdV1; const CHAIN_QUANTITY = '0x4fce'; @@ -475,6 +475,26 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { expect(server.calls).toHaveLength(1); }); + it('applies the two-endpoint ceiling after normalized endpoint deduplication', async () => { + const primary = await startRpcServer((call, response) => { + sendResult(response, call, '0x1'); + }); + const backup = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [primary.url, `${primary.url}/`, backup.url], + }); + + await expect(adapter(fixedRequest())).resolves.toMatchObject({ chainId: CHAIN_ID }); + expect(primary.calls.map(({ method }) => method)).toEqual(['eth_chainId']); + expect(backup.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + ]); + }); + it('never follows a redirect to an endpoint outside trusted local configuration', async () => { const unconfigured = await startRpcServer(successfulHandler()); const configured = await startRpcServer((_call, response) => { @@ -837,8 +857,17 @@ function fixedRequest( return { chainId: CHAIN_ID, to: TO, + from: CONTROL_EIP1271_CALL_FROM_V1, data: CANONICAL_CALL_DATA, + gasLimit: CONTROL_EIP1271_GAS_LIMIT_V1, maxReturnBytes: CONTROL_EIP1271_MAX_RETURN_BYTES_V1, + maxRpcResponseBytes: CONTROL_EIP1271_MAX_RPC_RESPONSE_BYTES_V1, + attemptTimeoutMs: CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, + maxAttempts: CONTROL_EIP1271_MAX_ATTEMPTS_V1, + endpointAttemptPolicy: CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1, + maxConcurrentCallsPerChain: CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, + totalDeadlineMs: CONTROL_EIP1271_TOTAL_DEADLINE_MS_V1, + ccipReadEnabled: false, signal: new AbortController().signal, ...overrides, }; From 6463a6a8509823ba9b2160f6e795b5a5dbecbf95 Mon Sep 17 00:00:00 2001 From: Jurij89 <138491694+Jurij89@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:05:18 -0400 Subject: [PATCH 204/292] refactor(publisher): promote clearTerminalJob single canonical payload read (#1893) (#1899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(publisher): promote clearTerminalJob single canonical payload read (#1893) Promote's clearTerminalJob classified a job with a dual read — a bare PROMOTE_STATE index probe (+ parseLiteral) then the canonical readJob — while the lift sibling derives the identical outcome set from a single payload read. - Add `classifyJobPayload` returning a typed `{absent|malformed|job}` result so the clear can distinguish already_absent, a corrupt payload (malformed), and a structurally-valid job whose `state` is not a recognized enum value (unknown) from ONE canonical PROMOTE_PAYLOAD read. The enum check is intentionally NOT applied in the classifier — it runs on the parsed job in the clearer. - `parseJobPayload` becomes a strict wrapper over classifyJobPayload (re-applies the enum drop), so list/readJob/conflict-detection paths stay byte-identical. - clearTerminalJob drops the PROMOTE_STATE probe + the now-dead parseLiteral import. No behaviour change (verified non-correctness on #1883). Tests: migrate the `unknown` fixture to a payload carrying a bogus state (bare state-triple would now be already_absent); add corrupt-payload -> malformed; add a focused classifyJobPayload unit block. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(publisher): classify missing/non-string promote state as malformed (#1893) Review follow-up on the single-read terminal clear: classifyJobPayload validated every structural field of a PromoteJob EXCEPT `state`, so a payload with a missing or non-string `state` was returned as `kind: 'job'` and the by-jobId clear reported it as `unknown` (HTTP 409) instead of `malformed` (HTTP 400). A missing/non-string state is structural corruption, not a well-formed-but-unrecognized state. - classifyJobPayload now requires `state` to be a non-empty string (else `malformed`), matching how every other field is validated. The enum membership check stays deferred so a well-formed non-enum state STRING is still `kind: 'job'` -> `unknown` (behaviour preserved). - Make the classifier boundary honest: the `job` result is now typed StructurallyValidPromoteJobPayload (`state: string`) rather than PromoteJob, so it never claims a value is a PromoteJobState before the enum check. The clear and parseJobPayload narrow to PromoteJobState only after that check. - Tests: classifier + terminal-clear cases proving missing/non-string/empty state -> malformed, while bogus-state STRING -> unknown is retained. Co-Authored-By: Claude Opus 4.8 (1M context) * test(publisher): lock parseJobPayload enum drop after classifier split (#1893) Review round-2 follow-up: classifyJobPayload deliberately accepts a non-enum state STRING as `kind: 'job'` (so clearTerminalJob can report `unknown`), which makes parseJobPayload the ONLY runtime guard keeping list/getStatus/conflict readers from surfacing a row with an impossible state. That strict-wrapper invariant was only asserted transitively; add a direct regression test: classifyJobPayload accepts a `bogus_state` payload while parseJobPayload returns null for it (and returns a valid enum-state job unchanged). Removing the enum drop from parseJobPayload now fails a test instead of silently passing. Publisher `tsc --noEmit` clean; async-promote-terminal-clear green (16 tests). Co-Authored-By: Claude Opus 4.8 (1M context) * test(publisher): assert terminal-clear rejects are non-mutating (#1893) Review round-3 follow-up: #1893 moves terminal-clear classification to the single canonical payload read and documents that a malformed/unknown reject never mutates the row, but the new tests only asserted the reason code. A regression that deleted the subject after classifying it would still have passed. Add a `rawPayloadOf` helper and, for each reject path introduced by this PR (unknown / corrupt-malformed / missing-state-malformed), read the stored PROMOTE_PAYLOAD before and after the clear and assert it is unchanged. Also assert an already_absent clear creates no row and a cleared row stays gone. Comparing the store's own before/after return form is immune to any insert-vs-return literal re-escaping. Publisher `tsc --noEmit` clean; async-promote-terminal-clear green (16 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../publisher/src/async-promote-queue-impl.ts | 38 +++---- .../src/async-promote-queue-utils.ts | 73 +++++++++--- .../test/async-promote-terminal-clear.test.ts | 107 +++++++++++++++++- 3 files changed, 173 insertions(+), 45 deletions(-) diff --git a/packages/publisher/src/async-promote-queue-impl.ts b/packages/publisher/src/async-promote-queue-impl.ts index 27cd1d6dec..adff5835fd 100644 --- a/packages/publisher/src/async-promote-queue-impl.ts +++ b/packages/publisher/src/async-promote-queue-impl.ts @@ -47,6 +47,7 @@ import { PROMOTE_PAYLOAD, PROMOTE_STATE, PROMOTE_UNIQUENESS_KEY, + classifyJobPayload, comparePromoteJobs, defaultBackoffMs, expectBindings, @@ -55,7 +56,6 @@ import { literal, normalizePromoteAgentLane, parseJobPayload, - parseLiteral, promoteLaneConflictScope, promoteLaneScopesConflict, serializeJob, @@ -615,9 +615,10 @@ export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue, PromoteT * #1837 — atomic by-exact-jobId terminal clear. Runs INSIDE withMutationLock, which * already serializes EVERY transition (incl. the only terminal→active path, recover() * failed→queued), so a transitioning job cannot be swept and concurrent clears are - * deterministic — no new lock needed. Reads the denormalized state triple to split - * already_absent / unknown / malformed without a JSON.parse that collapses them. Never - * throws / never mutates on a reject. + * deterministic — no new lock needed. Classifies from a SINGLE canonical payload read + * (matching the lift sibling): `classifyJobPayload` splits already_absent / malformed / + * job without the enum drop that collapses them, and the enum + terminal checks run on + * the parsed job. Never throws / never mutates on a reject. */ async clearTerminalJob(jobId: string): Promise { // Reject an empty OR SPARQL-unsafe jobId as malformed before building the jobSubject @@ -626,29 +627,22 @@ export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue, PromoteT if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; return this.withMutationLock(async () => { await this.ensureGraph(); - const stateRows = expectBindings( + const rows = expectBindings( await this.store.query( - `SELECT ?state WHERE { GRAPH <${this.graphUri}> { <${jobSubject(jobId)}> <${PROMOTE_STATE}> ?state } }`, + `SELECT ?payload WHERE { GRAPH <${this.graphUri}> { <${jobSubject(jobId)}> <${PROMOTE_PAYLOAD}> ?payload } }`, ), ); - if (stateRows.length === 0) return { outcome: 'already_absent' }; - const rawState = stateRows[0]?.['state']; - // parseLiteral is JSON.parse — a corrupt/non-JSON state literal must become a - // bounded reject, never throw out of the method (matches the lift sibling's guard). - let state: string | undefined; - try { - state = rawState === undefined ? undefined : String(parseLiteral(rawState)); - } catch { + const parsed = classifyJobPayload(rows[0]?.['payload']); + if (parsed.kind === 'absent') return { outcome: 'already_absent' }; + if (parsed.kind === 'malformed') return { outcome: 'rejected', reason: 'malformed' }; + const { job } = parsed; + // Structurally-valid job whose state is a well-formed string but not a recognized enum + // value → unknown (a missing/non-string state was already classified `malformed`). Narrow + // to PromoteJobState only AFTER the membership check confirms it. + if (!(PROMOTE_JOB_STATES as readonly string[]).includes(job.state)) { return { outcome: 'rejected', reason: 'unknown' }; } - if (state === undefined || !(PROMOTE_JOB_STATES as readonly string[]).includes(state)) { - return { outcome: 'rejected', reason: 'unknown' }; - } - // State literal is a known enum value; the full payload must also parse (a corrupt - // payload with a valid state triple is malformed, not unknown). - const job = await this.readJob(jobId); - if (job === null) return { outcome: 'rejected', reason: 'malformed' }; - if (!isTerminalPromoteJobState(job.state)) return { outcome: 'rejected', reason: 'nonterminal' }; + if (!isTerminalPromoteJobState(job.state as PromoteJobState)) return { outcome: 'rejected', reason: 'nonterminal' }; await this.deleteJob(jobId); return { outcome: 'cleared' }; }); diff --git a/packages/publisher/src/async-promote-queue-utils.ts b/packages/publisher/src/async-promote-queue-utils.ts index a9e98ed288..5bdecafa3a 100644 --- a/packages/publisher/src/async-promote-queue-utils.ts +++ b/packages/publisher/src/async-promote-queue-utils.ts @@ -244,39 +244,76 @@ function isCommitMarker(value: unknown): value is PromoteCommitMarker { } /** - * Parse a `payload` binding back into a full `PromoteJob`. Returns null - * if the literal is malformed (corrupted payload) — the queue logs and - * skips such rows rather than crashing. + * Bounded classification of a `payload` binding. Distinguishes an absent row, a corrupt + * payload, and a structurally-valid job whose `state` is not a recognized enum value — a + * distinction {@link parseJobPayload} deliberately collapses into `null`. The by-jobId + * terminal clear reads through this so it can classify `already_absent` / `malformed` / + * `unknown` from a SINGLE canonical payload read (matching the lift sibling), rather than + * reading a secondary state-index triple. Never throws. */ -export function parseJobPayload(binding: string | undefined): PromoteJob | null { - if (!binding) return null; +/** + * A payload that parsed as a structurally complete job whose `state` has been validated only + * as a non-empty string — the PROMOTE_JOB_STATES enum-membership check is deliberately deferred + * to the caller (so the by-jobId clear can report a well-formed-but-unrecognized state as + * `unknown`, distinct from a corrupt payload). Exposing `state: string` keeps the classifier + * boundary honest: it never types a value as `PromoteJobState` before that has been checked. + */ +export type StructurallyValidPromoteJobPayload = Omit & { readonly state: string }; + +export type PromoteJobParseResult = + | { readonly kind: 'absent' } + | { readonly kind: 'malformed' } + | { readonly kind: 'job'; readonly job: StructurallyValidPromoteJobPayload }; + +export function classifyJobPayload(binding: string | undefined): PromoteJobParseResult { + if (!binding) return { kind: 'absent' }; try { const payload = parseLiteral(binding); - if (typeof payload !== 'string') return null; + if (typeof payload !== 'string') return { kind: 'malformed' }; const parsed = JSON.parse(payload); - if (!isRecord(parsed)) return null; - if (typeof parsed['jobId'] !== 'string' || parsed['jobId'].length === 0) return null; - if (!(PROMOTE_JOB_STATES as readonly string[]).includes(String(parsed['state']))) return null; - if (!isPromoteRequest(parsed['request'])) return null; - if (!isFiniteNumber(parsed['enqueuedAt']) || !isFiniteNumber(parsed['updatedAt'])) return null; - if (!isRecord(parsed['attempt'])) return null; + if (!isRecord(parsed)) return { kind: 'malformed' }; + if (typeof parsed['jobId'] !== 'string' || parsed['jobId'].length === 0) return { kind: 'malformed' }; + // `state` is structurally validated as a non-empty string here, but the PROMOTE_JOB_STATES + // enum-membership check is intentionally NOT applied: a well-formed job carrying an + // unrecognized state *string* is `kind: 'job'` so the terminal clear can report it as + // `unknown`. A missing or non-string `state` is structural corruption (not an unrecognized + // state), so — like every other malformed field — it is `malformed`, never `unknown`. + if (typeof parsed['state'] !== 'string' || parsed['state'].length === 0) return { kind: 'malformed' }; + if (!isPromoteRequest(parsed['request'])) return { kind: 'malformed' }; + if (!isFiniteNumber(parsed['enqueuedAt']) || !isFiniteNumber(parsed['updatedAt'])) return { kind: 'malformed' }; + if (!isRecord(parsed['attempt'])) return { kind: 'malformed' }; if (!isFiniteNumber(parsed['attempt']['count']) || !isFiniteNumber(parsed['attempt']['maxRetries'])) { - return null; + return { kind: 'malformed' }; } if ( parsed['attempt']['nextRetryAt'] !== undefined && !isFiniteNumber(parsed['attempt']['nextRetryAt']) ) { - return null; + return { kind: 'malformed' }; } - if (parsed['lease'] !== undefined && !isLease(parsed['lease'])) return null; - if (parsed['commitMarker'] !== undefined && !isCommitMarker(parsed['commitMarker'])) return null; - return parsed as unknown as PromoteJob; + if (parsed['lease'] !== undefined && !isLease(parsed['lease'])) return { kind: 'malformed' }; + if (parsed['commitMarker'] !== undefined && !isCommitMarker(parsed['commitMarker'])) return { kind: 'malformed' }; + return { kind: 'job', job: parsed as unknown as StructurallyValidPromoteJobPayload }; } catch { - return null; + return { kind: 'malformed' }; } } +/** + * Parse a `payload` binding back into a full `PromoteJob`, or null when the payload is + * absent, malformed, or carries an unrecognized `state`. A strict view over + * {@link classifyJobPayload} (it re-applies the enum drop), so the read/list/conflict paths + * skip such rows rather than crashing. + */ +export function parseJobPayload(binding: string | undefined): PromoteJob | null { + const result = classifyJobPayload(binding); + if (result.kind !== 'job') return null; + // Re-apply the enum drop classifyJobPayload defers: a well-formed job whose state string is + // not a recognized value is skipped by the list/read/conflict paths. The cast is honest only + // once the membership check has passed (state is provably a PromoteJobState here). + return (PROMOTE_JOB_STATES as readonly string[]).includes(result.job.state) ? (result.job as PromoteJob) : null; +} + /** * Default exponential backoff: 1m, 2m, 4m, 8m, 15m (cap). Caller passes * 1-indexed attempt count. diff --git a/packages/publisher/test/async-promote-terminal-clear.test.ts b/packages/publisher/test/async-promote-terminal-clear.test.ts index 2d77f74a88..04129684c4 100644 --- a/packages/publisher/test/async-promote-terminal-clear.test.ts +++ b/packages/publisher/test/async-promote-terminal-clear.test.ts @@ -7,7 +7,7 @@ import { type PromoteTerminalJobClearer, } from '../src/async-promote-queue-types.js'; import { TripleStoreAsyncPromoteQueue } from '../src/async-promote-queue-impl.js'; -import { DEFAULT_PROMOTE_CONTROL_GRAPH_URI, PROMOTE_STATE, jobSubject, literal } from '../src/async-promote-queue-utils.js'; +import { DEFAULT_PROMOTE_CONTROL_GRAPH_URI, PROMOTE_PAYLOAD, classifyJobPayload, jobSubject, literal, parseJobPayload } from '../src/async-promote-queue-utils.js'; // #1837 — atomic by-exact-jobId TERMINAL clear for the SWM promote queue. describe('#1837 promote queue clearTerminalJob', () => { @@ -55,6 +55,16 @@ describe('#1837 promote queue clearTerminalJob', () => { return jobId; } + // Raw stored PROMOTE_PAYLOAD literal for a subject, in the store's own return form. Comparing + // the value read before vs after a clear proves a rejected clear neither deleted nor altered + // the row (#1893: "never mutates on a reject"), and is immune to insert-vs-return re-escaping. + async function rawPayloadOf(jobId: string): Promise { + const result = await store.query( + `SELECT ?payload WHERE { GRAPH <${DEFAULT_PROMOTE_CONTROL_GRAPH_URI}> { <${jobSubject(jobId)}> <${PROMOTE_PAYLOAD}> ?payload } }`, + ); + return result.type === 'bindings' ? result.bindings[0]?.['payload'] : undefined; + } + it('clears an exact succeeded job (cleared); no other job changes', async () => { const queue = createQueue(); const target = await enqueueSucceeded(queue, { assertionName: 'a' }); @@ -102,9 +112,11 @@ describe('#1837 promote queue clearTerminalJob', () => { it('is idempotent: an absent / already-cleared job returns already_absent', async () => { const queue = createQueue(); expect(await queue.clearTerminalJob('never-existed')).toEqual({ outcome: 'already_absent' }); + expect(await rawPayloadOf('never-existed')).toBeUndefined(); // clearing an absent job creates no row const jobId = await enqueueSucceeded(queue); expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'cleared' }); expect(await queue.clearTerminalJob(jobId)).toEqual({ outcome: 'already_absent' }); // repeat + expect(await rawPayloadOf(jobId)).toBeUndefined(); // cleared row stays gone }); it('rejects an empty or SPARQL-unsafe jobId as malformed without querying/mutating', async () => { @@ -115,14 +127,48 @@ describe('#1837 promote queue clearTerminalJob', () => { expect(await queue.clearTerminalJob('bad>id')).toEqual({ outcome: 'rejected', reason: 'malformed' }); }); - // #1883 review (🟡): a state triple present but not a recognized enum value must be a - // bounded reject (unknown), never throw — and the parse itself is now try/catch-guarded. - it('rejects a subject whose state is not a known enum value as unknown, without throwing', async () => { + // #1893: a structurally-valid payload whose `state` is not a recognized enum value must be + // a bounded reject (unknown) — classified from the single canonical payload read. + it('rejects a job whose payload state is not a known enum value as unknown, without throwing', async () => { const queue = createQueue(); + const bogusJob = { + jobId: 'bogus-1', state: 'bogus_state', + request: makeRequest(), enqueuedAt: now, updatedAt: now, + attempt: { count: 0, maxRetries: 3 }, + }; await store.insert([ - { subject: jobSubject('bogus-1'), predicate: PROMOTE_STATE, object: literal('bogus_state'), graph: DEFAULT_PROMOTE_CONTROL_GRAPH_URI }, + { subject: jobSubject('bogus-1'), predicate: PROMOTE_PAYLOAD, object: literal(JSON.stringify(bogusJob)), graph: DEFAULT_PROMOTE_CONTROL_GRAPH_URI }, ]); + const before = await rawPayloadOf('bogus-1'); + expect(before).toBeDefined(); await expect(queue.clearTerminalJob('bogus-1')).resolves.toEqual({ outcome: 'rejected', reason: 'unknown' }); + expect(await rawPayloadOf('bogus-1')).toBe(before); // rejected clear must NOT delete or alter the row + }); + + // #1893: a payload literal that is present but not a valid job is malformed, not unknown. + it('rejects a subject with a corrupt payload literal as malformed', async () => { + const queue = createQueue(); + await store.insert([ + { subject: jobSubject('corrupt-1'), predicate: PROMOTE_PAYLOAD, object: literal('not-a-job-json'), graph: DEFAULT_PROMOTE_CONTROL_GRAPH_URI }, + ]); + const before = await rawPayloadOf('corrupt-1'); + expect(before).toBeDefined(); + await expect(queue.clearTerminalJob('corrupt-1')).resolves.toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await rawPayloadOf('corrupt-1')).toBe(before); // rejected clear must NOT delete or alter the row + }); + + // #1893 (review): a payload that is otherwise well-formed but carries no string `state` is + // structural corruption → malformed (HTTP 400), NOT the unknown-state path (HTTP 409). + it('rejects a payload with a missing/non-string state as malformed, not unknown', async () => { + const queue = createQueue(); + const noState = { jobId: 'nostate-1', request: makeRequest(), enqueuedAt: now, updatedAt: now, attempt: { count: 0, maxRetries: 3 } }; + await store.insert([ + { subject: jobSubject('nostate-1'), predicate: PROMOTE_PAYLOAD, object: literal(JSON.stringify(noState)), graph: DEFAULT_PROMOTE_CONTROL_GRAPH_URI }, + ]); + const before = await rawPayloadOf('nostate-1'); + expect(before).toBeDefined(); + await expect(queue.clearTerminalJob('nostate-1')).resolves.toEqual({ outcome: 'rejected', reason: 'malformed' }); + expect(await rawPayloadOf('nostate-1')).toBe(before); // rejected clear must NOT delete or alter the row }); it('concurrent clears of one terminal job are deterministic: one cleared, rest already_absent, no other job affected', async () => { @@ -137,3 +183,54 @@ describe('#1837 promote queue clearTerminalJob', () => { expect((await queue.getStatus(other))?.state).toBe('succeeded'); // never affected }); }); + +// #1893 — the bounded classifier the single-read clear is built on. +describe('classifyJobPayload', () => { + const validJob = { + jobId: 'j1', state: 'queued', + request: { contextGraphId: 'g', assertionName: 'a', entities: 'all' }, + enqueuedAt: 1, updatedAt: 1, attempt: { count: 0, maxRetries: 3 }, + }; + // Mirror serializeJob's PROMOTE_PAYLOAD encoding: literal(JSON.stringify(job)). + const bind = (v: unknown) => literal(JSON.stringify(v)); + + it('absent for an undefined or empty binding', () => { + expect(classifyJobPayload(undefined)).toEqual({ kind: 'absent' }); + expect(classifyJobPayload('')).toEqual({ kind: 'absent' }); + }); + + it('malformed for a non-JSON or structurally-invalid payload', () => { + expect(classifyJobPayload(literal('not-json')).kind).toBe('malformed'); + expect(classifyJobPayload(bind({ ...validJob, jobId: '' })).kind).toBe('malformed'); + expect(classifyJobPayload(bind({ ...validJob, request: {} })).kind).toBe('malformed'); + expect(classifyJobPayload(bind({ ...validJob, enqueuedAt: 'x' })).kind).toBe('malformed'); + }); + + // #1893 (review): a missing or non-string `state` is structural corruption — it must be + // `malformed`, NOT `unknown` (which is reserved for a well-formed but non-enum state string). + it('malformed for a missing or non-string state', () => { + expect(classifyJobPayload(bind({ ...validJob, state: undefined })).kind).toBe('malformed'); // JSON.stringify drops the key + expect(classifyJobPayload(bind({ ...validJob, state: '' })).kind).toBe('malformed'); + expect(classifyJobPayload(bind({ ...validJob, state: 42 })).kind).toBe('malformed'); + expect(classifyJobPayload(bind({ ...validJob, state: null })).kind).toBe('malformed'); + }); + + it('job for a structurally-valid payload, INCLUDING a non-enum state string', () => { + expect(classifyJobPayload(bind(validJob))).toMatchObject({ kind: 'job' }); + const result = classifyJobPayload(bind({ ...validJob, state: 'bogus_state' })); + expect(result.kind).toBe('job'); + if (result.kind === 'job') expect(result.job.state).toBe('bogus_state'); + }); + + // #1893 (review round 2): the classifier deliberately accepts a non-enum state string as + // `kind: 'job'` (so the terminal clear can report `unknown`), which makes parseJobPayload the + // ONLY runtime guard that keeps ordinary readers (list/getStatus/conflict) from surfacing a + // row with an impossible state. Lock that strict enum-drop directly here: were it removed from + // parseJobPayload, this asserts red even though the classifier tests above stay green. + it('parseJobPayload re-applies the enum drop the classifier defers', () => { + const nonEnum = bind({ ...validJob, state: 'bogus_state' }); + expect(classifyJobPayload(nonEnum).kind).toBe('job'); // classifier accepts it for terminal-clear classification + expect(parseJobPayload(nonEnum)).toBeNull(); // strict wrapper rejects it for read/list/conflict callers + expect(parseJobPayload(bind(validJob))).not.toBeNull(); // a valid enum state is returned unchanged + }); +}); From 590f7387c629f73786915da843e8293032b62836 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 11:06:59 +0200 Subject: [PATCH 205/292] fix(chain): preserve finalized read failures --- .../src/current-finalized-evm-read-profile.ts | 3 +- .../src/strict-current-finalized-evm-rpc.ts | 74 ++++++++++++++----- ...ict-current-finalized-evm-rpc.unit.test.ts | 42 ++++++++++- 3 files changed, 96 insertions(+), 23 deletions(-) diff --git a/packages/chain/src/current-finalized-evm-read-profile.ts b/packages/chain/src/current-finalized-evm-read-profile.ts index 5d5f210f40..609f89a6ce 100644 --- a/packages/chain/src/current-finalized-evm-read-profile.ts +++ b/packages/chain/src/current-finalized-evm-read-profile.ts @@ -50,7 +50,6 @@ export class CurrentFinalizedEvmCallErrorV1 extends Error { super(message, options.cause === undefined ? undefined : { cause: options.cause }); this.name = 'CurrentFinalizedEvmCallErrorV1'; this.code = code; - // Package-internal typed failures finish their own fields before freezing. - if (new.target === CurrentFinalizedEvmCallErrorV1) Object.freeze(this); + Object.freeze(this); } } diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 9fd6cea2d7..2adbc8143e 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -109,20 +109,15 @@ const MAX_U256 = 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); +const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); +const PRE_DEADLINE_TERMINAL_FAILURES_V1 = new WeakSet(); const READ_REQUEST_KEYS = Object.freeze(['calls', 'chainId', 'signal'] as const); const READ_CALL_KEYS = Object.freeze(['data', 'maxReturnBytes', 'to'] as const); -class AuthenticatedFinalizedEvmRevertErrorV1 extends CurrentFinalizedEvmCallErrorV1 { - constructor(readonly revertData: string) { - super('revert', 'Contract call reverted at the resolved finalized anchor'); - Object.freeze(this); - } -} - /** Package-internal evidence available only for errors minted by this transport. */ export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { - return error instanceof AuthenticatedFinalizedEvmRevertErrorV1 - ? error.revertData + return error instanceof CurrentFinalizedEvmCallErrorV1 + ? AUTHENTICATED_REVERT_DATA_V1.get(error) : undefined; } @@ -219,7 +214,7 @@ export function createStrictCurrentFinalizedEvmReadV1( config, config.endpoints[index]!, request, - attemptDeadline.signal, + attemptDeadline, ); // Close races where transport completion and abort/deadline become // observable in the same turn. A late response never escapes merely @@ -278,8 +273,9 @@ async function executeEndpointAttempt( config: StrictRpcConfigSnapshotV1, endpoint: string, request: StrictCurrentFinalizedEvmReadRequestV1, - signal: AbortSignal, + attemptDeadline: DeadlineScope, ): Promise { + const { signal } = attemptDeadline; let requestId = 0; const rpc = async (method: string, params: readonly unknown[]): Promise => { requestId += 1; @@ -310,7 +306,7 @@ async function executeEndpointAttempt( await settleParallelBatch(uniqueTargets.map(async (to) => { const code = await rpc('eth_getCode', Object.freeze([to, blockReference])); assertDeployedCode(code); - })); + }), attemptDeadline); return settleParallelBatch(request.calls.map(async (call) => { const callObject = Object.freeze({ @@ -323,7 +319,7 @@ async function executeEndpointAttempt( await rpc('eth_call', Object.freeze([callObject, blockReference])), call.maxReturnBytes, ); - })); + }), attemptDeadline); }; let returnData: readonly string[]; @@ -391,12 +387,44 @@ async function executeEndpointAttempt( * started operation settles. This prevents an early rejection from leaving a * sibling fetch alive after the finalized-read concurrency slot is released. */ -async function settleParallelBatch(operations: readonly Promise[]): Promise { - const settled = await Promise.allSettled(operations); +async function settleParallelBatch( + operations: readonly Promise[], + attemptDeadline: DeadlineScope, +): Promise { + let firstFailure: unknown; + let hasFailure = false; + let firstPreDeadlineTerminalFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + const tracked = operations.map(async (operation) => { + try { + return await operation; + } catch (cause) { + if (!hasFailure) { + hasFailure = true; + firstFailure = cause; + } + if ( + firstPreDeadlineTerminalFailure === undefined + && cause instanceof CurrentFinalizedEvmCallErrorV1 + && isTerminalAttemptFailure(cause) + && !attemptDeadline.timedOut() + ) { + firstPreDeadlineTerminalFailure = cause; + PRE_DEADLINE_TERMINAL_FAILURES_V1.add(cause); + } + throw cause; + } + }); + const settled = await Promise.allSettled(tracked); + if (firstPreDeadlineTerminalFailure !== undefined) { + throw firstPreDeadlineTerminalFailure; + } + if (hasFailure) throw firstFailure; const values: T[] = []; for (let index = 0; index < settled.length; index += 1) { const result = settled[index]!; - if (result.status === 'rejected') throw result.reason; + if (result.status === 'rejected') { + throw unavailable('Parallel finalized-read operation failed without a recorded cause'); + } values.push(result.value); } return Object.freeze(values); @@ -659,6 +687,12 @@ function classifyAttemptFailure( attemptDeadline: DeadlineScope, ): CurrentFinalizedEvmCallErrorV1 { if (callerSignal.aborted) return cancelled('Current-finalized EVM call was cancelled'); + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && PRE_DEADLINE_TERMINAL_FAILURES_V1.has(cause) + ) { + return cause; + } if (totalDeadline.timedOut()) { return timedOut(`Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`); } @@ -863,12 +897,12 @@ function anchorDependentResourceLimited(message: string): CurrentFinalizedEvmCal } function revertedAtFinalizedAnchor(data?: string): CurrentFinalizedEvmCallErrorV1 { - return data === undefined - ? new CurrentFinalizedEvmCallErrorV1( + const error = new CurrentFinalizedEvmCallErrorV1( 'revert', 'Contract call reverted at the resolved finalized anchor', - ) - : new AuthenticatedFinalizedEvmRevertErrorV1(data); + ); + if (data !== undefined) AUTHENTICATED_REVERT_DATA_V1.set(error, data); + return error; } function isAnchorDependentResourceLimit( diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 60fb39da74..70928463ce 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -373,6 +373,45 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { await expect(read(request())).rejects.toMatchObject({ code: 'no-code' }); }); + it('preserves a terminal failure that precedes a slower sibling attempt timeout', async () => { + const siblingClosed = deferred(); + const first = await startRpcServer(async (call, response) => { + switch (call.method) { + case 'eth_chainId': + sendResult(response, call, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': + sendResult(response, call, { number: '0x7b', hash: BLOCK_HASH }); + return; + case 'eth_getCode': + if (call.params[0] === TO) { + sendResult(response, call, '0x'); + return; + } + response.on('close', () => siblingClosed.resolve(undefined)); + return; + default: + sendError(response, call, -32601, 'method not found'); + } + }); + const second = await startRpcServer(successfulHandler()); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + + await expect(read({ + chainId: CHAIN_ID, + calls: [ + { to: TO, data: FIRST_READ_DATA, maxReturnBytes: 32 }, + { to: OTHER_TO, data: SECOND_READ_DATA, maxReturnBytes: 32 }, + ], + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'no-code' }); + await siblingClosed.promise; + expect(second.calls).toHaveLength(0); + }, CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1 + 4_000); + it('uses the configured endpoint once, in exact EIP-1898 request order and shape', async () => { const server = await startRpcServer(successfulHandler()); const configuredEndpoints = [server.url]; @@ -635,7 +674,8 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { } catch (error) { caught = error; } - expect(caught).toMatchObject({ code: 'revert', revertData }); + expect(caught).toMatchObject({ code: 'revert' }); + expect(caught).not.toHaveProperty('revertData'); expect(Object.isFrozen(caught)).toBe(true); expect(readStrictCurrentFinalizedEvmRevertDataV1(caught)).toBe(revertData); expect(readStrictCurrentFinalizedEvmRevertDataV1( From 17d7c0880693a5a1cf16076293e0d10c47107a42 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 11:14:01 +0200 Subject: [PATCH 206/292] fix(chain): retain EIP-1271 router policy --- packages/chain/src/current-finalized-evm-call.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/chain/src/current-finalized-evm-call.ts b/packages/chain/src/current-finalized-evm-call.ts index 1bac14a2f9..95219629d2 100644 --- a/packages/chain/src/current-finalized-evm-call.ts +++ b/packages/chain/src/current-finalized-evm-call.ts @@ -20,7 +20,6 @@ import { type CurrentFinalizedEvmCallV1, } from './current-finalized-evm-call-model.js'; import { - CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, CURRENT_FINALIZED_EVM_CALL_ERROR_CODES_V1, CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; @@ -74,7 +73,7 @@ export function createCurrentFinalizedEvmCallRouterV1( ): CurrentFinalizedEvmCallV1 { const adapters = snapshotAdapterRegistry(registrations); const admission = createNonqueueingAdmissionGateV1( - CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + CONTROL_EIP1271_MAX_CONCURRENT_CALLS_PER_CHAIN_V1, ); const route: CurrentFinalizedEvmCallV1 = async (input) => { From d56a7c8a611baa3b0a29079366b1b5a94d089680 Mon Sep 17 00:00:00 2001 From: Jurij89 <138491694+Jurij89@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:19:22 -0400 Subject: [PATCH 207/292] refactor(publisher): named VmPublisherControl contract + neutral job-id grammar (#1889) (#1901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainability follow-up to #1837 (PR #1883). No runtime behaviour change. Part A — named control contract: - Add `VmPublisherControl` (composite of the four VmPublish* capability interfaces). `createPublisherControlFromStore` returns it, and `RequestContext.publisherControl` is typed with it instead of `ReturnType` — removing the factory indirection while the four base interfaces remain for narrow-surface callers. Part B — neutral job-id grammar: - Move the shared job-id grammar out of `terminal-job-clear.ts` (where it was misnamed as clear policy despite governing ALL promote routes via `validatePromoteJobId`) into a neutral `job-id.ts`: `SAFE_JOB_ID_PATTERN` / `SAFE_JOB_ID_MAX_LENGTH` / `isSafeJobId`. The lift + promote clear guards and the CLI route validator all delegate to it, so route-level and control-plane acceptance provably cannot drift. Clean rename (no back-compat shim, per repo policy); grammar is byte-identical. terminal-job-clear.ts is now the clear-OUTCOME module only. Part C (naming the promote-queue intersection) intentionally skipped: the agent.promoteQueue cast was already removed in #1883, so it carried no value. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/cli/src/daemon/routes/context.ts | 8 +++--- .../daemon/routes/shared-assertion-helpers.ts | 10 +++---- packages/cli/src/publisher-runner.ts | 7 ++--- .../src/async-lift-publisher-impl.ts | 5 ++-- .../src/async-lift-publisher-types.ts | 14 ++++++++++ .../publisher/src/async-lift-publisher.ts | 1 + .../publisher/src/async-promote-queue-impl.ts | 5 ++-- packages/publisher/src/index.ts | 8 +++--- packages/publisher/src/job-id.ts | 23 ++++++++++++++++ packages/publisher/src/terminal-job-clear.ts | 27 +++---------------- 10 files changed, 62 insertions(+), 46 deletions(-) create mode 100644 packages/publisher/src/job-id.ts diff --git a/packages/cli/src/daemon/routes/context.ts b/packages/cli/src/daemon/routes/context.ts index a06fe7c674..dbad8dd72d 100644 --- a/packages/cli/src/daemon/routes/context.ts +++ b/packages/cli/src/daemon/routes/context.ts @@ -16,10 +16,8 @@ import type { OperationTracker, } from '@origintrail-official/dkg-node-ui'; import type { DkgConfig, loadNetworkConfig } from '../../config.js'; -import type { - createPublisherControlFromStore, - PublisherState, -} from '../../publisher-runner.js'; +import type { VmPublisherControl } from '@origintrail-official/dkg-publisher'; +import type { PublisherState } from '../../publisher-runner.js'; import type { ExtractionStatusRecord } from '../../extraction-status.js'; import type { FileStore } from '../../file-store.js'; import type { VectorStore, EmbeddingProvider } from '../../vector-store.js'; @@ -61,7 +59,7 @@ export interface RequestContext { req: IncomingMessage; res: ServerResponse; agent: DKGAgent; - publisherControl: ReturnType; + publisherControl: VmPublisherControl; /** Lifecycle-owned runtime and readiness as one correlated state. */ publisherState: PublisherState; config: DkgConfig; diff --git a/packages/cli/src/daemon/routes/shared-assertion-helpers.ts b/packages/cli/src/daemon/routes/shared-assertion-helpers.ts index f0a42d2d6a..ee8df70950 100644 --- a/packages/cli/src/daemon/routes/shared-assertion-helpers.ts +++ b/packages/cli/src/daemon/routes/shared-assertion-helpers.ts @@ -23,8 +23,8 @@ import { import { type PromoteJob, type PromoteJobState, - SAFE_CLEAR_JOB_ID_PATTERN, - SAFE_CLEAR_JOB_ID_MAX_LENGTH, + SAFE_JOB_ID_PATTERN, + SAFE_JOB_ID_MAX_LENGTH, } from '@origintrail-official/dkg-publisher'; import { daemonState } from '../state.js'; import { @@ -122,11 +122,11 @@ export class ImportArtifactRouteError extends Error { export function validatePromoteJobId(jobId: string): { valid: true } | { valid: false; reason: string } { // Grammar + length bound are imported from the publisher (the single authoritative - // job-id contract, also enforced control-plane-side by `isSafeClearJobId`) so route + // job-id contract, also enforced control-plane-side by `isSafeJobId`) so route // acceptance and control-plane acceptance cannot drift. if (!jobId) return { valid: false, reason: "jobId is required" }; - if (jobId.length > SAFE_CLEAR_JOB_ID_MAX_LENGTH) return { valid: false, reason: "jobId is too long" }; - if (!SAFE_CLEAR_JOB_ID_PATTERN.test(jobId)) { + if (jobId.length > SAFE_JOB_ID_MAX_LENGTH) return { valid: false, reason: "jobId is too long" }; + if (!SAFE_JOB_ID_PATTERN.test(jobId)) { return { valid: false, reason: "jobId may only contain letters, numbers, '.', '_', ':', and '-'", diff --git a/packages/cli/src/publisher-runner.ts b/packages/cli/src/publisher-runner.ts index d23e4f1b51..de3f6a9072 100644 --- a/packages/cli/src/publisher-runner.ts +++ b/packages/cli/src/publisher-runner.ts @@ -30,10 +30,7 @@ import { type AsyncLiftPublisher, type AsyncLiftPublisherConfig, type AsyncLiftPublisherRecoveryResult, - type VmPublishIntentRecoveryPublisher, - type VmPublishIntentIndexBackfiller, - type VmPublishAdmissionJournalReader, - type VmPublishTerminalJobClearer, + type VmPublisherControl, type LiftJobBroadcast, type LiftJobHex, type LiftJobIncluded, @@ -375,7 +372,7 @@ export function createPublisherInspectorFromStore( export function createPublisherControlFromStore( store: TripleStore, options: { publicSnapshotStore?: WorkspacePublicSnapshotStore; maxRetries?: number } = {}, -): VmPublishIntentRecoveryPublisher & VmPublishIntentIndexBackfiller & VmPublishAdmissionJournalReader & VmPublishTerminalJobClearer { +): VmPublisherControl { // The daemon admission instance also serves the #1828 recovery lookup (route) // and the boot index backfill — segregated capabilities the base // AsyncLiftPublisher runtime contract intentionally does NOT carry. diff --git a/packages/publisher/src/async-lift-publisher-impl.ts b/packages/publisher/src/async-lift-publisher-impl.ts index f60b4edd84..9e8738bcc5 100644 --- a/packages/publisher/src/async-lift-publisher-impl.ts +++ b/packages/publisher/src/async-lift-publisher-impl.ts @@ -43,7 +43,8 @@ import type { VmPublishTerminalJobClearer, } from './async-lift-publisher-types.js'; import { AsyncLiftJobConflictError } from './async-lift-publisher-types.js'; -import { isSafeClearJobId, type TerminalJobClearOutcome } from './terminal-job-clear.js'; +import { type TerminalJobClearOutcome } from './terminal-job-clear.js'; +import { isSafeJobId } from './job-id.js'; import { mapPublishExceptionToLiftJobFailure, mapPublishResultToLiftJobSuccess, @@ -1044,7 +1045,7 @@ export class TripleStoreAsyncLiftPublisher // IRI — otherwise an attacker-controlled jobId (from the clear-job HTTP body) with a // space/'>'/'{' could break the query out of `<…>` and surface as a 500/injection // instead of the bounded outcome. - if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; + if (!isSafeJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; return this.withClaimLock(async () => { await this.ensureGraph(); const rows = expectBindings( diff --git a/packages/publisher/src/async-lift-publisher-types.ts b/packages/publisher/src/async-lift-publisher-types.ts index 84d8b118a2..2c1a491033 100644 --- a/packages/publisher/src/async-lift-publisher-types.ts +++ b/packages/publisher/src/async-lift-publisher-types.ts @@ -148,6 +148,20 @@ export interface VmPublishIntentIndexBackfiller { ensureVmPublishIntentIndex(): Promise; } +/** + * #1889 — the composite VM-publisher control surface returned by the daemon factory + * (`createPublisherControlFromStore`) and held by `RequestContext.publisherControl`. Names + * the capability set the daemon depends on, so the factory return type and the context field + * are a single named contract instead of an ad-hoc intersection. The four base interfaces + * remain the narrow contracts for callers that need a smaller surface (e.g. the boot + * backfill depends only on `VmPublishIntentIndexBackfiller`). + */ +export interface VmPublisherControl + extends VmPublishIntentRecoveryPublisher, + VmPublishIntentIndexBackfiller, + VmPublishAdmissionJournalReader, + VmPublishTerminalJobClearer {} + export interface AsyncLiftPublisherRecoveryResult { inclusion: LiftJobInclusionMetadata; finalization: LiftJobFinalizationMetadata; diff --git a/packages/publisher/src/async-lift-publisher.ts b/packages/publisher/src/async-lift-publisher.ts index e9b71e1738..d1213f205d 100644 --- a/packages/publisher/src/async-lift-publisher.ts +++ b/packages/publisher/src/async-lift-publisher.ts @@ -15,6 +15,7 @@ export type { VmPublishIntentIndexBackfiller, VmPublishAdmissionJournalReader, VmPublishTerminalJobClearer, + VmPublisherControl, IntentLookupInput, IntentLookupResult, JournalReadInput, diff --git a/packages/publisher/src/async-promote-queue-impl.ts b/packages/publisher/src/async-promote-queue-impl.ts index adff5835fd..f1ca23e5f6 100644 --- a/packages/publisher/src/async-promote-queue-impl.ts +++ b/packages/publisher/src/async-promote-queue-impl.ts @@ -18,7 +18,8 @@ */ import type { TripleStore } from '@origintrail-official/dkg-storage'; -import { isSafeClearJobId, type TerminalJobClearOutcome } from './terminal-job-clear.js'; +import { type TerminalJobClearOutcome } from './terminal-job-clear.js'; +import { isSafeJobId } from './job-id.js'; import { ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, ASYNC_PROMOTE_QUEUE_MIN_AUTO_RECOVERABLE_FORMAT_VERSION, @@ -624,7 +625,7 @@ export class TripleStoreAsyncPromoteQueue implements AsyncPromoteQueue, PromoteT // Reject an empty OR SPARQL-unsafe jobId as malformed before building the jobSubject // IRI (defense-in-depth; the SWM route already pre-validates via decodePromoteJobId, // but a direct agent.assertion.clearPromoteAsync caller must be bounded too). - if (!isSafeClearJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; + if (!isSafeJobId(jobId)) return { outcome: 'rejected', reason: 'malformed' }; return this.withMutationLock(async () => { await this.ensureGraph(); const rows = expectBindings( diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index c917e5f5fe..591714f8d1 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -268,6 +268,7 @@ export { type VmPublishIntentIndexBackfiller, type VmPublishAdmissionJournalReader, type VmPublishTerminalJobClearer, + type VmPublisherControl, type TerminalJobClearOutcome, type IntentLookupInput, type IntentLookupResult, @@ -275,9 +276,10 @@ export { type JournalReadResult, } from './async-lift-publisher.js'; export { - SAFE_CLEAR_JOB_ID_PATTERN, - SAFE_CLEAR_JOB_ID_MAX_LENGTH, -} from './terminal-job-clear.js'; + SAFE_JOB_ID_PATTERN, + SAFE_JOB_ID_MAX_LENGTH, + isSafeJobId, +} from './job-id.js'; export { TripleStoreAsyncPromoteQueue, ASYNC_PROMOTE_QUEUE_FORMAT_VERSION, diff --git a/packages/publisher/src/job-id.ts b/packages/publisher/src/job-id.ts new file mode 100644 index 0000000000..e86c9db2a8 --- /dev/null +++ b/packages/publisher/src/job-id.ts @@ -0,0 +1,23 @@ +// Neutral job-id grammar contract, shared by BOTH the by-jobId terminal-clear guard +// (`isSafeJobId`, used by the lift publisher and the promote queue) AND the CLI promote +// route validator (`validatePromoteJobId`). It is NOT clear-specific — status / cancel / +// recover / clear all accept the same job-id shape — so it lives in its own module rather +// than being named as terminal-clear policy, and a change here provably applies to every +// promote route + control-plane operation at once. + +// Producer grammar for a queue jobId (crypto.randomUUID(), or test 'job-N'): starts +// alphanumeric, then alnum / '.' / '_' / ':' / '-'. IRI-safe — it excludes every character +// that could break out of the `<…>` IRI in a control-plane SPARQL query (spaces, '<' '>' +// '"' '{' '}' '|' '^' '`', control chars). +export const SAFE_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +export const SAFE_JOB_ID_MAX_LENGTH = 256; + +/** + * True iff `jobId` is safe to interpolate into a control-plane SPARQL IRI. A by-jobId + * operation MUST reject an unsafe jobId BEFORE building the query, so an attacker-controlled + * jobId (e.g. from an HTTP body) yields a bounded reject rather than a query syntax error / + * injection / 500. + */ +export function isSafeJobId(jobId: string): boolean { + return jobId.length > 0 && jobId.length <= SAFE_JOB_ID_MAX_LENGTH && SAFE_JOB_ID_PATTERN.test(jobId); +} diff --git a/packages/publisher/src/terminal-job-clear.ts b/packages/publisher/src/terminal-job-clear.ts index 50c11cc6be..71d7e35edb 100644 --- a/packages/publisher/src/terminal-job-clear.ts +++ b/packages/publisher/src/terminal-job-clear.ts @@ -1,6 +1,6 @@ -// #1837 — shared contract for the atomic by-exact-jobId TERMINAL clear, owned by NEITHER -// queue (lift nor promote) so a generic admin-clear result is not coupled to one -// implementation family's type module. +// #1837 — shared contract for the atomic by-exact-jobId TERMINAL clear OUTCOME, owned by +// NEITHER queue (lift nor promote) so a generic admin-clear result is not coupled to one +// implementation family's type module. (The job-id grammar guard lives in `./job-id.ts`.) /** * Bounded outcome of a terminal clear, shared by the lift publisher and the SWM promote @@ -13,24 +13,3 @@ export type TerminalJobClearOutcome = | { readonly outcome: 'cleared' } | { readonly outcome: 'already_absent' } | { readonly outcome: 'rejected'; readonly reason: 'nonterminal' | 'unknown' | 'malformed' }; - -// Producer grammar for a queue jobId (crypto.randomUUID(), or test 'job-N'): starts -// alphanumeric, then alnum/'.'/'_'/':'/'-'. This is IRI-safe — it excludes every character -// that could break out of the `<…>` IRI in a control-plane SPARQL query (spaces, '<' '>' -// '"' '{' '}' '|' '^' '`', control chars). This is the SINGLE authoritative job-id grammar: -// the CLI route validator (`validatePromoteJobId`) imports it rather than re-declaring the -// regex, so route-level and control-plane job-id acceptance cannot drift. -export const SAFE_CLEAR_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; -export const SAFE_CLEAR_JOB_ID_MAX_LENGTH = 256; - -/** - * True iff `jobId` is safe to interpolate into a control-plane SPARQL IRI. A by-id clear - * MUST reject an unsafe jobId as `malformed` BEFORE building the query, so an - * attacker-controlled jobId (from the clear-job HTTP body) yields a bounded reject rather - * than a query syntax error / injection / 500. - */ -export function isSafeClearJobId(jobId: string): boolean { - return ( - jobId.length > 0 && jobId.length <= SAFE_CLEAR_JOB_ID_MAX_LENGTH && SAFE_CLEAR_JOB_ID_PATTERN.test(jobId) - ); -} From e44d66fb0d35e89db6cdb4414f955dd085b4f3b3 Mon Sep 17 00:00:00 2001 From: Jurij89 <138491694+Jurij89@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:20:21 -0400 Subject: [PATCH 208/292] test(publisher): consolidate the async-lift KA VM-publish fixture into _helpers/ (#1862) (#1907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async-lift publisher test suite repeated the same KA VM-publish request builder + share-snapshot staging + tx/validation constants across 6 specs, so a change to the KA VM-publish request contract meant editing every copy. Promote the shared pieces into `test/_helpers/ka-vm-publish.ts`: - `kaVmPublishRequest(overrides?)` (strongly-typed override param) + the `KA_VM_AUTHOR_ADDRESS` / `KA_VM_KA_NUMBER` / `KA_VM_KA_UAL` consts, - `stageKnowledgeAssetShareSnapshot(params)` — the storeKnowledgeAssetOperation PublicQuads staging, with the genuine per-file variations (store, graphManager, shareOperationId, kaUal, assertionVersion string-vs-number, accessPolicy) parameterized and defaulted; each spec keeps its thin local wrapper, - `KA_VM_VALIDATION` / `KA_VM_BROADCAST_TX` / `KA_VM_INCLUSION` / `KA_VM_EXECUTOR_TX_HASH`. The 6 specs consume the helper. Heterogeneous validation/broadcast blocks in async-lift-publisher.test.ts (distinct txHashes/proofRefs) are left in place. Pure test hygiene — zero it()/expect() changes (it() counts unchanged: 47/6/5/16/12/11); net -258 lines. Co-authored-by: Claude Opus 4.8 (1M context) --- .../publisher/test/_helpers/ka-vm-publish.ts | 143 ++++++++++++++++++ .../async-lift-broadcast-durability.test.ts | 58 +------ .../test/async-lift-intent-lookup.test.ts | 49 +----- .../test/async-lift-journal-append.test.ts | 68 ++------- .../async-lift-ka-broadcast-progress.test.ts | 93 ++---------- .../test/async-lift-publisher.test.ts | 71 ++------- .../test/async-lift-terminal-clear.test.ts | 41 ++--- 7 files changed, 204 insertions(+), 319 deletions(-) create mode 100644 packages/publisher/test/_helpers/ka-vm-publish.ts diff --git a/packages/publisher/test/_helpers/ka-vm-publish.ts b/packages/publisher/test/_helpers/ka-vm-publish.ts new file mode 100644 index 0000000000..a56c1d2037 --- /dev/null +++ b/packages/publisher/test/_helpers/ka-vm-publish.ts @@ -0,0 +1,143 @@ +/** + * Shared fixture for the KA async VM-publish specs. + * + * The `kaVmPublishRequest` builder, the `storeKnowledgeAssetOperationPublicQuads` + * share-snapshot staging, and the repeated validation / broadcast / inclusion + * constants were copy-pasted verbatim across six async-lift spec files (see + * GH #1862). This module is the single source for that fixture so the specs stay + * focused on the behaviour they assert. + * + * Every value here is byte-identical to the literals it replaced — no scenario + * or assertion changed. Cosmetic variations across the original copies (all of + * which resolved to the SAME value) were collapsed onto the canonical form: + * - `roots: [] as string[]` / `roots: []` → `roots: []` + * - `contentScopeVersion: 2 as const` → `GRAPH_KA_CONTENT_SCOPE_VERSION` (=== 2) + * - inline `did:dkg:31337//7` / `ROOTLESS_UAL` → `KA_VM_KA_UAL` + * - `` `sha256:${'ab'.repeat(32)}` `` / `'sha256:' + …` → template literal + */ +import { GraphManager, type TripleStore, type Quad } from '@origintrail-official/dkg-storage'; +import { GRAPH_KA_CONTENT_SCOPE_VERSION } from '@origintrail-official/dkg-core'; +import { + storeKnowledgeAssetOperationPublicQuads, + type KnowledgeAssetVmPublishRequest, + type LiftJobValidationMetadata, +} from '../../src/index.js'; + +/** Fixed KA author address shared by every VM-publish fixture request. */ +export const KA_VM_AUTHOR_ADDRESS = '0x1111111111111111111111111111111111111111'; +/** Fixed KA number shared by every VM-publish fixture request. */ +export const KA_VM_KA_NUMBER = 7n; +/** Rootless UAL for the one queued KA (`did:dkg:31337//`). */ +export const KA_VM_KA_UAL = `did:dkg:31337/${KA_VM_AUTHOR_ADDRESS}/${KA_VM_KA_NUMBER.toString()}`; + +/** + * Build the canonical KA VM-publish request, overriding any field. The + * strongly-typed `Partial` override subsumes the + * loose `Record` variant some of the original copies used. + */ +export function kaVmPublishRequest( + overrides: Partial = {}, +): KnowledgeAssetVmPublishRequest { + const base: KnowledgeAssetVmPublishRequest = { + contextGraphId: 'music-social', + name: 'albums', + shareOperationId: 'share-op-1', + roots: [], + contentScopeVersion: GRAPH_KA_CONTENT_SCOPE_VERSION, + kaUal: KA_VM_KA_UAL, + assertionVersion: '1', + publicTripleCount: 2, + privateTripleCount: 0, + seal: { + merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, + authorAddress: KA_VM_AUTHOR_ADDRESS as `0x${string}`, + signature: { + r: (`0x${'34'.repeat(32)}`) as `0x${string}`, + vs: (`0x${'56'.repeat(32)}`) as `0x${string}`, + }, + schemeVersion: 1, + reservedKaId: ((BigInt(KA_VM_AUTHOR_ADDRESS) << 96n) | KA_VM_KA_NUMBER).toString() as `${bigint}`, + }, + sealChainId: '31337' as `${bigint}`, + sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, + sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', + sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, + intentKey: `sha256:${'ab'.repeat(32)}`, + wmCurrentAssertion: '12'.repeat(32), + swmCurrentAssertion: '12'.repeat(32), + kaNumber: KA_VM_KA_NUMBER.toString(), + reservedUal: KA_VM_KA_UAL, + }; + return { ...base, ...overrides }; +} + +/** The two public album triples staged by every share-snapshot fixture. */ +export const KA_VM_ALBUM_QUADS: readonly Quad[] = [ + { subject: 'urn:album:one', predicate: 'http://schema.org/name', object: '"One"', graph: '' }, + { subject: 'urn:album:two', predicate: 'http://schema.org/name', object: '"Two"', graph: '' }, +]; + +export interface StageKnowledgeAssetShareSnapshotParams { + store: TripleStore; + /** Defaults to a fresh `GraphManager` over `store`. */ + graphManager?: GraphManager; + contextGraphId?: string; + shareOperationId?: string; + kaUal?: string; + /** + * Kept as `string | number | bigint` so each call site preserves its exact + * value AND type — the two SWM-staging specs pass the string `'1'`, the + * rootless-snapshot spec passes the number `1`. + */ + assertionVersion?: string | number | bigint; + publisherPeerId?: string; + accessPolicy?: 'public' | 'ownerOnly' | 'allowList'; + quads?: readonly Quad[]; +} + +/** + * Stage the KA share snapshot (`storeKnowledgeAssetOperationPublicQuads`) shared + * by the SWM-backed VM-publish specs. Every parameter defaults to the canonical + * fixture value; genuine per-file variations (store, assertionVersion type, + * accessPolicy, shareOperationId) are passed by the caller's thin wrapper. + */ +export async function stageKnowledgeAssetShareSnapshot( + params: StageKnowledgeAssetShareSnapshotParams, +): Promise { + await storeKnowledgeAssetOperationPublicQuads({ + store: params.store, + graphManager: params.graphManager ?? new GraphManager(params.store), + contextGraphId: params.contextGraphId ?? 'music-social', + shareOperationId: params.shareOperationId ?? 'share-op-1', + kaUal: params.kaUal ?? KA_VM_KA_UAL, + assertionVersion: params.assertionVersion ?? '1', + publisherPeerId: params.publisherPeerId ?? 'peer-1', + quads: params.quads ?? KA_VM_ALBUM_QUADS, + ...(params.accessPolicy !== undefined ? { accessPolicy: params.accessPolicy } : {}), + }); +} + +/** Shared `validated`-transition payload (`authorityProofRef: 'knowledge-asset-lifecycle'`, `swmQuadCount: 2`). */ +export const KA_VM_VALIDATION: LiftJobValidationMetadata = { + canonicalRoots: [], + canonicalRootMap: {}, + swmQuadCount: 2, + authorityProofRef: 'knowledge-asset-lifecycle', + transitionType: 'CREATE', +}; + +/** Shared `broadcast` payload (`0xef…` attempted tx hash, `wallet-1`). Untyped so it mirrors the specs' local `bx`. */ +export const KA_VM_BROADCAST_TX = { + txHash: (`0x${'ef'.repeat(32)}`) as `0x${string}`, + walletId: 'wallet-1', +}; + +/** Shared `inclusion` payload (`0xaa…` block hash). Untyped so it mirrors the specs' local `inc` (no txHash). */ +export const KA_VM_INCLUSION = { + blockNumber: 10, + blockHash: (`0x${'aa'.repeat(32)}`) as `0x${string}`, + blockTimestamp: 1, +}; + +/** Executor-signed on-chain tx hash (`0xcd…`) driven through the pre-send write-ahead. */ +export const KA_VM_EXECUTOR_TX_HASH = (`0x${'cd'.repeat(32)}`) as `0x${string}`; diff --git a/packages/publisher/test/async-lift-broadcast-durability.test.ts b/packages/publisher/test/async-lift-broadcast-durability.test.ts index 0f04169e65..b78d741cef 100644 --- a/packages/publisher/test/async-lift-broadcast-durability.test.ts +++ b/packages/publisher/test/async-lift-broadcast-durability.test.ts @@ -3,12 +3,11 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { NO_FUNDED_PUBLISHER_WALLET_CODE } from '@origintrail-official/dkg-core'; -import { GraphManager, OxigraphStore } from '@origintrail-official/dkg-storage'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { TripleStoreAsyncLiftPublisher, type AsyncLiftPublisherConfig, } from '../src/index.js'; -import { storeKnowledgeAssetOperationPublicQuads } from '../src/workspace-resolution.js'; import { DEFAULT_JOURNAL_GRAPH_URI, JOURNAL_SEQ, @@ -16,6 +15,11 @@ import { parseIntegerLiteral, parseLiteral, } from '../src/async-lift-control-plane.js'; +import { + KA_VM_EXECUTOR_TX_HASH, + kaVmPublishRequest, + stageKnowledgeAssetShareSnapshot, +} from './_helpers/ka-vm-publish.js'; // Read the journal kinds (seq-ordered) straight from the node-local journal graph. async function journalKinds(store: OxigraphStore): Promise { @@ -70,56 +74,10 @@ describe('async lift publisher broadcast durability', () => { }); } - function kaVmPublishRequest() { - const authorAddress = '0x1111111111111111111111111111111111111111'; - const kaNumber = 7n; - const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; - return { - contextGraphId: 'music-social', - name: 'albums', - shareOperationId: 'share-op-1', - roots: [] as string[], - contentScopeVersion: 2 as const, - kaUal, - assertionVersion: '1', - publicTripleCount: 2, - privateTripleCount: 0, - seal: { - merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - authorAddress: authorAddress as `0x${string}`, - signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, - schemeVersion: 1, - reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, - }, - sealChainId: '31337' as `${bigint}`, - sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, - sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', - sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - intentKey: `sha256:${'ab'.repeat(32)}`, - wmCurrentAssertion: '12'.repeat(32), - swmCurrentAssertion: '12'.repeat(32), - kaNumber: kaNumber.toString(), - reservedUal: kaUal, - }; - } - - const TX_HASH = `0x${'cd'.repeat(32)}` as `0x${string}`; + const TX_HASH = KA_VM_EXECUTOR_TX_HASH; async function stageShareSnapshot(targetStore: OxigraphStore): Promise { - const request = kaVmPublishRequest(); - await storeKnowledgeAssetOperationPublicQuads({ - store: targetStore, - graphManager: new GraphManager(targetStore), - contextGraphId: 'music-social', - shareOperationId: 'share-op-1', - kaUal: request.kaUal, - assertionVersion: request.assertionVersion, - publisherPeerId: 'peer-1', - quads: [ - { subject: 'urn:album:one', predicate: 'http://schema.org/name', object: '"One"', graph: '' }, - { subject: 'urn:album:two', predicate: 'http://schema.org/name', object: '"Two"', graph: '' }, - ], - }); + await stageKnowledgeAssetShareSnapshot({ store: targetStore }); } // A VM-publish executor that fires the pre-send write-ahead (onPhase → records diff --git a/packages/publisher/test/async-lift-intent-lookup.test.ts b/packages/publisher/test/async-lift-intent-lookup.test.ts index 3e67b0df7b..340435aadd 100644 --- a/packages/publisher/test/async-lift-intent-lookup.test.ts +++ b/packages/publisher/test/async-lift-intent-lookup.test.ts @@ -12,6 +12,7 @@ import { knowledgeAssetVmPublishLifecycleKey, serializeJob, } from '../src/async-lift-control-plane.js'; +import { KA_VM_BROADCAST_TX, KA_VM_VALIDATION, kaVmPublishRequest } from './_helpers/ka-vm-publish.js'; // #1828 — durable-admission recovery: exact intent lookup keyed on the lifecycle // facts a client retains, with a materialized index and deterministic @@ -37,40 +38,6 @@ describe('#1828 async lift intent lookup', () => { }); } - function kaVmPublishRequest(overrides: Record = {}) { - const authorAddress = '0x1111111111111111111111111111111111111111'; - const kaNumber = 7n; - const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; - return { - contextGraphId: 'music-social', - name: 'albums', - shareOperationId: 'share-op-1', - roots: [] as string[], - contentScopeVersion: 2 as const, - kaUal, - assertionVersion: '1', - publicTripleCount: 2, - privateTripleCount: 0, - seal: { - merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - authorAddress: authorAddress as `0x${string}`, - signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, - schemeVersion: 1, - reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, - }, - sealChainId: '31337' as `${bigint}`, - sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, - sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', - sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - intentKey: `sha256:${'ab'.repeat(32)}`, - wmCurrentAssertion: '12'.repeat(32), - swmCurrentAssertion: '12'.repeat(32), - kaNumber: kaNumber.toString(), - reservedUal: kaUal, - ...overrides, - }; - } - // The facts a recovering client retains (never the jobId or intentKey). const facts = { contextGraphId: 'music-social', name: 'albums' }; @@ -78,18 +45,8 @@ describe('#1828 async lift intent lookup', () => { const publisher = createPublisher({ recoveryLookupTimeoutMs: 10 }); const jobId = await publisher.enqueueKnowledgeAssetVmPublish(request); await publisher.claimNext('wallet-1'); - await publisher.update(jobId, 'validated', { - validation: { - canonicalRoots: [], - canonicalRootMap: {}, - swmQuadCount: 2, - authorityProofRef: 'knowledge-asset-lifecycle', - transitionType: 'CREATE', - }, - }); - await publisher.update(jobId, 'broadcast', { - broadcast: { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }, - }); + await publisher.update(jobId, 'validated', { validation: KA_VM_VALIDATION }); + await publisher.update(jobId, 'broadcast', { broadcast: KA_VM_BROADCAST_TX }); now += 20; await publisher.recover(); const job = await publisher.getStatus(jobId); diff --git a/packages/publisher/test/async-lift-journal-append.test.ts b/packages/publisher/test/async-lift-journal-append.test.ts index 908fd68f84..5a8be340c4 100644 --- a/packages/publisher/test/async-lift-journal-append.test.ts +++ b/packages/publisher/test/async-lift-journal-append.test.ts @@ -13,6 +13,12 @@ import { parseLiteral, serializeJob, } from '../src/async-lift-control-plane.js'; +import { + KA_VM_BROADCAST_TX, + KA_VM_INCLUSION, + KA_VM_VALIDATION, + kaVmPublishRequest, +} from './_helpers/ka-vm-publish.js'; // #1829 chunk 2-3 — appendJournal hooked into writeJob: per-lineageKey monotonic seq, // explicit kinds, daemon-only gating, and #1849 defensiveness (a legacy U+001F job @@ -37,52 +43,10 @@ describe('#1829 admission journal append (writeJob hook)', () => { }); } - function kaVmPublishRequest(overrides: Record = {}) { - const authorAddress = '0x1111111111111111111111111111111111111111'; - const kaNumber = 7n; - const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; - return { - contextGraphId: 'music-social', - name: 'albums', - shareOperationId: 'share-op-1', - roots: [] as string[], - contentScopeVersion: 2 as const, - kaUal, - assertionVersion: '1', - publicTripleCount: 2, - privateTripleCount: 0, - seal: { - merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - authorAddress: authorAddress as `0x${string}`, - signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, - schemeVersion: 1, - reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, - }, - sealChainId: '31337' as `${bigint}`, - sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, - sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', - sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - intentKey: `sha256:${'ab'.repeat(32)}`, - wmCurrentAssertion: '12'.repeat(32), - swmCurrentAssertion: '12'.repeat(32), - kaNumber: kaNumber.toString(), - reservedUal: kaUal, - ...overrides, - }; - } - - async function driveToValidated(publisher: TripleStoreAsyncLiftPublisher, overrides: Record = {}): Promise { + async function driveToValidated(publisher: TripleStoreAsyncLiftPublisher, overrides: Partial[0]> = {}): Promise { const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest(overrides)); await publisher.claimNext('wallet-1'); - await publisher.update(jobId, 'validated', { - validation: { - canonicalRoots: [], - canonicalRootMap: {}, - swmQuadCount: 2, - authorityProofRef: 'knowledge-asset-lifecycle', - transitionType: 'CREATE', - }, - }); + await publisher.update(jobId, 'validated', { validation: KA_VM_VALIDATION }); return jobId; } @@ -180,15 +144,15 @@ describe('#1829 admission journal append (writeJob hook)', () => { const jobId = await driveToValidated(publisher); // Force a local no-op finalize so the job reaches 'finalized' without a chain tx. await publisher.update(jobId, 'broadcast', { - broadcast: { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }, + broadcast: KA_VM_BROADCAST_TX, }); await publisher.update(jobId, 'included', { - broadcast: { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }, - inclusion: { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }, + broadcast: KA_VM_BROADCAST_TX, + inclusion: KA_VM_INCLUSION, }); await publisher.update(jobId, 'finalized', { - broadcast: { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }, - inclusion: { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }, + broadcast: KA_VM_BROADCAST_TX, + inclusion: KA_VM_INCLUSION, finalization: { mode: 'local' }, }); const before = await journalRows(); @@ -205,7 +169,7 @@ describe('#1829 admission journal append (writeJob hook)', () => { const publisher = createPublisher(); const jobId = await driveToValidated(publisher); await publisher.update(jobId, 'broadcast', { - broadcast: { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }, + broadcast: KA_VM_BROADCAST_TX, }); const res = await publisher.readJournalByIntent(facts); expect(res.entries.map((e) => e.seq)).toEqual([0, 1, 2, 3]); @@ -250,8 +214,8 @@ describe('#1829 admission journal append (writeJob hook)', () => { // First job driven to a TERMINAL state so it no longer occupies the subject and a // fresh enqueue admits a genuine successor (rather than dedup returning the same job). const first = await driveToValidated(publisher); - const bx = { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }; - const inc = { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }; + const bx = KA_VM_BROADCAST_TX; + const inc = KA_VM_INCLUSION; await publisher.update(first, 'broadcast', { broadcast: bx }); await publisher.update(first, 'included', { broadcast: bx, inclusion: inc }); await publisher.update(first, 'finalized', { broadcast: bx, inclusion: inc, finalization: { mode: 'local' } }); diff --git a/packages/publisher/test/async-lift-ka-broadcast-progress.test.ts b/packages/publisher/test/async-lift-ka-broadcast-progress.test.ts index 1e9383814d..cbc2ab2035 100644 --- a/packages/publisher/test/async-lift-ka-broadcast-progress.test.ts +++ b/packages/publisher/test/async-lift-ka-broadcast-progress.test.ts @@ -10,7 +10,12 @@ import { jobSubject, walletLockSubject, } from '../src/async-lift-control-plane.js'; -import { storeKnowledgeAssetOperationPublicQuads } from '../src/workspace-resolution.js'; +import { + KA_VM_EXECUTOR_TX_HASH, + KA_VM_VALIDATION, + kaVmPublishRequest, + stageKnowledgeAssetShareSnapshot, +} from './_helpers/ka-vm-publish.js'; describe('KA async VM publish broadcast progress', () => { let now = 1_000; @@ -35,62 +40,12 @@ describe('KA async VM publish broadcast progress', () => { }); } - function kaVmPublishRequest(overrides: Partial[0]> = {}) { - const authorAddress = '0x1111111111111111111111111111111111111111'; - const kaNumber = 7n; - const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; - return { - contextGraphId: 'music-social', - name: 'albums', - shareOperationId: 'share-op-1', - roots: [], - contentScopeVersion: 2 as const, - kaUal, - assertionVersion: '1', - publicTripleCount: 2, - privateTripleCount: 0, - seal: { - merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - authorAddress: authorAddress as `0x${string}`, - signature: { - r: (`0x${'34'.repeat(32)}`) as `0x${string}`, - vs: (`0x${'56'.repeat(32)}`) as `0x${string}`, - }, - schemeVersion: 1, - reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, - }, - sealChainId: '31337' as `${bigint}`, - sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, - sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', - sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - intentKey: `sha256:${'ab'.repeat(32)}`, - wmCurrentAssertion: '12'.repeat(32), - swmCurrentAssertion: '12'.repeat(32), - kaNumber: kaNumber.toString(), - reservedUal: kaUal, - ...overrides, - }; - } - async function stageShareSnapshot(): Promise { - const request = kaVmPublishRequest(); - await storeKnowledgeAssetOperationPublicQuads({ - store, - graphManager, - contextGraphId: 'music-social', - shareOperationId: 'share-op-1', - kaUal: request.kaUal, - assertionVersion: request.assertionVersion, - publisherPeerId: 'peer-1', - quads: [ - { subject: 'urn:album:one', predicate: 'http://schema.org/name', object: '"One"', graph: '' }, - { subject: 'urn:album:two', predicate: 'http://schema.org/name', object: '"Two"', graph: '' }, - ], - }); + await stageKnowledgeAssetShareSnapshot({ store, graphManager }); } it('persists KA broadcast tx hash when executor write-ahead fires before completion', async () => { - const txHash = `0x${'cd'.repeat(32)}` as `0x${string}`; + const txHash = KA_VM_EXECUTOR_TX_HASH; let jobId = ''; let statusDuringExecutor: Awaited> = null; const publisher = createPublisher({ @@ -127,13 +82,7 @@ describe('KA async VM publish broadcast progress', () => { const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); await publisher.claimNext('wallet-1'); await publisher.update(jobId, 'validated', { - validation: { - canonicalRoots: [], - canonicalRootMap: {}, - swmQuadCount: 2, - authorityProofRef: 'knowledge-asset-lifecycle', - transitionType: 'CREATE', - }, + validation: KA_VM_VALIDATION, }); await publisher.update(jobId, 'broadcast', { broadcast: { txHash, walletId: 'wallet-1' }, @@ -195,13 +144,7 @@ describe('KA async VM publish broadcast progress', () => { const jobId = await publisher.enqueueKnowledgeAssetVmPublish(request); await publisher.claimNext('wallet-1'); await publisher.update(jobId, 'validated', { - validation: { - canonicalRoots: [], - canonicalRootMap: {}, - swmQuadCount: 2, - authorityProofRef: 'knowledge-asset-lifecycle', - transitionType: 'CREATE', - }, + validation: KA_VM_VALIDATION, }); await publisher.update(jobId, 'broadcast', { broadcast: { txHash, walletId: 'wallet-1', merkleRoot: request.sealMerkleRoot }, @@ -221,13 +164,7 @@ describe('KA async VM publish broadcast progress', () => { const includedJobId = await publisher.enqueueKnowledgeAssetVmPublish(request); await publisher.claimNext('wallet-1'); await publisher.update(includedJobId, 'validated', { - validation: { - canonicalRoots: [], - canonicalRootMap: {}, - swmQuadCount: 2, - authorityProofRef: 'knowledge-asset-lifecycle', - transitionType: 'CREATE', - }, + validation: KA_VM_VALIDATION, }); await publisher.update(includedJobId, 'broadcast', { broadcast: { txHash, walletId: 'wallet-1', merkleRoot: request.sealMerkleRoot }, @@ -281,13 +218,7 @@ describe('KA async VM publish broadcast progress', () => { const jobId = await publisher.enqueueKnowledgeAssetVmPublish(request); await publisher.claimNext('wallet-1'); await publisher.update(jobId, 'validated', { - validation: { - canonicalRoots: [], - canonicalRootMap: {}, - swmQuadCount: 2, - authorityProofRef: 'knowledge-asset-lifecycle', - transitionType: 'CREATE', - }, + validation: KA_VM_VALIDATION, }); await publisher.update(jobId, 'broadcast', { broadcast: { txHash, walletId: 'wallet-1', merkleRoot: request.sealMerkleRoot }, diff --git a/packages/publisher/test/async-lift-publisher.test.ts b/packages/publisher/test/async-lift-publisher.test.ts index 3841573466..e7d3737504 100644 --- a/packages/publisher/test/async-lift-publisher.test.ts +++ b/packages/publisher/test/async-lift-publisher.test.ts @@ -17,7 +17,6 @@ import { QuorumUnmetError, TripleStoreAsyncLiftPublisher, createLiftJobFailureMetadata, - storeKnowledgeAssetOperationPublicQuads, type AsyncLiftPublisherConfig, type AsyncLiftPublisherRecoveryResult, type LiftRequest, @@ -51,6 +50,12 @@ import { walletLockSubject, } from '../src/async-lift-control-plane.js'; import { withLegacyRawLiftTestSeeder } from './_helpers/legacy-raw-lift.js'; +import { + KA_VM_KA_UAL, + KA_VM_VALIDATION, + kaVmPublishRequest, + stageKnowledgeAssetShareSnapshot, +} from './_helpers/ka-vm-publish.js'; describe('TripleStoreAsyncLiftPublisher', () => { let now = 1_000; @@ -61,7 +66,7 @@ describe('TripleStoreAsyncLiftPublisher', () => { let _kav10Address: string; let _provider: ethers.JsonRpcProvider; const _author = new ethers.Wallet(HARDHAT_KEYS.CORE_OP); - const ROOTLESS_UAL = 'did:dkg:31337/0x1111111111111111111111111111111111111111/7'; + const ROOTLESS_UAL = KA_VM_KA_UAL; function makeTestPublisher(opts: ConstructorParameters[0]): DKGPublisher { // OT-RFC-43 Option-1: wire a KA-number allocator so the real EVM adapter @@ -104,55 +109,11 @@ describe('TripleStoreAsyncLiftPublisher', () => { authority: { type: 'owner', proofRef: 'proof:owner:1' }, }); - const kaVmPublishRequest = (overrides: Partial[0]> = {}) => { - const authorAddress = '0x1111111111111111111111111111111111111111'; - const kaNumber = 7n; - const base = { - contextGraphId: 'music-social', - name: 'albums', - shareOperationId: 'share-op-1', - roots: [], - contentScopeVersion: GRAPH_KA_CONTENT_SCOPE_VERSION, - kaUal: ROOTLESS_UAL, - assertionVersion: '1', - publicTripleCount: 2, - privateTripleCount: 0, - seal: { - merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - authorAddress: authorAddress as `0x${string}`, - signature: { - r: (`0x${'34'.repeat(32)}`) as `0x${string}`, - vs: (`0x${'56'.repeat(32)}`) as `0x${string}`, - }, - schemeVersion: 1, - reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, - }, - sealChainId: '31337' as `${bigint}`, - sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, - sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', - sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - intentKey: 'sha256:' + 'ab'.repeat(32), - wmCurrentAssertion: '12'.repeat(32), - swmCurrentAssertion: '12'.repeat(32), - kaNumber: kaNumber.toString(), - reservedUal: ROOTLESS_UAL, - }; - return { ...base, ...overrides }; - }; - async function stageRootlessSnapshot(shareOperationId: string): Promise { - await storeKnowledgeAssetOperationPublicQuads({ + await stageKnowledgeAssetShareSnapshot({ store, - graphManager: new GraphManager(store), - contextGraphId: 'music-social', shareOperationId, - kaUal: ROOTLESS_UAL, assertionVersion: 1, - quads: [ - { subject: 'urn:album:one', predicate: 'http://schema.org/name', object: '"One"', graph: '' }, - { subject: 'urn:album:two', predicate: 'http://schema.org/name', object: '"Two"', graph: '' }, - ], - publisherPeerId: 'peer-1', accessPolicy: 'public', }); } @@ -1861,13 +1822,7 @@ describe('TripleStoreAsyncLiftPublisher', () => { const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); await publisher.claimNext('wallet-1'); await publisher.update(jobId, 'validated', { - validation: { - canonicalRoots: [], - canonicalRootMap: {}, - swmQuadCount: 2, - authorityProofRef: 'knowledge-asset-lifecycle', - transitionType: 'CREATE', - }, + validation: KA_VM_VALIDATION, }); await publisher.update(jobId, 'broadcast', { broadcast: { txHash: '0xkavm', walletId: 'wallet-1' }, @@ -1900,13 +1855,7 @@ describe('TripleStoreAsyncLiftPublisher', () => { const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); await publisher.claimNext('wallet-1'); await publisher.update(jobId, 'validated', { - validation: { - canonicalRoots: [], - canonicalRootMap: {}, - swmQuadCount: 2, - authorityProofRef: 'knowledge-asset-lifecycle', - transitionType: 'CREATE', - }, + validation: KA_VM_VALIDATION, }); await publisher.update(jobId, 'broadcast', { broadcast: { txHash: '0xkavm', walletId: 'wallet-1' }, diff --git a/packages/publisher/test/async-lift-terminal-clear.test.ts b/packages/publisher/test/async-lift-terminal-clear.test.ts index 2c347f1c9d..a700aa3215 100644 --- a/packages/publisher/test/async-lift-terminal-clear.test.ts +++ b/packages/publisher/test/async-lift-terminal-clear.test.ts @@ -2,6 +2,12 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { TripleStoreAsyncLiftPublisher, type AsyncLiftPublisherConfig } from '../src/index.js'; import { DEFAULT_CONTROL_GRAPH_URI, jobSubject, serializeJob } from '../src/async-lift-control-plane.js'; +import { + KA_VM_BROADCAST_TX, + KA_VM_INCLUSION, + KA_VM_VALIDATION, + kaVmPublishRequest, +} from './_helpers/ka-vm-publish.js'; // #1837 — atomic by-exact-jobId TERMINAL clear for the async publisher (lift) queue. describe('#1837 lift publisher clearTerminalJob', () => { @@ -24,39 +30,16 @@ describe('#1837 lift publisher clearTerminalJob', () => { }); } - function kaVmPublishRequest(overrides: Record = {}) { - const authorAddress = '0x1111111111111111111111111111111111111111'; - const kaNumber = 7n; - const kaUal = `did:dkg:31337/${authorAddress}/${kaNumber.toString()}`; - return { - contextGraphId: 'music-social', name: 'albums', shareOperationId: 'share-op-1', - roots: [] as string[], contentScopeVersion: 2 as const, kaUal, assertionVersion: '1', - publicTripleCount: 2, privateTripleCount: 0, - seal: { - merkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - authorAddress: authorAddress as `0x${string}`, - signature: { r: (`0x${'34'.repeat(32)}`) as `0x${string}`, vs: (`0x${'56'.repeat(32)}`) as `0x${string}` }, - schemeVersion: 1, - reservedKaId: ((BigInt(authorAddress) << 96n) | kaNumber).toString() as `${bigint}`, - }, - sealChainId: '31337' as `${bigint}`, sealKav10Address: '0x2222222222222222222222222222222222222222' as `0x${string}`, - sealFinalizedAtIso: '2026-01-01T00:00:00.000Z', sealMerkleRoot: (`0x${'12'.repeat(32)}`) as `0x${string}`, - intentKey: `sha256:${'ab'.repeat(32)}`, wmCurrentAssertion: '12'.repeat(32), swmCurrentAssertion: '12'.repeat(32), - kaNumber: kaNumber.toString(), reservedUal: kaUal, ...overrides, - }; - } - const bx = { txHash: `0x${'ef'.repeat(32)}` as `0x${string}`, walletId: 'wallet-1' }; - const inc = { blockNumber: 10, blockHash: `0x${'aa'.repeat(32)}` as `0x${string}`, blockTimestamp: 1 }; + const bx = KA_VM_BROADCAST_TX; + const inc = KA_VM_INCLUSION; - async function driveToValidated(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + async function driveToValidated(p: TripleStoreAsyncLiftPublisher, o: Partial[0]> = {}): Promise { const jobId = await p.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest(o)); await p.claimNext('wallet-1'); - await p.update(jobId, 'validated', { - validation: { canonicalRoots: [], canonicalRootMap: {}, swmQuadCount: 2, authorityProofRef: 'knowledge-asset-lifecycle', transitionType: 'CREATE' }, - }); + await p.update(jobId, 'validated', { validation: KA_VM_VALIDATION }); return jobId; } - async function driveToFinalized(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + async function driveToFinalized(p: TripleStoreAsyncLiftPublisher, o: Partial[0]> = {}): Promise { const jobId = await driveToValidated(p, o); await p.update(jobId, 'broadcast', { broadcast: bx }); await p.update(jobId, 'included', { broadcast: bx, inclusion: inc }); @@ -64,7 +47,7 @@ describe('#1837 lift publisher clearTerminalJob', () => { return jobId; } // Terminal, non-retryable (tx_reverted → fail_job): clearable, retry() won't touch it. - async function driveToTerminalFailed(p: TripleStoreAsyncLiftPublisher, o: Record = {}): Promise { + async function driveToTerminalFailed(p: TripleStoreAsyncLiftPublisher, o: Partial[0]> = {}): Promise { const jobId = await driveToValidated(p, o); await p.update(jobId, 'broadcast', { broadcast: bx }); await p.recordPublishFailure(jobId, { error: new Error('tx reverted on chain'), failedFromState: 'broadcast', errorPayloadRef: 'urn:err:1' }); From f30c869c5546b40d033ee40b05eeafcdfc1cbc6d Mon Sep 17 00:00:00 2001 From: Jurij89 <138491694+Jurij89@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:28:06 -0400 Subject: [PATCH 209/292] refactor(cli): shared request-body boundary for publisher admin routes (#1890) (#1902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(cli): shared request-body boundary for publisher admin routes (#1890) POST /api/publisher/{cancel,retry,clear,clear-job} each carried their own inline readBody + JSON.parse + shape-probe island. Extract one module-local `readSmallJsonObject(req, res)` and use it across all four: it owns the read, the shared invalid-JSON mapping (400 {error:'Invalid JSON body'}), and normalizes a missing/null/primitive/array body to {} so no route destructures a non-object. Each route keeps its own field validation + response shape. - clear-job wire contract is byte-identical (still 400 malformed on missing jobId, 200 cleared/already_absent otherwise); its own object-guard is now redundant. - Hardening (untested edges only): a `null`/non-object body on cancel/retry/clear previously destructured to a TypeError → 500; it now yields the route's bounded outcome (cancel/clear → 400, retry → 200, consistent with how they already treat `{}`). cancel's empty body also moves from 400 {Invalid JSON body} to 400 {Missing jobId} (same status). No pinned/ tested behavior changes. Adds publisher-admin-body-boundary.test.ts pinning all four routes' body handling (previously only clear-job was pinned). Co-Authored-By: Claude Opus 4.8 (1M context) * test(cli): exercise the empty-body path + reuse isPlainRecord (#1890) Address review on the shared publisher-admin request-body boundary: - readSmallJsonObject: replace the inline non-object check with the already-imported isPlainRecord() (semantically identical for every JSON.parse output; drops the cast and a now-unused import). - publisher-admin-body-boundary.test.ts: add true empty-body ('') cases for cancel (-> 400 Missing jobId) and retry (-> 200 retried) that drive the `body || "{}"` fallback the prior tests never hit, and rename the mislabeled retry `{}` case to "empty object body". - vitest.unit.config.ts: wire the new pure-handler test into the fast unit lane next to its clear-job sibling so `pnpm test:unit` runs it. clear-job's wire contract stays byte-identical (5/5 green); the body-boundary suite is 16/16 green; cli tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/cli/src/daemon/routes/publisher.ts | 71 +++++----- .../publisher-admin-body-boundary.test.ts | 125 ++++++++++++++++++ packages/cli/vitest.unit.config.ts | 4 + 3 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 packages/cli/test/publisher-admin-body-boundary.test.ts diff --git a/packages/cli/src/daemon/routes/publisher.ts b/packages/cli/src/daemon/routes/publisher.ts index 7d868146c4..28101fe6cc 100644 --- a/packages/cli/src/daemon/routes/publisher.ts +++ b/packages/cli/src/daemon/routes/publisher.ts @@ -374,6 +374,28 @@ function parsePublisherLifecycleFactsFromQuery( return { contextGraphId, name, subGraphName, agentAddress, intentKey }; } +// #1890 — one request-body boundary for the publisher admin POST routes (cancel / retry / +// clear / clear-job). Reads the small JSON body, applies the shared invalid-JSON mapping +// (400 `{ error: 'Invalid JSON body' }`), and normalizes a missing / `null` / primitive / +// array body to `{}` so no route destructures a non-object — a `null` body must not +// TypeError into a 500. Each route keeps its OWN field validation and response shape. +// Returns `null` AFTER responding, so callers do: +// const parsed = await readSmallJsonObject(req, res); if (!parsed) return; +async function readSmallJsonObject( + req: IncomingMessage, + res: ServerResponse, +): Promise | null> { + const body = await readBody(req, SMALL_BODY_BYTES); + let parsed: unknown; + try { + parsed = JSON.parse(body || "{}"); + } catch { + jsonResponse(res, 400, { error: "Invalid JSON body" }); + return null; + } + return isPlainRecord(parsed) ? parsed : {}; +} + export async function handlePublisherRoutes(ctx: RequestContext): Promise { const { req, @@ -497,14 +519,9 @@ export async function handlePublisherRoutes(ctx: RequestContext): Promise // POST /api/publisher/cancel if (req.method === "POST" && path === "/api/publisher/cancel") { - const body = await readBody(req, SMALL_BODY_BYTES); - let parsed: any; - try { - parsed = JSON.parse(body); - } catch { - return jsonResponse(res, 400, { error: "Invalid JSON body" }); - } - const { jobId } = parsed; + const parsed = await readSmallJsonObject(req, res); + if (!parsed) return; + const jobId = parsed.jobId as string | undefined; if (!jobId) return jsonResponse(res, 400, { error: "Missing jobId" }); await publisherControl.cancel(jobId); return jsonResponse(res, 200, { cancelled: jobId }); @@ -512,14 +529,9 @@ export async function handlePublisherRoutes(ctx: RequestContext): Promise // POST /api/publisher/retry if (req.method === "POST" && path === "/api/publisher/retry") { - const body = await readBody(req, SMALL_BODY_BYTES); - let retryParsed: any; - try { - retryParsed = JSON.parse(body || "{}"); - } catch { - return jsonResponse(res, 400, { error: "Invalid JSON body" }); - } - const { status } = retryParsed; + const parsed = await readSmallJsonObject(req, res); + if (!parsed) return; + const status = parsed.status as string | undefined; if (status && status !== "failed") return jsonResponse(res, 400, { error: "Only status=failed is supported", @@ -530,14 +542,9 @@ export async function handlePublisherRoutes(ctx: RequestContext): Promise // POST /api/publisher/clear if (req.method === "POST" && path === "/api/publisher/clear") { - const body = await readBody(req, SMALL_BODY_BYTES); - let clearParsed: any; - try { - clearParsed = JSON.parse(body || "{}"); - } catch { - return jsonResponse(res, 400, { error: "Invalid JSON body" }); - } - const { status } = clearParsed; + const parsed = await readSmallJsonObject(req, res); + if (!parsed) return; + const status = parsed.status as string | undefined; if (status !== "failed" && status !== "finalized") { return jsonResponse(res, 400, { error: "status must be failed or finalized", @@ -553,17 +560,11 @@ export async function handlePublisherRoutes(ctx: RequestContext): Promise // in a native terminal state, is idempotent for an absent job (already_absent = 200, // NOT 404), and never touches another job. Preserves the #1829 journal (subject-scoped). if (req.method === "POST" && path === "/api/publisher/clear-job") { - const body = await readBody(req, SMALL_BODY_BYTES); - let clearJobParsed: any; - try { - clearJobParsed = JSON.parse(body || "{}"); - } catch { - return jsonResponse(res, 400, { error: "Invalid JSON body" }); - } - // `JSON.parse("null")` etc. succeeds but yields a non-object; optional-chain so a - // `null`/primitive body falls through to the malformed guard (400), never a - // destructure TypeError → 500. - const jobId = clearJobParsed && typeof clearJobParsed === "object" ? clearJobParsed.jobId : undefined; + // readSmallJsonObject normalizes a `null`/primitive body to `{}`, so `jobId` is + // undefined there and falls through to the malformed guard (400) — never a 500. + const parsed = await readSmallJsonObject(req, res); + if (!parsed) return; + const jobId = parsed.jobId; if (typeof jobId !== "string" || jobId.trim().length === 0) { return jsonResponse(res, 400, { outcome: "rejected", reason: "malformed", error: "Missing jobId" }); } diff --git a/packages/cli/test/publisher-admin-body-boundary.test.ts b/packages/cli/test/publisher-admin-body-boundary.test.ts new file mode 100644 index 0000000000..204aac7eed --- /dev/null +++ b/packages/cli/test/publisher-admin-body-boundary.test.ts @@ -0,0 +1,125 @@ +import type { ServerResponse } from 'node:http'; +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { handlePublisherRoutes } from '../src/daemon/routes/publisher.js'; +import type { RequestContext } from '../src/daemon/routes/context.js'; + +// #1890 — the four publisher admin POST routes now share one request-body boundary +// (readSmallJsonObject). This pins each route's body handling — importantly the previously +// unpinned cancel/retry/clear edges: a `null`/non-object body is normalized to `{}` (so it +// yields the route's bounded 4xx/2xx instead of a destructure TypeError → 500), while every +// route keeps its own field validation + response shape. clear-job's wire contract is +// asserted byte-identical (also guarded end-to-end by publisher-clear-job-route.test.ts). +describe('#1890 publisher admin POST body boundary', () => { + function control(): RequestContext['publisherControl'] { + return { + cancel: async () => {}, + retry: async () => 3, + clear: async () => 2, + clearTerminalJob: async () => ({ outcome: 'already_absent' as const }), + } as unknown as RequestContext['publisherControl']; + } + + async function post(path: string, rawBody: string) { + const url = new URL(`http://127.0.0.1${path}`); + const req = Readable.from([]); + Object.assign(req, { + method: 'POST', url: path, headers: { host: '127.0.0.1' }, + __dkgPrebufferedBody: Buffer.from(rawBody, 'utf8'), + }); + const res = { + statusCode: 0, body: '', headers: undefined as Record | undefined, writableEnded: false, + writeHead(status: number, headers: Record) { this.statusCode = status; this.headers = headers; return this; }, + end(body?: string) { this.body = body ?? ''; this.writableEnded = true; return this; }, + }; + const ctx = { + req: req as RequestContext['req'], + res: res as unknown as ServerResponse, + agent: {} as RequestContext['agent'], + publisherControl: control(), + publisherState: { runtime: null, availability: { available: false, reason: 'publisher_disabled', retryable: false, operatorActionRequired: true } }, + config: {} as RequestContext['config'], + startedAt: 0, + dashDb: {} as RequestContext['dashDb'], + opWallets: { adminWallet: { address: '0x0', privateKey: '0x0' }, wallets: [] } as RequestContext['opWallets'], + network: null as RequestContext['network'], + tracker: {} as RequestContext['tracker'], + memoryManager: {} as RequestContext['memoryManager'], + bridgeAuthToken: undefined, + nodeVersion: 'test', nodeCommit: 'test', + catchupTracker: {} as RequestContext['catchupTracker'], + extractionRegistry: {} as RequestContext['extractionRegistry'], + fileStore: {} as RequestContext['fileStore'], + extractionStatus: new Map(), + assertionImportLocks: new Map(), + vectorStore: {} as RequestContext['vectorStore'], + embeddingProvider: null, + validTokens: new Set(), + apiHost: '127.0.0.1', apiPortRef: { value: 0 }, + url, path: url.pathname, + requestToken: undefined, requestAgentAddress: '0x0', + } as unknown as RequestContext; + await handlePublisherRoutes(ctx); + return { status: res.statusCode, body: res.body ? JSON.parse(res.body) as Record : {} }; + } + + describe('cancel', () => { + it('valid jobId → 200 cancelled', async () => { + expect(await post('/api/publisher/cancel', JSON.stringify({ jobId: 'j1' }))).toEqual({ status: 200, body: { cancelled: 'j1' } }); + }); + it('missing jobId → 400', async () => { + expect(await post('/api/publisher/cancel', '{}')).toEqual({ status: 400, body: { error: 'Missing jobId' } }); + }); + it('invalid JSON → 400 Invalid JSON body', async () => { + expect(await post('/api/publisher/cancel', '{bad')).toEqual({ status: 400, body: { error: 'Invalid JSON body' } }); + }); + it('null body → 400 (hardened, not a 500)', async () => { + expect(await post('/api/publisher/cancel', 'null')).toEqual({ status: 400, body: { error: 'Missing jobId' } }); + }); + it('empty body → 400 Missing jobId (hardened, not a 500)', async () => { + expect(await post('/api/publisher/cancel', '')).toEqual({ status: 400, body: { error: 'Missing jobId' } }); + }); + }); + + describe('retry', () => { + it('empty object body {} → 200 retried', async () => { + expect(await post('/api/publisher/retry', '{}')).toEqual({ status: 200, body: { retried: 3 } }); + }); + it('empty body → 200 retried (hardened, not a 500)', async () => { + expect(await post('/api/publisher/retry', '')).toEqual({ status: 200, body: { retried: 3 } }); + }); + it('unsupported status → 400', async () => { + expect(await post('/api/publisher/retry', JSON.stringify({ status: 'queued' }))).toEqual({ status: 400, body: { error: 'Only status=failed is supported' } }); + }); + it('null body → 200 (hardened, not a 500)', async () => { + expect(await post('/api/publisher/retry', 'null')).toEqual({ status: 200, body: { retried: 3 } }); + }); + }); + + describe('clear', () => { + it('valid status → 200 cleared', async () => { + expect(await post('/api/publisher/clear', JSON.stringify({ status: 'failed' }))).toEqual({ status: 200, body: { cleared: 2, status: 'failed' } }); + }); + it('missing/invalid status → 400', async () => { + expect(await post('/api/publisher/clear', '{}')).toEqual({ status: 400, body: { error: 'status must be failed or finalized' } }); + }); + it('null body → 400 (hardened, not a 500)', async () => { + expect(await post('/api/publisher/clear', 'null')).toEqual({ status: 400, body: { error: 'status must be failed or finalized' } }); + }); + }); + + describe('clear-job (byte-identical contract preserved)', () => { + it('valid jobId → 200 already_absent', async () => { + expect(await post('/api/publisher/clear-job', JSON.stringify({ jobId: 'j1' }))).toEqual({ status: 200, body: { outcome: 'already_absent', jobId: 'j1' } }); + }); + it('missing jobId → 400 malformed', async () => { + expect(await post('/api/publisher/clear-job', '{}')).toEqual({ status: 400, body: { outcome: 'rejected', reason: 'malformed', error: 'Missing jobId' } }); + }); + it('null body → 400 malformed (no 500)', async () => { + expect(await post('/api/publisher/clear-job', 'null')).toEqual({ status: 400, body: { outcome: 'rejected', reason: 'malformed', error: 'Missing jobId' } }); + }); + it('invalid JSON → 400 Invalid JSON body', async () => { + expect(await post('/api/publisher/clear-job', '{bad')).toEqual({ status: 400, body: { error: 'Invalid JSON body' } }); + }); + }); +}); diff --git a/packages/cli/vitest.unit.config.ts b/packages/cli/vitest.unit.config.ts index 5ca8fced20..1290348b20 100644 --- a/packages/cli/vitest.unit.config.ts +++ b/packages/cli/vitest.unit.config.ts @@ -19,6 +19,10 @@ export default defineConfig({ 'test/publisher-job-by-intent-route.test.ts', 'test/publisher-journal-route.test.ts', 'test/publisher-clear-job-route.test.ts', + // #1890 — shared request-body boundary for the four publisher admin + // POST routes (pure handler, no hardhat). Belongs in the fast lane + // alongside its clear-job sibling above. + 'test/publisher-admin-body-boundary.test.ts', // #1828 — daemon-boot intent-index backfill wiring (fail-open contract). 'test/vm-publish-intent-backfill.test.ts', 'test/agent-connect-routes.test.ts', From c00fef39a86cd486d0e09a1bb0c6051f99ad0f7c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 09:40:09 +0200 Subject: [PATCH 210/292] feat(rfc64): resolve finalized CG policy over RPC --- .../chain/src/finalized-context-graph-read.ts | 8 +- .../finalized-context-graph-rpc-resolver.ts | 144 +++++++++++++ packages/chain/src/index.ts | 5 + .../finalized-context-graph-read.unit.test.ts | 16 +- ...ed-context-graph-rpc-resolver.unit.test.ts | 191 ++++++++++++++++++ 5 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 packages/chain/src/finalized-context-graph-rpc-resolver.ts create mode 100644 packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts index dd602337f8..833769d680 100644 --- a/packages/chain/src/finalized-context-graph-read.ts +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -92,7 +92,10 @@ export type FinalizedContextGraphReadV1 = { }; export interface FinalizedContextGraphReadResolverV1 { - (binding: FinalizedContextGraphBindingV1): Promise; + ( + binding: FinalizedContextGraphBindingV1, + signal?: AbortSignal, + ): Promise; } function canonicalDecimalU256( @@ -301,8 +304,9 @@ function composeValidatedFinalizedContextGraphReadV1( export async function resolveFinalizedContextGraphReadV1( resolver: FinalizedContextGraphReadResolverV1, request: FinalizedContextGraphReadRequestV1, + signal: AbortSignal = new AbortController().signal, ): Promise { const binding = validateFinalizedContextGraphReadRequestV1(request); - const raw = await resolver(binding); + const raw = await resolver(binding, signal); return composeValidatedFinalizedContextGraphReadV1(binding, raw); } diff --git a/packages/chain/src/finalized-context-graph-rpc-resolver.ts b/packages/chain/src/finalized-context-graph-rpc-resolver.ts new file mode 100644 index 0000000000..31a5390c9f --- /dev/null +++ b/packages/chain/src/finalized-context-graph-rpc-resolver.ts @@ -0,0 +1,144 @@ +import { type EvmAddressV1 } from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +import { + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import { + type FinalizedContextGraphBindingV1, + type FinalizedContextGraphReadResolverV1, + type UntrustedFinalizedContextGraphFieldsV1, +} from './finalized-context-graph-read.js'; +import { + type StrictCurrentFinalizedEvmReadResultV1, + type StrictCurrentFinalizedEvmReadV1, +} from './strict-current-finalized-evm-rpc.js'; + +export const FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1 = + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1; +export const FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1 = 32; + +const CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE = new ethers.Interface([ + 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', + 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', +]); + +/** + * Bind the strict same-anchor transport to the two ContextGraphStorage reads + * required by the finalized RFC-64 policy seam. + */ +export function createFinalizedContextGraphRpcResolverV1( + read: StrictCurrentFinalizedEvmReadV1, +): FinalizedContextGraphReadResolverV1 { + if (typeof read !== 'function') { + throw new TypeError('Finalized Context Graph RPC resolver requires a read function'); + } + + const resolver: FinalizedContextGraphReadResolverV1 = async ( + binding, + signal = new AbortController().signal, + ) => { + const contextGraphId = BigInt(binding.contextGraphId); + const result = await read({ + chainId: binding.chainId, + calls: Object.freeze([ + Object.freeze({ + to: binding.governanceContract, + data: CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE.encodeFunctionData( + 'getContextGraph', + [contextGraphId], + ), + maxReturnBytes: FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1, + }), + Object.freeze({ + to: binding.governanceContract, + data: CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE.encodeFunctionData( + 'getNameHash', + [contextGraphId], + ), + maxReturnBytes: FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1, + }), + ]), + signal, + }); + return decodeFinalizedContextGraphResult(binding, result); + }; + + return Object.freeze(resolver); +} + +function decodeFinalizedContextGraphResult( + binding: FinalizedContextGraphBindingV1, + result: StrictCurrentFinalizedEvmReadResultV1, +): UntrustedFinalizedContextGraphFieldsV1 { + if (result.chainId !== binding.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Finalized Context Graph read returned chain ${result.chainId}, expected ${binding.chainId}`, + ); + } + if (!Array.isArray(result.returnData) || result.returnData.length !== 2) { + throw malformedReturn('Finalized Context Graph read must return exactly two ABI results'); + } + + try { + const contextGraph = CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE.decodeFunctionResult( + 'getContextGraph', + result.returnData[0]!, + ); + const nameHash = CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE.decodeFunctionResult( + 'getNameHash', + result.returnData[1]!, + ); + assertCanonicalAbiResult('getContextGraph', contextGraph, result.returnData[0]!); + assertCanonicalAbiResult('getNameHash', nameHash, result.returnData[1]!); + + return Object.freeze({ + blockNumber: result.blockNumber, + blockHash: result.blockHash, + owner: lowerAddress(contextGraph[0]), + active: contextGraph[3], + accessPolicy: Number(contextGraph[5]), + publishPolicy: Number(contextGraph[6]), + publishAuthority: lowerAddress(contextGraph[7]), + publishAuthorityAccountId: decimal(contextGraph[8]), + nameHash: String(nameHash[0]).toLowerCase(), + }); + } catch (cause) { + if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; + throw malformedReturn('Finalized Context Graph ABI result is malformed', cause); + } +} + +function assertCanonicalAbiResult( + functionName: 'getContextGraph' | 'getNameHash', + decoded: ethers.Result, + encoded: string, +): void { + const canonical = CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE + .encodeFunctionResult(functionName, [...decoded]) + .toLowerCase(); + if (canonical !== encoded) { + throw malformedReturn(`${functionName} returned a non-canonical ABI encoding`); + } +} + +function lowerAddress(value: unknown): EvmAddressV1 { + return String(value).toLowerCase() as EvmAddressV1; +} + +function decimal(value: unknown): string { + return BigInt(value as bigint).toString(10); +} + +function malformedReturn( + message: string, + cause?: unknown, +): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index a10334554d..5e11162f21 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -34,6 +34,11 @@ export { type CurrentFinalizedEvmChainAdapterRegistrationV1, type CurrentFinalizedEvmChainAdapterV1, } from './current-finalized-evm-call.js'; +export { + FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1, + FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1, + createFinalizedContextGraphRpcResolverV1, +} from './finalized-context-graph-rpc-resolver.js'; export { CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, createStrictCurrentFinalizedEvmChainAdapterV1, diff --git a/packages/chain/test/finalized-context-graph-read.unit.test.ts b/packages/chain/test/finalized-context-graph-read.unit.test.ts index 0e0221a81b..9ec5c2688b 100644 --- a/packages/chain/test/finalized-context-graph-read.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-read.unit.test.ts @@ -91,20 +91,24 @@ describe('RFC-64 finalized Context Graph chain read', () => { it('passes one frozen canonical binding through the resolver seam', async () => { const request = validRequest(); const raw = Object.freeze(validRaw()); + const signal = new AbortController().signal; const resolver = vi.fn((received: FinalizedContextGraphBindingV1) => { expect(received).not.toBe(request); expect(Object.isFrozen(received)).toBe(true); expect(received).toEqual(request); return Promise.resolve(raw); }); - const viaSeam = await resolveFinalizedContextGraphReadV1(resolver, request); + const viaSeam = await resolveFinalizedContextGraphReadV1(resolver, request, signal); const direct = composeFinalizedContextGraphReadV1(request, validRaw()); expect(resolver).toHaveBeenCalledOnce(); - expect(resolver).toHaveBeenCalledWith(Object.freeze({ - chainId: CHAIN_ID, - contextGraphId: '42', - governanceContract: CGS, - })); + expect(resolver).toHaveBeenCalledWith( + Object.freeze({ + chainId: CHAIN_ID, + contextGraphId: '42', + governanceContract: CGS, + }), + signal, + ); expect(viaSeam).toEqual(direct); }); diff --git a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts new file mode 100644 index 0000000000..cb5e0a9e4c --- /dev/null +++ b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + type BlockNumberV1, + type ChainIdV1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +import { + FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1, + FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1, + createFinalizedContextGraphRpcResolverV1, +} from '../src/finalized-context-graph-rpc-resolver.js'; +import { + resolveFinalizedContextGraphReadV1, + type FinalizedContextGraphBindingV1, +} from '../src/finalized-context-graph-read.js'; +import { type StrictCurrentFinalizedEvmReadV1 } from '../src/strict-current-finalized-evm-rpc.js'; + +const CHAIN_ID = '20430' as ChainIdV1; +const STORAGE = `0x${'11'.repeat(20)}` as EvmAddressV1; +const OWNER = `0x${'22'.repeat(20)}`; +const AUTHORITY = `0x${'33'.repeat(20)}`; +const PARTICIPANT = `0x${'44'.repeat(20)}`; +const BLOCK_HASH = `0x${'55'.repeat(32)}` as Digest32V1; +const NAME_HASH = `0x${'66'.repeat(32)}`; +const ZERO_HASH = `0x${'00'.repeat(32)}`; +const GET_CONTEXT_GRAPH_SELECTOR = '0xca22fff5'; +const GET_NAME_HASH_SELECTOR = '0x8ce6a5c1'; +const ENCODED_ID_42 = `${'0'.repeat(62)}2a`; + +const ABI = ethers.AbiCoder.defaultAbiCoder(); + +function binding(): FinalizedContextGraphBindingV1 { + return Object.freeze({ + chainId: CHAIN_ID, + contextGraphId: '42', + governanceContract: STORAGE, + }); +} + +function contextGraphResult(overrides: { + owner?: string; + active?: boolean; + accessPolicy?: number; + publishPolicy?: number; + publishAuthority?: string; + publishAuthorityAccountId?: bigint; +} = {}): string { + return ABI.encode( + ['address', 'address[]', 'uint256', 'bool', 'uint256', 'uint8', 'uint8', 'address', 'uint256'], + [ + overrides.owner ?? OWNER, + [PARTICIPANT], + 9n, + overrides.active ?? true, + 123n, + overrides.accessPolicy ?? 1, + overrides.publishPolicy ?? 0, + overrides.publishAuthority ?? AUTHORITY, + overrides.publishAuthorityAccountId ?? 7n, + ], + ); +} + +function nameHashResult(value = NAME_HASH): string { + return ABI.encode(['bytes32'], [value]); +} + +function readStub( + tuple = contextGraphResult(), + nameHash = nameHashResult(), + resultChainId: ChainIdV1 = CHAIN_ID, +): ReturnType> { + return vi.fn(async () => Object.freeze({ + chainId: resultChainId, + blockNumber: '123' as BlockNumberV1, + blockHash: BLOCK_HASH, + returnData: Object.freeze([tuple, nameHash]), + })); +} + +describe('RFC-64 finalized Context Graph RPC resolver', () => { + it('executes the two ABI reads at one transport anchor and decodes the policy tuple', async () => { + const read = readStub(); + const resolver = createFinalizedContextGraphRpcResolverV1(read); + const signal = new AbortController().signal; + + const raw = await resolver(binding(), signal); + + expect(Object.isFrozen(resolver)).toBe(true); + expect(Object.isFrozen(raw)).toBe(true); + expect(read).toHaveBeenCalledOnce(); + expect(read).toHaveBeenCalledWith({ + chainId: CHAIN_ID, + calls: [ + { + to: STORAGE, + data: `${GET_CONTEXT_GRAPH_SELECTOR}${ENCODED_ID_42}`, + maxReturnBytes: FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1, + }, + { + to: STORAGE, + data: `${GET_NAME_HASH_SELECTOR}${ENCODED_ID_42}`, + maxReturnBytes: FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1, + }, + ], + signal, + }); + expect(raw).toEqual({ + blockNumber: '123', + blockHash: BLOCK_HASH, + owner: OWNER, + active: true, + accessPolicy: 1, + publishPolicy: 0, + publishAuthority: AUTHORITY, + publishAuthorityAccountId: '7', + nameHash: NAME_HASH, + }); + }); + + it('feeds the concrete RPC result through the validated finalized read seam', async () => { + const resolver = createFinalizedContextGraphRpcResolverV1(readStub()); + const result = await resolveFinalizedContextGraphReadV1(resolver, binding()); + + expect(result).toEqual({ + ...binding(), + blockNumber: '123', + blockHash: BLOCK_HASH, + owner: OWNER, + active: true, + accessPolicy: 1, + publishPolicy: 0, + publishAuthority: AUTHORITY, + publishAuthorityAccountId: '7', + nameHash: NAME_HASH, + }); + }); + + it('preserves the chain zero-hash sentinel for canonical null normalization', async () => { + const resolver = createFinalizedContextGraphRpcResolverV1( + readStub(contextGraphResult({ + publishPolicy: 1, + publishAuthority: ethers.ZeroAddress, + publishAuthorityAccountId: 0n, + }), nameHashResult(ZERO_HASH)), + ); + + const result = await resolveFinalizedContextGraphReadV1(resolver, binding()); + expect(result.publishAuthority).toBeNull(); + expect(result.publishAuthorityAccountId).toBe('0'); + expect(result.nameHash).toBeNull(); + }); + + it('rejects a transport result from a different chain', async () => { + const resolver = createFinalizedContextGraphRpcResolverV1( + readStub(contextGraphResult(), nameHashResult(), '1' as ChainIdV1), + ); + await expect(resolver(binding())).rejects.toMatchObject({ code: 'chain-mismatch' }); + }); + + it('rejects missing, trailing, or non-canonical ABI result bytes', async () => { + const cases = [ + readStub('0x', nameHashResult()), + readStub(`${contextGraphResult()}00`, nameHashResult()), + readStub(contextGraphResult(), `${nameHashResult()}00`), + ]; + for (const read of cases) { + const resolver = createFinalizedContextGraphRpcResolverV1(read); + await expect(resolver(binding())).rejects.toMatchObject({ code: 'malformed-return' }); + } + }); + + it('rejects any transport shape other than exactly two results', async () => { + const read = vi.fn(async () => ({ + chainId: CHAIN_ID, + blockNumber: '123' as BlockNumberV1, + blockHash: BLOCK_HASH, + returnData: [contextGraphResult()], + })); + const resolver = createFinalizedContextGraphRpcResolverV1(read); + await expect(resolver(binding())).rejects.toMatchObject({ code: 'malformed-return' }); + }); + + it('rejects a non-function transport at construction', () => { + expect(() => createFinalizedContextGraphRpcResolverV1(null as never)).toThrow(TypeError); + }); +}); From 59e077991df06fac5ae701f65ca134939f5b86d9 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 09:54:45 +0200 Subject: [PATCH 211/292] fix(chain): tighten finalized CG resolver seam --- .../chain/src/finalized-context-graph-read.ts | 2 +- .../finalized-context-graph-rpc-resolver.ts | 26 ++++++++++++++----- ...ed-context-graph-rpc-resolver.unit.test.ts | 20 +++++++++----- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts index 833769d680..4de48202cc 100644 --- a/packages/chain/src/finalized-context-graph-read.ts +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -94,7 +94,7 @@ export type FinalizedContextGraphReadV1 = { export interface FinalizedContextGraphReadResolverV1 { ( binding: FinalizedContextGraphBindingV1, - signal?: AbortSignal, + signal: AbortSignal, ): Promise; } diff --git a/packages/chain/src/finalized-context-graph-rpc-resolver.ts b/packages/chain/src/finalized-context-graph-rpc-resolver.ts index 31a5390c9f..25864d5699 100644 --- a/packages/chain/src/finalized-context-graph-rpc-resolver.ts +++ b/packages/chain/src/finalized-context-graph-rpc-resolver.ts @@ -37,7 +37,7 @@ export function createFinalizedContextGraphRpcResolverV1( const resolver: FinalizedContextGraphReadResolverV1 = async ( binding, - signal = new AbortController().signal, + signal, ) => { const contextGraphId = BigInt(binding.contextGraphId); const result = await read({ @@ -94,15 +94,27 @@ function decodeFinalizedContextGraphResult( assertCanonicalAbiResult('getContextGraph', contextGraph, result.returnData[0]!); assertCanonicalAbiResult('getNameHash', nameHash, result.returnData[1]!); + const [ + owner, + _participantAgents, + _metadataBatchId, + active, + _createdAt, + accessPolicy, + publishPolicy, + publishAuthority, + publishAuthorityAccountId, + ] = contextGraph; + return Object.freeze({ blockNumber: result.blockNumber, blockHash: result.blockHash, - owner: lowerAddress(contextGraph[0]), - active: contextGraph[3], - accessPolicy: Number(contextGraph[5]), - publishPolicy: Number(contextGraph[6]), - publishAuthority: lowerAddress(contextGraph[7]), - publishAuthorityAccountId: decimal(contextGraph[8]), + owner: lowerAddress(owner), + active, + accessPolicy: Number(accessPolicy), + publishPolicy: Number(publishPolicy), + publishAuthority: lowerAddress(publishAuthority), + publishAuthorityAccountId: decimal(publishAuthorityAccountId), nameHash: String(nameHash[0]).toLowerCase(), }); } catch (cause) { diff --git a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts index cb5e0a9e4c..241fa15cb8 100644 --- a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts @@ -83,12 +83,14 @@ function readStub( } describe('RFC-64 finalized Context Graph RPC resolver', () => { + const signal = (): AbortSignal => new AbortController().signal; + it('executes the two ABI reads at one transport anchor and decodes the policy tuple', async () => { const read = readStub(); const resolver = createFinalizedContextGraphRpcResolverV1(read); - const signal = new AbortController().signal; + const requestSignal = signal(); - const raw = await resolver(binding(), signal); + const raw = await resolver(binding(), requestSignal); expect(Object.isFrozen(resolver)).toBe(true); expect(Object.isFrozen(raw)).toBe(true); @@ -107,7 +109,7 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { maxReturnBytes: FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1, }, ], - signal, + signal: requestSignal, }); expect(raw).toEqual({ blockNumber: '123', @@ -123,8 +125,10 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { }); it('feeds the concrete RPC result through the validated finalized read seam', async () => { - const resolver = createFinalizedContextGraphRpcResolverV1(readStub()); + const read = readStub(); + const resolver = createFinalizedContextGraphRpcResolverV1(read); const result = await resolveFinalizedContextGraphReadV1(resolver, binding()); + const normalizedSignal = read.mock.calls[0]?.[0].signal; expect(result).toEqual({ ...binding(), @@ -138,6 +142,8 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { publishAuthorityAccountId: '7', nameHash: NAME_HASH, }); + expect(normalizedSignal?.aborted).toBe(false); + expect(typeof normalizedSignal?.addEventListener).toBe('function'); }); it('preserves the chain zero-hash sentinel for canonical null normalization', async () => { @@ -159,7 +165,7 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { const resolver = createFinalizedContextGraphRpcResolverV1( readStub(contextGraphResult(), nameHashResult(), '1' as ChainIdV1), ); - await expect(resolver(binding())).rejects.toMatchObject({ code: 'chain-mismatch' }); + await expect(resolver(binding(), signal())).rejects.toMatchObject({ code: 'chain-mismatch' }); }); it('rejects missing, trailing, or non-canonical ABI result bytes', async () => { @@ -170,7 +176,7 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { ]; for (const read of cases) { const resolver = createFinalizedContextGraphRpcResolverV1(read); - await expect(resolver(binding())).rejects.toMatchObject({ code: 'malformed-return' }); + await expect(resolver(binding(), signal())).rejects.toMatchObject({ code: 'malformed-return' }); } }); @@ -182,7 +188,7 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { returnData: [contextGraphResult()], })); const resolver = createFinalizedContextGraphRpcResolverV1(read); - await expect(resolver(binding())).rejects.toMatchObject({ code: 'malformed-return' }); + await expect(resolver(binding(), signal())).rejects.toMatchObject({ code: 'malformed-return' }); }); it('rejects a non-function transport at construction', () => { From 699e3e49557925b915446c5b6128f7c81f5556fc Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:07:56 +0200 Subject: [PATCH 212/292] fix(chain): scope finalized CG return cap --- .../chain/src/finalized-context-graph-rpc-resolver.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-rpc-resolver.ts b/packages/chain/src/finalized-context-graph-rpc-resolver.ts index 25864d5699..e803501e79 100644 --- a/packages/chain/src/finalized-context-graph-rpc-resolver.ts +++ b/packages/chain/src/finalized-context-graph-rpc-resolver.ts @@ -1,10 +1,7 @@ import { type EvmAddressV1 } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; -import { - CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, - CurrentFinalizedEvmCallErrorV1, -} from './current-finalized-evm-read-profile.js'; +import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-profile.js'; import { type FinalizedContextGraphBindingV1, type FinalizedContextGraphReadResolverV1, @@ -15,8 +12,9 @@ import { type StrictCurrentFinalizedEvmReadV1, } from './strict-current-finalized-evm-rpc.js'; -export const FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1 = - CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1; +// getContextGraph has nine fixed words plus a chain-capped 256-address array: +// its maximal canonical ABI result is 8,512 bytes, below this domain ceiling. +export const FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1 = 9 * 1024; export const FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1 = 32; const CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE = new ethers.Interface([ From a5e855b557ac748d31a2af2467cc2df3c1466bb3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:13:40 +0200 Subject: [PATCH 213/292] refactor(chain): keep finalized CG decode untrusted --- .../finalized-context-graph-rpc-resolver.ts | 29 +++++-------------- ...ed-context-graph-rpc-resolver.unit.test.ts | 4 +-- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-rpc-resolver.ts b/packages/chain/src/finalized-context-graph-rpc-resolver.ts index e803501e79..6411ddd089 100644 --- a/packages/chain/src/finalized-context-graph-rpc-resolver.ts +++ b/packages/chain/src/finalized-context-graph-rpc-resolver.ts @@ -1,4 +1,3 @@ -import { type EvmAddressV1 } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-profile.js'; @@ -92,27 +91,15 @@ function decodeFinalizedContextGraphResult( assertCanonicalAbiResult('getContextGraph', contextGraph, result.returnData[0]!); assertCanonicalAbiResult('getNameHash', nameHash, result.returnData[1]!); - const [ - owner, - _participantAgents, - _metadataBatchId, - active, - _createdAt, - accessPolicy, - publishPolicy, - publishAuthority, - publishAuthorityAccountId, - ] = contextGraph; - return Object.freeze({ blockNumber: result.blockNumber, blockHash: result.blockHash, - owner: lowerAddress(owner), - active, - accessPolicy: Number(accessPolicy), - publishPolicy: Number(publishPolicy), - publishAuthority: lowerAddress(publishAuthority), - publishAuthorityAccountId: decimal(publishAuthorityAccountId), + owner: lowerAddress(contextGraph.owner), + active: contextGraph.active, + accessPolicy: Number(contextGraph.accessPolicy), + publishPolicy: Number(contextGraph.publishPolicy), + publishAuthority: lowerAddress(contextGraph.publishAuthority), + publishAuthorityAccountId: decimal(contextGraph.publishAuthorityAccountId), nameHash: String(nameHash[0]).toLowerCase(), }); } catch (cause) { @@ -134,8 +121,8 @@ function assertCanonicalAbiResult( } } -function lowerAddress(value: unknown): EvmAddressV1 { - return String(value).toLowerCase() as EvmAddressV1; +function lowerAddress(value: unknown): string { + return String(value).toLowerCase(); } function decimal(value: unknown): string { diff --git a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts index 241fa15cb8..7536dbefef 100644 --- a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts @@ -21,8 +21,8 @@ import { type StrictCurrentFinalizedEvmReadV1 } from '../src/strict-current-fina const CHAIN_ID = '20430' as ChainIdV1; const STORAGE = `0x${'11'.repeat(20)}` as EvmAddressV1; -const OWNER = `0x${'22'.repeat(20)}`; -const AUTHORITY = `0x${'33'.repeat(20)}`; +const OWNER = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; +const AUTHORITY = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; const PARTICIPANT = `0x${'44'.repeat(20)}`; const BLOCK_HASH = `0x${'55'.repeat(32)}` as Digest32V1; const NAME_HASH = `0x${'66'.repeat(32)}`; From 9514d301a6cf79435d35b045726755d6c5a4b447 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:18:22 +0200 Subject: [PATCH 214/292] fix(chain): map missing finalized context graph --- .../finalized-context-graph-rpc-resolver.ts | 74 +++++--- ...ed-context-graph-rpc-resolver.unit.test.ts | 163 +++++++++++++++++- 2 files changed, 214 insertions(+), 23 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-rpc-resolver.ts b/packages/chain/src/finalized-context-graph-rpc-resolver.ts index 6411ddd089..3bf0ca2686 100644 --- a/packages/chain/src/finalized-context-graph-rpc-resolver.ts +++ b/packages/chain/src/finalized-context-graph-rpc-resolver.ts @@ -2,11 +2,13 @@ import { ethers } from 'ethers'; import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-profile.js'; import { + FinalizedContextGraphReadErrorV1, type FinalizedContextGraphBindingV1, type FinalizedContextGraphReadResolverV1, type UntrustedFinalizedContextGraphFieldsV1, } from './finalized-context-graph-read.js'; import { + readStrictCurrentFinalizedEvmRevertDataV1, type StrictCurrentFinalizedEvmReadResultV1, type StrictCurrentFinalizedEvmReadV1, } from './strict-current-finalized-evm-rpc.js'; @@ -20,6 +22,9 @@ const CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE = new ethers.Interface([ 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', ]); +const ERC721_FINALIZED_READ_ERROR_INTERFACE = new ethers.Interface([ + 'error ERC721NonexistentToken(uint256 tokenId)', +]); /** * Bind the strict same-anchor transport to the two ContextGraphStorage reads @@ -37,34 +42,59 @@ export function createFinalizedContextGraphRpcResolverV1( signal, ) => { const contextGraphId = BigInt(binding.contextGraphId); - const result = await read({ - chainId: binding.chainId, - calls: Object.freeze([ - Object.freeze({ - to: binding.governanceContract, - data: CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE.encodeFunctionData( - 'getContextGraph', - [contextGraphId], - ), - maxReturnBytes: FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1, - }), - Object.freeze({ - to: binding.governanceContract, - data: CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE.encodeFunctionData( - 'getNameHash', - [contextGraphId], - ), - maxReturnBytes: FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1, - }), - ]), - signal, - }); + let result: StrictCurrentFinalizedEvmReadResultV1; + try { + result = await read({ + chainId: binding.chainId, + calls: Object.freeze([ + Object.freeze({ + to: binding.governanceContract, + data: CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE.encodeFunctionData( + 'getContextGraph', + [contextGraphId], + ), + maxReturnBytes: FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1, + }), + Object.freeze({ + to: binding.governanceContract, + data: CONTEXT_GRAPH_STORAGE_FINALIZED_READ_INTERFACE.encodeFunctionData( + 'getNameHash', + [contextGraphId], + ), + maxReturnBytes: FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1, + }), + ]), + signal, + }); + } catch (cause) { + if (isAuthenticatedMissingContextGraphRevert(cause, contextGraphId)) { + throw new FinalizedContextGraphReadErrorV1( + 'unregistered-context-graph', + `Context Graph ${binding.contextGraphId} is not registered at the finalized anchor`, + ); + } + throw cause; + } return decodeFinalizedContextGraphResult(binding, result); }; return Object.freeze(resolver); } +function isAuthenticatedMissingContextGraphRevert( + cause: unknown, + contextGraphId: bigint, +): boolean { + if (!(cause instanceof CurrentFinalizedEvmCallErrorV1) || cause.code !== 'revert') { + return false; + } + const authenticatedRevertData = readStrictCurrentFinalizedEvmRevertDataV1(cause); + if (authenticatedRevertData === undefined) return false; + return authenticatedRevertData === ERC721_FINALIZED_READ_ERROR_INTERFACE + .encodeErrorResult('ERC721NonexistentToken', [contextGraphId]) + .toLowerCase(); +} + function decodeFinalizedContextGraphResult( binding: FinalizedContextGraphBindingV1, result: StrictCurrentFinalizedEvmReadResultV1, diff --git a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts index 7536dbefef..1e708cf98e 100644 --- a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts @@ -1,3 +1,6 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + import { describe, expect, it, vi } from 'vitest'; import { @@ -14,10 +17,15 @@ import { createFinalizedContextGraphRpcResolverV1, } from '../src/finalized-context-graph-rpc-resolver.js'; import { + FinalizedContextGraphReadErrorV1, resolveFinalizedContextGraphReadV1, type FinalizedContextGraphBindingV1, } from '../src/finalized-context-graph-read.js'; -import { type StrictCurrentFinalizedEvmReadV1 } from '../src/strict-current-finalized-evm-rpc.js'; +import { + createStrictCurrentFinalizedEvmReadV1, + type StrictCurrentFinalizedEvmReadV1, +} from '../src/strict-current-finalized-evm-rpc.js'; +import { CurrentFinalizedEvmCallErrorV1 } from '../src/current-finalized-evm-read-profile.js'; const CHAIN_ID = '20430' as ChainIdV1; const STORAGE = `0x${'11'.repeat(20)}` as EvmAddressV1; @@ -32,6 +40,16 @@ const GET_NAME_HASH_SELECTOR = '0x8ce6a5c1'; const ENCODED_ID_42 = `${'0'.repeat(62)}2a`; const ABI = ethers.AbiCoder.defaultAbiCoder(); +const ERC721_ERRORS = new ethers.Interface([ + 'error ERC721NonexistentToken(uint256 tokenId)', +]); + +interface JsonRpcRequest { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly method: string; + readonly params: readonly unknown[]; +} function binding(): FinalizedContextGraphBindingV1 { return Object.freeze({ @@ -194,4 +212,147 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { it('rejects a non-function transport at construction', () => { expect(() => createFinalizedContextGraphRpcResolverV1(null as never)).toThrow(TypeError); }); + + it('maps an authenticated missing-token revert for the bound ID to unregistered', async () => { + const rpc = await startMissingContextGraphRpc(42n); + try { + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [rpc.url], + }); + const resolver = createFinalizedContextGraphRpcResolverV1(read); + + let caught: unknown; + try { + await resolveFinalizedContextGraphReadV1(resolver, binding()); + } catch (cause) { + caught = cause; + } + expect(caught).toBeInstanceOf(FinalizedContextGraphReadErrorV1); + expect(caught).toMatchObject({ code: 'unregistered-context-graph' }); + } finally { + await rpc.stop(); + } + }); + + it('does not map authenticated missing-token evidence for another ID', async () => { + const rpc = await startMissingContextGraphRpc(43n); + try { + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [rpc.url], + }); + const resolver = createFinalizedContextGraphRpcResolverV1(read); + + await expect(resolver(binding(), signal())).rejects.toMatchObject({ + name: 'CurrentFinalizedEvmCallErrorV1', + code: 'revert', + }); + } finally { + await rpc.stop(); + } + }); + + it('does not trust a forgeable public revert error as missing-token evidence', async () => { + const forged = new CurrentFinalizedEvmCallErrorV1('revert', 'forged'); + const read = vi.fn(async () => { + throw forged; + }); + const resolver = createFinalizedContextGraphRpcResolverV1(read); + + await expect(resolver(binding(), signal())).rejects.toBe(forged); + }); }); + +async function startMissingContextGraphRpc(missingId: bigint): Promise<{ + readonly url: string; + readonly stop: () => Promise; +}> { + const missingData = ERC721_ERRORS.encodeErrorResult( + 'ERC721NonexistentToken', + [missingId], + ).toLowerCase(); + const server = createServer(async (request, response) => { + try { + const call = JSON.parse(await readRequestBody(request)) as JsonRpcRequest; + switch (call.method) { + case 'eth_chainId': + sendResult(response, call, '0x4fce'); + return; + case 'eth_getBlockByNumber': + sendResult(response, call, { number: '0x7b', hash: BLOCK_HASH }); + return; + case 'eth_getCode': + sendResult(response, call, '0x6000'); + return; + case 'eth_call': { + const callObject = call.params[0] as { readonly data?: unknown }; + if (typeof callObject.data === 'string' + && callObject.data.startsWith(GET_CONTEXT_GRAPH_SELECTOR)) { + sendError(response, call, 3, 'execution reverted', missingData); + } else { + sendResult(response, call, nameHashResult()); + } + return; + } + default: + sendError(response, call, -32601, 'method not found'); + } + } catch (cause) { + if (!response.headersSent) response.writeHead(500, { 'content-type': 'text/plain' }); + if (!response.writableEnded) { + response.end(cause instanceof Error ? cause.message : 'failure'); + } + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + let stopped = false; + return Object.freeze({ + url: `http://127.0.0.1:${address.port}`, + stop: async () => { + if (stopped) return; + stopped = true; + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + }, + }); +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} + +function sendResult( + response: ServerResponse, + request: JsonRpcRequest, + result: unknown, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); +} + +function sendError( + response: ServerResponse, + request: JsonRpcRequest, + code: number, + message: string, + data?: string, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + error: { code, message, ...(data === undefined ? {} : { data }) }, + })); +} From 2e57097e0c011787614c5cba2402a63e48e07fd9 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:20:55 +0200 Subject: [PATCH 215/292] fix(chain): preserve finalized resolver compatibility --- .../chain/src/finalized-context-graph-read.ts | 17 +++++++- .../finalized-context-graph-rpc-resolver.ts | 6 +-- packages/chain/src/index.ts | 2 + .../finalized-context-graph-read.unit.test.ts | 42 ++++++++++++++----- ...ed-context-graph-rpc-resolver.unit.test.ts | 20 +++++++-- 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts index 4de48202cc..dd7398eb3c 100644 --- a/packages/chain/src/finalized-context-graph-read.ts +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -92,6 +92,11 @@ export type FinalizedContextGraphReadV1 = { }; export interface FinalizedContextGraphReadResolverV1 { + (binding: FinalizedContextGraphBindingV1): Promise; +} + +/** Signal-aware resolver used by transports that own cancellable I/O. */ +export interface FinalizedContextGraphReadResolverWithSignalV1 { ( binding: FinalizedContextGraphBindingV1, signal: AbortSignal, @@ -304,7 +309,17 @@ function composeValidatedFinalizedContextGraphReadV1( export async function resolveFinalizedContextGraphReadV1( resolver: FinalizedContextGraphReadResolverV1, request: FinalizedContextGraphReadRequestV1, - signal: AbortSignal = new AbortController().signal, +): Promise { + const binding = validateFinalizedContextGraphReadRequestV1(request); + const raw = await resolver(binding); + return composeValidatedFinalizedContextGraphReadV1(binding, raw); +} + +/** Resolve through a signal-aware transport without manufacturing cancellation ownership. */ +export async function resolveFinalizedContextGraphReadWithSignalV1( + resolver: FinalizedContextGraphReadResolverWithSignalV1, + request: FinalizedContextGraphReadRequestV1, + signal: AbortSignal, ): Promise { const binding = validateFinalizedContextGraphReadRequestV1(request); const raw = await resolver(binding, signal); diff --git a/packages/chain/src/finalized-context-graph-rpc-resolver.ts b/packages/chain/src/finalized-context-graph-rpc-resolver.ts index 3bf0ca2686..ed430dade6 100644 --- a/packages/chain/src/finalized-context-graph-rpc-resolver.ts +++ b/packages/chain/src/finalized-context-graph-rpc-resolver.ts @@ -4,7 +4,7 @@ import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-pro import { FinalizedContextGraphReadErrorV1, type FinalizedContextGraphBindingV1, - type FinalizedContextGraphReadResolverV1, + type FinalizedContextGraphReadResolverWithSignalV1, type UntrustedFinalizedContextGraphFieldsV1, } from './finalized-context-graph-read.js'; import { @@ -32,12 +32,12 @@ const ERC721_FINALIZED_READ_ERROR_INTERFACE = new ethers.Interface([ */ export function createFinalizedContextGraphRpcResolverV1( read: StrictCurrentFinalizedEvmReadV1, -): FinalizedContextGraphReadResolverV1 { +): FinalizedContextGraphReadResolverWithSignalV1 { if (typeof read !== 'function') { throw new TypeError('Finalized Context Graph RPC resolver requires a read function'); } - const resolver: FinalizedContextGraphReadResolverV1 = async ( + const resolver: FinalizedContextGraphReadResolverWithSignalV1 = async ( binding, signal, ) => { diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 5e11162f21..9730d455dd 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -53,12 +53,14 @@ export { export { FinalizedContextGraphReadErrorV1, composeFinalizedContextGraphReadV1, + resolveFinalizedContextGraphReadWithSignalV1, resolveFinalizedContextGraphReadV1, validateFinalizedContextGraphReadRequestV1, type FinalizedContextGraphBindingV1, type FinalizedContextGraphReadErrorCodeV1, type FinalizedContextGraphReadRequestV1, type FinalizedContextGraphReadResolverV1, + type FinalizedContextGraphReadResolverWithSignalV1, type FinalizedContextGraphReadV1, type UntrustedFinalizedContextGraphFieldsV1, } from './finalized-context-graph-read.js'; diff --git a/packages/chain/test/finalized-context-graph-read.unit.test.ts b/packages/chain/test/finalized-context-graph-read.unit.test.ts index 9ec5c2688b..91676946e7 100644 --- a/packages/chain/test/finalized-context-graph-read.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-read.unit.test.ts @@ -9,10 +9,13 @@ import type { import { FinalizedContextGraphReadErrorV1, composeFinalizedContextGraphReadV1, + resolveFinalizedContextGraphReadWithSignalV1, resolveFinalizedContextGraphReadV1, type FinalizedContextGraphBindingV1, type FinalizedContextGraphReadErrorCodeV1, type FinalizedContextGraphReadRequestV1, + type FinalizedContextGraphReadResolverV1, + type FinalizedContextGraphReadResolverWithSignalV1, type UntrustedFinalizedContextGraphFieldsV1, } from '../src/finalized-context-graph-read.js'; @@ -91,25 +94,44 @@ describe('RFC-64 finalized Context Graph chain read', () => { it('passes one frozen canonical binding through the resolver seam', async () => { const request = validRequest(); const raw = Object.freeze(validRaw()); - const signal = new AbortController().signal; - const resolver = vi.fn((received: FinalizedContextGraphBindingV1) => { + const resolver: FinalizedContextGraphReadResolverV1 = vi.fn((received) => { expect(received).not.toBe(request); expect(Object.isFrozen(received)).toBe(true); expect(received).toEqual(request); return Promise.resolve(raw); }); - const viaSeam = await resolveFinalizedContextGraphReadV1(resolver, request, signal); + const viaSeam = await resolveFinalizedContextGraphReadV1(resolver, request); const direct = composeFinalizedContextGraphReadV1(request, validRaw()); expect(resolver).toHaveBeenCalledOnce(); - expect(resolver).toHaveBeenCalledWith( - Object.freeze({ - chainId: CHAIN_ID, - contextGraphId: '42', - governanceContract: CGS, - }), + expect(resolver).toHaveBeenCalledWith(Object.freeze({ + chainId: CHAIN_ID, + contextGraphId: '42', + governanceContract: CGS, + })); + expect(viaSeam).toEqual(direct); + }); + + it('requires and forwards caller-owned cancellation for signal-aware resolvers', async () => { + const request = validRequest(); + const raw = Object.freeze(validRaw()); + const signal = new AbortController().signal; + const resolver: FinalizedContextGraphReadResolverWithSignalV1 = vi.fn( + async () => raw, + ); + + const result = await resolveFinalizedContextGraphReadWithSignalV1( + resolver, + request, signal, ); - expect(viaSeam).toEqual(direct); + + expect(resolver).toHaveBeenCalledOnce(); + expect(resolver).toHaveBeenCalledWith(Object.freeze({ + chainId: CHAIN_ID, + contextGraphId: '42', + governanceContract: CGS, + }), signal); + expect(result).toEqual(composeFinalizedContextGraphReadV1(request, raw)); }); it('rejects malformed identity before invoking the resolver', async () => { diff --git a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts index 1e708cf98e..d5c9655164 100644 --- a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts @@ -18,7 +18,7 @@ import { } from '../src/finalized-context-graph-rpc-resolver.js'; import { FinalizedContextGraphReadErrorV1, - resolveFinalizedContextGraphReadV1, + resolveFinalizedContextGraphReadWithSignalV1, type FinalizedContextGraphBindingV1, } from '../src/finalized-context-graph-read.js'; import { @@ -145,7 +145,11 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { it('feeds the concrete RPC result through the validated finalized read seam', async () => { const read = readStub(); const resolver = createFinalizedContextGraphRpcResolverV1(read); - const result = await resolveFinalizedContextGraphReadV1(resolver, binding()); + const result = await resolveFinalizedContextGraphReadWithSignalV1( + resolver, + binding(), + signal(), + ); const normalizedSignal = read.mock.calls[0]?.[0].signal; expect(result).toEqual({ @@ -173,7 +177,11 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { }), nameHashResult(ZERO_HASH)), ); - const result = await resolveFinalizedContextGraphReadV1(resolver, binding()); + const result = await resolveFinalizedContextGraphReadWithSignalV1( + resolver, + binding(), + signal(), + ); expect(result.publishAuthority).toBeNull(); expect(result.publishAuthorityAccountId).toBe('0'); expect(result.nameHash).toBeNull(); @@ -224,7 +232,11 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { let caught: unknown; try { - await resolveFinalizedContextGraphReadV1(resolver, binding()); + await resolveFinalizedContextGraphReadWithSignalV1( + resolver, + binding(), + signal(), + ); } catch (cause) { caught = cause; } From 0bb67ca8fdaea714a0e7cb2d2205fb142fbf28f9 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:33:59 +0200 Subject: [PATCH 216/292] test(chain): reuse loopback RPC harness --- ...ed-context-graph-rpc-resolver.unit.test.ts | 185 ++++++------------ 1 file changed, 59 insertions(+), 126 deletions(-) diff --git a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts index d5c9655164..cf6e88d472 100644 --- a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts @@ -1,7 +1,4 @@ -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; -import type { AddressInfo } from 'node:net'; - -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { type BlockNumberV1, @@ -26,6 +23,12 @@ import { type StrictCurrentFinalizedEvmReadV1, } from '../src/strict-current-finalized-evm-rpc.js'; import { CurrentFinalizedEvmCallErrorV1 } from '../src/current-finalized-evm-read-profile.js'; +import { + createLoopbackJsonRpcTestHarness, + sendJsonRpcError as sendError, + sendJsonRpcResult as sendResult, + type LoopbackJsonRpcServer, +} from './loopback-json-rpc-test-helpers.js'; const CHAIN_ID = '20430' as ChainIdV1; const STORAGE = `0x${'11'.repeat(20)}` as EvmAddressV1; @@ -44,12 +47,11 @@ const ERC721_ERRORS = new ethers.Interface([ 'error ERC721NonexistentToken(uint256 tokenId)', ]); -interface JsonRpcRequest { - readonly jsonrpc: '2.0'; - readonly id: number; - readonly method: string; - readonly params: readonly unknown[]; -} +const rpcHarness = createLoopbackJsonRpcTestHarness(); + +afterEach(async () => { + await rpcHarness.stopAll(); +}); function binding(): FinalizedContextGraphBindingV1 { return Object.freeze({ @@ -223,46 +225,38 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { it('maps an authenticated missing-token revert for the bound ID to unregistered', async () => { const rpc = await startMissingContextGraphRpc(42n); - try { - const read = createStrictCurrentFinalizedEvmReadV1({ - chainId: CHAIN_ID, - endpoints: [rpc.url], - }); - const resolver = createFinalizedContextGraphRpcResolverV1(read); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [rpc.url], + }); + const resolver = createFinalizedContextGraphRpcResolverV1(read); - let caught: unknown; - try { - await resolveFinalizedContextGraphReadWithSignalV1( - resolver, - binding(), - signal(), - ); - } catch (cause) { - caught = cause; - } - expect(caught).toBeInstanceOf(FinalizedContextGraphReadErrorV1); - expect(caught).toMatchObject({ code: 'unregistered-context-graph' }); - } finally { - await rpc.stop(); + let caught: unknown; + try { + await resolveFinalizedContextGraphReadWithSignalV1( + resolver, + binding(), + signal(), + ); + } catch (cause) { + caught = cause; } + expect(caught).toBeInstanceOf(FinalizedContextGraphReadErrorV1); + expect(caught).toMatchObject({ code: 'unregistered-context-graph' }); }); it('does not map authenticated missing-token evidence for another ID', async () => { const rpc = await startMissingContextGraphRpc(43n); - try { - const read = createStrictCurrentFinalizedEvmReadV1({ - chainId: CHAIN_ID, - endpoints: [rpc.url], - }); - const resolver = createFinalizedContextGraphRpcResolverV1(read); + const read = createStrictCurrentFinalizedEvmReadV1({ + chainId: CHAIN_ID, + endpoints: [rpc.url], + }); + const resolver = createFinalizedContextGraphRpcResolverV1(read); - await expect(resolver(binding(), signal())).rejects.toMatchObject({ - name: 'CurrentFinalizedEvmCallErrorV1', - code: 'revert', - }); - } finally { - await rpc.stop(); - } + await expect(resolver(binding(), signal())).rejects.toMatchObject({ + name: 'CurrentFinalizedEvmCallErrorV1', + code: 'revert', + }); }); it('does not trust a forgeable public revert error as missing-token evidence', async () => { @@ -276,95 +270,34 @@ describe('RFC-64 finalized Context Graph RPC resolver', () => { }); }); -async function startMissingContextGraphRpc(missingId: bigint): Promise<{ - readonly url: string; - readonly stop: () => Promise; -}> { +async function startMissingContextGraphRpc(missingId: bigint): Promise { const missingData = ERC721_ERRORS.encodeErrorResult( 'ERC721NonexistentToken', [missingId], ).toLowerCase(); - const server = createServer(async (request, response) => { - try { - const call = JSON.parse(await readRequestBody(request)) as JsonRpcRequest; - switch (call.method) { - case 'eth_chainId': - sendResult(response, call, '0x4fce'); - return; - case 'eth_getBlockByNumber': - sendResult(response, call, { number: '0x7b', hash: BLOCK_HASH }); - return; - case 'eth_getCode': - sendResult(response, call, '0x6000'); - return; - case 'eth_call': { - const callObject = call.params[0] as { readonly data?: unknown }; - if (typeof callObject.data === 'string' - && callObject.data.startsWith(GET_CONTEXT_GRAPH_SELECTOR)) { - sendError(response, call, 3, 'execution reverted', missingData); - } else { - sendResult(response, call, nameHashResult()); - } - return; + return rpcHarness.start((call, response) => { + switch (call.method) { + case 'eth_chainId': + sendResult(response, call, '0x4fce'); + return; + case 'eth_getBlockByNumber': + sendResult(response, call, { number: '0x7b', hash: BLOCK_HASH }); + return; + case 'eth_getCode': + sendResult(response, call, '0x6000'); + return; + case 'eth_call': { + const callObject = call.params[0] as { readonly data?: unknown }; + if (typeof callObject.data === 'string' + && callObject.data.startsWith(GET_CONTEXT_GRAPH_SELECTOR)) { + sendError(response, call, 3, 'execution reverted', missingData); + } else { + sendResult(response, call, nameHashResult()); } - default: - sendError(response, call, -32601, 'method not found'); - } - } catch (cause) { - if (!response.headersSent) response.writeHead(500, { 'content-type': 'text/plain' }); - if (!response.writableEnded) { - response.end(cause instanceof Error ? cause.message : 'failure'); + return; } + default: + sendError(response, call, -32601, 'method not found'); } }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.off('error', reject); - resolve(); - }); - }); - const address = server.address() as AddressInfo; - let stopped = false; - return Object.freeze({ - url: `http://127.0.0.1:${address.port}`, - stop: async () => { - if (stopped) return; - stopped = true; - await new Promise((resolve) => { - server.close(() => resolve()); - server.closeAllConnections(); - }); - }, - }); -} - -async function readRequestBody(request: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of request) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString('utf8'); -} - -function sendResult( - response: ServerResponse, - request: JsonRpcRequest, - result: unknown, -): void { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); -} - -function sendError( - response: ServerResponse, - request: JsonRpcRequest, - code: number, - message: string, - data?: string, -): void { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ - jsonrpc: '2.0', - id: request.id, - error: { code, message, ...(data === undefined ? {} : { data }) }, - })); } From 8f0690fb263381f33f242d503a1d90d3c3e0c1ac Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:55:57 +0200 Subject: [PATCH 217/292] test(chain): use canonical loopback RPC harness --- .../test/finalized-context-graph-rpc-resolver.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts index cf6e88d472..a3be9a8ba0 100644 --- a/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-rpc-resolver.unit.test.ts @@ -28,7 +28,7 @@ import { sendJsonRpcError as sendError, sendJsonRpcResult as sendResult, type LoopbackJsonRpcServer, -} from './loopback-json-rpc-test-helpers.js'; +} from './loopback-rpc-harness.js'; const CHAIN_ID = '20430' as ChainIdV1; const STORAGE = `0x${'11'.repeat(20)}` as EvmAddressV1; From f29d86973dce272e0355a4e540a624ab41034ec6 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 10:59:48 +0200 Subject: [PATCH 218/292] feat(chain): add scoped finalized snapshot reads --- .../src/current-finalized-evm-read-model.ts | 31 ++ .../src/current-finalized-evm-snapshot.ts | 84 ++++ packages/chain/src/index.ts | 12 +- .../src/strict-current-finalized-evm-rpc.ts | 435 ++++++++++++++++- ...urrent-finalized-evm-snapshot.unit.test.ts | 440 ++++++++++++++++++ 5 files changed, 975 insertions(+), 27 deletions(-) create mode 100644 packages/chain/src/current-finalized-evm-read-model.ts create mode 100644 packages/chain/src/current-finalized-evm-snapshot.ts create mode 100644 packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts diff --git a/packages/chain/src/current-finalized-evm-read-model.ts b/packages/chain/src/current-finalized-evm-read-model.ts new file mode 100644 index 0000000000..cc5a92e896 --- /dev/null +++ b/packages/chain/src/current-finalized-evm-read-model.ts @@ -0,0 +1,31 @@ +import { + type BlockNumberV1, + type ChainIdV1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +/** One trusted-local ABI call in a same-finalized-anchor read. */ +export interface StrictCurrentFinalizedEvmReadCallV1 { + readonly to: EvmAddressV1; + readonly data: string; + readonly maxReturnBytes: number; +} + +export interface StrictCurrentFinalizedEvmReadRequestV1 { + readonly chainId: ChainIdV1; + readonly calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; + readonly signal: AbortSignal; +} + +export interface StrictCurrentFinalizedEvmReadResultV1 { + readonly chainId: ChainIdV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly returnData: readonly string[]; +} + +export interface StrictCurrentFinalizedEvmReadV1 { + (request: StrictCurrentFinalizedEvmReadRequestV1): + Promise; +} diff --git a/packages/chain/src/current-finalized-evm-snapshot.ts b/packages/chain/src/current-finalized-evm-snapshot.ts new file mode 100644 index 0000000000..81093da419 --- /dev/null +++ b/packages/chain/src/current-finalized-evm-snapshot.ts @@ -0,0 +1,84 @@ +import { + type BlockNumberV1, + type ChainIdV1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, +} from './current-finalized-evm-read-profile.js'; +import { + type StrictCurrentFinalizedEvmReadCallV1, +} from './current-finalized-evm-read-model.js'; + +/** One heavyweight pinned scan per chain; each scan still batches up to four calls. */ +export const CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1 = 1; +export const CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 = 1_024; +export const CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 = + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 * CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1; +export const CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_DECLARED_RETURN_BYTES_V1 = 16 * 1024 * 1024; +export const CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1 = 60_000; + +export interface StrictCurrentFinalizedEvmSnapshotRequestV1 { + readonly chainId: ChainIdV1; + readonly signal: AbortSignal; +} + +/** + * Scoped capability for dynamic pages at one transport-owned finalized anchor. + * The read method becomes unusable as soon as its callback settles. + */ +export interface StrictCurrentFinalizedEvmSnapshotSessionV1 { + readonly chainId: ChainIdV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly read: ( + calls: readonly StrictCurrentFinalizedEvmReadCallV1[], + ) => Promise; +} + +export interface StrictCurrentFinalizedEvmSnapshotScopeV1 { + ( + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + consume: (session: StrictCurrentFinalizedEvmSnapshotSessionV1) => Promise, + ): Promise; +} + +export interface CurrentFinalizedEvmSnapshotBudgetV1 { + readonly consume: (calls: readonly StrictCurrentFinalizedEvmReadCallV1[]) => void; + readonly snapshot: () => Readonly<{ + batches: number; + calls: number; + declaredReturnBytes: number; + }>; +} + +/** Package-internal pure budget used by the scoped transport and cap tests. */ +export function createCurrentFinalizedEvmSnapshotBudgetV1( +): CurrentFinalizedEvmSnapshotBudgetV1 { + let batches = 0; + let calls = 0; + let declaredReturnBytes = 0; + + return Object.freeze({ + consume: (batch: readonly StrictCurrentFinalizedEvmReadCallV1[]) => { + const nextBatches = batches + 1; + const nextCalls = calls + batch.length; + const nextDeclaredReturnBytes = batch.reduce( + (total, call) => total + call.maxReturnBytes, + declaredReturnBytes, + ); + if ( + nextBatches > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 + || nextCalls > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 + || nextDeclaredReturnBytes > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_DECLARED_RETURN_BYTES_V1 + ) { + throw new RangeError('Current-finalized snapshot scan exceeded its fixed resource budget'); + } + batches = nextBatches; + calls = nextCalls; + declaredReturnBytes = nextDeclaredReturnBytes; + }, + snapshot: () => Object.freeze({ batches, calls, declaredReturnBytes }), + }); +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 9730d455dd..7a1061beea 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -34,6 +34,11 @@ export { type CurrentFinalizedEvmChainAdapterRegistrationV1, type CurrentFinalizedEvmChainAdapterV1, } from './current-finalized-evm-call.js'; +export { + type StrictCurrentFinalizedEvmSnapshotRequestV1, + type StrictCurrentFinalizedEvmSnapshotScopeV1, + type StrictCurrentFinalizedEvmSnapshotSessionV1, +} from './current-finalized-evm-snapshot.js'; export { FINALIZED_CONTEXT_GRAPH_NAME_HASH_MAX_RETURN_BYTES_V1, FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1, @@ -43,13 +48,16 @@ export { CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, + createStrictCurrentFinalizedEvmSnapshotScopeV1, type CurrentFinalizedEvmBlockReferenceProfileV1, + type StrictCurrentFinalizedEvmRpcConfigV1, +} from './strict-current-finalized-evm-rpc.js'; +export { type StrictCurrentFinalizedEvmReadCallV1, type StrictCurrentFinalizedEvmReadRequestV1, type StrictCurrentFinalizedEvmReadResultV1, type StrictCurrentFinalizedEvmReadV1, - type StrictCurrentFinalizedEvmRpcConfigV1, -} from './strict-current-finalized-evm-rpc.js'; +} from './current-finalized-evm-read-model.js'; export { FinalizedContextGraphReadErrorV1, composeFinalizedContextGraphReadV1, diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 2adbc8143e..df749b0072 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -22,6 +22,20 @@ import { snapshotCurrentFinalizedEvmCallRequestV1, type CurrentFinalizedEvmChainAdapterV1, } from './current-finalized-evm-call.js'; +import { + type StrictCurrentFinalizedEvmReadCallV1, + type StrictCurrentFinalizedEvmReadRequestV1, + type StrictCurrentFinalizedEvmReadResultV1, + type StrictCurrentFinalizedEvmReadV1, +} from './current-finalized-evm-read-model.js'; +import { + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, + createCurrentFinalizedEvmSnapshotBudgetV1, + type StrictCurrentFinalizedEvmSnapshotRequestV1, + type StrictCurrentFinalizedEvmSnapshotScopeV1, + type StrictCurrentFinalizedEvmSnapshotSessionV1, +} from './current-finalized-evm-snapshot.js'; import { assertCanonicalNonzeroEvmAddress, isAbortSignal, @@ -38,6 +52,13 @@ export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ export type CurrentFinalizedEvmBlockReferenceProfileV1 = (typeof CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1)[number]; +export type { + StrictCurrentFinalizedEvmReadCallV1, + StrictCurrentFinalizedEvmReadRequestV1, + StrictCurrentFinalizedEvmReadResultV1, + StrictCurrentFinalizedEvmReadV1, +} from './current-finalized-evm-read-model.js'; + export interface StrictCurrentFinalizedEvmRpcConfigV1 { /** Canonical decimal chain ID permanently bound to this adapter. */ readonly chainId: ChainIdV1; @@ -50,31 +71,6 @@ export interface StrictCurrentFinalizedEvmRpcConfigV1 { readonly blockReferenceProfile?: CurrentFinalizedEvmBlockReferenceProfileV1; } -/** One trusted-local ABI call in a same-finalized-anchor read. */ -export interface StrictCurrentFinalizedEvmReadCallV1 { - readonly to: EvmAddressV1; - readonly data: string; - readonly maxReturnBytes: number; -} - -export interface StrictCurrentFinalizedEvmReadRequestV1 { - readonly chainId: ChainIdV1; - readonly calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; - readonly signal: AbortSignal; -} - -export interface StrictCurrentFinalizedEvmReadResultV1 { - readonly chainId: ChainIdV1; - readonly blockNumber: BlockNumberV1; - readonly blockHash: Digest32V1; - readonly returnData: readonly string[]; -} - -export interface StrictCurrentFinalizedEvmReadV1 { - (request: StrictCurrentFinalizedEvmReadRequestV1): - Promise; -} - interface StrictRpcConfigSnapshotV1 { readonly chainId: ChainIdV1; readonly endpoints: readonly string[]; @@ -113,6 +109,7 @@ const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); const READ_REQUEST_KEYS = Object.freeze(['calls', 'chainId', 'signal'] as const); const READ_CALL_KEYS = Object.freeze(['data', 'maxReturnBytes', 'to'] as const); +const SNAPSHOT_REQUEST_KEYS = Object.freeze(['chainId', 'signal'] as const); /** Package-internal evidence available only for errors minted by this transport. */ export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { @@ -269,6 +266,349 @@ export function createStrictCurrentFinalizedEvmReadV1( return Object.freeze(read); } +/** + * Build a scoped dynamic-read capability pinned to one endpoint and one + * finalized anchor. Endpoint failover is allowed only during preflight; once + * the callback begins it is never replayed on another endpoint. + */ +export function createStrictCurrentFinalizedEvmSnapshotScopeV1( + input: StrictCurrentFinalizedEvmRpcConfigV1, +): StrictCurrentFinalizedEvmSnapshotScopeV1 { + const config = snapshotConfig(input); + const admission = createNonqueueingAdmissionGateV1( + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, + ); + + const withSnapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async ( + inputRequest, + consume, + ) => { + const request = snapshotSnapshotRequest(inputRequest); + if (typeof consume !== 'function') { + throw unavailable('Current-finalized snapshot consumer must be callable'); + } + if (request.chainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Snapshot adapter is configured for chain ${config.chainId}, not ${request.chainId}`, + ); + } + if (request.signal.aborted) { + throw cancelled('Current-finalized snapshot was cancelled before transport admission'); + } + + return admission.run(request.chainId, async () => { + const totalDeadline = createDeadlineScope( + request.signal, + CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, + 'current-finalized snapshot total deadline', + ); + let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + try { + for (let index = 0; index < config.endpoints.length; index += 1) { + const attemptDeadline = createDeadlineScope( + totalDeadline.signal, + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + `current-finalized snapshot preflight ${index + 1}`, + ); + let preflight: SnapshotEndpointPreflightV1 | undefined; + try { + preflight = await preflightSnapshotEndpoint( + config, + config.endpoints[index]!, + attemptDeadline.signal, + ); + if ( + request.signal.aborted + || totalDeadline.timedOut() + || attemptDeadline.timedOut() + ) { + throw classifySnapshotAttemptFailure( + new Error('Snapshot preflight completed after its lifecycle ended'), + request.signal, + totalDeadline, + attemptDeadline, + ); + } + } catch (cause) { + const failure = classifySnapshotAttemptFailure( + cause, + request.signal, + totalDeadline, + attemptDeadline, + ); + if (isTerminalAttemptFailure(failure)) throw failure; + lastRetryableFailure = failure; + } finally { + attemptDeadline.close(); + } + if (preflight !== undefined) { + // This is deliberately outside the preflight catch: once trusted + // local consumer code begins, neither it nor its reads are replayed. + return await executePinnedSnapshotScope( + config, + config.endpoints[index]!, + preflight, + request, + consume, + totalDeadline, + ); + } + } + throw lastRetryableFailure + ?? unavailable('No configured endpoint completed finalized snapshot preflight'); + } finally { + totalDeadline.close(); + } + }, (active) => new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + `Chain ${request.chainId} already has ${active} finalized snapshot in flight`, + )); + }; + + return Object.freeze(withSnapshot); +} + +interface SnapshotEndpointPreflightV1 { + readonly anchor: FinalizedAnchorV1; + readonly lastRequestId: number; +} + +async function preflightSnapshotEndpoint( + config: StrictRpcConfigSnapshotV1, + endpoint: string, + signal: AbortSignal, +): Promise { + let requestId = 0; + const rpc = async (method: string, params: readonly unknown[]): Promise => { + requestId += 1; + return postJsonRpc( + endpoint, + requestId, + method, + params, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + signal, + ); + }; + const remoteChainId = parseChainId(await rpc('eth_chainId', Object.freeze([]))); + if (remoteChainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Configured snapshot endpoint reported chain ${remoteChainId}, expected ${config.chainId}`, + ); + } + const anchor = parseFinalizedAnchor( + await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), + 'current finalized snapshot header', + ); + return Object.freeze({ anchor, lastRequestId: requestId }); +} + +async function executePinnedSnapshotScope( + config: StrictRpcConfigSnapshotV1, + endpoint: string, + preflight: SnapshotEndpointPreflightV1, + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + consume: (session: StrictCurrentFinalizedEvmSnapshotSessionV1) => Promise, + totalDeadline: DeadlineScope, +): Promise { + let requestId = preflight.lastRequestId; + const rpc = async (method: string, params: readonly unknown[]): Promise => { + if (request.signal.aborted) throw cancelled('Current-finalized snapshot was cancelled'); + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + const rpcDeadline = createDeadlineScope( + totalDeadline.signal, + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + `current-finalized snapshot JSON-RPC ${method}`, + ); + requestId += 1; + try { + return await postJsonRpc( + endpoint, + requestId, + method, + params, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + rpcDeadline.signal, + ); + } catch (cause) { + if (request.signal.aborted) throw cancelled('Current-finalized snapshot was cancelled'); + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + if (rpcDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot JSON-RPC exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, + ); + } + if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; + throw unavailable('Current-finalized snapshot JSON-RPC failed closed', cause); + } finally { + rpcDeadline.close(); + } + }; + + const budget = createCurrentFinalizedEvmSnapshotBudgetV1(); + const deployedTargets = new Set(); + let active = true; + let inFlight: Promise | undefined; + const read = Object.freeze(async ( + inputCalls: readonly StrictCurrentFinalizedEvmReadCallV1[], + ): Promise => { + if (!active) throw unavailable('Current-finalized snapshot session is closed'); + if (inFlight !== undefined) { + throw new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + 'Current-finalized snapshot permits only one dynamic batch at a time', + ); + } + const calls = snapshotSnapshotCalls(inputCalls); + try { + budget.consume(calls); + } catch { + throw resourceLimited('Current-finalized snapshot exceeded its fixed scan budget'); + } + let operation!: Promise; + operation = executeSnapshotBatch( + config, + preflight.anchor, + calls, + deployedTargets, + rpc, + ).finally(() => { + if (inFlight === operation) inFlight = undefined; + }); + inFlight = operation; + return operation; + }); + const session = Object.freeze({ + chainId: config.chainId, + blockNumber: preflight.anchor.blockNumber, + blockHash: preflight.anchor.blockHash, + read, + } satisfies StrictCurrentFinalizedEvmSnapshotSessionV1); + + let result!: T; + let callbackFailure: unknown; + let callbackSucceeded = false; + try { + result = await consume(session); + callbackSucceeded = true; + } catch (cause) { + callbackFailure = cause; + } + active = false; + + const danglingRead = inFlight; + if (danglingRead !== undefined) { + await danglingRead.catch(() => undefined); + if (callbackSucceeded) { + callbackSucceeded = false; + callbackFailure = unavailable( + 'Current-finalized snapshot consumer settled with an unawaited read in flight', + ); + } + } + + if (!callbackSucceeded) throw callbackFailure; + if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { + await assertSnapshotAnchorStable(rpc, preflight.anchor); + } + if (request.signal.aborted) throw cancelled('Current-finalized snapshot was cancelled'); + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + return result; +} + +async function executeSnapshotBatch( + config: StrictRpcConfigSnapshotV1, + anchor: FinalizedAnchorV1, + calls: readonly StrictCurrentFinalizedEvmReadCallV1[], + deployedTargets: Set, + rpc: (method: string, params: readonly unknown[]) => Promise, +): Promise { + const executeCallsAt = async (blockReference: unknown): Promise => { + const uncheckedTargets = [...new Set(calls.map(({ to }) => to))] + .filter((to) => !deployedTargets.has(to)); + await settleParallelBatch(uncheckedTargets.map(async (to) => { + assertDeployedCode(await rpc('eth_getCode', Object.freeze([to, blockReference]))); + deployedTargets.add(to); + })); + return settleParallelBatch(calls.map(async (call) => { + const callObject = Object.freeze({ + from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + to: call.to, + data: call.data, + gas: RPC_CALL_GAS_QUANTITY, + }); + return parseContractReturn( + await rpc('eth_call', Object.freeze([callObject, blockReference])), + call.maxReturnBytes, + ); + })); + }; + + if (config.blockReferenceProfile === 'eip1898') { + return executeCallsAt(Object.freeze({ + blockHash: anchor.blockHash, + requireCanonical: true as const, + })); + } + + let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + let returnData: readonly string[] | undefined; + try { + returnData = await executeCallsAt(anchor.blockNumberQuantity); + } catch (cause) { + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && ( + cause.code === 'no-code' + || cause.code === 'revert' + || cause.code === 'malformed-return' + || isAnchorDependentResourceLimit(cause) + ) + ) { + anchorDependentFailure = cause; + } else { + throw cause; + } + } + await assertSnapshotAnchorStable(rpc, anchor); + if (anchorDependentFailure !== undefined) throw anchorDependentFailure; + if (returnData === undefined) throw unavailable('Finalized snapshot batch produced no results'); + return returnData; +} + +async function assertSnapshotAnchorStable( + rpc: (method: string, params: readonly unknown[]) => Promise, + anchor: FinalizedAnchorV1, +): Promise { + const postAnchor = parseFinalizedAnchor( + await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + 'post-snapshot numbered header', + ); + if ( + postAnchor.blockNumber !== anchor.blockNumber + || postAnchor.blockHash !== anchor.blockHash + ) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + ); + } +} + async function executeEndpointAttempt( config: StrictRpcConfigSnapshotV1, endpoint: string, @@ -703,6 +1043,27 @@ function classifyAttemptFailure( return unavailable('Current-finalized endpoint attempt failed closed', cause); } +function classifySnapshotAttemptFailure( + cause: unknown, + callerSignal: AbortSignal, + totalDeadline: DeadlineScope, + attemptDeadline: DeadlineScope, +): CurrentFinalizedEvmCallErrorV1 { + if (callerSignal.aborted) return cancelled('Current-finalized snapshot was cancelled'); + if (totalDeadline.timedOut()) { + return timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + if (attemptDeadline.timedOut()) { + return timedOut( + `Current-finalized snapshot preflight exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, + ); + } + if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; + return unavailable('Current-finalized snapshot preflight failed closed', cause); +} + function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { return error.code === 'unsupported-chain' || error.code === 'resource-limit' @@ -752,6 +1113,30 @@ function snapshotReadRequest(input: unknown): StrictCurrentFinalizedEvmReadReque } } +function snapshotSnapshotRequest(input: unknown): StrictCurrentFinalizedEvmSnapshotRequestV1 { + try { + const record = snapshotExactDataRecord(input, SNAPSHOT_REQUEST_KEYS); + assertCanonicalChainId(record.chainId, 'strict finalized-snapshot chainId'); + if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); + return Object.freeze({ + chainId: record.chainId, + signal: record.signal, + }); + } catch { + throw unavailable('Strict current-finalized snapshot request failed the fixed local profile'); + } +} + +function snapshotSnapshotCalls( + input: unknown, +): readonly StrictCurrentFinalizedEvmReadCallV1[] { + try { + return snapshotReadCalls(input); + } catch { + throw unavailable('Strict current-finalized snapshot batch failed the fixed local profile'); + } +} + function snapshotReadCalls(input: unknown): readonly StrictCurrentFinalizedEvmReadCallV1[] { const entries = snapshotDenseDataArray(input, { label: 'Current-finalized read calls', diff --git a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts new file mode 100644 index 0000000000..d59b64885e --- /dev/null +++ b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts @@ -0,0 +1,440 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + type ChainIdV1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, +} from '../src/current-finalized-evm-read-profile.js'; +import { + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_DECLARED_RETURN_BYTES_V1, + createCurrentFinalizedEvmSnapshotBudgetV1, + type StrictCurrentFinalizedEvmSnapshotSessionV1, +} from '../src/current-finalized-evm-snapshot.js'; +import { + createStrictCurrentFinalizedEvmSnapshotScopeV1, + type StrictCurrentFinalizedEvmReadCallV1, +} from '../src/strict-current-finalized-evm-rpc.js'; +import { + createLoopbackJsonRpcTestHarness, + sendJsonRpcError, + sendJsonRpcResult, + type LoopbackJsonRpcHandler, +} from './loopback-rpc-harness.js'; + +const CHAIN_ID = '20430' as ChainIdV1; +const CHAIN_QUANTITY = '0x4fce'; +const TO = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const BLOCK_HASH = `0x${'22'.repeat(32)}`; +const OTHER_BLOCK_HASH = `0x${'23'.repeat(32)}`; +const FIRST_DATA = '0x11111111'; +const SECOND_DATA = '0x22222222'; +const HASH_REFERENCE = Object.freeze({ blockHash: BLOCK_HASH, requireCanonical: true }); +const rpcHarness = createLoopbackJsonRpcTestHarness(); + +afterEach(async () => { + await rpcHarness.stopAll(); +}); + +describe('RFC-64 scoped current-finalized EVM snapshot', () => { + it('pins dynamic batches to one endpoint and EIP-1898 anchor with one code check', async () => { + const server = await rpcHarness.start(successfulHandler()); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + + const result = await withSnapshot(request(), async (session) => { + expect(Object.isFrozen(session)).toBe(true); + expect(Object.isFrozen(session.read)).toBe(true); + expect(session).toMatchObject({ + chainId: CHAIN_ID, + blockNumber: '123', + blockHash: BLOCK_HASH, + }); + return [ + ...(await session.read([call(FIRST_DATA)])), + ...(await session.read([call(SECOND_DATA)])), + ]; + }); + + expect(Object.isFrozen(withSnapshot)).toBe(true); + expect(result).toEqual(['0xaaaa', '0xbbbb']); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_call', + ]); + const anchored = server.calls.filter(({ method }) => ( + method === 'eth_getCode' || method === 'eth_call' + )); + expect(anchored.map(({ params }) => params[1])).toEqual([ + HASH_REFERENCE, + HASH_REFERENCE, + HASH_REFERENCE, + ]); + }); + + it('fails over during preflight but invokes the scoped consumer exactly once', async () => { + const first = await rpcHarness.start((_call, response) => { + response.writeHead(503, { 'content-type': 'text/plain' }); + response.end('unavailable'); + }); + const second = await rpcHarness.start(successfulHandler()); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + let consumers = 0; + + await expect(withSnapshot(request(), async (session) => { + consumers += 1; + return session.read([call(FIRST_DATA)]); + })).resolves.toEqual(['0xaaaa']); + + expect(consumers).toBe(1); + expect(first.calls.map(({ method }) => method)).toEqual(['eth_chainId']); + expect(second.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + ]); + }); + + it('never retries or replays the consumer after callback execution begins', async () => { + const baseHandler = successfulHandler(); + const first = await rpcHarness.start((rpcCall, response, rawRequest) => { + if (rpcCall.method !== 'eth_call') { + return baseHandler(rpcCall, response, rawRequest); + } + response.writeHead(503, { 'content-type': 'text/plain' }); + response.end('unavailable'); + }); + const second = await rpcHarness.start(successfulHandler()); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + let consumers = 0; + + await expect(withSnapshot(request(), async (session) => { + consumers += 1; + return session.read([call(FIRST_DATA)]); + })).rejects.toMatchObject({ code: 'rpc-unavailable' }); + + expect(consumers).toBe(1); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + ]); + expect(second.calls).toHaveLength(0); + }); + + it('rejects a numbered fallback batch when its same-endpoint hash changes', async () => { + const server = await rpcHarness.start((rpcCall, response) => { + switch (rpcCall.method) { + case 'eth_chainId': + sendJsonRpcResult(response, rpcCall, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': + sendJsonRpcResult(response, rpcCall, { + number: '0x7b', + hash: rpcCall.params[0] === 'finalized' ? BLOCK_HASH : OTHER_BLOCK_HASH, + }); + return; + case 'eth_getCode': + sendJsonRpcResult(response, rpcCall, '0x6000'); + return; + case 'eth_call': + sendJsonRpcResult(response, rpcCall, '0xaaaa'); + return; + default: + sendJsonRpcError(response, rpcCall, -32601, 'method not found'); + } + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(withSnapshot(request(), async (session) => ( + session.read([call(FIRST_DATA)]) + ))).rejects.toMatchObject({ code: 'finalized-state-unavailable' }); + expect(server.calls.find(({ method }) => method === 'eth_call')?.params[1]).toBe('0x7b'); + }); + + it('rechecks the numbered fallback anchor after the scoped consumer completes', async () => { + let numberedHeaderReads = 0; + const server = await rpcHarness.start((rpcCall, response) => { + switch (rpcCall.method) { + case 'eth_chainId': + sendJsonRpcResult(response, rpcCall, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': { + const finalizedLookup = rpcCall.params[0] === 'finalized'; + if (!finalizedLookup) numberedHeaderReads += 1; + sendJsonRpcResult(response, rpcCall, { + number: '0x7b', + hash: finalizedLookup || numberedHeaderReads === 1 + ? BLOCK_HASH + : OTHER_BLOCK_HASH, + }); + return; + } + case 'eth_getCode': + sendJsonRpcResult(response, rpcCall, '0x6000'); + return; + case 'eth_call': + sendJsonRpcResult(response, rpcCall, '0xaaaa'); + return; + default: + sendJsonRpcError(response, rpcCall, -32601, 'method not found'); + } + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(withSnapshot(request(), async (session) => { + await session.read([call(FIRST_DATA)]); + return 'must-not-escape'; + })).rejects.toMatchObject({ code: 'finalized-state-unavailable' }); + expect(numberedHeaderReads).toBe(2); + }); + + it('closes an escaped session and cannot mix it into a later adapter scope', async () => { + const first = await rpcHarness.start(successfulHandler()); + const second = await rpcHarness.start(successfulHandler()); + const firstScope = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [first.url], + }); + const secondScope = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [second.url], + }); + let escaped: StrictCurrentFinalizedEvmSnapshotSessionV1 | undefined; + await firstScope(request(), async (session) => { + escaped = session; + return session.read([call(FIRST_DATA)]); + }); + const firstCalls = first.calls.length; + + await expect(escaped!.read([call(SECOND_DATA)])) + .rejects.toMatchObject({ code: 'rpc-unavailable' }); + await secondScope(request(), async () => { + await expect(escaped!.read([call(SECOND_DATA)])) + .rejects.toMatchObject({ code: 'rpc-unavailable' }); + }); + expect(first.calls).toHaveLength(firstCalls); + expect(second.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + ]); + }); + + it('drains an unawaited batch before releasing its single snapshot permit', async () => { + const callStarted = deferred(); + const releaseCall = deferred(); + const baseHandler = successfulHandler(); + const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { + if (rpcCall.method === 'eth_call') { + callStarted.resolve(undefined); + await releaseCall.promise; + } + await baseHandler(rpcCall, response, rawRequest); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + const first = withSnapshot(request(), async (session) => { + void session.read([call(FIRST_DATA)]); + return 'must-not-escape'; + }); + await callStarted.promise; + + await expect(withSnapshot(request(), async () => 'second')) + .rejects.toMatchObject({ code: 'concurrency-saturated' }); + releaseCall.resolve(undefined); + await expect(first).rejects.toMatchObject({ + code: 'rpc-unavailable', + message: expect.stringContaining('unawaited read'), + }); + }); + + it('rejects concurrent dynamic batches and more than four calls before extra I/O', async () => { + const callStarted = deferred(); + const releaseCall = deferred(); + const baseHandler = successfulHandler(); + const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { + if (rpcCall.method === 'eth_call') { + callStarted.resolve(undefined); + await releaseCall.promise; + } + await baseHandler(rpcCall, response, rawRequest); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + + await withSnapshot(request(), async (session) => { + const first = session.read([call(FIRST_DATA)]); + await callStarted.promise; + await expect(session.read([call(SECOND_DATA)])) + .rejects.toMatchObject({ code: 'concurrency-saturated' }); + releaseCall.resolve(undefined); + await expect(first).resolves.toEqual(['0xaaaa']); + + const callsBeforeOversizedBatch = server.calls.length; + await expect(session.read(Array.from( + { length: CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 + 1 }, + () => call(FIRST_DATA), + ))).rejects.toMatchObject({ code: 'rpc-unavailable' }); + expect(server.calls).toHaveLength(callsBeforeOversizedBatch); + }); + }); + + it('cancels the actual in-flight RPC from the scope-owned signal', async () => { + const started = deferred(); + const closed = deferred(); + const baseHandler = successfulHandler(); + const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { + if (rpcCall.method !== 'eth_call') { + return baseHandler(rpcCall, response, rawRequest); + } + response.on('close', () => closed.resolve(undefined)); + started.resolve(undefined); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + const controller = new AbortController(); + const operation = withSnapshot( + { chainId: CHAIN_ID, signal: controller.signal }, + async (session) => session.read([call(FIRST_DATA)]), + ); + await started.promise; + controller.abort(new Error('caller stopped')); + + await expect(operation).rejects.toMatchObject({ code: 'rpc-unavailable' }); + await closed.promise; + }); + + it('rejects forged block selectors, hostile batches, and non-callable consumers pre-I/O', async () => { + const server = await rpcHarness.start(successfulHandler()); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + await expect(withSnapshot( + { ...request(), blockTag: 'latest' } as never, + async () => undefined, + )).rejects.toMatchObject({ code: 'rpc-unavailable' }); + await expect(withSnapshot(request(), null as never)) + .rejects.toMatchObject({ code: 'rpc-unavailable' }); + expect(server.calls).toHaveLength(0); + + await expect(withSnapshot(request(), async (session) => session.read([ + { ...call(FIRST_DATA), blockHash: OTHER_BLOCK_HASH } as never, + ]))).rejects.toMatchObject({ code: 'rpc-unavailable' }); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + ]); + }); + + it('enforces pure aggregate batch and declared-return budgets before transport', () => { + const oneCall = [call(FIRST_DATA, 1)]; + const batchBudget = createCurrentFinalizedEvmSnapshotBudgetV1(); + for (let index = 0; index < CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1; index += 1) { + batchBudget.consume(oneCall); + } + expect(batchBudget.snapshot()).toEqual({ + batches: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + calls: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + declaredReturnBytes: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + }); + expect(() => batchBudget.consume(oneCall)).toThrow(RangeError); + + const returnBudget = createCurrentFinalizedEvmSnapshotBudgetV1(); + const maximalBatch = Array.from( + { length: CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 }, + () => call(FIRST_DATA, CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1), + ); + const batchesToReturnCap = CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_DECLARED_RETURN_BYTES_V1 + / (CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 + * CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1); + for (let index = 0; index < batchesToReturnCap; index += 1) { + returnBudget.consume(maximalBatch); + } + expect(returnBudget.snapshot()).toMatchObject({ + calls: batchesToReturnCap * CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + declaredReturnBytes: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_DECLARED_RETURN_BYTES_V1, + }); + expect(() => returnBudget.consume(oneCall)).toThrow(RangeError); + expect(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1).toBe( + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 + * CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + ); + }); +}); + +function request(): { readonly chainId: ChainIdV1; readonly signal: AbortSignal } { + return { chainId: CHAIN_ID, signal: new AbortController().signal }; +} + +function call(data: string, maxReturnBytes = 2): StrictCurrentFinalizedEvmReadCallV1 { + return Object.freeze({ to: TO, data, maxReturnBytes }); +} + +function successfulHandler(): LoopbackJsonRpcHandler { + return (rpcCall, response) => { + switch (rpcCall.method) { + case 'eth_chainId': + sendJsonRpcResult(response, rpcCall, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': + sendJsonRpcResult(response, rpcCall, { number: '0x7b', hash: BLOCK_HASH }); + return; + case 'eth_getCode': + sendJsonRpcResult(response, rpcCall, '0x6000'); + return; + case 'eth_call': { + const callObject = rpcCall.params[0] as { readonly data?: unknown }; + if (callObject.data === FIRST_DATA) sendJsonRpcResult(response, rpcCall, '0xaaaa'); + else if (callObject.data === SECOND_DATA) sendJsonRpcResult(response, rpcCall, '0xbbbb'); + else sendJsonRpcError(response, rpcCall, -32602, 'unexpected calldata'); + return; + } + default: + sendJsonRpcError(response, rpcCall, -32601, 'method not found'); + } + }; +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T | PromiseLike) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} From de3891064d7970d6acdb994029658d7776e60f59 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 11:11:02 +0200 Subject: [PATCH 219/292] fix(chain): bind snapshot batch settlement deadline --- packages/chain/src/strict-current-finalized-evm-rpc.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index df749b0072..47868d35be 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -482,6 +482,7 @@ async function executePinnedSnapshotScope( calls, deployedTargets, rpc, + totalDeadline, ).finally(() => { if (inFlight === operation) inFlight = undefined; }); @@ -536,6 +537,7 @@ async function executeSnapshotBatch( calls: readonly StrictCurrentFinalizedEvmReadCallV1[], deployedTargets: Set, rpc: (method: string, params: readonly unknown[]) => Promise, + totalDeadline: DeadlineScope, ): Promise { const executeCallsAt = async (blockReference: unknown): Promise => { const uncheckedTargets = [...new Set(calls.map(({ to }) => to))] @@ -543,7 +545,7 @@ async function executeSnapshotBatch( await settleParallelBatch(uncheckedTargets.map(async (to) => { assertDeployedCode(await rpc('eth_getCode', Object.freeze([to, blockReference]))); deployedTargets.add(to); - })); + }), totalDeadline); return settleParallelBatch(calls.map(async (call) => { const callObject = Object.freeze({ from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, @@ -555,7 +557,7 @@ async function executeSnapshotBatch( await rpc('eth_call', Object.freeze([callObject, blockReference])), call.maxReturnBytes, ); - })); + }), totalDeadline); }; if (config.blockReferenceProfile === 'eip1898') { From a2e79c5419fc85fe65771c7e7ee0d481e4fbd9ba Mon Sep 17 00:00:00 2001 From: Zvonimir Sculac <119160564+zsculac@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:35:11 +0200 Subject: [PATCH 220/292] fix(published-ka-sync): seek directly to public snapshot pages (#1873) * fix(publisher): store snapshot page indexes in sqlite * test(publisher): cover snapshot index wiring and utf8 offsets * test(agent): cover snapshot store injection * test(cli): cover publisher snapshot store injection --------- Co-authored-by: Zvonimir --- packages/agent/src/dkg-agent-types.ts | 3 + packages/agent/src/dkg-agent.ts | 3 +- ...dkg-agent-snapshot-store-injection.test.ts | 57 ++++ packages/agent/vitest.unit.config.ts | 1 + packages/cli/src/daemon/lifecycle.ts | 11 +- .../src/daemon/snapshot-page-index-store.ts | 79 +++++ packages/cli/src/publisher-runner.ts | 13 +- .../daemon-snapshot-page-index-wiring.test.ts | 233 +++++++++++++ ...r-runtime-snapshot-store-injection.test.ts | 97 ++++++ .../test/snapshot-page-index-store.test.ts | 131 +++++++ packages/cli/vitest.unit.config.ts | 5 + packages/node-ui/src/db.ts | 20 +- packages/node-ui/test/db.test.ts | 40 +-- .../node-ui/test/messenger-stores.test.ts | 2 +- packages/publisher/src/index.ts | 2 + .../publisher/src/workspace-snapshot-store.ts | 280 ++++++++++++++- .../test/workspace-snapshot-store.test.ts | 321 +++++++++++++++++- 17 files changed, 1250 insertions(+), 48 deletions(-) create mode 100644 packages/agent/test/dkg-agent-snapshot-store-injection.test.ts create mode 100644 packages/cli/src/daemon/snapshot-page-index-store.ts create mode 100644 packages/cli/test/daemon-snapshot-page-index-wiring.test.ts create mode 100644 packages/cli/test/publisher-runtime-snapshot-store-injection.test.ts create mode 100644 packages/cli/test/snapshot-page-index-store.test.ts diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index 6b621f2082..8bf63454f2 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -35,6 +35,7 @@ import type { PhaseCallback, SharedMemoryPublicSnapshotStorageConfig, StorageAckTiming, + WorkspacePublicSnapshotStore, CursorPersistence as ChainEventCursorPersistence, } from '@origintrail-official/dkg-publisher'; import type { ApprovalPolicy, ChainAdapter, ContextGraphRegistryScanCursorStore } from '@origintrail-official/dkg-chain'; @@ -1117,6 +1118,8 @@ export interface DKGAgentConfig { largeLiteralStorage?: LargeLiteralStorageConfig; /** Out-of-Oxigraph immutable public SWM operation snapshots. Defaults on when dataDir is set. */ sharedMemoryPublicSnapshotStorage?: SharedMemoryPublicSnapshotStorageConfig; + /** Optional caller-owned snapshot store, used by the daemon to inject durable page indexing. */ + publicSnapshotStore?: WorkspacePublicSnapshotStore; /** * Max automatic-retry budget stamped onto async VM-publish jobs admitted * through this agent's `publishAsync` (EPCIS / Kafka plugin paths). Mirrors diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index cf228a8335..b610e7e0ef 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -814,7 +814,8 @@ export class DKGAgent extends DKGAgentBase { const node = new DKGNode(nodeConfig); const workspaceOwnedEntities = new Map>(); const writeLocks = new Map>(); - const publicSnapshotStore = createPublicSnapshotStore(config.dataDir, config.sharedMemoryPublicSnapshotStorage); + const publicSnapshotStore = config.publicSnapshotStore + ?? createPublicSnapshotStore(config.dataDir, config.sharedMemoryPublicSnapshotStorage); const legacyAdapterOperationalKey = opKeys?.[0]; const legacyAdapterOperationalAddress = privateKeyAddress(legacyAdapterOperationalKey); const configuredPublisherAddress = normalizeAdapterPublisherAddress(config.publisherAddress); diff --git a/packages/agent/test/dkg-agent-snapshot-store-injection.test.ts b/packages/agent/test/dkg-agent-snapshot-store-injection.test.ts new file mode 100644 index 0000000000..c77292bbea --- /dev/null +++ b/packages/agent/test/dkg-agent-snapshot-store-injection.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { WorkspacePublicSnapshotStore } from '@origintrail-official/dkg-publisher'; +import type { Quad } from '@origintrail-official/dkg-storage'; +import { DKGAgent } from '../src/index.js'; + +describe('DKGAgent public snapshot store injection', () => { + let agent: DKGAgent | undefined; + let dataDir: string | undefined; + + afterEach(async () => { + if (agent) { + await agent.stop().catch(() => {}); + await agent.store.close().catch(() => {}); + } + if (dataDir) { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('uses the injected store for workspace public snapshots', async () => { + const publicQuads: Quad[] = [{ + subject: 'urn:snapshot-store-injection:entity', + predicate: 'http://schema.org/name', + object: '"Injected snapshot store"', + graph: '', + }]; + let persistedSnapshot: { digest: string; quads: readonly Quad[] } | undefined; + const publicSnapshotStore: WorkspacePublicSnapshotStore = { + async putSnapshot(input) { + persistedSnapshot = input; + return { ref: input.digest, byteLength: 0 }; + }, + async getSnapshot() { + return null; + }, + }; + dataDir = await mkdtemp(join(tmpdir(), 'dkg-agent-snapshot-store-injection-')); + agent = await DKGAgent.create({ + name: 'SnapshotStoreInjectionBot', + dataDir, + listenHost: '127.0.0.1', + publicSnapshotStore, + }); + + await agent.publisher.writeToWorkspace('snapshot-store-injection', publicQuads, { + publisherPeerId: 'snapshot-store-injection-peer', + }); + + expect(persistedSnapshot).toEqual({ + digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + quads: publicQuads, + }); + }); +}); diff --git a/packages/agent/vitest.unit.config.ts b/packages/agent/vitest.unit.config.ts index ae68dfc21d..90026c4481 100644 --- a/packages/agent/vitest.unit.config.ts +++ b/packages/agent/vitest.unit.config.ts @@ -72,6 +72,7 @@ export default defineConfig({ "test/rootless-durable-skips-legacy-partition.test.ts", "test/rootless-lifecycle-graph.test.ts", "test/swm-recovery.test.ts", + "test/dkg-agent-snapshot-store-injection.test.ts", "test/swm-snapshot-sync.test.ts", "test/sync-responder-protection.test.ts", "test/sync-on-connect-retry.test.ts", diff --git a/packages/cli/src/daemon/lifecycle.ts b/packages/cli/src/daemon/lifecycle.ts index 2935373700..b893dd0273 100644 --- a/packages/cli/src/daemon/lifecycle.ts +++ b/packages/cli/src/daemon/lifecycle.ts @@ -152,6 +152,7 @@ import { import { createDaemonLogSink } from './log-sink.js'; import { startRpcUsageTelemetry } from './rpc-usage-log.js'; import { startDashboardLogVolumePruner } from './dashboard-log-volume-pruner.js'; +import { SqliteSnapshotPageIndexStore } from './snapshot-page-index-store.js'; import { createInitialPublisherState, createPublicSnapshotStore, createPublisherControlFromStore, startPublisherRuntimeWithOutcome, type PublisherState } from '../publisher-runner.js'; import { backfillVmPublishIntentIndexOnBoot } from './vm-publish-intent-backfill.js'; import { createCatchupRunner, type CatchupJobResult, type CatchupRunner } from '../catchup-runner.js'; @@ -1607,6 +1608,12 @@ export async function runDaemonInner( : undefined; const dashDb = new DashboardDB({ dataDir: dkgDir() }); + const snapshotPageIndexStore = new SqliteSnapshotPageIndexStore(dashDb); + const publicSnapshotStore = createPublicSnapshotStore( + dkgDir(), + { sharedMemoryPublicSnapshotStorage: runtimeSnapshotStorage }, + snapshotPageIndexStore, + ); const chainCursorScope = chainBase?.type === 'mock' ? (chainBase.chainId ?? 'mock:31337') : chainBase?.hubAddress @@ -1743,6 +1750,7 @@ export async function runDaemonInner( } : undefined, largeLiteralStorage: runtimeLargeLiteralStorage, sharedMemoryPublicSnapshotStorage: runtimeSnapshotStorage, + publicSnapshotStore, syncSharedMemoryOnConnect: config.syncSharedMemoryOnConnect, syncReconcilerEnabled: config.syncReconcilerEnabled, syncOnConnectEnabled: config.syncOnConnectEnabled, @@ -1976,7 +1984,7 @@ export async function runDaemonInner( let shuttingDown = false; const publisherControl = createPublisherControlFromStore(agent.store, { - publicSnapshotStore: createPublicSnapshotStore(dkgDir(), config), + publicSnapshotStore, // #1836 — the daemon admission instance MUST carry the operator's retry // budget; without it every API-admitted VM-publish job was stamped with the // built-in default (10) even when publisher.maxRetries was configured (incl. 0). @@ -2266,6 +2274,7 @@ export async function runDaemonInner( }), publishEncryptionFactory: (publishOptions) => resolveDaemonPublishEncryption(agent, publishOptions), knowledgeAssetVmPublishHandler: createKnowledgeAssetVmPublishHandler(agent), + publicSnapshotStore, log, }); publisherState = outcome; diff --git a/packages/cli/src/daemon/snapshot-page-index-store.ts b/packages/cli/src/daemon/snapshot-page-index-store.ts new file mode 100644 index 0000000000..bdf1e704d3 --- /dev/null +++ b/packages/cli/src/daemon/snapshot-page-index-store.ts @@ -0,0 +1,79 @@ +import type { + SnapshotPageIndexRecord, + SnapshotPageIndexStore, +} from '@origintrail-official/dkg-publisher'; +import type { DashboardDB } from '@origintrail-official/dkg-node-ui'; + +interface SnapshotPageIndexRow { + snapshot_digest: string; + format_version: number; + stride: number; + snapshot_file_size: number; + modification_fingerprint: string; + offset_count: number; + offsets: Buffer; + checksum: string; +} + +export class SqliteSnapshotPageIndexStore implements SnapshotPageIndexStore { + constructor(private readonly dashboard: DashboardDB) {} + + async get(snapshotDigest: string): Promise { + const row = this.dashboard.db.prepare(` + SELECT + snapshot_digest, + format_version, + stride, + snapshot_file_size, + modification_fingerprint, + offset_count, + offsets, + checksum + FROM snapshot_page_indexes + WHERE snapshot_digest = ? + `).get(snapshotDigest) as SnapshotPageIndexRow | undefined; + if (!row) return null; + return { + snapshotDigest: row.snapshot_digest, + formatVersion: row.format_version, + stride: row.stride, + snapshotFileSize: row.snapshot_file_size, + modificationFingerprint: row.modification_fingerprint, + offsetCount: row.offset_count, + offsetsBlob: new Uint8Array(row.offsets), + checksum: row.checksum, + }; + } + + async upsert(record: SnapshotPageIndexRecord): Promise { + this.dashboard.db.prepare(` + INSERT INTO snapshot_page_indexes ( + snapshot_digest, + format_version, + stride, + snapshot_file_size, + modification_fingerprint, + offset_count, + offsets, + checksum + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(snapshot_digest) DO UPDATE SET + format_version = excluded.format_version, + stride = excluded.stride, + snapshot_file_size = excluded.snapshot_file_size, + modification_fingerprint = excluded.modification_fingerprint, + offset_count = excluded.offset_count, + offsets = excluded.offsets, + checksum = excluded.checksum + `).run( + record.snapshotDigest, + record.formatVersion, + record.stride, + record.snapshotFileSize, + record.modificationFingerprint, + record.offsetCount, + Buffer.from(record.offsetsBlob), + record.checksum, + ); + } +} diff --git a/packages/cli/src/publisher-runner.ts b/packages/cli/src/publisher-runner.ts index de3f6a9072..c5c10dc479 100644 --- a/packages/cli/src/publisher-runner.ts +++ b/packages/cli/src/publisher-runner.ts @@ -36,6 +36,7 @@ import { type LiftJobIncluded, type PublishOptions, type V10ACKProviderParams, + type SnapshotPageIndexStore, type WorkspacePublicSnapshotStore, } from '@origintrail-official/dkg-publisher'; import { createTripleStore, type TripleStore } from '@origintrail-official/dkg-storage'; @@ -181,6 +182,7 @@ export async function startPublisherRuntimeIfEnabled(args: { ackTransportFactory?: ACKTransportFactory; publishEncryptionFactory?: PublishEncryptionFactory; knowledgeAssetVmPublishHandler?: AsyncLiftPublisherConfig['knowledgeAssetVmPublishHandler']; + publicSnapshotStore?: WorkspacePublicSnapshotStore; }): Promise { if (!args.config.publisher?.enabled) { return null; @@ -199,6 +201,7 @@ export async function startPublisherRuntimeIfEnabled(args: { ackTransportFactory: args.ackTransportFactory, publishEncryptionFactory: args.publishEncryptionFactory, knowledgeAssetVmPublishHandler: args.knowledgeAssetVmPublishHandler, + publicSnapshotStore: args.publicSnapshotStore, }); await runtime.runner.start(); logPublisherWalletAttribution(runtime.wallets, args.log); @@ -399,6 +402,7 @@ export async function createPublisherRuntimeFromAgent(args: { v10ACKProviderFactory?: () => PublishOptions['v10ACKProvider']; publishEncryptionFactory?: PublishEncryptionFactory; knowledgeAssetVmPublishHandler?: AsyncLiftPublisherConfig['knowledgeAssetVmPublishHandler']; + publicSnapshotStore?: WorkspacePublicSnapshotStore; }): Promise { return createPublisherRuntimeFromBase({ dataDir: args.dataDir, @@ -412,7 +416,8 @@ export async function createPublisherRuntimeFromAgent(args: { v10ACKProviderFactory: args.v10ACKProviderFactory, publishEncryptionFactory: args.publishEncryptionFactory, knowledgeAssetVmPublishHandler: args.knowledgeAssetVmPublishHandler, - publicSnapshotStore: createPublicSnapshotStore(args.dataDir, args.config), + publicSnapshotStore: args.publicSnapshotStore + ?? createPublicSnapshotStore(args.dataDir, args.config), closeStoreOnStop: false, // #1829 — this is the daemon publisher runtime (processes named-KA jobs), so it // journals. Standalone `dkg publisher run` (createPublisherRuntime) does not set this. @@ -892,12 +897,16 @@ function defaultLargeLiteralStorage(dataDir: string, config: DkgConfig) { export function createPublicSnapshotStore( dataDir: string, config?: Pick, + pageIndexStore?: SnapshotPageIndexStore, ): WorkspacePublicSnapshotStore | undefined { const snapshotConfig = config?.sharedMemoryPublicSnapshotStorage; if (snapshotConfig?.enabled === false) { return undefined; } - return new FileWorkspacePublicSnapshotStore(snapshotConfig?.directory ?? join(dataDir, 'swm-public-snapshots')); + return new FileWorkspacePublicSnapshotStore( + snapshotConfig?.directory ?? join(dataDir, 'swm-public-snapshots'), + pageIndexStore, + ); } function isLocalOxigraphStoreConfig(storeConfig: { backend?: unknown }): boolean { diff --git a/packages/cli/test/daemon-snapshot-page-index-wiring.test.ts b/packages/cli/test/daemon-snapshot-page-index-wiring.test.ts new file mode 100644 index 0000000000..e6e8ce9be8 --- /dev/null +++ b/packages/cli/test/daemon-snapshot-page-index-wiring.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const mocks = vi.hoisted(() => ({ + agentCreate: vi.fn(), + backfillOnBoot: vi.fn(), + chainResetWipe: vi.fn(), + createPublicSnapshotStore: vi.fn(), + createPublisherControlFromStore: vi.fn(), + createServer: vi.fn(), + loadNetworkConfig: vi.fn(), + loadOpWallets: vi.fn(), + startPublisherRuntimeWithOutcome: vi.fn(), +})); + +vi.mock('node:http', () => ({ createServer: mocks.createServer })); + +vi.mock('@origintrail-official/dkg-agent', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + DKGAgent: { create: mocks.agentCreate }, + loadOpWallets: mocks.loadOpWallets, + KaNumberAllocator: class KaNumberAllocator {}, + }; +}); + +vi.mock('../src/config.js', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, loadNetworkConfig: mocks.loadNetworkConfig }; +}); + +vi.mock('../src/daemon/chain-reset-wipe.js', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, chainResetWipe: mocks.chainResetWipe }; +}); + +vi.mock('../src/daemon/vm-publish-intent-backfill.js', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, backfillVmPublishIntentIndexOnBoot: mocks.backfillOnBoot }; +}); + +vi.mock('../src/publisher-runner.js', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createPublicSnapshotStore: mocks.createPublicSnapshotStore, + createPublisherControlFromStore: mocks.createPublisherControlFromStore, + startPublisherRuntimeWithOutcome: mocks.startPublisherRuntimeWithOutcome, + }; +}); + +const { runDaemonInner } = await import('../src/daemon/lifecycle.js'); +const { SqliteSnapshotPageIndexStore } = await import('../src/daemon/snapshot-page-index-store.js'); + +function createFakeServer() { + const server = { + listen: vi.fn((_port: number, _host: string, callback?: () => void) => { + callback?.(); + return server; + }), + address: vi.fn(() => ({ port: 43123 })), + close: vi.fn((callback?: () => void) => { + callback?.(); + return server; + }), + on: vi.fn(() => server), + once: vi.fn(() => server), + }; + return server; +} + +function createFakeAgent() { + return { + peerId: 'self-peer', + multiaddrs: [], + wallet: { keypair: { publicKey: new Uint8Array([1]), secretKey: new Uint8Array([2]) } }, + store: {}, + node: { libp2p: { getMultiaddrs: vi.fn(() => []) } }, + eventBus: { on: vi.fn() }, + assertion: { create: vi.fn(), write: vi.fn() }, + setChatAcl: vi.fn(), + setSkillAcl: vi.fn(), + onChat: vi.fn(), + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + publishProfile: vi.fn(async () => undefined), + ensureProfilePublished: vi.fn(async () => undefined), + publishRelayRegistry: vi.fn(async () => undefined), + ensureContextGraphLocal: vi.fn(async () => undefined), + getSubscribedContextGraphs: vi.fn(() => new Map()), + subscribeToContextGraph: vi.fn(), + pingPeers: vi.fn(async () => undefined), + listLocalAgents: vi.fn(() => []), + registerImportedArtifactByteStore: vi.fn(), + getDefaultAgentAddress: vi.fn(() => undefined), + query: vi.fn(async () => ({ type: 'bindings', bindings: [] })), + createContextGraph: vi.fn(), + listContextGraphs: vi.fn(async () => []), + createACKTransportFactory: vi.fn(() => ({})), + drainRpcUsage: vi.fn(() => ({ calls: 0, errors: 0, throttledMs: 0, byEndpoint: {} })), + }; +} + +function closeDashboardDbFromAgentCreateArg(createArg: any): void { + const db = + createArg?.chainEventCursorStore?.cursors?.db + ?? createArg?.contextGraphRegistryScanCursorStore?.cursors?.db; + db?.close?.(); +} + +describe('runDaemonInner public snapshot page-index wiring', () => { + let tempHome: string | undefined; + let originalDkgHome: string | undefined; + let uncaughtExceptionListeners: NodeJS.UncaughtExceptionListener[] = []; + let unhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] = []; + let sigintListeners: NodeJS.SignalsListener[] = []; + let sigtermListeners: NodeJS.SignalsListener[] = []; + + beforeEach(async () => { + tempHome = await mkdtemp(join(tmpdir(), 'dkg-snapshot-index-wiring-')); + originalDkgHome = process.env.DKG_HOME; + process.env.DKG_HOME = tempHome; + uncaughtExceptionListeners = process.listeners('uncaughtException') as NodeJS.UncaughtExceptionListener[]; + unhandledRejectionListeners = process.listeners('unhandledRejection') as NodeJS.UnhandledRejectionListener[]; + sigintListeners = process.listeners('SIGINT') as NodeJS.SignalsListener[]; + sigtermListeners = process.listeners('SIGTERM') as NodeJS.SignalsListener[]; + + mocks.createServer.mockImplementation(createFakeServer); + mocks.agentCreate.mockResolvedValue(createFakeAgent()); + mocks.backfillOnBoot.mockResolvedValue(undefined); + mocks.createPublisherControlFromStore.mockReturnValue({ __brand: 'publisher-control' }); + mocks.startPublisherRuntimeWithOutcome.mockResolvedValue({ + runtime: null, + availability: { + available: false, + reason: 'no_publisher_wallets', + retryable: false, + operatorActionRequired: true, + }, + }); + mocks.loadNetworkConfig.mockResolvedValue({ + networkName: 'DKG V10 Gnosis Mainnet', + genesisId: 'gnosis-mainnet', + genesisVersion: 1, + relays: [], + defaultNodeRole: 'core', + }); + mocks.loadOpWallets.mockResolvedValue({ adminWallet: undefined, wallets: [] }); + mocks.chainResetWipe.mockResolvedValue({ + wiped: false, + skipped: false, + prevMarker: null, + removedFiles: [], + backedUpFiles: [], + failedFiles: [], + }); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { + throw new Error(`process.exit:${code}`); + }) as never); + }); + + afterEach(async () => { + vi.clearAllTimers(); + vi.useRealTimers(); + process.removeAllListeners('uncaughtException'); + for (const listener of uncaughtExceptionListeners) process.on('uncaughtException', listener); + process.removeAllListeners('unhandledRejection'); + for (const listener of unhandledRejectionListeners) process.on('unhandledRejection', listener); + process.removeAllListeners('SIGINT'); + for (const listener of sigintListeners) process.on('SIGINT', listener); + process.removeAllListeners('SIGTERM'); + for (const listener of sigtermListeners) process.on('SIGTERM', listener); + const createArg = mocks.agentCreate.mock.calls[0]?.[0]; + closeDashboardDbFromAgentCreateArg(createArg); + vi.restoreAllMocks(); + vi.clearAllMocks(); + if (originalDkgHome === undefined) delete process.env.DKG_HOME; + else process.env.DKG_HOME = originalDkgHome; + if (tempHome) await rm(tempHome, { recursive: true, force: true }); + tempHome = undefined; + }); + + it('injects one SQLite-indexed snapshot store into every daemon snapshot path', async () => { + vi.useFakeTimers(); + const publicSnapshotStore = { + putSnapshot: vi.fn(), + getSnapshot: vi.fn(), + getSnapshotPage: vi.fn(), + }; + mocks.createPublicSnapshotStore.mockReturnValue(publicSnapshotStore); + + await runDaemonInner(true, { + name: 'snapshot-index-wiring-test', + networkConfig: 'mainnet-gnosis', + listenPort: 0, + apiPort: 0, + bootstrapPeers: ['/ip4/178.104.54.178/tcp/9090/p2p/12D3KooWSmU3owJvB9sFw8uApDgKrv2VBMecsGGvgAc4Gq6hB57M'], + nodeRole: 'edge', + auth: { enabled: false }, + promoteQueue: { enabled: false }, + source: 'monorepo', + publisher: { enabled: true }, + chain: { + type: 'evm', + rpcUrl: 'https://private-rpc.example', + hubAddress: '0x1234567890123456789012345678901234567890', + chainId: 'evm:100', + }, + } as any, Date.now()); + + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.createPublicSnapshotStore).toHaveBeenCalledTimes(1); + const [, , pageIndexStore] = mocks.createPublicSnapshotStore.mock.calls[0] as unknown[]; + expect(pageIndexStore).toBeInstanceOf(SqliteSnapshotPageIndexStore); + + const agentCreateArg = mocks.agentCreate.mock.calls[0]?.[0] as any; + expect(agentCreateArg.publicSnapshotStore).toBe(publicSnapshotStore); + + const [, publisherControlOptions] = mocks.createPublisherControlFromStore.mock.calls[0] as [ + unknown, + { publicSnapshotStore?: unknown }, + ]; + expect(publisherControlOptions.publicSnapshotStore).toBe(publicSnapshotStore); + + const publisherRuntimeArg = mocks.startPublisherRuntimeWithOutcome.mock.calls[0]?.[0] as any; + expect(publisherRuntimeArg.publicSnapshotStore).toBe(publicSnapshotStore); + }); +}); diff --git a/packages/cli/test/publisher-runtime-snapshot-store-injection.test.ts b/packages/cli/test/publisher-runtime-snapshot-store-injection.test.ts new file mode 100644 index 0000000000..23f9746952 --- /dev/null +++ b/packages/cli/test/publisher-runtime-snapshot-store-injection.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; +import { NoChainAdapter } from '@origintrail-official/dkg-chain'; +import { + contextGraphWorkspaceGraphUri, + generateEd25519Keypair, + TypedEventBus, +} from '@origintrail-official/dkg-core'; +import { + DKGPublisher, + type WorkspacePublicSnapshotStore, +} from '@origintrail-official/dkg-publisher'; +import { createTripleStore, type Quad, type TripleStore } from '@origintrail-official/dkg-storage'; +import { seedLegacyRawLiftTestJob } from '../../publisher/test/_helpers/legacy-raw-lift.js'; +import { addPublisherWallet } from '../src/publisher-wallets.js'; +import { + createPublisherRuntimeFromAgent, + type PublisherRuntime, +} from '../src/publisher-runner.js'; + +const CONTEXT_GRAPH = 'publisher-runtime-snapshot-store-injection'; +const ENTITY = 'urn:publisher-runtime:snapshot-store-injection:entity'; + +describe('publisher runtime public snapshot store injection', () => { + let dataDir: string | undefined; + let store: TripleStore | undefined; + let runtime: PublisherRuntime | undefined; + + afterEach(async () => { + await runtime?.stop().catch(() => {}); + await store?.close().catch(() => {}); + if (dataDir) { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it('prepares queued public quads from the injected snapshot store', async () => { + dataDir = await mkdtemp(join(tmpdir(), 'dkg-publisher-runtime-snapshot-store-')); + store = await createTripleStore({ backend: 'oxigraph' }); + const wallet = ethers.Wallet.createRandom(); + const keypair = await generateEd25519Keypair(); + const snapshots = new Map(); + const publicSnapshotStore: WorkspacePublicSnapshotStore = { + async putSnapshot({ digest, quads }) { + snapshots.set(digest, quads.map((quad) => ({ ...quad }))); + return { ref: digest, byteLength: 0 }; + }, + async getSnapshot(ref) { + return snapshots.get(ref)?.map((quad) => ({ ...quad })) ?? null; + }, + }; + const publicQuads: Quad[] = [{ + subject: ENTITY, + predicate: 'http://schema.org/name', + object: '"Injected runtime snapshot store"', + graph: '', + }]; + + await addPublisherWallet(dataDir, wallet.privateKey); + const workspaceWriter = new DKGPublisher({ + store, + chain: new NoChainAdapter(), + eventBus: new TypedEventBus(), + keypair, + publisherPrivateKey: wallet.privateKey, + publicSnapshotStore, + }); + const write = await workspaceWriter.writeToWorkspace(CONTEXT_GRAPH, publicQuads, { + publisherPeerId: 'publisher-runtime-snapshot-store-peer', + }); + await store.dropGraph(contextGraphWorkspaceGraphUri(CONTEXT_GRAPH)); + + runtime = await createPublisherRuntimeFromAgent({ + dataDir, + store, + keypair, + publicSnapshotStore, + }); + const jobId = await seedLegacyRawLiftTestJob(store, { + swmId: 'swm-main', + shareOperationId: write.shareOperationId, + roots: [ENTITY], + contextGraphId: CONTEXT_GRAPH, + namespace: 'aloha', + scope: 'person-profile', + transitionType: 'CREATE', + authority: { type: 'owner', proofRef: 'proof:owner:runtime-snapshot-store' }, + }); + + const payload = await runtime.publisher.inspectPreparedPayload(jobId); + + expect(payload?.publishOptions.quads).toEqual(publicQuads); + }); +}); diff --git a/packages/cli/test/snapshot-page-index-store.test.ts b/packages/cli/test/snapshot-page-index-store.test.ts new file mode 100644 index 0000000000..b99dd92613 --- /dev/null +++ b/packages/cli/test/snapshot-page-index-store.test.ts @@ -0,0 +1,131 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DashboardDB } from '@origintrail-official/dkg-node-ui'; +import type { SnapshotPageIndexRecord } from '@origintrail-official/dkg-publisher'; +import { SqliteSnapshotPageIndexStore } from '../src/daemon/snapshot-page-index-store.js'; +import { createPublicSnapshotStore } from '../src/publisher-runner.js'; + +const DIGEST = `sha256:${'c'.repeat(64)}`; + +describe('SqliteSnapshotPageIndexStore', () => { + let directory: string | undefined; + let dashboard: DashboardDB | undefined; + + afterEach(async () => { + dashboard?.close(); + if (directory) await rm(directory, { recursive: true, force: true }); + dashboard = undefined; + directory = undefined; + }); + + it('upserts one binary row per digest and preserves it across database reopen', async () => { + directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-index-db-')); + dashboard = new DashboardDB({ dataDir: directory }); + const store = new SqliteSnapshotPageIndexStore(dashboard); + const first = makeRecord(new Uint8Array(16), 'first-checksum'); + const replacement = makeRecord( + Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 44]), + 'replacement-checksum', + ); + + await store.upsert(first); + await store.upsert(replacement); + expect(await store.get(DIGEST)).toEqual(replacement); + expect(await store.get(`sha256:${'d'.repeat(64)}`)).toBeNull(); + + dashboard.close(); + dashboard = new DashboardDB({ dataDir: directory }); + const reopenedStore = new SqliteSnapshotPageIndexStore(dashboard); + expect(await reopenedStore.get(DIGEST)).toEqual(replacement); + }); + + it('persists a new snapshot index and serves pages after SQLite and store reopen', async () => { + directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-index-db-')); + dashboard = new DashboardDB({ dataDir: directory }); + const pageIndexes = new SqliteSnapshotPageIndexStore(dashboard); + const snapshots = createPublicSnapshotStore(directory, undefined, pageIndexes)!; + const quads = Array.from({ length: 300 }, (_, index) => ({ + subject: `urn:snapshot:sqlite:reopen:${index}`, + predicate: 'http://schema.org/value', + object: `"${index}"`, + graph: '', + })); + + await snapshots.putSnapshot({ + digest: DIGEST, + quads, + }); + + expect(await pageIndexes.get(DIGEST)).toMatchObject({ + snapshotDigest: DIGEST, + formatVersion: 1, + stride: 128, + offsetCount: 3, + }); + + dashboard.close(); + dashboard = new DashboardDB({ dataDir: directory }); + const reopenedSnapshots = createPublicSnapshotStore( + directory, + undefined, + new SqliteSnapshotPageIndexStore(dashboard), + )!; + await expect(reopenedSnapshots.getSnapshotPage!(DIGEST, 257, 20)) + .resolves.toEqual(quads.slice(257, 277)); + }); + + it('migrates an existing version-30 node database before adapter use', async () => { + directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-index-db-')); + dashboard = new DashboardDB({ dataDir: directory }); + dashboard.db.exec('DROP TABLE snapshot_page_indexes;'); + dashboard.db.pragma('user_version = 30'); + dashboard.close(); + + dashboard = new DashboardDB({ dataDir: directory }); + const pageIndexes = new SqliteSnapshotPageIndexStore(dashboard); + const record = makeRecord(new Uint8Array(16), 'migrated-checksum'); + await pageIndexes.upsert(record); + + expect(await pageIndexes.get(DIGEST)).toEqual(record); + expect(dashboard.db.pragma('user_version', { simple: true })).toBe(31); + }); + + it('falls back to the snapshot when the real SQLite connection cannot read or write', async () => { + directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-index-db-')); + dashboard = new DashboardDB({ dataDir: directory }); + const pageIndexes = new SqliteSnapshotPageIndexStore(dashboard); + const quads = Array.from({ length: 260 }, (_, index) => ({ + subject: `urn:snapshot:sqlite-failure:${index}`, + predicate: 'http://schema.org/value', + object: `"${index}"`, + graph: '', + })); + await createPublicSnapshotStore(directory, undefined, pageIndexes)! + .putSnapshot({ digest: DIGEST, quads }); + dashboard.close(); + dashboard = undefined; + + const fallbackStore = createPublicSnapshotStore(directory, undefined, pageIndexes)!; + await expect(fallbackStore.getSnapshotPage!(DIGEST, 257, 20)) + .resolves.toEqual(quads.slice(257)); + await expect(fallbackStore.putSnapshot({ + digest: `sha256:${'d'.repeat(64)}`, + quads, + })).resolves.toMatchObject({ ref: `sha256:${'d'.repeat(64)}` }); + }); +}); + +function makeRecord(offsetsBlob: Uint8Array, checksum: string): SnapshotPageIndexRecord { + return { + snapshotDigest: DIGEST, + formatVersion: 1, + stride: 128, + snapshotFileSize: 300, + modificationFingerprint: 'mtimeMs:1;ctimeMs:2', + offsetCount: 2, + offsetsBlob, + checksum, + }; +} diff --git a/packages/cli/vitest.unit.config.ts b/packages/cli/vitest.unit.config.ts index 1290348b20..107c51da96 100644 --- a/packages/cli/vitest.unit.config.ts +++ b/packages/cli/vitest.unit.config.ts @@ -94,6 +94,7 @@ export default defineConfig({ 'test/store-identity-tag.test.ts', 'test/publisher-runner-lu11.test.ts', 'test/publisher-runner-ack-transport.test.ts', + 'test/publisher-runtime-snapshot-store-injection.test.ts', 'test/publisher-ka-recovery.test.ts', // #1836 — publisher.maxRetries must propagate through // createPublisherControlFromStore (incl. a literal 0). Pure logic. @@ -104,8 +105,12 @@ export default defineConfig({ // #1828 — daemon-boot wiring seam (runDaemonInner invokes the VM-publish // intent-index backfill with the admission publisher control). 'test/publisher-backfill-wiring-1828.test.ts', + // Public snapshot paging — one SQLite-indexed store must reach the + // agent sync responder, admission publisher, and background runtime. + 'test/daemon-snapshot-page-index-wiring.test.ts', // SQLite-backed vector store. Pure local DB coverage; no hardhat. 'test/vector-store-extra.test.ts', + 'test/snapshot-page-index-store.test.ts', // Release 2 — managed local Oxigraph server (opt-in). Pure logic // + injected fetch/spawn/fs; no network, no real binary. 'test/oxigraph-binary.test.ts', diff --git a/packages/node-ui/src/db.ts b/packages/node-ui/src/db.ts index ccaf36ef71..38645d147f 100644 --- a/packages/node-ui/src/db.ts +++ b/packages/node-ui/src/db.ts @@ -17,7 +17,7 @@ export { SqliteContextGraphRegistryScanCursorStore, } from './chain-cursor-stores.js'; -const SCHEMA_VERSION = 30; +const SCHEMA_VERSION = 31; // Default operator retention. Lowered from 90 → 14 days on V15 (2026-05) after // a production incident in which the `logs` table + its FTS5 shadow tables // grew to ~9 GB on a 12-day-old node and corrupted the SQLite page (header @@ -1193,6 +1193,24 @@ export class DashboardDB { // restart can resume that same list instead of discarding durable work. ensureSyncCheckpointResponderSessionColumns(); } + if (version < 31) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS snapshot_page_indexes ( + snapshot_digest TEXT PRIMARY KEY, + format_version INTEGER NOT NULL CHECK (format_version > 0), + stride INTEGER NOT NULL CHECK (stride > 0), + snapshot_file_size INTEGER NOT NULL CHECK (snapshot_file_size >= 0), + modification_fingerprint TEXT NOT NULL, + offset_count INTEGER NOT NULL CHECK (offset_count > 0), + offsets BLOB NOT NULL, + checksum TEXT NOT NULL, + CHECK (length(snapshot_digest) > 0), + CHECK (length(modification_fingerprint) > 0), + CHECK (length(offsets) = offset_count * 8), + CHECK (length(checksum) > 0) + ); + `); + } this.db.pragma(`user_version = ${SCHEMA_VERSION}`); if (upgradedExistingDb && !this.explicitRetentionDays) { this.retentionDays = LEGACY_IMPLICIT_RETENTION_DAYS; diff --git a/packages/node-ui/test/db.test.ts b/packages/node-ui/test/db.test.ts index 1ccbe5e159..30b73e20d1 100644 --- a/packages/node-ui/test/db.test.ts +++ b/packages/node-ui/test/db.test.ts @@ -86,7 +86,7 @@ describe('DashboardDB — metric snapshots', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const cols = (db.db.prepare('PRAGMA table_info(metric_snapshots)').all() as Array<{ name: string }>) .map((c) => c.name); @@ -142,7 +142,7 @@ describe('DashboardDB — metric snapshots', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const newSnapshotCols = (db.db.prepare('PRAGMA table_info(metric_snapshots)').all() as { name: string }[]) .map(c => c.name); @@ -632,7 +632,7 @@ describe('DashboardDB — V15 migration: drop FTS5 logs index', () => { const upgraded = new DashboardDB({ dataDir: upgradeDir }); try { - expect(upgraded.db.pragma('user_version', { simple: true })).toBe(30); + expect(upgraded.db.pragma('user_version', { simple: true })).toBe(31); const ftsTables = upgraded.db.prepare( `SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name LIKE 'logs_fts%'`, @@ -700,7 +700,7 @@ describe('DashboardDB — V27 join-approval ledger migration', () => { db.close(); const raw = new Database(dbPath); - expect(raw.pragma('user_version', { simple: true })).toBe(30); + expect(raw.pragma('user_version', { simple: true })).toBe(31); raw.exec('DROP TRIGGER IF EXISTS cap_cg_join_policy_audit_rows;'); raw.close(); @@ -724,7 +724,7 @@ describe('DashboardDB — V27 join-approval ledger migration', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const columns = db.db.pragma( 'table_info(context_graph_join_approval_ledger)', ) as Array<{ name: string }>; @@ -745,7 +745,7 @@ describe('DashboardDB — V27 join-approval ledger migration', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); expect(db.db.prepare(` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'context_graph_join_policy_audit' @@ -827,7 +827,7 @@ describe('DashboardDB — V27 join-approval ledger migration', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const trigger = db.db.prepare(` SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'cap_cg_join_policy_audit_rows' @@ -1232,7 +1232,7 @@ describe('DashboardDB — V17 subscription columns migration (Phase B)', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const cols = (db.db.prepare('PRAGMA table_info(context_graph_subscriptions)').all() as Array<{ name: string }>) .map((c) => c.name); @@ -1253,7 +1253,7 @@ describe('DashboardDB — V17 subscription columns migration (Phase B)', () => { .map((c) => c.name); expect(cols).toContain('on_chain_hash'); expect(cols).toContain('last_reconciled_ordinal'); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); }); }); @@ -1337,7 +1337,7 @@ describe('DashboardDB — V19 core_hosted column migration (Phase D)', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const cols = (db.db.prepare('PRAGMA table_info(context_graph_subscriptions)').all() as Array<{ name: string }>) .map((c) => c.name); @@ -1366,7 +1366,7 @@ describe('DashboardDB — V20 ka_numbers table migration (B2 KA-number allocator }); it('fresh install lands at the current schema and already carries the ka_numbers table', () => { - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const table = db.db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='ka_numbers'", @@ -1403,7 +1403,7 @@ describe('DashboardDB — V20 ka_numbers table migration (B2 KA-number allocator raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const table = db.db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='ka_numbers'", @@ -1496,7 +1496,7 @@ describe('DashboardDB — V21 sync_checkpoints table (A3 sync resume)', () => { }); it('fresh install carries the sync_checkpoints table and expiry index', () => { - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const tables = db.db.prepare( `SELECT name FROM sqlite_master WHERE type='table' AND name='sync_checkpoints'`, ).all(); @@ -1607,7 +1607,7 @@ describe('DashboardDB — V21 sync_checkpoints table (A3 sync resume)', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); expect(db.db.prepare( `SELECT name FROM sqlite_master WHERE type='table' AND name='sync_checkpoints'`, ).all()).toHaveLength(1); @@ -1636,7 +1636,7 @@ describe('DashboardDB — V21 sync_checkpoints table (A3 sync resume)', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const columns = new Set( (db.db.prepare('PRAGMA table_info(sync_checkpoints)').all() as Array<{ name: string }>) .map((column) => column.name), @@ -2085,7 +2085,7 @@ describe('DashboardDB — V11→V13 chat schema migration chain', () => { raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const cols = (db.db.prepare('PRAGMA table_info(chat_messages)').all() as Array<{ name: string }>) .map((c) => c.name); @@ -2151,7 +2151,7 @@ describe('DashboardDB — V16 notifications.context_graph_id migration (A1)', () raw.close(); db = new DashboardDB({ dataDir: dir }); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); const cols = (db.db.prepare('PRAGMA table_info(notifications)').all() as Array<{ name: string }>) .map((c) => c.name); @@ -2180,7 +2180,7 @@ describe('DashboardDB — V16 notifications.context_graph_id migration (A1)', () const cols = (db.db.prepare('PRAGMA table_info(notifications)').all() as Array<{ name: string }>) .map((c) => c.name); expect(cols).toContain('context_graph_id'); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); }); it('insertNotification writes context_graph_id to the column; omitted → NULL', () => { @@ -2423,7 +2423,7 @@ describe('DashboardDB — replication telemetry (Phase F)', () => { raw.pragma('user_version = 17'); raw.close(); const upgraded = new DashboardDB({ dataDir: dir }); - expect(upgraded.db.pragma('user_version', { simple: true })).toBe(30); + expect(upgraded.db.pragma('user_version', { simple: true })).toBe(31); // insert works → table exists upgraded.insertReplicationEvent({ ts: now, context_graph_id: 'cg', action: 'promote' }); expect(upgraded.getReplicationSummary(60_000).promotes).toBe(1); @@ -2478,6 +2478,6 @@ describe('SqliteChangelogEraGuard — OT-RFC-59 §6 P0 durable era guard', () => .map((t) => t.name); expect(tables).toContain('changelog_cursors'); expect(tables).toContain('changelog_era'); - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); }); }); diff --git a/packages/node-ui/test/messenger-stores.test.ts b/packages/node-ui/test/messenger-stores.test.ts index 836fbe122a..fec9bcba64 100644 --- a/packages/node-ui/test/messenger-stores.test.ts +++ b/packages/node-ui/test/messenger-stores.test.ts @@ -46,7 +46,7 @@ describe('V12 migration', () => { // DB layer in `db.test.ts`; this assertion just pins that // the substrate store fixtures are created against the // Current SCHEMA_VERSION includes the durable VM-reconcile negative cache. - expect(db.db.pragma('user_version', { simple: true })).toBe(30); + expect(db.db.pragma('user_version', { simple: true })).toBe(31); }); }); diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index 591714f8d1..32a0141935 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -342,6 +342,8 @@ export { serializeWorkspacePublicSnapshotQuads, workspacePublicQuadsDigest, type SharedMemoryPublicSnapshotStorageConfig, + type SnapshotPageIndexRecord, + type SnapshotPageIndexStore, type WorkspacePublicSnapshotStore, } from './workspace-snapshot-store.js'; export { UpdateHandler } from './update-handler.js'; diff --git a/packages/publisher/src/workspace-snapshot-store.ts b/packages/publisher/src/workspace-snapshot-store.ts index 20f08fcdc8..bf2296d0ad 100644 --- a/packages/publisher/src/workspace-snapshot-store.ts +++ b/packages/publisher/src/workspace-snapshot-store.ts @@ -1,4 +1,4 @@ -import { mkdir, open, readFile, rename, writeFile } from 'node:fs/promises'; +import { mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { dirname, join } from 'node:path'; import { createInterface } from 'node:readline'; @@ -28,8 +28,48 @@ export interface WorkspacePublicSnapshotStore { ): Promise; } +export interface SnapshotPageIndexRecord { + readonly snapshotDigest: string; + readonly formatVersion: number; + readonly stride: number; + readonly snapshotFileSize: number; + readonly modificationFingerprint: string; + readonly offsetCount: number; + readonly offsetsBlob: Uint8Array; + readonly checksum: string; +} + +export interface SnapshotPageIndexStore { + get(snapshotDigest: string): Promise; + upsert(record: SnapshotPageIndexRecord): Promise; +} + +const SNAPSHOT_PAGE_INDEX_VERSION = 1; +const SNAPSHOT_PAGE_INDEX_STRIDE = 128; +const SNAPSHOT_PAGE_INDEX_CACHE_MAX = 64; + +interface SnapshotPageIndexCore { + version: typeof SNAPSHOT_PAGE_INDEX_VERSION; + stride: number; + offsets: number[]; + fileBytes: number; + mtimeMs: number; + ctimeMs: number; +} + +interface SnapshotFileFingerprint { + size: number; + mtimeMs: number; + ctimeMs: number; +} + export class FileWorkspacePublicSnapshotStore implements WorkspacePublicSnapshotStore { - constructor(private readonly directory: string) {} + private readonly pageIndexCache = new Map>(); + + constructor( + private readonly directory: string, + private readonly pageIndexStore?: SnapshotPageIndexStore, + ) {} async putSnapshot(input: { readonly digest: string; @@ -37,7 +77,7 @@ export class FileWorkspacePublicSnapshotStore implements WorkspacePublicSnapshot }): Promise<{ readonly ref: string; readonly byteLength: number }> { const hash = snapshotHash(input.digest); const filePath = snapshotPath(this.directory, hash, 'nq'); - const payload = serializeWorkspacePublicSnapshotQuads(input.quads); + const { payload, offsets, fileBytes } = serializeWorkspacePublicSnapshotWithIndex(input.quads); await mkdir(dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; @@ -47,9 +87,21 @@ export class FileWorkspacePublicSnapshotStore implements WorkspacePublicSnapshot throw err; }); + const fingerprint = await stat(filePath); + const index: SnapshotPageIndexCore = { + version: SNAPSHOT_PAGE_INDEX_VERSION, + stride: SNAPSHOT_PAGE_INDEX_STRIDE, + offsets, + fileBytes, + mtimeMs: fingerprint.mtimeMs, + ctimeMs: fingerprint.ctimeMs, + }; + await this.persistSnapshotPageIndexBestEffort(input.digest, index); + this.rememberPageIndex(input.digest, Promise.resolve(index)); + return { ref: input.digest, - byteLength: Buffer.byteLength(payload, 'utf8'), + byteLength: fileBytes, }; } @@ -87,14 +139,30 @@ export class FileWorkspacePublicSnapshotStore implements WorkspacePublicSnapshot return legacy?.slice(safeOffset, safeOffset + safeLimit) ?? null; } + let startRow = 0; + let startByte = 0; + try { + const index = await this.getPageIndex(ref, nquadsPath); + const checkpoint = Math.min( + Math.floor(safeOffset / index.stride), + Math.max(0, index.offsets.length - 1), + ); + startRow = checkpoint * index.stride; + startByte = index.offsets[checkpoint] ?? 0; + } catch { + // Page indexes are derived data. The already-open snapshot remains the + // source of truth, so index failures fall back to scanning from row zero. + } + const input = file.createReadStream({ encoding: 'utf8', autoClose: false, + start: startByte, signal: options?.signal, }); const lines = createInterface({ input, crlfDelay: Infinity }); const page: Quad[] = []; - let row = 0; + let row = startRow; try { for await (const rawLine of lines) { const line = rawLine.trim(); @@ -113,6 +181,75 @@ export class FileWorkspacePublicSnapshotStore implements WorkspacePublicSnapshot } } + private getPageIndex(ref: string, nquadsPath: string): Promise { + const existing = this.pageIndexCache.get(ref); + if (existing) { + this.rememberPageIndex(ref, existing); + return existing; + } + const load = this.loadOrBuildPageIndex(ref, nquadsPath).catch((error) => { + if (this.pageIndexCache.get(ref) === load) this.pageIndexCache.delete(ref); + throw error; + }); + this.rememberPageIndex(ref, load); + return load; + } + + private async loadOrBuildPageIndex( + ref: string, + nquadsPath: string, + ): Promise { + const fingerprint = await stat(nquadsPath); + if (this.pageIndexStore) { + try { + const record = await this.pageIndexStore.get(canonicalSnapshotDigest(ref)); + const index = decodeSnapshotPageIndexRecord(record, fingerprint, ref); + if (index) return index; + } catch { + // The index is derived data; a failed SQLite read must not block paging. + } + } + + const index = await buildSnapshotPageIndex(nquadsPath); + await this.persistSnapshotPageIndexBestEffort(ref, index); + return index; + } + + private rememberPageIndex(ref: string, index: Promise): void { + this.pageIndexCache.delete(ref); + this.pageIndexCache.set(ref, index); + while (this.pageIndexCache.size > SNAPSHOT_PAGE_INDEX_CACHE_MAX) { + const oldest = this.pageIndexCache.keys().next().value as string | undefined; + if (oldest === undefined) break; + this.pageIndexCache.delete(oldest); + } + } + + private async persistSnapshotPageIndexBestEffort( + ref: string, + index: SnapshotPageIndexCore, + ): Promise { + if (!this.pageIndexStore) return; + try { + const offsetsBlob = encodeSnapshotPageIndexOffsets(index.offsets); + const recordCore = { + snapshotDigest: canonicalSnapshotDigest(ref), + formatVersion: index.version, + stride: index.stride, + snapshotFileSize: index.fileBytes, + modificationFingerprint: snapshotModificationFingerprint(index), + offsetCount: index.offsets.length, + offsetsBlob, + }; + await this.pageIndexStore.upsert({ + ...recordCore, + checksum: snapshotPageIndexRecordChecksum(recordCore), + }); + } catch { + // The index is derived data; snapshot writes and reads remain usable. + } + } + private async getLegacyJsonSnapshot(ref: string, hash: string): Promise { const jsonPath = snapshotPath(this.directory, hash, 'json'); let raw: string; @@ -148,13 +285,142 @@ function snapshotHash(ref: string): string { return hash.toLowerCase(); } +function canonicalSnapshotDigest(ref: string): string { + return `sha256:${snapshotHash(ref)}`; +} + function snapshotPath(directory: string, hash: string, extension: 'json' | 'nq'): string { return join(directory, hash.slice(0, 2), hash.slice(2, 4), `${hash}.${extension}`); } +function serializeWorkspacePublicSnapshotWithIndex(quads: readonly Quad[]): { + payload: string; + offsets: number[]; + fileBytes: number; +} { + if (quads.length === 0) return { payload: '', offsets: [0], fileBytes: 0 }; + const lines = quads.map(quadToNQuad); + const offsets = [0]; + let fileBytes = 0; + for (let row = 0; row < lines.length; row += 1) { + fileBytes += Buffer.byteLength(lines[row]!, 'utf8') + 1; + if (isSnapshotPageIndexCheckpoint(row + 1)) offsets.push(fileBytes); + } + return { payload: `${lines.join('\n')}\n`, offsets, fileBytes }; +} + +async function buildSnapshotPageIndex(nquadsPath: string): Promise { + const file = await open(nquadsPath, 'r'); + const offsets = [0]; + const buffer = Buffer.allocUnsafe(64 * 1024); + let absoluteOffset = 0; + let rows = 0; + try { + while (true) { + const { bytesRead } = await file.read(buffer, 0, buffer.length, absoluteOffset); + if (bytesRead === 0) break; + for (let index = 0; index < bytesRead; index += 1) { + if (buffer[index] !== 0x0a) continue; + rows += 1; + if (isSnapshotPageIndexCheckpoint(rows)) { + offsets.push(absoluteOffset + index + 1); + } + } + absoluteOffset += bytesRead; + } + } finally { + await file.close(); + } + const fingerprint = await stat(nquadsPath); + return { + version: SNAPSHOT_PAGE_INDEX_VERSION, + stride: SNAPSHOT_PAGE_INDEX_STRIDE, + offsets, + fileBytes: fingerprint.size, + mtimeMs: fingerprint.mtimeMs, + ctimeMs: fingerprint.ctimeMs, + }; +} + +function isSnapshotPageIndexCheckpoint(row: number): boolean { + return row > 0 && row % SNAPSHOT_PAGE_INDEX_STRIDE === 0; +} + +function snapshotModificationFingerprint( + index: Pick, +): string { + return `${index.mtimeMs}:${index.ctimeMs}`; +} + +function encodeSnapshotPageIndexOffsets(offsets: readonly number[]): Uint8Array { + const blob = Buffer.allocUnsafe(offsets.length * 8); + offsets.forEach((offset, position) => { + blob.writeBigUInt64BE(BigInt(offset), position * 8); + }); + return blob; +} + +function snapshotPageIndexRecordChecksum( + record: Omit, +): string { + return createHash('sha256') + .update(JSON.stringify([ + record.snapshotDigest, + record.formatVersion, + record.stride, + record.snapshotFileSize, + record.modificationFingerprint, + record.offsetCount, + ])) + .update(record.offsetsBlob) + .digest('hex'); +} + +function decodeSnapshotPageIndexRecord( + value: SnapshotPageIndexRecord | null, + fingerprint: SnapshotFileFingerprint, + ref: string, +): SnapshotPageIndexCore | null { + if (!value || typeof value !== 'object') return null; + if ( + value.snapshotDigest !== canonicalSnapshotDigest(ref) + || value.formatVersion !== SNAPSHOT_PAGE_INDEX_VERSION + || value.stride !== SNAPSHOT_PAGE_INDEX_STRIDE + || value.snapshotFileSize !== fingerprint.size + || value.modificationFingerprint !== snapshotModificationFingerprint(fingerprint) + || !Number.isSafeInteger(value.offsetCount) + || value.offsetCount <= 0 + || !(value.offsetsBlob instanceof Uint8Array) + || value.offsetsBlob.byteLength !== value.offsetCount * 8 + || typeof value.checksum !== 'string' + || value.checksum !== snapshotPageIndexRecordChecksum(value) + ) return null; + + const blob = Buffer.from(value.offsetsBlob); + const offsets: number[] = []; + let previous = -1; + for (let position = 0; position < value.offsetCount; position += 1) { + const offsetBigInt = blob.readBigUInt64BE(position * 8); + if (offsetBigInt > BigInt(Number.MAX_SAFE_INTEGER)) return null; + const offset = Number(offsetBigInt); + if (offset < previous || offset > fingerprint.size) return null; + offsets.push(offset); + previous = offset; + } + if (offsets[0] !== 0) return null; + + return { + version: SNAPSHOT_PAGE_INDEX_VERSION, + stride: SNAPSHOT_PAGE_INDEX_STRIDE, + offsets, + fileBytes: fingerprint.size, + mtimeMs: fingerprint.mtimeMs, + ctimeMs: fingerprint.ctimeMs, + }; +} + export function serializeWorkspacePublicSnapshotQuads(quads: readonly Quad[]): string { - if (quads.length === 0) return ''; - return `${quads.map(quadToNQuad).join('\n')}\n`; + return serializeWorkspacePublicSnapshotWithIndex(quads).payload; } export function workspacePublicQuadsDigest(quads: readonly Quad[]): string { diff --git a/packages/publisher/test/workspace-snapshot-store.test.ts b/packages/publisher/test/workspace-snapshot-store.test.ts index 08881351f8..efde1fe0cb 100644 --- a/packages/publisher/test/workspace-snapshot-store.test.ts +++ b/packages/publisher/test/workspace-snapshot-store.test.ts @@ -1,33 +1,324 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readdir, rename, rm, stat, utimes } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { FileWorkspacePublicSnapshotStore } from '../src/workspace-snapshot-store.js'; +import { + FileWorkspacePublicSnapshotStore, + type SnapshotPageIndexRecord, + type SnapshotPageIndexStore, +} from '../src/workspace-snapshot-store.js'; const DIGEST = `sha256:${'b'.repeat(64)}`; +class MemoryPageIndexStore implements SnapshotPageIndexStore { + readonly records = new Map(); + reads = 0; + writes = 0; + + async get(snapshotDigest: string): Promise { + this.reads += 1; + return this.records.get(snapshotDigest) ?? null; + } + + async upsert(record: SnapshotPageIndexRecord): Promise { + this.writes += 1; + this.records.set(record.snapshotDigest, record); + } +} + +function makeQuads(count: number, label = 'entity') { + return Array.from({ length: count }, (_, index) => ({ + subject: `urn:snapshot:${label}:${index.toString().padStart(3, '0')}`, + predicate: 'http://schema.org/value', + object: `"${index}"`, + graph: '', + })); +} + +function digestFor(index: number): string { + return `sha256:${index.toString(16).padStart(64, '0')}`; +} + +function snapshotDirectory(directory: string, digest = DIGEST): string { + const hash = digest.slice('sha256:'.length); + return join(directory, hash.slice(0, 2), hash.slice(2, 4)); +} + +function snapshotPath(directory: string, digest = DIGEST): string { + const hash = digest.slice('sha256:'.length); + return join(snapshotDirectory(directory, digest), `${hash}.nq`); +} + +function decodeOffsets(blob: Uint8Array): number[] { + const buffer = Buffer.from(blob); + return Array.from({ length: blob.byteLength / 8 }, (_, index) => + Number(buffer.readBigUInt64BE(index * 8))); +} + describe('FileWorkspacePublicSnapshotStore paging', () => { - it('reads only the requested N-Quads page and returns an empty EOF page', async () => { - const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); - const store = new FileWorkspacePublicSnapshotStore(directory); - const quads = Array.from({ length: 205 }, (_, index) => ({ - subject: `urn:snapshot:entity:${index.toString().padStart(3, '0')}`, - predicate: 'http://schema.org/value', - object: `"${index}"`, - graph: '', + it('persists one binary page index for a new snapshot without creating an idx file', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const store = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + const quads = makeQuads(205, 'new'); + + try { + await store.putSnapshot({ digest: DIGEST, quads }); + + const snapshot = await stat(snapshotPath(directory)); + const record = pageIndexes.records.get(DIGEST); + expect(record).toMatchObject({ + snapshotDigest: DIGEST, + formatVersion: 1, + stride: 128, + snapshotFileSize: snapshot.size, + offsetCount: 2, + }); + expect(record?.modificationFingerprint).toBeTruthy(); + expect(record?.offsetsBlob).toHaveLength(16); + expect(record?.checksum).toMatch(/^[a-f0-9]{64}$/); + expect(await readdir(snapshotDirectory(directory))).toEqual([`${'b'.repeat(64)}.nq`]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('loads and decodes a persisted page index once after the snapshot store is reopened', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const quads = makeQuads(300, 'reopen'); + + try { + await new FileWorkspacePublicSnapshotStore(directory, pageIndexes) + .putSnapshot({ digest: DIGEST, quads }); + + const reopenedStore = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + await expect(reopenedStore.getSnapshotPage(DIGEST, 257, 20)) + .resolves.toEqual(quads.slice(257, 277)); + await expect(reopenedStore.getSnapshotPage(DIGEST, 280, 20)) + .resolves.toEqual(quads.slice(280)); + expect(pageIndexes.reads).toBe(1); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('seeks persisted page indexes by UTF-8 bytes across a sparse checkpoint', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const quads = makeQuads(300, 'utf8').map((quad, index) => ({ + ...quad, + object: `"café 🚀 ${index}"`, })); + try { + await new FileWorkspacePublicSnapshotStore(directory, pageIndexes) + .putSnapshot({ digest: DIGEST, quads }); + + const reopenedStore = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + await expect(reopenedStore.getSnapshotPage(DIGEST, 128, 1)) + .resolves.toEqual(quads.slice(128, 129)); + await expect(reopenedStore.getSnapshotPage(DIGEST, 257, 12)) + .resolves.toEqual(quads.slice(257, 269)); + expect(pageIndexes.reads).toBe(1); + expect(pageIndexes.writes).toBe(1); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('lazily builds and persists a missing page-index row on the first page request', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const quads = makeQuads(300, 'legacy'); + + try { + await new FileWorkspacePublicSnapshotStore(directory) + .putSnapshot({ digest: DIGEST, quads }); + + const reopenedStore = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + await expect(reopenedStore.getSnapshotPage(DIGEST, 257, 20)) + .resolves.toEqual(quads.slice(257, 277)); + expect(pageIndexes.writes).toBe(1); + expect(pageIndexes.records.get(DIGEST)?.offsetsBlob.byteLength).toBeGreaterThan(0); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('rebuilds a page index whose snapshot modification fingerprint is stale', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const quads = makeQuads(300, 'stale'); + + try { + await new FileWorkspacePublicSnapshotStore(directory, pageIndexes) + .putSnapshot({ digest: DIGEST, quads }); + const originalFingerprint = pageIndexes.records.get(DIGEST)?.modificationFingerprint; + const changedTime = new Date(Date.now() + 10_000); + await utimes(snapshotPath(directory), changedTime, changedTime); + + const reopenedStore = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + await expect(reopenedStore.getSnapshotPage(DIGEST, 257, 20)) + .resolves.toEqual(quads.slice(257, 277)); + expect(pageIndexes.writes).toBe(2); + expect(pageIndexes.records.get(DIGEST)?.modificationFingerprint) + .not.toBe(originalFingerprint); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it.each([ + { + name: 'malformed offsets BLOB', + alter: (record: SnapshotPageIndexRecord): SnapshotPageIndexRecord => ({ + ...record, + offsetsBlob: new Uint8Array([1, 2, 3]), + }), + }, + { + name: 'altered offsets BLOB', + alter: (record: SnapshotPageIndexRecord): SnapshotPageIndexRecord => { + const offsetsBlob = Uint8Array.from(record.offsetsBlob); + offsetsBlob[offsetsBlob.length - 1] ^= 1; + return { ...record, offsetsBlob }; + }, + }, + { + name: 'invalid stride', + alter: (record: SnapshotPageIndexRecord): SnapshotPageIndexRecord => ({ + ...record, + stride: 1, + }), + }, + ])('rebuilds $name and still serves the requested page', async ({ alter }) => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const quads = makeQuads(300, 'corrupt'); + + try { + await new FileWorkspacePublicSnapshotStore(directory, pageIndexes) + .putSnapshot({ digest: DIGEST, quads }); + pageIndexes.records.set(DIGEST, alter(pageIndexes.records.get(DIGEST)!)); + + const reopenedStore = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + await expect(reopenedStore.getSnapshotPage(DIGEST, 257, 20)) + .resolves.toEqual(quads.slice(257, 277)); + expect(pageIndexes.writes).toBe(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('serves valid snapshots when page-index reads and writes fail', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const quads = makeQuads(300, 'sqlite-failure'); + const failingPageIndexes: SnapshotPageIndexStore = { + get: async () => { throw new Error('database is locked'); }, + upsert: async () => { throw new Error('attempt to write a readonly database'); }, + }; + + try { + const writer = new FileWorkspacePublicSnapshotStore(directory, failingPageIndexes); + await expect(writer.putSnapshot({ digest: DIGEST, quads })) + .resolves.toMatchObject({ ref: DIGEST }); + + const reopenedStore = new FileWorkspacePublicSnapshotStore(directory, failingPageIndexes); + await expect(reopenedStore.getSnapshotPage(DIGEST, 257, 20)) + .resolves.toEqual(quads.slice(257, 277)); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('serves an already-open snapshot when rebuilding its page index fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const quads = makeQuads(3, 'rebuild-failure'); + const nquadsPath = snapshotPath(directory); + const movedPath = `${nquadsPath}.moved`; + const disruptivePageIndexes: SnapshotPageIndexStore = { + get: async () => { + await rename(nquadsPath, movedPath); + return null; + }, + upsert: async () => {}, + }; + + try { + await new FileWorkspacePublicSnapshotStore(directory) + .putSnapshot({ digest: DIGEST, quads }); + + const reopenedStore = new FileWorkspacePublicSnapshotStore( + directory, + disruptivePageIndexes, + ); + await expect(reopenedStore.getSnapshotPage(DIGEST, 1, 1)) + .resolves.toEqual(quads.slice(1, 2)); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('stores the exact-stride EOF checkpoint and seeks an offset-128 page to EOF', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const store = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + const quads = makeQuads(128, 'boundary'); + try { await store.putSnapshot({ digest: DIGEST, quads }); - await expect(store.getSnapshotPage(DIGEST, 64, 64)) - .resolves.toEqual(quads.slice(64, 128)); - await expect(store.getSnapshotPage(DIGEST, 192, 64)) - .resolves.toEqual(quads.slice(192)); - await expect(store.getSnapshotPage(DIGEST, quads.length, 64)) + const snapshot = await stat(snapshotPath(directory)); + const record = pageIndexes.records.get(DIGEST)!; + expect(decodeOffsets(record.offsetsBlob)).toEqual([0, snapshot.size]); + await expect(store.getSnapshotPage(DIGEST, 128, 1)).resolves.toEqual([]); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('reads normal late pages from their nearest sparse checkpoint', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const store = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + const quads = makeQuads(400, 'late'); + + try { + await store.putSnapshot({ digest: DIGEST, quads }); + await expect(store.getSnapshotPage(DIGEST, 255, 25)) + .resolves.toEqual(quads.slice(255, 280)); + await expect(store.getSnapshotPage(DIGEST, quads.length, 25)) .resolves.toEqual([]); } finally { await rm(directory, { recursive: true, force: true }); } }); + + it('bounds the decoded in-memory page-index cache to 64 snapshots', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dkg-snapshot-page-')); + const pageIndexes = new MemoryPageIndexStore(); + const quads = makeQuads(129, 'cache'); + + try { + const writer = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + for (let index = 1; index <= 65; index += 1) { + await writer.putSnapshot({ digest: digestFor(index), quads }); + } + + const reopenedStore = new FileWorkspacePublicSnapshotStore(directory, pageIndexes); + for (let index = 1; index <= 65; index += 1) { + await expect(reopenedStore.getSnapshotPage(digestFor(index), 128, 1)) + .resolves.toEqual(quads.slice(128)); + } + expect(pageIndexes.reads).toBe(65); + + await reopenedStore.getSnapshotPage(digestFor(65), 128, 1); + expect(pageIndexes.reads).toBe(65); + await reopenedStore.getSnapshotPage(digestFor(1), 128, 1); + expect(pageIndexes.reads).toBe(66); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); From 1ba7ced00bc0a95ac8c8a8db6f26b7673c2d7d8d Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 11:57:16 +0200 Subject: [PATCH 221/292] fix(chain): authenticate snapshot batch state --- ...ct-current-finalized-evm-batch-executor.ts | 60 +++ .../src/strict-current-finalized-evm-rpc.ts | 408 ++-------------- ...rict-current-finalized-evm-snapshot-rpc.ts | 452 ++++++++++++++++++ ...urrent-finalized-evm-snapshot.unit.test.ts | 119 +++++ 4 files changed, 659 insertions(+), 380 deletions(-) create mode 100644 packages/chain/src/strict-current-finalized-evm-batch-executor.ts create mode 100644 packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts diff --git a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts new file mode 100644 index 0000000000..54a3986217 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts @@ -0,0 +1,60 @@ +import type { EvmAddressV1 } from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, +} from './current-finalized-evm-read-profile.js'; +import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; + +const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; + +export interface StrictFinalizedEvmBatchExecutorInputV1 { + readonly calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; + readonly blockReference: unknown; + readonly deployedTargets?: ReadonlySet; + readonly rpc: (method: string, params: readonly unknown[]) => Promise; + readonly settle: (operations: readonly Promise[]) => Promise; + readonly assertDeployedCode: (input: unknown) => void; + readonly parseContractReturn: (input: unknown, maxBytes: number) => string; +} + +export interface StrictFinalizedEvmBatchExecutorResultV1 { + readonly returnData: readonly string[]; + /** Targets proven in this batch but not yet committed to caller-owned state. */ + readonly verifiedTargets: readonly EvmAddressV1[]; +} + +/** + * Execute the code-check and eth_call phases shared by one-shot and snapshot + * reads. The helper never mutates caller state: snapshot callers commit the + * returned deployment evidence only after their anchor proof succeeds. + */ +export async function executeStrictFinalizedEvmBatchV1( + input: StrictFinalizedEvmBatchExecutorInputV1, +): Promise> { + const uncheckedTargets = [...new Set(input.calls.map(({ to }) => to))] + .filter((to) => !input.deployedTargets?.has(to)); + const verifiedTargets = await input.settle(uncheckedTargets.map(async (to) => { + input.assertDeployedCode(await input.rpc( + 'eth_getCode', + Object.freeze([to, input.blockReference]), + )); + return to; + })); + const returnData = await input.settle(input.calls.map(async (call) => { + const callObject = Object.freeze({ + from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + to: call.to, + data: call.data, + gas: RPC_CALL_GAS_QUANTITY, + }); + return input.parseContractReturn( + await input.rpc('eth_call', Object.freeze([callObject, input.blockReference])), + call.maxReturnBytes, + ); + })); + return Object.freeze({ + returnData, + verifiedTargets: Object.freeze([...verifiedTargets]), + }); +} diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 47868d35be..1740cbae06 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -8,8 +8,6 @@ import { import { CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, @@ -29,12 +27,8 @@ import { type StrictCurrentFinalizedEvmReadV1, } from './current-finalized-evm-read-model.js'; import { - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, - CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, - createCurrentFinalizedEvmSnapshotBudgetV1, type StrictCurrentFinalizedEvmSnapshotRequestV1, type StrictCurrentFinalizedEvmSnapshotScopeV1, - type StrictCurrentFinalizedEvmSnapshotSessionV1, } from './current-finalized-evm-snapshot.js'; import { assertCanonicalNonzeroEvmAddress, @@ -43,6 +37,8 @@ import { snapshotExactDataRecord, } from './strict-local-data.js'; import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; +import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; +import { createStrictFinalizedSnapshotRpcRuntimeV1 } from './strict-current-finalized-evm-snapshot-rpc.js'; export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ 'eip1898', @@ -103,7 +99,6 @@ const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; const MAX_U64 = 18_446_744_073_709_551_615n; const MAX_U256 = 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; -const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); const PRE_DEADLINE_TERMINAL_FAILURES_V1 = new WeakSet(); @@ -275,340 +270,23 @@ export function createStrictCurrentFinalizedEvmSnapshotScopeV1( input: StrictCurrentFinalizedEvmRpcConfigV1, ): StrictCurrentFinalizedEvmSnapshotScopeV1 { const config = snapshotConfig(input); - const admission = createNonqueueingAdmissionGateV1( - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, - ); - - const withSnapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async ( - inputRequest, - consume, - ) => { - const request = snapshotSnapshotRequest(inputRequest); - if (typeof consume !== 'function') { - throw unavailable('Current-finalized snapshot consumer must be callable'); - } - if (request.chainId !== config.chainId) { - throw new CurrentFinalizedEvmCallErrorV1( - 'chain-mismatch', - `Snapshot adapter is configured for chain ${config.chainId}, not ${request.chainId}`, - ); - } - if (request.signal.aborted) { - throw cancelled('Current-finalized snapshot was cancelled before transport admission'); - } - - return admission.run(request.chainId, async () => { - const totalDeadline = createDeadlineScope( - request.signal, - CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, - 'current-finalized snapshot total deadline', - ); - let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - try { - for (let index = 0; index < config.endpoints.length; index += 1) { - const attemptDeadline = createDeadlineScope( - totalDeadline.signal, - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - `current-finalized snapshot preflight ${index + 1}`, - ); - let preflight: SnapshotEndpointPreflightV1 | undefined; - try { - preflight = await preflightSnapshotEndpoint( - config, - config.endpoints[index]!, - attemptDeadline.signal, - ); - if ( - request.signal.aborted - || totalDeadline.timedOut() - || attemptDeadline.timedOut() - ) { - throw classifySnapshotAttemptFailure( - new Error('Snapshot preflight completed after its lifecycle ended'), - request.signal, - totalDeadline, - attemptDeadline, - ); - } - } catch (cause) { - const failure = classifySnapshotAttemptFailure( - cause, - request.signal, - totalDeadline, - attemptDeadline, - ); - if (isTerminalAttemptFailure(failure)) throw failure; - lastRetryableFailure = failure; - } finally { - attemptDeadline.close(); - } - if (preflight !== undefined) { - // This is deliberately outside the preflight catch: once trusted - // local consumer code begins, neither it nor its reads are replayed. - return await executePinnedSnapshotScope( - config, - config.endpoints[index]!, - preflight, - request, - consume, - totalDeadline, - ); - } - } - throw lastRetryableFailure - ?? unavailable('No configured endpoint completed finalized snapshot preflight'); - } finally { - totalDeadline.close(); - } - }, (active) => new CurrentFinalizedEvmCallErrorV1( - 'concurrency-saturated', - `Chain ${request.chainId} already has ${active} finalized snapshot in flight`, - )); - }; - - return Object.freeze(withSnapshot); -} - -interface SnapshotEndpointPreflightV1 { - readonly anchor: FinalizedAnchorV1; - readonly lastRequestId: number; -} - -async function preflightSnapshotEndpoint( - config: StrictRpcConfigSnapshotV1, - endpoint: string, - signal: AbortSignal, -): Promise { - let requestId = 0; - const rpc = async (method: string, params: readonly unknown[]): Promise => { - requestId += 1; - return postJsonRpc( - endpoint, - requestId, - method, - params, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - signal, - ); - }; - const remoteChainId = parseChainId(await rpc('eth_chainId', Object.freeze([]))); - if (remoteChainId !== config.chainId) { - throw new CurrentFinalizedEvmCallErrorV1( - 'chain-mismatch', - `Configured snapshot endpoint reported chain ${remoteChainId}, expected ${config.chainId}`, - ); - } - const anchor = parseFinalizedAnchor( - await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), - 'current finalized snapshot header', - ); - return Object.freeze({ anchor, lastRequestId: requestId }); -} - -async function executePinnedSnapshotScope( - config: StrictRpcConfigSnapshotV1, - endpoint: string, - preflight: SnapshotEndpointPreflightV1, - request: StrictCurrentFinalizedEvmSnapshotRequestV1, - consume: (session: StrictCurrentFinalizedEvmSnapshotSessionV1) => Promise, - totalDeadline: DeadlineScope, -): Promise { - let requestId = preflight.lastRequestId; - const rpc = async (method: string, params: readonly unknown[]): Promise => { - if (request.signal.aborted) throw cancelled('Current-finalized snapshot was cancelled'); - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - const rpcDeadline = createDeadlineScope( - totalDeadline.signal, - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - `current-finalized snapshot JSON-RPC ${method}`, - ); - requestId += 1; - try { - return await postJsonRpc( - endpoint, - requestId, - method, - params, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - rpcDeadline.signal, - ); - } catch (cause) { - if (request.signal.aborted) throw cancelled('Current-finalized snapshot was cancelled'); - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - if (rpcDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot JSON-RPC exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, - ); - } - if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; - throw unavailable('Current-finalized snapshot JSON-RPC failed closed', cause); - } finally { - rpcDeadline.close(); - } - }; - - const budget = createCurrentFinalizedEvmSnapshotBudgetV1(); - const deployedTargets = new Set(); - let active = true; - let inFlight: Promise | undefined; - const read = Object.freeze(async ( - inputCalls: readonly StrictCurrentFinalizedEvmReadCallV1[], - ): Promise => { - if (!active) throw unavailable('Current-finalized snapshot session is closed'); - if (inFlight !== undefined) { - throw new CurrentFinalizedEvmCallErrorV1( - 'concurrency-saturated', - 'Current-finalized snapshot permits only one dynamic batch at a time', - ); - } - const calls = snapshotSnapshotCalls(inputCalls); - try { - budget.consume(calls); - } catch { - throw resourceLimited('Current-finalized snapshot exceeded its fixed scan budget'); - } - let operation!: Promise; - operation = executeSnapshotBatch( - config, - preflight.anchor, - calls, - deployedTargets, - rpc, - totalDeadline, - ).finally(() => { - if (inFlight === operation) inFlight = undefined; - }); - inFlight = operation; - return operation; - }); - const session = Object.freeze({ - chainId: config.chainId, - blockNumber: preflight.anchor.blockNumber, - blockHash: preflight.anchor.blockHash, - read, - } satisfies StrictCurrentFinalizedEvmSnapshotSessionV1); - - let result!: T; - let callbackFailure: unknown; - let callbackSucceeded = false; - try { - result = await consume(session); - callbackSucceeded = true; - } catch (cause) { - callbackFailure = cause; - } - active = false; - - const danglingRead = inFlight; - if (danglingRead !== undefined) { - await danglingRead.catch(() => undefined); - if (callbackSucceeded) { - callbackSucceeded = false; - callbackFailure = unavailable( - 'Current-finalized snapshot consumer settled with an unawaited read in flight', - ); - } - } - - if (!callbackSucceeded) throw callbackFailure; - if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { - await assertSnapshotAnchorStable(rpc, preflight.anchor); - } - if (request.signal.aborted) throw cancelled('Current-finalized snapshot was cancelled'); - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - return result; -} - -async function executeSnapshotBatch( - config: StrictRpcConfigSnapshotV1, - anchor: FinalizedAnchorV1, - calls: readonly StrictCurrentFinalizedEvmReadCallV1[], - deployedTargets: Set, - rpc: (method: string, params: readonly unknown[]) => Promise, - totalDeadline: DeadlineScope, -): Promise { - const executeCallsAt = async (blockReference: unknown): Promise => { - const uncheckedTargets = [...new Set(calls.map(({ to }) => to))] - .filter((to) => !deployedTargets.has(to)); - await settleParallelBatch(uncheckedTargets.map(async (to) => { - assertDeployedCode(await rpc('eth_getCode', Object.freeze([to, blockReference]))); - deployedTargets.add(to); - }), totalDeadline); - return settleParallelBatch(calls.map(async (call) => { - const callObject = Object.freeze({ - from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - to: call.to, - data: call.data, - gas: RPC_CALL_GAS_QUANTITY, - }); - return parseContractReturn( - await rpc('eth_call', Object.freeze([callObject, blockReference])), - call.maxReturnBytes, - ); - }), totalDeadline); - }; - - if (config.blockReferenceProfile === 'eip1898') { - return executeCallsAt(Object.freeze({ - blockHash: anchor.blockHash, - requireCanonical: true as const, - })); - } - - let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - let returnData: readonly string[] | undefined; - try { - returnData = await executeCallsAt(anchor.blockNumberQuantity); - } catch (cause) { - if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 - && ( - cause.code === 'no-code' - || cause.code === 'revert' - || cause.code === 'malformed-return' - || isAnchorDependentResourceLimit(cause) - ) - ) { - anchorDependentFailure = cause; - } else { - throw cause; - } - } - await assertSnapshotAnchorStable(rpc, anchor); - if (anchorDependentFailure !== undefined) throw anchorDependentFailure; - if (returnData === undefined) throw unavailable('Finalized snapshot batch produced no results'); - return returnData; -} - -async function assertSnapshotAnchorStable( - rpc: (method: string, params: readonly unknown[]) => Promise, - anchor: FinalizedAnchorV1, -): Promise { - const postAnchor = parseFinalizedAnchor( - await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), - 'post-snapshot numbered header', - ); - if ( - postAnchor.blockNumber !== anchor.blockNumber - || postAnchor.blockHash !== anchor.blockHash - ) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', - ); - } + return createStrictFinalizedSnapshotRpcRuntimeV1(config, Object.freeze({ + snapshotRequest: snapshotSnapshotRequest, + snapshotCalls: snapshotSnapshotCalls, + createDeadline: createDeadlineScope, + postJsonRpc, + parseChainId, + parseAnchor: parseFinalizedAnchor, + assertDeployedCode, + parseContractReturn, + settle: settleParallelBatch, + isTerminalFailure: isTerminalAttemptFailure, + isAnchorDependentResourceLimit, + unavailable, + timedOut, + resourceLimited, + cancelled, + })); } async function executeEndpointAttempt( @@ -644,24 +322,15 @@ async function executeEndpointAttempt( 'current finalized header', ); const executeCallsAt = async (blockReference: unknown): Promise => { - const uniqueTargets = [...new Set(request.calls.map(({ to }) => to))]; - await settleParallelBatch(uniqueTargets.map(async (to) => { - const code = await rpc('eth_getCode', Object.freeze([to, blockReference])); - assertDeployedCode(code); - }), attemptDeadline); - - return settleParallelBatch(request.calls.map(async (call) => { - const callObject = Object.freeze({ - from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - to: call.to, - data: call.data, - gas: RPC_CALL_GAS_QUANTITY, - }); - return parseContractReturn( - await rpc('eth_call', Object.freeze([callObject, blockReference])), - call.maxReturnBytes, - ); - }), attemptDeadline); + const batch = await executeStrictFinalizedEvmBatchV1({ + calls: request.calls, + blockReference, + rpc, + settle: (operations) => settleParallelBatch(operations, attemptDeadline), + assertDeployedCode, + parseContractReturn, + }); + return batch.returnData; }; let returnData: readonly string[]; @@ -1045,27 +714,6 @@ function classifyAttemptFailure( return unavailable('Current-finalized endpoint attempt failed closed', cause); } -function classifySnapshotAttemptFailure( - cause: unknown, - callerSignal: AbortSignal, - totalDeadline: DeadlineScope, - attemptDeadline: DeadlineScope, -): CurrentFinalizedEvmCallErrorV1 { - if (callerSignal.aborted) return cancelled('Current-finalized snapshot was cancelled'); - if (totalDeadline.timedOut()) { - return timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - if (attemptDeadline.timedOut()) { - return timedOut( - `Current-finalized snapshot preflight exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, - ); - } - if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; - return unavailable('Current-finalized snapshot preflight failed closed', cause); -} - function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { return error.code === 'unsupported-chain' || error.code === 'resource-limit' diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts new file mode 100644 index 0000000000..77c884709e --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -0,0 +1,452 @@ +import type { + BlockNumberV1, + ChainIdV1, + Digest32V1, + EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; +import { + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, + createCurrentFinalizedEvmSnapshotBudgetV1, + type StrictCurrentFinalizedEvmSnapshotRequestV1, + type StrictCurrentFinalizedEvmSnapshotScopeV1, + type StrictCurrentFinalizedEvmSnapshotSessionV1, +} from './current-finalized-evm-snapshot.js'; +import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; +import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; + +export interface StrictFinalizedSnapshotRpcConfigV1 { + readonly chainId: ChainIdV1; + readonly endpoints: readonly string[]; + readonly blockReferenceProfile: 'eip1898' | 'trusted-block-number-hash-sandwich'; +} + +interface FinalizedAnchorV1 { + readonly blockNumber: BlockNumberV1; + readonly blockNumberQuantity: string; + readonly blockHash: Digest32V1; +} + +interface DeadlineScopeV1 { + readonly signal: AbortSignal; + readonly timedOut: () => boolean; + readonly close: () => void; +} + +export interface StrictFinalizedSnapshotRpcDependenciesV1 { + readonly snapshotRequest: (input: unknown) => StrictCurrentFinalizedEvmSnapshotRequestV1; + readonly snapshotCalls: (input: unknown) => readonly StrictCurrentFinalizedEvmReadCallV1[]; + readonly createDeadline: ( + parent: AbortSignal, + timeoutMs: number, + label: string, + ) => DeadlineScopeV1; + readonly postJsonRpc: ( + endpoint: string, + id: number, + method: string, + params: readonly unknown[], + maxResponseBytes: number, + signal: AbortSignal, + ) => Promise; + readonly parseChainId: (input: unknown) => ChainIdV1; + readonly parseAnchor: (input: unknown, label: string) => FinalizedAnchorV1; + readonly assertDeployedCode: (input: unknown) => void; + readonly parseContractReturn: (input: unknown, maxBytes: number) => string; + readonly settle: ( + operations: readonly Promise[], + deadline: DeadlineScopeV1, + ) => Promise; + readonly isTerminalFailure: (error: CurrentFinalizedEvmCallErrorV1) => boolean; + readonly isAnchorDependentResourceLimit: (error: CurrentFinalizedEvmCallErrorV1) => boolean; + readonly unavailable: (message: string, cause?: unknown) => CurrentFinalizedEvmCallErrorV1; + readonly timedOut: (message: string) => CurrentFinalizedEvmCallErrorV1; + readonly resourceLimited: (message: string) => CurrentFinalizedEvmCallErrorV1; + readonly cancelled: (message: string) => CurrentFinalizedEvmCallErrorV1; +} + +interface SnapshotEndpointPreflightV1 { + readonly anchor: FinalizedAnchorV1; + readonly lastRequestId: number; +} + +/** Package-internal runtime for a scoped, pinned finalized snapshot. */ +export function createStrictFinalizedSnapshotRpcRuntimeV1( + config: StrictFinalizedSnapshotRpcConfigV1, + dependencies: StrictFinalizedSnapshotRpcDependenciesV1, +): StrictCurrentFinalizedEvmSnapshotScopeV1 { + const admission = createNonqueueingAdmissionGateV1( + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, + ); + const withSnapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async ( + inputRequest, + consume, + ) => { + const request = dependencies.snapshotRequest(inputRequest); + if (typeof consume !== 'function') { + throw dependencies.unavailable('Current-finalized snapshot consumer must be callable'); + } + if (request.chainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Snapshot adapter is configured for chain ${config.chainId}, not ${request.chainId}`, + ); + } + if (request.signal.aborted) { + throw dependencies.cancelled( + 'Current-finalized snapshot was cancelled before transport admission', + ); + } + + return admission.run(request.chainId, async () => { + const totalDeadline = dependencies.createDeadline( + request.signal, + CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, + 'current-finalized snapshot total deadline', + ); + let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + try { + for (let index = 0; index < config.endpoints.length; index += 1) { + const attemptDeadline = dependencies.createDeadline( + totalDeadline.signal, + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + `current-finalized snapshot preflight ${index + 1}`, + ); + let preflight: SnapshotEndpointPreflightV1 | undefined; + try { + preflight = await preflightSnapshotEndpoint( + config, + config.endpoints[index]!, + attemptDeadline.signal, + dependencies, + ); + if ( + request.signal.aborted + || totalDeadline.timedOut() + || attemptDeadline.timedOut() + ) { + throw classifySnapshotAttemptFailure( + new Error('Snapshot preflight completed after its lifecycle ended'), + request.signal, + totalDeadline, + attemptDeadline, + dependencies, + ); + } + } catch (cause) { + const failure = classifySnapshotAttemptFailure( + cause, + request.signal, + totalDeadline, + attemptDeadline, + dependencies, + ); + if (dependencies.isTerminalFailure(failure)) throw failure; + lastRetryableFailure = failure; + } finally { + attemptDeadline.close(); + } + if (preflight !== undefined) { + // Once trusted local consumer code begins, it is never replayed. + return await executePinnedSnapshotScope( + config, + config.endpoints[index]!, + preflight, + request, + consume, + totalDeadline, + dependencies, + ); + } + } + throw lastRetryableFailure + ?? dependencies.unavailable( + 'No configured endpoint completed finalized snapshot preflight', + ); + } finally { + totalDeadline.close(); + } + }, (active) => new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + `Chain ${request.chainId} already has ${active} finalized snapshot in flight`, + )); + }; + return Object.freeze(withSnapshot); +} + +async function preflightSnapshotEndpoint( + config: StrictFinalizedSnapshotRpcConfigV1, + endpoint: string, + signal: AbortSignal, + dependencies: StrictFinalizedSnapshotRpcDependenciesV1, +): Promise { + let requestId = 0; + const rpc = async (method: string, params: readonly unknown[]): Promise => { + requestId += 1; + return dependencies.postJsonRpc( + endpoint, + requestId, + method, + params, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + signal, + ); + }; + const remoteChainId = dependencies.parseChainId( + await rpc('eth_chainId', Object.freeze([])), + ); + if (remoteChainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Configured snapshot endpoint reported chain ${remoteChainId}, expected ${config.chainId}`, + ); + } + const anchor = dependencies.parseAnchor( + await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), + 'current finalized snapshot header', + ); + return Object.freeze({ anchor, lastRequestId: requestId }); +} + +async function executePinnedSnapshotScope( + config: StrictFinalizedSnapshotRpcConfigV1, + endpoint: string, + preflight: SnapshotEndpointPreflightV1, + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + consume: (session: StrictCurrentFinalizedEvmSnapshotSessionV1) => Promise, + totalDeadline: DeadlineScopeV1, + dependencies: StrictFinalizedSnapshotRpcDependenciesV1, +): Promise { + let requestId = preflight.lastRequestId; + const rpc = async (method: string, params: readonly unknown[]): Promise => { + if (request.signal.aborted) { + throw dependencies.cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + throw dependencies.timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + const rpcDeadline = dependencies.createDeadline( + totalDeadline.signal, + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + `current-finalized snapshot JSON-RPC ${method}`, + ); + requestId += 1; + try { + return await dependencies.postJsonRpc( + endpoint, + requestId, + method, + params, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + rpcDeadline.signal, + ); + } catch (cause) { + if (request.signal.aborted) { + throw dependencies.cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + throw dependencies.timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + if (rpcDeadline.timedOut()) { + throw dependencies.timedOut( + `Current-finalized snapshot JSON-RPC exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, + ); + } + if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; + throw dependencies.unavailable('Current-finalized snapshot JSON-RPC failed closed', cause); + } finally { + rpcDeadline.close(); + } + }; + + const budget = createCurrentFinalizedEvmSnapshotBudgetV1(); + const deployedTargets = new Set(); + let active = true; + let inFlight: Promise | undefined; + const read = Object.freeze(async ( + inputCalls: readonly StrictCurrentFinalizedEvmReadCallV1[], + ): Promise => { + if (!active) throw dependencies.unavailable('Current-finalized snapshot session is closed'); + if (inFlight !== undefined) { + throw new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + 'Current-finalized snapshot permits only one dynamic batch at a time', + ); + } + const calls = dependencies.snapshotCalls(inputCalls); + try { + budget.consume(calls); + } catch { + throw dependencies.resourceLimited( + 'Current-finalized snapshot exceeded its fixed scan budget', + ); + } + let operation!: Promise; + operation = executeSnapshotBatch( + config, + preflight.anchor, + calls, + deployedTargets, + rpc, + totalDeadline, + dependencies, + ).finally(() => { + if (inFlight === operation) inFlight = undefined; + }); + inFlight = operation; + return operation; + }); + const session = Object.freeze({ + chainId: config.chainId, + blockNumber: preflight.anchor.blockNumber, + blockHash: preflight.anchor.blockHash, + read, + } satisfies StrictCurrentFinalizedEvmSnapshotSessionV1); + + let result!: T; + let callbackFailure: unknown; + let callbackSucceeded = false; + try { + result = await consume(session); + callbackSucceeded = true; + } catch (cause) { + callbackFailure = cause; + } + active = false; + + const danglingRead = inFlight; + if (danglingRead !== undefined) { + await danglingRead.catch(() => undefined); + if (callbackSucceeded) { + callbackSucceeded = false; + callbackFailure = dependencies.unavailable( + 'Current-finalized snapshot consumer settled with an unawaited read in flight', + ); + } + } + + if (!callbackSucceeded) throw callbackFailure; + if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { + await assertSnapshotAnchorStable(rpc, preflight.anchor, dependencies); + } + if (request.signal.aborted) { + throw dependencies.cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + throw dependencies.timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + return result; +} + +async function executeSnapshotBatch( + config: StrictFinalizedSnapshotRpcConfigV1, + anchor: FinalizedAnchorV1, + calls: readonly StrictCurrentFinalizedEvmReadCallV1[], + deployedTargets: Set, + rpc: (method: string, params: readonly unknown[]) => Promise, + totalDeadline: DeadlineScopeV1, + dependencies: StrictFinalizedSnapshotRpcDependenciesV1, +): Promise { + const executeCallsAt = (blockReference: unknown) => executeStrictFinalizedEvmBatchV1({ + calls, + blockReference, + deployedTargets, + rpc, + settle: (operations) => dependencies.settle(operations, totalDeadline), + assertDeployedCode: dependencies.assertDeployedCode, + parseContractReturn: dependencies.parseContractReturn, + }); + + if (config.blockReferenceProfile === 'eip1898') { + const batch = await executeCallsAt(Object.freeze({ + blockHash: anchor.blockHash, + requireCanonical: true as const, + })); + for (const target of batch.verifiedTargets) deployedTargets.add(target); + return batch.returnData; + } + + let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + let batch: Awaited> | undefined; + try { + batch = await executeCallsAt(anchor.blockNumberQuantity); + } catch (cause) { + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && ( + cause.code === 'no-code' + || cause.code === 'revert' + || cause.code === 'malformed-return' + || dependencies.isAnchorDependentResourceLimit(cause) + ) + ) { + anchorDependentFailure = cause; + } else { + throw cause; + } + } + await assertSnapshotAnchorStable(rpc, anchor, dependencies); + if (anchorDependentFailure !== undefined) throw anchorDependentFailure; + if (batch === undefined) { + throw dependencies.unavailable('Finalized snapshot batch produced no results'); + } + for (const target of batch.verifiedTargets) deployedTargets.add(target); + return batch.returnData; +} + +async function assertSnapshotAnchorStable( + rpc: (method: string, params: readonly unknown[]) => Promise, + anchor: FinalizedAnchorV1, + dependencies: StrictFinalizedSnapshotRpcDependenciesV1, +): Promise { + const postAnchor = dependencies.parseAnchor( + await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + 'post-snapshot numbered header', + ); + if ( + postAnchor.blockNumber !== anchor.blockNumber + || postAnchor.blockHash !== anchor.blockHash + ) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + ); + } +} + +function classifySnapshotAttemptFailure( + cause: unknown, + callerSignal: AbortSignal, + totalDeadline: DeadlineScopeV1, + attemptDeadline: DeadlineScopeV1, + dependencies: StrictFinalizedSnapshotRpcDependenciesV1, +): CurrentFinalizedEvmCallErrorV1 { + if (callerSignal.aborted) { + return dependencies.cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + return dependencies.timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + if (attemptDeadline.timedOut()) { + return dependencies.timedOut( + `Current-finalized snapshot preflight exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, + ); + } + if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; + return dependencies.unavailable('Current-finalized snapshot preflight failed closed', cause); +} diff --git a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts index d59b64885e..0fd0e45254 100644 --- a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts @@ -174,6 +174,86 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { expect(server.calls.find(({ method }) => method === 'eth_call')?.params[1]).toBe('0x7b'); }); + it('does not cache deployment evidence from a failed numbered-anchor proof', async () => { + let numberedHeaderReads = 0; + let codeReads = 0; + const server = await rpcHarness.start((rpcCall, response) => { + switch (rpcCall.method) { + case 'eth_chainId': + sendJsonRpcResult(response, rpcCall, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': { + const finalizedLookup = rpcCall.params[0] === 'finalized'; + if (!finalizedLookup) numberedHeaderReads += 1; + sendJsonRpcResult(response, rpcCall, { + number: '0x7b', + hash: finalizedLookup || numberedHeaderReads > 1 ? BLOCK_HASH : OTHER_BLOCK_HASH, + }); + return; + } + case 'eth_getCode': + codeReads += 1; + sendJsonRpcResult(response, rpcCall, codeReads === 1 ? '0x6000' : '0x'); + return; + case 'eth_call': + sendJsonRpcResult(response, rpcCall, '0xaaaa'); + return; + default: + sendJsonRpcError(response, rpcCall, -32601, 'method not found'); + } + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(withSnapshot(request(), async (session) => { + await expect(session.read([call(FIRST_DATA)])) + .rejects.toMatchObject({ code: 'finalized-state-unavailable' }); + await expect(session.read([call(FIRST_DATA)])) + .rejects.toMatchObject({ code: 'no-code' }); + return 'cache-remained-authenticated'; + })).resolves.toBe('cache-remained-authenticated'); + expect(codeReads).toBe(2); + }); + + it('authenticates numbered-anchor-dependent failures before exposing them', async () => { + for (const [postHash, expectedCode] of [ + [OTHER_BLOCK_HASH, 'finalized-state-unavailable'], + [BLOCK_HASH, 'no-code'], + ] as const) { + const server = await rpcHarness.start((rpcCall, response) => { + switch (rpcCall.method) { + case 'eth_chainId': + sendJsonRpcResult(response, rpcCall, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': + sendJsonRpcResult(response, rpcCall, { + number: '0x7b', + hash: rpcCall.params[0] === 'finalized' ? BLOCK_HASH : postHash, + }); + return; + case 'eth_getCode': + sendJsonRpcResult(response, rpcCall, '0x'); + return; + default: + sendJsonRpcError(response, rpcCall, -32601, 'method not found'); + } + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(withSnapshot(request(), async (session) => ( + session.read([call(FIRST_DATA)]) + ))).rejects.toMatchObject({ code: expectedCode }); + expect(server.calls.filter(({ method }) => method === 'eth_call')).toHaveLength(0); + } + }); + it('rechecks the numbered fallback anchor after the scoped consumer completes', async () => { let numberedHeaderReads = 0; const server = await rpcHarness.start((rpcCall, response) => { @@ -393,6 +473,45 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { * CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, ); }); + + it('enforces aggregate budgets through the public snapshot session before extra I/O', async () => { + const batchServer = await rpcHarness.start(successfulHandler()); + const batchScope = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [batchServer.url], + }); + await batchScope(request(), async (session) => { + for (let index = 0; index < CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1; index += 1) { + await session.read([call(FIRST_DATA)]); + } + const callsAtLimit = batchServer.calls.length; + await expect(session.read([call(FIRST_DATA)])) + .rejects.toMatchObject({ code: 'resource-limit' }); + expect(batchServer.calls).toHaveLength(callsAtLimit); + }); + + const returnServer = await rpcHarness.start(successfulHandler()); + const returnScope = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [returnServer.url], + }); + const maximalBatch = Array.from( + { length: CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 }, + () => call(FIRST_DATA, CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1), + ); + const batchesToReturnCap = CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_DECLARED_RETURN_BYTES_V1 + / (CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 + * CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1); + await returnScope(request(), async (session) => { + for (let index = 0; index < batchesToReturnCap; index += 1) { + await session.read(maximalBatch); + } + const callsAtLimit = returnServer.calls.length; + await expect(session.read([call(FIRST_DATA)])) + .rejects.toMatchObject({ code: 'resource-limit' }); + expect(returnServer.calls).toHaveLength(callsAtLimit); + }); + }); }); function request(): { readonly chainId: ChainIdV1; readonly signal: AbortSignal } { From c88cd681190a9f498bd16823a30bea3e38157eb9 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 12:15:26 +0200 Subject: [PATCH 222/292] fix(chain): centralize finalized snapshot anchor policy --- packages/chain/src/index.ts | 2 +- .../src/strict-current-finalized-evm-rpc.ts | 196 +++++++-------- ...-current-finalized-evm-snapshot-factory.ts | 17 ++ ...rict-current-finalized-evm-snapshot-rpc.ts | 226 +++++++----------- ...urrent-finalized-evm-snapshot.unit.test.ts | 89 ++++--- 5 files changed, 251 insertions(+), 279 deletions(-) create mode 100644 packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 7a1061beea..6cdb7109fb 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -48,10 +48,10 @@ export { CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, - createStrictCurrentFinalizedEvmSnapshotScopeV1, type CurrentFinalizedEvmBlockReferenceProfileV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from './strict-current-finalized-evm-rpc.js'; +export { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from './strict-current-finalized-evm-snapshot-factory.js'; export { type StrictCurrentFinalizedEvmReadCallV1, type StrictCurrentFinalizedEvmReadRequestV1, diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 1740cbae06..3b6cd76f94 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -28,7 +28,6 @@ import { } from './current-finalized-evm-read-model.js'; import { type StrictCurrentFinalizedEvmSnapshotRequestV1, - type StrictCurrentFinalizedEvmSnapshotScopeV1, } from './current-finalized-evm-snapshot.js'; import { assertCanonicalNonzeroEvmAddress, @@ -38,7 +37,6 @@ import { } from './strict-local-data.js'; import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; -import { createStrictFinalizedSnapshotRpcRuntimeV1 } from './strict-current-finalized-evm-snapshot-rpc.js'; export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ 'eip1898', @@ -67,19 +65,19 @@ export interface StrictCurrentFinalizedEvmRpcConfigV1 { readonly blockReferenceProfile?: CurrentFinalizedEvmBlockReferenceProfileV1; } -interface StrictRpcConfigSnapshotV1 { +export interface StrictRpcConfigSnapshotV1 { readonly chainId: ChainIdV1; readonly endpoints: readonly string[]; readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; } -interface FinalizedAnchorV1 { +export interface FinalizedAnchorV1 { readonly blockNumber: BlockNumberV1; readonly blockNumberQuantity: string; readonly blockHash: Digest32V1; } -interface DeadlineScope { +export interface DeadlineScope { readonly signal: AbortSignal; readonly timedOut: () => boolean; readonly close: () => void; @@ -261,34 +259,6 @@ export function createStrictCurrentFinalizedEvmReadV1( return Object.freeze(read); } -/** - * Build a scoped dynamic-read capability pinned to one endpoint and one - * finalized anchor. Endpoint failover is allowed only during preflight; once - * the callback begins it is never replayed on another endpoint. - */ -export function createStrictCurrentFinalizedEvmSnapshotScopeV1( - input: StrictCurrentFinalizedEvmRpcConfigV1, -): StrictCurrentFinalizedEvmSnapshotScopeV1 { - const config = snapshotConfig(input); - return createStrictFinalizedSnapshotRpcRuntimeV1(config, Object.freeze({ - snapshotRequest: snapshotSnapshotRequest, - snapshotCalls: snapshotSnapshotCalls, - createDeadline: createDeadlineScope, - postJsonRpc, - parseChainId, - parseAnchor: parseFinalizedAnchor, - assertDeployedCode, - parseContractReturn, - settle: settleParallelBatch, - isTerminalFailure: isTerminalAttemptFailure, - isAnchorDependentResourceLimit, - unavailable, - timedOut, - resourceLimited, - cancelled, - })); -} - async function executeEndpointAttempt( config: StrictRpcConfigSnapshotV1, endpoint: string, @@ -333,57 +303,18 @@ async function executeEndpointAttempt( return batch.returnData; }; - let returnData: readonly string[]; - if (config.blockReferenceProfile === 'eip1898') { - const blockReference = Object.freeze({ - blockHash: anchor.blockHash, - requireCanonical: true as const, - }); - returnData = await executeCallsAt(blockReference); - } else { - // Number-selected code/call evidence is not deterministic until the - // same-endpoint post-read closes the hash sandwich. Hold anchor-dependent - // outcomes until then, so a reorg cannot manufacture no-code/revert, a - // malformed return, or an execution-cap failure and poison admission. - let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - let provisionalReturnData: readonly string[] | undefined; - try { - provisionalReturnData = await executeCallsAt(anchor.blockNumberQuantity); - } catch (cause) { - if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 - && ( - cause.code === 'no-code' - || cause.code === 'revert' - || cause.code === 'malformed-return' - || isAnchorDependentResourceLimit(cause) - ) - ) { - anchorDependentFailure = cause; - } else { - throw cause; - } - } - - const postAnchor = parseFinalizedAnchor( + const returnData = await executeStrictFinalizedAnchorPolicyV1({ + blockReferenceProfile: config.blockReferenceProfile, + anchor, + executeAtReference: executeCallsAt, + readPostAnchor: async () => parseFinalizedAnchor( await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), 'post-call numbered header', - ); - if ( - postAnchor.blockNumber !== anchor.blockNumber - || postAnchor.blockHash !== anchor.blockHash - ) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - 'Block-number fallback hash sandwich did not preserve the resolved finalized anchor', - ); - } - if (anchorDependentFailure !== undefined) throw anchorDependentFailure; - if (provisionalReturnData === undefined) { - throw unavailable('Block-number fallback produced no contract results'); - } - returnData = provisionalReturnData; - } + ), + anchorMismatchMessage: + 'Block-number fallback hash sandwich did not preserve the resolved finalized anchor', + missingResultMessage: 'Block-number fallback produced no contract results', + }); return Object.freeze({ chainId: config.chainId, @@ -398,7 +329,76 @@ async function executeEndpointAttempt( * started operation settles. This prevents an early rejection from leaving a * sibling fetch alive after the finalized-read concurrency slot is released. */ -async function settleParallelBatch( +export interface StrictFinalizedAnchorPolicyOptionsV1 { + readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; + readonly anchor: FinalizedAnchorV1; + readonly executeAtReference: (blockReference: unknown) => Promise; + readonly readPostAnchor: () => Promise; + readonly anchorMismatchMessage: string; + readonly missingResultMessage: string; +} + +/** Canonical EIP-1898 / authenticated-numbered-anchor execution policy. */ +export async function executeStrictFinalizedAnchorPolicyV1( + options: StrictFinalizedAnchorPolicyOptionsV1, +): Promise { + if (options.blockReferenceProfile === 'eip1898') { + return options.executeAtReference(Object.freeze({ + blockHash: options.anchor.blockHash, + requireCanonical: true as const, + })); + } + + // Number-selected evidence is not deterministic until the same endpoint + // closes the hash sandwich. Delay every anchor-dependent invalidity until + // that proof succeeds so neither callers nor caches can observe false state. + let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + let provisionalResult: T | undefined; + try { + provisionalResult = await options.executeAtReference(options.anchor.blockNumberQuantity); + } catch (cause) { + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && ( + cause.code === 'no-code' + || cause.code === 'revert' + || cause.code === 'malformed-return' + || isAnchorDependentResourceLimit(cause) + ) + ) { + anchorDependentFailure = cause; + } else { + throw cause; + } + } + await assertStrictFinalizedAnchorStableV1( + options.anchor, + options.readPostAnchor, + options.anchorMismatchMessage, + ); + if (anchorDependentFailure !== undefined) throw anchorDependentFailure; + if (provisionalResult === undefined) throw unavailable(options.missingResultMessage); + return provisionalResult; +} + +export async function assertStrictFinalizedAnchorStableV1( + anchor: FinalizedAnchorV1, + readPostAnchor: () => Promise, + mismatchMessage: string, +): Promise { + const postAnchor = await readPostAnchor(); + if ( + postAnchor.blockNumber !== anchor.blockNumber + || postAnchor.blockHash !== anchor.blockHash + ) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + mismatchMessage, + ); + } +} + +export async function settleParallelBatch( operations: readonly Promise[], attemptDeadline: DeadlineScope, ): Promise { @@ -441,7 +441,7 @@ async function settleParallelBatch( return Object.freeze(values); } -async function postJsonRpc( +export async function postJsonRpc( endpoint: string, id: number, method: string, @@ -540,7 +540,7 @@ async function readResponseBodyBounded(response: Response, maxBytes: number): Pr } } -function parseChainId(input: unknown): ChainIdV1 { +export function parseChainId(input: unknown): ChainIdV1 { let parsed: bigint; try { parsed = parseCanonicalQuantity(input, MAX_U256); @@ -550,7 +550,7 @@ function parseChainId(input: unknown): ChainIdV1 { return parsed.toString(10) as ChainIdV1; } -function parseFinalizedAnchor(input: unknown, label: string): FinalizedAnchorV1 { +export function parseFinalizedAnchor(input: unknown, label: string): FinalizedAnchorV1 { if (input === null) { throw new CurrentFinalizedEvmCallErrorV1( 'finalized-state-unavailable', @@ -587,7 +587,7 @@ function parseFinalizedAnchor(input: unknown, label: string): FinalizedAnchorV1 }); } -function assertDeployedCode(input: unknown): void { +export function assertDeployedCode(input: unknown): void { if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { throw unavailable('eth_getCode returned malformed code bytes'); } @@ -599,7 +599,7 @@ function assertDeployedCode(input: unknown): void { } } -function parseContractReturn(input: unknown, maxBytes: number): string { +export function parseContractReturn(input: unknown, maxBytes: number): string { if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { throw new CurrentFinalizedEvmCallErrorV1( 'malformed-return', @@ -714,7 +714,7 @@ function classifyAttemptFailure( return unavailable('Current-finalized endpoint attempt failed closed', cause); } -function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { +export function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { return error.code === 'unsupported-chain' || error.code === 'resource-limit' || error.code === 'revert' @@ -722,7 +722,7 @@ function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolea || error.code === 'malformed-return'; } -function createDeadlineScope( +export function createDeadlineScope( parent: AbortSignal, timeoutMs: number, label: string, @@ -763,7 +763,7 @@ function snapshotReadRequest(input: unknown): StrictCurrentFinalizedEvmReadReque } } -function snapshotSnapshotRequest(input: unknown): StrictCurrentFinalizedEvmSnapshotRequestV1 { +export function snapshotSnapshotRequest(input: unknown): StrictCurrentFinalizedEvmSnapshotRequestV1 { try { const record = snapshotExactDataRecord(input, SNAPSHOT_REQUEST_KEYS); assertCanonicalChainId(record.chainId, 'strict finalized-snapshot chainId'); @@ -777,7 +777,7 @@ function snapshotSnapshotRequest(input: unknown): StrictCurrentFinalizedEvmSnaps } } -function snapshotSnapshotCalls( +export function snapshotSnapshotCalls( input: unknown, ): readonly StrictCurrentFinalizedEvmReadCallV1[] { try { @@ -821,7 +821,7 @@ function snapshotReadCalls(input: unknown): readonly StrictCurrentFinalizedEvmRe return Object.freeze(calls); } -function snapshotConfig(input: StrictCurrentFinalizedEvmRpcConfigV1): StrictRpcConfigSnapshotV1 { +export function snapshotConfig(input: StrictCurrentFinalizedEvmRpcConfigV1): StrictRpcConfigSnapshotV1 { if (!isPlainRecord(input)) { throw new TypeError('Strict current-finalized RPC config must be a plain data record'); } @@ -909,7 +909,7 @@ function isPlainRecord(value: unknown): value is Record { return prototype === Object.prototype || prototype === null; } -function unavailable(message: string, cause?: unknown): CurrentFinalizedEvmCallErrorV1 { +export function unavailable(message: string, cause?: unknown): CurrentFinalizedEvmCallErrorV1 { return new CurrentFinalizedEvmCallErrorV1( 'rpc-unavailable', message, @@ -917,11 +917,11 @@ function unavailable(message: string, cause?: unknown): CurrentFinalizedEvmCallE ); } -function timedOut(message: string): CurrentFinalizedEvmCallErrorV1 { +export function timedOut(message: string): CurrentFinalizedEvmCallErrorV1 { return new CurrentFinalizedEvmCallErrorV1('rpc-timeout', message); } -function resourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { +export function resourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { return new CurrentFinalizedEvmCallErrorV1('resource-limit', message); } @@ -940,14 +940,14 @@ function revertedAtFinalizedAnchor(data?: string): CurrentFinalizedEvmCallErrorV return error; } -function isAnchorDependentResourceLimit( +export function isAnchorDependentResourceLimit( error: CurrentFinalizedEvmCallErrorV1, ): boolean { return error.code === 'resource-limit' && ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.has(error); } -function cancelled(message: string): CurrentFinalizedEvmCallErrorV1 { +export function cancelled(message: string): CurrentFinalizedEvmCallErrorV1 { // Caller intent is authenticated by the verifier-owned AbortSignal. Keep // the adapter error retryable so a foreign gateway cannot forge a cancelled // disposition merely by throwing a public error code; the verifier observes diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts new file mode 100644 index 0000000000..a1d95eea31 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts @@ -0,0 +1,17 @@ +import type { StrictCurrentFinalizedEvmSnapshotScopeV1 } from './current-finalized-evm-snapshot.js'; +import { + snapshotConfig, + type StrictCurrentFinalizedEvmRpcConfigV1, +} from './strict-current-finalized-evm-rpc.js'; +import { createStrictFinalizedSnapshotRpcRuntimeV1 } from './strict-current-finalized-evm-snapshot-rpc.js'; + +/** + * Build a scoped dynamic-read capability pinned to one endpoint and one + * finalized anchor. Endpoint failover is allowed only during preflight; once + * the callback begins it is never replayed on another endpoint. + */ +export function createStrictCurrentFinalizedEvmSnapshotScopeV1( + input: StrictCurrentFinalizedEvmRpcConfigV1, +): StrictCurrentFinalizedEvmSnapshotScopeV1 { + return createStrictFinalizedSnapshotRpcRuntimeV1(snapshotConfig(input)); +} diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts index 77c884709e..01fe0c4282 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -1,7 +1,5 @@ import type { - BlockNumberV1, ChainIdV1, - Digest32V1, EvmAddressV1, } from '@origintrail-official/dkg-core'; @@ -21,55 +19,32 @@ import { } from './current-finalized-evm-snapshot.js'; import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; +import { + assertDeployedCode, + assertStrictFinalizedAnchorStableV1, + cancelled, + createDeadlineScope, + executeStrictFinalizedAnchorPolicyV1, + isTerminalAttemptFailure, + parseChainId, + parseContractReturn, + parseFinalizedAnchor, + postJsonRpc, + resourceLimited, + settleParallelBatch, + snapshotSnapshotCalls, + snapshotSnapshotRequest, + timedOut, + unavailable, + type CurrentFinalizedEvmBlockReferenceProfileV1, + type DeadlineScope, + type FinalizedAnchorV1, +} from './strict-current-finalized-evm-rpc.js'; export interface StrictFinalizedSnapshotRpcConfigV1 { readonly chainId: ChainIdV1; readonly endpoints: readonly string[]; - readonly blockReferenceProfile: 'eip1898' | 'trusted-block-number-hash-sandwich'; -} - -interface FinalizedAnchorV1 { - readonly blockNumber: BlockNumberV1; - readonly blockNumberQuantity: string; - readonly blockHash: Digest32V1; -} - -interface DeadlineScopeV1 { - readonly signal: AbortSignal; - readonly timedOut: () => boolean; - readonly close: () => void; -} - -export interface StrictFinalizedSnapshotRpcDependenciesV1 { - readonly snapshotRequest: (input: unknown) => StrictCurrentFinalizedEvmSnapshotRequestV1; - readonly snapshotCalls: (input: unknown) => readonly StrictCurrentFinalizedEvmReadCallV1[]; - readonly createDeadline: ( - parent: AbortSignal, - timeoutMs: number, - label: string, - ) => DeadlineScopeV1; - readonly postJsonRpc: ( - endpoint: string, - id: number, - method: string, - params: readonly unknown[], - maxResponseBytes: number, - signal: AbortSignal, - ) => Promise; - readonly parseChainId: (input: unknown) => ChainIdV1; - readonly parseAnchor: (input: unknown, label: string) => FinalizedAnchorV1; - readonly assertDeployedCode: (input: unknown) => void; - readonly parseContractReturn: (input: unknown, maxBytes: number) => string; - readonly settle: ( - operations: readonly Promise[], - deadline: DeadlineScopeV1, - ) => Promise; - readonly isTerminalFailure: (error: CurrentFinalizedEvmCallErrorV1) => boolean; - readonly isAnchorDependentResourceLimit: (error: CurrentFinalizedEvmCallErrorV1) => boolean; - readonly unavailable: (message: string, cause?: unknown) => CurrentFinalizedEvmCallErrorV1; - readonly timedOut: (message: string) => CurrentFinalizedEvmCallErrorV1; - readonly resourceLimited: (message: string) => CurrentFinalizedEvmCallErrorV1; - readonly cancelled: (message: string) => CurrentFinalizedEvmCallErrorV1; + readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; } interface SnapshotEndpointPreflightV1 { @@ -80,7 +55,6 @@ interface SnapshotEndpointPreflightV1 { /** Package-internal runtime for a scoped, pinned finalized snapshot. */ export function createStrictFinalizedSnapshotRpcRuntimeV1( config: StrictFinalizedSnapshotRpcConfigV1, - dependencies: StrictFinalizedSnapshotRpcDependenciesV1, ): StrictCurrentFinalizedEvmSnapshotScopeV1 { const admission = createNonqueueingAdmissionGateV1( CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, @@ -89,9 +63,9 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( inputRequest, consume, ) => { - const request = dependencies.snapshotRequest(inputRequest); + const request = snapshotSnapshotRequest(inputRequest); if (typeof consume !== 'function') { - throw dependencies.unavailable('Current-finalized snapshot consumer must be callable'); + throw unavailable('Current-finalized snapshot consumer must be callable'); } if (request.chainId !== config.chainId) { throw new CurrentFinalizedEvmCallErrorV1( @@ -100,13 +74,13 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( ); } if (request.signal.aborted) { - throw dependencies.cancelled( + throw cancelled( 'Current-finalized snapshot was cancelled before transport admission', ); } return admission.run(request.chainId, async () => { - const totalDeadline = dependencies.createDeadline( + const totalDeadline = createDeadlineScope( request.signal, CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, 'current-finalized snapshot total deadline', @@ -114,7 +88,7 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; try { for (let index = 0; index < config.endpoints.length; index += 1) { - const attemptDeadline = dependencies.createDeadline( + const attemptDeadline = createDeadlineScope( totalDeadline.signal, CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, `current-finalized snapshot preflight ${index + 1}`, @@ -125,7 +99,6 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( config, config.endpoints[index]!, attemptDeadline.signal, - dependencies, ); if ( request.signal.aborted @@ -137,7 +110,6 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( request.signal, totalDeadline, attemptDeadline, - dependencies, ); } } catch (cause) { @@ -146,9 +118,8 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( request.signal, totalDeadline, attemptDeadline, - dependencies, ); - if (dependencies.isTerminalFailure(failure)) throw failure; + if (isTerminalAttemptFailure(failure)) throw failure; lastRetryableFailure = failure; } finally { attemptDeadline.close(); @@ -162,12 +133,11 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( request, consume, totalDeadline, - dependencies, ); } } throw lastRetryableFailure - ?? dependencies.unavailable( + ?? unavailable( 'No configured endpoint completed finalized snapshot preflight', ); } finally { @@ -185,12 +155,11 @@ async function preflightSnapshotEndpoint( config: StrictFinalizedSnapshotRpcConfigV1, endpoint: string, signal: AbortSignal, - dependencies: StrictFinalizedSnapshotRpcDependenciesV1, ): Promise { let requestId = 0; const rpc = async (method: string, params: readonly unknown[]): Promise => { requestId += 1; - return dependencies.postJsonRpc( + return postJsonRpc( endpoint, requestId, method, @@ -199,7 +168,7 @@ async function preflightSnapshotEndpoint( signal, ); }; - const remoteChainId = dependencies.parseChainId( + const remoteChainId = parseChainId( await rpc('eth_chainId', Object.freeze([])), ); if (remoteChainId !== config.chainId) { @@ -208,7 +177,7 @@ async function preflightSnapshotEndpoint( `Configured snapshot endpoint reported chain ${remoteChainId}, expected ${config.chainId}`, ); } - const anchor = dependencies.parseAnchor( + const anchor = parseFinalizedAnchor( await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), 'current finalized snapshot header', ); @@ -221,27 +190,26 @@ async function executePinnedSnapshotScope( preflight: SnapshotEndpointPreflightV1, request: StrictCurrentFinalizedEvmSnapshotRequestV1, consume: (session: StrictCurrentFinalizedEvmSnapshotSessionV1) => Promise, - totalDeadline: DeadlineScopeV1, - dependencies: StrictFinalizedSnapshotRpcDependenciesV1, + totalDeadline: DeadlineScope, ): Promise { let requestId = preflight.lastRequestId; const rpc = async (method: string, params: readonly unknown[]): Promise => { if (request.signal.aborted) { - throw dependencies.cancelled('Current-finalized snapshot was cancelled'); + throw cancelled('Current-finalized snapshot was cancelled'); } if (totalDeadline.timedOut()) { - throw dependencies.timedOut( + throw timedOut( `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, ); } - const rpcDeadline = dependencies.createDeadline( + const rpcDeadline = createDeadlineScope( totalDeadline.signal, CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, `current-finalized snapshot JSON-RPC ${method}`, ); requestId += 1; try { - return await dependencies.postJsonRpc( + return await postJsonRpc( endpoint, requestId, method, @@ -251,20 +219,20 @@ async function executePinnedSnapshotScope( ); } catch (cause) { if (request.signal.aborted) { - throw dependencies.cancelled('Current-finalized snapshot was cancelled'); + throw cancelled('Current-finalized snapshot was cancelled'); } if (totalDeadline.timedOut()) { - throw dependencies.timedOut( + throw timedOut( `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, ); } if (rpcDeadline.timedOut()) { - throw dependencies.timedOut( + throw timedOut( `Current-finalized snapshot JSON-RPC exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, ); } if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; - throw dependencies.unavailable('Current-finalized snapshot JSON-RPC failed closed', cause); + throw unavailable('Current-finalized snapshot JSON-RPC failed closed', cause); } finally { rpcDeadline.close(); } @@ -277,18 +245,18 @@ async function executePinnedSnapshotScope( const read = Object.freeze(async ( inputCalls: readonly StrictCurrentFinalizedEvmReadCallV1[], ): Promise => { - if (!active) throw dependencies.unavailable('Current-finalized snapshot session is closed'); + if (!active) throw unavailable('Current-finalized snapshot session is closed'); if (inFlight !== undefined) { throw new CurrentFinalizedEvmCallErrorV1( 'concurrency-saturated', 'Current-finalized snapshot permits only one dynamic batch at a time', ); } - const calls = dependencies.snapshotCalls(inputCalls); + const calls = snapshotSnapshotCalls(inputCalls); try { budget.consume(calls); } catch { - throw dependencies.resourceLimited( + throw resourceLimited( 'Current-finalized snapshot exceeded its fixed scan budget', ); } @@ -300,7 +268,6 @@ async function executePinnedSnapshotScope( deployedTargets, rpc, totalDeadline, - dependencies, ).finally(() => { if (inFlight === operation) inFlight = undefined; }); @@ -330,7 +297,7 @@ async function executePinnedSnapshotScope( await danglingRead.catch(() => undefined); if (callbackSucceeded) { callbackSucceeded = false; - callbackFailure = dependencies.unavailable( + callbackFailure = unavailable( 'Current-finalized snapshot consumer settled with an unawaited read in flight', ); } @@ -338,13 +305,23 @@ async function executePinnedSnapshotScope( if (!callbackSucceeded) throw callbackFailure; if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { - await assertSnapshotAnchorStable(rpc, preflight.anchor, dependencies); + await assertStrictFinalizedAnchorStableV1( + preflight.anchor, + async () => parseFinalizedAnchor( + await rpc( + 'eth_getBlockByNumber', + Object.freeze([preflight.anchor.blockNumberQuantity, false]), + ), + 'post-snapshot numbered header', + ), + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + ); } if (request.signal.aborted) { - throw dependencies.cancelled('Current-finalized snapshot was cancelled'); + throw cancelled('Current-finalized snapshot was cancelled'); } if (totalDeadline.timedOut()) { - throw dependencies.timedOut( + throw timedOut( `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, ); } @@ -357,96 +334,53 @@ async function executeSnapshotBatch( calls: readonly StrictCurrentFinalizedEvmReadCallV1[], deployedTargets: Set, rpc: (method: string, params: readonly unknown[]) => Promise, - totalDeadline: DeadlineScopeV1, - dependencies: StrictFinalizedSnapshotRpcDependenciesV1, + totalDeadline: DeadlineScope, ): Promise { const executeCallsAt = (blockReference: unknown) => executeStrictFinalizedEvmBatchV1({ calls, blockReference, deployedTargets, rpc, - settle: (operations) => dependencies.settle(operations, totalDeadline), - assertDeployedCode: dependencies.assertDeployedCode, - parseContractReturn: dependencies.parseContractReturn, + settle: (operations) => settleParallelBatch(operations, totalDeadline), + assertDeployedCode, + parseContractReturn, }); - if (config.blockReferenceProfile === 'eip1898') { - const batch = await executeCallsAt(Object.freeze({ - blockHash: anchor.blockHash, - requireCanonical: true as const, - })); - for (const target of batch.verifiedTargets) deployedTargets.add(target); - return batch.returnData; - } - - let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - let batch: Awaited> | undefined; - try { - batch = await executeCallsAt(anchor.blockNumberQuantity); - } catch (cause) { - if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 - && ( - cause.code === 'no-code' - || cause.code === 'revert' - || cause.code === 'malformed-return' - || dependencies.isAnchorDependentResourceLimit(cause) - ) - ) { - anchorDependentFailure = cause; - } else { - throw cause; - } - } - await assertSnapshotAnchorStable(rpc, anchor, dependencies); - if (anchorDependentFailure !== undefined) throw anchorDependentFailure; - if (batch === undefined) { - throw dependencies.unavailable('Finalized snapshot batch produced no results'); - } + const batch = await executeStrictFinalizedAnchorPolicyV1({ + blockReferenceProfile: config.blockReferenceProfile, + anchor, + executeAtReference: executeCallsAt, + readPostAnchor: async () => parseFinalizedAnchor( + await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + 'post-snapshot numbered header', + ), + anchorMismatchMessage: + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + missingResultMessage: 'Finalized snapshot batch produced no results', + }); for (const target of batch.verifiedTargets) deployedTargets.add(target); return batch.returnData; } -async function assertSnapshotAnchorStable( - rpc: (method: string, params: readonly unknown[]) => Promise, - anchor: FinalizedAnchorV1, - dependencies: StrictFinalizedSnapshotRpcDependenciesV1, -): Promise { - const postAnchor = dependencies.parseAnchor( - await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), - 'post-snapshot numbered header', - ); - if ( - postAnchor.blockNumber !== anchor.blockNumber - || postAnchor.blockHash !== anchor.blockHash - ) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', - ); - } -} - function classifySnapshotAttemptFailure( cause: unknown, callerSignal: AbortSignal, - totalDeadline: DeadlineScopeV1, - attemptDeadline: DeadlineScopeV1, - dependencies: StrictFinalizedSnapshotRpcDependenciesV1, + totalDeadline: DeadlineScope, + attemptDeadline: DeadlineScope, ): CurrentFinalizedEvmCallErrorV1 { if (callerSignal.aborted) { - return dependencies.cancelled('Current-finalized snapshot was cancelled'); + return cancelled('Current-finalized snapshot was cancelled'); } if (totalDeadline.timedOut()) { - return dependencies.timedOut( + return timedOut( `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, ); } if (attemptDeadline.timedOut()) { - return dependencies.timedOut( + return timedOut( `Current-finalized snapshot preflight exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, ); } if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; - return dependencies.unavailable('Current-finalized snapshot preflight failed closed', cause); + return unavailable('Current-finalized snapshot preflight failed closed', cause); } diff --git a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts index 0fd0e45254..9e7b536468 100644 --- a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts @@ -17,9 +17,9 @@ import { type StrictCurrentFinalizedEvmSnapshotSessionV1, } from '../src/current-finalized-evm-snapshot.js'; import { - createStrictCurrentFinalizedEvmSnapshotScopeV1, type StrictCurrentFinalizedEvmReadCallV1, } from '../src/strict-current-finalized-evm-rpc.js'; +import { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from '../src/strict-current-finalized-evm-snapshot-factory.js'; import { createLoopbackJsonRpcTestHarness, sendJsonRpcError, @@ -218,39 +218,60 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { expect(codeReads).toBe(2); }); - it('authenticates numbered-anchor-dependent failures before exposing them', async () => { - for (const [postHash, expectedCode] of [ - [OTHER_BLOCK_HASH, 'finalized-state-unavailable'], - [BLOCK_HASH, 'no-code'], - ] as const) { - const server = await rpcHarness.start((rpcCall, response) => { - switch (rpcCall.method) { - case 'eth_chainId': - sendJsonRpcResult(response, rpcCall, CHAIN_QUANTITY); - return; - case 'eth_getBlockByNumber': - sendJsonRpcResult(response, rpcCall, { - number: '0x7b', - hash: rpcCall.params[0] === 'finalized' ? BLOCK_HASH : postHash, - }); - return; - case 'eth_getCode': - sendJsonRpcResult(response, rpcCall, '0x'); - return; - default: - sendJsonRpcError(response, rpcCall, -32601, 'method not found'); - } - }); - const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ - chainId: CHAIN_ID, - endpoints: [server.url], - blockReferenceProfile: 'trusted-block-number-hash-sandwich', - }); - - await expect(withSnapshot(request(), async (session) => ( - session.read([call(FIRST_DATA)]) - ))).rejects.toMatchObject({ code: expectedCode }); - expect(server.calls.filter(({ method }) => method === 'eth_call')).toHaveLength(0); + it('authenticates every numbered-anchor-dependent failure before exposing it', async () => { + const failureCases = [ + { kind: 'no-code', stableCode: 'no-code', maxReturnBytes: 2 }, + { kind: 'revert', stableCode: 'revert', maxReturnBytes: 2 }, + { kind: 'malformed-return', stableCode: 'malformed-return', maxReturnBytes: 1 }, + { kind: 'resource-limit', stableCode: 'resource-limit', maxReturnBytes: 2 }, + ] as const; + for (const failure of failureCases) { + for (const [postHash, expectedCode] of [ + [OTHER_BLOCK_HASH, 'finalized-state-unavailable'], + [BLOCK_HASH, failure.stableCode], + ] as const) { + const server = await rpcHarness.start((rpcCall, response) => { + switch (rpcCall.method) { + case 'eth_chainId': + sendJsonRpcResult(response, rpcCall, CHAIN_QUANTITY); + return; + case 'eth_getBlockByNumber': + sendJsonRpcResult(response, rpcCall, { + number: '0x7b', + hash: rpcCall.params[0] === 'finalized' ? BLOCK_HASH : postHash, + }); + return; + case 'eth_getCode': + sendJsonRpcResult(response, rpcCall, failure.kind === 'no-code' ? '0x' : '0x6000'); + return; + case 'eth_call': + if (failure.kind === 'revert') { + sendJsonRpcError(response, rpcCall, 3, 'execution reverted'); + } else if (failure.kind === 'resource-limit') { + sendJsonRpcError(response, rpcCall, -32000, 'out of gas'); + } else { + sendJsonRpcResult(response, rpcCall, '0xaaaa'); + } + return; + default: + sendJsonRpcError(response, rpcCall, -32601, 'method not found'); + } + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + }); + + await expect(withSnapshot(request(), async (session) => ( + session.read([call(FIRST_DATA, failure.maxReturnBytes)]) + ))).rejects.toMatchObject({ code: expectedCode }); + const expectedCalls = failure.kind === 'no-code' ? 0 : 1; + expect(server.calls.filter(({ method }) => method === 'eth_call')) + .toHaveLength(expectedCalls); + expect(server.calls.filter(({ method }) => method === 'eth_chainId')) + .toHaveLength(1); + } } }); From bbfa00db30f76fc3e2f2df3f9f1f7f62bb65adcb Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 12:41:19 +0200 Subject: [PATCH 223/292] fix(chain): own unawaited snapshot read rejection --- ...rict-current-finalized-evm-snapshot-rpc.ts | 39 +++++++++++++------ ...urrent-finalized-evm-snapshot.unit.test.ts | 38 ++++++++++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts index 01fe0c4282..b69e647629 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -242,35 +242,46 @@ async function executePinnedSnapshotScope( const deployedTargets = new Set(); let active = true; let inFlight: Promise | undefined; - const read = Object.freeze(async ( + const read = Object.freeze(( inputCalls: readonly StrictCurrentFinalizedEvmReadCallV1[], ): Promise => { - if (!active) throw unavailable('Current-finalized snapshot session is closed'); + if (!active) { + return handledRejectedRead(unavailable('Current-finalized snapshot session is closed')); + } if (inFlight !== undefined) { - throw new CurrentFinalizedEvmCallErrorV1( + return handledRejectedRead(new CurrentFinalizedEvmCallErrorV1( 'concurrency-saturated', 'Current-finalized snapshot permits only one dynamic batch at a time', - ); + )); + } + let calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; + try { + calls = snapshotSnapshotCalls(inputCalls); + } catch (cause) { + return handledRejectedRead(cause); } - const calls = snapshotSnapshotCalls(inputCalls); try { budget.consume(calls); } catch { - throw resourceLimited( + return handledRejectedRead(resourceLimited( 'Current-finalized snapshot exceeded its fixed scan budget', - ); + )); } - let operation!: Promise; - operation = executeSnapshotBatch( + const operation = executeSnapshotBatch( config, preflight.anchor, calls, deployedTargets, rpc, totalDeadline, - ).finally(() => { + ); + const clearInFlight = () => { if (inFlight === operation) inFlight = undefined; - }); + }; + // Attach both handlers to the same promise exposed to the consumer. This + // lets the scope own/drain an unawaited rejection without creating a + // second rejecting wrapper promise that can surface as unhandled. + void operation.then(clearInFlight, clearInFlight); inFlight = operation; return operation; }); @@ -328,6 +339,12 @@ async function executePinnedSnapshotScope( return result; } +function handledRejectedRead(cause: unknown): Promise { + const rejected = Promise.reject(cause); + void rejected.catch(() => undefined); + return rejected; +} + async function executeSnapshotBatch( config: StrictFinalizedSnapshotRpcConfigV1, anchor: FinalizedAnchorV1, diff --git a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts index 9e7b536468..b58553296b 100644 --- a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts @@ -377,6 +377,44 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { }); }); + it('owns the exact rejected promise returned by an unawaited snapshot read', async () => { + const callStarted = deferred(); + const releaseCall = deferred(); + const baseHandler = successfulHandler(); + const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { + if (rpcCall.method !== 'eth_call') { + await baseHandler(rpcCall, response, rawRequest); + return; + } + callStarted.resolve(undefined); + await releaseCall.promise; + sendJsonRpcError(response, rpcCall, -32000, 'forced rejection'); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + const unhandled: unknown[] = []; + const onUnhandled = (cause: unknown) => unhandled.push(cause); + process.on('unhandledRejection', onUnhandled); + try { + const scope = withSnapshot(request(), async (session) => { + void session.read([call(FIRST_DATA)]); + return 'must-not-escape'; + }); + await callStarted.promise; + releaseCall.resolve(undefined); + await expect(scope).rejects.toMatchObject({ + code: 'rpc-unavailable', + message: expect.stringContaining('unawaited read'), + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + it('rejects concurrent dynamic batches and more than four calls before extra I/O', async () => { const callStarted = deferred(); const releaseCall = deferred(); From bf293375183c9bad18879bc3e83f0d19d5562973 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:01:34 +0200 Subject: [PATCH 224/292] refactor(chain): own finalized batch validation --- ...ct-current-finalized-evm-batch-executor.ts | 40 +++++++++++++++++-- .../src/strict-current-finalized-evm-rpc.ts | 31 -------------- ...rict-current-finalized-evm-snapshot-rpc.ts | 4 -- 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts index 54a3986217..0c60cfd61d 100644 --- a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts +++ b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts @@ -3,10 +3,12 @@ import type { EvmAddressV1 } from '@origintrail-official/dkg-core'; import { CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, + CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; +const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; export interface StrictFinalizedEvmBatchExecutorInputV1 { readonly calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; @@ -14,8 +16,6 @@ export interface StrictFinalizedEvmBatchExecutorInputV1 { readonly deployedTargets?: ReadonlySet; readonly rpc: (method: string, params: readonly unknown[]) => Promise; readonly settle: (operations: readonly Promise[]) => Promise; - readonly assertDeployedCode: (input: unknown) => void; - readonly parseContractReturn: (input: unknown, maxBytes: number) => string; } export interface StrictFinalizedEvmBatchExecutorResultV1 { @@ -35,7 +35,7 @@ export async function executeStrictFinalizedEvmBatchV1( const uncheckedTargets = [...new Set(input.calls.map(({ to }) => to))] .filter((to) => !input.deployedTargets?.has(to)); const verifiedTargets = await input.settle(uncheckedTargets.map(async (to) => { - input.assertDeployedCode(await input.rpc( + assertDeployedCode(await input.rpc( 'eth_getCode', Object.freeze([to, input.blockReference]), )); @@ -48,7 +48,7 @@ export async function executeStrictFinalizedEvmBatchV1( data: call.data, gas: RPC_CALL_GAS_QUANTITY, }); - return input.parseContractReturn( + return parseContractReturn( await input.rpc('eth_call', Object.freeze([callObject, input.blockReference])), call.maxReturnBytes, ); @@ -58,3 +58,35 @@ export async function executeStrictFinalizedEvmBatchV1( verifiedTargets: Object.freeze([...verifiedTargets]), }); } + +function assertDeployedCode(input: unknown): void { + if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + 'eth_getCode returned malformed code bytes', + ); + } + if (input === '0x') { + throw new CurrentFinalizedEvmCallErrorV1( + 'no-code', + 'Finalized-read target has no deployed code at the resolved anchor', + ); + } +} + +function parseContractReturn(input: unknown, maxBytes: number): string { + if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + 'Finalized eth_call returned malformed bytes', + ); + } + const byteLength = (input.length - 2) / 2; + if (byteLength > maxBytes) { + throw new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + `Finalized eth_call returned ${byteLength} bytes; limit ${maxBytes}`, + ); + } + return input; +} diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 3b6cd76f94..55782b1af5 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -297,8 +297,6 @@ async function executeEndpointAttempt( blockReference, rpc, settle: (operations) => settleParallelBatch(operations, attemptDeadline), - assertDeployedCode, - parseContractReturn, }); return batch.returnData; }; @@ -587,35 +585,6 @@ export function parseFinalizedAnchor(input: unknown, label: string): FinalizedAn }); } -export function assertDeployedCode(input: unknown): void { - if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { - throw unavailable('eth_getCode returned malformed code bytes'); - } - if (input === '0x') { - throw new CurrentFinalizedEvmCallErrorV1( - 'no-code', - 'Finalized-read target has no deployed code at the resolved anchor', - ); - } -} - -export function parseContractReturn(input: unknown, maxBytes: number): string { - if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { - throw new CurrentFinalizedEvmCallErrorV1( - 'malformed-return', - 'Finalized eth_call returned malformed bytes', - ); - } - const byteLength = (input.length - 2) / 2; - if (byteLength > maxBytes) { - throw new CurrentFinalizedEvmCallErrorV1( - 'malformed-return', - `Finalized eth_call returned ${byteLength} bytes; limit ${maxBytes}`, - ); - } - return input; -} - function parseCanonicalQuantity(input: unknown, maximum: bigint): bigint { if (typeof input !== 'string' || !CANONICAL_LOWER_QUANTITY.test(input)) { throw new Error('not a canonical lowercase JSON-RPC quantity'); diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts index b69e647629..124d32fdec 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -20,14 +20,12 @@ import { import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; import { - assertDeployedCode, assertStrictFinalizedAnchorStableV1, cancelled, createDeadlineScope, executeStrictFinalizedAnchorPolicyV1, isTerminalAttemptFailure, parseChainId, - parseContractReturn, parseFinalizedAnchor, postJsonRpc, resourceLimited, @@ -359,8 +357,6 @@ async function executeSnapshotBatch( deployedTargets, rpc, settle: (operations) => settleParallelBatch(operations, totalDeadline), - assertDeployedCode, - parseContractReturn, }); const batch = await executeStrictFinalizedAnchorPolicyV1({ From 223eb5906accb57a65ad9b9b4ee7213d6c84485e Mon Sep 17 00:00:00 2001 From: Jurij89 <138491694+Jurij89@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:16:03 -0400 Subject: [PATCH 225/292] fix(agent): retry transient chain reads during durable sync (#1904) * fix(sync): probe explicitly selected catchup peer * fix(sync): keep incomplete reconnect progress non-fresh * fix(sync): preserve incomplete durable aggregate * fix(agent): retry transient chain reads during durable sync * fix(agent): preserve typed chain retry semantics * fix(agent): narrow durable authentication retries * fix(agent): bound durable chain authentication * test: use valid receipts for update rejection cases * refactor: isolate cancellable RPC transport * fix(chain): close authentication cancellation gaps --------- Co-authored-by: Branimir Rakic Co-authored-by: Branimir Rakic <33914812+branarakic@users.noreply.github.com> Co-authored-by: Jurij Skornik --- packages/agent/src/dkg-agent-crypto.ts | 278 ++++++++++-------- packages/agent/src/dkg-agent-lifecycle.ts | 161 +++++++++- packages/agent/src/sync/error-tags.ts | 3 +- .../agent/src/sync/requester/durable-sync.ts | 3 +- .../requester/graph-scoped-materialization.ts | 29 +- ...-sync-graph-scoped-materialization.test.ts | 141 ++++++++- .../durable-sync-lifecycle-binding.test.ts | 252 +++++++++++++++- packages/agent/test/oversize-filter.test.ts | 16 + .../test/swm-public-cg-plaintext.test.ts | 40 +++ packages/chain/src/chain-adapter.ts | 30 +- packages/chain/src/evm-adapter-base.ts | 25 +- .../chain/src/evm-adapter-context-graph.ts | 25 +- packages/chain/src/evm-adapter-publish.ts | 45 ++- packages/chain/src/evm-adapter-rpc.ts | 5 + .../chain/src/evm-adapter-storage-reads.ts | 31 +- packages/chain/src/rpc-failover-client.ts | 34 ++- packages/chain/src/rpc-request-transport.ts | 108 +++++++ packages/chain/src/rpc-usage.ts | 4 +- packages/chain/test/evm-adapter.unit.test.ts | 39 ++- packages/chain/test/loopback-rpc-harness.ts | 23 +- .../chain/test/mock-adapter-parity.test.ts | 2 + .../test/multi-rpc-read-failover.test.ts | 122 +++++++- packages/chain/test/rpc-usage.unit.test.ts | 37 +++ packages/publisher/test/ka-update.test.ts | 4 +- .../test/security-regressions.test.ts | 6 +- 25 files changed, 1236 insertions(+), 227 deletions(-) create mode 100644 packages/chain/src/rpc-request-transport.ts diff --git a/packages/agent/src/dkg-agent-crypto.ts b/packages/agent/src/dkg-agent-crypto.ts index 434cc85138..8141890fdc 100644 --- a/packages/agent/src/dkg-agent-crypto.ts +++ b/packages/agent/src/dkg-agent-crypto.ts @@ -96,7 +96,7 @@ import { pickNetworkTunables, } from '@origintrail-official/dkg-core'; import { GraphManager, PrivateContentStore, createTripleStore, type TripleStore, type TripleStoreConfig, type Quad, type LargeLiteralStorageConfig } from '@origintrail-official/dkg-storage'; -import { EVMChainAdapter, NoChainAdapter, enrichEvmError, type EVMAdapterConfig, type ChainAdapter, type CreateContextGraphParams, type CreateOnChainContextGraphParams, type CreateOnChainContextGraphResult, type TxResult, type V10PublishingConvictionAccountInfo } from '@origintrail-official/dkg-chain'; +import { EVMChainAdapter, NoChainAdapter, createRpcTimeoutError, enrichEvmError, type EVMAdapterConfig, type ChainAdapter, type CreateContextGraphParams, type CreateOnChainContextGraphParams, type CreateOnChainContextGraphResult, type TxResult, type V10PublishingConvictionAccountInfo } from '@origintrail-official/dkg-chain'; import { DKGPublisher, PublishHandler, SharedMemoryHandler, UpdateHandler, ChainEventPoller, AccessHandler, AccessClient, PublishJournal, StaleWriteError, @@ -418,6 +418,110 @@ function collectProjectedDelegatees( return out; } +type ContextGraphSlotBindingOutcome = + | { kind: 'match' } + | { kind: 'mismatch' } + | { kind: 'unprovable' } + | { kind: 'transportFailure'; error: unknown }; + +type ContextGraphSlotBindingPolicy = 'legacy' | 'compatibilityStrict' | 'retryableStrict'; + +function mapContextGraphSlotBindingOutcome( + outcome: ContextGraphSlotBindingOutcome, + policy: ContextGraphSlotBindingPolicy, +): boolean { + if (outcome.kind === 'match') return true; + if (outcome.kind === 'unprovable') return policy === 'legacy'; + if (outcome.kind === 'transportFailure' && policy === 'retryableStrict') { + throw outcome.error; + } + return false; +} + +async function evaluateContextGraphSlotBinding( + chain: ChainAdapter, + contextGraphId: string, + onChainId: string, + opCtx: OperationContext | undefined, + signal: AbortSignal | undefined, + isWireIdKeyedSubscription: (localId: string) => boolean, + warn: (ctx: OperationContext, message: string) => void, + raceRead: (read: Promise) => Promise, +): Promise { + let numericId: bigint; + try { + numericId = BigInt(onChainId); + } catch { + return { kind: 'unprovable' }; + } + if (numericId <= 0n) return { kind: 'unprovable' }; + + const trimmed = contextGraphId.trim(); + if (/^\d+$/.test(trimmed) && trimmed === numericId.toString()) { + return { kind: 'match' }; + } + const getNameHash = chain.getContextGraphNameHash; + if (typeof getNameHash !== 'function') return { kind: 'unprovable' }; + + const acceptable = new Set(); + try { + acceptable.add(ethers.keccak256(ethers.toUtf8Bytes(trimmed)).toLowerCase()); + } catch { + return { kind: 'unprovable' }; + } + if (/^0x[0-9a-fA-F]{64}$/.test(trimmed) && isWireIdKeyedSubscription(trimmed)) { + acceptable.add(trimmed.toLowerCase()); + } + + let onChainHash: string | null | typeof TIMEOUT_SENTINEL; + try { + const read = signal + ? getNameHash.call(chain, numericId, { signal }) + : getNameHash.call(chain, numericId); + onChainHash = await raceRead(read); + } catch (error) { + warn( + opCtx ?? createOperationContext('share'), + `isContextGraphPublicOnChain(${contextGraphId}): getContextGraphNameHash(${onChainId}) failed — ` + + 'cannot verify local-mapping identity, treating CG as NOT public (fail-closed): ' + + `${error instanceof Error ? error.message : String(error)}`, + ); + return { kind: 'transportFailure', error }; + } + if (onChainHash === TIMEOUT_SENTINEL) { + warn( + opCtx ?? createOperationContext('share'), + `isContextGraphPublicOnChain(${contextGraphId}): getContextGraphNameHash(${onChainId}) timed out after ` + + `${CHAIN_POLICY_READ_TIMEOUT_MS}ms — cannot verify local-mapping identity, ` + + 'treating CG as NOT public (fail-closed)', + ); + return { + kind: 'transportFailure', + error: createRpcTimeoutError( + `getContextGraphNameHash(${onChainId}) timed out after ${CHAIN_POLICY_READ_TIMEOUT_MS}ms`, + ), + }; + } + if (!onChainHash) { + warn( + opCtx ?? createOperationContext('share'), + `isContextGraphPublicOnChain(${contextGraphId}): locally-mapped on-chain id ${onChainId} has NO ` + + 'committed name-hash — cannot affirmatively bind identity (slot reused on a fresh chain?). ' + + 'Treating CG as NOT public (fail-closed).', + ); + return { kind: 'mismatch' }; + } + if (acceptable.has(onChainHash.toLowerCase())) return { kind: 'match' }; + + warn( + opCtx ?? createOperationContext('share'), + `isContextGraphPublicOnChain(${contextGraphId}): locally-mapped on-chain id ${onChainId} commits ` + + `name-hash ${onChainHash.toLowerCase()} ≠ this CG's expected wire id(s) ${[...acceptable].join(' | ')} — ` + + 'local mapping is STALE (slot reused on a fresh chain?). Treating CG as NOT public (fail-closed).', + ); + return { kind: 'mismatch' }; +} + export class WorkspaceCryptoMethods extends DKGAgentBase { getWorkspaceGossipSigningAgent(this: DKGAgent): (AgentKeyRecord & { privateKey: string }) | null { const defaultAddress = this.defaultAgentAddress?.toLowerCase(); @@ -961,142 +1065,58 @@ export class WorkspaceCryptoMethods extends DKGAgentBase { * hash-shaped cleartext id can't borrow a reused slot's commitment. * A genuinely reused slot commits a DIFFERENT name that matches neither. * - * Returns `false` (→ caller fails closed) on an AFFIRMATIVE mismatch (the - * committed hash matches neither derivation); whenever the hash cannot be - * verified once the adapter EXPOSES `getContextGraphNameHash` (RPC rejection or - * read timeout — #884 review 🔴 GZ8L_); AND when the slot has NO committed - * name-hash at all (`null` / empty — #884 review 🔴 GaZk2). A missing - * commitment is NOT an identity proof: a devnet-reset slot reused by a - * different no-commitment public CG would otherwise disable encryption for the - * wrong local graph, so a downgrade decision requires an affirmative binding. - * Returns `true` (proceed to the liveness/policy gate) where the adapter - * cannot supply the anchor at all (no `getContextGraphNameHash` getter), AND - * for a DIRECT NUMERIC SELF-ADDRESS — a local id that IS its own numeric - * on-chain slot (`onChainId === id`, e.g. a CG mirroring a raw slot created - * via the low-level `createOnChainContextGraph` path). The latter is the - * caller naming the slot directly, not a cleartext→numeric remapping, so it is - * treated like the bare-numeric raw-slot path (not identity-bound) and gated - * by liveness + fresh policy alone. - * - * `requireCommittedNameHash` is the strict durable-sync mode: unlike the - * legacy policy probe, it rejects malformed ids and adapters that cannot - * prove a non-numeric local id from the chain commitment. The canonical - * direct numeric self-address remains valid in either mode. + * The default legacy policy probe maps malformed/unprovable identifiers to + * `true`, but maps mismatches and transport failures to `false`. Its preserved + * `requireCommittedNameHash` option maps every non-match to `false`, including + * malformed ids and adapters without the getter. Durable sync uses the strict + * wrapper below, which additionally propagates transport failures so a bounded + * retry can perform a fresh read. All modes accept a canonical direct numeric + * self-address because it names the slot rather than a cleartext remapping. */ async localCgMatchesOnChainSlot(this: DKGAgent, contextGraphId: string, onChainId: string, opCtx?: OperationContext, - options?: { requireCommittedNameHash?: boolean }, + options?: { requireCommittedNameHash?: boolean; signal?: AbortSignal }, ): Promise { - let numericId: bigint; - try { - numericId = BigInt(onChainId); - } catch { - return options?.requireCommittedNameHash !== true; - } - if (numericId <= 0n) return options?.requireCommittedNameHash !== true; - - const trimmed = contextGraphId.trim(); - // DIRECT NUMERIC SELF-ADDRESS: a local CG whose own id IS its numeric - // on-chain slot (`getContextGraphOnChainId('42') === '42'` — e.g. a CG - // created to mirror a raw slot via the low-level createOnChainContextGraph - // path, whose local id is the numeric id itself) is NOT a cleartext→numeric - // indirection. It is the caller naming the slot directly, identical to the - // bare-numeric raw-slot path which is intentionally NOT identity-bound - // (#884 review GZEqF test). There is no separate committed cleartext name to - // bind against here (any curator name-hash is unrelated to the numeric id), - // so name-hash binding is inapplicable — defer to the liveness + fresh-policy - // gate. The stale-mapping risk the name-hash defends against (#884 review - // 🔴 GaZk2) only exists for a cleartext id that REMAPS to a different slot. - if (/^\d+$/.test(trimmed) && trimmed === numericId.toString()) return true; - const getNameHash = this.chain.getContextGraphNameHash; - if (typeof getNameHash !== 'function') { - return options?.requireCommittedNameHash !== true; - } - // A locally-resolved (cleartext) id can be committed two ways, and both are - // legitimate (#884 review 🔴 GZumc + 🔴 GaJf_), so accept a match against EITHER: - // - CLEARTEXT (always): a curator-created CG stores its cleartext id (even - // one that happens to look like a 0x+64-hex string), and registration - // commits keccak256(utf8(cleartextId)). → keccak(utf8(trimmed)). - // - WIRE-FORM (conditional): a host-only/core subscription is keyed by the - // wire id ITSELF (cleartext never left the curator), so the local id - // already IS the committed nameHash. → trimmed verbatim. - // The WIRE-FORM branch is only added when LOCAL metadata AFFIRMATIVELY proves - // this subscription is wire-id keyed (#884 review 🔴 GaZky). Accepting the - // verbatim value for EVERY 0x+64-hex id would make the gate ambiguous between - // a real host-mode wire id and a user-chosen cleartext id that merely looks - // hash-shaped — a reused/unrelated slot whose committed nameHash equalled - // that raw string would then falsely pass. A genuinely reused slot commits a - // DIFFERENT name that matches NEITHER accepted form, so this still fails - // closed on a true stale mapping while never forcing a wire-keyed host CG - // down the fail-closed path forever. - const acceptable = new Set(); - try { - acceptable.add(ethers.keccak256(ethers.toUtf8Bytes(trimmed)).toLowerCase()); - } catch { - return options?.requireCommittedNameHash !== true; - } - if (/^0x[0-9a-fA-F]{64}$/.test(trimmed) && this.isWireIdKeyedSubscription(trimmed)) { - acceptable.add(trimmed.toLowerCase()); - } - - let onChainHash: string | null | typeof TIMEOUT_SENTINEL; - try { - onChainHash = await this.raceChainPolicyRead(getNameHash.call(this.chain, numericId)); - } catch (err) { - // #884 review (🔴 GZ8L_): the adapter EXPOSES the name-hash getter (we - // passed the typeof check above), so an RPC REJECTION means we cannot - // VERIFY that the persisted local mapping still points at THIS CG. Fail - // closed — a transient flake must not re-enable the plaintext downgrade - // for a possibly devnet-reset / reused slot. (Fail-open is reserved for - // the explicit opt-out `null` below, where there is no commitment to - // check.) - this.log.warn( - opCtx ?? createOperationContext('share'), - `isContextGraphPublicOnChain(${contextGraphId}): getContextGraphNameHash(${onChainId}) failed — ` + - `cannot verify local-mapping identity, treating CG as NOT public (fail-closed): ${err instanceof Error ? err.message : String(err)}`, - ); - return false; - } - if (onChainHash === TIMEOUT_SENTINEL) { - // Same as a rejection: the getter exists but the hash couldn't be read in - // time, so the mapping identity is UNVERIFIED → fail closed. - this.log.warn( - opCtx ?? createOperationContext('share'), - `isContextGraphPublicOnChain(${contextGraphId}): getContextGraphNameHash(${onChainId}) timed out after ` + - `${CHAIN_POLICY_READ_TIMEOUT_MS}ms — cannot verify local-mapping identity, treating CG as NOT public (fail-closed)`, - ); - return false; - } - // MISSING commitment (`null` / empty): the slot has NO on-chain name-hash. - // This is NOT an affirmative identity proof and must NOT re-enable the - // plaintext downgrade (#884 review 🔴 GaZk2). After a devnet reset the - // persisted `localId → onChainId` mapping can point at a DIFFERENT public CG - // that ALSO never committed a name-hash; trusting `null` would then disable - // SWM encryption for the WRONG local graph. A downgrade (encrypt→plaintext) - // decision requires an AFFIRMATIVE binding, so a missing commitment fails - // closed. (Fail-open is reserved for the no-getter case above, where the - // adapter cannot supply the anchor at all — distinct from a present getter - // returning "no commitment".) - if (!onChainHash) { - this.log.warn( - opCtx ?? createOperationContext('share'), - `isContextGraphPublicOnChain(${contextGraphId}): locally-mapped on-chain id ${onChainId} has NO ` + - `committed name-hash — cannot affirmatively bind identity (slot reused on a fresh chain?). ` + - `Treating CG as NOT public (fail-closed).`, - ); - return false; - } - if (acceptable.has(onChainHash.toLowerCase())) return true; + const outcome = await evaluateContextGraphSlotBinding( + this.chain, + contextGraphId, + onChainId, + opCtx, + options?.signal, + (localId) => this.isWireIdKeyedSubscription(localId), + (ctx, message) => this.log.warn(ctx, message), + (read) => this.raceChainPolicyRead(read), + ); + return mapContextGraphSlotBindingOutcome( + outcome, + options?.requireCommittedNameHash === true ? 'compatibilityStrict' : 'legacy', + ); + } - this.log.warn( - opCtx ?? createOperationContext('share'), - `isContextGraphPublicOnChain(${contextGraphId}): locally-mapped on-chain id ${onChainId} commits ` + - `name-hash ${onChainHash.toLowerCase()} ≠ this CG's expected wire id(s) ${[...acceptable].join(' | ')} — ` + - `local mapping is STALE (slot reused on a fresh chain?). Treating CG as NOT public (fail-closed).`, + /** + * Strict durable-sync identity proof. Unlike the legacy policy probe, this + * rejects malformed or unprovable mappings and propagates chain transport + * failures so authentication can retry a fresh read. + */ + async requireLocalCgMatchesOnChainSlot(this: DKGAgent, + contextGraphId: string, + onChainId: string, + opCtx?: OperationContext, + options: { signal?: AbortSignal } = {}, + ): Promise { + const outcome = await evaluateContextGraphSlotBinding( + this.chain, + contextGraphId, + onChainId, + opCtx, + options.signal, + (localId) => this.isWireIdKeyedSubscription(localId), + (ctx, message) => this.log.warn(ctx, message), + (read) => this.raceChainPolicyRead(read), ); - return false; + return mapContextGraphSlotBindingOutcome(outcome, 'retryableStrict'); } /** diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 437af21db6..188af464c8 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -96,6 +96,7 @@ import { type SubscriptionSource, SUBSCRIPTION_SOURCES, pickNetworkTunables, + withRetry, } from '@origintrail-official/dkg-core'; import { GraphManager, PrivateContentStore, createTripleStore, asChangelogReader, tryReplaceGraphAtomically, type ChangelogReader, type TripleStore, type TripleStoreConfig, type Quad, type LargeLiteralStorageConfig } from '@origintrail-official/dkg-storage'; import { readChangelogDeltaPage } from './sync/responder/graph-plan.js'; @@ -104,8 +105,24 @@ import { runChangelogSync, planPageApply } from './sync/requester/changelog-sync import { authenticateVerifiedGraphScopedAsset, materializeVerifiedGraphScopedAsset, + type VerifiedGraphScopedAsset, + type VerifyContextGraphBinding, } from './sync/requester/graph-scoped-materialization.js'; -import { EVMChainAdapter, NoChainAdapter, enrichEvmError, buildKnowledgeAssetUal, type EVMAdapterConfig, type ChainAdapter, type CreateContextGraphParams, type CreateOnChainContextGraphParams, type CreateOnChainContextGraphResult, type TxResult, type V10PublishingConvictionAccountInfo } from '@origintrail-official/dkg-chain'; +import { + EVMChainAdapter, + NoChainAdapter, + buildKnowledgeAssetUal, + createRpcTimeoutError, + enrichEvmError, + isChainRpcTransportError, + type ChainAdapter, + type CreateContextGraphParams, + type CreateOnChainContextGraphParams, + type CreateOnChainContextGraphResult, + type EVMAdapterConfig, + type TxResult, + type V10PublishingConvictionAccountInfo, +} from '@origintrail-official/dkg-chain'; import { DKGPublisher, PublishHandler, SharedMemoryHandler, UpdateHandler, ChainEventPoller, AccessHandler, AccessClient, PublishJournal, StaleWriteError, @@ -782,6 +799,105 @@ export type DurableSyncOptions = { }; const MAX_DURABLE_SYNC_TOTAL_TIMEOUT_MS = 300_000; +const DURABLE_AUTHENTICATION_MAX_ATTEMPTS = 5; +const DURABLE_AUTHENTICATION_RETRY_BASE_MS = 1_000; +const DURABLE_AUTHENTICATION_RETRY_MAX_MS = 8_000; +const DURABLE_AUTHENTICATION_ATTEMPT_TIMEOUT_MS = 15_000; + +function raceAuthenticationWithSignal(work: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener('abort', onAbort); + const onAbort = () => { + cleanup(); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + work.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + if (signal.aborted) onAbort(); + }); +} + +async function authenticateDurableGraphScopedAsset(params: { + chain: ChainAdapter; + asset: VerifiedGraphScopedAsset; + verifyContextGraphBinding: VerifyContextGraphBinding; + deadline: number; + onRetry: (error: unknown, attempt: number, maxAttempts: number) => void; +}) { + const { chain, asset, verifyContextGraphBinding, deadline, onRetry } = params; + const receivedAt = new Date(); + const deadlineError = createRpcTimeoutError( + `Graph-scoped durable authentication for ${asset.ual} exceeded its context-graph deadline`, + ); + const remaining = deadline - Date.now(); + if (remaining <= 0) throw deadlineError; + + const deadlineController = new AbortController(); + const deadlineTimer = setTimeout( + () => deadlineController.abort(deadlineError), + remaining, + ); + try { + return await withRetry( + async () => { + const attemptRemaining = deadline - Date.now(); + if (attemptRemaining <= 0) throw deadlineError; + const attemptError = createRpcTimeoutError( + `Graph-scoped durable authentication attempt for ${asset.ual} timed out`, + ); + const attemptController = new AbortController(); + const abortFromDeadline = () => attemptController.abort(deadlineController.signal.reason); + deadlineController.signal.addEventListener('abort', abortFromDeadline, { once: true }); + const attemptTimer = setTimeout( + () => attemptController.abort(attemptError), + Math.min(DURABLE_AUTHENTICATION_ATTEMPT_TIMEOUT_MS, attemptRemaining), + ); + if (deadlineController.signal.aborted) abortFromDeadline(); + try { + return await raceAuthenticationWithSignal( + authenticateVerifiedGraphScopedAsset( + chain, + asset, + verifyContextGraphBinding, + receivedAt, + { signal: attemptController.signal }, + ), + attemptController.signal, + ); + } finally { + if (!attemptController.signal.aborted) attemptController.abort(); + clearTimeout(attemptTimer); + deadlineController.signal.removeEventListener('abort', abortFromDeadline); + } + }, + { + maxAttempts: DURABLE_AUTHENTICATION_MAX_ATTEMPTS, + baseDelayMs: DURABLE_AUTHENTICATION_RETRY_BASE_MS, + maxDelayMs: DURABLE_AUTHENTICATION_RETRY_MAX_MS, + isRetryable: isChainRpcTransportError, + signal: deadlineController.signal, + onRetry: (attempt, _delayMs, error) => { + onRetry(error, attempt, DURABLE_AUTHENTICATION_MAX_ATTEMPTS); + }, + }, + ); + } catch (error) { + if (deadlineController.signal.aborted) throw deadlineError; + throw error; + } finally { + clearTimeout(deadlineTimer); + } +} function normalizeDurableSyncTotalTimeoutMs(value: number | undefined): number { if (typeof value !== 'number' || !Number.isFinite(value)) return SYNC_TOTAL_TIMEOUT_MS; @@ -4164,27 +4280,39 @@ export class LifecycleSyncMethods extends DKGAgentBase { // The CG name commitment is immutable for an on-chain slot. Prove a // local/on-chain binding once per durable-sync invocation, then reuse the // successful proof for every KA in that batch. Without this operation- - // scoped cache, a large CG performs one RPC name-hash read per KA and a - // single transient timeout aborts the rest of an otherwise verified page. - // Rejections remain fail-closed and are never reused by a later operation. + // scoped cache, a large CG performs one RPC name-hash read per KA. Only an + // affirmative proof remains cached; false results and read failures are + // evicted so a bounded authentication retry performs a fresh chain read. const contextGraphBindingProofs = new Map>(); - const verifyContextGraphBinding = ( + const verifyContextGraphBinding = async ( localContextGraphId: string, onChainContextGraphId: bigint, + signal?: AbortSignal, ): Promise => { const onChainId = onChainContextGraphId.toString(); const key = `${localContextGraphId}\0${onChainId}`; let proof = contextGraphBindingProofs.get(key); if (!proof) { - proof = this.localCgMatchesOnChainSlot( + proof = this.requireLocalCgMatchesOnChainSlot( localContextGraphId, onChainId, ctx, - { requireCommittedNameHash: true }, + { signal }, ); contextGraphBindingProofs.set(key, proof); } - return proof; + try { + const matches = await proof; + if (!matches && contextGraphBindingProofs.get(key) === proof) { + contextGraphBindingProofs.delete(key); + } + return matches; + } catch (error) { + if (contextGraphBindingProofs.get(key) === proof) { + contextGraphBindingProofs.delete(key); + } + throw error; + } }; return runDurableSync({ ctx, @@ -4230,12 +4358,21 @@ export class LifecycleSyncMethods extends DKGAgentBase { priority: 'background', source: 'agent.durableSync.storeInsert', }), - storeGraphScopedAsset: async (asset) => { - const authentication = await authenticateVerifiedGraphScopedAsset( - this.chain, + storeGraphScopedAsset: async (asset, deadline) => { + const authentication = await authenticateDurableGraphScopedAsset({ + chain: this.chain, asset, verifyContextGraphBinding, - ); + deadline, + onRetry: (error, attempt, maxAttempts) => { + this.log.warn( + ctx, + `Retrying graph-scoped durable authentication for ${asset.ual} ` + + `after transient chain verification failure (${attempt}/${maxAttempts}): ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + }, + }); const verifiedOnChainId = authentication.onChainContextGraphId; const subscription = this.subscribedContextGraphs.get(asset.contextGraphId); if (verifiedOnChainId && subscription && subscription.onChainId !== verifiedOnChainId) { diff --git a/packages/agent/src/sync/error-tags.ts b/packages/agent/src/sync/error-tags.ts index 83ac107711..170c2ae1b1 100644 --- a/packages/agent/src/sync/error-tags.ts +++ b/packages/agent/src/sync/error-tags.ts @@ -1,4 +1,5 @@ import { isOversizedRdfLiteralError } from '@origintrail-official/dkg-core'; +import { isChainRpcTransportError } from '@origintrail-official/dkg-chain'; type SyncErrorTag = 'syncPeerResponded' | 'syncTransportFailure'; @@ -53,7 +54,7 @@ export function isSyncPermanentRejection(error: unknown): boolean { } export function isSyncBackoffWorthyError(error: unknown): boolean { - if (isSyncTransportFailure(error)) return true; + if (isSyncTransportFailure(error) || isChainRpcTransportError(error)) return true; const message = error instanceof Error ? error.message.toLowerCase() diff --git a/packages/agent/src/sync/requester/durable-sync.ts b/packages/agent/src/sync/requester/durable-sync.ts index 1a2d637c5f..b2a5003b0a 100644 --- a/packages/agent/src/sync/requester/durable-sync.ts +++ b/packages/agent/src/sync/requester/durable-sync.ts @@ -125,6 +125,7 @@ interface DurableSyncContext { /** Exact replacement path for verified V2 KAs; absent capability fails closed. */ storeGraphScopedAsset?: ( asset: VerifiedGraphScopedAsset, + deadline: number, ) => Promise; /** Runs after verified snapshot writes and before phase checkpoints advance. */ onVerifiedFullSnapshot?: (snapshot: VerifiedFullSnapshot) => Promise; @@ -535,7 +536,7 @@ export async function runDurableSync( ); } for (const asset of partitioned.assets) { - const outcome = await storeGraphScopedAsset!(asset); + const outcome = await storeGraphScopedAsset!(asset, deadline); if (outcome === 'applied') { // Materialization is atomic per asset, not per fetched page. Account // for each committed asset immediately so a later asset failure does diff --git a/packages/agent/src/sync/requester/graph-scoped-materialization.ts b/packages/agent/src/sync/requester/graph-scoped-materialization.ts index 2121980912..22e66c1087 100644 --- a/packages/agent/src/sync/requester/graph-scoped-materialization.ts +++ b/packages/agent/src/sync/requester/graph-scoped-materialization.ts @@ -45,6 +45,7 @@ export interface AuthenticatedGraphScopedAsset { export type VerifyContextGraphBinding = ( localContextGraphId: string, onChainContextGraphId: bigint, + signal?: AbortSignal, ) => Promise; export type GraphScopedMaterializationOutcome = 'applied' | 'stale' | 'quarantined'; @@ -61,6 +62,7 @@ export async function authenticateVerifiedGraphScopedAsset( asset: VerifiedGraphScopedAsset, verifyContextGraphBinding?: VerifyContextGraphBinding, receivedAt = new Date(), + options: { signal?: AbortSignal } = {}, ): Promise { const receivedAtMs = receivedAt.getTime(); if (!Number.isFinite(receivedAtMs)) { @@ -119,9 +121,9 @@ export async function authenticateVerifiedGraphScopedAsset( throw new Error(`Graph-scoped durable sync ${asset.ual} has ${roots.length} Merkle roots`); } const [latestRoot, rootCount, boundContextGraphId] = await Promise.all([ - chain.getLatestMerkleRoot(kaId), - chain.getMerkleRootCount(kaId), - chain.getKAContextGraphId(kaId), + chain.getLatestMerkleRoot(kaId, { signal: options.signal }), + chain.getMerkleRootCount(kaId, { signal: options.signal }), + chain.getKAContextGraphId(kaId, { signal: options.signal }), ]); if (latestRoot.length !== 32 || !bytesEqual(latestRoot, roots[0]!)) { throw Object.assign( @@ -152,7 +154,11 @@ export async function authenticateVerifiedGraphScopedAsset( { code: 'VM_CHAIN_VERIFICATION_UNSUPPORTED' }, ); } - if (!(await verifyContextGraphBinding(asset.contextGraphId, boundContextGraphId))) { + if (!(await verifyContextGraphBinding( + asset.contextGraphId, + boundContextGraphId, + options.signal, + ))) { throw Object.assign( new Error( `Graph-scoped durable sync ${asset.ual} is bound to context graph ${boundContextGraphId}, ` @@ -184,7 +190,9 @@ export async function authenticateVerifiedGraphScopedAsset( { code: 'VM_CHAIN_PROVENANCE_UNSUPPORTED' }, ); } - const resolved = await chain.resolvePublishByTxHash(transactionHash); + const resolved = await chain.resolvePublishByTxHash(transactionHash, { + signal: options.signal, + }); const resolvedKaId = resolved?.kaId ?? resolved?.batchId; if ( !resolved @@ -207,8 +215,15 @@ export async function authenticateVerifiedGraphScopedAsset( { code: 'VM_CHAIN_PROVENANCE_UNSUPPORTED' }, ); } - const publisherAddress = await chain.getLatestMerkleRootPublisher(kaId); - const verified = await chain.verifyKAUpdate(transactionHash, kaId, publisherAddress); + const publisherAddress = await chain.getLatestMerkleRootPublisher(kaId, { + signal: options.signal, + }); + const verified = await chain.verifyKAUpdate( + transactionHash, + kaId, + publisherAddress, + { signal: options.signal }, + ); if ( !verified.verified || verified.onChainMerkleRoot === undefined diff --git a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts index dcc80aa85f..9295ed65f4 100644 --- a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts +++ b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ethers } from 'ethers'; import type { OperationContext } from '@origintrail-official/dkg-core'; -import type { ChainAdapter } from '@origintrail-official/dkg-chain'; +import { + EVMChainAdapter, + type ChainAdapter, + type EVMAdapterConfig, +} from '@origintrail-official/dkg-chain'; import { LOCAL_TRUSTED_KA_CONTROLS_GRAPH, OxigraphStore, @@ -20,7 +24,9 @@ import { DKGAgent } from '../src/dkg-agent.js'; import { authenticateVerifiedGraphScopedAsset, materializeVerifiedGraphScopedAsset, + type GraphScopedMaterializationOutcome, type VerifyContextGraphBinding, + type VerifiedGraphScopedAsset, } from '../src/sync/requester/graph-scoped-materialization.js'; const DKG = 'http://dkg.io/ontology/'; @@ -32,6 +38,7 @@ const assertionGraph = `did:dkg:context-graph:${contextGraphId}/_verifiable_memo const ual = 'did:dkg:otp:2043/0x1111111111111111111111111111111111111111/1'; const packedKaId = '7719472615821079694904732333912527190217998977704089058462887978021305712641'; const ctx = { kind: 'system', id: 'test', startedAt: 0 } as OperationContext; +const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; function transactionHash(version: number): string { return `0x${version.toString(16).padStart(64, '0')}`; @@ -130,16 +137,97 @@ function strictContextGraphBindingVerifier( }; agentLike.isWireIdKeyedSubscription = (DKGAgent.prototype as any).isWireIdKeyedSubscription; agentLike.raceChainPolicyRead = (DKGAgent.prototype as any).raceChainPolicyRead; - return (localId, onChainId) => (DKGAgent.prototype as any).localCgMatchesOnChainSlot.call( + return (localId, onChainId, signal) => ( + DKGAgent.prototype as any + ).requireLocalCgMatchesOnChainSlot.call( agentLike, localId, onChainId.toString(), ctx, - { requireCommittedNameHash: true }, + { signal }, ); } +function authenticatedV2Chain(overrides: Partial = {}): ChainAdapter { + const root = new Uint8Array(32); + root[31] = 2; + return { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getLatestMerkleRootPublisher: async () => '0x2222222222222222222222222222222222222222', + verifyKAUpdate: async () => ({ + verified: true, + onChainMerkleRoot: root, + blockNumber: 123, + txIndex: 4, + merkleRootCount: 2n, + }), + ...overrides, + } as ChainAdapter; +} + +function runGraphScopedDurableSync(options: { + storeGraphScopedAsset: ( + asset: VerifiedGraphScopedAsset, + deadline: number, + ) => Promise; + createContextGraphSyncDeadline?: () => number; + deleteCheckpoint?: (key: string) => void; + setCheckpoint?: (key: string, offset: number) => void; + logWarn?: (ctx: OperationContext, message: string) => void; +}) { + const v2Data = dataQuad(2); + const v2Meta = metadata(2); + return runDurableSync({ + ctx, + remotePeerId: 'peer-graph-scoped-authentication', + contextGraphIds: [contextGraphId], + createContextGraphSyncDeadline: + options.createContextGraphSyncDeadline ?? (() => Date.now() + 60_000), + fetchSyncPages: async (_ctx, _peer, _cg, _shared, phase) => ( + phase === 'data' ? page(phase, [v2Data]) : page(phase, v2Meta) + ), + processDurableBatchInWorker: async () => ({ + verifiedData: [v2Data], + verifiedMeta: v2Meta, + verifiedGraphScopedDataGraphs: [assertionGraph], + totalFetchedDataQuads: 1, + totalFetchedMetaQuads: v2Meta.length, + rejectedKcs: 0, + emptyResponses: 0, + metaOnlyResponses: 0, + verifiedPrivateOnlyResponses: 0, + dataRejectedMissingMeta: 0, + }), + storeInsert: async () => {}, + storeGraphScopedAsset: options.storeGraphScopedAsset, + deleteCheckpoint: options.deleteCheckpoint ?? (() => {}), + setCheckpoint: options.setCheckpoint ?? (() => {}), + logInfo: () => {}, + logWarn: options.logWarn ?? (() => {}), + logDebug: () => {}, + }); +} + describe('durable graph-scoped KA materialization', () => { + it('hands the exact context-graph deadline to graph-scoped storage', async () => { + const deadline = 1_800_000_123_456; + const storeGraphScopedAsset = vi.fn(async ( + _asset: VerifiedGraphScopedAsset, + _deadline: number, + ): Promise => 'applied'); + + await runGraphScopedDurableSync({ + createContextGraphSyncDeadline: () => deadline, + storeGraphScopedAsset, + }); + + expect(storeGraphScopedAsset).toHaveBeenCalledTimes(1); + expect(storeGraphScopedAsset.mock.calls[0]?.[1]).toBe(deadline); + }); + it('adds reader-visible local metadata in no-chain mode and keeps receive time stable on replay', async () => { const store = new OxigraphStore(); const asset = { @@ -230,6 +318,51 @@ describe('durable graph-scoped KA materialization', () => { })); }); + it('preserves a typed receipt transport failure from the concrete EVM update verifier', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const transportError = Object.assign( + new Error('receipt providers unavailable'), + { code: 'RPC_RECEIPT_LOOKUP_FAILED' }, + ); + const config: EVMAdapterConfig = { + rpcUrl: 'http://127.0.0.1:1', + privateKey: TEST_PRIVATE_KEY, + hubAddress: '0x0000000000000000000000000000000000000001', + chainId: 'otp:2043', + allowNoAdminSigner: true, + }; + const chain: any = new EVMChainAdapter(config); + chain.initialized = true; + chain.init = async () => {}; + chain.getLatestMerkleRoot = async () => root; + chain.getMerkleRootCount = async () => 2n; + chain.getKAContextGraphId = async () => 14n; + chain.getLatestMerkleRootPublisher = async () => ( + '0x2222222222222222222222222222222222222222' + ); + chain.contracts.knowledgeAssetStorage = {}; + chain.getTransactionReceiptWithFailover = async () => { throw transportError; }; + + try { + await expect(authenticateVerifiedGraphScopedAsset( + chain, + { + contextGraphId, + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [dataQuad(2)], + metadataQuads: metadata(2), + }, + async () => true, + )).rejects.toBe(transportError); + } finally { + chain.destroy(); + } + }); + it('fails closed when the bound CG commits a different name hash', async () => { const root = new Uint8Array(32); root[31] = 2; diff --git a/packages/agent/test/durable-sync-lifecycle-binding.test.ts b/packages/agent/test/durable-sync-lifecycle-binding.test.ts index f3f0136fa3..ae7a8e8ab0 100644 --- a/packages/agent/test/durable-sync-lifecycle-binding.test.ts +++ b/packages/agent/test/durable-sync-lifecycle-binding.test.ts @@ -36,19 +36,88 @@ const ctx = { kind: 'sync', id: 'lifecycle-binding-test', startedAt: 0 } as Oper const mockedRunDurableSync = vi.mocked(runDurableSync); const mockedMaterialize = vi.mocked(materializeVerifiedGraphScopedAsset); +function graphScopedAsset( + root: Uint8Array, + assertionVersion: bigint = 2n, +): VerifiedGraphScopedAsset { + const rootHex = Array.from(root, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return { + contextGraphId, + ual, + assertionVersion, + assertionGraph, + metaGraph, + dataQuads: [], + metadataQuads: [ + { + subject: ual, + predicate: `${DKG}merkleRoot`, + object: `"${rootHex}"`, + graph: metaGraph, + }, + { + subject: ual, + predicate: `${DKG}transactionHash`, + object: `"0x${assertionVersion.toString(16).padStart(64, '0')}"`, + graph: metaGraph, + }, + ], + }; +} + +async function captureGraphScopedStore( + chain: ChainAdapter, + warn: ReturnType = vi.fn(), +) { + const agentLike: any = { + config: {}, + chain, + store: {}, + subscribedContextGraphs: new Map(), + wireIdToLocalCgId: new Map(), + bindSubscriptionOnChainId: vi.fn(), + persistContextGraphSubscriptionState: vi.fn(), + processDurableBatchInWorker: async () => ({}), + insertSyncedQuadsAndInvalidateListCache: async () => {}, + syncCheckpoints: new Map(), + oversizeTombstoneLog: { record: () => {} }, + invalidateListContextGraphsCache: vi.fn(), + contextGraphMetaProjection: { markDirtyFromQuads: vi.fn() }, + log: { info: () => {}, warn, debug: () => {} }, + }; + agentLike.localCgMatchesOnChainSlot = (DKGAgent.prototype as any).localCgMatchesOnChainSlot; + agentLike.requireLocalCgMatchesOnChainSlot = ( + DKGAgent.prototype as any + ).requireLocalCgMatchesOnChainSlot; + agentLike.isWireIdKeyedSubscription = (DKGAgent.prototype as any).isWireIdKeyedSubscription; + agentLike.raceChainPolicyRead = (DKGAgent.prototype as any).raceChainPolicyRead; + + await LifecycleSyncMethods.prototype.runLegacyDurableSyncForContextGraph.call( + agentLike, + ctx, + 'peer-remote', + contextGraphId, + 1, + ); + return mockedRunDurableSync.mock.calls[0]![0].storeGraphScopedAsset!; +} + describe('durable sync lifecycle chain binding', () => { beforeEach(() => { mockedRunDurableSync.mockClear(); mockedMaterialize.mockClear(); }); - it('persists the authenticated on-chain CG id before materializing the asset', async () => { + it('retries a transient binding read, caches only the successful proof, and persists the CG id', async () => { const root = new Uint8Array(32); root[31] = 2; const rootHex = Array.from(root, (byte) => byte.toString(16).padStart(2, '0')).join(''); - const getContextGraphNameHash = vi.fn(async () => ethers.keccak256( - ethers.toUtf8Bytes(contextGraphId), - )); + const getContextGraphNameHash = vi.fn() + .mockRejectedValueOnce(Object.assign( + new Error('all configured RPC endpoints failed'), + { code: 'RPC_ENDPOINTS_EXHAUSTED' }, + )) + .mockResolvedValue(ethers.keccak256(ethers.toUtf8Bytes(contextGraphId))); const chain = { chainId: 'otp:2043', getLatestMerkleRoot: async () => root, @@ -88,6 +157,9 @@ describe('durable sync lifecycle chain binding', () => { log: { info: () => {}, warn: () => {}, debug: () => {} }, }; agentLike.localCgMatchesOnChainSlot = (DKGAgent.prototype as any).localCgMatchesOnChainSlot; + agentLike.requireLocalCgMatchesOnChainSlot = ( + DKGAgent.prototype as any + ).requireLocalCgMatchesOnChainSlot; agentLike.isWireIdKeyedSubscription = (DKGAgent.prototype as any).isWireIdKeyedSubscription; agentLike.raceChainPolicyRead = (DKGAgent.prototype as any).raceChainPolicyRead; @@ -124,10 +196,27 @@ describe('durable sync lifecycle chain binding', () => { }, ], }; - await expect(storeGraphScopedAsset!(asset)).resolves.toBe('applied'); - await expect(storeGraphScopedAsset!(asset)).resolves.toBe('applied'); + vi.useFakeTimers(); + const random = vi.spyOn(Math, 'random').mockReturnValue(0.5); + try { + const firstMaterialization = storeGraphScopedAsset!(asset, Date.now() + 120_000); + await vi.advanceTimersByTimeAsync(0); + expect(getContextGraphNameHash).toHaveBeenCalledTimes(1); + expect(bindSubscriptionOnChainId).not.toHaveBeenCalled(); + expect(persistContextGraphSubscriptionState).not.toHaveBeenCalled(); + expect(mockedMaterialize).not.toHaveBeenCalled(); + expect(agentLike.invalidateListContextGraphsCache).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_099); + expect(getContextGraphNameHash).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await expect(firstMaterialization).resolves.toBe('applied'); + } finally { + random.mockRestore(); + vi.useRealTimers(); + } + await expect(storeGraphScopedAsset!(asset, Date.now() + 60_000)).resolves.toBe('applied'); - expect(getContextGraphNameHash).toHaveBeenCalledTimes(1); + expect(getContextGraphNameHash).toHaveBeenCalledTimes(2); expect(bindSubscriptionOnChainId).toHaveBeenCalledWith( contextGraphId, @@ -199,6 +288,9 @@ describe('durable sync lifecycle chain binding', () => { log: { info: () => {}, warn: () => {}, debug: () => {} }, }; agentLike.localCgMatchesOnChainSlot = (DKGAgent.prototype as any).localCgMatchesOnChainSlot; + agentLike.requireLocalCgMatchesOnChainSlot = ( + DKGAgent.prototype as any + ).requireLocalCgMatchesOnChainSlot; agentLike.isWireIdKeyedSubscription = (DKGAgent.prototype as any).isWireIdKeyedSubscription; agentLike.raceChainPolicyRead = (DKGAgent.prototype as any).raceChainPolicyRead; @@ -236,8 +328,8 @@ describe('durable sync lifecycle chain binding', () => { }; }; - await expect(storeGraphScopedAsset!(asset(1))).resolves.toBe('applied'); - await expect(storeGraphScopedAsset!(asset(2))).rejects.toMatchObject({ + await expect(storeGraphScopedAsset!(asset(1), Date.now() + 60_000)).resolves.toBe('applied'); + await expect(storeGraphScopedAsset!(asset(2), Date.now() + 60_000)).rejects.toMatchObject({ code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH', }); @@ -245,4 +337,146 @@ describe('durable sync lifecycle chain binding', () => { expect(getContextGraphNameHash.mock.calls.map(([id]) => id)).toEqual([14n, 15n]); expect(mockedMaterialize).toHaveBeenCalledTimes(1); }); + + it('cancels and retries a hung root read within the graph deadline', async () => { + vi.useFakeTimers(); + const random = vi.spyOn(Math, 'random').mockReturnValue(0.5); + try { + const signals: AbortSignal[] = []; + const getLatestMerkleRoot = vi.fn(( + _kaId: bigint, + options?: { signal?: AbortSignal }, + ) => new Promise((_resolve, reject) => { + const signal = options?.signal; + if (!signal) throw new Error('authentication root read received no abort signal'); + signals.push(signal); + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + })); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + } as ChainAdapter; + const warnings = vi.fn(); + const storeGraphScopedAsset = await captureGraphScopedStore(chain, warnings); + const pending = storeGraphScopedAsset( + graphScopedAsset(new Uint8Array(32)), + Date.now() + 120_000, + ); + const rejection = expect(pending).rejects.toMatchObject({ code: 'RPC_TIMEOUT' }); + + await vi.runAllTimersAsync(); + await rejection; + expect(getLatestMerkleRoot).toHaveBeenCalledTimes(5); + expect(signals).toHaveLength(5); + expect(signals.every((signal) => signal.aborted)).toBe(true); + expect(warnings).toHaveBeenCalledTimes(4); + expect(mockedMaterialize).not.toHaveBeenCalled(); + } finally { + random.mockRestore(); + vi.useRealTimers(); + } + }); + + it('cancels and retries a hung V1 publish provenance read within the graph deadline', async () => { + vi.useFakeTimers(); + const random = vi.spyOn(Math, 'random').mockReturnValue(0.5); + try { + const root = new Uint8Array(32); + root[31] = 1; + const signals: AbortSignal[] = []; + const resolvePublishByTxHash = vi.fn(( + _txHash: string, + options?: { signal?: AbortSignal }, + ) => new Promise((_resolve, reject) => { + const signal = options?.signal; + if (!signal) throw new Error('V1 provenance read received no abort signal'); + signals.push(signal); + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + })); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 1n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => ethers.keccak256( + ethers.toUtf8Bytes(contextGraphId), + ), + resolvePublishByTxHash, + } as ChainAdapter; + const warnings = vi.fn(); + const storeGraphScopedAsset = await captureGraphScopedStore(chain, warnings); + const pending = storeGraphScopedAsset( + graphScopedAsset(root, 1n), + Date.now() + 120_000, + ); + const rejection = expect(pending).rejects.toMatchObject({ code: 'RPC_TIMEOUT' }); + + await vi.runAllTimersAsync(); + await rejection; + expect(resolvePublishByTxHash).toHaveBeenCalledTimes(5); + expect(signals).toHaveLength(5); + expect(signals.every((signal) => signal.aborted)).toBe(true); + expect(warnings).toHaveBeenCalledTimes(4); + expect(mockedMaterialize).not.toHaveBeenCalled(); + } finally { + random.mockRestore(); + vi.useRealTimers(); + } + }); + + it('does not retry deterministic chain evidence mismatches', async () => { + const expectedRoot = new Uint8Array(32); + const actualRoot = new Uint8Array(32); + actualRoot[31] = 1; + const getLatestMerkleRoot = vi.fn(async () => actualRoot); + const warnings = vi.fn(); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + } as ChainAdapter; + const storeGraphScopedAsset = await captureGraphScopedStore(chain, warnings); + + await expect(storeGraphScopedAsset( + graphScopedAsset(expectedRoot), + Date.now() + 60_000, + )).rejects.toMatchObject({ code: 'VM_CHAIN_ROOT_MISMATCH' }); + expect(getLatestMerkleRoot).toHaveBeenCalledTimes(1); + expect(warnings).not.toHaveBeenCalled(); + expect(mockedMaterialize).not.toHaveBeenCalled(); + }); + + it('cancels sibling chain reads when one authentication read fails early', async () => { + const deterministicError = Object.assign(new Error('contract view reverted'), { + code: 'CALL_EXCEPTION', + }); + let siblingSignal: AbortSignal | undefined; + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => { throw deterministicError; }, + getMerkleRootCount: ( + _kaId: bigint, + options?: { signal?: AbortSignal }, + ) => new Promise((_resolve, reject) => { + siblingSignal = options?.signal; + siblingSignal?.addEventListener( + 'abort', + () => reject(siblingSignal?.reason), + { once: true }, + ); + }), + getKAContextGraphId: async () => 14n, + } as ChainAdapter; + const storeGraphScopedAsset = await captureGraphScopedStore(chain); + + await expect(storeGraphScopedAsset( + graphScopedAsset(new Uint8Array(32)), + Date.now() + 60_000, + )).rejects.toBe(deterministicError); + expect(siblingSignal?.aborted).toBe(true); + expect(mockedMaterialize).not.toHaveBeenCalled(); + }); }); diff --git a/packages/agent/test/oversize-filter.test.ts b/packages/agent/test/oversize-filter.test.ts index 52f89f5749..2f970830ba 100644 --- a/packages/agent/test/oversize-filter.test.ts +++ b/packages/agent/test/oversize-filter.test.ts @@ -221,6 +221,22 @@ describe('error-tags: permanent-rejection classification', () => { }); }); +describe('error-tags: retry classification', () => { + it('treats typed exhausted chain RPC reads as backoff-worthy', () => { + const error = Object.assign(new Error( + 'cgStorage.kaToContextGraph read failed on all configured RPC endpoints: ' + + 'RPC #3 timed out after 4000ms', + ), { code: 'RPC_ENDPOINTS_EXHAUSTED' }); + expect(isSyncBackoffWorthyError(error)).toBe(true); + expect(isSyncPermanentRejection(error)).toBe(false); + }); + + it('does not infer chain transport failure from message text alone', () => { + const error = new Error('read failed on all configured RPC endpoints'); + expect(isSyncBackoffWorthyError(error)).toBe(false); + }); +}); + describe('runDurableSync — the poison-page retry-loop regression', () => { const bad = quad(lit(120_000)); // the incident shape: a ~118KB agents-graph literal const goodA = quad(lit(10), DATA_GRAPH, 'http://ex.org/a'); diff --git a/packages/agent/test/swm-public-cg-plaintext.test.ts b/packages/agent/test/swm-public-cg-plaintext.test.ts index 34233b8900..55313bdb23 100644 --- a/packages/agent/test/swm-public-cg-plaintext.test.ts +++ b/packages/agent/test/swm-public-cg-plaintext.test.ts @@ -152,6 +152,46 @@ const isPublic = (a: any, cgId = '0xCURATOR/experimental-music') => (DKGAgent.prototype as any).isContextGraphPublicOnChain.call(a, cgId); describe('DKGAgent.isContextGraphPublicOnChain', () => { + it('preserves the legacy strict binding option while retryable strict reads propagate transport errors', async () => { + const cgId = '0xCURATOR/experimental-music'; + const agentLike = makeAgentLike({ onChainId: '5', accessPolicy: 0 }); + const legacyBinding = (DKGAgent.prototype as any).localCgMatchesOnChainSlot; + const retryableStrictBinding = (DKGAgent.prototype as any).requireLocalCgMatchesOnChainSlot; + + await expect(legacyBinding.call(agentLike, cgId, 'not-a-slot')).resolves.toBe(true); + await expect(legacyBinding.call( + agentLike, + cgId, + 'not-a-slot', + undefined, + { requireCommittedNameHash: true }, + )).resolves.toBe(false); + + delete agentLike.chain.getContextGraphNameHash; + await expect(legacyBinding.call(agentLike, cgId, '5')).resolves.toBe(true); + await expect(legacyBinding.call( + agentLike, + cgId, + '5', + undefined, + { requireCommittedNameHash: true }, + )).resolves.toBe(false); + + const transportError = Object.assign(new Error('name-hash RPC unavailable'), { + code: 'RPC_ENDPOINTS_EXHAUSTED', + }); + agentLike.chain.getContextGraphNameHash = async () => { throw transportError; }; + await expect(legacyBinding.call( + agentLike, + cgId, + '5', + undefined, + { requireCommittedNameHash: true }, + )).resolves.toBe(false); + await expect(retryableStrictBinding.call(agentLike, cgId, '5')) + .rejects.toBe(transportError); + }); + it('returns true for a LIVE CG whose on-chain access policy is public (0)', async () => { const agentLike = makeAgentLike({ onChainId: '1', accessPolicy: 0 }); await expect(isPublic(agentLike)).resolves.toBe(true); diff --git a/packages/chain/src/chain-adapter.ts b/packages/chain/src/chain-adapter.ts index 39b44bcf43..2a0fd6c3e3 100644 --- a/packages/chain/src/chain-adapter.ts +++ b/packages/chain/src/chain-adapter.ts @@ -911,6 +911,11 @@ export interface OperationalWalletRegistrationResult { taken: Array<{ address: string; identityId: bigint }>; } +/** Optional cancellation boundary for caller-owned, read-only chain work. */ +export interface ChainReadOptions { + signal?: AbortSignal; +} + /** * Chain-agnostic adapter interface for interacting with the DKG Trust Layer. * @@ -967,7 +972,10 @@ export interface ChainAdapter { * Recover a publish transaction by txHash and reconstruct its on-chain publish result. * Returns null when the tx is absent, pending, failed, or not a recognized publish tx. */ - resolvePublishByTxHash?(txHash: string): Promise; + resolvePublishByTxHash?( + txHash: string, + options?: ChainReadOptions, + ): Promise; /** * Required TRAC amount for publishing (from stake-weighted ask and byte size). @@ -982,7 +990,12 @@ export interface ChainAdapter { * root and block number so the caller can bind the gossip payload to * on-chain state. */ - verifyKAUpdate?(txHash: string, batchId: bigint, publisherAddress: string): Promise; + verifyKAUpdate?( + txHash: string, + batchId: bigint, + publisherAddress: string, + options?: ChainReadOptions, + ): Promise; /** * Verify that a publisher address owns the UAL range [startKAId, endKAId] on-chain. @@ -1550,10 +1563,10 @@ export interface ChainAdapter { * deployed on this Hub. Optional so non-V10 / no-chain adapters can * stub the prover surface. */ - getLatestMerkleRoot?(kaId: bigint): Promise; + getLatestMerkleRoot?(kaId: bigint, options?: ChainReadOptions): Promise; /** Number of committed roots for the KA (initial publish is version one). */ - getMerkleRootCount?(kaId: bigint): Promise; + getMerkleRootCount?(kaId: bigint, options?: ChainReadOptions): Promise; /** * V10 flat-KC merkle leaf count (sorted + deduped) recorded on-chain @@ -1603,7 +1616,7 @@ export interface ChainAdapter { * this as the compatibility path for update adapters whose successful * `TxResult` cannot directly include `publisherAddress`. */ - getLatestMerkleRootPublisher?(kaId: bigint): Promise; + getLatestMerkleRootPublisher?(kaId: bigint, options?: ChainReadOptions): Promise; /** * Verified author identity for the latest merkle-root entry of `kaId`. @@ -1635,7 +1648,7 @@ export interface ChainAdapter { * default-zero mapping). Callers MUST treat zero as "not found" and * skip the period rather than blindly querying CG `_meta:0`. */ - getKAContextGraphId?(kaId: bigint): Promise; + getKAContextGraphId?(kaId: bigint, options?: ChainReadOptions): Promise; /** * Number of Knowledge Assets registered to `contextGraphId`, sourced from @@ -1775,7 +1788,10 @@ export interface ChainAdapter { * value is write-once at create time (no setter exists), so stale * entries can't occur. */ - getContextGraphNameHash?(contextGraphId: bigint): Promise; + getContextGraphNameHash?( + contextGraphId: bigint, + options?: ChainReadOptions, + ): Promise; } // ----- Backward-compat deprecated aliases ----- diff --git a/packages/chain/src/evm-adapter-base.ts b/packages/chain/src/evm-adapter-base.ts index d937421338..bc657e9c4a 100644 --- a/packages/chain/src/evm-adapter-base.ts +++ b/packages/chain/src/evm-adapter-base.ts @@ -17,6 +17,7 @@ import type { FilterErrorSilencer } from './filter-error-silencer.js'; import { DEFAULT_APPROVAL_POLICY, buildEvmDeploymentId } from './chain-adapter.js'; import type { ApprovalPolicy, + ChainReadOptions, V10PublishParams, OnChainPublishResult, } from './chain-adapter.js'; @@ -1367,9 +1368,25 @@ export class EVMChainAdapterBase { label: string, method: string, ...args: unknown[] + ): Promise { + return this.readContractWithOptions(contract, label, method, args); + } + + /** + * Canonical simple contract view with request options. This keeps ordinary + * method-name reads on the same path as {@link readContract} while allowing + * cancellation and other read policy to be supplied without a custom lambda. + */ + protected readContractWithOptions( + contract: Contract, + label: string, + method: string, + args: readonly unknown[], + opts?: ReadOpts, ): Promise { return this.rpcFailover.readContract(label, contract, (c) => c[method](...args), { - rpcUsageConsumer: label, + ...opts, + rpcUsageConsumer: opts?.rpcUsageConsumer ?? label, }); } @@ -2718,7 +2735,10 @@ export class EVMChainAdapterBase { } } - protected async getBlockTimestamp(blockNumber: number): Promise { + protected async getBlockTimestamp( + blockNumber: number, + options: ChainReadOptions = {}, + ): Promise { // A CONCRETE (already-mined receipt) block — NOT the tip, so it uses normal // endpoint stickiness (the endpoint that produced the receipt is the one most // likely to already have the block). It is NOT a `skipPreferred` tip read: @@ -2738,6 +2758,7 @@ export class EVMChainAdapterBase { { rpcUsageConsumer: 'getBlock', endpointSetRetry: 'all-throttled', + signal: options.signal, }, ); return block?.timestamp != null ? Number(block.timestamp) : 0; diff --git a/packages/chain/src/evm-adapter-context-graph.ts b/packages/chain/src/evm-adapter-context-graph.ts index cd4a7bc631..cdceec00b0 100644 --- a/packages/chain/src/evm-adapter-context-graph.ts +++ b/packages/chain/src/evm-adapter-context-graph.ts @@ -18,7 +18,7 @@ import { isTooLowAllowanceError, } from './evm-adapter-errors.js'; import { ethers, Contract, type JsonRpcProvider } from 'ethers'; -import { ContextGraphChainScanPartialError, type CreateContextGraphParams, type TxResult, type ContextGraphOnChain, type ContextGraphChainScanOptions, type ContextGraphRegistryScanOptions, type ContextGraphRegistryScanPage, type CreateOnChainContextGraphParams, type CreateOnChainContextGraphResult, type VerifyParams, type PublishToContextGraphParams, type OnChainPublishResult } from './chain-adapter.js'; +import { ContextGraphChainScanPartialError, type ChainReadOptions, type CreateContextGraphParams, type TxResult, type ContextGraphOnChain, type ContextGraphChainScanOptions, type ContextGraphRegistryScanOptions, type ContextGraphRegistryScanPage, type CreateOnChainContextGraphParams, type CreateOnChainContextGraphResult, type VerifyParams, type PublishToContextGraphParams, type OnChainPublishResult } from './chain-adapter.js'; import { buildAuthorAttestationTypedData, AUTHOR_SCHEME_VERSION_V1 } from '@origintrail-official/dkg-core'; type ContextGraphRegistryScanPlan = @@ -716,11 +716,15 @@ export class ContextGraphMethods extends EVMChainAdapterBase { }); } - async getKAContextGraphId(kaId: bigint): Promise { + async getKAContextGraphId(kaId: bigint, options: ChainReadOptions = {}): Promise { await this.init(); const cgs = this.requireContextGraphStorage(); - const cgId: bigint = await this.readContract( - cgs, 'cgStorage.kaToContextGraph', 'kaToContextGraph', kaId, + const cgId: bigint = await this.readContractWithOptions( + cgs, + 'cgStorage.kaToContextGraph', + 'kaToContextGraph', + [kaId], + { signal: options.signal }, ); return BigInt(cgId); } @@ -849,11 +853,18 @@ export class ContextGraphMethods extends EVMChainAdapterBase { * the wrong slot. Letting the error throw lets the caller fail CLOSED * instead. */ - async getContextGraphNameHash(contextGraphId: bigint): Promise { + async getContextGraphNameHash( + contextGraphId: bigint, + options: ChainReadOptions = {}, + ): Promise { await this.init(); const cgs = this.requireContextGraphStorage(); - const raw: string = await this.readContract( - cgs, 'cgStorage.getNameHash', 'getNameHash', contextGraphId, + const raw: string = await this.readContractWithOptions( + cgs, + 'cgStorage.getNameHash', + 'getNameHash', + [contextGraphId], + { signal: options.signal }, ); if (!raw || raw === ethers.ZeroHash) return null; return raw.toLowerCase(); diff --git a/packages/chain/src/evm-adapter-publish.ts b/packages/chain/src/evm-adapter-publish.ts index a229e89009..14d44b69c7 100644 --- a/packages/chain/src/evm-adapter-publish.ts +++ b/packages/chain/src/evm-adapter-publish.ts @@ -11,13 +11,14 @@ import { EVMChainAdapterBase, decodeConvictionCostCovered } from './evm-adapter-base.js'; import { ethers, Wallet, Contract } from 'ethers'; -import type { ReservedRange, BatchMintParams, BatchMintResult, KAUpdateVerification, OnChainPublishResult, V10UpdateKAParams, TxResult, PublisherPublishPlan, PublisherPublishPlanRequest } from './chain-adapter.js'; +import type { ChainReadOptions, ReservedRange, BatchMintParams, BatchMintResult, KAUpdateVerification, OnChainPublishResult, V10UpdateKAParams, TxResult, PublisherPublishPlan, PublisherPublishPlanRequest } from './chain-adapter.js'; import { floorPublishTokenAmount, computeUpdateACKDigest, AUTHOR_SCHEME_VERSION_V1 } from '@origintrail-official/dkg-core'; import { resolveQuotedPublisherCandidatePricing, type PublisherConvictionPlanReader, } from './publisher-plan.js'; import { errorMessage } from './evm-adapter-errors.js'; +import { isChainRpcTransportError } from './chain-rpc-transport-error.js'; type PublisherCandidatePlan = PublisherPublishPlan & { signer: Wallet; address: string }; @@ -276,14 +277,21 @@ export class PublishMethods extends EVMChainAdapterBase { // V9: Update Verification (for gossip receivers) // ===================================================================== - async verifyKAUpdate(txHash: string, batchId: bigint, publisherAddress: string): Promise { + async verifyKAUpdate( + txHash: string, + batchId: bigint, + publisherAddress: string, + options: ChainReadOptions = {}, + ): Promise { await this.init(); if (!this.contracts.knowledgeAssetsStorage && !this.contracts.knowledgeAssetStorage) { return { verified: false }; } try { - const receipt = await this.getTransactionReceiptWithFailover(txHash); + const receipt = await this.getTransactionReceiptWithFailover(txHash, { + signal: options.signal, + }); if (!receipt || receipt.status !== 1) return { verified: false }; let onChainMerkleRoot: Uint8Array | undefined; @@ -336,12 +344,12 @@ export class PublishMethods extends EVMChainAdapterBase { let merkleRootCount: bigint | undefined; if (this.contracts.knowledgeAssetStorage) { try { - const roots = await this.readContract( + const roots = await this.readContractWithOptions( this.contracts.knowledgeAssetStorage, 'kas.getMerkleRootsAtUpdateBlock', 'getMerkleRoots', - batchId, - { blockTag: receipt.blockNumber }, + [batchId, { blockTag: receipt.blockNumber }], + { signal: options.signal }, ) as Array<{ publisher?: string; merkleRoot?: string } | readonly unknown[]>; let matchedIndex = -1; for (let i = roots.length - 1; i >= 0; i--) { @@ -361,7 +369,8 @@ export class PublishMethods extends EVMChainAdapterBase { return { verified: false }; } merkleRootCount = BigInt(matchedIndex + 1); - } catch { + } catch (error) { + if (isChainRpcTransportError(error)) throw error; // A latest-state fallback can authenticate a historical receipt with // a different publisher/root after a later update. V10 verification // therefore fails closed when the receipt-block view is unavailable. @@ -391,7 +400,8 @@ export class PublishMethods extends EVMChainAdapterBase { txIndex: receipt.index, merkleRootCount, }; - } catch { + } catch (error) { + if (isChainRpcTransportError(error)) throw error; return { verified: false }; } } @@ -426,20 +436,25 @@ export class PublishMethods extends EVMChainAdapterBase { return false; } - async resolvePublishByTxHash(txHash: string): Promise { + async resolvePublishByTxHash( + txHash: string, + options: ChainReadOptions = {}, + ): Promise { await this.init(); try { - const receipt = await this.getTransactionReceiptWithFailover(txHash); + const receipt = await this.getTransactionReceiptWithFailover(txHash, { + signal: options.signal, + }); if (!receipt || receipt.status !== 1) return null; const v10 = this.contracts.knowledgeAssetStorage - ? await this.parseV10PublishReceipt(receipt) + ? await this.parseV10PublishReceipt(receipt, options) : null; if (v10) return v10; const v9 = this.contracts.knowledgeAssetsStorage - ? await this.parseV9PublishReceipt(receipt) + ? await this.parseV9PublishReceipt(receipt, options) : null; return v9; } catch (err: any) { @@ -453,6 +468,7 @@ export class PublishMethods extends EVMChainAdapterBase { async parseV10PublishReceipt( receipt: NonNullable>>, + options: ChainReadOptions = {}, ): Promise { const kas = this.contracts.knowledgeAssetStorage; if (!kas) return null; @@ -497,7 +513,7 @@ export class PublishMethods extends EVMChainAdapterBase { publisherAddress = receipt.from ?? authorAddress ?? ''; } - const blockTimestamp = await this.getBlockTimestamp(receipt.blockNumber); + const blockTimestamp = await this.getBlockTimestamp(receipt.blockNumber, options); const convictionCostCovered = decodeConvictionCostCovered(receipt.logs); return { @@ -519,6 +535,7 @@ export class PublishMethods extends EVMChainAdapterBase { async parseV9PublishReceipt( receipt: NonNullable>>, + options: ChainReadOptions = {}, ): Promise { const storage = this.contracts.knowledgeAssetsStorage; if (!storage) return null; @@ -548,7 +565,7 @@ export class PublishMethods extends EVMChainAdapterBase { if (!foundBatchCreated) return null; - const blockTimestamp = await this.getBlockTimestamp(receipt.blockNumber); + const blockTimestamp = await this.getBlockTimestamp(receipt.blockNumber, options); return { batchId, diff --git a/packages/chain/src/evm-adapter-rpc.ts b/packages/chain/src/evm-adapter-rpc.ts index d2fe2b9042..bbfe894def 100644 --- a/packages/chain/src/evm-adapter-rpc.ts +++ b/packages/chain/src/evm-adapter-rpc.ts @@ -10,6 +10,7 @@ import { ethers, FetchRequest } from 'ethers'; import { enrichEvmError, errorCode, errorMessage, errorStatus } from './evm-adapter-errors.js'; import { createRpcTimeoutError } from './chain-rpc-transport-error.js'; +import { cancellableRpcGetUrl } from './rpc-request-transport.js'; /** * Per-request retry bound for ethers' built-in `FetchRequest`. ethers v6 @@ -95,6 +96,10 @@ export function boundedRetryFetchRequest( maxRetries: number = RPC_REQUEST_MAX_RETRIES, ): FetchRequest { const req = new FetchRequest(url); + // ethers 6.16's Node getUrl cancellation rejects the FetchRequest but does + // not close its underlying socket. Use the platform fetch transport so the + // same FetchCancelSignal also aborts the active HTTP request. + req.getUrlFunc = cancellableRpcGetUrl; req.retryFunc = async (_attemptReq, _response, attempt) => { if (attempt >= maxRetries) return false; await sleep(Math.min(500 * (attempt + 1), RPC_REQUEST_RETRY_BACKOFF_CAP_MS)); diff --git a/packages/chain/src/evm-adapter-storage-reads.ts b/packages/chain/src/evm-adapter-storage-reads.ts index ac33f5f665..15e8a8ec6d 100644 --- a/packages/chain/src/evm-adapter-storage-reads.ts +++ b/packages/chain/src/evm-adapter-storage-reads.ts @@ -11,6 +11,7 @@ import { EVMChainAdapterBase } from './evm-adapter-base.js'; import { Contract, ethers } from 'ethers'; +import type { ChainReadOptions } from './chain-adapter.js'; export class StorageReadMethods extends EVMChainAdapterBase { // ===================================================================== @@ -28,23 +29,28 @@ export class StorageReadMethods extends EVMChainAdapterBase { return kas; } - async getLatestMerkleRoot(kaId: bigint): Promise { + async getLatestMerkleRoot(kaId: bigint, options: ChainReadOptions = {}): Promise { await this.init(); const kas = this.requireKCStorage(); - const rootHex: string = await this.readContract( - kas, 'kas.getLatestMerkleRoot', 'getLatestMerkleRoot', kaId, + const rootHex: string = await this.readContractWithOptions( + kas, + 'kas.getLatestMerkleRoot', + 'getLatestMerkleRoot', + [kaId], + { signal: options.signal }, ); return ethers.getBytes(rootHex); } - async getMerkleRootCount(kaId: bigint): Promise { + async getMerkleRootCount(kaId: bigint, options: ChainReadOptions = {}): Promise { await this.init(); const kas = this.requireKCStorage(); - const context = await this.readContract( + const context = await this.readContractWithOptions( kas, 'kas.getKnowledgeAssetUpdateContext', 'getKnowledgeAssetUpdateContext', - kaId, + [kaId], + { signal: options.signal }, ) as { merkleRootsCount?: bigint } & readonly unknown[]; const rawCount = context.merkleRootsCount ?? context[0]; if (rawCount === undefined) { @@ -80,11 +86,18 @@ export class StorageReadMethods extends EVMChainAdapterBase { return Number(count); } - async getLatestMerkleRootPublisher(kaId: bigint): Promise { + async getLatestMerkleRootPublisher( + kaId: bigint, + options: ChainReadOptions = {}, + ): Promise { await this.init(); const kas = this.requireKCStorage(); - const publisher: string = await this.readContract( - kas, 'kas.getLatestMerkleRootPublisher', 'getLatestMerkleRootPublisher', kaId, + const publisher: string = await this.readContractWithOptions( + kas, + 'kas.getLatestMerkleRootPublisher', + 'getLatestMerkleRootPublisher', + [kaId], + { signal: options.signal }, ); return publisher; } diff --git a/packages/chain/src/rpc-failover-client.ts b/packages/chain/src/rpc-failover-client.ts index 3083c51f53..a07dc2d0ab 100644 --- a/packages/chain/src/rpc-failover-client.ts +++ b/packages/chain/src/rpc-failover-client.ts @@ -47,6 +47,7 @@ import { noteRpcFailover, noteRpcExhaustion, notePreferredEndpoint, noteRpcServe import { EndpointStickiness, type StickinessIntent } from './endpoint-stickiness.js'; import { ChainRpcTransportError, createRpcTimeoutError } from './chain-rpc-transport-error.js'; import { withRpcUsageConsumer } from './rpc-usage.js'; +import { withRpcRequestAbortSignal } from './rpc-request-transport.js'; import { RPC_READ_STALL_TIMEOUT_MS, RPC_LOG_SCAN_TIMEOUT_MS, @@ -122,6 +123,8 @@ export interface ReadOpts { isEmptyResult?: (value: unknown) => boolean; /** Retry a complete endpoint pass only when every failure was a throttle. */ endpointSetRetry?: 'all-throttled'; + /** Cancels the active raw ethers FetchRequest for this read. */ + signal?: AbortSignal; } /** Optional absolute deadline and low-cardinality label for one receipt pass. */ @@ -129,6 +132,8 @@ export interface ReceiptLookupOptions { deadlineMs?: number; /** Defaults to `receipt lookup`; direct transports use their caller label. */ logLabel?: string; + /** Cancels the active raw ethers FetchRequest for this receipt lookup. */ + signal?: AbortSignal; } /** @@ -343,7 +348,9 @@ export class RpcFailoverClient { label, fn, { - isRetryable: opts?.isRetryable ?? isRetryableRpcError, + isRetryable: error => !opts?.signal?.aborted && ( + opts?.isRetryable ?? isRetryableRpcError + )(error), intent: skipPreferred ? 'transparentRead' : 'stickyRead', attemptTimeoutMs: providerCount => resolveCapMs(policy, providerCount), isEmptyResult: opts?.isEmptyResult as ((value: T) => boolean) | undefined, @@ -354,7 +361,12 @@ export class RpcFailoverClient { }, ); const run = () => this.runReadPasses(label, runPass, opts?.endpointSetRetry); - return opts?.rpcUsageConsumer ? withRpcUsageConsumer(opts.rpcUsageConsumer, run) : run(); + const runWithAbort = () => opts?.signal + ? withRpcRequestAbortSignal(opts.signal, run) + : run(); + return opts?.rpcUsageConsumer + ? withRpcUsageConsumer(opts.rpcUsageConsumer, runWithAbort) + : runWithAbort(); } /** @@ -391,7 +403,9 @@ export class RpcFailoverClient { label, (p) => fn(this.rebindContract(contract, p)), { - isRetryable: opts?.isRetryable ?? isContractViewRetryable, + isRetryable: error => !opts?.signal?.aborted && ( + opts?.isRetryable ?? isContractViewRetryable + )(error), intent: skipPreferred ? 'transparentRead' : 'stickyRead', attemptTimeoutMs: providerCount => resolveCapMs(policy, providerCount), isEmptyResult: opts?.isEmptyResult as ((value: T) => boolean) | undefined, @@ -416,7 +430,12 @@ export class RpcFailoverClient { }, { attributes: { 'rpc.method': 'eth_call', 'dkg.chain_id': chainId, 'dkg.read': label } }, ); - return opts?.rpcUsageConsumer ? withRpcUsageConsumer(opts.rpcUsageConsumer, run) : run(); + const runWithAbort = () => opts?.signal + ? withRpcRequestAbortSignal(opts.signal, run) + : run(); + return opts?.rpcUsageConsumer + ? withRpcUsageConsumer(opts.rpcUsageConsumer, runWithAbort) + : runWithAbort(); } // --- write transport (called by the adapter's tx-orchestration; this layer @@ -634,7 +653,7 @@ export class RpcFailoverClient { ): Promise { const chainId = this.chainId(); const logLabel = options.logLabel ?? 'receipt lookup'; - return withSpan( + const run = () => withSpan( 'chain.tx_wait', async (span) => { const metrics = getMetrics(); @@ -644,7 +663,7 @@ export class RpcFailoverClient { logLabel, provider => provider.getTransactionReceipt(txHash), { - isRetryable: isRetryableRpcError, + isRetryable: error => !options.signal?.aborted && isRetryableRpcError(error), intent: 'write', attemptTimeoutMs: () => RPC_RECEIPT_ATTEMPT_TIMEOUT_MS, ...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }), @@ -697,6 +716,9 @@ export class RpcFailoverClient { }, { attributes: { 'rpc.method': 'eth_getTransactionReceipt', 'dkg.chain_id': chainId } }, ); + return options.signal + ? withRpcRequestAbortSignal(options.signal, run) + : run(); } /** diff --git a/packages/chain/src/rpc-request-transport.ts b/packages/chain/src/rpc-request-transport.ts new file mode 100644 index 0000000000..40cc9ad4a6 --- /dev/null +++ b/packages/chain/src/rpc-request-transport.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { + FetchCancelSignal, + FetchGetUrlFunc, + FetchRequest, +} from 'ethers'; +import { errorMessage } from './evm-adapter-errors.js'; + +const rpcRequestAbortContext = new AsyncLocalStorage(); + +/** Bind one caller-owned cancellation signal to the raw ethers HTTP request. */ +export function withRpcRequestAbortSignal(signal: AbortSignal, fn: () => T): T { + return rpcRequestAbortContext.run(signal, fn); +} + +function activeRpcRequestAbortSignal(): AbortSignal | undefined { + return rpcRequestAbortContext.getStore(); +} + +function throwAbortReason(signal: AbortSignal): never { + if (signal.reason instanceof Error) throw signal.reason; + const error = new Error(typeof signal.reason === 'string' ? signal.reason : 'RPC request aborted'); + error.name = 'AbortError'; + throw error; +} + +/** + * FetchRequest transport that combines ethers' cancellation signal with the + * caller-owned signal bound by {@link withRpcRequestAbortSignal}. Keeping the + * bridge here lets JsonRpcProvider retain its native `_send` implementation; + * this function owns only the HTTP request that can actually close the socket. + */ +export const cancellableRpcGetUrl: FetchGetUrlFunc = async ( + request: FetchRequest, + signal?: FetchCancelSignal, +) => { + signal?.checkSignal(); + const callerSignal = activeRpcRequestAbortSignal(); + if (callerSignal?.aborted) throwAbortReason(callerSignal); + + const controller = new AbortController(); + let cancelled = false; + let callerCancelled = false; + let timedOut = false; + signal?.addListener(() => { + cancelled = true; + controller.abort(); + }); + const onCallerAbort = () => { + callerCancelled = true; + controller.abort(); + }; + callerSignal?.addEventListener('abort', onCallerAbort, { once: true }); + if (callerSignal?.aborted) onCallerAbort(); + + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, request.timeout); + try { + let requestBody: ArrayBuffer | undefined; + if (request.body) { + requestBody = new ArrayBuffer(request.body.length); + new Uint8Array(requestBody).set(request.body); + } + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + body: requestBody, + signal: controller.signal, + }); + const headers: Record = {}; + response.headers.forEach((value, key) => { headers[key] = value; }); + const body = new Uint8Array(await response.arrayBuffer()); + return { + statusCode: response.status, + statusMessage: response.statusText, + headers, + body: body.length > 0 ? body : null, + }; + } catch (error) { + if (callerCancelled && callerSignal) throwAbortReason(callerSignal); + if (cancelled) { + throw Object.assign(new Error('RPC request cancelled', { cause: error }), { + code: 'CANCELLED', + }); + } + if (timedOut) { + throw Object.assign(new Error(`RPC request timed out after ${request.timeout}ms`, { + cause: error, + }), { code: 'TIMEOUT' }); + } + const causeCode = (error as { cause?: { code?: unknown } } | null)?.cause?.code; + throw Object.assign( + new Error(`RPC fetch failed: ${errorMessage(error)}`, { cause: error }), + { + code: typeof causeCode === 'string' && causeCode.length > 0 + ? causeCode.toUpperCase() + : 'NETWORK_ERROR', + }, + ); + } finally { + clearTimeout(timeout); + callerSignal?.removeEventListener('abort', onCallerAbort); + } +}; diff --git a/packages/chain/src/rpc-usage.ts b/packages/chain/src/rpc-usage.ts index cee8244ba4..4fa4b32811 100644 --- a/packages/chain/src/rpc-usage.ts +++ b/packages/chain/src/rpc-usage.ts @@ -311,7 +311,9 @@ export class CountingJsonRpcProvider extends JsonRpcProvider { super(url, network, options); } - override _send(payload: JsonRpcPayload | Array): Promise> { + override async _send( + payload: JsonRpcPayload | Array, + ): Promise> { try { const entries = Array.isArray(payload) ? payload : [payload]; for (const entry of entries) this.onRpcRequest(String(entry?.method ?? 'unknown')); diff --git a/packages/chain/test/evm-adapter.unit.test.ts b/packages/chain/test/evm-adapter.unit.test.ts index ec9b2b2b62..4cb0ab6774 100644 --- a/packages/chain/test/evm-adapter.unit.test.ts +++ b/packages/chain/test/evm-adapter.unit.test.ts @@ -78,17 +78,9 @@ describe('EVMChainAdapter historical KA update verification', () => { interface: iface, }; const latestRead = recorder(async () => publisher); - adapter.readContract = async ( - _contract: unknown, - _label: string, - method: string, - ) => { - if (method === 'getMerkleRoots') { - if (roots instanceof Error) throw roots; - return roots; - } - if (method === 'getLatestMerkleRootPublisher') return latestRead(); - throw new Error(`unexpected method ${method}`); + adapter.readContractWithOptions = async () => { + if (roots instanceof Error) throw roots; + return roots; }; return { adapter, latestRead }; } @@ -103,6 +95,31 @@ describe('EVMChainAdapter historical KA update verification', () => { .resolves.toEqual({ verified: false }); expect(latestRead.calls).toHaveLength(0); }); + + it.each([ + 'RPC_ENDPOINTS_EXHAUSTED', + 'RPC_RECEIPT_LOOKUP_FAILED', + 'RPC_TIMEOUT', + ])('preserves typed %s receipt failures for durable retry', async (code) => { + const { adapter } = adapterWithHistoricalRead([]); + const transportError = Object.assign(new Error(`transport failed: ${code}`), { code }); + (adapter as any).getTransactionReceiptWithFailover = async () => { + throw transportError; + }; + + await expect(adapter.verifyKAUpdate('0xreceipt', kaId, publisher)) + .rejects.toBe(transportError); + }); + + it('preserves typed receipt-block history failures for durable retry', async () => { + const transportError = Object.assign(new Error('archive RPC timed out'), { + code: 'RPC_TIMEOUT', + }); + const { adapter } = adapterWithHistoricalRead(transportError); + + await expect(adapter.verifyKAUpdate('0xreceipt', kaId, publisher)) + .rejects.toBe(transportError); + }); }); describe('EVMChainAdapter getIdentityIdForAddress cache', () => { diff --git a/packages/chain/test/loopback-rpc-harness.ts b/packages/chain/test/loopback-rpc-harness.ts index cf0bf942e4..35d60f275e 100644 --- a/packages/chain/test/loopback-rpc-harness.ts +++ b/packages/chain/test/loopback-rpc-harness.ts @@ -26,6 +26,8 @@ export interface LoopbackRpc { server: Server; /** Per-JSON-RPC-method request counts, e.g. `hits('eth_chainId')`. */ hits: (method: string) => number; + /** Requests whose client connection closed before a response was sent. */ + aborted: (method: string) => number; totalHits: () => number; /** Force-close sockets then close the server (afterEach teardown). */ close: () => Promise; @@ -34,11 +36,13 @@ export interface LoopbackRpc { export interface LoopbackOptions { /** JSON-RPC methods that respond HTTP 429 (rate-limited). */ throttle?: Iterable; - /** Override canned results per method (return a hex string). */ - results?: Record; + /** Override canned JSON-RPC results per method. */ + results?: Record; + /** JSON-RPC methods that accept the request but never send a response. */ + hang?: Iterable; } -const DEFAULT_RESULTS: Record = { +const DEFAULT_RESULTS: Record = { eth_chainId: CHAIN_ID_HEX, eth_blockNumber: '0x10', eth_getCode: '0x1234', @@ -55,8 +59,10 @@ const DEFAULT_RESULTS: Record = { */ export async function startLoopbackRpc(options: LoopbackOptions = {}): Promise { const throttle = new Set(options.throttle ?? []); + const hang = new Set(options.hang ?? []); const results = { ...DEFAULT_RESULTS, ...(options.results ?? {}) }; const counts = new Map(); + const abortedCounts = new Map(); const server = createServer((req, res) => { let raw = ''; @@ -73,6 +79,16 @@ export async function startLoopbackRpc(options: LoopbackOptions = {}): Promise hang.has(r.method)).map(r => r.method); + if (hungMethods.length > 0) { + res.on('close', () => { + if (res.writableEnded) return; + for (const method of hungMethods) { + abortedCounts.set(method, (abortedCounts.get(method) ?? 0) + 1); + } + }); + return; + } if (throttled) { res.writeHead(429, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ @@ -93,6 +109,7 @@ export async function startLoopbackRpc(options: LoopbackOptions = {}): Promise counts.get(method) ?? 0, + aborted: (method) => abortedCounts.get(method) ?? 0, totalHits: () => [...counts.values()].reduce((a, b) => a + b, 0), close: async () => { server.closeAllConnections?.(); diff --git a/packages/chain/test/mock-adapter-parity.test.ts b/packages/chain/test/mock-adapter-parity.test.ts index b8b34a53bd..bda6d1ad22 100644 --- a/packages/chain/test/mock-adapter-parity.test.ts +++ b/packages/chain/test/mock-adapter-parity.test.ts @@ -139,12 +139,14 @@ const MOCK_EXEMPT_FROM_EVM = new Set([ 'signPopulatedTransaction', // #1336 read-facade + populate plumbing: the chain-concept read facades over // the `RpcFailoverClient` transport — `readContract` (string-method point read), + // `readContractWithOptions` (the cancellable string-method variant), // `readContractWith` (policy/classifier-bearing contract read), and the raw // `readProvider` — plus the event-log scan wrapper, the contract rebind helper, // and the populate+sign-across-providers delegator. Protected EVM-only helpers // over `this.providers[]` (the mock has no RPC provider pool), not ChainAdapter // contract methods — same category as the write-failover helpers above. 'readContract', + 'readContractWithOptions', 'readContractWith', 'readProvider', 'readTipProvider', diff --git a/packages/chain/test/multi-rpc-read-failover.test.ts b/packages/chain/test/multi-rpc-read-failover.test.ts index 999860d6b2..3debcb21c9 100644 --- a/packages/chain/test/multi-rpc-read-failover.test.ts +++ b/packages/chain/test/multi-rpc-read-failover.test.ts @@ -37,14 +37,34 @@ * with a healthy backup = no failover). Do NOT weaken the assertions. */ import { describe, it, expect, afterEach, beforeEach } from 'vitest'; -import { JsonRpcProvider } from 'ethers'; +import { Contract, Interface, JsonRpcProvider } from 'ethers'; import { EVMChainAdapter, type EVMAdapterConfig } from '../src/evm-adapter.js'; import { RpcFailoverClient } from '../src/rpc-failover-client.js'; +import { createRpcTimeoutError } from '../src/chain-rpc-transport-error.js'; import { startLoopbackRpc, type LoopbackRpc } from './loopback-rpc-harness.js'; import { getRpcFailoverStats, _resetRpcFailoverStatsForTest } from '../src/rpc-failover-log.js'; const DEPLOYER_PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; const HUB = '0x0000000000000000000000000000000000000001'; +const KAS = '0x0000000000000000000000000000000000000002'; +const AUTHOR = '0x1111111111111111111111111111111111111111'; +const TX_HASH = `0x${'aa'.repeat(32)}`; +const BLOCK_HASH = `0x${'bb'.repeat(32)}`; +const KAS_ABI = [ + 'function getLatestMerkleRoot(uint256) view returns (bytes32)', + 'event KnowledgeAssetCreated(uint256 indexed id,address indexed author,string publishOperationId,bytes32 merkleRoot,uint88 byteSize,uint40 startEpoch,uint40 endEpoch,uint96 tokenAmount,bool isImmutable)', +]; + +async function waitForRpc( + rpc: LoopbackRpc, + method: string, + measure: 'hits' | 'aborted' = 'hits', +): Promise { + for (let turn = 0; turn < 100 && rpc[measure](method) === 0; turn += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(rpc[measure](method)).toBe(1); +} function minimalConfig(overrides: Partial = {}): EVMAdapterConfig { return { @@ -94,6 +114,93 @@ describe('multi-RPC read failover (real loopback providers)', () => { expect(only.hits('eth_chainId')).toBeGreaterThanOrEqual(1); }); + describe('caller-owned cancellation reaches concrete adapter reads', () => { + it('aborts a hung getLatestMerkleRoot eth_call at the HTTP socket', async () => { + const rpc = trackServer(await startLoopbackRpc({ hang: ['eth_call'] })); + const a = track(new EVMChainAdapter(minimalConfig({ rpcUrl: rpc.url }))); + const internal = a as unknown as { + initialized: boolean; + providers: JsonRpcProvider[]; + contracts: { knowledgeAssetStorage?: Contract }; + }; + internal.initialized = true; + internal.contracts.knowledgeAssetStorage = new Contract(KAS, KAS_ABI, internal.providers[0]); + + const controller = new AbortController(); + const timeoutError = createRpcTimeoutError('authentication attempt timed out'); + const pending = a.getLatestMerkleRoot(1n, { signal: controller.signal }); + try { + await waitForRpc(rpc, 'eth_call'); + controller.abort(timeoutError); + await expect(pending).rejects.toMatchObject({ code: 'RPC_TIMEOUT' }); + await waitForRpc(rpc, 'eth_call', 'aborted'); + } finally { + if (!controller.signal.aborted) controller.abort(timeoutError); + await pending.catch(() => {}); + } + }); + + it('aborts the receipt block read during V1 publish provenance parsing', async () => { + const kasInterface = new Interface(KAS_ABI); + const created = kasInterface.encodeEventLog( + kasInterface.getEvent('KnowledgeAssetCreated')!, + [1n, AUTHOR, 'publish-op', `0x${'11'.repeat(32)}`, 1n, 1n, 2n, 1n, false], + ); + const receipt = { + blockHash: BLOCK_HASH, + blockNumber: '0x10', + contractAddress: null, + cumulativeGasUsed: '0x5208', + from: AUTHOR, + gasPrice: '0x1', + gasUsed: '0x5208', + logs: [{ + address: KAS, + blockHash: BLOCK_HASH, + blockNumber: '0x10', + data: created.data, + logIndex: '0x0', + removed: false, + topics: created.topics, + transactionHash: TX_HASH, + transactionIndex: '0x0', + }], + logsBloom: `0x${'00'.repeat(256)}`, + status: '0x1', + to: KAS, + transactionHash: TX_HASH, + transactionIndex: '0x0', + type: '0x2', + }; + const rpc = trackServer(await startLoopbackRpc({ + results: { eth_getTransactionReceipt: receipt }, + hang: ['eth_getBlockByNumber'], + })); + const a = track(new EVMChainAdapter(minimalConfig({ rpcUrl: rpc.url }))); + const internal = a as unknown as { + initialized: boolean; + providers: JsonRpcProvider[]; + contracts: { knowledgeAssetStorage?: Contract }; + }; + internal.initialized = true; + internal.contracts.knowledgeAssetStorage = new Contract(KAS, KAS_ABI, internal.providers[0]); + + const controller = new AbortController(); + const timeoutError = createRpcTimeoutError('authentication attempt timed out'); + const pending = a.resolvePublishByTxHash(TX_HASH, { signal: controller.signal }); + try { + await waitForRpc(rpc, 'eth_getTransactionReceipt'); + await waitForRpc(rpc, 'eth_getBlockByNumber'); + controller.abort(timeoutError); + await expect(pending).rejects.toMatchObject({ code: 'RPC_TIMEOUT' }); + await waitForRpc(rpc, 'eth_getBlockByNumber', 'aborted'); + } finally { + if (!controller.signal.aborted) controller.abort(timeoutError); + await pending.catch(() => {}); + } + }); + }); + // ── TARGET (immediate failover) ────────────────────────────────────────── // UN-SKIP when R1 routes reads through the `RpcFailoverClient` read loop over // the bare providers AND multi-RPC sets per-endpoint retries = 0. On the CURRENT code @@ -106,6 +213,19 @@ describe('multi-RPC read failover (real loopback providers)', () => { // backup is healthy — i.e. no failover — so the first test fails exactly as a // regression gate should. It must flip GREEN once R1 lands; do not weaken it. describe('TARGET — immediate read failover (R1)', () => { + it('raw fetch failure on a refused primary advances to the healthy backup', async () => { + const backup = trackServer(await startLoopbackRpc()); + const a = track(new EVMChainAdapter(minimalConfig({ + rpcUrl: 'http://127.0.0.1:1', + rpcUrls: [backup.url], + }))); + + const start = Date.now(); + await expect(a.getEvmChainId()).resolves.toBe(31337n); + expect(backup.hits('eth_chainId')).toBeGreaterThanOrEqual(1); + expect(Date.now() - start).toBeLessThan(2_000); + }); + it('primary 429 on the read → served by the healthy backup, primary hit exactly once (no per-endpoint retry)', async () => { const primary = trackServer(await startLoopbackRpc({ throttle: ['eth_chainId'] })); const backup = trackServer(await startLoopbackRpc()); diff --git a/packages/chain/test/rpc-usage.unit.test.ts b/packages/chain/test/rpc-usage.unit.test.ts index 68f459b276..96b87d481d 100644 --- a/packages/chain/test/rpc-usage.unit.test.ts +++ b/packages/chain/test/rpc-usage.unit.test.ts @@ -26,9 +26,12 @@ import { normalizeRpcUsageConsumer, rpcUsageWindowTotal, RpcUsageTracker, + createCountingJsonRpcProvider, type RpcUsageDrainable, withRpcUsageConsumer, } from '../src/rpc-usage.js'; +import { withRpcRequestAbortSignal } from '../src/rpc-request-transport.js'; +import { createRpcTimeoutError } from '../src/chain-rpc-transport-error.js'; import type { ChainAdapter } from '../src/chain-adapter.js'; import { startLoopbackRpc, type LoopbackRpc } from './loopback-rpc-harness.js'; @@ -113,6 +116,40 @@ describe('RPC usage accounting — raw request counts EQUAL the server-received expect(drained.lifetimeTotal).toBe(usage.lifetimeTotal); }); + it('cancels the active ethers HTTP request when the caller aborts a chain read', async () => { + const rpc = await startLoopbackRpc({ hang: ['eth_blockNumber'] }); + servers.push(rpc); + const provider = createCountingJsonRpcProvider( + rpc.url, + 0, + new RpcUsageTracker(() => 'evm:31337'), + { batchMaxCount: 1 }, + ); + const controller = new AbortController(); + const timeoutError = createRpcTimeoutError('authentication attempt timed out'); + + const pending = withRpcRequestAbortSignal( + controller.signal, + () => provider.send('eth_blockNumber', []), + ); + try { + for (let turn = 0; turn < 50 && rpc.hits('eth_blockNumber') === 0; turn += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(rpc.hits('eth_blockNumber')).toBe(1); + controller.abort(timeoutError); + await expect(pending).rejects.toMatchObject({ code: 'RPC_TIMEOUT' }); + for (let turn = 0; turn < 50 && rpc.aborted('eth_blockNumber') === 0; turn += 1) { + await new Promise((resolve) => setTimeout(resolve, 2)); + } + expect(rpc.aborted('eth_blockNumber')).toBe(1); + } finally { + if (!controller.signal.aborted) controller.abort(timeoutError); + await pending.catch(() => {}); + provider.destroy(); + } + }); + it('STATIC NETWORK: getEvmChainId validates configured chain id once, then caches it', async () => { installMeter(); const rpc = await startLoopbackRpc(); diff --git a/packages/publisher/test/ka-update.test.ts b/packages/publisher/test/ka-update.test.ts index bee472baa2..b4dcec2240 100644 --- a/packages/publisher/test/ka-update.test.ts +++ b/packages/publisher/test/ka-update.test.ts @@ -837,13 +837,13 @@ describe('UpdateHandler', () => { quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')], }); - await publisher.update(original.kaId, { + const updateResult = await publisher.update(original.kaId, { contextGraphId: CONTEXT_GRAPH, quads: [q(ENTITY_A, 'http://schema.org/name', '"Updated"')], }); const verification = await chain.verifyKAUpdate( - '0xfaketx', + updateResult.onChainResult!.txHash, original.kaId, '0xWrongPublisher', ); diff --git a/packages/publisher/test/security-regressions.test.ts b/packages/publisher/test/security-regressions.test.ts index 898266a088..428e9cef29 100644 --- a/packages/publisher/test/security-regressions.test.ts +++ b/packages/publisher/test/security-regressions.test.ts @@ -540,7 +540,11 @@ describe('EVMChainAdapter.verifyKAUpdate', () => { }); expect(updateResult.status).toBe('confirmed'); - const verification = await chain.verifyKAUpdate('0xWRONG', original.kaId, wallet.address); + const verification = await chain.verifyKAUpdate( + original.onChainResult!.txHash, + original.kaId, + wallet.address, + ); expect(verification.verified).toBe(false); }); From 76ab62945a21bee0cee5f7d8569d8e7867c8f2a6 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:52:42 +0200 Subject: [PATCH 226/292] fix(chain): prove snapshot endpoint capability --- .../src/strict-current-finalized-evm-rpc.ts | 19 +- ...rict-current-finalized-evm-snapshot-rpc.ts | 194 +++++++++++++----- ...ict-current-finalized-evm-rpc.unit.test.ts | 16 ++ ...urrent-finalized-evm-snapshot.unit.test.ts | 111 ++++++++-- 4 files changed, 268 insertions(+), 72 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 55782b1af5..6b48abb92c 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -311,7 +311,6 @@ async function executeEndpointAttempt( ), anchorMismatchMessage: 'Block-number fallback hash sandwich did not preserve the resolved finalized anchor', - missingResultMessage: 'Block-number fallback produced no contract results', }); return Object.freeze({ @@ -333,7 +332,6 @@ export interface StrictFinalizedAnchorPolicyOptionsV1 { readonly executeAtReference: (blockReference: unknown) => Promise; readonly readPostAnchor: () => Promise; readonly anchorMismatchMessage: string; - readonly missingResultMessage: string; } /** Canonical EIP-1898 / authenticated-numbered-anchor execution policy. */ @@ -350,10 +348,14 @@ export async function executeStrictFinalizedAnchorPolicyV1( // Number-selected evidence is not deterministic until the same endpoint // closes the hash sandwich. Delay every anchor-dependent invalidity until // that proof succeeds so neither callers nor caches can observe false state. - let anchorDependentFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - let provisionalResult: T | undefined; + let provisionalExecution: + | Readonly<{ readonly ok: true; readonly value: T }> + | Readonly<{ readonly ok: false; readonly failure: CurrentFinalizedEvmCallErrorV1 }>; try { - provisionalResult = await options.executeAtReference(options.anchor.blockNumberQuantity); + provisionalExecution = Object.freeze({ + ok: true, + value: await options.executeAtReference(options.anchor.blockNumberQuantity), + }); } catch (cause) { if ( cause instanceof CurrentFinalizedEvmCallErrorV1 @@ -364,7 +366,7 @@ export async function executeStrictFinalizedAnchorPolicyV1( || isAnchorDependentResourceLimit(cause) ) ) { - anchorDependentFailure = cause; + provisionalExecution = Object.freeze({ ok: false, failure: cause }); } else { throw cause; } @@ -374,9 +376,8 @@ export async function executeStrictFinalizedAnchorPolicyV1( options.readPostAnchor, options.anchorMismatchMessage, ); - if (anchorDependentFailure !== undefined) throw anchorDependentFailure; - if (provisionalResult === undefined) throw unavailable(options.missingResultMessage); - return provisionalResult; + if (!provisionalExecution.ok) throw provisionalExecution.failure; + return provisionalExecution.value; } export async function assertStrictFinalizedAnchorStableV1( diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts index 124d32fdec..f4bc475860 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -5,6 +5,8 @@ import type { import { CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; @@ -50,6 +52,22 @@ interface SnapshotEndpointPreflightV1 { readonly lastRequestId: number; } +type SnapshotRpcV1 = ( + method: string, + params: readonly unknown[], +) => Promise; + +interface SnapshotReadSessionControllerV1 { + readonly session: StrictCurrentFinalizedEvmSnapshotSessionV1; + readonly closeAndDrain: () => Promise; +} + +const SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1 = + '0x0000000000000000000000000000000000000000' as EvmAddressV1; +const SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1 = + `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; +const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; + /** Package-internal runtime for a scoped, pinned finalized snapshot. */ export function createStrictFinalizedSnapshotRpcRuntimeV1( config: StrictFinalizedSnapshotRpcConfigV1, @@ -179,9 +197,58 @@ async function preflightSnapshotEndpoint( await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), 'current finalized snapshot header', ); + const blockReference = config.blockReferenceProfile === 'eip1898' + ? Object.freeze({ blockHash: anchor.blockHash, requireCanonical: true as const }) + : anchor.blockNumberQuantity; + await probeSnapshotReadProfile(rpc, blockReference); + if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { + await assertStrictFinalizedAnchorStableV1( + anchor, + async () => parseFinalizedAnchor( + await rpc( + 'eth_getBlockByNumber', + Object.freeze([anchor.blockNumberQuantity, false]), + ), + 'post-preflight numbered header', + ), + 'Snapshot read-profile preflight did not preserve the resolved finalized anchor', + ); + } return Object.freeze({ anchor, lastRequestId: requestId }); } +async function probeSnapshotReadProfile( + rpc: SnapshotRpcV1, + blockReference: unknown, +): Promise { + try { + const code = await rpc('eth_getCode', Object.freeze([ + SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, + blockReference, + ])); + if (typeof code !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(code)) { + throw new Error('eth_getCode capability probe returned malformed bytes'); + } + const callResult = await rpc('eth_call', Object.freeze([ + Object.freeze({ + from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + to: SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, + data: '0x', + gas: SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1, + }), + blockReference, + ])); + if (typeof callResult !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(callResult)) { + throw new Error('eth_call capability probe returned malformed bytes'); + } + } catch (cause) { + throw unavailable( + 'Configured snapshot endpoint cannot execute the required finalized read profile', + cause, + ); + } +} + async function executePinnedSnapshotScope( config: StrictFinalizedSnapshotRpcConfigV1, endpoint: string, @@ -190,8 +257,51 @@ async function executePinnedSnapshotScope( consume: (session: StrictCurrentFinalizedEvmSnapshotSessionV1) => Promise, totalDeadline: DeadlineScope, ): Promise { - let requestId = preflight.lastRequestId; - const rpc = async (method: string, params: readonly unknown[]): Promise => { + const rpc = createPinnedSnapshotRpcClient( + endpoint, + preflight.lastRequestId, + request, + totalDeadline, + ); + const readSession = createSnapshotReadSession( + config, + preflight.anchor, + rpc, + totalDeadline, + ); + const result = await runConsumerWithDrainedReads(readSession, consume); + if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { + await assertStrictFinalizedAnchorStableV1( + preflight.anchor, + async () => parseFinalizedAnchor( + await rpc( + 'eth_getBlockByNumber', + Object.freeze([preflight.anchor.blockNumberQuantity, false]), + ), + 'post-snapshot numbered header', + ), + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + ); + } + if (request.signal.aborted) { + throw cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + return result; +} + +function createPinnedSnapshotRpcClient( + endpoint: string, + lastRequestId: number, + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + totalDeadline: DeadlineScope, +): SnapshotRpcV1 { + let requestId = lastRequestId; + return async (method: string, params: readonly unknown[]): Promise => { if (request.signal.aborted) { throw cancelled('Current-finalized snapshot was cancelled'); } @@ -235,7 +345,14 @@ async function executePinnedSnapshotScope( rpcDeadline.close(); } }; +} +function createSnapshotReadSession( + config: StrictFinalizedSnapshotRpcConfigV1, + anchor: FinalizedAnchorV1, + rpc: SnapshotRpcV1, + totalDeadline: DeadlineScope, +): Readonly { const budget = createCurrentFinalizedEvmSnapshotBudgetV1(); const deployedTargets = new Set(); let active = true; @@ -267,7 +384,7 @@ async function executePinnedSnapshotScope( } const operation = executeSnapshotBatch( config, - preflight.anchor, + anchor, calls, deployedTargets, rpc, @@ -285,56 +402,42 @@ async function executePinnedSnapshotScope( }); const session = Object.freeze({ chainId: config.chainId, - blockNumber: preflight.anchor.blockNumber, - blockHash: preflight.anchor.blockHash, + blockNumber: anchor.blockNumber, + blockHash: anchor.blockHash, read, } satisfies StrictCurrentFinalizedEvmSnapshotSessionV1); + return Object.freeze({ + session, + closeAndDrain: async (): Promise => { + active = false; + const danglingRead = inFlight; + if (danglingRead === undefined) return false; + await danglingRead.catch(() => undefined); + return true; + }, + }); +} - let result!: T; - let callbackFailure: unknown; - let callbackSucceeded = false; +async function runConsumerWithDrainedReads( + readSession: Readonly, + consume: (session: StrictCurrentFinalizedEvmSnapshotSessionV1) => Promise, +): Promise { + let execution: + | Readonly<{ readonly ok: true; readonly value: T }> + | Readonly<{ readonly ok: false; readonly failure: unknown }>; try { - result = await consume(session); - callbackSucceeded = true; + execution = Object.freeze({ ok: true, value: await consume(readSession.session) }); } catch (cause) { - callbackFailure = cause; - } - active = false; - - const danglingRead = inFlight; - if (danglingRead !== undefined) { - await danglingRead.catch(() => undefined); - if (callbackSucceeded) { - callbackSucceeded = false; - callbackFailure = unavailable( - 'Current-finalized snapshot consumer settled with an unawaited read in flight', - ); - } + execution = Object.freeze({ ok: false, failure: cause }); } - - if (!callbackSucceeded) throw callbackFailure; - if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { - await assertStrictFinalizedAnchorStableV1( - preflight.anchor, - async () => parseFinalizedAnchor( - await rpc( - 'eth_getBlockByNumber', - Object.freeze([preflight.anchor.blockNumberQuantity, false]), - ), - 'post-snapshot numbered header', - ), - 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', - ); - } - if (request.signal.aborted) { - throw cancelled('Current-finalized snapshot was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + const hadDanglingRead = await readSession.closeAndDrain(); + if (execution.ok && hadDanglingRead) { + throw unavailable( + 'Current-finalized snapshot consumer settled with an unawaited read in flight', ); } - return result; + if (!execution.ok) throw execution.failure; + return execution.value; } function handledRejectedRead(cause: unknown): Promise { @@ -369,7 +472,6 @@ async function executeSnapshotBatch( ), anchorMismatchMessage: 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', - missingResultMessage: 'Finalized snapshot batch produced no results', }); for (const target of batch.verifiedTargets) deployedTargets.add(target); return batch.returnData; diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 70928463ce..09d4fe5397 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -32,6 +32,8 @@ import { import { createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, + executeStrictFinalizedAnchorPolicyV1, + parseFinalizedAnchor, readStrictCurrentFinalizedEvmRevertDataV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from '../src/strict-current-finalized-evm-rpc.js'; @@ -80,6 +82,20 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { .toBe(CURRENT_FINALIZED_EVM_READ_ENDPOINT_ATTEMPT_POLICY_V1); }); + it('preserves an undefined generic result through an authenticated numbered anchor', async () => { + const anchor = parseFinalizedAnchor( + { number: '0x7b', hash: BLOCK_HASH }, + 'generic void result test anchor', + ); + await expect(executeStrictFinalizedAnchorPolicyV1({ + blockReferenceProfile: 'trusted-block-number-hash-sandwich', + anchor, + executeAtReference: async () => undefined, + readPostAnchor: async () => anchor, + anchorMismatchMessage: 'generic void result anchor changed', + })).resolves.toBeUndefined(); + }); + it('executes multiple ABI reads at one EIP-1898 anchor and checks shared code once', async () => { const server = await startRpcServer((call, response) => { switch (call.method) { diff --git a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts index b58553296b..7babcae870 100644 --- a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts @@ -30,6 +30,7 @@ import { const CHAIN_ID = '20430' as ChainIdV1; const CHAIN_QUANTITY = '0x4fce'; const TO = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const PREFLIGHT_PROBE_TO = '0x0000000000000000000000000000000000000000'; const BLOCK_HASH = `0x${'22'.repeat(32)}`; const OTHER_BLOCK_HASH = `0x${'23'.repeat(32)}`; const FIRST_DATA = '0x11111111'; @@ -70,6 +71,8 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { 'eth_getBlockByNumber', 'eth_getCode', 'eth_call', + 'eth_getCode', + 'eth_call', 'eth_call', ]); const anchored = server.calls.filter(({ method }) => ( @@ -79,6 +82,8 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { HASH_REFERENCE, HASH_REFERENCE, HASH_REFERENCE, + HASH_REFERENCE, + HASH_REFERENCE, ]); }); @@ -106,13 +111,53 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { 'eth_getBlockByNumber', 'eth_getCode', 'eth_call', + 'eth_getCode', + 'eth_call', + ]); + }); + + it('fails over before consumer execution when an endpoint rejects the EIP-1898 read profile', async () => { + const baseHandler = successfulHandler(); + const first = await rpcHarness.start((rpcCall, response, rawRequest) => { + if (rpcCall.method === 'eth_call' && isPreflightProbe(rpcCall)) { + sendJsonRpcError(response, rpcCall, -32602, 'EIP-1898 block reference unsupported'); + return; + } + return baseHandler(rpcCall, response, rawRequest); + }); + const second = await rpcHarness.start(successfulHandler()); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [first.url, second.url], + }); + let consumers = 0; + + await expect(withSnapshot(request(), async (session) => { + consumers += 1; + return session.read([call(FIRST_DATA)]); + })).resolves.toEqual(['0xaaaa']); + + expect(consumers).toBe(1); + expect(first.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + ]); + expect(second.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getCode', + 'eth_call', ]); }); it('never retries or replays the consumer after callback execution begins', async () => { const baseHandler = successfulHandler(); const first = await rpcHarness.start((rpcCall, response, rawRequest) => { - if (rpcCall.method !== 'eth_call') { + if (rpcCall.method !== 'eth_call' || isPreflightProbe(rpcCall)) { return baseHandler(rpcCall, response, rawRequest); } response.writeHead(503, { 'content-type': 'text/plain' }); @@ -136,6 +181,8 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { 'eth_getBlockByNumber', 'eth_getCode', 'eth_call', + 'eth_getCode', + 'eth_call', ]); expect(second.calls).toHaveLength(0); }); @@ -187,14 +234,20 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { if (!finalizedLookup) numberedHeaderReads += 1; sendJsonRpcResult(response, rpcCall, { number: '0x7b', - hash: finalizedLookup || numberedHeaderReads > 1 ? BLOCK_HASH : OTHER_BLOCK_HASH, + hash: finalizedLookup || numberedHeaderReads !== 2 ? BLOCK_HASH : OTHER_BLOCK_HASH, }); return; } - case 'eth_getCode': - codeReads += 1; - sendJsonRpcResult(response, rpcCall, codeReads === 1 ? '0x6000' : '0x'); + case 'eth_getCode': { + const isProbe = rpcCall.params[0] === PREFLIGHT_PROBE_TO; + if (!isProbe) codeReads += 1; + sendJsonRpcResult( + response, + rpcCall, + isProbe || codeReads === 1 ? '0x6000' : '0x', + ); return; + } case 'eth_call': sendJsonRpcResult(response, rpcCall, '0xaaaa'); return; @@ -230,22 +283,34 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { [OTHER_BLOCK_HASH, 'finalized-state-unavailable'], [BLOCK_HASH, failure.stableCode], ] as const) { + let numberedHeaderReads = 0; const server = await rpcHarness.start((rpcCall, response) => { switch (rpcCall.method) { case 'eth_chainId': sendJsonRpcResult(response, rpcCall, CHAIN_QUANTITY); return; - case 'eth_getBlockByNumber': + case 'eth_getBlockByNumber': { + const finalizedLookup = rpcCall.params[0] === 'finalized'; + if (!finalizedLookup) numberedHeaderReads += 1; sendJsonRpcResult(response, rpcCall, { number: '0x7b', - hash: rpcCall.params[0] === 'finalized' ? BLOCK_HASH : postHash, + hash: finalizedLookup || numberedHeaderReads === 1 ? BLOCK_HASH : postHash, }); return; + } case 'eth_getCode': - sendJsonRpcResult(response, rpcCall, failure.kind === 'no-code' ? '0x' : '0x6000'); + sendJsonRpcResult( + response, + rpcCall, + rpcCall.params[0] === PREFLIGHT_PROBE_TO || failure.kind !== 'no-code' + ? '0x6000' + : '0x', + ); return; case 'eth_call': - if (failure.kind === 'revert') { + if (isPreflightProbe(rpcCall)) { + sendJsonRpcResult(response, rpcCall, '0x'); + } else if (failure.kind === 'revert') { sendJsonRpcError(response, rpcCall, 3, 'execution reverted'); } else if (failure.kind === 'resource-limit') { sendJsonRpcError(response, rpcCall, -32000, 'out of gas'); @@ -267,7 +332,9 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { session.read([call(FIRST_DATA, failure.maxReturnBytes)]) ))).rejects.toMatchObject({ code: expectedCode }); const expectedCalls = failure.kind === 'no-code' ? 0 : 1; - expect(server.calls.filter(({ method }) => method === 'eth_call')) + expect(server.calls.filter((rpcCall) => ( + rpcCall.method === 'eth_call' && !isPreflightProbe(rpcCall) + ))) .toHaveLength(expectedCalls); expect(server.calls.filter(({ method }) => method === 'eth_chainId')) .toHaveLength(1); @@ -287,7 +354,7 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { if (!finalizedLookup) numberedHeaderReads += 1; sendJsonRpcResult(response, rpcCall, { number: '0x7b', - hash: finalizedLookup || numberedHeaderReads === 1 + hash: finalizedLookup || numberedHeaderReads <= 2 ? BLOCK_HASH : OTHER_BLOCK_HASH, }); @@ -313,7 +380,7 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { await session.read([call(FIRST_DATA)]); return 'must-not-escape'; })).rejects.toMatchObject({ code: 'finalized-state-unavailable' }); - expect(numberedHeaderReads).toBe(2); + expect(numberedHeaderReads).toBe(3); }); it('closes an escaped session and cannot mix it into a later adapter scope', async () => { @@ -344,6 +411,8 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { expect(second.calls.map(({ method }) => method)).toEqual([ 'eth_chainId', 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', ]); }); @@ -352,7 +421,7 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { const releaseCall = deferred(); const baseHandler = successfulHandler(); const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { - if (rpcCall.method === 'eth_call') { + if (rpcCall.method === 'eth_call' && !isPreflightProbe(rpcCall)) { callStarted.resolve(undefined); await releaseCall.promise; } @@ -382,7 +451,7 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { const releaseCall = deferred(); const baseHandler = successfulHandler(); const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { - if (rpcCall.method !== 'eth_call') { + if (rpcCall.method !== 'eth_call' || isPreflightProbe(rpcCall)) { await baseHandler(rpcCall, response, rawRequest); return; } @@ -420,7 +489,7 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { const releaseCall = deferred(); const baseHandler = successfulHandler(); const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { - if (rpcCall.method === 'eth_call') { + if (rpcCall.method === 'eth_call' && !isPreflightProbe(rpcCall)) { callStarted.resolve(undefined); await releaseCall.promise; } @@ -453,7 +522,7 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { const closed = deferred(); const baseHandler = successfulHandler(); const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { - if (rpcCall.method !== 'eth_call') { + if (rpcCall.method !== 'eth_call' || isPreflightProbe(rpcCall)) { return baseHandler(rpcCall, response, rawRequest); } response.on('close', () => closed.resolve(undefined)); @@ -495,6 +564,8 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { expect(server.calls.map(({ method }) => method)).toEqual([ 'eth_chainId', 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', ]); }); @@ -595,7 +666,8 @@ function successfulHandler(): LoopbackJsonRpcHandler { return; case 'eth_call': { const callObject = rpcCall.params[0] as { readonly data?: unknown }; - if (callObject.data === FIRST_DATA) sendJsonRpcResult(response, rpcCall, '0xaaaa'); + if (callObject.data === '0x') sendJsonRpcResult(response, rpcCall, '0x'); + else if (callObject.data === FIRST_DATA) sendJsonRpcResult(response, rpcCall, '0xaaaa'); else if (callObject.data === SECOND_DATA) sendJsonRpcResult(response, rpcCall, '0xbbbb'); else sendJsonRpcError(response, rpcCall, -32602, 'unexpected calldata'); return; @@ -606,6 +678,11 @@ function successfulHandler(): LoopbackJsonRpcHandler { }; } +function isPreflightProbe(rpcCall: { readonly params: readonly unknown[] }): boolean { + const callObject = rpcCall.params[0] as { readonly to?: unknown; readonly data?: unknown }; + return callObject.to === PREFLIGHT_PROBE_TO && callObject.data === '0x'; +} + function deferred(): { readonly promise: Promise; readonly resolve: (value: T | PromiseLike) => void; From 6fe8da04d4090885454a886e24b2fcf135a93f33 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 14:28:42 +0200 Subject: [PATCH 227/292] fix(chain): classify malformed deployed code as RPC failure --- ...ct-current-finalized-evm-batch-executor.ts | 2 +- ...ict-current-finalized-evm-rpc.unit.test.ts | 33 ++++++++++ ...urrent-finalized-evm-snapshot.unit.test.ts | 63 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts index 0c60cfd61d..79d5be80f0 100644 --- a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts +++ b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts @@ -62,7 +62,7 @@ export async function executeStrictFinalizedEvmBatchV1( function assertDeployedCode(input: unknown): void { if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', + 'rpc-unavailable', 'eth_getCode returned malformed code bytes', ); } diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 09d4fe5397..267f379970 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -550,6 +550,39 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { ]); }); + it('classifies malformed deployed-code bytes as RPC failure and fails over', async () => { + const malformed = await startRpcServer(successfulHandler({ code: '0x0' })); + const backup = await startRpcServer(successfulHandler()); + const adapter = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [malformed.url, backup.url], + }); + + await expect(adapter(fixedRequest())).resolves.toMatchObject({ + chainId: CHAIN_ID, + blockHash: BLOCK_HASH, + }); + expect(malformed.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + ]); + expect(backup.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + ]); + + const terminal = createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [malformed.url], + }); + await expect(terminal(fixedRequest())).rejects.toMatchObject({ + code: 'rpc-unavailable', + }); + }); + it('never follows a redirect to an endpoint outside trusted local configuration', async () => { const unconfigured = await startRpcServer(successfulHandler()); const configured = await startRpcServer((_call, response) => { diff --git a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts index 7babcae870..6b7e45ceac 100644 --- a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts @@ -154,6 +154,69 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { ]); }); + it('fails over before consumer execution when the capability code probe is malformed', async () => { + const baseHandler = successfulHandler(); + const malformed = await rpcHarness.start((rpcCall, response, rawRequest) => { + if (rpcCall.method === 'eth_getCode') { + sendJsonRpcResult(response, rpcCall, '0x0'); + return; + } + return baseHandler(rpcCall, response, rawRequest); + }); + const backup = await rpcHarness.start(successfulHandler()); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [malformed.url, backup.url], + }); + let consumers = 0; + + await expect(withSnapshot(request(), async (session) => { + consumers += 1; + return session.read([call(FIRST_DATA)]); + })).resolves.toEqual(['0xaaaa']); + + expect(consumers).toBe(1); + expect(malformed.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + ]); + expect(backup.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getCode', + 'eth_call', + ]); + }); + + it('classifies malformed deployed-code bytes inside a snapshot as RPC failure', async () => { + const baseHandler = successfulHandler(); + const server = await rpcHarness.start((rpcCall, response, rawRequest) => { + if (rpcCall.method === 'eth_getCode' && rpcCall.params[0] === TO) { + sendJsonRpcResult(response, rpcCall, '0xABC0'); + return; + } + return baseHandler(rpcCall, response, rawRequest); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + + await expect(withSnapshot(request(), async (session) => ( + session.read([call(FIRST_DATA)]) + ))).rejects.toMatchObject({ code: 'rpc-unavailable' }); + expect(server.calls.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_getBlockByNumber', + 'eth_getCode', + 'eth_call', + 'eth_getCode', + ]); + }); + it('never retries or replays the consumer after callback execution begins', async () => { const baseHandler = successfulHandler(); const first = await rpcHarness.start((rpcCall, response, rawRequest) => { From b5754bdb31ff486fe447e7186b986dc013e6a47f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 15:04:26 +0200 Subject: [PATCH 228/292] refactor(chain): share finalized RPC transport lifecycle --- .../current-finalized-evm-read-validation.ts | 104 ++ .../finalized-context-graph-rpc-resolver.ts | 2 +- .../src/strict-current-finalized-evm-rpc.ts | 933 +----------------- ...-current-finalized-evm-snapshot-factory.ts | 6 +- ...rict-current-finalized-evm-snapshot-rpc.ts | 159 +-- .../strict-current-finalized-evm-transport.ts | 924 +++++++++++++++++ ...ict-current-finalized-evm-rpc.unit.test.ts | 6 +- 7 files changed, 1095 insertions(+), 1039 deletions(-) create mode 100644 packages/chain/src/current-finalized-evm-read-validation.ts create mode 100644 packages/chain/src/strict-current-finalized-evm-transport.ts diff --git a/packages/chain/src/current-finalized-evm-read-validation.ts b/packages/chain/src/current-finalized-evm-read-validation.ts new file mode 100644 index 0000000000..27da4ea959 --- /dev/null +++ b/packages/chain/src/current-finalized-evm-read-validation.ts @@ -0,0 +1,104 @@ +import { + assertCanonicalChainId, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import type { + StrictCurrentFinalizedEvmReadCallV1, + StrictCurrentFinalizedEvmReadRequestV1, +} from './current-finalized-evm-read-model.js'; +import type { StrictCurrentFinalizedEvmSnapshotRequestV1 } from './current-finalized-evm-snapshot.js'; +import { + assertCanonicalNonzeroEvmAddress, + isAbortSignal, + snapshotDenseDataArray, + snapshotExactDataRecord, +} from './strict-local-data.js'; + +const READ_REQUEST_KEYS = Object.freeze(['calls', 'chainId', 'signal'] as const); +const READ_CALL_KEYS = Object.freeze(['data', 'maxReturnBytes', 'to'] as const); +const SNAPSHOT_REQUEST_KEYS = Object.freeze(['chainId', 'signal'] as const); + +export function snapshotCurrentFinalizedEvmReadRequestV1( + input: unknown, +): StrictCurrentFinalizedEvmReadRequestV1 { + try { + const record = snapshotExactDataRecord(input, READ_REQUEST_KEYS); + assertCanonicalChainId(record.chainId, 'strict finalized-read chainId'); + if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); + return Object.freeze({ + chainId: record.chainId, + calls: snapshotCurrentFinalizedEvmReadCallsV1(record.calls), + signal: record.signal, + }); + } catch { + throw invalidProfile('Strict current-finalized read request failed the fixed local profile'); + } +} + +export function snapshotCurrentFinalizedEvmSnapshotRequestV1( + input: unknown, +): StrictCurrentFinalizedEvmSnapshotRequestV1 { + try { + const record = snapshotExactDataRecord(input, SNAPSHOT_REQUEST_KEYS); + assertCanonicalChainId(record.chainId, 'strict finalized-snapshot chainId'); + if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); + return Object.freeze({ chainId: record.chainId, signal: record.signal }); + } catch { + throw invalidProfile('Strict current-finalized snapshot request failed the fixed local profile'); + } +} + +export function snapshotCurrentFinalizedEvmSnapshotCallsV1( + input: unknown, +): readonly StrictCurrentFinalizedEvmReadCallV1[] { + try { + return snapshotCurrentFinalizedEvmReadCallsV1(input); + } catch { + throw invalidProfile('Strict current-finalized snapshot batch failed the fixed local profile'); + } +} + +function snapshotCurrentFinalizedEvmReadCallsV1( + input: unknown, +): readonly StrictCurrentFinalizedEvmReadCallV1[] { + const entries = snapshotDenseDataArray(input, { + label: 'Current-finalized read calls', + minLength: 1, + maxLength: CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + }); + const calls: StrictCurrentFinalizedEvmReadCallV1[] = []; + for (let index = 0; index < entries.length; index += 1) { + const record = snapshotExactDataRecord(entries[index], READ_CALL_KEYS); + assertCanonicalNonzeroEvmAddress(record.to, `current-finalized read calls[${index}].to`); + if ( + typeof record.data !== 'string' + || !/^0x[0-9a-f]{8}(?:[0-9a-f]{2})*$/.test(record.data) + ) { + throw new Error(`finalized read calls[${index}].data is not canonical ABI calldata`); + } + if ( + typeof record.maxReturnBytes !== 'number' + || !Number.isSafeInteger(record.maxReturnBytes) + || record.maxReturnBytes < 1 + || record.maxReturnBytes > CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 + ) { + throw new Error(`finalized read calls[${index}].maxReturnBytes is outside the fixed cap`); + } + calls.push(Object.freeze({ + to: record.to as EvmAddressV1, + data: record.data, + maxReturnBytes: record.maxReturnBytes, + })); + } + return Object.freeze(calls); +} + +function invalidProfile(message: string): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1('rpc-unavailable', message); +} diff --git a/packages/chain/src/finalized-context-graph-rpc-resolver.ts b/packages/chain/src/finalized-context-graph-rpc-resolver.ts index ed430dade6..7c441a6200 100644 --- a/packages/chain/src/finalized-context-graph-rpc-resolver.ts +++ b/packages/chain/src/finalized-context-graph-rpc-resolver.ts @@ -11,7 +11,7 @@ import { readStrictCurrentFinalizedEvmRevertDataV1, type StrictCurrentFinalizedEvmReadResultV1, type StrictCurrentFinalizedEvmReadV1, -} from './strict-current-finalized-evm-rpc.js'; +} from './strict-current-finalized-evm-transport.js'; // getContextGraph has nine fixed words plus a chain-capped 256-address array: // its maximal canonical ABI result is 8,512 bytes, below this domain ceiling. diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 6b48abb92c..33553633d3 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -1,50 +1,17 @@ -import { - assertCanonicalChainId, - type BlockNumberV1, - type ChainIdV1, - type Digest32V1, - type EvmAddressV1, -} from '@origintrail-official/dkg-core'; - -import { - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, - CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, - CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, - CurrentFinalizedEvmCallErrorV1, -} from './current-finalized-evm-read-profile.js'; -import { - snapshotCurrentFinalizedEvmCallRequestV1, - type CurrentFinalizedEvmChainAdapterV1, -} from './current-finalized-evm-call.js'; -import { - type StrictCurrentFinalizedEvmReadCallV1, - type StrictCurrentFinalizedEvmReadRequestV1, - type StrictCurrentFinalizedEvmReadResultV1, - type StrictCurrentFinalizedEvmReadV1, -} from './current-finalized-evm-read-model.js'; -import { - type StrictCurrentFinalizedEvmSnapshotRequestV1, -} from './current-finalized-evm-snapshot.js'; -import { - assertCanonicalNonzeroEvmAddress, - isAbortSignal, - snapshotDenseDataArray, - snapshotExactDataRecord, -} from './strict-local-data.js'; -import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; -import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; - -export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ - 'eip1898', - 'trusted-block-number-hash-sandwich', -] as const); - -export type CurrentFinalizedEvmBlockReferenceProfileV1 = - (typeof CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1)[number]; +/** + * Public one-shot strict current-finalized RPC surface. + * + * Transport orchestration and shared snapshot internals intentionally live in + * `strict-current-finalized-evm-transport.ts`; this module exposes only the + * supported one-shot factories, configuration, and read model. + */ +export { + CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, + createStrictCurrentFinalizedEvmChainAdapterV1, + createStrictCurrentFinalizedEvmReadV1, + type CurrentFinalizedEvmBlockReferenceProfileV1, + type StrictCurrentFinalizedEvmRpcConfigV1, +} from './strict-current-finalized-evm-transport.js'; export type { StrictCurrentFinalizedEvmReadCallV1, @@ -52,875 +19,3 @@ export type { StrictCurrentFinalizedEvmReadResultV1, StrictCurrentFinalizedEvmReadV1, } from './current-finalized-evm-read-model.js'; - -export interface StrictCurrentFinalizedEvmRpcConfigV1 { - /** Canonical decimal chain ID permanently bound to this adapter. */ - readonly chainId: ChainIdV1; - /** Trusted local configuration, in canonical failover order. */ - readonly endpoints: readonly string[]; - /** - * EIP-1898 is the default. The number/hash sandwich is an explicit trusted - * chain profile for deployments whose RPC endpoints cannot execute EIP-1898. - */ - readonly blockReferenceProfile?: CurrentFinalizedEvmBlockReferenceProfileV1; -} - -export interface StrictRpcConfigSnapshotV1 { - readonly chainId: ChainIdV1; - readonly endpoints: readonly string[]; - readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; -} - -export interface FinalizedAnchorV1 { - readonly blockNumber: BlockNumberV1; - readonly blockNumberQuantity: string; - readonly blockHash: Digest32V1; -} - -export interface DeadlineScope { - readonly signal: AbortSignal; - readonly timedOut: () => boolean; - readonly close: () => void; -} - -interface RpcErrorEnvelopeV1 { - readonly code: number; - readonly message: string; - readonly data?: string; -} - -const CONFIG_REQUIRED_KEYS = Object.freeze(['chainId', 'endpoints'] as const); -const CONFIG_OPTIONAL_KEYS = Object.freeze(['blockReferenceProfile'] as const); -const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; -const CANONICAL_LOWER_QUANTITY = /^0x(?:0|[1-9a-f][0-9a-f]*)$/; -const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; -const MAX_U64 = 18_446_744_073_709_551_615n; -const MAX_U256 = - 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; -const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); -const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); -const PRE_DEADLINE_TERMINAL_FAILURES_V1 = new WeakSet(); -const READ_REQUEST_KEYS = Object.freeze(['calls', 'chainId', 'signal'] as const); -const READ_CALL_KEYS = Object.freeze(['data', 'maxReturnBytes', 'to'] as const); -const SNAPSHOT_REQUEST_KEYS = Object.freeze(['chainId', 'signal'] as const); - -/** Package-internal evidence available only for errors minted by this transport. */ -export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { - return error instanceof CurrentFinalizedEvmCallErrorV1 - ? AUTHENTICATED_REVERT_DATA_V1.get(error) - : undefined; -} - -/** - * Build one strict raw-JSON-RPC adapter from trusted local chain configuration. - * - * This transport intentionally bypasses JsonRpcProvider, RpcFailoverClient, - * and generic timeout wrappers: native fetch gives this lane a streamed - * pre-parse body cap and an AbortSignal that cancels the actual HTTP I/O. POST - * requests are never retried, redirected, reordered, or made sticky. - */ -export function createStrictCurrentFinalizedEvmChainAdapterV1( - input: StrictCurrentFinalizedEvmRpcConfigV1, -): CurrentFinalizedEvmChainAdapterV1 { - const read = createStrictCurrentFinalizedEvmReadV1(input); - - const adapter: CurrentFinalizedEvmChainAdapterV1 = async (inputRequest) => { - const request = snapshotCurrentFinalizedEvmCallRequestV1(inputRequest); - const result = await read({ - chainId: request.chainId, - calls: Object.freeze([Object.freeze({ - to: request.to, - data: request.data, - maxReturnBytes: request.maxReturnBytes, - })]), - signal: request.signal, - }); - const returnData = result.returnData[0]; - if (returnData === undefined) { - throw unavailable('Single-call finalized read produced no contract result'); - } - return Object.freeze({ - chainId: result.chainId, - blockNumber: result.blockNumber, - blockHash: result.blockHash, - returnData, - }); - }; - - return Object.freeze(adapter); -} - -/** - * Build a bounded, non-queueing same-finalized-anchor read primitive. - * - * Calls, destinations, and configured endpoints are trusted local runtime - * inputs. The primitive still snapshots them strictly, fixes gas/deadline/body - * caps, and never accepts a block selector from its caller. - */ -export function createStrictCurrentFinalizedEvmReadV1( - input: StrictCurrentFinalizedEvmRpcConfigV1, -): StrictCurrentFinalizedEvmReadV1 { - const config = snapshotConfig(input); - const admission = createNonqueueingAdmissionGateV1( - CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, - ); - - const read: StrictCurrentFinalizedEvmReadV1 = async (inputRequest) => { - const request = snapshotReadRequest(inputRequest); - if (request.chainId !== config.chainId) { - throw new CurrentFinalizedEvmCallErrorV1( - 'chain-mismatch', - `Adapter is configured for chain ${config.chainId}, not ${request.chainId}`, - ); - } - if (request.signal.aborted) { - throw cancelled('Current-finalized EVM call was cancelled before transport admission'); - } - return admission.run(request.chainId, async () => { - const totalDeadline = createDeadlineScope( - request.signal, - CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, - 'current-finalized total deadline', - ); - let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - try { - for (let index = 0; index < config.endpoints.length; index += 1) { - if (request.signal.aborted) { - throw cancelled('Current-finalized EVM call was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - - const attemptDeadline = createDeadlineScope( - totalDeadline.signal, - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - `current-finalized endpoint attempt ${index + 1}`, - ); - try { - const result = await executeEndpointAttempt( - config, - config.endpoints[index]!, - request, - attemptDeadline, - ); - // Close races where transport completion and abort/deadline become - // observable in the same turn. A late response never escapes merely - // because its promise callback ran before the timer callback. - if (request.signal.aborted) { - throw cancelled('Current-finalized EVM call was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - if (attemptDeadline.timedOut()) { - throw timedOut( - `Current-finalized endpoint attempt exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, - ); - } - return result; - } catch (cause) { - const failure = classifyAttemptFailure( - cause, - request.signal, - totalDeadline, - attemptDeadline, - ); - if (isTerminalAttemptFailure(failure)) throw failure; - lastRetryableFailure = failure; - } finally { - attemptDeadline.close(); - } - } - - if (request.signal.aborted) { - throw cancelled('Current-finalized EVM call was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - throw lastRetryableFailure - ?? unavailable('No configured current-finalized endpoint succeeded'); - } finally { - totalDeadline.close(); - } - }, (active) => new CurrentFinalizedEvmCallErrorV1( - 'concurrency-saturated', - `Chain ${request.chainId} already has ${active} finalized reads in flight`, - )); - }; - - return Object.freeze(read); -} - -async function executeEndpointAttempt( - config: StrictRpcConfigSnapshotV1, - endpoint: string, - request: StrictCurrentFinalizedEvmReadRequestV1, - attemptDeadline: DeadlineScope, -): Promise { - const { signal } = attemptDeadline; - let requestId = 0; - const rpc = async (method: string, params: readonly unknown[]): Promise => { - requestId += 1; - return postJsonRpc( - endpoint, - requestId, - method, - params, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - signal, - ); - }; - - const remoteChainId = parseChainId(await rpc('eth_chainId', Object.freeze([]))); - if (remoteChainId !== config.chainId) { - throw new CurrentFinalizedEvmCallErrorV1( - 'chain-mismatch', - `Configured endpoint attempt reported chain ${remoteChainId}, expected ${config.chainId}`, - ); - } - - const anchor = parseFinalizedAnchor( - await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), - 'current finalized header', - ); - const executeCallsAt = async (blockReference: unknown): Promise => { - const batch = await executeStrictFinalizedEvmBatchV1({ - calls: request.calls, - blockReference, - rpc, - settle: (operations) => settleParallelBatch(operations, attemptDeadline), - }); - return batch.returnData; - }; - - const returnData = await executeStrictFinalizedAnchorPolicyV1({ - blockReferenceProfile: config.blockReferenceProfile, - anchor, - executeAtReference: executeCallsAt, - readPostAnchor: async () => parseFinalizedAnchor( - await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), - 'post-call numbered header', - ), - anchorMismatchMessage: - 'Block-number fallback hash sandwich did not preserve the resolved finalized anchor', - }); - - return Object.freeze({ - chainId: config.chainId, - blockNumber: anchor.blockNumber, - blockHash: anchor.blockHash, - returnData, - }); -} - -/** - * Execute one bounded phase concurrently but retain the permit until every - * started operation settles. This prevents an early rejection from leaving a - * sibling fetch alive after the finalized-read concurrency slot is released. - */ -export interface StrictFinalizedAnchorPolicyOptionsV1 { - readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; - readonly anchor: FinalizedAnchorV1; - readonly executeAtReference: (blockReference: unknown) => Promise; - readonly readPostAnchor: () => Promise; - readonly anchorMismatchMessage: string; -} - -/** Canonical EIP-1898 / authenticated-numbered-anchor execution policy. */ -export async function executeStrictFinalizedAnchorPolicyV1( - options: StrictFinalizedAnchorPolicyOptionsV1, -): Promise { - if (options.blockReferenceProfile === 'eip1898') { - return options.executeAtReference(Object.freeze({ - blockHash: options.anchor.blockHash, - requireCanonical: true as const, - })); - } - - // Number-selected evidence is not deterministic until the same endpoint - // closes the hash sandwich. Delay every anchor-dependent invalidity until - // that proof succeeds so neither callers nor caches can observe false state. - let provisionalExecution: - | Readonly<{ readonly ok: true; readonly value: T }> - | Readonly<{ readonly ok: false; readonly failure: CurrentFinalizedEvmCallErrorV1 }>; - try { - provisionalExecution = Object.freeze({ - ok: true, - value: await options.executeAtReference(options.anchor.blockNumberQuantity), - }); - } catch (cause) { - if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 - && ( - cause.code === 'no-code' - || cause.code === 'revert' - || cause.code === 'malformed-return' - || isAnchorDependentResourceLimit(cause) - ) - ) { - provisionalExecution = Object.freeze({ ok: false, failure: cause }); - } else { - throw cause; - } - } - await assertStrictFinalizedAnchorStableV1( - options.anchor, - options.readPostAnchor, - options.anchorMismatchMessage, - ); - if (!provisionalExecution.ok) throw provisionalExecution.failure; - return provisionalExecution.value; -} - -export async function assertStrictFinalizedAnchorStableV1( - anchor: FinalizedAnchorV1, - readPostAnchor: () => Promise, - mismatchMessage: string, -): Promise { - const postAnchor = await readPostAnchor(); - if ( - postAnchor.blockNumber !== anchor.blockNumber - || postAnchor.blockHash !== anchor.blockHash - ) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - mismatchMessage, - ); - } -} - -export async function settleParallelBatch( - operations: readonly Promise[], - attemptDeadline: DeadlineScope, -): Promise { - let firstFailure: unknown; - let hasFailure = false; - let firstPreDeadlineTerminalFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - const tracked = operations.map(async (operation) => { - try { - return await operation; - } catch (cause) { - if (!hasFailure) { - hasFailure = true; - firstFailure = cause; - } - if ( - firstPreDeadlineTerminalFailure === undefined - && cause instanceof CurrentFinalizedEvmCallErrorV1 - && isTerminalAttemptFailure(cause) - && !attemptDeadline.timedOut() - ) { - firstPreDeadlineTerminalFailure = cause; - PRE_DEADLINE_TERMINAL_FAILURES_V1.add(cause); - } - throw cause; - } - }); - const settled = await Promise.allSettled(tracked); - if (firstPreDeadlineTerminalFailure !== undefined) { - throw firstPreDeadlineTerminalFailure; - } - if (hasFailure) throw firstFailure; - const values: T[] = []; - for (let index = 0; index < settled.length; index += 1) { - const result = settled[index]!; - if (result.status === 'rejected') { - throw unavailable('Parallel finalized-read operation failed without a recorded cause'); - } - values.push(result.value); - } - return Object.freeze(values); -} - -export async function postJsonRpc( - endpoint: string, - id: number, - method: string, - params: readonly unknown[], - maxResponseBytes: number, - signal: AbortSignal, -): Promise { - let response: Response; - try { - response = await fetch(endpoint, { - method: 'POST', - headers: Object.freeze({ - accept: 'application/json', - 'content-type': 'application/json', - }), - body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), - redirect: 'error', - signal, - }); - } catch (cause) { - if (signal.aborted) throw cause; - throw unavailable(`JSON-RPC ${method} transport failed`, cause); - } - - const body = await readResponseBodyBounded(response, maxResponseBytes); - if (!response.ok) { - // An HTTP intermediary/provider failure is transport availability, even if - // its untrusted body happens to mimic a deterministic JSON-RPC revert. Only - // a successful JSON-RPC transport response may select an invalidity code. - throw unavailable(`JSON-RPC ${method} returned HTTP ${response.status}`); - } - - let parsed: unknown; - try { - parsed = JSON.parse(body) as unknown; - } catch (cause) { - throw unavailable(`JSON-RPC ${method} returned malformed JSON`, cause); - } - if (!isPlainRecord(parsed) || parsed.jsonrpc !== '2.0' || parsed.id !== id) { - throw unavailable(`JSON-RPC ${method} returned a mismatched response envelope`); - } - const hasResult = Object.prototype.hasOwnProperty.call(parsed, 'result'); - const hasError = Object.prototype.hasOwnProperty.call(parsed, 'error'); - if (hasResult === hasError) { - throw unavailable(`JSON-RPC ${method} response must contain exactly one of result or error`); - } - if (hasError) { - const error = parseRpcError(parsed.error); - if (error === undefined) throw unavailable(`JSON-RPC ${method} returned a malformed error`); - throw classifyJsonRpcError(method, error); - } - return parsed.result; -} - -async function readResponseBodyBounded(response: Response, maxBytes: number): Promise { - const contentLength = response.headers.get('content-length'); - if (contentLength !== null && /^\d+$/.test(contentLength)) { - const declared = BigInt(contentLength); - if (declared > BigInt(maxBytes)) { - await response.body?.cancel().catch(() => undefined); - throw resourceLimited( - `Raw JSON-RPC response declared ${declared.toString()} bytes, limit ${maxBytes}`, - ); - } - } - - if (response.body === null) return ''; - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > maxBytes) { - await reader.cancel().catch(() => undefined); - throw resourceLimited(`Raw JSON-RPC response exceeded ${maxBytes} bytes before parsing`); - } - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - try { - return new TextDecoder('utf-8', { fatal: true }).decode(bytes); - } catch (cause) { - throw unavailable('JSON-RPC response body is not valid UTF-8', cause); - } -} - -export function parseChainId(input: unknown): ChainIdV1 { - let parsed: bigint; - try { - parsed = parseCanonicalQuantity(input, MAX_U256); - } catch (cause) { - throw unavailable('eth_chainId returned a malformed chain ID', cause); - } - return parsed.toString(10) as ChainIdV1; -} - -export function parseFinalizedAnchor(input: unknown, label: string): FinalizedAnchorV1 { - if (input === null) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `${label} is unavailable`, - ); - } - if (!isPlainRecord(input)) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `${label} is malformed`, - ); - } - - let blockNumber: bigint; - try { - blockNumber = parseCanonicalQuantity(input.number, MAX_U64); - } catch (cause) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `${label} has a malformed block number`, - { cause }, - ); - } - if (typeof input.hash !== 'string' || !CANONICAL_DIGEST_32.test(input.hash)) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `${label} has a malformed block hash`, - ); - } - return Object.freeze({ - blockNumber: blockNumber.toString(10) as BlockNumberV1, - blockNumberQuantity: input.number as string, - blockHash: input.hash as Digest32V1, - }); -} - -function parseCanonicalQuantity(input: unknown, maximum: bigint): bigint { - if (typeof input !== 'string' || !CANONICAL_LOWER_QUANTITY.test(input)) { - throw new Error('not a canonical lowercase JSON-RPC quantity'); - } - const parsed = BigInt(input); - if (parsed > maximum) throw new Error('JSON-RPC quantity is out of range'); - return parsed; -} - -function parseRpcError(input: unknown): RpcErrorEnvelopeV1 | undefined { - if ( - !isPlainRecord(input) - || typeof input.code !== 'number' - || !Number.isSafeInteger(input.code) - || typeof input.message !== 'string' - ) { - return undefined; - } - const data = typeof input.data === 'string' - && CANONICAL_LOWER_HEX_BYTES.test(input.data) - ? input.data - : undefined; - return Object.freeze({ - code: input.code, - message: input.message, - ...(data === undefined ? {} : { data }), - }); -} - -function classifyJsonRpcError( - method: string, - error: RpcErrorEnvelopeV1, -): CurrentFinalizedEvmCallErrorV1 { - const message = error.message.toLowerCase(); - // Explicit revert evidence is deterministic even when a gateway decorates - // the message with gas-related text (for example, code 3 plus - // "execution reverted: out of gas"). A fixed-cap exhaustion that did not - // execute a REVERT remains a resource refusal below, but a proven REVERT - // must win so callers cannot misclassify invalid execution evidence as merely - // unsupported. - if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { - return revertedAtFinalizedAnchor(error.data); - } - if ( - method === 'eth_call' - && ( - message.includes('out of gas') - || message.includes('gas limit') - || message.includes('gas required') - || message.includes('exceeds allowance') - || message.includes('intrinsic gas') - ) - ) { - return anchorDependentResourceLimited( - 'Finalized contract execution could not complete within the fixed gas cap', - ); - } - if (message.includes('timeout') || message.includes('timed out')) { - return timedOut(`JSON-RPC ${method} timed out`); - } - if ( - method === 'eth_getBlockByNumber' - || message.includes('header not found') - || message.includes('unknown block') - || message.includes('block not found') - || message.includes('canonical') - ) { - return new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `JSON-RPC ${method} could not prove the required finalized anchor`, - ); - } - return unavailable(`JSON-RPC ${method} failed with code ${error.code}`); -} - -function classifyAttemptFailure( - cause: unknown, - callerSignal: AbortSignal, - totalDeadline: DeadlineScope, - attemptDeadline: DeadlineScope, -): CurrentFinalizedEvmCallErrorV1 { - if (callerSignal.aborted) return cancelled('Current-finalized EVM call was cancelled'); - if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 - && PRE_DEADLINE_TERMINAL_FAILURES_V1.has(cause) - ) { - return cause; - } - if (totalDeadline.timedOut()) { - return timedOut(`Current-finalized total deadline exceeded ${CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1}ms`); - } - if (attemptDeadline.timedOut()) { - return timedOut(`Current-finalized endpoint attempt exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`); - } - if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; - return unavailable('Current-finalized endpoint attempt failed closed', cause); -} - -export function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { - return error.code === 'unsupported-chain' - || error.code === 'resource-limit' - || error.code === 'revert' - || error.code === 'no-code' - || error.code === 'malformed-return'; -} - -export function createDeadlineScope( - parent: AbortSignal, - timeoutMs: number, - label: string, -): DeadlineScope { - const controller = new AbortController(); - let didTimeOut = false; - const expiresAt = performance.now() + timeoutMs; - const parentAbort = (): void => controller.abort(parent.reason); - if (parent.aborted) parentAbort(); - else parent.addEventListener('abort', parentAbort, { once: true }); - const timer = setTimeout(() => { - didTimeOut = true; - controller.abort(new Error(`${label} exceeded ${timeoutMs}ms`)); - }, timeoutMs); - timer.unref?.(); - return Object.freeze({ - signal: controller.signal, - timedOut: () => didTimeOut || performance.now() >= expiresAt, - close: () => { - clearTimeout(timer); - parent.removeEventListener('abort', parentAbort); - }, - }); -} - -function snapshotReadRequest(input: unknown): StrictCurrentFinalizedEvmReadRequestV1 { - try { - const record = snapshotExactDataRecord(input, READ_REQUEST_KEYS); - assertCanonicalChainId(record.chainId, 'strict finalized-read chainId'); - if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); - return Object.freeze({ - chainId: record.chainId, - calls: snapshotReadCalls(record.calls), - signal: record.signal, - }); - } catch { - throw unavailable('Strict current-finalized read request failed the fixed local profile'); - } -} - -export function snapshotSnapshotRequest(input: unknown): StrictCurrentFinalizedEvmSnapshotRequestV1 { - try { - const record = snapshotExactDataRecord(input, SNAPSHOT_REQUEST_KEYS); - assertCanonicalChainId(record.chainId, 'strict finalized-snapshot chainId'); - if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); - return Object.freeze({ - chainId: record.chainId, - signal: record.signal, - }); - } catch { - throw unavailable('Strict current-finalized snapshot request failed the fixed local profile'); - } -} - -export function snapshotSnapshotCalls( - input: unknown, -): readonly StrictCurrentFinalizedEvmReadCallV1[] { - try { - return snapshotReadCalls(input); - } catch { - throw unavailable('Strict current-finalized snapshot batch failed the fixed local profile'); - } -} - -function snapshotReadCalls(input: unknown): readonly StrictCurrentFinalizedEvmReadCallV1[] { - const entries = snapshotDenseDataArray(input, { - label: 'Current-finalized read calls', - minLength: 1, - maxLength: CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - }); - - const calls: StrictCurrentFinalizedEvmReadCallV1[] = []; - for (let index = 0; index < entries.length; index += 1) { - const record = snapshotExactDataRecord(entries[index], READ_CALL_KEYS); - assertCanonicalNonzeroEvmAddress(record.to, `current-finalized read calls[${index}].to`); - if ( - typeof record.data !== 'string' - || !/^0x[0-9a-f]{8}(?:[0-9a-f]{2})*$/.test(record.data) - ) { - throw new Error(`finalized read calls[${index}].data is not canonical ABI calldata`); - } - if ( - typeof record.maxReturnBytes !== 'number' - || !Number.isSafeInteger(record.maxReturnBytes) - || record.maxReturnBytes < 1 - || record.maxReturnBytes > CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1 - ) { - throw new Error(`finalized read calls[${index}].maxReturnBytes is outside the fixed cap`); - } - calls.push(Object.freeze({ - to: record.to as EvmAddressV1, - data: record.data, - maxReturnBytes: record.maxReturnBytes, - })); - } - return Object.freeze(calls); -} - -export function snapshotConfig(input: StrictCurrentFinalizedEvmRpcConfigV1): StrictRpcConfigSnapshotV1 { - if (!isPlainRecord(input)) { - throw new TypeError('Strict current-finalized RPC config must be a plain data record'); - } - assertConfigDataProperties(input); - try { - assertCanonicalChainId(input.chainId, 'strict current-finalized chainId'); - } catch { - throw new TypeError('Strict current-finalized chainId must be canonical decimal u256'); - } - - const endpoints = snapshotNormalizedEndpoints(input.endpoints); - const blockReferenceProfile = input.blockReferenceProfile ?? 'eip1898'; - if (!CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1.includes(blockReferenceProfile)) { - throw new TypeError('Unsupported strict current-finalized block reference profile'); - } - return Object.freeze({ - chainId: input.chainId, - endpoints, - blockReferenceProfile, - }); -} - -function assertConfigDataProperties(input: Record): void { - const keys = Reflect.ownKeys(input); - if (keys.some((key) => typeof key !== 'string')) { - throw new TypeError('Strict current-finalized RPC config cannot contain symbol keys'); - } - const allowed = new Set([...CONFIG_REQUIRED_KEYS, ...CONFIG_OPTIONAL_KEYS]); - if ( - !CONFIG_REQUIRED_KEYS.every((key) => keys.includes(key)) - || (keys as string[]).some((key) => !allowed.has(key)) - ) { - throw new TypeError('Strict current-finalized RPC config has unknown or missing fields'); - } - for (const key of keys as string[]) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - throw new TypeError('Strict current-finalized RPC config fields must be enumerable data properties'); - } - } -} - -function snapshotNormalizedEndpoints(input: unknown): readonly string[] { - const normalized: string[] = []; - try { - const endpoints = snapshotDenseDataArray(input, { - label: 'Strict current-finalized RPC endpoints', - minLength: 1, - }); - for (const entry of endpoints) { - const endpoint = normalizeEndpoint(entry); - if (!normalized.includes(endpoint)) normalized.push(endpoint); - } - } catch (cause) { - if (cause instanceof TypeError) throw cause; - throw new TypeError('Strict current-finalized endpoints must be a dense data-only array'); - } - if (normalized.length === 0 || normalized.length > CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { - throw new TypeError( - `Strict current-finalized RPC requires 1..${CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1} distinct endpoints`, - ); - } - return Object.freeze(normalized); -} - -function normalizeEndpoint(input: unknown): string { - if (typeof input !== 'string' || input.trim() === '') { - throw new TypeError('Strict current-finalized RPC endpoint must be a nonempty URL string'); - } - let url: URL; - try { - url = new URL(input.trim()); - } catch { - throw new TypeError('Strict current-finalized RPC endpoint must be an absolute URL'); - } - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.hash !== '') { - throw new TypeError('Strict current-finalized RPC endpoint must use HTTP(S) without a fragment'); - } - return url.href; -} - -function isPlainRecord(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -export function unavailable(message: string, cause?: unknown): CurrentFinalizedEvmCallErrorV1 { - return new CurrentFinalizedEvmCallErrorV1( - 'rpc-unavailable', - message, - cause === undefined ? undefined : { cause }, - ); -} - -export function timedOut(message: string): CurrentFinalizedEvmCallErrorV1 { - return new CurrentFinalizedEvmCallErrorV1('rpc-timeout', message); -} - -export function resourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { - return new CurrentFinalizedEvmCallErrorV1('resource-limit', message); -} - -function anchorDependentResourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { - const error = resourceLimited(message); - ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.add(error); - return error; -} - -function revertedAtFinalizedAnchor(data?: string): CurrentFinalizedEvmCallErrorV1 { - const error = new CurrentFinalizedEvmCallErrorV1( - 'revert', - 'Contract call reverted at the resolved finalized anchor', - ); - if (data !== undefined) AUTHENTICATED_REVERT_DATA_V1.set(error, data); - return error; -} - -export function isAnchorDependentResourceLimit( - error: CurrentFinalizedEvmCallErrorV1, -): boolean { - return error.code === 'resource-limit' - && ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.has(error); -} - -export function cancelled(message: string): CurrentFinalizedEvmCallErrorV1 { - // Caller intent is authenticated by the verifier-owned AbortSignal. Keep - // the adapter error retryable so a foreign gateway cannot forge a cancelled - // disposition merely by throwing a public error code; the verifier observes - // its caller signal first and maps this exact path to `cancelled`. - return new CurrentFinalizedEvmCallErrorV1('rpc-unavailable', message); -} diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts index a1d95eea31..57788d718e 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts @@ -1,8 +1,6 @@ import type { StrictCurrentFinalizedEvmSnapshotScopeV1 } from './current-finalized-evm-snapshot.js'; -import { - snapshotConfig, - type StrictCurrentFinalizedEvmRpcConfigV1, -} from './strict-current-finalized-evm-rpc.js'; +import type { StrictCurrentFinalizedEvmRpcConfigV1 } from './strict-current-finalized-evm-rpc.js'; +import { snapshotConfig } from './strict-current-finalized-evm-transport.js'; import { createStrictFinalizedSnapshotRpcRuntimeV1 } from './strict-current-finalized-evm-snapshot-rpc.js'; /** diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts index f4bc475860..11296162a5 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -19,27 +19,28 @@ import { type StrictCurrentFinalizedEvmSnapshotScopeV1, type StrictCurrentFinalizedEvmSnapshotSessionV1, } from './current-finalized-evm-snapshot.js'; -import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; +import { + snapshotCurrentFinalizedEvmSnapshotCallsV1, + snapshotCurrentFinalizedEvmSnapshotRequestV1, +} from './current-finalized-evm-read-validation.js'; import { assertStrictFinalizedAnchorStableV1, cancelled, createDeadlineScope, + createStrictFinalizedEndpointRunnerV1, executeStrictFinalizedAnchorPolicyV1, - isTerminalAttemptFailure, parseChainId, parseFinalizedAnchor, postJsonRpc, resourceLimited, settleParallelBatch, - snapshotSnapshotCalls, - snapshotSnapshotRequest, timedOut, unavailable, type CurrentFinalizedEvmBlockReferenceProfileV1, type DeadlineScope, type FinalizedAnchorV1, -} from './strict-current-finalized-evm-rpc.js'; +} from './strict-current-finalized-evm-transport.js'; export interface StrictFinalizedSnapshotRpcConfigV1 { readonly chainId: ChainIdV1; @@ -72,97 +73,52 @@ const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; export function createStrictFinalizedSnapshotRpcRuntimeV1( config: StrictFinalizedSnapshotRpcConfigV1, ): StrictCurrentFinalizedEvmSnapshotScopeV1 { - const admission = createNonqueueingAdmissionGateV1( - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, - ); + const runEndpoint = createStrictFinalizedEndpointRunnerV1({ + chainId: config.chainId, + endpoints: config.endpoints, + maxConcurrentPerChain: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, + totalDeadlineMs: CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, + attemptTimeoutMs: CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + chainMismatchMessage: (requested) => + `Snapshot adapter is configured for chain ${config.chainId}, not ${requested}`, + cancelledBeforeAdmissionMessage: + 'Current-finalized snapshot was cancelled before transport admission', + cancelledMessage: 'Current-finalized snapshot was cancelled', + totalDeadlineLabel: 'current-finalized snapshot total deadline', + totalDeadlineMessage: (timeoutMs) => + `Current-finalized snapshot deadline exceeded ${timeoutMs}ms`, + attemptDeadlineLabel: (attempt) => + `current-finalized snapshot preflight ${attempt}`, + attemptDeadlineMessage: (timeoutMs) => + `Current-finalized snapshot preflight exceeded ${timeoutMs}ms`, + attemptFailureMessage: 'Current-finalized snapshot preflight failed closed', + noEndpointMessage: 'No configured endpoint completed finalized snapshot preflight', + saturatedMessage: (active) => + `Chain ${config.chainId} already has ${active} finalized snapshot in flight`, + }); const withSnapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async ( inputRequest, consume, ) => { - const request = snapshotSnapshotRequest(inputRequest); + const request = snapshotCurrentFinalizedEvmSnapshotRequestV1(inputRequest); if (typeof consume !== 'function') { throw unavailable('Current-finalized snapshot consumer must be callable'); } - if (request.chainId !== config.chainId) { - throw new CurrentFinalizedEvmCallErrorV1( - 'chain-mismatch', - `Snapshot adapter is configured for chain ${config.chainId}, not ${request.chainId}`, - ); - } - if (request.signal.aborted) { - throw cancelled( - 'Current-finalized snapshot was cancelled before transport admission', - ); - } - - return admission.run(request.chainId, async () => { - const totalDeadline = createDeadlineScope( - request.signal, - CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, - 'current-finalized snapshot total deadline', - ); - let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - try { - for (let index = 0; index < config.endpoints.length; index += 1) { - const attemptDeadline = createDeadlineScope( - totalDeadline.signal, - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - `current-finalized snapshot preflight ${index + 1}`, - ); - let preflight: SnapshotEndpointPreflightV1 | undefined; - try { - preflight = await preflightSnapshotEndpoint( - config, - config.endpoints[index]!, - attemptDeadline.signal, - ); - if ( - request.signal.aborted - || totalDeadline.timedOut() - || attemptDeadline.timedOut() - ) { - throw classifySnapshotAttemptFailure( - new Error('Snapshot preflight completed after its lifecycle ended'), - request.signal, - totalDeadline, - attemptDeadline, - ); - } - } catch (cause) { - const failure = classifySnapshotAttemptFailure( - cause, - request.signal, - totalDeadline, - attemptDeadline, - ); - if (isTerminalAttemptFailure(failure)) throw failure; - lastRetryableFailure = failure; - } finally { - attemptDeadline.close(); - } - if (preflight !== undefined) { - // Once trusted local consumer code begins, it is never replayed. - return await executePinnedSnapshotScope( - config, - config.endpoints[index]!, - preflight, - request, - consume, - totalDeadline, - ); - } - } - throw lastRetryableFailure - ?? unavailable( - 'No configured endpoint completed finalized snapshot preflight', - ); - } finally { - totalDeadline.close(); - } - }, (active) => new CurrentFinalizedEvmCallErrorV1( - 'concurrency-saturated', - `Chain ${request.chainId} already has ${active} finalized snapshot in flight`, - )); + return runEndpoint({ + chainId: request.chainId, + signal: request.signal, + attempt: (endpoint, _attempt, deadline) => + preflightSnapshotEndpoint(config, endpoint, deadline.signal), + // This runs outside the runner's retry catch, so consumer code is never replayed. + accept: (endpoint, preflight, totalDeadline) => executePinnedSnapshotScope( + config, + endpoint, + preflight, + request, + consume, + totalDeadline, + ), + }); }; return Object.freeze(withSnapshot); } @@ -371,7 +327,7 @@ function createSnapshotReadSession( } let calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; try { - calls = snapshotSnapshotCalls(inputCalls); + calls = snapshotCurrentFinalizedEvmSnapshotCallsV1(inputCalls); } catch (cause) { return handledRejectedRead(cause); } @@ -476,26 +432,3 @@ async function executeSnapshotBatch( for (const target of batch.verifiedTargets) deployedTargets.add(target); return batch.returnData; } - -function classifySnapshotAttemptFailure( - cause: unknown, - callerSignal: AbortSignal, - totalDeadline: DeadlineScope, - attemptDeadline: DeadlineScope, -): CurrentFinalizedEvmCallErrorV1 { - if (callerSignal.aborted) { - return cancelled('Current-finalized snapshot was cancelled'); - } - if (totalDeadline.timedOut()) { - return timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - if (attemptDeadline.timedOut()) { - return timedOut( - `Current-finalized snapshot preflight exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, - ); - } - if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; - return unavailable('Current-finalized snapshot preflight failed closed', cause); -} diff --git a/packages/chain/src/strict-current-finalized-evm-transport.ts b/packages/chain/src/strict-current-finalized-evm-transport.ts new file mode 100644 index 0000000000..1d3e18a906 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-transport.ts @@ -0,0 +1,924 @@ +// Package-internal transport core shared by one-shot and scoped finalized reads. +import { + assertCanonicalChainId, + type BlockNumberV1, + type ChainIdV1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import { + snapshotCurrentFinalizedEvmCallRequestV1, + type CurrentFinalizedEvmChainAdapterV1, +} from './current-finalized-evm-call.js'; +import { + type StrictCurrentFinalizedEvmReadCallV1, + type StrictCurrentFinalizedEvmReadRequestV1, + type StrictCurrentFinalizedEvmReadResultV1, + type StrictCurrentFinalizedEvmReadV1, +} from './current-finalized-evm-read-model.js'; +import { + snapshotDenseDataArray, +} from './strict-local-data.js'; +import { snapshotCurrentFinalizedEvmReadRequestV1 } from './current-finalized-evm-read-validation.js'; +import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; +import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; + +export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ + 'eip1898', + 'trusted-block-number-hash-sandwich', +] as const); + +export type CurrentFinalizedEvmBlockReferenceProfileV1 = + (typeof CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1)[number]; + +export type { + StrictCurrentFinalizedEvmReadCallV1, + StrictCurrentFinalizedEvmReadRequestV1, + StrictCurrentFinalizedEvmReadResultV1, + StrictCurrentFinalizedEvmReadV1, +} from './current-finalized-evm-read-model.js'; + +export interface StrictCurrentFinalizedEvmRpcConfigV1 { + /** Canonical decimal chain ID permanently bound to this adapter. */ + readonly chainId: ChainIdV1; + /** Trusted local configuration, in canonical failover order. */ + readonly endpoints: readonly string[]; + /** + * EIP-1898 is the default. The number/hash sandwich is an explicit trusted + * chain profile for deployments whose RPC endpoints cannot execute EIP-1898. + */ + readonly blockReferenceProfile?: CurrentFinalizedEvmBlockReferenceProfileV1; +} + +export interface StrictRpcConfigSnapshotV1 { + readonly chainId: ChainIdV1; + readonly endpoints: readonly string[]; + readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; +} + +export interface FinalizedAnchorV1 { + readonly blockNumber: BlockNumberV1; + readonly blockNumberQuantity: string; + readonly blockHash: Digest32V1; +} + +export interface DeadlineScope { + readonly signal: AbortSignal; + readonly timedOut: () => boolean; + readonly close: () => void; +} + +export interface StrictFinalizedEndpointRunnerProfileV1 { + readonly chainId: ChainIdV1; + readonly endpoints: readonly string[]; + readonly maxConcurrentPerChain: number; + readonly totalDeadlineMs: number; + readonly attemptTimeoutMs: number; + readonly chainMismatchMessage: (requestedChainId: ChainIdV1) => string; + readonly cancelledBeforeAdmissionMessage: string; + readonly cancelledMessage: string; + readonly totalDeadlineLabel: string; + readonly totalDeadlineMessage: (timeoutMs: number) => string; + readonly attemptDeadlineLabel: (attempt: number) => string; + readonly attemptDeadlineMessage: (timeoutMs: number) => string; + readonly attemptFailureMessage: string; + readonly noEndpointMessage: string; + readonly saturatedMessage: (active: number) => string; +} + +export interface StrictFinalizedEndpointRunInputV1 { + readonly chainId: ChainIdV1; + readonly signal: AbortSignal; + readonly attempt: ( + endpoint: string, + attempt: number, + deadline: DeadlineScope, + ) => Promise; + /** Runs once, outside the retry catch. Snapshot consumers are never replayed. */ + readonly accept: ( + endpoint: string, + attemptResult: AttemptResult, + totalDeadline: DeadlineScope, + ) => Promise; +} + +export interface StrictFinalizedEndpointRunnerV1 { + ( + input: StrictFinalizedEndpointRunInputV1, + ): Promise; +} + +interface RpcErrorEnvelopeV1 { + readonly code: number; + readonly message: string; + readonly data?: string; +} + +const CONFIG_REQUIRED_KEYS = Object.freeze(['chainId', 'endpoints'] as const); +const CONFIG_OPTIONAL_KEYS = Object.freeze(['blockReferenceProfile'] as const); +const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; +const CANONICAL_LOWER_QUANTITY = /^0x(?:0|[1-9a-f][0-9a-f]*)$/; +const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; +const MAX_U64 = 18_446_744_073_709_551_615n; +const MAX_U256 = + 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; +const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); +const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); +const PRE_DEADLINE_TERMINAL_FAILURES_V1 = new WeakSet(); + +/** Package-internal evidence available only for errors minted by this transport. */ +export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { + return error instanceof CurrentFinalizedEvmCallErrorV1 + ? AUTHENTICATED_REVERT_DATA_V1.get(error) + : undefined; +} + +/** + * Build one strict raw-JSON-RPC adapter from trusted local chain configuration. + * + * This transport intentionally bypasses JsonRpcProvider, RpcFailoverClient, + * and generic timeout wrappers: native fetch gives this lane a streamed + * pre-parse body cap and an AbortSignal that cancels the actual HTTP I/O. POST + * requests are never retried, redirected, reordered, or made sticky. + */ +export function createStrictCurrentFinalizedEvmChainAdapterV1( + input: StrictCurrentFinalizedEvmRpcConfigV1, +): CurrentFinalizedEvmChainAdapterV1 { + const read = createStrictCurrentFinalizedEvmReadV1(input); + + const adapter: CurrentFinalizedEvmChainAdapterV1 = async (inputRequest) => { + const request = snapshotCurrentFinalizedEvmCallRequestV1(inputRequest); + const result = await read({ + chainId: request.chainId, + calls: Object.freeze([Object.freeze({ + to: request.to, + data: request.data, + maxReturnBytes: request.maxReturnBytes, + })]), + signal: request.signal, + }); + const returnData = result.returnData[0]; + if (returnData === undefined) { + throw unavailable('Single-call finalized read produced no contract result'); + } + return Object.freeze({ + chainId: result.chainId, + blockNumber: result.blockNumber, + blockHash: result.blockHash, + returnData, + }); + }; + + return Object.freeze(adapter); +} + +/** + * Build a bounded, non-queueing same-finalized-anchor read primitive. + * + * Calls, destinations, and configured endpoints are trusted local runtime + * inputs. The primitive still snapshots them strictly, fixes gas/deadline/body + * caps, and never accepts a block selector from its caller. + */ +export function createStrictCurrentFinalizedEvmReadV1( + input: StrictCurrentFinalizedEvmRpcConfigV1, +): StrictCurrentFinalizedEvmReadV1 { + const config = snapshotConfig(input); + const runEndpoint = createStrictFinalizedEndpointRunnerV1({ + chainId: config.chainId, + endpoints: config.endpoints, + maxConcurrentPerChain: CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, + totalDeadlineMs: CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, + attemptTimeoutMs: CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + chainMismatchMessage: (requested) => + `Adapter is configured for chain ${config.chainId}, not ${requested}`, + cancelledBeforeAdmissionMessage: + 'Current-finalized EVM call was cancelled before transport admission', + cancelledMessage: 'Current-finalized EVM call was cancelled', + totalDeadlineLabel: 'current-finalized total deadline', + totalDeadlineMessage: (timeoutMs) => + `Current-finalized total deadline exceeded ${timeoutMs}ms`, + attemptDeadlineLabel: (attempt) => `current-finalized endpoint attempt ${attempt}`, + attemptDeadlineMessage: (timeoutMs) => + `Current-finalized endpoint attempt exceeded ${timeoutMs}ms`, + attemptFailureMessage: 'Current-finalized endpoint attempt failed closed', + noEndpointMessage: 'No configured current-finalized endpoint succeeded', + saturatedMessage: (active) => + `Chain ${config.chainId} already has ${active} finalized reads in flight`, + }); + + const read: StrictCurrentFinalizedEvmReadV1 = async (inputRequest) => { + const request = snapshotCurrentFinalizedEvmReadRequestV1(inputRequest); + return runEndpoint({ + chainId: request.chainId, + signal: request.signal, + attempt: (endpoint, _attempt, deadline) => + executeEndpointAttempt(config, endpoint, request, deadline), + accept: async (_endpoint, result) => result, + }); + }; + + return Object.freeze(read); +} + +/** + * Canonical non-queueing endpoint lifecycle shared by one-shot reads and + * snapshot preflight. Only `attempt` participates in failover; `accept` runs + * once after the attempt deadline is closed and therefore cannot replay a + * snapshot consumer. + */ +export function createStrictFinalizedEndpointRunnerV1( + profile: StrictFinalizedEndpointRunnerProfileV1, +): StrictFinalizedEndpointRunnerV1 { + const admission = createNonqueueingAdmissionGateV1( + profile.maxConcurrentPerChain, + ); + const run: StrictFinalizedEndpointRunnerV1 = async ( + input: StrictFinalizedEndpointRunInputV1, + ): Promise => { + if (input.chainId !== profile.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + profile.chainMismatchMessage(input.chainId), + ); + } + if (input.signal.aborted) { + throw cancelled(profile.cancelledBeforeAdmissionMessage); + } + return admission.run(input.chainId, async () => { + const totalDeadline = createDeadlineScope( + input.signal, + profile.totalDeadlineMs, + profile.totalDeadlineLabel, + ); + let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + try { + for (let index = 0; index < profile.endpoints.length; index += 1) { + const attemptNumber = index + 1; + const attemptDeadline = createDeadlineScope( + totalDeadline.signal, + profile.attemptTimeoutMs, + profile.attemptDeadlineLabel(attemptNumber), + ); + let attemptResult!: AttemptResult; + let accepted = false; + try { + attemptResult = await input.attempt( + profile.endpoints[index]!, + attemptNumber, + attemptDeadline, + ); + if ( + input.signal.aborted + || totalDeadline.timedOut() + || attemptDeadline.timedOut() + ) { + throw classifyEndpointAttemptFailureV1( + new Error('Endpoint attempt completed after its lifecycle ended'), + input.signal, + totalDeadline, + attemptDeadline, + profile, + ); + } + accepted = true; + } catch (cause) { + const failure = classifyEndpointAttemptFailureV1( + cause, + input.signal, + totalDeadline, + attemptDeadline, + profile, + ); + if (isTerminalAttemptFailure(failure)) throw failure; + lastRetryableFailure = failure; + } finally { + attemptDeadline.close(); + } + if (accepted) { + return input.accept( + profile.endpoints[index]!, + attemptResult, + totalDeadline, + ); + } + } + throw classifyEndpointAttemptFailureV1( + lastRetryableFailure ?? unavailable(profile.noEndpointMessage), + input.signal, + totalDeadline, + null, + profile, + ); + } finally { + totalDeadline.close(); + } + }, (active) => new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + profile.saturatedMessage(active), + )); + }; + return Object.freeze(run); +} + +function classifyEndpointAttemptFailureV1( + cause: unknown, + callerSignal: AbortSignal, + totalDeadline: DeadlineScope, + attemptDeadline: DeadlineScope | null, + profile: StrictFinalizedEndpointRunnerProfileV1, +): CurrentFinalizedEvmCallErrorV1 { + if (callerSignal.aborted) return cancelled(profile.cancelledMessage); + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && PRE_DEADLINE_TERMINAL_FAILURES_V1.has(cause) + ) { + return cause; + } + if (totalDeadline.timedOut()) { + return timedOut(profile.totalDeadlineMessage(profile.totalDeadlineMs)); + } + if (attemptDeadline?.timedOut()) { + return timedOut(profile.attemptDeadlineMessage(profile.attemptTimeoutMs)); + } + if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; + return unavailable(profile.attemptFailureMessage, cause); +} + +async function executeEndpointAttempt( + config: StrictRpcConfigSnapshotV1, + endpoint: string, + request: StrictCurrentFinalizedEvmReadRequestV1, + attemptDeadline: DeadlineScope, +): Promise { + const { signal } = attemptDeadline; + let requestId = 0; + const rpc = async (method: string, params: readonly unknown[]): Promise => { + requestId += 1; + return postJsonRpc( + endpoint, + requestId, + method, + params, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + signal, + ); + }; + + const remoteChainId = parseChainId(await rpc('eth_chainId', Object.freeze([]))); + if (remoteChainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Configured endpoint attempt reported chain ${remoteChainId}, expected ${config.chainId}`, + ); + } + + const anchor = parseFinalizedAnchor( + await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), + 'current finalized header', + ); + const executeCallsAt = async (blockReference: unknown): Promise => { + const batch = await executeStrictFinalizedEvmBatchV1({ + calls: request.calls, + blockReference, + rpc, + settle: (operations) => settleParallelBatch(operations, attemptDeadline), + }); + return batch.returnData; + }; + + const returnData = await executeStrictFinalizedAnchorPolicyV1({ + blockReferenceProfile: config.blockReferenceProfile, + anchor, + executeAtReference: executeCallsAt, + readPostAnchor: async () => parseFinalizedAnchor( + await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + 'post-call numbered header', + ), + anchorMismatchMessage: + 'Block-number fallback hash sandwich did not preserve the resolved finalized anchor', + }); + + return Object.freeze({ + chainId: config.chainId, + blockNumber: anchor.blockNumber, + blockHash: anchor.blockHash, + returnData, + }); +} + +/** Inputs for the shared EIP-1898 or authenticated-numbered-anchor policy. */ +export interface StrictFinalizedAnchorPolicyOptionsV1 { + readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; + readonly anchor: FinalizedAnchorV1; + readonly executeAtReference: (blockReference: unknown) => Promise; + readonly readPostAnchor: () => Promise; + readonly anchorMismatchMessage: string; +} + +/** Canonical EIP-1898 / authenticated-numbered-anchor execution policy. */ +export async function executeStrictFinalizedAnchorPolicyV1( + options: StrictFinalizedAnchorPolicyOptionsV1, +): Promise { + if (options.blockReferenceProfile === 'eip1898') { + return options.executeAtReference(Object.freeze({ + blockHash: options.anchor.blockHash, + requireCanonical: true as const, + })); + } + + // Number-selected evidence is not deterministic until the same endpoint + // closes the hash sandwich. Delay every anchor-dependent invalidity until + // that proof succeeds so neither callers nor caches can observe false state. + let provisionalExecution: + | Readonly<{ readonly ok: true; readonly value: T }> + | Readonly<{ readonly ok: false; readonly failure: CurrentFinalizedEvmCallErrorV1 }>; + try { + provisionalExecution = Object.freeze({ + ok: true, + value: await options.executeAtReference(options.anchor.blockNumberQuantity), + }); + } catch (cause) { + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && ( + cause.code === 'no-code' + || cause.code === 'revert' + || cause.code === 'malformed-return' + || isAnchorDependentResourceLimit(cause) + ) + ) { + provisionalExecution = Object.freeze({ ok: false, failure: cause }); + } else { + throw cause; + } + } + await assertStrictFinalizedAnchorStableV1( + options.anchor, + options.readPostAnchor, + options.anchorMismatchMessage, + ); + if (!provisionalExecution.ok) throw provisionalExecution.failure; + return provisionalExecution.value; +} + +export async function assertStrictFinalizedAnchorStableV1( + anchor: FinalizedAnchorV1, + readPostAnchor: () => Promise, + mismatchMessage: string, +): Promise { + const postAnchor = await readPostAnchor(); + if ( + postAnchor.blockNumber !== anchor.blockNumber + || postAnchor.blockHash !== anchor.blockHash + ) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + mismatchMessage, + ); + } +} + +/** + * Execute one bounded phase concurrently but retain the permit until every + * started operation settles. This prevents an early rejection from leaving a + * sibling fetch alive after the finalized-read concurrency slot is released. + */ +export async function settleParallelBatch( + operations: readonly Promise[], + attemptDeadline: DeadlineScope, +): Promise { + let firstFailure: unknown; + let hasFailure = false; + let firstPreDeadlineTerminalFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + const tracked = operations.map(async (operation) => { + try { + return await operation; + } catch (cause) { + if (!hasFailure) { + hasFailure = true; + firstFailure = cause; + } + if ( + firstPreDeadlineTerminalFailure === undefined + && cause instanceof CurrentFinalizedEvmCallErrorV1 + && isTerminalAttemptFailure(cause) + && !attemptDeadline.timedOut() + ) { + firstPreDeadlineTerminalFailure = cause; + PRE_DEADLINE_TERMINAL_FAILURES_V1.add(cause); + } + throw cause; + } + }); + const settled = await Promise.allSettled(tracked); + if (firstPreDeadlineTerminalFailure !== undefined) { + throw firstPreDeadlineTerminalFailure; + } + if (hasFailure) throw firstFailure; + const values: T[] = []; + for (let index = 0; index < settled.length; index += 1) { + const result = settled[index]!; + if (result.status === 'rejected') { + throw unavailable('Parallel finalized-read operation failed without a recorded cause'); + } + values.push(result.value); + } + return Object.freeze(values); +} + +export async function postJsonRpc( + endpoint: string, + id: number, + method: string, + params: readonly unknown[], + maxResponseBytes: number, + signal: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers: Object.freeze({ + accept: 'application/json', + 'content-type': 'application/json', + }), + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + redirect: 'error', + signal, + }); + } catch (cause) { + if (signal.aborted) throw cause; + throw unavailable(`JSON-RPC ${method} transport failed`, cause); + } + + const body = await readResponseBodyBounded(response, maxResponseBytes); + if (!response.ok) { + // An HTTP intermediary/provider failure is transport availability, even if + // its untrusted body happens to mimic a deterministic JSON-RPC revert. Only + // a successful JSON-RPC transport response may select an invalidity code. + throw unavailable(`JSON-RPC ${method} returned HTTP ${response.status}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch (cause) { + throw unavailable(`JSON-RPC ${method} returned malformed JSON`, cause); + } + if (!isPlainRecord(parsed) || parsed.jsonrpc !== '2.0' || parsed.id !== id) { + throw unavailable(`JSON-RPC ${method} returned a mismatched response envelope`); + } + const hasResult = Object.prototype.hasOwnProperty.call(parsed, 'result'); + const hasError = Object.prototype.hasOwnProperty.call(parsed, 'error'); + if (hasResult === hasError) { + throw unavailable(`JSON-RPC ${method} response must contain exactly one of result or error`); + } + if (hasError) { + const error = parseRpcError(parsed.error); + if (error === undefined) throw unavailable(`JSON-RPC ${method} returned a malformed error`); + throw classifyJsonRpcError(method, error); + } + return parsed.result; +} + +async function readResponseBodyBounded(response: Response, maxBytes: number): Promise { + const contentLength = response.headers.get('content-length'); + if (contentLength !== null && /^\d+$/.test(contentLength)) { + const declared = BigInt(contentLength); + if (declared > BigInt(maxBytes)) { + await response.body?.cancel().catch(() => undefined); + throw resourceLimited( + `Raw JSON-RPC response declared ${declared.toString()} bytes, limit ${maxBytes}`, + ); + } + } + + if (response.body === null) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw resourceLimited(`Raw JSON-RPC response exceeded ${maxBytes} bytes before parsing`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (cause) { + throw unavailable('JSON-RPC response body is not valid UTF-8', cause); + } +} + +export function parseChainId(input: unknown): ChainIdV1 { + let parsed: bigint; + try { + parsed = parseCanonicalQuantity(input, MAX_U256); + } catch (cause) { + throw unavailable('eth_chainId returned a malformed chain ID', cause); + } + return parsed.toString(10) as ChainIdV1; +} + +export function parseFinalizedAnchor(input: unknown, label: string): FinalizedAnchorV1 { + if (input === null) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} is unavailable`, + ); + } + if (!isPlainRecord(input)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} is malformed`, + ); + } + + let blockNumber: bigint; + try { + blockNumber = parseCanonicalQuantity(input.number, MAX_U64); + } catch (cause) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} has a malformed block number`, + { cause }, + ); + } + if (typeof input.hash !== 'string' || !CANONICAL_DIGEST_32.test(input.hash)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} has a malformed block hash`, + ); + } + return Object.freeze({ + blockNumber: blockNumber.toString(10) as BlockNumberV1, + blockNumberQuantity: input.number as string, + blockHash: input.hash as Digest32V1, + }); +} + +function parseCanonicalQuantity(input: unknown, maximum: bigint): bigint { + if (typeof input !== 'string' || !CANONICAL_LOWER_QUANTITY.test(input)) { + throw new Error('not a canonical lowercase JSON-RPC quantity'); + } + const parsed = BigInt(input); + if (parsed > maximum) throw new Error('JSON-RPC quantity is out of range'); + return parsed; +} + +function parseRpcError(input: unknown): RpcErrorEnvelopeV1 | undefined { + if ( + !isPlainRecord(input) + || typeof input.code !== 'number' + || !Number.isSafeInteger(input.code) + || typeof input.message !== 'string' + ) { + return undefined; + } + const data = typeof input.data === 'string' + && CANONICAL_LOWER_HEX_BYTES.test(input.data) + ? input.data + : undefined; + return Object.freeze({ + code: input.code, + message: input.message, + ...(data === undefined ? {} : { data }), + }); +} + +function classifyJsonRpcError( + method: string, + error: RpcErrorEnvelopeV1, +): CurrentFinalizedEvmCallErrorV1 { + const message = error.message.toLowerCase(); + // Explicit revert evidence is deterministic even when a gateway decorates + // the message with gas-related text (for example, code 3 plus + // "execution reverted: out of gas"). A fixed-cap exhaustion that did not + // execute a REVERT remains a resource refusal below, but a proven REVERT + // must win so callers cannot misclassify invalid execution evidence as merely + // unsupported. + if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { + return revertedAtFinalizedAnchor(error.data); + } + if ( + method === 'eth_call' + && ( + message.includes('out of gas') + || message.includes('gas limit') + || message.includes('gas required') + || message.includes('exceeds allowance') + || message.includes('intrinsic gas') + ) + ) { + return anchorDependentResourceLimited( + 'Finalized contract execution could not complete within the fixed gas cap', + ); + } + if (message.includes('timeout') || message.includes('timed out')) { + return timedOut(`JSON-RPC ${method} timed out`); + } + if ( + method === 'eth_getBlockByNumber' + || message.includes('header not found') + || message.includes('unknown block') + || message.includes('block not found') + || message.includes('canonical') + ) { + return new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `JSON-RPC ${method} could not prove the required finalized anchor`, + ); + } + return unavailable(`JSON-RPC ${method} failed with code ${error.code}`); +} + +export function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { + return error.code === 'unsupported-chain' + || error.code === 'resource-limit' + || error.code === 'revert' + || error.code === 'no-code' + || error.code === 'malformed-return'; +} + +export function createDeadlineScope( + parent: AbortSignal, + timeoutMs: number, + label: string, +): DeadlineScope { + const controller = new AbortController(); + let didTimeOut = false; + const expiresAt = performance.now() + timeoutMs; + const parentAbort = (): void => controller.abort(parent.reason); + if (parent.aborted) parentAbort(); + else parent.addEventListener('abort', parentAbort, { once: true }); + const timer = setTimeout(() => { + didTimeOut = true; + controller.abort(new Error(`${label} exceeded ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + return Object.freeze({ + signal: controller.signal, + timedOut: () => didTimeOut || performance.now() >= expiresAt, + close: () => { + clearTimeout(timer); + parent.removeEventListener('abort', parentAbort); + }, + }); +} + +export function snapshotConfig(input: StrictCurrentFinalizedEvmRpcConfigV1): StrictRpcConfigSnapshotV1 { + if (!isPlainRecord(input)) { + throw new TypeError('Strict current-finalized RPC config must be a plain data record'); + } + assertConfigDataProperties(input); + try { + assertCanonicalChainId(input.chainId, 'strict current-finalized chainId'); + } catch { + throw new TypeError('Strict current-finalized chainId must be canonical decimal u256'); + } + + const endpoints = snapshotNormalizedEndpoints(input.endpoints); + const blockReferenceProfile = input.blockReferenceProfile ?? 'eip1898'; + if (!CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1.includes(blockReferenceProfile)) { + throw new TypeError('Unsupported strict current-finalized block reference profile'); + } + return Object.freeze({ + chainId: input.chainId, + endpoints, + blockReferenceProfile, + }); +} + +function assertConfigDataProperties(input: Record): void { + const keys = Reflect.ownKeys(input); + if (keys.some((key) => typeof key !== 'string')) { + throw new TypeError('Strict current-finalized RPC config cannot contain symbol keys'); + } + const allowed = new Set([...CONFIG_REQUIRED_KEYS, ...CONFIG_OPTIONAL_KEYS]); + if ( + !CONFIG_REQUIRED_KEYS.every((key) => keys.includes(key)) + || (keys as string[]).some((key) => !allowed.has(key)) + ) { + throw new TypeError('Strict current-finalized RPC config has unknown or missing fields'); + } + for (const key of keys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new TypeError('Strict current-finalized RPC config fields must be enumerable data properties'); + } + } +} + +function snapshotNormalizedEndpoints(input: unknown): readonly string[] { + const normalized: string[] = []; + try { + const endpoints = snapshotDenseDataArray(input, { + label: 'Strict current-finalized RPC endpoints', + minLength: 1, + }); + for (const entry of endpoints) { + const endpoint = normalizeEndpoint(entry); + if (!normalized.includes(endpoint)) normalized.push(endpoint); + } + } catch (cause) { + if (cause instanceof TypeError) throw cause; + throw new TypeError('Strict current-finalized endpoints must be a dense data-only array'); + } + if (normalized.length === 0 || normalized.length > CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { + throw new TypeError( + `Strict current-finalized RPC requires 1..${CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1} distinct endpoints`, + ); + } + return Object.freeze(normalized); +} + +function normalizeEndpoint(input: unknown): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new TypeError('Strict current-finalized RPC endpoint must be a nonempty URL string'); + } + let url: URL; + try { + url = new URL(input.trim()); + } catch { + throw new TypeError('Strict current-finalized RPC endpoint must be an absolute URL'); + } + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.hash !== '') { + throw new TypeError('Strict current-finalized RPC endpoint must use HTTP(S) without a fragment'); + } + return url.href; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export function unavailable(message: string, cause?: unknown): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1( + 'rpc-unavailable', + message, + cause === undefined ? undefined : { cause }, + ); +} + +export function timedOut(message: string): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1('rpc-timeout', message); +} + +export function resourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1('resource-limit', message); +} + +function anchorDependentResourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { + const error = resourceLimited(message); + ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.add(error); + return error; +} + +function revertedAtFinalizedAnchor(data?: string): CurrentFinalizedEvmCallErrorV1 { + const error = new CurrentFinalizedEvmCallErrorV1( + 'revert', + 'Contract call reverted at the resolved finalized anchor', + ); + if (data !== undefined) AUTHENTICATED_REVERT_DATA_V1.set(error, data); + return error; +} + +export function isAnchorDependentResourceLimit( + error: CurrentFinalizedEvmCallErrorV1, +): boolean { + return error.code === 'resource-limit' + && ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.has(error); +} + +export function cancelled(message: string): CurrentFinalizedEvmCallErrorV1 { + // Caller intent is authenticated by the verifier-owned AbortSignal. Keep + // the adapter error retryable so a foreign gateway cannot forge a cancelled + // disposition merely by throwing a public error code; the verifier observes + // its caller signal first and maps this exact path to `cancelled`. + return new CurrentFinalizedEvmCallErrorV1('rpc-unavailable', message); +} diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index 267f379970..dca85b7c18 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -32,11 +32,13 @@ import { import { createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, + type StrictCurrentFinalizedEvmRpcConfigV1, +} from '../src/strict-current-finalized-evm-rpc.js'; +import { executeStrictFinalizedAnchorPolicyV1, parseFinalizedAnchor, readStrictCurrentFinalizedEvmRevertDataV1, - type StrictCurrentFinalizedEvmRpcConfigV1, -} from '../src/strict-current-finalized-evm-rpc.js'; +} from '../src/strict-current-finalized-evm-transport.js'; import { createLoopbackJsonRpcTestHarness, sendJsonRpcError as sendError, From 7ad1bf4ac2564afb39d4e0846f525c4bfac4a1d6 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 15:31:58 +0200 Subject: [PATCH 229/292] refactor(chain): tighten finalized RPC boundaries --- .../finalized-context-graph-rpc-resolver.ts | 2 +- ...ct-current-finalized-evm-batch-executor.ts | 6 +- .../src/strict-current-finalized-evm-rpc.ts | 1 + ...rict-current-finalized-evm-snapshot-rpc.ts | 47 ++------ .../strict-current-finalized-evm-transport.ts | 112 ++++++++++++------ .../chain/src/strict-finalized-evm-bytes.ts | 6 + ...ict-current-finalized-evm-rpc.unit.test.ts | 2 +- 7 files changed, 97 insertions(+), 79 deletions(-) create mode 100644 packages/chain/src/strict-finalized-evm-bytes.ts diff --git a/packages/chain/src/finalized-context-graph-rpc-resolver.ts b/packages/chain/src/finalized-context-graph-rpc-resolver.ts index 7c441a6200..ed430dade6 100644 --- a/packages/chain/src/finalized-context-graph-rpc-resolver.ts +++ b/packages/chain/src/finalized-context-graph-rpc-resolver.ts @@ -11,7 +11,7 @@ import { readStrictCurrentFinalizedEvmRevertDataV1, type StrictCurrentFinalizedEvmReadResultV1, type StrictCurrentFinalizedEvmReadV1, -} from './strict-current-finalized-evm-transport.js'; +} from './strict-current-finalized-evm-rpc.js'; // getContextGraph has nine fixed words plus a chain-capped 256-address array: // its maximal canonical ABI result is 8,512 bytes, below this domain ceiling. diff --git a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts index 79d5be80f0..8dbdc8d5fd 100644 --- a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts +++ b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts @@ -6,9 +6,9 @@ import { CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; +import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; -const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; export interface StrictFinalizedEvmBatchExecutorInputV1 { readonly calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; @@ -60,7 +60,7 @@ export async function executeStrictFinalizedEvmBatchV1( } function assertDeployedCode(input: unknown): void { - if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { + if (!isCanonicalLowerHexBytesV1(input)) { throw new CurrentFinalizedEvmCallErrorV1( 'rpc-unavailable', 'eth_getCode returned malformed code bytes', @@ -75,7 +75,7 @@ function assertDeployedCode(input: unknown): void { } function parseContractReturn(input: unknown, maxBytes: number): string { - if (typeof input !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(input)) { + if (!isCanonicalLowerHexBytesV1(input)) { throw new CurrentFinalizedEvmCallErrorV1( 'malformed-return', 'Finalized eth_call returned malformed bytes', diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 33553633d3..32fb0e4fcd 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -9,6 +9,7 @@ export { CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, + readStrictCurrentFinalizedEvmRevertDataV1, type CurrentFinalizedEvmBlockReferenceProfileV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from './strict-current-finalized-evm-transport.js'; diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts index 11296162a5..db3389b960 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -1,7 +1,4 @@ -import type { - ChainIdV1, - EvmAddressV1, -} from '@origintrail-official/dkg-core'; +import type { EvmAddressV1 } from '@origintrail-official/dkg-core'; import { CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, @@ -37,16 +34,11 @@ import { settleParallelBatch, timedOut, unavailable, - type CurrentFinalizedEvmBlockReferenceProfileV1, type DeadlineScope, type FinalizedAnchorV1, + type StrictRpcConfigSnapshotV1, } from './strict-current-finalized-evm-transport.js'; - -export interface StrictFinalizedSnapshotRpcConfigV1 { - readonly chainId: ChainIdV1; - readonly endpoints: readonly string[]; - readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; -} +import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; interface SnapshotEndpointPreflightV1 { readonly anchor: FinalizedAnchorV1; @@ -67,34 +59,17 @@ const SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1 = '0x0000000000000000000000000000000000000000' as EvmAddressV1; const SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1 = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; -const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; - /** Package-internal runtime for a scoped, pinned finalized snapshot. */ export function createStrictFinalizedSnapshotRpcRuntimeV1( - config: StrictFinalizedSnapshotRpcConfigV1, + config: StrictRpcConfigSnapshotV1, ): StrictCurrentFinalizedEvmSnapshotScopeV1 { const runEndpoint = createStrictFinalizedEndpointRunnerV1({ + mode: 'snapshot-preflight', chainId: config.chainId, endpoints: config.endpoints, maxConcurrentPerChain: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, totalDeadlineMs: CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, attemptTimeoutMs: CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - chainMismatchMessage: (requested) => - `Snapshot adapter is configured for chain ${config.chainId}, not ${requested}`, - cancelledBeforeAdmissionMessage: - 'Current-finalized snapshot was cancelled before transport admission', - cancelledMessage: 'Current-finalized snapshot was cancelled', - totalDeadlineLabel: 'current-finalized snapshot total deadline', - totalDeadlineMessage: (timeoutMs) => - `Current-finalized snapshot deadline exceeded ${timeoutMs}ms`, - attemptDeadlineLabel: (attempt) => - `current-finalized snapshot preflight ${attempt}`, - attemptDeadlineMessage: (timeoutMs) => - `Current-finalized snapshot preflight exceeded ${timeoutMs}ms`, - attemptFailureMessage: 'Current-finalized snapshot preflight failed closed', - noEndpointMessage: 'No configured endpoint completed finalized snapshot preflight', - saturatedMessage: (active) => - `Chain ${config.chainId} already has ${active} finalized snapshot in flight`, }); const withSnapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async ( inputRequest, @@ -124,7 +99,7 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( } async function preflightSnapshotEndpoint( - config: StrictFinalizedSnapshotRpcConfigV1, + config: StrictRpcConfigSnapshotV1, endpoint: string, signal: AbortSignal, ): Promise { @@ -182,7 +157,7 @@ async function probeSnapshotReadProfile( SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, blockReference, ])); - if (typeof code !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(code)) { + if (!isCanonicalLowerHexBytesV1(code)) { throw new Error('eth_getCode capability probe returned malformed bytes'); } const callResult = await rpc('eth_call', Object.freeze([ @@ -194,7 +169,7 @@ async function probeSnapshotReadProfile( }), blockReference, ])); - if (typeof callResult !== 'string' || !CANONICAL_LOWER_HEX_BYTES.test(callResult)) { + if (!isCanonicalLowerHexBytesV1(callResult)) { throw new Error('eth_call capability probe returned malformed bytes'); } } catch (cause) { @@ -206,7 +181,7 @@ async function probeSnapshotReadProfile( } async function executePinnedSnapshotScope( - config: StrictFinalizedSnapshotRpcConfigV1, + config: StrictRpcConfigSnapshotV1, endpoint: string, preflight: SnapshotEndpointPreflightV1, request: StrictCurrentFinalizedEvmSnapshotRequestV1, @@ -304,7 +279,7 @@ function createPinnedSnapshotRpcClient( } function createSnapshotReadSession( - config: StrictFinalizedSnapshotRpcConfigV1, + config: StrictRpcConfigSnapshotV1, anchor: FinalizedAnchorV1, rpc: SnapshotRpcV1, totalDeadline: DeadlineScope, @@ -403,7 +378,7 @@ function handledRejectedRead(cause: unknown): Promise { } async function executeSnapshotBatch( - config: StrictFinalizedSnapshotRpcConfigV1, + config: StrictRpcConfigSnapshotV1, anchor: FinalizedAnchorV1, calls: readonly StrictCurrentFinalizedEvmReadCallV1[], deployedTargets: Set, diff --git a/packages/chain/src/strict-current-finalized-evm-transport.ts b/packages/chain/src/strict-current-finalized-evm-transport.ts index 1d3e18a906..f91f466722 100644 --- a/packages/chain/src/strict-current-finalized-evm-transport.ts +++ b/packages/chain/src/strict-current-finalized-evm-transport.ts @@ -30,6 +30,7 @@ import { import { snapshotCurrentFinalizedEvmReadRequestV1 } from './current-finalized-evm-read-validation.js'; import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; +import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ 'eip1898', @@ -76,22 +77,15 @@ export interface DeadlineScope { readonly close: () => void; } +export type StrictFinalizedEndpointRunnerModeV1 = 'read' | 'snapshot-preflight'; + export interface StrictFinalizedEndpointRunnerProfileV1 { + readonly mode: StrictFinalizedEndpointRunnerModeV1; readonly chainId: ChainIdV1; readonly endpoints: readonly string[]; readonly maxConcurrentPerChain: number; readonly totalDeadlineMs: number; readonly attemptTimeoutMs: number; - readonly chainMismatchMessage: (requestedChainId: ChainIdV1) => string; - readonly cancelledBeforeAdmissionMessage: string; - readonly cancelledMessage: string; - readonly totalDeadlineLabel: string; - readonly totalDeadlineMessage: (timeoutMs: number) => string; - readonly attemptDeadlineLabel: (attempt: number) => string; - readonly attemptDeadlineMessage: (timeoutMs: number) => string; - readonly attemptFailureMessage: string; - readonly noEndpointMessage: string; - readonly saturatedMessage: (active: number) => string; } export interface StrictFinalizedEndpointRunInputV1 { @@ -124,7 +118,6 @@ interface RpcErrorEnvelopeV1 { const CONFIG_REQUIRED_KEYS = Object.freeze(['chainId', 'endpoints'] as const); const CONFIG_OPTIONAL_KEYS = Object.freeze(['blockReferenceProfile'] as const); -const CANONICAL_LOWER_HEX_BYTES = /^0x(?:[0-9a-f]{2})*$/; const CANONICAL_LOWER_QUANTITY = /^0x(?:0|[1-9a-f][0-9a-f]*)$/; const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; const MAX_U64 = 18_446_744_073_709_551_615n; @@ -192,26 +185,12 @@ export function createStrictCurrentFinalizedEvmReadV1( ): StrictCurrentFinalizedEvmReadV1 { const config = snapshotConfig(input); const runEndpoint = createStrictFinalizedEndpointRunnerV1({ + mode: 'read', chainId: config.chainId, endpoints: config.endpoints, maxConcurrentPerChain: CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, totalDeadlineMs: CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, attemptTimeoutMs: CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - chainMismatchMessage: (requested) => - `Adapter is configured for chain ${config.chainId}, not ${requested}`, - cancelledBeforeAdmissionMessage: - 'Current-finalized EVM call was cancelled before transport admission', - cancelledMessage: 'Current-finalized EVM call was cancelled', - totalDeadlineLabel: 'current-finalized total deadline', - totalDeadlineMessage: (timeoutMs) => - `Current-finalized total deadline exceeded ${timeoutMs}ms`, - attemptDeadlineLabel: (attempt) => `current-finalized endpoint attempt ${attempt}`, - attemptDeadlineMessage: (timeoutMs) => - `Current-finalized endpoint attempt exceeded ${timeoutMs}ms`, - attemptFailureMessage: 'Current-finalized endpoint attempt failed closed', - noEndpointMessage: 'No configured current-finalized endpoint succeeded', - saturatedMessage: (active) => - `Chain ${config.chainId} already has ${active} finalized reads in flight`, }); const read: StrictCurrentFinalizedEvmReadV1 = async (inputRequest) => { @@ -237,6 +216,7 @@ export function createStrictCurrentFinalizedEvmReadV1( export function createStrictFinalizedEndpointRunnerV1( profile: StrictFinalizedEndpointRunnerProfileV1, ): StrictFinalizedEndpointRunnerV1 { + const messages = endpointRunnerMessages(profile.mode, profile.chainId); const admission = createNonqueueingAdmissionGateV1( profile.maxConcurrentPerChain, ); @@ -246,17 +226,17 @@ export function createStrictFinalizedEndpointRunnerV1( if (input.chainId !== profile.chainId) { throw new CurrentFinalizedEvmCallErrorV1( 'chain-mismatch', - profile.chainMismatchMessage(input.chainId), + messages.chainMismatch(input.chainId), ); } if (input.signal.aborted) { - throw cancelled(profile.cancelledBeforeAdmissionMessage); + throw cancelled(messages.cancelledBeforeAdmission); } return admission.run(input.chainId, async () => { const totalDeadline = createDeadlineScope( input.signal, profile.totalDeadlineMs, - profile.totalDeadlineLabel, + messages.totalDeadlineLabel, ); let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; try { @@ -265,7 +245,7 @@ export function createStrictFinalizedEndpointRunnerV1( const attemptDeadline = createDeadlineScope( totalDeadline.signal, profile.attemptTimeoutMs, - profile.attemptDeadlineLabel(attemptNumber), + messages.attemptDeadlineLabel(attemptNumber), ); let attemptResult!: AttemptResult; let accepted = false; @@ -311,7 +291,7 @@ export function createStrictFinalizedEndpointRunnerV1( } } throw classifyEndpointAttemptFailureV1( - lastRetryableFailure ?? unavailable(profile.noEndpointMessage), + lastRetryableFailure ?? unavailable(messages.noEndpoint), input.signal, totalDeadline, null, @@ -322,7 +302,7 @@ export function createStrictFinalizedEndpointRunnerV1( } }, (active) => new CurrentFinalizedEvmCallErrorV1( 'concurrency-saturated', - profile.saturatedMessage(active), + messages.saturated(active), )); }; return Object.freeze(run); @@ -335,7 +315,8 @@ function classifyEndpointAttemptFailureV1( attemptDeadline: DeadlineScope | null, profile: StrictFinalizedEndpointRunnerProfileV1, ): CurrentFinalizedEvmCallErrorV1 { - if (callerSignal.aborted) return cancelled(profile.cancelledMessage); + const messages = endpointRunnerMessages(profile.mode, profile.chainId); + if (callerSignal.aborted) return cancelled(messages.cancelled); if ( cause instanceof CurrentFinalizedEvmCallErrorV1 && PRE_DEADLINE_TERMINAL_FAILURES_V1.has(cause) @@ -343,13 +324,69 @@ function classifyEndpointAttemptFailureV1( return cause; } if (totalDeadline.timedOut()) { - return timedOut(profile.totalDeadlineMessage(profile.totalDeadlineMs)); + return timedOut(messages.totalDeadline(profile.totalDeadlineMs)); } if (attemptDeadline?.timedOut()) { - return timedOut(profile.attemptDeadlineMessage(profile.attemptTimeoutMs)); + return timedOut(messages.attemptDeadline(profile.attemptTimeoutMs)); } if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; - return unavailable(profile.attemptFailureMessage, cause); + return unavailable(messages.attemptFailure, cause); +} + +interface StrictFinalizedEndpointRunnerMessagesV1 { + readonly chainMismatch: (requested: ChainIdV1) => string; + readonly cancelledBeforeAdmission: string; + readonly cancelled: string; + readonly totalDeadlineLabel: string; + readonly totalDeadline: (timeoutMs: number) => string; + readonly attemptDeadlineLabel: (attempt: number) => string; + readonly attemptDeadline: (timeoutMs: number) => string; + readonly attemptFailure: string; + readonly noEndpoint: string; + readonly saturated: (active: number) => string; +} + +function endpointRunnerMessages( + mode: StrictFinalizedEndpointRunnerModeV1, + chainId: ChainIdV1, +): StrictFinalizedEndpointRunnerMessagesV1 { + if (mode === 'snapshot-preflight') { + return Object.freeze({ + chainMismatch: (requested: ChainIdV1) => + `Snapshot adapter is configured for chain ${chainId}, not ${requested}`, + cancelledBeforeAdmission: + 'Current-finalized snapshot was cancelled before transport admission', + cancelled: 'Current-finalized snapshot was cancelled', + totalDeadlineLabel: 'current-finalized snapshot total deadline', + totalDeadline: (timeoutMs: number) => + `Current-finalized snapshot deadline exceeded ${timeoutMs}ms`, + attemptDeadlineLabel: (attempt: number) => + `current-finalized snapshot preflight ${attempt}`, + attemptDeadline: (timeoutMs: number) => + `Current-finalized snapshot preflight exceeded ${timeoutMs}ms`, + attemptFailure: 'Current-finalized snapshot preflight failed closed', + noEndpoint: 'No configured endpoint completed finalized snapshot preflight', + saturated: (active: number) => + `Chain ${chainId} already has ${active} finalized snapshot in flight`, + }); + } + return Object.freeze({ + chainMismatch: (requested: ChainIdV1) => + `Adapter is configured for chain ${chainId}, not ${requested}`, + cancelledBeforeAdmission: + 'Current-finalized EVM call was cancelled before transport admission', + cancelled: 'Current-finalized EVM call was cancelled', + totalDeadlineLabel: 'current-finalized total deadline', + totalDeadline: (timeoutMs: number) => + `Current-finalized total deadline exceeded ${timeoutMs}ms`, + attemptDeadlineLabel: (attempt: number) => `current-finalized endpoint attempt ${attempt}`, + attemptDeadline: (timeoutMs: number) => + `Current-finalized endpoint attempt exceeded ${timeoutMs}ms`, + attemptFailure: 'Current-finalized endpoint attempt failed closed', + noEndpoint: 'No configured current-finalized endpoint succeeded', + saturated: (active: number) => + `Chain ${chainId} already has ${active} finalized reads in flight`, + }); } async function executeEndpointAttempt( @@ -698,8 +735,7 @@ function parseRpcError(input: unknown): RpcErrorEnvelopeV1 | undefined { ) { return undefined; } - const data = typeof input.data === 'string' - && CANONICAL_LOWER_HEX_BYTES.test(input.data) + const data = isCanonicalLowerHexBytesV1(input.data) ? input.data : undefined; return Object.freeze({ diff --git a/packages/chain/src/strict-finalized-evm-bytes.ts b/packages/chain/src/strict-finalized-evm-bytes.ts new file mode 100644 index 0000000000..0047284fb2 --- /dev/null +++ b/packages/chain/src/strict-finalized-evm-bytes.ts @@ -0,0 +1,6 @@ +const CANONICAL_LOWER_HEX_BYTES_V1 = /^0x(?:[0-9a-f]{2})*$/; + +/** Shared strict-profile predicate for canonical lowercase EVM byte strings. */ +export function isCanonicalLowerHexBytesV1(input: unknown): input is string { + return typeof input === 'string' && CANONICAL_LOWER_HEX_BYTES_V1.test(input); +} diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index dca85b7c18..fc7ab968c4 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -32,12 +32,12 @@ import { import { createStrictCurrentFinalizedEvmChainAdapterV1, createStrictCurrentFinalizedEvmReadV1, + readStrictCurrentFinalizedEvmRevertDataV1, type StrictCurrentFinalizedEvmRpcConfigV1, } from '../src/strict-current-finalized-evm-rpc.js'; import { executeStrictFinalizedAnchorPolicyV1, parseFinalizedAnchor, - readStrictCurrentFinalizedEvmRevertDataV1, } from '../src/strict-current-finalized-evm-transport.js'; import { createLoopbackJsonRpcTestHarness, From 7034d2b6cc0b4df0a01f04df4a8f4e96584f6e75 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:07:25 +0200 Subject: [PATCH 230/292] refactor(chain): split finalized RPC transport boundaries --- .../strict-current-finalized-evm-config.ts | 102 ++ .../strict-current-finalized-evm-errors.ts | 80 ++ .../strict-current-finalized-evm-lifecycle.ts | 371 ++++++++ ...strict-current-finalized-evm-rpc-client.ts | 251 +++++ .../src/strict-current-finalized-evm-rpc.ts | 6 +- ...-current-finalized-evm-snapshot-factory.ts | 6 +- ...rict-current-finalized-evm-snapshot-rpc.ts | 304 +----- ...urrent-finalized-evm-snapshot-transport.ts | 333 +++++++ .../strict-current-finalized-evm-transport.ts | 868 +----------------- .../src/strict-current-finalized-evm-types.ts | 43 + ...ict-current-finalized-evm-rpc.unit.test.ts | 6 +- 11 files changed, 1253 insertions(+), 1117 deletions(-) create mode 100644 packages/chain/src/strict-current-finalized-evm-config.ts create mode 100644 packages/chain/src/strict-current-finalized-evm-errors.ts create mode 100644 packages/chain/src/strict-current-finalized-evm-lifecycle.ts create mode 100644 packages/chain/src/strict-current-finalized-evm-rpc-client.ts create mode 100644 packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts create mode 100644 packages/chain/src/strict-current-finalized-evm-types.ts diff --git a/packages/chain/src/strict-current-finalized-evm-config.ts b/packages/chain/src/strict-current-finalized-evm-config.ts new file mode 100644 index 0000000000..1875fd69c7 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-config.ts @@ -0,0 +1,102 @@ +import { assertCanonicalChainId } from '@origintrail-official/dkg-core'; + +import { CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1 } from './current-finalized-evm-read-profile.js'; +import { snapshotDenseDataArray } from './strict-local-data.js'; +import { + CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, + type StrictCurrentFinalizedEvmRpcConfigV1, + type StrictRpcConfigSnapshotV1, +} from './strict-current-finalized-evm-types.js'; + +const CONFIG_REQUIRED_KEYS = Object.freeze(['chainId', 'endpoints'] as const); +const CONFIG_OPTIONAL_KEYS = Object.freeze(['blockReferenceProfile'] as const); + +export function snapshotStrictCurrentFinalizedEvmConfigV1( + input: StrictCurrentFinalizedEvmRpcConfigV1, +): StrictRpcConfigSnapshotV1 { + if (!isPlainRecord(input)) { + throw new TypeError('Strict current-finalized RPC config must be a plain data record'); + } + assertConfigDataProperties(input); + try { + assertCanonicalChainId(input.chainId, 'strict current-finalized chainId'); + } catch { + throw new TypeError('Strict current-finalized chainId must be canonical decimal u256'); + } + + const endpoints = snapshotNormalizedEndpoints(input.endpoints); + const blockReferenceProfile = input.blockReferenceProfile ?? 'eip1898'; + if (!CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1.includes(blockReferenceProfile)) { + throw new TypeError('Unsupported strict current-finalized block reference profile'); + } + return Object.freeze({ + chainId: input.chainId, + endpoints, + blockReferenceProfile, + }); +} + +function assertConfigDataProperties(input: Record): void { + const keys = Reflect.ownKeys(input); + if (keys.some((key) => typeof key !== 'string')) { + throw new TypeError('Strict current-finalized RPC config cannot contain symbol keys'); + } + const allowed = new Set([...CONFIG_REQUIRED_KEYS, ...CONFIG_OPTIONAL_KEYS]); + if ( + !CONFIG_REQUIRED_KEYS.every((key) => keys.includes(key)) + || (keys as string[]).some((key) => !allowed.has(key)) + ) { + throw new TypeError('Strict current-finalized RPC config has unknown or missing fields'); + } + for (const key of keys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new TypeError('Strict current-finalized RPC config fields must be enumerable data properties'); + } + } +} + +function snapshotNormalizedEndpoints(input: unknown): readonly string[] { + const normalized: string[] = []; + try { + const endpoints = snapshotDenseDataArray(input, { + label: 'Strict current-finalized RPC endpoints', + minLength: 1, + }); + for (const entry of endpoints) { + const endpoint = normalizeEndpoint(entry); + if (!normalized.includes(endpoint)) normalized.push(endpoint); + } + } catch (cause) { + if (cause instanceof TypeError) throw cause; + throw new TypeError('Strict current-finalized endpoints must be a dense data-only array'); + } + if (normalized.length === 0 || normalized.length > CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { + throw new TypeError( + `Strict current-finalized RPC requires 1..${CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1} distinct endpoints`, + ); + } + return Object.freeze(normalized); +} + +function normalizeEndpoint(input: unknown): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new TypeError('Strict current-finalized RPC endpoint must be a nonempty URL string'); + } + let url: URL; + try { + url = new URL(input.trim()); + } catch { + throw new TypeError('Strict current-finalized RPC endpoint must be an absolute URL'); + } + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.hash !== '') { + throw new TypeError('Strict current-finalized RPC endpoint must use HTTP(S) without a fragment'); + } + return url.href; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/chain/src/strict-current-finalized-evm-errors.ts b/packages/chain/src/strict-current-finalized-evm-errors.ts new file mode 100644 index 0000000000..d38dfe5347 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-errors.ts @@ -0,0 +1,80 @@ +import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-profile.js'; + +const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); +const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); +const PRE_DEADLINE_TERMINAL_FAILURES_V1 = new WeakSet(); + +/** Package-internal evidence available only for errors minted by this transport. */ +export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { + return error instanceof CurrentFinalizedEvmCallErrorV1 + ? AUTHENTICATED_REVERT_DATA_V1.get(error) + : undefined; +} + +export function unavailable(message: string, cause?: unknown): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1( + 'rpc-unavailable', + message, + cause === undefined ? undefined : { cause }, + ); +} + +export function timedOut(message: string): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1('rpc-timeout', message); +} + +export function resourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1('resource-limit', message); +} + +export function anchorDependentResourceLimited( + message: string, +): CurrentFinalizedEvmCallErrorV1 { + const error = resourceLimited(message); + ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.add(error); + return error; +} + +export function revertedAtFinalizedAnchor(data?: string): CurrentFinalizedEvmCallErrorV1 { + const error = new CurrentFinalizedEvmCallErrorV1( + 'revert', + 'Contract call reverted at the resolved finalized anchor', + ); + if (data !== undefined) AUTHENTICATED_REVERT_DATA_V1.set(error, data); + return error; +} + +export function isAnchorDependentResourceLimit( + error: CurrentFinalizedEvmCallErrorV1, +): boolean { + return error.code === 'resource-limit' + && ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.has(error); +} + +export function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { + return error.code === 'unsupported-chain' + || error.code === 'resource-limit' + || error.code === 'revert' + || error.code === 'no-code' + || error.code === 'malformed-return'; +} + +export function markPreDeadlineTerminalFailure( + error: CurrentFinalizedEvmCallErrorV1, +): void { + PRE_DEADLINE_TERMINAL_FAILURES_V1.add(error); +} + +export function isPreDeadlineTerminalFailure( + error: CurrentFinalizedEvmCallErrorV1, +): boolean { + return PRE_DEADLINE_TERMINAL_FAILURES_V1.has(error); +} + +export function cancelled(message: string): CurrentFinalizedEvmCallErrorV1 { + // Caller intent is authenticated by the verifier-owned AbortSignal. Keep + // the adapter error retryable so a foreign gateway cannot forge a cancelled + // disposition merely by throwing a public error code; the verifier observes + // its caller signal first and maps this exact path to `cancelled`. + return new CurrentFinalizedEvmCallErrorV1('rpc-unavailable', message); +} diff --git a/packages/chain/src/strict-current-finalized-evm-lifecycle.ts b/packages/chain/src/strict-current-finalized-evm-lifecycle.ts new file mode 100644 index 0000000000..c9348f7652 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-lifecycle.ts @@ -0,0 +1,371 @@ +import type { ChainIdV1 } from '@origintrail-official/dkg-core'; + +import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-profile.js'; +import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; +import { + cancelled, + isAnchorDependentResourceLimit, + isPreDeadlineTerminalFailure, + isTerminalAttemptFailure, + markPreDeadlineTerminalFailure, + timedOut, + unavailable, +} from './strict-current-finalized-evm-errors.js'; +import type { + CurrentFinalizedEvmBlockReferenceProfileV1, + DeadlineScopeV1, + FinalizedAnchorV1, +} from './strict-current-finalized-evm-types.js'; + +type StrictFinalizedEndpointRunnerModeV1 = 'read' | 'snapshot-preflight'; + +export interface StrictFinalizedEndpointRunnerProfileV1 { + readonly mode: StrictFinalizedEndpointRunnerModeV1; + readonly chainId: ChainIdV1; + readonly endpoints: readonly string[]; + readonly maxConcurrentPerChain: number; + readonly totalDeadlineMs: number; + readonly attemptTimeoutMs: number; +} + +interface StrictFinalizedEndpointRunInputV1 { + readonly chainId: ChainIdV1; + readonly signal: AbortSignal; + readonly attempt: ( + endpoint: string, + attempt: number, + deadline: DeadlineScopeV1, + ) => Promise; + /** Runs once, outside the retry catch. Snapshot consumers are never replayed. */ + readonly accept: ( + endpoint: string, + attemptResult: AttemptResult, + totalDeadline: DeadlineScopeV1, + ) => Promise; +} + +export interface StrictFinalizedEndpointRunnerV1 { + ( + input: StrictFinalizedEndpointRunInputV1, + ): Promise; +} + +/** + * Canonical non-queueing endpoint lifecycle shared by one-shot reads and + * snapshot preflight. Only `attempt` participates in failover; `accept` runs + * once after the attempt deadline is closed and therefore cannot replay a + * snapshot consumer. + */ +export function createStrictFinalizedEndpointRunnerV1( + profile: StrictFinalizedEndpointRunnerProfileV1, +): StrictFinalizedEndpointRunnerV1 { + const messages = endpointRunnerMessages(profile.mode, profile.chainId); + const admission = createNonqueueingAdmissionGateV1( + profile.maxConcurrentPerChain, + ); + const run: StrictFinalizedEndpointRunnerV1 = async ( + input: StrictFinalizedEndpointRunInputV1, + ): Promise => { + if (input.chainId !== profile.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + messages.chainMismatch(input.chainId), + ); + } + if (input.signal.aborted) { + throw cancelled(messages.cancelledBeforeAdmission); + } + return admission.run(input.chainId, async () => { + const totalDeadline = createDeadlineScopeV1( + input.signal, + profile.totalDeadlineMs, + messages.totalDeadlineLabel, + ); + let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + try { + for (let index = 0; index < profile.endpoints.length; index += 1) { + const attemptNumber = index + 1; + const attemptDeadline = createDeadlineScopeV1( + totalDeadline.signal, + profile.attemptTimeoutMs, + messages.attemptDeadlineLabel(attemptNumber), + ); + let attemptResult!: AttemptResult; + let accepted = false; + try { + attemptResult = await input.attempt( + profile.endpoints[index]!, + attemptNumber, + attemptDeadline, + ); + if ( + input.signal.aborted + || totalDeadline.timedOut() + || attemptDeadline.timedOut() + ) { + throw classifyEndpointAttemptFailureV1( + new Error('Endpoint attempt completed after its lifecycle ended'), + input.signal, + totalDeadline, + attemptDeadline, + profile, + ); + } + accepted = true; + } catch (cause) { + const failure = classifyEndpointAttemptFailureV1( + cause, + input.signal, + totalDeadline, + attemptDeadline, + profile, + ); + if (isTerminalAttemptFailure(failure)) throw failure; + lastRetryableFailure = failure; + } finally { + attemptDeadline.close(); + } + if (accepted) { + return input.accept( + profile.endpoints[index]!, + attemptResult, + totalDeadline, + ); + } + } + throw classifyEndpointAttemptFailureV1( + lastRetryableFailure ?? unavailable(messages.noEndpoint), + input.signal, + totalDeadline, + null, + profile, + ); + } finally { + totalDeadline.close(); + } + }, (active) => new CurrentFinalizedEvmCallErrorV1( + 'concurrency-saturated', + messages.saturated(active), + )); + }; + return Object.freeze(run); +} + +function classifyEndpointAttemptFailureV1( + cause: unknown, + callerSignal: AbortSignal, + totalDeadline: DeadlineScopeV1, + attemptDeadline: DeadlineScopeV1 | null, + profile: StrictFinalizedEndpointRunnerProfileV1, +): CurrentFinalizedEvmCallErrorV1 { + const messages = endpointRunnerMessages(profile.mode, profile.chainId); + if (callerSignal.aborted) return cancelled(messages.cancelled); + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && isPreDeadlineTerminalFailure(cause) + ) { + return cause; + } + if (totalDeadline.timedOut()) { + return timedOut(messages.totalDeadline(profile.totalDeadlineMs)); + } + if (attemptDeadline?.timedOut()) { + return timedOut(messages.attemptDeadline(profile.attemptTimeoutMs)); + } + if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; + return unavailable(messages.attemptFailure, cause); +} + +interface StrictFinalizedEndpointRunnerMessagesV1 { + readonly chainMismatch: (requested: ChainIdV1) => string; + readonly cancelledBeforeAdmission: string; + readonly cancelled: string; + readonly totalDeadlineLabel: string; + readonly totalDeadline: (timeoutMs: number) => string; + readonly attemptDeadlineLabel: (attempt: number) => string; + readonly attemptDeadline: (timeoutMs: number) => string; + readonly attemptFailure: string; + readonly noEndpoint: string; + readonly saturated: (active: number) => string; +} + +function endpointRunnerMessages( + mode: StrictFinalizedEndpointRunnerModeV1, + chainId: ChainIdV1, +): StrictFinalizedEndpointRunnerMessagesV1 { + if (mode === 'snapshot-preflight') { + return Object.freeze({ + chainMismatch: (requested: ChainIdV1) => + `Snapshot adapter is configured for chain ${chainId}, not ${requested}`, + cancelledBeforeAdmission: + 'Current-finalized snapshot was cancelled before transport admission', + cancelled: 'Current-finalized snapshot was cancelled', + totalDeadlineLabel: 'current-finalized snapshot total deadline', + totalDeadline: (timeoutMs: number) => + `Current-finalized snapshot deadline exceeded ${timeoutMs}ms`, + attemptDeadlineLabel: (attempt: number) => + `current-finalized snapshot preflight ${attempt}`, + attemptDeadline: (timeoutMs: number) => + `Current-finalized snapshot preflight exceeded ${timeoutMs}ms`, + attemptFailure: 'Current-finalized snapshot preflight failed closed', + noEndpoint: 'No configured endpoint completed finalized snapshot preflight', + saturated: (active: number) => + `Chain ${chainId} already has ${active} finalized snapshot in flight`, + }); + } + return Object.freeze({ + chainMismatch: (requested: ChainIdV1) => + `Adapter is configured for chain ${chainId}, not ${requested}`, + cancelledBeforeAdmission: + 'Current-finalized EVM call was cancelled before transport admission', + cancelled: 'Current-finalized EVM call was cancelled', + totalDeadlineLabel: 'current-finalized total deadline', + totalDeadline: (timeoutMs: number) => + `Current-finalized total deadline exceeded ${timeoutMs}ms`, + attemptDeadlineLabel: (attempt: number) => `current-finalized endpoint attempt ${attempt}`, + attemptDeadline: (timeoutMs: number) => + `Current-finalized endpoint attempt exceeded ${timeoutMs}ms`, + attemptFailure: 'Current-finalized endpoint attempt failed closed', + noEndpoint: 'No configured current-finalized endpoint succeeded', + saturated: (active: number) => + `Chain ${chainId} already has ${active} finalized reads in flight`, + }); +} + +/** Inputs for the shared EIP-1898 or authenticated-numbered-anchor policy. */ +export interface StrictFinalizedAnchorPolicyOptionsV1 { + readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; + readonly anchor: FinalizedAnchorV1; + readonly executeAtReference: (blockReference: unknown) => Promise; + readonly readPostAnchor: () => Promise; + readonly anchorMismatchMessage: string; +} + +/** Canonical EIP-1898 / authenticated-numbered-anchor execution policy. */ +export async function executeStrictFinalizedAnchorPolicyV1( + options: StrictFinalizedAnchorPolicyOptionsV1, +): Promise { + if (options.blockReferenceProfile === 'eip1898') { + return options.executeAtReference(Object.freeze({ + blockHash: options.anchor.blockHash, + requireCanonical: true as const, + })); + } + + let provisionalExecution: + | Readonly<{ readonly ok: true; readonly value: T }> + | Readonly<{ readonly ok: false; readonly failure: CurrentFinalizedEvmCallErrorV1 }>; + try { + provisionalExecution = Object.freeze({ + ok: true, + value: await options.executeAtReference(options.anchor.blockNumberQuantity), + }); + } catch (cause) { + if ( + cause instanceof CurrentFinalizedEvmCallErrorV1 + && ( + cause.code === 'no-code' + || cause.code === 'revert' + || cause.code === 'malformed-return' + || isAnchorDependentResourceLimit(cause) + ) + ) { + provisionalExecution = Object.freeze({ ok: false, failure: cause }); + } else { + throw cause; + } + } + await assertStrictFinalizedAnchorStableV1( + options.anchor, + options.readPostAnchor, + options.anchorMismatchMessage, + ); + if (!provisionalExecution.ok) throw provisionalExecution.failure; + return provisionalExecution.value; +} + +export async function assertStrictFinalizedAnchorStableV1( + anchor: FinalizedAnchorV1, + readPostAnchor: () => Promise, + mismatchMessage: string, +): Promise { + const postAnchor = await readPostAnchor(); + if ( + postAnchor.blockNumber !== anchor.blockNumber + || postAnchor.blockHash !== anchor.blockHash + ) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + mismatchMessage, + ); + } +} + +export async function settleStrictFinalizedParallelBatchV1( + operations: readonly Promise[], + attemptDeadline: DeadlineScopeV1, +): Promise { + let firstFailure: unknown; + let hasFailure = false; + let firstPreDeadlineTerminalFailure: CurrentFinalizedEvmCallErrorV1 | undefined; + const tracked = operations.map(async (operation) => { + try { + return await operation; + } catch (cause) { + if (!hasFailure) { + hasFailure = true; + firstFailure = cause; + } + if ( + firstPreDeadlineTerminalFailure === undefined + && cause instanceof CurrentFinalizedEvmCallErrorV1 + && isTerminalAttemptFailure(cause) + && !attemptDeadline.timedOut() + ) { + firstPreDeadlineTerminalFailure = cause; + markPreDeadlineTerminalFailure(cause); + } + throw cause; + } + }); + const settled = await Promise.allSettled(tracked); + if (firstPreDeadlineTerminalFailure !== undefined) { + throw firstPreDeadlineTerminalFailure; + } + if (hasFailure) throw firstFailure; + const values: T[] = []; + for (let index = 0; index < settled.length; index += 1) { + const result = settled[index]!; + if (result.status === 'rejected') { + throw unavailable('Parallel finalized-read operation failed without a recorded cause'); + } + values.push(result.value); + } + return Object.freeze(values); +} + +export function createDeadlineScopeV1( + parent: AbortSignal, + timeoutMs: number, + label: string, +): DeadlineScopeV1 { + const controller = new AbortController(); + let didTimeOut = false; + const expiresAt = performance.now() + timeoutMs; + const parentAbort = (): void => controller.abort(parent.reason); + if (parent.aborted) parentAbort(); + else parent.addEventListener('abort', parentAbort, { once: true }); + const timer = setTimeout(() => { + didTimeOut = true; + controller.abort(new Error(`${label} exceeded ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + return Object.freeze({ + signal: controller.signal, + timedOut: () => didTimeOut || performance.now() >= expiresAt, + close: () => { + clearTimeout(timer); + parent.removeEventListener('abort', parentAbort); + }, + }); +} diff --git a/packages/chain/src/strict-current-finalized-evm-rpc-client.ts b/packages/chain/src/strict-current-finalized-evm-rpc-client.ts new file mode 100644 index 0000000000..60c3c8fb67 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-rpc-client.ts @@ -0,0 +1,251 @@ +import type { + BlockNumberV1, + ChainIdV1, + Digest32V1, +} from '@origintrail-official/dkg-core'; + +import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-profile.js'; +import { + anchorDependentResourceLimited, + resourceLimited, + revertedAtFinalizedAnchor, + timedOut, + unavailable, +} from './strict-current-finalized-evm-errors.js'; +import type { FinalizedAnchorV1 } from './strict-current-finalized-evm-types.js'; +import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; + +interface RpcErrorEnvelopeV1 { + readonly code: number; + readonly message: string; + readonly data?: string; +} + +const CANONICAL_LOWER_QUANTITY = /^0x(?:0|[1-9a-f][0-9a-f]*)$/; +const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; +const MAX_U64 = 18_446_744_073_709_551_615n; +const MAX_U256 = + 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; + +export async function postStrictFinalizedJsonRpcV1( + endpoint: string, + id: number, + method: string, + params: readonly unknown[], + maxResponseBytes: number, + signal: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers: Object.freeze({ + accept: 'application/json', + 'content-type': 'application/json', + }), + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + redirect: 'error', + signal, + }); + } catch (cause) { + if (signal.aborted) throw cause; + throw unavailable(`JSON-RPC ${method} transport failed`, cause); + } + + const body = await readResponseBodyBounded(response, maxResponseBytes); + if (!response.ok) { + // An HTTP intermediary/provider failure is transport availability, even if + // its untrusted body happens to mimic a deterministic JSON-RPC revert. Only + // a successful JSON-RPC transport response may select an invalidity code. + throw unavailable(`JSON-RPC ${method} returned HTTP ${response.status}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch (cause) { + throw unavailable(`JSON-RPC ${method} returned malformed JSON`, cause); + } + if (!isPlainRecord(parsed) || parsed.jsonrpc !== '2.0' || parsed.id !== id) { + throw unavailable(`JSON-RPC ${method} returned a mismatched response envelope`); + } + const hasResult = Object.prototype.hasOwnProperty.call(parsed, 'result'); + const hasError = Object.prototype.hasOwnProperty.call(parsed, 'error'); + if (hasResult === hasError) { + throw unavailable(`JSON-RPC ${method} response must contain exactly one of result or error`); + } + if (hasError) { + const error = parseRpcError(parsed.error); + if (error === undefined) throw unavailable(`JSON-RPC ${method} returned a malformed error`); + throw classifyJsonRpcError(method, error); + } + return parsed.result; +} + +async function readResponseBodyBounded(response: Response, maxBytes: number): Promise { + const contentLength = response.headers.get('content-length'); + if (contentLength !== null && /^\d+$/.test(contentLength)) { + const declared = BigInt(contentLength); + if (declared > BigInt(maxBytes)) { + await response.body?.cancel().catch(() => undefined); + throw resourceLimited( + `Raw JSON-RPC response declared ${declared.toString()} bytes, limit ${maxBytes}`, + ); + } + } + + if (response.body === null) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + throw resourceLimited(`Raw JSON-RPC response exceeded ${maxBytes} bytes before parsing`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (cause) { + throw unavailable('JSON-RPC response body is not valid UTF-8', cause); + } +} + +export function parseStrictFinalizedChainIdV1(input: unknown): ChainIdV1 { + let parsed: bigint; + try { + parsed = parseCanonicalQuantity(input, MAX_U256); + } catch (cause) { + throw unavailable('eth_chainId returned a malformed chain ID', cause); + } + return parsed.toString(10) as ChainIdV1; +} + +export function parseStrictFinalizedAnchorV1( + input: unknown, + label: string, +): FinalizedAnchorV1 { + if (input === null) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} is unavailable`, + ); + } + if (!isPlainRecord(input)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} is malformed`, + ); + } + + let blockNumber: bigint; + try { + blockNumber = parseCanonicalQuantity(input.number, MAX_U64); + } catch (cause) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} has a malformed block number`, + { cause }, + ); + } + if (typeof input.hash !== 'string' || !CANONICAL_DIGEST_32.test(input.hash)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `${label} has a malformed block hash`, + ); + } + return Object.freeze({ + blockNumber: blockNumber.toString(10) as BlockNumberV1, + blockNumberQuantity: input.number as string, + blockHash: input.hash as Digest32V1, + }); +} + +function parseCanonicalQuantity(input: unknown, maximum: bigint): bigint { + if (typeof input !== 'string' || !CANONICAL_LOWER_QUANTITY.test(input)) { + throw new Error('not a canonical lowercase JSON-RPC quantity'); + } + const parsed = BigInt(input); + if (parsed > maximum) throw new Error('JSON-RPC quantity is out of range'); + return parsed; +} + +function parseRpcError(input: unknown): RpcErrorEnvelopeV1 | undefined { + if ( + !isPlainRecord(input) + || typeof input.code !== 'number' + || !Number.isSafeInteger(input.code) + || typeof input.message !== 'string' + ) { + return undefined; + } + const data = isCanonicalLowerHexBytesV1(input.data) + ? input.data + : undefined; + return Object.freeze({ + code: input.code, + message: input.message, + ...(data === undefined ? {} : { data }), + }); +} + +function classifyJsonRpcError( + method: string, + error: RpcErrorEnvelopeV1, +): CurrentFinalizedEvmCallErrorV1 { + const message = error.message.toLowerCase(); + if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { + return revertedAtFinalizedAnchor(error.data); + } + if ( + method === 'eth_call' + && ( + message.includes('out of gas') + || message.includes('gas limit') + || message.includes('gas required') + || message.includes('exceeds allowance') + || message.includes('intrinsic gas') + ) + ) { + return anchorDependentResourceLimited( + 'Finalized contract execution could not complete within the fixed gas cap', + ); + } + if (message.includes('timeout') || message.includes('timed out')) { + return timedOut(`JSON-RPC ${method} timed out`); + } + if ( + method === 'eth_getBlockByNumber' + || message.includes('header not found') + || message.includes('unknown block') + || message.includes('block not found') + || message.includes('canonical') + ) { + return new CurrentFinalizedEvmCallErrorV1( + 'finalized-state-unavailable', + `JSON-RPC ${method} could not prove the required finalized anchor`, + ); + } + return unavailable(`JSON-RPC ${method} failed with code ${error.code}`); +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/chain/src/strict-current-finalized-evm-rpc.ts b/packages/chain/src/strict-current-finalized-evm-rpc.ts index 32fb0e4fcd..0f08ce14b1 100644 --- a/packages/chain/src/strict-current-finalized-evm-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-rpc.ts @@ -1,9 +1,9 @@ /** * Public one-shot strict current-finalized RPC surface. * - * Transport orchestration and shared snapshot internals intentionally live in - * `strict-current-finalized-evm-transport.ts`; this module exposes only the - * supported one-shot factories, configuration, and read model. + * This module exposes only the supported one-shot factories, configuration, + * and read model. Config, endpoint lifecycle, RPC framing, and scoped snapshot + * transport remain cohesive package-internal modules. */ export { CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts index 57788d718e..a359a52411 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts @@ -1,6 +1,6 @@ import type { StrictCurrentFinalizedEvmSnapshotScopeV1 } from './current-finalized-evm-snapshot.js'; import type { StrictCurrentFinalizedEvmRpcConfigV1 } from './strict-current-finalized-evm-rpc.js'; -import { snapshotConfig } from './strict-current-finalized-evm-transport.js'; +import { snapshotStrictCurrentFinalizedEvmConfigV1 } from './strict-current-finalized-evm-config.js'; import { createStrictFinalizedSnapshotRpcRuntimeV1 } from './strict-current-finalized-evm-snapshot-rpc.js'; /** @@ -11,5 +11,7 @@ import { createStrictFinalizedSnapshotRpcRuntimeV1 } from './strict-current-fina export function createStrictCurrentFinalizedEvmSnapshotScopeV1( input: StrictCurrentFinalizedEvmRpcConfigV1, ): StrictCurrentFinalizedEvmSnapshotScopeV1 { - return createStrictFinalizedSnapshotRpcRuntimeV1(snapshotConfig(input)); + return createStrictFinalizedSnapshotRpcRuntimeV1( + snapshotStrictCurrentFinalizedEvmConfigV1(input), + ); } diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts index db3389b960..59423dd201 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -1,76 +1,36 @@ import type { EvmAddressV1 } from '@origintrail-official/dkg-core'; -import { - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - CurrentFinalizedEvmCallErrorV1, -} from './current-finalized-evm-read-profile.js'; +import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-profile.js'; import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; import { - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, - CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, createCurrentFinalizedEvmSnapshotBudgetV1, - type StrictCurrentFinalizedEvmSnapshotRequestV1, type StrictCurrentFinalizedEvmSnapshotScopeV1, type StrictCurrentFinalizedEvmSnapshotSessionV1, } from './current-finalized-evm-snapshot.js'; -import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; import { snapshotCurrentFinalizedEvmSnapshotCallsV1, snapshotCurrentFinalizedEvmSnapshotRequestV1, } from './current-finalized-evm-read-validation.js'; import { - assertStrictFinalizedAnchorStableV1, - cancelled, - createDeadlineScope, - createStrictFinalizedEndpointRunnerV1, - executeStrictFinalizedAnchorPolicyV1, - parseChainId, - parseFinalizedAnchor, - postJsonRpc, resourceLimited, - settleParallelBatch, - timedOut, unavailable, - type DeadlineScope, - type FinalizedAnchorV1, - type StrictRpcConfigSnapshotV1, -} from './strict-current-finalized-evm-transport.js'; -import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; - -interface SnapshotEndpointPreflightV1 { - readonly anchor: FinalizedAnchorV1; - readonly lastRequestId: number; -} - -type SnapshotRpcV1 = ( - method: string, - params: readonly unknown[], -) => Promise; +} from './strict-current-finalized-evm-errors.js'; +import { + createStrictFinalizedSnapshotTransportV1, + type StrictFinalizedSnapshotTransportSessionV1, +} from './strict-current-finalized-evm-snapshot-transport.js'; +import type { StrictRpcConfigSnapshotV1 } from './strict-current-finalized-evm-types.js'; interface SnapshotReadSessionControllerV1 { readonly session: StrictCurrentFinalizedEvmSnapshotSessionV1; readonly closeAndDrain: () => Promise; } -const SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1 = - '0x0000000000000000000000000000000000000000' as EvmAddressV1; -const SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1 = - `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; /** Package-internal runtime for a scoped, pinned finalized snapshot. */ export function createStrictFinalizedSnapshotRpcRuntimeV1( config: StrictRpcConfigSnapshotV1, ): StrictCurrentFinalizedEvmSnapshotScopeV1 { - const runEndpoint = createStrictFinalizedEndpointRunnerV1({ - mode: 'snapshot-preflight', - chainId: config.chainId, - endpoints: config.endpoints, - maxConcurrentPerChain: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, - totalDeadlineMs: CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, - attemptTimeoutMs: CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - }); + const transport = createStrictFinalizedSnapshotTransportV1(config); const withSnapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async ( inputRequest, consume, @@ -79,210 +39,16 @@ export function createStrictFinalizedSnapshotRpcRuntimeV1( if (typeof consume !== 'function') { throw unavailable('Current-finalized snapshot consumer must be callable'); } - return runEndpoint({ - chainId: request.chainId, - signal: request.signal, - attempt: (endpoint, _attempt, deadline) => - preflightSnapshotEndpoint(config, endpoint, deadline.signal), - // This runs outside the runner's retry catch, so consumer code is never replayed. - accept: (endpoint, preflight, totalDeadline) => executePinnedSnapshotScope( - config, - endpoint, - preflight, - request, - consume, - totalDeadline, - ), + return transport(request, async (transportSession) => { + const readSession = createSnapshotReadSession(transportSession); + return runConsumerWithDrainedReads(readSession, consume); }); }; return Object.freeze(withSnapshot); } -async function preflightSnapshotEndpoint( - config: StrictRpcConfigSnapshotV1, - endpoint: string, - signal: AbortSignal, -): Promise { - let requestId = 0; - const rpc = async (method: string, params: readonly unknown[]): Promise => { - requestId += 1; - return postJsonRpc( - endpoint, - requestId, - method, - params, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - signal, - ); - }; - const remoteChainId = parseChainId( - await rpc('eth_chainId', Object.freeze([])), - ); - if (remoteChainId !== config.chainId) { - throw new CurrentFinalizedEvmCallErrorV1( - 'chain-mismatch', - `Configured snapshot endpoint reported chain ${remoteChainId}, expected ${config.chainId}`, - ); - } - const anchor = parseFinalizedAnchor( - await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), - 'current finalized snapshot header', - ); - const blockReference = config.blockReferenceProfile === 'eip1898' - ? Object.freeze({ blockHash: anchor.blockHash, requireCanonical: true as const }) - : anchor.blockNumberQuantity; - await probeSnapshotReadProfile(rpc, blockReference); - if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { - await assertStrictFinalizedAnchorStableV1( - anchor, - async () => parseFinalizedAnchor( - await rpc( - 'eth_getBlockByNumber', - Object.freeze([anchor.blockNumberQuantity, false]), - ), - 'post-preflight numbered header', - ), - 'Snapshot read-profile preflight did not preserve the resolved finalized anchor', - ); - } - return Object.freeze({ anchor, lastRequestId: requestId }); -} - -async function probeSnapshotReadProfile( - rpc: SnapshotRpcV1, - blockReference: unknown, -): Promise { - try { - const code = await rpc('eth_getCode', Object.freeze([ - SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, - blockReference, - ])); - if (!isCanonicalLowerHexBytesV1(code)) { - throw new Error('eth_getCode capability probe returned malformed bytes'); - } - const callResult = await rpc('eth_call', Object.freeze([ - Object.freeze({ - from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - to: SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, - data: '0x', - gas: SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1, - }), - blockReference, - ])); - if (!isCanonicalLowerHexBytesV1(callResult)) { - throw new Error('eth_call capability probe returned malformed bytes'); - } - } catch (cause) { - throw unavailable( - 'Configured snapshot endpoint cannot execute the required finalized read profile', - cause, - ); - } -} - -async function executePinnedSnapshotScope( - config: StrictRpcConfigSnapshotV1, - endpoint: string, - preflight: SnapshotEndpointPreflightV1, - request: StrictCurrentFinalizedEvmSnapshotRequestV1, - consume: (session: StrictCurrentFinalizedEvmSnapshotSessionV1) => Promise, - totalDeadline: DeadlineScope, -): Promise { - const rpc = createPinnedSnapshotRpcClient( - endpoint, - preflight.lastRequestId, - request, - totalDeadline, - ); - const readSession = createSnapshotReadSession( - config, - preflight.anchor, - rpc, - totalDeadline, - ); - const result = await runConsumerWithDrainedReads(readSession, consume); - if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { - await assertStrictFinalizedAnchorStableV1( - preflight.anchor, - async () => parseFinalizedAnchor( - await rpc( - 'eth_getBlockByNumber', - Object.freeze([preflight.anchor.blockNumberQuantity, false]), - ), - 'post-snapshot numbered header', - ), - 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', - ); - } - if (request.signal.aborted) { - throw cancelled('Current-finalized snapshot was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - return result; -} - -function createPinnedSnapshotRpcClient( - endpoint: string, - lastRequestId: number, - request: StrictCurrentFinalizedEvmSnapshotRequestV1, - totalDeadline: DeadlineScope, -): SnapshotRpcV1 { - let requestId = lastRequestId; - return async (method: string, params: readonly unknown[]): Promise => { - if (request.signal.aborted) { - throw cancelled('Current-finalized snapshot was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - const rpcDeadline = createDeadlineScope( - totalDeadline.signal, - CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - `current-finalized snapshot JSON-RPC ${method}`, - ); - requestId += 1; - try { - return await postJsonRpc( - endpoint, - requestId, - method, - params, - CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, - rpcDeadline.signal, - ); - } catch (cause) { - if (request.signal.aborted) { - throw cancelled('Current-finalized snapshot was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - if (rpcDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot JSON-RPC exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, - ); - } - if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; - throw unavailable('Current-finalized snapshot JSON-RPC failed closed', cause); - } finally { - rpcDeadline.close(); - } - }; -} - function createSnapshotReadSession( - config: StrictRpcConfigSnapshotV1, - anchor: FinalizedAnchorV1, - rpc: SnapshotRpcV1, - totalDeadline: DeadlineScope, + transportSession: StrictFinalizedSnapshotTransportSessionV1, ): Readonly { const budget = createCurrentFinalizedEvmSnapshotBudgetV1(); const deployedTargets = new Set(); @@ -313,14 +79,7 @@ function createSnapshotReadSession( 'Current-finalized snapshot exceeded its fixed scan budget', )); } - const operation = executeSnapshotBatch( - config, - anchor, - calls, - deployedTargets, - rpc, - totalDeadline, - ); + const operation = transportSession.read(calls, deployedTargets); const clearInFlight = () => { if (inFlight === operation) inFlight = undefined; }; @@ -332,9 +91,9 @@ function createSnapshotReadSession( return operation; }); const session = Object.freeze({ - chainId: config.chainId, - blockNumber: anchor.blockNumber, - blockHash: anchor.blockHash, + chainId: transportSession.chainId, + blockNumber: transportSession.blockNumber, + blockHash: transportSession.blockHash, read, } satisfies StrictCurrentFinalizedEvmSnapshotSessionV1); return Object.freeze({ @@ -376,34 +135,3 @@ function handledRejectedRead(cause: unknown): Promise { void rejected.catch(() => undefined); return rejected; } - -async function executeSnapshotBatch( - config: StrictRpcConfigSnapshotV1, - anchor: FinalizedAnchorV1, - calls: readonly StrictCurrentFinalizedEvmReadCallV1[], - deployedTargets: Set, - rpc: (method: string, params: readonly unknown[]) => Promise, - totalDeadline: DeadlineScope, -): Promise { - const executeCallsAt = (blockReference: unknown) => executeStrictFinalizedEvmBatchV1({ - calls, - blockReference, - deployedTargets, - rpc, - settle: (operations) => settleParallelBatch(operations, totalDeadline), - }); - - const batch = await executeStrictFinalizedAnchorPolicyV1({ - blockReferenceProfile: config.blockReferenceProfile, - anchor, - executeAtReference: executeCallsAt, - readPostAnchor: async () => parseFinalizedAnchor( - await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), - 'post-snapshot numbered header', - ), - anchorMismatchMessage: - 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', - }); - for (const target of batch.verifiedTargets) deployedTargets.add(target); - return batch.returnData; -} diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts new file mode 100644 index 0000000000..1242003736 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts @@ -0,0 +1,333 @@ +import type { + ChainIdV1, + Digest32V1, + EvmAddressV1, + BlockNumberV1, +} from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; +import { + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, + type StrictCurrentFinalizedEvmSnapshotRequestV1, +} from './current-finalized-evm-snapshot.js'; +import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; +import { + cancelled, + timedOut, + unavailable, +} from './strict-current-finalized-evm-errors.js'; +import { + assertStrictFinalizedAnchorStableV1, + createDeadlineScopeV1, + createStrictFinalizedEndpointRunnerV1, + executeStrictFinalizedAnchorPolicyV1, + settleStrictFinalizedParallelBatchV1, +} from './strict-current-finalized-evm-lifecycle.js'; +import { + parseStrictFinalizedAnchorV1, + parseStrictFinalizedChainIdV1, + postStrictFinalizedJsonRpcV1, +} from './strict-current-finalized-evm-rpc-client.js'; +import type { + DeadlineScopeV1, + FinalizedAnchorV1, + StrictRpcConfigSnapshotV1, +} from './strict-current-finalized-evm-types.js'; +import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; + +interface SnapshotEndpointPreflightV1 { + readonly anchor: FinalizedAnchorV1; + readonly lastRequestId: number; +} + +type SnapshotRpcV1 = ( + method: string, + params: readonly unknown[], +) => Promise; + +export interface StrictFinalizedSnapshotTransportSessionV1 { + readonly chainId: ChainIdV1; + readonly blockNumber: BlockNumberV1; + readonly blockHash: Digest32V1; + readonly read: ( + calls: readonly StrictCurrentFinalizedEvmReadCallV1[], + deployedTargets: Set, + ) => Promise; +} + +export interface StrictFinalizedSnapshotTransportV1 { + ( + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + consume: (session: StrictFinalizedSnapshotTransportSessionV1) => Promise, + ): Promise; +} + +const SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1 = + '0x0000000000000000000000000000000000000000' as EvmAddressV1; +const SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1 = + `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; + +/** + * Own the complete endpoint/preflight/deadline/anchor lifecycle for one scoped + * finalized snapshot. Consumers receive only a pinned read session; transport + * internals never leak into the snapshot materialization layer. + */ +export function createStrictFinalizedSnapshotTransportV1( + config: StrictRpcConfigSnapshotV1, +): StrictFinalizedSnapshotTransportV1 { + const runEndpoint = createStrictFinalizedEndpointRunnerV1({ + mode: 'snapshot-preflight', + chainId: config.chainId, + endpoints: config.endpoints, + maxConcurrentPerChain: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, + totalDeadlineMs: CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, + attemptTimeoutMs: CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + }); + const run: StrictFinalizedSnapshotTransportV1 = async ( + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + consume: (session: StrictFinalizedSnapshotTransportSessionV1) => Promise, + ): Promise => runEndpoint({ + chainId: request.chainId, + signal: request.signal, + attempt: (endpoint, _attempt, deadline) => + preflightSnapshotEndpoint(config, endpoint, deadline.signal), + accept: (endpoint, preflight, totalDeadline) => executePinnedSnapshotScope( + config, + endpoint, + preflight, + request, + consume, + totalDeadline, + ), + }); + return Object.freeze(run); +} + +async function preflightSnapshotEndpoint( + config: StrictRpcConfigSnapshotV1, + endpoint: string, + signal: AbortSignal, +): Promise { + let requestId = 0; + const rpc = async (method: string, params: readonly unknown[]): Promise => { + requestId += 1; + return postStrictFinalizedJsonRpcV1( + endpoint, + requestId, + method, + params, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + signal, + ); + }; + const remoteChainId = parseStrictFinalizedChainIdV1( + await rpc('eth_chainId', Object.freeze([])), + ); + if (remoteChainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Configured snapshot endpoint reported chain ${remoteChainId}, expected ${config.chainId}`, + ); + } + const anchor = parseStrictFinalizedAnchorV1( + await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), + 'current finalized snapshot header', + ); + const blockReference = config.blockReferenceProfile === 'eip1898' + ? Object.freeze({ blockHash: anchor.blockHash, requireCanonical: true as const }) + : anchor.blockNumberQuantity; + await probeSnapshotReadProfile(rpc, blockReference); + if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { + await assertStrictFinalizedAnchorStableV1( + anchor, + async () => parseStrictFinalizedAnchorV1( + await rpc( + 'eth_getBlockByNumber', + Object.freeze([anchor.blockNumberQuantity, false]), + ), + 'post-preflight numbered header', + ), + 'Snapshot read-profile preflight did not preserve the resolved finalized anchor', + ); + } + return Object.freeze({ anchor, lastRequestId: requestId }); +} + +async function probeSnapshotReadProfile( + rpc: SnapshotRpcV1, + blockReference: unknown, +): Promise { + try { + const code = await rpc('eth_getCode', Object.freeze([ + SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, + blockReference, + ])); + if (!isCanonicalLowerHexBytesV1(code)) { + throw new Error('eth_getCode capability probe returned malformed bytes'); + } + const callResult = await rpc('eth_call', Object.freeze([ + Object.freeze({ + from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + to: SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, + data: '0x', + gas: SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1, + }), + blockReference, + ])); + if (!isCanonicalLowerHexBytesV1(callResult)) { + throw new Error('eth_call capability probe returned malformed bytes'); + } + } catch (cause) { + throw unavailable( + 'Configured snapshot endpoint cannot execute the required finalized read profile', + cause, + ); + } +} + +async function executePinnedSnapshotScope( + config: StrictRpcConfigSnapshotV1, + endpoint: string, + preflight: SnapshotEndpointPreflightV1, + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + consume: (session: StrictFinalizedSnapshotTransportSessionV1) => Promise, + totalDeadline: DeadlineScopeV1, +): Promise { + const rpc = createPinnedSnapshotRpcClient( + endpoint, + preflight.lastRequestId, + request, + totalDeadline, + ); + const session = Object.freeze({ + chainId: config.chainId, + blockNumber: preflight.anchor.blockNumber, + blockHash: preflight.anchor.blockHash, + read: ( + calls: readonly StrictCurrentFinalizedEvmReadCallV1[], + deployedTargets: Set, + ) => executeSnapshotBatch( + config, + preflight.anchor, + calls, + deployedTargets, + rpc, + totalDeadline, + ), + } satisfies StrictFinalizedSnapshotTransportSessionV1); + const result = await consume(session); + if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { + await assertStrictFinalizedAnchorStableV1( + preflight.anchor, + async () => parseStrictFinalizedAnchorV1( + await rpc( + 'eth_getBlockByNumber', + Object.freeze([preflight.anchor.blockNumberQuantity, false]), + ), + 'post-snapshot numbered header', + ), + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + ); + } + if (request.signal.aborted) { + throw cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + return result; +} + +function createPinnedSnapshotRpcClient( + endpoint: string, + lastRequestId: number, + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + totalDeadline: DeadlineScopeV1, +): SnapshotRpcV1 { + let requestId = lastRequestId; + return async (method: string, params: readonly unknown[]): Promise => { + if (request.signal.aborted) { + throw cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + const rpcDeadline = createDeadlineScopeV1( + totalDeadline.signal, + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + `current-finalized snapshot JSON-RPC ${method}`, + ); + requestId += 1; + try { + return await postStrictFinalizedJsonRpcV1( + endpoint, + requestId, + method, + params, + CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + rpcDeadline.signal, + ); + } catch (cause) { + if (request.signal.aborted) { + throw cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + if (rpcDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot JSON-RPC exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, + ); + } + if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; + throw unavailable('Current-finalized snapshot JSON-RPC failed closed', cause); + } finally { + rpcDeadline.close(); + } + }; +} + +async function executeSnapshotBatch( + config: StrictRpcConfigSnapshotV1, + anchor: FinalizedAnchorV1, + calls: readonly StrictCurrentFinalizedEvmReadCallV1[], + deployedTargets: Set, + rpc: SnapshotRpcV1, + totalDeadline: DeadlineScopeV1, +): Promise { + const executeCallsAt = (blockReference: unknown) => executeStrictFinalizedEvmBatchV1({ + calls, + blockReference, + deployedTargets, + rpc, + settle: (operations) => settleStrictFinalizedParallelBatchV1(operations, totalDeadline), + }); + + const batch = await executeStrictFinalizedAnchorPolicyV1({ + blockReferenceProfile: config.blockReferenceProfile, + anchor, + executeAtReference: executeCallsAt, + readPostAnchor: async () => parseStrictFinalizedAnchorV1( + await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + 'post-snapshot numbered header', + ), + anchorMismatchMessage: + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + }); + for (const target of batch.verifiedTargets) deployedTargets.add(target); + return batch.returnData; +} diff --git a/packages/chain/src/strict-current-finalized-evm-transport.ts b/packages/chain/src/strict-current-finalized-evm-transport.ts index f91f466722..fb44b22bcf 100644 --- a/packages/chain/src/strict-current-finalized-evm-transport.ts +++ b/packages/chain/src/strict-current-finalized-evm-transport.ts @@ -1,14 +1,7 @@ -// Package-internal transport core shared by one-shot and scoped finalized reads. -import { - assertCanonicalChainId, - type BlockNumberV1, - type ChainIdV1, - type Digest32V1, -} from '@origintrail-official/dkg-core'; - +// Public one-shot orchestration. Cohesive config, lifecycle, JSON-RPC, and +// snapshot transport concerns live in their own package-internal modules. import { CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, @@ -19,27 +12,39 @@ import { type CurrentFinalizedEvmChainAdapterV1, } from './current-finalized-evm-call.js'; import { - type StrictCurrentFinalizedEvmReadCallV1, type StrictCurrentFinalizedEvmReadRequestV1, type StrictCurrentFinalizedEvmReadResultV1, type StrictCurrentFinalizedEvmReadV1, } from './current-finalized-evm-read-model.js'; -import { - snapshotDenseDataArray, -} from './strict-local-data.js'; import { snapshotCurrentFinalizedEvmReadRequestV1 } from './current-finalized-evm-read-validation.js'; -import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; -import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; - -export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ - 'eip1898', - 'trusted-block-number-hash-sandwich', -] as const); - -export type CurrentFinalizedEvmBlockReferenceProfileV1 = - (typeof CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1)[number]; - +import { snapshotStrictCurrentFinalizedEvmConfigV1 } from './strict-current-finalized-evm-config.js'; +import { + readStrictCurrentFinalizedEvmRevertDataV1, + unavailable, +} from './strict-current-finalized-evm-errors.js'; +import { + createStrictFinalizedEndpointRunnerV1, + executeStrictFinalizedAnchorPolicyV1, + settleStrictFinalizedParallelBatchV1, +} from './strict-current-finalized-evm-lifecycle.js'; +import { + parseStrictFinalizedAnchorV1, + parseStrictFinalizedChainIdV1, + postStrictFinalizedJsonRpcV1, +} from './strict-current-finalized-evm-rpc-client.js'; +import type { + DeadlineScopeV1, + StrictCurrentFinalizedEvmRpcConfigV1, + StrictRpcConfigSnapshotV1, +} from './strict-current-finalized-evm-types.js'; + +export { + CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, + type CurrentFinalizedEvmBlockReferenceProfileV1, + type StrictCurrentFinalizedEvmRpcConfigV1, +} from './strict-current-finalized-evm-types.js'; +export { readStrictCurrentFinalizedEvmRevertDataV1 }; export type { StrictCurrentFinalizedEvmReadCallV1, StrictCurrentFinalizedEvmReadRequestV1, @@ -47,100 +52,9 @@ export type { StrictCurrentFinalizedEvmReadV1, } from './current-finalized-evm-read-model.js'; -export interface StrictCurrentFinalizedEvmRpcConfigV1 { - /** Canonical decimal chain ID permanently bound to this adapter. */ - readonly chainId: ChainIdV1; - /** Trusted local configuration, in canonical failover order. */ - readonly endpoints: readonly string[]; - /** - * EIP-1898 is the default. The number/hash sandwich is an explicit trusted - * chain profile for deployments whose RPC endpoints cannot execute EIP-1898. - */ - readonly blockReferenceProfile?: CurrentFinalizedEvmBlockReferenceProfileV1; -} - -export interface StrictRpcConfigSnapshotV1 { - readonly chainId: ChainIdV1; - readonly endpoints: readonly string[]; - readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; -} - -export interface FinalizedAnchorV1 { - readonly blockNumber: BlockNumberV1; - readonly blockNumberQuantity: string; - readonly blockHash: Digest32V1; -} - -export interface DeadlineScope { - readonly signal: AbortSignal; - readonly timedOut: () => boolean; - readonly close: () => void; -} - -export type StrictFinalizedEndpointRunnerModeV1 = 'read' | 'snapshot-preflight'; - -export interface StrictFinalizedEndpointRunnerProfileV1 { - readonly mode: StrictFinalizedEndpointRunnerModeV1; - readonly chainId: ChainIdV1; - readonly endpoints: readonly string[]; - readonly maxConcurrentPerChain: number; - readonly totalDeadlineMs: number; - readonly attemptTimeoutMs: number; -} - -export interface StrictFinalizedEndpointRunInputV1 { - readonly chainId: ChainIdV1; - readonly signal: AbortSignal; - readonly attempt: ( - endpoint: string, - attempt: number, - deadline: DeadlineScope, - ) => Promise; - /** Runs once, outside the retry catch. Snapshot consumers are never replayed. */ - readonly accept: ( - endpoint: string, - attemptResult: AttemptResult, - totalDeadline: DeadlineScope, - ) => Promise; -} - -export interface StrictFinalizedEndpointRunnerV1 { - ( - input: StrictFinalizedEndpointRunInputV1, - ): Promise; -} - -interface RpcErrorEnvelopeV1 { - readonly code: number; - readonly message: string; - readonly data?: string; -} - -const CONFIG_REQUIRED_KEYS = Object.freeze(['chainId', 'endpoints'] as const); -const CONFIG_OPTIONAL_KEYS = Object.freeze(['blockReferenceProfile'] as const); -const CANONICAL_LOWER_QUANTITY = /^0x(?:0|[1-9a-f][0-9a-f]*)$/; -const CANONICAL_DIGEST_32 = /^0x[0-9a-f]{64}$/; -const MAX_U64 = 18_446_744_073_709_551_615n; -const MAX_U256 = - 115_792_089_237_316_195_423_570_985_008_687_907_853_269_984_665_640_564_039_457_584_007_913_129_639_935n; -const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); -const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); -const PRE_DEADLINE_TERMINAL_FAILURES_V1 = new WeakSet(); - -/** Package-internal evidence available only for errors minted by this transport. */ -export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { - return error instanceof CurrentFinalizedEvmCallErrorV1 - ? AUTHENTICATED_REVERT_DATA_V1.get(error) - : undefined; -} - /** * Build one strict raw-JSON-RPC adapter from trusted local chain configuration. - * - * This transport intentionally bypasses JsonRpcProvider, RpcFailoverClient, - * and generic timeout wrappers: native fetch gives this lane a streamed - * pre-parse body cap and an AbortSignal that cancels the actual HTTP I/O. POST - * requests are never retried, redirected, reordered, or made sticky. + * Native fetch preserves the streamed body cap and real HTTP cancellation. */ export function createStrictCurrentFinalizedEvmChainAdapterV1( input: StrictCurrentFinalizedEvmRpcConfigV1, @@ -173,17 +87,11 @@ export function createStrictCurrentFinalizedEvmChainAdapterV1( return Object.freeze(adapter); } -/** - * Build a bounded, non-queueing same-finalized-anchor read primitive. - * - * Calls, destinations, and configured endpoints are trusted local runtime - * inputs. The primitive still snapshots them strictly, fixes gas/deadline/body - * caps, and never accepts a block selector from its caller. - */ +/** Build a bounded, non-queueing same-finalized-anchor read primitive. */ export function createStrictCurrentFinalizedEvmReadV1( input: StrictCurrentFinalizedEvmRpcConfigV1, ): StrictCurrentFinalizedEvmReadV1 { - const config = snapshotConfig(input); + const config = snapshotStrictCurrentFinalizedEvmConfigV1(input); const runEndpoint = createStrictFinalizedEndpointRunnerV1({ mode: 'read', chainId: config.chainId, @@ -207,199 +115,17 @@ export function createStrictCurrentFinalizedEvmReadV1( return Object.freeze(read); } -/** - * Canonical non-queueing endpoint lifecycle shared by one-shot reads and - * snapshot preflight. Only `attempt` participates in failover; `accept` runs - * once after the attempt deadline is closed and therefore cannot replay a - * snapshot consumer. - */ -export function createStrictFinalizedEndpointRunnerV1( - profile: StrictFinalizedEndpointRunnerProfileV1, -): StrictFinalizedEndpointRunnerV1 { - const messages = endpointRunnerMessages(profile.mode, profile.chainId); - const admission = createNonqueueingAdmissionGateV1( - profile.maxConcurrentPerChain, - ); - const run: StrictFinalizedEndpointRunnerV1 = async ( - input: StrictFinalizedEndpointRunInputV1, - ): Promise => { - if (input.chainId !== profile.chainId) { - throw new CurrentFinalizedEvmCallErrorV1( - 'chain-mismatch', - messages.chainMismatch(input.chainId), - ); - } - if (input.signal.aborted) { - throw cancelled(messages.cancelledBeforeAdmission); - } - return admission.run(input.chainId, async () => { - const totalDeadline = createDeadlineScope( - input.signal, - profile.totalDeadlineMs, - messages.totalDeadlineLabel, - ); - let lastRetryableFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - try { - for (let index = 0; index < profile.endpoints.length; index += 1) { - const attemptNumber = index + 1; - const attemptDeadline = createDeadlineScope( - totalDeadline.signal, - profile.attemptTimeoutMs, - messages.attemptDeadlineLabel(attemptNumber), - ); - let attemptResult!: AttemptResult; - let accepted = false; - try { - attemptResult = await input.attempt( - profile.endpoints[index]!, - attemptNumber, - attemptDeadline, - ); - if ( - input.signal.aborted - || totalDeadline.timedOut() - || attemptDeadline.timedOut() - ) { - throw classifyEndpointAttemptFailureV1( - new Error('Endpoint attempt completed after its lifecycle ended'), - input.signal, - totalDeadline, - attemptDeadline, - profile, - ); - } - accepted = true; - } catch (cause) { - const failure = classifyEndpointAttemptFailureV1( - cause, - input.signal, - totalDeadline, - attemptDeadline, - profile, - ); - if (isTerminalAttemptFailure(failure)) throw failure; - lastRetryableFailure = failure; - } finally { - attemptDeadline.close(); - } - if (accepted) { - return input.accept( - profile.endpoints[index]!, - attemptResult, - totalDeadline, - ); - } - } - throw classifyEndpointAttemptFailureV1( - lastRetryableFailure ?? unavailable(messages.noEndpoint), - input.signal, - totalDeadline, - null, - profile, - ); - } finally { - totalDeadline.close(); - } - }, (active) => new CurrentFinalizedEvmCallErrorV1( - 'concurrency-saturated', - messages.saturated(active), - )); - }; - return Object.freeze(run); -} - -function classifyEndpointAttemptFailureV1( - cause: unknown, - callerSignal: AbortSignal, - totalDeadline: DeadlineScope, - attemptDeadline: DeadlineScope | null, - profile: StrictFinalizedEndpointRunnerProfileV1, -): CurrentFinalizedEvmCallErrorV1 { - const messages = endpointRunnerMessages(profile.mode, profile.chainId); - if (callerSignal.aborted) return cancelled(messages.cancelled); - if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 - && PRE_DEADLINE_TERMINAL_FAILURES_V1.has(cause) - ) { - return cause; - } - if (totalDeadline.timedOut()) { - return timedOut(messages.totalDeadline(profile.totalDeadlineMs)); - } - if (attemptDeadline?.timedOut()) { - return timedOut(messages.attemptDeadline(profile.attemptTimeoutMs)); - } - if (cause instanceof CurrentFinalizedEvmCallErrorV1) return cause; - return unavailable(messages.attemptFailure, cause); -} - -interface StrictFinalizedEndpointRunnerMessagesV1 { - readonly chainMismatch: (requested: ChainIdV1) => string; - readonly cancelledBeforeAdmission: string; - readonly cancelled: string; - readonly totalDeadlineLabel: string; - readonly totalDeadline: (timeoutMs: number) => string; - readonly attemptDeadlineLabel: (attempt: number) => string; - readonly attemptDeadline: (timeoutMs: number) => string; - readonly attemptFailure: string; - readonly noEndpoint: string; - readonly saturated: (active: number) => string; -} - -function endpointRunnerMessages( - mode: StrictFinalizedEndpointRunnerModeV1, - chainId: ChainIdV1, -): StrictFinalizedEndpointRunnerMessagesV1 { - if (mode === 'snapshot-preflight') { - return Object.freeze({ - chainMismatch: (requested: ChainIdV1) => - `Snapshot adapter is configured for chain ${chainId}, not ${requested}`, - cancelledBeforeAdmission: - 'Current-finalized snapshot was cancelled before transport admission', - cancelled: 'Current-finalized snapshot was cancelled', - totalDeadlineLabel: 'current-finalized snapshot total deadline', - totalDeadline: (timeoutMs: number) => - `Current-finalized snapshot deadline exceeded ${timeoutMs}ms`, - attemptDeadlineLabel: (attempt: number) => - `current-finalized snapshot preflight ${attempt}`, - attemptDeadline: (timeoutMs: number) => - `Current-finalized snapshot preflight exceeded ${timeoutMs}ms`, - attemptFailure: 'Current-finalized snapshot preflight failed closed', - noEndpoint: 'No configured endpoint completed finalized snapshot preflight', - saturated: (active: number) => - `Chain ${chainId} already has ${active} finalized snapshot in flight`, - }); - } - return Object.freeze({ - chainMismatch: (requested: ChainIdV1) => - `Adapter is configured for chain ${chainId}, not ${requested}`, - cancelledBeforeAdmission: - 'Current-finalized EVM call was cancelled before transport admission', - cancelled: 'Current-finalized EVM call was cancelled', - totalDeadlineLabel: 'current-finalized total deadline', - totalDeadline: (timeoutMs: number) => - `Current-finalized total deadline exceeded ${timeoutMs}ms`, - attemptDeadlineLabel: (attempt: number) => `current-finalized endpoint attempt ${attempt}`, - attemptDeadline: (timeoutMs: number) => - `Current-finalized endpoint attempt exceeded ${timeoutMs}ms`, - attemptFailure: 'Current-finalized endpoint attempt failed closed', - noEndpoint: 'No configured current-finalized endpoint succeeded', - saturated: (active: number) => - `Chain ${chainId} already has ${active} finalized reads in flight`, - }); -} - async function executeEndpointAttempt( config: StrictRpcConfigSnapshotV1, endpoint: string, request: StrictCurrentFinalizedEvmReadRequestV1, - attemptDeadline: DeadlineScope, + attemptDeadline: DeadlineScopeV1, ): Promise { const { signal } = attemptDeadline; let requestId = 0; const rpc = async (method: string, params: readonly unknown[]): Promise => { requestId += 1; - return postJsonRpc( + return postStrictFinalizedJsonRpcV1( endpoint, requestId, method, @@ -409,7 +135,9 @@ async function executeEndpointAttempt( ); }; - const remoteChainId = parseChainId(await rpc('eth_chainId', Object.freeze([]))); + const remoteChainId = parseStrictFinalizedChainIdV1( + await rpc('eth_chainId', Object.freeze([])), + ); if (remoteChainId !== config.chainId) { throw new CurrentFinalizedEvmCallErrorV1( 'chain-mismatch', @@ -417,7 +145,7 @@ async function executeEndpointAttempt( ); } - const anchor = parseFinalizedAnchor( + const anchor = parseStrictFinalizedAnchorV1( await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), 'current finalized header', ); @@ -426,7 +154,10 @@ async function executeEndpointAttempt( calls: request.calls, blockReference, rpc, - settle: (operations) => settleParallelBatch(operations, attemptDeadline), + settle: (operations) => settleStrictFinalizedParallelBatchV1( + operations, + attemptDeadline, + ), }); return batch.returnData; }; @@ -435,8 +166,11 @@ async function executeEndpointAttempt( blockReferenceProfile: config.blockReferenceProfile, anchor, executeAtReference: executeCallsAt, - readPostAnchor: async () => parseFinalizedAnchor( - await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + readPostAnchor: async () => parseStrictFinalizedAnchorV1( + await rpc( + 'eth_getBlockByNumber', + Object.freeze([anchor.blockNumberQuantity, false]), + ), 'post-call numbered header', ), anchorMismatchMessage: @@ -450,511 +184,3 @@ async function executeEndpointAttempt( returnData, }); } - -/** Inputs for the shared EIP-1898 or authenticated-numbered-anchor policy. */ -export interface StrictFinalizedAnchorPolicyOptionsV1 { - readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; - readonly anchor: FinalizedAnchorV1; - readonly executeAtReference: (blockReference: unknown) => Promise; - readonly readPostAnchor: () => Promise; - readonly anchorMismatchMessage: string; -} - -/** Canonical EIP-1898 / authenticated-numbered-anchor execution policy. */ -export async function executeStrictFinalizedAnchorPolicyV1( - options: StrictFinalizedAnchorPolicyOptionsV1, -): Promise { - if (options.blockReferenceProfile === 'eip1898') { - return options.executeAtReference(Object.freeze({ - blockHash: options.anchor.blockHash, - requireCanonical: true as const, - })); - } - - // Number-selected evidence is not deterministic until the same endpoint - // closes the hash sandwich. Delay every anchor-dependent invalidity until - // that proof succeeds so neither callers nor caches can observe false state. - let provisionalExecution: - | Readonly<{ readonly ok: true; readonly value: T }> - | Readonly<{ readonly ok: false; readonly failure: CurrentFinalizedEvmCallErrorV1 }>; - try { - provisionalExecution = Object.freeze({ - ok: true, - value: await options.executeAtReference(options.anchor.blockNumberQuantity), - }); - } catch (cause) { - if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 - && ( - cause.code === 'no-code' - || cause.code === 'revert' - || cause.code === 'malformed-return' - || isAnchorDependentResourceLimit(cause) - ) - ) { - provisionalExecution = Object.freeze({ ok: false, failure: cause }); - } else { - throw cause; - } - } - await assertStrictFinalizedAnchorStableV1( - options.anchor, - options.readPostAnchor, - options.anchorMismatchMessage, - ); - if (!provisionalExecution.ok) throw provisionalExecution.failure; - return provisionalExecution.value; -} - -export async function assertStrictFinalizedAnchorStableV1( - anchor: FinalizedAnchorV1, - readPostAnchor: () => Promise, - mismatchMessage: string, -): Promise { - const postAnchor = await readPostAnchor(); - if ( - postAnchor.blockNumber !== anchor.blockNumber - || postAnchor.blockHash !== anchor.blockHash - ) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - mismatchMessage, - ); - } -} - -/** - * Execute one bounded phase concurrently but retain the permit until every - * started operation settles. This prevents an early rejection from leaving a - * sibling fetch alive after the finalized-read concurrency slot is released. - */ -export async function settleParallelBatch( - operations: readonly Promise[], - attemptDeadline: DeadlineScope, -): Promise { - let firstFailure: unknown; - let hasFailure = false; - let firstPreDeadlineTerminalFailure: CurrentFinalizedEvmCallErrorV1 | undefined; - const tracked = operations.map(async (operation) => { - try { - return await operation; - } catch (cause) { - if (!hasFailure) { - hasFailure = true; - firstFailure = cause; - } - if ( - firstPreDeadlineTerminalFailure === undefined - && cause instanceof CurrentFinalizedEvmCallErrorV1 - && isTerminalAttemptFailure(cause) - && !attemptDeadline.timedOut() - ) { - firstPreDeadlineTerminalFailure = cause; - PRE_DEADLINE_TERMINAL_FAILURES_V1.add(cause); - } - throw cause; - } - }); - const settled = await Promise.allSettled(tracked); - if (firstPreDeadlineTerminalFailure !== undefined) { - throw firstPreDeadlineTerminalFailure; - } - if (hasFailure) throw firstFailure; - const values: T[] = []; - for (let index = 0; index < settled.length; index += 1) { - const result = settled[index]!; - if (result.status === 'rejected') { - throw unavailable('Parallel finalized-read operation failed without a recorded cause'); - } - values.push(result.value); - } - return Object.freeze(values); -} - -export async function postJsonRpc( - endpoint: string, - id: number, - method: string, - params: readonly unknown[], - maxResponseBytes: number, - signal: AbortSignal, -): Promise { - let response: Response; - try { - response = await fetch(endpoint, { - method: 'POST', - headers: Object.freeze({ - accept: 'application/json', - 'content-type': 'application/json', - }), - body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), - redirect: 'error', - signal, - }); - } catch (cause) { - if (signal.aborted) throw cause; - throw unavailable(`JSON-RPC ${method} transport failed`, cause); - } - - const body = await readResponseBodyBounded(response, maxResponseBytes); - if (!response.ok) { - // An HTTP intermediary/provider failure is transport availability, even if - // its untrusted body happens to mimic a deterministic JSON-RPC revert. Only - // a successful JSON-RPC transport response may select an invalidity code. - throw unavailable(`JSON-RPC ${method} returned HTTP ${response.status}`); - } - - let parsed: unknown; - try { - parsed = JSON.parse(body) as unknown; - } catch (cause) { - throw unavailable(`JSON-RPC ${method} returned malformed JSON`, cause); - } - if (!isPlainRecord(parsed) || parsed.jsonrpc !== '2.0' || parsed.id !== id) { - throw unavailable(`JSON-RPC ${method} returned a mismatched response envelope`); - } - const hasResult = Object.prototype.hasOwnProperty.call(parsed, 'result'); - const hasError = Object.prototype.hasOwnProperty.call(parsed, 'error'); - if (hasResult === hasError) { - throw unavailable(`JSON-RPC ${method} response must contain exactly one of result or error`); - } - if (hasError) { - const error = parseRpcError(parsed.error); - if (error === undefined) throw unavailable(`JSON-RPC ${method} returned a malformed error`); - throw classifyJsonRpcError(method, error); - } - return parsed.result; -} - -async function readResponseBodyBounded(response: Response, maxBytes: number): Promise { - const contentLength = response.headers.get('content-length'); - if (contentLength !== null && /^\d+$/.test(contentLength)) { - const declared = BigInt(contentLength); - if (declared > BigInt(maxBytes)) { - await response.body?.cancel().catch(() => undefined); - throw resourceLimited( - `Raw JSON-RPC response declared ${declared.toString()} bytes, limit ${maxBytes}`, - ); - } - } - - if (response.body === null) return ''; - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > maxBytes) { - await reader.cancel().catch(() => undefined); - throw resourceLimited(`Raw JSON-RPC response exceeded ${maxBytes} bytes before parsing`); - } - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - try { - return new TextDecoder('utf-8', { fatal: true }).decode(bytes); - } catch (cause) { - throw unavailable('JSON-RPC response body is not valid UTF-8', cause); - } -} - -export function parseChainId(input: unknown): ChainIdV1 { - let parsed: bigint; - try { - parsed = parseCanonicalQuantity(input, MAX_U256); - } catch (cause) { - throw unavailable('eth_chainId returned a malformed chain ID', cause); - } - return parsed.toString(10) as ChainIdV1; -} - -export function parseFinalizedAnchor(input: unknown, label: string): FinalizedAnchorV1 { - if (input === null) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `${label} is unavailable`, - ); - } - if (!isPlainRecord(input)) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `${label} is malformed`, - ); - } - - let blockNumber: bigint; - try { - blockNumber = parseCanonicalQuantity(input.number, MAX_U64); - } catch (cause) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `${label} has a malformed block number`, - { cause }, - ); - } - if (typeof input.hash !== 'string' || !CANONICAL_DIGEST_32.test(input.hash)) { - throw new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `${label} has a malformed block hash`, - ); - } - return Object.freeze({ - blockNumber: blockNumber.toString(10) as BlockNumberV1, - blockNumberQuantity: input.number as string, - blockHash: input.hash as Digest32V1, - }); -} - -function parseCanonicalQuantity(input: unknown, maximum: bigint): bigint { - if (typeof input !== 'string' || !CANONICAL_LOWER_QUANTITY.test(input)) { - throw new Error('not a canonical lowercase JSON-RPC quantity'); - } - const parsed = BigInt(input); - if (parsed > maximum) throw new Error('JSON-RPC quantity is out of range'); - return parsed; -} - -function parseRpcError(input: unknown): RpcErrorEnvelopeV1 | undefined { - if ( - !isPlainRecord(input) - || typeof input.code !== 'number' - || !Number.isSafeInteger(input.code) - || typeof input.message !== 'string' - ) { - return undefined; - } - const data = isCanonicalLowerHexBytesV1(input.data) - ? input.data - : undefined; - return Object.freeze({ - code: input.code, - message: input.message, - ...(data === undefined ? {} : { data }), - }); -} - -function classifyJsonRpcError( - method: string, - error: RpcErrorEnvelopeV1, -): CurrentFinalizedEvmCallErrorV1 { - const message = error.message.toLowerCase(); - // Explicit revert evidence is deterministic even when a gateway decorates - // the message with gas-related text (for example, code 3 plus - // "execution reverted: out of gas"). A fixed-cap exhaustion that did not - // execute a REVERT remains a resource refusal below, but a proven REVERT - // must win so callers cannot misclassify invalid execution evidence as merely - // unsupported. - if (method === 'eth_call' && (error.code === 3 || message.includes('revert'))) { - return revertedAtFinalizedAnchor(error.data); - } - if ( - method === 'eth_call' - && ( - message.includes('out of gas') - || message.includes('gas limit') - || message.includes('gas required') - || message.includes('exceeds allowance') - || message.includes('intrinsic gas') - ) - ) { - return anchorDependentResourceLimited( - 'Finalized contract execution could not complete within the fixed gas cap', - ); - } - if (message.includes('timeout') || message.includes('timed out')) { - return timedOut(`JSON-RPC ${method} timed out`); - } - if ( - method === 'eth_getBlockByNumber' - || message.includes('header not found') - || message.includes('unknown block') - || message.includes('block not found') - || message.includes('canonical') - ) { - return new CurrentFinalizedEvmCallErrorV1( - 'finalized-state-unavailable', - `JSON-RPC ${method} could not prove the required finalized anchor`, - ); - } - return unavailable(`JSON-RPC ${method} failed with code ${error.code}`); -} - -export function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): boolean { - return error.code === 'unsupported-chain' - || error.code === 'resource-limit' - || error.code === 'revert' - || error.code === 'no-code' - || error.code === 'malformed-return'; -} - -export function createDeadlineScope( - parent: AbortSignal, - timeoutMs: number, - label: string, -): DeadlineScope { - const controller = new AbortController(); - let didTimeOut = false; - const expiresAt = performance.now() + timeoutMs; - const parentAbort = (): void => controller.abort(parent.reason); - if (parent.aborted) parentAbort(); - else parent.addEventListener('abort', parentAbort, { once: true }); - const timer = setTimeout(() => { - didTimeOut = true; - controller.abort(new Error(`${label} exceeded ${timeoutMs}ms`)); - }, timeoutMs); - timer.unref?.(); - return Object.freeze({ - signal: controller.signal, - timedOut: () => didTimeOut || performance.now() >= expiresAt, - close: () => { - clearTimeout(timer); - parent.removeEventListener('abort', parentAbort); - }, - }); -} - -export function snapshotConfig(input: StrictCurrentFinalizedEvmRpcConfigV1): StrictRpcConfigSnapshotV1 { - if (!isPlainRecord(input)) { - throw new TypeError('Strict current-finalized RPC config must be a plain data record'); - } - assertConfigDataProperties(input); - try { - assertCanonicalChainId(input.chainId, 'strict current-finalized chainId'); - } catch { - throw new TypeError('Strict current-finalized chainId must be canonical decimal u256'); - } - - const endpoints = snapshotNormalizedEndpoints(input.endpoints); - const blockReferenceProfile = input.blockReferenceProfile ?? 'eip1898'; - if (!CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1.includes(blockReferenceProfile)) { - throw new TypeError('Unsupported strict current-finalized block reference profile'); - } - return Object.freeze({ - chainId: input.chainId, - endpoints, - blockReferenceProfile, - }); -} - -function assertConfigDataProperties(input: Record): void { - const keys = Reflect.ownKeys(input); - if (keys.some((key) => typeof key !== 'string')) { - throw new TypeError('Strict current-finalized RPC config cannot contain symbol keys'); - } - const allowed = new Set([...CONFIG_REQUIRED_KEYS, ...CONFIG_OPTIONAL_KEYS]); - if ( - !CONFIG_REQUIRED_KEYS.every((key) => keys.includes(key)) - || (keys as string[]).some((key) => !allowed.has(key)) - ) { - throw new TypeError('Strict current-finalized RPC config has unknown or missing fields'); - } - for (const key of keys as string[]) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { - throw new TypeError('Strict current-finalized RPC config fields must be enumerable data properties'); - } - } -} - -function snapshotNormalizedEndpoints(input: unknown): readonly string[] { - const normalized: string[] = []; - try { - const endpoints = snapshotDenseDataArray(input, { - label: 'Strict current-finalized RPC endpoints', - minLength: 1, - }); - for (const entry of endpoints) { - const endpoint = normalizeEndpoint(entry); - if (!normalized.includes(endpoint)) normalized.push(endpoint); - } - } catch (cause) { - if (cause instanceof TypeError) throw cause; - throw new TypeError('Strict current-finalized endpoints must be a dense data-only array'); - } - if (normalized.length === 0 || normalized.length > CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { - throw new TypeError( - `Strict current-finalized RPC requires 1..${CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1} distinct endpoints`, - ); - } - return Object.freeze(normalized); -} - -function normalizeEndpoint(input: unknown): string { - if (typeof input !== 'string' || input.trim() === '') { - throw new TypeError('Strict current-finalized RPC endpoint must be a nonempty URL string'); - } - let url: URL; - try { - url = new URL(input.trim()); - } catch { - throw new TypeError('Strict current-finalized RPC endpoint must be an absolute URL'); - } - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.hash !== '') { - throw new TypeError('Strict current-finalized RPC endpoint must use HTTP(S) without a fragment'); - } - return url.href; -} - -function isPlainRecord(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -export function unavailable(message: string, cause?: unknown): CurrentFinalizedEvmCallErrorV1 { - return new CurrentFinalizedEvmCallErrorV1( - 'rpc-unavailable', - message, - cause === undefined ? undefined : { cause }, - ); -} - -export function timedOut(message: string): CurrentFinalizedEvmCallErrorV1 { - return new CurrentFinalizedEvmCallErrorV1('rpc-timeout', message); -} - -export function resourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { - return new CurrentFinalizedEvmCallErrorV1('resource-limit', message); -} - -function anchorDependentResourceLimited(message: string): CurrentFinalizedEvmCallErrorV1 { - const error = resourceLimited(message); - ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.add(error); - return error; -} - -function revertedAtFinalizedAnchor(data?: string): CurrentFinalizedEvmCallErrorV1 { - const error = new CurrentFinalizedEvmCallErrorV1( - 'revert', - 'Contract call reverted at the resolved finalized anchor', - ); - if (data !== undefined) AUTHENTICATED_REVERT_DATA_V1.set(error, data); - return error; -} - -export function isAnchorDependentResourceLimit( - error: CurrentFinalizedEvmCallErrorV1, -): boolean { - return error.code === 'resource-limit' - && ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1.has(error); -} - -export function cancelled(message: string): CurrentFinalizedEvmCallErrorV1 { - // Caller intent is authenticated by the verifier-owned AbortSignal. Keep - // the adapter error retryable so a foreign gateway cannot forge a cancelled - // disposition merely by throwing a public error code; the verifier observes - // its caller signal first and maps this exact path to `cancelled`. - return new CurrentFinalizedEvmCallErrorV1('rpc-unavailable', message); -} diff --git a/packages/chain/src/strict-current-finalized-evm-types.ts b/packages/chain/src/strict-current-finalized-evm-types.ts new file mode 100644 index 0000000000..8208cf0320 --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-types.ts @@ -0,0 +1,43 @@ +import type { + BlockNumberV1, + ChainIdV1, + Digest32V1, +} from '@origintrail-official/dkg-core'; + +export const CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1 = Object.freeze([ + 'eip1898', + 'trusted-block-number-hash-sandwich', +] as const); + +export type CurrentFinalizedEvmBlockReferenceProfileV1 = + (typeof CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1)[number]; + +export interface StrictCurrentFinalizedEvmRpcConfigV1 { + /** Canonical decimal chain ID permanently bound to this adapter. */ + readonly chainId: ChainIdV1; + /** Trusted local configuration, in canonical failover order. */ + readonly endpoints: readonly string[]; + /** + * EIP-1898 is the default. The number/hash sandwich is an explicit trusted + * chain profile for deployments whose RPC endpoints cannot execute EIP-1898. + */ + readonly blockReferenceProfile?: CurrentFinalizedEvmBlockReferenceProfileV1; +} + +export interface StrictRpcConfigSnapshotV1 { + readonly chainId: ChainIdV1; + readonly endpoints: readonly string[]; + readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; +} + +export interface FinalizedAnchorV1 { + readonly blockNumber: BlockNumberV1; + readonly blockNumberQuantity: string; + readonly blockHash: Digest32V1; +} + +export interface DeadlineScopeV1 { + readonly signal: AbortSignal; + readonly timedOut: () => boolean; + readonly close: () => void; +} diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index fc7ab968c4..808cfdb7e4 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -37,8 +37,8 @@ import { } from '../src/strict-current-finalized-evm-rpc.js'; import { executeStrictFinalizedAnchorPolicyV1, - parseFinalizedAnchor, -} from '../src/strict-current-finalized-evm-transport.js'; +} from '../src/strict-current-finalized-evm-lifecycle.js'; +import { parseStrictFinalizedAnchorV1 } from '../src/strict-current-finalized-evm-rpc-client.js'; import { createLoopbackJsonRpcTestHarness, sendJsonRpcError as sendError, @@ -85,7 +85,7 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { }); it('preserves an undefined generic result through an authenticated numbered anchor', async () => { - const anchor = parseFinalizedAnchor( + const anchor = parseStrictFinalizedAnchorV1( { number: '0x7b', hash: BLOCK_HASH }, 'generic void result test anchor', ); From 7142820879df0fc83f3d255f8605ce59f71325f8 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:30:40 +0200 Subject: [PATCH 231/292] refactor(chain): decouple finalized endpoint profiles --- ...ct-current-finalized-evm-batch-executor.ts | 63 +++------------ .../strict-current-finalized-evm-lifecycle.ts | 77 ++++-------------- ...t-current-finalized-evm-read-operations.ts | 78 ++++++++++++++++++ ...urrent-finalized-evm-snapshot-transport.ts | 79 +++++++++++++------ .../strict-current-finalized-evm-transport.ts | 28 ++++++- 5 files changed, 188 insertions(+), 137 deletions(-) create mode 100644 packages/chain/src/strict-current-finalized-evm-read-operations.ts diff --git a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts index 8dbdc8d5fd..eda1585d0f 100644 --- a/packages/chain/src/strict-current-finalized-evm-batch-executor.ts +++ b/packages/chain/src/strict-current-finalized-evm-batch-executor.ts @@ -1,14 +1,12 @@ import type { EvmAddressV1 } from '@origintrail-official/dkg-core'; import { - CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, - CurrentFinalizedEvmCallErrorV1, -} from './current-finalized-evm-read-profile.js'; + assertStrictFinalizedEvmCodeResultV1, + createStrictFinalizedEvmCallParamsV1, + createStrictFinalizedEvmCodeParamsV1, + parseStrictFinalizedEvmCallResultV1, +} from './strict-current-finalized-evm-read-operations.js'; import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; -import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; - -const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; export interface StrictFinalizedEvmBatchExecutorInputV1 { readonly calls: readonly StrictCurrentFinalizedEvmReadCallV1[]; @@ -35,21 +33,18 @@ export async function executeStrictFinalizedEvmBatchV1( const uncheckedTargets = [...new Set(input.calls.map(({ to }) => to))] .filter((to) => !input.deployedTargets?.has(to)); const verifiedTargets = await input.settle(uncheckedTargets.map(async (to) => { - assertDeployedCode(await input.rpc( + assertStrictFinalizedEvmCodeResultV1(await input.rpc( 'eth_getCode', - Object.freeze([to, input.blockReference]), - )); + createStrictFinalizedEvmCodeParamsV1(to, input.blockReference), + ), { allowNoCode: false }); return to; })); const returnData = await input.settle(input.calls.map(async (call) => { - const callObject = Object.freeze({ - from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - to: call.to, - data: call.data, - gas: RPC_CALL_GAS_QUANTITY, - }); - return parseContractReturn( - await input.rpc('eth_call', Object.freeze([callObject, input.blockReference])), + return parseStrictFinalizedEvmCallResultV1( + await input.rpc( + 'eth_call', + createStrictFinalizedEvmCallParamsV1(call, input.blockReference), + ), call.maxReturnBytes, ); })); @@ -58,35 +53,3 @@ export async function executeStrictFinalizedEvmBatchV1( verifiedTargets: Object.freeze([...verifiedTargets]), }); } - -function assertDeployedCode(input: unknown): void { - if (!isCanonicalLowerHexBytesV1(input)) { - throw new CurrentFinalizedEvmCallErrorV1( - 'rpc-unavailable', - 'eth_getCode returned malformed code bytes', - ); - } - if (input === '0x') { - throw new CurrentFinalizedEvmCallErrorV1( - 'no-code', - 'Finalized-read target has no deployed code at the resolved anchor', - ); - } -} - -function parseContractReturn(input: unknown, maxBytes: number): string { - if (!isCanonicalLowerHexBytesV1(input)) { - throw new CurrentFinalizedEvmCallErrorV1( - 'malformed-return', - 'Finalized eth_call returned malformed bytes', - ); - } - const byteLength = (input.length - 2) / 2; - if (byteLength > maxBytes) { - throw new CurrentFinalizedEvmCallErrorV1( - 'malformed-return', - `Finalized eth_call returned ${byteLength} bytes; limit ${maxBytes}`, - ); - } - return input; -} diff --git a/packages/chain/src/strict-current-finalized-evm-lifecycle.ts b/packages/chain/src/strict-current-finalized-evm-lifecycle.ts index c9348f7652..1059fcd03c 100644 --- a/packages/chain/src/strict-current-finalized-evm-lifecycle.ts +++ b/packages/chain/src/strict-current-finalized-evm-lifecycle.ts @@ -17,15 +17,26 @@ import type { FinalizedAnchorV1, } from './strict-current-finalized-evm-types.js'; -type StrictFinalizedEndpointRunnerModeV1 = 'read' | 'snapshot-preflight'; - export interface StrictFinalizedEndpointRunnerProfileV1 { - readonly mode: StrictFinalizedEndpointRunnerModeV1; readonly chainId: ChainIdV1; readonly endpoints: readonly string[]; readonly maxConcurrentPerChain: number; readonly totalDeadlineMs: number; readonly attemptTimeoutMs: number; + readonly messages: StrictFinalizedEndpointRunnerMessagesV1; +} + +export interface StrictFinalizedEndpointRunnerMessagesV1 { + readonly chainMismatch: (requested: ChainIdV1) => string; + readonly cancelledBeforeAdmission: string; + readonly cancelled: string; + readonly totalDeadlineLabel: string; + readonly totalDeadline: (timeoutMs: number) => string; + readonly attemptDeadlineLabel: (attempt: number) => string; + readonly attemptDeadline: (timeoutMs: number) => string; + readonly attemptFailure: string; + readonly noEndpoint: string; + readonly saturated: (active: number) => string; } interface StrictFinalizedEndpointRunInputV1 { @@ -59,7 +70,7 @@ export interface StrictFinalizedEndpointRunnerV1 { export function createStrictFinalizedEndpointRunnerV1( profile: StrictFinalizedEndpointRunnerProfileV1, ): StrictFinalizedEndpointRunnerV1 { - const messages = endpointRunnerMessages(profile.mode, profile.chainId); + const { messages } = profile; const admission = createNonqueueingAdmissionGateV1( profile.maxConcurrentPerChain, ); @@ -158,7 +169,7 @@ function classifyEndpointAttemptFailureV1( attemptDeadline: DeadlineScopeV1 | null, profile: StrictFinalizedEndpointRunnerProfileV1, ): CurrentFinalizedEvmCallErrorV1 { - const messages = endpointRunnerMessages(profile.mode, profile.chainId); + const { messages } = profile; if (callerSignal.aborted) return cancelled(messages.cancelled); if ( cause instanceof CurrentFinalizedEvmCallErrorV1 @@ -176,62 +187,6 @@ function classifyEndpointAttemptFailureV1( return unavailable(messages.attemptFailure, cause); } -interface StrictFinalizedEndpointRunnerMessagesV1 { - readonly chainMismatch: (requested: ChainIdV1) => string; - readonly cancelledBeforeAdmission: string; - readonly cancelled: string; - readonly totalDeadlineLabel: string; - readonly totalDeadline: (timeoutMs: number) => string; - readonly attemptDeadlineLabel: (attempt: number) => string; - readonly attemptDeadline: (timeoutMs: number) => string; - readonly attemptFailure: string; - readonly noEndpoint: string; - readonly saturated: (active: number) => string; -} - -function endpointRunnerMessages( - mode: StrictFinalizedEndpointRunnerModeV1, - chainId: ChainIdV1, -): StrictFinalizedEndpointRunnerMessagesV1 { - if (mode === 'snapshot-preflight') { - return Object.freeze({ - chainMismatch: (requested: ChainIdV1) => - `Snapshot adapter is configured for chain ${chainId}, not ${requested}`, - cancelledBeforeAdmission: - 'Current-finalized snapshot was cancelled before transport admission', - cancelled: 'Current-finalized snapshot was cancelled', - totalDeadlineLabel: 'current-finalized snapshot total deadline', - totalDeadline: (timeoutMs: number) => - `Current-finalized snapshot deadline exceeded ${timeoutMs}ms`, - attemptDeadlineLabel: (attempt: number) => - `current-finalized snapshot preflight ${attempt}`, - attemptDeadline: (timeoutMs: number) => - `Current-finalized snapshot preflight exceeded ${timeoutMs}ms`, - attemptFailure: 'Current-finalized snapshot preflight failed closed', - noEndpoint: 'No configured endpoint completed finalized snapshot preflight', - saturated: (active: number) => - `Chain ${chainId} already has ${active} finalized snapshot in flight`, - }); - } - return Object.freeze({ - chainMismatch: (requested: ChainIdV1) => - `Adapter is configured for chain ${chainId}, not ${requested}`, - cancelledBeforeAdmission: - 'Current-finalized EVM call was cancelled before transport admission', - cancelled: 'Current-finalized EVM call was cancelled', - totalDeadlineLabel: 'current-finalized total deadline', - totalDeadline: (timeoutMs: number) => - `Current-finalized total deadline exceeded ${timeoutMs}ms`, - attemptDeadlineLabel: (attempt: number) => `current-finalized endpoint attempt ${attempt}`, - attemptDeadline: (timeoutMs: number) => - `Current-finalized endpoint attempt exceeded ${timeoutMs}ms`, - attemptFailure: 'Current-finalized endpoint attempt failed closed', - noEndpoint: 'No configured current-finalized endpoint succeeded', - saturated: (active: number) => - `Chain ${chainId} already has ${active} finalized reads in flight`, - }); -} - /** Inputs for the shared EIP-1898 or authenticated-numbered-anchor policy. */ export interface StrictFinalizedAnchorPolicyOptionsV1 { readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; diff --git a/packages/chain/src/strict-current-finalized-evm-read-operations.ts b/packages/chain/src/strict-current-finalized-evm-read-operations.ts new file mode 100644 index 0000000000..3eb4a4618a --- /dev/null +++ b/packages/chain/src/strict-current-finalized-evm-read-operations.ts @@ -0,0 +1,78 @@ +import type { EvmAddressV1 } from '@origintrail-official/dkg-core'; + +import { + CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; +import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; + +const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; + +/** Canonical eth_getCode parameters for every strict finalized read surface. */ +export function createStrictFinalizedEvmCodeParamsV1( + target: EvmAddressV1, + blockReference: unknown, +): readonly unknown[] { + return Object.freeze([target, blockReference]); +} + +/** + * Validate canonical code bytes. Capability probes may tolerate an empty + * result, while real reads require code to be deployed at the pinned anchor. + */ +export function assertStrictFinalizedEvmCodeResultV1( + input: unknown, + options: Readonly<{ readonly allowNoCode: boolean }>, +): void { + if (!isCanonicalLowerHexBytesV1(input)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'rpc-unavailable', + 'eth_getCode returned malformed code bytes', + ); + } + if (!options.allowNoCode && input === '0x') { + throw new CurrentFinalizedEvmCallErrorV1( + 'no-code', + 'Finalized-read target has no deployed code at the resolved anchor', + ); + } +} + +/** Canonical eth_call object and pinned block reference for strict reads. */ +export function createStrictFinalizedEvmCallParamsV1( + call: StrictCurrentFinalizedEvmReadCallV1, + blockReference: unknown, +): readonly unknown[] { + return Object.freeze([ + Object.freeze({ + from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, + to: call.to, + data: call.data, + gas: RPC_CALL_GAS_QUANTITY, + }), + blockReference, + ]); +} + +/** Canonical strict eth_call return-byte validation and caller-owned cap. */ +export function parseStrictFinalizedEvmCallResultV1( + input: unknown, + maxBytes: number, +): string { + if (!isCanonicalLowerHexBytesV1(input)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + 'Finalized eth_call returned malformed bytes', + ); + } + const byteLength = (input.length - 2) / 2; + if (byteLength > maxBytes) { + throw new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + `Finalized eth_call returned ${byteLength} bytes; limit ${maxBytes}`, + ); + } + return input; +} diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts index 1242003736..53bb1474bf 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts @@ -7,9 +7,8 @@ import type { import { CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, - CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; @@ -30,18 +29,24 @@ import { createStrictFinalizedEndpointRunnerV1, executeStrictFinalizedAnchorPolicyV1, settleStrictFinalizedParallelBatchV1, + type StrictFinalizedEndpointRunnerMessagesV1, } from './strict-current-finalized-evm-lifecycle.js'; import { parseStrictFinalizedAnchorV1, parseStrictFinalizedChainIdV1, postStrictFinalizedJsonRpcV1, } from './strict-current-finalized-evm-rpc-client.js'; +import { + assertStrictFinalizedEvmCodeResultV1, + createStrictFinalizedEvmCallParamsV1, + createStrictFinalizedEvmCodeParamsV1, + parseStrictFinalizedEvmCallResultV1, +} from './strict-current-finalized-evm-read-operations.js'; import type { DeadlineScopeV1, FinalizedAnchorV1, StrictRpcConfigSnapshotV1, } from './strict-current-finalized-evm-types.js'; -import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; interface SnapshotEndpointPreflightV1 { readonly anchor: FinalizedAnchorV1; @@ -72,8 +77,6 @@ export interface StrictFinalizedSnapshotTransportV1 { const SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1 = '0x0000000000000000000000000000000000000000' as EvmAddressV1; -const SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1 = - `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; /** * Own the complete endpoint/preflight/deadline/anchor lifecycle for one scoped @@ -84,12 +87,12 @@ export function createStrictFinalizedSnapshotTransportV1( config: StrictRpcConfigSnapshotV1, ): StrictFinalizedSnapshotTransportV1 { const runEndpoint = createStrictFinalizedEndpointRunnerV1({ - mode: 'snapshot-preflight', chainId: config.chainId, endpoints: config.endpoints, maxConcurrentPerChain: CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CONCURRENT_PER_CHAIN_V1, totalDeadlineMs: CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, attemptTimeoutMs: CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + messages: createSnapshotEndpointRunnerMessagesV1(config.chainId), }); const run: StrictFinalizedSnapshotTransportV1 = async ( request: StrictCurrentFinalizedEvmSnapshotRequestV1, @@ -111,6 +114,29 @@ export function createStrictFinalizedSnapshotTransportV1( return Object.freeze(run); } +function createSnapshotEndpointRunnerMessagesV1( + chainId: ChainIdV1, +): StrictFinalizedEndpointRunnerMessagesV1 { + return Object.freeze({ + chainMismatch: (requested: ChainIdV1) => + `Snapshot adapter is configured for chain ${chainId}, not ${requested}`, + cancelledBeforeAdmission: + 'Current-finalized snapshot was cancelled before transport admission', + cancelled: 'Current-finalized snapshot was cancelled', + totalDeadlineLabel: 'current-finalized snapshot total deadline', + totalDeadline: (timeoutMs: number) => + `Current-finalized snapshot deadline exceeded ${timeoutMs}ms`, + attemptDeadlineLabel: (attempt: number) => + `current-finalized snapshot preflight ${attempt}`, + attemptDeadline: (timeoutMs: number) => + `Current-finalized snapshot preflight exceeded ${timeoutMs}ms`, + attemptFailure: 'Current-finalized snapshot preflight failed closed', + noEndpoint: 'No configured endpoint completed finalized snapshot preflight', + saturated: (active: number) => + `Chain ${chainId} already has ${active} finalized snapshot in flight`, + }); +} + async function preflightSnapshotEndpoint( config: StrictRpcConfigSnapshotV1, endpoint: string, @@ -166,25 +192,28 @@ async function probeSnapshotReadProfile( blockReference: unknown, ): Promise { try { - const code = await rpc('eth_getCode', Object.freeze([ - SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, - blockReference, - ])); - if (!isCanonicalLowerHexBytesV1(code)) { - throw new Error('eth_getCode capability probe returned malformed bytes'); - } - const callResult = await rpc('eth_call', Object.freeze([ - Object.freeze({ - from: CURRENT_FINALIZED_EVM_READ_CALL_FROM_V1, - to: SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, - data: '0x', - gas: SNAPSHOT_PREFLIGHT_PROBE_GAS_QUANTITY_V1, - }), - blockReference, - ])); - if (!isCanonicalLowerHexBytesV1(callResult)) { - throw new Error('eth_call capability probe returned malformed bytes'); - } + assertStrictFinalizedEvmCodeResultV1( + await rpc( + 'eth_getCode', + createStrictFinalizedEvmCodeParamsV1( + SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, + blockReference, + ), + ), + { allowNoCode: true }, + ); + const probeCall = Object.freeze({ + to: SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, + data: '0x', + maxReturnBytes: CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, + } satisfies StrictCurrentFinalizedEvmReadCallV1); + parseStrictFinalizedEvmCallResultV1( + await rpc( + 'eth_call', + createStrictFinalizedEvmCallParamsV1(probeCall, blockReference), + ), + probeCall.maxReturnBytes, + ); } catch (cause) { throw unavailable( 'Configured snapshot endpoint cannot execute the required finalized read profile', diff --git a/packages/chain/src/strict-current-finalized-evm-transport.ts b/packages/chain/src/strict-current-finalized-evm-transport.ts index fb44b22bcf..a2a45f3eb1 100644 --- a/packages/chain/src/strict-current-finalized-evm-transport.ts +++ b/packages/chain/src/strict-current-finalized-evm-transport.ts @@ -1,5 +1,7 @@ // Public one-shot orchestration. Cohesive config, lifecycle, JSON-RPC, and // snapshot transport concerns live in their own package-internal modules. +import type { ChainIdV1 } from '@origintrail-official/dkg-core'; + import { CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, @@ -27,6 +29,7 @@ import { createStrictFinalizedEndpointRunnerV1, executeStrictFinalizedAnchorPolicyV1, settleStrictFinalizedParallelBatchV1, + type StrictFinalizedEndpointRunnerMessagesV1, } from './strict-current-finalized-evm-lifecycle.js'; import { parseStrictFinalizedAnchorV1, @@ -93,12 +96,12 @@ export function createStrictCurrentFinalizedEvmReadV1( ): StrictCurrentFinalizedEvmReadV1 { const config = snapshotStrictCurrentFinalizedEvmConfigV1(input); const runEndpoint = createStrictFinalizedEndpointRunnerV1({ - mode: 'read', chainId: config.chainId, endpoints: config.endpoints, maxConcurrentPerChain: CURRENT_FINALIZED_EVM_READ_MAX_CONCURRENT_PER_CHAIN_V1, totalDeadlineMs: CURRENT_FINALIZED_EVM_READ_TOTAL_DEADLINE_MS_V1, attemptTimeoutMs: CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, + messages: createReadEndpointRunnerMessagesV1(config.chainId), }); const read: StrictCurrentFinalizedEvmReadV1 = async (inputRequest) => { @@ -115,6 +118,29 @@ export function createStrictCurrentFinalizedEvmReadV1( return Object.freeze(read); } +function createReadEndpointRunnerMessagesV1( + chainId: ChainIdV1, +): StrictFinalizedEndpointRunnerMessagesV1 { + return Object.freeze({ + chainMismatch: (requested: ChainIdV1) => + `Adapter is configured for chain ${chainId}, not ${requested}`, + cancelledBeforeAdmission: + 'Current-finalized EVM call was cancelled before transport admission', + cancelled: 'Current-finalized EVM call was cancelled', + totalDeadlineLabel: 'current-finalized total deadline', + totalDeadline: (timeoutMs: number) => + `Current-finalized total deadline exceeded ${timeoutMs}ms`, + attemptDeadlineLabel: (attempt: number) => + `current-finalized endpoint attempt ${attempt}`, + attemptDeadline: (timeoutMs: number) => + `Current-finalized endpoint attempt exceeded ${timeoutMs}ms`, + attemptFailure: 'Current-finalized endpoint attempt failed closed', + noEndpoint: 'No configured current-finalized endpoint succeeded', + saturated: (active: number) => + `Chain ${chainId} already has ${active} finalized reads in flight`, + }); +} + async function executeEndpointAttempt( config: StrictRpcConfigSnapshotV1, endpoint: string, From 273caf3a71681a31d35a6b41e03737b9e2008095 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:44:24 +0200 Subject: [PATCH 232/292] fix(chain): tighten finalized snapshot transport ownership --- .../src/strict-current-finalized-evm-read-operations.ts | 8 ++++++-- .../src/strict-current-finalized-evm-snapshot-rpc.ts | 5 +---- .../strict-current-finalized-evm-snapshot-transport.ts | 8 +++----- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-read-operations.ts b/packages/chain/src/strict-current-finalized-evm-read-operations.ts index 3eb4a4618a..c280a15973 100644 --- a/packages/chain/src/strict-current-finalized-evm-read-operations.ts +++ b/packages/chain/src/strict-current-finalized-evm-read-operations.ts @@ -5,11 +5,15 @@ import { CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1, CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; -import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; import { isCanonicalLowerHexBytesV1 } from './strict-finalized-evm-bytes.js'; const RPC_CALL_GAS_QUANTITY = `0x${CURRENT_FINALIZED_EVM_READ_GAS_LIMIT_V1.toString(16)}`; +interface StrictFinalizedEvmCallParamsInputV1 { + readonly to: EvmAddressV1; + readonly data: string; +} + /** Canonical eth_getCode parameters for every strict finalized read surface. */ export function createStrictFinalizedEvmCodeParamsV1( target: EvmAddressV1, @@ -42,7 +46,7 @@ export function assertStrictFinalizedEvmCodeResultV1( /** Canonical eth_call object and pinned block reference for strict reads. */ export function createStrictFinalizedEvmCallParamsV1( - call: StrictCurrentFinalizedEvmReadCallV1, + call: StrictFinalizedEvmCallParamsInputV1, blockReference: unknown, ): readonly unknown[] { return Object.freeze([ diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts index 59423dd201..bcc6a42498 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-rpc.ts @@ -1,5 +1,3 @@ -import type { EvmAddressV1 } from '@origintrail-official/dkg-core'; - import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-profile.js'; import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; import { @@ -51,7 +49,6 @@ function createSnapshotReadSession( transportSession: StrictFinalizedSnapshotTransportSessionV1, ): Readonly { const budget = createCurrentFinalizedEvmSnapshotBudgetV1(); - const deployedTargets = new Set(); let active = true; let inFlight: Promise | undefined; const read = Object.freeze(( @@ -79,7 +76,7 @@ function createSnapshotReadSession( 'Current-finalized snapshot exceeded its fixed scan budget', )); } - const operation = transportSession.read(calls, deployedTargets); + const operation = transportSession.read(calls); const clearInFlight = () => { if (inFlight === operation) inFlight = undefined; }; diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts index 53bb1474bf..1c0a8070bd 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts @@ -64,7 +64,6 @@ export interface StrictFinalizedSnapshotTransportSessionV1 { readonly blockHash: Digest32V1; readonly read: ( calls: readonly StrictCurrentFinalizedEvmReadCallV1[], - deployedTargets: Set, ) => Promise; } @@ -205,14 +204,13 @@ async function probeSnapshotReadProfile( const probeCall = Object.freeze({ to: SNAPSHOT_PREFLIGHT_PROBE_ADDRESS_V1, data: '0x', - maxReturnBytes: CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, - } satisfies StrictCurrentFinalizedEvmReadCallV1); + }); parseStrictFinalizedEvmCallResultV1( await rpc( 'eth_call', createStrictFinalizedEvmCallParamsV1(probeCall, blockReference), ), - probeCall.maxReturnBytes, + CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, ); } catch (cause) { throw unavailable( @@ -236,13 +234,13 @@ async function executePinnedSnapshotScope( request, totalDeadline, ); + const deployedTargets = new Set(); const session = Object.freeze({ chainId: config.chainId, blockNumber: preflight.anchor.blockNumber, blockHash: preflight.anchor.blockHash, read: ( calls: readonly StrictCurrentFinalizedEvmReadCallV1[], - deployedTargets: Set, ) => executeSnapshotBatch( config, preflight.anchor, From e941ea04d9c8acb45bd76a000772b2e715fcf449 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 17:12:42 +0200 Subject: [PATCH 233/292] fix(chain): retain finalized snapshot deadlines --- .../strict-current-finalized-evm-errors.ts | 13 --- .../strict-current-finalized-evm-lifecycle.ts | 101 ++++++++++++++---- ...urrent-finalized-evm-snapshot-transport.ts | 33 +++--- ...urrent-finalized-evm-snapshot.unit.test.ts | 95 +++++++++++++++- 4 files changed, 195 insertions(+), 47 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-errors.ts b/packages/chain/src/strict-current-finalized-evm-errors.ts index d38dfe5347..d218685e7b 100644 --- a/packages/chain/src/strict-current-finalized-evm-errors.ts +++ b/packages/chain/src/strict-current-finalized-evm-errors.ts @@ -2,7 +2,6 @@ import { CurrentFinalizedEvmCallErrorV1 } from './current-finalized-evm-read-pro const ANCHOR_DEPENDENT_RESOURCE_LIMITS_V1 = new WeakSet(); const AUTHENTICATED_REVERT_DATA_V1 = new WeakMap(); -const PRE_DEADLINE_TERMINAL_FAILURES_V1 = new WeakSet(); /** Package-internal evidence available only for errors minted by this transport. */ export function readStrictCurrentFinalizedEvmRevertDataV1(error: unknown): string | undefined { @@ -59,18 +58,6 @@ export function isTerminalAttemptFailure(error: CurrentFinalizedEvmCallErrorV1): || error.code === 'malformed-return'; } -export function markPreDeadlineTerminalFailure( - error: CurrentFinalizedEvmCallErrorV1, -): void { - PRE_DEADLINE_TERMINAL_FAILURES_V1.add(error); -} - -export function isPreDeadlineTerminalFailure( - error: CurrentFinalizedEvmCallErrorV1, -): boolean { - return PRE_DEADLINE_TERMINAL_FAILURES_V1.has(error); -} - export function cancelled(message: string): CurrentFinalizedEvmCallErrorV1 { // Caller intent is authenticated by the verifier-owned AbortSignal. Keep // the adapter error retryable so a foreign gateway cannot forge a cancelled diff --git a/packages/chain/src/strict-current-finalized-evm-lifecycle.ts b/packages/chain/src/strict-current-finalized-evm-lifecycle.ts index 1059fcd03c..6cb7b7c625 100644 --- a/packages/chain/src/strict-current-finalized-evm-lifecycle.ts +++ b/packages/chain/src/strict-current-finalized-evm-lifecycle.ts @@ -5,9 +5,7 @@ import { createNonqueueingAdmissionGateV1 } from './nonqueueing-admission.js'; import { cancelled, isAnchorDependentResourceLimit, - isPreDeadlineTerminalFailure, isTerminalAttemptFailure, - markPreDeadlineTerminalFailure, timedOut, unavailable, } from './strict-current-finalized-evm-errors.js'; @@ -137,11 +135,20 @@ export function createStrictFinalizedEndpointRunnerV1( attemptDeadline.close(); } if (accepted) { - return input.accept( - profile.endpoints[index]!, - attemptResult, - totalDeadline, - ); + try { + return await input.accept( + profile.endpoints[index]!, + attemptResult, + totalDeadline, + ); + } catch (cause) { + rethrowEndpointAcceptanceFailureV1( + cause, + input.signal, + totalDeadline, + profile, + ); + } } } throw classifyEndpointAttemptFailureV1( @@ -162,6 +169,22 @@ export function createStrictFinalizedEndpointRunnerV1( return Object.freeze(run); } +function rethrowEndpointAcceptanceFailureV1( + cause: unknown, + callerSignal: AbortSignal, + totalDeadline: DeadlineScopeV1, + profile: StrictFinalizedEndpointRunnerProfileV1, +): never { + if (callerSignal.aborted) throw cancelled(profile.messages.cancelled); + if (cause instanceof StrictFinalizedPreDeadlineTerminalBatchFailureV1) { + throw cause.failure; + } + if (totalDeadline.timedOut()) { + throw timedOut(profile.messages.totalDeadline(profile.totalDeadlineMs)); + } + throw cause; +} + function classifyEndpointAttemptFailureV1( cause: unknown, callerSignal: AbortSignal, @@ -171,11 +194,8 @@ function classifyEndpointAttemptFailureV1( ): CurrentFinalizedEvmCallErrorV1 { const { messages } = profile; if (callerSignal.aborted) return cancelled(messages.cancelled); - if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 - && isPreDeadlineTerminalFailure(cause) - ) { - return cause; + if (cause instanceof StrictFinalizedPreDeadlineTerminalBatchFailureV1) { + return cause.failure; } if (totalDeadline.timedOut()) { return timedOut(messages.totalDeadline(profile.totalDeadlineMs)); @@ -187,6 +207,22 @@ function classifyEndpointAttemptFailureV1( return unavailable(messages.attemptFailure, cause); } +/** + * Explicit package-internal batch outcome. A terminal failure observed before + * the batch deadline must retain priority while slower siblings drain, without + * mutating or side-registering the public error object. + */ +class StrictFinalizedPreDeadlineTerminalBatchFailureV1 extends Error { + public constructor( + public readonly failure: CurrentFinalizedEvmCallErrorV1, + ) { + super('A terminal finalized-read batch operation failed before its deadline', { + cause: failure, + }); + this.name = 'StrictFinalizedPreDeadlineTerminalBatchFailureV1'; + } +} + /** Inputs for the shared EIP-1898 or authenticated-numbered-anchor policy. */ export interface StrictFinalizedAnchorPolicyOptionsV1 { readonly blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1; @@ -209,23 +245,36 @@ export async function executeStrictFinalizedAnchorPolicyV1( let provisionalExecution: | Readonly<{ readonly ok: true; readonly value: T }> - | Readonly<{ readonly ok: false; readonly failure: CurrentFinalizedEvmCallErrorV1 }>; + | Readonly<{ + readonly ok: false; + readonly failure: + | CurrentFinalizedEvmCallErrorV1 + | StrictFinalizedPreDeadlineTerminalBatchFailureV1; + }>; try { provisionalExecution = Object.freeze({ ok: true, value: await options.executeAtReference(options.anchor.blockNumberQuantity), }); } catch (cause) { + const classifiedCause = cause instanceof StrictFinalizedPreDeadlineTerminalBatchFailureV1 + ? cause.failure + : cause; if ( - cause instanceof CurrentFinalizedEvmCallErrorV1 + classifiedCause instanceof CurrentFinalizedEvmCallErrorV1 && ( - cause.code === 'no-code' - || cause.code === 'revert' - || cause.code === 'malformed-return' - || isAnchorDependentResourceLimit(cause) + classifiedCause.code === 'no-code' + || classifiedCause.code === 'revert' + || classifiedCause.code === 'malformed-return' + || isAnchorDependentResourceLimit(classifiedCause) ) ) { - provisionalExecution = Object.freeze({ ok: false, failure: cause }); + provisionalExecution = Object.freeze({ + ok: false, + failure: cause instanceof StrictFinalizedPreDeadlineTerminalBatchFailureV1 + ? cause + : classifiedCause, + }); } else { throw cause; } @@ -239,6 +288,15 @@ export async function executeStrictFinalizedAnchorPolicyV1( return provisionalExecution.value; } +/** Package-internal boundary helper for scoped snapshot reads. */ +export function unwrapStrictFinalizedParallelBatchFailureV1( + cause: unknown, +): unknown { + return cause instanceof StrictFinalizedPreDeadlineTerminalBatchFailureV1 + ? cause.failure + : cause; +} + export async function assertStrictFinalizedAnchorStableV1( anchor: FinalizedAnchorV1, readPostAnchor: () => Promise, @@ -278,14 +336,15 @@ export async function settleStrictFinalizedParallelBatchV1( && !attemptDeadline.timedOut() ) { firstPreDeadlineTerminalFailure = cause; - markPreDeadlineTerminalFailure(cause); } throw cause; } }); const settled = await Promise.allSettled(tracked); if (firstPreDeadlineTerminalFailure !== undefined) { - throw firstPreDeadlineTerminalFailure; + throw new StrictFinalizedPreDeadlineTerminalBatchFailureV1( + firstPreDeadlineTerminalFailure, + ); } if (hasFailure) throw firstFailure; const values: T[] = []; diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts index 1c0a8070bd..90659a0b50 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts @@ -17,7 +17,10 @@ import { CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, type StrictCurrentFinalizedEvmSnapshotRequestV1, } from './current-finalized-evm-snapshot.js'; -import { executeStrictFinalizedEvmBatchV1 } from './strict-current-finalized-evm-batch-executor.js'; +import { + executeStrictFinalizedEvmBatchV1, + type StrictFinalizedEvmBatchExecutorResultV1, +} from './strict-current-finalized-evm-batch-executor.js'; import { cancelled, timedOut, @@ -30,6 +33,7 @@ import { executeStrictFinalizedAnchorPolicyV1, settleStrictFinalizedParallelBatchV1, type StrictFinalizedEndpointRunnerMessagesV1, + unwrapStrictFinalizedParallelBatchFailureV1, } from './strict-current-finalized-evm-lifecycle.js'; import { parseStrictFinalizedAnchorV1, @@ -344,17 +348,22 @@ async function executeSnapshotBatch( settle: (operations) => settleStrictFinalizedParallelBatchV1(operations, totalDeadline), }); - const batch = await executeStrictFinalizedAnchorPolicyV1({ - blockReferenceProfile: config.blockReferenceProfile, - anchor, - executeAtReference: executeCallsAt, - readPostAnchor: async () => parseStrictFinalizedAnchorV1( - await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), - 'post-snapshot numbered header', - ), - anchorMismatchMessage: - 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', - }); + let batch: Readonly; + try { + batch = await executeStrictFinalizedAnchorPolicyV1({ + blockReferenceProfile: config.blockReferenceProfile, + anchor, + executeAtReference: executeCallsAt, + readPostAnchor: async () => parseStrictFinalizedAnchorV1( + await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + 'post-snapshot numbered header', + ), + anchorMismatchMessage: + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + }); + } catch (cause) { + throw unwrapStrictFinalizedParallelBatchFailureV1(cause); + } for (const target of batch.verifiedTargets) deployedTargets.add(target); return batch.returnData; } diff --git a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts index 6b7e45ceac..5a0803cecd 100644 --- a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { type ChainIdV1, @@ -6,6 +6,7 @@ import { } from '@origintrail-official/dkg-core'; import { + CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1, CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, CURRENT_FINALIZED_EVM_READ_MAX_RETURN_BYTES_V1, } from '../src/current-finalized-evm-read-profile.js'; @@ -13,6 +14,7 @@ import { CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_DECLARED_RETURN_BYTES_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1, createCurrentFinalizedEvmSnapshotBudgetV1, type StrictCurrentFinalizedEvmSnapshotSessionV1, } from '../src/current-finalized-evm-snapshot.js'; @@ -30,6 +32,7 @@ import { const CHAIN_ID = '20430' as ChainIdV1; const CHAIN_QUANTITY = '0x4fce'; const TO = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const OTHER_TO = '0x2222222222222222222222222222222222222222' as EvmAddressV1; const PREFLIGHT_PROBE_TO = '0x0000000000000000000000000000000000000000'; const BLOCK_HASH = `0x${'22'.repeat(32)}`; const OTHER_BLOCK_HASH = `0x${'23'.repeat(32)}`; @@ -603,10 +606,96 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { await started.promise; controller.abort(new Error('caller stopped')); + await expect(Promise.race([ + operation.then(() => 'settled', () => 'settled'), + delay(500).then(() => 'still-pending'), + ])).resolves.toBe('settled'); await expect(operation).rejects.toMatchObject({ code: 'rpc-unavailable' }); await closed.promise; }); + it('aborts an in-flight RPC when the total snapshot deadline expires', async () => { + const consumerEntered = deferred(); + const started = deferred(); + const closed = deferred(); + const baseHandler = successfulHandler(); + const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { + if (rpcCall.method !== 'eth_call' || isPreflightProbe(rpcCall)) { + return baseHandler(rpcCall, response, rawRequest); + } + response.on('close', () => closed.resolve(undefined)); + started.resolve(undefined); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + try { + const operation = withSnapshot( + request(), + async (session) => { + consumerEntered.resolve(undefined); + await delay(CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1 - 1_000); + return session.read([call(FIRST_DATA)]); + }, + ); + const outcome = operation.then( + () => undefined, + (cause: unknown) => cause, + ); + await consumerEntered.promise; + await vi.advanceTimersByTimeAsync( + CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1 - 1_000, + ); + await started.promise; + await vi.advanceTimersByTimeAsync(1_000); + + expect(await outcome).toMatchObject({ code: 'rpc-timeout' }); + await closed.promise; + } finally { + vi.useRealTimers(); + } + }); + + it('preserves a terminal batch failure that precedes a sibling RPC deadline', async () => { + const siblingStarted = deferred(); + const siblingClosed = deferred(); + const baseHandler = successfulHandler(); + const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { + if (rpcCall.method !== 'eth_getCode' || rpcCall.params[0] === PREFLIGHT_PROBE_TO) { + return baseHandler(rpcCall, response, rawRequest); + } + if (rpcCall.params[0] === TO) { + sendJsonRpcResult(response, rpcCall, '0x'); + return; + } + response.on('close', () => siblingClosed.resolve(undefined)); + siblingStarted.resolve(undefined); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + try { + const operation = withSnapshot(request(), async (session) => session.read([ + call(FIRST_DATA), + Object.freeze({ to: OTHER_TO, data: SECOND_DATA, maxReturnBytes: 2 }), + ])); + const rejection = expect(operation).rejects.toMatchObject({ code: 'no-code' }); + await siblingStarted.promise; + await vi.advanceTimersByTimeAsync(CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1); + + await rejection; + await siblingClosed.promise; + } finally { + vi.useRealTimers(); + } + }); + it('rejects forged block selectors, hostile batches, and non-callable consumers pre-I/O', async () => { const server = await rpcHarness.start(successfulHandler()); const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ @@ -756,3 +845,7 @@ function deferred(): { }); return { promise, resolve }; } + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} From ec6d0a1adf43377ccdb802732ea33cefd0217e31 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 17:23:42 +0200 Subject: [PATCH 234/292] test(chain): close finalized snapshot RPC deadlines --- ...urrent-finalized-evm-snapshot-transport.ts | 38 ++++--- ...urrent-finalized-evm-snapshot.unit.test.ts | 99 ++++++++++++++++++- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts index 90659a0b50..2c734688d1 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts @@ -302,7 +302,7 @@ function createPinnedSnapshotRpcClient( ); requestId += 1; try { - return await postStrictFinalizedJsonRpcV1( + const result = await postStrictFinalizedJsonRpcV1( endpoint, requestId, method, @@ -310,20 +310,10 @@ function createPinnedSnapshotRpcClient( CURRENT_FINALIZED_EVM_READ_MAX_RPC_RESPONSE_BYTES_V1, rpcDeadline.signal, ); + assertPinnedSnapshotRpcLifecycleV1(request, totalDeadline, rpcDeadline); + return result; } catch (cause) { - if (request.signal.aborted) { - throw cancelled('Current-finalized snapshot was cancelled'); - } - if (totalDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, - ); - } - if (rpcDeadline.timedOut()) { - throw timedOut( - `Current-finalized snapshot JSON-RPC exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, - ); - } + assertPinnedSnapshotRpcLifecycleV1(request, totalDeadline, rpcDeadline); if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; throw unavailable('Current-finalized snapshot JSON-RPC failed closed', cause); } finally { @@ -332,6 +322,26 @@ function createPinnedSnapshotRpcClient( }; } +function assertPinnedSnapshotRpcLifecycleV1( + request: StrictCurrentFinalizedEvmSnapshotRequestV1, + totalDeadline: DeadlineScopeV1, + rpcDeadline: DeadlineScopeV1, +): void { + if (request.signal.aborted) { + throw cancelled('Current-finalized snapshot was cancelled'); + } + if (totalDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot deadline exceeded ${CURRENT_FINALIZED_EVM_SNAPSHOT_TOTAL_DEADLINE_MS_V1}ms`, + ); + } + if (rpcDeadline.timedOut()) { + throw timedOut( + `Current-finalized snapshot JSON-RPC exceeded ${CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1}ms`, + ); + } +} + async function executeSnapshotBatch( config: StrictRpcConfigSnapshotV1, anchor: FinalizedAnchorV1, diff --git a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts index 5a0803cecd..4c64b9f8d3 100644 --- a/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-snapshot.unit.test.ts @@ -21,7 +21,7 @@ import { import { type StrictCurrentFinalizedEvmReadCallV1, } from '../src/strict-current-finalized-evm-rpc.js'; -import { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from '../src/strict-current-finalized-evm-snapshot-factory.js'; +import { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from '../src/index.js'; import { createLoopbackJsonRpcTestHarness, sendJsonRpcError, @@ -659,6 +659,103 @@ describe('RFC-64 scoped current-finalized EVM snapshot', () => { } }); + it('aborts a hung dynamic RPC at its per-request deadline', async () => { + const started = deferred(); + const closed = deferred(); + const baseHandler = successfulHandler(); + const server = await rpcHarness.start(async (rpcCall, response, rawRequest) => { + if (rpcCall.method !== 'eth_call' || isPreflightProbe(rpcCall)) { + return baseHandler(rpcCall, response, rawRequest); + } + response.on('close', () => closed.resolve(undefined)); + started.resolve(undefined); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [server.url], + }); + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + try { + const operation = withSnapshot( + request(), + async (session) => session.read([call(FIRST_DATA)]), + ); + const outcome = operation.then( + () => undefined, + (cause: unknown) => cause, + ); + await started.promise; + await vi.advanceTimersByTimeAsync(CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1); + + expect(await outcome).toMatchObject({ code: 'rpc-timeout' }); + await closed.promise; + } finally { + vi.useRealTimers(); + } + }); + + it('rejects a successful dynamic RPC result delivered after its deadline', async () => { + const dynamicCallStarted = deferred(); + vi.stubGlobal('fetch', async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { + readonly id: number; + readonly method: string; + readonly params: readonly unknown[]; + }; + let result: unknown; + switch (body.method) { + case 'eth_chainId': + result = CHAIN_QUANTITY; + break; + case 'eth_getBlockByNumber': + result = { number: '0x7b', hash: BLOCK_HASH }; + break; + case 'eth_getCode': + result = '0x6000'; + break; + case 'eth_call': + if (isPreflightProbe({ params: body.params })) { + result = '0x'; + } else { + dynamicCallStarted.resolve(undefined); + await delay(CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1 + 500); + result = '0xaaaa'; + } + break; + default: + throw new Error(`Unexpected JSON-RPC method ${body.method}`); + } + return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + const withSnapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: ['https://snapshot-rpc.test'], + }); + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + try { + const operation = withSnapshot( + request(), + async (session) => session.read([call(FIRST_DATA)]), + ); + const outcome = operation.then( + () => undefined, + (cause: unknown) => cause, + ); + await dynamicCallStarted.promise; + await vi.advanceTimersByTimeAsync(CURRENT_FINALIZED_EVM_READ_ATTEMPT_TIMEOUT_MS_V1 + 500); + + expect(await outcome).toMatchObject({ code: 'rpc-timeout' }); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + it('preserves a terminal batch failure that precedes a sibling RPC deadline', async () => { const siblingStarted = deferred(); const siblingClosed = deferred(); From dffa0278577be6bdb5173f15d07d6081dc8b9024 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 17:37:52 +0200 Subject: [PATCH 235/292] refactor(chain): centralize finalized anchor policy --- .../strict-current-finalized-evm-lifecycle.ts | 55 +++++++++++++ ...urrent-finalized-evm-snapshot-transport.ts | 80 +++++++++---------- 2 files changed, 92 insertions(+), 43 deletions(-) diff --git a/packages/chain/src/strict-current-finalized-evm-lifecycle.ts b/packages/chain/src/strict-current-finalized-evm-lifecycle.ts index 6cb7b7c625..8eb0eb53d8 100644 --- a/packages/chain/src/strict-current-finalized-evm-lifecycle.ts +++ b/packages/chain/src/strict-current-finalized-evm-lifecycle.ts @@ -314,6 +314,61 @@ export async function assertStrictFinalizedAnchorStableV1( } } +/** + * One resolved anchor's complete block-reference policy. Snapshot transport + * owns phase orchestration; this capability owns every profile-specific + * decision for preflight probing, dynamic batch execution, and scope exit. + */ +export interface StrictFinalizedAnchorProfilePolicyV1 { + readonly anchor: FinalizedAnchorV1; + readonly blockReferenceForProbe: unknown; + readonly executeBatchAtAnchor: (options: Readonly<{ + executeAtReference: (blockReference: unknown) => Promise; + readPostAnchor: () => Promise; + anchorMismatchMessage: string; + }>) => Promise; + readonly assertScopeStillPinned: ( + readPostAnchor: () => Promise, + anchorMismatchMessage: string, + ) => Promise; +} + +export function createStrictFinalizedAnchorProfilePolicyV1( + blockReferenceProfile: CurrentFinalizedEvmBlockReferenceProfileV1, + anchor: FinalizedAnchorV1, +): StrictFinalizedAnchorProfilePolicyV1 { + const usesNumberedHashSandwich = + blockReferenceProfile === 'trusted-block-number-hash-sandwich'; + const blockReferenceForProbe = blockReferenceProfile === 'eip1898' + ? Object.freeze({ blockHash: anchor.blockHash, requireCanonical: true as const }) + : anchor.blockNumberQuantity; + const assertScopeStillPinned = async ( + readPostAnchor: () => Promise, + anchorMismatchMessage: string, + ): Promise => { + if (!usesNumberedHashSandwich) return; + await assertStrictFinalizedAnchorStableV1( + anchor, + readPostAnchor, + anchorMismatchMessage, + ); + }; + return Object.freeze({ + anchor, + blockReferenceForProbe, + executeBatchAtAnchor: (options: Readonly<{ + executeAtReference: (blockReference: unknown) => Promise; + readPostAnchor: () => Promise; + anchorMismatchMessage: string; + }>): Promise => executeStrictFinalizedAnchorPolicyV1({ + blockReferenceProfile, + anchor, + ...options, + }), + assertScopeStillPinned, + }); +} + export async function settleStrictFinalizedParallelBatchV1( operations: readonly Promise[], attemptDeadline: DeadlineScopeV1, diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts index 2c734688d1..78b5af5891 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-transport.ts @@ -27,11 +27,11 @@ import { unavailable, } from './strict-current-finalized-evm-errors.js'; import { - assertStrictFinalizedAnchorStableV1, createDeadlineScopeV1, + createStrictFinalizedAnchorProfilePolicyV1, createStrictFinalizedEndpointRunnerV1, - executeStrictFinalizedAnchorPolicyV1, settleStrictFinalizedParallelBatchV1, + type StrictFinalizedAnchorProfilePolicyV1, type StrictFinalizedEndpointRunnerMessagesV1, unwrapStrictFinalizedParallelBatchFailureV1, } from './strict-current-finalized-evm-lifecycle.js'; @@ -48,12 +48,11 @@ import { } from './strict-current-finalized-evm-read-operations.js'; import type { DeadlineScopeV1, - FinalizedAnchorV1, StrictRpcConfigSnapshotV1, } from './strict-current-finalized-evm-types.js'; interface SnapshotEndpointPreflightV1 { - readonly anchor: FinalizedAnchorV1; + readonly anchorPolicy: StrictFinalizedAnchorProfilePolicyV1; readonly lastRequestId: number; } @@ -170,24 +169,22 @@ async function preflightSnapshotEndpoint( await rpc('eth_getBlockByNumber', Object.freeze(['finalized', false])), 'current finalized snapshot header', ); - const blockReference = config.blockReferenceProfile === 'eip1898' - ? Object.freeze({ blockHash: anchor.blockHash, requireCanonical: true as const }) - : anchor.blockNumberQuantity; - await probeSnapshotReadProfile(rpc, blockReference); - if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { - await assertStrictFinalizedAnchorStableV1( - anchor, - async () => parseStrictFinalizedAnchorV1( - await rpc( - 'eth_getBlockByNumber', - Object.freeze([anchor.blockNumberQuantity, false]), - ), - 'post-preflight numbered header', + const anchorPolicy = createStrictFinalizedAnchorProfilePolicyV1( + config.blockReferenceProfile, + anchor, + ); + await probeSnapshotReadProfile(rpc, anchorPolicy.blockReferenceForProbe); + await anchorPolicy.assertScopeStillPinned( + async () => parseStrictFinalizedAnchorV1( + await rpc( + 'eth_getBlockByNumber', + Object.freeze([anchor.blockNumberQuantity, false]), ), - 'Snapshot read-profile preflight did not preserve the resolved finalized anchor', - ); - } - return Object.freeze({ anchor, lastRequestId: requestId }); + 'post-preflight numbered header', + ), + 'Snapshot read-profile preflight did not preserve the resolved finalized anchor', + ); + return Object.freeze({ anchorPolicy, lastRequestId: requestId }); } async function probeSnapshotReadProfile( @@ -239,15 +236,15 @@ async function executePinnedSnapshotScope( totalDeadline, ); const deployedTargets = new Set(); + const { anchorPolicy } = preflight; const session = Object.freeze({ chainId: config.chainId, - blockNumber: preflight.anchor.blockNumber, - blockHash: preflight.anchor.blockHash, + blockNumber: anchorPolicy.anchor.blockNumber, + blockHash: anchorPolicy.anchor.blockHash, read: ( calls: readonly StrictCurrentFinalizedEvmReadCallV1[], ) => executeSnapshotBatch( - config, - preflight.anchor, + anchorPolicy, calls, deployedTargets, rpc, @@ -255,19 +252,16 @@ async function executePinnedSnapshotScope( ), } satisfies StrictFinalizedSnapshotTransportSessionV1); const result = await consume(session); - if (config.blockReferenceProfile === 'trusted-block-number-hash-sandwich') { - await assertStrictFinalizedAnchorStableV1( - preflight.anchor, - async () => parseStrictFinalizedAnchorV1( - await rpc( - 'eth_getBlockByNumber', - Object.freeze([preflight.anchor.blockNumberQuantity, false]), - ), - 'post-snapshot numbered header', + await anchorPolicy.assertScopeStillPinned( + async () => parseStrictFinalizedAnchorV1( + await rpc( + 'eth_getBlockByNumber', + Object.freeze([anchorPolicy.anchor.blockNumberQuantity, false]), ), - 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', - ); - } + 'post-snapshot numbered header', + ), + 'Pinned snapshot hash sandwich did not preserve the resolved finalized anchor', + ); if (request.signal.aborted) { throw cancelled('Current-finalized snapshot was cancelled'); } @@ -343,8 +337,7 @@ function assertPinnedSnapshotRpcLifecycleV1( } async function executeSnapshotBatch( - config: StrictRpcConfigSnapshotV1, - anchor: FinalizedAnchorV1, + anchorPolicy: StrictFinalizedAnchorProfilePolicyV1, calls: readonly StrictCurrentFinalizedEvmReadCallV1[], deployedTargets: Set, rpc: SnapshotRpcV1, @@ -360,12 +353,13 @@ async function executeSnapshotBatch( let batch: Readonly; try { - batch = await executeStrictFinalizedAnchorPolicyV1({ - blockReferenceProfile: config.blockReferenceProfile, - anchor, + batch = await anchorPolicy.executeBatchAtAnchor({ executeAtReference: executeCallsAt, readPostAnchor: async () => parseStrictFinalizedAnchorV1( - await rpc('eth_getBlockByNumber', Object.freeze([anchor.blockNumberQuantity, false])), + await rpc( + 'eth_getBlockByNumber', + Object.freeze([anchorPolicy.anchor.blockNumberQuantity, false]), + ), 'post-snapshot numbered header', ), anchorMismatchMessage: From d2318556c20917ea1fc8683fa128c5eeb2145bf7 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 18:23:42 +0200 Subject: [PATCH 236/292] refactor(chain): keep snapshot factory on internal types --- .../chain/src/strict-current-finalized-evm-snapshot-factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts b/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts index a359a52411..17719208ff 100644 --- a/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts +++ b/packages/chain/src/strict-current-finalized-evm-snapshot-factory.ts @@ -1,5 +1,5 @@ import type { StrictCurrentFinalizedEvmSnapshotScopeV1 } from './current-finalized-evm-snapshot.js'; -import type { StrictCurrentFinalizedEvmRpcConfigV1 } from './strict-current-finalized-evm-rpc.js'; +import type { StrictCurrentFinalizedEvmRpcConfigV1 } from './strict-current-finalized-evm-types.js'; import { snapshotStrictCurrentFinalizedEvmConfigV1 } from './strict-current-finalized-evm-config.js'; import { createStrictFinalizedSnapshotRpcRuntimeV1 } from './strict-current-finalized-evm-snapshot-rpc.js'; From a760dc86719c367ef21712d59d4ae5eea2db2a0e Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 11:26:33 +0200 Subject: [PATCH 237/292] feat(chain): scan finalized VM ordinals --- .../chain/src/finalized-vm-chain-scanner.ts | 407 ++++++++++++++++++ packages/chain/src/index.ts | 9 + .../finalized-vm-chain-scanner.unit.test.ts | 314 ++++++++++++++ 3 files changed, 730 insertions(+) create mode 100644 packages/chain/src/finalized-vm-chain-scanner.ts create mode 100644 packages/chain/test/finalized-vm-chain-scanner.unit.test.ts diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts new file mode 100644 index 0000000000..2dda09116b --- /dev/null +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -0,0 +1,407 @@ +import { + assertCanonicalChainId, + assertCanonicalDecimalU256, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertNetworkIdV1, + type BlockNumberV1, + type ChainIdV1, + type DecimalU256V1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +import { loadAbi } from './evm-adapter-abi.js'; +import { + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import { + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, + type StrictCurrentFinalizedEvmSnapshotScopeV1, + type StrictCurrentFinalizedEvmSnapshotSessionV1, +} from './current-finalized-evm-snapshot.js'; +import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; +import { + assertCanonicalNonzeroEvmAddress, + isAbortSignal, + snapshotExactDataRecord, +} from './strict-local-data.js'; + +const CONTEXT_GRAPH_STORAGE_INTERFACE = new ethers.Interface(loadAbi('ContextGraphStorage')); +const KNOWLEDGE_ASSET_STORAGE_INTERFACE = new ethers.Interface(loadAbi('DKGKnowledgeAssets')); + +const CONFIG_KEYS = Object.freeze([ + 'chainId', + 'contextGraphStorageAddress', + 'knowledgeAssetStorageAddress', + 'networkId', + 'snapshot', +] as const); +const REQUEST_KEYS = Object.freeze(['contextGraphId', 'signal'] as const); +const UINT256_RETURN_BYTES = 32; +const UPDATE_CONTEXT_RETURN_BYTES = 7 * 32; +const PACKED_KA_NUMBER_BITS = 96n; +const PACKED_KA_NUMBER_MASK = (1n << PACKED_KA_NUMBER_BITS) - 1n; + +/** + * One count read plus one indexed-ID read and two assertion reads per row. + * The exact grouping below consumes one count batch, ceil(N/4) ID batches, + * and ceil(2N/4) assertion batches. 1,364 is the largest N that stays inside + * both the pinned-snapshot batch and call budgets. + */ +export const FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 = Math.min( + Math.floor((CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 - 1) / 3), + 1_364, +); + +export interface FinalizedVmChainScannerConfigV1 { + readonly networkId: NetworkIdV1; + readonly chainId: ChainIdV1; + readonly contextGraphStorageAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; +} + +export interface FinalizedVmChainScanRequestV1 { + readonly contextGraphId: DecimalU256V1; + readonly signal: AbortSignal; +} + +/** Chain-authenticated assertion identity before subgraph placement is joined. */ +export interface FinalizedVmChainCandidateV1 { + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly ordinal: DecimalU64V1; + readonly kaId: string; + readonly ual: string; + readonly authorAddress: EvmAddressV1; + readonly assertionVersion: DecimalU64V1; + readonly assertionRoot: Digest32V1; + readonly finalizedBlockNumber: BlockNumberV1; + readonly finalizedBlockHash: Digest32V1; +} + +export interface FinalizedVmChainInventoryV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: DecimalU256V1; + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly finalizedBlockNumber: BlockNumberV1; + readonly finalizedBlockHash: Digest32V1; + readonly highestFinalizedOrdinal: DecimalU64V1 | null; + readonly rows: readonly Readonly[]; +} + +export interface FinalizedVmChainScannerV1 { + (request: FinalizedVmChainScanRequestV1): Promise>; +} + +interface ScannerConfigSnapshotV1 { + readonly networkId: NetworkIdV1; + readonly chainId: ChainIdV1; + readonly contextGraphStorageAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; +} + +/** + * Build one bounded finalized-chain ordinal scanner. + * + * The scanner deliberately stops at chain-authenticated candidates. It does + * not guess a subgraph or manufacture placementEvidenceDigest; the RFC-64 + * placement layer must join author-authorized catalog/placement evidence later. + */ +export function createFinalizedVmChainScannerV1( + input: FinalizedVmChainScannerConfigV1, +): FinalizedVmChainScannerV1 { + const config = snapshotConfig(input); + const scan: FinalizedVmChainScannerV1 = async (inputRequest) => { + const request = snapshotRequest(inputRequest); + return config.snapshot({ chainId: config.chainId, signal: request.signal }, async (session) => + scanPinnedInventory(config, request.contextGraphId, session)); + }; + return Object.freeze(scan); +} + +async function scanPinnedInventory( + config: ScannerConfigSnapshotV1, + contextGraphId: DecimalU256V1, + session: StrictCurrentFinalizedEvmSnapshotSessionV1, +): Promise> { + if (session.chainId !== config.chainId) { + throw new CurrentFinalizedEvmCallErrorV1( + 'chain-mismatch', + `Finalized VM snapshot returned chain ${session.chainId}, expected ${config.chainId}`, + ); + } + try { + assertCanonicalDecimalU64(session.blockNumber, 'finalized VM snapshot block number'); + assertCanonicalDigest(session.blockHash, 'finalized VM snapshot block hash'); + if (typeof session.read !== 'function') throw new Error('snapshot read is not callable'); + } catch (cause) { + throw malformedReturn('Finalized VM snapshot returned a malformed anchor capability', cause); + } + const contextGraphIdValue = BigInt(contextGraphId); + const [encodedCount] = await session.read(Object.freeze([Object.freeze({ + to: config.contextGraphStorageAddress, + data: CONTEXT_GRAPH_STORAGE_INTERFACE.encodeFunctionData( + 'getContextGraphKaCount', + [contextGraphIdValue], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + })])); + if (encodedCount === undefined) { + throw malformedReturn('Finalized VM count read returned no result'); + } + const rowCount = decodeUint256( + CONTEXT_GRAPH_STORAGE_INTERFACE, + 'getContextGraphKaCount', + encodedCount, + ); + if (rowCount > BigInt(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1)) { + throw new CurrentFinalizedEvmCallErrorV1( + 'resource-limit', + `Finalized VM inventory has ${rowCount} rows, above the v1 pinned-scan limit ${FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1}`, + ); + } + + const rowCountNumber = Number(rowCount); + const idCalls: StrictCurrentFinalizedEvmReadCallV1[] = []; + for (let ordinal = 0; ordinal < rowCountNumber; ordinal += 1) { + idCalls.push(Object.freeze({ + to: config.contextGraphStorageAddress, + data: CONTEXT_GRAPH_STORAGE_INTERFACE.encodeFunctionData( + 'getContextGraphKaAt', + [contextGraphIdValue, BigInt(ordinal)], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + })); + } + const encodedIds = await readBatches(session, idCalls); + const kaIds = encodedIds.map((encoded) => decodeUint256( + CONTEXT_GRAPH_STORAGE_INTERFACE, + 'getContextGraphKaAt', + encoded, + )); + + const assertionCalls: StrictCurrentFinalizedEvmReadCallV1[] = []; + for (const kaId of kaIds) { + assertionCalls.push( + Object.freeze({ + to: config.knowledgeAssetStorageAddress, + data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( + 'getKnowledgeAssetUpdateContext', + [kaId], + ), + maxReturnBytes: UPDATE_CONTEXT_RETURN_BYTES, + }), + Object.freeze({ + to: config.knowledgeAssetStorageAddress, + data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( + 'getLatestMerkleRoot', + [kaId], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + }), + ); + } + const encodedAssertions = await readBatches(session, assertionCalls); + const rows = kaIds.map((kaId, ordinal) => { + const encodedContext = encodedAssertions[ordinal * 2]; + const encodedRoot = encodedAssertions[(ordinal * 2) + 1]; + if (encodedContext === undefined || encodedRoot === undefined) { + throw malformedReturn(`Finalized VM ordinal ${ordinal} is missing assertion results`); + } + const assertionVersion = decodeAssertionVersion(encodedContext); + const assertionRoot = decodeBytes32( + KNOWLEDGE_ASSET_STORAGE_INTERFACE, + 'getLatestMerkleRoot', + encodedRoot, + ); + const identity = unpackRootlessKaIdentity(kaId); + return Object.freeze({ + chainId: config.chainId, + contractAddress: config.contextGraphStorageAddress, + ordinal: String(ordinal) as DecimalU64V1, + kaId: kaId.toString(10), + ual: `did:dkg:${config.networkId}/${identity.authorAddress}/${identity.kaNumber}`, + authorAddress: identity.authorAddress, + assertionVersion, + assertionRoot, + finalizedBlockNumber: session.blockNumber, + finalizedBlockHash: session.blockHash, + } satisfies FinalizedVmChainCandidateV1); + }); + + const highestFinalizedOrdinal = rowCount === 0n + ? null + : (rowCount - 1n).toString(10) as DecimalU64V1; + return Object.freeze({ + networkId: config.networkId, + contextGraphId, + chainId: config.chainId, + contractAddress: config.contextGraphStorageAddress, + finalizedBlockNumber: session.blockNumber, + finalizedBlockHash: session.blockHash, + highestFinalizedOrdinal, + rows: Object.freeze(rows), + }); +} + +async function readBatches( + session: StrictCurrentFinalizedEvmSnapshotSessionV1, + calls: readonly StrictCurrentFinalizedEvmReadCallV1[], +): Promise { + const results: string[] = []; + for (let offset = 0; offset < calls.length; offset += CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) { + const batch = Object.freeze(calls.slice( + offset, + offset + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + )); + const batchResults = await session.read(batch); + if (!Array.isArray(batchResults) || batchResults.length !== batch.length) { + throw malformedReturn('Finalized VM batch result count differs from its request count'); + } + results.push(...batchResults); + } + return Object.freeze(results); +} + +function decodeAssertionVersion(encoded: string): DecimalU64V1 { + const decoded = decodeCanonicalResult( + KNOWLEDGE_ASSET_STORAGE_INTERFACE, + 'getKnowledgeAssetUpdateContext', + encoded, + ); + const version = BigInt(decoded[0] as bigint).toString(10); + try { + assertCanonicalDecimalU64(version, 'finalized VM assertion version'); + } catch (cause) { + throw malformedReturn('Finalized VM assertion version is outside the canonical u64 domain', cause); + } + if (version === '0') { + throw malformedReturn('Finalized VM assertion version must be positive'); + } + return version; +} + +function decodeUint256( + abi: ethers.Interface, + functionName: 'getContextGraphKaCount' | 'getContextGraphKaAt', + encoded: string, +): bigint { + const decoded = decodeCanonicalResult(abi, functionName, encoded); + return BigInt(decoded[0] as bigint); +} + +function decodeBytes32( + abi: ethers.Interface, + functionName: 'getLatestMerkleRoot', + encoded: string, +): Digest32V1 { + const decoded = decodeCanonicalResult(abi, functionName, encoded); + const value = String(decoded[0]).toLowerCase(); + if (!/^0x[0-9a-f]{64}$/.test(value) || value === `0x${'00'.repeat(32)}`) { + throw malformedReturn('Finalized VM assertion root must be a nonzero bytes32 value'); + } + return value as Digest32V1; +} + +function decodeCanonicalResult( + abi: ethers.Interface, + functionName: string, + encoded: string, +): ethers.Result { + try { + const decoded = abi.decodeFunctionResult(functionName, encoded); + const canonical = abi.encodeFunctionResult(functionName, [...decoded]).toLowerCase(); + if (canonical !== encoded) { + throw new Error(`${functionName} returned a non-canonical ABI encoding`); + } + return decoded; + } catch (cause) { + if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; + throw malformedReturn(`Finalized VM ${functionName} result is malformed`, cause); + } +} + +function unpackRootlessKaIdentity(kaId: bigint): Readonly<{ + authorAddress: EvmAddressV1; + kaNumber: string; +}> { + const authorValue = kaId >> PACKED_KA_NUMBER_BITS; + if (authorValue === 0n || authorValue >= (1n << 160n)) { + throw malformedReturn('Finalized VM row does not use the rootless packed KA identity'); + } + const authorAddress = `0x${authorValue.toString(16).padStart(40, '0')}` as EvmAddressV1; + const kaNumber = (kaId & PACKED_KA_NUMBER_MASK).toString(10); + return Object.freeze({ authorAddress, kaNumber }); +} + +function snapshotConfig(input: unknown): ScannerConfigSnapshotV1 { + try { + const record = snapshotExactDataRecord(input, CONFIG_KEYS); + assertNetworkIdV1(record.networkId, 'finalized VM scanner networkId'); + assertCanonicalChainId(record.chainId, 'finalized VM scanner chainId'); + assertCanonicalNonzeroEvmAddress( + record.contextGraphStorageAddress, + 'finalized VM scanner ContextGraphStorage address', + ); + assertCanonicalNonzeroEvmAddress( + record.knowledgeAssetStorageAddress, + 'finalized VM scanner DKGKnowledgeAssets address', + ); + if (typeof record.snapshot !== 'function') throw new Error('snapshot is not callable'); + const snapshot = record.snapshot as StrictCurrentFinalizedEvmSnapshotScopeV1; + return Object.freeze({ + networkId: record.networkId, + chainId: record.chainId, + contextGraphStorageAddress: record.contextGraphStorageAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + snapshot, + }); + } catch (cause) { + throw new TypeError('Finalized VM scanner configuration is invalid', { cause }); + } +} + +function snapshotRequest(input: unknown): Readonly { + try { + const record = snapshotExactDataRecord(input, REQUEST_KEYS); + assertCanonicalDecimalU256(record.contextGraphId, 'finalized VM contextGraphId'); + if (!isAbortSignal(record.signal)) throw new Error('signal is not an AbortSignal'); + return Object.freeze({ contextGraphId: record.contextGraphId, signal: record.signal }); + } catch (cause) { + throw new CurrentFinalizedEvmCallErrorV1( + 'rpc-unavailable', + 'Finalized VM scan request failed the fixed local profile', + { cause }, + ); + } +} + +function malformedReturn( + message: string, + cause?: unknown, +): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + message, + cause === undefined ? {} : { cause }, + ); +} + +// Keep the derived public bound tied to the two snapshot budgets if either is +// tightened later; this assertion is module-load local and performs no I/O. +if ( + 1 + Math.ceil(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) + + Math.ceil((FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * 2) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) + > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 +) { + throw new Error('Finalized VM scan row bound exceeds the pinned-snapshot batch budget'); +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 6cdb7109fb..c26a3102f0 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -44,6 +44,15 @@ export { FINALIZED_CONTEXT_GRAPH_TUPLE_MAX_RETURN_BYTES_V1, createFinalizedContextGraphRpcResolverV1, } from './finalized-context-graph-rpc-resolver.js'; +export { + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + createFinalizedVmChainScannerV1, + type FinalizedVmChainCandidateV1, + type FinalizedVmChainInventoryV1, + type FinalizedVmChainScanRequestV1, + type FinalizedVmChainScannerConfigV1, + type FinalizedVmChainScannerV1, +} from './finalized-vm-chain-scanner.js'; export { CURRENT_FINALIZED_EVM_BLOCK_REFERENCE_PROFILES_V1, createStrictCurrentFinalizedEvmChainAdapterV1, diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts new file mode 100644 index 0000000000..92110beba2 --- /dev/null +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -0,0 +1,314 @@ +import { + type BlockNumberV1, + type ChainIdV1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { loadAbi } from '../src/evm-adapter-abi.js'; +import { + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + createFinalizedVmChainScannerV1, +} from '../src/finalized-vm-chain-scanner.js'; +import { + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, + type StrictCurrentFinalizedEvmSnapshotScopeV1, + type StrictCurrentFinalizedEvmSnapshotSessionV1, +} from '../src/current-finalized-evm-snapshot.js'; +import { CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 } from '../src/current-finalized-evm-read-profile.js'; +import { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from '../src/strict-current-finalized-evm-rpc.js'; +import { + createLoopbackJsonRpcTestHarness, + sendJsonRpcError as sendError, + sendJsonRpcResult as sendResult, +} from './loopback-rpc-harness.js'; + +const NETWORK_ID = 'otp:20430' as NetworkIdV1; +const CHAIN_ID = '20430' as ChainIdV1; +const CG_STORAGE = `0x${'11'.repeat(20)}` as EvmAddressV1; +const KA_STORAGE = `0x${'22'.repeat(20)}` as EvmAddressV1; +const AUTHOR_A = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as EvmAddressV1; +const AUTHOR_B = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as EvmAddressV1; +const ROOT_A = `0x${'aa'.repeat(32)}` as Digest32V1; +const ROOT_B = `0x${'bb'.repeat(32)}` as Digest32V1; +const BLOCK_HASH = `0x${'44'.repeat(32)}` as Digest32V1; +const BLOCK_NUMBER = '123' as BlockNumberV1; +const CG_ID = '14' as const; +const CG_ABI = new ethers.Interface(loadAbi('ContextGraphStorage')); +const KA_ABI = new ethers.Interface(loadAbi('DKGKnowledgeAssets')); +const ABI = ethers.AbiCoder.defaultAbiCoder(); +const KA_A = pack(AUTHOR_A, 7n); +const KA_B = pack(AUTHOR_B, 9n); + +const rpcHarness = createLoopbackJsonRpcTestHarness(); + +afterEach(async () => { + await rpcHarness.stopAll(); +}); + +describe('RFC-64 finalized VM chain scanner', () => { + it('pins its complete-row ceiling to both snapshot resource budgets', () => { + const calls = (rows: number) => 1 + (rows * 3); + const batches = (rows: number) => 1 + + Math.ceil(rows / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) + + Math.ceil((rows * 2) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1); + + expect(calls(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1)) + .toBeLessThanOrEqual(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1); + expect(batches(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1)) + .toBeLessThanOrEqual(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1); + expect(batches(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 + 1)) + .toBeGreaterThan(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1); + }); + + it('derives ordered rootless VM candidates from one pinned snapshot', async () => { + const calls: Array = []; + const snapshot = snapshotStub(async (batch) => { + calls.push(batch); + if (calls.length === 1) return [uintResult(2n)]; + if (calls.length === 2) return [uintResult(KA_A), uintResult(KA_B)]; + return [ + updateContextResult(2n), + bytes32Result(ROOT_A), + updateContextResult(3n), + bytes32Result(ROOT_B), + ]; + }); + const scanner = createFinalizedVmChainScannerV1(config(snapshot)); + const requestSignal = new AbortController().signal; + + const inventory = await scanner({ contextGraphId: CG_ID, signal: requestSignal }); + + expect(Object.isFrozen(scanner)).toBe(true); + expect(Object.isFrozen(inventory)).toBe(true); + expect(Object.isFrozen(inventory.rows)).toBe(true); + expect(inventory).toEqual({ + networkId: NETWORK_ID, + contextGraphId: CG_ID, + chainId: CHAIN_ID, + contractAddress: CG_STORAGE, + finalizedBlockNumber: BLOCK_NUMBER, + finalizedBlockHash: BLOCK_HASH, + highestFinalizedOrdinal: '1', + rows: [ + { + chainId: CHAIN_ID, + contractAddress: CG_STORAGE, + ordinal: '0', + kaId: KA_A.toString(), + ual: `did:dkg:${NETWORK_ID}/${AUTHOR_A}/7`, + authorAddress: AUTHOR_A, + assertionVersion: '2', + assertionRoot: ROOT_A, + finalizedBlockNumber: BLOCK_NUMBER, + finalizedBlockHash: BLOCK_HASH, + }, + { + chainId: CHAIN_ID, + contractAddress: CG_STORAGE, + ordinal: '1', + kaId: KA_B.toString(), + ual: `did:dkg:${NETWORK_ID}/${AUTHOR_B}/9`, + authorAddress: AUTHOR_B, + assertionVersion: '3', + assertionRoot: ROOT_B, + finalizedBlockNumber: BLOCK_NUMBER, + finalizedBlockHash: BLOCK_HASH, + }, + ], + }); + expect(calls.map((batch) => batch.length)).toEqual([1, 2, 4]); + expect(calls[0]![0]!.to).toBe(CG_STORAGE); + expect(calls[1]!.every(({ to }) => to === CG_STORAGE)).toBe(true); + expect(calls[2]!.every(({ to }) => to === KA_STORAGE)).toBe(true); + }); + + it('returns a canonical empty inventory without issuing ordinal reads', async () => { + let batches = 0; + const scanner = createFinalizedVmChainScannerV1(config(snapshotStub(async () => { + batches += 1; + return [uintResult(0n)]; + }))); + + await expect(scanner({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + })).resolves.toMatchObject({ + highestFinalizedOrdinal: null, + rows: [], + }); + expect(batches).toBe(1); + }); + + it('fails before ordinal reads when the finalized inventory exceeds the v1 scope', async () => { + let batches = 0; + const scanner = createFinalizedVmChainScannerV1(config(snapshotStub(async () => { + batches += 1; + return [uintResult(BigInt(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 + 1))]; + }))); + + await expect(scanner({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'resource-limit' }); + expect(batches).toBe(1); + }); + + it('rejects legacy sequential ids, zero versions, zero roots, and batch shape drift', async () => { + const cases: StrictCurrentFinalizedEvmSnapshotScopeV1[] = [ + scriptedSnapshot([[uintResult(1n)], [uintResult(7n)], [updateContextResult(1n), bytes32Result(ROOT_A)]]), + scriptedSnapshot([[uintResult(1n)], [uintResult(KA_A)], [updateContextResult(0n), bytes32Result(ROOT_A)]]), + scriptedSnapshot([[uintResult(1n)], [uintResult(KA_A)], [updateContextResult(1n), bytes32Result(ethers.ZeroHash)]]), + scriptedSnapshot([[uintResult(1n)], []]), + ]; + for (const snapshot of cases) { + const scanner = createFinalizedVmChainScannerV1(config(snapshot)); + await expect(scanner({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'malformed-return' }); + } + }); + + it('rejects non-canonical ABI bytes and hostile local shapes', async () => { + const malformed = createFinalizedVmChainScannerV1(config(snapshotStub(async () => [ + `${uintResult(0n)}00`, + ]))); + await expect(malformed({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'malformed-return' }); + + expect(() => createFinalizedVmChainScannerV1({ + ...config(snapshotStub(async () => [uintResult(0n)])), + contextGraphStorageAddress: CG_STORAGE.toUpperCase() as EvmAddressV1, + })).toThrow(TypeError); + await expect(malformed({ + contextGraphId: '014' as never, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'rpc-unavailable' }); + await expect(malformed({ + contextGraphId: CG_ID, + signal: {} as never, + })).rejects.toMatchObject({ code: 'rpc-unavailable' }); + }); + + it('executes the complete scan over the real strict loopback transport', async () => { + const rpc = await rpcHarness.start((call, response) => { + switch (call.method) { + case 'eth_chainId': + sendResult(response, call, '0x4fce'); + return; + case 'eth_getBlockByNumber': + sendResult(response, call, { number: '0x7b', hash: BLOCK_HASH }); + return; + case 'eth_getCode': + sendResult(response, call, '0x6000'); + return; + case 'eth_call': { + const request = call.params[0] as { readonly data?: string }; + const data = request.data ?? ''; + const selector = data.slice(0, 10); + if (selector === CG_ABI.getFunction('getContextGraphKaCount')!.selector) { + sendResult(response, call, uintResult(2n)); + } else if (selector === CG_ABI.getFunction('getContextGraphKaAt')!.selector) { + const [, ordinal] = CG_ABI.decodeFunctionData('getContextGraphKaAt', data); + sendResult(response, call, uintResult(BigInt(ordinal) === 0n ? KA_A : KA_B)); + } else if (selector === KA_ABI.getFunction('getKnowledgeAssetUpdateContext')!.selector) { + const [kaId] = KA_ABI.decodeFunctionData('getKnowledgeAssetUpdateContext', data); + sendResult(response, call, updateContextResult(BigInt(kaId) === KA_A ? 2n : 3n)); + } else if (selector === KA_ABI.getFunction('getLatestMerkleRoot')!.selector) { + const [kaId] = KA_ABI.decodeFunctionData('getLatestMerkleRoot', data); + sendResult(response, call, bytes32Result(BigInt(kaId) === KA_A ? ROOT_A : ROOT_B)); + } else { + sendError(response, call, -32601, 'unexpected eth_call selector'); + } + return; + } + default: + sendError(response, call, -32601, 'method not found'); + } + }); + const snapshot = createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId: CHAIN_ID, + endpoints: [rpc.url], + }); + const scanner = createFinalizedVmChainScannerV1(config(snapshot)); + + const inventory = await scanner({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + }); + + expect(inventory.rows.map(({ ual, assertionVersion, assertionRoot }) => ({ + ual, + assertionVersion, + assertionRoot, + }))).toEqual([ + { ual: `did:dkg:${NETWORK_ID}/${AUTHOR_A}/7`, assertionVersion: '2', assertionRoot: ROOT_A }, + { ual: `did:dkg:${NETWORK_ID}/${AUTHOR_B}/9`, assertionVersion: '3', assertionRoot: ROOT_B }, + ]); + const ethCalls = rpc.calls.filter(({ method }) => method === 'eth_call'); + expect(ethCalls).toHaveLength(7); + expect(ethCalls.every(({ params }) => { + const block = params[1] as { readonly blockHash?: unknown; readonly requireCanonical?: unknown }; + return block.blockHash === BLOCK_HASH && block.requireCanonical === true; + })).toBe(true); + }); +}); + +function config(snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1) { + return Object.freeze({ + networkId: NETWORK_ID, + chainId: CHAIN_ID, + contextGraphStorageAddress: CG_STORAGE, + knowledgeAssetStorageAddress: KA_STORAGE, + snapshot, + }); +} + +function snapshotStub( + read: StrictCurrentFinalizedEvmSnapshotSessionV1['read'], +): StrictCurrentFinalizedEvmSnapshotScopeV1 { + return Object.freeze(async (request: { readonly chainId: ChainIdV1 }, consume: ( + session: StrictCurrentFinalizedEvmSnapshotSessionV1, + ) => Promise): Promise => { + expect(request.chainId).toBe(CHAIN_ID); + return consume(Object.freeze({ + chainId: CHAIN_ID, + blockNumber: BLOCK_NUMBER, + blockHash: BLOCK_HASH, + read, + })); + }); +} + +function scriptedSnapshot( + responses: readonly (readonly string[])[], +): StrictCurrentFinalizedEvmSnapshotScopeV1 { + let index = 0; + return snapshotStub(async () => responses[index++] ?? []); +} + +function pack(author: EvmAddressV1, number: bigint): bigint { + return (BigInt(author) << 96n) | number; +} + +function uintResult(value: bigint): string { + return ABI.encode(['uint256'], [value]); +} + +function bytes32Result(value: string): string { + return ABI.encode(['bytes32'], [value]); +} + +function updateContextResult(version: bigint): string { + return ABI.encode( + ['uint256', 'uint256', 'uint88', 'uint40', 'uint96', 'bool', 'uint32'], + [version, 1n, 100n, 999n, 1n, false, 3n], + ); +} From 9d4a37be14768b35af431063cc9f05aa704838be Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 11:39:58 +0200 Subject: [PATCH 238/292] fix(chain): bind finalized VM authority evidence --- .../chain/src/finalized-vm-chain-scanner.ts | 147 ++++++++++++++++-- packages/chain/src/index.ts | 2 + .../finalized-vm-chain-scanner.unit.test.ts | 124 +++++++++++++-- 3 files changed, 250 insertions(+), 23 deletions(-) diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts index 2dda09116b..d4c7a420a2 100644 --- a/packages/chain/src/finalized-vm-chain-scanner.ts +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -42,6 +42,12 @@ const CONFIG_KEYS = Object.freeze([ 'networkId', 'snapshot', ] as const); +const SESSION_CONFIG_KEYS = Object.freeze([ + 'chainId', + 'contextGraphStorageAddress', + 'knowledgeAssetStorageAddress', + 'networkId', +] as const); const REQUEST_KEYS = Object.freeze(['contextGraphId', 'signal'] as const); const UINT256_RETURN_BYTES = 32; const UPDATE_CONTEXT_RETURN_BYTES = 7 * 32; @@ -49,21 +55,28 @@ const PACKED_KA_NUMBER_BITS = 96n; const PACKED_KA_NUMBER_MASK = (1n << PACKED_KA_NUMBER_BITS) - 1n; /** - * One count read plus one indexed-ID read and two assertion reads per row. + * One count read plus one indexed-ID read and four assertion reads per row. * The exact grouping below consumes one count batch, ceil(N/4) ID batches, - * and ceil(2N/4) assertion batches. 1,364 is the largest N that stays inside - * both the pinned-snapshot batch and call budgets. + * and N assertion batches. The exported ceiling is derived from both the + * pinned-snapshot batch and call budgets rather than copied as a magic limit. */ export const FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 = Math.min( - Math.floor((CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 - 1) / 3), - 1_364, + Math.floor((CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 - 1) / 5), + Math.floor( + ((CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 - 1) + * CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) / 5, + ), ); -export interface FinalizedVmChainScannerConfigV1 { +export interface FinalizedVmChainSessionScannerConfigV1 { readonly networkId: NetworkIdV1; readonly chainId: ChainIdV1; readonly contextGraphStorageAddress: EvmAddressV1; readonly knowledgeAssetStorageAddress: EvmAddressV1; +} + +export interface FinalizedVmChainScannerConfigV1 + extends FinalizedVmChainSessionScannerConfigV1 { readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; } @@ -76,10 +89,15 @@ export interface FinalizedVmChainScanRequestV1 { export interface FinalizedVmChainCandidateV1 { readonly chainId: ChainIdV1; readonly contractAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; readonly ordinal: DecimalU64V1; readonly kaId: string; readonly ual: string; readonly authorAddress: EvmAddressV1; + /** Latest chain-verified author attestation; null is explicit legacy/incomplete state. */ + readonly attestedAuthorAddress: EvmAddressV1 | null; + /** EOA that submitted the latest root; curated admission consumes this chain truth. */ + readonly publisherAddress: EvmAddressV1 | null; readonly assertionVersion: DecimalU64V1; readonly assertionRoot: Digest32V1; readonly finalizedBlockNumber: BlockNumberV1; @@ -91,6 +109,7 @@ export interface FinalizedVmChainInventoryV1 { readonly contextGraphId: DecimalU256V1; readonly chainId: ChainIdV1; readonly contractAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; readonly finalizedBlockNumber: BlockNumberV1; readonly finalizedBlockHash: Digest32V1; readonly highestFinalizedOrdinal: DecimalU64V1 | null; @@ -101,11 +120,14 @@ export interface FinalizedVmChainScannerV1 { (request: FinalizedVmChainScanRequestV1): Promise>; } -interface ScannerConfigSnapshotV1 { +interface ScannerSessionConfigSnapshotV1 { readonly networkId: NetworkIdV1; readonly chainId: ChainIdV1; readonly contextGraphStorageAddress: EvmAddressV1; readonly knowledgeAssetStorageAddress: EvmAddressV1; +} + +interface ScannerConfigSnapshotV1 extends ScannerSessionConfigSnapshotV1 { readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; } @@ -128,8 +150,23 @@ export function createFinalizedVmChainScannerV1( return Object.freeze(scan); } +/** + * Scan inside a caller-owned snapshot session. Runtime composition uses this + * seam to read the CG policy/name binding and VM inventory at one exact anchor + * instead of opening a second independently finalized view. + */ +export function scanFinalizedVmChainInventoryInSnapshotV1( + input: FinalizedVmChainSessionScannerConfigV1, + inputRequest: FinalizedVmChainScanRequestV1, + session: StrictCurrentFinalizedEvmSnapshotSessionV1, +): Promise> { + const config = snapshotSessionConfig(input); + const request = snapshotRequest(inputRequest); + return scanPinnedInventory(config, request.contextGraphId, session); +} + async function scanPinnedInventory( - config: ScannerConfigSnapshotV1, + config: ScannerSessionConfigSnapshotV1, contextGraphId: DecimalU256V1, session: StrictCurrentFinalizedEvmSnapshotSessionV1, ): Promise> { @@ -208,13 +245,37 @@ async function scanPinnedInventory( ), maxReturnBytes: UINT256_RETURN_BYTES, }), + Object.freeze({ + to: config.knowledgeAssetStorageAddress, + data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( + 'getLatestMerkleRootAuthor', + [kaId], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + }), + Object.freeze({ + to: config.knowledgeAssetStorageAddress, + data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( + 'getLatestMerkleRootPublisher', + [kaId], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + }), ); } const encodedAssertions = await readBatches(session, assertionCalls); const rows = kaIds.map((kaId, ordinal) => { - const encodedContext = encodedAssertions[ordinal * 2]; - const encodedRoot = encodedAssertions[(ordinal * 2) + 1]; - if (encodedContext === undefined || encodedRoot === undefined) { + const offset = ordinal * 4; + const encodedContext = encodedAssertions[offset]; + const encodedRoot = encodedAssertions[offset + 1]; + const encodedAuthor = encodedAssertions[offset + 2]; + const encodedPublisher = encodedAssertions[offset + 3]; + if ( + encodedContext === undefined + || encodedRoot === undefined + || encodedAuthor === undefined + || encodedPublisher === undefined + ) { throw malformedReturn(`Finalized VM ordinal ${ordinal} is missing assertion results`); } const assertionVersion = decodeAssertionVersion(encodedContext); @@ -224,13 +285,34 @@ async function scanPinnedInventory( encodedRoot, ); const identity = unpackRootlessKaIdentity(kaId); + const attestedAuthorAddress = decodeNullableAddress( + KNOWLEDGE_ASSET_STORAGE_INTERFACE, + 'getLatestMerkleRootAuthor', + encodedAuthor, + ); + const publisherAddress = decodeNullableAddress( + KNOWLEDGE_ASSET_STORAGE_INTERFACE, + 'getLatestMerkleRootPublisher', + encodedPublisher, + ); + if ( + attestedAuthorAddress !== null + && attestedAuthorAddress !== identity.authorAddress + ) { + throw malformedReturn( + `Finalized VM ordinal ${ordinal} author attestation differs from its packed KA identity`, + ); + } return Object.freeze({ chainId: config.chainId, contractAddress: config.contextGraphStorageAddress, + knowledgeAssetStorageAddress: config.knowledgeAssetStorageAddress, ordinal: String(ordinal) as DecimalU64V1, kaId: kaId.toString(10), ual: `did:dkg:${config.networkId}/${identity.authorAddress}/${identity.kaNumber}`, authorAddress: identity.authorAddress, + attestedAuthorAddress, + publisherAddress, assertionVersion, assertionRoot, finalizedBlockNumber: session.blockNumber, @@ -246,6 +328,7 @@ async function scanPinnedInventory( contextGraphId, chainId: config.chainId, contractAddress: config.contextGraphStorageAddress, + knowledgeAssetStorageAddress: config.knowledgeAssetStorageAddress, finalizedBlockNumber: session.blockNumber, finalizedBlockHash: session.blockHash, highestFinalizedOrdinal, @@ -312,6 +395,22 @@ function decodeBytes32( return value as Digest32V1; } +function decodeNullableAddress( + abi: ethers.Interface, + functionName: 'getLatestMerkleRootAuthor' | 'getLatestMerkleRootPublisher', + encoded: string, +): EvmAddressV1 | null { + const decoded = decodeCanonicalResult(abi, functionName, encoded); + const value = String(decoded[0]).toLowerCase(); + if (value === ethers.ZeroAddress) return null; + try { + assertCanonicalNonzeroEvmAddress(value, `finalized VM ${functionName} address`); + } catch (cause) { + throw malformedReturn(`Finalized VM ${functionName} address is malformed`, cause); + } + return value; +} + function decodeCanonicalResult( abi: ethers.Interface, functionName: string, @@ -370,6 +469,30 @@ function snapshotConfig(input: unknown): ScannerConfigSnapshotV1 { } } +function snapshotSessionConfig(input: unknown): ScannerSessionConfigSnapshotV1 { + try { + const record = snapshotExactDataRecord(input, SESSION_CONFIG_KEYS); + assertNetworkIdV1(record.networkId, 'finalized VM scanner networkId'); + assertCanonicalChainId(record.chainId, 'finalized VM scanner chainId'); + assertCanonicalNonzeroEvmAddress( + record.contextGraphStorageAddress, + 'finalized VM scanner ContextGraphStorage address', + ); + assertCanonicalNonzeroEvmAddress( + record.knowledgeAssetStorageAddress, + 'finalized VM scanner DKGKnowledgeAssets address', + ); + return Object.freeze({ + networkId: record.networkId, + chainId: record.chainId, + contextGraphStorageAddress: record.contextGraphStorageAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + }); + } catch (cause) { + throw new TypeError('Finalized VM session scanner configuration is invalid', { cause }); + } +} + function snapshotRequest(input: unknown): Readonly { try { const record = snapshotExactDataRecord(input, REQUEST_KEYS); @@ -400,7 +523,7 @@ function malformedReturn( // tightened later; this assertion is module-load local and performs no I/O. if ( 1 + Math.ceil(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) - + Math.ceil((FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * 2) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) + + Math.ceil((FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * 4) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 ) { throw new Error('Finalized VM scan row bound exceeds the pinned-snapshot batch budget'); diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index c26a3102f0..d6575610eb 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -47,10 +47,12 @@ export { export { FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, createFinalizedVmChainScannerV1, + scanFinalizedVmChainInventoryInSnapshotV1, type FinalizedVmChainCandidateV1, type FinalizedVmChainInventoryV1, type FinalizedVmChainScanRequestV1, type FinalizedVmChainScannerConfigV1, + type FinalizedVmChainSessionScannerConfigV1, type FinalizedVmChainScannerV1, } from './finalized-vm-chain-scanner.js'; export { diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index 92110beba2..1e9e621119 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -12,6 +12,7 @@ import { loadAbi } from '../src/evm-adapter-abi.js'; import { FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, createFinalizedVmChainScannerV1, + scanFinalizedVmChainInventoryInSnapshotV1, } from '../src/finalized-vm-chain-scanner.js'; import { CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, @@ -33,6 +34,8 @@ const CG_STORAGE = `0x${'11'.repeat(20)}` as EvmAddressV1; const KA_STORAGE = `0x${'22'.repeat(20)}` as EvmAddressV1; const AUTHOR_A = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as EvmAddressV1; const AUTHOR_B = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as EvmAddressV1; +const PUBLISHER_A = '0x1111111111111111111111111111111111111111' as EvmAddressV1; +const PUBLISHER_B = '0x3333333333333333333333333333333333333333' as EvmAddressV1; const ROOT_A = `0x${'aa'.repeat(32)}` as Digest32V1; const ROOT_B = `0x${'bb'.repeat(32)}` as Digest32V1; const BLOCK_HASH = `0x${'44'.repeat(32)}` as Digest32V1; @@ -52,10 +55,10 @@ afterEach(async () => { describe('RFC-64 finalized VM chain scanner', () => { it('pins its complete-row ceiling to both snapshot resource budgets', () => { - const calls = (rows: number) => 1 + (rows * 3); + const calls = (rows: number) => 1 + (rows * 5); const batches = (rows: number) => 1 + Math.ceil(rows / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) - + Math.ceil((rows * 2) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1); + + Math.ceil((rows * 4) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1); expect(calls(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1)) .toBeLessThanOrEqual(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1); @@ -71,11 +74,17 @@ describe('RFC-64 finalized VM chain scanner', () => { calls.push(batch); if (calls.length === 1) return [uintResult(2n)]; if (calls.length === 2) return [uintResult(KA_A), uintResult(KA_B)]; - return [ + if (calls.length === 3) return [ updateContextResult(2n), bytes32Result(ROOT_A), + addressResult(AUTHOR_A), + addressResult(PUBLISHER_A), + ]; + return [ updateContextResult(3n), bytes32Result(ROOT_B), + addressResult(AUTHOR_B), + addressResult(PUBLISHER_B), ]; }); const scanner = createFinalizedVmChainScannerV1(config(snapshot)); @@ -91,6 +100,7 @@ describe('RFC-64 finalized VM chain scanner', () => { contextGraphId: CG_ID, chainId: CHAIN_ID, contractAddress: CG_STORAGE, + knowledgeAssetStorageAddress: KA_STORAGE, finalizedBlockNumber: BLOCK_NUMBER, finalizedBlockHash: BLOCK_HASH, highestFinalizedOrdinal: '1', @@ -98,10 +108,13 @@ describe('RFC-64 finalized VM chain scanner', () => { { chainId: CHAIN_ID, contractAddress: CG_STORAGE, + knowledgeAssetStorageAddress: KA_STORAGE, ordinal: '0', kaId: KA_A.toString(), ual: `did:dkg:${NETWORK_ID}/${AUTHOR_A}/7`, authorAddress: AUTHOR_A, + attestedAuthorAddress: AUTHOR_A, + publisherAddress: PUBLISHER_A, assertionVersion: '2', assertionRoot: ROOT_A, finalizedBlockNumber: BLOCK_NUMBER, @@ -110,10 +123,13 @@ describe('RFC-64 finalized VM chain scanner', () => { { chainId: CHAIN_ID, contractAddress: CG_STORAGE, + knowledgeAssetStorageAddress: KA_STORAGE, ordinal: '1', kaId: KA_B.toString(), ual: `did:dkg:${NETWORK_ID}/${AUTHOR_B}/9`, authorAddress: AUTHOR_B, + attestedAuthorAddress: AUTHOR_B, + publisherAddress: PUBLISHER_B, assertionVersion: '3', assertionRoot: ROOT_B, finalizedBlockNumber: BLOCK_NUMBER, @@ -121,10 +137,10 @@ describe('RFC-64 finalized VM chain scanner', () => { }, ], }); - expect(calls.map((batch) => batch.length)).toEqual([1, 2, 4]); + expect(calls.map((batch) => batch.length)).toEqual([1, 2, 4, 4]); expect(calls[0]![0]!.to).toBe(CG_STORAGE); expect(calls[1]!.every(({ to }) => to === CG_STORAGE)).toBe(true); - expect(calls[2]!.every(({ to }) => to === KA_STORAGE)).toBe(true); + expect(calls.slice(2).flat().every(({ to }) => to === KA_STORAGE)).toBe(true); }); it('returns a canonical empty inventory without issuing ordinal reads', async () => { @@ -160,9 +176,9 @@ describe('RFC-64 finalized VM chain scanner', () => { it('rejects legacy sequential ids, zero versions, zero roots, and batch shape drift', async () => { const cases: StrictCurrentFinalizedEvmSnapshotScopeV1[] = [ - scriptedSnapshot([[uintResult(1n)], [uintResult(7n)], [updateContextResult(1n), bytes32Result(ROOT_A)]]), - scriptedSnapshot([[uintResult(1n)], [uintResult(KA_A)], [updateContextResult(0n), bytes32Result(ROOT_A)]]), - scriptedSnapshot([[uintResult(1n)], [uintResult(KA_A)], [updateContextResult(1n), bytes32Result(ethers.ZeroHash)]]), + scriptedSnapshot([[uintResult(1n)], [uintResult(7n)], assertionResults(1n, ROOT_A, AUTHOR_A, PUBLISHER_A)]), + scriptedSnapshot([[uintResult(1n)], [uintResult(KA_A)], assertionResults(0n, ROOT_A, AUTHOR_A, PUBLISHER_A)]), + scriptedSnapshot([[uintResult(1n)], [uintResult(KA_A)], assertionResults(1n, ethers.ZeroHash, AUTHOR_A, PUBLISHER_A)]), scriptedSnapshot([[uintResult(1n)], []]), ]; for (const snapshot of cases) { @@ -174,6 +190,56 @@ describe('RFC-64 finalized VM chain scanner', () => { } }); + it('keeps absent chain author/publisher evidence explicit and rejects identity drift', async () => { + const noEvidence = createFinalizedVmChainScannerV1(config(scriptedSnapshot([ + [uintResult(1n)], + [uintResult(KA_A)], + assertionResults(1n, ROOT_A, ethers.ZeroAddress, ethers.ZeroAddress), + ]))); + await expect(noEvidence({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + })).resolves.toMatchObject({ + rows: [{ attestedAuthorAddress: null, publisherAddress: null }], + }); + + const wrongAuthor = createFinalizedVmChainScannerV1(config(scriptedSnapshot([ + [uintResult(1n)], + [uintResult(KA_A)], + assertionResults(1n, ROOT_A, AUTHOR_B, PUBLISHER_A), + ]))); + await expect(wrongAuthor({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'malformed-return' }); + }); + + it('scans inside a caller-owned snapshot session for same-anchor runtime composition', async () => { + const session = Object.freeze({ + chainId: CHAIN_ID, + blockNumber: BLOCK_NUMBER, + blockHash: BLOCK_HASH, + read: scriptedRead([ + [uintResult(1n)], + [uintResult(KA_A)], + assertionResults(2n, ROOT_A, AUTHOR_A, PUBLISHER_A), + ]), + }) satisfies StrictCurrentFinalizedEvmSnapshotSessionV1; + + const inventory = await scanFinalizedVmChainInventoryInSnapshotV1( + sessionConfig(), + { contextGraphId: CG_ID, signal: new AbortController().signal }, + session, + ); + + expect(inventory.rows).toHaveLength(1); + expect(inventory.rows[0]).toMatchObject({ + attestedAuthorAddress: AUTHOR_A, + publisherAddress: PUBLISHER_A, + finalizedBlockHash: BLOCK_HASH, + }); + }); + it('rejects non-canonical ABI bytes and hostile local shapes', async () => { const malformed = createFinalizedVmChainScannerV1(config(snapshotStub(async () => [ `${uintResult(0n)}00`, @@ -224,6 +290,12 @@ describe('RFC-64 finalized VM chain scanner', () => { } else if (selector === KA_ABI.getFunction('getLatestMerkleRoot')!.selector) { const [kaId] = KA_ABI.decodeFunctionData('getLatestMerkleRoot', data); sendResult(response, call, bytes32Result(BigInt(kaId) === KA_A ? ROOT_A : ROOT_B)); + } else if (selector === KA_ABI.getFunction('getLatestMerkleRootAuthor')!.selector) { + const [kaId] = KA_ABI.decodeFunctionData('getLatestMerkleRootAuthor', data); + sendResult(response, call, addressResult(BigInt(kaId) === KA_A ? AUTHOR_A : AUTHOR_B)); + } else if (selector === KA_ABI.getFunction('getLatestMerkleRootPublisher')!.selector) { + const [kaId] = KA_ABI.decodeFunctionData('getLatestMerkleRootPublisher', data); + sendResult(response, call, addressResult(BigInt(kaId) === KA_A ? PUBLISHER_A : PUBLISHER_B)); } else { sendError(response, call, -32601, 'unexpected eth_call selector'); } @@ -253,7 +325,7 @@ describe('RFC-64 finalized VM chain scanner', () => { { ual: `did:dkg:${NETWORK_ID}/${AUTHOR_B}/9`, assertionVersion: '3', assertionRoot: ROOT_B }, ]); const ethCalls = rpc.calls.filter(({ method }) => method === 'eth_call'); - expect(ethCalls).toHaveLength(7); + expect(ethCalls).toHaveLength(11); expect(ethCalls.every(({ params }) => { const block = params[1] as { readonly blockHash?: unknown; readonly requireCanonical?: unknown }; return block.blockHash === BLOCK_HASH && block.requireCanonical === true; @@ -262,12 +334,18 @@ describe('RFC-64 finalized VM chain scanner', () => { }); function config(snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1) { + return Object.freeze({ + ...sessionConfig(), + snapshot, + }); +} + +function sessionConfig() { return Object.freeze({ networkId: NETWORK_ID, chainId: CHAIN_ID, contextGraphStorageAddress: CG_STORAGE, knowledgeAssetStorageAddress: KA_STORAGE, - snapshot, }); } @@ -290,8 +368,14 @@ function snapshotStub( function scriptedSnapshot( responses: readonly (readonly string[])[], ): StrictCurrentFinalizedEvmSnapshotScopeV1 { + return snapshotStub(scriptedRead(responses)); +} + +function scriptedRead( + responses: readonly (readonly string[])[], +): StrictCurrentFinalizedEvmSnapshotSessionV1['read'] { let index = 0; - return snapshotStub(async () => responses[index++] ?? []); + return async () => responses[index++] ?? []; } function pack(author: EvmAddressV1, number: bigint): bigint { @@ -306,6 +390,24 @@ function bytes32Result(value: string): string { return ABI.encode(['bytes32'], [value]); } +function addressResult(value: string): string { + return ABI.encode(['address'], [value]); +} + +function assertionResults( + version: bigint, + root: string, + author: string, + publisher: string, +): readonly string[] { + return [ + updateContextResult(version), + bytes32Result(root), + addressResult(author), + addressResult(publisher), + ]; +} + function updateContextResult(version: bigint): string { return ABI.encode( ['uint256', 'uint256', 'uint88', 'uint40', 'uint96', 'bool', 'uint32'], From 2413f117ad9f9db175c97d3b6a65477612c0c844 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 11:47:28 +0200 Subject: [PATCH 239/292] fix(chain): harden finalized VM scan admission --- .../chain/src/finalized-vm-chain-scanner.ts | 306 +++++++++++------- .../finalized-vm-chain-scanner.unit.test.ts | 117 ++++++- packages/core/src/ka-content-scope.ts | 32 ++ packages/core/test/ka-content-scope.test.ts | 19 ++ 4 files changed, 344 insertions(+), 130 deletions(-) diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts index d4c7a420a2..717c0cbbad 100644 --- a/packages/chain/src/finalized-vm-chain-scanner.ts +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -4,6 +4,7 @@ import { assertCanonicalDecimalU64, assertCanonicalDigest, assertNetworkIdV1, + unpackDeterministicRootlessKnowledgeAssetId, type BlockNumberV1, type ChainIdV1, type DecimalU256V1, @@ -51,21 +52,20 @@ const SESSION_CONFIG_KEYS = Object.freeze([ const REQUEST_KEYS = Object.freeze(['contextGraphId', 'signal'] as const); const UINT256_RETURN_BYTES = 32; const UPDATE_CONTEXT_RETURN_BYTES = 7 * 32; -const PACKED_KA_NUMBER_BITS = 96n; -const PACKED_KA_NUMBER_MASK = (1n << PACKED_KA_NUMBER_BITS) - 1n; +const FIXED_SCAN_CALLS = 2; +const ID_CALLS_PER_ROW = 1; +const ASSERTION_CALLS_PER_ROW = 4; +const TOTAL_CALLS_PER_ROW = ID_CALLS_PER_ROW + ASSERTION_CALLS_PER_ROW; /** - * One count read plus one indexed-ID read and four assertion reads per row. - * The exact grouping below consumes one count batch, ceil(N/4) ID batches, - * and N assertion batches. The exported ceiling is derived from both the - * pinned-snapshot batch and call budgets rather than copied as a magic limit. + * One active/count header plus one indexed-ID read and four assertion reads + * per row. The exported ceiling is derived from the exact call and batching + * equations so transport-budget changes cannot silently invalidate it. */ -export const FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 = Math.min( - Math.floor((CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 - 1) / 5), - Math.floor( - ((CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 - 1) - * CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) / 5, - ), +export const FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 = deriveFinalizedVmScanMaxRows( + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, ); export interface FinalizedVmChainSessionScannerConfigV1 { @@ -131,6 +131,13 @@ interface ScannerConfigSnapshotV1 extends ScannerSessionConfigSnapshotV1 { readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; } +interface FinalizedVmAssertionReadV1 { + readonly assertionVersion: DecimalU64V1; + readonly assertionRoot: Digest32V1; + readonly attestedAuthorAddress: EvmAddressV1 | null; + readonly publisherAddress: EvmAddressV1 | null; +} + /** * Build one bounded finalized-chain ordinal scanner. * @@ -184,16 +191,35 @@ async function scanPinnedInventory( throw malformedReturn('Finalized VM snapshot returned a malformed anchor capability', cause); } const contextGraphIdValue = BigInt(contextGraphId); - const [encodedCount] = await session.read(Object.freeze([Object.freeze({ - to: config.contextGraphStorageAddress, - data: CONTEXT_GRAPH_STORAGE_INTERFACE.encodeFunctionData( - 'getContextGraphKaCount', - [contextGraphIdValue], - ), - maxReturnBytes: UINT256_RETURN_BYTES, - })])); - if (encodedCount === undefined) { - throw malformedReturn('Finalized VM count read returned no result'); + const encodedHeader = await session.read(Object.freeze([ + Object.freeze({ + to: config.contextGraphStorageAddress, + data: CONTEXT_GRAPH_STORAGE_INTERFACE.encodeFunctionData( + 'isContextGraphActive', + [contextGraphIdValue], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + }), + Object.freeze({ + to: config.contextGraphStorageAddress, + data: CONTEXT_GRAPH_STORAGE_INTERFACE.encodeFunctionData( + 'getContextGraphKaCount', + [contextGraphIdValue], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + }), + ])); + if (!Array.isArray(encodedHeader) || encodedHeader.length !== FIXED_SCAN_CALLS) { + throw malformedReturn('Finalized VM context-graph header result count is invalid'); + } + const [encodedActive, encodedCount] = encodedHeader; + if (encodedActive === undefined || encodedCount === undefined) { + throw malformedReturn('Finalized VM context-graph header read returned incomplete results'); + } + if (!decodeBoolean(CONTEXT_GRAPH_STORAGE_INTERFACE, 'isContextGraphActive', encodedActive)) { + throw malformedReturn( + `Finalized VM context graph ${contextGraphId} is not active at the pinned anchor`, + ); } const rowCount = decodeUint256( CONTEXT_GRAPH_STORAGE_INTERFACE, @@ -226,78 +252,28 @@ async function scanPinnedInventory( encoded, )); - const assertionCalls: StrictCurrentFinalizedEvmReadCallV1[] = []; - for (const kaId of kaIds) { - assertionCalls.push( - Object.freeze({ - to: config.knowledgeAssetStorageAddress, - data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( - 'getKnowledgeAssetUpdateContext', - [kaId], - ), - maxReturnBytes: UPDATE_CONTEXT_RETURN_BYTES, - }), - Object.freeze({ - to: config.knowledgeAssetStorageAddress, - data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( - 'getLatestMerkleRoot', - [kaId], - ), - maxReturnBytes: UINT256_RETURN_BYTES, - }), - Object.freeze({ - to: config.knowledgeAssetStorageAddress, - data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( - 'getLatestMerkleRootAuthor', - [kaId], - ), - maxReturnBytes: UINT256_RETURN_BYTES, - }), - Object.freeze({ - to: config.knowledgeAssetStorageAddress, - data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( - 'getLatestMerkleRootPublisher', - [kaId], - ), - maxReturnBytes: UINT256_RETURN_BYTES, - }), - ); - } - const encodedAssertions = await readBatches(session, assertionCalls); + const assertions = await readFinalizedVmAssertions( + session, + config.knowledgeAssetStorageAddress, + kaIds, + ); const rows = kaIds.map((kaId, ordinal) => { - const offset = ordinal * 4; - const encodedContext = encodedAssertions[offset]; - const encodedRoot = encodedAssertions[offset + 1]; - const encodedAuthor = encodedAssertions[offset + 2]; - const encodedPublisher = encodedAssertions[offset + 3]; - if ( - encodedContext === undefined - || encodedRoot === undefined - || encodedAuthor === undefined - || encodedPublisher === undefined - ) { + const assertion = assertions[ordinal]; + if (assertion === undefined) { throw malformedReturn(`Finalized VM ordinal ${ordinal} is missing assertion results`); } - const assertionVersion = decodeAssertionVersion(encodedContext); - const assertionRoot = decodeBytes32( - KNOWLEDGE_ASSET_STORAGE_INTERFACE, - 'getLatestMerkleRoot', - encodedRoot, - ); - const identity = unpackRootlessKaIdentity(kaId); - const attestedAuthorAddress = decodeNullableAddress( - KNOWLEDGE_ASSET_STORAGE_INTERFACE, - 'getLatestMerkleRootAuthor', - encodedAuthor, - ); - const publisherAddress = decodeNullableAddress( - KNOWLEDGE_ASSET_STORAGE_INTERFACE, - 'getLatestMerkleRootPublisher', - encodedPublisher, - ); + let identity: ReturnType; + try { + identity = unpackDeterministicRootlessKnowledgeAssetId(config.networkId, kaId); + } catch (cause) { + throw malformedReturn( + `Finalized VM ordinal ${ordinal} does not use the canonical rootless KA identity`, + cause, + ); + } if ( - attestedAuthorAddress !== null - && attestedAuthorAddress !== identity.authorAddress + assertion.attestedAuthorAddress !== null + && assertion.attestedAuthorAddress !== identity.agentAddress ) { throw malformedReturn( `Finalized VM ordinal ${ordinal} author attestation differs from its packed KA identity`, @@ -308,13 +284,13 @@ async function scanPinnedInventory( contractAddress: config.contextGraphStorageAddress, knowledgeAssetStorageAddress: config.knowledgeAssetStorageAddress, ordinal: String(ordinal) as DecimalU64V1, - kaId: kaId.toString(10), - ual: `did:dkg:${config.networkId}/${identity.authorAddress}/${identity.kaNumber}`, - authorAddress: identity.authorAddress, - attestedAuthorAddress, - publisherAddress, - assertionVersion, - assertionRoot, + kaId: identity.kaId, + ual: identity.ual, + authorAddress: identity.agentAddress as EvmAddressV1, + attestedAuthorAddress: assertion.attestedAuthorAddress, + publisherAddress: assertion.publisherAddress, + assertionVersion: assertion.assertionVersion, + assertionRoot: assertion.assertionRoot, finalizedBlockNumber: session.blockNumber, finalizedBlockHash: session.blockHash, } satisfies FinalizedVmChainCandidateV1); @@ -336,6 +312,79 @@ async function scanPinnedInventory( }); } +async function readFinalizedVmAssertions( + session: StrictCurrentFinalizedEvmSnapshotSessionV1, + knowledgeAssetStorageAddress: EvmAddressV1, + kaIds: readonly bigint[], +): Promise[]> { + const rows: Readonly[] = []; + for (const [ordinal, kaId] of kaIds.entries()) { + const calls = Object.freeze([ + Object.freeze({ + to: knowledgeAssetStorageAddress, + data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( + 'getKnowledgeAssetUpdateContext', + [kaId], + ), + maxReturnBytes: UPDATE_CONTEXT_RETURN_BYTES, + }), + Object.freeze({ + to: knowledgeAssetStorageAddress, + data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData('getLatestMerkleRoot', [kaId]), + maxReturnBytes: UINT256_RETURN_BYTES, + }), + Object.freeze({ + to: knowledgeAssetStorageAddress, + data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( + 'getLatestMerkleRootAuthor', + [kaId], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + }), + Object.freeze({ + to: knowledgeAssetStorageAddress, + data: KNOWLEDGE_ASSET_STORAGE_INTERFACE.encodeFunctionData( + 'getLatestMerkleRootPublisher', + [kaId], + ), + maxReturnBytes: UINT256_RETURN_BYTES, + }), + ] as const); + const encoded = await session.read(calls); + if (!Array.isArray(encoded) || encoded.length !== calls.length) { + throw malformedReturn(`Finalized VM ordinal ${ordinal} assertion batch is incomplete`); + } + const [encodedContext, encodedRoot, encodedAuthor, encodedPublisher] = encoded; + if ( + encodedContext === undefined + || encodedRoot === undefined + || encodedAuthor === undefined + || encodedPublisher === undefined + ) { + throw malformedReturn(`Finalized VM ordinal ${ordinal} is missing assertion results`); + } + rows.push(Object.freeze({ + assertionVersion: decodeAssertionVersion(encodedContext), + assertionRoot: decodeBytes32( + KNOWLEDGE_ASSET_STORAGE_INTERFACE, + 'getLatestMerkleRoot', + encodedRoot, + ), + attestedAuthorAddress: decodeNullableAddress( + KNOWLEDGE_ASSET_STORAGE_INTERFACE, + 'getLatestMerkleRootAuthor', + encodedAuthor, + ), + publisherAddress: decodeNullableAddress( + KNOWLEDGE_ASSET_STORAGE_INTERFACE, + 'getLatestMerkleRootPublisher', + encodedPublisher, + ), + } satisfies FinalizedVmAssertionReadV1)); + } + return Object.freeze(rows); +} + async function readBatches( session: StrictCurrentFinalizedEvmSnapshotSessionV1, calls: readonly StrictCurrentFinalizedEvmReadCallV1[], @@ -382,6 +431,18 @@ function decodeUint256( return BigInt(decoded[0] as bigint); } +function decodeBoolean( + abi: ethers.Interface, + functionName: 'isContextGraphActive', + encoded: string, +): boolean { + const decoded = decodeCanonicalResult(abi, functionName, encoded); + if (typeof decoded[0] !== 'boolean') { + throw malformedReturn(`Finalized VM ${functionName} result is not boolean`); + } + return decoded[0]; +} + function decodeBytes32( abi: ethers.Interface, functionName: 'getLatestMerkleRoot', @@ -429,19 +490,6 @@ function decodeCanonicalResult( } } -function unpackRootlessKaIdentity(kaId: bigint): Readonly<{ - authorAddress: EvmAddressV1; - kaNumber: string; -}> { - const authorValue = kaId >> PACKED_KA_NUMBER_BITS; - if (authorValue === 0n || authorValue >= (1n << 160n)) { - throw malformedReturn('Finalized VM row does not use the rootless packed KA identity'); - } - const authorAddress = `0x${authorValue.toString(16).padStart(40, '0')}` as EvmAddressV1; - const kaNumber = (kaId & PACKED_KA_NUMBER_MASK).toString(10); - return Object.freeze({ authorAddress, kaNumber }); -} - function snapshotConfig(input: unknown): ScannerConfigSnapshotV1 { try { const record = snapshotExactDataRecord(input, CONFIG_KEYS); @@ -497,6 +545,7 @@ function snapshotRequest(input: unknown): Readonly ( + FIXED_SCAN_CALLS + (rows * TOTAL_CALLS_PER_ROW) <= maxCalls + && 1 + + Math.ceil((rows * ID_CALLS_PER_ROW) / maxCallsPerBatch) + + Math.ceil((rows * ASSERTION_CALLS_PER_ROW) / maxCallsPerBatch) + <= maxBatches + ); + let lower = 0; + let upper = Math.max(0, Math.floor((maxCalls - FIXED_SCAN_CALLS) / TOTAL_CALLS_PER_ROW)); + while (lower < upper) { + const candidate = Math.ceil((lower + upper) / 2); + if (fits(candidate)) lower = candidate; + else upper = candidate - 1; + } + return lower; +} + function malformedReturn( message: string, cause?: unknown, @@ -522,9 +593,18 @@ function malformedReturn( // Keep the derived public bound tied to the two snapshot budgets if either is // tightened later; this assertion is module-load local and performs no I/O. if ( - 1 + Math.ceil(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) - + Math.ceil((FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * 4) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) + 1 + + Math.ceil( + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ID_CALLS_PER_ROW) + / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + ) + + Math.ceil( + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ASSERTION_CALLS_PER_ROW) + / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + ) > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 + || FIXED_SCAN_CALLS + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * TOTAL_CALLS_PER_ROW) + > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 ) { - throw new Error('Finalized VM scan row bound exceeds the pinned-snapshot batch budget'); + throw new Error('Finalized VM scan row bound exceeds a pinned-snapshot resource budget'); } diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index 1e9e621119..3195600d6e 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -55,7 +55,7 @@ afterEach(async () => { describe('RFC-64 finalized VM chain scanner', () => { it('pins its complete-row ceiling to both snapshot resource budgets', () => { - const calls = (rows: number) => 1 + (rows * 5); + const calls = (rows: number) => 2 + (rows * 5); const batches = (rows: number) => 1 + Math.ceil(rows / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) + Math.ceil((rows * 4) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1); @@ -72,7 +72,7 @@ describe('RFC-64 finalized VM chain scanner', () => { const calls: Array = []; const snapshot = snapshotStub(async (batch) => { calls.push(batch); - if (calls.length === 1) return [uintResult(2n)]; + if (calls.length === 1) return [boolResult(true), uintResult(2n)]; if (calls.length === 2) return [uintResult(KA_A), uintResult(KA_B)]; if (calls.length === 3) return [ updateContextResult(2n), @@ -137,7 +137,7 @@ describe('RFC-64 finalized VM chain scanner', () => { }, ], }); - expect(calls.map((batch) => batch.length)).toEqual([1, 2, 4, 4]); + expect(calls.map((batch) => batch.length)).toEqual([2, 2, 4, 4]); expect(calls[0]![0]!.to).toBe(CG_STORAGE); expect(calls[1]!.every(({ to }) => to === CG_STORAGE)).toBe(true); expect(calls.slice(2).flat().every(({ to }) => to === KA_STORAGE)).toBe(true); @@ -145,14 +145,15 @@ describe('RFC-64 finalized VM chain scanner', () => { it('returns a canonical empty inventory without issuing ordinal reads', async () => { let batches = 0; + const requestSignal = new AbortController().signal; const scanner = createFinalizedVmChainScannerV1(config(snapshotStub(async () => { batches += 1; - return [uintResult(0n)]; - }))); + return [boolResult(true), uintResult(0n)]; + }, requestSignal))); await expect(scanner({ contextGraphId: CG_ID, - signal: new AbortController().signal, + signal: requestSignal, })).resolves.toMatchObject({ highestFinalizedOrdinal: null, rows: [], @@ -160,11 +161,74 @@ describe('RFC-64 finalized VM chain scanner', () => { expect(batches).toBe(1); }); + it('fails closed when the pinned context graph is inactive', async () => { + const scanner = createFinalizedVmChainScannerV1(config(scriptedSnapshot([ + [boolResult(false), uintResult(0n)], + ]))); + await expect(scanner({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'malformed-return' }); + }); + + it('splits five rows into bounded ordered ID and assertion batches', async () => { + const batchSizes: number[] = []; + const kaIds = Array.from({ length: 5 }, (_, index) => pack(AUTHOR_A, BigInt(index + 1))); + const snapshot = snapshotStub(async (batch) => { + batchSizes.push(batch.length); + return batch.map(({ data }) => { + const selector = data.slice(0, 10); + if (selector === CG_ABI.getFunction('isContextGraphActive')!.selector) { + return boolResult(true); + } + if (selector === CG_ABI.getFunction('getContextGraphKaCount')!.selector) { + return uintResult(5n); + } + if (selector === CG_ABI.getFunction('getContextGraphKaAt')!.selector) { + const [, ordinal] = CG_ABI.decodeFunctionData('getContextGraphKaAt', data); + return uintResult(kaIds[Number(ordinal)]!); + } + if (selector === KA_ABI.getFunction('getKnowledgeAssetUpdateContext')!.selector) { + return updateContextResult(1n); + } + if (selector === KA_ABI.getFunction('getLatestMerkleRoot')!.selector) { + return bytes32Result(ROOT_A); + } + if (selector === KA_ABI.getFunction('getLatestMerkleRootAuthor')!.selector) { + return addressResult(AUTHOR_A); + } + if (selector === KA_ABI.getFunction('getLatestMerkleRootPublisher')!.selector) { + return addressResult(PUBLISHER_A); + } + throw new Error(`Unexpected selector ${selector}`); + }); + }); + const scanner = createFinalizedVmChainScannerV1(config(snapshot)); + + const inventory = await scanner({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + }); + + expect(batchSizes).toEqual([2, 4, 1, 4, 4, 4, 4, 4]); + expect(batchSizes.every((size) => size <= CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1)) + .toBe(true); + expect(inventory.rows.map(({ kaId, ual }) => ({ kaId, ual }))).toEqual( + kaIds.map((kaId, index) => ({ + kaId: kaId.toString(), + ual: `did:dkg:${NETWORK_ID}/${AUTHOR_A}/${index + 1}`, + })), + ); + }); + it('fails before ordinal reads when the finalized inventory exceeds the v1 scope', async () => { let batches = 0; const scanner = createFinalizedVmChainScannerV1(config(snapshotStub(async () => { batches += 1; - return [uintResult(BigInt(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 + 1))]; + return [ + boolResult(true), + uintResult(BigInt(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 + 1)), + ]; }))); await expect(scanner({ @@ -176,10 +240,10 @@ describe('RFC-64 finalized VM chain scanner', () => { it('rejects legacy sequential ids, zero versions, zero roots, and batch shape drift', async () => { const cases: StrictCurrentFinalizedEvmSnapshotScopeV1[] = [ - scriptedSnapshot([[uintResult(1n)], [uintResult(7n)], assertionResults(1n, ROOT_A, AUTHOR_A, PUBLISHER_A)]), - scriptedSnapshot([[uintResult(1n)], [uintResult(KA_A)], assertionResults(0n, ROOT_A, AUTHOR_A, PUBLISHER_A)]), - scriptedSnapshot([[uintResult(1n)], [uintResult(KA_A)], assertionResults(1n, ethers.ZeroHash, AUTHOR_A, PUBLISHER_A)]), - scriptedSnapshot([[uintResult(1n)], []]), + scriptedSnapshot([activeCountResults(1n), [uintResult(7n)], assertionResults(1n, ROOT_A, AUTHOR_A, PUBLISHER_A)]), + scriptedSnapshot([activeCountResults(1n), [uintResult(KA_A)], assertionResults(0n, ROOT_A, AUTHOR_A, PUBLISHER_A)]), + scriptedSnapshot([activeCountResults(1n), [uintResult(KA_A)], assertionResults(1n, ethers.ZeroHash, AUTHOR_A, PUBLISHER_A)]), + scriptedSnapshot([activeCountResults(1n), []]), ]; for (const snapshot of cases) { const scanner = createFinalizedVmChainScannerV1(config(snapshot)); @@ -192,7 +256,7 @@ describe('RFC-64 finalized VM chain scanner', () => { it('keeps absent chain author/publisher evidence explicit and rejects identity drift', async () => { const noEvidence = createFinalizedVmChainScannerV1(config(scriptedSnapshot([ - [uintResult(1n)], + activeCountResults(1n), [uintResult(KA_A)], assertionResults(1n, ROOT_A, ethers.ZeroAddress, ethers.ZeroAddress), ]))); @@ -204,7 +268,7 @@ describe('RFC-64 finalized VM chain scanner', () => { }); const wrongAuthor = createFinalizedVmChainScannerV1(config(scriptedSnapshot([ - [uintResult(1n)], + activeCountResults(1n), [uintResult(KA_A)], assertionResults(1n, ROOT_A, AUTHOR_B, PUBLISHER_A), ]))); @@ -220,7 +284,7 @@ describe('RFC-64 finalized VM chain scanner', () => { blockNumber: BLOCK_NUMBER, blockHash: BLOCK_HASH, read: scriptedRead([ - [uintResult(1n)], + activeCountResults(1n), [uintResult(KA_A)], assertionResults(2n, ROOT_A, AUTHOR_A, PUBLISHER_A), ]), @@ -253,6 +317,10 @@ describe('RFC-64 finalized VM chain scanner', () => { ...config(snapshotStub(async () => [uintResult(0n)])), contextGraphStorageAddress: CG_STORAGE.toUpperCase() as EvmAddressV1, })).toThrow(TypeError); + await expect(malformed({ + contextGraphId: '0' as never, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'rpc-unavailable' }); await expect(malformed({ contextGraphId: '014' as never, signal: new AbortController().signal, @@ -279,7 +347,9 @@ describe('RFC-64 finalized VM chain scanner', () => { const request = call.params[0] as { readonly data?: string }; const data = request.data ?? ''; const selector = data.slice(0, 10); - if (selector === CG_ABI.getFunction('getContextGraphKaCount')!.selector) { + if (selector === CG_ABI.getFunction('isContextGraphActive')!.selector) { + sendResult(response, call, boolResult(true)); + } else if (selector === CG_ABI.getFunction('getContextGraphKaCount')!.selector) { sendResult(response, call, uintResult(2n)); } else if (selector === CG_ABI.getFunction('getContextGraphKaAt')!.selector) { const [, ordinal] = CG_ABI.decodeFunctionData('getContextGraphKaAt', data); @@ -325,7 +395,7 @@ describe('RFC-64 finalized VM chain scanner', () => { { ual: `did:dkg:${NETWORK_ID}/${AUTHOR_B}/9`, assertionVersion: '3', assertionRoot: ROOT_B }, ]); const ethCalls = rpc.calls.filter(({ method }) => method === 'eth_call'); - expect(ethCalls).toHaveLength(11); + expect(ethCalls).toHaveLength(12); expect(ethCalls.every(({ params }) => { const block = params[1] as { readonly blockHash?: unknown; readonly requireCanonical?: unknown }; return block.blockHash === BLOCK_HASH && block.requireCanonical === true; @@ -351,11 +421,16 @@ function sessionConfig() { function snapshotStub( read: StrictCurrentFinalizedEvmSnapshotSessionV1['read'], + expectedSignal?: AbortSignal, ): StrictCurrentFinalizedEvmSnapshotScopeV1 { - return Object.freeze(async (request: { readonly chainId: ChainIdV1 }, consume: ( + return Object.freeze(async (request: { + readonly chainId: ChainIdV1; + readonly signal: AbortSignal; + }, consume: ( session: StrictCurrentFinalizedEvmSnapshotSessionV1, ) => Promise): Promise => { expect(request.chainId).toBe(CHAIN_ID); + if (expectedSignal !== undefined) expect(request.signal).toBe(expectedSignal); return consume(Object.freeze({ chainId: CHAIN_ID, blockNumber: BLOCK_NUMBER, @@ -386,6 +461,14 @@ function uintResult(value: bigint): string { return ABI.encode(['uint256'], [value]); } +function boolResult(value: boolean): string { + return ABI.encode(['bool'], [value]); +} + +function activeCountResults(count: bigint): readonly string[] { + return [boolResult(true), uintResult(count)]; +} + function bytes32Result(value: string): string { return ABI.encode(['bytes32'], [value]); } diff --git a/packages/core/src/ka-content-scope.ts b/packages/core/src/ka-content-scope.ts index 888f550a81..8610933863 100644 --- a/packages/core/src/ka-content-scope.ts +++ b/packages/core/src/ka-content-scope.ts @@ -60,6 +60,12 @@ export interface DeterministicKnowledgeAssetUalParts { readonly kaNumber: string; } +export interface DeterministicRootlessKnowledgeAssetIdentity + extends DeterministicKnowledgeAssetUalParts { + /** Canonical base-10 packed `(uint160(author) << 96) | uint96(number)` id. */ + readonly kaId: string; +} + export class LegacyKnowledgeAssetReadOnlyError extends Error { readonly code = 'LEGACY_KA_READ_ONLY'; @@ -77,6 +83,8 @@ const DETERMINISTIC_KA_UAL_RE = /^did:dkg:([^/]+)\/(0x[0-9a-fA-F]{40})\/([0-9]+) * through the packed identity are valid graph identities. */ export const MAX_KNOWLEDGE_ASSET_NUMBER = (1n << 96n) - 1n; +const MAX_PACKED_ROOTLESS_KNOWLEDGE_ASSET_ID = (1n << 256n) - 1n; +const PACKED_ROOTLESS_KNOWLEDGE_ASSET_NUMBER_BITS = 96n; /** * Parse and canonicalize the deterministic Option-1 UAL used as graph identity. @@ -115,6 +123,30 @@ export function parseDeterministicKnowledgeAssetUal( }; } +/** + * Unpack the canonical rootless on-chain KA id into the same deterministic + * identity used by graph-scoped content. Legacy sequential ids have no author + * bits and therefore fail closed instead of aliasing a graph identity. + */ +export function unpackDeterministicRootlessKnowledgeAssetId( + chainId: string, + kaId: bigint, +): Readonly { + if (typeof kaId !== 'bigint' || kaId < 1n || kaId > MAX_PACKED_ROOTLESS_KNOWLEDGE_ASSET_ID) { + throw new Error('Rootless Knowledge Asset id must be a nonzero uint256'); + } + const authorValue = kaId >> PACKED_ROOTLESS_KNOWLEDGE_ASSET_NUMBER_BITS; + if (authorValue === 0n) { + throw new Error('Rootless Knowledge Asset id does not contain a packed author'); + } + const agentAddress = `0x${authorValue.toString(16).padStart(40, '0')}`; + const kaNumber = (kaId & MAX_KNOWLEDGE_ASSET_NUMBER).toString(10); + const parsed = parseDeterministicKnowledgeAssetUal( + `did:dkg:${chainId}/${agentAddress}/${kaNumber}`, + ); + return Object.freeze({ ...parsed, kaId: kaId.toString(10) }); +} + export function canonicalAssertionVersion( rawVersion: string | number | bigint, ): string { diff --git a/packages/core/test/ka-content-scope.test.ts b/packages/core/test/ka-content-scope.test.ts index 50675eb45b..96971fe476 100644 --- a/packages/core/test/ka-content-scope.test.ts +++ b/packages/core/test/ka-content-scope.test.ts @@ -10,6 +10,7 @@ import { parseDeterministicKnowledgeAssetUal, resolveKnowledgeAssetReadScope, resolveKnowledgeAssetWriteScope, + unpackDeterministicRootlessKnowledgeAssetId, } from '../src/index.js'; const UAL = 'did:dkg:base:8453/0x70997970C51812dc3A010C7d01b50e0d17dc79C8/0007'; @@ -39,6 +40,24 @@ describe('KA content scope', () => { .toThrow(/packed uint96 identity domain/); }); + it('canonically unpacks rootless ids and rejects legacy or out-of-range ids', () => { + const author = 0x70997970c51812dc3a010c7d01b50e0d17dc79c8n; + const packed = (author << 96n) | 7n; + expect(unpackDeterministicRootlessKnowledgeAssetId('base:8453', packed)).toEqual({ + kaId: packed.toString(), + ual: CANONICAL_UAL, + chainId: 'base:8453', + agentAddress: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + kaNumber: '7', + }); + expect(() => unpackDeterministicRootlessKnowledgeAssetId('base:8453', 7n)) + .toThrow(/packed author/); + expect(() => unpackDeterministicRootlessKnowledgeAssetId('base:8453', 0n)) + .toThrow(/nonzero uint256/); + expect(() => unpackDeterministicRootlessKnowledgeAssetId('base:8453', 1n << 256n)) + .toThrow(/nonzero uint256/); + }); + it('derives one stable per-KA graph while assertion version remains explicit', () => { const v1 = createGraphKnowledgeAssetScope(UAL, 1); const v2 = createGraphKnowledgeAssetScope(UAL, 2); From f3221400edf15f9e89b0dfd5ba517ecef1398b7a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 12:18:01 +0200 Subject: [PATCH 240/292] test(chain): close finalized VM scanner review gaps --- .../chain/src/finalized-vm-chain-scanner.ts | 54 +++++++++---------- .../finalized-vm-chain-scanner.unit.test.ts | 28 ++++++++-- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts index 717c0cbbad..1ca054746e 100644 --- a/packages/chain/src/finalized-vm-chain-scanner.ts +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -493,23 +493,11 @@ function decodeCanonicalResult( function snapshotConfig(input: unknown): ScannerConfigSnapshotV1 { try { const record = snapshotExactDataRecord(input, CONFIG_KEYS); - assertNetworkIdV1(record.networkId, 'finalized VM scanner networkId'); - assertCanonicalChainId(record.chainId, 'finalized VM scanner chainId'); - assertCanonicalNonzeroEvmAddress( - record.contextGraphStorageAddress, - 'finalized VM scanner ContextGraphStorage address', - ); - assertCanonicalNonzeroEvmAddress( - record.knowledgeAssetStorageAddress, - 'finalized VM scanner DKGKnowledgeAssets address', - ); + const sessionConfig = snapshotSessionConfigFields(record); if (typeof record.snapshot !== 'function') throw new Error('snapshot is not callable'); const snapshot = record.snapshot as StrictCurrentFinalizedEvmSnapshotScopeV1; return Object.freeze({ - networkId: record.networkId, - chainId: record.chainId, - contextGraphStorageAddress: record.contextGraphStorageAddress, - knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + ...sessionConfig, snapshot, }); } catch (cause) { @@ -520,27 +508,33 @@ function snapshotConfig(input: unknown): ScannerConfigSnapshotV1 { function snapshotSessionConfig(input: unknown): ScannerSessionConfigSnapshotV1 { try { const record = snapshotExactDataRecord(input, SESSION_CONFIG_KEYS); - assertNetworkIdV1(record.networkId, 'finalized VM scanner networkId'); - assertCanonicalChainId(record.chainId, 'finalized VM scanner chainId'); - assertCanonicalNonzeroEvmAddress( - record.contextGraphStorageAddress, - 'finalized VM scanner ContextGraphStorage address', - ); - assertCanonicalNonzeroEvmAddress( - record.knowledgeAssetStorageAddress, - 'finalized VM scanner DKGKnowledgeAssets address', - ); - return Object.freeze({ - networkId: record.networkId, - chainId: record.chainId, - contextGraphStorageAddress: record.contextGraphStorageAddress, - knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, - }); + return snapshotSessionConfigFields(record); } catch (cause) { throw new TypeError('Finalized VM session scanner configuration is invalid', { cause }); } } +function snapshotSessionConfigFields( + record: Record, +): ScannerSessionConfigSnapshotV1 { + assertNetworkIdV1(record.networkId, 'finalized VM scanner networkId'); + assertCanonicalChainId(record.chainId, 'finalized VM scanner chainId'); + assertCanonicalNonzeroEvmAddress( + record.contextGraphStorageAddress, + 'finalized VM scanner ContextGraphStorage address', + ); + assertCanonicalNonzeroEvmAddress( + record.knowledgeAssetStorageAddress, + 'finalized VM scanner DKGKnowledgeAssets address', + ); + return Object.freeze({ + networkId: record.networkId, + chainId: record.chainId, + contextGraphStorageAddress: record.contextGraphStorageAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + }); +} + function snapshotRequest(input: unknown): Readonly { try { const record = snapshotExactDataRecord(input, REQUEST_KEYS); diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index 3195600d6e..7a9d8e178d 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -21,7 +21,7 @@ import { type StrictCurrentFinalizedEvmSnapshotSessionV1, } from '../src/current-finalized-evm-snapshot.js'; import { CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1 } from '../src/current-finalized-evm-read-profile.js'; -import { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from '../src/strict-current-finalized-evm-rpc.js'; +import { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from '../src/strict-current-finalized-evm-snapshot-factory.js'; import { createLoopbackJsonRpcTestHarness, sendJsonRpcError as sendError, @@ -304,9 +304,31 @@ describe('RFC-64 finalized VM chain scanner', () => { }); }); - it('rejects non-canonical ABI bytes and hostile local shapes', async () => { + it('rejects a decodable non-canonical ABI result at the assertion decoder', async () => { + let batches = 0; + const nonCanonicalRoot = `${bytes32Result(ROOT_A).slice(0, 2)}${bytes32Result(ROOT_A).slice(2).toUpperCase()}`; + const scanner = createFinalizedVmChainScannerV1(config(snapshotStub(async () => { + batches += 1; + if (batches === 1) return activeCountResults(1n); + if (batches === 2) return [uintResult(KA_A)]; + return [ + updateContextResult(2n), + nonCanonicalRoot, + addressResult(AUTHOR_A), + addressResult(PUBLISHER_A), + ]; + }))); + + await expect(scanner({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'malformed-return' }); + expect(batches).toBe(3); + }); + + it('rejects incomplete batch shapes and hostile local inputs', async () => { const malformed = createFinalizedVmChainScannerV1(config(snapshotStub(async () => [ - `${uintResult(0n)}00`, + uintResult(0n), ]))); await expect(malformed({ contextGraphId: CG_ID, From 4e4d4b9168ce1150452e45f7895ba185dba5abf8 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 12:46:17 +0200 Subject: [PATCH 241/292] fix(core): type rootless KA network namespace --- packages/core/src/ka-content-scope.ts | 9 +++++++-- packages/core/test/ka-content-scope.test.ts | 12 ++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/core/src/ka-content-scope.ts b/packages/core/src/ka-content-scope.ts index 8610933863..e01d2dadca 100644 --- a/packages/core/src/ka-content-scope.ts +++ b/packages/core/src/ka-content-scope.ts @@ -4,6 +4,10 @@ import { } from './constants.js'; import type { MemoryLayer } from './memory-model.js'; import { assertSafeIri, isSafeIri } from './sparql-safe.js'; +import { + assertNetworkIdV1, + type NetworkIdV1, +} from './sync-wire-identifiers.js'; /** Existing V10 KAs may be resolved through the quarantined read-only path. */ export const LEGACY_ROOT_CONTENT_SCOPE_VERSION = 1 as const; @@ -129,9 +133,10 @@ export function parseDeterministicKnowledgeAssetUal( * bits and therefore fail closed instead of aliasing a graph identity. */ export function unpackDeterministicRootlessKnowledgeAssetId( - chainId: string, + networkId: NetworkIdV1, kaId: bigint, ): Readonly { + assertNetworkIdV1(networkId, 'rootless Knowledge Asset networkId'); if (typeof kaId !== 'bigint' || kaId < 1n || kaId > MAX_PACKED_ROOTLESS_KNOWLEDGE_ASSET_ID) { throw new Error('Rootless Knowledge Asset id must be a nonzero uint256'); } @@ -142,7 +147,7 @@ export function unpackDeterministicRootlessKnowledgeAssetId( const agentAddress = `0x${authorValue.toString(16).padStart(40, '0')}`; const kaNumber = (kaId & MAX_KNOWLEDGE_ASSET_NUMBER).toString(10); const parsed = parseDeterministicKnowledgeAssetUal( - `did:dkg:${chainId}/${agentAddress}/${kaNumber}`, + `did:dkg:${networkId}/${agentAddress}/${kaNumber}`, ); return Object.freeze({ ...parsed, kaId: kaId.toString(10) }); } diff --git a/packages/core/test/ka-content-scope.test.ts b/packages/core/test/ka-content-scope.test.ts index 96971fe476..219ace0fa3 100644 --- a/packages/core/test/ka-content-scope.test.ts +++ b/packages/core/test/ka-content-scope.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import type { NetworkIdV1 } from '../src/index.js'; import { GRAPH_KA_CONTENT_SCOPE_VERSION, LEGACY_ROOT_CONTENT_SCOPE_VERSION, @@ -15,6 +16,7 @@ import { const UAL = 'did:dkg:base:8453/0x70997970C51812dc3A010C7d01b50e0d17dc79C8/0007'; const CANONICAL_UAL = 'did:dkg:base:8453/0x70997970c51812dc3a010c7d01b50e0d17dc79c8/7'; +const NETWORK_ID = 'base:8453' as NetworkIdV1; describe('KA content scope', () => { it('canonicalizes a deterministic UAL and assertion version', () => { @@ -43,19 +45,21 @@ describe('KA content scope', () => { it('canonically unpacks rootless ids and rejects legacy or out-of-range ids', () => { const author = 0x70997970c51812dc3a010c7d01b50e0d17dc79c8n; const packed = (author << 96n) | 7n; - expect(unpackDeterministicRootlessKnowledgeAssetId('base:8453', packed)).toEqual({ + expect(unpackDeterministicRootlessKnowledgeAssetId(NETWORK_ID, packed)).toEqual({ kaId: packed.toString(), ual: CANONICAL_UAL, chainId: 'base:8453', agentAddress: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', kaNumber: '7', }); - expect(() => unpackDeterministicRootlessKnowledgeAssetId('base:8453', 7n)) + expect(() => unpackDeterministicRootlessKnowledgeAssetId(NETWORK_ID, 7n)) .toThrow(/packed author/); - expect(() => unpackDeterministicRootlessKnowledgeAssetId('base:8453', 0n)) + expect(() => unpackDeterministicRootlessKnowledgeAssetId(NETWORK_ID, 0n)) .toThrow(/nonzero uint256/); - expect(() => unpackDeterministicRootlessKnowledgeAssetId('base:8453', 1n << 256n)) + expect(() => unpackDeterministicRootlessKnowledgeAssetId(NETWORK_ID, 1n << 256n)) .toThrow(/nonzero uint256/); + expect(() => unpackDeterministicRootlessKnowledgeAssetId('base/8453' as never, packed)) + .toThrow(/networkId grammar/); }); it('derives one stable per-KA graph while assertion version remains explicit', () => { From 1a8cc2438d0eb86137f83c8dce95fbc222b9b20d Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:02:56 +0200 Subject: [PATCH 242/292] test(chain): prove finalized VM scan budget --- .../finalized-vm-chain-scanner.unit.test.ts | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index 7a9d8e178d..1b06095a22 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -54,18 +54,57 @@ afterEach(async () => { }); describe('RFC-64 finalized VM chain scanner', () => { - it('pins its complete-row ceiling to both snapshot resource budgets', () => { - const calls = (rows: number) => 2 + (rows * 5); - const batches = (rows: number) => 1 - + Math.ceil(rows / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) - + Math.ceil((rows * 4) / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1); - - expect(calls(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1)) - .toBeLessThanOrEqual(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1); - expect(batches(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1)) - .toBeLessThanOrEqual(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1); - expect(batches(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 + 1)) - .toBeGreaterThan(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1); + it('executes its exported row ceiling within both snapshot resource budgets', async () => { + let batches = 0; + let calls = 0; + const snapshot = snapshotStub(async (batch) => { + batches += 1; + calls += batch.length; + return batch.map(({ data }) => { + const selector = data.slice(0, 10); + if (selector === CG_ABI.getFunction('isContextGraphActive')!.selector) { + return boolResult(true); + } + if (selector === CG_ABI.getFunction('getContextGraphKaCount')!.selector) { + return uintResult(BigInt(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1)); + } + if (selector === CG_ABI.getFunction('getContextGraphKaAt')!.selector) { + const [, ordinal] = CG_ABI.decodeFunctionData('getContextGraphKaAt', data); + return uintResult(pack(AUTHOR_A, BigInt(ordinal) + 1n)); + } + if (selector === KA_ABI.getFunction('getKnowledgeAssetUpdateContext')!.selector) { + return updateContextResult(1n); + } + if (selector === KA_ABI.getFunction('getLatestMerkleRoot')!.selector) { + return bytes32Result(ROOT_A); + } + if (selector === KA_ABI.getFunction('getLatestMerkleRootAuthor')!.selector) { + return addressResult(AUTHOR_A); + } + if (selector === KA_ABI.getFunction('getLatestMerkleRootPublisher')!.selector) { + return addressResult(PUBLISHER_A); + } + throw new Error(`Unexpected selector ${selector}`); + }); + }); + const scanner = createFinalizedVmChainScannerV1(config(snapshot)); + + const inventory = await scanner({ + contextGraphId: CG_ID, + signal: new AbortController().signal, + }); + + expect(inventory.rows).toHaveLength(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1); + expect(inventory.rows.at(-1)?.ordinal) + .toBe(String(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 - 1)); + expect(calls).toBe(2 + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * 5)); + expect(batches).toBe( + 1 + + Math.ceil(FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1) + + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + ); + expect(calls).toBeLessThanOrEqual(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1); + expect(batches).toBeLessThanOrEqual(CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1); }); it('derives ordered rootless VM candidates from one pinned snapshot', async () => { From dd287dd0807b87987cb24f35fdb33adf89ca8c5b Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:37:07 +0200 Subject: [PATCH 243/292] fix(chain): preserve transferred KA author evidence --- packages/chain/src/finalized-vm-chain-scanner.ts | 8 -------- .../test/finalized-vm-chain-scanner.unit.test.ts | 15 +++++++++++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts index 1ca054746e..e78cffdb15 100644 --- a/packages/chain/src/finalized-vm-chain-scanner.ts +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -271,14 +271,6 @@ async function scanPinnedInventory( cause, ); } - if ( - assertion.attestedAuthorAddress !== null - && assertion.attestedAuthorAddress !== identity.agentAddress - ) { - throw malformedReturn( - `Finalized VM ordinal ${ordinal} author attestation differs from its packed KA identity`, - ); - } return Object.freeze({ chainId: config.chainId, contractAddress: config.contextGraphStorageAddress, diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index 1b06095a22..d8fe798b3c 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -293,7 +293,7 @@ describe('RFC-64 finalized VM chain scanner', () => { } }); - it('keeps absent chain author/publisher evidence explicit and rejects identity drift', async () => { + it('keeps absent evidence explicit and preserves transferred KA namespace separately', async () => { const noEvidence = createFinalizedVmChainScannerV1(config(scriptedSnapshot([ activeCountResults(1n), [uintResult(KA_A)], @@ -306,15 +306,22 @@ describe('RFC-64 finalized VM chain scanner', () => { rows: [{ attestedAuthorAddress: null, publisherAddress: null }], }); - const wrongAuthor = createFinalizedVmChainScannerV1(config(scriptedSnapshot([ + const transferredUpdate = createFinalizedVmChainScannerV1(config(scriptedSnapshot([ activeCountResults(1n), [uintResult(KA_A)], assertionResults(1n, ROOT_A, AUTHOR_B, PUBLISHER_A), ]))); - await expect(wrongAuthor({ + await expect(transferredUpdate({ contextGraphId: CG_ID, signal: new AbortController().signal, - })).rejects.toMatchObject({ code: 'malformed-return' }); + })).resolves.toMatchObject({ + rows: [{ + kaId: KA_A.toString(), + ual: `did:dkg:${NETWORK_ID}/${AUTHOR_A}/7`, + authorAddress: AUTHOR_A, + attestedAuthorAddress: AUTHOR_B, + }], + }); }); it('scans inside a caller-owned snapshot session for same-anchor runtime composition', async () => { From 8031d88e7553d818686a69a6a8dc30d240955d64 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:54:38 +0200 Subject: [PATCH 244/292] test(chain): cover snapshot capability preflight --- packages/chain/test/finalized-vm-chain-scanner.unit.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index d8fe798b3c..bc48f3c72e 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -415,7 +415,9 @@ describe('RFC-64 finalized VM chain scanner', () => { const request = call.params[0] as { readonly data?: string }; const data = request.data ?? ''; const selector = data.slice(0, 10); - if (selector === CG_ABI.getFunction('isContextGraphActive')!.selector) { + if (data === '0x') { + sendResult(response, call, '0x'); + } else if (selector === CG_ABI.getFunction('isContextGraphActive')!.selector) { sendResult(response, call, boolResult(true)); } else if (selector === CG_ABI.getFunction('getContextGraphKaCount')!.selector) { sendResult(response, call, uintResult(2n)); @@ -463,7 +465,7 @@ describe('RFC-64 finalized VM chain scanner', () => { { ual: `did:dkg:${NETWORK_ID}/${AUTHOR_B}/9`, assertionVersion: '3', assertionRoot: ROOT_B }, ]); const ethCalls = rpc.calls.filter(({ method }) => method === 'eth_call'); - expect(ethCalls).toHaveLength(12); + expect(ethCalls).toHaveLength(13); expect(ethCalls.every(({ params }) => { const block = params[1] as { readonly blockHash?: unknown; readonly requireCanonical?: unknown }; return block.blockHash === BLOCK_HASH && block.requireCanonical === true; From 1f64ca05d6236c8b2a03eb0ea884a839e5a4da15 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 12:18:41 +0200 Subject: [PATCH 245/292] feat(agent): compose authorized finalized VM placements --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/index.ts | 1 + .../src/rfc64/finalized-vm-composer-v1.ts | 500 ++++++++++++++++++ .../rfc64-finalized-vm-composer-v1.test.ts | 426 +++++++++++++++ packages/agent/vitest.rfc64-unit-tests.ts | 1 + 6 files changed, 930 insertions(+) create mode 100644 packages/agent/src/rfc64/finalized-vm-composer-v1.ts create mode 100644 packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 19364a2f77..7cfa17881f 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -26,6 +26,7 @@ "./dist/rfc64/catalog-authority-config-v1.js": null, "./dist/rfc64/catalog-transport-authorization-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, + "./dist/rfc64/finalized-vm-composer-v1.js": null, "./dist/rfc64/ka-bundle-store-v1-internal.js": null, "./dist/rfc64/ka-bundle-store-v1.js": null, "./dist/rfc64/open-catalog-policy-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 2db6284cc7..1d19572495 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -84,6 +84,7 @@ const blockedRfc64Modules = [ 'control-object-store-v1-internal.js', 'control-object-store-v1.js', 'durable-file-store-v1.js', + 'finalized-vm-composer-v1.js', 'ka-bundle-store-v1-internal.js', 'ka-bundle-store-v1.js', 'open-catalog-policy-v1.js', diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 73242508d4..22dd13a37b 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -35,6 +35,7 @@ export { type VerifyAgentDelegationOptions, } from './auth/agent-delegation.js'; export * from './rfc64/catalog-row-authorship.js'; +export * from './rfc64/finalized-vm-composer-v1.js'; export * from './rfc64/author-catalog-producer.js'; export * from './rfc64/public-catalog-transport-v1.js'; export * from './rfc64/open-catalog-policy-v1.js'; diff --git a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts new file mode 100644 index 0000000000..2e48465090 --- /dev/null +++ b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts @@ -0,0 +1,500 @@ +import { + FinalizedVmSetAccumulatorV1, + assertCanonicalChainId, + assertCanonicalDecimalU256, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalKaId, + assertContextGraphIdV1, + assertNetworkIdV1, + assertSubGraphNameV1, + readVerifiedCatalogSealBindingV1, + unpackDeterministicRootlessKnowledgeAssetId, + type ContextGraphIdV1, + type FinalizedVmSetEvidenceV1, + type FinalizedVmSetRowV1, + type SubGraphNameV1, + type VerifiedCatalogSealBindingV1, +} from '@origintrail-official/dkg-core'; +/* + * These are scanner outputs, not trusted casts. The composer revalidates the + * complete structural inventory before consuming either capability set. + */ +import { + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + type FinalizedVmChainCandidateV1, + type FinalizedVmChainInventoryV1, +} from '@origintrail-official/dkg-chain'; + +import { + readVerifiedAuthorCatalogRowAuthorshipV1, + type VerifiedAuthorCatalogRowAuthorshipV1, +} from './catalog-row-authorship.js'; + +const COMPOSITION_KEYS = ['catalogLane', 'inventory', 'placements'] as const; +const CATALOG_LANE_KEYS = ['contextGraphId', 'subGraphName'] as const; +const PLACEMENT_KEYS = ['authorship', 'sealBinding'] as const; +const INVENTORY_KEYS = [ + 'chainId', + 'contextGraphId', + 'contractAddress', + 'finalizedBlockHash', + 'finalizedBlockNumber', + 'highestFinalizedOrdinal', + 'knowledgeAssetStorageAddress', + 'networkId', + 'rows', +] as const; +const CANDIDATE_KEYS = [ + 'assertionRoot', + 'assertionVersion', + 'attestedAuthorAddress', + 'authorAddress', + 'chainId', + 'contractAddress', + 'finalizedBlockHash', + 'finalizedBlockNumber', + 'kaId', + 'knowledgeAssetStorageAddress', + 'ordinal', + 'publisherAddress', + 'ual', +] as const; +const ZERO_ADDRESS = `0x${'00'.repeat(20)}`; + +export interface FinalizedVmCatalogLaneV1 { + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; +} + +/** Two process-local capabilities proving one author-authorized placed catalog row. */ +export interface FinalizedVmPlacementEvidenceV1 { + readonly authorship: VerifiedAuthorCatalogRowAuthorshipV1; + readonly sealBinding: VerifiedCatalogSealBindingV1; +} + +export interface ComposeFinalizedVmSetRequestV1 { + readonly catalogLane: FinalizedVmCatalogLaneV1; + readonly inventory: FinalizedVmChainInventoryV1; + readonly placements: readonly FinalizedVmPlacementEvidenceV1[]; +} + +export interface ComposedFinalizedVmSetV1 { + readonly catalogLane: Readonly; + readonly evidence: Readonly; + readonly rows: readonly Readonly[]; +} + +export type FinalizedVmCompositionErrorCodeV1 = + | 'finalized-vm-composition-input' + | 'finalized-vm-composition-inventory' + | 'finalized-vm-composition-placement' + | 'finalized-vm-composition-mismatch' + | 'finalized-vm-composition-duplicate'; + +export class FinalizedVmCompositionErrorV1 extends Error { + constructor( + readonly code: FinalizedVmCompositionErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'FinalizedVmCompositionErrorV1'; + } +} + +/** + * Join author-authorized catalog placement to a same-anchor finalized chain inventory. + * + * Placement rows may be a strict subset of the CG-wide on-chain inventory because + * one catalog lane can be the root or one named subgraph. Every supplied placement + * must resolve exactly once; output retains the authoritative on-chain ordinal order. + */ +export function composeFinalizedVmSetV1( + untrustedRequest: ComposeFinalizedVmSetRequestV1, +): Readonly { + const request = snapshotRecord( + untrustedRequest, + COMPOSITION_KEYS, + 'finalized VM composition request', + 'finalized-vm-composition-input', + ); + const catalogLane = snapshotCatalogLane(request.catalogLane); + const inventory = snapshotInventory(request.inventory); + let placements: readonly unknown[]; + try { + placements = snapshotDenseArray( + request.placements, + 'finalized VM placements', + inventory.rows.length, + ); + } catch (cause) { + fail( + 'finalized-vm-composition-placement', + 'finalized VM placements are not a bounded dense data-only array', + cause, + ); + } + + const placementsByKaId = new Map>(); + for (const [index, untrustedPlacement] of placements.entries()) { + const placement = snapshotPlacement(untrustedPlacement, index); + let authorship: ReturnType; + let sealBinding: ReturnType; + try { + authorship = readVerifiedAuthorCatalogRowAuthorshipV1(placement.authorship); + sealBinding = readVerifiedCatalogSealBindingV1(placement.sealBinding); + } catch (cause) { + fail( + 'finalized-vm-composition-placement', + `placement ${index} was not minted by the catalog authority verifiers`, + cause, + ); + } + if ( + authorship.contextGraphId !== catalogLane.contextGraphId + || authorship.subGraphName !== catalogLane.subGraphName + ) { + fail( + 'finalized-vm-composition-mismatch', + `placement ${index} belongs to a different catalog lane`, + ); + } + if ( + authorship.catalogScopeDigest !== sealBinding.catalogScopeDigest + || authorship.catalogRowDigest !== sealBinding.catalogRowDigest + || authorship.networkId !== sealBinding.networkId + || authorship.authorAddress !== sealBinding.authorAddress + || authorship.row.kaId !== sealBinding.kaId + || authorship.row.assertionVersion !== sealBinding.assertionVersion + ) { + fail( + 'finalized-vm-composition-mismatch', + `placement ${index} authorship and seal capabilities do not close over one row`, + ); + } + if (placementsByKaId.has(sealBinding.kaId)) { + fail( + 'finalized-vm-composition-duplicate', + `catalog lane contains duplicate placement evidence for KA ${sealBinding.kaId}`, + ); + } + placementsByKaId.set(sealBinding.kaId, Object.freeze(placement)); + } + + const scope = Object.freeze({ + networkId: inventory.networkId, + chainId: inventory.chainId, + contractAddress: inventory.contractAddress, + }); + const accumulator = new FinalizedVmSetAccumulatorV1(scope); + const rows: Readonly[] = []; + for (const candidate of inventory.rows) { + const placement = placementsByKaId.get(candidate.kaId); + if (placement === undefined) continue; + const authorship = readVerifiedAuthorCatalogRowAuthorshipV1(placement.authorship); + const sealBinding = readVerifiedCatalogSealBindingV1(placement.sealBinding); + assertCandidateMatchesPlacement(candidate, inventory, authorship, sealBinding); + placementsByKaId.delete(candidate.kaId); + + const row = Object.freeze({ + chainId: candidate.chainId, + contractAddress: candidate.contractAddress, + ordinal: candidate.ordinal, + ual: candidate.ual, + authorAddress: candidate.authorAddress, + assertionVersion: candidate.assertionVersion, + assertionRoot: candidate.assertionRoot, + finalizedBlockNumber: candidate.finalizedBlockNumber, + finalizedBlockHash: candidate.finalizedBlockHash, + // The row digest commits catalogScopeDigest, whose exact scope includes + // contextGraphId, subGraphName, and authorAddress, plus the selected row. + placementEvidenceDigest: authorship.catalogRowDigest, + } satisfies FinalizedVmSetRowV1); + accumulator.append(row); + rows.push(row); + } + if (placementsByKaId.size !== 0) { + const [missingKaId] = placementsByKaId.keys(); + fail( + 'finalized-vm-composition-mismatch', + `catalog placement KA ${missingKaId} is absent from the finalized chain inventory`, + ); + } + + return Object.freeze({ + catalogLane, + evidence: accumulator.finalize(), + rows: Object.freeze(rows), + }); +} + +function assertCandidateMatchesPlacement( + candidate: Readonly, + inventory: Readonly, + authorship: ReturnType, + sealBinding: ReturnType, +): void { + const seal = sealBinding.seal; + if ( + candidate.chainId !== seal.assertedAtChainId + || candidate.knowledgeAssetStorageAddress !== seal.assertedAtKav10Address + || candidate.kaId !== sealBinding.kaId + || candidate.ual !== seal.kaUal + || candidate.authorAddress !== sealBinding.authorAddress + || candidate.assertionVersion !== seal.assertionVersion + || candidate.assertionRoot !== seal.assertionMerkleRoot + || candidate.attestedAuthorAddress === null + || candidate.attestedAuthorAddress !== seal.authorAddress + || candidate.publisherAddress === null + || authorship.networkId !== inventory.networkId + ) { + fail( + 'finalized-vm-composition-mismatch', + `catalog placement for KA ${candidate.kaId} differs from finalized chain truth`, + ); + } +} + +function snapshotCatalogLane(input: unknown): Readonly { + const record = snapshotRecord( + input, + CATALOG_LANE_KEYS, + 'finalized VM catalog lane', + 'finalized-vm-composition-input', + ); + try { + assertContextGraphIdV1(record.contextGraphId, 'catalogLane.contextGraphId'); + if (record.subGraphName !== null) { + assertSubGraphNameV1(record.subGraphName, 'catalogLane.subGraphName'); + } + } catch (cause) { + fail('finalized-vm-composition-input', 'catalog lane is not canonical', cause); + } + return Object.freeze({ + contextGraphId: record.contextGraphId, + subGraphName: record.subGraphName, + }) as Readonly; +} + +function snapshotInventory(input: unknown): Readonly { + const record = snapshotRecord( + input, + INVENTORY_KEYS, + 'finalized VM chain inventory', + 'finalized-vm-composition-inventory', + ); + let rows: readonly Readonly[]; + try { + assertNetworkIdV1(record.networkId, 'inventory.networkId'); + assertCanonicalDecimalU256(record.contextGraphId, 'inventory.contextGraphId'); + assertCanonicalChainId(record.chainId, 'inventory.chainId'); + assertNonzeroAddress(record.contractAddress, 'inventory.contractAddress'); + assertNonzeroAddress( + record.knowledgeAssetStorageAddress, + 'inventory.knowledgeAssetStorageAddress', + ); + assertCanonicalDecimalU64(record.finalizedBlockNumber, 'inventory.finalizedBlockNumber'); + assertCanonicalDigest(record.finalizedBlockHash, 'inventory.finalizedBlockHash'); + if (record.highestFinalizedOrdinal !== null) { + assertCanonicalDecimalU64( + record.highestFinalizedOrdinal, + 'inventory.highestFinalizedOrdinal', + ); + } + const untrustedRows = snapshotDenseArray( + record.rows, + 'finalized VM inventory rows', + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + ); + rows = Object.freeze(untrustedRows.map((row, index) => snapshotCandidate(row, index, record))); + } catch (cause) { + if (cause instanceof FinalizedVmCompositionErrorV1) throw cause; + fail('finalized-vm-composition-inventory', 'chain inventory is not canonical', cause); + } + + const expectedHighest = rows.length === 0 ? null : String(rows.length - 1); + if (record.highestFinalizedOrdinal !== expectedHighest) { + fail( + 'finalized-vm-composition-inventory', + 'chain inventory highest ordinal does not match its dense indexed rows', + ); + } + return Object.freeze({ + networkId: record.networkId, + contextGraphId: record.contextGraphId, + chainId: record.chainId, + contractAddress: record.contractAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + finalizedBlockNumber: record.finalizedBlockNumber, + finalizedBlockHash: record.finalizedBlockHash, + highestFinalizedOrdinal: record.highestFinalizedOrdinal, + rows, + }) as Readonly; +} + +function snapshotCandidate( + input: unknown, + index: number, + inventory: Record, +): Readonly { + const record = snapshotRecord( + input, + CANDIDATE_KEYS, + `finalized VM candidate ${index}`, + 'finalized-vm-composition-inventory', + ); + try { + assertCanonicalChainId(record.chainId, `candidate ${index} chainId`); + assertNonzeroAddress(record.contractAddress, `candidate ${index} contractAddress`); + assertNonzeroAddress( + record.knowledgeAssetStorageAddress, + `candidate ${index} knowledgeAssetStorageAddress`, + ); + assertCanonicalDecimalU64(record.ordinal, `candidate ${index} ordinal`); + assertCanonicalKaId(record.kaId, `candidate ${index} kaId`); + assertNonzeroAddress(record.authorAddress, `candidate ${index} authorAddress`); + assertNullableNonzeroAddress( + record.attestedAuthorAddress, + `candidate ${index} attestedAuthorAddress`, + ); + assertNullableNonzeroAddress(record.publisherAddress, `candidate ${index} publisherAddress`); + assertCanonicalDecimalU64(record.assertionVersion, `candidate ${index} assertionVersion`); + assertCanonicalDigest(record.assertionRoot, `candidate ${index} assertionRoot`); + assertCanonicalDecimalU64( + record.finalizedBlockNumber, + `candidate ${index} finalizedBlockNumber`, + ); + assertCanonicalDigest(record.finalizedBlockHash, `candidate ${index} finalizedBlockHash`); + const identity = unpackDeterministicRootlessKnowledgeAssetId( + inventory.networkId as never, + BigInt(record.kaId as string), + ); + if (record.ual !== identity.ual || record.authorAddress !== identity.agentAddress) { + throw new Error(`candidate ${index} identity differs from its packed KA id`); + } + if (record.assertionVersion === '0' || record.assertionRoot === `0x${'00'.repeat(32)}`) { + throw new Error(`candidate ${index} assertion state must be nonzero`); + } + } catch (cause) { + fail('finalized-vm-composition-inventory', `candidate ${index} is not canonical`, cause); + } + if ( + record.ordinal !== String(index) + || record.chainId !== inventory.chainId + || record.contractAddress !== inventory.contractAddress + || record.knowledgeAssetStorageAddress !== inventory.knowledgeAssetStorageAddress + || record.finalizedBlockNumber !== inventory.finalizedBlockNumber + || record.finalizedBlockHash !== inventory.finalizedBlockHash + ) { + fail( + 'finalized-vm-composition-inventory', + `candidate ${index} differs from the inventory lane or pinned anchor`, + ); + } + return Object.freeze({ ...record }) as unknown as Readonly; +} + +function snapshotPlacement(input: unknown, index: number): FinalizedVmPlacementEvidenceV1 { + const record = snapshotRecord( + input, + PLACEMENT_KEYS, + `finalized VM placement ${index}`, + 'finalized-vm-composition-placement', + ); + return { + authorship: record.authorship as VerifiedAuthorCatalogRowAuthorshipV1, + sealBinding: record.sealBinding as VerifiedCatalogSealBindingV1, + }; +} + +function snapshotDenseArray( + input: unknown, + label: string, + maxLength = Number.MAX_SAFE_INTEGER, +): readonly unknown[] { + if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { + throw new Error(`${label} must be an ordinary array`); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); + const length = lengthDescriptor?.value; + if (!Number.isSafeInteger(length) || length < 0 || length > maxLength) { + throw new Error(`${label} length is invalid`); + } + const ownKeys = Reflect.ownKeys(input); + if (ownKeys.length !== length + 1) throw new Error(`${label} must be dense and data-only`); + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new Error(`${label} entries must be enumerable data properties`); + } + snapshot.push(descriptor.value); + } + return Object.freeze(snapshot); +} + +function snapshotRecord( + input: unknown, + expectedKeys: readonly string[], + label: string, + code: Code, +): Record { + try { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('not a record'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) throw new Error('not plain'); + const actualKeys = Reflect.ownKeys(input); + const expected = new Set(expectedKeys); + if ( + actualKeys.length !== expectedKeys.length + || actualKeys.some((key) => typeof key !== 'string' || !expected.has(key)) + ) { + throw new Error('unknown or missing fields'); + } + const snapshot = Object.create(null) as Record; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new Error('fields must be enumerable data properties'); + } + snapshot[key] = descriptor.value; + } + return snapshot; + } catch (cause) { + fail(code, `${label} is not a closed data-only record`, cause); + } +} + +function assertNullableNonzeroAddress(value: unknown, label: string): void { + if (value === null) return; + assertNonzeroAddress(value, label); +} + +function assertNonzeroAddress(value: unknown, label: string): void { + assertCanonicalEvmAddress(value, label); + if (value === ZERO_ADDRESS) throw new Error(`${label} must be nonzero`); +} + +function fail( + code: FinalizedVmCompositionErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new FinalizedVmCompositionErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts new file mode 100644 index 0000000000..c7c251e3e5 --- /dev/null +++ b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts @@ -0,0 +1,426 @@ +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + computeAuthorCatalogScopeDigestV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + computeControlObjectDigestHex, + verifyAuthorCatalogDirectoryPathV1, + verifyCatalogSealBindingV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type CanonicalGraphScopedAuthorSealV1, + type Digest32V1, + type EvmAddressV1, + type FinalizedVmSetRowV1, + type KaIdV1, + type NetworkIdV1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + verifyControlEnvelopeIssuerSignatureV1, + type FinalizedVmChainInventoryV1, +} from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; +import { describe, expect, it } from 'vitest'; + +import { + FinalizedVmCompositionErrorV1, + composeFinalizedVmSetV1, + type ComposeFinalizedVmSetRequestV1, + type FinalizedVmPlacementEvidenceV1, +} from '../src/rfc64/finalized-vm-composer-v1.js'; +import { + readVerifiedAuthorCatalogRowAuthorshipV1, + verifyAuthorCatalogRowAuthorshipV1, +} from '../src/rfc64/catalog-row-authorship.js'; + +const AUTHOR_WALLET = new ethers.Wallet(`0x${'11'.repeat(32)}`); +const CATALOG_WALLET = new ethers.Wallet(`0x${'22'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const CATALOG_ISSUER = CATALOG_WALLET.address.toLowerCase() as EvmAddressV1; +const NETWORK_ID = 'otp:20430' as NetworkIdV1; +const CHAIN_ID = '20430' as const; +const CONTEXT_GRAPH_NAME = 'agent-blackbox-vm' as const; +const ON_CHAIN_CONTEXT_GRAPH_ID = '14' as const; +const CG_STORAGE = `0x${'33'.repeat(20)}` as EvmAddressV1; +const KA_STORAGE = `0x${'44'.repeat(20)}` as EvmAddressV1; +const GOVERNANCE_CONTRACT = `0x${'55'.repeat(20)}` as EvmAddressV1; +const PUBLISHER = `0x${'66'.repeat(20)}` as EvmAddressV1; +const BLOCK_HASH = `0x${'77'.repeat(32)}` as Digest32V1; +const ROOT_1 = `0x${'88'.repeat(32)}` as Digest32V1; +const ROOT_2 = `0x${'99'.repeat(32)}` as Digest32V1; +const ZERO_DIGEST = `0x${'00'.repeat(32)}` as Digest32V1; +const KA_1 = packKaId(1n); +const KA_2 = packKaId(2n); + +describe('RFC-64 finalized VM placement composition', () => { + it('joins an authorized root-lane subset in finalized chain ordinal order', async () => { + const placement = await createPlacement(KA_2, ROOT_2); + const request = requestFor([placement]); + + const composed = composeFinalizedVmSetV1(request); + const authorship = readVerifiedAuthorCatalogRowAuthorshipV1(placement.authorship); + + expect(composed.catalogLane).toEqual({ + contextGraphId: CONTEXT_GRAPH_NAME, + subGraphName: null, + }); + expect(composed.rows).toEqual([{ + chainId: CHAIN_ID, + contractAddress: CG_STORAGE, + ordinal: '1', + ual: ual(2n), + authorAddress: AUTHOR, + assertionVersion: '2', + assertionRoot: ROOT_2, + finalizedBlockNumber: '123', + finalizedBlockHash: BLOCK_HASH, + placementEvidenceDigest: authorship.catalogRowDigest, + } satisfies FinalizedVmSetRowV1]); + expect(composed.evidence).toMatchObject({ + rowCount: '1', + highestFinalizedOrdinal: '1', + scope: { + networkId: NETWORK_ID, + chainId: CHAIN_ID, + contractAddress: CG_STORAGE, + }, + }); + expect(Object.isFrozen(composed)).toBe(true); + expect(Object.isFrozen(composed.rows)).toBe(true); + expect(Object.isFrozen(composed.rows[0])).toBe(true); + }); + + it('fails closed on structural capability forgeries and duplicate placement', async () => { + const placement = await createPlacement(KA_2, ROOT_2); + expectCode(() => composeFinalizedVmSetV1(requestFor([{ + ...placement, + authorship: Object.freeze(Object.create(null)) as never, + }])), 'finalized-vm-composition-placement'); + expectCode( + () => composeFinalizedVmSetV1(requestFor([placement, placement])), + 'finalized-vm-composition-duplicate', + ); + }); + + it('binds lane, author attestation, root, publisher, and deployment to chain truth', async () => { + const placement = await createPlacement(KA_2, ROOT_2); + const base = requestFor([placement]); + const mutations: Array<(row: Record) => void> = [ + (row) => { row.assertionRoot = ROOT_1; }, + (row) => { row.attestedAuthorAddress = null; }, + (row) => { row.publisherAddress = null; }, + (row) => { row.knowledgeAssetStorageAddress = `0x${'ab'.repeat(20)}`; }, + ]; + for (const mutate of mutations) { + const inventory = structuredClone(base.inventory) as unknown as Record; + const rows = inventory.rows as Array>; + mutate(rows[1]!); + if (rows[1]!.knowledgeAssetStorageAddress !== KA_STORAGE) { + inventory.knowledgeAssetStorageAddress = rows[1]!.knowledgeAssetStorageAddress; + rows[0]!.knowledgeAssetStorageAddress = rows[1]!.knowledgeAssetStorageAddress; + } + expectCode( + () => composeFinalizedVmSetV1({ ...base, inventory } as never), + 'finalized-vm-composition-mismatch', + ); + } + + expectCode(() => composeFinalizedVmSetV1({ + ...base, + catalogLane: { contextGraphId: 'another-graph', subGraphName: null }, + } as never), 'finalized-vm-composition-mismatch'); + }); + + it('rejects placements absent from the finalized inventory and malformed unplaced rows', async () => { + const placement = await createPlacement(KA_2, ROOT_2); + const base = requestFor([placement]); + const firstOnly = { + ...structuredClone(base.inventory), + highestFinalizedOrdinal: '0', + rows: [structuredClone(base.inventory.rows[0])], + }; + expectCode( + () => composeFinalizedVmSetV1({ ...base, inventory: firstOnly } as never), + 'finalized-vm-composition-mismatch', + ); + + const malformed = structuredClone(base.inventory); + (malformed.rows[0] as { ual: string }).ual = ual(999n); + expectCode( + () => composeFinalizedVmSetV1({ ...base, inventory: malformed } as never), + 'finalized-vm-composition-inventory', + ); + }); + + it('accepts an empty placement lane without weakening inventory validation', () => { + const composed = composeFinalizedVmSetV1(requestFor([])); + expect(composed.rows).toEqual([]); + expect(composed.evidence).toMatchObject({ + rowCount: '0', + highestFinalizedOrdinal: null, + }); + }); +}); + +function requestFor( + placements: readonly FinalizedVmPlacementEvidenceV1[], +): ComposeFinalizedVmSetRequestV1 { + return { + catalogLane: { + contextGraphId: CONTEXT_GRAPH_NAME, + subGraphName: null, + }, + inventory: inventory(), + placements: [...placements], + }; +} + +function inventory(): FinalizedVmChainInventoryV1 { + return { + networkId: NETWORK_ID, + contextGraphId: ON_CHAIN_CONTEXT_GRAPH_ID, + chainId: CHAIN_ID, + contractAddress: CG_STORAGE, + knowledgeAssetStorageAddress: KA_STORAGE, + finalizedBlockNumber: '123', + finalizedBlockHash: BLOCK_HASH, + highestFinalizedOrdinal: '1', + rows: [ + candidate(KA_1, 0, ROOT_1, '1'), + candidate(KA_2, 1, ROOT_2, '2'), + ], + }; +} + +function candidate( + kaId: KaIdV1, + ordinal: number, + assertionRoot: Digest32V1, + assertionVersion: string, +): FinalizedVmChainInventoryV1['rows'][number] { + const kaNumber = BigInt(kaId) & ((1n << 96n) - 1n); + return { + chainId: CHAIN_ID, + contractAddress: CG_STORAGE, + knowledgeAssetStorageAddress: KA_STORAGE, + ordinal: String(ordinal) as never, + kaId, + ual: ual(kaNumber), + authorAddress: AUTHOR, + attestedAuthorAddress: AUTHOR, + publisherAddress: PUBLISHER, + assertionVersion: assertionVersion as never, + assertionRoot, + finalizedBlockNumber: '123', + finalizedBlockHash: BLOCK_HASH, + }; +} + +async function createPlacement( + kaId: KaIdV1, + assertionRoot: Digest32V1, +): Promise { + const scope = { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_NAME, + governanceChainId: CHAIN_ID, + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1; + const seal = { + assertionMerkleRoot: assertionRoot, + authorAddress: AUTHOR, + authorAttestationR: `0x${'aa'.repeat(32)}`, + authorAttestationVS: `0x${'bb'.repeat(32)}`, + authorSchemeVersion: '1', + assertedAtChainId: CHAIN_ID, + assertedAtKav10Address: KA_STORAGE, + reservedKaId: kaId, + assertionFinalizedAt: '2026-07-22T08:00:00.000Z', + contentScopeVersion: '2', + kaUal: ual(BigInt(kaId) & ((1n << 96n) - 1n)), + assertionVersion: '2', + publicTripleCount: '10', + privateTripleCount: '0', + privateMerkleRoot: null, + } as CanonicalGraphScopedAuthorSealV1; + const row = { + kaId, + assertionCoordinate: 'vm-fixture', + assertionVersion: '2', + projectionId: 'cg-shared-v1', + projectionDigest: ZERO_DIGEST, + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), + transfer: { + codec: 'dkg-ka-bundle-v1', + projectionId: 'cg-shared-v1', + projectionDigest: ZERO_DIGEST, + byteLength: '16', + chunkSize: '262144', + chunkCount: '1', + blobDigest: `0x${'cc'.repeat(32)}`, + chunkTreeRoot: `0x${'dd'.repeat(32)}`, + }, + } as AuthorCatalogRowV1; + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + + const delegation = await signEnvelope({ + issuer: AUTHOR, + objectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + payload: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_NAME, + governanceChainId: CHAIN_ID, + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + previousDelegationDigest: null, + catalogIssuerKey: CATALOG_ISSUER, + authorAuthorityEvidenceDigest: null, + effectiveAt: '1700000000000', + expiresAt: '1700000120000', + }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, AUTHOR_WALLET) as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + + const bucketPayload = { + catalogScopeDigest: scopeDigest, + era: '0', + bucketCount: '1', + bucketId: '0', + rows: [row], + }; + const bucket = await signEnvelope({ + issuer: CATALOG_ISSUER, + objectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + payload: bucketPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, CATALOG_WALLET) as SignedAuthorCatalogBucketEnvelopeV1; + const directory = await signEnvelope({ + issuer: CATALOG_ISSUER, + objectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + payload: { + catalogScopeDigest: scopeDigest, + era: '0', + level: '0', + firstBucketId: '0', + entries: [{ + bucketId: '0', + bucketDigest: bucket.objectDigest, + rowCount: '1', + byteLength: String(canonicalBytes(bucketPayload).byteLength), + }], + }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, CATALOG_WALLET) as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + const head = await signEnvelope({ + issuer: CATALOG_ISSUER, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_NAME, + governanceChainId: CHAIN_ID, + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + catalogIssuerDelegationDigest: delegation.objectDigest, + era: '0', + version: '0', + previousHeadDigest: null, + bucketCount: '1', + totalRows: '1', + directoryHeight: '0', + directoryRootDigest: directory.objectDigest, + issuedAt: '1700000000123', + }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, CATALOG_WALLET) as SignedAuthorCatalogHeadEnvelopeV1; + + const authorship = verifyAuthorCatalogRowAuthorshipV1({ + catalogIssuerDelegation: delegation, + catalogIssuerDelegationSignature: await verifyControlEnvelopeIssuerSignatureV1(delegation), + parentAuthorAgentEvidence: null, + catalogHead: head, + catalogHeadSignature: await verifyControlEnvelopeIssuerSignatureV1(head), + directoryPathEnvelopes: [directory], + directoryPathSignatures: [await verifyControlEnvelopeIssuerSignatureV1(directory)], + directoryPathProof: verifyAuthorCatalogDirectoryPathV1(head, [directory], '0'), + catalogBucket: bucket, + catalogBucketSignature: await verifyControlEnvelopeIssuerSignatureV1(bucket), + targetKaId: kaId, + }); + const sealBinding = verifyCatalogSealBindingV1( + scope, + row, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1(seal), + { + networkId: NETWORK_ID, + assertedAtChainId: CHAIN_ID, + assertedAtKav10Address: KA_STORAGE, + }, + ); + return { authorship, sealBinding }; +} + +async function signEnvelope( + unsigned: UnsignedControlEnvelopeV1, + wallet: ethers.Wallet, +): Promise { + const objectDigest = computeControlObjectDigestHex(unsigned); + return { + ...unsigned, + objectDigest, + signature: await wallet.signMessage(ethers.getBytes(objectDigest)), + }; +} + +function canonicalBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(canonicalJson(value)); +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`; +} + +function packKaId(kaNumber: bigint): KaIdV1 { + return ((BigInt(AUTHOR) << 96n) | kaNumber).toString() as KaIdV1; +} + +function ual(kaNumber: bigint): string { + return `did:dkg:${NETWORK_ID}/${AUTHOR}/${kaNumber}`; +} + +function expectCode(operation: () => unknown, code: FinalizedVmCompositionErrorV1['code']): void { + try { + operation(); + } catch (error) { + expect(error).toBeInstanceOf(FinalizedVmCompositionErrorV1); + expect((error as FinalizedVmCompositionErrorV1).code).toBe(code); + return; + } + throw new Error(`Expected ${code}`); +} diff --git a/packages/agent/vitest.rfc64-unit-tests.ts b/packages/agent/vitest.rfc64-unit-tests.ts index 013af775bf..dd277f8112 100644 --- a/packages/agent/vitest.rfc64-unit-tests.ts +++ b/packages/agent/vitest.rfc64-unit-tests.ts @@ -8,6 +8,7 @@ export const RFC64_UNIT_TESTS = [ "test/rfc64-inventory-v1-candidate-faults.test.ts", "test/rfc64-candidate-transfer-admission.test.ts", "test/rfc64-catalog-row-authorship.test.ts", + "test/rfc64-finalized-vm-composer-v1.test.ts", "test/rfc64-agent-inventory-lifecycle.test.ts", "test/rfc64-author-catalog-producer.test.ts", "test/rfc64-control-object-store-v1.test.ts", From babae3fe971648f9e2f2da7556aa7b2cc3c35611 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:03:58 +0200 Subject: [PATCH 246/292] fix(agent): close finalized VM composition proofs --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + .../src/rfc64/finalized-vm-composer-v1.ts | 234 +++++------------- .../public-catalog-native-receiver-v1.ts | 48 +--- .../public-catalog-successor-producer-v1.ts | 2 +- .../recoverable-author-attestation-v1.ts | 53 ++++ .../rfc64-finalized-vm-composer-v1.test.ts | 80 +++++- .../chain/src/finalized-context-graph-read.ts | 51 ++++ .../chain/src/finalized-vm-chain-scanner.ts | 150 ++++++++++- packages/chain/src/index.ts | 2 + .../finalized-context-graph-read.unit.test.ts | 19 ++ .../finalized-vm-chain-scanner.unit.test.ts | 6 + 12 files changed, 421 insertions(+), 226 deletions(-) create mode 100644 packages/agent/src/rfc64/recoverable-author-attestation-v1.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 7cfa17881f..3fde12788b 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -44,6 +44,7 @@ "./dist/rfc64/public-catalog-issuer-delegation-v1.js": null, "./dist/rfc64/public-catalog-successor-producer-v1.js": null, "./dist/rfc64/public-catalog-transport-v1.js": null, + "./dist/rfc64/recoverable-author-attestation-v1.js": null, "./dist/rfc64/secure-filesystem-policy-v1.js": null, "./dist/rfc64/*": null, "./dist/*": "./dist/*" diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 1d19572495..a35ce45234 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -102,6 +102,7 @@ const blockedRfc64Modules = [ 'public-catalog-issuer-delegation-v1.js', 'public-catalog-successor-producer-v1.js', 'public-catalog-transport-v1.js', + 'recoverable-author-attestation-v1.js', 'secure-filesystem-policy-v1.js', ]; const packageExports = packageManifest.exports; diff --git a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts index 2e48465090..982469ddc6 100644 --- a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts @@ -1,67 +1,37 @@ import { FinalizedVmSetAccumulatorV1, - assertCanonicalChainId, - assertCanonicalDecimalU256, - assertCanonicalDecimalU64, - assertCanonicalDigest, - assertCanonicalEvmAddress, - assertCanonicalKaId, assertContextGraphIdV1, - assertNetworkIdV1, assertSubGraphNameV1, readVerifiedCatalogSealBindingV1, - unpackDeterministicRootlessKnowledgeAssetId, type ContextGraphIdV1, type FinalizedVmSetEvidenceV1, type FinalizedVmSetRowV1, type SubGraphNameV1, type VerifiedCatalogSealBindingV1, } from '@origintrail-official/dkg-core'; -/* - * These are scanner outputs, not trusted casts. The composer revalidates the - * complete structural inventory before consuming either capability set. - */ import { - FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + snapshotFinalizedContextGraphReadV1, + snapshotFinalizedVmChainInventoryV1, + type FinalizedContextGraphReadV1, type FinalizedVmChainCandidateV1, type FinalizedVmChainInventoryV1, } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; import { readVerifiedAuthorCatalogRowAuthorshipV1, type VerifiedAuthorCatalogRowAuthorshipV1, } from './catalog-row-authorship.js'; +import { assertRecoverableAuthorAttestationV1 } from './recoverable-author-attestation-v1.js'; -const COMPOSITION_KEYS = ['catalogLane', 'inventory', 'placements'] as const; +const COMPOSITION_KEYS = [ + 'catalogLane', + 'finalizedContextGraph', + 'inventory', + 'placements', +] as const; const CATALOG_LANE_KEYS = ['contextGraphId', 'subGraphName'] as const; const PLACEMENT_KEYS = ['authorship', 'sealBinding'] as const; -const INVENTORY_KEYS = [ - 'chainId', - 'contextGraphId', - 'contractAddress', - 'finalizedBlockHash', - 'finalizedBlockNumber', - 'highestFinalizedOrdinal', - 'knowledgeAssetStorageAddress', - 'networkId', - 'rows', -] as const; -const CANDIDATE_KEYS = [ - 'assertionRoot', - 'assertionVersion', - 'attestedAuthorAddress', - 'authorAddress', - 'chainId', - 'contractAddress', - 'finalizedBlockHash', - 'finalizedBlockNumber', - 'kaId', - 'knowledgeAssetStorageAddress', - 'ordinal', - 'publisherAddress', - 'ual', -] as const; -const ZERO_ADDRESS = `0x${'00'.repeat(20)}`; export interface FinalizedVmCatalogLaneV1 { readonly contextGraphId: ContextGraphIdV1; @@ -76,10 +46,16 @@ export interface FinalizedVmPlacementEvidenceV1 { export interface ComposeFinalizedVmSetRequestV1 { readonly catalogLane: FinalizedVmCatalogLaneV1; + readonly finalizedContextGraph: FinalizedContextGraphReadV1; readonly inventory: FinalizedVmChainInventoryV1; readonly placements: readonly FinalizedVmPlacementEvidenceV1[]; } +interface ResolvedFinalizedVmPlacementV1 { + readonly authorship: ReturnType; + readonly sealBinding: ReturnType; +} + export interface ComposedFinalizedVmSetV1 { readonly catalogLane: Readonly; readonly evidence: Readonly; @@ -121,7 +97,25 @@ export function composeFinalizedVmSetV1( 'finalized-vm-composition-input', ); const catalogLane = snapshotCatalogLane(request.catalogLane); - const inventory = snapshotInventory(request.inventory); + let inventory: Readonly; + let finalizedContextGraph: Readonly; + try { + inventory = snapshotFinalizedVmChainInventoryV1(request.inventory); + finalizedContextGraph = snapshotFinalizedContextGraphReadV1( + request.finalizedContextGraph, + ); + } catch (cause) { + fail( + 'finalized-vm-composition-inventory', + 'chain inventory or finalized Context Graph binding is not canonical', + cause, + ); + } + assertCatalogLaneMatchesFinalizedContextGraph( + catalogLane, + finalizedContextGraph, + inventory, + ); let placements: readonly unknown[]; try { placements = snapshotDenseArray( @@ -137,7 +131,7 @@ export function composeFinalizedVmSetV1( ); } - const placementsByKaId = new Map>(); + const placementsByKaId = new Map>(); for (const [index, untrustedPlacement] of placements.entries()) { const placement = snapshotPlacement(untrustedPlacement, index); let authorship: ReturnType; @@ -145,6 +139,7 @@ export function composeFinalizedVmSetV1( try { authorship = readVerifiedAuthorCatalogRowAuthorshipV1(placement.authorship); sealBinding = readVerifiedCatalogSealBindingV1(placement.sealBinding); + assertRecoverableAuthorAttestationV1(sealBinding); } catch (cause) { fail( 'finalized-vm-composition-placement', @@ -155,6 +150,8 @@ export function composeFinalizedVmSetV1( if ( authorship.contextGraphId !== catalogLane.contextGraphId || authorship.subGraphName !== catalogLane.subGraphName + || authorship.governanceChainId !== finalizedContextGraph.chainId + || authorship.governanceContractAddress !== finalizedContextGraph.governanceContract ) { fail( 'finalized-vm-composition-mismatch', @@ -180,7 +177,7 @@ export function composeFinalizedVmSetV1( `catalog lane contains duplicate placement evidence for KA ${sealBinding.kaId}`, ); } - placementsByKaId.set(sealBinding.kaId, Object.freeze(placement)); + placementsByKaId.set(sealBinding.kaId, Object.freeze({ authorship, sealBinding })); } const scope = Object.freeze({ @@ -193,9 +190,12 @@ export function composeFinalizedVmSetV1( for (const candidate of inventory.rows) { const placement = placementsByKaId.get(candidate.kaId); if (placement === undefined) continue; - const authorship = readVerifiedAuthorCatalogRowAuthorshipV1(placement.authorship); - const sealBinding = readVerifiedCatalogSealBindingV1(placement.sealBinding); - assertCandidateMatchesPlacement(candidate, inventory, authorship, sealBinding); + assertCandidateMatchesPlacement( + candidate, + inventory, + placement.authorship, + placement.sealBinding, + ); placementsByKaId.delete(candidate.kaId); const row = Object.freeze({ @@ -210,7 +210,7 @@ export function composeFinalizedVmSetV1( finalizedBlockHash: candidate.finalizedBlockHash, // The row digest commits catalogScopeDigest, whose exact scope includes // contextGraphId, subGraphName, and authorAddress, plus the selected row. - placementEvidenceDigest: authorship.catalogRowDigest, + placementEvidenceDigest: placement.authorship.catalogRowDigest, } satisfies FinalizedVmSetRowV1); accumulator.append(row); rows.push(row); @@ -278,122 +278,28 @@ function snapshotCatalogLane(input: unknown): Readonly }) as Readonly; } -function snapshotInventory(input: unknown): Readonly { - const record = snapshotRecord( - input, - INVENTORY_KEYS, - 'finalized VM chain inventory', - 'finalized-vm-composition-inventory', - ); - let rows: readonly Readonly[]; - try { - assertNetworkIdV1(record.networkId, 'inventory.networkId'); - assertCanonicalDecimalU256(record.contextGraphId, 'inventory.contextGraphId'); - assertCanonicalChainId(record.chainId, 'inventory.chainId'); - assertNonzeroAddress(record.contractAddress, 'inventory.contractAddress'); - assertNonzeroAddress( - record.knowledgeAssetStorageAddress, - 'inventory.knowledgeAssetStorageAddress', - ); - assertCanonicalDecimalU64(record.finalizedBlockNumber, 'inventory.finalizedBlockNumber'); - assertCanonicalDigest(record.finalizedBlockHash, 'inventory.finalizedBlockHash'); - if (record.highestFinalizedOrdinal !== null) { - assertCanonicalDecimalU64( - record.highestFinalizedOrdinal, - 'inventory.highestFinalizedOrdinal', - ); - } - const untrustedRows = snapshotDenseArray( - record.rows, - 'finalized VM inventory rows', - FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, - ); - rows = Object.freeze(untrustedRows.map((row, index) => snapshotCandidate(row, index, record))); - } catch (cause) { - if (cause instanceof FinalizedVmCompositionErrorV1) throw cause; - fail('finalized-vm-composition-inventory', 'chain inventory is not canonical', cause); - } - - const expectedHighest = rows.length === 0 ? null : String(rows.length - 1); - if (record.highestFinalizedOrdinal !== expectedHighest) { - fail( - 'finalized-vm-composition-inventory', - 'chain inventory highest ordinal does not match its dense indexed rows', - ); - } - return Object.freeze({ - networkId: record.networkId, - contextGraphId: record.contextGraphId, - chainId: record.chainId, - contractAddress: record.contractAddress, - knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, - finalizedBlockNumber: record.finalizedBlockNumber, - finalizedBlockHash: record.finalizedBlockHash, - highestFinalizedOrdinal: record.highestFinalizedOrdinal, - rows, - }) as Readonly; -} - -function snapshotCandidate( - input: unknown, - index: number, - inventory: Record, -): Readonly { - const record = snapshotRecord( - input, - CANDIDATE_KEYS, - `finalized VM candidate ${index}`, - 'finalized-vm-composition-inventory', - ); - try { - assertCanonicalChainId(record.chainId, `candidate ${index} chainId`); - assertNonzeroAddress(record.contractAddress, `candidate ${index} contractAddress`); - assertNonzeroAddress( - record.knowledgeAssetStorageAddress, - `candidate ${index} knowledgeAssetStorageAddress`, - ); - assertCanonicalDecimalU64(record.ordinal, `candidate ${index} ordinal`); - assertCanonicalKaId(record.kaId, `candidate ${index} kaId`); - assertNonzeroAddress(record.authorAddress, `candidate ${index} authorAddress`); - assertNullableNonzeroAddress( - record.attestedAuthorAddress, - `candidate ${index} attestedAuthorAddress`, - ); - assertNullableNonzeroAddress(record.publisherAddress, `candidate ${index} publisherAddress`); - assertCanonicalDecimalU64(record.assertionVersion, `candidate ${index} assertionVersion`); - assertCanonicalDigest(record.assertionRoot, `candidate ${index} assertionRoot`); - assertCanonicalDecimalU64( - record.finalizedBlockNumber, - `candidate ${index} finalizedBlockNumber`, - ); - assertCanonicalDigest(record.finalizedBlockHash, `candidate ${index} finalizedBlockHash`); - const identity = unpackDeterministicRootlessKnowledgeAssetId( - inventory.networkId as never, - BigInt(record.kaId as string), - ); - if (record.ual !== identity.ual || record.authorAddress !== identity.agentAddress) { - throw new Error(`candidate ${index} identity differs from its packed KA id`); - } - if (record.assertionVersion === '0' || record.assertionRoot === `0x${'00'.repeat(32)}`) { - throw new Error(`candidate ${index} assertion state must be nonzero`); - } - } catch (cause) { - fail('finalized-vm-composition-inventory', `candidate ${index} is not canonical`, cause); - } +function assertCatalogLaneMatchesFinalizedContextGraph( + catalogLane: Readonly, + contextGraph: Readonly, + inventory: Readonly, +): void { + const expectedNameHash = ethers.keccak256( + ethers.toUtf8Bytes(catalogLane.contextGraphId), + ).toLowerCase(); if ( - record.ordinal !== String(index) - || record.chainId !== inventory.chainId - || record.contractAddress !== inventory.contractAddress - || record.knowledgeAssetStorageAddress !== inventory.knowledgeAssetStorageAddress - || record.finalizedBlockNumber !== inventory.finalizedBlockNumber - || record.finalizedBlockHash !== inventory.finalizedBlockHash + !contextGraph.active + || contextGraph.nameHash !== expectedNameHash + || contextGraph.chainId !== inventory.chainId + || contextGraph.contextGraphId !== inventory.contextGraphId + || contextGraph.governanceContract !== inventory.contractAddress + || contextGraph.blockNumber !== inventory.finalizedBlockNumber + || contextGraph.blockHash !== inventory.finalizedBlockHash ) { fail( - 'finalized-vm-composition-inventory', - `candidate ${index} differs from the inventory lane or pinned anchor`, + 'finalized-vm-composition-mismatch', + 'catalog lane is not bound to this same-anchor finalized Context Graph inventory', ); } - return Object.freeze({ ...record }) as unknown as Readonly; } function snapshotPlacement(input: unknown, index: number): FinalizedVmPlacementEvidenceV1 { @@ -477,16 +383,6 @@ function snapshotRecord( } } -function assertNullableNonzeroAddress(value: unknown, label: string): void { - if (value === null) return; - assertNonzeroAddress(value, label); -} - -function assertNonzeroAddress(value: unknown, label: string): void { - assertCanonicalEvmAddress(value, label); - if (value === ZERO_ADDRESS) throw new Error(`${label} must be nonzero`); -} - function fail( code: FinalizedVmCompositionErrorCodeV1, message: string, diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 27d0419ae8..ee267caded 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -14,7 +14,6 @@ import { AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, - AUTHOR_SCHEME_VERSION_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, @@ -30,7 +29,6 @@ import { assertSignedAuthorCatalogDirectoryNodeEnvelopeV1, assertSignedAuthorCatalogHeadEnvelopeV1, assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, - buildAuthorAttestationTypedData, canonicalizeAuthorCatalogBucketPayloadBytesV1, computeAuthorCatalogScopeDigestV1, computeControlSignatureVariantDigestHex, @@ -81,6 +79,7 @@ import type { AppliedCatalogHeadSnapshotV1, Rfc64InventoryV1OperationsV1, } from './inventory-v1/index.js'; +import { assertRecoverableAuthorAttestationV1 } from './recoverable-author-attestation-v1.js'; import { computeRfc64AppliedInventoryDigestV1, verifyRfc64PublicCatalogInventoryCompletenessV1, @@ -1774,51 +1773,6 @@ async function activateExactPublicProjection( }; } -/** - * Require the transferred v1 AuthorAttestation to recover the catalog author. - * This first receiver slice intentionally supports the recoverable EOA scheme; - * EIP-1271 contract-author admission needs a separately pinned chain verifier. - */ -export function assertRecoverableAuthorAttestationV1( - binding: VerifiedCatalogSealBindingSnapshotV1, -): void { - const { seal } = binding; - if (seal.authorSchemeVersion !== String(AUTHOR_SCHEME_VERSION_V1)) { - fail('catalog-native-receiver-transfer', 'unsupported author attestation scheme'); - } - try { - const typedData = buildAuthorAttestationTypedData({ - chainId: BigInt(seal.assertedAtChainId), - kav10Address: seal.assertedAtKav10Address, - merkleRoot: ethers.getBytes(seal.assertionMerkleRoot), - authorAddress: seal.authorAddress, - reservedKaId: BigInt(seal.reservedKaId), - schemeVersion: AUTHOR_SCHEME_VERSION_V1, - }); - const digest = ethers.TypedDataEncoder.hash( - typedData.domain, - typedData.types, - typedData.message, - ); - const signature = ethers.Signature.from({ - r: seal.authorAttestationR, - yParityAndS: seal.authorAttestationVS, - }); - const recovered = ethers.recoverAddress(digest, signature); - if (recovered.toLowerCase() !== seal.authorAddress) { - throw new Error( - `signature recovers ${recovered.toLowerCase()} instead of ${seal.authorAddress}`, - ); - } - } catch (cause) { - fail( - 'catalog-native-receiver-transfer', - 'author attestation does not recover the catalog author', - cause, - ); - } -} - async function assertExactAuthorSealPostRead( store: TripleStore, binding: VerifiedCatalogSealBindingSnapshotV1, diff --git a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts index a5feca7b5c..db66f61d5b 100644 --- a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts @@ -67,7 +67,7 @@ import type { Rfc64ControlObjectOperationsV1, StageVerifiedControlObjectsResultV1, } from './control-object-store-v1.js'; -import { assertRecoverableAuthorAttestationV1 } from './public-catalog-native-receiver-v1.js'; +import { assertRecoverableAuthorAttestationV1 } from './recoverable-author-attestation-v1.js'; import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1, addRfc64PublicCatalogExactSetBundleBytesV1, diff --git a/packages/agent/src/rfc64/recoverable-author-attestation-v1.ts b/packages/agent/src/rfc64/recoverable-author-attestation-v1.ts new file mode 100644 index 0000000000..82c0ea48ad --- /dev/null +++ b/packages/agent/src/rfc64/recoverable-author-attestation-v1.ts @@ -0,0 +1,53 @@ +import { + AUTHOR_SCHEME_VERSION_V1, + buildAuthorAttestationTypedData, + type VerifiedCatalogSealBindingSnapshotV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +export class RecoverableAuthorAttestationErrorV1 extends Error { + constructor(message: string, options: ErrorOptions = {}) { + super(message, options); + this.name = 'RecoverableAuthorAttestationErrorV1'; + } +} + +/** Require the v1 EOA AuthorAttestation committed by a catalog seal to recover its author. */ +export function assertRecoverableAuthorAttestationV1( + binding: VerifiedCatalogSealBindingSnapshotV1, +): void { + const { seal } = binding; + if (seal.authorSchemeVersion !== String(AUTHOR_SCHEME_VERSION_V1)) { + throw new RecoverableAuthorAttestationErrorV1( + 'unsupported author attestation scheme', + ); + } + try { + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(seal.assertedAtChainId), + kav10Address: seal.assertedAtKav10Address, + merkleRoot: ethers.getBytes(seal.assertionMerkleRoot), + authorAddress: seal.authorAddress, + reservedKaId: BigInt(seal.reservedKaId), + schemeVersion: AUTHOR_SCHEME_VERSION_V1, + }); + const digest = ethers.TypedDataEncoder.hash( + typedData.domain, + typedData.types, + typedData.message, + ); + const signature = ethers.Signature.from({ + r: seal.authorAttestationR, + yParityAndS: seal.authorAttestationVS, + }); + const recovered = ethers.recoverAddress(digest, signature).toLowerCase(); + if (recovered !== seal.authorAddress) { + throw new Error(`signature recovers ${recovered} instead of ${seal.authorAddress}`); + } + } catch (cause) { + throw new RecoverableAuthorAttestationErrorV1( + 'author attestation does not recover the catalog author', + { cause }, + ); + } +} diff --git a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts index c7c251e3e5..2faa41e248 100644 --- a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts @@ -1,8 +1,10 @@ import { + AUTHOR_SCHEME_VERSION_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + buildAuthorAttestationTypedData, canonicalizeCanonicalGraphScopedAuthorSealBytesV1, computeAuthorCatalogScopeDigestV1, computeCanonicalGraphScopedAuthorSealDigestV1, @@ -52,7 +54,6 @@ const CONTEXT_GRAPH_NAME = 'agent-blackbox-vm' as const; const ON_CHAIN_CONTEXT_GRAPH_ID = '14' as const; const CG_STORAGE = `0x${'33'.repeat(20)}` as EvmAddressV1; const KA_STORAGE = `0x${'44'.repeat(20)}` as EvmAddressV1; -const GOVERNANCE_CONTRACT = `0x${'55'.repeat(20)}` as EvmAddressV1; const PUBLISHER = `0x${'66'.repeat(20)}` as EvmAddressV1; const BLOCK_HASH = `0x${'77'.repeat(32)}` as Digest32V1; const ROOT_1 = `0x${'88'.repeat(32)}` as Digest32V1; @@ -111,6 +112,24 @@ describe('RFC-64 finalized VM placement composition', () => { ); }); + it('requires a recoverable author attestation and rejects spliced valid capabilities', async () => { + const first = await createPlacement(KA_1, ROOT_1); + const second = await createPlacement(KA_2, ROOT_2); + const unsigned = await createPlacement(KA_2, ROOT_2, false); + + expectCode( + () => composeFinalizedVmSetV1(requestFor([unsigned])), + 'finalized-vm-composition-placement', + ); + expectCode( + () => composeFinalizedVmSetV1(requestFor([{ + authorship: first.authorship, + sealBinding: second.sealBinding, + }])), + 'finalized-vm-composition-mismatch', + ); + }); + it('binds lane, author attestation, root, publisher, and deployment to chain truth', async () => { const placement = await createPlacement(KA_2, ROOT_2); const base = requestFor([placement]); @@ -161,6 +180,22 @@ describe('RFC-64 finalized VM placement composition', () => { ); }); + it('binds the cleartext catalog lane to the exact same-anchor numeric Context Graph', async () => { + const placement = await createPlacement(KA_2, ROOT_2); + const base = requestFor([placement]); + expectCode(() => composeFinalizedVmSetV1({ + ...base, + inventory: { ...base.inventory, contextGraphId: '15' }, + } as never), 'finalized-vm-composition-mismatch'); + expectCode(() => composeFinalizedVmSetV1({ + ...base, + finalizedContextGraph: { + ...base.finalizedContextGraph, + nameHash: ethers.keccak256(ethers.toUtf8Bytes('another-graph')).toLowerCase(), + }, + } as never), 'finalized-vm-composition-mismatch'); + }); + it('accepts an empty placement lane without weakening inventory validation', () => { const composed = composeFinalizedVmSetV1(requestFor([])); expect(composed.rows).toEqual([]); @@ -179,6 +214,20 @@ function requestFor( contextGraphId: CONTEXT_GRAPH_NAME, subGraphName: null, }, + finalizedContextGraph: { + chainId: CHAIN_ID, + contextGraphId: ON_CHAIN_CONTEXT_GRAPH_ID, + governanceContract: CG_STORAGE, + blockNumber: '123', + blockHash: BLOCK_HASH, + owner: AUTHOR, + active: true, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + nameHash: ethers.keccak256(ethers.toUtf8Bytes(CONTEXT_GRAPH_NAME)).toLowerCase(), + }, inventory: inventory(), placements: [...placements], }; @@ -228,23 +277,38 @@ function candidate( async function createPlacement( kaId: KaIdV1, assertionRoot: Digest32V1, + validAttestation = true, ): Promise { + const assertionVersion = kaId === KA_1 ? '1' : '2'; const scope = { networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_NAME, governanceChainId: CHAIN_ID, - governanceContractAddress: GOVERNANCE_CONTRACT, + governanceContractAddress: CG_STORAGE, ownershipTransitionDigest: null, subGraphName: null, authorAddress: AUTHOR, era: '0', bucketCount: '1', } as AuthorCatalogScopeV1; + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(CHAIN_ID), + kav10Address: KA_STORAGE, + merkleRoot: ethers.getBytes(assertionRoot), + authorAddress: AUTHOR, + reservedKaId: BigInt(kaId), + schemeVersion: AUTHOR_SCHEME_VERSION_V1, + }); + const attestation = AUTHOR_WALLET.signingKey.sign(ethers.TypedDataEncoder.hash( + typedData.domain, + typedData.types, + typedData.message, + )); const seal = { assertionMerkleRoot: assertionRoot, authorAddress: AUTHOR, - authorAttestationR: `0x${'aa'.repeat(32)}`, - authorAttestationVS: `0x${'bb'.repeat(32)}`, + authorAttestationR: validAttestation ? attestation.r : `0x${'aa'.repeat(32)}`, + authorAttestationVS: validAttestation ? attestation.yParityAndS : `0x${'bb'.repeat(32)}`, authorSchemeVersion: '1', assertedAtChainId: CHAIN_ID, assertedAtKav10Address: KA_STORAGE, @@ -252,7 +316,7 @@ async function createPlacement( assertionFinalizedAt: '2026-07-22T08:00:00.000Z', contentScopeVersion: '2', kaUal: ual(BigInt(kaId) & ((1n << 96n) - 1n)), - assertionVersion: '2', + assertionVersion, publicTripleCount: '10', privateTripleCount: '0', privateMerkleRoot: null, @@ -260,7 +324,7 @@ async function createPlacement( const row = { kaId, assertionCoordinate: 'vm-fixture', - assertionVersion: '2', + assertionVersion, projectionId: 'cg-shared-v1', projectionDigest: ZERO_DIGEST, sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), @@ -284,7 +348,7 @@ async function createPlacement( networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_NAME, governanceChainId: CHAIN_ID, - governanceContractAddress: GOVERNANCE_CONTRACT, + governanceContractAddress: CG_STORAGE, ownershipTransitionDigest: null, subGraphName: null, authorAddress: AUTHOR, @@ -338,7 +402,7 @@ async function createPlacement( networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_NAME, governanceChainId: CHAIN_ID, - governanceContractAddress: GOVERNANCE_CONTRACT, + governanceContractAddress: CG_STORAGE, ownershipTransitionDigest: null, subGraphName: null, authorAddress: AUTHOR, diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts index dd7398eb3c..71299ff426 100644 --- a/packages/chain/src/finalized-context-graph-read.ts +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -17,6 +17,8 @@ import { type EvmAddressV1, } from '@origintrail-official/dkg-core'; +import { snapshotExactDataRecord } from './strict-local-data.js'; + // This module validates one finalized ContextGraphStorage read. It deliberately // does not define another RFC-64 policy object: the chain surface cannot supply // the network/name identifier, era, version, predecessor, or timestamps needed @@ -26,6 +28,20 @@ import { const ZERO_DIGEST_32 = `0x${'0'.repeat(64)}`; const ZERO_ADDRESS = `0x${'0'.repeat(40)}`; const CANONICAL_LOWER_EVM_ADDRESS = /^0x[0-9a-f]{40}$/; +const FINALIZED_CONTEXT_GRAPH_READ_KEYS = Object.freeze([ + 'accessPolicy', + 'active', + 'blockHash', + 'blockNumber', + 'chainId', + 'contextGraphId', + 'governanceContract', + 'nameHash', + 'owner', + 'publishAuthority', + 'publishAuthorityAccountId', + 'publishPolicy', +] as const); export type FinalizedContextGraphReadErrorCodeV1 = | 'request-binding' @@ -325,3 +341,38 @@ export async function resolveFinalizedContextGraphReadWithSignalV1( const raw = await resolver(binding, signal); return composeValidatedFinalizedContextGraphReadV1(binding, raw); } + +/** Revalidate and freeze a finalized Context Graph read crossing a package boundary. */ +export function snapshotFinalizedContextGraphReadV1( + input: unknown, +): FinalizedContextGraphReadV1 { + let record: Record; + try { + record = snapshotExactDataRecord(input, FINALIZED_CONTEXT_GRAPH_READ_KEYS); + } catch { + throw new FinalizedContextGraphReadErrorV1( + 'request-binding', + 'finalized Context Graph read must be an exact data-only record', + ); + } + return composeFinalizedContextGraphReadV1( + { + chainId: record.chainId, + contextGraphId: record.contextGraphId, + governanceContract: record.governanceContract, + }, + { + blockNumber: record.blockNumber, + blockHash: record.blockHash, + owner: record.owner, + active: record.active, + accessPolicy: record.accessPolicy, + publishPolicy: record.publishPolicy, + publishAuthority: record.publishAuthority === null + ? ZERO_ADDRESS + : record.publishAuthority, + publishAuthorityAccountId: record.publishAuthorityAccountId, + nameHash: record.nameHash, + }, + ); +} diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts index e78cffdb15..e3cf24e06c 100644 --- a/packages/chain/src/finalized-vm-chain-scanner.ts +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -3,6 +3,7 @@ import { assertCanonicalDecimalU256, assertCanonicalDecimalU64, assertCanonicalDigest, + assertCanonicalKaId, assertNetworkIdV1, unpackDeterministicRootlessKnowledgeAssetId, type BlockNumberV1, @@ -30,6 +31,7 @@ import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-ev import { assertCanonicalNonzeroEvmAddress, isAbortSignal, + snapshotDenseDataArray, snapshotExactDataRecord, } from './strict-local-data.js'; @@ -50,6 +52,32 @@ const SESSION_CONFIG_KEYS = Object.freeze([ 'networkId', ] as const); const REQUEST_KEYS = Object.freeze(['contextGraphId', 'signal'] as const); +const INVENTORY_KEYS = Object.freeze([ + 'chainId', + 'contextGraphId', + 'contractAddress', + 'finalizedBlockHash', + 'finalizedBlockNumber', + 'highestFinalizedOrdinal', + 'knowledgeAssetStorageAddress', + 'networkId', + 'rows', +] as const); +const CANDIDATE_KEYS = Object.freeze([ + 'assertionRoot', + 'assertionVersion', + 'attestedAuthorAddress', + 'authorAddress', + 'chainId', + 'contractAddress', + 'finalizedBlockHash', + 'finalizedBlockNumber', + 'kaId', + 'knowledgeAssetStorageAddress', + 'ordinal', + 'publisherAddress', + 'ual', +] as const); const UINT256_RETURN_BYTES = 32; const UPDATE_CONTEXT_RETURN_BYTES = 7 * 32; const FIXED_SCAN_CALLS = 2; @@ -120,6 +148,59 @@ export interface FinalizedVmChainScannerV1 { (request: FinalizedVmChainScanRequestV1): Promise>; } +/** Canonical chain-owned boundary for scanner output consumed across packages. */ +export function snapshotFinalizedVmChainInventoryV1( + input: unknown, +): Readonly { + try { + const record = snapshotExactDataRecord(input, INVENTORY_KEYS); + assertNetworkIdV1(record.networkId, 'finalized VM inventory networkId'); + assertCanonicalDecimalU256(record.contextGraphId, 'finalized VM inventory contextGraphId'); + if (record.contextGraphId === '0') throw new Error('contextGraphId must be nonzero'); + assertCanonicalChainId(record.chainId, 'finalized VM inventory chainId'); + assertCanonicalNonzeroEvmAddress(record.contractAddress, 'finalized VM inventory contract'); + assertCanonicalNonzeroEvmAddress( + record.knowledgeAssetStorageAddress, + 'finalized VM inventory KA storage', + ); + assertCanonicalDecimalU64( + record.finalizedBlockNumber, + 'finalized VM inventory block number', + ); + assertCanonicalDigest(record.finalizedBlockHash, 'finalized VM inventory block hash'); + if (record.highestFinalizedOrdinal !== null) { + assertCanonicalDecimalU64( + record.highestFinalizedOrdinal, + 'finalized VM inventory highest ordinal', + ); + } + const untrustedRows = snapshotDenseDataArray(record.rows, { + label: 'finalized VM inventory rows', + maxLength: FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + }); + const rows = Object.freeze(untrustedRows.map((row, index) => + snapshotInventoryCandidate(row, index, record))); + const expectedHighest = rows.length === 0 ? null : String(rows.length - 1); + if (record.highestFinalizedOrdinal !== expectedHighest) { + throw new Error('highest ordinal does not match the dense inventory rows'); + } + return Object.freeze({ + networkId: record.networkId, + contextGraphId: record.contextGraphId, + chainId: record.chainId, + contractAddress: record.contractAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + finalizedBlockNumber: record.finalizedBlockNumber, + finalizedBlockHash: record.finalizedBlockHash, + highestFinalizedOrdinal: record.highestFinalizedOrdinal, + rows, + }) as Readonly; + } catch (cause) { + if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; + throw malformedReturn('Finalized VM chain inventory is not canonical', cause); + } +} + interface ScannerSessionConfigSnapshotV1 { readonly networkId: NetworkIdV1; readonly chainId: ChainIdV1; @@ -291,7 +372,7 @@ async function scanPinnedInventory( const highestFinalizedOrdinal = rowCount === 0n ? null : (rowCount - 1n).toString(10) as DecimalU64V1; - return Object.freeze({ + return snapshotFinalizedVmChainInventoryV1({ networkId: config.networkId, contextGraphId, chainId: config.chainId, @@ -304,6 +385,73 @@ async function scanPinnedInventory( }); } +function snapshotInventoryCandidate( + input: unknown, + index: number, + inventory: Record, +): Readonly { + const record = snapshotExactDataRecord(input, CANDIDATE_KEYS); + assertCanonicalChainId(record.chainId, `finalized VM candidate ${index} chainId`); + assertCanonicalNonzeroEvmAddress( + record.contractAddress, + `finalized VM candidate ${index} contract`, + ); + assertCanonicalNonzeroEvmAddress( + record.knowledgeAssetStorageAddress, + `finalized VM candidate ${index} KA storage`, + ); + assertCanonicalDecimalU64(record.ordinal, `finalized VM candidate ${index} ordinal`); + assertCanonicalKaId(record.kaId, `finalized VM candidate ${index} kaId`); + assertCanonicalNonzeroEvmAddress( + record.authorAddress, + `finalized VM candidate ${index} author`, + ); + assertNullableInventoryAddress( + record.attestedAuthorAddress, + `finalized VM candidate ${index} attested author`, + ); + assertNullableInventoryAddress( + record.publisherAddress, + `finalized VM candidate ${index} publisher`, + ); + assertCanonicalDecimalU64( + record.assertionVersion, + `finalized VM candidate ${index} assertion version`, + ); + assertCanonicalDigest(record.assertionRoot, `finalized VM candidate ${index} assertion root`); + assertCanonicalDecimalU64( + record.finalizedBlockNumber, + `finalized VM candidate ${index} block number`, + ); + assertCanonicalDigest( + record.finalizedBlockHash, + `finalized VM candidate ${index} block hash`, + ); + const identity = unpackDeterministicRootlessKnowledgeAssetId( + inventory.networkId as NetworkIdV1, + BigInt(record.kaId as string), + ); + if ( + record.ual !== identity.ual + || record.authorAddress !== identity.agentAddress + || record.assertionVersion === '0' + || record.assertionRoot === ethers.ZeroHash + || record.ordinal !== String(index) + || record.chainId !== inventory.chainId + || record.contractAddress !== inventory.contractAddress + || record.knowledgeAssetStorageAddress !== inventory.knowledgeAssetStorageAddress + || record.finalizedBlockNumber !== inventory.finalizedBlockNumber + || record.finalizedBlockHash !== inventory.finalizedBlockHash + ) { + throw new Error(`finalized VM candidate ${index} differs from its identity or inventory lane`); + } + return Object.freeze({ ...record }) as unknown as Readonly; +} + +function assertNullableInventoryAddress(value: unknown, label: string): void { + if (value !== null) assertCanonicalNonzeroEvmAddress(value, label); +} + async function readFinalizedVmAssertions( session: StrictCurrentFinalizedEvmSnapshotSessionV1, knowledgeAssetStorageAddress: EvmAddressV1, diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index d6575610eb..0981f572c6 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -48,6 +48,7 @@ export { FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, createFinalizedVmChainScannerV1, scanFinalizedVmChainInventoryInSnapshotV1, + snapshotFinalizedVmChainInventoryV1, type FinalizedVmChainCandidateV1, type FinalizedVmChainInventoryV1, type FinalizedVmChainScanRequestV1, @@ -74,6 +75,7 @@ export { composeFinalizedContextGraphReadV1, resolveFinalizedContextGraphReadWithSignalV1, resolveFinalizedContextGraphReadV1, + snapshotFinalizedContextGraphReadV1, validateFinalizedContextGraphReadRequestV1, type FinalizedContextGraphBindingV1, type FinalizedContextGraphReadErrorCodeV1, diff --git a/packages/chain/test/finalized-context-graph-read.unit.test.ts b/packages/chain/test/finalized-context-graph-read.unit.test.ts index 91676946e7..0277c2b4f6 100644 --- a/packages/chain/test/finalized-context-graph-read.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-read.unit.test.ts @@ -11,6 +11,7 @@ import { composeFinalizedContextGraphReadV1, resolveFinalizedContextGraphReadWithSignalV1, resolveFinalizedContextGraphReadV1, + snapshotFinalizedContextGraphReadV1, type FinalizedContextGraphBindingV1, type FinalizedContextGraphReadErrorCodeV1, type FinalizedContextGraphReadRequestV1, @@ -91,6 +92,24 @@ describe('RFC-64 finalized Context Graph chain read', () => { }); }); + it('revalidates normalized finalized reads at the cross-package boundary', () => { + const read = composeFinalizedContextGraphReadV1(validRequest(), validRaw()); + expect(snapshotFinalizedContextGraphReadV1(structuredClone(read))).toEqual(read); + expectFailure( + () => snapshotFinalizedContextGraphReadV1({ ...read, unexpected: true }), + 'request-binding', + ); + + const open = composeFinalizedContextGraphReadV1(validRequest(), { + ...validRaw(), + publishPolicy: 1, + publishAuthority: ZERO, + publishAuthorityAccountId: '0', + }); + expect(snapshotFinalizedContextGraphReadV1(structuredClone(open)).publishAuthority) + .toBeNull(); + }); + it('passes one frozen canonical binding through the resolver seam', async () => { const request = validRequest(); const raw = Object.freeze(validRaw()); diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index bc48f3c72e..1f688cf86c 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -13,6 +13,7 @@ import { FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, createFinalizedVmChainScannerV1, scanFinalizedVmChainInventoryInSnapshotV1, + snapshotFinalizedVmChainInventoryV1, } from '../src/finalized-vm-chain-scanner.js'; import { CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, @@ -134,6 +135,11 @@ describe('RFC-64 finalized VM chain scanner', () => { expect(Object.isFrozen(scanner)).toBe(true); expect(Object.isFrozen(inventory)).toBe(true); expect(Object.isFrozen(inventory.rows)).toBe(true); + expect(snapshotFinalizedVmChainInventoryV1(structuredClone(inventory))).toEqual(inventory); + expect(() => snapshotFinalizedVmChainInventoryV1({ + ...structuredClone(inventory), + unexpected: true, + })).toThrow(/not canonical/); expect(inventory).toEqual({ networkId: NETWORK_ID, contextGraphId: CG_ID, From d0e4f7b2ced6af7136220e301f8e9b7e6fe22fdf Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:40:20 +0200 Subject: [PATCH 247/292] fix(agent): preserve finalized VM public contracts --- packages/agent/scripts/test-package-root.mjs | 4 ++ packages/agent/src/index.ts | 4 ++ .../chain/src/finalized-vm-chain-scanner.ts | 68 ++++++++++++++----- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index a35ce45234..f5f6ae82ff 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -20,6 +20,10 @@ if ( || typeof root.Rfc64PublicCatalogSuccessorProducerV1 !== 'function' || typeof root.computeRfc64AppliedInventoryDigestV1 !== 'function' || typeof root.classifyRfc64PolicyCellV1 !== 'function' + || typeof root.composeFinalizedVmSetV1 !== 'function' + || typeof root.FinalizedVmCompositionErrorV1 !== 'function' + || typeof root.assertRecoverableAuthorAttestationV1 !== 'function' + || typeof root.RecoverableAuthorAttestationErrorV1 !== 'function' ) { throw new Error('published agent entry points did not expose required root APIs'); } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 22dd13a37b..fbf5d7857a 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -36,6 +36,10 @@ export { } from './auth/agent-delegation.js'; export * from './rfc64/catalog-row-authorship.js'; export * from './rfc64/finalized-vm-composer-v1.js'; +export { + RecoverableAuthorAttestationErrorV1, + assertRecoverableAuthorAttestationV1, +} from './rfc64/recoverable-author-attestation-v1.js'; export * from './rfc64/author-catalog-producer.js'; export * from './rfc64/public-catalog-transport-v1.js'; export * from './rfc64/open-catalog-policy-v1.js'; diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts index e3cf24e06c..1552bdacad 100644 --- a/packages/chain/src/finalized-vm-chain-scanner.ts +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -148,6 +148,17 @@ export interface FinalizedVmChainScannerV1 { (request: FinalizedVmChainScanRequestV1): Promise>; } +interface FinalizedVmChainInventoryHeaderSnapshotV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: DecimalU256V1; + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly finalizedBlockNumber: BlockNumberV1; + readonly finalizedBlockHash: Digest32V1; + readonly highestFinalizedOrdinal: DecimalU64V1 | null; +} + /** Canonical chain-owned boundary for scanner output consumed across packages. */ export function snapshotFinalizedVmChainInventoryV1( input: unknown, @@ -168,33 +179,37 @@ export function snapshotFinalizedVmChainInventoryV1( 'finalized VM inventory block number', ); assertCanonicalDigest(record.finalizedBlockHash, 'finalized VM inventory block hash'); - if (record.highestFinalizedOrdinal !== null) { + const highestFinalizedOrdinal = record.highestFinalizedOrdinal; + if (highestFinalizedOrdinal !== null) { assertCanonicalDecimalU64( - record.highestFinalizedOrdinal, + highestFinalizedOrdinal, 'finalized VM inventory highest ordinal', ); } + const header = Object.freeze({ + networkId: record.networkId, + contextGraphId: record.contextGraphId, + chainId: record.chainId, + contractAddress: record.contractAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + finalizedBlockNumber: record.finalizedBlockNumber, + finalizedBlockHash: record.finalizedBlockHash, + highestFinalizedOrdinal, + } satisfies FinalizedVmChainInventoryHeaderSnapshotV1); const untrustedRows = snapshotDenseDataArray(record.rows, { label: 'finalized VM inventory rows', maxLength: FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, }); const rows = Object.freeze(untrustedRows.map((row, index) => - snapshotInventoryCandidate(row, index, record))); + snapshotInventoryCandidate(row, index, header))); const expectedHighest = rows.length === 0 ? null : String(rows.length - 1); - if (record.highestFinalizedOrdinal !== expectedHighest) { + if (header.highestFinalizedOrdinal !== expectedHighest) { throw new Error('highest ordinal does not match the dense inventory rows'); } return Object.freeze({ - networkId: record.networkId, - contextGraphId: record.contextGraphId, - chainId: record.chainId, - contractAddress: record.contractAddress, - knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, - finalizedBlockNumber: record.finalizedBlockNumber, - finalizedBlockHash: record.finalizedBlockHash, - highestFinalizedOrdinal: record.highestFinalizedOrdinal, + ...header, rows, - }) as Readonly; + } satisfies FinalizedVmChainInventoryV1); } catch (cause) { if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; throw malformedReturn('Finalized VM chain inventory is not canonical', cause); @@ -388,7 +403,7 @@ async function scanPinnedInventory( function snapshotInventoryCandidate( input: unknown, index: number, - inventory: Record, + inventory: Readonly, ): Readonly { const record = snapshotExactDataRecord(input, CANDIDATE_KEYS); assertCanonicalChainId(record.chainId, `finalized VM candidate ${index} chainId`); @@ -428,8 +443,8 @@ function snapshotInventoryCandidate( `finalized VM candidate ${index} block hash`, ); const identity = unpackDeterministicRootlessKnowledgeAssetId( - inventory.networkId as NetworkIdV1, - BigInt(record.kaId as string), + inventory.networkId, + BigInt(record.kaId), ); if ( record.ual !== identity.ual @@ -445,10 +460,27 @@ function snapshotInventoryCandidate( ) { throw new Error(`finalized VM candidate ${index} differs from its identity or inventory lane`); } - return Object.freeze({ ...record }) as unknown as Readonly; + return Object.freeze({ + chainId: record.chainId, + contractAddress: record.contractAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + ordinal: record.ordinal, + kaId: record.kaId, + ual: record.ual, + authorAddress: record.authorAddress, + attestedAuthorAddress: record.attestedAuthorAddress, + publisherAddress: record.publisherAddress, + assertionVersion: record.assertionVersion, + assertionRoot: record.assertionRoot, + finalizedBlockNumber: record.finalizedBlockNumber, + finalizedBlockHash: record.finalizedBlockHash, + } satisfies FinalizedVmChainCandidateV1); } -function assertNullableInventoryAddress(value: unknown, label: string): void { +function assertNullableInventoryAddress( + value: unknown, + label: string, +): asserts value is EvmAddressV1 | null { if (value !== null) assertCanonicalNonzeroEvmAddress(value, label); } From 8fd9605cf9294113bab6bfd35591df833332d2be Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 14:03:17 +0200 Subject: [PATCH 248/292] fix(rfc64): keep finalized VM boundaries canonical --- .../rfc64-finalized-vm-composer-v1.test.ts | 30 +++++++ .../chain/src/finalized-context-graph-read.ts | 85 +++++++++++++------ .../finalized-context-graph-read.unit.test.ts | 4 + 3 files changed, 94 insertions(+), 25 deletions(-) diff --git a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts index 2faa41e248..8e2c2422d5 100644 --- a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts @@ -100,6 +100,36 @@ describe('RFC-64 finalized VM placement composition', () => { expect(Object.isFrozen(composed.rows[0])).toBe(true); }); + it('orders multiple joined placements by finalized inventory ordinal', async () => { + const first = await createPlacement(KA_1, ROOT_1); + const second = await createPlacement(KA_2, ROOT_2); + + const composed = composeFinalizedVmSetV1(requestFor([second, first])); + + expect(composed.rows.map(({ ordinal, ual, placementEvidenceDigest }) => ({ + ordinal, + ual, + placementEvidenceDigest, + }))).toEqual([ + { + ordinal: '0', + ual: ual(1n), + placementEvidenceDigest: + readVerifiedAuthorCatalogRowAuthorshipV1(first.authorship).catalogRowDigest, + }, + { + ordinal: '1', + ual: ual(2n), + placementEvidenceDigest: + readVerifiedAuthorCatalogRowAuthorshipV1(second.authorship).catalogRowDigest, + }, + ]); + expect(composed.evidence).toMatchObject({ + rowCount: '2', + highestFinalizedOrdinal: '1', + }); + }); + it('fails closed on structural capability forgeries and duplicate placement', async () => { const placement = await createPlacement(KA_2, ROOT_2); expectCode(() => composeFinalizedVmSetV1(requestFor([{ diff --git a/packages/chain/src/finalized-context-graph-read.ts b/packages/chain/src/finalized-context-graph-read.ts index 71299ff426..ccabc57bd3 100644 --- a/packages/chain/src/finalized-context-graph-read.ts +++ b/packages/chain/src/finalized-context-graph-read.ts @@ -181,6 +181,15 @@ function canonicalNullableAddress( return canonicalNonZeroAddress(value, code, label); } +function canonicalNormalizedNullableAddress( + value: unknown, + code: FinalizedContextGraphReadErrorCodeV1, + label: string, +): EvmAddressV1 | null { + if (value === null) return null; + return canonicalNonZeroAddress(value, code, label); +} + function canonicalDigest32( value: unknown, code: FinalizedContextGraphReadErrorCodeV1, @@ -202,6 +211,17 @@ function canonicalNullableNameHash(value: unknown): Digest32V1 | null { return canonicalDigest32(value, 'malformed-name-binding', 'nameHash'); } +function canonicalNormalizedNullableNameHash(value: unknown): Digest32V1 | null { + if (value === null) return null; + if (value === ZERO_DIGEST_32) { + throw new FinalizedContextGraphReadErrorV1( + 'malformed-name-binding', + 'normalized nameHash must use null instead of the raw zero-digest sentinel', + ); + } + return canonicalDigest32(value, 'malformed-name-binding', 'nameHash'); +} + /** Validate and freeze the identity before any resolver can consume it. */ export function validateFinalizedContextGraphReadRequestV1( request: FinalizedContextGraphReadRequestV1, @@ -245,8 +265,6 @@ function composeValidatedFinalizedContextGraphReadV1( binding: FinalizedContextGraphBindingV1, raw: UntrustedFinalizedContextGraphFieldsV1, ): FinalizedContextGraphReadV1 { - const { chainId, contextGraphId, governanceContract } = binding; - const owner = canonicalNullableAddress(raw.owner, 'malformed-owner', 'owner'); if (owner === null) { throw new FinalizedContextGraphReadErrorV1( @@ -255,35 +273,53 @@ function composeValidatedFinalizedContextGraphReadV1( ); } - const publishAuthority = canonicalNullableAddress( - raw.publishAuthority, + return composeNormalizedFinalizedContextGraphReadV1(binding, { + ...raw, + owner, + publishAuthority: canonicalNullableAddress( + raw.publishAuthority, + 'malformed-authority', + 'publishAuthority', + ), + nameHash: canonicalNullableNameHash(raw.nameHash), + }); +} + +function composeNormalizedFinalizedContextGraphReadV1( + binding: FinalizedContextGraphBindingV1, + fields: UntrustedFinalizedContextGraphFieldsV1, +): FinalizedContextGraphReadV1 { + const { chainId, contextGraphId, governanceContract } = binding; + const owner = canonicalNonZeroAddress(fields.owner, 'malformed-owner', 'owner'); + const publishAuthority = canonicalNormalizedNullableAddress( + fields.publishAuthority, 'malformed-authority', 'publishAuthority', ); const publishAuthorityAccountId = canonicalDecimalU256( - raw.publishAuthorityAccountId, + fields.publishAuthorityAccountId, 'request-binding', 'publishAuthorityAccountId', ); try { - assertContextGraphAccessPolicyV1(raw.accessPolicy); + assertContextGraphAccessPolicyV1(fields.accessPolicy); } catch { throw new FinalizedContextGraphReadErrorV1( 'unsupported-access-policy', - `unsupported accessPolicy ${String(raw.accessPolicy)}`, + `unsupported accessPolicy ${String(fields.accessPolicy)}`, ); } - const accessPolicy: ContextGraphAccessPolicyV1 = raw.accessPolicy; + const accessPolicy: ContextGraphAccessPolicyV1 = fields.accessPolicy; try { - assertContextGraphPublishPolicyV1(raw.publishPolicy); + assertContextGraphPublishPolicyV1(fields.publishPolicy); } catch { throw new FinalizedContextGraphReadErrorV1( 'unsupported-publish-policy', - `unsupported publishPolicy ${String(raw.publishPolicy)}`, + `unsupported publishPolicy ${String(fields.publishPolicy)}`, ); } - const publishPolicy: ContextGraphPublishPolicyV1 = raw.publishPolicy; + const publishPolicy: ContextGraphPublishPolicyV1 = fields.publishPolicy; let publishDomain: ContextGraphPublishDomainV1; try { publishDomain = snapshotContextGraphPublishDomainV1( @@ -297,13 +333,13 @@ function composeValidatedFinalizedContextGraphReadV1( 'publish policy disagrees with its normalized authority tuple', ); } - if (typeof raw.active !== 'boolean') { + if (typeof fields.active !== 'boolean') { throw new FinalizedContextGraphReadErrorV1('malformed-anchor', 'active must be a boolean'); } - const blockHash = canonicalDigest32(raw.blockHash, 'malformed-anchor', 'blockHash'); - const blockNumber = canonicalBlockNumber(raw.blockNumber, 'request-binding'); - const nameHash = canonicalNullableNameHash(raw.nameHash); + const blockHash = canonicalDigest32(fields.blockHash, 'malformed-anchor', 'blockHash'); + const blockNumber = canonicalBlockNumber(fields.blockNumber, 'request-binding'); + const nameHash = canonicalNormalizedNullableNameHash(fields.nameHash); return Object.freeze({ chainId, @@ -312,7 +348,7 @@ function composeValidatedFinalizedContextGraphReadV1( blockNumber, blockHash, owner, - active: raw.active, + active: fields.active, accessPolicy, publishPolicy: publishDomain.publishPolicy, publishAuthority: publishDomain.publishAuthority, @@ -355,12 +391,13 @@ export function snapshotFinalizedContextGraphReadV1( 'finalized Context Graph read must be an exact data-only record', ); } - return composeFinalizedContextGraphReadV1( - { - chainId: record.chainId, - contextGraphId: record.contextGraphId, - governanceContract: record.governanceContract, - }, + const binding = validateFinalizedContextGraphReadRequestV1({ + chainId: record.chainId, + contextGraphId: record.contextGraphId, + governanceContract: record.governanceContract, + }); + return composeNormalizedFinalizedContextGraphReadV1( + binding, { blockNumber: record.blockNumber, blockHash: record.blockHash, @@ -368,9 +405,7 @@ export function snapshotFinalizedContextGraphReadV1( active: record.active, accessPolicy: record.accessPolicy, publishPolicy: record.publishPolicy, - publishAuthority: record.publishAuthority === null - ? ZERO_ADDRESS - : record.publishAuthority, + publishAuthority: record.publishAuthority, publishAuthorityAccountId: record.publishAuthorityAccountId, nameHash: record.nameHash, }, diff --git a/packages/chain/test/finalized-context-graph-read.unit.test.ts b/packages/chain/test/finalized-context-graph-read.unit.test.ts index 0277c2b4f6..13f6967e6e 100644 --- a/packages/chain/test/finalized-context-graph-read.unit.test.ts +++ b/packages/chain/test/finalized-context-graph-read.unit.test.ts @@ -108,6 +108,10 @@ describe('RFC-64 finalized Context Graph chain read', () => { }); expect(snapshotFinalizedContextGraphReadV1(structuredClone(open)).publishAuthority) .toBeNull(); + expectFailure( + () => snapshotFinalizedContextGraphReadV1({ ...open, publishAuthority: ZERO }), + 'malformed-authority', + ); }); it('passes one frozen canonical binding through the resolver seam', async () => { From 3407ca0e1eb0472610e69094ed6829c98cba6b54 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 15:11:16 +0200 Subject: [PATCH 249/292] refactor(chain): separate finalized VM inventory model --- .../chain/src/finalized-vm-chain-inventory.ts | 290 ++++++++++++++++++ .../chain/src/finalized-vm-chain-scanner.ts | 275 +---------------- packages/chain/src/index.ts | 6 +- .../finalized-vm-chain-scanner.unit.test.ts | 4 +- 4 files changed, 303 insertions(+), 272 deletions(-) create mode 100644 packages/chain/src/finalized-vm-chain-inventory.ts diff --git a/packages/chain/src/finalized-vm-chain-inventory.ts b/packages/chain/src/finalized-vm-chain-inventory.ts new file mode 100644 index 0000000000..df30f340e2 --- /dev/null +++ b/packages/chain/src/finalized-vm-chain-inventory.ts @@ -0,0 +1,290 @@ +import { + assertCanonicalChainId, + assertCanonicalDecimalU256, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertCanonicalKaId, + assertNetworkIdV1, + unpackDeterministicRootlessKnowledgeAssetId, + type BlockNumberV1, + type ChainIdV1, + type DecimalU256V1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +import { + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + CurrentFinalizedEvmCallErrorV1, +} from './current-finalized-evm-read-profile.js'; +import { + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, +} from './current-finalized-evm-snapshot.js'; +import { + assertCanonicalNonzeroEvmAddress, + snapshotDenseDataArray, + snapshotExactDataRecord, +} from './strict-local-data.js'; + +const INVENTORY_KEYS = Object.freeze([ + 'chainId', + 'contextGraphId', + 'contractAddress', + 'finalizedBlockHash', + 'finalizedBlockNumber', + 'highestFinalizedOrdinal', + 'knowledgeAssetStorageAddress', + 'networkId', + 'rows', +] as const); +const CANDIDATE_KEYS = Object.freeze([ + 'assertionRoot', + 'assertionVersion', + 'attestedAuthorAddress', + 'authorAddress', + 'chainId', + 'contractAddress', + 'finalizedBlockHash', + 'finalizedBlockNumber', + 'kaId', + 'knowledgeAssetStorageAddress', + 'ordinal', + 'publisherAddress', + 'ual', +] as const); +const FIXED_SCAN_CALLS = 2; +const ID_CALLS_PER_ROW = 1; +const ASSERTION_CALLS_PER_ROW = 4; +const TOTAL_CALLS_PER_ROW = ID_CALLS_PER_ROW + ASSERTION_CALLS_PER_ROW; + +/** Exact dense-row ceiling shared by scanner orchestration and the model boundary. */ +export const FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 = deriveFinalizedVmScanMaxRows( + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, +); + +/** Chain-authenticated assertion identity before subgraph placement is joined. */ +export interface FinalizedVmChainCandidateV1 { + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly ordinal: DecimalU64V1; + readonly kaId: string; + readonly ual: string; + readonly authorAddress: EvmAddressV1; + readonly attestedAuthorAddress: EvmAddressV1 | null; + readonly publisherAddress: EvmAddressV1 | null; + readonly assertionVersion: DecimalU64V1; + readonly assertionRoot: Digest32V1; + readonly finalizedBlockNumber: BlockNumberV1; + readonly finalizedBlockHash: Digest32V1; +} + +export interface FinalizedVmChainInventoryV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: DecimalU256V1; + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly finalizedBlockNumber: BlockNumberV1; + readonly finalizedBlockHash: Digest32V1; + readonly highestFinalizedOrdinal: DecimalU64V1 | null; + readonly rows: readonly Readonly[]; +} + +interface FinalizedVmChainInventoryHeaderSnapshotV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: DecimalU256V1; + readonly chainId: ChainIdV1; + readonly contractAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly finalizedBlockNumber: BlockNumberV1; + readonly finalizedBlockHash: Digest32V1; + readonly highestFinalizedOrdinal: DecimalU64V1 | null; +} + +/** Canonical chain-owned boundary for scanner output consumed across packages. */ +export function snapshotFinalizedVmChainInventoryV1( + input: unknown, +): Readonly { + try { + const record = snapshotExactDataRecord(input, INVENTORY_KEYS); + assertNetworkIdV1(record.networkId, 'finalized VM inventory networkId'); + assertCanonicalDecimalU256(record.contextGraphId, 'finalized VM inventory contextGraphId'); + if (record.contextGraphId === '0') throw new Error('contextGraphId must be nonzero'); + assertCanonicalChainId(record.chainId, 'finalized VM inventory chainId'); + assertCanonicalNonzeroEvmAddress(record.contractAddress, 'finalized VM inventory contract'); + assertCanonicalNonzeroEvmAddress( + record.knowledgeAssetStorageAddress, + 'finalized VM inventory KA storage', + ); + assertCanonicalDecimalU64( + record.finalizedBlockNumber, + 'finalized VM inventory block number', + ); + assertCanonicalDigest(record.finalizedBlockHash, 'finalized VM inventory block hash'); + const highestFinalizedOrdinal = record.highestFinalizedOrdinal; + if (highestFinalizedOrdinal !== null) { + assertCanonicalDecimalU64( + highestFinalizedOrdinal, + 'finalized VM inventory highest ordinal', + ); + } + const header = Object.freeze({ + networkId: record.networkId, + contextGraphId: record.contextGraphId, + chainId: record.chainId, + contractAddress: record.contractAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + finalizedBlockNumber: record.finalizedBlockNumber, + finalizedBlockHash: record.finalizedBlockHash, + highestFinalizedOrdinal, + } satisfies FinalizedVmChainInventoryHeaderSnapshotV1); + const untrustedRows = snapshotDenseDataArray(record.rows, { + label: 'finalized VM inventory rows', + maxLength: FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + }); + const rows = Object.freeze(untrustedRows.map((row, index) => + snapshotInventoryCandidate(row, index, header))); + const expectedHighest = rows.length === 0 ? null : String(rows.length - 1); + if (header.highestFinalizedOrdinal !== expectedHighest) { + throw new Error('highest ordinal does not match the dense inventory rows'); + } + return Object.freeze({ ...header, rows } satisfies FinalizedVmChainInventoryV1); + } catch (cause) { + if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; + throw malformedReturn('Finalized VM chain inventory is not canonical', cause); + } +} + +function snapshotInventoryCandidate( + input: unknown, + index: number, + inventory: Readonly, +): Readonly { + const record = snapshotExactDataRecord(input, CANDIDATE_KEYS); + assertCanonicalChainId(record.chainId, `finalized VM candidate ${index} chainId`); + assertCanonicalNonzeroEvmAddress(record.contractAddress, `finalized VM candidate ${index} contract`); + assertCanonicalNonzeroEvmAddress( + record.knowledgeAssetStorageAddress, + `finalized VM candidate ${index} KA storage`, + ); + assertCanonicalDecimalU64(record.ordinal, `finalized VM candidate ${index} ordinal`); + assertCanonicalKaId(record.kaId, `finalized VM candidate ${index} kaId`); + assertCanonicalNonzeroEvmAddress(record.authorAddress, `finalized VM candidate ${index} author`); + assertNullableInventoryAddress( + record.attestedAuthorAddress, + `finalized VM candidate ${index} attested author`, + ); + assertNullableInventoryAddress( + record.publisherAddress, + `finalized VM candidate ${index} publisher`, + ); + assertCanonicalDecimalU64( + record.assertionVersion, + `finalized VM candidate ${index} assertion version`, + ); + assertCanonicalDigest(record.assertionRoot, `finalized VM candidate ${index} assertion root`); + assertCanonicalDecimalU64( + record.finalizedBlockNumber, + `finalized VM candidate ${index} block number`, + ); + assertCanonicalDigest(record.finalizedBlockHash, `finalized VM candidate ${index} block hash`); + const identity = unpackDeterministicRootlessKnowledgeAssetId( + inventory.networkId, + BigInt(record.kaId), + ); + if ( + record.ual !== identity.ual + || record.authorAddress !== identity.agentAddress + || record.assertionVersion === '0' + || record.assertionRoot === ethers.ZeroHash + || record.ordinal !== String(index) + || record.chainId !== inventory.chainId + || record.contractAddress !== inventory.contractAddress + || record.knowledgeAssetStorageAddress !== inventory.knowledgeAssetStorageAddress + || record.finalizedBlockNumber !== inventory.finalizedBlockNumber + || record.finalizedBlockHash !== inventory.finalizedBlockHash + ) { + throw new Error(`finalized VM candidate ${index} differs from its identity or inventory lane`); + } + return Object.freeze({ + chainId: record.chainId, + contractAddress: record.contractAddress, + knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, + ordinal: record.ordinal, + kaId: record.kaId, + ual: record.ual, + authorAddress: record.authorAddress, + attestedAuthorAddress: record.attestedAuthorAddress, + publisherAddress: record.publisherAddress, + assertionVersion: record.assertionVersion, + assertionRoot: record.assertionRoot, + finalizedBlockNumber: record.finalizedBlockNumber, + finalizedBlockHash: record.finalizedBlockHash, + } satisfies FinalizedVmChainCandidateV1); +} + +function assertNullableInventoryAddress( + value: unknown, + label: string, +): asserts value is EvmAddressV1 | null { + if (value !== null) assertCanonicalNonzeroEvmAddress(value, label); +} + +function deriveFinalizedVmScanMaxRows( + maxCalls: number, + maxBatches: number, + maxCallsPerBatch: number, +): number { + const fits = (rows: number): boolean => ( + FIXED_SCAN_CALLS + (rows * TOTAL_CALLS_PER_ROW) <= maxCalls + && 1 + + Math.ceil((rows * ID_CALLS_PER_ROW) / maxCallsPerBatch) + + Math.ceil((rows * ASSERTION_CALLS_PER_ROW) / maxCallsPerBatch) + <= maxBatches + ); + let lower = 0; + let upper = Math.max(0, Math.floor((maxCalls - FIXED_SCAN_CALLS) / TOTAL_CALLS_PER_ROW)); + while (lower < upper) { + const candidate = Math.ceil((lower + upper) / 2); + if (fits(candidate)) lower = candidate; + else upper = candidate - 1; + } + return lower; +} + +function malformedReturn( + message: string, + cause?: unknown, +): CurrentFinalizedEvmCallErrorV1 { + return new CurrentFinalizedEvmCallErrorV1( + 'malformed-return', + message, + cause === undefined ? {} : { cause }, + ); +} + +// Keep the derived public bound tied to the two snapshot budgets if either is +// tightened later; this assertion is module-load local and performs no I/O. +if ( + 1 + + Math.ceil( + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ID_CALLS_PER_ROW) + / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + ) + + Math.ceil( + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ASSERTION_CALLS_PER_ROW) + / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + ) + > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 + || FIXED_SCAN_CALLS + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * TOTAL_CALLS_PER_ROW) + > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 +) { + throw new Error('Finalized VM scan row bound exceeds a pinned-snapshot resource budget'); +} diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts index 1552bdacad..ec7f802f89 100644 --- a/packages/chain/src/finalized-vm-chain-scanner.ts +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -3,10 +3,8 @@ import { assertCanonicalDecimalU256, assertCanonicalDecimalU64, assertCanonicalDigest, - assertCanonicalKaId, assertNetworkIdV1, unpackDeterministicRootlessKnowledgeAssetId, - type BlockNumberV1, type ChainIdV1, type DecimalU256V1, type DecimalU64V1, @@ -22,16 +20,19 @@ import { CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; import { - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, type StrictCurrentFinalizedEvmSnapshotScopeV1, type StrictCurrentFinalizedEvmSnapshotSessionV1, } from './current-finalized-evm-snapshot.js'; import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; +import { + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + snapshotFinalizedVmChainInventoryV1, + type FinalizedVmChainCandidateV1, + type FinalizedVmChainInventoryV1, +} from './finalized-vm-chain-inventory.js'; import { assertCanonicalNonzeroEvmAddress, isAbortSignal, - snapshotDenseDataArray, snapshotExactDataRecord, } from './strict-local-data.js'; @@ -52,49 +53,9 @@ const SESSION_CONFIG_KEYS = Object.freeze([ 'networkId', ] as const); const REQUEST_KEYS = Object.freeze(['contextGraphId', 'signal'] as const); -const INVENTORY_KEYS = Object.freeze([ - 'chainId', - 'contextGraphId', - 'contractAddress', - 'finalizedBlockHash', - 'finalizedBlockNumber', - 'highestFinalizedOrdinal', - 'knowledgeAssetStorageAddress', - 'networkId', - 'rows', -] as const); -const CANDIDATE_KEYS = Object.freeze([ - 'assertionRoot', - 'assertionVersion', - 'attestedAuthorAddress', - 'authorAddress', - 'chainId', - 'contractAddress', - 'finalizedBlockHash', - 'finalizedBlockNumber', - 'kaId', - 'knowledgeAssetStorageAddress', - 'ordinal', - 'publisherAddress', - 'ual', -] as const); const UINT256_RETURN_BYTES = 32; const UPDATE_CONTEXT_RETURN_BYTES = 7 * 32; const FIXED_SCAN_CALLS = 2; -const ID_CALLS_PER_ROW = 1; -const ASSERTION_CALLS_PER_ROW = 4; -const TOTAL_CALLS_PER_ROW = ID_CALLS_PER_ROW + ASSERTION_CALLS_PER_ROW; - -/** - * One active/count header plus one indexed-ID read and four assertion reads - * per row. The exported ceiling is derived from the exact call and batching - * equations so transport-budget changes cannot silently invalidate it. - */ -export const FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 = deriveFinalizedVmScanMaxRows( - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, - CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, -); export interface FinalizedVmChainSessionScannerConfigV1 { readonly networkId: NetworkIdV1; @@ -113,109 +74,10 @@ export interface FinalizedVmChainScanRequestV1 { readonly signal: AbortSignal; } -/** Chain-authenticated assertion identity before subgraph placement is joined. */ -export interface FinalizedVmChainCandidateV1 { - readonly chainId: ChainIdV1; - readonly contractAddress: EvmAddressV1; - readonly knowledgeAssetStorageAddress: EvmAddressV1; - readonly ordinal: DecimalU64V1; - readonly kaId: string; - readonly ual: string; - readonly authorAddress: EvmAddressV1; - /** Latest chain-verified author attestation; null is explicit legacy/incomplete state. */ - readonly attestedAuthorAddress: EvmAddressV1 | null; - /** EOA that submitted the latest root; curated admission consumes this chain truth. */ - readonly publisherAddress: EvmAddressV1 | null; - readonly assertionVersion: DecimalU64V1; - readonly assertionRoot: Digest32V1; - readonly finalizedBlockNumber: BlockNumberV1; - readonly finalizedBlockHash: Digest32V1; -} - -export interface FinalizedVmChainInventoryV1 { - readonly networkId: NetworkIdV1; - readonly contextGraphId: DecimalU256V1; - readonly chainId: ChainIdV1; - readonly contractAddress: EvmAddressV1; - readonly knowledgeAssetStorageAddress: EvmAddressV1; - readonly finalizedBlockNumber: BlockNumberV1; - readonly finalizedBlockHash: Digest32V1; - readonly highestFinalizedOrdinal: DecimalU64V1 | null; - readonly rows: readonly Readonly[]; -} - export interface FinalizedVmChainScannerV1 { (request: FinalizedVmChainScanRequestV1): Promise>; } -interface FinalizedVmChainInventoryHeaderSnapshotV1 { - readonly networkId: NetworkIdV1; - readonly contextGraphId: DecimalU256V1; - readonly chainId: ChainIdV1; - readonly contractAddress: EvmAddressV1; - readonly knowledgeAssetStorageAddress: EvmAddressV1; - readonly finalizedBlockNumber: BlockNumberV1; - readonly finalizedBlockHash: Digest32V1; - readonly highestFinalizedOrdinal: DecimalU64V1 | null; -} - -/** Canonical chain-owned boundary for scanner output consumed across packages. */ -export function snapshotFinalizedVmChainInventoryV1( - input: unknown, -): Readonly { - try { - const record = snapshotExactDataRecord(input, INVENTORY_KEYS); - assertNetworkIdV1(record.networkId, 'finalized VM inventory networkId'); - assertCanonicalDecimalU256(record.contextGraphId, 'finalized VM inventory contextGraphId'); - if (record.contextGraphId === '0') throw new Error('contextGraphId must be nonzero'); - assertCanonicalChainId(record.chainId, 'finalized VM inventory chainId'); - assertCanonicalNonzeroEvmAddress(record.contractAddress, 'finalized VM inventory contract'); - assertCanonicalNonzeroEvmAddress( - record.knowledgeAssetStorageAddress, - 'finalized VM inventory KA storage', - ); - assertCanonicalDecimalU64( - record.finalizedBlockNumber, - 'finalized VM inventory block number', - ); - assertCanonicalDigest(record.finalizedBlockHash, 'finalized VM inventory block hash'); - const highestFinalizedOrdinal = record.highestFinalizedOrdinal; - if (highestFinalizedOrdinal !== null) { - assertCanonicalDecimalU64( - highestFinalizedOrdinal, - 'finalized VM inventory highest ordinal', - ); - } - const header = Object.freeze({ - networkId: record.networkId, - contextGraphId: record.contextGraphId, - chainId: record.chainId, - contractAddress: record.contractAddress, - knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, - finalizedBlockNumber: record.finalizedBlockNumber, - finalizedBlockHash: record.finalizedBlockHash, - highestFinalizedOrdinal, - } satisfies FinalizedVmChainInventoryHeaderSnapshotV1); - const untrustedRows = snapshotDenseDataArray(record.rows, { - label: 'finalized VM inventory rows', - maxLength: FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, - }); - const rows = Object.freeze(untrustedRows.map((row, index) => - snapshotInventoryCandidate(row, index, header))); - const expectedHighest = rows.length === 0 ? null : String(rows.length - 1); - if (header.highestFinalizedOrdinal !== expectedHighest) { - throw new Error('highest ordinal does not match the dense inventory rows'); - } - return Object.freeze({ - ...header, - rows, - } satisfies FinalizedVmChainInventoryV1); - } catch (cause) { - if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; - throw malformedReturn('Finalized VM chain inventory is not canonical', cause); - } -} - interface ScannerSessionConfigSnapshotV1 { readonly networkId: NetworkIdV1; readonly chainId: ChainIdV1; @@ -400,90 +262,6 @@ async function scanPinnedInventory( }); } -function snapshotInventoryCandidate( - input: unknown, - index: number, - inventory: Readonly, -): Readonly { - const record = snapshotExactDataRecord(input, CANDIDATE_KEYS); - assertCanonicalChainId(record.chainId, `finalized VM candidate ${index} chainId`); - assertCanonicalNonzeroEvmAddress( - record.contractAddress, - `finalized VM candidate ${index} contract`, - ); - assertCanonicalNonzeroEvmAddress( - record.knowledgeAssetStorageAddress, - `finalized VM candidate ${index} KA storage`, - ); - assertCanonicalDecimalU64(record.ordinal, `finalized VM candidate ${index} ordinal`); - assertCanonicalKaId(record.kaId, `finalized VM candidate ${index} kaId`); - assertCanonicalNonzeroEvmAddress( - record.authorAddress, - `finalized VM candidate ${index} author`, - ); - assertNullableInventoryAddress( - record.attestedAuthorAddress, - `finalized VM candidate ${index} attested author`, - ); - assertNullableInventoryAddress( - record.publisherAddress, - `finalized VM candidate ${index} publisher`, - ); - assertCanonicalDecimalU64( - record.assertionVersion, - `finalized VM candidate ${index} assertion version`, - ); - assertCanonicalDigest(record.assertionRoot, `finalized VM candidate ${index} assertion root`); - assertCanonicalDecimalU64( - record.finalizedBlockNumber, - `finalized VM candidate ${index} block number`, - ); - assertCanonicalDigest( - record.finalizedBlockHash, - `finalized VM candidate ${index} block hash`, - ); - const identity = unpackDeterministicRootlessKnowledgeAssetId( - inventory.networkId, - BigInt(record.kaId), - ); - if ( - record.ual !== identity.ual - || record.authorAddress !== identity.agentAddress - || record.assertionVersion === '0' - || record.assertionRoot === ethers.ZeroHash - || record.ordinal !== String(index) - || record.chainId !== inventory.chainId - || record.contractAddress !== inventory.contractAddress - || record.knowledgeAssetStorageAddress !== inventory.knowledgeAssetStorageAddress - || record.finalizedBlockNumber !== inventory.finalizedBlockNumber - || record.finalizedBlockHash !== inventory.finalizedBlockHash - ) { - throw new Error(`finalized VM candidate ${index} differs from its identity or inventory lane`); - } - return Object.freeze({ - chainId: record.chainId, - contractAddress: record.contractAddress, - knowledgeAssetStorageAddress: record.knowledgeAssetStorageAddress, - ordinal: record.ordinal, - kaId: record.kaId, - ual: record.ual, - authorAddress: record.authorAddress, - attestedAuthorAddress: record.attestedAuthorAddress, - publisherAddress: record.publisherAddress, - assertionVersion: record.assertionVersion, - assertionRoot: record.assertionRoot, - finalizedBlockNumber: record.finalizedBlockNumber, - finalizedBlockHash: record.finalizedBlockHash, - } satisfies FinalizedVmChainCandidateV1); -} - -function assertNullableInventoryAddress( - value: unknown, - label: string, -): asserts value is EvmAddressV1 | null { - if (value !== null) assertCanonicalNonzeroEvmAddress(value, label); -} - async function readFinalizedVmAssertions( session: StrictCurrentFinalizedEvmSnapshotSessionV1, knowledgeAssetStorageAddress: EvmAddressV1, @@ -723,28 +501,6 @@ function snapshotRequest(input: unknown): Readonly ( - FIXED_SCAN_CALLS + (rows * TOTAL_CALLS_PER_ROW) <= maxCalls - && 1 - + Math.ceil((rows * ID_CALLS_PER_ROW) / maxCallsPerBatch) - + Math.ceil((rows * ASSERTION_CALLS_PER_ROW) / maxCallsPerBatch) - <= maxBatches - ); - let lower = 0; - let upper = Math.max(0, Math.floor((maxCalls - FIXED_SCAN_CALLS) / TOTAL_CALLS_PER_ROW)); - while (lower < upper) { - const candidate = Math.ceil((lower + upper) / 2); - if (fits(candidate)) lower = candidate; - else upper = candidate - 1; - } - return lower; -} - function malformedReturn( message: string, cause?: unknown, @@ -755,22 +511,3 @@ function malformedReturn( cause === undefined ? {} : { cause }, ); } - -// Keep the derived public bound tied to the two snapshot budgets if either is -// tightened later; this assertion is module-load local and performs no I/O. -if ( - 1 - + Math.ceil( - (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ID_CALLS_PER_ROW) - / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - ) - + Math.ceil( - (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ASSERTION_CALLS_PER_ROW) - / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - ) - > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 - || FIXED_SCAN_CALLS + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * TOTAL_CALLS_PER_ROW) - > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 -) { - throw new Error('Finalized VM scan row bound exceeds a pinned-snapshot resource budget'); -} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 0981f572c6..ac096535a2 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -46,11 +46,13 @@ export { } from './finalized-context-graph-rpc-resolver.js'; export { FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, - createFinalizedVmChainScannerV1, - scanFinalizedVmChainInventoryInSnapshotV1, snapshotFinalizedVmChainInventoryV1, type FinalizedVmChainCandidateV1, type FinalizedVmChainInventoryV1, +} from './finalized-vm-chain-inventory.js'; +export { + createFinalizedVmChainScannerV1, + scanFinalizedVmChainInventoryInSnapshotV1, type FinalizedVmChainScanRequestV1, type FinalizedVmChainScannerConfigV1, type FinalizedVmChainSessionScannerConfigV1, diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index 1f688cf86c..4744b54fc0 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -11,9 +11,11 @@ import { afterEach, describe, expect, it } from 'vitest'; import { loadAbi } from '../src/evm-adapter-abi.js'; import { FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + snapshotFinalizedVmChainInventoryV1, +} from '../src/finalized-vm-chain-inventory.js'; +import { createFinalizedVmChainScannerV1, scanFinalizedVmChainInventoryInSnapshotV1, - snapshotFinalizedVmChainInventoryV1, } from '../src/finalized-vm-chain-scanner.js'; import { CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, From 7a85ac240157a196ef532cc007b2efde7b8c3517 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 15:37:05 +0200 Subject: [PATCH 250/292] fix(rfc64): preserve VM model boundaries --- packages/agent/scripts/test-package-root.mjs | 13 +++ packages/agent/src/index.ts | 23 +++- .../chain/src/finalized-vm-chain-inventory.ts | 103 +++++------------- .../chain/src/finalized-vm-chain-scanner.ts | 78 +++++++++++-- packages/chain/src/index.ts | 4 +- .../finalized-vm-chain-scanner.unit.test.ts | 2 +- 6 files changed, 133 insertions(+), 90 deletions(-) diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index f5f6ae82ff..fcc76522d1 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -27,6 +27,19 @@ if ( ) { throw new Error('published agent entry points did not expose required root APIs'); } +try { + root.assertRecoverableAuthorAttestationV1({ + seal: { authorSchemeVersion: 'unsupported' }, + }); + throw new Error('package-root author attestation guard accepted an unsupported scheme'); +} catch (error) { + if ( + !(error instanceof root.Rfc64PublicCatalogNativeReceiverErrorV1) + || error.code !== 'catalog-native-receiver-transfer' + ) { + throw new Error('package-root author attestation guard changed its receiver error contract'); + } +} const requiredCatalogMethods = [ 'acceptRfc64CatalogAccessSnapshotV1', 'publishAuthorCatalogGenesisV1', diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index fbf5d7857a..dd54d3a083 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -38,7 +38,6 @@ export * from './rfc64/catalog-row-authorship.js'; export * from './rfc64/finalized-vm-composer-v1.js'; export { RecoverableAuthorAttestationErrorV1, - assertRecoverableAuthorAttestationV1, } from './rfc64/recoverable-author-attestation-v1.js'; export * from './rfc64/author-catalog-producer.js'; export * from './rfc64/public-catalog-transport-v1.js'; @@ -48,6 +47,24 @@ export * from './rfc64/public-catalog-service-v1.js'; export * from './rfc64/public-catalog-issuer-delegation-v1.js'; export * from './rfc64/public-catalog-native-transport-v1.js'; export * from './rfc64/public-catalog-native-receiver-v1.js'; + +/** Preserve the pre-refactor package-root receiver error contract. */ +export function assertRecoverableAuthorAttestationV1( + binding: VerifiedCatalogSealBindingSnapshotV1, +): void { + try { + assertRecoverableAuthorAttestationCapabilityV1(binding); + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogNativeReceiverErrorV1) throw cause; + throw new Rfc64PublicCatalogNativeReceiverErrorV1( + 'catalog-native-receiver-transfer', + cause instanceof Error + ? cause.message + : 'author attestation does not recover the catalog author', + { cause }, + ); + } +} export { computeRfc64AppliedInventoryDigestV1, type ComputeRfc64AppliedInventoryDigestInputV1, @@ -314,3 +331,7 @@ export { // exported. The serve-side resolver `shouldWithholdAgentsDurableMeta` stays // internal; only the in-agent lifecycle (at its env boundary) + tests use it. export { resolveSyncAgentsMeta, parseBooleanEnv } from './sync/agents-meta-policy.js'; +import type { VerifiedCatalogSealBindingSnapshotV1 } from '@origintrail-official/dkg-core'; + +import { assertRecoverableAuthorAttestationV1 as assertRecoverableAuthorAttestationCapabilityV1 } from './rfc64/recoverable-author-attestation-v1.js'; +import { Rfc64PublicCatalogNativeReceiverErrorV1 } from './rfc64/public-catalog-native-receiver-v1.js'; diff --git a/packages/chain/src/finalized-vm-chain-inventory.ts b/packages/chain/src/finalized-vm-chain-inventory.ts index df30f340e2..a7f9e2f987 100644 --- a/packages/chain/src/finalized-vm-chain-inventory.ts +++ b/packages/chain/src/finalized-vm-chain-inventory.ts @@ -16,14 +16,6 @@ import { } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; -import { - CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - CurrentFinalizedEvmCallErrorV1, -} from './current-finalized-evm-read-profile.js'; -import { - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, -} from './current-finalized-evm-snapshot.js'; import { assertCanonicalNonzeroEvmAddress, snapshotDenseDataArray, @@ -56,18 +48,6 @@ const CANDIDATE_KEYS = Object.freeze([ 'publisherAddress', 'ual', ] as const); -const FIXED_SCAN_CALLS = 2; -const ID_CALLS_PER_ROW = 1; -const ASSERTION_CALLS_PER_ROW = 4; -const TOTAL_CALLS_PER_ROW = ID_CALLS_PER_ROW + ASSERTION_CALLS_PER_ROW; - -/** Exact dense-row ceiling shared by scanner orchestration and the model boundary. */ -export const FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 = deriveFinalizedVmScanMaxRows( - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, - CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, - CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, -); - /** Chain-authenticated assertion identity before subgraph placement is joined. */ export interface FinalizedVmChainCandidateV1 { readonly chainId: ChainIdV1; @@ -97,6 +77,18 @@ export interface FinalizedVmChainInventoryV1 { readonly rows: readonly Readonly[]; } +export interface FinalizedVmChainInventorySnapshotOptionsV1 { + /** Optional caller-owned resource ceiling; not part of the inventory model. */ + readonly maxRows?: number; +} + +export class FinalizedVmChainInventoryValidationErrorV1 extends TypeError { + constructor(message: string, options: ErrorOptions = {}) { + super(message, options); + this.name = 'FinalizedVmChainInventoryValidationErrorV1'; + } +} + interface FinalizedVmChainInventoryHeaderSnapshotV1 { readonly networkId: NetworkIdV1; readonly contextGraphId: DecimalU256V1; @@ -111,8 +103,18 @@ interface FinalizedVmChainInventoryHeaderSnapshotV1 { /** Canonical chain-owned boundary for scanner output consumed across packages. */ export function snapshotFinalizedVmChainInventoryV1( input: unknown, + options: FinalizedVmChainInventorySnapshotOptionsV1 = {}, ): Readonly { try { + if ( + options.maxRows !== undefined + && ( + !Number.isSafeInteger(options.maxRows) + || options.maxRows < 0 + ) + ) { + throw new Error('maxRows must be a nonnegative safe integer'); + } const record = snapshotExactDataRecord(input, INVENTORY_KEYS); assertNetworkIdV1(record.networkId, 'finalized VM inventory networkId'); assertCanonicalDecimalU256(record.contextGraphId, 'finalized VM inventory contextGraphId'); @@ -147,7 +149,7 @@ export function snapshotFinalizedVmChainInventoryV1( } satisfies FinalizedVmChainInventoryHeaderSnapshotV1); const untrustedRows = snapshotDenseDataArray(record.rows, { label: 'finalized VM inventory rows', - maxLength: FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + ...(options.maxRows === undefined ? {} : { maxLength: options.maxRows }), }); const rows = Object.freeze(untrustedRows.map((row, index) => snapshotInventoryCandidate(row, index, header))); @@ -157,8 +159,11 @@ export function snapshotFinalizedVmChainInventoryV1( } return Object.freeze({ ...header, rows } satisfies FinalizedVmChainInventoryV1); } catch (cause) { - if (cause instanceof CurrentFinalizedEvmCallErrorV1) throw cause; - throw malformedReturn('Finalized VM chain inventory is not canonical', cause); + if (cause instanceof FinalizedVmChainInventoryValidationErrorV1) throw cause; + throw new FinalizedVmChainInventoryValidationErrorV1( + 'Finalized VM chain inventory is not canonical', + { cause }, + ); } } @@ -236,55 +241,3 @@ function assertNullableInventoryAddress( ): asserts value is EvmAddressV1 | null { if (value !== null) assertCanonicalNonzeroEvmAddress(value, label); } - -function deriveFinalizedVmScanMaxRows( - maxCalls: number, - maxBatches: number, - maxCallsPerBatch: number, -): number { - const fits = (rows: number): boolean => ( - FIXED_SCAN_CALLS + (rows * TOTAL_CALLS_PER_ROW) <= maxCalls - && 1 - + Math.ceil((rows * ID_CALLS_PER_ROW) / maxCallsPerBatch) - + Math.ceil((rows * ASSERTION_CALLS_PER_ROW) / maxCallsPerBatch) - <= maxBatches - ); - let lower = 0; - let upper = Math.max(0, Math.floor((maxCalls - FIXED_SCAN_CALLS) / TOTAL_CALLS_PER_ROW)); - while (lower < upper) { - const candidate = Math.ceil((lower + upper) / 2); - if (fits(candidate)) lower = candidate; - else upper = candidate - 1; - } - return lower; -} - -function malformedReturn( - message: string, - cause?: unknown, -): CurrentFinalizedEvmCallErrorV1 { - return new CurrentFinalizedEvmCallErrorV1( - 'malformed-return', - message, - cause === undefined ? {} : { cause }, - ); -} - -// Keep the derived public bound tied to the two snapshot budgets if either is -// tightened later; this assertion is module-load local and performs no I/O. -if ( - 1 - + Math.ceil( - (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ID_CALLS_PER_ROW) - / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - ) - + Math.ceil( - (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ASSERTION_CALLS_PER_ROW) - / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, - ) - > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 - || FIXED_SCAN_CALLS + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * TOTAL_CALLS_PER_ROW) - > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 -) { - throw new Error('Finalized VM scan row bound exceeds a pinned-snapshot resource budget'); -} diff --git a/packages/chain/src/finalized-vm-chain-scanner.ts b/packages/chain/src/finalized-vm-chain-scanner.ts index ec7f802f89..d65c77108f 100644 --- a/packages/chain/src/finalized-vm-chain-scanner.ts +++ b/packages/chain/src/finalized-vm-chain-scanner.ts @@ -20,12 +20,13 @@ import { CurrentFinalizedEvmCallErrorV1, } from './current-finalized-evm-read-profile.js'; import { + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, type StrictCurrentFinalizedEvmSnapshotScopeV1, type StrictCurrentFinalizedEvmSnapshotSessionV1, } from './current-finalized-evm-snapshot.js'; import type { StrictCurrentFinalizedEvmReadCallV1 } from './current-finalized-evm-read-model.js'; import { - FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, snapshotFinalizedVmChainInventoryV1, type FinalizedVmChainCandidateV1, type FinalizedVmChainInventoryV1, @@ -56,6 +57,16 @@ const REQUEST_KEYS = Object.freeze(['contextGraphId', 'signal'] as const); const UINT256_RETURN_BYTES = 32; const UPDATE_CONTEXT_RETURN_BYTES = 7 * 32; const FIXED_SCAN_CALLS = 2; +const ID_CALLS_PER_ROW = 1; +const ASSERTION_CALLS_PER_ROW = 4; +const TOTAL_CALLS_PER_ROW = ID_CALLS_PER_ROW + ASSERTION_CALLS_PER_ROW; + +/** Exact row ceiling owned by pinned-snapshot scanner orchestration. */ +export const FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 = deriveFinalizedVmScanMaxRows( + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1, + CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1, + CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, +); export interface FinalizedVmChainSessionScannerConfigV1 { readonly networkId: NetworkIdV1; @@ -249,17 +260,21 @@ async function scanPinnedInventory( const highestFinalizedOrdinal = rowCount === 0n ? null : (rowCount - 1n).toString(10) as DecimalU64V1; - return snapshotFinalizedVmChainInventoryV1({ - networkId: config.networkId, - contextGraphId, - chainId: config.chainId, - contractAddress: config.contextGraphStorageAddress, - knowledgeAssetStorageAddress: config.knowledgeAssetStorageAddress, - finalizedBlockNumber: session.blockNumber, - finalizedBlockHash: session.blockHash, - highestFinalizedOrdinal, - rows: Object.freeze(rows), - }); + try { + return snapshotFinalizedVmChainInventoryV1({ + networkId: config.networkId, + contextGraphId, + chainId: config.chainId, + contractAddress: config.contextGraphStorageAddress, + knowledgeAssetStorageAddress: config.knowledgeAssetStorageAddress, + finalizedBlockNumber: session.blockNumber, + finalizedBlockHash: session.blockHash, + highestFinalizedOrdinal, + rows: Object.freeze(rows), + }, { maxRows: FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 }); + } catch (cause) { + throw malformedReturn('Finalized VM scanner produced a non-canonical inventory', cause); + } } async function readFinalizedVmAssertions( @@ -511,3 +526,42 @@ function malformedReturn( cause === undefined ? {} : { cause }, ); } + +function deriveFinalizedVmScanMaxRows( + maxCalls: number, + maxBatches: number, + maxCallsPerBatch: number, +): number { + const fits = (rows: number): boolean => ( + FIXED_SCAN_CALLS + (rows * TOTAL_CALLS_PER_ROW) <= maxCalls + && 1 + + Math.ceil((rows * ID_CALLS_PER_ROW) / maxCallsPerBatch) + + Math.ceil((rows * ASSERTION_CALLS_PER_ROW) / maxCallsPerBatch) + <= maxBatches + ); + let lower = 0; + let upper = Math.max(0, Math.floor((maxCalls - FIXED_SCAN_CALLS) / TOTAL_CALLS_PER_ROW)); + while (lower < upper) { + const candidate = Math.ceil((lower + upper) / 2); + if (fits(candidate)) lower = candidate; + else upper = candidate - 1; + } + return lower; +} + +if ( + 1 + + Math.ceil( + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ID_CALLS_PER_ROW) + / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + ) + + Math.ceil( + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * ASSERTION_CALLS_PER_ROW) + / CURRENT_FINALIZED_EVM_READ_MAX_CALLS_V1, + ) + > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_BATCHES_V1 + || FIXED_SCAN_CALLS + (FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 * TOTAL_CALLS_PER_ROW) + > CURRENT_FINALIZED_EVM_SNAPSHOT_MAX_CALLS_V1 +) { + throw new Error('Finalized VM scan row bound exceeds a pinned-snapshot resource budget'); +} diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index ac096535a2..206bcc1dbd 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -45,12 +45,14 @@ export { createFinalizedContextGraphRpcResolverV1, } from './finalized-context-graph-rpc-resolver.js'; export { - FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + FinalizedVmChainInventoryValidationErrorV1, snapshotFinalizedVmChainInventoryV1, type FinalizedVmChainCandidateV1, type FinalizedVmChainInventoryV1, + type FinalizedVmChainInventorySnapshotOptionsV1, } from './finalized-vm-chain-inventory.js'; export { + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, createFinalizedVmChainScannerV1, scanFinalizedVmChainInventoryInSnapshotV1, type FinalizedVmChainScanRequestV1, diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index 4744b54fc0..b46dec537d 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -10,10 +10,10 @@ import { afterEach, describe, expect, it } from 'vitest'; import { loadAbi } from '../src/evm-adapter-abi.js'; import { - FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, snapshotFinalizedVmChainInventoryV1, } from '../src/finalized-vm-chain-inventory.js'; import { + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, createFinalizedVmChainScannerV1, scanFinalizedVmChainInventoryInSnapshotV1, } from '../src/finalized-vm-chain-scanner.js'; From 3993ec6a41744de6fe124a63abae1ab31cf2462f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:10:16 +0200 Subject: [PATCH 251/292] refactor(agent): localize attestation receiver adapter --- packages/agent/src/index.ts | 22 ---------------- .../src/rfc64/finalized-vm-composer-v1.ts | 4 +-- .../public-catalog-native-receiver-v1.ts | 25 +++++++++++++++++-- .../public-catalog-successor-producer-v1.ts | 6 ++--- .../recoverable-author-attestation-v1.ts | 2 +- 5 files changed, 29 insertions(+), 30 deletions(-) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index dd54d3a083..5b35dd4e23 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -47,24 +47,6 @@ export * from './rfc64/public-catalog-service-v1.js'; export * from './rfc64/public-catalog-issuer-delegation-v1.js'; export * from './rfc64/public-catalog-native-transport-v1.js'; export * from './rfc64/public-catalog-native-receiver-v1.js'; - -/** Preserve the pre-refactor package-root receiver error contract. */ -export function assertRecoverableAuthorAttestationV1( - binding: VerifiedCatalogSealBindingSnapshotV1, -): void { - try { - assertRecoverableAuthorAttestationCapabilityV1(binding); - } catch (cause) { - if (cause instanceof Rfc64PublicCatalogNativeReceiverErrorV1) throw cause; - throw new Rfc64PublicCatalogNativeReceiverErrorV1( - 'catalog-native-receiver-transfer', - cause instanceof Error - ? cause.message - : 'author attestation does not recover the catalog author', - { cause }, - ); - } -} export { computeRfc64AppliedInventoryDigestV1, type ComputeRfc64AppliedInventoryDigestInputV1, @@ -331,7 +313,3 @@ export { // exported. The serve-side resolver `shouldWithholdAgentsDurableMeta` stays // internal; only the in-agent lifecycle (at its env boundary) + tests use it. export { resolveSyncAgentsMeta, parseBooleanEnv } from './sync/agents-meta-policy.js'; -import type { VerifiedCatalogSealBindingSnapshotV1 } from '@origintrail-official/dkg-core'; - -import { assertRecoverableAuthorAttestationV1 as assertRecoverableAuthorAttestationCapabilityV1 } from './rfc64/recoverable-author-attestation-v1.js'; -import { Rfc64PublicCatalogNativeReceiverErrorV1 } from './rfc64/public-catalog-native-receiver-v1.js'; diff --git a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts index 982469ddc6..445c94a23c 100644 --- a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts @@ -22,7 +22,7 @@ import { readVerifiedAuthorCatalogRowAuthorshipV1, type VerifiedAuthorCatalogRowAuthorshipV1, } from './catalog-row-authorship.js'; -import { assertRecoverableAuthorAttestationV1 } from './recoverable-author-attestation-v1.js'; +import { assertRecoverableAuthorAttestationCapabilityV1 } from './recoverable-author-attestation-v1.js'; const COMPOSITION_KEYS = [ 'catalogLane', @@ -139,7 +139,7 @@ export function composeFinalizedVmSetV1( try { authorship = readVerifiedAuthorCatalogRowAuthorshipV1(placement.authorship); sealBinding = readVerifiedCatalogSealBindingV1(placement.sealBinding); - assertRecoverableAuthorAttestationV1(sealBinding); + assertRecoverableAuthorAttestationCapabilityV1(sealBinding); } catch (cause) { fail( 'finalized-vm-composition-placement', diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index ee267caded..f56428f3d4 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -79,7 +79,7 @@ import type { AppliedCatalogHeadSnapshotV1, Rfc64InventoryV1OperationsV1, } from './inventory-v1/index.js'; -import { assertRecoverableAuthorAttestationV1 } from './recoverable-author-attestation-v1.js'; +import { assertRecoverableAuthorAttestationCapabilityV1 } from './recoverable-author-attestation-v1.js'; import { computeRfc64AppliedInventoryDigestV1, verifyRfc64PublicCatalogInventoryCompletenessV1, @@ -682,7 +682,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { sealBinding = readVerifiedCatalogSealBindingV1( transferredMetadata.catalogSealBinding, ); - assertRecoverableAuthorAttestationV1(sealBinding); + assertRecoverableAuthorAttestationCapabilityV1(sealBinding); const projection = verifyCgSharedProjectionV1(transferred, head, row, deployment); projectionMetadata = readVerifiedCgSharedProjectionMetadataV1( projection, @@ -1830,6 +1830,27 @@ export function rfc64CatalogSignatureVariantDigestV1( ) as Digest32V1; } +/** + * Preserve the package-root receiver contract while keeping the pure + * attestation capability free of receiver-specific error translation. + */ +export function assertRecoverableAuthorAttestationV1( + binding: VerifiedCatalogSealBindingSnapshotV1, +): void { + try { + assertRecoverableAuthorAttestationCapabilityV1(binding); + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogNativeReceiverErrorV1) throw cause; + fail( + 'catalog-native-receiver-transfer', + cause instanceof Error + ? cause.message + : 'author attestation does not recover the catalog author', + cause, + ); + } +} + function fail( code: Rfc64PublicCatalogNativeReceiverErrorCodeV1, message: string, diff --git a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts index db66f61d5b..aaa11c684a 100644 --- a/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-successor-producer-v1.ts @@ -67,7 +67,7 @@ import type { Rfc64ControlObjectOperationsV1, StageVerifiedControlObjectsResultV1, } from './control-object-store-v1.js'; -import { assertRecoverableAuthorAttestationV1 } from './recoverable-author-attestation-v1.js'; +import { assertRecoverableAuthorAttestationCapabilityV1 } from './recoverable-author-attestation-v1.js'; import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_RESPONSE_MAX_BYTES_V1, addRfc64PublicCatalogExactSetBundleBytesV1, @@ -264,7 +264,7 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { prepared.sealBytes, prepared.deployment, ); - assertRecoverableAuthorAttestationV1( + assertRecoverableAuthorAttestationCapabilityV1( readVerifiedCatalogSealBindingV1(initialSealBinding), ); } catch (cause) { @@ -333,7 +333,7 @@ export class Rfc64PublicCatalogSuccessorProducerV1 { prepared.deployment, ); const sealBinding = readVerifiedCatalogSealBindingV1(transfer.catalogSealBinding); - assertRecoverableAuthorAttestationV1(sealBinding); + assertRecoverableAuthorAttestationCapabilityV1(sealBinding); const verifiedProjection = verifyCgSharedProjectionV1( transferred, publication.head, diff --git a/packages/agent/src/rfc64/recoverable-author-attestation-v1.ts b/packages/agent/src/rfc64/recoverable-author-attestation-v1.ts index 82c0ea48ad..96be4eccbf 100644 --- a/packages/agent/src/rfc64/recoverable-author-attestation-v1.ts +++ b/packages/agent/src/rfc64/recoverable-author-attestation-v1.ts @@ -13,7 +13,7 @@ export class RecoverableAuthorAttestationErrorV1 extends Error { } /** Require the v1 EOA AuthorAttestation committed by a catalog seal to recover its author. */ -export function assertRecoverableAuthorAttestationV1( +export function assertRecoverableAuthorAttestationCapabilityV1( binding: VerifiedCatalogSealBindingSnapshotV1, ): void { const { seal } = binding; From 33a898fb245df999d8b2386e627b180268275a9a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:19:26 +0200 Subject: [PATCH 252/292] test(chain): enforce finalized VM snapshot row ceilings --- .../test/finalized-vm-chain-scanner.unit.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts index b46dec537d..c45ef7474a 100644 --- a/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts +++ b/packages/chain/test/finalized-vm-chain-scanner.unit.test.ts @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { loadAbi } from '../src/evm-adapter-abi.js'; import { + FinalizedVmChainInventoryValidationErrorV1, snapshotFinalizedVmChainInventoryV1, } from '../src/finalized-vm-chain-inventory.js'; import { @@ -138,6 +139,20 @@ describe('RFC-64 finalized VM chain scanner', () => { expect(Object.isFrozen(inventory)).toBe(true); expect(Object.isFrozen(inventory.rows)).toBe(true); expect(snapshotFinalizedVmChainInventoryV1(structuredClone(inventory))).toEqual(inventory); + expect(snapshotFinalizedVmChainInventoryV1( + structuredClone(inventory), + { maxRows: 2 }, + )).toEqual(inventory); + expect(() => snapshotFinalizedVmChainInventoryV1( + structuredClone(inventory), + { maxRows: 1 }, + )).toThrow(FinalizedVmChainInventoryValidationErrorV1); + for (const maxRows of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN]) { + expect(() => snapshotFinalizedVmChainInventoryV1( + structuredClone(inventory), + { maxRows }, + )).toThrow(FinalizedVmChainInventoryValidationErrorV1); + } expect(() => snapshotFinalizedVmChainInventoryV1({ ...structuredClone(inventory), unexpected: true, From 085ba209b7c8436570498293bb56b4b5b7459d1c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:06:10 +0200 Subject: [PATCH 253/292] feat(agent): run finalized public VM composition --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + .../src/rfc64/finalized-vm-runtime-v1.ts | 594 ++++++++++++++++++ .../rfc64-finalized-vm-runtime-v1.test.ts | 264 ++++++++ .../rfc64-finalized-vm-placement-fixture.ts | 242 +++++++ packages/agent/vitest.rfc64-unit-tests.ts | 1 + 6 files changed, 1103 insertions(+) create mode 100644 packages/agent/src/rfc64/finalized-vm-runtime-v1.ts create mode 100644 packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts create mode 100644 packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 3fde12788b..246fec3a13 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -27,6 +27,7 @@ "./dist/rfc64/catalog-transport-authorization-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, "./dist/rfc64/finalized-vm-composer-v1.js": null, + "./dist/rfc64/finalized-vm-runtime-v1.js": null, "./dist/rfc64/ka-bundle-store-v1-internal.js": null, "./dist/rfc64/ka-bundle-store-v1.js": null, "./dist/rfc64/open-catalog-policy-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index fcc76522d1..365e5b04e5 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -102,6 +102,7 @@ const blockedRfc64Modules = [ 'control-object-store-v1.js', 'durable-file-store-v1.js', 'finalized-vm-composer-v1.js', + 'finalized-vm-runtime-v1.js', 'ka-bundle-store-v1-internal.js', 'ka-bundle-store-v1.js', 'open-catalog-policy-v1.js', diff --git a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts new file mode 100644 index 0000000000..179a417b61 --- /dev/null +++ b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts @@ -0,0 +1,594 @@ +import { + assertCanonicalChainId, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertCanonicalKaId, + assertContextGraphIdV1, + assertNetworkIdV1, + assertSubGraphNameV1, + canonicalizeContextGraphPolicyPayloadV1, + parseCanonicalContextGraphPolicyPayloadV1, + readVerifiedCatalogSealBindingV1, + type ChainIdV1, + type ContextGraphIdV1, + type DecimalU256V1, + type ContextGraphPolicyV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type KaIdV1, + type NetworkIdV1, + type SubGraphNameV1, +} from '@origintrail-official/dkg-core'; +import { + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + createFinalizedContextGraphRpcResolverV1, + resolveFinalizedContextGraphReadWithSignalV1, + scanFinalizedVmChainInventoryInSnapshotV1, + type FinalizedContextGraphReadV1, + type FinalizedVmChainCandidateV1, + type FinalizedVmChainInventoryV1, + type StrictCurrentFinalizedEvmReadV1, + type StrictCurrentFinalizedEvmSnapshotScopeV1, +} from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; + +import type { AcceptedRfc64CatalogAccessSnapshotV1 } from './catalog-access-policy-v1.js'; +import { + composeFinalizedVmSetV1, + type ComposedFinalizedVmSetV1, + type FinalizedVmCatalogLaneV1, + type FinalizedVmPlacementEvidenceV1, +} from './finalized-vm-composer-v1.js'; + +const CONFIG_KEYS = Object.freeze([ + 'chainId', + 'contextGraphStorageAddress', + 'knowledgeAssetStorageAddress', + 'materialize', + 'networkId', + 'snapshot', +] as const); +const REQUEST_KEYS = Object.freeze([ + 'acceptedPolicy', + 'catalogLane', + 'onChainContextGraphId', + 'placements', + 'signal', +] as const); +const ACCEPTED_POLICY_KEYS = Object.freeze(['policy', 'policyDigest', 'roster'] as const); +const CATALOG_LANE_KEYS = Object.freeze(['contextGraphId', 'subGraphName'] as const); +const RECEIPT_KEYS = Object.freeze([ + 'kaId', + 'ordinal', + 'postReadDigest', + 'status', + 'tripleCount', + 'ual', + 'vmGraphIri', +] as const); +const MAX_VM_GRAPH_IRI_BYTES_V1 = 8 * 1024; +const UTF8 = new TextEncoder(); + +export interface FinalizedVmMaterializationReceiptV1 { + readonly kaId: KaIdV1; + readonly ordinal: DecimalU64V1; + readonly ual: string; + readonly status: 'materialized' | 'existing'; + readonly vmGraphIri: string; + readonly tripleCount: DecimalU64V1; + readonly postReadDigest: Digest32V1; +} + +export interface FinalizedVmMaterializeRequestV1 { + readonly acceptedPolicy: Readonly; + readonly acceptedPolicyDigest: Digest32V1; + readonly catalogLane: Readonly; + readonly finalizedContextGraph: Readonly; + readonly candidate: Readonly; + readonly placement: Readonly; + readonly row: Readonly; + readonly signal: AbortSignal; +} + +/** + * Idempotently materialize one already-authorized row and post-read the exact VM graph. + * A thrown error may leave earlier rows materialized; callers must not advance their + * applied-head CAS until the complete runtime result has been returned. + */ +export interface FinalizedVmMaterializerV1 { + (request: FinalizedVmMaterializeRequestV1): + Promise; +} + +export interface FinalizedVmRuntimeConfigV1 { + readonly networkId: NetworkIdV1; + readonly chainId: ChainIdV1; + readonly contextGraphStorageAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; + readonly materialize: FinalizedVmMaterializerV1; +} + +export interface FinalizedVmRuntimeRequestV1 { + readonly catalogLane: FinalizedVmCatalogLaneV1; + readonly onChainContextGraphId: DecimalU256V1; + readonly acceptedPolicy: AcceptedRfc64CatalogAccessSnapshotV1; + readonly placements: readonly FinalizedVmPlacementEvidenceV1[]; + readonly signal: AbortSignal; +} + +export interface FinalizedVmRuntimeResultV1 { + readonly acceptedPolicyDigest: Digest32V1; + readonly finalizedContextGraph: Readonly; + readonly inventory: Readonly; + readonly composed: Readonly; + readonly receipts: readonly Readonly[]; +} + +export interface FinalizedVmRuntimeV1 { + (request: FinalizedVmRuntimeRequestV1): Promise>; +} + +export type FinalizedVmRuntimeErrorCodeV1 = + | 'finalized-vm-runtime-config' + | 'finalized-vm-runtime-request' + | 'finalized-vm-runtime-policy' + | 'finalized-vm-runtime-anchor' + | 'finalized-vm-runtime-materialization'; + +export class FinalizedVmRuntimeErrorV1 extends Error { + constructor( + readonly code: FinalizedVmRuntimeErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'FinalizedVmRuntimeErrorV1'; + } +} + +interface RuntimeConfigSnapshotV1 { + readonly networkId: NetworkIdV1; + readonly chainId: FinalizedVmChainInventoryV1['chainId']; + readonly contextGraphStorageAddress: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; + readonly materialize: FinalizedVmMaterializerV1; +} + +interface RuntimeRequestSnapshotV1 { + readonly catalogLane: FinalizedVmCatalogLaneV1; + readonly onChainContextGraphId: FinalizedVmChainInventoryV1['contextGraphId']; + readonly acceptedPolicy: Readonly; + readonly acceptedPolicyDigest: Digest32V1; + readonly placements: readonly FinalizedVmPlacementEvidenceV1[]; + readonly signal: AbortSignal; +} + +interface PreparedMaterializationV1 { + readonly candidate: Readonly; + readonly placement: Readonly; + readonly row: Readonly; +} + +interface VerifiedSnapshotV1 { + readonly finalizedContextGraph: Readonly; + readonly inventory: Readonly; + readonly composed: Readonly; + readonly materializations: readonly PreparedMaterializationV1[]; +} + +/** + * Verify public RFC-64 policy, name binding, chain inventory, and catalog placement at + * one exact finalized anchor before invoking any triple-store materialization. + */ +export function createFinalizedVmRuntimeV1( + input: FinalizedVmRuntimeConfigV1, +): FinalizedVmRuntimeV1 { + const config = snapshotConfig(input); + const runtime: FinalizedVmRuntimeV1 = async (inputRequest) => { + const request = snapshotRequest(inputRequest); + request.signal.throwIfAborted(); + + const verified = await config.snapshot( + { chainId: config.chainId, signal: request.signal }, + async (session): Promise => { + const read: StrictCurrentFinalizedEvmReadV1 = async (readRequest) => { + if (readRequest.chainId !== session.chainId) { + fail('finalized-vm-runtime-anchor', 'policy read requested a different chain'); + } + const returnData = await session.read(readRequest.calls); + return Object.freeze({ + chainId: session.chainId, + blockNumber: session.blockNumber, + blockHash: session.blockHash, + returnData, + }); + }; + const finalizedContextGraph = await resolveFinalizedContextGraphReadWithSignalV1( + createFinalizedContextGraphRpcResolverV1(read), + { + chainId: config.chainId, + contextGraphId: request.onChainContextGraphId, + governanceContract: config.contextGraphStorageAddress, + }, + request.signal, + ); + const inventory = await scanFinalizedVmChainInventoryInSnapshotV1( + { + networkId: config.networkId, + chainId: config.chainId, + contextGraphStorageAddress: config.contextGraphStorageAddress, + knowledgeAssetStorageAddress: config.knowledgeAssetStorageAddress, + }, + { contextGraphId: request.onChainContextGraphId, signal: request.signal }, + session, + ); + assertExactAnchor(finalizedContextGraph, inventory); + assertAcceptedPublicPolicy( + config, + request.catalogLane, + request.acceptedPolicy, + finalizedContextGraph, + ); + + const composed = composeFinalizedVmSetV1({ + catalogLane: request.catalogLane, + inventory, + placements: request.placements, + }); + return Object.freeze({ + finalizedContextGraph, + inventory, + composed, + materializations: prepareMaterializations(inventory, composed, request.placements), + }); + }, + ); + + const receipts: Readonly[] = []; + for (const prepared of verified.materializations) { + request.signal.throwIfAborted(); + let untrustedReceipt: FinalizedVmMaterializationReceiptV1; + try { + untrustedReceipt = await config.materialize(Object.freeze({ + acceptedPolicy: request.acceptedPolicy, + acceptedPolicyDigest: request.acceptedPolicyDigest, + catalogLane: verified.composed.catalogLane, + finalizedContextGraph: verified.finalizedContextGraph, + candidate: prepared.candidate, + placement: prepared.placement, + row: prepared.row, + signal: request.signal, + })); + } catch (cause) { + if (request.signal.aborted) request.signal.throwIfAborted(); + fail( + 'finalized-vm-runtime-materialization', + `materializer failed at finalized ordinal ${prepared.row.ordinal}`, + cause, + ); + } + request.signal.throwIfAborted(); + receipts.push(snapshotReceipt(untrustedReceipt, prepared.candidate)); + } + + return Object.freeze({ + acceptedPolicyDigest: request.acceptedPolicyDigest, + finalizedContextGraph: verified.finalizedContextGraph, + inventory: verified.inventory, + composed: verified.composed, + receipts: Object.freeze(receipts), + }); + }; + return Object.freeze(runtime); +} + +function snapshotConfig(input: unknown): RuntimeConfigSnapshotV1 { + let fields: Record; + try { + fields = snapshotExactRecord(input, CONFIG_KEYS); + assertNetworkIdV1(fields.networkId, 'finalized VM runtime networkId'); + assertCanonicalChainId(fields.chainId, 'finalized VM runtime chainId'); + assertNonzeroAddress(fields.contextGraphStorageAddress, 'contextGraphStorageAddress'); + assertNonzeroAddress(fields.knowledgeAssetStorageAddress, 'knowledgeAssetStorageAddress'); + if (typeof fields.snapshot !== 'function') throw new TypeError('snapshot is not callable'); + if (typeof fields.materialize !== 'function') throw new TypeError('materialize is not callable'); + } catch (cause) { + fail('finalized-vm-runtime-config', 'runtime config is not canonical', cause); + } + return Object.freeze({ + networkId: fields.networkId as NetworkIdV1, + chainId: fields.chainId as FinalizedVmChainInventoryV1['chainId'], + contextGraphStorageAddress: fields.contextGraphStorageAddress as EvmAddressV1, + knowledgeAssetStorageAddress: fields.knowledgeAssetStorageAddress as EvmAddressV1, + snapshot: fields.snapshot as StrictCurrentFinalizedEvmSnapshotScopeV1, + materialize: fields.materialize as FinalizedVmMaterializerV1, + }); +} + +function snapshotRequest(input: unknown): RuntimeRequestSnapshotV1 { + let fields: Record; + let accepted: Record; + try { + fields = snapshotExactRecord(input, REQUEST_KEYS); + accepted = snapshotExactRecord(fields.acceptedPolicy, ACCEPTED_POLICY_KEYS); + if (accepted.roster !== null) { + throw new TypeError('public finalized VM runtime forbids a private member roster'); + } + assertCanonicalDigest(accepted.policyDigest, 'accepted policy digest'); + const policy = parseCanonicalContextGraphPolicyPayloadV1( + canonicalizeContextGraphPolicyPayloadV1(accepted.policy as ContextGraphPolicyV1), + ); + if (!isAbortSignal(fields.signal)) throw new TypeError('signal is not an AbortSignal'); + const catalogLane = snapshotCatalogLane(fields.catalogLane); + const placements = snapshotDenseArray( + fields.placements, + FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + ); + return Object.freeze({ + catalogLane, + onChainContextGraphId: fields.onChainContextGraphId as FinalizedVmChainInventoryV1['contextGraphId'], + acceptedPolicy: policy, + acceptedPolicyDigest: accepted.policyDigest, + placements: placements as readonly FinalizedVmPlacementEvidenceV1[], + signal: fields.signal, + }); + } catch (cause) { + fail('finalized-vm-runtime-request', 'runtime request is not canonical', cause); + } +} + +function snapshotCatalogLane(input: unknown): FinalizedVmCatalogLaneV1 { + const fields = snapshotExactRecord(input, CATALOG_LANE_KEYS); + assertContextGraphIdV1(fields.contextGraphId, 'catalogLane.contextGraphId'); + if (fields.subGraphName !== null) { + assertSubGraphNameV1(fields.subGraphName, 'catalogLane.subGraphName'); + } + return Object.freeze({ + contextGraphId: fields.contextGraphId as ContextGraphIdV1, + subGraphName: fields.subGraphName as SubGraphNameV1 | null, + }); +} + +function snapshotDenseArray(input: unknown, maxLength: number): readonly unknown[] { + if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { + throw new TypeError('placements must be an ordinary array'); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); + if ( + lengthDescriptor === undefined + || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || typeof lengthDescriptor.value !== 'number' + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + || lengthDescriptor.value > maxLength + ) { + throw new TypeError('placements length is outside the runtime bound'); + } + const length = lengthDescriptor.value; + const output: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError('placements must be dense and data-only'); + } + output.push(descriptor.value); + } + if (Reflect.ownKeys(input).length !== length + 1) { + throw new TypeError('placements must not have extra properties'); + } + return Object.freeze(output); +} + +function assertAcceptedPublicPolicy( + config: RuntimeConfigSnapshotV1, + catalogLane: FinalizedVmCatalogLaneV1, + policy: Readonly, + finalized: Readonly, +): void { + const source = policy.source; + const expectedNameHash = ethers.keccak256( + ethers.toUtf8Bytes(catalogLane.contextGraphId), + ).toLowerCase(); + const sourcePrecedesAnchor = source.kind === 'finalized-chain' + && BigInt(source.blockNumber) <= BigInt(finalized.blockNumber); + const sameSourceAnchor = source.kind === 'finalized-chain' + && source.blockNumber === finalized.blockNumber; + if ( + policy.accessPolicy !== 0 + || policy.networkId !== config.networkId + || policy.contextGraphId !== catalogLane.contextGraphId + || policy.governanceChainId !== config.chainId + || policy.governanceContractAddress !== config.contextGraphStorageAddress + || source.kind !== 'finalized-chain' + || source.chainId !== config.chainId + || source.contractAddress !== config.contextGraphStorageAddress + || !sourcePrecedesAnchor + || (sameSourceAnchor && source.blockHash !== finalized.blockHash) + || !finalized.active + || finalized.nameHash !== expectedNameHash + || finalized.accessPolicy !== policy.accessPolicy + || finalized.publishPolicy !== policy.publishPolicy + || finalized.publishAuthority !== policy.publishAuthority + || finalized.publishAuthorityAccountId !== policy.publishAuthorityAccountId + ) { + fail( + 'finalized-vm-runtime-policy', + 'accepted public policy or cleartext name binding differs from finalized chain truth', + ); + } +} + +function assertExactAnchor( + contextGraph: Readonly, + inventory: Readonly, +): void { + if ( + contextGraph.chainId !== inventory.chainId + || contextGraph.contextGraphId !== inventory.contextGraphId + || contextGraph.governanceContract !== inventory.contractAddress + || contextGraph.blockNumber !== inventory.finalizedBlockNumber + || contextGraph.blockHash !== inventory.finalizedBlockHash + ) { + fail( + 'finalized-vm-runtime-anchor', + 'policy/name read and VM inventory do not share one exact finalized anchor', + ); + } +} + +function prepareMaterializations( + inventory: Readonly, + composed: Readonly, + placements: readonly FinalizedVmPlacementEvidenceV1[], +): readonly PreparedMaterializationV1[] { + const placementByKaId = new Map(); + for (const placement of placements) { + const binding = readVerifiedCatalogSealBindingV1(placement.sealBinding); + placementByKaId.set(binding.kaId, placement); + } + const candidatesByOrdinal = new Map(inventory.rows.map((candidate) => [ + candidate.ordinal, + candidate, + ])); + return Object.freeze(composed.rows.map((row) => { + const candidate = candidatesByOrdinal.get(row.ordinal); + const placement = candidate === undefined ? undefined : placementByKaId.get(candidate.kaId); + if (candidate === undefined || placement === undefined) { + fail( + 'finalized-vm-runtime-anchor', + `composed ordinal ${row.ordinal} lost its verified chain/catalog join`, + ); + } + return Object.freeze({ candidate, placement, row }); + })); +} + +function snapshotReceipt( + input: unknown, + candidate: Readonly, +): Readonly { + let fields: Record; + try { + fields = snapshotExactRecord(input, RECEIPT_KEYS); + assertCanonicalKaId(fields.kaId, 'materialization receipt kaId'); + assertCanonicalDecimalU64(fields.ordinal, 'materialization receipt ordinal'); + assertCanonicalDecimalU64(fields.tripleCount, 'materialization receipt tripleCount'); + assertCanonicalDigest(fields.postReadDigest, 'materialization receipt postReadDigest'); + if (fields.status !== 'materialized' && fields.status !== 'existing') { + throw new TypeError('materialization receipt status is invalid'); + } + if ( + typeof fields.ual !== 'string' + || fields.ual !== candidate.ual + || fields.kaId !== candidate.kaId + || fields.ordinal !== candidate.ordinal + ) { + throw new TypeError('materialization receipt does not bind the requested chain row'); + } + assertBoundedIri(fields.vmGraphIri); + } catch (cause) { + fail( + 'finalized-vm-runtime-materialization', + `materializer returned an invalid receipt for finalized ordinal ${candidate.ordinal}`, + cause, + ); + } + return Object.freeze({ + kaId: fields.kaId as KaIdV1, + ordinal: fields.ordinal as DecimalU64V1, + ual: fields.ual as string, + status: fields.status as FinalizedVmMaterializationReceiptV1['status'], + vmGraphIri: fields.vmGraphIri as string, + tripleCount: fields.tripleCount as DecimalU64V1, + postReadDigest: fields.postReadDigest as Digest32V1, + }); +} + +function snapshotExactRecord( + input: unknown, + expectedKeys: readonly string[], +): Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('input is not a record'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('input is not a plain record'); + } + const keys = Reflect.ownKeys(input); + if ( + keys.length !== expectedKeys.length + || keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) + ) { + throw new TypeError('input has unknown or missing fields'); + } + const output: Record = Object.create(null) as Record; + for (const key of expectedKeys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + throw new TypeError('input fields must be enumerable data properties'); + } + output[key] = descriptor.value; + } + return output; +} + +function assertNonzeroAddress(value: unknown, label: string): asserts value is EvmAddressV1 { + assertCanonicalEvmAddress(value, label); + if (value === `0x${'00'.repeat(20)}`) throw new TypeError(`${label} must be nonzero`); +} + +function assertBoundedIri(value: unknown): asserts value is string { + if ( + typeof value !== 'string' + || value.length === 0 + || value.trim() !== value + || UTF8.encode(value).byteLength > MAX_VM_GRAPH_IRI_BYTES_V1 + || /[\u0000-\u0020<>"{}|^`\\]/u.test(value) + ) { + throw new TypeError('vmGraphIri must be a bounded safe absolute IRI'); + } + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new TypeError('vmGraphIri must be an absolute IRI'); + } + if (parsed.protocol.length <= 1) throw new TypeError('vmGraphIri must have a scheme'); +} + +function isAbortSignal(value: unknown): value is AbortSignal { + if (value === null || typeof value !== 'object') return false; + try { + const getter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + if (getter === undefined) return false; + getter.call(value); + return true; + } catch { + return false; + } +} + +function fail( + code: FinalizedVmRuntimeErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new FinalizedVmRuntimeErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts new file mode 100644 index 0000000000..d9ce19fc70 --- /dev/null +++ b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts @@ -0,0 +1,264 @@ +import { + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + type ContextGraphPolicyV1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; +import type { + StrictCurrentFinalizedEvmReadCallV1, + StrictCurrentFinalizedEvmSnapshotScopeV1, +} from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; +import { describe, expect, it, vi } from 'vitest'; + +import type { AcceptedRfc64CatalogAccessSnapshotV1 } from '../src/rfc64/catalog-access-policy-v1.js'; +import { + FinalizedVmRuntimeErrorV1, + createFinalizedVmRuntimeV1, + type FinalizedVmMaterializerV1, +} from '../src/rfc64/finalized-vm-runtime-v1.js'; +import { + RFC64_VM_ASSERTION_ROOT, + RFC64_VM_AUTHOR, + RFC64_VM_BLOCK_HASH, + RFC64_VM_CG_STORAGE, + RFC64_VM_CHAIN_ID, + RFC64_VM_CONTEXT_GRAPH_NAME, + RFC64_VM_KA_STORAGE, + RFC64_VM_NETWORK_ID, + RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, + RFC64_VM_POLICY_DIGEST, + RFC64_VM_PUBLISHER, + createRfc64FinalizedVmPlacementFixture, + rfc64VmPackKaId, + rfc64VmUal, +} from './support/rfc64-finalized-vm-placement-fixture.js'; + +const OWNER = `0x${'aa'.repeat(20)}` as const; +const RECEIPT_DIGEST = `0x${'ee'.repeat(32)}` as Digest32V1; +const ZERO_ADDRESS = ethers.ZeroAddress.toLowerCase(); +const NAME_HASH = ethers.keccak256(ethers.toUtf8Bytes(RFC64_VM_CONTEXT_GRAPH_NAME)).toLowerCase(); +const CG = new ethers.Interface([ + 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', + 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', + 'function isContextGraphActive(uint256 contextGraphId) view returns (bool)', + 'function getContextGraphKaCount(uint256 contextGraphId) view returns (uint256)', + 'function getContextGraphKaAt(uint256 contextGraphId, uint256 ordinal) view returns (uint256)', +]); +const KA = new ethers.Interface([ + 'function getKnowledgeAssetUpdateContext(uint256 id) view returns (uint256 merkleRootsCount, uint256 minted, uint88 byteSize, uint40 endEpoch, uint96 tokenAmount, bool isImmutable, uint32 merkleLeafCount)', + 'function getLatestMerkleRoot(uint256 id) view returns (bytes32)', + 'function getLatestMerkleRootAuthor(uint256 id) view returns (address)', + 'function getLatestMerkleRootPublisher(uint256 id) view returns (address)', +]); + +describe('RFC-64 finalized public VM runtime', () => { + it('verifies one exact finalized view before materializing in ordinal order', async () => { + const placement = await createRfc64FinalizedVmPlacementFixture(); + const transport = snapshotTransport(); + const materialize = vi.fn(async (request) => { + expect(transport.isOpen()).toBe(false); + expect(request.candidate.finalizedBlockHash).toBe(RFC64_VM_BLOCK_HASH); + expect(request.finalizedContextGraph.blockHash).toBe(RFC64_VM_BLOCK_HASH); + expect(Object.isFrozen(request)).toBe(true); + return receipt(); + }); + const runtime = createFinalizedVmRuntimeV1(runtimeConfig(transport.snapshot, materialize)); + + const result = await runtime(request(placement)); + + expect(transport.reads()).toBe(4); + expect(materialize).toHaveBeenCalledTimes(1); + expect(result.composed.evidence).toMatchObject({ + rowCount: '1', + highestFinalizedOrdinal: '0', + }); + expect(result.receipts).toEqual([receipt()]); + expect(result.acceptedPolicyDigest).toBe(RFC64_VM_POLICY_DIGEST); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.receipts)).toBe(true); + }); + + it('fails closed before store writes on name, policy, or placement mismatch', async () => { + const placement = await createRfc64FinalizedVmPlacementFixture(); + const mutations = [ + { transport: { nameHash: `0x${'12'.repeat(32)}` }, policy: {} }, + { transport: {}, policy: { publishPolicy: 0, publishAuthority: RFC64_VM_PUBLISHER } }, + { transport: { assertionRoot: `0x${'13'.repeat(32)}` }, policy: {} }, + ] as const; + for (const mutation of mutations) { + const transport = snapshotTransport(mutation.transport); + const materialize = vi.fn(async () => receipt()); + const runtime = createFinalizedVmRuntimeV1(runtimeConfig(transport.snapshot, materialize)); + const input = request(placement, mutation.policy); + await expect(runtime(input)).rejects.toThrow(); + expect(materialize).not.toHaveBeenCalled(); + } + }); + + it('accepts an older still-current policy anchor but rejects a conflicting same-height hash', async () => { + const placement = await createRfc64FinalizedVmPlacementFixture(); + const transport = snapshotTransport(); + const materialize = vi.fn(async () => receipt()); + const runtime = createFinalizedVmRuntimeV1(runtimeConfig(transport.snapshot, materialize)); + + await expect(runtime(request(placement))).resolves.toBeDefined(); + await expect(runtime(request(placement, {}, { + blockNumber: '123', + blockHash: `0x${'14'.repeat(32)}`, + }))).rejects.toMatchObject({ code: 'finalized-vm-runtime-policy' }); + expect(materialize).toHaveBeenCalledTimes(1); + }); + + it('rejects a mismatched post-read receipt and reports the exact ordinal', async () => { + const placement = await createRfc64FinalizedVmPlacementFixture(); + const transport = snapshotTransport(); + const runtime = createFinalizedVmRuntimeV1(runtimeConfig( + transport.snapshot, + async () => ({ ...receipt(), ordinal: '1' }), + )); + + await expect(runtime(request(placement))).rejects.toMatchObject({ + code: 'finalized-vm-runtime-materialization', + } satisfies Partial); + }); +}); + +function runtimeConfig( + snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1, + materialize: FinalizedVmMaterializerV1, +) { + return { + networkId: RFC64_VM_NETWORK_ID, + chainId: RFC64_VM_CHAIN_ID, + contextGraphStorageAddress: RFC64_VM_CG_STORAGE, + knowledgeAssetStorageAddress: RFC64_VM_KA_STORAGE, + snapshot, + materialize, + } as const; +} + +function request( + placement: Awaited>, + policyOverrides: Partial = {}, + sourceOverrides: Partial> = {}, +) { + const policy = publicPolicy(policyOverrides, sourceOverrides); + return { + catalogLane: { contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, subGraphName: null }, + onChainContextGraphId: RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, + acceptedPolicy: { + policy, + policyDigest: RFC64_VM_POLICY_DIGEST, + roster: null, + } satisfies AcceptedRfc64CatalogAccessSnapshotV1, + placements: [placement], + signal: new AbortController().signal, + } as const; +} + +function publicPolicy( + overrides: Partial, + sourceOverrides: Partial>, +): ContextGraphPolicyV1 { + return { + networkId: RFC64_VM_NETWORK_ID, + contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, + governanceChainId: RFC64_VM_CHAIN_ID, + governanceContractAddress: RFC64_VM_CG_STORAGE, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'finalized-chain', + chainId: RFC64_VM_CHAIN_ID, + contractAddress: RFC64_VM_CG_STORAGE, + blockNumber: '120', + blockHash: `0x${'76'.repeat(32)}`, + ...sourceOverrides, + }, + effectiveAt: '1700000000000', + issuedAt: '1700000000000', + ...overrides, + }; +} + +function receipt() { + return { + kaId: rfc64VmPackKaId(1n), + ordinal: '0', + ual: rfc64VmUal(1n), + status: 'materialized', + vmGraphIri: `${rfc64VmUal(1n)}/VerifiableMemory/2`, + tripleCount: '10', + postReadDigest: RECEIPT_DIGEST, + } as const; +} + +function snapshotTransport(options: { + readonly nameHash?: string; + readonly assertionRoot?: string; +} = {}) { + let open = false; + let readCount = 0; + const snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async (snapshotRequest, consume) => { + expect(snapshotRequest.chainId).toBe(RFC64_VM_CHAIN_ID); + open = true; + try { + return await consume(Object.freeze({ + chainId: RFC64_VM_CHAIN_ID, + blockNumber: '123', + blockHash: RFC64_VM_BLOCK_HASH, + read: async (calls) => { + if (!open) throw new Error('snapshot session escaped its scope'); + readCount += 1; + return Object.freeze(calls.map(encodeCallResult)); + }, + })); + } finally { + open = false; + } + }; + return { + snapshot, + isOpen: () => open, + reads: () => readCount, + }; + + function encodeCallResult(call: StrictCurrentFinalizedEvmReadCallV1): string { + const selector = call.data.slice(0, 10); + switch (selector) { + case CG.getFunction('getContextGraph')!.selector: + return CG.encodeFunctionResult('getContextGraph', [ + OWNER, [], 0n, true, 1n, 0, 1, ZERO_ADDRESS, 0n, + ]); + case CG.getFunction('getNameHash')!.selector: + return CG.encodeFunctionResult('getNameHash', [options.nameHash ?? NAME_HASH]); + case CG.getFunction('isContextGraphActive')!.selector: + return CG.encodeFunctionResult('isContextGraphActive', [true]); + case CG.getFunction('getContextGraphKaCount')!.selector: + return CG.encodeFunctionResult('getContextGraphKaCount', [1n]); + case CG.getFunction('getContextGraphKaAt')!.selector: + return CG.encodeFunctionResult('getContextGraphKaAt', [BigInt(rfc64VmPackKaId(1n))]); + case KA.getFunction('getKnowledgeAssetUpdateContext')!.selector: + return KA.encodeFunctionResult('getKnowledgeAssetUpdateContext', [2n, 0n, 0n, 0n, 0n, false, 0]); + case KA.getFunction('getLatestMerkleRoot')!.selector: + return KA.encodeFunctionResult( + 'getLatestMerkleRoot', + [options.assertionRoot ?? RFC64_VM_ASSERTION_ROOT], + ); + case KA.getFunction('getLatestMerkleRootAuthor')!.selector: + return KA.encodeFunctionResult('getLatestMerkleRootAuthor', [RFC64_VM_AUTHOR]); + case KA.getFunction('getLatestMerkleRootPublisher')!.selector: + return KA.encodeFunctionResult('getLatestMerkleRootPublisher', [RFC64_VM_PUBLISHER]); + default: + throw new Error(`unexpected finalized VM read ${selector}`); + } + } +} diff --git a/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts b/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts new file mode 100644 index 0000000000..54f97cbe99 --- /dev/null +++ b/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts @@ -0,0 +1,242 @@ +import { + AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1, + computeAuthorCatalogScopeDigestV1, + computeCanonicalGraphScopedAuthorSealDigestV1, + computeControlObjectDigestHex, + verifyAuthorCatalogDirectoryPathV1, + verifyCatalogSealBindingV1, + type AuthorCatalogRowV1, + type AuthorCatalogScopeV1, + type CanonicalGraphScopedAuthorSealV1, + type Digest32V1, + type EvmAddressV1, + type KaIdV1, + type NetworkIdV1, + type SignedAuthorCatalogBucketEnvelopeV1, + type SignedAuthorCatalogDirectoryNodeEnvelopeV1, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedAuthorCatalogIssuerDelegationEnvelopeV1, + type SignedControlEnvelopeV1, + type UnsignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; + +import { + verifyAuthorCatalogRowAuthorshipV1, +} from '../../src/rfc64/catalog-row-authorship.js'; +import type { FinalizedVmPlacementEvidenceV1 } from '../../src/rfc64/finalized-vm-composer-v1.js'; + +export const RFC64_VM_AUTHOR_WALLET = new ethers.Wallet(`0x${'11'.repeat(32)}`); +export const RFC64_VM_CATALOG_WALLET = new ethers.Wallet(`0x${'22'.repeat(32)}`); +export const RFC64_VM_AUTHOR = RFC64_VM_AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +export const RFC64_VM_CATALOG_ISSUER = + RFC64_VM_CATALOG_WALLET.address.toLowerCase() as EvmAddressV1; +export const RFC64_VM_NETWORK_ID = 'otp:20430' as NetworkIdV1; +export const RFC64_VM_CHAIN_ID = '20430' as const; +export const RFC64_VM_CONTEXT_GRAPH_NAME = 'agent-blackbox-vm' as const; +export const RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID = '14' as const; +export const RFC64_VM_CG_STORAGE = `0x${'33'.repeat(20)}` as EvmAddressV1; +export const RFC64_VM_KA_STORAGE = `0x${'44'.repeat(20)}` as EvmAddressV1; +export const RFC64_VM_PUBLISHER = `0x${'66'.repeat(20)}` as EvmAddressV1; +export const RFC64_VM_BLOCK_HASH = `0x${'77'.repeat(32)}` as Digest32V1; +export const RFC64_VM_ASSERTION_ROOT = `0x${'88'.repeat(32)}` as Digest32V1; +export const RFC64_VM_POLICY_DIGEST = `0x${'ab'.repeat(32)}` as Digest32V1; +const ZERO_DIGEST = `0x${'00'.repeat(32)}` as Digest32V1; + +export function rfc64VmPackKaId(kaNumber: bigint): KaIdV1 { + return ((BigInt(RFC64_VM_AUTHOR) << 96n) | kaNumber).toString() as KaIdV1; +} + +export function rfc64VmUal(kaNumber: bigint): string { + return `did:dkg:${RFC64_VM_NETWORK_ID}/${RFC64_VM_AUTHOR}/${kaNumber}`; +} + +export async function createRfc64FinalizedVmPlacementFixture(options: { + readonly kaNumber?: bigint; + readonly assertionRoot?: Digest32V1; +} = {}): Promise { + const kaNumber = options.kaNumber ?? 1n; + const kaId = rfc64VmPackKaId(kaNumber); + const assertionRoot = options.assertionRoot ?? RFC64_VM_ASSERTION_ROOT; + const scope = { + networkId: RFC64_VM_NETWORK_ID, + contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, + governanceChainId: RFC64_VM_CHAIN_ID, + governanceContractAddress: RFC64_VM_CG_STORAGE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: RFC64_VM_AUTHOR, + era: '0', + bucketCount: '1', + } as AuthorCatalogScopeV1; + const seal = { + assertionMerkleRoot: assertionRoot, + authorAddress: RFC64_VM_AUTHOR, + authorAttestationR: `0x${'aa'.repeat(32)}`, + authorAttestationVS: `0x${'bb'.repeat(32)}`, + authorSchemeVersion: '1', + assertedAtChainId: RFC64_VM_CHAIN_ID, + assertedAtKav10Address: RFC64_VM_KA_STORAGE, + reservedKaId: kaId, + assertionFinalizedAt: '2026-07-22T08:00:00.000Z', + contentScopeVersion: '2', + kaUal: rfc64VmUal(kaNumber), + assertionVersion: '2', + publicTripleCount: '10', + privateTripleCount: '0', + privateMerkleRoot: null, + } as CanonicalGraphScopedAuthorSealV1; + const row = { + kaId, + assertionCoordinate: 'vm-runtime-fixture', + assertionVersion: '2', + projectionId: 'cg-shared-v1', + projectionDigest: ZERO_DIGEST, + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), + transfer: { + codec: 'dkg-ka-bundle-v1', + projectionId: 'cg-shared-v1', + projectionDigest: ZERO_DIGEST, + byteLength: '16', + chunkSize: '262144', + chunkCount: '1', + blobDigest: `0x${'cc'.repeat(32)}`, + chunkTreeRoot: `0x${'dd'.repeat(32)}`, + }, + } as AuthorCatalogRowV1; + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + + const delegation = await signEnvelope({ + issuer: RFC64_VM_AUTHOR, + objectType: AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + payload: { + networkId: RFC64_VM_NETWORK_ID, + contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, + governanceChainId: RFC64_VM_CHAIN_ID, + governanceContractAddress: RFC64_VM_CG_STORAGE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: RFC64_VM_AUTHOR, + catalogEra: '0', + previousDelegationDigest: null, + catalogIssuerKey: RFC64_VM_CATALOG_ISSUER, + authorAuthorityEvidenceDigest: null, + effectiveAt: '1700000000000', + expiresAt: '1700000120000', + }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, RFC64_VM_AUTHOR_WALLET) as SignedAuthorCatalogIssuerDelegationEnvelopeV1; + + const bucketPayload = { + catalogScopeDigest: scopeDigest, + era: '0', + bucketCount: '1', + bucketId: '0', + rows: [row], + }; + const bucket = await signEnvelope({ + issuer: RFC64_VM_CATALOG_ISSUER, + objectType: AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, + payload: bucketPayload, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, RFC64_VM_CATALOG_WALLET) as SignedAuthorCatalogBucketEnvelopeV1; + const directory = await signEnvelope({ + issuer: RFC64_VM_CATALOG_ISSUER, + objectType: AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + payload: { + catalogScopeDigest: scopeDigest, + era: '0', + level: '0', + firstBucketId: '0', + entries: [{ + bucketId: '0', + bucketDigest: bucket.objectDigest, + rowCount: '1', + byteLength: String(canonicalBytes(bucketPayload).byteLength), + }], + }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, RFC64_VM_CATALOG_WALLET) as SignedAuthorCatalogDirectoryNodeEnvelopeV1; + const head = await signEnvelope({ + issuer: RFC64_VM_CATALOG_ISSUER, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + payload: { + networkId: RFC64_VM_NETWORK_ID, + contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, + governanceChainId: RFC64_VM_CHAIN_ID, + governanceContractAddress: RFC64_VM_CG_STORAGE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: RFC64_VM_AUTHOR, + catalogIssuerDelegationDigest: delegation.objectDigest, + era: '0', + version: '0', + previousHeadDigest: null, + bucketCount: '1', + totalRows: '1', + directoryHeight: '0', + directoryRootDigest: directory.objectDigest, + issuedAt: '1700000000123', + }, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + }, RFC64_VM_CATALOG_WALLET) as SignedAuthorCatalogHeadEnvelopeV1; + + return { + authorship: verifyAuthorCatalogRowAuthorshipV1({ + catalogIssuerDelegation: delegation, + catalogIssuerDelegationSignature: await verifyControlEnvelopeIssuerSignatureV1(delegation), + parentAuthorAgentEvidence: null, + catalogHead: head, + catalogHeadSignature: await verifyControlEnvelopeIssuerSignatureV1(head), + directoryPathEnvelopes: [directory], + directoryPathSignatures: [await verifyControlEnvelopeIssuerSignatureV1(directory)], + directoryPathProof: verifyAuthorCatalogDirectoryPathV1(head, [directory], '0'), + catalogBucket: bucket, + catalogBucketSignature: await verifyControlEnvelopeIssuerSignatureV1(bucket), + targetKaId: kaId, + }), + sealBinding: verifyCatalogSealBindingV1( + scope, + row, + canonicalizeCanonicalGraphScopedAuthorSealBytesV1(seal), + { + networkId: RFC64_VM_NETWORK_ID, + assertedAtChainId: RFC64_VM_CHAIN_ID, + assertedAtKav10Address: RFC64_VM_KA_STORAGE, + }, + ), + }; +} + +async function signEnvelope( + unsigned: UnsignedControlEnvelopeV1, + wallet: ethers.Wallet, +): Promise { + const objectDigest = computeControlObjectDigestHex(unsigned); + return { + ...unsigned, + objectDigest, + signature: await wallet.signMessage(ethers.getBytes(objectDigest)), + }; +} + +function canonicalBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(canonicalJson(value)); +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`; +} diff --git a/packages/agent/vitest.rfc64-unit-tests.ts b/packages/agent/vitest.rfc64-unit-tests.ts index dd277f8112..b9b2485756 100644 --- a/packages/agent/vitest.rfc64-unit-tests.ts +++ b/packages/agent/vitest.rfc64-unit-tests.ts @@ -9,6 +9,7 @@ export const RFC64_UNIT_TESTS = [ "test/rfc64-candidate-transfer-admission.test.ts", "test/rfc64-catalog-row-authorship.test.ts", "test/rfc64-finalized-vm-composer-v1.test.ts", + "test/rfc64-finalized-vm-runtime-v1.test.ts", "test/rfc64-agent-inventory-lifecycle.test.ts", "test/rfc64-author-catalog-producer.test.ts", "test/rfc64-control-object-store-v1.test.ts", From 4e8354dad9b5d3abfe4e7bd021bdd325274be1cf Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:20:36 +0200 Subject: [PATCH 254/292] feat(agent): materialize finalized catalog VM rows --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + .../src/rfc64/finalized-vm-runtime-v1.ts | 1 + .../finalized-vm-store-materializer-v1.ts | 269 ++++++++++++++++++ .../rfc64-finalized-vm-runtime-v1.test.ts | 66 +++++ .../rfc64-finalized-vm-placement-fixture.ts | 26 +- 6 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 246fec3a13..2d391f796c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -28,6 +28,7 @@ "./dist/rfc64/durable-file-store-v1.js": null, "./dist/rfc64/finalized-vm-composer-v1.js": null, "./dist/rfc64/finalized-vm-runtime-v1.js": null, + "./dist/rfc64/finalized-vm-store-materializer-v1.js": null, "./dist/rfc64/ka-bundle-store-v1-internal.js": null, "./dist/rfc64/ka-bundle-store-v1.js": null, "./dist/rfc64/open-catalog-policy-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 365e5b04e5..dea4e7bcc3 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -103,6 +103,7 @@ const blockedRfc64Modules = [ 'durable-file-store-v1.js', 'finalized-vm-composer-v1.js', 'finalized-vm-runtime-v1.js', + 'finalized-vm-store-materializer-v1.js', 'ka-bundle-store-v1-internal.js', 'ka-bundle-store-v1.js', 'open-catalog-policy-v1.js', diff --git a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts index 179a417b61..cb54fd85b0 100644 --- a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts @@ -236,6 +236,7 @@ export function createFinalizedVmRuntimeV1( const composed = composeFinalizedVmSetV1({ catalogLane: request.catalogLane, + finalizedContextGraph, inventory, placements: request.placements, }); diff --git a/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts b/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts new file mode 100644 index 0000000000..d2237ffdd3 --- /dev/null +++ b/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts @@ -0,0 +1,269 @@ +import { + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, + MemoryLayer, + contextGraphLayerUri, + contextGraphMetaUri, + parseDeterministicKnowledgeAssetUal, + readVerifiedCatalogSealBindingV1, + type DecimalU64V1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; +import { + quadsToNQuads, + readExactGraphPaged, + type Quad, + type TripleStore, +} from '@origintrail-official/dkg-storage'; +import { + computeFlatKCRootV10, + generateGraphKnowledgeAssetMetadata, +} from '@origintrail-official/dkg-publisher'; +import { ethers } from 'ethers'; + +import { + materializeVerifiedGraphScopedAsset, + type VerifiedGraphScopedAsset, +} from '../sync/requester/graph-scoped-materialization.js'; +import type { + FinalizedVmMaterializationReceiptV1, + FinalizedVmMaterializerV1, +} from './finalized-vm-runtime-v1.js'; + +const DKG = 'http://dkg.io/ontology/'; +const XSD_INTEGER = 'http://www.w3.org/2001/XMLSchema#integer'; +const POST_READ_DIGEST_DOMAIN_V1 = ethers.toUtf8Bytes( + 'OT-RFC-64:finalized-vm-post-read:v1\0', +); + +export interface FinalizedVmStoreMaterializerOptionsV1 { + readonly store: TripleStore; +} + +/** + * Promote one catalog-verified SWM projection through the existing atomic + * graph-scoped materializer, then independently verify the exact VM post-read. + */ +export function createFinalizedVmStoreMaterializerV1( + options: FinalizedVmStoreMaterializerOptionsV1, +): FinalizedVmMaterializerV1 { + const storeDescriptor = options !== null && typeof options === 'object' + ? Object.getOwnPropertyDescriptor(options, 'store') + : undefined; + if ( + options === null + || typeof options !== 'object' + || Object.getPrototypeOf(options) !== Object.prototype + || Reflect.ownKeys(options).length !== 1 + || storeDescriptor === undefined + || !storeDescriptor.enumerable + || !Object.prototype.hasOwnProperty.call(storeDescriptor, 'value') + || storeDescriptor.value === null + || typeof storeDescriptor.value !== 'object' + ) { + throw new TypeError('finalized VM store materializer requires one TripleStore'); + } + const store = storeDescriptor.value as TripleStore; + return Object.freeze(async (request): Promise => { + request.signal.throwIfAborted(); + const binding = readVerifiedCatalogSealBindingV1(request.placement.sealBinding); + const { seal } = binding; + const identity = parseDeterministicKnowledgeAssetUal(request.candidate.ual); + const subGraphName = request.catalogLane.subGraphName ?? undefined; + const publicTripleCount = boundedTripleCount( + seal.publicTripleCount, + 'publicTripleCount', + ); + const privateTripleCount = boundedTripleCount( + seal.privateTripleCount, + 'privateTripleCount', + ); + const privateMerkleRoot = seal.privateMerkleRoot === null + ? undefined + : ethers.getBytes(seal.privateMerkleRoot); + if ((privateTripleCount > 0) !== (privateMerkleRoot !== undefined)) { + throw new Error('finalized VM seal private count/root tuple is inconsistent'); + } + + const swmGraph = contextGraphLayerUri( + request.catalogLane.contextGraphId, + MemoryLayer.SharedWorkingMemory, + identity.agentAddress, + identity.kaNumber, + subGraphName, + ); + const vmGraph = contextGraphLayerUri( + request.catalogLane.contextGraphId, + MemoryLayer.VerifiableMemory, + identity.agentAddress, + identity.kaNumber, + subGraphName, + ); + const graphlessProjection = await readExactGraphPaged(store, swmGraph, { + expectedQuadCount: publicTripleCount, + maxQuadCount: publicTripleCount, + maxNQuadsBytes: + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1.maxProjectionBytes, + outputGraph: '', + queryOptions: { source: 'rfc64-finalized-vm-swm-read' }, + }); + assertProjectionRoot( + graphlessProjection, + privateMerkleRoot, + request.candidate.assertionRoot, + ); + request.signal.throwIfAborted(); + + const metaGraph = contextGraphMetaUri(request.catalogLane.contextGraphId); + const metadataQuads = locallyAuthenticatedConfirmedMetadata({ + contextGraphId: request.catalogLane.contextGraphId, + ual: request.candidate.ual, + assertionVersion: request.candidate.assertionVersion, + assertionRoot: request.candidate.assertionRoot, + authorAddress: binding.authorAddress, + publicTripleCount, + privateTripleCount, + privateMerkleRoot, + vmGraph, + metaGraph, + subGraphName, + kaId: request.candidate.kaId, + finalizedBlockNumber: boundedMaterializedBlockNumber( + request.candidate.finalizedBlockNumber, + ), + finalizedAt: seal.assertionFinalizedAt, + }); + const asset = Object.freeze({ + contextGraphId: request.catalogLane.contextGraphId, + ual: request.candidate.ual, + assertionVersion: BigInt(request.candidate.assertionVersion), + assertionGraph: vmGraph, + metaGraph, + dataQuads: graphlessProjection.map((quad) => ({ ...quad, graph: vmGraph })), + metadataQuads: [...metadataQuads], + }) satisfies VerifiedGraphScopedAsset; + const outcome = await materializeVerifiedGraphScopedAsset({ + store, + asset, + options: { source: 'rfc64-finalized-vm-materialization' }, + }); + if (outcome === 'quarantined') { + throw new Error('finalized VM projection was quarantined by store limits'); + } + request.signal.throwIfAborted(); + + const postRead = await readExactGraphPaged(store, vmGraph, { + expectedQuadCount: publicTripleCount, + maxQuadCount: publicTripleCount, + maxNQuadsBytes: + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1.maxProjectionBytes, + outputGraph: '', + queryOptions: { source: 'rfc64-finalized-vm-post-read' }, + }); + assertProjectionRoot(postRead, privateMerkleRoot, request.candidate.assertionRoot); + if (quadsToNQuads(postRead) !== quadsToNQuads(graphlessProjection)) { + throw new Error('finalized VM post-read differs from the verified catalog projection'); + } + const postReadDigest = ethers.keccak256(ethers.concat([ + POST_READ_DIGEST_DOMAIN_V1, + ethers.toUtf8Bytes(quadsToNQuads(postRead)), + ])).toLowerCase() as Digest32V1; + return Object.freeze({ + kaId: binding.kaId, + ordinal: request.candidate.ordinal, + ual: request.candidate.ual, + status: outcome === 'stale' ? 'existing' : 'materialized', + vmGraphIri: vmGraph, + tripleCount: String(postRead.length) as DecimalU64V1, + postReadDigest, + }); + }); +} + +function locallyAuthenticatedConfirmedMetadata(input: { + readonly contextGraphId: string; + readonly ual: string; + readonly assertionVersion: string; + readonly assertionRoot: Digest32V1; + readonly authorAddress: string; + readonly publicTripleCount: number; + readonly privateTripleCount: number; + readonly privateMerkleRoot?: Uint8Array; + readonly vmGraph: string; + readonly metaGraph: string; + readonly subGraphName?: string; + readonly kaId: string; + readonly finalizedBlockNumber: string; + readonly finalizedAt: string; +}): readonly Quad[] { + const timestamp = new Date(input.finalizedAt); + if (!Number.isFinite(timestamp.getTime())) { + throw new Error('finalized VM seal timestamp is invalid'); + } + const tentative = generateGraphKnowledgeAssetMetadata({ + ual: input.ual, + contextGraphId: input.contextGraphId, + merkleRoot: ethers.getBytes(input.assertionRoot), + publisherPeerId: 'rfc64-finalized-catalog-v1', + accessPolicy: 'public', + allowedPeers: [], + timestamp, + ...(input.subGraphName ? { subGraphName: input.subGraphName } : {}), + authorAddress: input.authorAddress, + assertionVersion: input.assertionVersion, + publicTripleCount: input.publicTripleCount, + ...(input.privateMerkleRoot ? { privateMerkleRoot: input.privateMerkleRoot } : {}), + privateTripleCount: input.privateTripleCount, + assertionGraph: input.vmGraph, + }, 'tentative'); + const confirmed = tentative.map((quad) => quad.predicate === `${DKG}status` + ? Object.freeze({ ...quad, object: '"confirmed"' }) + : Object.freeze({ ...quad })); + confirmed.push( + Object.freeze({ + subject: input.ual, + predicate: `${DKG}batchId`, + object: `"${input.kaId}"^^<${XSD_INTEGER}>`, + graph: input.metaGraph, + }), + Object.freeze({ + subject: input.ual, + predicate: `${DKG}materializedVersion`, + object: `"${input.finalizedBlockNumber}:0"`, + graph: input.metaGraph, + }), + ); + return Object.freeze(confirmed); +} + +function boundedTripleCount(value: string, label: string): number { + const parsed = BigInt(value); + if ( + parsed < 0n + || parsed > BigInt(DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1.maxPublicTriples) + ) { + throw new RangeError(`${label} exceeds the finalized VM materializer limit`); + } + return Number(parsed); +} + +function boundedMaterializedBlockNumber(value: string): string { + const parsed = BigInt(value); + if (parsed < 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError('finalized block number exceeds the VM metadata ordering domain'); + } + return parsed.toString(10); +} + +function assertProjectionRoot( + quads: readonly Quad[], + privateMerkleRoot: Uint8Array | undefined, + expectedRoot: Digest32V1, +): void { + const actual = ethers.hexlify(computeFlatKCRootV10( + quads.map((quad) => ({ ...quad, graph: '' })), + privateMerkleRoot === undefined ? [] : [privateMerkleRoot], + )).toLowerCase(); + if (actual !== expectedRoot) { + throw new Error('finalized VM projection differs from the current finalized chain root'); + } +} diff --git a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts index d9ce19fc70..569b6715cc 100644 --- a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts @@ -1,5 +1,7 @@ import { CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + MemoryLayer, + contextGraphLayerUri, type ContextGraphPolicyV1, type Digest32V1, } from '@origintrail-official/dkg-core'; @@ -8,6 +10,12 @@ import type { StrictCurrentFinalizedEvmSnapshotScopeV1, } from '@origintrail-official/dkg-chain'; import { ethers } from 'ethers'; +import { + OxigraphStore, + readExactGraphPaged, + type Quad, +} from '@origintrail-official/dkg-storage'; +import { computeFlatKCRootV10 } from '@origintrail-official/dkg-publisher'; import { describe, expect, it, vi } from 'vitest'; import type { AcceptedRfc64CatalogAccessSnapshotV1 } from '../src/rfc64/catalog-access-policy-v1.js'; @@ -16,6 +24,7 @@ import { createFinalizedVmRuntimeV1, type FinalizedVmMaterializerV1, } from '../src/rfc64/finalized-vm-runtime-v1.js'; +import { createFinalizedVmStoreMaterializerV1 } from '../src/rfc64/finalized-vm-store-materializer-v1.js'; import { RFC64_VM_ASSERTION_ROOT, RFC64_VM_AUTHOR, @@ -121,6 +130,63 @@ describe('RFC-64 finalized public VM runtime', () => { code: 'finalized-vm-runtime-materialization', } satisfies Partial); }); + + it('atomically promotes the verified catalog projection into an exact VM graph', async () => { + const store = new OxigraphStore(); + const graphlessProjection: Quad[] = [ + { subject: 'urn:rfc64:asset', predicate: 'urn:rfc64:value', object: '"one"', graph: '' }, + { subject: 'urn:rfc64:asset', predicate: 'urn:rfc64:kind', object: '"demo"', graph: '' }, + ]; + const assertionRoot = ethers.hexlify(computeFlatKCRootV10( + graphlessProjection, + [], + )).toLowerCase() as Digest32V1; + const placement = await createRfc64FinalizedVmPlacementFixture({ + assertionRoot, + publicTripleCount: graphlessProjection.length, + }); + const swmGraph = contextGraphLayerUri( + RFC64_VM_CONTEXT_GRAPH_NAME, + MemoryLayer.SharedWorkingMemory, + RFC64_VM_AUTHOR, + 1, + ); + const vmGraph = contextGraphLayerUri( + RFC64_VM_CONTEXT_GRAPH_NAME, + MemoryLayer.VerifiableMemory, + RFC64_VM_AUTHOR, + 1, + ); + await store.insert(graphlessProjection.map((quad) => ({ ...quad, graph: swmGraph }))); + const runtime = createFinalizedVmRuntimeV1(runtimeConfig( + snapshotTransport({ assertionRoot }).snapshot, + createFinalizedVmStoreMaterializerV1({ store }), + )); + + const result = await runtime(request(placement)); + + expect(result.receipts).toHaveLength(1); + expect(result.receipts[0]).toMatchObject({ + status: 'materialized', + vmGraphIri: vmGraph, + tripleCount: String(graphlessProjection.length), + }); + const vmPostRead = await readExactGraphPaged(store, vmGraph, { + expectedQuadCount: graphlessProjection.length, + outputGraph: '', + }); + expect(vmPostRead).toHaveLength(graphlessProjection.length); + expect(vmPostRead).toEqual(expect.arrayContaining(graphlessProjection)); + await expect(store.query( + `ASK { GRAPH { ` + + `<${rfc64VmUal(1n)}> "confirmed" } }`, + )).resolves.toEqual({ type: 'boolean', value: true }); + const receiptRows = await store.query( + `SELECT ?tx WHERE { GRAPH { ` + + `<${rfc64VmUal(1n)}> ?tx } }`, + ); + expect(receiptRows).toEqual({ type: 'bindings', bindings: [] }); + }); }); function runtimeConfig( diff --git a/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts b/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts index 54f97cbe99..06e9e1847d 100644 --- a/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts +++ b/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts @@ -1,8 +1,10 @@ import { + AUTHOR_SCHEME_VERSION_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, + buildAuthorAttestationTypedData, canonicalizeCanonicalGraphScopedAuthorSealBytesV1, computeAuthorCatalogScopeDigestV1, computeCanonicalGraphScopedAuthorSealDigestV1, @@ -59,10 +61,15 @@ export function rfc64VmUal(kaNumber: bigint): string { export async function createRfc64FinalizedVmPlacementFixture(options: { readonly kaNumber?: bigint; readonly assertionRoot?: Digest32V1; + readonly publicTripleCount?: number; } = {}): Promise { const kaNumber = options.kaNumber ?? 1n; const kaId = rfc64VmPackKaId(kaNumber); const assertionRoot = options.assertionRoot ?? RFC64_VM_ASSERTION_ROOT; + const publicTripleCount = options.publicTripleCount ?? 10; + if (!Number.isSafeInteger(publicTripleCount) || publicTripleCount < 1) { + throw new RangeError('publicTripleCount must be a positive safe integer'); + } const scope = { networkId: RFC64_VM_NETWORK_ID, contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, @@ -74,12 +81,23 @@ export async function createRfc64FinalizedVmPlacementFixture(options: { era: '0', bucketCount: '1', } as AuthorCatalogScopeV1; + const typedData = buildAuthorAttestationTypedData({ + chainId: BigInt(RFC64_VM_CHAIN_ID), + kav10Address: RFC64_VM_KA_STORAGE, + merkleRoot: ethers.getBytes(assertionRoot), + authorAddress: RFC64_VM_AUTHOR, + reservedKaId: BigInt(kaId), + schemeVersion: AUTHOR_SCHEME_VERSION_V1, + }); + const attestation = RFC64_VM_AUTHOR_WALLET.signingKey.sign( + ethers.TypedDataEncoder.hash(typedData.domain, typedData.types, typedData.message), + ); const seal = { assertionMerkleRoot: assertionRoot, authorAddress: RFC64_VM_AUTHOR, - authorAttestationR: `0x${'aa'.repeat(32)}`, - authorAttestationVS: `0x${'bb'.repeat(32)}`, - authorSchemeVersion: '1', + authorAttestationR: attestation.r, + authorAttestationVS: attestation.yParityAndS, + authorSchemeVersion: String(AUTHOR_SCHEME_VERSION_V1), assertedAtChainId: RFC64_VM_CHAIN_ID, assertedAtKav10Address: RFC64_VM_KA_STORAGE, reservedKaId: kaId, @@ -87,7 +105,7 @@ export async function createRfc64FinalizedVmPlacementFixture(options: { contentScopeVersion: '2', kaUal: rfc64VmUal(kaNumber), assertionVersion: '2', - publicTripleCount: '10', + publicTripleCount: String(publicTripleCount), privateTripleCount: '0', privateMerkleRoot: null, } as CanonicalGraphScopedAuthorSealV1; From 4808c672ddc9d6822432f5775dbbe6d15602bfb6 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:36:08 +0200 Subject: [PATCH 255/292] feat(agent): apply finalized VM before catalog head --- packages/agent/src/dkg-agent-cg-registry.ts | 13 + packages/agent/src/dkg-agent-lifecycle.ts | 42 ++- packages/agent/src/dkg-agent-rfc64-catalog.ts | 84 +++++- .../public-catalog-native-receiver-v1.ts | 78 ++++- ...kg-agent-native-wiring.integration.test.ts | 283 +++++++++++++++++- ...c-catalog-native-gate1.integration.test.ts | 70 +++++ 6 files changed, 553 insertions(+), 17 deletions(-) diff --git a/packages/agent/src/dkg-agent-cg-registry.ts b/packages/agent/src/dkg-agent-cg-registry.ts index 09973ad3fd..28574cfa91 100644 --- a/packages/agent/src/dkg-agent-cg-registry.ts +++ b/packages/agent/src/dkg-agent-cg-registry.ts @@ -421,6 +421,19 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { const subscribed = this.subscribedContextGraphs.get(contextGraphId)?.onChainId; if (subscribed) return subscribed; + // Registered CG events carry only the curator-committed name hash. Resolve + // the cleartext subscription through the in-memory reverse index before + // consulting RDF state; this mapping is populated and persisted by the + // chain-event lifecycle path. + const wireId = /^0x[0-9a-fA-F]{64}$/.test(contextGraphId) + ? contextGraphId.toLowerCase() + : ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)).toLowerCase(); + const mappedLocalId = this.wireIdToLocalCgId.get(wireId); + if (mappedLocalId !== undefined) { + const mapped = this.subscribedContextGraphs.get(mappedLocalId)?.onChainId; + if (mapped) return mapped; + } + const ontologyGraph = contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY); const contextGraphUri = `did:dkg:context-graph:${contextGraphId}`; const result = await this.store.query( diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 7e3bab8303..e1a6cff39f 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -1936,6 +1936,15 @@ export class LifecycleSyncMethods extends DKGAgentBase { onContextGraphCreated: async ({ contextGraphId, creator, accessPolicy, publishPolicy, nameHash, blockNumber }) => { this.log.info(ctx, `Discovered on-chain context graph ${contextGraphId.slice(0, 16)}… (block ${blockNumber}, creator ${creator.slice(0, 10)}…, policy ${accessPolicy}, publishPolicy ${publishPolicy ?? '?'}, nameHash ${nameHash ? nameHash.slice(0, 10) + '…' : '(opt-out)'})`); + // Bind an already-explicit cleartext subscription directly from the + // chain event's name commitment. Public CGs do not enter the curated + // host-mode block below, so without this store-free comparison a cold + // receiver could know the right graph name yet remain dependent on an + // ontology triple that durable sync has not materialized. + if (nameHash) { + this.bindContextGraphCreatedNameHashV1(nameHash, contextGraphId); + } + // Track the numeric on-chain id for dedup. const alreadyKnown = this.seenOnChainIds.has(contextGraphId) || [...this.subscribedContextGraphs.values()].some(s => s.onChainId === contextGraphId); @@ -1998,13 +2007,14 @@ export class LifecycleSyncMethods extends DKGAgentBase { // input and find the on-chain participant agents without an // RPC round-trip per envelope. const hashLower = nameHash.toLowerCase(); + const localId = this.wireIdToLocalCgId.get(hashLower) ?? hashLower; // Stage a synthetic subscription record for the host-only // case: cores hosting CGs they never joined have no // cleartext; the hash IS their local id. `recordCgWireId` // would no-op on this without a pre-existing record, so // upsert a minimal stub first. - if (!this.subscribedContextGraphs.has(hashLower)) { - this.setContextGraphSubscription(hashLower, { + if (!this.subscribedContextGraphs.has(localId)) { + this.setContextGraphSubscription(localId, { subscribed: false, synced: false, onChainId: contextGraphId, @@ -2012,12 +2022,12 @@ export class LifecycleSyncMethods extends DKGAgentBase { pendingMeta: true, }, { persist: false }); } else { - const next = { ...this.subscribedContextGraphs.get(hashLower)! }; - this.bindSubscriptionOnChainId(hashLower, next, contextGraphId); + const next = { ...this.subscribedContextGraphs.get(localId)! }; + this.bindSubscriptionOnChainId(localId, next, contextGraphId); next.onChainHash = hashLower; - this.setContextGraphSubscription(hashLower, next, { persist: false }); + this.setContextGraphSubscription(localId, next, { persist: false }); } - this.recordCgWireId(hashLower, hashLower); + this.recordCgWireId(localId, hashLower); // Delegate to the host-mode reconciler — it owns the // sharding-table check, swmHostMode flag, and the wire-up @@ -2025,7 +2035,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { // the periodic reconciler covers the timer-driven fallback // path, so a missed event here heals on the next sweep. void this.reconcileSwmHostModeSubscription( - hashLower, + localId, SUBSCRIPTION_SOURCES.CHAIN_EVENT, ).catch((err) => { this.log.warn( @@ -5977,6 +5987,24 @@ export class LifecycleSyncMethods extends DKGAgentBase { return next; } + /** Store-free binding used by the ContextGraphCreated event callback. */ + protected bindContextGraphCreatedNameHashV1( + this: DKGAgent, + nameHash: string, + onChainContextGraphId: string, + ): void { + const hashLower = nameHash.toLowerCase(); + for (const [localId, current] of [...this.subscribedContextGraphs]) { + const computed = ethers.keccak256(ethers.toUtf8Bytes(localId)).toLowerCase(); + if (computed !== hashLower) continue; + const next = { ...current }; + this.bindSubscriptionOnChainId(localId, next, onChainContextGraphId); + next.onChainHash = hashLower; + this.setContextGraphSubscription(localId, next); + this.recordCgWireId(localId, hashLower); + } + } + markContextGraphSubscriptionState(this: DKGAgent, contextGraphId: string, patch: Partial): void { const existing = this.subscribedContextGraphs.get(contextGraphId); if (!existing) return; diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 60bf2984d0..40c15b237b 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -33,7 +33,9 @@ import { type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, type OperationContext, + type ChainIdV1, type ContextGraphIdV1, + type DecimalU256V1, type Digest32V1, type EvmAddressV1, type KaIdV1, @@ -47,7 +49,11 @@ import { type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, } from '@origintrail-official/dkg-core'; -import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { + createStrictCurrentFinalizedEvmSnapshotScopeV1, + resolveRpcUrls, + verifyControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; import { DKGAgentBase } from './dkg-agent-base.js'; import type { DKGAgent } from './dkg-agent.js'; import type { Rfc64AuthorCatalogEip191SignerV1 } from './rfc64/author-catalog-producer.js'; @@ -76,8 +82,11 @@ import { import { Rfc64PublicCatalogNativeReceiverErrorV1, Rfc64PublicCatalogNativeReceiverV1, + type Rfc64PublicCatalogNativeFinalizedVmPrecommitV1, type Rfc64PublicCatalogNativeSynchronizationEvidenceV1, } from './rfc64/public-catalog-native-receiver-v1.js'; +import { createFinalizedVmRuntimeV1 } from './rfc64/finalized-vm-runtime-v1.js'; +import { createFinalizedVmStoreMaterializerV1 } from './rfc64/finalized-vm-store-materializer-v1.js'; import { createRfc64PublicOpenCatalogNativeReconcilerV1, type Rfc64PublicOpenCatalogDeploymentResolverV1, @@ -753,6 +762,8 @@ export class Rfc64CatalogMethods extends DKGAgentBase { controlObjects: persistence.controlObjects, inventory: persistence.inventory, store: this.store, + beforeAppliedHeadCommit: (evidence, signal) => + this.runRfc64FinalizedVmPrecommitV1(evidence, signal), transportTimeoutMs: clients.transportTimeoutMs, }); const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ @@ -808,6 +819,77 @@ export class Rfc64CatalogMethods extends DKGAgentBase { }); } + /** + * Promote an exact public catalog inventory only when its accepted policy is + * already anchored to finalized chain truth. The owner-signed/unregistered + * Gate-1 lane remains SWM-only until a finalized policy is accepted. + */ + private async runRfc64FinalizedVmPrecommitV1( + this: DKGAgent, + evidence: Readonly, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const service = this.requireRfc64PublicCatalogServiceV1(); + const acceptedPolicy = service.acceptedPolicySnapshotForCatalogScope( + evidence.catalogScope, + ); + const { policy } = acceptedPolicy; + if (policy.source.kind !== 'finalized-chain') return; + if ( + policy.accessPolicy !== 0 + || policy.governanceChainId === null + || policy.governanceContractAddress === null + ) { + throw new Error('RFC-64 finalized VM precommit requires one public finalized policy'); + } + const chainConfig = this.config.chainConfig; + if (chainConfig === undefined) { + throw new Error('RFC-64 finalized VM precommit requires trusted RPC configuration'); + } + if (typeof this.chain.getDKGKnowledgeAssetsAddress !== 'function') { + throw new Error('RFC-64 finalized VM precommit requires DKGKnowledgeAssets resolution'); + } + + const [onChainContextGraphId, liveChainId, knowledgeAssetStorageAddress] = + await Promise.all([ + this.getContextGraphOnChainId(evidence.catalogScope.contextGraphId, { signal }), + this.chain.getEvmChainId(), + this.chain.getDKGKnowledgeAssetsAddress(), + ]); + signal.throwIfAborted(); + if (onChainContextGraphId === null) { + throw new Error('RFC-64 finalized VM precommit could not resolve the numeric context graph id'); + } + if (liveChainId.toString() !== policy.governanceChainId) { + throw new Error('RFC-64 finalized VM policy differs from the configured chain id'); + } + + const chainId = policy.governanceChainId as ChainIdV1; + const runtime = createFinalizedVmRuntimeV1({ + networkId: evidence.catalogScope.networkId, + chainId, + contextGraphStorageAddress: policy.governanceContractAddress, + knowledgeAssetStorageAddress: + knowledgeAssetStorageAddress.toLowerCase() as EvmAddressV1, + snapshot: createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId, + endpoints: resolveRpcUrls(chainConfig.rpcUrl, chainConfig.rpcUrls), + }), + materialize: createFinalizedVmStoreMaterializerV1({ store: this.store }), + }); + await runtime({ + catalogLane: Object.freeze({ + contextGraphId: evidence.catalogScope.contextGraphId, + subGraphName: evidence.catalogScope.subGraphName, + }), + onChainContextGraphId: onChainContextGraphId as DecimalU256V1, + acceptedPolicy, + placements: Object.freeze(evidence.rows.map((row) => row.placement)), + signal, + }); + } + /** Reject a stale applied-head dedupe before trusting any durable row. */ private assertRfc64CatalogNetworkMatchesTrustedSourceV1( this: DKGAgent, diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index f56428f3d4..5258c36668 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -54,6 +54,7 @@ import { type SignedAuthorCatalogDirectoryNodeEnvelopeV1, type SignedAuthorCatalogHeadEnvelopeV1, type SignedAuthorCatalogIssuerDelegationEnvelopeV1, + type VerifiedCatalogSealBindingV1, type VerifiedCatalogSealBindingSnapshotV1, } from '@origintrail-official/dkg-core'; import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; @@ -72,8 +73,10 @@ import { unpackKnowledgeAssetId } from '../ka-identity.js'; import { readVerifiedAuthorCatalogRowAuthorshipV1, verifyAuthorCatalogRowAuthorshipV1, + type VerifiedAuthorCatalogRowAuthorshipV1, type VerifiedAuthorCatalogRowAuthorshipSnapshotV1, } from './catalog-row-authorship.js'; +import type { FinalizedVmPlacementEvidenceV1 } from './finalized-vm-composer-v1.js'; import type { Rfc64ControlObjectOperationsV1 } from './control-object-store-v1.js'; import type { AppliedCatalogHeadSnapshotV1, @@ -125,9 +128,32 @@ export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { 'readAppliedCatalogHeadV1' | 'compareAndSwapAppliedCatalogHeadV1' >; readonly store: TripleStore; + /** + * Final fail-closed semantic admission step. It runs after the exact SWM + * post-read and before the durable applied-head CAS. A rejection leaves the + * head unapplied so a later synchronization can idempotently repair both + * SWM and any partially materialized VM rows. + */ + readonly beforeAppliedHeadCommit?: Rfc64PublicCatalogNativeFinalizedVmPrecommitHandlerV1; readonly transportTimeoutMs?: number; } +/** Exact same-process evidence admitted to finalized VM composition. */ +export interface Rfc64PublicCatalogNativeFinalizedVmPrecommitV1 { + readonly catalogScope: Readonly; + readonly catalogHeadDigest: Digest32V1; + readonly inventoryDigest: Digest32V1; + /** Strictly increasing by mathematical KA ID. */ + readonly rows: readonly Readonly[]; +} + +export interface Rfc64PublicCatalogNativeFinalizedVmPrecommitHandlerV1 { + ( + evidence: Readonly, + signal: AbortSignal, + ): Promise; +} + export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { /** Digest computed from the exact semantic projection+seal post-read. */ readonly inventoryDigest: Digest32V1; @@ -141,6 +167,8 @@ export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { readonly swmGraph: string; /** Exact signed delegation/head/path/bucket/row authorization closure. */ readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + /** Process-local capabilities retained for same-process finalized VM admission. */ + readonly placement: FinalizedVmPlacementEvidenceV1; /** Exact predecessor rows absent from this head and physically deactivated before its CAS. */ readonly removedRows: readonly Readonly[]; readonly removedRowCount: number; @@ -159,6 +187,8 @@ export interface Rfc64PublicCatalogNativeActivatedRowEvidenceV1 readonly swmGraph: string; /** Exact signed delegation/head/path/bucket/row authorization closure. */ readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + /** Process-local capabilities retained for same-process finalized VM admission. */ + readonly placement: FinalizedVmPlacementEvidenceV1; } /** Exact evidence for one bounded multi-asset successor inventory. */ @@ -229,6 +259,10 @@ export class Rfc64PublicCatalogNativeReceiverV1 { || typeof options.inventory?.readAppliedCatalogHeadV1 !== 'function' || typeof options.inventory?.compareAndSwapAppliedCatalogHeadV1 !== 'function' || typeof options.store?.query !== 'function' + || ( + options.beforeAppliedHeadCommit !== undefined + && typeof options.beforeAppliedHeadCommit !== 'function' + ) ) { fail('catalog-native-receiver-input', 'receiver dependencies are incomplete'); } @@ -623,16 +657,19 @@ export class Rfc64PublicCatalogNativeReceiverV1 { const preparedRows: Array<{ readonly row: AuthorCatalogRowV1; readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + readonly authorshipCapability: VerifiedAuthorCatalogRowAuthorshipV1; readonly projectionMetadata: ReturnType; readonly sealBinding: VerifiedCatalogSealBindingSnapshotV1; + readonly sealBindingCapability: VerifiedCatalogSealBindingV1; readonly projectionBytes: Uint8Array; readonly expectedEvidence: Rfc64PublicCatalogInventoryEvidenceRowV1; }> = []; for (const row of bucket.payload.rows) { throwIfAborted(signal); let authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; + let authorshipCapability: VerifiedAuthorCatalogRowAuthorshipV1; try { - const authorshipToken = verifyAuthorCatalogRowAuthorshipV1({ + authorshipCapability = verifyAuthorCatalogRowAuthorshipV1({ catalogIssuerDelegation: fetchedDelegation.envelope, catalogIssuerDelegationSignature: fetchedDelegation.issuerSignature, parentAuthorAgentEvidence: null, @@ -645,7 +682,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { catalogBucketSignature: fetchedBucket.issuerSignature, targetKaId: row.kaId, }); - authorship = readVerifiedAuthorCatalogRowAuthorshipV1(authorshipToken); + authorship = readVerifiedAuthorCatalogRowAuthorshipV1(authorshipCapability); } catch (cause) { fail( 'catalog-native-receiver-authorization', @@ -670,6 +707,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { let projectionMetadata: ReturnType; let sealBinding: VerifiedCatalogSealBindingSnapshotV1; + let sealBindingCapability: VerifiedCatalogSealBindingV1; let projectionBytes: Uint8Array; try { const transferred = verifyTransferredCatalogBundleV1(head, row, bundle, deployment); @@ -679,8 +717,9 @@ export class Rfc64PublicCatalogNativeReceiverV1 { row, deployment, ); + sealBindingCapability = transferredMetadata.catalogSealBinding; sealBinding = readVerifiedCatalogSealBindingV1( - transferredMetadata.catalogSealBinding, + sealBindingCapability, ); assertRecoverableAuthorAttestationCapabilityV1(sealBinding); const projection = verifyCgSharedProjectionV1(transferred, head, row, deployment); @@ -717,8 +756,10 @@ export class Rfc64PublicCatalogNativeReceiverV1 { preparedRows.push(Object.freeze({ row, authorship, + authorshipCapability, projectionMetadata, sealBinding, + sealBindingCapability, projectionBytes, expectedEvidence, })); @@ -808,6 +849,10 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ...activation.evidence, swmGraph: activation.swmGraph, authorship: prepared.authorship, + placement: Object.freeze({ + authorship: prepared.authorshipCapability, + sealBinding: prepared.sealBindingCapability, + }), })); } completion = verifyRfc64PublicCatalogInventoryCompletenessV1({ @@ -855,6 +900,25 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ); } + const precommitSignal = signal ?? new AbortController().signal; + try { + throwIfAborted(precommitSignal); + await this.options.beforeAppliedHeadCommit?.(Object.freeze({ + catalogScope: trustedCatalogScope, + catalogHeadDigest: head.objectDigest as Digest32V1, + inventoryDigest: completion.inventoryDigest, + rows: Object.freeze(activatedRows), + }), precommitSignal); + throwIfAborted(precommitSignal); + } catch (cause) { + if (precommitSignal.aborted && cause === precommitSignal.reason) throw cause; + fail( + 'catalog-native-receiver-activation', + 'finalized VM precommit rejected the exact activated inventory', + cause, + ); + } + let appliedHeadStatus: 'applied' | 'existing'; if (replay) { if (currentAppliedHead!.appliedInventoryDigest !== completion.inventoryDigest) { @@ -930,6 +994,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { activatedTripleCount: only.activatedTripleCount, swmGraph: only.swmGraph, authorship: only.authorship, + placement: only.placement, removedRows: Object.freeze(removedRows), removedRowCount: removedRows.length, appliedHeadStatus, @@ -1039,13 +1104,10 @@ function snapshotTrustedPublicOpenScope( try { assertAuthorCatalogScopeV1(scope); if ( - scope.governanceChainId !== null - || scope.governanceContractAddress !== null - || scope.ownershipTransitionDigest !== null - || scope.subGraphName !== null + scope.subGraphName !== null || scope.bucketCount !== '1' ) { - throw new Error('Gate 1 requires the public/open null-governance root scope'); + throw new Error('bounded public catalog synchronization requires the root one-bucket lane'); } if ( announcement.networkId !== scope.networkId diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index e057cac94a..b40beb846d 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -5,9 +5,11 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + MemoryLayer, assertCanonicalGraphScopedAuthorSealV1, buildAuthorAttestationTypedData, computeAuthorCatalogScopeDigestV1, + contextGraphLayerUri, type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, type ContextGraphIdV1, @@ -18,9 +20,10 @@ import { type NetworkIdV1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; +import { MockChainAdapter } from '@origintrail-official/dkg-chain'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { ethers } from 'ethers'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DKGAgent } from '../src/dkg-agent.js'; import { @@ -28,6 +31,11 @@ import { snapshotRfc64CatalogDeploymentProfileV1, } from '../src/dkg-agent-rfc64-catalog.js'; import type { Rfc64CatalogAccessPolicyAuthorityConfigV1 } from '../src/dkg-agent-types.js'; +import { + createLoopbackJsonRpcTestHarness, + sendJsonRpcError, + sendJsonRpcResult, +} from '../../chain/test/loopback-rpc-harness.js'; const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const NETWORK_ID = 'otp:20430' as NetworkIdV1; @@ -41,6 +49,11 @@ const SUCCESSOR_ISSUED_AT = '1773900001000' as TimestampMsV1; const SECOND_SUCCESSOR_ISSUED_AT = '1773900002000' as TimestampMsV1; const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const CONTEXT_GRAPH_STORAGE = + '0x3333333333333333333333333333333333333333' as EvmAddressV1; +const ON_CHAIN_CONTEXT_GRAPH_ID = '14'; +const FINALIZED_BLOCK_HASH = `0x${'77'.repeat(32)}` as Digest32V1; +const FINALIZED_POLICY_DIGEST = `0x${'cd'.repeat(32)}` as Digest32V1; const ASSERTION_ROOT = '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f' as Digest32V1; const PROJECTION = new TextEncoder().encode( @@ -55,12 +68,46 @@ const NATIVE_DEPLOYMENT = Object.freeze({ const agents: DKGAgent[] = []; const tempDirs: string[] = []; +const rpcHarness = createLoopbackJsonRpcTestHarness(); + +const CG = new ethers.Interface([ + 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', + 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', + 'function isContextGraphActive(uint256 contextGraphId) view returns (bool)', + 'function getContextGraphKaCount(uint256 contextGraphId) view returns (uint256)', + 'function getContextGraphKaAt(uint256 contextGraphId, uint256 ordinal) view returns (uint256)', +]); +const KA = new ethers.Interface([ + 'function getKnowledgeAssetUpdateContext(uint256 id) view returns (uint256 merkleRootsCount, uint256 minted, uint88 byteSize, uint40 endEpoch, uint96 tokenAmount, bool isImmutable, uint32 merkleLeafCount)', + 'function getLatestMerkleRoot(uint256 id) view returns (bytes32)', + 'function getLatestMerkleRootAuthor(uint256 id) view returns (address)', + 'function getLatestMerkleRootPublisher(uint256 id) view returns (address)', +]); + +class FinalizedVmMockChainAdapter extends MockChainAdapter { + constructor() { + super(NETWORK_ID); + } + + override async getEvmChainId(): Promise { + return 20_430n; + } + + override async getKnowledgeAssetsLifecycleAddress(): Promise { + return KAV10; + } + + override async getDKGKnowledgeAssetsAddress(): Promise { + return KAV10; + } +} afterEach(async () => { for (const agent of agents.splice(0)) { try { await agent.stop(); } catch { /* best-effort */ } } await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); + await rpcHarness.stopAll(); }); async function startNativeAgent( @@ -68,6 +115,10 @@ async function startNativeAgent( deployment: CatalogSealDeploymentProfileV1 = NATIVE_DEPLOYMENT, existingDataDir?: string, accessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1, + finalizedRuntime?: Readonly<{ + rpcUrl: string; + chainAdapter: FinalizedVmMockChainAdapter; + }>, ): Promise { const dataDir = existingDataDir ?? await mkdtemp(join(tmpdir(), `dkg-rfc64-native-${name}-`)); @@ -87,6 +138,14 @@ async function startNativeAgent( agentProfileHeartbeatMs: 0, rfc64CatalogDeploymentProfile: deployment, rfc64CatalogAccessPolicyAuthority: accessPolicyAuthority, + ...(finalizedRuntime === undefined ? {} : { + chainAdapter: finalizedRuntime.chainAdapter, + chainConfig: { + rpcUrl: finalizedRuntime.rpcUrl, + hubAddress: CONTEXT_GRAPH_STORAGE, + operationalKeys: [`0x${'12'.repeat(32)}`], + }, + }), }); agents.push(agent); await agent.start(); @@ -144,6 +203,34 @@ function privateCatalogPolicy(): ContextGraphPolicyV1 { }; } +function finalizedPublicCatalogPolicy(): ContextGraphPolicyV1 { + return { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: CONTEXT_GRAPH_STORAGE, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'finalized-chain', + chainId: '20430', + contractAddress: CONTEXT_GRAPH_STORAGE, + blockNumber: '120', + blockHash: `0x${'76'.repeat(32)}`, + }, + effectiveAt: '1773900000000', + issuedAt: '1773900000000', + }; +} + function privateCatalogRoster( policy: ContextGraphPolicyV1, policyDigest: Digest32V1, @@ -457,6 +544,200 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); + it('materializes finalized VM through production two-agent wiring before applying the head', async () => { + const kaNumber = 7n; + const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); + const nameHash = ethers.keccak256(ethers.toUtf8Bytes(CONTEXT_GRAPH_ID)).toLowerCase(); + const rpc = await rpcHarness.start((call, response) => { + switch (call.method) { + case 'eth_chainId': + sendJsonRpcResult(response, call, ethers.toQuantity(20_430)); + return; + case 'eth_getBlockByNumber': + sendJsonRpcResult(response, call, { + number: '0x7b', + hash: FINALIZED_BLOCK_HASH, + }); + return; + case 'eth_getCode': + sendJsonRpcResult(response, call, '0x6000'); + return; + case 'eth_call': { + const request = call.params[0] as { readonly data?: string }; + const selector = request.data?.slice(0, 10); + switch (selector) { + case CG.getFunction('getContextGraph')!.selector: + sendJsonRpcResult(response, call, CG.encodeFunctionResult( + 'getContextGraph', + [AUTHOR, [], 0n, true, 1n, 0, 1, ethers.ZeroAddress, 0n], + )); + return; + case CG.getFunction('getNameHash')!.selector: + sendJsonRpcResult(response, call, CG.encodeFunctionResult('getNameHash', [nameHash])); + return; + case CG.getFunction('isContextGraphActive')!.selector: + sendJsonRpcResult( + response, + call, + CG.encodeFunctionResult('isContextGraphActive', [true]), + ); + return; + case CG.getFunction('getContextGraphKaCount')!.selector: + sendJsonRpcResult( + response, + call, + CG.encodeFunctionResult('getContextGraphKaCount', [1n]), + ); + return; + case CG.getFunction('getContextGraphKaAt')!.selector: + sendJsonRpcResult( + response, + call, + CG.encodeFunctionResult('getContextGraphKaAt', [BigInt(kaId)]), + ); + return; + case KA.getFunction('getKnowledgeAssetUpdateContext')!.selector: + sendJsonRpcResult(response, call, KA.encodeFunctionResult( + 'getKnowledgeAssetUpdateContext', + [1n, 0n, 0n, 0n, 0n, false, 0], + )); + return; + case KA.getFunction('getLatestMerkleRoot')!.selector: + sendJsonRpcResult( + response, + call, + KA.encodeFunctionResult('getLatestMerkleRoot', [ASSERTION_ROOT]), + ); + return; + case KA.getFunction('getLatestMerkleRootAuthor')!.selector: + sendJsonRpcResult( + response, + call, + KA.encodeFunctionResult('getLatestMerkleRootAuthor', [AUTHOR]), + ); + return; + case KA.getFunction('getLatestMerkleRootPublisher')!.selector: + sendJsonRpcResult(response, call, KA.encodeFunctionResult( + 'getLatestMerkleRootPublisher', + [`0x${'66'.repeat(20)}`], + )); + return; + default: + sendJsonRpcError(response, call, -32602, `unexpected eth_call ${selector}`); + return; + } + } + default: + sendJsonRpcError(response, call, -32601, 'method not found'); + } + }); + const [author, receiver] = await Promise.all([ + startNativeAgent( + 'vm-author', + NATIVE_DEPLOYMENT, + undefined, + undefined, + { rpcUrl: rpc.url, chainAdapter: new FinalizedVmMockChainAdapter() }, + ), + startNativeAgent( + 'vm-receiver', + NATIVE_DEPLOYMENT, + undefined, + undefined, + { rpcUrl: rpc.url, chainAdapter: new FinalizedVmMockChainAdapter() }, + ), + ]); + const policy = finalizedPublicCatalogPolicy(); + for (const agent of [author, receiver]) { + agent.acceptRfc64CatalogAccessSnapshotV1({ + policy, + policyDigest: FINALIZED_POLICY_DIGEST, + roster: null, + }); + } + (receiver as any).setContextGraphSubscription(CONTEXT_GRAPH_ID, { + subscribed: true, + synced: false, + }, { persist: false }); + (receiver as any).bindContextGraphCreatedNameHashV1( + nameHash, + ON_CHAIN_CONTEXT_GRAPH_ID, + ); + const storeQuery = vi.spyOn((receiver as any).store, 'query'); + await expect(receiver.getContextGraphOnChainId(CONTEXT_GRAPH_ID)).resolves.toBe( + ON_CHAIN_CONTEXT_GRAPH_ID, + ); + expect(storeQuery.mock.calls.some(([query]) => String(query).includes('OnChainId'))).toBe(false); + storeQuery.mockRestore(); + await connectBothWays(author, receiver); + + const scope = { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: CONTEXT_GRAPH_STORAGE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as const; + const genesis = await author.publishAuthorCatalogGenesisV1({ + scope, + author: AUTHOR_WALLET, + peers: [receiver.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: MULTI_DELEGATION_EXPIRES_AT, + }); + expect(genesis.announcedPeers).toEqual([receiver.peerId]); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + const successor = await author.publishAuthorCatalogExactSetSuccessorV1({ + previousHead: { + objectDigest: genesis.headObjectDigest, + signatureVariantDigest: genesis.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + assets: [{ + assertionCoordinate: 'finalized-vm-production-wire' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(kaNumber), + }], + deployment: NATIVE_DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [receiver.peerId], + }); + expect(successor.announcedPeers).toEqual([receiver.peerId]); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + + const vmGraph = contextGraphLayerUri( + CONTEXT_GRAPH_ID, + MemoryLayer.VerifiableMemory, + AUTHOR, + Number(kaNumber), + ); + expect(receiver.readRfc64PublicCatalogReconciliationFailureV1( + successor.headObjectDigest, + )).toBeNull(); + await expect((receiver as any).store.query( + `SELECT ?s ?p ?o WHERE { GRAPH <${vmGraph}> { ?s ?p ?o } }`, + )).resolves.toMatchObject({ + type: 'bindings', + bindings: expect.arrayContaining([ + expect.objectContaining({ s: 'https://example.org/alice' }), + ]), + }); + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + })).toMatchObject({ + currentCatalogHeadDigest: successor.headObjectDigest, + inventoryRowCount: '1', + }); + expect(rpc.calls.some(({ method }) => method === 'eth_call')).toBe(true); + }, 60_000); + it('exposes bounded typed failure evidence when wire scope differs from local deployment', async () => { const [author, receiver] = await Promise.all([ startNativeAgent('mismatch-author'), diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index fc3557c543..0e4121c848 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -48,6 +48,7 @@ import { import { Rfc64PublicCatalogNativeReceiverV1, rfc64CatalogSignatureVariantDigestV1, + type Rfc64PublicCatalogNativeFinalizedVmPrecommitHandlerV1, } from '../src/rfc64/public-catalog-native-receiver-v1.js'; import { computeRfc64AppliedInventoryDigestV1, @@ -1199,6 +1200,73 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); }, 30_000); + + it('runs finalized VM precommit on exact placement evidence before CAS and retries rejection', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + const compareAndSwapAppliedCatalogHeadV1 = vi.fn( + fixture.receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1.bind( + fixture.receiverPersistence.inventory, + ), + ); + const rejectingPrecommit = vi.fn(async () => { + throw new Error('simulated finalized VM materialization failure'); + }); + const rejectingReceiver = fixture.createReceiver({ + readAppliedCatalogHeadV1: + fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1.bind( + fixture.receiverPersistence.inventory, + ), + compareAndSwapAppliedCatalogHeadV1, + }, undefined, undefined, fixture.receiverStore, rejectingPrecommit); + + await expect(fixture.synchronize( + fixture.announcement, + rejectingReceiver, + )).rejects.toMatchObject({ + code: 'catalog-native-receiver-activation', + message: expect.stringContaining('finalized VM precommit rejected'), + }); + expect(rejectingPrecommit).toHaveBeenCalledOnce(); + const [rejectedEvidence, rejectedSignal] = rejectingPrecommit.mock.calls[0]!; + expect(rejectedEvidence).toMatchObject({ + catalogScope: fixture.scope, + catalogHeadDigest: fixture.successor.head.objectDigest, + rows: [{ + kaId: fixture.rowBundle.row.kaId, + kaUal: fixture.rowBundle.kaUal, + }], + }); + expect(Object.isFrozen(rejectedEvidence)).toBe(true); + expect(Object.isFrozen(rejectedEvidence.rows)).toBe(true); + expect(Object.isFrozen(rejectedEvidence.rows[0]?.placement)).toBe(true); + expect(rejectedSignal).toBeInstanceOf(AbortSignal); + expect(compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + + const acceptingPrecommit = vi.fn(async () => {}); + const repaired = await fixture.synchronize( + fixture.announcement, + fixture.createReceiver( + fixture.receiverPersistence.inventory, + undefined, + undefined, + fixture.receiverStore, + acceptingPrecommit, + ), + ); + expect(acceptingPrecommit).toHaveBeenCalledOnce(); + expect(repaired.appliedHeadStatus).toBe('applied'); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + }, 30_000); }); async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { @@ -1626,12 +1694,14 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { fetchKaBundle: receiverBundleFetch, }, store: TripleStore = receiverStore, + beforeAppliedHeadCommit?: Rfc64PublicCatalogNativeFinalizedVmPrecommitHandlerV1, ) => new Rfc64PublicCatalogNativeReceiverV1({ headTransport: { fetchCatalogHead: receiverHeadFetch }, contentTransport, controlObjects, inventory, store, + beforeAppliedHeadCommit, }); const createCasObservedReceiver = (contentTransport?: Pick< Rfc64PublicCatalogNativeTransportV1, From 27a589852a415da7d5d60c1fb6f0242486433709 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 13:59:27 +0200 Subject: [PATCH 256/292] test(agent): cover finalized RPC capability preflight --- .../test/rfc64-dkg-agent-native-wiring.integration.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index b40beb846d..f37bd14a99 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -564,6 +564,10 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { return; case 'eth_call': { const request = call.params[0] as { readonly data?: string }; + if (request.data === '0x') { + sendJsonRpcResult(response, call, '0x'); + return; + } const selector = request.data?.slice(0, 10); switch (selector) { case CG.getFunction('getContextGraph')!.selector: From 2c76b378f7bf0f25f803ea9d78e9c501426222ba Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 14:22:52 +0200 Subject: [PATCH 257/292] fix(agent): isolate finalized VM precommit boundaries --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/dkg-agent-rfc64-catalog.ts | 102 ++++-------------- .../rfc64/finalized-vm-agent-precommit-v1.ts | 93 ++++++++++++++++ .../src/rfc64/finalized-vm-composer-v1.ts | 26 ++++- .../src/rfc64/finalized-vm-runtime-v1.ts | 38 +------ .../finalized-vm-store-materializer-v1.ts | 94 ++++------------ .../public-catalog-native-receiver-v1.ts | 39 ++++--- ...kg-agent-native-wiring.integration.test.ts | 33 ++++-- .../rfc64-finalized-vm-composer-v1.test.ts | 10 ++ .../rfc64-finalized-vm-runtime-v1.test.ts | 13 +++ ...c-catalog-native-gate1.integration.test.ts | 94 +++++++++++----- packages/publisher/src/index.ts | 2 +- packages/publisher/src/metadata.ts | 54 ++++++++-- 14 files changed, 340 insertions(+), 260 deletions(-) create mode 100644 packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 2d391f796c..5a92db7882 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -26,6 +26,7 @@ "./dist/rfc64/catalog-authority-config-v1.js": null, "./dist/rfc64/catalog-transport-authorization-v1.js": null, "./dist/rfc64/durable-file-store-v1.js": null, + "./dist/rfc64/finalized-vm-agent-precommit-v1.js": null, "./dist/rfc64/finalized-vm-composer-v1.js": null, "./dist/rfc64/finalized-vm-runtime-v1.js": null, "./dist/rfc64/finalized-vm-store-materializer-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index dea4e7bcc3..f311f14724 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -101,6 +101,7 @@ const blockedRfc64Modules = [ 'control-object-store-v1-internal.js', 'control-object-store-v1.js', 'durable-file-store-v1.js', + 'finalized-vm-agent-precommit-v1.js', 'finalized-vm-composer-v1.js', 'finalized-vm-runtime-v1.js', 'finalized-vm-store-materializer-v1.js', diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 40c15b237b..07aed78e18 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -33,9 +33,7 @@ import { type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, type OperationContext, - type ChainIdV1, type ContextGraphIdV1, - type DecimalU256V1, type Digest32V1, type EvmAddressV1, type KaIdV1, @@ -50,7 +48,6 @@ import { type SignedAuthorCatalogHeadEnvelopeV1, } from '@origintrail-official/dkg-core'; import { - createStrictCurrentFinalizedEvmSnapshotScopeV1, resolveRpcUrls, verifyControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; @@ -82,11 +79,9 @@ import { import { Rfc64PublicCatalogNativeReceiverErrorV1, Rfc64PublicCatalogNativeReceiverV1, - type Rfc64PublicCatalogNativeFinalizedVmPrecommitV1, type Rfc64PublicCatalogNativeSynchronizationEvidenceV1, } from './rfc64/public-catalog-native-receiver-v1.js'; -import { createFinalizedVmRuntimeV1 } from './rfc64/finalized-vm-runtime-v1.js'; -import { createFinalizedVmStoreMaterializerV1 } from './rfc64/finalized-vm-store-materializer-v1.js'; +import { createRfc64FinalizedVmAgentPrecommitV1 } from './rfc64/finalized-vm-agent-precommit-v1.js'; import { createRfc64PublicOpenCatalogNativeReconcilerV1, type Rfc64PublicOpenCatalogDeploymentResolverV1, @@ -756,14 +751,34 @@ export class Rfc64CatalogMethods extends DKGAgentBase { }, readKaBundleByDigest: persistence.kaBundles.readKaBundleByDigest, createReconciler: (clients: Readonly) => { + const chainConfig = this.config.chainConfig; + const finalizedVmPrecommit = createRfc64FinalizedVmAgentPrecommitV1({ + acceptedPolicySnapshotForCatalogScope: (scope) => + this.requireRfc64PublicCatalogServiceV1() + .acceptedPolicySnapshotForCatalogScope(scope), + rpcEndpoints: chainConfig === undefined + ? null + : resolveRpcUrls(chainConfig.rpcUrl, chainConfig.rpcUrls), + getOnChainContextGraphId: (contextGraphId, signal) => + this.getContextGraphOnChainId(contextGraphId, { signal }), + getEvmChainId: () => this.chain.getEvmChainId(), + getKnowledgeAssetStorageAddress: async () => { + if (typeof this.chain.getDKGKnowledgeAssetsAddress !== 'function') { + throw new Error( + 'RFC-64 finalized VM precommit requires DKGKnowledgeAssets resolution', + ); + } + return this.chain.getDKGKnowledgeAssetsAddress(); + }, + store: this.store, + }); const nativeReceiver = new Rfc64PublicCatalogNativeReceiverV1({ headTransport: clients.headTransport, contentTransport: clients.contentTransport, controlObjects: persistence.controlObjects, inventory: persistence.inventory, store: this.store, - beforeAppliedHeadCommit: (evidence, signal) => - this.runRfc64FinalizedVmPrecommitV1(evidence, signal), + beforeAppliedHeadCommit: finalizedVmPrecommit, transportTimeoutMs: clients.transportTimeoutMs, }); const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ @@ -819,77 +834,6 @@ export class Rfc64CatalogMethods extends DKGAgentBase { }); } - /** - * Promote an exact public catalog inventory only when its accepted policy is - * already anchored to finalized chain truth. The owner-signed/unregistered - * Gate-1 lane remains SWM-only until a finalized policy is accepted. - */ - private async runRfc64FinalizedVmPrecommitV1( - this: DKGAgent, - evidence: Readonly, - signal: AbortSignal, - ): Promise { - signal.throwIfAborted(); - const service = this.requireRfc64PublicCatalogServiceV1(); - const acceptedPolicy = service.acceptedPolicySnapshotForCatalogScope( - evidence.catalogScope, - ); - const { policy } = acceptedPolicy; - if (policy.source.kind !== 'finalized-chain') return; - if ( - policy.accessPolicy !== 0 - || policy.governanceChainId === null - || policy.governanceContractAddress === null - ) { - throw new Error('RFC-64 finalized VM precommit requires one public finalized policy'); - } - const chainConfig = this.config.chainConfig; - if (chainConfig === undefined) { - throw new Error('RFC-64 finalized VM precommit requires trusted RPC configuration'); - } - if (typeof this.chain.getDKGKnowledgeAssetsAddress !== 'function') { - throw new Error('RFC-64 finalized VM precommit requires DKGKnowledgeAssets resolution'); - } - - const [onChainContextGraphId, liveChainId, knowledgeAssetStorageAddress] = - await Promise.all([ - this.getContextGraphOnChainId(evidence.catalogScope.contextGraphId, { signal }), - this.chain.getEvmChainId(), - this.chain.getDKGKnowledgeAssetsAddress(), - ]); - signal.throwIfAborted(); - if (onChainContextGraphId === null) { - throw new Error('RFC-64 finalized VM precommit could not resolve the numeric context graph id'); - } - if (liveChainId.toString() !== policy.governanceChainId) { - throw new Error('RFC-64 finalized VM policy differs from the configured chain id'); - } - - const chainId = policy.governanceChainId as ChainIdV1; - const runtime = createFinalizedVmRuntimeV1({ - networkId: evidence.catalogScope.networkId, - chainId, - contextGraphStorageAddress: policy.governanceContractAddress, - knowledgeAssetStorageAddress: - knowledgeAssetStorageAddress.toLowerCase() as EvmAddressV1, - snapshot: createStrictCurrentFinalizedEvmSnapshotScopeV1({ - chainId, - endpoints: resolveRpcUrls(chainConfig.rpcUrl, chainConfig.rpcUrls), - }), - materialize: createFinalizedVmStoreMaterializerV1({ store: this.store }), - }); - await runtime({ - catalogLane: Object.freeze({ - contextGraphId: evidence.catalogScope.contextGraphId, - subGraphName: evidence.catalogScope.subGraphName, - }), - onChainContextGraphId: onChainContextGraphId as DecimalU256V1, - acceptedPolicy, - placements: Object.freeze(evidence.rows.map((row) => row.placement)), - signal, - }); - } - /** Reject a stale applied-head dedupe before trusting any durable row. */ private assertRfc64CatalogNetworkMatchesTrustedSourceV1( this: DKGAgent, diff --git a/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts b/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts new file mode 100644 index 0000000000..368581a58b --- /dev/null +++ b/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts @@ -0,0 +1,93 @@ +import type { + AuthorCatalogScopeV1, + ChainIdV1, + ContextGraphIdV1, + DecimalU256V1, + EvmAddressV1, +} from '@origintrail-official/dkg-core'; +import { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from '@origintrail-official/dkg-chain'; +import type { TripleStore } from '@origintrail-official/dkg-storage'; + +import type { AcceptedRfc64CatalogAccessSnapshotV1 } from './catalog-access-policy-v1.js'; +import type { + Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1, +} from './public-catalog-native-receiver-v1.js'; +import { createFinalizedVmRuntimeV1 } from './finalized-vm-runtime-v1.js'; +import { createFinalizedVmStoreMaterializerV1 } from './finalized-vm-store-materializer-v1.js'; + +export interface Rfc64FinalizedVmAgentPrecommitOptionsV1 { + readonly acceptedPolicySnapshotForCatalogScope: + (scope: Readonly) => AcceptedRfc64CatalogAccessSnapshotV1; + readonly rpcEndpoints: readonly string[] | null; + readonly getOnChainContextGraphId: + (contextGraphId: ContextGraphIdV1, signal: AbortSignal) => Promise; + readonly getEvmChainId: () => Promise; + readonly getKnowledgeAssetStorageAddress: () => Promise; + readonly store: TripleStore; +} + +/** + * Build the finalized-VM-specific implementation of the receiver's generic + * pre-CAS barrier. The public catalog receiver owns synchronization ordering; + * this service owns policy/RPC resolution and VM materialization only. + */ +export function createRfc64FinalizedVmAgentPrecommitV1( + options: Rfc64FinalizedVmAgentPrecommitOptionsV1, +): Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1 { + return Object.freeze(async (plan, signal): Promise => { + signal.throwIfAborted(); + const acceptedPolicy = options.acceptedPolicySnapshotForCatalogScope(plan.catalogScope); + const { policy } = acceptedPolicy; + if (policy.source.kind !== 'finalized-chain') return; + if ( + policy.accessPolicy !== 0 + || policy.governanceChainId === null + || policy.governanceContractAddress === null + ) { + throw new Error('RFC-64 finalized VM precommit requires one public finalized policy'); + } + if (options.rpcEndpoints === null || options.rpcEndpoints.length === 0) { + throw new Error('RFC-64 finalized VM precommit requires trusted RPC configuration'); + } + + const [onChainContextGraphId, liveChainId, knowledgeAssetStorageAddress] = + await Promise.all([ + options.getOnChainContextGraphId(plan.catalogScope.contextGraphId, signal), + options.getEvmChainId(), + options.getKnowledgeAssetStorageAddress(), + ]); + signal.throwIfAborted(); + if (onChainContextGraphId === null) { + throw new Error('RFC-64 finalized VM precommit could not resolve the numeric context graph id'); + } + if (liveChainId.toString() !== policy.governanceChainId) { + throw new Error('RFC-64 finalized VM policy differs from the configured chain id'); + } + + const chainId = policy.governanceChainId as ChainIdV1; + const runtime = createFinalizedVmRuntimeV1({ + networkId: plan.catalogScope.networkId, + chainId, + contextGraphStorageAddress: policy.governanceContractAddress, + knowledgeAssetStorageAddress: knowledgeAssetStorageAddress.toLowerCase() as EvmAddressV1, + snapshot: createStrictCurrentFinalizedEvmSnapshotScopeV1({ + chainId, + endpoints: options.rpcEndpoints, + }), + materialize: createFinalizedVmStoreMaterializerV1({ store: options.store }), + }); + await runtime({ + catalogLane: Object.freeze({ + contextGraphId: plan.catalogScope.contextGraphId, + subGraphName: plan.catalogScope.subGraphName, + }), + onChainContextGraphId: onChainContextGraphId as DecimalU256V1, + acceptedPolicy, + placements: Object.freeze(plan.rows.map((row) => Object.freeze({ + authorship: row.authorship, + sealBinding: row.sealBinding, + }))), + signal, + }); + }); +} diff --git a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts index 445c94a23c..04f8b8339c 100644 --- a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts @@ -54,12 +54,21 @@ export interface ComposeFinalizedVmSetRequestV1 { interface ResolvedFinalizedVmPlacementV1 { readonly authorship: ReturnType; readonly sealBinding: ReturnType; + readonly placement: Readonly; +} + +export interface FinalizedVmMaterializationPlanRowV1 { + readonly candidate: Readonly; + readonly placement: Readonly; + readonly row: Readonly; } export interface ComposedFinalizedVmSetV1 { readonly catalogLane: Readonly; readonly evidence: Readonly; readonly rows: readonly Readonly[]; + /** Exact placement/inventory join in authoritative finalized ordinal order. */ + readonly materializations: readonly Readonly[]; } export type FinalizedVmCompositionErrorCodeV1 = @@ -177,7 +186,11 @@ export function composeFinalizedVmSetV1( `catalog lane contains duplicate placement evidence for KA ${sealBinding.kaId}`, ); } - placementsByKaId.set(sealBinding.kaId, Object.freeze({ authorship, sealBinding })); + placementsByKaId.set(sealBinding.kaId, Object.freeze({ + authorship, + sealBinding, + placement: Object.freeze(placement), + })); } const scope = Object.freeze({ @@ -187,6 +200,7 @@ export function composeFinalizedVmSetV1( }); const accumulator = new FinalizedVmSetAccumulatorV1(scope); const rows: Readonly[] = []; + const materializations: Readonly[] = []; for (const candidate of inventory.rows) { const placement = placementsByKaId.get(candidate.kaId); if (placement === undefined) continue; @@ -214,6 +228,11 @@ export function composeFinalizedVmSetV1( } satisfies FinalizedVmSetRowV1); accumulator.append(row); rows.push(row); + materializations.push(Object.freeze({ + candidate, + placement: placement.placement, + row, + })); } if (placementsByKaId.size !== 0) { const [missingKaId] = placementsByKaId.keys(); @@ -227,6 +246,7 @@ export function composeFinalizedVmSetV1( catalogLane, evidence: accumulator.finalize(), rows: Object.freeze(rows), + materializations: Object.freeze(materializations), }); } @@ -309,10 +329,10 @@ function snapshotPlacement(input: unknown, index: number): FinalizedVmPlacementE `finalized VM placement ${index}`, 'finalized-vm-composition-placement', ); - return { + return Object.freeze({ authorship: record.authorship as VerifiedAuthorCatalogRowAuthorshipV1, sealBinding: record.sealBinding as VerifiedCatalogSealBindingV1, - }; + }); } function snapshotDenseArray( diff --git a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts index cb54fd85b0..22f932e4c0 100644 --- a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts @@ -9,7 +9,6 @@ import { assertSubGraphNameV1, canonicalizeContextGraphPolicyPayloadV1, parseCanonicalContextGraphPolicyPayloadV1, - readVerifiedCatalogSealBindingV1, type ChainIdV1, type ContextGraphIdV1, type DecimalU256V1, @@ -167,17 +166,10 @@ interface RuntimeRequestSnapshotV1 { readonly signal: AbortSignal; } -interface PreparedMaterializationV1 { - readonly candidate: Readonly; - readonly placement: Readonly; - readonly row: Readonly; -} - interface VerifiedSnapshotV1 { readonly finalizedContextGraph: Readonly; readonly inventory: Readonly; readonly composed: Readonly; - readonly materializations: readonly PreparedMaterializationV1[]; } /** @@ -244,13 +236,12 @@ export function createFinalizedVmRuntimeV1( finalizedContextGraph, inventory, composed, - materializations: prepareMaterializations(inventory, composed, request.placements), }); }, ); const receipts: Readonly[] = []; - for (const prepared of verified.materializations) { + for (const prepared of verified.composed.materializations) { request.signal.throwIfAborted(); let untrustedReceipt: FinalizedVmMaterializationReceiptV1; try { @@ -445,33 +436,6 @@ function assertExactAnchor( } } -function prepareMaterializations( - inventory: Readonly, - composed: Readonly, - placements: readonly FinalizedVmPlacementEvidenceV1[], -): readonly PreparedMaterializationV1[] { - const placementByKaId = new Map(); - for (const placement of placements) { - const binding = readVerifiedCatalogSealBindingV1(placement.sealBinding); - placementByKaId.set(binding.kaId, placement); - } - const candidatesByOrdinal = new Map(inventory.rows.map((candidate) => [ - candidate.ordinal, - candidate, - ])); - return Object.freeze(composed.rows.map((row) => { - const candidate = candidatesByOrdinal.get(row.ordinal); - const placement = candidate === undefined ? undefined : placementByKaId.get(candidate.kaId); - if (candidate === undefined || placement === undefined) { - fail( - 'finalized-vm-runtime-anchor', - `composed ordinal ${row.ordinal} lost its verified chain/catalog join`, - ); - } - return Object.freeze({ candidate, placement, row }); - })); -} - function snapshotReceipt( input: unknown, candidate: Readonly, diff --git a/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts b/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts index d2237ffdd3..62d98be8d0 100644 --- a/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts @@ -16,7 +16,7 @@ import { } from '@origintrail-official/dkg-storage'; import { computeFlatKCRootV10, - generateGraphKnowledgeAssetMetadata, + generateRfc64FinalizedGraphKnowledgeAssetMetadata, } from '@origintrail-official/dkg-publisher'; import { ethers } from 'ethers'; @@ -29,8 +29,6 @@ import type { FinalizedVmMaterializerV1, } from './finalized-vm-runtime-v1.js'; -const DKG = 'http://dkg.io/ontology/'; -const XSD_INTEGER = 'http://www.w3.org/2001/XMLSchema#integer'; const POST_READ_DIGEST_DOMAIN_V1 = ethers.toUtf8Bytes( 'OT-RFC-64:finalized-vm-post-read:v1\0', ); @@ -114,23 +112,31 @@ export function createFinalizedVmStoreMaterializerV1( request.signal.throwIfAborted(); const metaGraph = contextGraphMetaUri(request.catalogLane.contextGraphId); - const metadataQuads = locallyAuthenticatedConfirmedMetadata({ + const timestamp = new Date(seal.assertionFinalizedAt); + if (!Number.isFinite(timestamp.getTime())) { + throw new Error('finalized VM seal timestamp is invalid'); + } + const metadataQuads = generateRfc64FinalizedGraphKnowledgeAssetMetadata({ contextGraphId: request.catalogLane.contextGraphId, ual: request.candidate.ual, + merkleRoot: ethers.getBytes(request.candidate.assertionRoot), + publisherPeerId: 'rfc64-finalized-catalog-v1', + accessPolicy: 'public', + allowedPeers: [], + timestamp, assertionVersion: request.candidate.assertionVersion, - assertionRoot: request.candidate.assertionRoot, authorAddress: binding.authorAddress, publicTripleCount, privateTripleCount, - privateMerkleRoot, - vmGraph, - metaGraph, - subGraphName, - kaId: request.candidate.kaId, - finalizedBlockNumber: boundedMaterializedBlockNumber( - request.candidate.finalizedBlockNumber, - ), - finalizedAt: seal.assertionFinalizedAt, + ...(privateMerkleRoot ? { privateMerkleRoot } : {}), + assertionGraph: vmGraph, + ...(subGraphName ? { subGraphName } : {}), + }, { + batchId: BigInt(request.candidate.kaId), + materializedVersion: { + blockNumber: boundedMaterializedBlockNumber(request.candidate.finalizedBlockNumber), + txIndex: 0, + }, }); const asset = Object.freeze({ contextGraphId: request.catalogLane.contextGraphId, @@ -179,62 +185,6 @@ export function createFinalizedVmStoreMaterializerV1( }); } -function locallyAuthenticatedConfirmedMetadata(input: { - readonly contextGraphId: string; - readonly ual: string; - readonly assertionVersion: string; - readonly assertionRoot: Digest32V1; - readonly authorAddress: string; - readonly publicTripleCount: number; - readonly privateTripleCount: number; - readonly privateMerkleRoot?: Uint8Array; - readonly vmGraph: string; - readonly metaGraph: string; - readonly subGraphName?: string; - readonly kaId: string; - readonly finalizedBlockNumber: string; - readonly finalizedAt: string; -}): readonly Quad[] { - const timestamp = new Date(input.finalizedAt); - if (!Number.isFinite(timestamp.getTime())) { - throw new Error('finalized VM seal timestamp is invalid'); - } - const tentative = generateGraphKnowledgeAssetMetadata({ - ual: input.ual, - contextGraphId: input.contextGraphId, - merkleRoot: ethers.getBytes(input.assertionRoot), - publisherPeerId: 'rfc64-finalized-catalog-v1', - accessPolicy: 'public', - allowedPeers: [], - timestamp, - ...(input.subGraphName ? { subGraphName: input.subGraphName } : {}), - authorAddress: input.authorAddress, - assertionVersion: input.assertionVersion, - publicTripleCount: input.publicTripleCount, - ...(input.privateMerkleRoot ? { privateMerkleRoot: input.privateMerkleRoot } : {}), - privateTripleCount: input.privateTripleCount, - assertionGraph: input.vmGraph, - }, 'tentative'); - const confirmed = tentative.map((quad) => quad.predicate === `${DKG}status` - ? Object.freeze({ ...quad, object: '"confirmed"' }) - : Object.freeze({ ...quad })); - confirmed.push( - Object.freeze({ - subject: input.ual, - predicate: `${DKG}batchId`, - object: `"${input.kaId}"^^<${XSD_INTEGER}>`, - graph: input.metaGraph, - }), - Object.freeze({ - subject: input.ual, - predicate: `${DKG}materializedVersion`, - object: `"${input.finalizedBlockNumber}:0"`, - graph: input.metaGraph, - }), - ); - return Object.freeze(confirmed); -} - function boundedTripleCount(value: string, label: string): number { const parsed = BigInt(value); if ( @@ -246,12 +196,12 @@ function boundedTripleCount(value: string, label: string): number { return Number(parsed); } -function boundedMaterializedBlockNumber(value: string): string { +function boundedMaterializedBlockNumber(value: string): number { const parsed = BigInt(value); if (parsed < 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) { throw new RangeError('finalized block number exceeds the VM metadata ordering domain'); } - return parsed.toString(10); + return Number(parsed); } function assertProjectionRoot( diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 5258c36668..a19799e70a 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -76,7 +76,6 @@ import { type VerifiedAuthorCatalogRowAuthorshipV1, type VerifiedAuthorCatalogRowAuthorshipSnapshotV1, } from './catalog-row-authorship.js'; -import type { FinalizedVmPlacementEvidenceV1 } from './finalized-vm-composer-v1.js'; import type { Rfc64ControlObjectOperationsV1 } from './control-object-store-v1.js'; import type { AppliedCatalogHeadSnapshotV1, @@ -129,27 +128,33 @@ export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { >; readonly store: TripleStore; /** - * Final fail-closed semantic admission step. It runs after the exact SWM + * Final fail-closed same-process barrier. It runs after the exact SWM * post-read and before the durable applied-head CAS. A rejection leaves the - * head unapplied so a later synchronization can idempotently repair both - * SWM and any partially materialized VM rows. + * head unapplied so a later synchronization can idempotently repair SWM and + * any consumer-specific side effects performed by an earlier attempt. */ - readonly beforeAppliedHeadCommit?: Rfc64PublicCatalogNativeFinalizedVmPrecommitHandlerV1; + readonly beforeAppliedHeadCommit?: Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1; readonly transportTimeoutMs?: number; } -/** Exact same-process evidence admitted to finalized VM composition. */ -export interface Rfc64PublicCatalogNativeFinalizedVmPrecommitV1 { +/** Process-local verified row capabilities withheld from serializable receiver evidence. */ +export interface Rfc64PublicCatalogNativePrecommitRowPlanV1 { + readonly authorship: VerifiedAuthorCatalogRowAuthorshipV1; + readonly sealBinding: VerifiedCatalogSealBindingV1; +} + +/** Generic same-process barrier plan executed after semantic post-read and before head CAS. */ +export interface Rfc64PublicCatalogNativeBeforeAppliedHeadCommitPlanV1 { readonly catalogScope: Readonly; readonly catalogHeadDigest: Digest32V1; readonly inventoryDigest: Digest32V1; /** Strictly increasing by mathematical KA ID. */ - readonly rows: readonly Readonly[]; + readonly rows: readonly Readonly[]; } -export interface Rfc64PublicCatalogNativeFinalizedVmPrecommitHandlerV1 { +export interface Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1 { ( - evidence: Readonly, + plan: Readonly, signal: AbortSignal, ): Promise; } @@ -167,8 +172,6 @@ export interface Rfc64PublicCatalogNativeActivationEvidenceV1 { readonly swmGraph: string; /** Exact signed delegation/head/path/bucket/row authorization closure. */ readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; - /** Process-local capabilities retained for same-process finalized VM admission. */ - readonly placement: FinalizedVmPlacementEvidenceV1; /** Exact predecessor rows absent from this head and physically deactivated before its CAS. */ readonly removedRows: readonly Readonly[]; readonly removedRowCount: number; @@ -187,8 +190,6 @@ export interface Rfc64PublicCatalogNativeActivatedRowEvidenceV1 readonly swmGraph: string; /** Exact signed delegation/head/path/bucket/row authorization closure. */ readonly authorship: VerifiedAuthorCatalogRowAuthorshipSnapshotV1; - /** Process-local capabilities retained for same-process finalized VM admission. */ - readonly placement: FinalizedVmPlacementEvidenceV1; } /** Exact evidence for one bounded multi-asset successor inventory. */ @@ -849,10 +850,6 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ...activation.evidence, swmGraph: activation.swmGraph, authorship: prepared.authorship, - placement: Object.freeze({ - authorship: prepared.authorshipCapability, - sealBinding: prepared.sealBindingCapability, - }), })); } completion = verifyRfc64PublicCatalogInventoryCompletenessV1({ @@ -907,7 +904,10 @@ export class Rfc64PublicCatalogNativeReceiverV1 { catalogScope: trustedCatalogScope, catalogHeadDigest: head.objectDigest as Digest32V1, inventoryDigest: completion.inventoryDigest, - rows: Object.freeze(activatedRows), + rows: Object.freeze(preparedRows.map((prepared) => Object.freeze({ + authorship: prepared.authorshipCapability, + sealBinding: prepared.sealBindingCapability, + }))), }), precommitSignal); throwIfAborted(precommitSignal); } catch (cause) { @@ -994,7 +994,6 @@ export class Rfc64PublicCatalogNativeReceiverV1 { activatedTripleCount: only.activatedTripleCount, swmGraph: only.swmGraph, authorship: only.authorship, - placement: only.placement, removedRows: Object.freeze(removedRows), removedRowCount: removedRows.length, appliedHeadStatus, diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index f37bd14a99..39bfebfaf1 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -118,6 +118,7 @@ async function startNativeAgent( finalizedRuntime?: Readonly<{ rpcUrl: string; chainAdapter: FinalizedVmMockChainAdapter; + initialSubscription?: ContextGraphIdV1; }>, ): Promise { const dataDir = existingDataDir @@ -147,6 +148,12 @@ async function startNativeAgent( }, }), }); + if (finalizedRuntime?.initialSubscription !== undefined) { + (agent as any).setContextGraphSubscription(finalizedRuntime.initialSubscription, { + subscribed: true, + synced: false, + }, { persist: false }); + } agents.push(agent); await agent.start(); return agent; @@ -635,20 +642,33 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { sendJsonRpcError(response, call, -32601, 'method not found'); } }); + const authorChain = new FinalizedVmMockChainAdapter(); + const receiverChain = new FinalizedVmMockChainAdapter(); + (receiverChain as any).nextContextGraphId = BigInt(ON_CHAIN_CONTEXT_GRAPH_ID); + const created = await receiverChain.createOnChainContextGraph({ + accessPolicy: 0, + publishPolicy: 1, + nameHash, + }); + expect(created.contextGraphId.toString()).toBe(ON_CHAIN_CONTEXT_GRAPH_ID); const [author, receiver] = await Promise.all([ startNativeAgent( 'vm-author', NATIVE_DEPLOYMENT, undefined, undefined, - { rpcUrl: rpc.url, chainAdapter: new FinalizedVmMockChainAdapter() }, + { rpcUrl: rpc.url, chainAdapter: authorChain }, ), startNativeAgent( 'vm-receiver', NATIVE_DEPLOYMENT, undefined, undefined, - { rpcUrl: rpc.url, chainAdapter: new FinalizedVmMockChainAdapter() }, + { + rpcUrl: rpc.url, + chainAdapter: receiverChain, + initialSubscription: CONTEXT_GRAPH_ID, + }, ), ]); const policy = finalizedPublicCatalogPolicy(); @@ -659,14 +679,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { roster: null, }); } - (receiver as any).setContextGraphSubscription(CONTEXT_GRAPH_ID, { - subscribed: true, - synced: false, - }, { persist: false }); - (receiver as any).bindContextGraphCreatedNameHashV1( - nameHash, - ON_CHAIN_CONTEXT_GRAPH_ID, - ); + await (receiver as any).chainPoller.inFlightPoll; const storeQuery = vi.spyOn((receiver as any).store, 'query'); await expect(receiver.getContextGraphOnChainId(CONTEXT_GRAPH_ID)).resolves.toBe( ON_CHAIN_CONTEXT_GRAPH_ID, diff --git a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts index 8e2c2422d5..53650b418c 100644 --- a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts @@ -124,6 +124,16 @@ describe('RFC-64 finalized VM placement composition', () => { readVerifiedAuthorCatalogRowAuthorshipV1(second.authorship).catalogRowDigest, }, ]); + expect(composed.materializations.map(({ candidate, placement, row }) => ({ + candidateOrdinal: candidate.ordinal, + placementKaId: readVerifiedAuthorCatalogRowAuthorshipV1(placement.authorship).row.kaId, + rowOrdinal: row.ordinal, + }))).toEqual([ + { candidateOrdinal: '0', placementKaId: KA_1, rowOrdinal: '0' }, + { candidateOrdinal: '1', placementKaId: KA_2, rowOrdinal: '1' }, + ]); + expect(Object.isFrozen(composed.materializations)).toBe(true); + expect(composed.materializations.every(Object.isFrozen)).toBe(true); expect(composed.evidence).toMatchObject({ rowCount: '2', highestFinalizedOrdinal: '1', diff --git a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts index 569b6715cc..07e4aa17c2 100644 --- a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts @@ -186,6 +186,19 @@ describe('RFC-64 finalized public VM runtime', () => { + `<${rfc64VmUal(1n)}> ?tx } }`, ); expect(receiptRows).toEqual({ type: 'bindings', bindings: [] }); + const canonicalFinalizationRows = await store.query( + `SELECT ?batchId ?materializedVersion WHERE { ` + + `GRAPH { ` + + `<${rfc64VmUal(1n)}> ?batchId ; ` + + ` ?materializedVersion } }`, + ); + expect(canonicalFinalizationRows).toEqual({ + type: 'bindings', + bindings: [{ + batchId: `"${rfc64VmPackKaId(1n)}"^^`, + materializedVersion: '"123:0"', + }], + }); }); }); diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 0e4121c848..35a0b60003 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -48,8 +48,9 @@ import { import { Rfc64PublicCatalogNativeReceiverV1, rfc64CatalogSignatureVariantDigestV1, - type Rfc64PublicCatalogNativeFinalizedVmPrecommitHandlerV1, + type Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1, } from '../src/rfc64/public-catalog-native-receiver-v1.js'; +import { readVerifiedAuthorCatalogRowAuthorshipV1 } from '../src/rfc64/catalog-row-authorship.js'; import { computeRfc64AppliedInventoryDigestV1, verifyRfc64PublicCatalogInventoryCompletenessV1, @@ -1201,71 +1202,106 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); }, 30_000); - it('runs finalized VM precommit on exact placement evidence before CAS and retries rejection', async () => { + it('retries a partially materialized two-row precommit before committing the head', async () => { const fixture = await setupLiveReceiver(); await fixture.bootstrap(); + await fixture.synchronize(); const compareAndSwapAppliedCatalogHeadV1 = vi.fn( fixture.receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1.bind( fixture.receiverPersistence.inventory, ), ); - const rejectingPrecommit = vi.fn(async () => { - throw new Error('simulated finalized VM materialization failure'); - }); + const materializedKaIds = new Set(); + const materializationAttempts: string[] = []; + let failSecondRow = true; + const partialPrecommit = vi.fn( + async (plan) => { + for (const row of plan.rows) { + const kaId = readVerifiedAuthorCatalogRowAuthorshipV1(row.authorship).row.kaId; + if (materializedKaIds.has(kaId)) { + materializationAttempts.push(`${kaId}:existing`); + continue; + } + if (kaId === fixture.secondRowBundle.row.kaId && failSecondRow) { + failSecondRow = false; + materializationAttempts.push(`${kaId}:failed`); + throw new Error('simulated second-row finalized VM materialization failure'); + } + materializedKaIds.add(kaId); + materializationAttempts.push(`${kaId}:materialized`); + } + }, + ); const rejectingReceiver = fixture.createReceiver({ readAppliedCatalogHeadV1: fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1.bind( fixture.receiverPersistence.inventory, ), compareAndSwapAppliedCatalogHeadV1, - }, undefined, undefined, fixture.receiverStore, rejectingPrecommit); + }, undefined, undefined, fixture.receiverStore, partialPrecommit); - await expect(fixture.synchronize( - fixture.announcement, + await expect(fixture.synchronizeAny( + fixture.multiAssetAnnouncement, rejectingReceiver, )).rejects.toMatchObject({ code: 'catalog-native-receiver-activation', message: expect.stringContaining('finalized VM precommit rejected'), }); - expect(rejectingPrecommit).toHaveBeenCalledOnce(); - const [rejectedEvidence, rejectedSignal] = rejectingPrecommit.mock.calls[0]!; - expect(rejectedEvidence).toMatchObject({ + expect(partialPrecommit).toHaveBeenCalledOnce(); + const [rejectedPlan, rejectedSignal] = partialPrecommit.mock.calls[0]!; + expect(rejectedPlan).toMatchObject({ catalogScope: fixture.scope, - catalogHeadDigest: fixture.successor.head.objectDigest, - rows: [{ - kaId: fixture.rowBundle.row.kaId, - kaUal: fixture.rowBundle.kaUal, - }], + catalogHeadDigest: fixture.multiAssetSuccessor.head.objectDigest, }); - expect(Object.isFrozen(rejectedEvidence)).toBe(true); - expect(Object.isFrozen(rejectedEvidence.rows)).toBe(true); - expect(Object.isFrozen(rejectedEvidence.rows[0]?.placement)).toBe(true); + expect(rejectedPlan.rows.map((row) => + readVerifiedAuthorCatalogRowAuthorshipV1(row.authorship).row.kaId)).toEqual([ + fixture.rowBundle.row.kaId, + fixture.secondRowBundle.row.kaId, + ]); + expect(rejectedPlan.rows.every((row) => !('placement' in row))).toBe(true); + expect(Object.isFrozen(rejectedPlan)).toBe(true); + expect(Object.isFrozen(rejectedPlan.rows)).toBe(true); + expect(rejectedPlan.rows.every(Object.isFrozen)).toBe(true); expect(rejectedSignal).toBeInstanceOf(AbortSignal); + expect(materializationAttempts).toEqual([ + `${fixture.rowBundle.row.kaId}:materialized`, + `${fixture.secondRowBundle.row.kaId}:failed`, + ]); + expect(materializedKaIds).toEqual(new Set([fixture.rowBundle.row.kaId])); expect(compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( fixture.scopeDigest, AUTHOR, - )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); - await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(32); - const acceptingPrecommit = vi.fn(async () => {}); - const repaired = await fixture.synchronize( - fixture.announcement, + const repaired = await fixture.synchronizeAny( + fixture.multiAssetAnnouncement, fixture.createReceiver( fixture.receiverPersistence.inventory, undefined, undefined, fixture.receiverStore, - acceptingPrecommit, + partialPrecommit, ), ); - expect(acceptingPrecommit).toHaveBeenCalledOnce(); + expect(partialPrecommit).toHaveBeenCalledTimes(2); + expect(materializationAttempts).toEqual([ + `${fixture.rowBundle.row.kaId}:materialized`, + `${fixture.secondRowBundle.row.kaId}:failed`, + `${fixture.rowBundle.row.kaId}:existing`, + `${fixture.secondRowBundle.row.kaId}:materialized`, + ]); + expect(materializedKaIds).toEqual(new Set([ + fixture.rowBundle.row.kaId, + fixture.secondRowBundle.row.kaId, + ])); expect(repaired.appliedHeadStatus).toBe('applied'); expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( fixture.scopeDigest, AUTHOR, - )?.currentCatalogHeadDigest).toBe(fixture.successor.head.objectDigest); - await expect(fixture.receiverStore.countQuads()).resolves.toBe(16); + )?.currentCatalogHeadDigest).toBe(fixture.multiAssetSuccessor.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(32); }, 30_000); }); @@ -1694,7 +1730,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { fetchKaBundle: receiverBundleFetch, }, store: TripleStore = receiverStore, - beforeAppliedHeadCommit?: Rfc64PublicCatalogNativeFinalizedVmPrecommitHandlerV1, + beforeAppliedHeadCommit?: Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1, ) => new Rfc64PublicCatalogNativeReceiverV1({ headTransport: { fetchCatalogHead: receiverHeadFetch }, contentTransport, diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index c44350641c..e5c91a985c 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -75,7 +75,7 @@ export { type ValidationResult, type ValidationOptions, } from './validation.js'; -export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; +export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, generateRfc64FinalizedGraphKnowledgeAssetMetadata, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; export { pruneSupersededAgentRegistryMeta, insertBoundedAgentRegistryMeta } from './agent-registry-meta-retention.js'; export { DKGPublisher, diff --git a/packages/publisher/src/metadata.ts b/packages/publisher/src/metadata.ts index 86775d462e..ac25a009f2 100644 --- a/packages/publisher/src/metadata.ts +++ b/packages/publisher/src/metadata.ts @@ -332,6 +332,50 @@ export function generateGraphKnowledgeAssetMetadata( status: 'tentative' | 'confirmed', provenance?: OnChainProvenance, ): Quad[] { + const { scope, metaGraph, quads } = generateGraphKnowledgeAssetMetadataBase(meta); + if (status === 'confirmed') { + if (!provenance) { + throw new Error('Confirmed graph-scoped KA metadata requires on-chain provenance'); + } + quads.push(...generateConfirmedMetadata(scope.ual, meta.contextGraphId, provenance)); + } else { + quads.push(mq(scope.ual, `${DKG}status`, lit('tentative'), metaGraph)); + } + return quads; +} + +/** + * Canonical confirmed metadata for RFC-64 finalized-chain materialization. + * The finalized inventory supplies a KA id and chain ordering point but no + * publication transaction hash, so this deliberately emits no synthetic + * transactionHash statement. + */ +export function generateRfc64FinalizedGraphKnowledgeAssetMetadata( + meta: GraphKnowledgeAssetMetadata, + confirmation: Readonly<{ + batchId: bigint; + materializedVersion: MaterializedVersion; + }>, +): Quad[] { + if (confirmation.batchId < 0n) { + throw new Error('RFC-64 finalized graph metadata batchId must be non-negative'); + } + const { scope, metaGraph, quads } = generateGraphKnowledgeAssetMetadataBase(meta); + quads.push( + getConfirmedStatusQuad(scope.ual, meta.contextGraphId), + mq(scope.ual, `${DKG}batchId`, intLit(confirmation.batchId), metaGraph), + materializedVersionQuad(metaGraph, scope.ual, confirmation.materializedVersion), + ); + return quads; +} + +function generateGraphKnowledgeAssetMetadataBase( + meta: GraphKnowledgeAssetMetadata, +): Readonly<{ + scope: ReturnType; + metaGraph: string; + quads: Quad[]; +}> { const scope = createGraphKnowledgeAssetScope(meta.ual, meta.assertionVersion); if (!Number.isSafeInteger(meta.publicTripleCount) || meta.publicTripleCount < 0) { throw new Error(`Invalid graph-scoped KA public triple count: ${meta.publicTripleCount}`); @@ -365,15 +409,7 @@ export function generateGraphKnowledgeAssetMetadata( mq(scope.ual, `${DKG}privateMerkleRoot`, lit(toHex(meta.privateMerkleRoot)), metaGraph), ); } - if (status === 'confirmed') { - if (!provenance) { - throw new Error('Confirmed graph-scoped KA metadata requires on-chain provenance'); - } - quads.push(...generateConfirmedMetadata(scope.ual, meta.contextGraphId, provenance)); - } else { - quads.push(mq(scope.ual, `${DKG}status`, lit('tentative'), metaGraph)); - } - return quads; + return { scope, metaGraph, quads }; } export interface ConfirmedGraphKnowledgeAssetMetadataEnvelope { From c8ad31469a5c34865ee60029eb03ed380e8692c6 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 14:42:07 +0200 Subject: [PATCH 258/292] test(devnet): prove finalized public VM across processes --- .../README.md | 12 + .../adapter-process.ts | 346 +++++++++++- .../agent-child.ts | 5 + .../launch-public-vm.ts | 19 + .../package.json | 1 + .../public-vm.ts | 514 ++++++++++++++++++ .../two-agent-harness.ts | 8 + 7 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/launch-public-vm.ts create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/public-vm.ts diff --git a/devnet/rfc64-gate2-multi-asset-completeness/README.md b/devnet/rfc64-gate2-multi-asset-completeness/README.md index e810d81f52..d44f902a13 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/README.md +++ b/devnet/rfc64-gate2-multi-asset-completeness/README.md @@ -82,3 +82,15 @@ node --experimental-strip-types src/cli/verify.ts raw.json > verdict.json Two identical generator invocations must produce byte-identical raw artifacts; two verifier invocations over them must produce byte-identical verdicts. + +## M2 public finalized-VM process proof + +`pnpm live:public-vm` clean-builds the exact repository HEAD, then starts an +author and receiver as distinct real `DKGAgent` OS processes. The receiver +learns numeric Context Graph id `14` from a production `ContextGraphCreated` +poller event, receives one policy-bound public catalog successor over the +production router, reads one finalized on-chain inventory snapshot, writes the +exact projection to VM, and only then commits the catalog head. The emitted +`artifacts/m2-public-vm-result.json` proves exact projection/count, confirmed +metadata, no synthetic transaction hash, durable head equality, peer/process +identity separation, and the clean-build runtime manifest. diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts index a3d878ed34..c50b083a70 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -1,4 +1,6 @@ import { mkdir, open, readFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { join, resolve } from 'node:path'; import process from 'node:process'; import { createInterface } from 'node:readline'; @@ -10,7 +12,10 @@ import { produceDirectAuthorCatalogIssuerDelegationV1, produceEmptyAuthorCatalogGenesisV1, } from '@origintrail-official/dkg-agent'; -import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { + MockChainAdapter, + verifyControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; import { assertAssertionCoordinateV1, assertAuthorCatalogScopeV1, @@ -47,6 +52,8 @@ const role = process.argv[2]; const dataDirInput = process.env.DKG_RFC64_GATE2_ADAPTER_DATA_DIR; const masterKeyHex = process.env.DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX; const runtimeBuildManifestDigest = process.env.DKG_RFC64_GATE2_RUNTIME_MANIFEST_DIGEST; +const finalizedVmConfigInput = process.env.DKG_RFC64_GATE2_FINALIZED_VM_CONFIG; +const networkChainIdInput = process.env.DKG_RFC64_GATE2_NETWORK_CHAIN_ID; if (role !== 'author' && role !== 'receiver') throw new Error('adapter role is required'); if (!dataDirInput) throw new Error('DKG_RFC64_GATE2_ADAPTER_DATA_DIR is required'); if (!masterKeyHex || !/^[0-9a-f]{64}$/u.test(masterKeyHex)) { @@ -64,9 +71,54 @@ const RFC64_GATE2_DEPLOYMENT = Object.freeze({ assertedAtKav10Address: '0x4444444444444444444444444444444444444444', }); let agent: DKGAgent | undefined; +let finalizedVmRpcServer: Server | undefined; let stopping = false; let commandTail = Promise.resolve(); +const FINALIZED_VM_CG = new ethers.Interface([ + 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', + 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', + 'function isContextGraphActive(uint256 contextGraphId) view returns (bool)', + 'function getContextGraphKaCount(uint256 contextGraphId) view returns (uint256)', + 'function getContextGraphKaAt(uint256 contextGraphId, uint256 ordinal) view returns (uint256)', +]); +const FINALIZED_VM_KA = new ethers.Interface([ + 'function getKnowledgeAssetUpdateContext(uint256 id) view returns (uint256 merkleRootsCount, uint256 minted, uint88 byteSize, uint40 endEpoch, uint96 tokenAmount, bool isImmutable, uint32 merkleLeafCount)', + 'function getLatestMerkleRoot(uint256 id) view returns (bytes32)', + 'function getLatestMerkleRootAuthor(uint256 id) view returns (address)', + 'function getLatestMerkleRootPublisher(uint256 id) view returns (address)', +]); +const FINALIZED_VM_BLOCK_HASH = `0x${'77'.repeat(32)}`; +const FINALIZED_VM_ZERO_ADDRESS = ethers.ZeroAddress.toLowerCase(); + +interface FinalizedVmHarnessConfigV1 { + readonly assertionRoot: string; + readonly assertionVersion: string; + readonly authorAddress: string; + readonly contextGraphId: string; + readonly kaId: string; + readonly nameHash: string; + readonly onChainContextGraphId: string; +} + +class FinalizedVmHarnessMockChainAdapter extends MockChainAdapter { + constructor() { + super(RFC64_GATE2_DEPLOYMENT.networkId); + } + + override async getEvmChainId(): Promise { + return BigInt(RFC64_GATE2_DEPLOYMENT.assertedAtChainId); + } + + override async getKnowledgeAssetsLifecycleAddress(): Promise { + return RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address; + } + + override async getDKGKnowledgeAssetsAddress(): Promise { + return RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address; + } +} + interface Command { readonly command: string; readonly input?: unknown; @@ -117,6 +169,30 @@ async function ensureDeterministicAgentKey(): Promise { async function boot(): Promise { await ensureDeterministicAgentKey(); + const finalizedVmConfig = finalizedVmConfigInput === undefined + ? null + : parseFinalizedVmHarnessConfig(finalizedVmConfigInput); + if (finalizedVmConfig !== null && role !== 'receiver') { + throw new Error('finalized VM harness runtime is receiver-only'); + } + if (networkChainIdInput !== undefined) { + assertNetworkIdV1(networkChainIdInput); + } + if ( + finalizedVmConfig !== null + && networkChainIdInput !== RFC64_GATE2_DEPLOYMENT.networkId + ) { + throw new Error('finalized VM harness network chain id differs from its deployment'); + } + let finalizedVmRuntime: Readonly<{ + readonly chainAdapter: FinalizedVmHarnessMockChainAdapter; + readonly rpcUrl: string; + }> | null = null; + if (finalizedVmConfig !== null) { + finalizedVmRuntime = await startFinalizedVmHarnessRuntime(finalizedVmConfig); + } + const networkChainAdapter = finalizedVmRuntime?.chainAdapter + ?? (networkChainIdInput === undefined ? undefined : new MockChainAdapter(networkChainIdInput)); const created = await DKGAgent.create({ name: `RFC64Gate2${role}`, dataDir, @@ -131,9 +207,38 @@ async function boot(): Promise { durableSyncEnabled: false, agentProfileHeartbeatMs: 0, rfc64CatalogDeploymentProfile: RFC64_GATE2_DEPLOYMENT as never, + ...(networkChainAdapter === undefined ? {} : { chainAdapter: networkChainAdapter }), + ...(finalizedVmRuntime === null ? {} : { + chainConfig: { + rpcUrl: finalizedVmRuntime.rpcUrl, + hubAddress: '0x3333333333333333333333333333333333333333', + operationalKeys: [`0x${'12'.repeat(32)}`], + }, + }), }); agent = created; + if (finalizedVmConfig !== null) { + // Models a cleartext subscription restored before the startup chain-event + // scan. The production poller must derive the numeric id from nameHash. + (created as unknown as { + setContextGraphSubscription( + id: string, + state: Readonly<{ subscribed: boolean; synced: boolean }>, + options: Readonly<{ persist: boolean }>, + ): void; + }).setContextGraphSubscription( + finalizedVmConfig.contextGraphId, + { subscribed: true, synced: false }, + { persist: false }, + ); + } await created.start(); + if (finalizedVmConfig !== null) { + const inFlightPoll = (created as unknown as { + chainPoller?: { inFlightPoll?: Promise | null }; + }).chainPoller?.inFlightPoll; + if (inFlightPoll) await inFlightPoll; + } const tcp = created.multiaddrs.find((address) => address.includes('/tcp/')); if (tcp === undefined) throw new Error('real DKGAgent exposed no TCP multiaddr'); emit({ @@ -145,7 +250,9 @@ async function boot(): Promise { multiaddr: tcp, peerId: created.peerId, protocolVersion: GATE2_ADAPTER_PROTOCOL_VERSION, + processId: process.pid, runtimeBuildManifestDigest, + finalizedVmRuntime: finalizedVmConfig !== null, startupRepair: null, }); } @@ -290,6 +397,49 @@ async function handle(command: Command): Promise { }); return; } + case 'contextGraphOnChainIdReadback': { + requireRole('receiver'); + const input = plainRecord(command.input, 'contextGraphOnChainIdReadback input'); + const contextGraphId = requiredString( + input.contextGraphId, + 'contextGraphOnChainIdReadback.contextGraphId', + ); + assertContextGraphIdV1(contextGraphId); + const output = await currentAgent.getContextGraphOnChainId(contextGraphId); + emitOperationResult(command, output); + return; + } + case 'vmGraphReadback': { + requireRole('receiver'); + const input = plainRecord(command.input, 'vmGraphReadback input'); + const vmGraph = safeHarnessIri(input.vmGraph, 'vmGraphReadback.vmGraph'); + const metaGraph = safeHarnessIri(input.metaGraph, 'vmGraphReadback.metaGraph'); + const ual = safeHarnessIri(input.ual, 'vmGraphReadback.ual'); + const graphResult = await currentAgent.store.query( + `CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <${vmGraph}> { ?s ?p ?o } }`, + { source: 'rfc64-m2-public-vm-graph-readback' }, + ); + if (graphResult.type !== 'quads') { + throw new Error('VM graph readback did not return quads'); + } + const projection = [...graphResult.quads] + .map((quad): Quad => ({ ...quad, graph: '' })) + .sort(compareQuad); + const metadataResult = await currentAgent.store.query( + `SELECT ?p ?o WHERE { GRAPH <${metaGraph}> { <${ual}> ?p ?o } } ORDER BY ?p ?o`, + { source: 'rfc64-m2-public-vm-metadata-readback' }, + ); + if (metadataResult.type !== 'bindings') { + throw new Error('VM metadata readback did not return bindings'); + } + emitOperationResult(command, { + metadataBindings: metadataResult.bindings, + projectionNQuads: `${quadsToNQuads(projection)}\n`, + tripleCount: projection.length, + vmGraph, + }); + return; + } case 'terminalFailureReadback': { requireRole('receiver'); const input = plainRecord(command.input, 'terminalFailureReadback input'); @@ -886,6 +1036,7 @@ async function stop(exitCode: number, requestId?: string): Promise { stopping = true; try { await agent?.stop(); + await closeFinalizedVmRpcServer(); if (requestId !== undefined) { await emitAndFlush({ event: 'stopped', @@ -900,6 +1051,199 @@ async function stop(exitCode: number, requestId?: string): Promise { } } +function parseFinalizedVmHarnessConfig(input: string): Readonly { + if (Buffer.byteLength(input) > 16_384) { + throw new TypeError('finalized VM harness config exceeds 16 KiB'); + } + const parsed = plainRecord(JSON.parse(input), 'finalized VM harness config'); + const contextGraphId = requiredString(parsed.contextGraphId, 'finalizedVm.contextGraphId'); + assertContextGraphIdV1(contextGraphId); + const authorAddress = canonicalEvmAddress(parsed.authorAddress, 'finalizedVm.authorAddress'); + const assertionRoot = requiredDigest(parsed.assertionRoot, 'finalizedVm.assertionRoot'); + const assertionVersion = canonicalDecimalWire( + parsed.assertionVersion, + 'finalizedVm.assertionVersion', + ); + if (BigInt(assertionVersion) === 0n) { + throw new TypeError('finalized VM assertion version must be non-zero'); + } + const nameHash = requiredDigest(parsed.nameHash, 'finalizedVm.nameHash'); + const kaId = canonicalDecimalWire(parsed.kaId, 'finalizedVm.kaId'); + const onChainContextGraphId = canonicalDecimalWire( + parsed.onChainContextGraphId, + 'finalizedVm.onChainContextGraphId', + ); + if (BigInt(onChainContextGraphId) === 0n) { + throw new TypeError('finalized VM on-chain context graph id must be non-zero'); + } + return Object.freeze({ + assertionRoot, + assertionVersion, + authorAddress, + contextGraphId, + kaId, + nameHash, + onChainContextGraphId, + }); +} + +async function startFinalizedVmHarnessRuntime( + config: Readonly, +): Promise> { + const chainAdapter = new FinalizedVmHarnessMockChainAdapter(); + (chainAdapter as unknown as { nextContextGraphId: bigint }).nextContextGraphId = + BigInt(config.onChainContextGraphId); + const created = await chainAdapter.createOnChainContextGraph({ + accessPolicy: 0, + publishPolicy: 1, + nameHash: config.nameHash, + }); + if (created.contextGraphId.toString() !== config.onChainContextGraphId) { + throw new Error('mock chain created a different numeric context graph id'); + } + + const server = createServer(async (request, response) => { + try { + if (request.method !== 'POST') { + response.writeHead(405, { 'content-type': 'text/plain' }); + response.end('method not allowed'); + return; + } + const chunks: Buffer[] = []; + let byteLength = 0; + for await (const chunk of request) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += bytes.byteLength; + if (byteLength > 1_000_000) throw new Error('JSON-RPC request exceeds 1 MiB'); + chunks.push(bytes); + } + const call = plainRecord( + JSON.parse(Buffer.concat(chunks).toString('utf8')), + 'finalized VM JSON-RPC call', + ); + const method = requiredString(call.method, 'finalized VM JSON-RPC method'); + const params = plainArray(call.params, 'finalized VM JSON-RPC params'); + let result: unknown; + switch (method) { + case 'eth_chainId': + result = '0x4fce'; + break; + case 'eth_getBlockByNumber': + result = { number: '0x7b', hash: FINALIZED_VM_BLOCK_HASH }; + break; + case 'eth_getCode': + result = '0x6000'; + break; + case 'eth_call': + result = finalizedVmEthCallResult(params, config); + break; + default: + throw new Error(`unexpected finalized VM JSON-RPC method ${method}`); + } + sendHarnessRpcResponse(response, call.id, { result }); + } catch (error) { + sendHarnessRpcResponse(response, null, { + error: { code: -32603, message: error instanceof Error ? error.message : String(error) }, + }); + } + }); + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(0, '127.0.0.1', () => { + server.off('error', rejectListen); + resolveListen(); + }); + }); + finalizedVmRpcServer = server; + const address = server.address() as AddressInfo | null; + if (address === null) throw new Error('finalized VM JSON-RPC server has no address'); + return Object.freeze({ + chainAdapter, + rpcUrl: `http://127.0.0.1:${address.port}`, + }); +} + +function finalizedVmEthCallResult( + params: readonly unknown[], + config: Readonly, +): string { + const call = plainRecord(params[0], 'finalized VM eth_call object'); + const data = requiredString(call.data, 'finalized VM eth_call data'); + if (data === '0x') return '0x'; + const selector = data.slice(0, 10); + switch (selector) { + case FINALIZED_VM_CG.getFunction('getContextGraph')!.selector: + return FINALIZED_VM_CG.encodeFunctionResult('getContextGraph', [ + config.authorAddress, + [], + 0n, + true, + 1n, + 0, + 1, + FINALIZED_VM_ZERO_ADDRESS, + 0n, + ]); + case FINALIZED_VM_CG.getFunction('getNameHash')!.selector: + return FINALIZED_VM_CG.encodeFunctionResult('getNameHash', [config.nameHash]); + case FINALIZED_VM_CG.getFunction('isContextGraphActive')!.selector: + return FINALIZED_VM_CG.encodeFunctionResult('isContextGraphActive', [true]); + case FINALIZED_VM_CG.getFunction('getContextGraphKaCount')!.selector: + return FINALIZED_VM_CG.encodeFunctionResult('getContextGraphKaCount', [1n]); + case FINALIZED_VM_CG.getFunction('getContextGraphKaAt')!.selector: + return FINALIZED_VM_CG.encodeFunctionResult('getContextGraphKaAt', [BigInt(config.kaId)]); + case FINALIZED_VM_KA.getFunction('getKnowledgeAssetUpdateContext')!.selector: + return FINALIZED_VM_KA.encodeFunctionResult( + 'getKnowledgeAssetUpdateContext', + [BigInt(config.assertionVersion), 0n, 0n, 0n, 0n, false, 0], + ); + case FINALIZED_VM_KA.getFunction('getLatestMerkleRoot')!.selector: + return FINALIZED_VM_KA.encodeFunctionResult('getLatestMerkleRoot', [config.assertionRoot]); + case FINALIZED_VM_KA.getFunction('getLatestMerkleRootAuthor')!.selector: + return FINALIZED_VM_KA.encodeFunctionResult( + 'getLatestMerkleRootAuthor', + [config.authorAddress], + ); + case FINALIZED_VM_KA.getFunction('getLatestMerkleRootPublisher')!.selector: + return FINALIZED_VM_KA.encodeFunctionResult( + 'getLatestMerkleRootPublisher', + ['0x6666666666666666666666666666666666666666'], + ); + default: + throw new Error(`unexpected finalized VM eth_call selector ${selector}`); + } +} + +function sendHarnessRpcResponse( + response: import('node:http').ServerResponse, + id: unknown, + payload: Readonly<{ readonly result: unknown } | { readonly error: unknown }>, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id, ...payload })); +} + +async function closeFinalizedVmRpcServer(): Promise { + const server = finalizedVmRpcServer; + finalizedVmRpcServer = undefined; + if (server === undefined) return; + await new Promise((resolveClose, rejectClose) => { + server.close((error) => error ? rejectClose(error) : resolveClose()); + server.closeIdleConnections(); + }); +} + +function safeHarnessIri(value: unknown, label: string): string { + const iri = requiredString(value, label); + if (!/^did:dkg:[A-Za-z0-9:._/-]+$/u.test(iri)) { + throw new TypeError(`${label} is not a safe DKG IRI`); + } + return iri; +} + function requireAgent(): DKGAgent { if (agent === undefined) throw new Error('real DKGAgent is not ready'); return agent; diff --git a/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts b/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts index 622bbca9db..d091085e9a 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/agent-child.ts @@ -86,6 +86,11 @@ export class Gate2AgentChild { return this.options.role; } + /** Bounded child output for a parent-side protocol assertion failure. */ + diagnosticTail(): string { + return `stdout tail:\n${this.#stdout}\nstderr tail:\n${this.#stderr}`; + } + waitFor(expectedEvent: string, requestId: string | null = null): Promise { const existing = this.#events.find((event) => event.event === expectedEvent && (requestId === null || event.requestId === requestId)); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/launch-public-vm.ts b/devnet/rfc64-gate2-multi-asset-completeness/launch-public-vm.ts new file mode 100644 index 0000000000..0ec090105f --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/launch-public-vm.ts @@ -0,0 +1,19 @@ +import { resolve } from 'node:path'; + +import { readCleanRepositoryHead } from '../rfc64-persistence-lifecycle/evidence.js'; +import { + buildGate2RuntimeManifestV1, + installGate2RuntimeLaunchReceiptV1, + runGate2CleanRuntimeBuildV1, +} from './runtime-provenance.ts'; + +const repoRoot = resolve(import.meta.dirname, '../..'); +const sourceCommit = readCleanRepositoryHead(repoRoot); +runGate2CleanRuntimeBuildV1(repoRoot); +const headAfterBuild = readCleanRepositoryHead(repoRoot); +if (headAfterBuild !== sourceCommit) { + throw new Error('RFC-64 M2 source HEAD changed during the clean runtime build'); +} +const manifest = buildGate2RuntimeManifestV1(repoRoot, sourceCommit); +installGate2RuntimeLaunchReceiptV1({ manifest, sourceCommit }); +await import('./public-vm.ts'); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/package.json b/devnet/rfc64-gate2-multi-asset-completeness/package.json index 16934d9ac3..0c339352ce 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/package.json +++ b/devnet/rfc64-gate2-multi-asset-completeness/package.json @@ -18,6 +18,7 @@ "generate": "node --experimental-strip-types src/cli/generate.ts", "verify": "node --experimental-strip-types src/cli/verify.ts", "live:generate": "node --import tsx run.ts", + "live:public-vm": "node --import tsx launch-public-vm.ts", "live:verify": "node --import tsx verify-live.ts" } } diff --git a/devnet/rfc64-gate2-multi-asset-completeness/public-vm.ts b/devnet/rfc64-gate2-multi-asset-completeness/public-vm.ts new file mode 100644 index 0000000000..d4add2d712 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/public-vm.ts @@ -0,0 +1,514 @@ +import { rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; + +import { + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + MemoryLayer, + assertCanonicalGraphScopedAuthorSealV1, + buildAuthorAttestationTypedData, + computeAuthorCatalogScopeDigestV1, + contextGraphLayerUri, + contextGraphMetaUri, + type CanonicalGraphScopedAuthorSealV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +import { + atomicWriteExactBytes, + readCleanRepositoryHead, +} from '../rfc64-persistence-lifecycle/evidence.js'; +import { + ChildProcessRegistry, + cleanupPreservingPrimaryFailure, +} from '../rfc64-persistence-lifecycle/process-lifecycle.js'; +import { type Gate2AgentEvent } from './agent-child.js'; +import { canonicalDocument, type CanonicalValue } from './src/canonical.ts'; +import { + assertGate2ExecutedRuntimeMatchesBuildV1, + consumeGate2RuntimeLaunchReceiptV1, + type Gate2ExecutedRuntimeManifestV1, +} from './runtime-provenance.ts'; +import { + assertGate2HarnessReadyV1, + assertGate2HarnessSourceStateV1, + connectGate2HarnessAgentsV1, + createGate2TwoAgentDataDirsV1, + spawnGate2HarnessAgentV1, +} from './two-agent-harness.ts'; + +const REPO_ROOT = resolve(import.meta.dirname, '../..'); +const DEFAULT_ARTIFACT = join(import.meta.dirname, 'artifacts/m2-public-vm-result.json'); +const NETWORK_ID = 'otp:20430'; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/m2-public-vm-process'; +const ON_CHAIN_CONTEXT_GRAPH_ID = '14'; +const CG_STORAGE = '0x3333333333333333333333333333333333333333'; +const KA_STORAGE = '0x4444444444444444444444444444444444444444'; +const AUTHOR_PRIVATE_KEY = `0x${'64'.repeat(32)}`; +const AUTHOR_WALLET = new ethers.Wallet(AUTHOR_PRIVATE_KEY); +const AUTHOR_ADDRESS = AUTHOR_WALLET.address.toLowerCase(); +const KA_NUMBER = 7n; +const KA_ID = ((BigInt(AUTHOR_ADDRESS) << 96n) | KA_NUMBER).toString(); +const KA_UAL = `did:dkg:${NETWORK_ID}/${AUTHOR_ADDRESS}/${KA_NUMBER}`; +const ASSERTION_VERSION = '1'; +const ASSERTION_ROOT = + '0x8d7a7be6029c98db1a7300bf47008c90084d5de4a3b97a68c043c0ea4773609f'; +const POLICY_DIGEST = `0x${'cd'.repeat(32)}`; +const PROJECTION_NQUADS = + ' ' + + '"42"^^ .\n' + + ' "Alice" .\n'; +const DEPLOYMENT = Object.freeze({ + networkId: NETWORK_ID, + assertedAtChainId: '20430', + assertedAtKav10Address: KA_STORAGE, +}); +const POLICY = Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: CG_STORAGE, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'finalized-chain', + chainId: '20430', + contractAddress: CG_STORAGE, + blockNumber: '120', + blockHash: `0x${'76'.repeat(32)}`, + }, + effectiveAt: '1773900000000', + issuedAt: '1773900000000', +}); + +await execute(); + +async function execute(): Promise { + const launchReceipt = consumeGate2RuntimeLaunchReceiptV1(); + const headBefore = assertGate2HarnessSourceStateV1( + REPO_ROOT, + launchReceipt.sourceCommit, + launchReceipt.manifest, + ); + const dataDirs = createGate2TwoAgentDataDirsV1('m2-public-vm'); + const children = new ChildProcessRegistry(20_000); + let operationFailed = true; + let primaryFailure: unknown; + try { + const author = spawnGate2HarnessAgentV1({ + role: 'author', + dataDir: dataDirs.author, + networkChainId: NETWORK_ID, + registry: children, + repoRoot: REPO_ROOT, + runtimeManifestDigest: launchReceipt.manifest.manifestDigest, + sourceCommit: headBefore, + }); + const finalizedVmConfigJson = JSON.stringify({ + assertionRoot: ASSERTION_ROOT, + assertionVersion: ASSERTION_VERSION, + authorAddress: AUTHOR_ADDRESS, + contextGraphId: CONTEXT_GRAPH_ID, + kaId: KA_ID, + nameHash: ethers.keccak256(ethers.toUtf8Bytes(CONTEXT_GRAPH_ID)).toLowerCase(), + onChainContextGraphId: ON_CHAIN_CONTEXT_GRAPH_ID, + }); + const receiver = spawnGate2HarnessAgentV1({ + role: 'receiver', + dataDir: dataDirs.receiver, + finalizedVmConfigJson, + networkChainId: NETWORK_ID, + registry: children, + repoRoot: REPO_ROOT, + runtimeManifestDigest: launchReceipt.manifest.manifestDigest, + sourceCommit: headBefore, + }); + const [authorReady, receiverReady] = await Promise.all([ + author.waitFor('ready'), + receiver.waitFor('ready'), + ]); + assertGate2HarnessReadyV1(authorReady, 'author', launchReceipt.manifest.manifestDigest); + assertGate2HarnessReadyV1(receiverReady, 'receiver', launchReceipt.manifest.manifestDigest); + exact(authorReady.finalizedVmRuntime, false, 'author finalized VM runtime'); + exact(receiverReady.finalizedVmRuntime, true, 'receiver finalized VM runtime'); + requireCondition(authorReady.peerId !== receiverReady.peerId, 'peer identities are distinct'); + requireCondition( + authorReady.processId !== receiverReady.processId + && authorReady.processId !== process.pid + && receiverReady.processId !== process.pid, + 'author, receiver, and harness use distinct OS processes', + ); + await connectGate2HarnessAgentsV1(author, receiver, authorReady, receiverReady, 'm2-public-vm'); + + const acceptedSnapshot = { policy: POLICY, policyDigest: POLICY_DIGEST, roster: null }; + const [authorPolicy, receiverPolicy] = await Promise.all([ + author.request( + 'acceptPolicySnapshot', + 'author-finalized-policy-v1', + 'operation-completed', + acceptedSnapshot, + ), + receiver.request( + 'acceptPolicySnapshot', + 'receiver-finalized-policy-v1', + 'operation-completed', + acceptedSnapshot, + ), + ]); + exact(outputRecord(authorPolicy, 'author policy').policyDigest, POLICY_DIGEST, 'author policy'); + exact( + outputRecord(receiverPolicy, 'receiver policy').policyDigest, + POLICY_DIGEST, + 'receiver policy', + ); + + const scope = Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: CG_STORAGE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR_ADDRESS, + era: '0', + bucketCount: '1', + }); + const genesis = outputRecord(await author.request( + 'publishCatalogGenesis', + 'm2-public-vm-genesis-v1', + 'operation-completed', + { + scope, + authorPrivateKey: AUTHOR_PRIVATE_KEY, + issuedAt: '1773900000000', + catalogIssuerDelegationEffectiveAt: '1773899999000', + catalogIssuerDelegationExpiresAt: '1774000000000', + }, + ), 'genesis'); + await announceAndDrain( + author, + receiver, + record(genesis.announcement, 'genesis announcement'), + string(receiverReady.peerId, 'receiver peer id'), + 'genesis', + ); + + const successor = outputRecord(await author.request( + 'publishCatalogExactSetSuccessor', + 'm2-public-vm-successor-v1', + 'operation-completed', + { + previousHead: stagedHead(genesis, 'genesis'), + authorPrivateKey: AUTHOR_PRIVATE_KEY, + catalogIssuerAuthorization: record( + genesis.catalogIssuerAuthorization, + 'genesis authorization', + ), + assets: [{ + assertionCoordinate: 'm2-public-vm-process-object', + projectionNQuads: PROJECTION_NQUADS, + seal: await authorSeal(), + }], + deployment: DEPLOYMENT, + issuedAt: '1773900001000', + }, + ), 'successor'); + const successorDigest = digest(successor.headObjectDigest, 'successor head'); + await announceAndDrain( + author, + receiver, + record(successor.announcement, 'successor announcement'), + string(receiverReady.peerId, 'receiver peer id'), + 'successor', + ); + + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope as never); + const applied = outputRecord(await receiver.request( + 'appliedHeadReadback', + 'm2-public-vm-applied-v1', + 'operation-completed', + { catalogScopeDigest: scopeDigest, authorAddress: AUTHOR_ADDRESS }, + ), 'applied head'); + const terminalFailure = await receiver.request( + 'terminalFailureReadback', + 'm2-public-vm-failure-v1', + 'operation-completed', + { catalogHeadDigest: successorDigest }, + ); + if (applied.currentCatalogHeadDigest !== successorDigest) { + throw new Error( + `durable applied head differs: ${JSON.stringify(applied.currentCatalogHeadDigest)}` + + ` != ${JSON.stringify(successorDigest)}; terminal failure: ` + + `${JSON.stringify(terminalFailure.output)}\n` + + receiver.diagnosticTail(), + ); + } + exact(applied.inventoryRowCount, '1', 'durable inventory row count'); + + const synchronization = outputRecord(await receiver.request( + 'exactInventoryReadback', + 'm2-public-vm-inventory-v1', + 'operation-completed', + { catalogHeadDigest: successorDigest }, + ), 'synchronization evidence'); + exact(synchronization.catalogHeadDigest, successorDigest, 'synchronized head'); + exact(synchronization.inventoryRowCount, 1, 'synchronized inventory row count'); + exact(synchronization.appliedHeadStatus, 'applied', 'synchronized applied status'); + + const numericId = await receiver.request( + 'contextGraphOnChainIdReadback', + 'm2-public-vm-numeric-id-v1', + 'operation-completed', + { contextGraphId: CONTEXT_GRAPH_ID }, + ); + exact(numericId.output, ON_CHAIN_CONTEXT_GRAPH_ID, 'event-derived numeric context graph id'); + + const vmGraph = contextGraphLayerUri( + CONTEXT_GRAPH_ID, + MemoryLayer.VerifiableMemory, + AUTHOR_ADDRESS, + Number(KA_NUMBER), + ); + const metaGraph = contextGraphMetaUri(CONTEXT_GRAPH_ID); + const vm = outputRecord(await receiver.request( + 'vmGraphReadback', + 'm2-public-vm-readback-v1', + 'operation-completed', + { vmGraph, metaGraph, ual: KA_UAL }, + ), 'VM readback'); + exact(vm.tripleCount, 2, 'VM triple count'); + exact(vm.projectionNQuads, PROJECTION_NQUADS, 'exact VM projection'); + const metadata = array(vm.metadataBindings, 'VM metadata').map((row, index) => + record(row, `VM metadata row ${index}`)); + metadataObject(metadata, 'status', '"confirmed"'); + metadataObject( + metadata, + 'batchId', + `"${KA_ID}"^^`, + ); + metadataObject(metadata, 'materializedVersion', '"123:0"'); + requireCondition( + metadata.every((row) => !string(row.p, 'metadata predicate').endsWith('transactionHash')), + 'finalized VM metadata contains no synthetic transaction hash', + ); + + exact(terminalFailure.output, null, 'receiver terminal failure'); + + const [receiverBoundary, authorBoundary] = await Promise.all([ + receiver.stop('m2-public-vm-receiver-stop-v1'), + author.stop('m2-public-vm-author-stop-v1'), + ]); + const receiverManifest = executedManifest(receiverBoundary.event, 'receiver'); + const authorManifest = executedManifest(authorBoundary.event, 'author'); + assertGate2ExecutedRuntimeMatchesBuildV1(authorManifest, launchReceipt.manifest); + assertGate2ExecutedRuntimeMatchesBuildV1(receiverManifest, launchReceipt.manifest); + const headAfter = assertGate2HarnessSourceStateV1( + REPO_ROOT, + headBefore, + launchReceipt.manifest, + ); + + const artifact = Object.freeze({ + gate: 'OT-RFC-64 M2 public finalized VM separate-process proof', + status: 'PASS', + repository: { testedHeadCommit: headAfter, cleanBeforeAndAfter: true }, + processes: { + harnessPid: process.pid, + authorPid: authorReady.processId, + receiverPid: receiverReady.processId, + authorPeerId: authorReady.peerId, + receiverPeerId: receiverReady.peerId, + }, + policy: { digest: POLICY_DIGEST, source: 'finalized-chain' }, + chain: { + numericContextGraphId: numericId.output, + finalizedBlockNumber: '123', + finalizedBlockHash: `0x${'77'.repeat(32)}`, + }, + catalog: { + headDigest: successorDigest, + appliedInventoryDigest: applied.appliedInventoryDigest, + inventoryRowCount: applied.inventoryRowCount, + }, + verifiableMemory: { + graph: vmGraph, + tripleCount: vm.tripleCount, + exactProjection: vm.projectionNQuads, + metadataBindings: metadata, + }, + runtimeBuildManifestDigest: launchReceipt.manifest.manifestDigest, + }); + const artifactPath = process.env.DKG_RFC64_M2_PUBLIC_VM_ARTIFACT ?? DEFAULT_ARTIFACT; + const publication = atomicWriteExactBytes( + artifactPath, + new TextEncoder().encode(canonicalDocument(artifact as unknown as CanonicalValue)), + ); + process.stdout.write( + `[rfc64-m2-public-vm] PASS artifact=${artifactPath} sha256=${publication.sha256}\n`, + ); + operationFailed = false; + } catch (error) { + primaryFailure = error; + } finally { + await cleanupPreservingPrimaryFailure({ + operationFailed, + primaryFailure, + cleanup: () => children.terminateAllThenCleanup(() => { + rmSync(dataDirs.author, { force: true, recursive: true }); + rmSync(dataDirs.receiver, { force: true, recursive: true }); + }), + reportSecondaryFailure: (primary, secondary) => { + process.stderr.write( + `[rfc64-m2-public-vm] cleanup failure after ${String(primary)}: ${String(secondary)}\n`, + ); + }, + }); + } +} + +async function announceAndDrain( + author: ReturnType, + receiver: ReturnType, + announcement: Record, + receiverPeerId: string, + label: string, +): Promise { + const result = outputRecord(await author.request( + 'announce', + `${label}-announce-v1`, + 'operation-completed', + { announcement, peers: [receiverPeerId] }, + ), `${label} announce`); + if (JSON.stringify(result.failedPeers) !== '[]') { + throw new Error( + `${label} failed peers differs: ${JSON.stringify(result.failedPeers)} != []\n` + + receiver.diagnosticTail(), + ); + } + exactJson(result.announcedPeers, [receiverPeerId], `${label} announced peers`); + await receiver.request( + 'awaitReceiverIdle', + `${label}-receiver-idle-v1`, + 'receiver-idle', + ); +} + +async function authorSeal(): Promise { + const typedData = buildAuthorAttestationTypedData({ + chainId: 20_430n, + kav10Address: KA_STORAGE, + merkleRoot: ethers.getBytes(ASSERTION_ROOT), + authorAddress: AUTHOR_ADDRESS, + reservedKaId: BigInt(KA_ID), + }); + const signature = ethers.Signature.from(await AUTHOR_WALLET.signTypedData( + typedData.domain, + typedData.types, + typedData.message, + )); + const seal = { + assertionMerkleRoot: ASSERTION_ROOT, + authorAddress: AUTHOR_ADDRESS, + authorAttestationR: signature.r, + authorAttestationVS: signature.yParityAndS, + authorSchemeVersion: '1', + assertedAtChainId: '20430', + assertedAtKav10Address: KA_STORAGE, + reservedKaId: KA_ID, + assertionFinalizedAt: '2026-07-19T12:34:56.789Z', + contentScopeVersion: '2', + kaUal: KA_UAL, + assertionVersion: ASSERTION_VERSION, + publicTripleCount: '2', + privateTripleCount: '0', + privateMerkleRoot: null, + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(seal); + return seal; +} + +function stagedHead(output: Record, label: string): Record { + return { + objectDigest: digest(output.headObjectDigest, `${label} object digest`), + signatureVariantDigest: digest( + output.signatureVariantDigest, + `${label} signature variant digest`, + ), + }; +} + +function metadataObject( + rows: readonly Record[], + predicateSuffix: string, + expectedObject: string, +): void { + const matching = rows.filter((row) => + string(row.p, `metadata ${predicateSuffix} predicate`).endsWith(predicateSuffix)); + exact(matching.length, 1, `metadata ${predicateSuffix} row count`); + exact(matching[0]!.o, expectedObject, `metadata ${predicateSuffix} object`); +} + +function executedManifest(event: Gate2AgentEvent, label: string): Gate2ExecutedRuntimeManifestV1 { + return record( + event.executedRuntimeManifest, + `${label} runtime manifest`, + ) as unknown as Gate2ExecutedRuntimeManifestV1; +} + +function outputRecord(event: Gate2AgentEvent, label: string): Record { + return record(event.output, `${label} output`); +} + +function record(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} is not an object`); + } + return value as Record; +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value) || value.length > 1_024) { + throw new Error(`${label} is not a bounded array`); + } + return value; +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 16_384) { + throw new Error(`${label} is not a bounded string`); + } + return value; +} + +function digest(value: unknown, label: string): string { + const selected = string(value, label); + if (!/^0x[0-9a-f]{64}$/u.test(selected)) throw new Error(`${label} is not a digest`); + return selected; +} + +function exact(actual: unknown, expected: unknown, label: string): void { + if (actual !== expected) { + throw new Error(`${label} differs: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`); + } +} + +function exactJson(actual: unknown, expected: unknown, label: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `${label} differs: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`, + ); + } +} + +function requireCondition(condition: boolean, label: string): asserts condition { + if (!condition) throw new Error(label); +} diff --git a/devnet/rfc64-gate2-multi-asset-completeness/two-agent-harness.ts b/devnet/rfc64-gate2-multi-asset-completeness/two-agent-harness.ts index 1b478d6254..d2548ada50 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/two-agent-harness.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/two-agent-harness.ts @@ -56,6 +56,8 @@ export function assertGate2HarnessSourceStateV1( export function spawnGate2HarnessAgentV1(input: { readonly dataDir: string; + readonly finalizedVmConfigJson?: string; + readonly networkChainId?: string; readonly registry: ChildProcessRegistry; readonly repoRoot: string; readonly role: Gate2HarnessAgentRoleV1; @@ -80,6 +82,12 @@ export function spawnGate2HarnessAgentV1(input: { DKG_RFC64_GATE2_AGENT_MASTER_KEY_HEX: ROLE_MASTER_KEYS[input.role], DKG_RFC64_GATE2_RUNTIME_MANIFEST_DIGEST: input.runtimeManifestDigest, DKG_RFC64_GATE2_RUNTIME_SOURCE_COMMIT: input.sourceCommit, + ...(input.networkChainId === undefined + ? {} + : { DKG_RFC64_GATE2_NETWORK_CHAIN_ID: input.networkChainId }), + ...(input.finalizedVmConfigJson === undefined + ? {} + : { DKG_RFC64_GATE2_FINALIZED_VM_CONFIG: input.finalizedVmConfigJson }), NODE_ENV: 'production', }, }, From 79ffde59b26feb753cc2de7b1897b7398def2f3c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 15:25:24 +0200 Subject: [PATCH 259/292] fix(rfc64): close finalized VM runtime review gaps --- .../adapter-process.ts | 257 +-------------- .../finalized-vm-harness-runtime.ts | 307 ++++++++++++++++++ packages/agent/src/dkg-agent-cg-registry.ts | 14 +- packages/agent/src/dkg-agent-cg-resolve.ts | 60 +++- packages/agent/src/dkg-agent-lifecycle.ts | 41 +-- .../src/rfc64/finalized-vm-runtime-v1.ts | 222 ++++--------- ...kg-agent-native-wiring.integration.test.ts | 3 + ...64-finalized-vm-agent-precommit-v1.test.ts | 138 ++++++++ .../rfc64-finalized-vm-runtime-v1.test.ts | 64 +++- .../host-mode-key-canonicalization.test.ts | 38 ++- packages/agent/vitest.rfc64-unit-tests.ts | 1 + 11 files changed, 678 insertions(+), 467 deletions(-) create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts create mode 100644 packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts index c50b083a70..4e5e65085a 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -1,6 +1,4 @@ import { mkdir, open, readFile } from 'node:fs/promises'; -import { createServer, type Server } from 'node:http'; -import type { AddressInfo } from 'node:net'; import { join, resolve } from 'node:path'; import process from 'node:process'; import { createInterface } from 'node:readline'; @@ -46,6 +44,12 @@ import { GATE2_AGENT_EVENT_PREFIX, GATE2_REAL_DKG_AGENT_ADAPTER_ID, } from './model.js'; +import { + RFC64_GATE2_DEPLOYMENT, + parseFinalizedVmHarnessConfigV1, + startFinalizedVmHarnessRuntimeV1, + type FinalizedVmHarnessRuntimeV1, +} from './finalized-vm-harness-runtime.ts'; import { sealGate2ExecutedRuntimeManifestV1 } from './runtime-load-hook.ts'; const role = process.argv[2]; @@ -65,60 +69,11 @@ if (!runtimeBuildManifestDigest || !/^0x[0-9a-f]{64}$/u.test(runtimeBuildManifes const dataDir = resolve(dataDirInput); const pinnedMasterKeyHex = masterKeyHex; -const RFC64_GATE2_DEPLOYMENT = Object.freeze({ - networkId: 'otp:20430', - assertedAtChainId: '20430', - assertedAtKav10Address: '0x4444444444444444444444444444444444444444', -}); let agent: DKGAgent | undefined; -let finalizedVmRpcServer: Server | undefined; +let finalizedVmRuntime: Readonly | undefined; let stopping = false; let commandTail = Promise.resolve(); -const FINALIZED_VM_CG = new ethers.Interface([ - 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', - 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', - 'function isContextGraphActive(uint256 contextGraphId) view returns (bool)', - 'function getContextGraphKaCount(uint256 contextGraphId) view returns (uint256)', - 'function getContextGraphKaAt(uint256 contextGraphId, uint256 ordinal) view returns (uint256)', -]); -const FINALIZED_VM_KA = new ethers.Interface([ - 'function getKnowledgeAssetUpdateContext(uint256 id) view returns (uint256 merkleRootsCount, uint256 minted, uint88 byteSize, uint40 endEpoch, uint96 tokenAmount, bool isImmutable, uint32 merkleLeafCount)', - 'function getLatestMerkleRoot(uint256 id) view returns (bytes32)', - 'function getLatestMerkleRootAuthor(uint256 id) view returns (address)', - 'function getLatestMerkleRootPublisher(uint256 id) view returns (address)', -]); -const FINALIZED_VM_BLOCK_HASH = `0x${'77'.repeat(32)}`; -const FINALIZED_VM_ZERO_ADDRESS = ethers.ZeroAddress.toLowerCase(); - -interface FinalizedVmHarnessConfigV1 { - readonly assertionRoot: string; - readonly assertionVersion: string; - readonly authorAddress: string; - readonly contextGraphId: string; - readonly kaId: string; - readonly nameHash: string; - readonly onChainContextGraphId: string; -} - -class FinalizedVmHarnessMockChainAdapter extends MockChainAdapter { - constructor() { - super(RFC64_GATE2_DEPLOYMENT.networkId); - } - - override async getEvmChainId(): Promise { - return BigInt(RFC64_GATE2_DEPLOYMENT.assertedAtChainId); - } - - override async getKnowledgeAssetsLifecycleAddress(): Promise { - return RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address; - } - - override async getDKGKnowledgeAssetsAddress(): Promise { - return RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address; - } -} - interface Command { readonly command: string; readonly input?: unknown; @@ -171,7 +126,7 @@ async function boot(): Promise { await ensureDeterministicAgentKey(); const finalizedVmConfig = finalizedVmConfigInput === undefined ? null - : parseFinalizedVmHarnessConfig(finalizedVmConfigInput); + : parseFinalizedVmHarnessConfigV1(finalizedVmConfigInput); if (finalizedVmConfig !== null && role !== 'receiver') { throw new Error('finalized VM harness runtime is receiver-only'); } @@ -184,12 +139,8 @@ async function boot(): Promise { ) { throw new Error('finalized VM harness network chain id differs from its deployment'); } - let finalizedVmRuntime: Readonly<{ - readonly chainAdapter: FinalizedVmHarnessMockChainAdapter; - readonly rpcUrl: string; - }> | null = null; if (finalizedVmConfig !== null) { - finalizedVmRuntime = await startFinalizedVmHarnessRuntime(finalizedVmConfig); + finalizedVmRuntime = await startFinalizedVmHarnessRuntimeV1(finalizedVmConfig); } const networkChainAdapter = finalizedVmRuntime?.chainAdapter ?? (networkChainIdInput === undefined ? undefined : new MockChainAdapter(networkChainIdInput)); @@ -208,7 +159,7 @@ async function boot(): Promise { agentProfileHeartbeatMs: 0, rfc64CatalogDeploymentProfile: RFC64_GATE2_DEPLOYMENT as never, ...(networkChainAdapter === undefined ? {} : { chainAdapter: networkChainAdapter }), - ...(finalizedVmRuntime === null ? {} : { + ...(finalizedVmRuntime === undefined ? {} : { chainConfig: { rpcUrl: finalizedVmRuntime.rpcUrl, hubAddress: '0x3333333333333333333333333333333333333333', @@ -1036,7 +987,8 @@ async function stop(exitCode: number, requestId?: string): Promise { stopping = true; try { await agent?.stop(); - await closeFinalizedVmRpcServer(); + await finalizedVmRuntime?.close(); + finalizedVmRuntime = undefined; if (requestId !== undefined) { await emitAndFlush({ event: 'stopped', @@ -1051,191 +1003,6 @@ async function stop(exitCode: number, requestId?: string): Promise { } } -function parseFinalizedVmHarnessConfig(input: string): Readonly { - if (Buffer.byteLength(input) > 16_384) { - throw new TypeError('finalized VM harness config exceeds 16 KiB'); - } - const parsed = plainRecord(JSON.parse(input), 'finalized VM harness config'); - const contextGraphId = requiredString(parsed.contextGraphId, 'finalizedVm.contextGraphId'); - assertContextGraphIdV1(contextGraphId); - const authorAddress = canonicalEvmAddress(parsed.authorAddress, 'finalizedVm.authorAddress'); - const assertionRoot = requiredDigest(parsed.assertionRoot, 'finalizedVm.assertionRoot'); - const assertionVersion = canonicalDecimalWire( - parsed.assertionVersion, - 'finalizedVm.assertionVersion', - ); - if (BigInt(assertionVersion) === 0n) { - throw new TypeError('finalized VM assertion version must be non-zero'); - } - const nameHash = requiredDigest(parsed.nameHash, 'finalizedVm.nameHash'); - const kaId = canonicalDecimalWire(parsed.kaId, 'finalizedVm.kaId'); - const onChainContextGraphId = canonicalDecimalWire( - parsed.onChainContextGraphId, - 'finalizedVm.onChainContextGraphId', - ); - if (BigInt(onChainContextGraphId) === 0n) { - throw new TypeError('finalized VM on-chain context graph id must be non-zero'); - } - return Object.freeze({ - assertionRoot, - assertionVersion, - authorAddress, - contextGraphId, - kaId, - nameHash, - onChainContextGraphId, - }); -} - -async function startFinalizedVmHarnessRuntime( - config: Readonly, -): Promise> { - const chainAdapter = new FinalizedVmHarnessMockChainAdapter(); - (chainAdapter as unknown as { nextContextGraphId: bigint }).nextContextGraphId = - BigInt(config.onChainContextGraphId); - const created = await chainAdapter.createOnChainContextGraph({ - accessPolicy: 0, - publishPolicy: 1, - nameHash: config.nameHash, - }); - if (created.contextGraphId.toString() !== config.onChainContextGraphId) { - throw new Error('mock chain created a different numeric context graph id'); - } - - const server = createServer(async (request, response) => { - try { - if (request.method !== 'POST') { - response.writeHead(405, { 'content-type': 'text/plain' }); - response.end('method not allowed'); - return; - } - const chunks: Buffer[] = []; - let byteLength = 0; - for await (const chunk of request) { - const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - byteLength += bytes.byteLength; - if (byteLength > 1_000_000) throw new Error('JSON-RPC request exceeds 1 MiB'); - chunks.push(bytes); - } - const call = plainRecord( - JSON.parse(Buffer.concat(chunks).toString('utf8')), - 'finalized VM JSON-RPC call', - ); - const method = requiredString(call.method, 'finalized VM JSON-RPC method'); - const params = plainArray(call.params, 'finalized VM JSON-RPC params'); - let result: unknown; - switch (method) { - case 'eth_chainId': - result = '0x4fce'; - break; - case 'eth_getBlockByNumber': - result = { number: '0x7b', hash: FINALIZED_VM_BLOCK_HASH }; - break; - case 'eth_getCode': - result = '0x6000'; - break; - case 'eth_call': - result = finalizedVmEthCallResult(params, config); - break; - default: - throw new Error(`unexpected finalized VM JSON-RPC method ${method}`); - } - sendHarnessRpcResponse(response, call.id, { result }); - } catch (error) { - sendHarnessRpcResponse(response, null, { - error: { code: -32603, message: error instanceof Error ? error.message : String(error) }, - }); - } - }); - await new Promise((resolveListen, rejectListen) => { - server.once('error', rejectListen); - server.listen(0, '127.0.0.1', () => { - server.off('error', rejectListen); - resolveListen(); - }); - }); - finalizedVmRpcServer = server; - const address = server.address() as AddressInfo | null; - if (address === null) throw new Error('finalized VM JSON-RPC server has no address'); - return Object.freeze({ - chainAdapter, - rpcUrl: `http://127.0.0.1:${address.port}`, - }); -} - -function finalizedVmEthCallResult( - params: readonly unknown[], - config: Readonly, -): string { - const call = plainRecord(params[0], 'finalized VM eth_call object'); - const data = requiredString(call.data, 'finalized VM eth_call data'); - if (data === '0x') return '0x'; - const selector = data.slice(0, 10); - switch (selector) { - case FINALIZED_VM_CG.getFunction('getContextGraph')!.selector: - return FINALIZED_VM_CG.encodeFunctionResult('getContextGraph', [ - config.authorAddress, - [], - 0n, - true, - 1n, - 0, - 1, - FINALIZED_VM_ZERO_ADDRESS, - 0n, - ]); - case FINALIZED_VM_CG.getFunction('getNameHash')!.selector: - return FINALIZED_VM_CG.encodeFunctionResult('getNameHash', [config.nameHash]); - case FINALIZED_VM_CG.getFunction('isContextGraphActive')!.selector: - return FINALIZED_VM_CG.encodeFunctionResult('isContextGraphActive', [true]); - case FINALIZED_VM_CG.getFunction('getContextGraphKaCount')!.selector: - return FINALIZED_VM_CG.encodeFunctionResult('getContextGraphKaCount', [1n]); - case FINALIZED_VM_CG.getFunction('getContextGraphKaAt')!.selector: - return FINALIZED_VM_CG.encodeFunctionResult('getContextGraphKaAt', [BigInt(config.kaId)]); - case FINALIZED_VM_KA.getFunction('getKnowledgeAssetUpdateContext')!.selector: - return FINALIZED_VM_KA.encodeFunctionResult( - 'getKnowledgeAssetUpdateContext', - [BigInt(config.assertionVersion), 0n, 0n, 0n, 0n, false, 0], - ); - case FINALIZED_VM_KA.getFunction('getLatestMerkleRoot')!.selector: - return FINALIZED_VM_KA.encodeFunctionResult('getLatestMerkleRoot', [config.assertionRoot]); - case FINALIZED_VM_KA.getFunction('getLatestMerkleRootAuthor')!.selector: - return FINALIZED_VM_KA.encodeFunctionResult( - 'getLatestMerkleRootAuthor', - [config.authorAddress], - ); - case FINALIZED_VM_KA.getFunction('getLatestMerkleRootPublisher')!.selector: - return FINALIZED_VM_KA.encodeFunctionResult( - 'getLatestMerkleRootPublisher', - ['0x6666666666666666666666666666666666666666'], - ); - default: - throw new Error(`unexpected finalized VM eth_call selector ${selector}`); - } -} - -function sendHarnessRpcResponse( - response: import('node:http').ServerResponse, - id: unknown, - payload: Readonly<{ readonly result: unknown } | { readonly error: unknown }>, -): void { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ jsonrpc: '2.0', id, ...payload })); -} - -async function closeFinalizedVmRpcServer(): Promise { - const server = finalizedVmRpcServer; - finalizedVmRpcServer = undefined; - if (server === undefined) return; - await new Promise((resolveClose, rejectClose) => { - server.close((error) => error ? rejectClose(error) : resolveClose()); - server.closeIdleConnections(); - }); -} - function safeHarnessIri(value: unknown, label: string): string { const iri = requiredString(value, label); if (!/^did:dkg:[A-Za-z0-9:._/-]+$/u.test(iri)) { diff --git a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts new file mode 100644 index 0000000000..9ca92ed289 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts @@ -0,0 +1,307 @@ +import { createServer, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { MockChainAdapter } from '@origintrail-official/dkg-chain'; +import { + assertContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +export const RFC64_GATE2_DEPLOYMENT = Object.freeze({ + networkId: 'otp:20430', + assertedAtChainId: '20430', + assertedAtKav10Address: '0x4444444444444444444444444444444444444444', +}); + +export interface FinalizedVmHarnessConfigV1 { + readonly assertionRoot: Digest32V1; + readonly assertionVersion: string; + readonly authorAddress: EvmAddressV1; + readonly contextGraphId: string; + readonly kaId: string; + readonly nameHash: Digest32V1; + readonly onChainContextGraphId: string; +} + +export interface FinalizedVmHarnessRuntimeV1 { + readonly chainAdapter: MockChainAdapter; + readonly rpcUrl: string; + close(): Promise; +} + +const CONTEXT_GRAPH_INTERFACE = new ethers.Interface([ + 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', + 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', + 'function isContextGraphActive(uint256 contextGraphId) view returns (bool)', + 'function getContextGraphKaCount(uint256 contextGraphId) view returns (uint256)', + 'function getContextGraphKaAt(uint256 contextGraphId, uint256 ordinal) view returns (uint256)', +]); +const KNOWLEDGE_ASSET_INTERFACE = new ethers.Interface([ + 'function getKnowledgeAssetUpdateContext(uint256 id) view returns (uint256 merkleRootsCount, uint256 minted, uint88 byteSize, uint40 endEpoch, uint96 tokenAmount, bool isImmutable, uint32 merkleLeafCount)', + 'function getLatestMerkleRoot(uint256 id) view returns (bytes32)', + 'function getLatestMerkleRootAuthor(uint256 id) view returns (address)', + 'function getLatestMerkleRootPublisher(uint256 id) view returns (address)', +]); +const FINALIZED_BLOCK_HASH = `0x${'77'.repeat(32)}`; +const ZERO_ADDRESS = ethers.ZeroAddress.toLowerCase(); + +class FinalizedVmHarnessMockChainAdapter extends MockChainAdapter { + constructor() { + super(RFC64_GATE2_DEPLOYMENT.networkId); + } + + override async getEvmChainId(): Promise { + return BigInt(RFC64_GATE2_DEPLOYMENT.assertedAtChainId); + } + + override async getKnowledgeAssetsLifecycleAddress(): Promise { + return RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address; + } + + override async getDKGKnowledgeAssetsAddress(): Promise { + return RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address; + } +} + +export function parseFinalizedVmHarnessConfigV1( + input: string, +): Readonly { + if (Buffer.byteLength(input) > 16_384) { + throw new TypeError('finalized VM harness config exceeds 16 KiB'); + } + const parsed = plainRecord(JSON.parse(input), 'finalized VM harness config'); + const contextGraphId = requiredString(parsed.contextGraphId, 'finalizedVm.contextGraphId'); + assertContextGraphIdV1(contextGraphId); + const authorAddress = canonicalEvmAddress(parsed.authorAddress, 'finalizedVm.authorAddress'); + const assertionRoot = requiredDigest(parsed.assertionRoot, 'finalizedVm.assertionRoot'); + const assertionVersion = canonicalDecimalWire( + parsed.assertionVersion, + 'finalizedVm.assertionVersion', + ); + if (BigInt(assertionVersion) === 0n) { + throw new TypeError('finalized VM assertion version must be non-zero'); + } + const nameHash = requiredDigest(parsed.nameHash, 'finalizedVm.nameHash'); + const kaId = canonicalDecimalWire(parsed.kaId, 'finalizedVm.kaId'); + const onChainContextGraphId = canonicalDecimalWire( + parsed.onChainContextGraphId, + 'finalizedVm.onChainContextGraphId', + ); + if (BigInt(onChainContextGraphId) === 0n) { + throw new TypeError('finalized VM on-chain context graph id must be non-zero'); + } + return Object.freeze({ + assertionRoot, + assertionVersion, + authorAddress, + contextGraphId, + kaId, + nameHash, + onChainContextGraphId, + }); +} + +export async function startFinalizedVmHarnessRuntimeV1( + config: Readonly, +): Promise> { + const chainAdapter = new FinalizedVmHarnessMockChainAdapter(); + (chainAdapter as unknown as { nextContextGraphId: bigint }).nextContextGraphId = + BigInt(config.onChainContextGraphId); + const created = await chainAdapter.createOnChainContextGraph({ + accessPolicy: 0, + publishPolicy: 1, + nameHash: config.nameHash, + }); + if (created.contextGraphId.toString() !== config.onChainContextGraphId) { + throw new Error('mock chain created a different numeric context graph id'); + } + + let activeServer: Server | undefined; + const server = createServer(async (request, response) => { + try { + if (request.method !== 'POST') { + response.writeHead(405, { 'content-type': 'text/plain' }); + response.end('method not allowed'); + return; + } + const chunks: Buffer[] = []; + let byteLength = 0; + for await (const chunk of request) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += bytes.byteLength; + if (byteLength > 1_000_000) throw new Error('JSON-RPC request exceeds 1 MiB'); + chunks.push(bytes); + } + const call = plainRecord( + JSON.parse(Buffer.concat(chunks).toString('utf8')), + 'finalized VM JSON-RPC call', + ); + const method = requiredString(call.method, 'finalized VM JSON-RPC method'); + const params = plainArray(call.params, 'finalized VM JSON-RPC params'); + let result: unknown; + switch (method) { + case 'eth_chainId': + result = '0x4fce'; + break; + case 'eth_getBlockByNumber': + result = { number: '0x7b', hash: FINALIZED_BLOCK_HASH }; + break; + case 'eth_getCode': + result = '0x6000'; + break; + case 'eth_call': + result = finalizedVmEthCallResult(params, config); + break; + default: + throw new Error(`unexpected finalized VM JSON-RPC method ${method}`); + } + sendRpcResponse(response, call.id, { result }); + } catch (error) { + sendRpcResponse(response, null, { + error: { code: -32603, message: error instanceof Error ? error.message : String(error) }, + }); + } + }); + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(0, '127.0.0.1', () => { + server.off('error', rejectListen); + resolveListen(); + }); + }); + activeServer = server; + const address = server.address() as AddressInfo | null; + if (address === null) { + await closeServer(server); + throw new Error('finalized VM JSON-RPC server has no address'); + } + return Object.freeze({ + chainAdapter, + rpcUrl: `http://127.0.0.1:${address.port}`, + close: async () => { + const current = activeServer; + activeServer = undefined; + if (current !== undefined) await closeServer(current); + }, + }); +} + +function finalizedVmEthCallResult( + params: readonly unknown[], + config: Readonly, +): string { + const call = plainRecord(params[0], 'finalized VM eth_call object'); + const data = requiredString(call.data, 'finalized VM eth_call data'); + if (data === '0x') return '0x'; + const selector = data.slice(0, 10); + switch (selector) { + case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraph')!.selector: + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('getContextGraph', [ + config.authorAddress, + [], + 0n, + true, + 1n, + 0, + 1, + ZERO_ADDRESS, + 0n, + ]); + case CONTEXT_GRAPH_INTERFACE.getFunction('getNameHash')!.selector: + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('getNameHash', [config.nameHash]); + case CONTEXT_GRAPH_INTERFACE.getFunction('isContextGraphActive')!.selector: + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('isContextGraphActive', [true]); + case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraphKaCount')!.selector: + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('getContextGraphKaCount', [1n]); + case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraphKaAt')!.selector: + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult( + 'getContextGraphKaAt', + [BigInt(config.kaId)], + ); + case KNOWLEDGE_ASSET_INTERFACE.getFunction('getKnowledgeAssetUpdateContext')!.selector: + return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( + 'getKnowledgeAssetUpdateContext', + [BigInt(config.assertionVersion), 0n, 0n, 0n, 0n, false, 0], + ); + case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRoot')!.selector: + return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( + 'getLatestMerkleRoot', + [config.assertionRoot], + ); + case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRootAuthor')!.selector: + return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( + 'getLatestMerkleRootAuthor', + [config.authorAddress], + ); + case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRootPublisher')!.selector: + return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( + 'getLatestMerkleRootPublisher', + ['0x6666666666666666666666666666666666666666'], + ); + default: + throw new Error(`unexpected finalized VM eth_call selector ${selector}`); + } +} + +function sendRpcResponse( + response: ServerResponse, + id: unknown, + payload: Readonly<{ readonly result: unknown } | { readonly error: unknown }>, +): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ jsonrpc: '2.0', id, ...payload })); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolveClose, rejectClose) => { + server.close((error) => error ? rejectClose(error) : resolveClose()); + server.closeIdleConnections(); + }); +} + +function plainRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${label} must be a plain object`); + } + return value as Record; +} + +function plainArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value) || value.length > 1_024) { + throw new TypeError(`${label} must be a bounded Array`); + } + return value; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) { + throw new TypeError(`${label} must be a bounded non-empty string`); + } + return value; +} + +function requiredDigest(value: unknown, label: string): Digest32V1 { + if (typeof value !== 'string' || !/^0x[0-9a-f]{64}$/u.test(value)) { + throw new TypeError(`${label} must be a canonical digest`); + } + return value as Digest32V1; +} + +function canonicalDecimalWire(value: unknown, label: string): string { + if (typeof value === 'string' && /^(0|[1-9][0-9]*)$/u.test(value)) return value; + throw new TypeError(`${label} is not a canonical non-negative integer`); +} + +function canonicalEvmAddress(value: unknown, label: string): EvmAddressV1 { + const address = requiredString(value, label); + if (!/^0x[0-9a-f]{40}$/u.test(address) || address === `0x${'0'.repeat(40)}`) { + throw new TypeError(`${label} is not a canonical non-zero EVM address`); + } + return address as EvmAddressV1; +} diff --git a/packages/agent/src/dkg-agent-cg-registry.ts b/packages/agent/src/dkg-agent-cg-registry.ts index 28574cfa91..95eb1b951f 100644 --- a/packages/agent/src/dkg-agent-cg-registry.ts +++ b/packages/agent/src/dkg-agent-cg-registry.ts @@ -123,7 +123,6 @@ import { type WorkspaceSenderKeyEncryptInput, type SharedMemoryPublicSnapshotStorageConfig, type WorkspacePublicSnapshotStore, } from '@origintrail-official/dkg-publisher'; -import { ethers } from 'ethers'; import { join } from 'node:path'; import { DKGQueryEngine, QueryHandler, @@ -425,14 +424,11 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { // the cleartext subscription through the in-memory reverse index before // consulting RDF state; this mapping is populated and persisted by the // chain-event lifecycle path. - const wireId = /^0x[0-9a-fA-F]{64}$/.test(contextGraphId) - ? contextGraphId.toLowerCase() - : ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)).toLowerCase(); - const mappedLocalId = this.wireIdToLocalCgId.get(wireId); - if (mappedLocalId !== undefined) { - const mapped = this.subscribedContextGraphs.get(mappedLocalId)?.onChainId; - if (mapped) return mapped; - } + const mappedLocalId = this.localCgIdForWireId( + this.contextGraphWireId(contextGraphId), + ); + const mapped = this.subscribedContextGraphs.get(mappedLocalId)?.onChainId; + if (mapped) return mapped; const ontologyGraph = contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY); const contextGraphUri = `did:dkg:context-graph:${contextGraphId}`; diff --git a/packages/agent/src/dkg-agent-cg-resolve.ts b/packages/agent/src/dkg-agent-cg-resolve.ts index c9c778265b..646d5ef9ca 100644 --- a/packages/agent/src/dkg-agent-cg-resolve.ts +++ b/packages/agent/src/dkg-agent-cg-resolve.ts @@ -1490,9 +1490,14 @@ export class ContextGraphResolveMethods extends DKGAgentBase { */ gossipWireIdFor(this: DKGAgent, localId: string): string { const sub = this.subscribedContextGraphs.get(localId); - if (sub?.onChainHash) return sub.onChainHash; - if (/^0x[0-9a-fA-F]{64}$/.test(localId)) return localId.toLowerCase(); - return ethers.keccak256(ethers.toUtf8Bytes(localId)).toLowerCase(); + if (sub?.onChainHash) return this.contextGraphWireId(sub.onChainHash); + return this.contextGraphWireId(localId); + } + + /** Canonical cleartext-or-hash Context Graph identity used by every index path. */ + contextGraphWireId(this: DKGAgent, contextGraphId: string): string { + if (/^0x[0-9a-fA-F]{64}$/.test(contextGraphId)) return contextGraphId.toLowerCase(); + return ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)).toLowerCase(); } /** @@ -1600,6 +1605,39 @@ export class ContextGraphResolveMethods extends DKGAgentBase { return lower; } + /** + * Index one local subscription under the same canonical identity used by + * event binding and registry lookup. This is deliberately store-free. + */ + indexContextGraphWireId( + this: DKGAgent, + localId: string, + wireId = this.contextGraphWireId(localId), + ): string { + const canonicalWireId = this.contextGraphWireId(wireId); + this.wireIdToLocalCgId.set(canonicalWireId, localId); + return canonicalWireId; + } + + /** Bind a name-hash event to its indexed cleartext or hash-only subscription. */ + bindOnChainContextGraphIdFromNameHash( + this: DKGAgent, + nameHash: string, + onChainContextGraphId: string, + options?: { persist?: boolean }, + ): string | null { + const wireId = this.contextGraphWireId(nameHash); + const localId = this.localCgIdForWireId(wireId); + const current = this.subscribedContextGraphs.get(localId); + if (current === undefined) return null; + const next = { ...current }; + this.bindSubscriptionOnChainId(localId, next, onChainContextGraphId); + next.onChainHash = wireId; + this.setContextGraphSubscription(localId, next, options); + this.recordCgWireId(localId, wireId); + return localId; + } + /** * Record the curator-committed wire id for a local CG. Keeps the * forward (subscribedContextGraphs) and reverse (wireIdToLocalCgId) @@ -1610,21 +1648,25 @@ export class ContextGraphResolveMethods extends DKGAgentBase { */ recordCgWireId(this: DKGAgent, localId: string, wireId: string | null): void { const sub = this.subscribedContextGraphs.get(localId); - const lower = wireId ? wireId.toLowerCase() : null; + const previous = sub?.onChainHash?.toLowerCase(); + const lower = wireId ? this.contextGraphWireId(wireId) : null; if (sub) { sub.onChainHash = lower ?? undefined; } // Drop any stale reverse entry that pointed at this localId under // a different hash (curator rotated the wire id — currently // unsupported but cheap to defend against). - if (sub?.onChainHash && (!lower || sub.onChainHash !== lower)) { - const prev = sub.onChainHash; - if (this.wireIdToLocalCgId.get(prev) === localId) { - this.wireIdToLocalCgId.delete(prev); + if (previous && previous !== lower) { + if (this.wireIdToLocalCgId.get(previous) === localId) { + this.wireIdToLocalCgId.delete(previous); } } + const derived = this.contextGraphWireId(localId); + if (derived !== lower && this.wireIdToLocalCgId.get(derived) === localId) { + this.wireIdToLocalCgId.delete(derived); + } if (lower) { - this.wireIdToLocalCgId.set(lower, localId); + this.indexContextGraphWireId(localId, lower); } } diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index e1a6cff39f..4e447b4617 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -1942,7 +1942,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { // receiver could know the right graph name yet remain dependent on an // ontology triple that durable sync has not materialized. if (nameHash) { - this.bindContextGraphCreatedNameHashV1(nameHash, contextGraphId); + this.bindOnChainContextGraphIdFromNameHash(nameHash, contextGraphId); } // Track the numeric on-chain id for dedup. @@ -2006,8 +2006,8 @@ export class LifecycleSyncMethods extends DKGAgentBase { // path's chain fallback resolver (Scope A) can take a hash // input and find the on-chain participant agents without an // RPC round-trip per envelope. - const hashLower = nameHash.toLowerCase(); - const localId = this.wireIdToLocalCgId.get(hashLower) ?? hashLower; + const hashLower = this.contextGraphWireId(nameHash); + const localId = this.localCgIdForWireId(hashLower); // Stage a synthetic subscription record for the host-only // case: cores hosting CGs they never joined have no // cleartext; the hash IS their local id. `recordCgWireId` @@ -2017,17 +2017,14 @@ export class LifecycleSyncMethods extends DKGAgentBase { this.setContextGraphSubscription(localId, { subscribed: false, synced: false, - onChainId: contextGraphId, - onChainHash: hashLower, pendingMeta: true, }, { persist: false }); - } else { - const next = { ...this.subscribedContextGraphs.get(localId)! }; - this.bindSubscriptionOnChainId(localId, next, contextGraphId); - next.onChainHash = hashLower; - this.setContextGraphSubscription(localId, next, { persist: false }); } - this.recordCgWireId(localId, hashLower); + this.bindOnChainContextGraphIdFromNameHash( + hashLower, + contextGraphId, + { persist: false }, + ); // Delegate to the host-mode reconciler — it owns the // sharding-table check, swmHostMode flag, and the wire-up @@ -5964,6 +5961,10 @@ export class LifecycleSyncMethods extends DKGAgentBase { ): ContextGraphSub { this.invalidateListContextGraphsCache(); this.subscribedContextGraphs.set(contextGraphId, next); + this.indexContextGraphWireId( + contextGraphId, + next.onChainHash ?? this.contextGraphWireId(contextGraphId), + ); if (!next.subscribed && !next.coreHosted) { this.clearVmReconcileStateForContextGraph(contextGraphId); } @@ -5987,24 +5988,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { return next; } - /** Store-free binding used by the ContextGraphCreated event callback. */ - protected bindContextGraphCreatedNameHashV1( - this: DKGAgent, - nameHash: string, - onChainContextGraphId: string, - ): void { - const hashLower = nameHash.toLowerCase(); - for (const [localId, current] of [...this.subscribedContextGraphs]) { - const computed = ethers.keccak256(ethers.toUtf8Bytes(localId)).toLowerCase(); - if (computed !== hashLower) continue; - const next = { ...current }; - this.bindSubscriptionOnChainId(localId, next, onChainContextGraphId); - next.onChainHash = hashLower; - this.setContextGraphSubscription(localId, next); - this.recordCgWireId(localId, hashLower); - } - } - markContextGraphSubscriptionState(this: DKGAgent, contextGraphId: string, patch: Partial): void { const existing = this.subscribedContextGraphs.get(contextGraphId); if (!existing) return; diff --git a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts index 22f932e4c0..b23871f6fb 100644 --- a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts @@ -1,5 +1,6 @@ import { assertCanonicalChainId, + assertCanonicalDecimalU256, assertCanonicalDecimalU64, assertCanonicalDigest, assertCanonicalEvmAddress, @@ -41,32 +42,6 @@ import { type FinalizedVmPlacementEvidenceV1, } from './finalized-vm-composer-v1.js'; -const CONFIG_KEYS = Object.freeze([ - 'chainId', - 'contextGraphStorageAddress', - 'knowledgeAssetStorageAddress', - 'materialize', - 'networkId', - 'snapshot', -] as const); -const REQUEST_KEYS = Object.freeze([ - 'acceptedPolicy', - 'catalogLane', - 'onChainContextGraphId', - 'placements', - 'signal', -] as const); -const ACCEPTED_POLICY_KEYS = Object.freeze(['policy', 'policyDigest', 'roster'] as const); -const CATALOG_LANE_KEYS = Object.freeze(['contextGraphId', 'subGraphName'] as const); -const RECEIPT_KEYS = Object.freeze([ - 'kaId', - 'ordinal', - 'postReadDigest', - 'status', - 'tripleCount', - 'ual', - 'vmGraphIri', -] as const); const MAX_VM_GRAPH_IRI_BYTES_V1 = 8 * 1024; const UTF8 = new TextEncoder(); @@ -278,107 +253,74 @@ export function createFinalizedVmRuntimeV1( return Object.freeze(runtime); } -function snapshotConfig(input: unknown): RuntimeConfigSnapshotV1 { - let fields: Record; +function snapshotConfig(input: FinalizedVmRuntimeConfigV1): RuntimeConfigSnapshotV1 { try { - fields = snapshotExactRecord(input, CONFIG_KEYS); - assertNetworkIdV1(fields.networkId, 'finalized VM runtime networkId'); - assertCanonicalChainId(fields.chainId, 'finalized VM runtime chainId'); - assertNonzeroAddress(fields.contextGraphStorageAddress, 'contextGraphStorageAddress'); - assertNonzeroAddress(fields.knowledgeAssetStorageAddress, 'knowledgeAssetStorageAddress'); - if (typeof fields.snapshot !== 'function') throw new TypeError('snapshot is not callable'); - if (typeof fields.materialize !== 'function') throw new TypeError('materialize is not callable'); + assertNetworkIdV1(input.networkId, 'finalized VM runtime networkId'); + assertCanonicalChainId(input.chainId, 'finalized VM runtime chainId'); + assertNonzeroAddress(input.contextGraphStorageAddress, 'contextGraphStorageAddress'); + assertNonzeroAddress(input.knowledgeAssetStorageAddress, 'knowledgeAssetStorageAddress'); + if (typeof input.snapshot !== 'function') throw new TypeError('snapshot is not callable'); + if (typeof input.materialize !== 'function') throw new TypeError('materialize is not callable'); } catch (cause) { fail('finalized-vm-runtime-config', 'runtime config is not canonical', cause); } return Object.freeze({ - networkId: fields.networkId as NetworkIdV1, - chainId: fields.chainId as FinalizedVmChainInventoryV1['chainId'], - contextGraphStorageAddress: fields.contextGraphStorageAddress as EvmAddressV1, - knowledgeAssetStorageAddress: fields.knowledgeAssetStorageAddress as EvmAddressV1, - snapshot: fields.snapshot as StrictCurrentFinalizedEvmSnapshotScopeV1, - materialize: fields.materialize as FinalizedVmMaterializerV1, + networkId: input.networkId, + chainId: input.chainId, + contextGraphStorageAddress: input.contextGraphStorageAddress, + knowledgeAssetStorageAddress: input.knowledgeAssetStorageAddress, + snapshot: input.snapshot, + materialize: input.materialize, }); } -function snapshotRequest(input: unknown): RuntimeRequestSnapshotV1 { - let fields: Record; - let accepted: Record; +function snapshotRequest(input: FinalizedVmRuntimeRequestV1): RuntimeRequestSnapshotV1 { try { - fields = snapshotExactRecord(input, REQUEST_KEYS); - accepted = snapshotExactRecord(fields.acceptedPolicy, ACCEPTED_POLICY_KEYS); - if (accepted.roster !== null) { + if (input.acceptedPolicy.roster !== null) { throw new TypeError('public finalized VM runtime forbids a private member roster'); } - assertCanonicalDigest(accepted.policyDigest, 'accepted policy digest'); + assertCanonicalDigest(input.acceptedPolicy.policyDigest, 'accepted policy digest'); const policy = parseCanonicalContextGraphPolicyPayloadV1( - canonicalizeContextGraphPolicyPayloadV1(accepted.policy as ContextGraphPolicyV1), + canonicalizeContextGraphPolicyPayloadV1(input.acceptedPolicy.policy), ); - if (!isAbortSignal(fields.signal)) throw new TypeError('signal is not an AbortSignal'); - const catalogLane = snapshotCatalogLane(fields.catalogLane); - const placements = snapshotDenseArray( - fields.placements, - FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1, + assertCanonicalDecimalU256( + input.onChainContextGraphId, + 'finalized VM runtime onChainContextGraphId', ); + if (input.onChainContextGraphId === '0') { + throw new TypeError('onChainContextGraphId must be nonzero'); + } + const catalogLane = snapshotCatalogLane(input.catalogLane); + if ( + !Array.isArray(input.placements) + || input.placements.length > FINALIZED_VM_CHAIN_SCAN_MAX_ROWS_V1 + ) { + throw new TypeError('placements exceed the finalized VM runtime bound'); + } return Object.freeze({ catalogLane, - onChainContextGraphId: fields.onChainContextGraphId as FinalizedVmChainInventoryV1['contextGraphId'], + onChainContextGraphId: input.onChainContextGraphId, acceptedPolicy: policy, - acceptedPolicyDigest: accepted.policyDigest, - placements: placements as readonly FinalizedVmPlacementEvidenceV1[], - signal: fields.signal, + acceptedPolicyDigest: input.acceptedPolicy.policyDigest, + placements: Object.freeze([...input.placements]), + signal: input.signal, }); } catch (cause) { fail('finalized-vm-runtime-request', 'runtime request is not canonical', cause); } } -function snapshotCatalogLane(input: unknown): FinalizedVmCatalogLaneV1 { - const fields = snapshotExactRecord(input, CATALOG_LANE_KEYS); - assertContextGraphIdV1(fields.contextGraphId, 'catalogLane.contextGraphId'); - if (fields.subGraphName !== null) { - assertSubGraphNameV1(fields.subGraphName, 'catalogLane.subGraphName'); +function snapshotCatalogLane(input: FinalizedVmCatalogLaneV1): FinalizedVmCatalogLaneV1 { + assertContextGraphIdV1(input.contextGraphId, 'catalogLane.contextGraphId'); + if (input.subGraphName !== null) { + assertSubGraphNameV1(input.subGraphName, 'catalogLane.subGraphName'); } return Object.freeze({ - contextGraphId: fields.contextGraphId as ContextGraphIdV1, - subGraphName: fields.subGraphName as SubGraphNameV1 | null, + contextGraphId: input.contextGraphId, + subGraphName: input.subGraphName, }); } -function snapshotDenseArray(input: unknown, maxLength: number): readonly unknown[] { - if (!Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype) { - throw new TypeError('placements must be an ordinary array'); - } - const lengthDescriptor = Object.getOwnPropertyDescriptor(input, 'length'); - if ( - lengthDescriptor === undefined - || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') - || typeof lengthDescriptor.value !== 'number' - || !Number.isSafeInteger(lengthDescriptor.value) - || lengthDescriptor.value < 0 - || lengthDescriptor.value > maxLength - ) { - throw new TypeError('placements length is outside the runtime bound'); - } - const length = lengthDescriptor.value; - const output: unknown[] = []; - for (let index = 0; index < length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); - if ( - descriptor === undefined - || !descriptor.enumerable - || !Object.prototype.hasOwnProperty.call(descriptor, 'value') - ) { - throw new TypeError('placements must be dense and data-only'); - } - output.push(descriptor.value); - } - if (Reflect.ownKeys(input).length !== length + 1) { - throw new TypeError('placements must not have extra properties'); - } - return Object.freeze(output); -} - function assertAcceptedPublicPolicy( config: RuntimeConfigSnapshotV1, catalogLane: FinalizedVmCatalogLaneV1, @@ -437,28 +379,25 @@ function assertExactAnchor( } function snapshotReceipt( - input: unknown, + input: FinalizedVmMaterializationReceiptV1, candidate: Readonly, ): Readonly { - let fields: Record; try { - fields = snapshotExactRecord(input, RECEIPT_KEYS); - assertCanonicalKaId(fields.kaId, 'materialization receipt kaId'); - assertCanonicalDecimalU64(fields.ordinal, 'materialization receipt ordinal'); - assertCanonicalDecimalU64(fields.tripleCount, 'materialization receipt tripleCount'); - assertCanonicalDigest(fields.postReadDigest, 'materialization receipt postReadDigest'); - if (fields.status !== 'materialized' && fields.status !== 'existing') { + assertCanonicalKaId(input.kaId, 'materialization receipt kaId'); + assertCanonicalDecimalU64(input.ordinal, 'materialization receipt ordinal'); + assertCanonicalDecimalU64(input.tripleCount, 'materialization receipt tripleCount'); + assertCanonicalDigest(input.postReadDigest, 'materialization receipt postReadDigest'); + if (input.status !== 'materialized' && input.status !== 'existing') { throw new TypeError('materialization receipt status is invalid'); } if ( - typeof fields.ual !== 'string' - || fields.ual !== candidate.ual - || fields.kaId !== candidate.kaId - || fields.ordinal !== candidate.ordinal + input.ual !== candidate.ual + || input.kaId !== candidate.kaId + || input.ordinal !== candidate.ordinal ) { throw new TypeError('materialization receipt does not bind the requested chain row'); } - assertBoundedIri(fields.vmGraphIri); + assertBoundedIri(input.vmGraphIri); } catch (cause) { fail( 'finalized-vm-runtime-materialization', @@ -467,49 +406,16 @@ function snapshotReceipt( ); } return Object.freeze({ - kaId: fields.kaId as KaIdV1, - ordinal: fields.ordinal as DecimalU64V1, - ual: fields.ual as string, - status: fields.status as FinalizedVmMaterializationReceiptV1['status'], - vmGraphIri: fields.vmGraphIri as string, - tripleCount: fields.tripleCount as DecimalU64V1, - postReadDigest: fields.postReadDigest as Digest32V1, + kaId: input.kaId, + ordinal: input.ordinal, + ual: input.ual, + status: input.status, + vmGraphIri: input.vmGraphIri, + tripleCount: input.tripleCount, + postReadDigest: input.postReadDigest, }); } -function snapshotExactRecord( - input: unknown, - expectedKeys: readonly string[], -): Record { - if (input === null || typeof input !== 'object' || Array.isArray(input)) { - throw new TypeError('input is not a record'); - } - const prototype = Object.getPrototypeOf(input); - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError('input is not a plain record'); - } - const keys = Reflect.ownKeys(input); - if ( - keys.length !== expectedKeys.length - || keys.some((key) => typeof key !== 'string' || !expectedKeys.includes(key)) - ) { - throw new TypeError('input has unknown or missing fields'); - } - const output: Record = Object.create(null) as Record; - for (const key of expectedKeys) { - const descriptor = Object.getOwnPropertyDescriptor(input, key); - if ( - descriptor === undefined - || !descriptor.enumerable - || !Object.prototype.hasOwnProperty.call(descriptor, 'value') - ) { - throw new TypeError('input fields must be enumerable data properties'); - } - output[key] = descriptor.value; - } - return output; -} - function assertNonzeroAddress(value: unknown, label: string): asserts value is EvmAddressV1 { assertCanonicalEvmAddress(value, label); if (value === `0x${'00'.repeat(20)}`) throw new TypeError(`${label} must be nonzero`); @@ -534,18 +440,6 @@ function assertBoundedIri(value: unknown): asserts value is string { if (parsed.protocol.length <= 1) throw new TypeError('vmGraphIri must have a scheme'); } -function isAbortSignal(value: unknown): value is AbortSignal { - if (value === null || typeof value !== 'object') return false; - try { - const getter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; - if (getter === undefined) return false; - getter.call(value); - return true; - } catch { - return false; - } -} - function fail( code: FinalizedVmRuntimeErrorCodeV1, message: string, diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 39bfebfaf1..f7ee0ba18f 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -684,6 +684,9 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { await expect(receiver.getContextGraphOnChainId(CONTEXT_GRAPH_ID)).resolves.toBe( ON_CHAIN_CONTEXT_GRAPH_ID, ); + await expect(receiver.getContextGraphOnChainId(nameHash)).resolves.toBe( + ON_CHAIN_CONTEXT_GRAPH_ID, + ); expect(storeQuery.mock.calls.some(([query]) => String(query).includes('OnChainId'))).toBe(false); storeQuery.mockRestore(); await connectBothWays(author, receiver); diff --git a/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts new file mode 100644 index 0000000000..63ebe0d46f --- /dev/null +++ b/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts @@ -0,0 +1,138 @@ +import { + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + type AuthorCatalogScopeV1, + type ContextGraphPolicyV1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { describe, expect, it, vi } from 'vitest'; + +import type { AcceptedRfc64CatalogAccessSnapshotV1 } from '../src/rfc64/catalog-access-policy-v1.js'; +import { createRfc64FinalizedVmAgentPrecommitV1 } from '../src/rfc64/finalized-vm-agent-precommit-v1.js'; +import type { Rfc64PublicCatalogNativeBeforeAppliedHeadCommitPlanV1 } from '../src/rfc64/public-catalog-native-receiver-v1.js'; +import { + RFC64_VM_AUTHOR, + RFC64_VM_BLOCK_HASH, + RFC64_VM_CG_STORAGE, + RFC64_VM_CHAIN_ID, + RFC64_VM_CONTEXT_GRAPH_NAME, + RFC64_VM_KA_STORAGE, + RFC64_VM_NETWORK_ID, + RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, + RFC64_VM_POLICY_DIGEST, +} from './support/rfc64-finalized-vm-placement-fixture.js'; + +const CATALOG_HEAD_DIGEST = `0x${'91'.repeat(32)}` as Digest32V1; +const INVENTORY_DIGEST = `0x${'92'.repeat(32)}` as Digest32V1; + +describe('RFC-64 finalized VM agent precommit', () => { + it('rejects when the cleartext catalog lane has no numeric on-chain binding', async () => { + const getOnChainContextGraphId = vi.fn(async () => null); + const handler = createRfc64FinalizedVmAgentPrecommitV1({ + ...baseOptions(), + getOnChainContextGraphId, + }); + + await expect(handler(plan(), new AbortController().signal)).rejects.toThrow( + 'could not resolve the numeric context graph id', + ); + expect(getOnChainContextGraphId).toHaveBeenCalledWith( + RFC64_VM_CONTEXT_GRAPH_NAME, + expect.any(AbortSignal), + ); + }); + + it('rejects before chain resolution when trusted RPC endpoints are empty', async () => { + const getOnChainContextGraphId = vi.fn(async () => RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID); + const getEvmChainId = vi.fn(async () => BigInt(RFC64_VM_CHAIN_ID)); + const getKnowledgeAssetStorageAddress = vi.fn(async () => RFC64_VM_KA_STORAGE); + const handler = createRfc64FinalizedVmAgentPrecommitV1({ + ...baseOptions(), + rpcEndpoints: [], + getOnChainContextGraphId, + getEvmChainId, + getKnowledgeAssetStorageAddress, + }); + + await expect(handler(plan(), new AbortController().signal)).rejects.toThrow( + 'requires trusted RPC configuration', + ); + expect(getOnChainContextGraphId).not.toHaveBeenCalled(); + expect(getEvmChainId).not.toHaveBeenCalled(); + expect(getKnowledgeAssetStorageAddress).not.toHaveBeenCalled(); + }); + + it('rejects when the live adapter chain differs from the accepted finalized policy', async () => { + const handler = createRfc64FinalizedVmAgentPrecommitV1({ + ...baseOptions(), + getEvmChainId: async () => 1n, + }); + + await expect(handler(plan(), new AbortController().signal)).rejects.toThrow( + 'policy differs from the configured chain id', + ); + }); +}); + +function baseOptions() { + return { + acceptedPolicySnapshotForCatalogScope: () => acceptedPolicy(), + rpcEndpoints: ['http://127.0.0.1:8545'], + getOnChainContextGraphId: async () => RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, + getEvmChainId: async () => BigInt(RFC64_VM_CHAIN_ID), + getKnowledgeAssetStorageAddress: async () => RFC64_VM_KA_STORAGE, + store: new OxigraphStore(), + } as const; +} + +function plan(): Readonly { + return Object.freeze({ + catalogScope: Object.freeze({ + networkId: RFC64_VM_NETWORK_ID, + contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, + governanceChainId: RFC64_VM_CHAIN_ID, + governanceContractAddress: RFC64_VM_CG_STORAGE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: RFC64_VM_AUTHOR, + era: '0', + bucketCount: '1', + } satisfies AuthorCatalogScopeV1), + catalogHeadDigest: CATALOG_HEAD_DIGEST, + inventoryDigest: INVENTORY_DIGEST, + rows: Object.freeze([]), + }); +} + +function acceptedPolicy(): AcceptedRfc64CatalogAccessSnapshotV1 { + const policy = Object.freeze({ + networkId: RFC64_VM_NETWORK_ID, + contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, + governanceChainId: RFC64_VM_CHAIN_ID, + governanceContractAddress: RFC64_VM_CG_STORAGE, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'finalized-chain', + chainId: RFC64_VM_CHAIN_ID, + contractAddress: RFC64_VM_CG_STORAGE, + blockNumber: '123', + blockHash: RFC64_VM_BLOCK_HASH, + }, + effectiveAt: '1700000000000', + issuedAt: '1700000000000', + } satisfies ContextGraphPolicyV1); + return Object.freeze({ + policy, + policyDigest: RFC64_VM_POLICY_DIGEST, + roster: null, + }); +} diff --git a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts index 07e4aa17c2..289e874377 100644 --- a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts @@ -22,6 +22,7 @@ import type { AcceptedRfc64CatalogAccessSnapshotV1 } from '../src/rfc64/catalog- import { FinalizedVmRuntimeErrorV1, createFinalizedVmRuntimeV1, + type FinalizedVmMaterializationReceiptV1, type FinalizedVmMaterializerV1, } from '../src/rfc64/finalized-vm-runtime-v1.js'; import { createFinalizedVmStoreMaterializerV1 } from '../src/rfc64/finalized-vm-store-materializer-v1.js'; @@ -87,6 +88,32 @@ describe('RFC-64 finalized public VM runtime', () => { expect(Object.isFrozen(result.receipts)).toBe(true); }); + it('materializes and returns two reversed placements in finalized ordinal order', async () => { + const [first, second] = await Promise.all([ + createRfc64FinalizedVmPlacementFixture({ kaNumber: 1n }), + createRfc64FinalizedVmPlacementFixture({ kaNumber: 2n }), + ]); + const transport = snapshotTransport({ kaNumbers: [1n, 2n] }); + const materializedOrdinals: string[] = []; + const materialize = vi.fn(async ({ candidate }) => { + materializedOrdinals.push(candidate.ordinal); + const kaNumber = candidate.ordinal === '0' ? 1n : 2n; + return receipt(kaNumber, candidate.ordinal); + }); + const runtime = createFinalizedVmRuntimeV1(runtimeConfig(transport.snapshot, materialize)); + + const result = await runtime(request([second, first])); + + expect(materializedOrdinals).toEqual(['0', '1']); + expect(materialize).toHaveBeenCalledTimes(2); + expect(result.composed.rows.map(({ ordinal }) => ordinal)).toEqual(['0', '1']); + expect(result.receipts.map(({ ordinal }) => ordinal)).toEqual(['0', '1']); + expect(result.receipts.map(({ kaId }) => kaId)).toEqual([ + rfc64VmPackKaId(1n), + rfc64VmPackKaId(2n), + ]); + }); + it('fails closed before store writes on name, policy, or placement mismatch', async () => { const placement = await createRfc64FinalizedVmPlacementFixture(); const mutations = [ @@ -217,11 +244,16 @@ function runtimeConfig( } function request( - placement: Awaited>, + placementOrPlacements: + | Awaited> + | readonly Awaited>[], policyOverrides: Partial = {}, sourceOverrides: Partial> = {}, ) { const policy = publicPolicy(policyOverrides, sourceOverrides); + const placements = Array.isArray(placementOrPlacements) + ? [...placementOrPlacements] + : [placementOrPlacements]; return { catalogLane: { contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, subGraphName: null }, onChainContextGraphId: RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, @@ -230,7 +262,7 @@ function request( policyDigest: RFC64_VM_POLICY_DIGEST, roster: null, } satisfies AcceptedRfc64CatalogAccessSnapshotV1, - placements: [placement], + placements, signal: new AbortController().signal, } as const; } @@ -268,13 +300,16 @@ function publicPolicy( }; } -function receipt() { +function receipt( + kaNumber = 1n, + ordinal: FinalizedVmMaterializationReceiptV1['ordinal'] = '0', +): FinalizedVmMaterializationReceiptV1 { return { - kaId: rfc64VmPackKaId(1n), - ordinal: '0', - ual: rfc64VmUal(1n), + kaId: rfc64VmPackKaId(kaNumber), + ordinal, + ual: rfc64VmUal(kaNumber), status: 'materialized', - vmGraphIri: `${rfc64VmUal(1n)}/VerifiableMemory/2`, + vmGraphIri: `${rfc64VmUal(kaNumber)}/VerifiableMemory/2`, tripleCount: '10', postReadDigest: RECEIPT_DIGEST, } as const; @@ -283,7 +318,9 @@ function receipt() { function snapshotTransport(options: { readonly nameHash?: string; readonly assertionRoot?: string; + readonly kaNumbers?: readonly bigint[]; } = {}) { + const kaNumbers = options.kaNumbers ?? [1n]; let open = false; let readCount = 0; const snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async (snapshotRequest, consume) => { @@ -322,9 +359,16 @@ function snapshotTransport(options: { case CG.getFunction('isContextGraphActive')!.selector: return CG.encodeFunctionResult('isContextGraphActive', [true]); case CG.getFunction('getContextGraphKaCount')!.selector: - return CG.encodeFunctionResult('getContextGraphKaCount', [1n]); - case CG.getFunction('getContextGraphKaAt')!.selector: - return CG.encodeFunctionResult('getContextGraphKaAt', [BigInt(rfc64VmPackKaId(1n))]); + return CG.encodeFunctionResult('getContextGraphKaCount', [BigInt(kaNumbers.length)]); + case CG.getFunction('getContextGraphKaAt')!.selector: { + const [, ordinal] = CG.decodeFunctionData('getContextGraphKaAt', call.data); + const kaNumber = kaNumbers[Number(ordinal)]; + if (kaNumber === undefined) throw new Error(`unexpected finalized VM ordinal ${ordinal}`); + return CG.encodeFunctionResult( + 'getContextGraphKaAt', + [BigInt(rfc64VmPackKaId(kaNumber))], + ); + } case KA.getFunction('getKnowledgeAssetUpdateContext')!.selector: return KA.encodeFunctionResult('getKnowledgeAssetUpdateContext', [2n, 0n, 0n, 0n, 0n, false, 0]); case KA.getFunction('getLatestMerkleRoot')!.selector: diff --git a/packages/agent/test/swm/host-mode-key-canonicalization.test.ts b/packages/agent/test/swm/host-mode-key-canonicalization.test.ts index 5e0ca11911..9f33cf5444 100644 --- a/packages/agent/test/swm/host-mode-key-canonicalization.test.ts +++ b/packages/agent/test/swm/host-mode-key-canonicalization.test.ts @@ -21,7 +21,7 @@ * `swmHostModeSubscribed` / `swmHostModeHandlers` maps holding exactly * one entry keyed by the wire hash. */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ethers } from 'ethers'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -94,6 +94,17 @@ interface AgentInternals { enqueueHostModePersistence(contextGraphId: string, subscribe: boolean): void; awaitHostModePersistence(contextGraphId: string): Promise; hostModePersistenceStoreKey(rawCgId: string): string; + contextGraphWireId(contextGraphId: string): string; + bindOnChainContextGraphIdFromNameHash( + nameHash: string, + onChainContextGraphId: string, + options?: { persist?: boolean }, + ): string | null; + setContextGraphSubscription( + contextGraphId: string, + next: { subscribed: boolean; synced: boolean }, + options?: { persist?: boolean }, + ): void; } async function installHostModeStore(core: DKGAgent, dataDir: string): Promise { @@ -230,6 +241,31 @@ describe('host-mode bookkeeping key canonicalisation', () => { expect(internals.swmHostModeHandlers.has(wireHash)).toBe(true); }); + it('uses one indexed wire-id helper for event binding and store-free registry lookup', async () => { + const { core } = await makeCore(); + const internals = core as unknown as AgentInternals; + const cleartext = 'canon-cg-event-binding'; + const wireHash = ethers.keccak256(ethers.toUtf8Bytes(cleartext)).toLowerCase(); + internals.setContextGraphSubscription( + cleartext, + { subscribed: true, synced: false }, + { persist: false }, + ); + + expect(internals.contextGraphWireId(cleartext)).toBe(wireHash); + expect(internals.contextGraphWireId(wireHash.toUpperCase().replace('0X', '0x'))).toBe(wireHash); + expect(internals.bindOnChainContextGraphIdFromNameHash( + wireHash, + '14', + { persist: false }, + )).toBe(cleartext); + + const storeQuery = vi.spyOn(core.store, 'query'); + await expect(core.getContextGraphOnChainId(cleartext)).resolves.toBe('14'); + await expect(core.getContextGraphOnChainId(wireHash)).resolves.toBe('14'); + expect(storeQuery).not.toHaveBeenCalled(); + }); + it('exactly one gossip handler is wired on the underlying topic regardless of subscribe shape', async () => { const { core, bus } = await makeCore(); // Use the wire-hash form first to mirror the chain-event / diff --git a/packages/agent/vitest.rfc64-unit-tests.ts b/packages/agent/vitest.rfc64-unit-tests.ts index b9b2485756..a355701446 100644 --- a/packages/agent/vitest.rfc64-unit-tests.ts +++ b/packages/agent/vitest.rfc64-unit-tests.ts @@ -10,6 +10,7 @@ export const RFC64_UNIT_TESTS = [ "test/rfc64-catalog-row-authorship.test.ts", "test/rfc64-finalized-vm-composer-v1.test.ts", "test/rfc64-finalized-vm-runtime-v1.test.ts", + "test/rfc64-finalized-vm-agent-precommit-v1.test.ts", "test/rfc64-agent-inventory-lifecycle.test.ts", "test/rfc64-author-catalog-producer.test.ts", "test/rfc64-control-object-store-v1.test.ts", From 9f4827626ccaa8126b099fcbdda9ce50b4e94a2f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 15:39:51 +0200 Subject: [PATCH 260/292] test(rfc64): prove VM precommit withholds head CAS --- ...c-catalog-native-gate1.integration.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 35a0b60003..547b03dd07 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -6,6 +6,7 @@ import { multiaddr } from '@multiformats/multiaddr'; import { AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, DKGNode, ProtocolRouter, assertAuthorCatalogRowV1, @@ -27,6 +28,7 @@ import { type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, type ContextGraphIdV1, + type ContextGraphPolicyV1, type Digest32V1, type EvmAddressV1, type NetworkIdV1, @@ -51,6 +53,7 @@ import { type Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1, } from '../src/rfc64/public-catalog-native-receiver-v1.js'; import { readVerifiedAuthorCatalogRowAuthorshipV1 } from '../src/rfc64/catalog-row-authorship.js'; +import { createRfc64FinalizedVmAgentPrecommitV1 } from '../src/rfc64/finalized-vm-agent-precommit-v1.js'; import { computeRfc64AppliedInventoryDigestV1, verifyRfc64PublicCatalogInventoryCompletenessV1, @@ -1303,6 +1306,78 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { )?.currentCatalogHeadDigest).toBe(fixture.multiAssetSuccessor.head.objectDigest); await expect(fixture.receiverStore.countQuads()).resolves.toBe(32); }, 30_000); + + it('keeps the governed successor head unapplied when the production VM precommit lacks RPC', async () => { + const fixture = await setupLiveReceiver(); + const compareAndSwapAppliedCatalogHeadV1 = vi.fn( + fixture.receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1.bind( + fixture.receiverPersistence.inventory, + ), + ); + const getOnChainContextGraphId = vi.fn(async () => '14'); + const getEvmChainId = vi.fn(async () => 20_430n); + const getKnowledgeAssetStorageAddress = vi.fn(async () => KAV10); + const policy = Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: fixture.governedScope.ownershipTransitionDigest, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'finalized-chain', + chainId: '20430', + contractAddress: GOVERNANCE_CONTRACT, + blockNumber: '123', + blockHash: `0x${'77'.repeat(32)}`, + }, + effectiveAt: '1773900000000', + issuedAt: '1773900000000', + } satisfies ContextGraphPolicyV1); + const precommit = createRfc64FinalizedVmAgentPrecommitV1({ + acceptedPolicySnapshotForCatalogScope: () => Object.freeze({ + policy, + policyDigest: POLICY_DIGEST, + roster: null, + }), + rpcEndpoints: [], + getOnChainContextGraphId, + getEvmChainId, + getKnowledgeAssetStorageAddress, + store: fixture.receiverStore, + }); + const receiver = fixture.createReceiver({ + readAppliedCatalogHeadV1: + fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1.bind( + fixture.receiverPersistence.inventory, + ), + compareAndSwapAppliedCatalogHeadV1, + }, undefined, undefined, fixture.receiverStore, precommit); + + await fixture.bootstrapGoverned(receiver); + compareAndSwapAppliedCatalogHeadV1.mockClear(); + await expect(fixture.synchronizeGoverned(receiver)).rejects.toMatchObject({ + code: 'catalog-native-receiver-activation', + message: expect.stringContaining('finalized VM precommit rejected'), + }); + + expect(getOnChainContextGraphId).not.toHaveBeenCalled(); + expect(getEvmChainId).not.toHaveBeenCalled(); + expect(getKnowledgeAssetStorageAddress).not.toHaveBeenCalled(); + expect(compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + computeAuthorCatalogScopeDigestV1(fixture.governedScope), + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.governedGenesis.head.objectDigest); + }, 30_000); }); async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { @@ -1786,6 +1861,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { genesisAnnouncement, governedGenesis, governedGenesisAnnouncement, + governedScope, governedSuccessor, governedSuccessorAnnouncement, invalidGenesisAnnouncement, @@ -1822,6 +1898,16 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { DEPLOYMENT, signal, ), + bootstrapGoverned: ( + selectedReceiver = receiver, + signal?: AbortSignal, + ) => selectedReceiver.bootstrapEmptyPublicOpenCatalog( + authorNode.peerId, + governedGenesisAnnouncement, + governedScope, + DEPLOYMENT, + signal, + ), synchronize: ( selectedAnnouncement = announcement, selectedReceiver = receiver, @@ -1844,6 +1930,16 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { DEPLOYMENT, signal, ), + synchronizeGoverned: ( + selectedReceiver = receiver, + signal?: AbortSignal, + ) => selectedReceiver.synchronizeOnePublicOpenRow( + authorNode.peerId, + governedSuccessorAnnouncement, + governedScope, + DEPLOYMENT, + signal, + ), }; } From 079cc9b72cd65dfb56f86bd5f299fd4d9b1552d9 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 15:54:04 +0200 Subject: [PATCH 261/292] refactor(rfc64): close finalized VM review gaps --- packages/agent/src/dkg-agent-cg-resolve.ts | 50 ++-------- packages/agent/src/dkg-agent-context-graph.ts | 11 +-- packages/agent/src/dkg-agent-lifecycle.ts | 42 +++++--- packages/agent/src/dkg-agent-swm-host.ts | 19 ++-- packages/agent/src/finalization-handler.ts | 2 +- packages/agent/src/gossip-publish-handler.ts | 15 +-- .../finalized-vm-store-materializer-v1.ts | 95 +++++++++++++------ .../rfc64-finalized-vm-runtime-v1.test.ts | 23 +++++ packages/publisher/src/dkg-publisher.ts | 4 +- packages/publisher/src/index.ts | 2 +- packages/publisher/src/metadata.ts | 65 +++++++------ packages/publisher/src/update-handler.ts | 2 +- 12 files changed, 196 insertions(+), 134 deletions(-) diff --git a/packages/agent/src/dkg-agent-cg-resolve.ts b/packages/agent/src/dkg-agent-cg-resolve.ts index 646d5ef9ca..548b113304 100644 --- a/packages/agent/src/dkg-agent-cg-resolve.ts +++ b/packages/agent/src/dkg-agent-cg-resolve.ts @@ -1605,20 +1605,6 @@ export class ContextGraphResolveMethods extends DKGAgentBase { return lower; } - /** - * Index one local subscription under the same canonical identity used by - * event binding and registry lookup. This is deliberately store-free. - */ - indexContextGraphWireId( - this: DKGAgent, - localId: string, - wireId = this.contextGraphWireId(localId), - ): string { - const canonicalWireId = this.contextGraphWireId(wireId); - this.wireIdToLocalCgId.set(canonicalWireId, localId); - return canonicalWireId; - } - /** Bind a name-hash event to its indexed cleartext or hash-only subscription. */ bindOnChainContextGraphIdFromNameHash( this: DKGAgent, @@ -1634,40 +1620,24 @@ export class ContextGraphResolveMethods extends DKGAgentBase { this.bindSubscriptionOnChainId(localId, next, onChainContextGraphId); next.onChainHash = wireId; this.setContextGraphSubscription(localId, next, options); - this.recordCgWireId(localId, wireId); return localId; } /** - * Record the curator-committed wire id for a local CG. Keeps the - * forward (subscribedContextGraphs) and reverse (wireIdToLocalCgId) - * mappings in lockstep. Idempotent. + * Compatibility adapter for callers that only enrich an existing + * subscription's wire id. The canonical subscription mutator owns both the + * forward record and reverse index update. * - * Pass `null` to clear the mapping (rare — used when a CG is - * deactivated and we want to free the reverse-index slot). + * Pass `null` to clear the curator commitment and restore the canonical + * local-id-derived reverse mapping. */ recordCgWireId(this: DKGAgent, localId: string, wireId: string | null): void { const sub = this.subscribedContextGraphs.get(localId); - const previous = sub?.onChainHash?.toLowerCase(); - const lower = wireId ? this.contextGraphWireId(wireId) : null; - if (sub) { - sub.onChainHash = lower ?? undefined; - } - // Drop any stale reverse entry that pointed at this localId under - // a different hash (curator rotated the wire id — currently - // unsupported but cheap to defend against). - if (previous && previous !== lower) { - if (this.wireIdToLocalCgId.get(previous) === localId) { - this.wireIdToLocalCgId.delete(previous); - } - } - const derived = this.contextGraphWireId(localId); - if (derived !== lower && this.wireIdToLocalCgId.get(derived) === localId) { - this.wireIdToLocalCgId.delete(derived); - } - if (lower) { - this.indexContextGraphWireId(localId, lower); - } + if (sub === undefined) return; + this.setContextGraphSubscription(localId, { + ...sub, + onChainHash: wireId ? this.contextGraphWireId(wireId) : undefined, + }, { persist: false }); } /** diff --git a/packages/agent/src/dkg-agent-context-graph.ts b/packages/agent/src/dkg-agent-context-graph.ts index 825f655226..d7628e0671 100644 --- a/packages/agent/src/dkg-agent-context-graph.ts +++ b/packages/agent/src/dkg-agent-context-graph.ts @@ -1477,13 +1477,10 @@ export class ContextGraphMethods extends DKGAgentBase { // Update in-memory subscription record and ensure we're subscribed const sub = this.subscribedContextGraphs.get(id); if (sub) { - this.bindSubscriptionOnChainId(id, sub, onChainId); - // Keep the forward + reverse maps in lockstep so the receive - // path can translate the wire id back to `id` (see - // {@link recordCgWireId}). - this.recordCgWireId(id, nameHash); - if (!sub.subscribed) { - sub.subscribed = true; + const next = { ...sub, onChainHash: nameHash }; + this.bindSubscriptionOnChainId(id, next, onChainId); + this.setContextGraphSubscription(id, next, { persist: false }); + if (!next.subscribed) { this.subscribeToContextGraph(id, { trackSyncScope: true }); this.log.info(ctx, `Subscribed to newly registered context graph "${id}"`); } diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 4e447b4617..c1763da4be 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -5918,6 +5918,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { let current = this.subscribedContextGraphs.get(contextGraphId) ?? sub; let registrationChanged = false; + let nextOnChainHash = current.onChainHash; if (confirmedOnChainId && current.onChainId !== confirmedOnChainId) { this.bindSubscriptionOnChainId(contextGraphId, current, confirmedOnChainId); registrationChanged = true; @@ -5926,11 +5927,14 @@ export class LifecycleSyncMethods extends DKGAgentBase { confirmedOnChainHash && current.onChainHash?.toLowerCase() !== confirmedOnChainHash ) { - this.recordCgWireId(contextGraphId, confirmedOnChainHash); + nextOnChainHash = confirmedOnChainHash; registrationChanged = true; } if (registrationChanged) { - this.setContextGraphSubscription(contextGraphId, { ...current }); + this.setContextGraphSubscription(contextGraphId, { + ...current, + onChainHash: nextOnChainHash, + }); current = this.subscribedContextGraphs.get(contextGraphId) ?? current; } @@ -5960,12 +5964,27 @@ export class LifecycleSyncMethods extends DKGAgentBase { options?: { persist?: boolean; updateRehydrationStatus?: boolean }, ): ContextGraphSub { this.invalidateListContextGraphsCache(); - this.subscribedContextGraphs.set(contextGraphId, next); - this.indexContextGraphWireId( - contextGraphId, - next.onChainHash ?? this.contextGraphWireId(contextGraphId), - ); - if (!next.subscribed && !next.coreHosted) { + const previous = this.subscribedContextGraphs.get(contextGraphId); + const localWireId = this.contextGraphWireId(contextGraphId); + const previousWireId = previous?.onChainHash + ? this.contextGraphWireId(previous.onChainHash) + : localWireId; + const nextOnChainHash = next.onChainHash + ? this.contextGraphWireId(next.onChainHash) + : undefined; + const nextWireId = nextOnChainHash ?? localWireId; + const canonicalNext = next.onChainHash === nextOnChainHash + ? next + : { ...next, onChainHash: nextOnChainHash }; + if ( + previousWireId !== nextWireId + && this.wireIdToLocalCgId.get(previousWireId) === contextGraphId + ) { + this.wireIdToLocalCgId.delete(previousWireId); + } + this.subscribedContextGraphs.set(contextGraphId, canonicalNext); + this.wireIdToLocalCgId.set(nextWireId, contextGraphId); + if (!canonicalNext.subscribed && !canonicalNext.coreHosted) { this.clearVmReconcileStateForContextGraph(contextGraphId); } if (options?.persist !== false) { @@ -5979,13 +5998,13 @@ export class LifecycleSyncMethods extends DKGAgentBase { }, ); } - if (next.subscribed) { + if (canonicalNext.subscribed) { this.persistLocalNodeMembership(contextGraphId); } else { this.deleteContextGraphMember(contextGraphId, 'node', this.peerId); } } - return next; + return canonicalNext; } markContextGraphSubscriptionState(this: DKGAgent, contextGraphId: string, patch: Partial): void { @@ -6711,9 +6730,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { lastReconciledOrdinal: row.lastReconciledOrdinal, coreHosted: row.coreHosted, }, { persist: false }); - if (row.onChainHash) { - this.recordCgWireId(row.id, row.onChainHash); - } if (row.syncScoped) { this.trackSyncContextGraph(row.id); } diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index 187624078c..6444ad7ecf 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -1123,7 +1123,15 @@ export class SwmHostModeMethods extends DKGAgentBase { // chain-fallback resolver and the catchup-request path can // translate either direction without an extra RPC. if (storageCgId !== contextGraphId) { - this.recordCgWireId(storageCgId, subscriptionWireId); + const storageSubscription = this.subscribedContextGraphs.get(storageCgId) ?? { + subscribed: false, + synced: false, + pendingMeta: true, + }; + this.setContextGraphSubscription(storageCgId, { + ...storageSubscription, + onChainHash: subscriptionWireId, + }, { persist: false }); } // Cheap "is this ciphertext" sniff: try to decode as one of the // two encrypted carriers; if neither parses, drop early so we @@ -1679,11 +1687,9 @@ export class SwmHostModeMethods extends DKGAgentBase { } this.beaconCuratorByWireId.set(wireId, curatorEoa); - // Stage the synthetic subscription record + wire-id reverse - // mapping — same as the chain-event auto-subscribe path. The - // hash IS the local id for cores that didn't create or join - // the CG, so `recordCgWireId(wireId, wireId)` is the right - // identity-translation entry. + // Stage the synthetic subscription record + wire-id reverse mapping through + // the canonical subscription mutator. The hash is the local id for cores + // that did not create or join the CG. if (!this.subscribedContextGraphs.has(wireId)) { this.setContextGraphSubscription(wireId, { subscribed: false, @@ -1695,7 +1701,6 @@ export class SwmHostModeMethods extends DKGAgentBase { const existing = this.subscribedContextGraphs.get(wireId)!; this.setContextGraphSubscription(wireId, { ...existing, onChainHash: wireId }, { persist: false }); } - this.recordCgWireId(wireId, wireId); try { await this.reconcileSwmHostModeSubscription(wireId, SUBSCRIPTION_SOURCES.BEACON); diff --git a/packages/agent/src/finalization-handler.ts b/packages/agent/src/finalization-handler.ts index 1bec59e316..370842b73c 100644 --- a/packages/agent/src/finalization-handler.ts +++ b/packages/agent/src/finalization-handler.ts @@ -1544,7 +1544,7 @@ export class FinalizationHandler { assertionGraph: vmGraph, }, 'confirmed', - provenance, + { kind: 'transaction', provenance }, ); await this.store.deleteByPattern({ graph: metaGraph, subject: scope.ual }); await this.store.insert(metadata); diff --git a/packages/agent/src/gossip-publish-handler.ts b/packages/agent/src/gossip-publish-handler.ts index d0c4df839d..f8910b2bde 100644 --- a/packages/agent/src/gossip-publish-handler.ts +++ b/packages/agent/src/gossip-publish-handler.ts @@ -589,12 +589,15 @@ export class GossipPublishHandler { verified ? 'confirmed' : 'tentative', verified ? { - txHash, - blockNumber, - blockTimestamp: Math.floor(Date.now() / 1000), - publisherAddress: request.publisherAddress, - batchId: startKAId, - chainId: request.chainId, + kind: 'transaction', + provenance: { + txHash, + blockNumber, + blockTimestamp: Math.floor(Date.now() / 1000), + publisherAddress: request.publisherAddress, + batchId: startKAId, + chainId: request.chainId, + }, } : undefined, ); diff --git a/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts b/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts index 62d98be8d0..9870fbb52d 100644 --- a/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts @@ -1,6 +1,7 @@ import { DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, MemoryLayer, + assertSafeIri, contextGraphLayerUri, contextGraphMetaUri, parseDeterministicKnowledgeAssetUal, @@ -16,7 +17,7 @@ import { } from '@origintrail-official/dkg-storage'; import { computeFlatKCRootV10, - generateRfc64FinalizedGraphKnowledgeAssetMetadata, + generateGraphKnowledgeAssetMetadata, } from '@origintrail-official/dkg-publisher'; import { ethers } from 'ethers'; @@ -32,6 +33,7 @@ import type { const POST_READ_DIGEST_DOMAIN_V1 = ethers.toUtf8Bytes( 'OT-RFC-64:finalized-vm-post-read:v1\0', ); +const PUBLISHED_AT_PREDICATE = 'http://dkg.io/ontology/publishedAt'; export interface FinalizedVmStoreMaterializerOptionsV1 { readonly store: TripleStore; @@ -44,23 +46,7 @@ export interface FinalizedVmStoreMaterializerOptionsV1 { export function createFinalizedVmStoreMaterializerV1( options: FinalizedVmStoreMaterializerOptionsV1, ): FinalizedVmMaterializerV1 { - const storeDescriptor = options !== null && typeof options === 'object' - ? Object.getOwnPropertyDescriptor(options, 'store') - : undefined; - if ( - options === null - || typeof options !== 'object' - || Object.getPrototypeOf(options) !== Object.prototype - || Reflect.ownKeys(options).length !== 1 - || storeDescriptor === undefined - || !storeDescriptor.enumerable - || !Object.prototype.hasOwnProperty.call(storeDescriptor, 'value') - || storeDescriptor.value === null - || typeof storeDescriptor.value !== 'object' - ) { - throw new TypeError('finalized VM store materializer requires one TripleStore'); - } - const store = storeDescriptor.value as TripleStore; + const { store } = options; return Object.freeze(async (request): Promise => { request.signal.throwIfAborted(); const binding = readVerifiedCatalogSealBindingV1(request.placement.sealBinding); @@ -116,7 +102,7 @@ export function createFinalizedVmStoreMaterializerV1( if (!Number.isFinite(timestamp.getTime())) { throw new Error('finalized VM seal timestamp is invalid'); } - const metadataQuads = generateRfc64FinalizedGraphKnowledgeAssetMetadata({ + const metadataQuads = generateGraphKnowledgeAssetMetadata({ contextGraphId: request.catalogLane.contextGraphId, ual: request.candidate.ual, merkleRoot: ethers.getBytes(request.candidate.assertionRoot), @@ -131,11 +117,14 @@ export function createFinalizedVmStoreMaterializerV1( ...(privateMerkleRoot ? { privateMerkleRoot } : {}), assertionGraph: vmGraph, ...(subGraphName ? { subGraphName } : {}), - }, { - batchId: BigInt(request.candidate.kaId), - materializedVersion: { - blockNumber: boundedMaterializedBlockNumber(request.candidate.finalizedBlockNumber), - txIndex: 0, + }, 'confirmed', { + kind: 'finalized-materialization', + provenance: { + batchId: BigInt(request.candidate.kaId), + materializedVersion: { + blockNumber: boundedMaterializedBlockNumber(request.candidate.finalizedBlockNumber), + txIndex: 0, + }, }, }); const asset = Object.freeze({ @@ -147,11 +136,18 @@ export function createFinalizedVmStoreMaterializerV1( dataQuads: graphlessProjection.map((quad) => ({ ...quad, graph: vmGraph })), metadataQuads: [...metadataQuads], }) satisfies VerifiedGraphScopedAsset; - const outcome = await materializeVerifiedGraphScopedAsset({ + const existingBefore = await hasExactFinalizedMaterialization( store, asset, - options: { source: 'rfc64-finalized-vm-materialization' }, - }); + graphlessProjection, + ); + const outcome = existingBefore + ? 'stale' + : await materializeVerifiedGraphScopedAsset({ + store, + asset, + options: { source: 'rfc64-finalized-vm-materialization' }, + }); if (outcome === 'quarantined') { throw new Error('finalized VM projection was quarantined by store limits'); } @@ -169,6 +165,12 @@ export function createFinalizedVmStoreMaterializerV1( if (quadsToNQuads(postRead) !== quadsToNQuads(graphlessProjection)) { throw new Error('finalized VM post-read differs from the verified catalog projection'); } + if ( + existingBefore + && !(await hasExactFinalizedMaterialization(store, asset, graphlessProjection)) + ) { + throw new Error('finalized VM replay metadata changed during exact post-read'); + } const postReadDigest = ethers.keccak256(ethers.concat([ POST_READ_DIGEST_DOMAIN_V1, ethers.toUtf8Bytes(quadsToNQuads(postRead)), @@ -185,6 +187,45 @@ export function createFinalizedVmStoreMaterializerV1( }); } +async function hasExactFinalizedMaterialization( + store: TripleStore, + asset: VerifiedGraphScopedAsset, + graphlessProjection: readonly Quad[], +): Promise { + let currentProjection: Quad[]; + try { + currentProjection = await readExactGraphPaged(store, asset.assertionGraph, { + expectedQuadCount: graphlessProjection.length, + maxQuadCount: graphlessProjection.length, + maxNQuadsBytes: + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1.maxProjectionBytes, + outputGraph: '', + queryOptions: { source: 'rfc64-finalized-vm-replay-read' }, + }); + } catch { + return false; + } + if (quadsToNQuads(currentProjection) !== quadsToNQuads(graphlessProjection)) { + return false; + } + const result = await store.query(` + SELECT ?predicate ?object WHERE { + GRAPH <${assertSafeIri(asset.metaGraph)}> { + <${assertSafeIri(asset.ual)}> ?predicate ?object . + } + } + `, { source: 'rfc64-finalized-vm-replay-meta-read' }); + if (result.type !== 'bindings') return false; + const current = new Set(result.bindings + .filter((row) => row.predicate !== PUBLISHED_AT_PREDICATE) + .map((row) => `${row.predicate}\0${row.object}`)); + const expected = new Set(asset.metadataQuads + .filter((quad) => quad.predicate !== PUBLISHED_AT_PREDICATE) + .map((quad) => `${quad.predicate}\0${quad.object}`)); + if (current.size !== expected.size) return false; + return [...expected].every((row) => current.has(row)); +} + function boundedTripleCount(value: string, label: string): number { const parsed = BigInt(value); if ( diff --git a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts index 289e874377..754d307897 100644 --- a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts @@ -204,6 +204,29 @@ describe('RFC-64 finalized public VM runtime', () => { }); expect(vmPostRead).toHaveLength(graphlessProjection.length); expect(vmPostRead).toEqual(expect.arrayContaining(graphlessProjection)); + const metadataBeforeReplay = await store.query( + `SELECT ?p ?o WHERE { ` + + `GRAPH { ` + + `<${rfc64VmUal(1n)}> ?p ?o } } ORDER BY ?p ?o`, + ); + + const replay = await runtime(request(placement)); + + expect(replay.receipts).toHaveLength(1); + expect(replay.receipts[0]).toMatchObject({ + status: 'existing', + vmGraphIri: vmGraph, + tripleCount: String(graphlessProjection.length), + }); + await expect(readExactGraphPaged(store, vmGraph, { + expectedQuadCount: graphlessProjection.length, + outputGraph: '', + })).resolves.toEqual(vmPostRead); + await expect(store.query( + `SELECT ?p ?o WHERE { ` + + `GRAPH { ` + + `<${rfc64VmUal(1n)}> ?p ?o } } ORDER BY ?p ?o`, + )).resolves.toEqual(metadataBeforeReplay); await expect(store.query( `ASK { GRAPH { ` + `<${rfc64VmUal(1n)}> "confirmed" } }`, diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index a013cfaaea..d83ab879a8 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -4028,7 +4028,7 @@ export class DKGPublisher implements Publisher { ), }, 'confirmed', - confirmedProvenance, + { kind: 'transaction', provenance: confirmedProvenance }, ) : generateConfirmedFullMetadata( confirmedMeta, @@ -4824,7 +4824,7 @@ export class DKGPublisher implements Publisher { assertionGraph: dataGraph, }, provenance ? 'confirmed' : 'tentative', - provenance, + provenance ? { kind: 'transaction', provenance } : undefined, ); await replaceLocallyTrustedKnowledgeAssetControls( this.store, diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index e5c91a985c..541df6051d 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -75,7 +75,7 @@ export { type ValidationResult, type ValidationOptions, } from './validation.js'; -export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, generateRfc64FinalizedGraphKnowledgeAssetMetadata, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; +export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type GraphKnowledgeAssetConfirmation, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; export { pruneSupersededAgentRegistryMeta, insertBoundedAgentRegistryMeta } from './agent-registry-meta-retention.js'; export { DKGPublisher, diff --git a/packages/publisher/src/metadata.ts b/packages/publisher/src/metadata.ts index ac25a009f2..68720eb101 100644 --- a/packages/publisher/src/metadata.ts +++ b/packages/publisher/src/metadata.ts @@ -106,6 +106,19 @@ export interface OnChainProvenance { chainId: string; } +export type GraphKnowledgeAssetConfirmation = + | Readonly<{ + kind: 'transaction'; + provenance: OnChainProvenance; + }> + | Readonly<{ + kind: 'finalized-materialization'; + provenance: Readonly<{ + batchId: bigint; + materializedVersion: MaterializedVersion; + }>; + }>; + export interface GraphKnowledgeAssetMetadata extends KCMetadata { assertionVersion: string | number | bigint; publicTripleCount: number; @@ -330,45 +343,39 @@ export function generateConfirmedFullMetadata( export function generateGraphKnowledgeAssetMetadata( meta: GraphKnowledgeAssetMetadata, status: 'tentative' | 'confirmed', - provenance?: OnChainProvenance, + confirmation?: GraphKnowledgeAssetConfirmation, ): Quad[] { const { scope, metaGraph, quads } = generateGraphKnowledgeAssetMetadataBase(meta); if (status === 'confirmed') { - if (!provenance) { - throw new Error('Confirmed graph-scoped KA metadata requires on-chain provenance'); + if (!confirmation) { + throw new Error('Confirmed graph-scoped KA metadata requires confirmation provenance'); + } + if (confirmation.kind === 'transaction') { + quads.push(...generateConfirmedMetadata( + scope.ual, + meta.contextGraphId, + confirmation.provenance, + )); + } else { + const { batchId, materializedVersion } = confirmation.provenance; + if (batchId < 0n) { + throw new Error('Finalized graph metadata batchId must be non-negative'); + } + quads.push( + getConfirmedStatusQuad(scope.ual, meta.contextGraphId), + mq(scope.ual, `${DKG}batchId`, intLit(batchId), metaGraph), + materializedVersionQuad(metaGraph, scope.ual, materializedVersion), + ); } - quads.push(...generateConfirmedMetadata(scope.ual, meta.contextGraphId, provenance)); } else { + if (confirmation !== undefined) { + throw new Error('Tentative graph-scoped KA metadata cannot carry confirmation provenance'); + } quads.push(mq(scope.ual, `${DKG}status`, lit('tentative'), metaGraph)); } return quads; } -/** - * Canonical confirmed metadata for RFC-64 finalized-chain materialization. - * The finalized inventory supplies a KA id and chain ordering point but no - * publication transaction hash, so this deliberately emits no synthetic - * transactionHash statement. - */ -export function generateRfc64FinalizedGraphKnowledgeAssetMetadata( - meta: GraphKnowledgeAssetMetadata, - confirmation: Readonly<{ - batchId: bigint; - materializedVersion: MaterializedVersion; - }>, -): Quad[] { - if (confirmation.batchId < 0n) { - throw new Error('RFC-64 finalized graph metadata batchId must be non-negative'); - } - const { scope, metaGraph, quads } = generateGraphKnowledgeAssetMetadataBase(meta); - quads.push( - getConfirmedStatusQuad(scope.ual, meta.contextGraphId), - mq(scope.ual, `${DKG}batchId`, intLit(confirmation.batchId), metaGraph), - materializedVersionQuad(metaGraph, scope.ual, confirmation.materializedVersion), - ); - return quads; -} - function generateGraphKnowledgeAssetMetadataBase( meta: GraphKnowledgeAssetMetadata, ): Readonly<{ diff --git a/packages/publisher/src/update-handler.ts b/packages/publisher/src/update-handler.ts index 75b9cd1a75..df4e257cfe 100644 --- a/packages/publisher/src/update-handler.ts +++ b/packages/publisher/src/update-handler.ts @@ -706,7 +706,7 @@ export class UpdateHandler { assertionGraph: vmGraph, }, 'confirmed', - provenance, + { kind: 'transaction', provenance }, ); const outcome = await withMaterializationLock( From 36ef27e88e9903e6237043c589328762e24c6153 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:12:34 +0200 Subject: [PATCH 262/292] test(rfc64): expose guarded mock CG fixture seam --- .../finalized-vm-harness-runtime.ts | 15 ++++++++------- packages/chain/src/mock-adapter.ts | 19 +++++++++++++++++++ .../test/mock-adapter-fixtures.unit.test.ts | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 packages/chain/test/mock-adapter-fixtures.unit.test.ts diff --git a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts index 9ca92ed289..89767f32b0 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts @@ -107,13 +107,14 @@ export async function startFinalizedVmHarnessRuntimeV1( config: Readonly, ): Promise> { const chainAdapter = new FinalizedVmHarnessMockChainAdapter(); - (chainAdapter as unknown as { nextContextGraphId: bigint }).nextContextGraphId = - BigInt(config.onChainContextGraphId); - const created = await chainAdapter.createOnChainContextGraph({ - accessPolicy: 0, - publishPolicy: 1, - nameHash: config.nameHash, - }); + const created = await chainAdapter.createOnChainContextGraphAtIdForTesting( + BigInt(config.onChainContextGraphId), + { + accessPolicy: 0, + publishPolicy: 1, + nameHash: config.nameHash, + }, + ); if (created.contextGraphId.toString() !== config.onChainContextGraphId) { throw new Error('mock chain created a different numeric context graph id'); } diff --git a/packages/chain/src/mock-adapter.ts b/packages/chain/src/mock-adapter.ts index bbf6d22e1c..0ec1d56cc8 100644 --- a/packages/chain/src/mock-adapter.ts +++ b/packages/chain/src/mock-adapter.ts @@ -922,6 +922,25 @@ export class MockChainAdapter implements ChainAdapter { }>(); private nextContextGraphId = 1n; + /** + * Explicit fixture seam for harnesses that must reproduce an existing + * on-chain numeric CG id. It is intentionally one-shot and must run before + * any CG is created so tests cannot rewrite live mock registry state. + */ + async createOnChainContextGraphAtIdForTesting( + contextGraphId: bigint, + params: CreateOnChainContextGraphParams, + ): Promise { + if (contextGraphId < 1n) { + throw new TypeError('Mock fixture context graph id must be positive'); + } + if (this.contextGraphs.size !== 0 || this.nextContextGraphId !== 1n) { + throw new Error('Mock fixture context graph id must be seeded before any CG exists'); + } + this.nextContextGraphId = contextGraphId; + return this.createOnChainContextGraph(params); + } + async createOnChainContextGraph(params: CreateOnChainContextGraphParams): Promise { if (params.accessPolicy === undefined || params.publishPolicy === undefined) { throw new Error( diff --git a/packages/chain/test/mock-adapter-fixtures.unit.test.ts b/packages/chain/test/mock-adapter-fixtures.unit.test.ts new file mode 100644 index 0000000000..0a55b4cb0f --- /dev/null +++ b/packages/chain/test/mock-adapter-fixtures.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { MockChainAdapter } from '../src/mock-adapter.js'; + +describe('MockChainAdapter explicit fixture seams', () => { + it('seeds a numeric context graph id once without exposing adapter internals', async () => { + const mock = new MockChainAdapter(); + const created = await mock.createOnChainContextGraphAtIdForTesting(14n, { + accessPolicy: 0, + publishPolicy: 1, + }); + expect(created.contextGraphId).toBe(14n); + await expect(mock.createOnChainContextGraphAtIdForTesting(15n, { + accessPolicy: 0, + publishPolicy: 1, + })).rejects.toThrow('before any CG exists'); + }); +}); From a54d608b2b9e2b09ddcba1230cfb9c06b2737fad Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:24:30 +0200 Subject: [PATCH 263/292] test(rfc64): share finalized VM loopback fixture --- .../finalized-vm-harness-runtime.ts | 142 +++-------- .../finalized-vm-loopback-fixture.ts | 227 ++++++++++++++++++ ...kg-agent-native-wiring.integration.test.ts | 174 ++++---------- 3 files changed, 306 insertions(+), 237 deletions(-) create mode 100644 devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.ts diff --git a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts index 89767f32b0..58c8291076 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts @@ -1,13 +1,18 @@ import { createServer, type Server, type ServerResponse } from 'node:http'; import type { AddressInfo } from 'node:net'; -import { MockChainAdapter } from '@origintrail-official/dkg-chain'; import { assertContextGraphIdV1, type Digest32V1, type EvmAddressV1, + type NetworkIdV1, } from '@origintrail-official/dkg-core'; -import { ethers } from 'ethers'; + +import { + FinalizedVmLoopbackMockChainAdapterV1, + createFinalizedVmLoopbackRpcV1, + type FinalizedVmLoopbackFixtureConfigV1, +} from './finalized-vm-loopback-fixture.js'; export const RFC64_GATE2_DEPLOYMENT = Object.freeze({ networkId: 'otp:20430', @@ -26,44 +31,12 @@ export interface FinalizedVmHarnessConfigV1 { } export interface FinalizedVmHarnessRuntimeV1 { - readonly chainAdapter: MockChainAdapter; + readonly chainAdapter: FinalizedVmLoopbackMockChainAdapterV1; readonly rpcUrl: string; close(): Promise; } -const CONTEXT_GRAPH_INTERFACE = new ethers.Interface([ - 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', - 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', - 'function isContextGraphActive(uint256 contextGraphId) view returns (bool)', - 'function getContextGraphKaCount(uint256 contextGraphId) view returns (uint256)', - 'function getContextGraphKaAt(uint256 contextGraphId, uint256 ordinal) view returns (uint256)', -]); -const KNOWLEDGE_ASSET_INTERFACE = new ethers.Interface([ - 'function getKnowledgeAssetUpdateContext(uint256 id) view returns (uint256 merkleRootsCount, uint256 minted, uint88 byteSize, uint40 endEpoch, uint96 tokenAmount, bool isImmutable, uint32 merkleLeafCount)', - 'function getLatestMerkleRoot(uint256 id) view returns (bytes32)', - 'function getLatestMerkleRootAuthor(uint256 id) view returns (address)', - 'function getLatestMerkleRootPublisher(uint256 id) view returns (address)', -]); const FINALIZED_BLOCK_HASH = `0x${'77'.repeat(32)}`; -const ZERO_ADDRESS = ethers.ZeroAddress.toLowerCase(); - -class FinalizedVmHarnessMockChainAdapter extends MockChainAdapter { - constructor() { - super(RFC64_GATE2_DEPLOYMENT.networkId); - } - - override async getEvmChainId(): Promise { - return BigInt(RFC64_GATE2_DEPLOYMENT.assertedAtChainId); - } - - override async getKnowledgeAssetsLifecycleAddress(): Promise { - return RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address; - } - - override async getDKGKnowledgeAssetsAddress(): Promise { - return RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address; - } -} export function parseFinalizedVmHarnessConfigV1( input: string, @@ -106,7 +79,29 @@ export function parseFinalizedVmHarnessConfigV1( export async function startFinalizedVmHarnessRuntimeV1( config: Readonly, ): Promise> { - const chainAdapter = new FinalizedVmHarnessMockChainAdapter(); + const fixture = Object.freeze({ + accessPolicy: 0, + active: true, + assertedAtChainId: RFC64_GATE2_DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: + RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address as EvmAddressV1, + assets: Object.freeze([Object.freeze({ + assertionRoot: config.assertionRoot, + assertionVersion: config.assertionVersion, + authorAddress: config.authorAddress, + kaId: config.kaId, + publisherAddress: '0x6666666666666666666666666666666666666666' as EvmAddressV1, + })]), + blockHash: FINALIZED_BLOCK_HASH as Digest32V1, + blockNumberQuantity: '0x7b', + nameHash: config.nameHash, + networkId: RFC64_GATE2_DEPLOYMENT.networkId as NetworkIdV1, + onChainContextGraphId: config.onChainContextGraphId, + ownerAddress: config.authorAddress, + publishPolicy: 1, + } satisfies FinalizedVmLoopbackFixtureConfigV1); + const rpcFixture = createFinalizedVmLoopbackRpcV1(fixture); + const chainAdapter = new FinalizedVmLoopbackMockChainAdapterV1(fixture); const created = await chainAdapter.createOnChainContextGraphAtIdForTesting( BigInt(config.onChainContextGraphId), { @@ -141,23 +136,7 @@ export async function startFinalizedVmHarnessRuntimeV1( ); const method = requiredString(call.method, 'finalized VM JSON-RPC method'); const params = plainArray(call.params, 'finalized VM JSON-RPC params'); - let result: unknown; - switch (method) { - case 'eth_chainId': - result = '0x4fce'; - break; - case 'eth_getBlockByNumber': - result = { number: '0x7b', hash: FINALIZED_BLOCK_HASH }; - break; - case 'eth_getCode': - result = '0x6000'; - break; - case 'eth_call': - result = finalizedVmEthCallResult(params, config); - break; - default: - throw new Error(`unexpected finalized VM JSON-RPC method ${method}`); - } + const result = rpcFixture.respond(method, params); sendRpcResponse(response, call.id, { result }); } catch (error) { sendRpcResponse(response, null, { @@ -189,63 +168,6 @@ export async function startFinalizedVmHarnessRuntimeV1( }); } -function finalizedVmEthCallResult( - params: readonly unknown[], - config: Readonly, -): string { - const call = plainRecord(params[0], 'finalized VM eth_call object'); - const data = requiredString(call.data, 'finalized VM eth_call data'); - if (data === '0x') return '0x'; - const selector = data.slice(0, 10); - switch (selector) { - case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraph')!.selector: - return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('getContextGraph', [ - config.authorAddress, - [], - 0n, - true, - 1n, - 0, - 1, - ZERO_ADDRESS, - 0n, - ]); - case CONTEXT_GRAPH_INTERFACE.getFunction('getNameHash')!.selector: - return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('getNameHash', [config.nameHash]); - case CONTEXT_GRAPH_INTERFACE.getFunction('isContextGraphActive')!.selector: - return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('isContextGraphActive', [true]); - case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraphKaCount')!.selector: - return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('getContextGraphKaCount', [1n]); - case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraphKaAt')!.selector: - return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult( - 'getContextGraphKaAt', - [BigInt(config.kaId)], - ); - case KNOWLEDGE_ASSET_INTERFACE.getFunction('getKnowledgeAssetUpdateContext')!.selector: - return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( - 'getKnowledgeAssetUpdateContext', - [BigInt(config.assertionVersion), 0n, 0n, 0n, 0n, false, 0], - ); - case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRoot')!.selector: - return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( - 'getLatestMerkleRoot', - [config.assertionRoot], - ); - case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRootAuthor')!.selector: - return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( - 'getLatestMerkleRootAuthor', - [config.authorAddress], - ); - case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRootPublisher')!.selector: - return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( - 'getLatestMerkleRootPublisher', - ['0x6666666666666666666666666666666666666666'], - ); - default: - throw new Error(`unexpected finalized VM eth_call selector ${selector}`); - } -} - function sendRpcResponse( response: ServerResponse, id: unknown, diff --git a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.ts b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.ts new file mode 100644 index 0000000000..321dc9a39b --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.ts @@ -0,0 +1,227 @@ +import { MockChainAdapter } from '@origintrail-official/dkg-chain'; +import type { + Digest32V1, + EvmAddressV1, + NetworkIdV1, +} from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +export interface FinalizedVmLoopbackAssetV1 { + readonly assertionRoot: Digest32V1; + readonly assertionVersion: string; + readonly authorAddress: EvmAddressV1; + readonly kaId: string; + readonly publisherAddress: EvmAddressV1; +} + +export interface FinalizedVmLoopbackFixtureConfigV1 { + readonly accessPolicy: 0 | 1; + readonly active: boolean; + readonly assertedAtChainId: string; + readonly assertedAtKav10Address: EvmAddressV1; + readonly assets: readonly FinalizedVmLoopbackAssetV1[]; + readonly blockHash: Digest32V1; + readonly blockNumberQuantity: string; + readonly nameHash: Digest32V1; + readonly networkId: NetworkIdV1; + readonly onChainContextGraphId: string; + readonly ownerAddress: EvmAddressV1; + readonly publishPolicy: 0 | 1; +} + +export interface FinalizedVmLoopbackRpcCallV1 { + readonly method: string; + readonly params: readonly unknown[]; +} + +export interface FinalizedVmLoopbackRpcV1 { + readonly calls: readonly FinalizedVmLoopbackRpcCallV1[]; + respond(method: string, params: readonly unknown[]): unknown; +} + +const CONTEXT_GRAPH_INTERFACE = new ethers.Interface([ + 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', + 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', + 'function isContextGraphActive(uint256 contextGraphId) view returns (bool)', + 'function getContextGraphKaCount(uint256 contextGraphId) view returns (uint256)', + 'function getContextGraphKaAt(uint256 contextGraphId, uint256 ordinal) view returns (uint256)', +]); +const KNOWLEDGE_ASSET_INTERFACE = new ethers.Interface([ + 'function getKnowledgeAssetUpdateContext(uint256 id) view returns (uint256 merkleRootsCount, uint256 minted, uint88 byteSize, uint40 endEpoch, uint96 tokenAmount, bool isImmutable, uint32 merkleLeafCount)', + 'function getLatestMerkleRoot(uint256 id) view returns (bytes32)', + 'function getLatestMerkleRootAuthor(uint256 id) view returns (address)', + 'function getLatestMerkleRootPublisher(uint256 id) view returns (address)', +]); + +/** Mock adapter whose chain identity matches the loopback finalized-RPC lane. */ +export class FinalizedVmLoopbackMockChainAdapterV1 extends MockChainAdapter { + readonly #fixture: FinalizedVmLoopbackFixtureConfigV1; + + constructor(fixture: FinalizedVmLoopbackFixtureConfigV1) { + super(fixture.networkId); + this.#fixture = fixture; + } + + override async getEvmChainId(): Promise { + return BigInt(this.#fixture.assertedAtChainId); + } + + override async getKnowledgeAssetsLifecycleAddress(): Promise { + return this.#fixture.assertedAtKav10Address; + } + + override async getDKGKnowledgeAssetsAddress(): Promise { + return this.#fixture.assertedAtKav10Address; + } +} + +/** + * One protocol-shaped finalized RPC table shared by the integration test and + * the separate-process devnet proof. Callers own only fixture data and I/O. + */ +export function createFinalizedVmLoopbackRpcV1( + fixture: FinalizedVmLoopbackFixtureConfigV1, +): FinalizedVmLoopbackRpcV1 { + const calls: FinalizedVmLoopbackRpcCallV1[] = []; + const assets = new Map(fixture.assets.map((asset) => [asset.kaId, asset])); + const respond = (method: string, params: readonly unknown[]): unknown => { + calls.push(Object.freeze({ method, params: Object.freeze([...params]) })); + switch (method) { + case 'eth_chainId': + return ethers.toQuantity(BigInt(fixture.assertedAtChainId)); + case 'eth_getBlockByNumber': + return { number: fixture.blockNumberQuantity, hash: fixture.blockHash }; + case 'eth_getCode': + return '0x6000'; + case 'eth_call': + return finalizedVmEthCallResult(params, fixture, assets); + default: + throw new Error(`unexpected finalized VM JSON-RPC method ${method}`); + } + }; + return Object.freeze({ calls, respond }); +} + +function finalizedVmEthCallResult( + params: readonly unknown[], + fixture: FinalizedVmLoopbackFixtureConfigV1, + assets: ReadonlyMap, +): string { + const call = plainRecord(params[0], 'finalized VM eth_call object'); + const data = requiredString(call.data, 'finalized VM eth_call data'); + if (data === '0x') return '0x'; + const selector = data.slice(0, 10); + switch (selector) { + case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraph')!.selector: + assertContextGraphCall('getContextGraph', data, fixture.onChainContextGraphId); + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('getContextGraph', [ + fixture.ownerAddress, + [], + 0n, + fixture.active, + 1n, + fixture.accessPolicy, + fixture.publishPolicy, + fixture.publishPolicy === 1 ? ethers.ZeroAddress : fixture.ownerAddress, + 0n, + ]); + case CONTEXT_GRAPH_INTERFACE.getFunction('getNameHash')!.selector: + assertContextGraphCall('getNameHash', data, fixture.onChainContextGraphId); + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult('getNameHash', [fixture.nameHash]); + case CONTEXT_GRAPH_INTERFACE.getFunction('isContextGraphActive')!.selector: + assertContextGraphCall('isContextGraphActive', data, fixture.onChainContextGraphId); + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult( + 'isContextGraphActive', + [fixture.active], + ); + case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraphKaCount')!.selector: + assertContextGraphCall('getContextGraphKaCount', data, fixture.onChainContextGraphId); + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult( + 'getContextGraphKaCount', + [BigInt(fixture.assets.length)], + ); + case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraphKaAt')!.selector: { + const [contextGraphId, ordinal] = CONTEXT_GRAPH_INTERFACE.decodeFunctionData( + 'getContextGraphKaAt', + data, + ); + assertNumericId(contextGraphId, fixture.onChainContextGraphId, 'context graph'); + const asset = fixture.assets[Number(ordinal)]; + if (asset === undefined) throw new Error(`unknown finalized VM ordinal ${ordinal}`); + return CONTEXT_GRAPH_INTERFACE.encodeFunctionResult( + 'getContextGraphKaAt', + [BigInt(asset.kaId)], + ); + } + case KNOWLEDGE_ASSET_INTERFACE.getFunction('getKnowledgeAssetUpdateContext')!.selector: { + const asset = readAssetCall('getKnowledgeAssetUpdateContext', data, assets); + return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( + 'getKnowledgeAssetUpdateContext', + [BigInt(asset.assertionVersion), 0n, 0n, 0n, 0n, false, 0], + ); + } + case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRoot')!.selector: { + const asset = readAssetCall('getLatestMerkleRoot', data, assets); + return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( + 'getLatestMerkleRoot', + [asset.assertionRoot], + ); + } + case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRootAuthor')!.selector: { + const asset = readAssetCall('getLatestMerkleRootAuthor', data, assets); + return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( + 'getLatestMerkleRootAuthor', + [asset.authorAddress], + ); + } + case KNOWLEDGE_ASSET_INTERFACE.getFunction('getLatestMerkleRootPublisher')!.selector: { + const asset = readAssetCall('getLatestMerkleRootPublisher', data, assets); + return KNOWLEDGE_ASSET_INTERFACE.encodeFunctionResult( + 'getLatestMerkleRootPublisher', + [asset.publisherAddress], + ); + } + default: + throw new Error(`unexpected finalized VM eth_call selector ${selector}`); + } +} + +function assertContextGraphCall( + method: string, + data: string, + expectedId: string, +): void { + const [contextGraphId] = CONTEXT_GRAPH_INTERFACE.decodeFunctionData(method, data); + assertNumericId(contextGraphId, expectedId, 'context graph'); +} + +function readAssetCall( + method: string, + data: string, + assets: ReadonlyMap, +): FinalizedVmLoopbackAssetV1 { + const [kaId] = KNOWLEDGE_ASSET_INTERFACE.decodeFunctionData(method, data); + const asset = assets.get(String(kaId)); + if (asset === undefined) throw new Error(`unknown finalized VM KA ${kaId}`); + return asset; +} + +function assertNumericId(actual: unknown, expected: string, label: string): void { + if (String(actual) !== expected) { + throw new Error(`unexpected finalized VM ${label} id ${String(actual)}`); + } +} + +function plainRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value as Record; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) { + throw new TypeError(`${label} must be a bounded non-empty string`); + } + return value; +} diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index f7ee0ba18f..ba5b2f3e48 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -20,7 +20,6 @@ import { type NetworkIdV1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; -import { MockChainAdapter } from '@origintrail-official/dkg-chain'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -36,6 +35,11 @@ import { sendJsonRpcError, sendJsonRpcResult, } from '../../chain/test/loopback-rpc-harness.js'; +import { + FinalizedVmLoopbackMockChainAdapterV1, + createFinalizedVmLoopbackRpcV1, + type FinalizedVmLoopbackFixtureConfigV1, +} from '../../../devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.js'; const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const NETWORK_ID = 'otp:20430' as NetworkIdV1; @@ -70,38 +74,6 @@ const agents: DKGAgent[] = []; const tempDirs: string[] = []; const rpcHarness = createLoopbackJsonRpcTestHarness(); -const CG = new ethers.Interface([ - 'function getContextGraph(uint256 contextGraphId) view returns (address owner, address[] participantAgents, uint256 metadataBatchId, bool active, uint256 createdAt, uint8 accessPolicy, uint8 publishPolicy, address publishAuthority, uint256 publishAuthorityAccountId)', - 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', - 'function isContextGraphActive(uint256 contextGraphId) view returns (bool)', - 'function getContextGraphKaCount(uint256 contextGraphId) view returns (uint256)', - 'function getContextGraphKaAt(uint256 contextGraphId, uint256 ordinal) view returns (uint256)', -]); -const KA = new ethers.Interface([ - 'function getKnowledgeAssetUpdateContext(uint256 id) view returns (uint256 merkleRootsCount, uint256 minted, uint88 byteSize, uint40 endEpoch, uint96 tokenAmount, bool isImmutable, uint32 merkleLeafCount)', - 'function getLatestMerkleRoot(uint256 id) view returns (bytes32)', - 'function getLatestMerkleRootAuthor(uint256 id) view returns (address)', - 'function getLatestMerkleRootPublisher(uint256 id) view returns (address)', -]); - -class FinalizedVmMockChainAdapter extends MockChainAdapter { - constructor() { - super(NETWORK_ID); - } - - override async getEvmChainId(): Promise { - return 20_430n; - } - - override async getKnowledgeAssetsLifecycleAddress(): Promise { - return KAV10; - } - - override async getDKGKnowledgeAssetsAddress(): Promise { - return KAV10; - } -} - afterEach(async () => { for (const agent of agents.splice(0)) { try { await agent.stop(); } catch { /* best-effort */ } @@ -117,7 +89,7 @@ async function startNativeAgent( accessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1, finalizedRuntime?: Readonly<{ rpcUrl: string; - chainAdapter: FinalizedVmMockChainAdapter; + chainAdapter: FinalizedVmLoopbackMockChainAdapterV1; initialSubscription?: ContextGraphIdV1; }>, ): Promise { @@ -555,101 +527,49 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { const kaNumber = 7n; const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); const nameHash = ethers.keccak256(ethers.toUtf8Bytes(CONTEXT_GRAPH_ID)).toLowerCase(); - const rpc = await rpcHarness.start((call, response) => { - switch (call.method) { - case 'eth_chainId': - sendJsonRpcResult(response, call, ethers.toQuantity(20_430)); - return; - case 'eth_getBlockByNumber': - sendJsonRpcResult(response, call, { - number: '0x7b', - hash: FINALIZED_BLOCK_HASH, - }); - return; - case 'eth_getCode': - sendJsonRpcResult(response, call, '0x6000'); - return; - case 'eth_call': { - const request = call.params[0] as { readonly data?: string }; - if (request.data === '0x') { - sendJsonRpcResult(response, call, '0x'); - return; - } - const selector = request.data?.slice(0, 10); - switch (selector) { - case CG.getFunction('getContextGraph')!.selector: - sendJsonRpcResult(response, call, CG.encodeFunctionResult( - 'getContextGraph', - [AUTHOR, [], 0n, true, 1n, 0, 1, ethers.ZeroAddress, 0n], - )); - return; - case CG.getFunction('getNameHash')!.selector: - sendJsonRpcResult(response, call, CG.encodeFunctionResult('getNameHash', [nameHash])); - return; - case CG.getFunction('isContextGraphActive')!.selector: - sendJsonRpcResult( - response, - call, - CG.encodeFunctionResult('isContextGraphActive', [true]), - ); - return; - case CG.getFunction('getContextGraphKaCount')!.selector: - sendJsonRpcResult( - response, - call, - CG.encodeFunctionResult('getContextGraphKaCount', [1n]), - ); - return; - case CG.getFunction('getContextGraphKaAt')!.selector: - sendJsonRpcResult( - response, - call, - CG.encodeFunctionResult('getContextGraphKaAt', [BigInt(kaId)]), - ); - return; - case KA.getFunction('getKnowledgeAssetUpdateContext')!.selector: - sendJsonRpcResult(response, call, KA.encodeFunctionResult( - 'getKnowledgeAssetUpdateContext', - [1n, 0n, 0n, 0n, 0n, false, 0], - )); - return; - case KA.getFunction('getLatestMerkleRoot')!.selector: - sendJsonRpcResult( - response, - call, - KA.encodeFunctionResult('getLatestMerkleRoot', [ASSERTION_ROOT]), - ); - return; - case KA.getFunction('getLatestMerkleRootAuthor')!.selector: - sendJsonRpcResult( - response, - call, - KA.encodeFunctionResult('getLatestMerkleRootAuthor', [AUTHOR]), - ); - return; - case KA.getFunction('getLatestMerkleRootPublisher')!.selector: - sendJsonRpcResult(response, call, KA.encodeFunctionResult( - 'getLatestMerkleRootPublisher', - [`0x${'66'.repeat(20)}`], - )); - return; - default: - sendJsonRpcError(response, call, -32602, `unexpected eth_call ${selector}`); - return; - } - } - default: - sendJsonRpcError(response, call, -32601, 'method not found'); - } - }); - const authorChain = new FinalizedVmMockChainAdapter(); - const receiverChain = new FinalizedVmMockChainAdapter(); - (receiverChain as any).nextContextGraphId = BigInt(ON_CHAIN_CONTEXT_GRAPH_ID); - const created = await receiverChain.createOnChainContextGraph({ + const fixture = Object.freeze({ accessPolicy: 0, + active: true, + assertedAtChainId: NATIVE_DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10, + assets: Object.freeze([Object.freeze({ + assertionRoot: ASSERTION_ROOT, + assertionVersion: '1', + authorAddress: AUTHOR, + kaId, + publisherAddress: `0x${'66'.repeat(20)}` as EvmAddressV1, + })]), + blockHash: FINALIZED_BLOCK_HASH, + blockNumberQuantity: '0x7b', + nameHash: nameHash as Digest32V1, + networkId: NETWORK_ID, + onChainContextGraphId: ON_CHAIN_CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, publishPolicy: 1, - nameHash, + } satisfies FinalizedVmLoopbackFixtureConfigV1); + const finalizedRpc = createFinalizedVmLoopbackRpcV1(fixture); + const rpc = await rpcHarness.start((call, response) => { + try { + sendJsonRpcResult(response, call, finalizedRpc.respond(call.method, call.params)); + } catch (cause) { + sendJsonRpcError( + response, + call, + -32602, + cause instanceof Error ? cause.message : String(cause), + ); + } }); + const authorChain = new FinalizedVmLoopbackMockChainAdapterV1(fixture); + const receiverChain = new FinalizedVmLoopbackMockChainAdapterV1(fixture); + const created = await receiverChain.createOnChainContextGraphAtIdForTesting( + BigInt(ON_CHAIN_CONTEXT_GRAPH_ID), + { + accessPolicy: 0, + publishPolicy: 1, + nameHash, + }, + ); expect(created.contextGraphId.toString()).toBe(ON_CHAIN_CONTEXT_GRAPH_ID); const [author, receiver] = await Promise.all([ startNativeAgent( @@ -755,7 +675,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { currentCatalogHeadDigest: successor.headObjectDigest, inventoryRowCount: '1', }); - expect(rpc.calls.some(({ method }) => method === 'eth_call')).toBe(true); + expect(finalizedRpc.calls.some(({ method }) => method === 'eth_call')).toBe(true); }, 60_000); it('exposes bounded typed failure evidence when wire scope differs from local deployment', async () => { From b76eb6dd49449680417e9356fd3301833e785d03 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:38:45 +0200 Subject: [PATCH 264/292] refactor(publisher): type graph confirmation state --- packages/agent/src/finalization-handler.ts | 6 +- packages/agent/src/gossip-publish-handler.ts | 22 ++++--- .../finalized-vm-store-materializer-v1.ts | 17 +++-- .../rfc64-graph-metadata-state.typecheck.ts | 17 +++++ .../rootless-durable-bounded-progress.test.ts | 2 +- ...ess-durable-skips-legacy-partition.test.ts | 2 +- .../sync-control-metadata-admission.test.ts | 8 +-- .../test/sync-durable-worker-wire.test.ts | 2 +- .../agent/test/sync-verify-rootless.test.ts | 2 +- packages/publisher/src/dkg-publisher.ts | 16 +++-- packages/publisher/src/index.ts | 2 +- packages/publisher/src/metadata.ts | 19 +++--- packages/publisher/src/publish-handler.ts | 2 +- packages/publisher/src/update-handler.ts | 6 +- packages/publisher/test/metadata.test.ts | 66 +++++++++++++++++++ .../publisher/test/rootless-access.test.ts | 2 +- 16 files changed, 145 insertions(+), 46 deletions(-) create mode 100644 packages/agent/test/rfc64-graph-metadata-state.typecheck.ts diff --git a/packages/agent/src/finalization-handler.ts b/packages/agent/src/finalization-handler.ts index 370842b73c..7eb1a341b0 100644 --- a/packages/agent/src/finalization-handler.ts +++ b/packages/agent/src/finalization-handler.ts @@ -1543,8 +1543,10 @@ export class FinalizationHandler { privateTripleCount, assertionGraph: vmGraph, }, - 'confirmed', - { kind: 'transaction', provenance }, + { + status: 'confirmed', + confirmation: { kind: 'transaction', provenance }, + }, ); await this.store.deleteByPattern({ graph: metaGraph, subject: scope.ual }); await this.store.insert(metadata); diff --git a/packages/agent/src/gossip-publish-handler.ts b/packages/agent/src/gossip-publish-handler.ts index f8910b2bde..99c8420963 100644 --- a/packages/agent/src/gossip-publish-handler.ts +++ b/packages/agent/src/gossip-publish-handler.ts @@ -586,20 +586,22 @@ export class GossipPublishHandler { : {}), assertionGraph: dataGraph, }, - verified ? 'confirmed' : 'tentative', verified ? { - kind: 'transaction', - provenance: { - txHash, - blockNumber, - blockTimestamp: Math.floor(Date.now() / 1000), - publisherAddress: request.publisherAddress, - batchId: startKAId, - chainId: request.chainId, + status: 'confirmed', + confirmation: { + kind: 'transaction', + provenance: { + txHash, + blockNumber, + blockTimestamp: Math.floor(Date.now() / 1000), + publisherAddress: request.publisherAddress, + batchId: startKAId, + chainId: request.chainId, + }, }, } - : undefined, + : { status: 'tentative' }, ); const metaGraph = contextGraphMetaGraphUri(request.contextGraphId); const incomingVersion = { diff --git a/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts b/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts index 9870fbb52d..f8a7d74b0a 100644 --- a/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-store-materializer-v1.ts @@ -117,13 +117,16 @@ export function createFinalizedVmStoreMaterializerV1( ...(privateMerkleRoot ? { privateMerkleRoot } : {}), assertionGraph: vmGraph, ...(subGraphName ? { subGraphName } : {}), - }, 'confirmed', { - kind: 'finalized-materialization', - provenance: { - batchId: BigInt(request.candidate.kaId), - materializedVersion: { - blockNumber: boundedMaterializedBlockNumber(request.candidate.finalizedBlockNumber), - txIndex: 0, + }, { + status: 'confirmed', + confirmation: { + kind: 'finalized-materialization', + provenance: { + batchId: BigInt(request.candidate.kaId), + materializedVersion: { + blockNumber: boundedMaterializedBlockNumber(request.candidate.finalizedBlockNumber), + txIndex: 0, + }, }, }, }); diff --git a/packages/agent/test/rfc64-graph-metadata-state.typecheck.ts b/packages/agent/test/rfc64-graph-metadata-state.typecheck.ts new file mode 100644 index 0000000000..ffc9f39c3c --- /dev/null +++ b/packages/agent/test/rfc64-graph-metadata-state.typecheck.ts @@ -0,0 +1,17 @@ +import { + generateGraphKnowledgeAssetMetadata, + type GraphKnowledgeAssetConfirmation, + type GraphKnowledgeAssetMetadata, +} from '@origintrail-official/dkg-publisher'; + +declare const meta: GraphKnowledgeAssetMetadata; +declare const confirmation: GraphKnowledgeAssetConfirmation; + +generateGraphKnowledgeAssetMetadata(meta, { status: 'tentative' }); +generateGraphKnowledgeAssetMetadata(meta, { status: 'confirmed', confirmation }); + +// @ts-expect-error confirmed metadata cannot omit provenance +generateGraphKnowledgeAssetMetadata(meta, { status: 'confirmed' }); + +// @ts-expect-error tentative metadata cannot carry confirmation provenance +generateGraphKnowledgeAssetMetadata(meta, { status: 'tentative', confirmation }); diff --git a/packages/agent/test/rootless-durable-bounded-progress.test.ts b/packages/agent/test/rootless-durable-bounded-progress.test.ts index ea1557a231..f820c6d2d9 100644 --- a/packages/agent/test/rootless-durable-bounded-progress.test.ts +++ b/packages/agent/test/rootless-durable-bounded-progress.test.ts @@ -55,7 +55,7 @@ function asset(kaNumber: number, tripleCount = 4): AssetFixture { publicTripleCount: payload.length, privateTripleCount: 0, assertionGraph: graph, - }, 'tentative'); + }, { status: 'tentative' }); return { graph, payload, meta }; } diff --git a/packages/agent/test/rootless-durable-skips-legacy-partition.test.ts b/packages/agent/test/rootless-durable-skips-legacy-partition.test.ts index 9ea94c13eb..9a08f2f2b8 100644 --- a/packages/agent/test/rootless-durable-skips-legacy-partition.test.ts +++ b/packages/agent/test/rootless-durable-skips-legacy-partition.test.ts @@ -50,7 +50,7 @@ describe('rootless durable legacy partition bypass', () => { publicTripleCount: data.length, privateTripleCount: 0, assertionGraph: graph, - }, 'tentative'); + }, { status: 'tentative' }); const result = selectVerifiedDurableSyncQuads(data, meta, false, { kind: 'changelogPage', diff --git a/packages/agent/test/sync-control-metadata-admission.test.ts b/packages/agent/test/sync-control-metadata-admission.test.ts index f33fd4cfad..76d5ea18c7 100644 --- a/packages/agent/test/sync-control-metadata-admission.test.ts +++ b/packages/agent/test/sync-control-metadata-admission.test.ts @@ -55,7 +55,7 @@ describe('durable sync control metadata admission', () => { assertionVersion: '1', publicTripleCount: data.length, assertionGraph, - }, 'tentative'); + }, { status: 'tentative' }); const authenticatedBatch = quad(UAL, `${DKG}batchId`, integer(41n)); const poison = 'urn:unverified:delta-control'; const unverifiedControls = [ @@ -108,7 +108,7 @@ describe('durable sync control metadata admission', () => { assertionVersion: '1', publicTripleCount: data.length, assertionGraph, - }, 'tentative'); + }, { status: 'tentative' }); const lifecycle = `urn:dkg:assertion:${CONTEXT_GRAPH_ID}:0x00000000000000000000000000000000000000AB:asset`; const lifecycleRows = [ quad(lifecycle, `${DKG}contentScopeVersion`, integer(2n)), @@ -144,7 +144,7 @@ describe('durable sync control metadata admission', () => { assertionVersion: '1', publicTripleCount: data.length, assertionGraph, - }, 'tentative'); + }, { status: 'tentative' }); const lifecycle = 'urn:dkg:assertion:conflicting-owner'; const descriptiveRows = [ quad(lifecycle, `${DKG}contentScopeVersion`, integer(2n)), @@ -201,7 +201,7 @@ describe('durable sync control metadata admission', () => { assertionVersion: '1', publicTripleCount: payload.length, assertionGraph, - }, 'tentative'), + }, { status: 'tentative' }), quad(UAL, `${DKG}batchId`, integer(5n)), ]; const selection = selectVerifiedDurableSyncQuads( diff --git a/packages/agent/test/sync-durable-worker-wire.test.ts b/packages/agent/test/sync-durable-worker-wire.test.ts index 49ca1a7c56..31036e5781 100644 --- a/packages/agent/test/sync-durable-worker-wire.test.ts +++ b/packages/agent/test/sync-durable-worker-wire.test.ts @@ -45,7 +45,7 @@ function graphScopedAsset(ual: string, name: string, batchId?: bigint) { privateTripleCount: 0, assertionGraph, }, - 'tentative', + { status: 'tentative' }, ); if (batchId !== undefined) { meta.push({ diff --git a/packages/agent/test/sync-verify-rootless.test.ts b/packages/agent/test/sync-verify-rootless.test.ts index e86002f3d8..efbdf0b9ae 100644 --- a/packages/agent/test/sync-verify-rootless.test.ts +++ b/packages/agent/test/sync-verify-rootless.test.ts @@ -90,7 +90,7 @@ function fixture(options: { assertionGraph, subGraphName: options.subGraphName, }, - 'tentative', + { status: 'tentative' }, ); if (options.batchId !== undefined) { meta.push(quad( diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index d83ab879a8..e04be56c02 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -3551,7 +3551,7 @@ export class DKGPublisher implements Publisher { ...(privateRoots[0] ? { privateMerkleRoot: privateRoots[0] } : {}), assertionGraph: dataGraph, }, - 'tentative', + { status: 'tentative' }, ) : generateTentativeMetadata(commonMeta, kaMetadata); if (options.targetMetaGraphUri) { @@ -4027,8 +4027,10 @@ export class DKGPublisher implements Publisher { options.subGraphName, ), }, - 'confirmed', - { kind: 'transaction', provenance: confirmedProvenance }, + { + status: 'confirmed', + confirmation: { kind: 'transaction', provenance: confirmedProvenance }, + }, ) : generateConfirmedFullMetadata( confirmedMeta, @@ -4823,8 +4825,12 @@ export class DKGPublisher implements Publisher { : {}), assertionGraph: dataGraph, }, - provenance ? 'confirmed' : 'tentative', - provenance ? { kind: 'transaction', provenance } : undefined, + provenance + ? { + status: 'confirmed', + confirmation: { kind: 'transaction', provenance }, + } + : { status: 'tentative' }, ); await replaceLocallyTrustedKnowledgeAssetControls( this.store, diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index 541df6051d..7af1356d13 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -75,7 +75,7 @@ export { type ValidationResult, type ValidationOptions, } from './validation.js'; -export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type GraphKnowledgeAssetConfirmation, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; +export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type GraphKnowledgeAssetConfirmation, type GraphKnowledgeAssetMetadataState, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; export { pruneSupersededAgentRegistryMeta, insertBoundedAgentRegistryMeta } from './agent-registry-meta-retention.js'; export { DKGPublisher, diff --git a/packages/publisher/src/metadata.ts b/packages/publisher/src/metadata.ts index 68720eb101..62745350e6 100644 --- a/packages/publisher/src/metadata.ts +++ b/packages/publisher/src/metadata.ts @@ -119,6 +119,13 @@ export type GraphKnowledgeAssetConfirmation = }>; }>; +export type GraphKnowledgeAssetMetadataState = + | Readonly<{ readonly status: 'tentative' }> + | Readonly<{ + readonly status: 'confirmed'; + readonly confirmation: GraphKnowledgeAssetConfirmation; + }>; + export interface GraphKnowledgeAssetMetadata extends KCMetadata { assertionVersion: string | number | bigint; publicTripleCount: number; @@ -342,14 +349,11 @@ export function generateConfirmedFullMetadata( */ export function generateGraphKnowledgeAssetMetadata( meta: GraphKnowledgeAssetMetadata, - status: 'tentative' | 'confirmed', - confirmation?: GraphKnowledgeAssetConfirmation, + state: GraphKnowledgeAssetMetadataState, ): Quad[] { const { scope, metaGraph, quads } = generateGraphKnowledgeAssetMetadataBase(meta); - if (status === 'confirmed') { - if (!confirmation) { - throw new Error('Confirmed graph-scoped KA metadata requires confirmation provenance'); - } + if (state.status === 'confirmed') { + const { confirmation } = state; if (confirmation.kind === 'transaction') { quads.push(...generateConfirmedMetadata( scope.ual, @@ -368,9 +372,6 @@ export function generateGraphKnowledgeAssetMetadata( ); } } else { - if (confirmation !== undefined) { - throw new Error('Tentative graph-scoped KA metadata cannot carry confirmation provenance'); - } quads.push(mq(scope.ual, `${DKG}status`, lit('tentative'), metaGraph)); } return quads; diff --git a/packages/publisher/src/publish-handler.ts b/packages/publisher/src/publish-handler.ts index 97f3a5dbc0..e9b1c4fe6a 100644 --- a/packages/publisher/src/publish-handler.ts +++ b/packages/publisher/src/publish-handler.ts @@ -586,7 +586,7 @@ export class PublishHandler { : {}), assertionGraph: vmGraph, }, - 'tentative', + { status: 'tentative' }, ); const metaGraph = this.graphManager.metaGraphUri(request.contextGraphId); const applyOutcome = await withMaterializationLock( diff --git a/packages/publisher/src/update-handler.ts b/packages/publisher/src/update-handler.ts index df4e257cfe..96412ce255 100644 --- a/packages/publisher/src/update-handler.ts +++ b/packages/publisher/src/update-handler.ts @@ -705,8 +705,10 @@ export class UpdateHandler { : {}), assertionGraph: vmGraph, }, - 'confirmed', - { kind: 'transaction', provenance }, + { + status: 'confirmed', + confirmation: { kind: 'transaction', provenance }, + }, ); const outcome = await withMaterializationLock( diff --git a/packages/publisher/test/metadata.test.ts b/packages/publisher/test/metadata.test.ts index 6e401b07f2..1db172a8a7 100644 --- a/packages/publisher/test/metadata.test.ts +++ b/packages/publisher/test/metadata.test.ts @@ -6,6 +6,7 @@ import { getConfirmedStatusQuad, generateConfirmedMetadata, generateConfirmedFullMetadata, + generateGraphKnowledgeAssetMetadata, generateShareMetadata, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, @@ -22,6 +23,7 @@ import { PROV_WAS_REVISION_OF, type KCMetadata, type KAMetadata, + type GraphKnowledgeAssetMetadata, type OnChainProvenance, type ShareMetadata, type AssertionCreatedMeta, @@ -69,6 +71,23 @@ const PROVENANCE: OnChainProvenance = { chainId: 'base-sepolia', }; +const GRAPH_UAL = + 'did:dkg:otp:20430/0x1111111111111111111111111111111111111111/7'; + +function makeGraphMeta(): GraphKnowledgeAssetMetadata { + return { + ual: GRAPH_UAL, + contextGraphId: CONTEXT_GRAPH, + merkleRoot: new Uint8Array(32).fill(7), + publisherPeerId: '12D3KooWGraphPeer', + timestamp: new Date('2026-03-01T00:00:00Z'), + assertionVersion: '1', + publicTripleCount: 2, + privateTripleCount: 0, + assertionGraph: `${META_GRAPH}/vm/7`, + }; +} + describe('generateKCMetadata', () => { it('RFC ka-metadata-trim: emits NO rdf:type rows (KC nor aggregate/per-token KA)', () => { const quads = generateKCMetadata(makeMeta(), [makeKA()]); @@ -355,6 +374,53 @@ describe('generateConfirmedFullMetadata', () => { }); }); +describe('generateGraphKnowledgeAssetMetadata confirmation state', () => { + it('preserves the tentative metadata shape without confirmation provenance', () => { + const quads = generateGraphKnowledgeAssetMetadata( + makeGraphMeta(), + { status: 'tentative' }, + ); + const byPredicate = new Map(quads.map((quad) => [quad.predicate, quad.object])); + + expect(byPredicate.get(`${DKG}status`)).toBe('"tentative"'); + expect(byPredicate.has(`${DKG}transactionHash`)).toBe(false); + expect(byPredicate.has(`${DKG}batchId`)).toBe(false); + expect(byPredicate.has(`${DKG}materializedVersion`)).toBe(false); + }); + + it('preserves transaction-confirmed provenance', () => { + const quads = generateGraphKnowledgeAssetMetadata(makeGraphMeta(), { + status: 'confirmed', + confirmation: { kind: 'transaction', provenance: PROVENANCE }, + }); + const byPredicate = new Map(quads.map((quad) => [quad.predicate, quad.object])); + + expect(byPredicate.get(`${DKG}status`)).toBe('"confirmed"'); + expect(byPredicate.get(`${DKG}transactionHash`)).toBe(`"${PROVENANCE.txHash}"`); + expect(byPredicate.get(`${DKG}batchId`)).toContain('42'); + expect(byPredicate.has(`${DKG}materializedVersion`)).toBe(false); + }); + + it('preserves finalized-materialization provenance without a transaction claim', () => { + const quads = generateGraphKnowledgeAssetMetadata(makeGraphMeta(), { + status: 'confirmed', + confirmation: { + kind: 'finalized-materialization', + provenance: { + batchId: 7n, + materializedVersion: { blockNumber: 123, txIndex: 4 }, + }, + }, + }); + const byPredicate = new Map(quads.map((quad) => [quad.predicate, quad.object])); + + expect(byPredicate.get(`${DKG}status`)).toBe('"confirmed"'); + expect(byPredicate.get(`${DKG}batchId`)).toContain('7'); + expect(byPredicate.get(`${DKG}materializedVersion`)).toBe('"123:4"'); + expect(byPredicate.has(`${DKG}transactionHash`)).toBe(false); + }); +}); + describe('generateShareMetadata', () => { const wsMeta: ShareMetadata = { shareOperationId: 'op-123', diff --git a/packages/publisher/test/rootless-access.test.ts b/packages/publisher/test/rootless-access.test.ts index 9978cf90a3..0aa5e95aaf 100644 --- a/packages/publisher/test/rootless-access.test.ts +++ b/packages/publisher/test/rootless-access.test.ts @@ -116,7 +116,7 @@ async function seedRootlessPrivateKA(options: { assertionGraph, subGraphName: options.subGraphName, }, - 'tentative', + { status: 'tentative' }, )); await store.insert([rootContextGraphRegistration()]); return { store, privateStore, scope, payload, privateRoot }; From 4513549165893c2ba2b77857a34ca5d2cb3a700a Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 16:49:10 +0200 Subject: [PATCH 265/292] fix(agent): bind finalized VM reads to contract identity --- .../finalized-vm-harness-runtime.ts | 6 +++- packages/agent/src/dkg-agent-crypto.ts | 15 ++++---- .../rfc64/finalized-vm-agent-precommit-v1.ts | 26 +++++++++----- .../src/rfc64/finalized-vm-composer-v1.ts | 8 ++--- ...kg-agent-native-wiring.integration.test.ts | 19 +++++++++-- ...64-finalized-vm-agent-precommit-v1.test.ts | 18 ++++++++++ .../rfc64-finalized-vm-runtime-v1.test.ts | 27 +++++++++++++++ .../rfc64-finalized-vm-loopback-fixture.ts | 34 +++++++++++++++++-- .../test/swm-public-cg-plaintext.test.ts | 32 +++++++++++++++++ 9 files changed, 158 insertions(+), 27 deletions(-) rename devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.ts => packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts (87%) diff --git a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts index 58c8291076..3b9bf036c0 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts @@ -12,7 +12,7 @@ import { FinalizedVmLoopbackMockChainAdapterV1, createFinalizedVmLoopbackRpcV1, type FinalizedVmLoopbackFixtureConfigV1, -} from './finalized-vm-loopback-fixture.js'; +} from '../../packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.js'; export const RFC64_GATE2_DEPLOYMENT = Object.freeze({ networkId: 'otp:20430', @@ -20,6 +20,9 @@ export const RFC64_GATE2_DEPLOYMENT = Object.freeze({ assertedAtKav10Address: '0x4444444444444444444444444444444444444444', }); +const RFC64_GATE2_CONTEXT_GRAPH_STORAGE_ADDRESS = + '0x3333333333333333333333333333333333333333' as EvmAddressV1; + export interface FinalizedVmHarnessConfigV1 { readonly assertionRoot: Digest32V1; readonly assertionVersion: string; @@ -94,6 +97,7 @@ export async function startFinalizedVmHarnessRuntimeV1( })]), blockHash: FINALIZED_BLOCK_HASH as Digest32V1, blockNumberQuantity: '0x7b', + contextGraphStorageAddress: RFC64_GATE2_CONTEXT_GRAPH_STORAGE_ADDRESS, nameHash: config.nameHash, networkId: RFC64_GATE2_DEPLOYMENT.networkId as NetworkIdV1, onChainContextGraphId: config.onChainContextGraphId, diff --git a/packages/agent/src/dkg-agent-crypto.ts b/packages/agent/src/dkg-agent-crypto.ts index 434cc85138..fd48a04fe9 100644 --- a/packages/agent/src/dkg-agent-crypto.ts +++ b/packages/agent/src/dkg-agent-crypto.ts @@ -1105,21 +1105,18 @@ export class WorkspaceCryptoMethods extends DKGAgentBase { * rather than a user-chosen cleartext id that merely looks hash-shaped. * * Host-only auto-subscribe paths (chain-event + discovery-beacon) stage the - * wire id AS the local id and record `onChainHash === id` (and the reverse - * index `wireIdToLocalCgId[id] === id`). Only that self-referential local - * commitment licenses {@link localCgMatchesOnChainSlot} to accept the verbatim - * id against the on-chain name-hash; without it the id is treated as cleartext - * and must match `keccak256(utf8(id))`, so a reused slot cannot impersonate a - * wire-keyed CG just by sharing a hash-shaped string. + * wire id AS the local id and explicitly record `onChainHash === id`. Only + * that self-referential subscription commitment licenses + * {@link localCgMatchesOnChainSlot} to accept the verbatim id against the + * on-chain name-hash. The general reverse index is deliberately insufficient: + * every subscription is indexed there, including hash-shaped cleartext ids. */ isWireIdKeyedSubscription(this: DKGAgent, localId: string): boolean { if (!/^0x[0-9a-fA-F]{64}$/.test(localId)) return false; const lower = localId.toLowerCase(); const sub = this.subscribedContextGraphs?.get(localId) ?? this.subscribedContextGraphs?.get(lower); - if (sub?.onChainHash && sub.onChainHash.toLowerCase() === lower) return true; - const reverse = this.wireIdToLocalCgId?.get(lower); - return !!reverse && reverse.toLowerCase() === lower; + return !!sub?.onChainHash && sub.onChainHash.toLowerCase() === lower; } /** diff --git a/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts b/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts index 368581a58b..85b54184a8 100644 --- a/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts @@ -1,9 +1,8 @@ -import type { - AuthorCatalogScopeV1, - ChainIdV1, - ContextGraphIdV1, - DecimalU256V1, - EvmAddressV1, +import { + assertCanonicalDecimalU256, + assertCanonicalEvmAddress, + type AuthorCatalogScopeV1, + type ContextGraphIdV1, } from '@origintrail-official/dkg-core'; import { createStrictCurrentFinalizedEvmSnapshotScopeV1 } from '@origintrail-official/dkg-chain'; import type { TripleStore } from '@origintrail-official/dkg-storage'; @@ -64,12 +63,21 @@ export function createRfc64FinalizedVmAgentPrecommitV1( throw new Error('RFC-64 finalized VM policy differs from the configured chain id'); } - const chainId = policy.governanceChainId as ChainIdV1; + assertCanonicalDecimalU256( + onChainContextGraphId, + 'RFC-64 finalized VM on-chain context graph id', + ); + const canonicalKnowledgeAssetStorageAddress = knowledgeAssetStorageAddress.toLowerCase(); + assertCanonicalEvmAddress( + canonicalKnowledgeAssetStorageAddress, + 'RFC-64 finalized VM knowledge asset storage address', + ); + const chainId = policy.governanceChainId; const runtime = createFinalizedVmRuntimeV1({ networkId: plan.catalogScope.networkId, chainId, contextGraphStorageAddress: policy.governanceContractAddress, - knowledgeAssetStorageAddress: knowledgeAssetStorageAddress.toLowerCase() as EvmAddressV1, + knowledgeAssetStorageAddress: canonicalKnowledgeAssetStorageAddress, snapshot: createStrictCurrentFinalizedEvmSnapshotScopeV1({ chainId, endpoints: options.rpcEndpoints, @@ -81,7 +89,7 @@ export function createRfc64FinalizedVmAgentPrecommitV1( contextGraphId: plan.catalogScope.contextGraphId, subGraphName: plan.catalogScope.subGraphName, }), - onChainContextGraphId: onChainContextGraphId as DecimalU256V1, + onChainContextGraphId, acceptedPolicy, placements: Object.freeze(plan.rows.map((row) => Object.freeze({ authorship: row.authorship, diff --git a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts index 04f8b8339c..fbb3306d33 100644 --- a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts @@ -199,7 +199,6 @@ export function composeFinalizedVmSetV1( contractAddress: inventory.contractAddress, }); const accumulator = new FinalizedVmSetAccumulatorV1(scope); - const rows: Readonly[] = []; const materializations: Readonly[] = []; for (const candidate of inventory.rows) { const placement = placementsByKaId.get(candidate.kaId); @@ -227,7 +226,6 @@ export function composeFinalizedVmSetV1( placementEvidenceDigest: placement.authorship.catalogRowDigest, } satisfies FinalizedVmSetRowV1); accumulator.append(row); - rows.push(row); materializations.push(Object.freeze({ candidate, placement: placement.placement, @@ -242,11 +240,13 @@ export function composeFinalizedVmSetV1( ); } + const frozenMaterializations = Object.freeze(materializations); return Object.freeze({ catalogLane, evidence: accumulator.finalize(), - rows: Object.freeze(rows), - materializations: Object.freeze(materializations), + // Backward-compatible evidence view derived from the canonical ordered plan. + rows: Object.freeze(frozenMaterializations.map(({ row }) => row)), + materializations: frozenMaterializations, }); } diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index ba5b2f3e48..3de67ceb6d 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -39,7 +39,7 @@ import { FinalizedVmLoopbackMockChainAdapterV1, createFinalizedVmLoopbackRpcV1, type FinalizedVmLoopbackFixtureConfigV1, -} from '../../../devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.js'; +} from './support/rfc64-finalized-vm-loopback-fixture.js'; const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const NETWORK_ID = 'otp:20430' as NetworkIdV1; @@ -541,6 +541,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { })]), blockHash: FINALIZED_BLOCK_HASH, blockNumberQuantity: '0x7b', + contextGraphStorageAddress: CONTEXT_GRAPH_STORAGE, nameHash: nameHash as Digest32V1, networkId: NETWORK_ID, onChainContextGraphId: ON_CHAIN_CONTEXT_GRAPH_ID, @@ -548,6 +549,16 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { publishPolicy: 1, } satisfies FinalizedVmLoopbackFixtureConfigV1); const finalizedRpc = createFinalizedVmLoopbackRpcV1(fixture); + const contextGraphInterface = new ethers.Interface([ + 'function getNameHash(uint256 contextGraphId) view returns (bytes32)', + ]); + expect(() => finalizedRpc.respond('eth_call', [{ + to: KAV10, + data: contextGraphInterface.encodeFunctionData( + 'getNameHash', + [BigInt(ON_CHAIN_CONTEXT_GRAPH_ID)], + ), + }, 'finalized'])).toThrow('context graph target'); const rpc = await rpcHarness.start((call, response) => { try { sendJsonRpcResult(response, call, finalizedRpc.respond(call.method, call.params)); @@ -675,7 +686,11 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { currentCatalogHeadDigest: successor.headObjectDigest, inventoryRowCount: '1', }); - expect(finalizedRpc.calls.some(({ method }) => method === 'eth_call')).toBe(true); + const finalizedCallTargets = finalizedRpc.calls + .filter(({ method }) => method === 'eth_call') + .map(({ params }) => (params[0] as { readonly to?: string }).to?.toLowerCase()); + expect(finalizedCallTargets).toContain(CONTEXT_GRAPH_STORAGE); + expect(finalizedCallTargets).toContain(KAV10); }, 60_000); it('exposes bounded typed failure evidence when wire scope differs from local deployment', async () => { diff --git a/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts index 63ebe0d46f..06222e566d 100644 --- a/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts @@ -72,6 +72,24 @@ describe('RFC-64 finalized VM agent precommit', () => { 'policy differs from the configured chain id', ); }); + + it('canonicalizes chain-service scalar responses at the precommit boundary', async () => { + const noncanonicalContextGraphId = createRfc64FinalizedVmAgentPrecommitV1({ + ...baseOptions(), + getOnChainContextGraphId: async () => '01', + }); + await expect( + noncanonicalContextGraphId(plan(), new AbortController().signal), + ).rejects.toThrow('on-chain context graph id must be a canonical unsigned decimal'); + + const noncanonicalKnowledgeAssetStorage = createRfc64FinalizedVmAgentPrecommitV1({ + ...baseOptions(), + getKnowledgeAssetStorageAddress: async () => '0x1234', + }); + await expect( + noncanonicalKnowledgeAssetStorage(plan(), new AbortController().signal), + ).rejects.toThrow('knowledge asset storage address must be a lowercase 20-byte'); + }); }); function baseOptions() { diff --git a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts index 754d307897..9e4f4b9d8a 100644 --- a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts @@ -77,6 +77,10 @@ describe('RFC-64 finalized public VM runtime', () => { const result = await runtime(request(placement)); expect(transport.reads()).toBe(4); + expect(new Set(transport.targets())).toEqual(new Set([ + RFC64_VM_CG_STORAGE, + RFC64_VM_KA_STORAGE, + ])); expect(materialize).toHaveBeenCalledTimes(1); expect(result.composed.evidence).toMatchObject({ rowCount: '1', @@ -346,6 +350,7 @@ function snapshotTransport(options: { const kaNumbers = options.kaNumbers ?? [1n]; let open = false; let readCount = 0; + const targets: string[] = []; const snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1 = async (snapshotRequest, consume) => { expect(snapshotRequest.chainId).toBe(RFC64_VM_CHAIN_ID); open = true; @@ -357,6 +362,7 @@ function snapshotTransport(options: { read: async (calls) => { if (!open) throw new Error('snapshot session escaped its scope'); readCount += 1; + targets.push(...calls.map(({ to }) => to.toLowerCase())); return Object.freeze(calls.map(encodeCallResult)); }, })); @@ -368,10 +374,16 @@ function snapshotTransport(options: { snapshot, isOpen: () => open, reads: () => readCount, + targets: () => [...targets], }; function encodeCallResult(call: StrictCurrentFinalizedEvmReadCallV1): string { const selector = call.data.slice(0, 10); + if (CG_SELECTORS.has(selector)) { + expect(call.to.toLowerCase()).toBe(RFC64_VM_CG_STORAGE); + } else if (KA_SELECTORS.has(selector)) { + expect(call.to.toLowerCase()).toBe(RFC64_VM_KA_STORAGE); + } switch (selector) { case CG.getFunction('getContextGraph')!.selector: return CG.encodeFunctionResult('getContextGraph', [ @@ -408,3 +420,18 @@ function snapshotTransport(options: { } } } + +const CG_SELECTORS = new Set([ + 'getContextGraph', + 'getNameHash', + 'isContextGraphActive', + 'getContextGraphKaCount', + 'getContextGraphKaAt', +].map((method) => CG.getFunction(method)!.selector)); + +const KA_SELECTORS = new Set([ + 'getKnowledgeAssetUpdateContext', + 'getLatestMerkleRoot', + 'getLatestMerkleRootAuthor', + 'getLatestMerkleRootPublisher', +].map((method) => KA.getFunction(method)!.selector)); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.ts b/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts similarity index 87% rename from devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.ts rename to packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts index 321dc9a39b..89cbc820d0 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-loopback-fixture.ts +++ b/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts @@ -22,6 +22,7 @@ export interface FinalizedVmLoopbackFixtureConfigV1 { readonly assets: readonly FinalizedVmLoopbackAssetV1[]; readonly blockHash: Digest32V1; readonly blockNumberQuantity: string; + readonly contextGraphStorageAddress: EvmAddressV1; readonly nameHash: Digest32V1; readonly networkId: NetworkIdV1; readonly onChainContextGraphId: string; @@ -76,8 +77,8 @@ export class FinalizedVmLoopbackMockChainAdapterV1 extends MockChainAdapter { } /** - * One protocol-shaped finalized RPC table shared by the integration test and - * the separate-process devnet proof. Callers own only fixture data and I/O. + * Package-owned protocol-shaped finalized RPC test support shared by the + * integration suite and separate-process devnet proof. Callers own I/O only. */ export function createFinalizedVmLoopbackRpcV1( fixture: FinalizedVmLoopbackFixtureConfigV1, @@ -108,9 +109,15 @@ function finalizedVmEthCallResult( assets: ReadonlyMap, ): string { const call = plainRecord(params[0], 'finalized VM eth_call object'); + const target = requiredString(call.to, 'finalized VM eth_call target').toLowerCase(); const data = requiredString(call.data, 'finalized VM eth_call data'); if (data === '0x') return '0x'; const selector = data.slice(0, 10); + if (CONTEXT_GRAPH_SELECTORS.has(selector)) { + assertCallTarget(target, fixture.contextGraphStorageAddress, 'context graph'); + } else if (KNOWLEDGE_ASSET_SELECTORS.has(selector)) { + assertCallTarget(target, fixture.assertedAtKav10Address, 'knowledge asset'); + } switch (selector) { case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraph')!.selector: assertContextGraphCall('getContextGraph', data, fixture.onChainContextGraphId); @@ -186,6 +193,29 @@ function finalizedVmEthCallResult( } } +const CONTEXT_GRAPH_SELECTORS = new Set([ + 'getContextGraph', + 'getNameHash', + 'isContextGraphActive', + 'getContextGraphKaCount', + 'getContextGraphKaAt', +].map((method) => CONTEXT_GRAPH_INTERFACE.getFunction(method)!.selector)); + +const KNOWLEDGE_ASSET_SELECTORS = new Set([ + 'getKnowledgeAssetUpdateContext', + 'getLatestMerkleRoot', + 'getLatestMerkleRootAuthor', + 'getLatestMerkleRootPublisher', +].map((method) => KNOWLEDGE_ASSET_INTERFACE.getFunction(method)!.selector)); + +function assertCallTarget(actual: string, expected: EvmAddressV1, label: string): void { + if (actual !== expected.toLowerCase()) { + throw new Error( + `unexpected finalized VM ${label} target ${actual}; expected ${expected.toLowerCase()}`, + ); + } +} + function assertContextGraphCall( method: string, data: string, diff --git a/packages/agent/test/swm-public-cg-plaintext.test.ts b/packages/agent/test/swm-public-cg-plaintext.test.ts index 34233b8900..bccf22b29b 100644 --- a/packages/agent/test/swm-public-cg-plaintext.test.ts +++ b/packages/agent/test/swm-public-cg-plaintext.test.ts @@ -274,6 +274,38 @@ describe('DKGAgent.isContextGraphPublicOnChain', () => { expect((agentLike.chain.getContextGraphAccessPolicy as any).calls.at(-1)).toEqual([5n]); }); + it('keeps a hash-shaped cleartext subscription bound to keccak(utf8(id)), not its raw reverse-index key', async () => { + const hexCleartext = '0x' + 'bc'.repeat(32); + const committed = ethers.keccak256(ethers.toUtf8Bytes(hexCleartext)).toLowerCase(); + const matching = makeAgentLike({ + onChainId: '5', + accessPolicy: 0, + onChainNameHash: committed, + }); + matching.subscribedContextGraphs.set(hexCleartext, { + subscribed: true, + synced: false, + }); + // setContextGraphSubscription maintains this general reverse lookup for + // every subscription. It is routing metadata, not proof that the local id + // is host-only/wire-keyed. + matching.wireIdToLocalCgId.set(hexCleartext, hexCleartext); + await expect(isPublic(matching, hexCleartext)).resolves.toBe(true); + + const rawOnly = makeAgentLike({ + onChainId: '5', + accessPolicy: 0, + onChainNameHash: hexCleartext, + }); + rawOnly.subscribedContextGraphs.set(hexCleartext, { + subscribed: true, + synced: false, + }); + rawOnly.wireIdToLocalCgId.set(hexCleartext, hexCleartext); + await expect(isPublic(rawOnly, hexCleartext)).resolves.toBe(false); + expect((rawOnly.chain.getContextGraphAccessPolicy as any).calls).toEqual([]); + }); + it('fails closed when the name-hash getter is PRESENT but REJECTS — identity unverifiable (#884 review GZ8L_)', async () => { // The adapter exposes getContextGraphNameHash, so an RPC rejection means we // cannot prove the local mapping still points at this CG. A transient flake From cb91ce2587674f8bf5231a15bc5987d7c0d510dc Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 17:34:35 +0200 Subject: [PATCH 266/292] fix(agent): authenticate finalized VM durable sync --- .../requester/graph-scoped-materialization.ts | 31 +++- ...-sync-graph-scoped-materialization.test.ts | 137 ++++++++++++++++++ 2 files changed, 161 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/sync/requester/graph-scoped-materialization.ts b/packages/agent/src/sync/requester/graph-scoped-materialization.ts index 2121980912..443796652a 100644 --- a/packages/agent/src/sync/requester/graph-scoped-materialization.ts +++ b/packages/agent/src/sync/requester/graph-scoped-materialization.ts @@ -80,6 +80,11 @@ export async function authenticateVerifiedGraphScopedAsset( graph: asset.metaGraph, }, ]; + const structuralMetadata = asset.metadataQuads.filter((quad) => ( + quad.predicate !== PUBLISHED_AT + && quad.predicate !== STATUS + && quad.predicate !== MATERIALIZED_VERSION + )); // The timestamp above is local receive time, not peer metadata. It is safe // for discovery ordering and keeps graph-scoped KAs visible without trusting // a peer-controlled dkg:publishedAt value. No-chain mode remains explicitly @@ -88,7 +93,7 @@ export async function authenticateVerifiedGraphScopedAsset( return { asset: { ...asset, - metadataQuads: [...asset.metadataQuads, ...locallyVisibleMetadata('tentative')], + metadataQuads: [...structuralMetadata, ...locallyVisibleMetadata('tentative')], }, onChainContextGraphId: null, }; @@ -164,20 +169,31 @@ export async function authenticateVerifiedGraphScopedAsset( const transactionHashes = asset.metadataQuads .filter((quad) => quad.predicate === TRANSACTION_HASH) .map((quad) => parseTransactionHashLiteral(quad.object)); - if (transactionHashes.length !== 1) { + if (transactionHashes.length > 1) { throw Object.assign( new Error( - `Graph-scoped durable sync ${asset.ual} requires one receipt transaction hash, ` + `Graph-scoped durable sync ${asset.ual} allows at most one receipt transaction hash, ` + `got ${transactionHashes.length}`, ), - { code: 'VM_CHAIN_PROVENANCE_MISSING' }, + { code: 'VM_CHAIN_PROVENANCE_MISMATCH' }, ); } - const transactionHash = transactionHashes[0]!; let materializedBlock: number; let materializedTxIndex: number; - if (asset.assertionVersion === 1n) { + if (transactionHashes.length === 0) { + // RFC-64 finalized VM materialization deliberately has no receipt claim: + // it is reconstructed from a pinned finalized chain snapshot. A later + // durable-sync requester must not trust the serving peer's local + // materializedVersion, and the requester strips that field before this + // boundary. The independently read current root, root count, KA->CG + // binding, and local CG name binding above fully authenticate the exact + // current assertion. Use a neutral LOCAL ordering stamp; assertionVersion + // remains the authoritative stale-write guard for this receiptless lane. + materializedBlock = 0; + materializedTxIndex = 0; + } else if (asset.assertionVersion === 1n) { + const transactionHash = transactionHashes[0]!; if (!chain.resolvePublishByTxHash) { throw Object.assign( new Error('Graph-scoped durable sync requires receipt-backed publish verification'), @@ -201,6 +217,7 @@ export async function authenticateVerifiedGraphScopedAsset( materializedBlock = resolved.blockNumber; materializedTxIndex = resolved.txIndex ?? 0; } else { + const transactionHash = transactionHashes[0]!; if (!chain.verifyKAUpdate || !chain.getLatestMerkleRootPublisher) { throw Object.assign( new Error('Graph-scoped durable sync requires receipt-backed update verification'), @@ -237,7 +254,7 @@ export async function authenticateVerifiedGraphScopedAsset( asset: { ...asset, metadataQuads: [ - ...asset.metadataQuads, + ...structuralMetadata, ...locallyVisibleMetadata('confirmed'), { subject: asset.ual, diff --git a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts index dcc80aa85f..83f3f815e4 100644 --- a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts +++ b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts @@ -10,6 +10,7 @@ import { } from '@origintrail-official/dkg-storage'; import { computeFlatKCRootV10, + generateGraphKnowledgeAssetMetadata, replaceLocallyTrustedKnowledgeAssetControls, shouldApplyMaterialization, } from '@origintrail-official/dkg-publisher'; @@ -63,6 +64,35 @@ function metadata(version: number, merkleRoot = String(version).padStart(64, '0' })); } +function finalizedMaterializationMetadata( + version: number, + merkleRoot: Uint8Array, +): Quad[] { + return generateGraphKnowledgeAssetMetadata({ + contextGraphId, + ual, + merkleRoot, + publisherPeerId: 'rfc64-finalized-catalog-v1', + accessPolicy: 'public', + allowedPeers: [], + timestamp: new Date('2026-07-16T08:00:00.000Z'), + assertionVersion: version, + authorAddress: '0x1111111111111111111111111111111111111111', + publicTripleCount: 1, + privateTripleCount: 0, + assertionGraph, + }, { + status: 'confirmed', + confirmation: { + kind: 'finalized-materialization', + provenance: { + batchId: BigInt(packedKaId), + materializedVersion: { blockNumber: 123, txIndex: 0 }, + }, + }, + }); +} + function toHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); } @@ -230,6 +260,47 @@ describe('durable graph-scoped KA materialization', () => { })); }); + it('authenticates finalized materialization from independent chain state without a receipt claim', async () => { + const v2Data = dataQuad(2); + const root = computeFlatKCRootV10([v2Data], []); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)), + resolvePublishByTxHash: async () => { + throw new Error('receipt lookup must not run for finalized materialization'); + }, + verifyKAUpdate: async () => { + throw new Error('receipt lookup must not run for finalized materialization'); + }, + } as ChainAdapter; + + const authenticated = await authenticateVerifiedGraphScopedAsset( + chain, + { + contextGraphId, + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [v2Data], + metadataQuads: finalizedMaterializationMetadata(2, root), + }, + strictContextGraphBindingVerifier(chain), + new Date('2026-07-16T08:30:00.000Z'), + ); + + expect(authenticated.onChainContextGraphId).toBe('14'); + expect(authenticated.asset.metadataQuads).not.toContainEqual(expect.objectContaining({ + predicate: `${DKG}transactionHash`, + })); + expect(authenticated.asset.metadataQuads.filter( + (quad) => quad.predicate === `${DKG}materializedVersion`, + )).toEqual([expect.objectContaining({ object: '"0:0"' })]); + }); + it('fails closed when the bound CG commits a different name hash', async () => { const root = new Uint8Array(32); root[31] = 2; @@ -657,6 +728,72 @@ describe('durable graph-scoped KA materialization', () => { )).resolves.toBe(false); }); + it('syncs a finalized-materialized VM asset from one node store into a fresh requester', async () => { + const sourceNodeStore = new OxigraphStore(); + const freshRequesterStore = new OxigraphStore(); + const v2Data = dataQuad(2); + const root = computeFlatKCRootV10([v2Data], []); + const sourceMetadata = finalizedMaterializationMetadata(2, root); + await sourceNodeStore.insert([v2Data, ...sourceMetadata]); + const servedData = await graphQuads(sourceNodeStore, assertionGraph); + const servedMeta = await graphQuads(sourceNodeStore, metaGraph); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)), + } as ChainAdapter; + + const summary = await runDurableSync({ + ctx, + remotePeerId: 'finalized-vm-source-node', + contextGraphIds: [contextGraphId], + createContextGraphSyncDeadline: () => Date.now() + 10_000, + fetchSyncPages: async (_ctx, _peer, _cg, _shared, phase) => ( + phase === 'data' ? page(phase, servedData) : page(phase, servedMeta) + ), + processDurableBatchInWorker: async (dataQuads, metaQuads, _ctx, acceptUnverified, mode) => { + const verified = processDurableBatchForWire( + dataQuads, + metaQuads, + acceptUnverified, + mode, + ); + return { + ...verified, + verifiedData: verified.verifiedDataIndexes.map((index) => dataQuads[index]!), + verifiedMeta: verified.verifiedMetaIndexes.map((index) => metaQuads[index]!), + }; + }, + storeInsert: (quads) => freshRequesterStore.insert(quads), + storeGraphScopedAsset: async (asset) => materializeVerifiedGraphScopedAsset({ + store: freshRequesterStore, + asset: (await authenticateVerifiedGraphScopedAsset( + chain, + asset, + strictContextGraphBindingVerifier(chain), + new Date('2026-07-16T09:00:00.000Z'), + )).asset, + }), + deleteCheckpoint: () => {}, + setCheckpoint: () => {}, + logInfo: () => {}, + logWarn: () => {}, + logDebug: () => {}, + }); + + expect(summary.failedPeers).toBe(0); + expect(summary.failedPhases).toBe(0); + expect(summary.insertedDataTriples).toBe(1); + expect(await graphQuads(freshRequesterStore, assertionGraph)).toEqual(servedData); + expect(await values(freshRequesterStore, 'status')).toEqual(['"confirmed"']); + expect(await values(freshRequesterStore, 'transactionHash')).toEqual([]); + // The requester never accepts the serving node's 123:0 local ordering + // claim; it derives a neutral local stamp after chain authentication. + expect(await values(freshRequesterStore, 'materializedVersion')).toEqual(['"0:0"']); + }); + it('does not let a stale durable page replace a newer local assertion', async () => { const store = new OxigraphStore(); const v1Data = dataQuad(1); From 889fe480aaf5eb262736ec135f415f24b0e50c4f Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 18:47:19 +0200 Subject: [PATCH 267/292] fix(rfc64): make finalized sync provenance explicit --- .../finalized-vm-harness-runtime.ts | 13 +++---- .../agent/src/sync/requester/durable-sync.ts | 30 ++++++++++++++-- .../requester/graph-scoped-materialization.ts | 27 +++++++++++++- ...-sync-graph-scoped-materialization.test.ts | 30 ++++++++++++++++ ...kg-agent-native-wiring.integration.test.ts | 13 +++---- .../rfc64-finalized-vm-loopback-fixture.ts | 9 +++-- packages/chain/src/index.ts | 1 + packages/chain/src/mock-adapter.ts | 35 ++++++++----------- .../test/mock-adapter-fixtures.unit.test.ts | 17 +++++---- packages/publisher/src/metadata.ts | 12 +++++++ packages/publisher/test/metadata.test.ts | 2 ++ 11 files changed, 141 insertions(+), 48 deletions(-) diff --git a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts index 3b9bf036c0..62ea512458 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts @@ -106,14 +106,11 @@ export async function startFinalizedVmHarnessRuntimeV1( } satisfies FinalizedVmLoopbackFixtureConfigV1); const rpcFixture = createFinalizedVmLoopbackRpcV1(fixture); const chainAdapter = new FinalizedVmLoopbackMockChainAdapterV1(fixture); - const created = await chainAdapter.createOnChainContextGraphAtIdForTesting( - BigInt(config.onChainContextGraphId), - { - accessPolicy: 0, - publishPolicy: 1, - nameHash: config.nameHash, - }, - ); + const created = await chainAdapter.createOnChainContextGraph({ + accessPolicy: 0, + publishPolicy: 1, + nameHash: config.nameHash, + }); if (created.contextGraphId.toString() !== config.onChainContextGraphId) { throw new Error('mock chain created a different numeric context graph id'); } diff --git a/packages/agent/src/sync/requester/durable-sync.ts b/packages/agent/src/sync/requester/durable-sync.ts index 02c53ace84..3e98224012 100644 --- a/packages/agent/src/sync/requester/durable-sync.ts +++ b/packages/agent/src/sync/requester/durable-sync.ts @@ -26,6 +26,7 @@ const CONTEXT_GRAPH = `${DKG_NS}contextGraph`; const BATCH_ID = `${DKG_NS}batchId`; const MATERIALIZED_VERSION = `${DKG_NS}materializedVersion`; const TRANSACTION_HASH = `${DKG_NS}transactionHash`; +const CONFIRMATION_KIND = `${DKG_NS}confirmationKind`; const XSD_INTEGER = 'http://www.w3.org/2001/XMLSchema#integer'; const PEER_UNTRUSTED_METADATA_PREDICATES = new Set([ MATERIALIZED_VERSION, @@ -46,6 +47,7 @@ const GRAPH_SCOPED_SYNC_METADATA_PREDICATES = new Set([ `${DKG_NS}contextGraph`, `${DKG_NS}subGraphName`, TRANSACTION_HASH, + CONFIRMATION_KIND, ]); export interface DurableSyncSummary { @@ -720,9 +722,10 @@ function partitionVerifiedGraphScopedAssets( throw new Error(`Verified graph-scoped assertion ${assertionGraph} has ${owners?.size ?? 0} metadata owners`); } const [ual] = owners; - // Only persist structural fields used to verify and locate the exact - // assertion. ACLs, status, timestamps and provenance are not Merkle-bound - // by V2 data and must never become trusted local controls from a peer. + // Carry only structural fields plus the bounded provenance discriminator + // and receipt claim consumed by the chain authenticator below. ACLs, + // status, timestamps, and local ordering are never accepted as trusted + // controls from a peer. const metadataQuads = (metadataBySubject.get(ual) ?? []).filter( (quad) => GRAPH_SCOPED_SYNC_METADATA_PREDICATES.has(quad.predicate), ); @@ -738,6 +741,26 @@ function partitionVerifiedGraphScopedAssets( if (!versionRaw || !/^\d+$/.test(versionRaw)) { throw new Error(`Verified graph-scoped KA ${ual} has invalid assertionVersion ${versionRaw ?? ''}`); } + const confirmationKinds = new Set( + metadataQuads + .filter((quad) => quad.predicate === CONFIRMATION_KIND) + .map((quad) => stripLiteral(quad.object)), + ); + if (confirmationKinds.size > 1) { + throw new Error( + `Verified graph-scoped KA ${ual} has ${confirmationKinds.size} confirmation kinds`, + ); + } + const [confirmationKindRaw] = confirmationKinds; + if ( + confirmationKindRaw !== undefined + && confirmationKindRaw !== 'transaction' + && confirmationKindRaw !== 'finalized-materialization' + ) { + throw new Error( + `Verified graph-scoped KA ${ual} has unsupported confirmation kind ${confirmationKindRaw}`, + ); + } const metaGraphs = new Set(metadataQuads.map((quad) => quad.graph)); if (metaGraphs.size !== 1) { throw new Error(`Verified graph-scoped KA ${ual} spans ${metaGraphs.size} metadata graphs`); @@ -770,6 +793,7 @@ function partitionVerifiedGraphScopedAssets( contextGraphId, ual, assertionVersion: BigInt(versionRaw), + confirmationKind: confirmationKindRaw ?? 'transaction', assertionGraph, metaGraph, dataQuads: dataByGraph.get(assertionGraph) ?? [], diff --git a/packages/agent/src/sync/requester/graph-scoped-materialization.ts b/packages/agent/src/sync/requester/graph-scoped-materialization.ts index 443796652a..c0e1cd352d 100644 --- a/packages/agent/src/sync/requester/graph-scoped-materialization.ts +++ b/packages/agent/src/sync/requester/graph-scoped-materialization.ts @@ -30,6 +30,8 @@ export interface VerifiedGraphScopedAsset { contextGraphId: string; ual: string; assertionVersion: bigint; + /** Omitted only for rolling-compatible receipt-backed metadata. */ + confirmationKind?: 'transaction' | 'finalized-materialization'; assertionGraph: string; metaGraph: string; dataQuads: Quad[]; @@ -179,9 +181,18 @@ export async function authenticateVerifiedGraphScopedAsset( ); } + const confirmationKind = asset.confirmationKind ?? 'transaction'; let materializedBlock: number; let materializedTxIndex: number; - if (transactionHashes.length === 0) { + if (confirmationKind === 'finalized-materialization') { + if (transactionHashes.length !== 0) { + throw Object.assign( + new Error( + `Graph-scoped durable sync ${asset.ual} finalized materialization must not claim a receipt`, + ), + { code: 'VM_CHAIN_PROVENANCE_MISMATCH' }, + ); + } // RFC-64 finalized VM materialization deliberately has no receipt claim: // it is reconstructed from a pinned finalized chain snapshot. A later // durable-sync requester must not trust the serving peer's local @@ -192,6 +203,20 @@ export async function authenticateVerifiedGraphScopedAsset( // remains the authoritative stale-write guard for this receiptless lane. materializedBlock = 0; materializedTxIndex = 0; + } else if (confirmationKind !== 'transaction') { + throw Object.assign( + new Error( + `Graph-scoped durable sync ${asset.ual} has unsupported confirmation kind ${String(confirmationKind)}`, + ), + { code: 'VM_CHAIN_PROVENANCE_MISMATCH' }, + ); + } else if (transactionHashes.length !== 1) { + throw Object.assign( + new Error( + `Graph-scoped durable sync ${asset.ual} receipt-backed confirmation requires one transaction hash`, + ), + { code: 'VM_CHAIN_PROVENANCE_MISMATCH' }, + ); } else if (asset.assertionVersion === 1n) { const transactionHash = transactionHashes[0]!; if (!chain.resolvePublishByTxHash) { diff --git a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts index 83f3f815e4..05b7e10a16 100644 --- a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts +++ b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts @@ -283,6 +283,7 @@ describe('durable graph-scoped KA materialization', () => { contextGraphId, ual, assertionVersion: 2n, + confirmationKind: 'finalized-materialization', assertionGraph, metaGraph, dataQuads: [v2Data], @@ -301,6 +302,35 @@ describe('durable graph-scoped KA materialization', () => { )).toEqual([expect.objectContaining({ object: '"0:0"' })]); }); + it('rejects receipt-backed graph metadata when its transaction claim is missing', async () => { + const v2Data = dataQuad(2); + const root = computeFlatKCRootV10([v2Data], []); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)), + } as ChainAdapter; + + await expect(authenticateVerifiedGraphScopedAsset( + chain, + { + contextGraphId, + ual, + assertionVersion: 2n, + confirmationKind: 'transaction', + assertionGraph, + metaGraph, + dataQuads: [v2Data], + metadataQuads: metadata(2, toHex(root)).filter( + (quad) => quad.predicate !== `${DKG}transactionHash`, + ), + }, + strictContextGraphBindingVerifier(chain), + )).rejects.toMatchObject({ code: 'VM_CHAIN_PROVENANCE_MISMATCH' }); + }); + it('fails closed when the bound CG commits a different name hash', async () => { const root = new Uint8Array(32); root[31] = 2; diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 3de67ceb6d..3b62c10098 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -573,14 +573,11 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); const authorChain = new FinalizedVmLoopbackMockChainAdapterV1(fixture); const receiverChain = new FinalizedVmLoopbackMockChainAdapterV1(fixture); - const created = await receiverChain.createOnChainContextGraphAtIdForTesting( - BigInt(ON_CHAIN_CONTEXT_GRAPH_ID), - { - accessPolicy: 0, - publishPolicy: 1, - nameHash, - }, - ); + const created = await receiverChain.createOnChainContextGraph({ + accessPolicy: 0, + publishPolicy: 1, + nameHash, + }); expect(created.contextGraphId.toString()).toBe(ON_CHAIN_CONTEXT_GRAPH_ID); const [author, receiver] = await Promise.all([ startNativeAgent( diff --git a/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts b/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts index 89cbc820d0..d6851f8ae4 100644 --- a/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts +++ b/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts @@ -1,4 +1,7 @@ -import { MockChainAdapter } from '@origintrail-official/dkg-chain'; +import { + MockChainAdapter, + MOCK_DEFAULT_SIGNER, +} from '@origintrail-official/dkg-chain'; import type { Digest32V1, EvmAddressV1, @@ -59,7 +62,9 @@ export class FinalizedVmLoopbackMockChainAdapterV1 extends MockChainAdapter { readonly #fixture: FinalizedVmLoopbackFixtureConfigV1; constructor(fixture: FinalizedVmLoopbackFixtureConfigV1) { - super(fixture.networkId); + super(fixture.networkId, MOCK_DEFAULT_SIGNER, { + initialContextGraphId: BigInt(fixture.onChainContextGraphId), + }); this.#fixture = fixture; } diff --git a/packages/chain/src/index.ts b/packages/chain/src/index.ts index 206bcc1dbd..7d2ca61149 100644 --- a/packages/chain/src/index.ts +++ b/packages/chain/src/index.ts @@ -114,6 +114,7 @@ export { type RpcUsageWindow, } from './rpc-usage.js'; export { MockChainAdapter, MOCK_DEFAULT_SIGNER } from './mock-adapter.js'; +export type { MockChainAdapterOptions } from './mock-adapter.js'; export { EVMChainAdapter, type EVMAdapterConfig, diff --git a/packages/chain/src/mock-adapter.ts b/packages/chain/src/mock-adapter.ts index 0ec1d56cc8..a94591e9a5 100644 --- a/packages/chain/src/mock-adapter.ts +++ b/packages/chain/src/mock-adapter.ts @@ -41,6 +41,11 @@ import { ethers } from 'ethers'; export const MOCK_DEFAULT_SIGNER = '0x' + '1'.repeat(40); +export interface MockChainAdapterOptions { + /** Seed the first CG allocation for fixtures that model an existing registry. */ + initialContextGraphId?: bigint; +} + interface MockBatch { merkleRoot: Uint8Array; kaCount: number; @@ -117,10 +122,19 @@ export class MockChainAdapter implements ChainAdapter { // on-chain shape. Multiaddrs are not stored on Profile (RFC 04 §5.2). private relayCapableByIdentity = new Map(); - constructor(chainId = 'mock:31337', signerAddress = MOCK_DEFAULT_SIGNER) { + constructor( + chainId = 'mock:31337', + signerAddress = MOCK_DEFAULT_SIGNER, + options: Readonly = {}, + ) { this.chainId = chainId; this.signerAddress = signerAddress; this.allowedPublisherAddresses = new Set([ethers.getAddress(signerAddress).toLowerCase()]); + const initialContextGraphId = options.initialContextGraphId ?? 1n; + if (initialContextGraphId < 1n) { + throw new TypeError('Mock initial context graph id must be positive'); + } + this.nextContextGraphId = initialContextGraphId; } async getIdentityId(): Promise { @@ -922,25 +936,6 @@ export class MockChainAdapter implements ChainAdapter { }>(); private nextContextGraphId = 1n; - /** - * Explicit fixture seam for harnesses that must reproduce an existing - * on-chain numeric CG id. It is intentionally one-shot and must run before - * any CG is created so tests cannot rewrite live mock registry state. - */ - async createOnChainContextGraphAtIdForTesting( - contextGraphId: bigint, - params: CreateOnChainContextGraphParams, - ): Promise { - if (contextGraphId < 1n) { - throw new TypeError('Mock fixture context graph id must be positive'); - } - if (this.contextGraphs.size !== 0 || this.nextContextGraphId !== 1n) { - throw new Error('Mock fixture context graph id must be seeded before any CG exists'); - } - this.nextContextGraphId = contextGraphId; - return this.createOnChainContextGraph(params); - } - async createOnChainContextGraph(params: CreateOnChainContextGraphParams): Promise { if (params.accessPolicy === undefined || params.publishPolicy === undefined) { throw new Error( diff --git a/packages/chain/test/mock-adapter-fixtures.unit.test.ts b/packages/chain/test/mock-adapter-fixtures.unit.test.ts index 0a55b4cb0f..1aa8f07fe0 100644 --- a/packages/chain/test/mock-adapter-fixtures.unit.test.ts +++ b/packages/chain/test/mock-adapter-fixtures.unit.test.ts @@ -1,18 +1,23 @@ import { describe, expect, it } from 'vitest'; -import { MockChainAdapter } from '../src/mock-adapter.js'; +import { MockChainAdapter, MOCK_DEFAULT_SIGNER } from '../src/mock-adapter.js'; describe('MockChainAdapter explicit fixture seams', () => { - it('seeds a numeric context graph id once without exposing adapter internals', async () => { - const mock = new MockChainAdapter(); - const created = await mock.createOnChainContextGraphAtIdForTesting(14n, { + it('seeds the first numeric context graph id through immutable fixture setup', async () => { + const mock = new MockChainAdapter('mock:31337', MOCK_DEFAULT_SIGNER, { + initialContextGraphId: 14n, + }); + const created = await mock.createOnChainContextGraph({ accessPolicy: 0, publishPolicy: 1, }); expect(created.contextGraphId).toBe(14n); - await expect(mock.createOnChainContextGraphAtIdForTesting(15n, { + await expect(mock.createOnChainContextGraph({ accessPolicy: 0, publishPolicy: 1, - })).rejects.toThrow('before any CG exists'); + })).resolves.toMatchObject({ contextGraphId: 15n }); + expect(() => new MockChainAdapter('mock:31337', MOCK_DEFAULT_SIGNER, { + initialContextGraphId: 0n, + })).toThrow('must be positive'); }); }); diff --git a/packages/publisher/src/metadata.ts b/packages/publisher/src/metadata.ts index 62745350e6..2a90ec1b87 100644 --- a/packages/publisher/src/metadata.ts +++ b/packages/publisher/src/metadata.ts @@ -360,6 +360,12 @@ export function generateGraphKnowledgeAssetMetadata( meta.contextGraphId, confirmation.provenance, )); + quads.push(mq( + scope.ual, + `${DKG}confirmationKind`, + lit('transaction'), + metaGraph, + )); } else { const { batchId, materializedVersion } = confirmation.provenance; if (batchId < 0n) { @@ -368,6 +374,12 @@ export function generateGraphKnowledgeAssetMetadata( quads.push( getConfirmedStatusQuad(scope.ual, meta.contextGraphId), mq(scope.ual, `${DKG}batchId`, intLit(batchId), metaGraph), + mq( + scope.ual, + `${DKG}confirmationKind`, + lit('finalized-materialization'), + metaGraph, + ), materializedVersionQuad(metaGraph, scope.ual, materializedVersion), ); } diff --git a/packages/publisher/test/metadata.test.ts b/packages/publisher/test/metadata.test.ts index 1db172a8a7..f457514cca 100644 --- a/packages/publisher/test/metadata.test.ts +++ b/packages/publisher/test/metadata.test.ts @@ -396,6 +396,7 @@ describe('generateGraphKnowledgeAssetMetadata confirmation state', () => { const byPredicate = new Map(quads.map((quad) => [quad.predicate, quad.object])); expect(byPredicate.get(`${DKG}status`)).toBe('"confirmed"'); + expect(byPredicate.get(`${DKG}confirmationKind`)).toBe('"transaction"'); expect(byPredicate.get(`${DKG}transactionHash`)).toBe(`"${PROVENANCE.txHash}"`); expect(byPredicate.get(`${DKG}batchId`)).toContain('42'); expect(byPredicate.has(`${DKG}materializedVersion`)).toBe(false); @@ -415,6 +416,7 @@ describe('generateGraphKnowledgeAssetMetadata confirmation state', () => { const byPredicate = new Map(quads.map((quad) => [quad.predicate, quad.object])); expect(byPredicate.get(`${DKG}status`)).toBe('"confirmed"'); + expect(byPredicate.get(`${DKG}confirmationKind`)).toBe('"finalized-materialization"'); expect(byPredicate.get(`${DKG}batchId`)).toContain('7'); expect(byPredicate.get(`${DKG}materializedVersion`)).toBe('"123:4"'); expect(byPredicate.has(`${DKG}transactionHash`)).toBe(false); From f202f1631c273a06e11de8415bc3186e9b4e2f5c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 19:10:55 +0200 Subject: [PATCH 268/292] fix(rfc64): harden finalized VM durable sync boundaries --- .../adapter-process.ts | 26 +-- packages/agent/src/dkg-agent-lifecycle.ts | 9 +- packages/agent/src/dkg-agent-rfc64-catalog.ts | 12 +- packages/agent/src/dkg-agent-types.ts | 13 ++ packages/agent/src/dkg-agent.ts | 5 + packages/agent/src/index.ts | 1 + .../public-catalog-native-receiver-v1.ts | 42 ++--- .../public-catalog-native-reconciler-v1.ts | 42 ++--- .../src/rfc64/public-catalog-service-v1.ts | 4 +- .../agent/src/sync/requester/durable-sync.ts | 34 ++-- .../requester/graph-scoped-materialization.ts | 127 +++++++++++++-- ...-sync-graph-scoped-materialization.test.ts | 150 +++++++++++++++++- ...kg-agent-native-wiring.integration.test.ts | 14 +- ...c-catalog-native-gate1.integration.test.ts | 12 +- ...ublic-catalog-native-reconciler-v1.test.ts | 34 ++-- packages/publisher/src/chain-event-poller.ts | 6 + packages/publisher/src/index.ts | 2 +- packages/publisher/src/metadata.ts | 44 ++++- packages/publisher/test/metadata.test.ts | 29 ++++ 19 files changed, 464 insertions(+), 142 deletions(-) diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts index 4e5e65085a..42dc62be1f 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -166,29 +166,17 @@ async function boot(): Promise { operationalKeys: [`0x${'12'.repeat(32)}`], }, }), + ...(finalizedVmConfig === null ? {} : { + initialContextGraphSubscriptions: [{ + contextGraphId: finalizedVmConfig.contextGraphId, + state: { subscribed: true, synced: false }, + }], + }), }); agent = created; - if (finalizedVmConfig !== null) { - // Models a cleartext subscription restored before the startup chain-event - // scan. The production poller must derive the numeric id from nameHash. - (created as unknown as { - setContextGraphSubscription( - id: string, - state: Readonly<{ subscribed: boolean; synced: boolean }>, - options: Readonly<{ persist: boolean }>, - ): void; - }).setContextGraphSubscription( - finalizedVmConfig.contextGraphId, - { subscribed: true, synced: false }, - { persist: false }, - ); - } await created.start(); if (finalizedVmConfig !== null) { - const inFlightPoll = (created as unknown as { - chainPoller?: { inFlightPoll?: Promise | null }; - }).chainPoller?.inFlightPoll; - if (inFlightPoll) await inFlightPoll; + await created.awaitInitialChainPoll(); } const tcp = created.multiaddrs.find((address) => address.includes('/tcp/')); if (tcp === undefined) throw new Error('real DKGAgent exposed no TCP multiaddr'); diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index c1763da4be..21bf3d8308 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -1192,6 +1192,13 @@ export class LifecycleSyncMethods extends DKGAgentBase { await this.loadSwmSenderKeyState(); await this.initializeSwmHostModeStore(); await this.rehydrateContextGraphSubscriptions(); + for (const seed of this.config.initialContextGraphSubscriptions ?? []) { + this.setContextGraphSubscription( + seed.contextGraphId, + { ...seed.state }, + { persist: false }, + ); + } this.networkAdmissionCoordinator.registerIdentityProtocol(this.router); @@ -2056,7 +2063,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { } : undefined, }); - this.chainPoller.start(); + await this.chainPoller.start(); this.log.info(ctx, `Chain event poller started`); } diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 07aed78e18..8dd7777956 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -83,8 +83,8 @@ import { } from './rfc64/public-catalog-native-receiver-v1.js'; import { createRfc64FinalizedVmAgentPrecommitV1 } from './rfc64/finalized-vm-agent-precommit-v1.js'; import { - createRfc64PublicOpenCatalogNativeReconcilerV1, - type Rfc64PublicOpenCatalogDeploymentResolverV1, + createRfc64BoundedPublicRootCatalogNativeReconcilerV1, + type Rfc64BoundedPublicRootCatalogDeploymentResolverV1, } from './rfc64/public-catalog-native-reconciler-v1.js'; import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; import type { @@ -736,7 +736,7 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return undefined; } this.rfc64PublicCatalogSynchronizationEvidenceV1.clear(); - const resolveDeployment: Rfc64PublicOpenCatalogDeploymentResolverV1 = + const resolveDeployment: Rfc64BoundedPublicRootCatalogDeploymentResolverV1 = (announcement, signal) => this.resolveRfc64CatalogDeploymentProfileV1( announcement.networkId, signal, @@ -781,10 +781,10 @@ export class Rfc64CatalogMethods extends DKGAgentBase { beforeAppliedHeadCommit: finalizedVmPrecommit, transportTimeoutMs: clients.transportTimeoutMs, }); - const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + const reconciler = createRfc64BoundedPublicRootCatalogNativeReconcilerV1({ nativeReceiver: Object.freeze({ - synchronizePublicOpenCatalog: async (...args) => { - const evidence = await nativeReceiver.synchronizePublicOpenCatalog(...args); + synchronizeBoundedPublicRootCatalog: async (...args) => { + const evidence = await nativeReceiver.synchronizeBoundedPublicRootCatalog(...args); this.rfc64PublicCatalogSynchronizationEvidenceV1.set( evidence.catalogHeadDigest, evidence, diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index 0f9c61bc0b..72573d00da 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -676,6 +676,17 @@ export interface ContextGraphSub { pendingMeta?: boolean; } +/** + * Typed startup seed for controlled runtimes that need to model a subscription + * restored before the first chain scan (for example, the RFC-64 process proof). + * Seeds are in-memory overlays; durable production state still belongs to + * `contextGraphSubscriptionStore`. + */ +export interface InitialContextGraphSubscription { + readonly contextGraphId: string; + readonly state: Readonly; +} + /** * Metadata that passive discovery is allowed to contribute to the local * Context Graph catalogue. @@ -1071,6 +1082,8 @@ export interface DKGAgentConfig { * RFC-64 catalog policy. Omission preserves the legacy open-only lane. */ rfc64CatalogAccessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1; + /** Apply these typed in-memory subscriptions after durable rehydration and before chain polling. */ + initialContextGraphSubscriptions?: readonly InitialContextGraphSubscription[]; /** * public-projection enable flag. When set, a private CG's confirmed VM * publishes emit/refresh a verifiable public projection (the floor: existence, diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 7a968ebeb7..d906f890e9 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1582,6 +1582,11 @@ export class DKGAgent extends DKGAgentBase { }; } + /** Wait for the chain scan launched during `start()` through a stable public boundary. */ + async awaitInitialChainPoll(): Promise { + await this.chainPoller?.waitForCurrentPoll(); + } + async stop(): Promise { if (!this.started) return; if (this.chainPoller) { diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 5b35dd4e23..9f25caeb0b 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -192,6 +192,7 @@ export { type Rfc64CatalogAccessPolicyAuthorityConfigV1, type DKGAgentACKTransportOptions, type ContextGraphSub, + type InitialContextGraphSubscription, type ContextGraphDiscoveryMetadata, type ContextGraphDiscoveryOptions, type PublishOpts, diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index a19799e70a..c9e8b5128f 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Bounded receiver for one public/open root-lane catalog bucket. + * Bounded receiver for one public root-lane catalog bucket. The locally + * accepted scope may be open or governed; root lane and one bucket are the + * stable synchronization invariant. * * The supported vertical slice is intentionally narrow: one successor head, * one bucket with 1..1,024 rows, the root context-graph lane, and complete @@ -274,14 +276,14 @@ export class Rfc64PublicCatalogNativeReceiverV1 { this.#timeoutMs = timeoutMs; } - async synchronizeOnePublicOpenRow( + async synchronizeOneBoundedPublicRootRow( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, trustedCatalogScope: AuthorCatalogScopeV1, deployment: CatalogSealDeploymentProfileV1, signal?: AbortSignal, ): Promise { - const trustedScope = snapshotTrustedPublicOpenScope( + const trustedScope = snapshotTrustedBoundedPublicRootScope( trustedCatalogScope, announcement, ); @@ -289,7 +291,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { return this.withScopeSerialization( computeAuthorCatalogScopeDigestV1(trustedScope), async () => { - const evidence = await this.synchronizePublicOpenCatalogSerialized( + const evidence = await this.synchronizeBoundedPublicRootCatalogSerialized( remotePeerId, announcement, trustedScope, @@ -310,21 +312,21 @@ export class Rfc64PublicCatalogNativeReceiverV1 { * 1..1,024-row successor. This is the production-facing entrypoint for a fresh receiver: * callers do not need to seed durable history out of band. */ - async synchronizePublicOpenCatalog( + async synchronizeBoundedPublicRootCatalog( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, trustedCatalogScope: AuthorCatalogScopeV1, deployment: CatalogSealDeploymentProfileV1, signal?: AbortSignal, ): Promise { - const trustedScope = snapshotTrustedPublicOpenScope( + const trustedScope = snapshotTrustedBoundedPublicRootScope( trustedCatalogScope, announcement, ); const trustedDeployment = snapshotTrustedDeployment(deployment, trustedScope); return this.withScopeSerialization( computeAuthorCatalogScopeDigestV1(trustedScope), - () => this.synchronizePublicOpenCatalogSerialized( + () => this.synchronizeBoundedPublicRootCatalogSerialized( remotePeerId, announcement, trustedScope, @@ -336,14 +338,14 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } /** Fetch, fully verify, and durably initialize exactly one empty genesis. */ - async bootstrapEmptyPublicOpenCatalog( + async bootstrapEmptyBoundedPublicRootCatalog( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, trustedCatalogScope: AuthorCatalogScopeV1, deployment: CatalogSealDeploymentProfileV1, signal?: AbortSignal, ): Promise { - const trustedScope = snapshotTrustedPublicOpenScope( + const trustedScope = snapshotTrustedBoundedPublicRootScope( trustedCatalogScope, announcement, ); @@ -351,7 +353,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { return this.withScopeSerialization( computeAuthorCatalogScopeDigestV1(trustedScope), async () => { - const evidence = await this.synchronizePublicOpenCatalogSerialized( + const evidence = await this.synchronizeBoundedPublicRootCatalogSerialized( remotePeerId, announcement, trustedScope, @@ -367,7 +369,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ); } - private async synchronizePublicOpenCatalogSerialized( + private async synchronizeBoundedPublicRootCatalogSerialized( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, trustedCatalogScope: Readonly, @@ -387,7 +389,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { const head = fetchedHead.envelope; assertFetchedHeadMatchesTrustedScope(head, trustedCatalogScope); if (expected === 'genesis' || (expected === 'any' && claimsGenesisHistory(head))) { - return this.bootstrapEmptyPublicOpenCatalogFetched( + return this.bootstrapEmptyBoundedPublicRootCatalogFetched( remotePeerId, announcement, trustedCatalogScope, @@ -401,7 +403,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { if (expected === 'one-successor' && head.payload.totalRows !== '1') { fail('catalog-native-receiver-slice', 'one-row synchronization does not accept multi-row heads'); } - return this.synchronizeBoundedPublicOpenCatalogFetched( + return this.synchronizeBoundedPublicRootCatalogFetched( remotePeerId, announcement, trustedCatalogScope, @@ -411,7 +413,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { ); } - private async bootstrapEmptyPublicOpenCatalogFetched( + private async bootstrapEmptyBoundedPublicRootCatalogFetched( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, trustedCatalogScope: Readonly, @@ -537,7 +539,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { }); } - private async synchronizeBoundedPublicOpenCatalogFetched( + private async synchronizeBoundedPublicRootCatalogFetched( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, trustedCatalogScope: Readonly, @@ -1085,7 +1087,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } } -function snapshotTrustedPublicOpenScope( +function snapshotTrustedBoundedPublicRootScope( input: AuthorCatalogScopeV1, announcement: Rfc64PublicCatalogHeadAnnouncementV1, ): Readonly { @@ -1120,7 +1122,7 @@ function snapshotTrustedPublicOpenScope( } catch (cause) { fail( 'catalog-native-receiver-authorization', - 'catalog request is not bound to the locally accepted public/open policy scope', + 'catalog request is not bound to the locally accepted bounded public root scope', cause, ); } @@ -1167,7 +1169,7 @@ function assertFetchedHeadMatchesTrustedScope( } catch (cause) { fail( 'catalog-native-receiver-authorization', - 'fetched catalog head differs from the locally accepted public/open policy scope', + 'fetched catalog head differs from the locally accepted bounded public root scope', cause, ); } @@ -1189,7 +1191,7 @@ function assertEmptyGenesisHead(head: SignedAuthorCatalogHeadEnvelopeV1): void { ) { fail( 'catalog-native-receiver-slice', - 'genesis bootstrap requires the canonical empty public/open root catalog', + 'genesis bootstrap requires the canonical empty bounded public root catalog', ); } } @@ -1353,7 +1355,7 @@ function assertDirectAuthorCatalogIssuerDelegationBindingV1( || left.authorAddress !== trustedCatalogScope.authorAddress || left.catalogEra !== trustedCatalogScope.era ) { - throw new Error('delegation differs from the locally trusted public/open catalog scope'); + throw new Error('delegation differs from the locally trusted bounded public root catalog scope'); } if ( (left.catalogEra === '0') !== (left.previousDelegationDigest === null) diff --git a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts index 30ad11d69e..e292137ea3 100644 --- a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Production scheduler adapter for the bounded RFC-64 public/open native lane. + * Production scheduler adapter for the bounded RFC-64 public root native lane. * * The scheduler reasons about durable semantic application, while the native * receiver owns fetch, verification, activation, exact post-read, and the @@ -34,43 +34,43 @@ import type { Rfc64PublicCatalogHeadAnnouncementV1, } from './public-catalog-transport-v1.js'; -export type Rfc64PublicOpenCatalogNativeReceiverClientV1 = Pick< +export type Rfc64BoundedPublicRootCatalogNativeReceiverClientV1 = Pick< Rfc64PublicCatalogNativeReceiverV1, - 'synchronizePublicOpenCatalog' + 'synchronizeBoundedPublicRootCatalog' >; -export type Rfc64PublicOpenCatalogDeploymentResolverV1 = ( +export type Rfc64BoundedPublicRootCatalogDeploymentResolverV1 = ( announcement: Rfc64PublicCatalogHeadAnnouncementV1, signal: AbortSignal, ) => Promise; /** Resolve the exact catalog scope from independently accepted local policy state. */ -export type Rfc64PublicOpenCatalogTrustedScopeResolverV1 = ( +export type Rfc64BoundedPublicRootCatalogTrustedScopeResolverV1 = ( announcement: Rfc64PublicCatalogHeadAnnouncementV1, ) => Readonly; /** Exact verified signature variant loaded from the durable control-object store. */ -export interface Rfc64PublicOpenCatalogStagedHeadV1 { +export interface Rfc64BoundedPublicRootCatalogStagedHeadV1 { readonly envelope: SignedAuthorCatalogHeadEnvelopeV1; readonly signatureVariantDigest: Digest32V1; } -export type Rfc64PublicOpenCatalogStagedHeadReaderV1 = ( +export type Rfc64BoundedPublicRootCatalogStagedHeadReaderV1 = ( announcement: Rfc64PublicCatalogHeadAnnouncementV1, -) => Promise; +) => Promise; -export interface Rfc64PublicOpenCatalogNativeReconcilerOptionsV1 { - readonly nativeReceiver: Rfc64PublicOpenCatalogNativeReceiverClientV1; +export interface Rfc64BoundedPublicRootCatalogNativeReconcilerOptionsV1 { + readonly nativeReceiver: Rfc64BoundedPublicRootCatalogNativeReceiverClientV1; readonly inventory: Pick; /** Resolve from accepted policy state; never reconstruct authority from wire fields alone. */ - readonly resolveTrustedCatalogScope: Rfc64PublicOpenCatalogTrustedScopeResolverV1; + readonly resolveTrustedCatalogScope: Rfc64BoundedPublicRootCatalogTrustedScopeResolverV1; /** Resolve the locally trusted deployment tuple; never copy it from the wire. */ - readonly resolveDeployment: Rfc64PublicOpenCatalogDeploymentResolverV1; + readonly resolveDeployment: Rfc64BoundedPublicRootCatalogDeploymentResolverV1; /** * Read the exact verified signature variant staged by the native receiver. * Optional only for Gate-1 compatibility; a multi-row lane must provide it. */ - readonly readStagedCatalogHead?: Rfc64PublicOpenCatalogStagedHeadReaderV1; + readonly readStagedCatalogHead?: Rfc64BoundedPublicRootCatalogStagedHeadReaderV1; } /** @@ -112,13 +112,13 @@ export function deriveRfc64PublicOpenCatalogScopeV1( }); } -export class Rfc64PublicOpenCatalogNativeReconcilerV1 +export class Rfc64BoundedPublicRootCatalogNativeReconcilerV1 implements Rfc64PublicCatalogReceiverReconcilerV1 { constructor( - private readonly options: Rfc64PublicOpenCatalogNativeReconcilerOptionsV1, + private readonly options: Rfc64BoundedPublicRootCatalogNativeReconcilerOptionsV1, ) { if ( - typeof options?.nativeReceiver?.synchronizePublicOpenCatalog !== 'function' + typeof options?.nativeReceiver?.synchronizeBoundedPublicRootCatalog !== 'function' || typeof options?.inventory?.readAppliedCatalogHeadV1 !== 'function' || typeof options?.resolveTrustedCatalogScope !== 'function' || typeof options?.resolveDeployment !== 'function' @@ -127,7 +127,7 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 && typeof options.readStagedCatalogHead !== 'function' ) ) { - throw new TypeError('RFC-64 public/open native reconciler dependencies are incomplete'); + throw new TypeError('RFC-64 bounded public root native reconciler dependencies are incomplete'); } } @@ -205,7 +205,7 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 const deployment = await this.options.resolveDeployment(announcement, signal); throwIfAborted(signal); try { - await this.options.nativeReceiver.synchronizePublicOpenCatalog( + await this.options.nativeReceiver.synchronizeBoundedPublicRootCatalog( remotePeerId, announcement, trustedCatalogScope, @@ -226,10 +226,10 @@ export class Rfc64PublicOpenCatalogNativeReconcilerV1 } /** Construct the production scheduler adapter around one native receiver. */ -export function createRfc64PublicOpenCatalogNativeReconcilerV1( - options: Rfc64PublicOpenCatalogNativeReconcilerOptionsV1, +export function createRfc64BoundedPublicRootCatalogNativeReconcilerV1( + options: Rfc64BoundedPublicRootCatalogNativeReconcilerOptionsV1, ): Rfc64PublicCatalogReceiverReconcilerV1 { - return new Rfc64PublicOpenCatalogNativeReconcilerV1(options); + return new Rfc64BoundedPublicRootCatalogNativeReconcilerV1(options); } function throwIfAborted(signal: AbortSignal): void { diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 760ee55c12..6121002e50 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -76,7 +76,7 @@ import { produceDirectAuthorCatalogIssuerDelegationV1, } from './public-catalog-issuer-delegation-v1.js'; import { - type Rfc64PublicOpenCatalogTrustedScopeResolverV1, + type Rfc64BoundedPublicRootCatalogTrustedScopeResolverV1, } from './public-catalog-native-reconciler-v1.js'; import type { Rfc64PublicCatalogIssuerAuthorizationV1, @@ -132,7 +132,7 @@ export type Rfc64PublicCatalogContentFetchClientV1 = Pick< export interface Rfc64PublicCatalogReconcilerClientsV1 { readonly headTransport: Rfc64PublicCatalogHeadFetchClientV1; readonly contentTransport: Rfc64PublicCatalogContentFetchClientV1; - readonly resolveTrustedCatalogScope: Rfc64PublicOpenCatalogTrustedScopeResolverV1; + readonly resolveTrustedCatalogScope: Rfc64BoundedPublicRootCatalogTrustedScopeResolverV1; readonly transportTimeoutMs: number; } diff --git a/packages/agent/src/sync/requester/durable-sync.ts b/packages/agent/src/sync/requester/durable-sync.ts index 3e98224012..df9a8f9963 100644 --- a/packages/agent/src/sync/requester/durable-sync.ts +++ b/packages/agent/src/sync/requester/durable-sync.ts @@ -5,7 +5,11 @@ import { import { contextGraphDataGraphUri, contextGraphMetaGraphUri } from '@origintrail-official/dkg-core'; import type { OperationContext } from '@origintrail-official/dkg-core'; import type { Quad } from '@origintrail-official/dkg-storage'; -import type { PhaseCallback } from '@origintrail-official/dkg-publisher'; +import { + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, + readGraphKnowledgeAssetConfirmationKindV1, + type PhaseCallback, +} from '@origintrail-official/dkg-publisher'; import type { DurableBatchVerificationMode } from '../../sync-verify-worker.js'; import { packKnowledgeAssetIdFromIdentity } from '../../ka-identity.js'; import { planBoundedGraphScopedDurableBatch } from '../durable-integrity.js'; @@ -26,7 +30,6 @@ const CONTEXT_GRAPH = `${DKG_NS}contextGraph`; const BATCH_ID = `${DKG_NS}batchId`; const MATERIALIZED_VERSION = `${DKG_NS}materializedVersion`; const TRANSACTION_HASH = `${DKG_NS}transactionHash`; -const CONFIRMATION_KIND = `${DKG_NS}confirmationKind`; const XSD_INTEGER = 'http://www.w3.org/2001/XMLSchema#integer'; const PEER_UNTRUSTED_METADATA_PREDICATES = new Set([ MATERIALIZED_VERSION, @@ -47,7 +50,7 @@ const GRAPH_SCOPED_SYNC_METADATA_PREDICATES = new Set([ `${DKG_NS}contextGraph`, `${DKG_NS}subGraphName`, TRANSACTION_HASH, - CONFIRMATION_KIND, + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, ]); export interface DurableSyncSummary { @@ -741,24 +744,13 @@ function partitionVerifiedGraphScopedAssets( if (!versionRaw || !/^\d+$/.test(versionRaw)) { throw new Error(`Verified graph-scoped KA ${ual} has invalid assertionVersion ${versionRaw ?? ''}`); } - const confirmationKinds = new Set( - metadataQuads - .filter((quad) => quad.predicate === CONFIRMATION_KIND) - .map((quad) => stripLiteral(quad.object)), - ); - if (confirmationKinds.size > 1) { - throw new Error( - `Verified graph-scoped KA ${ual} has ${confirmationKinds.size} confirmation kinds`, - ); - } - const [confirmationKindRaw] = confirmationKinds; - if ( - confirmationKindRaw !== undefined - && confirmationKindRaw !== 'transaction' - && confirmationKindRaw !== 'finalized-materialization' - ) { + let confirmationKind; + try { + confirmationKind = readGraphKnowledgeAssetConfirmationKindV1(metadataQuads); + } catch (cause) { throw new Error( - `Verified graph-scoped KA ${ual} has unsupported confirmation kind ${confirmationKindRaw}`, + `Verified graph-scoped KA ${ual} has invalid confirmation metadata`, + { cause }, ); } const metaGraphs = new Set(metadataQuads.map((quad) => quad.graph)); @@ -793,7 +785,7 @@ function partitionVerifiedGraphScopedAssets( contextGraphId, ual, assertionVersion: BigInt(versionRaw), - confirmationKind: confirmationKindRaw ?? 'transaction', + confirmationKind, assertionGraph, metaGraph, dataQuads: dataByGraph.get(assertionGraph) ?? [], diff --git a/packages/agent/src/sync/requester/graph-scoped-materialization.ts b/packages/agent/src/sync/requester/graph-scoped-materialization.ts index c0e1cd352d..b5e8b256d4 100644 --- a/packages/agent/src/sync/requester/graph-scoped-materialization.ts +++ b/packages/agent/src/sync/requester/graph-scoped-materialization.ts @@ -10,8 +10,11 @@ import { type TripleStore, } from '@origintrail-official/dkg-storage'; import { + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, + normalizeGraphKnowledgeAssetConfirmationKindV1, readLocallyTrustedKnowledgeAssetControls, withMaterializationLock, + type GraphKnowledgeAssetConfirmationKind, } from '@origintrail-official/dkg-publisher'; import { filterOversizedSyncQuads, @@ -31,7 +34,7 @@ export interface VerifiedGraphScopedAsset { ual: string; assertionVersion: bigint; /** Omitted only for rolling-compatible receipt-backed metadata. */ - confirmationKind?: 'transaction' | 'finalized-materialization'; + confirmationKind?: GraphKnowledgeAssetConfirmationKind; assertionGraph: string; metaGraph: string; dataQuads: Quad[]; @@ -181,7 +184,9 @@ export async function authenticateVerifiedGraphScopedAsset( ); } - const confirmationKind = asset.confirmationKind ?? 'transaction'; + const confirmationKind = normalizeGraphKnowledgeAssetConfirmationKindV1( + asset.confirmationKind, + ); let materializedBlock: number; let materializedTxIndex: number; if (confirmationKind === 'finalized-materialization') { @@ -203,13 +208,6 @@ export async function authenticateVerifiedGraphScopedAsset( // remains the authoritative stale-write guard for this receiptless lane. materializedBlock = 0; materializedTxIndex = 0; - } else if (confirmationKind !== 'transaction') { - throw Object.assign( - new Error( - `Graph-scoped durable sync ${asset.ual} has unsupported confirmation kind ${String(confirmationKind)}`, - ), - { code: 'VM_CHAIN_PROVENANCE_MISMATCH' }, - ); } else if (transactionHashes.length !== 1) { throw Object.assign( new Error( @@ -327,18 +325,33 @@ export async function materializeVerifiedGraphScopedAsset(params: { } let replacementMetadata = asset.metadataQuads; if (currentVersion === asset.assertionVersion) { - const currentPublishedAt = await readCurrentPublishedAt( - store, - asset.metaGraph, - asset.ual, - options, - ); + const [currentPublishedAt, currentReceiptProvenance] = await Promise.all([ + readCurrentPublishedAt(store, asset.metaGraph, asset.ual, options), + asset.confirmationKind === 'finalized-materialization' + ? readCurrentReceiptBackedProvenance( + store, + asset.metaGraph, + asset.ual, + options, + ) + : Promise.resolve(undefined), + ]); if (currentPublishedAt) { replacementMetadata = [ ...asset.metadataQuads.filter((quad) => quad.predicate !== PUBLISHED_AT), currentPublishedAt, ]; } + if (currentReceiptProvenance) { + replacementMetadata = [ + ...replacementMetadata.filter((quad) => ( + quad.predicate !== TRANSACTION_HASH + && quad.predicate !== MATERIALIZED_VERSION + && quad.predicate !== GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE + )), + ...currentReceiptProvenance, + ]; + } } const locallyTrustedMetadata = await readLocallyTrustedKnowledgeAssetControls( store, @@ -367,6 +380,78 @@ export async function materializeVerifiedGraphScopedAsset(params: { }); } +/** + * Preserve stronger locally authenticated receipt provenance when a peer + * replays the same assertion through the receiptless finalized lane. The data + * graph is still replaced exactly (healing poisoned replicas), but remote + * metadata cannot downgrade a transaction-confirmed local assertion to 0:0. + */ +async function readCurrentReceiptBackedProvenance( + store: TripleStore, + metaGraph: string, + ual: string, + options: QueryOptions, +): Promise { + const result = await store.query(` + SELECT ?predicate ?object WHERE { + GRAPH <${assertSafeIri(metaGraph)}> { + <${assertSafeIri(ual)}> ?predicate ?object . + VALUES ?predicate { + <${TRANSACTION_HASH}> + <${MATERIALIZED_VERSION}> + <${GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE}> + <${STATUS}> + } + } + } + `, options); + if (result.type !== 'bindings') return undefined; + const byPredicate = new Map(); + for (const row of result.bindings) { + if (!row.predicate || !row.object) return undefined; + const values = byPredicate.get(row.predicate) ?? []; + values.push(row.object); + byPredicate.set(row.predicate, values); + } + const hashes = byPredicate.get(TRANSACTION_HASH) ?? []; + const versions = byPredicate.get(MATERIALIZED_VERSION) ?? []; + const kinds = byPredicate.get(GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE) ?? []; + const statuses = byPredicate.get(STATUS) ?? []; + if ( + hashes.length !== 1 + || kinds.length > 1 + || statuses.length !== 1 + || parseRdfLiteral(statuses[0]!) !== 'confirmed' + || (kinds.length === 1 && parseRdfLiteral(kinds[0]!) !== 'transaction') + ) { + return undefined; + } + try { + parseTransactionHashLiteral(hashes[0]!); + } catch { + return undefined; + } + const materializedVersion = versions.length === 1 + && /^(?:0|[1-9]\d*):(?:0|[1-9]\d*)$/.test(parseRdfLiteral(versions[0]!) ?? '') + ? versions[0] + : undefined; + return [ + { subject: ual, predicate: TRANSACTION_HASH, object: hashes[0]!, graph: metaGraph }, + { + subject: ual, + predicate: GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, + object: '"transaction"', + graph: metaGraph, + }, + ...(materializedVersion === undefined ? [] : [{ + subject: ual, + predicate: MATERIALIZED_VERSION, + object: materializedVersion, + graph: metaGraph, + }]), + ]; +} + async function readCurrentPublishedAt( store: TripleStore, metaGraph: string, @@ -425,13 +510,23 @@ function parseBytes32Literal(raw: string, field: string): Uint8Array { } function parseTransactionHashLiteral(raw: string): string { - const lexical = raw.match(/^"([^"]*)"(?:\^\^.*|@.*)?$/)?.[1] ?? raw; + const lexical = parseRdfLiteral(raw) ?? raw; if (!/^0x[0-9a-f]{64}$/i.test(lexical)) { throw new Error('Graph-scoped durable sync transactionHash must be a 32-byte hex literal'); } return lexical; } +function parseRdfLiteral(raw: string): string | undefined { + const encoded = /^("(?:\\.|[^"\\])*")/.exec(raw)?.[1]; + if (encoded === undefined) return undefined; + try { + return JSON.parse(encoded); + } catch { + return undefined; + } +} + function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { return left.length === right.length && left.every((byte, index) => byte === right[index]); } diff --git a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts index 05b7e10a16..25a7818d18 100644 --- a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts +++ b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts @@ -539,12 +539,81 @@ describe('durable graph-scoped KA materialization', () => { }); expect(assets).toHaveLength(1); - expect(assets[0]).toMatchObject({ ual, assertionGraph }); + expect(assets[0]).toMatchObject({ + ual, + assertionGraph, + confirmationKind: 'transaction', + }); expect(inserted.filter((quad) => quad.subject === lifecycle)).toEqual( lifecycleRows.filter((quad) => quad.predicate !== `${DKG}assertionVersion`), ); }); + it.each([ + ['conflicting', ['"transaction"', '"finalized-materialization"']], + ['unsupported', ['"unsupported"']], + ])('rejects %s peer confirmation metadata before durable materialization', async (_label, kinds) => { + const v2Data = dataQuad(2); + const v2Meta = metadata(2); + v2Meta.push( + { + subject: ual, + predicate: `${DKG}publicTripleCount`, + object: `"1"^^<${XSD_INTEGER}>`, + graph: metaGraph, + }, + { + subject: ual, + predicate: `${DKG}privateTripleCount`, + object: `"0"^^<${XSD_INTEGER}>`, + graph: metaGraph, + }, + ...kinds.map((object) => ({ + subject: ual, + predicate: `${DKG}confirmationKind`, + object, + graph: metaGraph, + })), + ); + const materialized: unknown[] = []; + + const summary = await runDurableSync({ + ctx, + remotePeerId: 'peer-invalid-confirmation-kind', + contextGraphIds: [contextGraphId], + createContextGraphSyncDeadline: () => Date.now() + 10_000, + fetchSyncPages: async (_ctx, _peer, _cg, _shared, phase) => ( + phase === 'data' ? page(phase, [v2Data]) : page(phase, v2Meta) + ), + processDurableBatchInWorker: async () => ({ + verifiedData: [v2Data], + verifiedMeta: v2Meta, + verifiedGraphScopedDataGraphs: [assertionGraph], + totalFetchedDataQuads: 1, + totalFetchedMetaQuads: v2Meta.length, + rejectedKcs: 0, + emptyResponses: 0, + metaOnlyResponses: 0, + verifiedPrivateOnlyResponses: 0, + dataRejectedMissingMeta: 0, + }), + storeInsert: async () => {}, + storeGraphScopedAsset: async (asset) => { + materialized.push(asset); + return 'applied'; + }, + deleteCheckpoint: () => {}, + setCheckpoint: () => {}, + logInfo: () => {}, + logWarn: () => {}, + logDebug: () => {}, + }); + + expect(summary.failedPhases).toBe(1); + expect(summary.insertedTriples).toBe(0); + expect(materialized).toEqual([]); + }); + it('replaces a poisoned v1 union with the verified v2 assertion and metadata', async () => { const store = new OxigraphStore(); const v1Data = dataQuad(1); @@ -824,6 +893,85 @@ describe('durable graph-scoped KA materialization', () => { expect(await values(freshRequesterStore, 'materializedVersion')).toEqual(['"0:0"']); }); + it('does not downgrade same-version local receipt provenance during finalized durable replay', async () => { + const sourceNodeStore = new OxigraphStore(); + const requesterStore = new OxigraphStore(); + const v2Data = dataQuad(2); + const root = computeFlatKCRootV10([v2Data], []); + const sourceMetadata = finalizedMaterializationMetadata(2, root); + await sourceNodeStore.insert([v2Data, ...sourceMetadata]); + await requesterStore.insert([ + dataQuad(1), + ...metadata(2, toHex(root)), + ...[ + ['publicTripleCount', `"1"^^<${XSD_INTEGER}>`], + ['privateTripleCount', `"0"^^<${XSD_INTEGER}>`], + ['status', '"confirmed"'], + ['publishedAt', `"2026-07-16T08:00:00.000Z"^^<${XSD_DATE_TIME}>`], + ].map(([predicate, object]) => ({ + subject: ual, + predicate: `${DKG}${predicate}`, + object, + graph: metaGraph, + })), + ]); + const servedData = await graphQuads(sourceNodeStore, assertionGraph); + const servedMeta = await graphQuads(sourceNodeStore, metaGraph); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot: async () => root, + getMerkleRootCount: async () => 2n, + getKAContextGraphId: async () => 14n, + getContextGraphNameHash: async () => ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)), + } as ChainAdapter; + + const summary = await runDurableSync({ + ctx, + remotePeerId: 'finalized-vm-replay-source', + contextGraphIds: [contextGraphId], + createContextGraphSyncDeadline: () => Date.now() + 10_000, + fetchSyncPages: async (_ctx, _peer, _cg, _shared, phase) => ( + phase === 'data' ? page(phase, servedData) : page(phase, servedMeta) + ), + processDurableBatchInWorker: async (dataQuads, metaQuads, _ctx, acceptUnverified, mode) => { + const verified = processDurableBatchForWire( + dataQuads, + metaQuads, + acceptUnverified, + mode, + ); + return { + ...verified, + verifiedData: verified.verifiedDataIndexes.map((index) => dataQuads[index]!), + verifiedMeta: verified.verifiedMetaIndexes.map((index) => metaQuads[index]!), + }; + }, + storeInsert: (quads) => requesterStore.insert(quads), + storeGraphScopedAsset: async (asset) => materializeVerifiedGraphScopedAsset({ + store: requesterStore, + asset: (await authenticateVerifiedGraphScopedAsset( + chain, + asset, + strictContextGraphBindingVerifier(chain), + )).asset, + }), + deleteCheckpoint: () => {}, + setCheckpoint: () => {}, + logInfo: () => {}, + logWarn: () => {}, + logDebug: () => {}, + }); + + expect(summary.failedPhases).toBe(0); + expect(await graphQuads(requesterStore, assertionGraph)).toEqual(servedData); + expect(await values(requesterStore, 'transactionHash')).toEqual([`"${transactionHash(2)}"`]); + expect(await values(requesterStore, 'confirmationKind')).toEqual(['"transaction"']); + expect(await values(requesterStore, 'materializedVersion')).toEqual([]); + expect(await values(requesterStore, 'publishedAt')).toEqual([ + `"2026-07-16T08:00:00Z"^^<${XSD_DATE_TIME}>`, + ]); + }); + it('does not let a stale durable page replace a newer local assertion', async () => { const store = new OxigraphStore(); const v1Data = dataQuad(1); diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 3b62c10098..49beec0455 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -118,14 +118,14 @@ async function startNativeAgent( hubAddress: CONTEXT_GRAPH_STORAGE, operationalKeys: [`0x${'12'.repeat(32)}`], }, + ...(finalizedRuntime.initialSubscription === undefined ? {} : { + initialContextGraphSubscriptions: [{ + contextGraphId: finalizedRuntime.initialSubscription, + state: { subscribed: true, synced: false }, + }], + }), }), }); - if (finalizedRuntime?.initialSubscription !== undefined) { - (agent as any).setContextGraphSubscription(finalizedRuntime.initialSubscription, { - subscribed: true, - synced: false, - }, { persist: false }); - } agents.push(agent); await agent.start(); return agent; @@ -607,7 +607,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { roster: null, }); } - await (receiver as any).chainPoller.inFlightPoll; + await receiver.awaitInitialChainPoll(); const storeQuery = vi.spyOn((receiver as any).store, 'query'); await expect(receiver.getContextGraphOnChainId(CONTEXT_GRAPH_ID)).resolves.toBe( ON_CHAIN_CONTEXT_GRAPH_ID, diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 547b03dd07..7b94a5a80c 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -896,7 +896,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { it('rejects a locally resolved deployment for another network before head fetch', async () => { const fixture = await setupLiveReceiver(); - await expect(fixture.receiver.bootstrapEmptyPublicOpenCatalog( + await expect(fixture.receiver.bootstrapEmptyBoundedPublicRootCatalog( 'peer-unused', fixture.genesisAnnouncement, fixture.scope, @@ -1891,7 +1891,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { selectedAnnouncement = genesisAnnouncement, selectedReceiver = receiver, signal?: AbortSignal, - ) => selectedReceiver.bootstrapEmptyPublicOpenCatalog( + ) => selectedReceiver.bootstrapEmptyBoundedPublicRootCatalog( authorNode.peerId, selectedAnnouncement, scope, @@ -1901,7 +1901,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { bootstrapGoverned: ( selectedReceiver = receiver, signal?: AbortSignal, - ) => selectedReceiver.bootstrapEmptyPublicOpenCatalog( + ) => selectedReceiver.bootstrapEmptyBoundedPublicRootCatalog( authorNode.peerId, governedGenesisAnnouncement, governedScope, @@ -1912,7 +1912,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { selectedAnnouncement = announcement, selectedReceiver = receiver, signal?: AbortSignal, - ) => selectedReceiver.synchronizeOnePublicOpenRow( + ) => selectedReceiver.synchronizeOneBoundedPublicRootRow( authorNode.peerId, selectedAnnouncement, scope, @@ -1923,7 +1923,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { selectedAnnouncement = announcement, selectedReceiver = receiver, signal?: AbortSignal, - ) => selectedReceiver.synchronizePublicOpenCatalog( + ) => selectedReceiver.synchronizeBoundedPublicRootCatalog( authorNode.peerId, selectedAnnouncement, scope, @@ -1933,7 +1933,7 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { synchronizeGoverned: ( selectedReceiver = receiver, signal?: AbortSignal, - ) => selectedReceiver.synchronizeOnePublicOpenRow( + ) => selectedReceiver.synchronizeOneBoundedPublicRootRow( authorNode.peerId, governedSuccessorAnnouncement, governedScope, diff --git a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts index b86134ab1c..8897a3a0b4 100644 --- a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts @@ -9,10 +9,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { AppliedCatalogHeadSnapshotV1 } from '../src/rfc64/inventory-v1/index.js'; import { buildOpenOwnerContextGraphPolicyV1 } from '../src/rfc64/open-catalog-policy-v1.js'; import { - createRfc64PublicOpenCatalogNativeReconcilerV1, + createRfc64BoundedPublicRootCatalogNativeReconcilerV1, deriveRfc64PublicOpenCatalogScopeV1, - type Rfc64PublicOpenCatalogNativeReceiverClientV1, - type Rfc64PublicOpenCatalogStagedHeadV1, + type Rfc64BoundedPublicRootCatalogNativeReceiverClientV1, + type Rfc64BoundedPublicRootCatalogStagedHeadV1, } from '../src/rfc64/public-catalog-native-reconciler-v1.js'; import { Rfc64PublicCatalogNativeReceiverErrorV1, @@ -91,18 +91,18 @@ function snapshot( } function receiver( - synchronizePublicOpenCatalog: Rfc64PublicOpenCatalogNativeReceiverClientV1[ - 'synchronizePublicOpenCatalog' + synchronizeBoundedPublicRootCatalog: Rfc64BoundedPublicRootCatalogNativeReceiverClientV1[ + 'synchronizeBoundedPublicRootCatalog' ], -): Rfc64PublicOpenCatalogNativeReceiverClientV1 { - return { synchronizePublicOpenCatalog }; +): Rfc64BoundedPublicRootCatalogNativeReceiverClientV1 { + return { synchronizeBoundedPublicRootCatalog }; } function stagedHead( input: Rfc64PublicCatalogHeadAnnouncementV1, totalRows: string, - overrides: Partial = {}, -): Rfc64PublicOpenCatalogStagedHeadV1 { + overrides: Partial = {}, +): Rfc64BoundedPublicRootCatalogStagedHeadV1 { return { envelope: { objectDigest: input.catalogHeadObjectDigest, @@ -130,11 +130,11 @@ function stagedHead( }; } -describe('RFC-64 public/open native reconciler v1', () => { +describe('RFC-64 bounded public root native reconciler v1', () => { it('maps both genesis and successor evidence to applied and passes deployment plus cancellation', async () => { const synchronize = vi.fn(async () => ({ inventoryRowCount: 0 } as never)); const resolveDeployment = vi.fn(async () => DEPLOYMENT); - const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + const reconciler = createRfc64BoundedPublicRootCatalogNativeReconcilerV1({ nativeReceiver: receiver(synchronize), inventory: { readAppliedCatalogHeadV1: () => null }, resolveTrustedCatalogScope, @@ -167,9 +167,9 @@ describe('RFC-64 public/open native reconciler v1', () => { ); }); - it('dedupes only an exact fixed public/open applied head', async () => { + it('dedupes only an exact bounded public root applied head', async () => { const readAppliedCatalogHeadV1 = vi.fn(); - const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + const reconciler = createRfc64BoundedPublicRootCatalogNativeReconcilerV1({ nativeReceiver: receiver(vi.fn()), inventory: { readAppliedCatalogHeadV1 }, resolveTrustedCatalogScope, @@ -214,7 +214,7 @@ describe('RFC-64 public/open native reconciler v1', () => { const readAppliedCatalogHeadV1 = vi.fn(() => snapshot(successor, { inventoryRowCount: '2', })); - const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + const reconciler = createRfc64BoundedPublicRootCatalogNativeReconcilerV1({ nativeReceiver: receiver(vi.fn()), inventory: { readAppliedCatalogHeadV1 }, resolveTrustedCatalogScope, @@ -236,7 +236,7 @@ describe('RFC-64 public/open native reconciler v1', () => { readStagedCatalogHead.mockResolvedValueOnce(null); await expect(reconciler.isHeadApplied(successor)).resolves.toBe(false); - const legacyOnly = createRfc64PublicOpenCatalogNativeReconcilerV1({ + const legacyOnly = createRfc64BoundedPublicRootCatalogNativeReconcilerV1({ nativeReceiver: receiver(vi.fn()), inventory: { readAppliedCatalogHeadV1 }, resolveTrustedCatalogScope, @@ -251,7 +251,7 @@ describe('RFC-64 public/open native reconciler v1', () => { 'missing', ); const synchronize = vi.fn(async () => { throw notFound; }); - const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + const reconciler = createRfc64BoundedPublicRootCatalogNativeReconcilerV1({ nativeReceiver: receiver(synchronize), inventory: { readAppliedCatalogHeadV1: () => null }, resolveTrustedCatalogScope, @@ -280,7 +280,7 @@ describe('RFC-64 public/open native reconciler v1', () => { const synchronize = vi.fn(); const deploymentFailure = new Error('trusted deployment unavailable'); const resolveDeployment = vi.fn(async () => { throw deploymentFailure; }); - const reconciler = createRfc64PublicOpenCatalogNativeReconcilerV1({ + const reconciler = createRfc64BoundedPublicRootCatalogNativeReconcilerV1({ nativeReceiver: receiver(synchronize), inventory: { readAppliedCatalogHeadV1: () => null }, resolveTrustedCatalogScope, diff --git a/packages/publisher/src/chain-event-poller.ts b/packages/publisher/src/chain-event-poller.ts index ac202e3422..83cd3b63f5 100644 --- a/packages/publisher/src/chain-event-poller.ts +++ b/packages/publisher/src/chain-event-poller.ts @@ -206,6 +206,12 @@ export class ChainEventPoller { .finally(() => { this.inFlightPoll = null; }); } + /** Wait for the startup/current poll without exposing poller internals. */ + async waitForCurrentPoll(): Promise { + const pending = this.inFlightPoll; + if (pending) await pending; + } + /** * Stop the interval and wait for any in-flight poll to settle. * diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index 7af1356d13..6e7b84e3a6 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -75,7 +75,7 @@ export { type ValidationResult, type ValidationOptions, } from './validation.js'; -export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type GraphKnowledgeAssetConfirmation, type GraphKnowledgeAssetMetadataState, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; +export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, normalizeGraphKnowledgeAssetConfirmationKindV1, readGraphKnowledgeAssetConfirmationKindV1, GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type GraphKnowledgeAssetConfirmation, type GraphKnowledgeAssetConfirmationKind, type GraphKnowledgeAssetMetadataState, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; export { pruneSupersededAgentRegistryMeta, insertBoundedAgentRegistryMeta } from './agent-registry-meta-retention.js'; export { DKGPublisher, diff --git a/packages/publisher/src/metadata.ts b/packages/publisher/src/metadata.ts index 2a90ec1b87..6056ed6049 100644 --- a/packages/publisher/src/metadata.ts +++ b/packages/publisher/src/metadata.ts @@ -106,13 +106,19 @@ export interface OnChainProvenance { chainId: string; } +export const GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE = `${DKG}confirmationKind`; + +export type GraphKnowledgeAssetConfirmationKind = + | 'transaction' + | 'finalized-materialization'; + export type GraphKnowledgeAssetConfirmation = | Readonly<{ - kind: 'transaction'; + kind: Extract; provenance: OnChainProvenance; }> | Readonly<{ - kind: 'finalized-materialization'; + kind: Extract; provenance: Readonly<{ batchId: bigint; materializedVersion: MaterializedVersion; @@ -126,6 +132,36 @@ export type GraphKnowledgeAssetMetadataState = readonly confirmation: GraphKnowledgeAssetConfirmation; }>; +/** + * Parse the graph-scoped confirmation discriminator shared by metadata writers + * and durable-sync readers. Missing metadata is the rolling-compatible legacy + * receipt-backed shape; every explicit value must name exactly one supported + * confirmation lane. + */ +export function normalizeGraphKnowledgeAssetConfirmationKindV1( + raw: string | undefined, +): GraphKnowledgeAssetConfirmationKind { + if (raw === undefined || raw === 'transaction') return 'transaction'; + if (raw === 'finalized-materialization') return raw; + throw new Error(`Unsupported graph knowledge asset confirmation kind: ${raw}`); +} + +/** Read and validate the confirmation state from one KA's structural metadata. */ +export function readGraphKnowledgeAssetConfirmationKindV1( + metadataQuads: readonly Pick[], +): GraphKnowledgeAssetConfirmationKind { + const values = metadataQuads + .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE) + .map((quad) => rdfLiteralLexicalValue(quad.object)); + if (values.length > 1) { + throw new Error(`Graph knowledge asset metadata has ${values.length} confirmation kinds`); + } + if (values.length === 1 && values[0] === undefined) { + throw new Error('Graph knowledge asset confirmation kind must be an RDF literal'); + } + return normalizeGraphKnowledgeAssetConfirmationKindV1(values[0]); +} + export interface GraphKnowledgeAssetMetadata extends KCMetadata { assertionVersion: string | number | bigint; publicTripleCount: number; @@ -362,7 +398,7 @@ export function generateGraphKnowledgeAssetMetadata( )); quads.push(mq( scope.ual, - `${DKG}confirmationKind`, + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, lit('transaction'), metaGraph, )); @@ -376,7 +412,7 @@ export function generateGraphKnowledgeAssetMetadata( mq(scope.ual, `${DKG}batchId`, intLit(batchId), metaGraph), mq( scope.ual, - `${DKG}confirmationKind`, + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, lit('finalized-materialization'), metaGraph, ), diff --git a/packages/publisher/test/metadata.test.ts b/packages/publisher/test/metadata.test.ts index f457514cca..efdd7e5ff0 100644 --- a/packages/publisher/test/metadata.test.ts +++ b/packages/publisher/test/metadata.test.ts @@ -7,6 +7,7 @@ import { generateConfirmedMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, + readGraphKnowledgeAssetConfirmationKindV1, generateShareMetadata, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, @@ -375,6 +376,34 @@ describe('generateConfirmedFullMetadata', () => { }); describe('generateGraphKnowledgeAssetMetadata confirmation state', () => { + it('parses explicit and rolling-compatible legacy confirmation state centrally', () => { + const base = generateGraphKnowledgeAssetMetadata(makeGraphMeta(), { status: 'tentative' }); + expect(readGraphKnowledgeAssetConfirmationKindV1(base)).toBe('transaction'); + expect(readGraphKnowledgeAssetConfirmationKindV1([ + { + subject: GRAPH_UAL, + predicate: `${DKG}confirmationKind`, + object: '"finalized-materialization"', + graph: META_GRAPH, + }, + ])).toBe('finalized-materialization'); + }); + + it('rejects unsupported and conflicting confirmation state centrally', () => { + const quad = (object: string) => ({ + subject: GRAPH_UAL, + predicate: `${DKG}confirmationKind`, + object, + graph: META_GRAPH, + }); + expect(() => readGraphKnowledgeAssetConfirmationKindV1([quad('"bogus"')])) + .toThrow('Unsupported graph knowledge asset confirmation kind'); + expect(() => readGraphKnowledgeAssetConfirmationKindV1([ + quad('"transaction"'), + quad('"finalized-materialization"'), + ])).toThrow('2 confirmation kinds'); + }); + it('preserves the tentative metadata shape without confirmation provenance', () => { const quads = generateGraphKnowledgeAssetMetadata( makeGraphMeta(), From f05c391ae23a2ed6722fea775d112109c7353a8c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 19:30:26 +0200 Subject: [PATCH 269/292] fix(rfc64): harden finalized VM trust boundaries --- .../adapter-process.ts | 30 ++++++++++++-- .../finalized-vm-harness-runtime.ts | 3 ++ .../public-vm.ts | 9 +++-- .../run.ts | 24 ++++++++++++ packages/agent/src/dkg-agent-lifecycle.ts | 7 ---- packages/agent/src/dkg-agent-rfc64-catalog.ts | 2 + packages/agent/src/dkg-agent-types.ts | 13 ------- packages/agent/src/index.ts | 1 - .../rfc64/finalized-vm-agent-precommit-v1.ts | 15 ++++++- .../src/rfc64/finalized-vm-composer-v1.ts | 15 ++++++- .../src/rfc64/finalized-vm-runtime-v1.ts | 6 +++ .../agent/src/sync/requester/durable-sync.ts | 4 +- .../requester/graph-scoped-materialization.ts | 12 ++---- ...-sync-graph-scoped-materialization.test.ts | 6 +-- ...kg-agent-native-wiring.integration.test.ts | 39 +++++++++++++++---- ...64-finalized-vm-agent-precommit-v1.test.ts | 10 +++++ .../rfc64-finalized-vm-composer-v1.test.ts | 17 ++++---- .../rfc64-finalized-vm-runtime-v1.test.ts | 2 + ...c-catalog-native-gate1.integration.test.ts | 1 + .../rfc64-finalized-vm-loopback-fixture.ts | 5 ++- .../rfc64-finalized-vm-placement-fixture.ts | 7 ++-- 21 files changed, 163 insertions(+), 65 deletions(-) diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts index 42dc62be1f..8692372521 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -9,6 +9,8 @@ import { RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, produceDirectAuthorCatalogIssuerDelegationV1, produceEmptyAuthorCatalogGenesisV1, + type ContextGraphSubscriptionRecord, + type ContextGraphSubscriptionStore, } from '@origintrail-official/dkg-agent'; import { MockChainAdapter, @@ -167,10 +169,9 @@ async function boot(): Promise { }, }), ...(finalizedVmConfig === null ? {} : { - initialContextGraphSubscriptions: [{ - contextGraphId: finalizedVmConfig.contextGraphId, - state: { subscribed: true, synced: false }, - }], + contextGraphSubscriptionStore: createHarnessSubscriptionStore( + finalizedVmConfig.contextGraphId, + ), }), }); agent = created; @@ -196,6 +197,27 @@ async function boot(): Promise { }); } +/** Model an already-durable subscription through the production restore boundary. */ +function createHarnessSubscriptionStore( + contextGraphId: string, +): ContextGraphSubscriptionStore { + const records = new Map([[contextGraphId, { + id: contextGraphId, + subscribed: true, + synced: false, + syncScoped: true, + }]]); + return { + loadAll: async () => [...records.values()].map((record) => ({ ...record })), + load: async (id) => { + const record = records.get(id); + return record === undefined ? null : { ...record }; + }, + save: async (record) => { records.set(record.id, { ...record }); }, + delete: async (id) => { records.delete(id); }, + }; +} + async function handle(command: Command): Promise { if (typeof command.requestId !== 'string' || command.requestId.length === 0) { throw new Error('requestId is required'); diff --git a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts index 62ea512458..d35130a68e 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/finalized-vm-harness-runtime.ts @@ -22,6 +22,8 @@ export const RFC64_GATE2_DEPLOYMENT = Object.freeze({ const RFC64_GATE2_CONTEXT_GRAPH_STORAGE_ADDRESS = '0x3333333333333333333333333333333333333333' as EvmAddressV1; +const RFC64_GATE2_KNOWLEDGE_ASSET_STORAGE_ADDRESS = + '0x5555555555555555555555555555555555555555' as EvmAddressV1; export interface FinalizedVmHarnessConfigV1 { readonly assertionRoot: Digest32V1; @@ -88,6 +90,7 @@ export async function startFinalizedVmHarnessRuntimeV1( assertedAtChainId: RFC64_GATE2_DEPLOYMENT.assertedAtChainId, assertedAtKav10Address: RFC64_GATE2_DEPLOYMENT.assertedAtKav10Address as EvmAddressV1, + knowledgeAssetStorageAddress: RFC64_GATE2_KNOWLEDGE_ASSET_STORAGE_ADDRESS, assets: Object.freeze([Object.freeze({ assertionRoot: config.assertionRoot, assertionVersion: config.assertionVersion, diff --git a/devnet/rfc64-gate2-multi-asset-completeness/public-vm.ts b/devnet/rfc64-gate2-multi-asset-completeness/public-vm.ts index d4add2d712..c86164ec0c 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/public-vm.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/public-vm.ts @@ -44,7 +44,8 @@ const CONTEXT_GRAPH_ID = '0x1111111111111111111111111111111111111111/m2-public-vm-process'; const ON_CHAIN_CONTEXT_GRAPH_ID = '14'; const CG_STORAGE = '0x3333333333333333333333333333333333333333'; -const KA_STORAGE = '0x4444444444444444444444444444444444444444'; +const KAV10 = '0x4444444444444444444444444444444444444444'; +const KA_STORAGE = '0x5555555555555555555555555555555555555555'; const AUTHOR_PRIVATE_KEY = `0x${'64'.repeat(32)}`; const AUTHOR_WALLET = new ethers.Wallet(AUTHOR_PRIVATE_KEY); const AUTHOR_ADDRESS = AUTHOR_WALLET.address.toLowerCase(); @@ -62,7 +63,7 @@ const PROJECTION_NQUADS = const DEPLOYMENT = Object.freeze({ networkId: NETWORK_ID, assertedAtChainId: '20430', - assertedAtKav10Address: KA_STORAGE, + assertedAtKav10Address: KAV10, }); const POLICY = Object.freeze({ networkId: NETWORK_ID, @@ -405,7 +406,7 @@ async function announceAndDrain( async function authorSeal(): Promise { const typedData = buildAuthorAttestationTypedData({ chainId: 20_430n, - kav10Address: KA_STORAGE, + kav10Address: KAV10, merkleRoot: ethers.getBytes(ASSERTION_ROOT), authorAddress: AUTHOR_ADDRESS, reservedKaId: BigInt(KA_ID), @@ -422,7 +423,7 @@ async function authorSeal(): Promise { authorAttestationVS: signature.yParityAndS, authorSchemeVersion: '1', assertedAtChainId: '20430', - assertedAtKav10Address: KA_STORAGE, + assertedAtKav10Address: KAV10, reservedKaId: KA_ID, assertionFinalizedAt: '2026-07-19T12:34:56.789Z', contentScopeVersion: '2', diff --git a/devnet/rfc64-gate2-multi-asset-completeness/run.ts b/devnet/rfc64-gate2-multi-asset-completeness/run.ts index 3bfef1fee6..c0e5e8c9fc 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/run.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/run.ts @@ -242,6 +242,16 @@ async function execute(): Promise { publication.headObjectDigest, `exact-set-${index + 1}.headObjectDigest`, ); + const terminalFailure = await readTerminalFailureNullable( + receiver, + headDigest, + `exact-set-${index + 1}`, + ); + if (terminalFailure !== null) { + throw new Error( + `exact-set-${index + 1} reconciliation failed: ${JSON.stringify(terminalFailure)}`, + ); + } let synchronization: Record | undefined; if (index === 0) { const applied = await readApplied( @@ -760,6 +770,20 @@ async function readAppliedNullable( }); } +async function readTerminalFailureNullable( + receiver: Gate2AgentChild, + catalogHeadDigest: string, + label: string, +): Promise | null> { + const event = await receiver.request( + 'terminalFailureReadback', + `${label}-terminal-failure-v1`, + 'operation-completed', + { catalogHeadDigest }, + ); + return event.output === null ? null : outputRecord(event, `${label} terminal failure`); +} + async function readSynchronization( receiver: Gate2AgentChild, catalogHeadDigest: string, diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 21bf3d8308..1642153832 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -1192,13 +1192,6 @@ export class LifecycleSyncMethods extends DKGAgentBase { await this.loadSwmSenderKeyState(); await this.initializeSwmHostModeStore(); await this.rehydrateContextGraphSubscriptions(); - for (const seed of this.config.initialContextGraphSubscriptions ?? []) { - this.setContextGraphSubscription( - seed.contextGraphId, - { ...seed.state }, - { persist: false }, - ); - } this.networkAdmissionCoordinator.registerIdentityProtocol(this.router); diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 8dd7777956..7c155f886d 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -770,6 +770,8 @@ export class Rfc64CatalogMethods extends DKGAgentBase { } return this.chain.getDKGKnowledgeAssetsAddress(); }, + getKnowledgeAssetsLifecycleAddress: () => + this.chain.getKnowledgeAssetsLifecycleAddress(), store: this.store, }); const nativeReceiver = new Rfc64PublicCatalogNativeReceiverV1({ diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index 72573d00da..0f9c61bc0b 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -676,17 +676,6 @@ export interface ContextGraphSub { pendingMeta?: boolean; } -/** - * Typed startup seed for controlled runtimes that need to model a subscription - * restored before the first chain scan (for example, the RFC-64 process proof). - * Seeds are in-memory overlays; durable production state still belongs to - * `contextGraphSubscriptionStore`. - */ -export interface InitialContextGraphSubscription { - readonly contextGraphId: string; - readonly state: Readonly; -} - /** * Metadata that passive discovery is allowed to contribute to the local * Context Graph catalogue. @@ -1082,8 +1071,6 @@ export interface DKGAgentConfig { * RFC-64 catalog policy. Omission preserves the legacy open-only lane. */ rfc64CatalogAccessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1; - /** Apply these typed in-memory subscriptions after durable rehydration and before chain polling. */ - initialContextGraphSubscriptions?: readonly InitialContextGraphSubscription[]; /** * public-projection enable flag. When set, a private CG's confirmed VM * publishes emit/refresh a verifiable public projection (the floor: existence, diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 9f25caeb0b..5b35dd4e23 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -192,7 +192,6 @@ export { type Rfc64CatalogAccessPolicyAuthorityConfigV1, type DKGAgentACKTransportOptions, type ContextGraphSub, - type InitialContextGraphSubscription, type ContextGraphDiscoveryMetadata, type ContextGraphDiscoveryOptions, type PublishOpts, diff --git a/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts b/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts index 85b54184a8..79653c5827 100644 --- a/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-agent-precommit-v1.ts @@ -22,6 +22,7 @@ export interface Rfc64FinalizedVmAgentPrecommitOptionsV1 { (contextGraphId: ContextGraphIdV1, signal: AbortSignal) => Promise; readonly getEvmChainId: () => Promise; readonly getKnowledgeAssetStorageAddress: () => Promise; + readonly getKnowledgeAssetsLifecycleAddress: () => Promise; readonly store: TripleStore; } @@ -49,11 +50,17 @@ export function createRfc64FinalizedVmAgentPrecommitV1( throw new Error('RFC-64 finalized VM precommit requires trusted RPC configuration'); } - const [onChainContextGraphId, liveChainId, knowledgeAssetStorageAddress] = + const [ + onChainContextGraphId, + liveChainId, + knowledgeAssetStorageAddress, + knowledgeAssetsLifecycleAddress, + ] = await Promise.all([ options.getOnChainContextGraphId(plan.catalogScope.contextGraphId, signal), options.getEvmChainId(), options.getKnowledgeAssetStorageAddress(), + options.getKnowledgeAssetsLifecycleAddress(), ]); signal.throwIfAborted(); if (onChainContextGraphId === null) { @@ -72,12 +79,18 @@ export function createRfc64FinalizedVmAgentPrecommitV1( canonicalKnowledgeAssetStorageAddress, 'RFC-64 finalized VM knowledge asset storage address', ); + const canonicalKnowledgeAssetsLifecycleAddress = knowledgeAssetsLifecycleAddress.toLowerCase(); + assertCanonicalEvmAddress( + canonicalKnowledgeAssetsLifecycleAddress, + 'RFC-64 finalized VM knowledge assets lifecycle address', + ); const chainId = policy.governanceChainId; const runtime = createFinalizedVmRuntimeV1({ networkId: plan.catalogScope.networkId, chainId, contextGraphStorageAddress: policy.governanceContractAddress, knowledgeAssetStorageAddress: canonicalKnowledgeAssetStorageAddress, + knowledgeAssetsLifecycleAddress: canonicalKnowledgeAssetsLifecycleAddress, snapshot: createStrictCurrentFinalizedEvmSnapshotScopeV1({ chainId, endpoints: options.rpcEndpoints, diff --git a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts index fbb3306d33..21fffb8e09 100644 --- a/packages/agent/src/rfc64/finalized-vm-composer-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-composer-v1.ts @@ -1,9 +1,11 @@ import { FinalizedVmSetAccumulatorV1, + assertCanonicalEvmAddress, assertContextGraphIdV1, assertSubGraphNameV1, readVerifiedCatalogSealBindingV1, type ContextGraphIdV1, + type EvmAddressV1, type FinalizedVmSetEvidenceV1, type FinalizedVmSetRowV1, type SubGraphNameV1, @@ -25,6 +27,7 @@ import { import { assertRecoverableAuthorAttestationCapabilityV1 } from './recoverable-author-attestation-v1.js'; const COMPOSITION_KEYS = [ + 'assertedAtKav10Address', 'catalogLane', 'finalizedContextGraph', 'inventory', @@ -45,6 +48,8 @@ export interface FinalizedVmPlacementEvidenceV1 { } export interface ComposeFinalizedVmSetRequestV1 { + /** Trusted lifecycle/KAV10 deployment address against which catalog seals were authored. */ + readonly assertedAtKav10Address: EvmAddressV1; readonly catalogLane: FinalizedVmCatalogLaneV1; readonly finalizedContextGraph: FinalizedContextGraphReadV1; readonly inventory: FinalizedVmChainInventoryV1; @@ -106,9 +111,15 @@ export function composeFinalizedVmSetV1( 'finalized-vm-composition-input', ); const catalogLane = snapshotCatalogLane(request.catalogLane); + let assertedAtKav10Address: EvmAddressV1; let inventory: Readonly; let finalizedContextGraph: Readonly; try { + assertCanonicalEvmAddress( + request.assertedAtKav10Address, + 'finalized VM assertedAtKav10Address', + ); + assertedAtKav10Address = request.assertedAtKav10Address; inventory = snapshotFinalizedVmChainInventoryV1(request.inventory); finalizedContextGraph = snapshotFinalizedContextGraphReadV1( request.finalizedContextGraph, @@ -206,6 +217,7 @@ export function composeFinalizedVmSetV1( assertCandidateMatchesPlacement( candidate, inventory, + assertedAtKav10Address, placement.authorship, placement.sealBinding, ); @@ -253,13 +265,14 @@ export function composeFinalizedVmSetV1( function assertCandidateMatchesPlacement( candidate: Readonly, inventory: Readonly, + assertedAtKav10Address: EvmAddressV1, authorship: ReturnType, sealBinding: ReturnType, ): void { const seal = sealBinding.seal; if ( candidate.chainId !== seal.assertedAtChainId - || candidate.knowledgeAssetStorageAddress !== seal.assertedAtKav10Address + || assertedAtKav10Address !== seal.assertedAtKav10Address || candidate.kaId !== sealBinding.kaId || candidate.ual !== seal.kaUal || candidate.authorAddress !== sealBinding.authorAddress diff --git a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts index b23871f6fb..120fe5c07e 100644 --- a/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts +++ b/packages/agent/src/rfc64/finalized-vm-runtime-v1.ts @@ -81,6 +81,8 @@ export interface FinalizedVmRuntimeConfigV1 { readonly chainId: ChainIdV1; readonly contextGraphStorageAddress: EvmAddressV1; readonly knowledgeAssetStorageAddress: EvmAddressV1; + /** KnowledgeAssetsV10 lifecycle contract used by author attestations and catalog seals. */ + readonly knowledgeAssetsLifecycleAddress: EvmAddressV1; readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; readonly materialize: FinalizedVmMaterializerV1; } @@ -128,6 +130,7 @@ interface RuntimeConfigSnapshotV1 { readonly chainId: FinalizedVmChainInventoryV1['chainId']; readonly contextGraphStorageAddress: EvmAddressV1; readonly knowledgeAssetStorageAddress: EvmAddressV1; + readonly knowledgeAssetsLifecycleAddress: EvmAddressV1; readonly snapshot: StrictCurrentFinalizedEvmSnapshotScopeV1; readonly materialize: FinalizedVmMaterializerV1; } @@ -202,6 +205,7 @@ export function createFinalizedVmRuntimeV1( ); const composed = composeFinalizedVmSetV1({ + assertedAtKav10Address: config.knowledgeAssetsLifecycleAddress, catalogLane: request.catalogLane, finalizedContextGraph, inventory, @@ -259,6 +263,7 @@ function snapshotConfig(input: FinalizedVmRuntimeConfigV1): RuntimeConfigSnapsho assertCanonicalChainId(input.chainId, 'finalized VM runtime chainId'); assertNonzeroAddress(input.contextGraphStorageAddress, 'contextGraphStorageAddress'); assertNonzeroAddress(input.knowledgeAssetStorageAddress, 'knowledgeAssetStorageAddress'); + assertNonzeroAddress(input.knowledgeAssetsLifecycleAddress, 'knowledgeAssetsLifecycleAddress'); if (typeof input.snapshot !== 'function') throw new TypeError('snapshot is not callable'); if (typeof input.materialize !== 'function') throw new TypeError('materialize is not callable'); } catch (cause) { @@ -269,6 +274,7 @@ function snapshotConfig(input: FinalizedVmRuntimeConfigV1): RuntimeConfigSnapsho chainId: input.chainId, contextGraphStorageAddress: input.contextGraphStorageAddress, knowledgeAssetStorageAddress: input.knowledgeAssetStorageAddress, + knowledgeAssetsLifecycleAddress: input.knowledgeAssetsLifecycleAddress, snapshot: input.snapshot, materialize: input.materialize, }); diff --git a/packages/agent/src/sync/requester/durable-sync.ts b/packages/agent/src/sync/requester/durable-sync.ts index df9a8f9963..6ac7474f03 100644 --- a/packages/agent/src/sync/requester/durable-sync.ts +++ b/packages/agent/src/sync/requester/durable-sync.ts @@ -744,9 +744,8 @@ function partitionVerifiedGraphScopedAssets( if (!versionRaw || !/^\d+$/.test(versionRaw)) { throw new Error(`Verified graph-scoped KA ${ual} has invalid assertionVersion ${versionRaw ?? ''}`); } - let confirmationKind; try { - confirmationKind = readGraphKnowledgeAssetConfirmationKindV1(metadataQuads); + readGraphKnowledgeAssetConfirmationKindV1(metadataQuads); } catch (cause) { throw new Error( `Verified graph-scoped KA ${ual} has invalid confirmation metadata`, @@ -785,7 +784,6 @@ function partitionVerifiedGraphScopedAssets( contextGraphId, ual, assertionVersion: BigInt(versionRaw), - confirmationKind, assertionGraph, metaGraph, dataQuads: dataByGraph.get(assertionGraph) ?? [], diff --git a/packages/agent/src/sync/requester/graph-scoped-materialization.ts b/packages/agent/src/sync/requester/graph-scoped-materialization.ts index b5e8b256d4..a936837a43 100644 --- a/packages/agent/src/sync/requester/graph-scoped-materialization.ts +++ b/packages/agent/src/sync/requester/graph-scoped-materialization.ts @@ -11,10 +11,9 @@ import { } from '@origintrail-official/dkg-storage'; import { GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, - normalizeGraphKnowledgeAssetConfirmationKindV1, + readGraphKnowledgeAssetConfirmationKindV1, readLocallyTrustedKnowledgeAssetControls, withMaterializationLock, - type GraphKnowledgeAssetConfirmationKind, } from '@origintrail-official/dkg-publisher'; import { filterOversizedSyncQuads, @@ -33,8 +32,6 @@ export interface VerifiedGraphScopedAsset { contextGraphId: string; ual: string; assertionVersion: bigint; - /** Omitted only for rolling-compatible receipt-backed metadata. */ - confirmationKind?: GraphKnowledgeAssetConfirmationKind; assertionGraph: string; metaGraph: string; dataQuads: Quad[]; @@ -184,9 +181,7 @@ export async function authenticateVerifiedGraphScopedAsset( ); } - const confirmationKind = normalizeGraphKnowledgeAssetConfirmationKindV1( - asset.confirmationKind, - ); + const confirmationKind = readGraphKnowledgeAssetConfirmationKindV1(asset.metadataQuads); let materializedBlock: number; let materializedTxIndex: number; if (confirmationKind === 'finalized-materialization') { @@ -327,7 +322,8 @@ export async function materializeVerifiedGraphScopedAsset(params: { if (currentVersion === asset.assertionVersion) { const [currentPublishedAt, currentReceiptProvenance] = await Promise.all([ readCurrentPublishedAt(store, asset.metaGraph, asset.ual, options), - asset.confirmationKind === 'finalized-materialization' + readGraphKnowledgeAssetConfirmationKindV1(asset.metadataQuads) + === 'finalized-materialization' ? readCurrentReceiptBackedProvenance( store, asset.metaGraph, diff --git a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts index 25a7818d18..16869789ea 100644 --- a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts +++ b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts @@ -11,6 +11,7 @@ import { import { computeFlatKCRootV10, generateGraphKnowledgeAssetMetadata, + readGraphKnowledgeAssetConfirmationKindV1, replaceLocallyTrustedKnowledgeAssetControls, shouldApplyMaterialization, } from '@origintrail-official/dkg-publisher'; @@ -283,7 +284,6 @@ describe('durable graph-scoped KA materialization', () => { contextGraphId, ual, assertionVersion: 2n, - confirmationKind: 'finalized-materialization', assertionGraph, metaGraph, dataQuads: [v2Data], @@ -319,7 +319,6 @@ describe('durable graph-scoped KA materialization', () => { contextGraphId, ual, assertionVersion: 2n, - confirmationKind: 'transaction', assertionGraph, metaGraph, dataQuads: [v2Data], @@ -542,8 +541,9 @@ describe('durable graph-scoped KA materialization', () => { expect(assets[0]).toMatchObject({ ual, assertionGraph, - confirmationKind: 'transaction', }); + expect(readGraphKnowledgeAssetConfirmationKindV1(assets[0]!.metadataQuads)) + .toBe('transaction'); expect(inserted.filter((quad) => quad.subject === lifecycle)).toEqual( lifecycleRows.filter((quad) => quad.predicate !== `${DKG}assertionVersion`), ); diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 49beec0455..97593914d2 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -29,7 +29,11 @@ import { snapshotRfc64CatalogAccessPolicyAuthorityV1, snapshotRfc64CatalogDeploymentProfileV1, } from '../src/dkg-agent-rfc64-catalog.js'; -import type { Rfc64CatalogAccessPolicyAuthorityConfigV1 } from '../src/dkg-agent-types.js'; +import type { + ContextGraphSubscriptionRecord, + ContextGraphSubscriptionStore, + Rfc64CatalogAccessPolicyAuthorityConfigV1, +} from '../src/dkg-agent-types.js'; import { createLoopbackJsonRpcTestHarness, sendJsonRpcError, @@ -53,6 +57,7 @@ const SUCCESSOR_ISSUED_AT = '1773900001000' as TimestampMsV1; const SECOND_SUCCESSOR_ISSUED_AT = '1773900002000' as TimestampMsV1; const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; const KAV10 = '0x4444444444444444444444444444444444444444' as EvmAddressV1; +const KA_STORAGE = '0x5555555555555555555555555555555555555555' as EvmAddressV1; const CONTEXT_GRAPH_STORAGE = '0x3333333333333333333333333333333333333333' as EvmAddressV1; const ON_CHAIN_CONTEXT_GRAPH_ID = '14'; @@ -119,10 +124,9 @@ async function startNativeAgent( operationalKeys: [`0x${'12'.repeat(32)}`], }, ...(finalizedRuntime.initialSubscription === undefined ? {} : { - initialContextGraphSubscriptions: [{ - contextGraphId: finalizedRuntime.initialSubscription, - state: { subscribed: true, synced: false }, - }], + contextGraphSubscriptionStore: seededSubscriptionStore( + finalizedRuntime.initialSubscription, + ), }), }), }); @@ -131,6 +135,24 @@ async function startNativeAgent( return agent; } +function seededSubscriptionStore(contextGraphId: string): ContextGraphSubscriptionStore { + const records = new Map([[contextGraphId, { + id: contextGraphId, + subscribed: true, + synced: false, + syncScoped: true, + }]]); + return { + loadAll: async () => [...records.values()].map((record) => ({ ...record })), + load: async (id) => { + const record = records.get(id); + return record === undefined ? null : { ...record }; + }, + save: async (record) => { records.set(record.id, { ...record }); }, + delete: async (id) => { records.delete(id); }, + }; +} + function tcpMultiaddr(agent: DKGAgent): string { const address = agent.multiaddrs.find((candidate) => candidate.includes('/tcp/')); if (address === undefined) throw new Error('agent has no TCP multiaddr'); @@ -532,6 +554,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { active: true, assertedAtChainId: NATIVE_DEPLOYMENT.assertedAtChainId, assertedAtKav10Address: KAV10, + knowledgeAssetStorageAddress: KA_STORAGE, assets: Object.freeze([Object.freeze({ assertionRoot: ASSERTION_ROOT, assertionVersion: '1', @@ -559,6 +582,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { [BigInt(ON_CHAIN_CONTEXT_GRAPH_ID)], ), }, 'finalized'])).toThrow('context graph target'); + const callsBeforeRuntime = finalizedRpc.calls.length; const rpc = await rpcHarness.start((call, response) => { try { sendJsonRpcResult(response, call, finalizedRpc.respond(call.method, call.params)); @@ -683,11 +707,12 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { currentCatalogHeadDigest: successor.headObjectDigest, inventoryRowCount: '1', }); - const finalizedCallTargets = finalizedRpc.calls + const finalizedCallTargets = finalizedRpc.calls.slice(callsBeforeRuntime) .filter(({ method }) => method === 'eth_call') .map(({ params }) => (params[0] as { readonly to?: string }).to?.toLowerCase()); expect(finalizedCallTargets).toContain(CONTEXT_GRAPH_STORAGE); - expect(finalizedCallTargets).toContain(KAV10); + expect(finalizedCallTargets).toContain(KA_STORAGE); + expect(finalizedCallTargets).not.toContain(KAV10); }, 60_000); it('exposes bounded typed failure evidence when wire scope differs from local deployment', async () => { diff --git a/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts index 06222e566d..bb560b3bfb 100644 --- a/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts @@ -16,6 +16,7 @@ import { RFC64_VM_CG_STORAGE, RFC64_VM_CHAIN_ID, RFC64_VM_CONTEXT_GRAPH_NAME, + RFC64_VM_KAV10, RFC64_VM_KA_STORAGE, RFC64_VM_NETWORK_ID, RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, @@ -89,6 +90,14 @@ describe('RFC-64 finalized VM agent precommit', () => { await expect( noncanonicalKnowledgeAssetStorage(plan(), new AbortController().signal), ).rejects.toThrow('knowledge asset storage address must be a lowercase 20-byte'); + + const noncanonicalKnowledgeAssetsLifecycle = createRfc64FinalizedVmAgentPrecommitV1({ + ...baseOptions(), + getKnowledgeAssetsLifecycleAddress: async () => '0x1234', + }); + await expect( + noncanonicalKnowledgeAssetsLifecycle(plan(), new AbortController().signal), + ).rejects.toThrow('knowledge assets lifecycle address must be a lowercase 20-byte'); }); }); @@ -99,6 +108,7 @@ function baseOptions() { getOnChainContextGraphId: async () => RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, getEvmChainId: async () => BigInt(RFC64_VM_CHAIN_ID), getKnowledgeAssetStorageAddress: async () => RFC64_VM_KA_STORAGE, + getKnowledgeAssetsLifecycleAddress: async () => RFC64_VM_KAV10, store: new OxigraphStore(), } as const; } diff --git a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts index 53650b418c..122264d825 100644 --- a/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-composer-v1.test.ts @@ -54,6 +54,7 @@ const CONTEXT_GRAPH_NAME = 'agent-blackbox-vm' as const; const ON_CHAIN_CONTEXT_GRAPH_ID = '14' as const; const CG_STORAGE = `0x${'33'.repeat(20)}` as EvmAddressV1; const KA_STORAGE = `0x${'44'.repeat(20)}` as EvmAddressV1; +const KAV10 = `0x${'55'.repeat(20)}` as EvmAddressV1; const PUBLISHER = `0x${'66'.repeat(20)}` as EvmAddressV1; const BLOCK_HASH = `0x${'77'.repeat(32)}` as Digest32V1; const ROOT_1 = `0x${'88'.repeat(32)}` as Digest32V1; @@ -177,16 +178,11 @@ describe('RFC-64 finalized VM placement composition', () => { (row) => { row.assertionRoot = ROOT_1; }, (row) => { row.attestedAuthorAddress = null; }, (row) => { row.publisherAddress = null; }, - (row) => { row.knowledgeAssetStorageAddress = `0x${'ab'.repeat(20)}`; }, ]; for (const mutate of mutations) { const inventory = structuredClone(base.inventory) as unknown as Record; const rows = inventory.rows as Array>; mutate(rows[1]!); - if (rows[1]!.knowledgeAssetStorageAddress !== KA_STORAGE) { - inventory.knowledgeAssetStorageAddress = rows[1]!.knowledgeAssetStorageAddress; - rows[0]!.knowledgeAssetStorageAddress = rows[1]!.knowledgeAssetStorageAddress; - } expectCode( () => composeFinalizedVmSetV1({ ...base, inventory } as never), 'finalized-vm-composition-mismatch', @@ -197,6 +193,10 @@ describe('RFC-64 finalized VM placement composition', () => { ...base, catalogLane: { contextGraphId: 'another-graph', subGraphName: null }, } as never), 'finalized-vm-composition-mismatch'); + expectCode(() => composeFinalizedVmSetV1({ + ...base, + assertedAtKav10Address: `0x${'ab'.repeat(20)}`, + } as never), 'finalized-vm-composition-mismatch'); }); it('rejects placements absent from the finalized inventory and malformed unplaced rows', async () => { @@ -250,6 +250,7 @@ function requestFor( placements: readonly FinalizedVmPlacementEvidenceV1[], ): ComposeFinalizedVmSetRequestV1 { return { + assertedAtKav10Address: KAV10, catalogLane: { contextGraphId: CONTEXT_GRAPH_NAME, subGraphName: null, @@ -333,7 +334,7 @@ async function createPlacement( } as AuthorCatalogScopeV1; const typedData = buildAuthorAttestationTypedData({ chainId: BigInt(CHAIN_ID), - kav10Address: KA_STORAGE, + kav10Address: KAV10, merkleRoot: ethers.getBytes(assertionRoot), authorAddress: AUTHOR, reservedKaId: BigInt(kaId), @@ -351,7 +352,7 @@ async function createPlacement( authorAttestationVS: validAttestation ? attestation.yParityAndS : `0x${'bb'.repeat(32)}`, authorSchemeVersion: '1', assertedAtChainId: CHAIN_ID, - assertedAtKav10Address: KA_STORAGE, + assertedAtKav10Address: KAV10, reservedKaId: kaId, assertionFinalizedAt: '2026-07-22T08:00:00.000Z', contentScopeVersion: '2', @@ -480,7 +481,7 @@ async function createPlacement( { networkId: NETWORK_ID, assertedAtChainId: CHAIN_ID, - assertedAtKav10Address: KA_STORAGE, + assertedAtKav10Address: KAV10, }, ); return { authorship, sealBinding }; diff --git a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts index 9e4f4b9d8a..1bbcce5a38 100644 --- a/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-runtime-v1.test.ts @@ -33,6 +33,7 @@ import { RFC64_VM_CG_STORAGE, RFC64_VM_CHAIN_ID, RFC64_VM_CONTEXT_GRAPH_NAME, + RFC64_VM_KAV10, RFC64_VM_KA_STORAGE, RFC64_VM_NETWORK_ID, RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, @@ -265,6 +266,7 @@ function runtimeConfig( chainId: RFC64_VM_CHAIN_ID, contextGraphStorageAddress: RFC64_VM_CG_STORAGE, knowledgeAssetStorageAddress: RFC64_VM_KA_STORAGE, + knowledgeAssetsLifecycleAddress: RFC64_VM_KAV10, snapshot, materialize, } as const; diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 7b94a5a80c..187f59222a 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -1352,6 +1352,7 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { getOnChainContextGraphId, getEvmChainId, getKnowledgeAssetStorageAddress, + getKnowledgeAssetsLifecycleAddress: async () => KAV10, store: fixture.receiverStore, }); const receiver = fixture.createReceiver({ diff --git a/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts b/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts index d6851f8ae4..7b63e9dda7 100644 --- a/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts +++ b/packages/agent/test/support/rfc64-finalized-vm-loopback-fixture.ts @@ -22,6 +22,7 @@ export interface FinalizedVmLoopbackFixtureConfigV1 { readonly active: boolean; readonly assertedAtChainId: string; readonly assertedAtKav10Address: EvmAddressV1; + readonly knowledgeAssetStorageAddress: EvmAddressV1; readonly assets: readonly FinalizedVmLoopbackAssetV1[]; readonly blockHash: Digest32V1; readonly blockNumberQuantity: string; @@ -77,7 +78,7 @@ export class FinalizedVmLoopbackMockChainAdapterV1 extends MockChainAdapter { } override async getDKGKnowledgeAssetsAddress(): Promise { - return this.#fixture.assertedAtKav10Address; + return this.#fixture.knowledgeAssetStorageAddress; } } @@ -121,7 +122,7 @@ function finalizedVmEthCallResult( if (CONTEXT_GRAPH_SELECTORS.has(selector)) { assertCallTarget(target, fixture.contextGraphStorageAddress, 'context graph'); } else if (KNOWLEDGE_ASSET_SELECTORS.has(selector)) { - assertCallTarget(target, fixture.assertedAtKav10Address, 'knowledge asset'); + assertCallTarget(target, fixture.knowledgeAssetStorageAddress, 'knowledge asset storage'); } switch (selector) { case CONTEXT_GRAPH_INTERFACE.getFunction('getContextGraph')!.selector: diff --git a/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts b/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts index 06e9e1847d..a78174dd49 100644 --- a/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts +++ b/packages/agent/test/support/rfc64-finalized-vm-placement-fixture.ts @@ -44,6 +44,7 @@ export const RFC64_VM_CONTEXT_GRAPH_NAME = 'agent-blackbox-vm' as const; export const RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID = '14' as const; export const RFC64_VM_CG_STORAGE = `0x${'33'.repeat(20)}` as EvmAddressV1; export const RFC64_VM_KA_STORAGE = `0x${'44'.repeat(20)}` as EvmAddressV1; +export const RFC64_VM_KAV10 = `0x${'55'.repeat(20)}` as EvmAddressV1; export const RFC64_VM_PUBLISHER = `0x${'66'.repeat(20)}` as EvmAddressV1; export const RFC64_VM_BLOCK_HASH = `0x${'77'.repeat(32)}` as Digest32V1; export const RFC64_VM_ASSERTION_ROOT = `0x${'88'.repeat(32)}` as Digest32V1; @@ -83,7 +84,7 @@ export async function createRfc64FinalizedVmPlacementFixture(options: { } as AuthorCatalogScopeV1; const typedData = buildAuthorAttestationTypedData({ chainId: BigInt(RFC64_VM_CHAIN_ID), - kav10Address: RFC64_VM_KA_STORAGE, + kav10Address: RFC64_VM_KAV10, merkleRoot: ethers.getBytes(assertionRoot), authorAddress: RFC64_VM_AUTHOR, reservedKaId: BigInt(kaId), @@ -99,7 +100,7 @@ export async function createRfc64FinalizedVmPlacementFixture(options: { authorAttestationVS: attestation.yParityAndS, authorSchemeVersion: String(AUTHOR_SCHEME_VERSION_V1), assertedAtChainId: RFC64_VM_CHAIN_ID, - assertedAtKav10Address: RFC64_VM_KA_STORAGE, + assertedAtKav10Address: RFC64_VM_KAV10, reservedKaId: kaId, assertionFinalizedAt: '2026-07-22T08:00:00.000Z', contentScopeVersion: '2', @@ -229,7 +230,7 @@ export async function createRfc64FinalizedVmPlacementFixture(options: { { networkId: RFC64_VM_NETWORK_ID, assertedAtChainId: RFC64_VM_CHAIN_ID, - assertedAtKav10Address: RFC64_VM_KA_STORAGE, + assertedAtKav10Address: RFC64_VM_KAV10, }, ), }; From 6150af9ca13fc4491755c5bf31e23b67f72390dc Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 19:54:28 +0200 Subject: [PATCH 270/292] fix(rfc64): close final M2 review gaps --- packages/agent/src/dkg-agent-cg-resolve.ts | 21 ++- packages/agent/src/dkg-agent-lifecycle.ts | 20 ++- .../requester/graph-scoped-materialization.ts | 126 +++------------ ...-sync-graph-scoped-materialization.test.ts | 3 +- .../host-mode-key-canonicalization.test.ts | 52 ++++++ packages/publisher/src/index.ts | 2 +- packages/publisher/src/metadata.ts | 151 +++++++++++++++++- 7 files changed, 264 insertions(+), 111 deletions(-) diff --git a/packages/agent/src/dkg-agent-cg-resolve.ts b/packages/agent/src/dkg-agent-cg-resolve.ts index 548b113304..caf3dc4b6c 100644 --- a/packages/agent/src/dkg-agent-cg-resolve.ts +++ b/packages/agent/src/dkg-agent-cg-resolve.ts @@ -1500,6 +1500,19 @@ export class ContextGraphResolveMethods extends DKGAgentBase { return ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)).toLowerCase(); } + /** + * Derive the curator commitment for a LOCAL cleartext identifier. + * + * This deliberately hashes every string, including values that happen to + * look like a 32-byte wire id. A hash-shaped user-chosen CG name is still + * cleartext and its on-chain commitment is keccak256(utf8(name)). Host-only + * subscriptions carry an explicit `onChainHash`, so callers never need to + * guess which interpretation applies from the string shape alone. + */ + contextGraphNameCommitment(this: DKGAgent, localId: string): string { + return ethers.keccak256(ethers.toUtf8Bytes(localId)).toLowerCase(); + } + /** * OT-RFC-39 Codex review (round 2) on PR #727: * `gossipWireIdFor(rawId)` would happily keccak a literal numeric @@ -1613,7 +1626,13 @@ export class ContextGraphResolveMethods extends DKGAgentBase { options?: { persist?: boolean }, ): string | null { const wireId = this.contextGraphWireId(nameHash); - const localId = this.localCgIdForWireId(wireId); + // Chain events may only enrich a subscription that explicitly indexed this + // commitment. Falling back to `localId === wireId` is ambiguous: a user may + // legitimately choose a cleartext id that itself looks like a 32-byte hash. + // Host-only records are indexed under their explicit `onChainHash`, so the + // reverse map covers both safe cases without a shape-based fallback. + const localId = this.wireIdToLocalCgId.get(wireId); + if (localId === undefined) return null; const current = this.subscribedContextGraphs.get(localId); if (current === undefined) return null; const next = { ...current }; diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 1642153832..1440dc02ff 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -2007,7 +2007,8 @@ export class LifecycleSyncMethods extends DKGAgentBase { // input and find the on-chain participant agents without an // RPC round-trip per envelope. const hashLower = this.contextGraphWireId(nameHash); - const localId = this.localCgIdForWireId(hashLower); + const indexedLocalId = this.wireIdToLocalCgId.get(hashLower); + const localId = indexedLocalId ?? hashLower; // Stage a synthetic subscription record for the host-only // case: cores hosting CGs they never joined have no // cleartext; the hash IS their local id. `recordCgWireId` @@ -2017,8 +2018,20 @@ export class LifecycleSyncMethods extends DKGAgentBase { this.setContextGraphSubscription(localId, { subscribed: false, synced: false, + onChainHash: hashLower, pendingMeta: true, }, { persist: false }); + } else if (indexedLocalId === undefined) { + // A local subscription already uses the event's hash as its + // cleartext id, but did not explicitly claim wire-id identity. + // Treat this as an ambiguous hash-shaped-name collision instead + // of rebinding or auto-hosting the unrelated on-chain graph. + this.log.warn( + ctx, + `Skipping host-mode auto-subscribe for ${hashLower.slice(0, 18)}…: ` + + 'the same string is already used by an uncommitted local CG id', + ); + return; } this.bindOnChainContextGraphIdFromNameHash( hashLower, @@ -5965,7 +5978,10 @@ export class LifecycleSyncMethods extends DKGAgentBase { ): ContextGraphSub { this.invalidateListContextGraphsCache(); const previous = this.subscribedContextGraphs.get(contextGraphId); - const localWireId = this.contextGraphWireId(contextGraphId); + // A local id is always cleartext unless the subscription explicitly says + // otherwise through `onChainHash`. This distinction matters for a valid + // user-chosen id that happens to match the 0x+64-hex wire-id shape. + const localWireId = this.contextGraphNameCommitment(contextGraphId); const previousWireId = previous?.onChainHash ? this.contextGraphWireId(previous.onChainHash) : localWireId; diff --git a/packages/agent/src/sync/requester/graph-scoped-materialization.ts b/packages/agent/src/sync/requester/graph-scoped-materialization.ts index a936837a43..e2dd697289 100644 --- a/packages/agent/src/sync/requester/graph-scoped-materialization.ts +++ b/packages/agent/src/sync/requester/graph-scoped-materialization.ts @@ -10,7 +10,7 @@ import { type TripleStore, } from '@origintrail-official/dkg-storage'; import { - GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, + mergeSameVersionGraphKnowledgeAssetMetadataV1, readGraphKnowledgeAssetConfirmationKindV1, readLocallyTrustedKnowledgeAssetControls, withMaterializationLock, @@ -320,33 +320,17 @@ export async function materializeVerifiedGraphScopedAsset(params: { } let replacementMetadata = asset.metadataQuads; if (currentVersion === asset.assertionVersion) { - const [currentPublishedAt, currentReceiptProvenance] = await Promise.all([ - readCurrentPublishedAt(store, asset.metaGraph, asset.ual, options), - readGraphKnowledgeAssetConfirmationKindV1(asset.metadataQuads) - === 'finalized-materialization' - ? readCurrentReceiptBackedProvenance( - store, - asset.metaGraph, - asset.ual, - options, - ) - : Promise.resolve(undefined), - ]); - if (currentPublishedAt) { - replacementMetadata = [ - ...asset.metadataQuads.filter((quad) => quad.predicate !== PUBLISHED_AT), - currentPublishedAt, - ]; - } - if (currentReceiptProvenance) { - replacementMetadata = [ - ...replacementMetadata.filter((quad) => ( - quad.predicate !== TRANSACTION_HASH - && quad.predicate !== MATERIALIZED_VERSION - && quad.predicate !== GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE - )), - ...currentReceiptProvenance, - ]; + const currentMetadata = await readCurrentGraphKnowledgeAssetMetadata( + store, + asset.metaGraph, + asset.ual, + options, + ); + if (currentMetadata) { + replacementMetadata = mergeSameVersionGraphKnowledgeAssetMetadataV1( + replacementMetadata, + currentMetadata, + ); } } const locallyTrustedMetadata = await readLocallyTrustedKnowledgeAssetControls( @@ -376,13 +360,8 @@ export async function materializeVerifiedGraphScopedAsset(params: { }); } -/** - * Preserve stronger locally authenticated receipt provenance when a peer - * replays the same assertion through the receiptless finalized lane. The data - * graph is still replaced exactly (healing poisoned replicas), but remote - * metadata cannot downgrade a transaction-confirmed local assertion to 0:0. - */ -async function readCurrentReceiptBackedProvenance( +/** Read the current subject once; publisher metadata helpers own typed merging. */ +async function readCurrentGraphKnowledgeAssetMetadata( store: TripleStore, metaGraph: string, ual: string, @@ -392,80 +371,17 @@ async function readCurrentReceiptBackedProvenance( SELECT ?predicate ?object WHERE { GRAPH <${assertSafeIri(metaGraph)}> { <${assertSafeIri(ual)}> ?predicate ?object . - VALUES ?predicate { - <${TRANSACTION_HASH}> - <${MATERIALIZED_VERSION}> - <${GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE}> - <${STATUS}> - } } } `, options); if (result.type !== 'bindings') return undefined; - const byPredicate = new Map(); - for (const row of result.bindings) { - if (!row.predicate || !row.object) return undefined; - const values = byPredicate.get(row.predicate) ?? []; - values.push(row.object); - byPredicate.set(row.predicate, values); - } - const hashes = byPredicate.get(TRANSACTION_HASH) ?? []; - const versions = byPredicate.get(MATERIALIZED_VERSION) ?? []; - const kinds = byPredicate.get(GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE) ?? []; - const statuses = byPredicate.get(STATUS) ?? []; - if ( - hashes.length !== 1 - || kinds.length > 1 - || statuses.length !== 1 - || parseRdfLiteral(statuses[0]!) !== 'confirmed' - || (kinds.length === 1 && parseRdfLiteral(kinds[0]!) !== 'transaction') - ) { - return undefined; - } - try { - parseTransactionHashLiteral(hashes[0]!); - } catch { - return undefined; - } - const materializedVersion = versions.length === 1 - && /^(?:0|[1-9]\d*):(?:0|[1-9]\d*)$/.test(parseRdfLiteral(versions[0]!) ?? '') - ? versions[0] - : undefined; - return [ - { subject: ual, predicate: TRANSACTION_HASH, object: hashes[0]!, graph: metaGraph }, - { - subject: ual, - predicate: GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, - object: '"transaction"', - graph: metaGraph, - }, - ...(materializedVersion === undefined ? [] : [{ - subject: ual, - predicate: MATERIALIZED_VERSION, - object: materializedVersion, - graph: metaGraph, - }]), - ]; -} - -async function readCurrentPublishedAt( - store: TripleStore, - metaGraph: string, - ual: string, - options: QueryOptions, -): Promise { - const result = await store.query(` - SELECT ?publishedAt WHERE { - GRAPH <${assertSafeIri(metaGraph)}> { - <${assertSafeIri(ual)}> <${PUBLISHED_AT}> ?publishedAt . - } - } - `, options); - if (result.type !== 'bindings' || result.bindings.length !== 1) return undefined; - const object = result.bindings[0]?.publishedAt; - const lexical = object?.match(/^"([^"\\]*(?:\\.[^"\\]*)*)"(?:\^\^.*|@.*)?$/)?.[1]; - if (!object || !lexical || !Number.isFinite(Date.parse(lexical))) return undefined; - return { subject: ual, predicate: PUBLISHED_AT, object, graph: metaGraph }; + if (result.bindings.some((row) => !row.predicate || !row.object)) return undefined; + return result.bindings.map((row) => ({ + subject: ual, + predicate: row.predicate!, + object: row.object!, + graph: metaGraph, + })); } async function readCurrentAssertionVersion( diff --git a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts index 16869789ea..84b1c4e034 100644 --- a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts +++ b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts @@ -908,6 +908,7 @@ describe('durable graph-scoped KA materialization', () => { ['privateTripleCount', `"0"^^<${XSD_INTEGER}>`], ['status', '"confirmed"'], ['publishedAt', `"2026-07-16T08:00:00.000Z"^^<${XSD_DATE_TIME}>`], + ['materializedVersion', '"456:7"'], ].map(([predicate, object]) => ({ subject: ual, predicate: `${DKG}${predicate}`, @@ -966,7 +967,7 @@ describe('durable graph-scoped KA materialization', () => { expect(await graphQuads(requesterStore, assertionGraph)).toEqual(servedData); expect(await values(requesterStore, 'transactionHash')).toEqual([`"${transactionHash(2)}"`]); expect(await values(requesterStore, 'confirmationKind')).toEqual(['"transaction"']); - expect(await values(requesterStore, 'materializedVersion')).toEqual([]); + expect(await values(requesterStore, 'materializedVersion')).toEqual(['"456:7"']); expect(await values(requesterStore, 'publishedAt')).toEqual([ `"2026-07-16T08:00:00Z"^^<${XSD_DATE_TIME}>`, ]); diff --git a/packages/agent/test/swm/host-mode-key-canonicalization.test.ts b/packages/agent/test/swm/host-mode-key-canonicalization.test.ts index 9f33cf5444..8c0580c313 100644 --- a/packages/agent/test/swm/host-mode-key-canonicalization.test.ts +++ b/packages/agent/test/swm/host-mode-key-canonicalization.test.ts @@ -95,6 +95,7 @@ interface AgentInternals { awaitHostModePersistence(contextGraphId: string): Promise; hostModePersistenceStoreKey(rawCgId: string): string; contextGraphWireId(contextGraphId: string): string; + contextGraphNameCommitment(contextGraphId: string): string; bindOnChainContextGraphIdFromNameHash( nameHash: string, onChainContextGraphId: string, @@ -266,6 +267,57 @@ describe('host-mode bookkeeping key canonicalisation', () => { expect(storeQuery).not.toHaveBeenCalled(); }); + it('does not bind a hash-shaped cleartext CG to an unrelated raw name-hash event', async () => { + const { core } = await makeCore(); + const internals = core as unknown as AgentInternals; + const hashShapedCleartext = `0x${'bc'.repeat(32)}`; + const committedNameHash = ethers.keccak256( + ethers.toUtf8Bytes(hashShapedCleartext), + ).toLowerCase(); + internals.setContextGraphSubscription( + hashShapedCleartext, + { subscribed: true, synced: false }, + { persist: false }, + ); + + expect(internals.contextGraphNameCommitment(hashShapedCleartext)).toBe(committedNameHash); + expect(internals.bindOnChainContextGraphIdFromNameHash( + hashShapedCleartext, + '13', + { persist: false }, + )).toBeNull(); + expect(internals.bindOnChainContextGraphIdFromNameHash( + committedNameHash, + '14', + { persist: false }, + )).toBe(hashShapedCleartext); + expect(internals.subscribedContextGraphs.get(hashShapedCleartext)).toMatchObject({ + onChainId: '14', + onChainHash: committedNameHash, + }); + }); + + it('binds an explicitly wire-keyed host-only subscription', async () => { + const { core } = await makeCore(); + const internals = core as unknown as AgentInternals; + const wireHash = `0x${'de'.repeat(32)}`; + internals.setContextGraphSubscription( + wireHash, + { subscribed: false, synced: false, onChainHash: wireHash, coreHosted: true }, + { persist: false }, + ); + + expect(internals.bindOnChainContextGraphIdFromNameHash( + wireHash, + '15', + { persist: false }, + )).toBe(wireHash); + expect(internals.subscribedContextGraphs.get(wireHash)).toMatchObject({ + onChainId: '15', + onChainHash: wireHash, + }); + }); + it('exactly one gossip handler is wired on the underlying topic regardless of subscribe shape', async () => { const { core, bus } = await makeCore(); // Use the wire-hash form first to mirror the chain-event / diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index 6e7b84e3a6..38c7b7db0a 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -75,7 +75,7 @@ export { type ValidationResult, type ValidationOptions, } from './validation.js'; -export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, normalizeGraphKnowledgeAssetConfirmationKindV1, readGraphKnowledgeAssetConfirmationKindV1, GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type GraphKnowledgeAssetConfirmation, type GraphKnowledgeAssetConfirmationKind, type GraphKnowledgeAssetMetadataState, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; +export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, generateGraphKnowledgeAssetMetadata, normalizeGraphKnowledgeAssetConfirmationKindV1, readGraphKnowledgeAssetConfirmationKindV1, readGraphKnowledgeAssetReceiptProvenanceV1, preserveGraphKnowledgeAssetReceiptProvenanceV1, mergeSameVersionGraphKnowledgeAssetMetadataV1, GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, replaceLocallyTrustedKnowledgeAssetControls, readLocallyTrustedKnowledgeAssetControls, readConfirmedGraphKnowledgeAssetMetadataEnvelope, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateKnowledgeAssetShareMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, materializedVersionQuad, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type GraphKnowledgeAssetMetadata, type GraphKnowledgeAssetConfirmation, type GraphKnowledgeAssetConfirmationKind, type GraphKnowledgeAssetMetadataState, type GraphKnowledgeAssetReceiptProvenanceV1, type ConfirmedGraphKnowledgeAssetMetadataEnvelope, type ConfirmedGraphKnowledgeAssetMetadataRead, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type KnowledgeAssetShareMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; export { pruneSupersededAgentRegistryMeta, insertBoundedAgentRegistryMeta } from './agent-registry-meta-retention.js'; export { DKGPublisher, diff --git a/packages/publisher/src/metadata.ts b/packages/publisher/src/metadata.ts index 6056ed6049..751aac26c0 100644 --- a/packages/publisher/src/metadata.ts +++ b/packages/publisher/src/metadata.ts @@ -107,6 +107,10 @@ export interface OnChainProvenance { } export const GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE = `${DKG}confirmationKind`; +const GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE = `${DKG}status`; +const GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE = `${DKG}transactionHash`; +const MATERIALIZED_VERSION_PRED = `${DKG}materializedVersion`; +const GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE = `${DKG}publishedAt`; export type GraphKnowledgeAssetConfirmationKind = | 'transaction' @@ -162,6 +166,152 @@ export function readGraphKnowledgeAssetConfirmationKindV1( return normalizeGraphKnowledgeAssetConfirmationKindV1(values[0]); } +export interface GraphKnowledgeAssetReceiptProvenanceV1 { + readonly transactionHash: string; + readonly materializedVersion?: MaterializedVersion; +} + +/** + * Read the locally authenticated, receipt-backed part of graph-scoped KA + * metadata. This is the canonical parser used when a receiptless finalized + * replay must preserve stronger local transaction provenance. + * + * Invalid, tentative, or finalized-materialization metadata is not eligible + * for preservation and returns null. A missing confirmationKind is accepted as + * the rolling-compatible legacy transaction shape. + */ +export function readGraphKnowledgeAssetReceiptProvenanceV1( + metadataQuads: readonly Pick[], +): GraphKnowledgeAssetReceiptProvenanceV1 | null { + const statuses = metadataQuads + .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE) + .map((quad) => rdfLiteralLexicalValue(quad.object)); + if (statuses.length !== 1 || statuses[0] !== 'confirmed') return null; + + let confirmationKind: GraphKnowledgeAssetConfirmationKind; + try { + confirmationKind = readGraphKnowledgeAssetConfirmationKindV1(metadataQuads); + } catch { + return null; + } + if (confirmationKind !== 'transaction') return null; + + const hashes = metadataQuads + .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE) + .map((quad) => rdfLiteralLexicalValue(quad.object)); + if ( + hashes.length !== 1 + || hashes[0] === undefined + || !/^0x[0-9a-fA-F]{64}$/.test(hashes[0]) + ) { + return null; + } + + const versions = metadataQuads + .filter((quad) => quad.predicate === MATERIALIZED_VERSION_PRED) + .map((quad) => rdfLiteralLexicalValue(quad.object)); + if (versions.length > 1 || (versions.length === 1 && versions[0] === undefined)) { + return null; + } + const materializedVersion = versions.length === 0 + ? undefined + : parseCanonicalMaterializedVersionV1(versions[0]!); + if (versions.length === 1 && materializedVersion === null) return null; + const validMaterializedVersion = materializedVersion ?? undefined; + + return Object.freeze({ + transactionHash: hashes[0], + ...(validMaterializedVersion === undefined + ? {} + : { materializedVersion: Object.freeze(validMaterializedVersion) }), + }); +} + +/** + * Preserve valid local receipt provenance while accepting an otherwise exact + * same-version metadata replacement from the receiptless finalized lane. + */ +export function preserveGraphKnowledgeAssetReceiptProvenanceV1( + incomingMetadata: readonly Quad[], + currentMetadata: readonly Pick[], +): Quad[] { + const provenance = readGraphKnowledgeAssetReceiptProvenanceV1(currentMetadata); + if (provenance === null) return [...incomingMetadata]; + const identity = incomingMetadata[0]; + if (identity === undefined) return [...incomingMetadata]; + return [ + ...incomingMetadata.filter((quad) => ( + quad.predicate !== GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE + && quad.predicate !== MATERIALIZED_VERSION_PRED + && quad.predicate !== GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE + )), + mq( + identity.subject, + GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE, + lit(provenance.transactionHash), + identity.graph, + ), + mq( + identity.subject, + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, + lit('transaction'), + identity.graph, + ), + ...(provenance.materializedVersion === undefined + ? [] + : [materializedVersionQuad( + identity.graph, + identity.subject, + provenance.materializedVersion, + )]), + ]; +} + +/** + * Canonically merge metadata for an exact same-assertion replay. Stable local + * receive time is retained for every lane; a receiptless finalized replay also + * retains stronger, valid transaction provenance already authenticated here. + */ +export function mergeSameVersionGraphKnowledgeAssetMetadataV1( + incomingMetadata: readonly Quad[], + currentMetadata: readonly Quad[], +): Quad[] { + const identity = incomingMetadata[0]; + const currentPublishedAt = currentMetadata.filter( + (quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE, + ); + const publishedAtLexical = currentPublishedAt.length === 1 + ? rdfLiteralLexicalValue(currentPublishedAt[0]!.object) + : undefined; + const preservePublishedAt = identity !== undefined + && currentPublishedAt.length === 1 + && currentPublishedAt[0]!.subject === identity.subject + && currentPublishedAt[0]!.graph === identity.graph + && publishedAtLexical !== undefined + && Number.isFinite(Date.parse(publishedAtLexical)); + let merged = preservePublishedAt + ? [ + ...incomingMetadata.filter( + (quad) => quad.predicate !== GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE, + ), + currentPublishedAt[0]!, + ] + : [...incomingMetadata]; + if (readGraphKnowledgeAssetConfirmationKindV1(incomingMetadata) === 'finalized-materialization') { + merged = preserveGraphKnowledgeAssetReceiptProvenanceV1(merged, currentMetadata); + } + return merged; +} + +function parseCanonicalMaterializedVersionV1(raw: string): MaterializedVersion | null { + const match = /^(0|[1-9]\d*):(0|[1-9]\d*)$/.exec(raw); + if (!match) return null; + const blockNumber = Number(match[1]); + const txIndex = Number(match[2]); + if (!Number.isSafeInteger(blockNumber) || !Number.isSafeInteger(txIndex)) return null; + return { blockNumber, txIndex }; +} + export interface GraphKnowledgeAssetMetadata extends KCMetadata { assertionVersion: string | number | bigint; publicTripleCount: number; @@ -1263,7 +1413,6 @@ const SKOLEM_INFIX = '/.well-known/genid/'; // writes, and have every writer refuse to apply a state OLDER than what is // already materialised. This gives the projection the same ordering guarantee // the chain log already has, regardless of interleaving. -const MATERIALIZED_VERSION_PRED = `${DKG}materializedVersion`; const ASSERTION_VERSION_PRED = `${DKG}assertionVersion`; export interface MaterializedVersion { From 568ee9ecc173743a37f520a1763546a1dcac72e5 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 20:10:10 +0200 Subject: [PATCH 271/292] refactor(rfc64): isolate graph metadata state --- .../src/graph-knowledge-asset-metadata.ts | 423 ++++++++++++++++++ packages/publisher/src/metadata.ts | 271 ++--------- 2 files changed, 455 insertions(+), 239 deletions(-) create mode 100644 packages/publisher/src/graph-knowledge-asset-metadata.ts diff --git a/packages/publisher/src/graph-knowledge-asset-metadata.ts b/packages/publisher/src/graph-knowledge-asset-metadata.ts new file mode 100644 index 0000000000..95493d7d52 --- /dev/null +++ b/packages/publisher/src/graph-knowledge-asset-metadata.ts @@ -0,0 +1,423 @@ +import { + GRAPH_KA_CONTENT_SCOPE_VERSION, + createGraphKnowledgeAssetScope, +} from '@origintrail-official/dkg-core'; +import type { Quad } from '@origintrail-official/dkg-storage'; + +import type { + KCMetadata, + MaterializedVersion, + OnChainProvenance, +} from './metadata.js'; + +const DKG = 'http://dkg.io/ontology/'; +const PROV = 'http://www.w3.org/ns/prov#'; +const XSD = 'http://www.w3.org/2001/XMLSchema#'; + +export const GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE = `${DKG}confirmationKind`; +const GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE = `${DKG}status`; +const GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE = `${DKG}transactionHash`; +const MATERIALIZED_VERSION_PREDICATE = `${DKG}materializedVersion`; +const GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE = `${DKG}publishedAt`; + +export type GraphKnowledgeAssetConfirmationKind = + | 'transaction' + | 'finalized-materialization'; + +export type GraphKnowledgeAssetConfirmation = + | Readonly<{ + kind: Extract; + provenance: OnChainProvenance; + }> + | Readonly<{ + kind: Extract; + provenance: Readonly<{ + batchId: bigint; + materializedVersion: MaterializedVersion; + }>; + }>; + +export type GraphKnowledgeAssetMetadataState = + | Readonly<{ readonly status: 'tentative' }> + | Readonly<{ + readonly status: 'confirmed'; + readonly confirmation: GraphKnowledgeAssetConfirmation; + }>; + +export interface GraphKnowledgeAssetMetadata extends KCMetadata { + assertionVersion: string | number | bigint; + publicTripleCount: number; + privateTripleCount?: number; + privateMerkleRoot?: Uint8Array; + assertionGraph: string; +} + +export interface GraphKnowledgeAssetReceiptProvenanceV1 { + readonly transactionHash: string; + readonly materializedVersion?: MaterializedVersion; +} + +/** + * Parse the graph-scoped confirmation discriminator shared by metadata writers + * and durable-sync readers. Missing metadata is the rolling-compatible legacy + * receipt-backed shape; every explicit value must name exactly one supported + * confirmation lane. + */ +export function normalizeGraphKnowledgeAssetConfirmationKindV1( + raw: string | undefined, +): GraphKnowledgeAssetConfirmationKind { + if (raw === undefined || raw === 'transaction') return 'transaction'; + if (raw === 'finalized-materialization') return raw; + throw new Error(`Unsupported graph knowledge asset confirmation kind: ${raw}`); +} + +/** Read and validate the confirmation state from one KA's structural metadata. */ +export function readGraphKnowledgeAssetConfirmationKindV1( + metadataQuads: readonly Pick[], +): GraphKnowledgeAssetConfirmationKind { + const values = metadataQuads + .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE) + .map((quad) => rdfLiteralLexicalValue(quad.object)); + if (values.length > 1) { + throw new Error(`Graph knowledge asset metadata has ${values.length} confirmation kinds`); + } + if (values.length === 1 && values[0] === undefined) { + throw new Error('Graph knowledge asset confirmation kind must be an RDF literal'); + } + return normalizeGraphKnowledgeAssetConfirmationKindV1(values[0]); +} + +/** + * Read the locally authenticated, receipt-backed part of graph-scoped KA + * metadata. This is the canonical parser used when a receiptless finalized + * replay must preserve stronger local transaction provenance. + * + * Invalid, tentative, or finalized-materialization metadata is not eligible + * for preservation and returns null. A missing confirmationKind is accepted as + * the rolling-compatible legacy transaction shape. + */ +export function readGraphKnowledgeAssetReceiptProvenanceV1( + metadataQuads: readonly Pick[], +): GraphKnowledgeAssetReceiptProvenanceV1 | null { + const statuses = metadataQuads + .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE) + .map((quad) => rdfLiteralLexicalValue(quad.object)); + if (statuses.length !== 1 || statuses[0] !== 'confirmed') return null; + + let confirmationKind: GraphKnowledgeAssetConfirmationKind; + try { + confirmationKind = readGraphKnowledgeAssetConfirmationKindV1(metadataQuads); + } catch { + return null; + } + if (confirmationKind !== 'transaction') return null; + + const hashes = metadataQuads + .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE) + .map((quad) => rdfLiteralLexicalValue(quad.object)); + if ( + hashes.length !== 1 + || hashes[0] === undefined + || !/^0x[0-9a-fA-F]{64}$/.test(hashes[0]) + ) { + return null; + } + + const versions = metadataQuads + .filter((quad) => quad.predicate === MATERIALIZED_VERSION_PREDICATE) + .map((quad) => rdfLiteralLexicalValue(quad.object)); + if (versions.length > 1 || (versions.length === 1 && versions[0] === undefined)) { + return null; + } + const materializedVersion = versions.length === 0 + ? undefined + : parseCanonicalMaterializedVersionV1(versions[0]!); + if (versions.length === 1 && materializedVersion === null) return null; + const validMaterializedVersion = materializedVersion ?? undefined; + + return Object.freeze({ + transactionHash: hashes[0], + ...(validMaterializedVersion === undefined + ? {} + : { materializedVersion: Object.freeze(validMaterializedVersion) }), + }); +} + +/** + * Preserve valid local receipt provenance while accepting an otherwise exact + * same-version metadata replacement from the receiptless finalized lane. + */ +export function preserveGraphKnowledgeAssetReceiptProvenanceV1( + incomingMetadata: readonly Quad[], + currentMetadata: readonly Pick[], +): Quad[] { + const provenance = readGraphKnowledgeAssetReceiptProvenanceV1(currentMetadata); + if (provenance === null) return [...incomingMetadata]; + const identity = incomingMetadata[0]; + if (identity === undefined) return [...incomingMetadata]; + return [ + ...incomingMetadata.filter((quad) => ( + quad.predicate !== GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE + && quad.predicate !== MATERIALIZED_VERSION_PREDICATE + && quad.predicate !== GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE + )), + mq( + identity.subject, + GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE, + lit(provenance.transactionHash), + identity.graph, + ), + mq( + identity.subject, + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, + lit('transaction'), + identity.graph, + ), + ...(provenance.materializedVersion === undefined + ? [] + : [materializedVersionQuad( + identity.graph, + identity.subject, + provenance.materializedVersion, + )]), + ]; +} + +/** + * Canonically merge metadata for an exact same-assertion replay. Stable local + * receive time is retained for every lane; a receiptless finalized replay also + * retains stronger, valid transaction provenance already authenticated here. + */ +export function mergeSameVersionGraphKnowledgeAssetMetadataV1( + incomingMetadata: readonly Quad[], + currentMetadata: readonly Quad[], +): Quad[] { + const identity = incomingMetadata[0]; + const currentPublishedAt = currentMetadata.filter( + (quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE, + ); + const publishedAtLexical = currentPublishedAt.length === 1 + ? rdfLiteralLexicalValue(currentPublishedAt[0]!.object) + : undefined; + const preservePublishedAt = identity !== undefined + && currentPublishedAt.length === 1 + && currentPublishedAt[0]!.subject === identity.subject + && currentPublishedAt[0]!.graph === identity.graph + && publishedAtLexical !== undefined + && Number.isFinite(Date.parse(publishedAtLexical)); + let merged = preservePublishedAt + ? [ + ...incomingMetadata.filter( + (quad) => quad.predicate !== GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE, + ), + currentPublishedAt[0]!, + ] + : [...incomingMetadata]; + if (readGraphKnowledgeAssetConfirmationKindV1(incomingMetadata) === 'finalized-materialization') { + merged = preserveGraphKnowledgeAssetReceiptProvenanceV1(merged, currentMetadata); + } + return merged; +} + +/** + * Constant-size VM metadata for one graph-scoped KA. RDF subjects in the KA + * payload never become membership, token, ownership, or trust rows here. + */ +export function generateGraphKnowledgeAssetMetadata( + meta: GraphKnowledgeAssetMetadata, + state: GraphKnowledgeAssetMetadataState, +): Quad[] { + const { scope, metaGraph, quads } = generateGraphKnowledgeAssetMetadataBase(meta); + if (state.status === 'confirmed') { + const { confirmation } = state; + if (confirmation.kind === 'transaction') { + quads.push( + mq(scope.ual, GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE, lit('confirmed'), metaGraph), + mq( + scope.ual, + GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE, + lit(confirmation.provenance.txHash), + metaGraph, + ), + mq(scope.ual, `${DKG}batchId`, intLit(confirmation.provenance.batchId), metaGraph), + mq( + scope.ual, + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, + lit('transaction'), + metaGraph, + ), + ); + } else { + const { batchId, materializedVersion } = confirmation.provenance; + if (batchId < 0n) { + throw new Error('Finalized graph metadata batchId must be non-negative'); + } + quads.push( + mq(scope.ual, GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE, lit('confirmed'), metaGraph), + mq(scope.ual, `${DKG}batchId`, intLit(batchId), metaGraph), + mq( + scope.ual, + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, + lit('finalized-materialization'), + metaGraph, + ), + materializedVersionQuad(metaGraph, scope.ual, materializedVersion), + ); + } + } else { + quads.push(mq(scope.ual, GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE, lit('tentative'), metaGraph)); + } + return quads; +} + +function generateGraphKnowledgeAssetMetadataBase( + meta: GraphKnowledgeAssetMetadata, +): Readonly<{ + scope: ReturnType; + metaGraph: string; + quads: Quad[]; +}> { + const scope = createGraphKnowledgeAssetScope(meta.ual, meta.assertionVersion); + if (!Number.isSafeInteger(meta.publicTripleCount) || meta.publicTripleCount < 0) { + throw new Error(`Invalid graph-scoped KA public triple count: ${meta.publicTripleCount}`); + } + const privateTripleCount = meta.privateTripleCount ?? 0; + if (!Number.isSafeInteger(privateTripleCount) || privateTripleCount < 0) { + throw new Error(`Invalid graph-scoped KA private triple count: ${privateTripleCount}`); + } + if (privateTripleCount > 0 && meta.privateMerkleRoot?.length !== 32) { + throw new Error('Graph-scoped KA private content requires one 32-byte private Merkle root'); + } + if (privateTripleCount === 0 && meta.privateMerkleRoot !== undefined) { + throw new Error('Graph-scoped KA private Merkle root requires a positive private triple count'); + } + if (meta.publicTripleCount === 0 && privateTripleCount === 0) { + throw new Error('Graph-scoped KA metadata cannot describe an empty asset'); + } + assertSafeGraphIriForSparql(meta.assertionGraph); + + const metaGraph = `did:dkg:context-graph:${meta.contextGraphId}/_meta`; + const publisherPeerId = meta.publisherPeerId || 'unknown'; + const attributedAgent = meta.agentAddress ?? meta.authorAddress; + const quads: Quad[] = [ + mq(scope.ual, `${DKG}merkleRoot`, lit(toHex(meta.merkleRoot)), metaGraph), + mq(scope.ual, GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE, dateLit(meta.timestamp), metaGraph), + mq(scope.ual, `${DKG}accessPolicy`, lit(meta.accessPolicy ?? 'public'), metaGraph), + mq(scope.ual, `${DKG}publisherPeerId`, lit(publisherPeerId), metaGraph), + mq( + scope.ual, + `${PROV}wasAttributedTo`, + attributedAgent && !isZeroEthAddress(attributedAgent) + ? agentDid(attributedAgent) + : lit(publisherPeerId), + metaGraph, + ), + mq( + scope.ual, + `${DKG}contextGraph`, + `did:dkg:context-graph:${meta.contextGraphId}`, + metaGraph, + ), + ]; + if (meta.subGraphName) { + quads.push(mq(scope.ual, `${DKG}subGraphName`, lit(meta.subGraphName), metaGraph)); + } + for (const peerId of meta.allowedPeers ?? []) { + quads.push(mq(scope.ual, `${DKG}allowedPeer`, lit(peerId), metaGraph)); + } + quads.push( + mq( + scope.ual, + `${DKG}contentScopeVersion`, + intLit(GRAPH_KA_CONTENT_SCOPE_VERSION), + metaGraph, + ), + mq(scope.ual, `${DKG}kaUal`, scope.ual, metaGraph), + mq(scope.ual, `${DKG}assertionVersion`, intLit(BigInt(scope.assertionVersion)), metaGraph), + mq(scope.ual, `${DKG}publicTripleCount`, intLit(meta.publicTripleCount), metaGraph), + mq(scope.ual, `${DKG}privateTripleCount`, intLit(privateTripleCount), metaGraph), + mq(scope.ual, `${DKG}assertionGraph`, meta.assertionGraph, metaGraph), + ); + if (meta.privateMerkleRoot) { + quads.push( + mq(scope.ual, `${DKG}privateMerkleRoot`, lit(toHex(meta.privateMerkleRoot)), metaGraph), + ); + } + return { scope, metaGraph, quads }; +} + +function parseCanonicalMaterializedVersionV1(raw: string): MaterializedVersion | null { + const match = /^(0|[1-9]\d*):(0|[1-9]\d*)$/.exec(raw); + if (!match) return null; + const blockNumber = Number(match[1]); + const txIndex = Number(match[2]); + if (!Number.isSafeInteger(blockNumber) || !Number.isSafeInteger(txIndex)) return null; + return { blockNumber, txIndex }; +} + +function rdfLiteralLexicalValue(value: string): string | undefined { + const match = /^("(?:\\.|[^"\\])*")/.exec(value); + if (!match) return undefined; + try { + return JSON.parse(match[1]); + } catch { + return undefined; + } +} + +function materializedVersionQuad( + metaGraph: string, + ual: string, + version: MaterializedVersion, +): Quad { + assertSafeGraphIriForSparql(metaGraph); + assertSafeGraphIriForSparql(ual); + return mq( + ual, + MATERIALIZED_VERSION_PREDICATE, + lit(`${version.blockNumber}:${version.txIndex}`), + metaGraph, + ); +} + +function assertSafeGraphIriForSparql(graphIri: string): void { + if (/[<>"{}|^`\\\s]/.test(graphIri)) { + throw new Error(`Unsafe graph IRI for SPARQL query: "${graphIri}"`); + } +} + +function mq(subject: string, predicate: string, object: string, graph: string): Quad { + return { subject, predicate, object, graph }; +} + +function lit(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); + return `"${escaped}"`; +} + +function intLit(value: number | bigint): string { + return `"${value}"^^<${XSD}integer>`; +} + +function dateLit(value: Date): string { + return `"${value.toISOString()}"^^<${XSD}dateTime>`; +} + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function isZeroEthAddress(address: string): boolean { + return /^0x0{40}$/i.test(address); +} + +function agentDid(address: string): string { + const subject = /^0x[0-9a-fA-F]{40}$/.test(address) ? address.toLowerCase() : address; + return `did:dkg:agent:${subject}`; +} diff --git a/packages/publisher/src/metadata.ts b/packages/publisher/src/metadata.ts index 751aac26c0..6252a40e6e 100644 --- a/packages/publisher/src/metadata.ts +++ b/packages/publisher/src/metadata.ts @@ -18,12 +18,29 @@ import { knowledgeAssetLayerGraphUri, } from '@origintrail-official/dkg-core'; import type { AssertionState } from '@origintrail-official/dkg-core'; +import { + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE as GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE_V1, + generateGraphKnowledgeAssetMetadata as generateGraphKnowledgeAssetMetadataV1, + mergeSameVersionGraphKnowledgeAssetMetadataV1 as mergeSameVersionGraphKnowledgeAssetMetadata, + normalizeGraphKnowledgeAssetConfirmationKindV1 as normalizeGraphKnowledgeAssetConfirmationKind, + preserveGraphKnowledgeAssetReceiptProvenanceV1 as preserveGraphKnowledgeAssetReceiptProvenance, + readGraphKnowledgeAssetConfirmationKindV1 as readGraphKnowledgeAssetConfirmationKind, + readGraphKnowledgeAssetReceiptProvenanceV1 as readGraphKnowledgeAssetReceiptProvenance, +} from './graph-knowledge-asset-metadata.js'; +import type { + GraphKnowledgeAssetConfirmation as GraphKnowledgeAssetConfirmationV1, + GraphKnowledgeAssetConfirmationKind as GraphKnowledgeAssetConfirmationKindV1, + GraphKnowledgeAssetMetadata as GraphKnowledgeAssetMetadataV1, + GraphKnowledgeAssetMetadataState as GraphKnowledgeAssetMetadataStateV1, + GraphKnowledgeAssetReceiptProvenanceV1 as GraphKnowledgeAssetReceiptProvenance, +} from './graph-knowledge-asset-metadata.js'; const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; const SCHEMA = 'http://schema.org/'; const DKG = 'http://dkg.io/ontology/'; const PROV = 'http://www.w3.org/ns/prov#'; const XSD = 'http://www.w3.org/2001/XMLSchema#'; +const MATERIALIZED_VERSION_PRED = `${DKG}materializedVersion`; const LOCAL_TRUSTED_KA_CONTROL_PREDICATES = new Set([ `${DKG}accessPolicy`, `${DKG}allowedPeer`, @@ -106,35 +123,12 @@ export interface OnChainProvenance { chainId: string; } -export const GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE = `${DKG}confirmationKind`; -const GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE = `${DKG}status`; -const GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE = `${DKG}transactionHash`; -const MATERIALIZED_VERSION_PRED = `${DKG}materializedVersion`; -const GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE = `${DKG}publishedAt`; - -export type GraphKnowledgeAssetConfirmationKind = - | 'transaction' - | 'finalized-materialization'; - -export type GraphKnowledgeAssetConfirmation = - | Readonly<{ - kind: Extract; - provenance: OnChainProvenance; - }> - | Readonly<{ - kind: Extract; - provenance: Readonly<{ - batchId: bigint; - materializedVersion: MaterializedVersion; - }>; - }>; - -export type GraphKnowledgeAssetMetadataState = - | Readonly<{ readonly status: 'tentative' }> - | Readonly<{ - readonly status: 'confirmed'; - readonly confirmation: GraphKnowledgeAssetConfirmation; - }>; +export const GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE = + GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE_V1; + +export type GraphKnowledgeAssetConfirmationKind = GraphKnowledgeAssetConfirmationKindV1; +export type GraphKnowledgeAssetConfirmation = GraphKnowledgeAssetConfirmationV1; +export type GraphKnowledgeAssetMetadataState = GraphKnowledgeAssetMetadataStateV1; /** * Parse the graph-scoped confirmation discriminator shared by metadata writers @@ -145,31 +139,17 @@ export type GraphKnowledgeAssetMetadataState = export function normalizeGraphKnowledgeAssetConfirmationKindV1( raw: string | undefined, ): GraphKnowledgeAssetConfirmationKind { - if (raw === undefined || raw === 'transaction') return 'transaction'; - if (raw === 'finalized-materialization') return raw; - throw new Error(`Unsupported graph knowledge asset confirmation kind: ${raw}`); + return normalizeGraphKnowledgeAssetConfirmationKind(raw); } /** Read and validate the confirmation state from one KA's structural metadata. */ export function readGraphKnowledgeAssetConfirmationKindV1( metadataQuads: readonly Pick[], ): GraphKnowledgeAssetConfirmationKind { - const values = metadataQuads - .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE) - .map((quad) => rdfLiteralLexicalValue(quad.object)); - if (values.length > 1) { - throw new Error(`Graph knowledge asset metadata has ${values.length} confirmation kinds`); - } - if (values.length === 1 && values[0] === undefined) { - throw new Error('Graph knowledge asset confirmation kind must be an RDF literal'); - } - return normalizeGraphKnowledgeAssetConfirmationKindV1(values[0]); + return readGraphKnowledgeAssetConfirmationKind(metadataQuads); } -export interface GraphKnowledgeAssetReceiptProvenanceV1 { - readonly transactionHash: string; - readonly materializedVersion?: MaterializedVersion; -} +export type GraphKnowledgeAssetReceiptProvenanceV1 = GraphKnowledgeAssetReceiptProvenance; /** * Read the locally authenticated, receipt-backed part of graph-scoped KA @@ -183,48 +163,7 @@ export interface GraphKnowledgeAssetReceiptProvenanceV1 { export function readGraphKnowledgeAssetReceiptProvenanceV1( metadataQuads: readonly Pick[], ): GraphKnowledgeAssetReceiptProvenanceV1 | null { - const statuses = metadataQuads - .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_STATUS_PREDICATE) - .map((quad) => rdfLiteralLexicalValue(quad.object)); - if (statuses.length !== 1 || statuses[0] !== 'confirmed') return null; - - let confirmationKind: GraphKnowledgeAssetConfirmationKind; - try { - confirmationKind = readGraphKnowledgeAssetConfirmationKindV1(metadataQuads); - } catch { - return null; - } - if (confirmationKind !== 'transaction') return null; - - const hashes = metadataQuads - .filter((quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE) - .map((quad) => rdfLiteralLexicalValue(quad.object)); - if ( - hashes.length !== 1 - || hashes[0] === undefined - || !/^0x[0-9a-fA-F]{64}$/.test(hashes[0]) - ) { - return null; - } - - const versions = metadataQuads - .filter((quad) => quad.predicate === MATERIALIZED_VERSION_PRED) - .map((quad) => rdfLiteralLexicalValue(quad.object)); - if (versions.length > 1 || (versions.length === 1 && versions[0] === undefined)) { - return null; - } - const materializedVersion = versions.length === 0 - ? undefined - : parseCanonicalMaterializedVersionV1(versions[0]!); - if (versions.length === 1 && materializedVersion === null) return null; - const validMaterializedVersion = materializedVersion ?? undefined; - - return Object.freeze({ - transactionHash: hashes[0], - ...(validMaterializedVersion === undefined - ? {} - : { materializedVersion: Object.freeze(validMaterializedVersion) }), - }); + return readGraphKnowledgeAssetReceiptProvenance(metadataQuads); } /** @@ -235,36 +174,7 @@ export function preserveGraphKnowledgeAssetReceiptProvenanceV1( incomingMetadata: readonly Quad[], currentMetadata: readonly Pick[], ): Quad[] { - const provenance = readGraphKnowledgeAssetReceiptProvenanceV1(currentMetadata); - if (provenance === null) return [...incomingMetadata]; - const identity = incomingMetadata[0]; - if (identity === undefined) return [...incomingMetadata]; - return [ - ...incomingMetadata.filter((quad) => ( - quad.predicate !== GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE - && quad.predicate !== MATERIALIZED_VERSION_PRED - && quad.predicate !== GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE - )), - mq( - identity.subject, - GRAPH_KNOWLEDGE_ASSET_TRANSACTION_HASH_PREDICATE, - lit(provenance.transactionHash), - identity.graph, - ), - mq( - identity.subject, - GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, - lit('transaction'), - identity.graph, - ), - ...(provenance.materializedVersion === undefined - ? [] - : [materializedVersionQuad( - identity.graph, - identity.subject, - provenance.materializedVersion, - )]), - ]; + return preserveGraphKnowledgeAssetReceiptProvenance(incomingMetadata, currentMetadata); } /** @@ -276,50 +186,11 @@ export function mergeSameVersionGraphKnowledgeAssetMetadataV1( incomingMetadata: readonly Quad[], currentMetadata: readonly Quad[], ): Quad[] { - const identity = incomingMetadata[0]; - const currentPublishedAt = currentMetadata.filter( - (quad) => quad.predicate === GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE, - ); - const publishedAtLexical = currentPublishedAt.length === 1 - ? rdfLiteralLexicalValue(currentPublishedAt[0]!.object) - : undefined; - const preservePublishedAt = identity !== undefined - && currentPublishedAt.length === 1 - && currentPublishedAt[0]!.subject === identity.subject - && currentPublishedAt[0]!.graph === identity.graph - && publishedAtLexical !== undefined - && Number.isFinite(Date.parse(publishedAtLexical)); - let merged = preservePublishedAt - ? [ - ...incomingMetadata.filter( - (quad) => quad.predicate !== GRAPH_KNOWLEDGE_ASSET_PUBLISHED_AT_PREDICATE, - ), - currentPublishedAt[0]!, - ] - : [...incomingMetadata]; - if (readGraphKnowledgeAssetConfirmationKindV1(incomingMetadata) === 'finalized-materialization') { - merged = preserveGraphKnowledgeAssetReceiptProvenanceV1(merged, currentMetadata); - } - return merged; -} - -function parseCanonicalMaterializedVersionV1(raw: string): MaterializedVersion | null { - const match = /^(0|[1-9]\d*):(0|[1-9]\d*)$/.exec(raw); - if (!match) return null; - const blockNumber = Number(match[1]); - const txIndex = Number(match[2]); - if (!Number.isSafeInteger(blockNumber) || !Number.isSafeInteger(txIndex)) return null; - return { blockNumber, txIndex }; -} - -export interface GraphKnowledgeAssetMetadata extends KCMetadata { - assertionVersion: string | number | bigint; - publicTripleCount: number; - privateTripleCount?: number; - privateMerkleRoot?: Uint8Array; - assertionGraph: string; + return mergeSameVersionGraphKnowledgeAssetMetadata(incomingMetadata, currentMetadata); } +export type GraphKnowledgeAssetMetadata = GraphKnowledgeAssetMetadataV1; + function assertSafeContextGraphIdForSparql(contextGraphId: string): void { if (/[<>"{}|^`\\\s]/.test(contextGraphId)) { throw new Error(`Unsafe contextGraphId for SPARQL graph IRI: "${contextGraphId}"`); @@ -537,85 +408,7 @@ export function generateGraphKnowledgeAssetMetadata( meta: GraphKnowledgeAssetMetadata, state: GraphKnowledgeAssetMetadataState, ): Quad[] { - const { scope, metaGraph, quads } = generateGraphKnowledgeAssetMetadataBase(meta); - if (state.status === 'confirmed') { - const { confirmation } = state; - if (confirmation.kind === 'transaction') { - quads.push(...generateConfirmedMetadata( - scope.ual, - meta.contextGraphId, - confirmation.provenance, - )); - quads.push(mq( - scope.ual, - GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, - lit('transaction'), - metaGraph, - )); - } else { - const { batchId, materializedVersion } = confirmation.provenance; - if (batchId < 0n) { - throw new Error('Finalized graph metadata batchId must be non-negative'); - } - quads.push( - getConfirmedStatusQuad(scope.ual, meta.contextGraphId), - mq(scope.ual, `${DKG}batchId`, intLit(batchId), metaGraph), - mq( - scope.ual, - GRAPH_KNOWLEDGE_ASSET_CONFIRMATION_KIND_PREDICATE, - lit('finalized-materialization'), - metaGraph, - ), - materializedVersionQuad(metaGraph, scope.ual, materializedVersion), - ); - } - } else { - quads.push(mq(scope.ual, `${DKG}status`, lit('tentative'), metaGraph)); - } - return quads; -} - -function generateGraphKnowledgeAssetMetadataBase( - meta: GraphKnowledgeAssetMetadata, -): Readonly<{ - scope: ReturnType; - metaGraph: string; - quads: Quad[]; -}> { - const scope = createGraphKnowledgeAssetScope(meta.ual, meta.assertionVersion); - if (!Number.isSafeInteger(meta.publicTripleCount) || meta.publicTripleCount < 0) { - throw new Error(`Invalid graph-scoped KA public triple count: ${meta.publicTripleCount}`); - } - const privateTripleCount = meta.privateTripleCount ?? 0; - if (!Number.isSafeInteger(privateTripleCount) || privateTripleCount < 0) { - throw new Error(`Invalid graph-scoped KA private triple count: ${privateTripleCount}`); - } - if (privateTripleCount > 0 && meta.privateMerkleRoot?.length !== 32) { - throw new Error('Graph-scoped KA private content requires one 32-byte private Merkle root'); - } - if (privateTripleCount === 0 && meta.privateMerkleRoot !== undefined) { - throw new Error('Graph-scoped KA private Merkle root requires a positive private triple count'); - } - if (meta.publicTripleCount === 0 && privateTripleCount === 0) { - throw new Error('Graph-scoped KA metadata cannot describe an empty asset'); - } - assertSafeGraphIriForSparql(meta.assertionGraph); - const metaGraph = `did:dkg:context-graph:${meta.contextGraphId}/_meta`; - const quads = [ - ...generateKCMetadata({ ...meta, ual: scope.ual }, []), - mq(scope.ual, `${DKG}contentScopeVersion`, intLit(GRAPH_KA_CONTENT_SCOPE_VERSION), metaGraph), - mq(scope.ual, `${DKG}kaUal`, scope.ual, metaGraph), - mq(scope.ual, `${DKG}assertionVersion`, intLit(BigInt(scope.assertionVersion)), metaGraph), - mq(scope.ual, `${DKG}publicTripleCount`, intLit(meta.publicTripleCount), metaGraph), - mq(scope.ual, `${DKG}privateTripleCount`, intLit(privateTripleCount), metaGraph), - mq(scope.ual, `${DKG}assertionGraph`, meta.assertionGraph, metaGraph), - ]; - if (meta.privateMerkleRoot) { - quads.push( - mq(scope.ual, `${DKG}privateMerkleRoot`, lit(toHex(meta.privateMerkleRoot)), metaGraph), - ); - } - return { scope, metaGraph, quads }; + return generateGraphKnowledgeAssetMetadataV1(meta, state); } export interface ConfirmedGraphKnowledgeAssetMetadataEnvelope { From ed5e58dd23bb409fc6382974fee029a5b23eeba8 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:33:12 +0200 Subject: [PATCH 272/292] feat(agent): add RFC-64 current-head discovery seam --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/index.ts | 1 + ...ublic-catalog-current-head-discovery-v1.ts | 673 ++++++++++++++++++ .../src/rfc64/public-catalog-service-v1.ts | 161 ++++- ...-catalog-current-head-discovery-v1.test.ts | 410 +++++++++++ packages/agent/vitest.rfc64-unit-tests.ts | 1 + 7 files changed, 1247 insertions(+), 1 deletion(-) create mode 100644 packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts create mode 100644 packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 5a92db7882..fd29890bb5 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -37,6 +37,7 @@ "./dist/rfc64/persistence-root-ownership-v1-internal.js": null, "./dist/rfc64/persistence-v1.js": null, "./dist/rfc64/policy-cell-v1.js": null, + "./dist/rfc64/public-catalog-current-head-discovery-v1.js": null, "./dist/rfc64/public-catalog-inventory-completeness-v1.js": null, "./dist/rfc64/public-catalog-native-receiver-v1.js": null, "./dist/rfc64/public-catalog-native-reconciler-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index f311f14724..83d0b72b9a 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -112,6 +112,7 @@ const blockedRfc64Modules = [ 'persistence-root-ownership-v1-internal.js', 'persistence-v1.js', 'policy-cell-v1.js', + 'public-catalog-current-head-discovery-v1.js', 'public-catalog-inventory-completeness-v1.js', 'public-catalog-native-reconciler-v1.js', 'public-catalog-native-receiver-v1.js', diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index e48d9c8f7c..fcc0ad7ebf 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -41,6 +41,7 @@ export { } from './rfc64/recoverable-author-attestation-v1.js'; export * from './rfc64/author-catalog-producer.js'; export * from './rfc64/public-catalog-transport-v1.js'; +export * from './rfc64/public-catalog-current-head-discovery-v1.js'; export * from './rfc64/open-catalog-policy-v1.js'; export * from './rfc64/public-catalog-receiver-v1.js'; export * from './rfc64/public-catalog-service-v1.js'; diff --git a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts new file mode 100644 index 0000000000..7ff48b9bf3 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts @@ -0,0 +1,673 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * RFC-64 public/open current-head discovery transport. + * + * Discovery is deliberately a narrow hint protocol. A requester names one + * independently accepted public/open catalog scope and a provider resolves it + * through a trusted semantic-current-head reader. Before advertising the + * result, the provider re-reads and verifies the exact signed head object. The + * requester must still exact-fetch and verify that object before treating the + * metadata as authenticated; {@link Rfc64PublicCatalogServiceV1} owns that + * composition. + * + * This transport does not stage control objects, schedule reconciliation, + * choose peers, walk predecessor history, or activate semantic state. + */ + +import { + AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + ProtocolRouter, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertContextGraphIdV1, + assertNetworkIdV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + assertSubGraphNameV1, + computeControlSignatureVariantDigestHex, + type ContextGraphAccessPolicyV1, + type ContextGraphIdV1, + type DecimalU64V1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type SendOptions, + type SignedAuthorCatalogHeadEnvelopeV1, + type SignedControlEnvelopeV1, + type SubGraphNameV1, +} from '@origintrail-official/dkg-core'; +import { + readVerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; + +import type { Rfc64PublicCatalogHeadAnnouncementV1 } from './public-catalog-transport-v1.js'; +import { + RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + encodeRfc64PublicCatalogHeadAnnouncementV1, + parseRfc64PublicCatalogHeadAnnouncementV1, +} from './public-catalog-transport-v1.js'; + +export const RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1 = + '/dkg/catalog/1/author-head/current' as const; +export const RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1 = + 'rfc64-author-catalog-current-head-query-v1' as const; + +export const RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_MAX_BYTES_V1 = 2 * 1024; +export const RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1 = 2 * 1024 + 1; + +const CURRENT_HEAD_NOT_FOUND = 0; +const CURRENT_HEAD_FOUND = 1; +const CURRENT_HEAD_DENIED = 2; +const CURRENT_HEAD_SNAPSHOT_MAX_ATTEMPTS = 2; +const MAX_PEER_ID_BYTES = 256; +const UTF8 = new TextEncoder(); +// Keep a leading BOM visible so canonical re-encoding rejects it. +const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +const QUERY_KEYS = Object.freeze([ + 'authorAddress', + 'catalogEra', + 'contextGraphId', + 'kind', + 'networkId', + 'policyDigest', + 'subGraphName', +] as const); + +export interface Rfc64PublicCatalogCurrentHeadScopeV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogEra: DecimalU64V1; +} + +export interface Rfc64PublicCatalogCurrentHeadQueryV1 + extends Rfc64PublicCatalogCurrentHeadScopeV1 { + readonly kind: typeof RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1; + readonly policyDigest: Digest32V1; +} + +export type Rfc64PublicCatalogCurrentHeadDiscoveryOperationV1 = + | 'current-head-discovery-outbound' + | 'current-head-discovery-inbound'; + +export interface Rfc64PublicCatalogCurrentHeadAuthorizationInputV1 + extends Rfc64PublicCatalogCurrentHeadScopeV1 { + readonly operation: Rfc64PublicCatalogCurrentHeadDiscoveryOperationV1; + readonly remotePeerId: string; + readonly policyDigest: Digest32V1; + readonly objectType: typeof AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1; +} + +export interface Rfc64PublicCatalogCurrentHeadAuthorizationV1 { + readonly accessPolicy: ContextGraphAccessPolicyV1; + readonly policyDigest: Digest32V1; +} + +export interface Rfc64PublicCatalogCurrentHeadControlObjectReaderV1 { + getVerifiedObjectByDigest(input: { + readonly objectDigest: Digest32V1; + readonly verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; + }): Promise<{ + readonly envelope: SignedControlEnvelopeV1; + readonly issuerSignature: VerifiedControlEnvelopeIssuerSignatureV1; + } | null>; +} + +export interface Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1 { + readonly controlObjects: Rfc64PublicCatalogCurrentHeadControlObjectReaderV1; + /** + * Resolve only a durable, semantically applied current-head pointer for the + * trusted query scope. A staged-only or candidate pointer violates this + * capability contract. + */ + readonly readCurrentAppliedCatalogHeadDigest: ( + query: Rfc64PublicCatalogCurrentHeadQueryV1, + ) => Promise; + /** Must consult accepted current policy state, never echo the wire digest. */ + readonly authorizeOpenCatalogOperation: ( + input: Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, + ) => Promise; + readonly verifyIssuerSignature: ( + envelope: SignedControlEnvelopeV1, + ) => Promise; +} + +export const RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_ERROR_CODES_V1 = Object.freeze([ + 'catalog-discovery-input', + 'catalog-discovery-wire', + 'catalog-discovery-policy-denied', + 'catalog-discovery-object-mismatch', + 'catalog-discovery-signature', + 'catalog-discovery-state', +] as const); + +export type Rfc64PublicCatalogCurrentHeadDiscoveryErrorCodeV1 = + (typeof RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_ERROR_CODES_V1)[number]; + +export class Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1 extends Error { + constructor( + readonly code: Rfc64PublicCatalogCurrentHeadDiscoveryErrorCodeV1, + message: string, + options: ErrorOptions = {}, + ) { + super(`[${code}] ${message}`, options); + this.name = 'Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1'; + } +} + +/** + * Policy-gated current-head query/response on a production ProtocolRouter. + * Returned announcements remain hints until the caller exact-fetches and + * verifies the signed head named by both digests. + */ +export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { + #started = false; + + constructor( + private readonly router: ProtocolRouter, + private readonly options: Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1, + ) { + if (typeof options?.controlObjects?.getVerifiedObjectByDigest !== 'function') { + fail('catalog-discovery-input', 'controlObjects.getVerifiedObjectByDigest must be a function'); + } + if (typeof options.readCurrentAppliedCatalogHeadDigest !== 'function') { + fail('catalog-discovery-input', 'readCurrentAppliedCatalogHeadDigest must be a function'); + } + if (typeof options.authorizeOpenCatalogOperation !== 'function') { + fail('catalog-discovery-input', 'authorizeOpenCatalogOperation must be a function'); + } + if (typeof options.verifyIssuerSignature !== 'function') { + fail('catalog-discovery-input', 'verifyIssuerSignature must be a function'); + } + } + + get started(): boolean { + return this.#started; + } + + start(): void { + if (this.#started) return; + this.#started = true; + try { + this.router.register( + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1, + async (data, peerId) => this.handleQuery(data, peerId.toString()), + { maxReadBytes: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_MAX_BYTES_V1 }, + ); + } catch (cause) { + this.#started = false; + this.router.unregister(RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1); + throw cause; + } + } + + stop(): void { + if (!this.#started) return; + this.#started = false; + this.router.unregister(RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1); + } + + async discoverCurrentCatalogHead( + remotePeerIdInput: string, + queryInput: Rfc64PublicCatalogCurrentHeadQueryV1, + sendOptions?: SendOptions, + ): Promise { + this.requireStarted(); + const remotePeerId = snapshotPeerId(remotePeerIdInput); + const query = parseQuery(encodeQuery(queryInput)); + await this.requireOpenPolicy('current-head-discovery-outbound', remotePeerId, query); + const response = await this.router.send( + remotePeerId, + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1, + encodeQuery(query), + sendOptions, + ); + const announcement = parseResponse(response); + await this.requireOpenPolicy('current-head-discovery-outbound', remotePeerId, query); + if (announcement === null) return null; + assertAnnouncementMatchesQuery(announcement, query); + return announcement; + } + + private async handleQuery( + data: Uint8Array, + remotePeerIdInput: string, + ): Promise { + this.requireStarted(); + const remotePeerId = snapshotPeerId(remotePeerIdInput); + const query = parseQuery(data); + for (let attempt = 0; attempt < CURRENT_HEAD_SNAPSHOT_MAX_ATTEMPTS; attempt += 1) { + if (!await this.isOpenPolicy('current-head-discovery-inbound', remotePeerId, query)) { + return Uint8Array.of(CURRENT_HEAD_DENIED); + } + + const currentDigest = await this.readCurrentAppliedCatalogHeadDigest(query); + const announcement = currentDigest === null + ? null + : await this.readCurrentAnnouncement(currentDigest, query); + + // A semantic ref can advance while its immutable object is read. Confirm + // the pointer immediately before responding so discovery never knowingly + // advertises a superseded or transient snapshot. One retry admits the + // common single-writer CAS race while keeping work per request bounded. + const confirmedDigest = await this.readCurrentAppliedCatalogHeadDigest(query); + if (confirmedDigest !== currentDigest) continue; + + if (!await this.isOpenPolicy('current-head-discovery-inbound', remotePeerId, query)) { + return Uint8Array.of(CURRENT_HEAD_DENIED); + } + return announcement === null + ? Uint8Array.of(CURRENT_HEAD_NOT_FOUND) + : foundResponse(announcement); + } + + fail( + 'catalog-discovery-state', + 'current applied catalog-head pointer changed during bounded discovery', + ); + } + + private async readCurrentAppliedCatalogHeadDigest( + query: Rfc64PublicCatalogCurrentHeadQueryV1, + ): Promise { + try { + const currentDigest = await this.options.readCurrentAppliedCatalogHeadDigest(query); + if (currentDigest !== null) { + assertCanonicalDigest(currentDigest, 'current catalog head digest'); + } + return currentDigest; + } catch (cause) { + fail('catalog-discovery-state', 'current applied catalog-head lookup failed', cause); + } + } + + private async readCurrentAnnouncement( + currentDigest: Digest32V1, + query: Rfc64PublicCatalogCurrentHeadQueryV1, + ): Promise { + let stored: Awaited>; + try { + stored = await this.options.controlObjects.getVerifiedObjectByDigest({ + objectDigest: currentDigest, + verifyIssuerSignature: this.options.verifyIssuerSignature, + }); + } catch (cause) { + fail('catalog-discovery-state', 'current catalog-head object lookup failed', cause); + } + if (stored === null) { + fail( + 'catalog-discovery-state', + 'durable current catalog-head pointer has no exact verified control object', + ); + } + + let envelope: SignedAuthorCatalogHeadEnvelopeV1; + try { + assertSignedAuthorCatalogHeadEnvelopeV1(stored.envelope); + envelope = stored.envelope; + assertHeadMatchesQuery(envelope, currentDigest, query); + assertExactIssuerSignatureProof(envelope, stored.issuerSignature); + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1) throw cause; + fail( + 'catalog-discovery-object-mismatch', + 'current catalog-head pointer does not resolve to the exact queried signed head', + cause, + ); + } + return announcementFromHead(envelope, query.policyDigest); + } + + private async isOpenPolicy( + operation: Rfc64PublicCatalogCurrentHeadDiscoveryOperationV1, + remotePeerId: string, + query: Rfc64PublicCatalogCurrentHeadQueryV1, + ): Promise { + try { + await this.requireOpenPolicy(operation, remotePeerId, query); + return true; + } catch (cause) { + if ( + cause instanceof Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1 + && cause.code === 'catalog-discovery-policy-denied' + ) return false; + throw cause; + } + } + + private async requireOpenPolicy( + operation: Rfc64PublicCatalogCurrentHeadDiscoveryOperationV1, + remotePeerId: string, + query: Rfc64PublicCatalogCurrentHeadQueryV1, + ): Promise { + const input = Object.freeze({ + operation, + remotePeerId, + networkId: query.networkId, + contextGraphId: query.contextGraphId, + subGraphName: query.subGraphName, + authorAddress: query.authorAddress, + catalogEra: query.catalogEra, + policyDigest: query.policyDigest, + objectType: AUTHOR_CATALOG_HEAD_OBJECT_TYPE_V1, + }) satisfies Rfc64PublicCatalogCurrentHeadAuthorizationInputV1; + let authorization: Rfc64PublicCatalogCurrentHeadAuthorizationV1 | null; + try { + authorization = await this.options.authorizeOpenCatalogOperation(input); + } catch (cause) { + fail('catalog-discovery-policy-denied', 'open catalog discovery authorization failed', cause); + } + if (authorization === null || authorization.accessPolicy !== 0) { + fail('catalog-discovery-policy-denied', 'current-head discovery is not authorized by open policy'); + } + try { + assertCanonicalDigest(authorization.policyDigest, 'authorized policyDigest'); + } catch (cause) { + fail('catalog-discovery-policy-denied', 'discovery authorization returned an invalid digest', cause); + } + if (authorization.policyDigest !== query.policyDigest) { + fail('catalog-discovery-policy-denied', 'current-head discovery policy generation is stale or mismatched'); + } + } + + private requireStarted(): void { + if (!this.#started) { + fail('catalog-discovery-state', 'RFC-64 current-head discovery transport is not started'); + } + } +} + +export function encodeRfc64PublicCatalogCurrentHeadQueryV1( + input: Rfc64PublicCatalogCurrentHeadQueryV1, +): Uint8Array { + return encodeQuery(input); +} + +export function parseRfc64PublicCatalogCurrentHeadQueryV1( + input: Uint8Array, +): Rfc64PublicCatalogCurrentHeadQueryV1 { + return parseQuery(input); +} + +function encodeQuery(input: Rfc64PublicCatalogCurrentHeadQueryV1): Uint8Array { + const snapshot = validateQuery(input); + return encodeFlatCanonicalJson(snapshot, RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_MAX_BYTES_V1); +} + +function parseQuery(input: Uint8Array): Rfc64PublicCatalogCurrentHeadQueryV1 { + const parsed = parseFlatCanonicalJson( + input, + QUERY_KEYS, + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_MAX_BYTES_V1, + ); + return validateQuery(parsed); +} + +function validateQuery(value: unknown): Rfc64PublicCatalogCurrentHeadQueryV1 { + if (!isPlainRecord(value)) { + fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain object'); + } + assertExactWireKeys(value, QUERY_KEYS); + if (value.kind !== RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1) { + fail( + 'catalog-discovery-wire', + `RFC-64 current-head query kind must be ${RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1}`, + ); + } + try { + assertNetworkIdV1(value.networkId); + assertContextGraphIdV1(value.contextGraphId); + if (value.subGraphName !== null) assertSubGraphNameV1(value.subGraphName); + assertCanonicalEvmAddressV1(value.authorAddress, 'authorAddress'); + assertCanonicalDecimalU64(value.catalogEra, 'catalogEra'); + assertCanonicalDigest(value.policyDigest, 'policyDigest'); + return Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + networkId: value.networkId, + contextGraphId: value.contextGraphId, + subGraphName: value.subGraphName, + authorAddress: value.authorAddress, + catalogEra: value.catalogEra, + policyDigest: value.policyDigest, + }); + } catch (cause) { + if (cause instanceof Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1) throw cause; + fail('catalog-discovery-wire', 'RFC-64 current-head query contains an invalid scalar', cause); + } +} + +function foundResponse(announcement: Rfc64PublicCatalogHeadAnnouncementV1): Uint8Array { + const bytes = encodeRfc64PublicCatalogHeadAnnouncementV1(announcement); + const response = new Uint8Array(bytes.byteLength + 1); + response[0] = CURRENT_HEAD_FOUND; + response.set(bytes, 1); + if (response.byteLength > RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1) { + fail('catalog-discovery-wire', 'current-head discovery response exceeds its v1 cap'); + } + return response; +} + +function parseResponse(input: Uint8Array): Rfc64PublicCatalogHeadAnnouncementV1 | null { + if ( + !(input instanceof Uint8Array) + || input.byteLength < 1 + || input.byteLength > RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1 + ) { + fail('catalog-discovery-wire', 'current-head discovery response is empty or oversized'); + } + if (input[0] === CURRENT_HEAD_NOT_FOUND) { + if (input.byteLength !== 1) { + fail('catalog-discovery-wire', 'not-found current-head response has trailing bytes'); + } + return null; + } + if (input[0] === CURRENT_HEAD_DENIED) { + if (input.byteLength !== 1) { + fail('catalog-discovery-wire', 'denied current-head response has trailing bytes'); + } + fail('catalog-discovery-policy-denied', 'remote peer denied current-head discovery'); + } + if (input[0] !== CURRENT_HEAD_FOUND || input.byteLength === 1) { + fail('catalog-discovery-wire', 'current-head discovery response has an invalid status'); + } + try { + return parseRfc64PublicCatalogHeadAnnouncementV1(input.subarray(1)); + } catch (cause) { + fail('catalog-discovery-wire', 'current-head discovery response is not canonical', cause); + } +} + +function assertAnnouncementMatchesQuery( + announcement: Rfc64PublicCatalogHeadAnnouncementV1, + query: Rfc64PublicCatalogCurrentHeadQueryV1, +): void { + if ( + announcement.networkId !== query.networkId + || announcement.contextGraphId !== query.contextGraphId + || announcement.subGraphName !== query.subGraphName + || announcement.authorAddress !== query.authorAddress + || announcement.catalogEra !== query.catalogEra + || announcement.policyDigest !== query.policyDigest + ) { + fail( + 'catalog-discovery-object-mismatch', + 'discovered catalog-head metadata does not match the exact requested scope', + ); + } +} + +function assertHeadMatchesQuery( + envelope: SignedAuthorCatalogHeadEnvelopeV1, + currentDigest: Digest32V1, + query: Rfc64PublicCatalogCurrentHeadQueryV1, +): void { + const payload = envelope.payload; + if ( + envelope.objectDigest !== currentDigest + || payload.networkId !== query.networkId + || payload.contextGraphId !== query.contextGraphId + || payload.subGraphName !== query.subGraphName + || payload.authorAddress !== query.authorAddress + || payload.era !== query.catalogEra + ) { + fail( + 'catalog-discovery-object-mismatch', + 'current catalog-head object does not match the exact requested scope and digest', + ); + } +} + +function announcementFromHead( + envelope: SignedAuthorCatalogHeadEnvelopeV1, + policyDigest: Digest32V1, +): Rfc64PublicCatalogHeadAnnouncementV1 { + return Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, + networkId: envelope.payload.networkId, + contextGraphId: envelope.payload.contextGraphId, + subGraphName: envelope.payload.subGraphName, + authorAddress: envelope.payload.authorAddress, + catalogEra: envelope.payload.era, + catalogVersion: envelope.payload.version, + policyDigest, + catalogHeadObjectDigest: envelope.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ) as Digest32V1, + }); +} + +function assertExactIssuerSignatureProof( + envelope: SignedAuthorCatalogHeadEnvelopeV1, + proof: VerifiedControlEnvelopeIssuerSignatureV1, +): void { + let snapshot; + try { + snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + } catch (cause) { + fail('catalog-discovery-signature', 'issuer signature proof was not minted by the verifier', cause); + } + const expectedVariant = computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ); + if ( + snapshot.objectDigest !== envelope.objectDigest + || snapshot.signatureVariantDigest !== expectedVariant + || snapshot.issuer !== envelope.issuer + || snapshot.signatureSuite !== envelope.signatureSuite + ) { + fail('catalog-discovery-signature', 'issuer signature proof is not bound to the exact head envelope'); + } +} + +function encodeFlatCanonicalJson(value: object, maxBytes: number): Uint8Array { + if (!isPlainRecord(value)) { + fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain object'); + } + const fields: string[] = []; + for (const key of Object.keys(value).sort()) { + const field = value[key]; + if (field !== null && typeof field !== 'string') { + fail('catalog-discovery-wire', 'RFC-64 current-head query accepts only string or null fields'); + } + fields.push(`${JSON.stringify(key)}:${JSON.stringify(field)}`); + } + const bytes = UTF8.encode(`{${fields.join(',')}}`); + if (bytes.byteLength > maxBytes) { + fail('catalog-discovery-wire', `RFC-64 current-head query exceeds ${maxBytes} bytes`); + } + return bytes; +} + +function parseFlatCanonicalJson( + input: Uint8Array, + expectedKeys: readonly string[], + maxBytes: number, +): Record { + if (!(input instanceof Uint8Array) || input.byteLength < 2 || input.byteLength > maxBytes) { + fail('catalog-discovery-wire', 'RFC-64 current-head query is empty or oversized'); + } + let parsed: unknown; + try { + parsed = JSON.parse(UTF8_FATAL.decode(input)); + } catch (cause) { + fail('catalog-discovery-wire', 'RFC-64 current-head query is not strict UTF-8 JSON', cause); + } + if (!isPlainRecord(parsed)) { + fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain JSON object'); + } + assertExactWireKeys(parsed, expectedKeys); + if (!bytesEqual(encodeFlatCanonicalJson(parsed, maxBytes), input)) { + fail('catalog-discovery-wire', 'RFC-64 current-head query bytes are not canonical JCS'); + } + return parsed; +} + +function assertExactWireKeys( + value: Record, + expectedKeys: readonly string[], +): void { + const actual = Object.keys(value).sort(); + if ( + actual.length !== expectedKeys.length + || actual.some((key, index) => key !== expectedKeys[index]) + ) { + fail('catalog-discovery-wire', 'RFC-64 current-head query has missing or unknown fields'); + } +} + +function assertCanonicalEvmAddressV1(value: unknown, label: string): asserts value is EvmAddressV1 { + if ( + typeof value !== 'string' + || !/^0x[0-9a-f]{40}$/.test(value) + || value === '0x0000000000000000000000000000000000000000' + ) { + fail('catalog-discovery-wire', `${label} must be a canonical lowercase nonzero EVM address`); + } +} + +function snapshotPeerId(value: unknown): string { + if (typeof value !== 'string') { + fail('catalog-discovery-input', 'remotePeerId must be a string'); + } + const byteLength = UTF8.encode(value).byteLength; + if (byteLength < 1 || byteLength > MAX_PEER_ID_BYTES || value.trim() !== value) { + fail('catalog-discovery-input', 'remotePeerId is empty, oversized, or noncanonical'); + } + return value; +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function fail( + code: Rfc64PublicCatalogCurrentHeadDiscoveryErrorCodeV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1( + code, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 6121002e50..1c8ea90862 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -30,6 +30,7 @@ import { type SignedControlEnvelopeV1, type AuthorCatalogScopeV1, type ContextGraphIdV1, + type CountV1, type Digest32V1, type NetworkIdV1, type TimestampMsV1, @@ -66,6 +67,14 @@ import { type Rfc64PublicCatalogReceiverOptionsV1, type Rfc64PublicCatalogReceiverStatsV1, } from './public-catalog-receiver-v1.js'; +import { + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1, + type Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, + type Rfc64PublicCatalogCurrentHeadAuthorizationV1, + type Rfc64PublicCatalogCurrentHeadQueryV1, + type Rfc64PublicCatalogCurrentHeadScopeV1, +} from './public-catalog-current-head-discovery-v1.js'; import { Rfc64PublicCatalogNativeTransportV1, type Rfc64PublicCatalogNativeAuthorizationInputV1, @@ -86,6 +95,7 @@ import { Rfc64PublicCatalogTransportV1, encodeRfc64PublicCatalogHeadAnnouncementV1, parseRfc64PublicCatalogHeadAnnouncementV1, + type FetchedRfc64PublicCatalogHeadV1, type Rfc64PublicCatalogHeadAnnouncementV1, } from './public-catalog-transport-v1.js'; @@ -104,6 +114,8 @@ export interface Rfc64PublicCatalogServiceOptionsV1 { readonly receiver?: Rfc64PublicCatalogReceiverOptionsV1; /** Full production native content/reconciliation path. Omission is diagnostic-only. */ readonly native?: Rfc64PublicCatalogServiceNativeOptionsV1; + /** Optional Gate-3 pull-discovery capability; no automatic lifecycle trigger. */ + readonly currentHeadDiscovery?: Rfc64PublicCatalogServiceCurrentHeadDiscoveryOptionsV1; /** Per-peer announce/fetch timeout (ms). */ readonly transportTimeoutMs?: number; /** @@ -146,6 +158,16 @@ export interface Rfc64PublicCatalogServiceNativeOptionsV1 extends Pick< ) => Rfc64PublicCatalogReceiverReconcilerV1; } +export interface Rfc64PublicCatalogServiceCurrentHeadDiscoveryOptionsV1 { + /** + * Resolve the durable semantically applied head for one locally trusted + * public/open root scope. Staged-only and candidate heads must not be returned. + */ + readonly readCurrentAppliedCatalogHeadDigest: ( + trustedScope: Readonly, + ) => Promise; +} + export interface PublishAuthorCatalogGenesisInputV1 { readonly scope: AuthorCatalogScopeV1; readonly signer: Rfc64AuthorCatalogEip191SignerV1; @@ -194,6 +216,18 @@ export interface AnnounceRfc64PublicCatalogHeadResultV1 { readonly failedPeers: ReadonlyArray<{ readonly peerId: string; readonly error: string }>; } +export interface DiscoverRfc64PublicCatalogCurrentHeadInputV1 { + readonly remotePeerId: string; + readonly scope: Rfc64PublicCatalogCurrentHeadScopeV1; + readonly signal?: AbortSignal; +} + +/** Verified discovery result. Returning it never stages or activates the head. */ +export interface DiscoveredRfc64PublicCatalogCurrentHeadV1 { + readonly announcement: Rfc64PublicCatalogHeadAnnouncementV1; + readonly head: FetchedRfc64PublicCatalogHeadV1; +} + export interface Rfc64PublicCatalogServiceStatsV1 { readonly started: boolean; readonly acceptedPolicies: number; @@ -208,6 +242,8 @@ export class Rfc64PublicCatalogServiceV1 { readonly #policies: Rfc64CatalogAccessPolicyRegistryV1; readonly #receiver: Rfc64PublicCatalogReceiverV1; readonly #transport: Rfc64PublicCatalogTransportV1; + readonly #currentHeadDiscoveryTransport: + Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 | undefined; readonly #nativeTransport: Rfc64PublicCatalogNativeTransportV1 | undefined; readonly #transportTimeoutMs: number; #started = false; @@ -231,6 +267,19 @@ export class Rfc64PublicCatalogServiceV1 { }, }); + this.#currentHeadDiscoveryTransport = options.currentHeadDiscovery === undefined + ? undefined + : new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(options.router, { + controlObjects: this.#controlObjects, + readCurrentAppliedCatalogHeadDigest: (query) => + options.currentHeadDiscovery!.readCurrentAppliedCatalogHeadDigest( + this.#resolveTrustedCurrentHeadScope(query), + ), + authorizeOpenCatalogOperation: (input) => + this.#authorizeCurrentHeadDiscovery(input), + verifyIssuerSignature: this.#verifyIssuerSignature, + }); + this.#nativeTransport = options.native === undefined ? undefined : new Rfc64PublicCatalogNativeTransportV1(options.router, { @@ -332,11 +381,13 @@ export class Rfc64PublicCatalogServiceV1 { if (this.#started) return; this.#nativeTransport?.start(); try { + this.#currentHeadDiscoveryTransport?.start(); // Register the announcement protocol last so no callback can schedule - // reconciliation before the content-fetch protocols are live. + // reconciliation before content-fetch and pull-discovery are live. this.#transport.start(); this.#started = true; } catch (cause) { + this.#currentHeadDiscoveryTransport?.stop(); this.#nativeTransport?.stop(); throw cause; } @@ -353,6 +404,7 @@ export class Rfc64PublicCatalogServiceV1 { await this.#receiver.close(); } finally { this.#transport.stop(); + this.#currentHeadDiscoveryTransport?.stop(); this.#nativeTransport?.stop(); } } @@ -492,6 +544,58 @@ export class Rfc64PublicCatalogServiceV1 { return this.#announceCatalogHeadSnapshot(announcement, peers); } + /** + * Pull and authenticate one provider's semantically current public/open head. + * The discovery response is treated as a hint: this method exact-fetches the + * named signed head, re-verifies it, and binds it to local accepted policy + * before returning. It intentionally does not stage, schedule, or activate. + */ + async discoverCurrentCatalogHead( + input: DiscoverRfc64PublicCatalogCurrentHeadInputV1, + ): Promise { + this.#requireStarted(); + const discovery = this.#currentHeadDiscoveryTransport; + if (discovery === undefined) { + throw new Error('RFC-64 current-head discovery is not configured'); + } + const trustedScope = this.#resolveTrustedCurrentHeadScope(input.scope); + const held = this.#policies.lookup(trustedScope.networkId, trustedScope.contextGraphId)!; + const query: Rfc64PublicCatalogCurrentHeadQueryV1 = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + networkId: trustedScope.networkId, + contextGraphId: trustedScope.contextGraphId, + subGraphName: trustedScope.subGraphName, + authorAddress: trustedScope.authorAddress, + catalogEra: trustedScope.era, + policyDigest: held.policyDigest, + }); + const announcement = await discovery.discoverCurrentCatalogHead( + input.remotePeerId, + query, + this.#sendOptions(input.signal), + ); + if (announcement === null) return null; + this.#assertAcceptedOpenAnnouncement(announcement); + const currentTrustedScope = this.#resolveTrustedCatalogScope(announcement); + const head = await this.#transport.fetchCatalogHead( + input.remotePeerId, + announcement, + this.#sendOptions(input.signal), + ); + if (head === null) { + throw new Error('RFC-64 discovered current head is no longer available by exact digest'); + } + try { + assertAuthorCatalogHeadScopeBindingV1(head.envelope.payload, currentTrustedScope); + } catch (cause) { + throw new Error( + 'RFC-64 discovered head differs from the accepted public/open policy scope', + { cause }, + ); + } + return Object.freeze({ announcement, head }); + } + /** Idle-await the receiver (tests / graceful shutdown coordination). */ whenReceiverIdle(): Promise { return this.#receiver.whenIdle(); @@ -555,6 +659,23 @@ export class Rfc64PublicCatalogServiceV1 { } return held; } + + async #authorizeCurrentHeadDiscovery( + input: Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, + ): Promise { + try { + this.#resolveTrustedCurrentHeadScope(input); + } catch { + return null; + } + const record = this.#policies.lookup(input.networkId, input.contextGraphId); + if (record === null || record.policy.accessPolicy !== 0) return null; + return Object.freeze({ + accessPolicy: 0, + policyDigest: record.policyDigest, + }); + } + async #authorizeNativeOperation( input: Rfc64PublicCatalogNativeAuthorizationInputV1, ): Promise { @@ -582,6 +703,44 @@ export class Rfc64PublicCatalogServiceV1 { }) as Readonly; } + #resolveTrustedCurrentHeadScope( + input: Rfc64PublicCatalogCurrentHeadScopeV1, + ): Readonly { + const record = this.#policies.lookup(input.networkId, input.contextGraphId); + const policy = record?.policy; + if ( + record === null + || policy === undefined + || policy.accessPolicy !== 0 + || policy.source.kind !== 'owner-signed-unregistered' + || policy.networkId !== input.networkId + || policy.contextGraphId !== input.contextGraphId + || policy.governanceChainId !== null + || policy.governanceContractAddress !== null + || policy.ownershipTransitionDigest !== null + || policy.era !== input.catalogEra + || input.subGraphName !== null + || policy.source.ownerAddress !== input.authorAddress + ) { + throw new Error( + 'RFC-64 current-head query is not bound to the accepted public/open root policy', + ); + } + const scope = Object.freeze({ + networkId: policy.networkId, + contextGraphId: policy.contextGraphId, + governanceChainId: policy.governanceChainId, + governanceContractAddress: policy.governanceContractAddress, + ownershipTransitionDigest: policy.ownershipTransitionDigest, + subGraphName: null, + authorAddress: policy.source.ownerAddress, + era: policy.era, + bucketCount: '1' as CountV1, + }); + assertAuthorCatalogScopeV1(scope); + return scope; + } + async #stageHeadOnly( remotePeerId: string, announcement: Rfc64PublicCatalogHeadAnnouncementV1, diff --git a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts new file mode 100644 index 0000000000..3147734877 --- /dev/null +++ b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts @@ -0,0 +1,410 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { multiaddr } from '@multiformats/multiaddr'; +import { + DKGNode, + ProtocolRouter, + computeControlSignatureVariantDigestHex, + type AuthorCatalogScopeV1, + type Digest32V1, + type EvmAddressV1, + type TimestampMsV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { ethers } from 'ethers'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1, + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + encodeRfc64PublicCatalogCurrentHeadQueryV1, + parseRfc64PublicCatalogCurrentHeadQueryV1, + type Rfc64PublicCatalogCurrentHeadQueryV1, +} from '../src/rfc64/public-catalog-current-head-discovery-v1.js'; +import { + Rfc64PublicCatalogServiceV1, +} from '../src/rfc64/public-catalog-service-v1.js'; +import { + openRfc64PersistenceV1, + type Rfc64PersistenceV1, +} from '../src/rfc64/persistence-v1.js'; + +const NETWORK_ID = 'otp:20430' as const; +const CONTEXT_GRAPH_ID = + '0x1111111111111111111111111111111111111111/gate-3-discovery' as const; +const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); +const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; +const DELEGATION_DIGEST = `0x${'72'.repeat(32)}` as Digest32V1; + +const temporaryDirectories: string[] = []; +const nodes: DKGNode[] = []; +const persistences: Rfc64PersistenceV1[] = []; +const services: Rfc64PublicCatalogServiceV1[] = []; + +afterEach(async () => { + for (const service of services.splice(0)) { + try { await service.close(); } catch {} + } + for (const persistence of persistences.splice(0)) { + try { await persistence.close(); } catch {} + } + for (const node of nodes.splice(0)) { + try { await node.stop(); } catch {} + } + await Promise.all(temporaryDirectories.splice(0).map(async (path) => { + await rm(path, { recursive: true, force: true }); + })); +}); + +async function startNode(): Promise { + const node = new DKGNode({ + listenAddresses: ['/ip4/127.0.0.1/tcp/0'], + enableMdns: false, + }); + nodes.push(node); + await node.start(); + return node; +} + +async function connect(from: DKGNode, to: DKGNode): Promise { + const address = to.multiaddrs.find((candidate) => candidate.includes('/tcp/')); + if (address === undefined) throw new Error('test node has no TCP multiaddr'); + await from.libp2p.dial(multiaddr(address)); +} + +async function openPersistence(label: string): Promise { + const path = await mkdtemp(join(tmpdir(), `dkg-rfc64-gate3-${label}-`)); + temporaryDirectories.push(path); + const persistence = await openRfc64PersistenceV1(path, { + yieldAfterPurgeBatch: async () => {}, + }); + persistences.push(persistence); + return persistence; +} + +function catalogScope( + contextGraphId = CONTEXT_GRAPH_ID, +): AuthorCatalogScopeV1 { + return Object.freeze({ + networkId: NETWORK_ID, + contextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + }) as AuthorCatalogScopeV1; +} + +async function stageHead( + persistence: Rfc64PersistenceV1, + scope = catalogScope(), + issuedAt: TimestampMsV1 = '1773900000000' as TimestampMsV1, +) { + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt, + signer: { + issuer: AUTHOR, + signDigest: (digest) => AUTHOR_WALLET.signMessage(digest), + }, + }); + const verified = await Promise.all(produced.stagedObjects.map(async (envelope) => ({ + envelope, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(envelope), + }))); + await persistence.controlObjects.stageVerifiedObjects(verified); + return produced.head; +} + +function acceptPolicy( + service: Rfc64PublicCatalogServiceV1, + issuedAt?: TimestampMsV1, +) { + return service.acceptOpenPolicy({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + issuedAt, + }); +} + +function discoveryScope() { + return Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + }) as const; +} + +describe('RFC-64 public catalog current-head discovery v1', () => { + it('discovers and exact-verifies one applied head without staging or scheduling it', async () => { + const [providerNode, requesterNode, providerPersistence, requesterPersistence] = + await Promise.all([ + startNode(), + startNode(), + openPersistence('provider'), + openPersistence('requester'), + ]); + await connect(requesterNode, providerNode); + const head = await stageHead(providerPersistence); + const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => + head.objectDigest as Digest32V1); + + const provider = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(providerNode), + controlObjects: providerPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest }, + transportTimeoutMs: 4_000, + }); + const requester = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(requesterNode), + controlObjects: requesterPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, + transportTimeoutMs: 4_000, + }); + services.push(provider, requester); + const providerPolicy = acceptPolicy(provider); + const requesterPolicy = acceptPolicy(requester); + expect(providerPolicy.policyDigest).toBe(requesterPolicy.policyDigest); + provider.start(); + requester.start(); + + expect(RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1) + .toBe('/dkg/catalog/1/author-head/current'); + const result = await requester.discoverCurrentCatalogHead({ + remotePeerId: providerNode.peerId, + scope: discoveryScope(), + }); + + expect(result?.announcement).toEqual({ + kind: 'rfc64-author-catalog-head-availability-v1', + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + catalogVersion: '0', + policyDigest: requesterPolicy.policyDigest, + catalogHeadObjectDigest: head.objectDigest, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + head.objectDigest, + head.signature, + ), + }); + expect(result?.head.envelope).toEqual(head); + expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalledTimes(2); + for (const [scope] of readCurrentAppliedCatalogHeadDigest.mock.calls) { + expect(scope).toEqual(catalogScope()); + } + expect(Object.isFrozen(result)).toBe(true); + expect(requester.stats().receiver).toMatchObject({ + scheduled: 0, + applied: 0, + stagedOnly: 0, + }); + await expect(requesterPersistence.controlObjects.getVerifiedObject({ + objectDigest: head.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + head.objectDigest, + head.signature, + ) as Digest32V1, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).resolves.toBeNull(); + }, 30_000); + + it('retries once when the applied ref advances while the old immutable head is read', async () => { + const [providerNode, requesterNode, providerPersistence, requesterPersistence] = + await Promise.all([ + startNode(), + startNode(), + openPersistence('advancing-provider'), + openPersistence('advancing-requester'), + ]); + await connect(requesterNode, providerNode); + const oldHead = await stageHead( + providerPersistence, + catalogScope(), + '1773900000000' as TimestampMsV1, + ); + const currentHead = await stageHead( + providerPersistence, + catalogScope(), + '1773900000001' as TimestampMsV1, + ); + expect(oldHead.objectDigest).not.toBe(currentHead.objectDigest); + const readCurrentAppliedCatalogHeadDigest = vi.fn() + .mockResolvedValueOnce(oldHead.objectDigest as Digest32V1) + .mockResolvedValue(currentHead.objectDigest as Digest32V1); + + const provider = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(providerNode), + controlObjects: providerPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest }, + transportTimeoutMs: 4_000, + }); + const requester = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(requesterNode), + controlObjects: requesterPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, + transportTimeoutMs: 4_000, + }); + services.push(provider, requester); + acceptPolicy(provider); + acceptPolicy(requester); + provider.start(); + requester.start(); + + const result = await requester.discoverCurrentCatalogHead({ + remotePeerId: providerNode.peerId, + scope: discoveryScope(), + }); + + expect(result?.announcement.catalogHeadObjectDigest).toBe(currentHead.objectDigest); + expect(result?.head.envelope).toEqual(currentHead); + expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalledTimes(4); + expect(requester.stats().receiver.scheduled).toBe(0); + }, 30_000); + + it('returns not-found only after a stable applied-ref snapshot', async () => { + const [providerNode, requesterNode, providerPersistence, requesterPersistence] = + await Promise.all([ + startNode(), + startNode(), + openPersistence('empty-provider'), + openPersistence('empty-requester'), + ]); + await connect(requesterNode, providerNode); + const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); + const provider = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(providerNode), + controlObjects: providerPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest }, + transportTimeoutMs: 4_000, + }); + const requester = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(requesterNode), + controlObjects: requesterPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, + transportTimeoutMs: 4_000, + }); + services.push(provider, requester); + acceptPolicy(provider); + acceptPolicy(requester); + provider.start(); + requester.start(); + + await expect(requester.discoverCurrentCatalogHead({ + remotePeerId: providerNode.peerId, + scope: discoveryScope(), + })).resolves.toBeNull(); + expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalledTimes(2); + expect(requester.stats().receiver.scheduled).toBe(0); + }, 20_000); + + it('denies a mismatched policy generation before consulting provider head state', async () => { + const [providerNode, requesterNode, providerPersistence, requesterPersistence] = + await Promise.all([ + startNode(), + startNode(), + openPersistence('policy-provider'), + openPersistence('policy-requester'), + ]); + await connect(requesterNode, providerNode); + const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); + const provider = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(providerNode), + controlObjects: providerPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest }, + transportTimeoutMs: 4_000, + }); + const requester = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(requesterNode), + controlObjects: requesterPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, + transportTimeoutMs: 4_000, + }); + services.push(provider, requester); + const providerPolicy = acceptPolicy(provider, '1' as TimestampMsV1); + const requesterPolicy = acceptPolicy(requester, '0' as TimestampMsV1); + expect(providerPolicy.policyDigest).not.toBe(requesterPolicy.policyDigest); + provider.start(); + requester.start(); + + await expect(requester.discoverCurrentCatalogHead({ + remotePeerId: providerNode.peerId, + scope: discoveryScope(), + })).rejects.toMatchObject({ code: 'catalog-discovery-policy-denied' }); + expect(readCurrentAppliedCatalogHeadDigest).not.toHaveBeenCalled(); + expect(requester.stats().receiver.scheduled).toBe(0); + }, 20_000); + + it('fails closed when an applied-head pointer has no verified control object', async () => { + const [providerNode, requesterNode, providerPersistence, requesterPersistence] = + await Promise.all([ + startNode(), + startNode(), + openPersistence('dangling-provider'), + openPersistence('dangling-requester'), + ]); + await connect(requesterNode, providerNode); + const danglingDigest = `0x${'dd'.repeat(32)}` as Digest32V1; + const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => danglingDigest); + const provider = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(providerNode), + controlObjects: providerPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest }, + transportTimeoutMs: 4_000, + }); + const requester = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(requesterNode), + controlObjects: requesterPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, + transportTimeoutMs: 4_000, + }); + services.push(provider, requester); + acceptPolicy(provider); + acceptPolicy(requester); + provider.start(); + requester.start(); + + await expect(requester.discoverCurrentCatalogHead({ + remotePeerId: providerNode.peerId, + scope: discoveryScope(), + })).rejects.toThrow(); + expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalled(); + for (const [scope] of readCurrentAppliedCatalogHeadDigest.mock.calls) { + expect(scope).toEqual(catalogScope()); + } + expect(requester.stats().receiver.scheduled).toBe(0); + }, 20_000); + + it('round-trips only exact canonical query fields', () => { + const query = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + ...discoveryScope(), + policyDigest: `0x${'71'.repeat(32)}` as Digest32V1, + }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; + const encoded = encodeRfc64PublicCatalogCurrentHeadQueryV1(query); + expect(parseRfc64PublicCatalogCurrentHeadQueryV1(encoded)).toEqual(query); + + const parsed = JSON.parse(new TextDecoder().decode(encoded)); + const noncanonical = new TextEncoder().encode(JSON.stringify( + Object.fromEntries(Object.entries(parsed).reverse()), + )); + expect(() => parseRfc64PublicCatalogCurrentHeadQueryV1(noncanonical)) + .toThrow(/canonical JCS/); + + const withUnknown = new TextEncoder().encode(JSON.stringify({ ...parsed, surprise: 'x' })); + expect(() => parseRfc64PublicCatalogCurrentHeadQueryV1(withUnknown)) + .toThrow(/missing or unknown fields/); + }); +}); diff --git a/packages/agent/vitest.rfc64-unit-tests.ts b/packages/agent/vitest.rfc64-unit-tests.ts index a355701446..491b0eb465 100644 --- a/packages/agent/vitest.rfc64-unit-tests.ts +++ b/packages/agent/vitest.rfc64-unit-tests.ts @@ -18,6 +18,7 @@ export const RFC64_UNIT_TESTS = [ "test/rfc64-durable-file-store-v1.test.ts", "test/rfc64-secure-filesystem-policy-v1.test.ts", "test/rfc64-public-catalog-transport-v1.test.ts", + "test/rfc64-public-catalog-current-head-discovery-v1.test.ts", "test/rfc64-public-catalog-receiver-v1.test.ts", "test/rfc64-public-catalog-reconciliation-failure-v1.test.ts", "test/rfc64-public-catalog-service-v1.test.ts", From d57a2596b1d42479daa699473634d29532911d94 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Sun, 19 Jul 2026 21:43:26 +0200 Subject: [PATCH 273/292] fix(agent): harden RFC-64 head discovery boundaries --- ...ublic-catalog-current-head-discovery-v1.ts | 93 +++++++++++++++--- .../src/rfc64/public-catalog-service-v1.ts | 14 ++- ...-catalog-current-head-discovery-v1.test.ts | 98 ++++++++++++++++++- 3 files changed, 183 insertions(+), 22 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts index 7ff48b9bf3..ad51afd319 100644 --- a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts @@ -196,7 +196,8 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { try { this.router.register( RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1, - async (data, peerId) => this.handleQuery(data, peerId.toString()), + async (data, peerId, handlerOptions) => + this.handleQuery(data, peerId.toString(), handlerOptions?.signal), { maxReadBytes: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_MAX_BYTES_V1 }, ); } catch (cause) { @@ -237,7 +238,9 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { private async handleQuery( data: Uint8Array, remotePeerIdInput: string, + signal?: AbortSignal, ): Promise { + throwIfAborted(signal); this.requireStarted(); const remotePeerId = snapshotPeerId(remotePeerIdInput); const query = parseQuery(data); @@ -245,22 +248,27 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { if (!await this.isOpenPolicy('current-head-discovery-inbound', remotePeerId, query)) { return Uint8Array.of(CURRENT_HEAD_DENIED); } + throwIfAborted(signal); const currentDigest = await this.readCurrentAppliedCatalogHeadDigest(query); + throwIfAborted(signal); const announcement = currentDigest === null ? null : await this.readCurrentAnnouncement(currentDigest, query); + throwIfAborted(signal); // A semantic ref can advance while its immutable object is read. Confirm // the pointer immediately before responding so discovery never knowingly // advertises a superseded or transient snapshot. One retry admits the // common single-writer CAS race while keeping work per request bounded. const confirmedDigest = await this.readCurrentAppliedCatalogHeadDigest(query); + throwIfAborted(signal); if (confirmedDigest !== currentDigest) continue; if (!await this.isOpenPolicy('current-head-discovery-inbound', remotePeerId, query)) { return Uint8Array.of(CURRENT_HEAD_DENIED); } + throwIfAborted(signal); return announcement === null ? Uint8Array.of(CURRENT_HEAD_NOT_FOUND) : foundResponse(announcement); @@ -414,28 +422,32 @@ function validateQuery(value: unknown): Rfc64PublicCatalogCurrentHeadQueryV1 { if (!isPlainRecord(value)) { fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain object'); } - assertExactWireKeys(value, QUERY_KEYS); - if (value.kind !== RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1) { + // Consume caller-owned JavaScript values exactly once through own data + // descriptors. Reading fields once for validation and again for assembly + // would let a switching Proxy emit bytes that were never validated. It also + // avoids invoking accessors that can re-enter policy or lifecycle code. + const snapshot = snapshotExactWireRecord(value, QUERY_KEYS); + if (snapshot.kind !== RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1) { fail( 'catalog-discovery-wire', `RFC-64 current-head query kind must be ${RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1}`, ); } try { - assertNetworkIdV1(value.networkId); - assertContextGraphIdV1(value.contextGraphId); - if (value.subGraphName !== null) assertSubGraphNameV1(value.subGraphName); - assertCanonicalEvmAddressV1(value.authorAddress, 'authorAddress'); - assertCanonicalDecimalU64(value.catalogEra, 'catalogEra'); - assertCanonicalDigest(value.policyDigest, 'policyDigest'); + assertNetworkIdV1(snapshot.networkId); + assertContextGraphIdV1(snapshot.contextGraphId); + if (snapshot.subGraphName !== null) assertSubGraphNameV1(snapshot.subGraphName); + assertCanonicalEvmAddressV1(snapshot.authorAddress, 'authorAddress'); + assertCanonicalDecimalU64(snapshot.catalogEra, 'catalogEra'); + assertCanonicalDigest(snapshot.policyDigest, 'policyDigest'); return Object.freeze({ kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, - networkId: value.networkId, - contextGraphId: value.contextGraphId, - subGraphName: value.subGraphName, - authorAddress: value.authorAddress, - catalogEra: value.catalogEra, - policyDigest: value.policyDigest, + networkId: snapshot.networkId, + contextGraphId: snapshot.contextGraphId, + subGraphName: snapshot.subGraphName, + authorAddress: snapshot.authorAddress, + catalogEra: snapshot.catalogEra, + policyDigest: snapshot.policyDigest, }); } catch (cause) { if (cause instanceof Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1) throw cause; @@ -616,13 +628,54 @@ function assertExactWireKeys( value: Record, expectedKeys: readonly string[], ): void { - const actual = Object.keys(value).sort(); + void snapshotExactWireRecord(value, expectedKeys); +} + +function snapshotExactWireRecord( + value: Record, + expectedKeys: readonly string[], +): Readonly> { + let ownKeys: readonly PropertyKey[]; + try { + ownKeys = Reflect.ownKeys(value); + } catch (cause) { + fail('catalog-discovery-wire', 'RFC-64 current-head query fields could not be inspected', cause); + } + if (ownKeys.some((key) => typeof key !== 'string')) { + fail('catalog-discovery-wire', 'RFC-64 current-head query has symbol fields'); + } + const actual = [...ownKeys as readonly string[]].sort(); if ( actual.length !== expectedKeys.length || actual.some((key, index) => key !== expectedKeys[index]) ) { fail('catalog-discovery-wire', 'RFC-64 current-head query has missing or unknown fields'); } + const snapshot: Record = Object.create(null); + for (const key of expectedKeys) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch (cause) { + fail( + 'catalog-discovery-wire', + 'RFC-64 current-head query field descriptor could not be inspected', + cause, + ); + } + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + fail( + 'catalog-discovery-wire', + 'RFC-64 current-head query fields must be enumerable data properties', + ); + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot); } function assertCanonicalEvmAddressV1(value: unknown, label: string): asserts value is EvmAddressV1 { @@ -660,6 +713,14 @@ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { return true; } +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + throw new Error('RFC-64 current-head discovery request was aborted', { + cause: signal.reason, + }); +} + function fail( code: Rfc64PublicCatalogCurrentHeadDiscoveryErrorCodeV1, message: string, diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 1c8ea90862..3293d63e64 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -558,6 +558,12 @@ export class Rfc64PublicCatalogServiceV1 { if (discovery === undefined) { throw new Error('RFC-64 current-head discovery is not configured'); } + // Detach caller-owned fields before the first await. In particular, the + // same immutable peer-id primitive must drive both the hint query and its + // exact-head fetch; a mutable object or switching accessor must not rebind + // those two halves to different providers. + const remotePeerId = input.remotePeerId; + const signal = input.signal; const trustedScope = this.#resolveTrustedCurrentHeadScope(input.scope); const held = this.#policies.lookup(trustedScope.networkId, trustedScope.contextGraphId)!; const query: Rfc64PublicCatalogCurrentHeadQueryV1 = Object.freeze({ @@ -570,17 +576,17 @@ export class Rfc64PublicCatalogServiceV1 { policyDigest: held.policyDigest, }); const announcement = await discovery.discoverCurrentCatalogHead( - input.remotePeerId, + remotePeerId, query, - this.#sendOptions(input.signal), + this.#sendOptions(signal), ); if (announcement === null) return null; this.#assertAcceptedOpenAnnouncement(announcement); const currentTrustedScope = this.#resolveTrustedCatalogScope(announcement); const head = await this.#transport.fetchCatalogHead( - input.remotePeerId, + remotePeerId, announcement, - this.#sendOptions(input.signal), + this.#sendOptions(signal), ); if (head === null) { throw new Error('RFC-64 discovered current head is no longer available by exact digest'); diff --git a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts index 3147734877..da99f50bfa 100644 --- a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts @@ -20,6 +20,7 @@ import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog- import { RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1, RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1, encodeRfc64PublicCatalogCurrentHeadQueryV1, parseRfc64PublicCatalogCurrentHeadQueryV1, type Rfc64PublicCatalogCurrentHeadQueryV1, @@ -180,10 +181,21 @@ describe('RFC-64 public catalog current-head discovery v1', () => { expect(RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1) .toBe('/dkg/catalog/1/author-head/current'); - const result = await requester.discoverCurrentCatalogHead({ - remotePeerId: providerNode.peerId, + let remotePeerIdReads = 0; + const discoveryInput = { scope: discoveryScope(), + } as { + remotePeerId: string; + scope: ReturnType; + }; + Object.defineProperty(discoveryInput, 'remotePeerId', { + enumerable: true, + get() { + remotePeerIdReads += 1; + return remotePeerIdReads === 1 ? providerNode.peerId : requesterNode.peerId; + }, }); + const result = await requester.discoverCurrentCatalogHead(discoveryInput); expect(result?.announcement).toEqual({ kind: 'rfc64-author-catalog-head-availability-v1', @@ -202,6 +214,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { }); expect(result?.head.envelope).toEqual(head); expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalledTimes(2); + expect(remotePeerIdReads).toBe(1); for (const [scope] of readCurrentAppliedCatalogHeadDigest.mock.calls) { expect(scope).toEqual(catalogScope()); } @@ -407,4 +420,85 @@ describe('RFC-64 public catalog current-head discovery v1', () => { expect(() => parseRfc64PublicCatalogCurrentHeadQueryV1(withUnknown)) .toThrow(/missing or unknown fields/); }); + + it('snapshots switching Proxies and rejects accessors without invoking them', () => { + const query = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + ...discoveryScope(), + policyDigest: `0x${'71'.repeat(32)}` as Digest32V1, + }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; + const expected = encodeRfc64PublicCatalogCurrentHeadQueryV1(query); + let switchedReads = 0; + const switching = new Proxy({ ...query }, { + get(target, property, receiver) { + if (property === 'networkId') { + switchedReads += 1; + return switchedReads === 1 ? NETWORK_ID : ''; + } + return Reflect.get(target, property, receiver); + }, + }); + + expect(encodeRfc64PublicCatalogCurrentHeadQueryV1(switching)).toEqual(expected); + expect(switchedReads).toBe(0); + + let accessorCalls = 0; + const accessorQuery = { ...query } as Record; + Object.defineProperty(accessorQuery, 'networkId', { + enumerable: true, + get() { + accessorCalls += 1; + return NETWORK_ID; + }, + }); + expect(() => encodeRfc64PublicCatalogCurrentHeadQueryV1( + accessorQuery as unknown as Rfc64PublicCatalogCurrentHeadQueryV1, + )).toThrow(/enumerable data properties/); + expect(accessorCalls).toBe(0); + }); + + it('honors the router stream abort before authorization or applied-head work', async () => { + let handler: (( + data: Uint8Array, + peerId: { toString(): string }, + options: { signal?: AbortSignal }, + ) => Promise) | undefined; + const router = { + register(_protocol: string, registered: typeof handler) { + handler = registered; + }, + unregister() {}, + } as unknown as ProtocolRouter; + const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); + const authorizeOpenCatalogOperation = vi.fn(async () => ({ + accessPolicy: 0 as const, + policyDigest: `0x${'71'.repeat(32)}` as Digest32V1, + })); + const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { + controlObjects: { + getVerifiedObjectByDigest: vi.fn(async () => null), + }, + readCurrentAppliedCatalogHeadDigest, + authorizeOpenCatalogOperation, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + transport.start(); + const query = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + ...discoveryScope(), + policyDigest: `0x${'71'.repeat(32)}` as Digest32V1, + }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; + const controller = new AbortController(); + const reason = new Error('test stream closed'); + controller.abort(reason); + + await expect(handler!( + encodeRfc64PublicCatalogCurrentHeadQueryV1(query), + { toString: () => 'test-peer' }, + { signal: controller.signal }, + )).rejects.toBe(reason); + expect(authorizeOpenCatalogOperation).not.toHaveBeenCalled(); + expect(readCurrentAppliedCatalogHeadDigest).not.toHaveBeenCalled(); + transport.stop(); + }); }); From 082756bc0da2c2357cf5e03e893a151e862e40f2 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 23:03:57 +0200 Subject: [PATCH 274/292] fix(agent): harden RFC-64 head discovery review boundaries --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + .../catalog-transport-wire-v1-internal.ts | 263 +++++++++++++++++ ...ublic-catalog-current-head-discovery-v1.ts | 253 ++++++++--------- .../public-catalog-native-reconciler-v1.ts | 23 +- .../public-catalog-native-transport-v1.ts | 224 ++++++++------- .../src/rfc64/public-catalog-service-v1.ts | 41 +-- .../src/rfc64/public-catalog-transport-v1.ts | 268 +++++++++--------- ...-catalog-current-head-discovery-v1.test.ts | 210 +++++++++++++- 9 files changed, 876 insertions(+), 408 deletions(-) create mode 100644 packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index fd29890bb5..56539e315f 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -25,6 +25,7 @@ "./dist/rfc64/catalog-access-policy-v1.js": null, "./dist/rfc64/catalog-authority-config-v1.js": null, "./dist/rfc64/catalog-transport-authorization-v1.js": null, + "./dist/rfc64/catalog-transport-wire-v1-internal.js": null, "./dist/rfc64/durable-file-store-v1.js": null, "./dist/rfc64/finalized-vm-agent-precommit-v1.js": null, "./dist/rfc64/finalized-vm-composer-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 83d0b72b9a..eea83e7363 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -98,6 +98,7 @@ const blockedRfc64Modules = [ 'catalog-access-policy-v1.js', 'catalog-authority-config-v1.js', 'catalog-transport-authorization-v1.js', + 'catalog-transport-wire-v1-internal.js', 'control-object-store-v1-internal.js', 'control-object-store-v1.js', 'durable-file-store-v1.js', diff --git a/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts b/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts new file mode 100644 index 0000000000..e7644c401f --- /dev/null +++ b/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shared, package-private RFC-64 catalog transport wire primitives. + * + * Protocol modules retain their own typed validators, byte ceilings, and + * public error classes. This module owns only byte-identical framing and the + * security-sensitive scalar/proof checks that must not drift between catalog + * protocols. + */ + +import { + computeControlSignatureVariantDigestHex, + type EvmAddressV1, + type SignedControlEnvelopeV1, +} from '@origintrail-official/dkg-core'; +import { + readVerifiedControlEnvelopeIssuerSignatureV1, + type VerifiedControlEnvelopeIssuerSignatureV1, +} from '@origintrail-official/dkg-chain'; + +const MAX_PEER_ID_BYTES_V1 = 256; +const UTF8 = new TextEncoder(); +// Keep a leading BOM visible so canonical re-encoding rejects it. +const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +export type Rfc64CatalogTransportWireUtilityErrorReasonV1 = + | 'plain-object' + | 'field-shape' + | 'oversized' + | 'strict-json' + | 'exact-keys' + | 'noncanonical' + | 'evm-address' + | 'peer-id-type' + | 'peer-id-canonical' + | 'issuer-proof-unminted' + | 'issuer-proof-mismatch' + | 'response-size' + | 'response-trailing' + | 'response-status'; + +export class Rfc64CatalogTransportWireUtilityErrorV1 extends Error { + constructor( + readonly reason: Rfc64CatalogTransportWireUtilityErrorReasonV1, + message: string, + options: ErrorOptions = {}, + ) { + super(message, options); + this.name = 'Rfc64CatalogTransportWireUtilityErrorV1'; + } +} + +export function encodeRfc64FlatCanonicalJsonV1( + value: object, + maxBytes: number, +): Uint8Array { + const snapshot = snapshotRfc64ExactWireRecordV1(value, undefined); + const fields: string[] = []; + for (const key of Object.keys(snapshot).sort()) { + const field = snapshot[key]; + if (field !== null && typeof field !== 'string') { + utilityFail('field-shape', 'RFC-64 flat canonical JSON accepts only string or null fields'); + } + fields.push(`${JSON.stringify(key)}:${JSON.stringify(field)}`); + } + const bytes = UTF8.encode(`{${fields.join(',')}}`); + if (bytes.byteLength > maxBytes) { + utilityFail('oversized', `RFC-64 flat canonical JSON exceeds ${maxBytes} bytes`); + } + return bytes; +} + +export function parseRfc64FlatCanonicalJsonV1( + input: Uint8Array, + expectedKeys: readonly string[], + maxBytes: number, +): Readonly> { + if (!(input instanceof Uint8Array) || input.byteLength < 2 || input.byteLength > maxBytes) { + utilityFail('oversized', 'RFC-64 flat canonical JSON is empty or oversized'); + } + let parsed: unknown; + try { + parsed = JSON.parse(UTF8_FATAL.decode(input)); + } catch (cause) { + utilityFail('strict-json', 'RFC-64 flat canonical JSON is not strict UTF-8 JSON', cause); + } + const snapshot = snapshotRfc64ExactWireRecordV1(parsed, expectedKeys); + if (!rfc64WireBytesEqualV1(encodeRfc64FlatCanonicalJsonV1(snapshot, maxBytes), input)) { + utilityFail('noncanonical', 'RFC-64 flat JSON bytes are not canonical JCS'); + } + return snapshot; +} + +/** + * Snapshot own enumerable data properties exactly once. This rejects accessor + * and Proxy switching attacks without invoking caller-controlled getters. + */ +export function snapshotRfc64ExactWireRecordV1( + value: unknown, + expectedKeys: readonly string[] | undefined, +): Readonly> { + if (!isPlainRecord(value)) { + utilityFail('plain-object', 'RFC-64 wire value must be a plain object'); + } + let ownKeys: readonly PropertyKey[]; + try { + ownKeys = Reflect.ownKeys(value); + } catch (cause) { + utilityFail('exact-keys', 'RFC-64 wire fields could not be inspected', cause); + } + if (ownKeys.some((key) => typeof key !== 'string')) { + utilityFail('exact-keys', 'RFC-64 wire value has symbol fields'); + } + const actual = [...ownKeys as readonly string[]].sort(); + if ( + expectedKeys !== undefined + && ( + actual.length !== expectedKeys.length + || actual.some((key, index) => key !== expectedKeys[index]) + ) + ) { + utilityFail('exact-keys', 'RFC-64 wire value has missing or unknown fields'); + } + const snapshot: Record = Object.create(null); + for (const key of actual) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch (cause) { + utilityFail('exact-keys', 'RFC-64 wire field descriptor could not be inspected', cause); + } + if ( + descriptor === undefined + || !descriptor.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + utilityFail('exact-keys', 'RFC-64 wire fields must be enumerable data properties'); + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot); +} + +export function assertRfc64CanonicalEvmAddressV1( + value: unknown, + label: string, +): asserts value is EvmAddressV1 { + if ( + typeof value !== 'string' + || !/^0x[0-9a-f]{40}$/.test(value) + || value === '0x0000000000000000000000000000000000000000' + ) { + utilityFail( + 'evm-address', + `${label} must be a canonical lowercase nonzero EVM address`, + ); + } +} + +export function snapshotRfc64PeerIdV1(value: unknown): string { + if (typeof value !== 'string') { + utilityFail('peer-id-type', 'remotePeerId must be a string'); + } + const byteLength = UTF8.encode(value).byteLength; + if (byteLength < 1 || byteLength > MAX_PEER_ID_BYTES_V1 || value.trim() !== value) { + utilityFail('peer-id-canonical', 'remotePeerId is empty, oversized, or noncanonical'); + } + return value; +} + +export function assertRfc64ExactIssuerSignatureProofV1( + envelope: SignedControlEnvelopeV1, + proof: VerifiedControlEnvelopeIssuerSignatureV1, +): void { + let snapshot; + try { + snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + } catch (cause) { + utilityFail('issuer-proof-unminted', 'issuer signature proof was not minted by the verifier', cause); + } + const expectedVariant = computeControlSignatureVariantDigestHex( + envelope.objectDigest, + envelope.signature, + ); + if ( + snapshot.objectDigest !== envelope.objectDigest + || snapshot.signatureVariantDigest !== expectedVariant + || snapshot.issuer !== envelope.issuer + || snapshot.signatureSuite !== envelope.signatureSuite + ) { + utilityFail('issuer-proof-mismatch', 'issuer signature proof is not bound to the exact envelope'); + } +} + +export function encodeRfc64FoundStatusResponseV1( + payload: Uint8Array, + maxBytes?: number, +): Uint8Array { + const result = new Uint8Array(payload.byteLength + 1); + result[0] = 1; + result.set(payload, 1); + if (maxBytes !== undefined && result.byteLength > maxBytes) { + utilityFail('response-size', 'RFC-64 found response exceeds its byte ceiling'); + } + return result; +} + +export type Rfc64StatusResponsePayloadV1 = + | { readonly status: 'not-found' } + | { readonly status: 'found'; readonly payload: Uint8Array } + | { readonly status: 'denied' }; + +export function parseRfc64StatusResponsePayloadV1( + input: Uint8Array, + maxBytes: number, +): Rfc64StatusResponsePayloadV1 { + if (!(input instanceof Uint8Array) || input.byteLength < 1 || input.byteLength > maxBytes) { + utilityFail('response-size', 'RFC-64 status response is empty or oversized'); + } + const status = input[0]; + if (status === 0 || status === 2) { + if (input.byteLength !== 1) { + utilityFail('response-trailing', 'RFC-64 status-only response has trailing bytes'); + } + return Object.freeze({ status: status === 0 ? 'not-found' : 'denied' }); + } + if (status !== 1 || input.byteLength === 1) { + utilityFail('response-status', 'RFC-64 status response has an invalid status'); + } + return Object.freeze({ status: 'found', payload: input.subarray(1) }); +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + let prototype: object | null; + try { + prototype = Object.getPrototypeOf(value); + } catch (cause) { + utilityFail('plain-object', 'RFC-64 wire value prototype could not be inspected', cause); + } + return prototype === Object.prototype || prototype === null; +} + +function rfc64WireBytesEqualV1(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) return false; + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function utilityFail( + reason: Rfc64CatalogTransportWireUtilityErrorReasonV1, + message: string, + cause?: unknown, +): never { + throw new Rfc64CatalogTransportWireUtilityErrorV1( + reason, + message, + cause === undefined ? {} : { cause }, + ); +} diff --git a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts index ad51afd319..9929f3023b 100644 --- a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts @@ -37,10 +37,21 @@ import { type SubGraphNameV1, } from '@origintrail-official/dkg-core'; import { - readVerifiedControlEnvelopeIssuerSignatureV1, type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; +import { + Rfc64CatalogTransportWireUtilityErrorV1, + assertRfc64CanonicalEvmAddressV1, + assertRfc64ExactIssuerSignatureProofV1, + encodeRfc64FlatCanonicalJsonV1, + encodeRfc64FoundStatusResponseV1, + parseRfc64FlatCanonicalJsonV1, + parseRfc64StatusResponsePayloadV1, + snapshotRfc64ExactWireRecordV1, + snapshotRfc64PeerIdV1, +} from './catalog-transport-wire-v1-internal.js'; + import type { Rfc64PublicCatalogHeadAnnouncementV1 } from './public-catalog-transport-v1.js'; import { RFC64_PUBLIC_CATALOG_HEAD_ANNOUNCEMENT_KIND_V1, @@ -57,13 +68,8 @@ export const RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_MAX_BYTES_V1 = 2 * 1024; export const RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1 = 2 * 1024 + 1; const CURRENT_HEAD_NOT_FOUND = 0; -const CURRENT_HEAD_FOUND = 1; const CURRENT_HEAD_DENIED = 2; const CURRENT_HEAD_SNAPSHOT_MAX_ATTEMPTS = 2; -const MAX_PEER_ID_BYTES = 256; -const UTF8 = new TextEncoder(); -// Keep a leading BOM visible so canonical re-encoding rejects it. -const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); const QUERY_KEYS = Object.freeze([ 'authorAddress', @@ -457,40 +463,50 @@ function validateQuery(value: unknown): Rfc64PublicCatalogCurrentHeadQueryV1 { function foundResponse(announcement: Rfc64PublicCatalogHeadAnnouncementV1): Uint8Array { const bytes = encodeRfc64PublicCatalogHeadAnnouncementV1(announcement); - const response = new Uint8Array(bytes.byteLength + 1); - response[0] = CURRENT_HEAD_FOUND; - response.set(bytes, 1); - if (response.byteLength > RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1) { + try { + return encodeRfc64FoundStatusResponseV1( + bytes, + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1, + ); + } catch (cause) { fail('catalog-discovery-wire', 'current-head discovery response exceeds its v1 cap'); } - return response; } function parseResponse(input: Uint8Array): Rfc64PublicCatalogHeadAnnouncementV1 | null { - if ( - !(input instanceof Uint8Array) - || input.byteLength < 1 - || input.byteLength > RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1 - ) { - fail('catalog-discovery-wire', 'current-head discovery response is empty or oversized'); - } - if (input[0] === CURRENT_HEAD_NOT_FOUND) { - if (input.byteLength !== 1) { - fail('catalog-discovery-wire', 'not-found current-head response has trailing bytes'); + let framed; + try { + framed = parseRfc64StatusResponsePayloadV1( + input, + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1, + ); + } catch (cause) { + if ( + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'response-trailing' + ) { + fail( + 'catalog-discovery-wire', + input[0] === CURRENT_HEAD_NOT_FOUND + ? 'not-found current-head response has trailing bytes' + : 'denied current-head response has trailing bytes', + cause, + ); } - return null; - } - if (input[0] === CURRENT_HEAD_DENIED) { - if (input.byteLength !== 1) { - fail('catalog-discovery-wire', 'denied current-head response has trailing bytes'); + if ( + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'response-status' + ) { + fail('catalog-discovery-wire', 'current-head discovery response has an invalid status', cause); } - fail('catalog-discovery-policy-denied', 'remote peer denied current-head discovery'); + fail('catalog-discovery-wire', 'current-head discovery response is empty or oversized', cause); } - if (input[0] !== CURRENT_HEAD_FOUND || input.byteLength === 1) { - fail('catalog-discovery-wire', 'current-head discovery response has an invalid status'); + if (framed.status === 'not-found') return null; + if (framed.status === 'denied') { + fail('catalog-discovery-policy-denied', 'remote peer denied current-head discovery'); } try { - return parseRfc64PublicCatalogHeadAnnouncementV1(input.subarray(1)); + return parseRfc64PublicCatalogHeadAnnouncementV1(framed.payload); } catch (cause) { fail('catalog-discovery-wire', 'current-head discovery response is not canonical', cause); } @@ -561,43 +577,41 @@ function assertExactIssuerSignatureProof( envelope: SignedAuthorCatalogHeadEnvelopeV1, proof: VerifiedControlEnvelopeIssuerSignatureV1, ): void { - let snapshot; try { - snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + assertRfc64ExactIssuerSignatureProofV1(envelope, proof); } catch (cause) { - fail('catalog-discovery-signature', 'issuer signature proof was not minted by the verifier', cause); - } - const expectedVariant = computeControlSignatureVariantDigestHex( - envelope.objectDigest, - envelope.signature, - ); - if ( - snapshot.objectDigest !== envelope.objectDigest - || snapshot.signatureVariantDigest !== expectedVariant - || snapshot.issuer !== envelope.issuer - || snapshot.signatureSuite !== envelope.signatureSuite - ) { - fail('catalog-discovery-signature', 'issuer signature proof is not bound to the exact head envelope'); + fail( + 'catalog-discovery-signature', + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'issuer-proof-unminted' + ? 'issuer signature proof was not minted by the verifier' + : 'issuer signature proof is not bound to the exact head envelope', + cause, + ); } } function encodeFlatCanonicalJson(value: object, maxBytes: number): Uint8Array { - if (!isPlainRecord(value)) { - fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain object'); - } - const fields: string[] = []; - for (const key of Object.keys(value).sort()) { - const field = value[key]; - if (field !== null && typeof field !== 'string') { - fail('catalog-discovery-wire', 'RFC-64 current-head query accepts only string or null fields'); + try { + return encodeRfc64FlatCanonicalJsonV1(value, maxBytes); + } catch (cause) { + if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { + if (cause.reason === 'plain-object') { + fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain object', cause); + } + if (cause.reason === 'field-shape') { + fail( + 'catalog-discovery-wire', + 'RFC-64 current-head query accepts only string or null fields', + cause, + ); + } + if (cause.reason === 'oversized') { + fail('catalog-discovery-wire', `RFC-64 current-head query exceeds ${maxBytes} bytes`, cause); + } } - fields.push(`${JSON.stringify(key)}:${JSON.stringify(field)}`); - } - const bytes = UTF8.encode(`{${fields.join(',')}}`); - if (bytes.byteLength > maxBytes) { - fail('catalog-discovery-wire', `RFC-64 current-head query exceeds ${maxBytes} bytes`); + throw cause; } - return bytes; } function parseFlatCanonicalJson( @@ -605,98 +619,69 @@ function parseFlatCanonicalJson( expectedKeys: readonly string[], maxBytes: number, ): Record { - if (!(input instanceof Uint8Array) || input.byteLength < 2 || input.byteLength > maxBytes) { - fail('catalog-discovery-wire', 'RFC-64 current-head query is empty or oversized'); - } - let parsed: unknown; try { - parsed = JSON.parse(UTF8_FATAL.decode(input)); + return parseRfc64FlatCanonicalJsonV1(input, expectedKeys, maxBytes); } catch (cause) { - fail('catalog-discovery-wire', 'RFC-64 current-head query is not strict UTF-8 JSON', cause); - } - if (!isPlainRecord(parsed)) { - fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain JSON object'); - } - assertExactWireKeys(parsed, expectedKeys); - if (!bytesEqual(encodeFlatCanonicalJson(parsed, maxBytes), input)) { - fail('catalog-discovery-wire', 'RFC-64 current-head query bytes are not canonical JCS'); + if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { + if (cause.reason === 'oversized') { + fail('catalog-discovery-wire', 'RFC-64 current-head query is empty or oversized', cause); + } + if (cause.reason === 'strict-json') { + fail('catalog-discovery-wire', 'RFC-64 current-head query is not strict UTF-8 JSON', cause); + } + if (cause.reason === 'plain-object') { + fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain JSON object', cause); + } + if (cause.reason === 'exact-keys') { + fail('catalog-discovery-wire', 'RFC-64 current-head query has missing or unknown fields', cause); + } + if (cause.reason === 'noncanonical') { + fail('catalog-discovery-wire', 'RFC-64 current-head query bytes are not canonical JCS', cause); + } + } + throw cause; } - return parsed; -} - -function assertExactWireKeys( - value: Record, - expectedKeys: readonly string[], -): void { - void snapshotExactWireRecord(value, expectedKeys); } function snapshotExactWireRecord( value: Record, expectedKeys: readonly string[], ): Readonly> { - let ownKeys: readonly PropertyKey[]; try { - ownKeys = Reflect.ownKeys(value); + return snapshotRfc64ExactWireRecordV1(value, expectedKeys); } catch (cause) { - fail('catalog-discovery-wire', 'RFC-64 current-head query fields could not be inspected', cause); - } - if (ownKeys.some((key) => typeof key !== 'string')) { - fail('catalog-discovery-wire', 'RFC-64 current-head query has symbol fields'); - } - const actual = [...ownKeys as readonly string[]].sort(); - if ( - actual.length !== expectedKeys.length - || actual.some((key, index) => key !== expectedKeys[index]) - ) { - fail('catalog-discovery-wire', 'RFC-64 current-head query has missing or unknown fields'); - } - const snapshot: Record = Object.create(null); - for (const key of expectedKeys) { - let descriptor: PropertyDescriptor | undefined; - try { - descriptor = Object.getOwnPropertyDescriptor(value, key); - } catch (cause) { - fail( - 'catalog-discovery-wire', - 'RFC-64 current-head query field descriptor could not be inspected', - cause, - ); + if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { + fail('catalog-discovery-wire', cause.message.replace('RFC-64 wire', 'RFC-64 current-head query'), cause); } - if ( - descriptor === undefined - || !descriptor.enumerable - || !Object.prototype.hasOwnProperty.call(descriptor, 'value') - ) { - fail( - 'catalog-discovery-wire', - 'RFC-64 current-head query fields must be enumerable data properties', - ); - } - snapshot[key] = descriptor.value; + throw cause; } - return Object.freeze(snapshot); } function assertCanonicalEvmAddressV1(value: unknown, label: string): asserts value is EvmAddressV1 { - if ( - typeof value !== 'string' - || !/^0x[0-9a-f]{40}$/.test(value) - || value === '0x0000000000000000000000000000000000000000' - ) { - fail('catalog-discovery-wire', `${label} must be a canonical lowercase nonzero EVM address`); + try { + assertRfc64CanonicalEvmAddressV1(value, label); + } catch (cause) { + fail( + 'catalog-discovery-wire', + `${label} must be a canonical lowercase nonzero EVM address`, + cause, + ); } } function snapshotPeerId(value: unknown): string { - if (typeof value !== 'string') { - fail('catalog-discovery-input', 'remotePeerId must be a string'); - } - const byteLength = UTF8.encode(value).byteLength; - if (byteLength < 1 || byteLength > MAX_PEER_ID_BYTES || value.trim() !== value) { - fail('catalog-discovery-input', 'remotePeerId is empty, oversized, or noncanonical'); + try { + return snapshotRfc64PeerIdV1(value); + } catch (cause) { + fail( + 'catalog-discovery-input', + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'peer-id-type' + ? 'remotePeerId must be a string' + : 'remotePeerId is empty, oversized, or noncanonical', + cause, + ); } - return value; } function isPlainRecord(value: unknown): value is Record { @@ -705,14 +690,6 @@ function isPlainRecord(value: unknown): value is Record { return prototype === Object.prototype || prototype === null; } -function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { - if (left.byteLength !== right.byteLength) return false; - for (let index = 0; index < left.byteLength; index += 1) { - if (left[index] !== right[index]) return false; - } - return true; -} - function throwIfAborted(signal: AbortSignal | undefined): void { if (!signal?.aborted) return; if (signal.reason instanceof Error) throw signal.reason; diff --git a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts index e292137ea3..2e0ddc7e53 100644 --- a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts @@ -34,6 +34,15 @@ import type { Rfc64PublicCatalogHeadAnnouncementV1, } from './public-catalog-transport-v1.js'; +/** Wire fields that identify one claimed public/open root author-catalog scope. */ +export interface Rfc64PublicOpenCatalogScopeClaimV1 { + readonly networkId: Rfc64PublicCatalogHeadAnnouncementV1['networkId']; + readonly contextGraphId: Rfc64PublicCatalogHeadAnnouncementV1['contextGraphId']; + readonly subGraphName: Rfc64PublicCatalogHeadAnnouncementV1['subGraphName']; + readonly authorAddress: Rfc64PublicCatalogHeadAnnouncementV1['authorAddress']; + readonly catalogEra: Rfc64PublicCatalogHeadAnnouncementV1['catalogEra']; +} + export type Rfc64BoundedPublicRootCatalogNativeReceiverClientV1 = Pick< Rfc64PublicCatalogNativeReceiverV1, 'synchronizeBoundedPublicRootCatalog' @@ -80,23 +89,23 @@ export interface Rfc64BoundedPublicRootCatalogNativeReconcilerOptionsV1 { * not semantic catalog identity, and therefore do not participate. */ export function deriveRfc64PublicOpenCatalogScopeV1( - announcement: Rfc64PublicCatalogHeadAnnouncementV1, + claim: Rfc64PublicOpenCatalogScopeClaimV1, acceptedPolicy: ContextGraphPolicyV1, ): AuthorCatalogScopeV1 { if ( acceptedPolicy.accessPolicy !== 0 || acceptedPolicy.source.kind !== 'owner-signed-unregistered' - || acceptedPolicy.networkId !== announcement.networkId - || acceptedPolicy.contextGraphId !== announcement.contextGraphId + || acceptedPolicy.networkId !== claim.networkId + || acceptedPolicy.contextGraphId !== claim.contextGraphId || acceptedPolicy.governanceChainId !== null || acceptedPolicy.governanceContractAddress !== null || acceptedPolicy.ownershipTransitionDigest !== null - || acceptedPolicy.era !== announcement.catalogEra - || announcement.subGraphName !== null - || acceptedPolicy.source.ownerAddress !== announcement.authorAddress + || acceptedPolicy.era !== claim.catalogEra + || claim.subGraphName !== null + || acceptedPolicy.source.ownerAddress !== claim.authorAddress ) { throw new Error( - 'RFC-64 Gate 1 announcement is not bound to the accepted null-governance owner policy', + 'RFC-64 public/open catalog claim is not bound to the accepted null-governance owner policy', ); } return Object.freeze({ diff --git a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts index a9c6eee9dc..e41d534a03 100644 --- a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts @@ -24,7 +24,6 @@ import { assertSignedControlEnvelope, assertSubGraphNameV1, canonicalizeSignedControlEnvelopeBytes, - computeControlSignatureVariantDigestHex, decodeOpaqueKaBundleV1, parseCanonicalDecimalU64, parseCanonicalSignedControlEnvelope, @@ -38,7 +37,6 @@ import { type SubGraphNameV1, } from '@origintrail-official/dkg-core'; import { - readVerifiedControlEnvelopeIssuerSignatureV1, type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; @@ -53,6 +51,17 @@ import { withCurrentRfc64CatalogPolicyV1, } from './catalog-transport-authorization-v1.js'; import type { Rfc64AuthorizedCatalogWorkResultV1 } from './catalog-transport-authorization-v1.js'; +import { + Rfc64CatalogTransportWireUtilityErrorV1, + assertRfc64CanonicalEvmAddressV1, + assertRfc64ExactIssuerSignatureProofV1, + encodeRfc64FlatCanonicalJsonV1, + encodeRfc64FoundStatusResponseV1, + parseRfc64FlatCanonicalJsonV1, + parseRfc64StatusResponsePayloadV1, + snapshotRfc64ExactWireRecordV1, + snapshotRfc64PeerIdV1, +} from './catalog-transport-wire-v1-internal.js'; export const RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1 = '/dkg/catalog/1/control-object/by-digest' as const; @@ -112,11 +121,8 @@ export function assertRfc64PublicCatalogExactSetBundleBytesV1( } const FETCH_NOT_FOUND = 0; -const FETCH_FOUND = 1; const FETCH_DENIED = 2; -const MAX_PEER_ID_BYTES = 256; const UTF8 = new TextEncoder(); -const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); const SCOPE_KEYS = Object.freeze([ 'authorAddress', @@ -496,18 +502,7 @@ export class Rfc64PublicCatalogNativeTransportV1 { ): Promise { try { const proof = await this.options.verifyIssuerSignature(envelope); - const snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); - if ( - snapshot.objectDigest !== envelope.objectDigest - || snapshot.signatureVariantDigest !== computeControlSignatureVariantDigestHex( - envelope.objectDigest, - envelope.signature, - ) - || snapshot.issuer !== envelope.issuer - || snapshot.signatureSuite !== envelope.signatureSuite - ) { - throw new Error('issuer-signature proof identifies another envelope'); - } + assertRfc64ExactIssuerSignatureProofV1(envelope, proof); return proof; } catch (cause) { fail('catalog-native-signature', 'catalog object issuer signature is invalid', cause); @@ -554,39 +549,39 @@ function parseBundleRequest(input: Uint8Array): Rfc64PublicCatalogBundleFetchReq } function validateObjectRequest(value: unknown): Rfc64PublicCatalogObjectFetchRequestV1 { - const scope = validateScope(value, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1); - if (!isPlainRecord(value)) throw new Error('unreachable'); - if (typeof value.targetObjectType !== 'string' || value.targetObjectType.length < 1 - || UTF8.encode(value.targetObjectType).byteLength > 256) { + const snapshot = snapshotExactWireRecord(value, OBJECT_REQUEST_KEYS); + const scope = validateScope(snapshot, RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1); + if (typeof snapshot.targetObjectType !== 'string' || snapshot.targetObjectType.length < 1 + || UTF8.encode(snapshot.targetObjectType).byteLength > 256) { fail('catalog-native-wire', 'targetObjectType is empty or oversized'); } try { - assertCanonicalDigest(value.targetObjectDigest, 'targetObjectDigest'); + assertCanonicalDigest(snapshot.targetObjectDigest, 'targetObjectDigest'); } catch (cause) { fail('catalog-native-wire', 'targetObjectDigest is invalid', cause); } return Object.freeze({ ...scope, kind: RFC64_PUBLIC_CATALOG_OBJECT_FETCH_KIND_V1, - targetObjectType: value.targetObjectType, - targetObjectDigest: value.targetObjectDigest, + targetObjectType: snapshot.targetObjectType, + targetObjectDigest: snapshot.targetObjectDigest, }) as Rfc64PublicCatalogObjectFetchRequestV1; } function validateBundleRequest(value: unknown): Rfc64PublicCatalogBundleFetchRequestV1 { - const scope = validateScope(value, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1); - if (!isPlainRecord(value)) throw new Error('unreachable'); + const snapshot = snapshotExactWireRecord(value, BUNDLE_REQUEST_KEYS); + const scope = validateScope(snapshot, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1); try { - assertCanonicalDigest(value.blobDigest, 'blobDigest'); - assertCanonicalDecimalU64(value.byteLength, 'byteLength'); + assertCanonicalDigest(snapshot.blobDigest, 'blobDigest'); + assertCanonicalDecimalU64(snapshot.byteLength, 'byteLength'); } catch (cause) { fail('catalog-native-wire', 'bundle request contains an invalid digest or length', cause); } return Object.freeze({ ...scope, kind: RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, - blobDigest: value.blobDigest, - byteLength: value.byteLength, + blobDigest: snapshot.blobDigest, + byteLength: snapshot.byteLength, }) as Rfc64PublicCatalogBundleFetchRequestV1; } @@ -623,51 +618,58 @@ function validateScope( } function encodeRequest(value: object): Uint8Array { - if (!isPlainRecord(value)) { - fail('catalog-native-wire', 'catalog native request must be a plain object'); - } - const fields: string[] = []; - for (const key of Object.keys(value).sort()) { - const field = value[key]; - if (field !== null && typeof field !== 'string') { - fail('catalog-native-wire', 'catalog native requests accept only string or null fields'); + try { + return encodeRfc64FlatCanonicalJsonV1( + value, + RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1, + ); + } catch (cause) { + if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { + if (cause.reason === 'plain-object') { + fail('catalog-native-wire', 'catalog native request must be a plain object', cause); + } + if (cause.reason === 'field-shape') { + fail( + 'catalog-native-wire', + 'catalog native requests accept only string or null fields', + cause, + ); + } + if (cause.reason === 'oversized') { + fail('catalog-native-wire', 'catalog native request exceeds its byte ceiling', cause); + } } - fields.push(`${JSON.stringify(key)}:${JSON.stringify(field)}`); + throw cause; } - const bytes = UTF8.encode(`{${fields.join(',')}}`); - if (bytes.byteLength > RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1) { - fail('catalog-native-wire', 'catalog native request exceeds its byte ceiling'); - } - return bytes; } function parseRequest(input: Uint8Array, expectedKeys: readonly string[]): Record { - if ( - !(input instanceof Uint8Array) - || input.byteLength < 2 - || input.byteLength > RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1 - ) { - fail('catalog-native-wire', 'catalog native request is empty or oversized'); - } - let parsed: unknown; try { - parsed = JSON.parse(UTF8_FATAL.decode(input)); + return parseRfc64FlatCanonicalJsonV1( + input, + expectedKeys, + RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1, + ); } catch (cause) { - fail('catalog-native-wire', 'catalog native request is not strict UTF-8 JSON', cause); - } - if (!isPlainRecord(parsed)) fail('catalog-native-wire', 'catalog native request must be an object'); - const actual = Object.keys(parsed).sort(); - if ( - actual.length !== expectedKeys.length - || actual.some((key, index) => key !== expectedKeys[index]) - ) { - fail('catalog-native-wire', 'catalog native request has missing or unknown fields'); - } - const canonical = encodeRequest(parsed); - if (!bytesEqual(canonical, input)) { - fail('catalog-native-wire', 'catalog native request bytes are not canonical JCS'); + if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { + if (cause.reason === 'oversized') { + fail('catalog-native-wire', 'catalog native request is empty or oversized', cause); + } + if (cause.reason === 'strict-json') { + fail('catalog-native-wire', 'catalog native request is not strict UTF-8 JSON', cause); + } + if (cause.reason === 'plain-object') { + fail('catalog-native-wire', 'catalog native request must be an object', cause); + } + if (cause.reason === 'exact-keys') { + fail('catalog-native-wire', 'catalog native request has missing or unknown fields', cause); + } + if (cause.reason === 'noncanonical') { + fail('catalog-native-wire', 'catalog native request bytes are not canonical JCS', cause); + } + } + throw cause; } - return parsed; } function parseCatalogObjectResponse(input: Uint8Array): SignedControlEnvelopeV1 | null { @@ -696,21 +698,35 @@ function parseBundleResponse( } function responsePayload(input: Uint8Array, maxBytes: number): Uint8Array | null { - if (!(input instanceof Uint8Array) || input.byteLength < 1 || input.byteLength > maxBytes) { - fail('catalog-native-wire', 'catalog native response is empty or oversized'); - } - if (input[0] === FETCH_NOT_FOUND) { - if (input.byteLength !== 1) fail('catalog-native-wire', 'not-found response has trailing bytes'); - return null; + let framed; + try { + framed = parseRfc64StatusResponsePayloadV1(input, maxBytes); + } catch (cause) { + if ( + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'response-trailing' + ) { + fail( + 'catalog-native-wire', + input[0] === FETCH_NOT_FOUND + ? 'not-found response has trailing bytes' + : 'denied response has trailing bytes', + cause, + ); + } + if ( + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'response-status' + ) { + fail('catalog-native-wire', 'catalog native response has an invalid status', cause); + } + fail('catalog-native-wire', 'catalog native response is empty or oversized', cause); } - if (input[0] === FETCH_DENIED) { - if (input.byteLength !== 1) fail('catalog-native-wire', 'denied response has trailing bytes'); + if (framed.status === 'not-found') return null; + if (framed.status === 'denied') { fail('catalog-native-policy-denied', 'remote peer denied the catalog native fetch'); } - if (input[0] !== FETCH_FOUND || input.byteLength === 1) { - fail('catalog-native-wire', 'catalog native response has an invalid status'); - } - return input.subarray(1); + return framed.payload; } function assertCatalogObjectMatchesRequest( @@ -750,29 +766,41 @@ function assertExactBundle( } function foundResponse(payload: Uint8Array): Uint8Array { - const result = new Uint8Array(payload.byteLength + 1); - result[0] = FETCH_FOUND; - result.set(payload, 1); - return result; + return encodeRfc64FoundStatusResponseV1(payload); } function assertCanonicalEvmAddress(value: unknown, label: string): asserts value is EvmAddressV1 { - if ( - typeof value !== 'string' - || !/^0x[0-9a-f]{40}$/.test(value) - || value === '0x0000000000000000000000000000000000000000' - ) { - fail('catalog-native-wire', `${label} must be a lowercase nonzero EVM address`); + try { + assertRfc64CanonicalEvmAddressV1(value, label); + } catch (cause) { + fail('catalog-native-wire', `${label} must be a lowercase nonzero EVM address`, cause); } } function snapshotPeerId(value: unknown): string { - if (typeof value !== 'string') fail('catalog-native-input', 'remotePeerId must be a string'); - const byteLength = UTF8.encode(value).byteLength; - if (byteLength < 1 || byteLength > MAX_PEER_ID_BYTES || value.trim() !== value) { - fail('catalog-native-input', 'remotePeerId is empty, oversized, or noncanonical'); + try { + return snapshotRfc64PeerIdV1(value); + } catch (cause) { + fail( + 'catalog-native-input', + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'peer-id-type' + ? 'remotePeerId must be a string' + : 'remotePeerId is empty, oversized, or noncanonical', + cause, + ); + } +} + +function snapshotExactWireRecord( + value: unknown, + expectedKeys: readonly string[], +): Readonly> { + try { + return snapshotRfc64ExactWireRecordV1(value, expectedKeys); + } catch (cause) { + fail('catalog-native-wire', 'catalog native request has missing or unknown fields', cause); } - return value; } function isPlainRecord(value: unknown): value is Record { @@ -787,14 +815,6 @@ function deepFreeze(value: T): T { return Object.freeze(value); } -function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { - if (left.byteLength !== right.byteLength) return false; - for (let index = 0; index < left.byteLength; index += 1) { - if (left[index] !== right[index]) return false; - } - return true; -} - function fail( code: Rfc64PublicCatalogNativeTransportErrorCodeV1, message: string, diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 3293d63e64..fea2a1ddc9 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -30,7 +30,6 @@ import { type SignedControlEnvelopeV1, type AuthorCatalogScopeV1, type ContextGraphIdV1, - type CountV1, type Digest32V1, type NetworkIdV1, type TimestampMsV1, @@ -85,6 +84,7 @@ import { produceDirectAuthorCatalogIssuerDelegationV1, } from './public-catalog-issuer-delegation-v1.js'; import { + deriveRfc64PublicOpenCatalogScopeV1, type Rfc64BoundedPublicRootCatalogTrustedScopeResolverV1, } from './public-catalog-native-reconciler-v1.js'; import type { @@ -581,7 +581,7 @@ export class Rfc64PublicCatalogServiceV1 { this.#sendOptions(signal), ); if (announcement === null) return null; - this.#assertAcceptedOpenAnnouncement(announcement); + this.#assertAcceptedCatalogAnnouncement(announcement); const currentTrustedScope = this.#resolveTrustedCatalogScope(announcement); const head = await this.#transport.fetchCatalogHead( remotePeerId, @@ -713,38 +713,19 @@ export class Rfc64PublicCatalogServiceV1 { input: Rfc64PublicCatalogCurrentHeadScopeV1, ): Readonly { const record = this.#policies.lookup(input.networkId, input.contextGraphId); - const policy = record?.policy; - if ( - record === null - || policy === undefined - || policy.accessPolicy !== 0 - || policy.source.kind !== 'owner-signed-unregistered' - || policy.networkId !== input.networkId - || policy.contextGraphId !== input.contextGraphId - || policy.governanceChainId !== null - || policy.governanceContractAddress !== null - || policy.ownershipTransitionDigest !== null - || policy.era !== input.catalogEra - || input.subGraphName !== null - || policy.source.ownerAddress !== input.authorAddress - ) { + if (record === null) { throw new Error( 'RFC-64 current-head query is not bound to the accepted public/open root policy', ); } - const scope = Object.freeze({ - networkId: policy.networkId, - contextGraphId: policy.contextGraphId, - governanceChainId: policy.governanceChainId, - governanceContractAddress: policy.governanceContractAddress, - ownershipTransitionDigest: policy.ownershipTransitionDigest, - subGraphName: null, - authorAddress: policy.source.ownerAddress, - era: policy.era, - bucketCount: '1' as CountV1, - }); - assertAuthorCatalogScopeV1(scope); - return scope; + try { + return deriveRfc64PublicOpenCatalogScopeV1(input, record.policy); + } catch (cause) { + throw new Error( + 'RFC-64 current-head query is not bound to the accepted public/open root policy', + { cause }, + ); + } } async #stageHeadOnly( diff --git a/packages/agent/src/rfc64/public-catalog-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-transport-v1.ts index e0af149434..eedebb0577 100644 --- a/packages/agent/src/rfc64/public-catalog-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-transport-v1.ts @@ -21,7 +21,6 @@ import { type SubGraphNameV1, } from '@origintrail-official/dkg-core'; import { - readVerifiedControlEnvelopeIssuerSignatureV1, type VerifiedControlEnvelopeIssuerSignatureV1, } from '@origintrail-official/dkg-chain'; @@ -36,6 +35,17 @@ import { withCurrentRfc64CatalogPolicyV1, } from './catalog-transport-authorization-v1.js'; import type { Rfc64AuthorizedCatalogWorkResultV1 } from './catalog-transport-authorization-v1.js'; +import { + Rfc64CatalogTransportWireUtilityErrorV1, + assertRfc64CanonicalEvmAddressV1, + assertRfc64ExactIssuerSignatureProofV1, + encodeRfc64FlatCanonicalJsonV1, + encodeRfc64FoundStatusResponseV1, + parseRfc64FlatCanonicalJsonV1, + parseRfc64StatusResponsePayloadV1, + snapshotRfc64ExactWireRecordV1, + snapshotRfc64PeerIdV1, +} from './catalog-transport-wire-v1-internal.js'; /** * Additive RFC-64 protocol IDs. Their `/catalog/1` component is the wire @@ -59,12 +69,7 @@ export const RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1 = 32 * 1024; const ACK = Uint8Array.of(1); const ANNOUNCEMENT_DENIED = 0; const FETCH_NOT_FOUND = 0; -const FETCH_FOUND = 1; const FETCH_DENIED = 2; -const MAX_PEER_ID_BYTES = 256; -const UTF8 = new TextEncoder(); -// Keep a leading BOM visible so canonical re-encoding rejects it. -const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); const ANNOUNCEMENT_KEYS = Object.freeze([ 'authorAddress', @@ -368,13 +373,14 @@ export class Rfc64PublicCatalogTransportV1 { ); } const envelopeBytes = canonicalizeSignedAuthorCatalogHeadEnvelopeBytesV1(envelope); - const response = new Uint8Array(1 + envelopeBytes.byteLength); - response[0] = FETCH_FOUND; - response.set(envelopeBytes, 1); - if (response.byteLength > RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1) { + try { + return encodeRfc64FoundStatusResponseV1( + envelopeBytes, + RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1, + ); + } catch (cause) { fail('catalog-transport-wire', 'author-catalog head exceeds the v1 fetch response cap'); } - return response; }, ); if (!served.authorized) { @@ -555,33 +561,36 @@ function validateWireScope RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1 - ) { - fail('catalog-transport-wire', 'author-catalog head response is empty or oversized'); - } - if (input[0] === FETCH_NOT_FOUND) { - if (input.byteLength !== 1) { - fail('catalog-transport-wire', 'not-found author-catalog response has trailing bytes'); + let framed; + try { + framed = parseRfc64StatusResponsePayloadV1( + input, + RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1, + ); + } catch (cause) { + if ( + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'response-trailing' + ) { + fail( + 'catalog-transport-wire', + input[0] === FETCH_NOT_FOUND + ? 'not-found author-catalog response has trailing bytes' + : 'denied author-catalog response has trailing bytes', + cause, + ); } - return null; - } - if (input[0] === FETCH_DENIED) { - if (input.byteLength !== 1) { - fail('catalog-transport-wire', 'denied author-catalog response has trailing bytes'); + if ( + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'response-status' + ) { + fail('catalog-transport-wire', 'author-catalog head response has an invalid status', cause); } - fail('catalog-transport-policy-denied', 'remote peer denied the author-catalog fetch'); + fail('catalog-transport-wire', 'author-catalog head response is empty or oversized', cause); } - if (input[0] !== FETCH_FOUND || input.byteLength === 1) { - fail('catalog-transport-wire', 'author-catalog head response has an invalid status'); + if (framed.status === 'not-found') return null; + if (framed.status === 'denied') { + fail('catalog-transport-policy-denied', 'remote peer denied the author-catalog fetch'); } try { - return parseCanonicalSignedAuthorCatalogHeadEnvelopeV1(input.subarray(1), { + return parseCanonicalSignedAuthorCatalogHeadEnvelopeV1(framed.payload, { maxBytes: RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1 - 1, }); } catch (cause) { @@ -671,23 +690,17 @@ function assertExactIssuerSignatureProof( envelope: SignedControlEnvelopeV1, proof: VerifiedControlEnvelopeIssuerSignatureV1, ): void { - let snapshot; try { - snapshot = readVerifiedControlEnvelopeIssuerSignatureV1(proof); + assertRfc64ExactIssuerSignatureProofV1(envelope, proof); } catch (cause) { - fail('catalog-transport-signature', 'issuer signature proof was not minted by the verifier', cause); - } - const expectedVariant = computeControlSignatureVariantDigestHex( - envelope.objectDigest, - envelope.signature, - ); - if ( - snapshot.objectDigest !== envelope.objectDigest - || snapshot.signatureVariantDigest !== expectedVariant - || snapshot.issuer !== envelope.issuer - || snapshot.signatureSuite !== envelope.signatureSuite - ) { - fail('catalog-transport-signature', 'issuer signature proof is not bound to the exact envelope'); + fail( + 'catalog-transport-signature', + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'issuer-proof-unminted' + ? 'issuer signature proof was not minted by the verifier' + : 'issuer signature proof is not bound to the exact envelope', + cause, + ); } } @@ -695,23 +708,26 @@ function encodeFlatCanonicalJson( value: object, maxBytes: number, ): Uint8Array { - if (!isPlainRecord(value)) { - fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain object'); - } - const keys = Object.keys(value).sort(); - const fields: string[] = []; - for (const key of keys) { - const field = value[key]; - if (field !== null && typeof field !== 'string') { - fail('catalog-transport-wire', 'RFC-64 catalog messages accept only string or null fields'); + try { + return encodeRfc64FlatCanonicalJsonV1(value, maxBytes); + } catch (cause) { + if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { + if (cause.reason === 'plain-object') { + fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain object', cause); + } + if (cause.reason === 'field-shape') { + fail( + 'catalog-transport-wire', + 'RFC-64 catalog messages accept only string or null fields', + cause, + ); + } + if (cause.reason === 'oversized') { + fail('catalog-transport-wire', `RFC-64 catalog message exceeds ${maxBytes} bytes`, cause); + } } - fields.push(`${JSON.stringify(key)}:${JSON.stringify(field)}`); + throw cause; } - const bytes = UTF8.encode(`{${fields.join(',')}}`); - if (bytes.byteLength > maxBytes) { - fail('catalog-transport-wire', `RFC-64 catalog message exceeds ${maxBytes} bytes`); - } - return bytes; } function parseFlatCanonicalJson( @@ -719,60 +735,66 @@ function parseFlatCanonicalJson( expectedKeys: readonly string[], maxBytes: number, ): Record { - if (!(input instanceof Uint8Array) || input.byteLength < 2 || input.byteLength > maxBytes) { - fail('catalog-transport-wire', 'RFC-64 catalog message is empty or oversized'); - } - let text: string; - let parsed: unknown; try { - text = UTF8_FATAL.decode(input); - parsed = JSON.parse(text); + return parseRfc64FlatCanonicalJsonV1(input, expectedKeys, maxBytes); } catch (cause) { - fail('catalog-transport-wire', 'RFC-64 catalog message is not strict UTF-8 JSON', cause); - } - if (!isPlainRecord(parsed)) { - fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain JSON object'); - } - assertExactWireKeys(parsed, expectedKeys); - const canonical = encodeFlatCanonicalJson(parsed, maxBytes); - if (!bytesEqual(canonical, input)) { - fail('catalog-transport-wire', 'RFC-64 catalog message bytes are not canonical JCS'); + if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { + if (cause.reason === 'oversized') { + fail('catalog-transport-wire', 'RFC-64 catalog message is empty or oversized', cause); + } + if (cause.reason === 'strict-json') { + fail('catalog-transport-wire', 'RFC-64 catalog message is not strict UTF-8 JSON', cause); + } + if (cause.reason === 'plain-object') { + fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain JSON object', cause); + } + if (cause.reason === 'exact-keys') { + fail('catalog-transport-wire', 'RFC-64 catalog message has missing or unknown fields', cause); + } + if (cause.reason === 'noncanonical') { + fail('catalog-transport-wire', 'RFC-64 catalog message bytes are not canonical JCS', cause); + } + } + throw cause; } - return parsed; } -function assertExactWireKeys( - value: Record, +function snapshotExactWireRecord( + value: unknown, expectedKeys: readonly string[], -): void { - const actual = Object.keys(value).sort(); - if ( - actual.length !== expectedKeys.length - || actual.some((key, index) => key !== expectedKeys[index]) - ) { - fail('catalog-transport-wire', 'RFC-64 catalog message has missing or unknown fields'); +): Readonly> { + try { + return snapshotRfc64ExactWireRecordV1(value, expectedKeys); + } catch (cause) { + fail('catalog-transport-wire', 'RFC-64 catalog message has missing or unknown fields', cause); } } function assertCanonicalEvmAddressV1(value: unknown, label: string): asserts value is EvmAddressV1 { - if ( - typeof value !== 'string' - || !/^0x[0-9a-f]{40}$/.test(value) - || value === '0x0000000000000000000000000000000000000000' - ) { - fail('catalog-transport-wire', `${label} must be a canonical lowercase nonzero EVM address`); + try { + assertRfc64CanonicalEvmAddressV1(value, label); + } catch (cause) { + fail( + 'catalog-transport-wire', + `${label} must be a canonical lowercase nonzero EVM address`, + cause, + ); } } function snapshotPeerId(value: unknown): string { - if (typeof value !== 'string') { - fail('catalog-transport-input', 'remotePeerId must be a string'); - } - const byteLength = UTF8.encode(value).byteLength; - if (byteLength < 1 || byteLength > MAX_PEER_ID_BYTES || value.trim() !== value) { - fail('catalog-transport-input', 'remotePeerId is empty, oversized, or noncanonical'); + try { + return snapshotRfc64PeerIdV1(value); + } catch (cause) { + fail( + 'catalog-transport-input', + cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 + && cause.reason === 'peer-id-type' + ? 'remotePeerId must be a string' + : 'remotePeerId is empty, oversized, or noncanonical', + cause, + ); } - return value; } function isPlainRecord(value: unknown): value is Record { @@ -781,14 +803,6 @@ function isPlainRecord(value: unknown): value is Record { return prototype === Object.prototype || prototype === null; } -function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { - if (left.byteLength !== right.byteLength) return false; - for (let index = 0; index < left.byteLength; index += 1) { - if (left[index] !== right[index]) return false; - } - return true; -} - function deepFreeze(value: T): T { if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value; for (const nested of Object.values(value as Record)) deepFreeze(nested); diff --git a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts index da99f50bfa..857d557744 100644 --- a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts @@ -39,6 +39,7 @@ const CONTEXT_GRAPH_ID = const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; const DELEGATION_DIGEST = `0x${'72'.repeat(32)}` as Digest32V1; +const POLICY_DIGEST = `0x${'71'.repeat(32)}` as Digest32V1; const temporaryDirectories: string[] = []; const nodes: DKGNode[] = []; @@ -124,6 +125,25 @@ async function stageHead( return produced.head; } +async function produceHeadWithProof( + scope = catalogScope(), + issuedAt: TimestampMsV1 = '1773900000000' as TimestampMsV1, +) { + const produced = await produceEmptyAuthorCatalogGenesisV1({ + scope, + catalogIssuerDelegationDigest: DELEGATION_DIGEST, + issuedAt, + signer: { + issuer: AUTHOR, + signDigest: (digest) => AUTHOR_WALLET.signMessage(digest), + }, + }); + return Object.freeze({ + head: produced.head, + issuerSignature: await verifyControlEnvelopeIssuerSignatureV1(produced.head), + }); +} + function acceptPolicy( service: Rfc64PublicCatalogServiceV1, issuedAt?: TimestampMsV1, @@ -400,11 +420,193 @@ describe('RFC-64 public catalog current-head discovery v1', () => { expect(requester.stats().receiver.scheduled).toBe(0); }, 20_000); + it('rejects a durable current pointer bound to a different context graph', async () => { + let handler: (( + data: Uint8Array, + peerId: { toString(): string }, + options?: { signal?: AbortSignal }, + ) => Promise) | undefined; + const wrongContextGraphId = + '0x1111111111111111111111111111111111111111/other-graph' as const; + const stored = await produceHeadWithProof(catalogScope(wrongContextGraphId)); + const router = { + register(_protocol: string, registered: typeof handler) { + handler = registered; + }, + unregister() {}, + } as unknown as ProtocolRouter; + const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { + controlObjects: { + getVerifiedObjectByDigest: vi.fn(async () => ({ + envelope: stored.head, + issuerSignature: stored.issuerSignature, + })), + }, + readCurrentAppliedCatalogHeadDigest: vi.fn(async () => + stored.head.objectDigest as Digest32V1), + authorizeOpenCatalogOperation: vi.fn(async () => ({ + accessPolicy: 0 as const, + policyDigest: POLICY_DIGEST, + })), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + transport.start(); + const query = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + ...discoveryScope(), + policyDigest: POLICY_DIGEST, + }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; + + await expect(handler!( + encodeRfc64PublicCatalogCurrentHeadQueryV1(query), + { toString: () => 'requester-peer' }, + )).rejects.toMatchObject({ code: 'catalog-discovery-object-mismatch' }); + transport.stop(); + }); + + it('rejects an issuer proof minted for a different head envelope', async () => { + let handler: (( + data: Uint8Array, + peerId: { toString(): string }, + options?: { signal?: AbortSignal }, + ) => Promise) | undefined; + const stored = await produceHeadWithProof( + catalogScope(), + '1773900000000' as TimestampMsV1, + ); + const other = await produceHeadWithProof( + catalogScope(), + '1773900000001' as TimestampMsV1, + ); + const router = { + register(_protocol: string, registered: typeof handler) { + handler = registered; + }, + unregister() {}, + } as unknown as ProtocolRouter; + const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { + controlObjects: { + getVerifiedObjectByDigest: vi.fn(async () => ({ + envelope: stored.head, + issuerSignature: other.issuerSignature, + })), + }, + readCurrentAppliedCatalogHeadDigest: vi.fn(async () => + stored.head.objectDigest as Digest32V1), + authorizeOpenCatalogOperation: vi.fn(async () => ({ + accessPolicy: 0 as const, + policyDigest: POLICY_DIGEST, + })), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + transport.start(); + const query = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + ...discoveryScope(), + policyDigest: POLICY_DIGEST, + }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; + + await expect(handler!( + encodeRfc64PublicCatalogCurrentHeadQueryV1(query), + { toString: () => 'requester-peer' }, + )).rejects.toMatchObject({ code: 'catalog-discovery-signature' }); + transport.stop(); + }); + + it('rechecks outbound policy after the awaited remote response', async () => { + const authorizeOpenCatalogOperation = vi.fn() + .mockResolvedValueOnce({ accessPolicy: 0 as const, policyDigest: POLICY_DIGEST }) + .mockResolvedValueOnce(null); + const send = vi.fn(async () => Uint8Array.of(0)); + const router = { + register() {}, + unregister() {}, + send, + } as unknown as ProtocolRouter; + const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { + controlObjects: { getVerifiedObjectByDigest: vi.fn(async () => null) }, + readCurrentAppliedCatalogHeadDigest: vi.fn(async () => null), + authorizeOpenCatalogOperation, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + transport.start(); + const query = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + ...discoveryScope(), + policyDigest: POLICY_DIGEST, + }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; + + await expect(transport.discoverCurrentCatalogHead('provider-peer', query)) + .rejects.toMatchObject({ code: 'catalog-discovery-policy-denied' }); + expect(send).toHaveBeenCalledTimes(1); + expect(authorizeOpenCatalogOperation).toHaveBeenCalledTimes(2); + transport.stop(); + }); + + it('returns denied when inbound policy changes after awaited state reads', async () => { + let handler: (( + data: Uint8Array, + peerId: { toString(): string }, + options?: { signal?: AbortSignal }, + ) => Promise) | undefined; + const authorizeOpenCatalogOperation = vi.fn() + .mockResolvedValueOnce({ accessPolicy: 0 as const, policyDigest: POLICY_DIGEST }) + .mockResolvedValueOnce(null); + const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); + const router = { + register(_protocol: string, registered: typeof handler) { + handler = registered; + }, + unregister() {}, + } as unknown as ProtocolRouter; + const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { + controlObjects: { getVerifiedObjectByDigest: vi.fn(async () => null) }, + readCurrentAppliedCatalogHeadDigest, + authorizeOpenCatalogOperation, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + transport.start(); + const query = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + ...discoveryScope(), + policyDigest: POLICY_DIGEST, + }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; + + await expect(handler!( + encodeRfc64PublicCatalogCurrentHeadQueryV1(query), + { toString: () => 'requester-peer' }, + )).resolves.toEqual(Uint8Array.of(2)); + expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalledTimes(2); + expect(authorizeOpenCatalogOperation).toHaveBeenCalledTimes(2); + transport.stop(); + }); + + it('rejects a non-root discovery claim through the shared public scope derivation', async () => { + const [node, persistence] = await Promise.all([ + startNode(), + openPersistence('scope-derivation'), + ]); + const service = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(node), + controlObjects: persistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, + transportTimeoutMs: 1_000, + }); + services.push(service); + acceptPolicy(service); + service.start(); + + await expect(service.discoverCurrentCatalogHead({ + remotePeerId: 'unreachable-peer', + scope: Object.freeze({ ...discoveryScope(), subGraphName: 'nested' as const }), + })).rejects.toThrow(/accepted public\/open root policy/); + }); + it('round-trips only exact canonical query fields', () => { const query = Object.freeze({ kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, ...discoveryScope(), - policyDigest: `0x${'71'.repeat(32)}` as Digest32V1, + policyDigest: POLICY_DIGEST, }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; const encoded = encodeRfc64PublicCatalogCurrentHeadQueryV1(query); expect(parseRfc64PublicCatalogCurrentHeadQueryV1(encoded)).toEqual(query); @@ -425,7 +627,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { const query = Object.freeze({ kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, ...discoveryScope(), - policyDigest: `0x${'71'.repeat(32)}` as Digest32V1, + policyDigest: POLICY_DIGEST, }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; const expected = encodeRfc64PublicCatalogCurrentHeadQueryV1(query); let switchedReads = 0; @@ -472,7 +674,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); const authorizeOpenCatalogOperation = vi.fn(async () => ({ accessPolicy: 0 as const, - policyDigest: `0x${'71'.repeat(32)}` as Digest32V1, + policyDigest: POLICY_DIGEST, })); const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { controlObjects: { @@ -486,7 +688,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { const query = Object.freeze({ kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, ...discoveryScope(), - policyDigest: `0x${'71'.repeat(32)}` as Digest32V1, + policyDigest: POLICY_DIGEST, }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; const controller = new AbortController(); const reason = new Error('test stream closed'); From 901c0b5095867851bf4732733144f6294d2eb385 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 23:37:14 +0200 Subject: [PATCH 275/292] fix(agent): close RFC-64 discovery review boundaries --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/index.ts | 1 + .../catalog-transport-wire-v1-internal.ts | 7 +- .../public-catalog-native-reconciler-v1.ts | 53 +-------------- .../src/rfc64/public-catalog-service-v1.ts | 2 +- .../src/rfc64/public-open-catalog-scope-v1.ts | 67 +++++++++++++++++++ ...-catalog-current-head-discovery-v1.test.ts | 8 +++ ...ublic-catalog-native-reconciler-v1.test.ts | 2 +- 9 files changed, 85 insertions(+), 57 deletions(-) create mode 100644 packages/agent/src/rfc64/public-open-catalog-scope-v1.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index 56539e315f..f4ae5bf4b1 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -43,6 +43,7 @@ "./dist/rfc64/public-catalog-native-receiver-v1.js": null, "./dist/rfc64/public-catalog-native-reconciler-v1.js": null, "./dist/rfc64/public-catalog-native-transport-v1.js": null, + "./dist/rfc64/public-open-catalog-scope-v1.js": null, "./dist/rfc64/public-catalog-reconciliation-failure-v1.js": null, "./dist/rfc64/public-catalog-receiver-v1.js": null, "./dist/rfc64/public-catalog-service-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index eea83e7363..d6ea163fb8 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -118,6 +118,7 @@ const blockedRfc64Modules = [ 'public-catalog-native-reconciler-v1.js', 'public-catalog-native-receiver-v1.js', 'public-catalog-native-transport-v1.js', + 'public-open-catalog-scope-v1.js', 'public-catalog-reconciliation-failure-v1.js', 'public-catalog-receiver-v1.js', 'public-catalog-service-v1.js', diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index fcc0ad7ebf..06a9cb13a7 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -54,6 +54,7 @@ export { type Rfc64AppliedInventoryDigestRowV1, } from './rfc64/public-catalog-inventory-completeness-v1.js'; export * from './rfc64/public-catalog-successor-producer-v1.js'; +export * from './rfc64/public-open-catalog-scope-v1.js'; export * from './rfc64/public-catalog-native-reconciler-v1.js'; export * from './rfc64/policy-cell-v1.js'; export { encrypt, decrypt, ed25519ToX25519Private, ed25519ToX25519Public, x25519SharedSecret } from './encryption.js'; diff --git a/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts b/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts index e7644c401f..7da17954c9 100644 --- a/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts +++ b/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts @@ -113,11 +113,12 @@ export function snapshotRfc64ExactWireRecordV1( utilityFail('exact-keys', 'RFC-64 wire value has symbol fields'); } const actual = [...ownKeys as readonly string[]].sort(); + const expected = expectedKeys === undefined ? undefined : [...expectedKeys].sort(); if ( - expectedKeys !== undefined + expected !== undefined && ( - actual.length !== expectedKeys.length - || actual.some((key, index) => key !== expectedKeys[index]) + actual.length !== expected.length + || actual.some((key, index) => key !== expected[index]) ) ) { utilityFail('exact-keys', 'RFC-64 wire value has missing or unknown fields'); diff --git a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts index 2e0ddc7e53..13c4557004 100644 --- a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts @@ -16,7 +16,6 @@ import { type AuthorCatalogScopeV1, type CatalogSealDeploymentProfileV1, type CountV1, - type ContextGraphPolicyV1, type Digest32V1, type SignedAuthorCatalogHeadEnvelopeV1, } from '@origintrail-official/dkg-core'; @@ -30,18 +29,7 @@ import type { Rfc64PublicCatalogReceiverReconcilerV1, Rfc64PublicCatalogReconcileResultV1, } from './public-catalog-receiver-v1.js'; -import type { - Rfc64PublicCatalogHeadAnnouncementV1, -} from './public-catalog-transport-v1.js'; - -/** Wire fields that identify one claimed public/open root author-catalog scope. */ -export interface Rfc64PublicOpenCatalogScopeClaimV1 { - readonly networkId: Rfc64PublicCatalogHeadAnnouncementV1['networkId']; - readonly contextGraphId: Rfc64PublicCatalogHeadAnnouncementV1['contextGraphId']; - readonly subGraphName: Rfc64PublicCatalogHeadAnnouncementV1['subGraphName']; - readonly authorAddress: Rfc64PublicCatalogHeadAnnouncementV1['authorAddress']; - readonly catalogEra: Rfc64PublicCatalogHeadAnnouncementV1['catalogEra']; -} +import type { Rfc64PublicCatalogHeadAnnouncementV1 } from './public-catalog-transport-v1.js'; export type Rfc64BoundedPublicRootCatalogNativeReceiverClientV1 = Pick< Rfc64PublicCatalogNativeReceiverV1, @@ -82,45 +70,6 @@ export interface Rfc64BoundedPublicRootCatalogNativeReconcilerOptionsV1 { readonly readStagedCatalogHead?: Rfc64BoundedPublicRootCatalogStagedHeadReaderV1; } -/** - * Derive the one fixed Gate-1 public/open scope from accepted local policy and - * require the announcement to name that exact owner/network/CG/era/root lane. - * Policy and signature-variant digests are transport/authentication context, - * not semantic catalog identity, and therefore do not participate. - */ -export function deriveRfc64PublicOpenCatalogScopeV1( - claim: Rfc64PublicOpenCatalogScopeClaimV1, - acceptedPolicy: ContextGraphPolicyV1, -): AuthorCatalogScopeV1 { - if ( - acceptedPolicy.accessPolicy !== 0 - || acceptedPolicy.source.kind !== 'owner-signed-unregistered' - || acceptedPolicy.networkId !== claim.networkId - || acceptedPolicy.contextGraphId !== claim.contextGraphId - || acceptedPolicy.governanceChainId !== null - || acceptedPolicy.governanceContractAddress !== null - || acceptedPolicy.ownershipTransitionDigest !== null - || acceptedPolicy.era !== claim.catalogEra - || claim.subGraphName !== null - || acceptedPolicy.source.ownerAddress !== claim.authorAddress - ) { - throw new Error( - 'RFC-64 public/open catalog claim is not bound to the accepted null-governance owner policy', - ); - } - return Object.freeze({ - networkId: acceptedPolicy.networkId, - contextGraphId: acceptedPolicy.contextGraphId, - governanceChainId: acceptedPolicy.governanceChainId, - governanceContractAddress: acceptedPolicy.governanceContractAddress, - ownershipTransitionDigest: acceptedPolicy.ownershipTransitionDigest, - subGraphName: null, - authorAddress: acceptedPolicy.source.ownerAddress, - era: acceptedPolicy.era, - bucketCount: '1' as CountV1, - }); -} - export class Rfc64BoundedPublicRootCatalogNativeReconcilerV1 implements Rfc64PublicCatalogReceiverReconcilerV1 { constructor( diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index fea2a1ddc9..918adfd064 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -84,9 +84,9 @@ import { produceDirectAuthorCatalogIssuerDelegationV1, } from './public-catalog-issuer-delegation-v1.js'; import { - deriveRfc64PublicOpenCatalogScopeV1, type Rfc64BoundedPublicRootCatalogTrustedScopeResolverV1, } from './public-catalog-native-reconciler-v1.js'; +import { deriveRfc64PublicOpenCatalogScopeV1 } from './public-open-catalog-scope-v1.js'; import type { Rfc64PublicCatalogIssuerAuthorizationV1, } from './public-catalog-successor-producer-v1.js'; diff --git a/packages/agent/src/rfc64/public-open-catalog-scope-v1.ts b/packages/agent/src/rfc64/public-open-catalog-scope-v1.ts new file mode 100644 index 0000000000..cdf236001b --- /dev/null +++ b/packages/agent/src/rfc64/public-open-catalog-scope-v1.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shared policy primitive for the RFC-64 public/open root author-catalog lane. + * + * This module deliberately sits below transport, service, and reconciliation: + * each caller derives catalog authority from independently accepted local + * policy state rather than reconstructing it from peer-controlled wire data. + */ + +import { + type AuthorCatalogScopeV1, + type ContextGraphPolicyV1, + type CountV1, +} from '@origintrail-official/dkg-core'; + +import type { + Rfc64PublicCatalogHeadAnnouncementV1, +} from './public-catalog-transport-v1.js'; + +/** Wire fields that identify one claimed public/open root author-catalog scope. */ +export interface Rfc64PublicOpenCatalogScopeClaimV1 { + readonly networkId: Rfc64PublicCatalogHeadAnnouncementV1['networkId']; + readonly contextGraphId: Rfc64PublicCatalogHeadAnnouncementV1['contextGraphId']; + readonly subGraphName: Rfc64PublicCatalogHeadAnnouncementV1['subGraphName']; + readonly authorAddress: Rfc64PublicCatalogHeadAnnouncementV1['authorAddress']; + readonly catalogEra: Rfc64PublicCatalogHeadAnnouncementV1['catalogEra']; +} + +/** + * Derive the one fixed Gate-1 public/open scope from accepted local policy and + * require the peer claim to name that exact owner/network/CG/era/root lane. + * Policy and signature-variant digests are transport/authentication context, + * not semantic catalog identity, and therefore do not participate. + */ +export function deriveRfc64PublicOpenCatalogScopeV1( + claim: Rfc64PublicOpenCatalogScopeClaimV1, + acceptedPolicy: ContextGraphPolicyV1, +): AuthorCatalogScopeV1 { + if ( + acceptedPolicy.accessPolicy !== 0 + || acceptedPolicy.source.kind !== 'owner-signed-unregistered' + || acceptedPolicy.networkId !== claim.networkId + || acceptedPolicy.contextGraphId !== claim.contextGraphId + || acceptedPolicy.governanceChainId !== null + || acceptedPolicy.governanceContractAddress !== null + || acceptedPolicy.ownershipTransitionDigest !== null + || acceptedPolicy.era !== claim.catalogEra + || claim.subGraphName !== null + || acceptedPolicy.source.ownerAddress !== claim.authorAddress + ) { + throw new Error( + 'RFC-64 public/open catalog claim is not bound to the accepted null-governance owner policy', + ); + } + return Object.freeze({ + networkId: acceptedPolicy.networkId, + contextGraphId: acceptedPolicy.contextGraphId, + governanceChainId: acceptedPolicy.governanceChainId, + governanceContractAddress: acceptedPolicy.governanceContractAddress, + ownershipTransitionDigest: acceptedPolicy.ownershipTransitionDigest, + subGraphName: null, + authorAddress: acceptedPolicy.source.ownerAddress, + era: acceptedPolicy.era, + bucketCount: '1' as CountV1, + }); +} diff --git a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts index 857d557744..34dcba7301 100644 --- a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts @@ -17,6 +17,7 @@ import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { produceEmptyAuthorCatalogGenesisV1 } from '../src/rfc64/author-catalog-producer.js'; +import { snapshotRfc64ExactWireRecordV1 } from '../src/rfc64/catalog-transport-wire-v1-internal.js'; import { RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1, RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, @@ -167,6 +168,13 @@ function discoveryScope() { } describe('RFC-64 public catalog current-head discovery v1', () => { + it('treats an unsorted expected-key declaration as an exact key set', () => { + expect(snapshotRfc64ExactWireRecordV1( + { alpha: '1', beta: '2' }, + ['beta', 'alpha'], + )).toEqual({ alpha: '1', beta: '2' }); + }); + it('discovers and exact-verifies one applied head without staging or scheduling it', async () => { const [providerNode, requesterNode, providerPersistence, requesterPersistence] = await Promise.all([ diff --git a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts index 8897a3a0b4..0f0cf1c0f7 100644 --- a/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-reconciler-v1.test.ts @@ -10,10 +10,10 @@ import type { AppliedCatalogHeadSnapshotV1 } from '../src/rfc64/inventory-v1/ind import { buildOpenOwnerContextGraphPolicyV1 } from '../src/rfc64/open-catalog-policy-v1.js'; import { createRfc64BoundedPublicRootCatalogNativeReconcilerV1, - deriveRfc64PublicOpenCatalogScopeV1, type Rfc64BoundedPublicRootCatalogNativeReceiverClientV1, type Rfc64BoundedPublicRootCatalogStagedHeadV1, } from '../src/rfc64/public-catalog-native-reconciler-v1.js'; +import { deriveRfc64PublicOpenCatalogScopeV1 } from '../src/rfc64/public-open-catalog-scope-v1.js'; import { Rfc64PublicCatalogNativeReceiverErrorV1, } from '../src/rfc64/public-catalog-native-receiver-v1.js'; From 60cc042af6b078e9b1273a486ef9fc29a905d6fd Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 23:42:18 +0200 Subject: [PATCH 276/292] feat(agent): cold-start RFC-64 from provider head --- packages/agent/src/dkg-agent-rfc64-catalog.ts | 77 +++++++++++++ .../public-catalog-native-receiver-v1.ts | 77 ++++++++++--- .../public-catalog-native-reconciler-v1.ts | 34 ++++++ .../src/rfc64/public-catalog-service-v1.ts | 60 +++++++++- ...kg-agent-native-wiring.integration.test.ts | 109 ++++++++++++++++++ ...-catalog-current-head-discovery-v1.test.ts | 2 +- ...c-catalog-native-gate1.integration.test.ts | 43 ++++++- 7 files changed, 378 insertions(+), 24 deletions(-) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 7c155f886d..3bc98d65be 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -81,6 +81,9 @@ import { Rfc64PublicCatalogNativeReceiverV1, type Rfc64PublicCatalogNativeSynchronizationEvidenceV1, } from './rfc64/public-catalog-native-receiver-v1.js'; +import type { + Rfc64PublicCatalogCurrentHeadScopeV1, +} from './rfc64/public-catalog-current-head-discovery-v1.js'; import { createRfc64FinalizedVmAgentPrecommitV1 } from './rfc64/finalized-vm-agent-precommit-v1.js'; import { createRfc64BoundedPublicRootCatalogNativeReconcilerV1, @@ -167,6 +170,20 @@ export interface Rfc64AppliedCatalogHeadRefV1 { readonly authorAddress: EvmAddressV1; } +/** Explicit provider + independently accepted public-root scope for a cold pull. */ +export interface SynchronizeRfc64PublicCatalogFromProviderParamsV1 { + readonly remotePeerId: string; + readonly scope: Rfc64PublicCatalogCurrentHeadScopeV1; + readonly signal?: AbortSignal; +} + +/** Exact durable postcondition of a successful provider synchronization. */ +export interface SynchronizeRfc64PublicCatalogFromProviderResultV1 + extends AppliedCatalogHeadSnapshotV1 { + readonly providerPeerId: string; + readonly signatureVariantDigest: Digest32V1; +} + export { RFC64_PUBLIC_CATALOG_RECONCILIATION_FAILURE_MAX_ENTRIES_V1, type Rfc64PublicCatalogReconciliationFailureV1, @@ -280,6 +297,15 @@ export class Rfc64CatalogMethods extends DKGAgentBase { controlObjects: persistence.controlObjects, accessPolicyAuthority: this.config.rfc64CatalogAccessPolicyAuthority, native: this.createRfc64PublicCatalogNativeOptionsV1(), + currentHeadDiscovery: { + readCurrentAppliedCatalogHeadDigest: async (trustedScope) => { + const applied = persistence.inventory.readAppliedCatalogHeadV1( + computeAuthorCatalogScopeDigestV1(trustedScope), + trustedScope.authorAddress, + ); + return applied?.currentCatalogHeadDigest ?? null; + }, + }, receiver: { onError: (announcement, error) => { this.rfc64PublicCatalogReconciliationFailuresV1.record( @@ -406,6 +432,56 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return this.requireRfc64PublicCatalogServiceV1().announceCatalogHead(input); } + /** + * Pull one provider's authenticated current public-root head and run it + * through the ordinary durable receiver. `null` means the provider has no + * applied head for the accepted scope. A non-null return proves the exact + * discovered head is now the receiver's durable applied-head post-read. + */ + async synchronizeRfc64PublicCatalogFromProviderV1( + this: DKGAgent, + params: SynchronizeRfc64PublicCatalogFromProviderParamsV1, + ): Promise { + const providerPeerId = params.remotePeerId; + const scope = params.scope; + const signal = params.signal; + const synchronized = await this.requireRfc64PublicCatalogServiceV1() + .synchronizeCurrentCatalogHead({ + remotePeerId: providerPeerId, + scope, + ...(signal === undefined ? {} : { signal }), + }); + if (synchronized === null) return null; + const catalogScope = deriveAuthorCatalogScopeFromHeadV1( + synchronized.head.envelope.payload, + ); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(catalogScope); + const applied = this.rfc64PersistenceV1?.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + synchronized.announcement.authorAddress, + ) ?? null; + if ( + applied === null + || applied.currentCatalogHeadDigest + !== synchronized.announcement.catalogHeadObjectDigest + || applied.catalogVersion !== synchronized.announcement.catalogVersion + ) { + const failure = this.readRfc64PublicCatalogReconciliationFailureV1( + synchronized.announcement.catalogHeadObjectDigest, + ); + throw new Error( + failure === null + ? 'RFC-64 current public catalog head did not reach its durable applied postcondition' + : `RFC-64 current public catalog head reconciliation failed (${failure.errorCode ?? failure.errorName})`, + ); + } + return Object.freeze({ + ...applied, + providerPeerId, + signatureVariantDigest: synchronized.announcement.signatureVariantDigest, + }); + } + /** * Public/open author path for one exact root-lane successor. The predecessor * is reloaded from durable provider storage, the hardened producer performs @@ -779,6 +855,7 @@ export class Rfc64CatalogMethods extends DKGAgentBase { contentTransport: clients.contentTransport, controlObjects: persistence.controlObjects, inventory: persistence.inventory, + kaBundles: persistence.kaBundles, store: this.store, beforeAppliedHeadCommit: finalizedVmPrecommit, transportTimeoutMs: clients.transportTimeoutMs, diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index c9e8b5128f..e95e5d0f94 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -83,6 +83,7 @@ import type { AppliedCatalogHeadSnapshotV1, Rfc64InventoryV1OperationsV1, } from './inventory-v1/index.js'; +import type { Rfc64KaBundleOperationsV1 } from './ka-bundle-store-v1.js'; import { assertRecoverableAuthorAttestationCapabilityV1 } from './recoverable-author-attestation-v1.js'; import { computeRfc64AppliedInventoryDigestV1, @@ -128,6 +129,8 @@ export interface Rfc64PublicCatalogNativeReceiverOptionsV1 { Rfc64InventoryV1OperationsV1, 'readAppliedCatalogHeadV1' | 'compareAndSwapAppliedCatalogHeadV1' >; + /** Durable immutable cache that makes every applied receiver a provider. */ + readonly kaBundles: Pick; readonly store: TripleStore; /** * Final fail-closed same-process barrier. It runs after the exact SWM @@ -261,6 +264,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { || typeof options.controlObjects?.getVerifiedObjectByDigest !== 'function' || typeof options.inventory?.readAppliedCatalogHeadV1 !== 'function' || typeof options.inventory?.compareAndSwapAppliedCatalogHeadV1 !== 'function' + || typeof options.kaBundles?.putKaBundle !== 'function' || typeof options.store?.query !== 'function' || ( options.beforeAppliedHeadCommit !== undefined @@ -554,7 +558,11 @@ export class Rfc64PublicCatalogNativeReceiverV1 { catalogScopeDigest, head.payload.authorAddress, ); - const replay = assertMonotonicSuccessorHistory(currentAppliedHead, head, expectedRowCount); + const historyDisposition = classifySuccessorHistory( + currentAppliedHead, + head, + expectedRowCount, + ); const scope = nativeScope(announcement, trustedCatalogScope, head); const fetchedDelegation = await this.fetchDirectAuthorCatalogIssuerDelegation( remotePeerId, @@ -664,6 +672,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { readonly projectionMetadata: ReturnType; readonly sealBinding: VerifiedCatalogSealBindingSnapshotV1; readonly sealBindingCapability: VerifiedCatalogSealBindingV1; + readonly bundleBytes: Uint8Array; readonly projectionBytes: Uint8Array; readonly expectedEvidence: Rfc64PublicCatalogInventoryEvidenceRowV1; }> = []; @@ -763,6 +772,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { projectionMetadata, sealBinding, sealBindingCapability, + bundleBytes: new Uint8Array(bundle), projectionBytes, expectedEvidence, })); @@ -792,17 +802,40 @@ export class Rfc64PublicCatalogNativeReceiverV1 { // predecessor after every target row/bundle has verified, but before the // first semantic mutation. This also makes exact-head replay a repair path // for a prior indeterminate removal failure. - const predecessorRows = await loadExactAppliedPredecessorRows( - this.options.controlObjects, - head, - trustedCatalogScope, - ); + // Every bounded successor is a complete signed exact set. A receiver with + // no applied-head record can therefore initialize directly from the + // authenticated current snapshot without replaying historical versions. + // Existing receivers still reconstruct their exact durable predecessor so + // only rows owned by that applied closure may be removed. + const predecessorRows = historyDisposition === 'cold-bootstrap' + ? Object.freeze([]) as readonly Readonly[] + : await loadExactAppliedPredecessorRows( + this.options.controlObjects, + head, + trustedCatalogScope, + ); const targetKaIds = new Set(preparedRows.map(({ row }) => row.kaId)); const plannedRemovals = predecessorRows .filter((row) => !targetKaIds.has(row.kaId)) .map((row) => planOwnedRowRemoval(trustedCatalogScope, row)); try { + // An applied-head pointer advertises this node as a current provider, so + // every exact bundle must cross its immutable durability barrier before + // semantic activation and before that pointer can advance. + for (const prepared of preparedRows) { + const receipt = await this.options.kaBundles.putKaBundle({ + blobDigest: prepared.row.transfer.blobDigest, + bundleBytes: prepared.bundleBytes, + }); + if ( + receipt.durable !== true + || receipt.blobDigest !== prepared.row.transfer.blobDigest + || receipt.byteLength.toString() !== prepared.row.transfer.byteLength + ) { + throw new Error('KA-bundle store returned a different durable receipt'); + } + } await this.options.controlObjects.stageVerifiedObjects([ fetchedDelegation, fetchedHead, @@ -810,7 +843,11 @@ export class Rfc64PublicCatalogNativeReceiverV1 { fetchedBucket, ]); } catch (cause) { - fail('catalog-native-receiver-catalog', 'verified catalog objects could not be staged', cause); + fail( + 'catalog-native-receiver-catalog', + 'verified catalog objects or KA bundles could not be staged', + cause, + ); } const transitionJournal = await snapshotSemanticTransitionV1( @@ -922,7 +959,7 @@ export class Rfc64PublicCatalogNativeReceiverV1 { } let appliedHeadStatus: 'applied' | 'existing'; - if (replay) { + if (historyDisposition === 'replay') { if (currentAppliedHead!.appliedInventoryDigest !== completion.inventoryDigest) { fail( 'catalog-native-receiver-history', @@ -935,7 +972,9 @@ export class Rfc64PublicCatalogNativeReceiverV1 { appliedHeadStatus = this.options.inventory.compareAndSwapAppliedCatalogHeadV1({ catalogScopeDigest, authorAddress: head.payload.authorAddress, - expectedCurrentCatalogHeadDigest: head.payload.previousHeadDigest, + expectedCurrentCatalogHeadDigest: historyDisposition === 'cold-bootstrap' + ? null + : head.payload.previousHeadDigest, currentCatalogHeadDigest: head.objectDigest as Digest32V1, appliedInventoryDigest: completion.inventoryDigest, catalogVersion: head.payload.version, @@ -1227,17 +1266,17 @@ function isExactEmptyGenesisSnapshot( && current.inventoryRowCount === '0'; } -function assertMonotonicSuccessorHistory( +type Rfc64SuccessorHistoryDispositionV1 = + | 'cold-bootstrap' + | 'monotonic-successor' + | 'replay'; + +function classifySuccessorHistory( current: AppliedCatalogHeadSnapshotV1 | null, head: SignedAuthorCatalogHeadEnvelopeV1, expectedRowCount: number, -): boolean { - if (current === null) { - fail( - 'catalog-native-receiver-history', - 'successor requires a durable initialized predecessor head', - ); - } +): Rfc64SuccessorHistoryDispositionV1 { + if (current === null) return 'cold-bootstrap'; if (current.currentCatalogHeadDigest === head.objectDigest) { if ( current.catalogVersion !== head.payload.version @@ -1245,7 +1284,7 @@ function assertMonotonicSuccessorHistory( ) { fail('catalog-native-receiver-history', 'replayed head differs from its durable applied state'); } - return true; + return 'replay'; } if ( current.currentCatalogHeadDigest !== head.payload.previousHeadDigest @@ -1256,7 +1295,7 @@ function assertMonotonicSuccessorHistory( 'successor does not monotonically extend the durable current head', ); } - return false; + return 'monotonic-successor'; } function assertBoundedSuccessorHead(head: SignedAuthorCatalogHeadEnvelopeV1): number { diff --git a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts index 2e0ddc7e53..75419ec89d 100644 --- a/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-reconciler-v1.ts @@ -121,6 +121,40 @@ export function deriveRfc64PublicOpenCatalogScopeV1( }); } +/** + * Derive a public root catalog scope for any accepted public policy cell, + * including finalized-chain policies and non-owner SWM authors. Principal + * authorization remains the accepted-policy registry's responsibility; this + * pure helper binds only the graph/governance/lane/era identity. + */ +export function deriveRfc64PublicRootCatalogScopeV1( + claim: Rfc64PublicOpenCatalogScopeClaimV1, + acceptedPolicy: ContextGraphPolicyV1, +): AuthorCatalogScopeV1 { + if ( + acceptedPolicy.accessPolicy !== 0 + || acceptedPolicy.networkId !== claim.networkId + || acceptedPolicy.contextGraphId !== claim.contextGraphId + || acceptedPolicy.era !== claim.catalogEra + || claim.subGraphName !== null + ) { + throw new Error( + 'RFC-64 public catalog claim is not bound to the accepted public root policy', + ); + } + return Object.freeze({ + networkId: acceptedPolicy.networkId, + contextGraphId: acceptedPolicy.contextGraphId, + governanceChainId: acceptedPolicy.governanceChainId, + governanceContractAddress: acceptedPolicy.governanceContractAddress, + ownershipTransitionDigest: acceptedPolicy.ownershipTransitionDigest, + subGraphName: null, + authorAddress: claim.authorAddress, + era: acceptedPolicy.era, + bucketCount: '1' as CountV1, + }); +} + export class Rfc64BoundedPublicRootCatalogNativeReconcilerV1 implements Rfc64PublicCatalogReceiverReconcilerV1 { constructor( diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index fea2a1ddc9..e590f93feb 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -85,6 +85,7 @@ import { } from './public-catalog-issuer-delegation-v1.js'; import { deriveRfc64PublicOpenCatalogScopeV1, + deriveRfc64PublicRootCatalogScopeV1, type Rfc64BoundedPublicRootCatalogTrustedScopeResolverV1, } from './public-catalog-native-reconciler-v1.js'; import type { @@ -228,6 +229,14 @@ export interface DiscoveredRfc64PublicCatalogCurrentHeadV1 { readonly head: FetchedRfc64PublicCatalogHeadV1; } +/** + * A current head that was authenticated through discovery and handed to the + * receiver scheduler. The receiver remains the only owner of semantic + * activation and the durable applied-head commit. + */ +export type SynchronizedRfc64PublicCatalogCurrentHeadV1 = + DiscoveredRfc64PublicCatalogCurrentHeadV1; + export interface Rfc64PublicCatalogServiceStatsV1 { readonly started: boolean; readonly acceptedPolicies: number; @@ -602,6 +611,39 @@ export class Rfc64PublicCatalogServiceV1 { return Object.freeze({ announcement, head }); } + /** + * Discover one provider's current public/open head, enqueue that exact + * authenticated head through the ordinary receiver, and wait for all + * scheduled reconciliation work to drain. A caller must still inspect the + * durable applied-head record: receiver failures are reported through its + * bounded diagnostic channel rather than thrown from the scheduler. + * + * Once discovery has completed, reconciliation is deliberately durable work + * owned by the receiver lifecycle. Aborting the caller's signal after that + * boundary does not cancel a semantic transition already accepted by the + * scheduler; service close remains the cancellation authority. + */ + async synchronizeCurrentCatalogHead( + input: DiscoverRfc64PublicCatalogCurrentHeadInputV1, + ): Promise { + // Snapshot caller-owned values before discovery's first await so a Proxy or + // switching accessor cannot redirect the later scheduled fetch to another + // provider. + const remotePeerId = input.remotePeerId; + const scope = input.scope; + const signal = input.signal; + const discovered = await this.discoverCurrentCatalogHead({ + remotePeerId, + scope, + ...(signal === undefined ? {} : { signal }), + }); + if (discovered === null) return null; + if (signal?.aborted) throw signal.reason; + this.#receiver.schedule(discovered.announcement, remotePeerId); + await this.#receiver.whenIdle(); + return discovered; + } + /** Idle-await the receiver (tests / graceful shutdown coordination). */ whenReceiverIdle(): Promise { return this.#receiver.whenIdle(); @@ -719,10 +761,24 @@ export class Rfc64PublicCatalogServiceV1 { ); } try { - return deriveRfc64PublicOpenCatalogScopeV1(input, record.policy); + if (!this.#policies.isSwmAuthorAuthorized({ + networkId: input.networkId, + contextGraphId: input.contextGraphId, + policyDigest: record.policyDigest, + authorAddress: input.authorAddress, + })) { + throw new Error('catalog author is not authorized by the accepted policy'); + } + return record.policy.source.kind === 'owner-signed-unregistered' + && record.policy.governanceChainId === null + && record.policy.governanceContractAddress === null + && record.policy.ownershipTransitionDigest === null + && record.policy.source.ownerAddress === input.authorAddress + ? deriveRfc64PublicOpenCatalogScopeV1(input, record.policy) + : deriveRfc64PublicRootCatalogScopeV1(input, record.policy); } catch (cause) { throw new Error( - 'RFC-64 current-head query is not bound to the accepted public/open root policy', + 'RFC-64 current-head query is not bound to the accepted public root policy', { cause }, ); } diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 97593914d2..5858ee9ac8 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -545,6 +545,115 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); + it('cold-starts after publication from a provider current-head snapshot', async () => { + const [author, provider] = await Promise.all([ + startNativeAgent('cold-author'), + startNativeAgent('cold-provider'), + ]); + provider.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + await connectBothWays(author, provider); + + const genesis = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [provider.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: MULTI_DELEGATION_EXPIRES_AT, + }); + await provider.whenRfc64PublicCatalogReceiverIdleV1(); + const successor = await author.publishOpenAuthorCatalogSuccessorV1({ + previousHead: { + objectDigest: genesis.headObjectDigest, + signatureVariantDigest: genesis.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + assertionCoordinate: 'cold-current-snapshot' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(7n), + deployment: NATIVE_DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [provider.peerId], + }); + await provider.whenRfc64PublicCatalogReceiverIdleV1(); + expect(provider.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })?.currentCatalogHeadDigest).toBe(successor.headObjectDigest); + + // The third agent does not exist until after the successor is durable on + // the provider, so it cannot have observed either publication hint. + const cold = await startNativeAgent('cold-late-receiver'); + cold.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + expect(cold.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toBeNull(); + await connectBothWays(provider, cold); + + const synchronized = await cold.synchronizeRfc64PublicCatalogFromProviderV1({ + remotePeerId: provider.peerId, + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + }, + }); + expect(synchronized).toMatchObject({ + providerPeerId: provider.peerId, + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + currentCatalogHeadDigest: successor.headObjectDigest, + signatureVariantDigest: successor.signatureVariantDigest, + catalogVersion: '1', + inventoryRowCount: '1', + }); + expect(cold.readRfc64PublicCatalogReconciliationFailureV1( + successor.headObjectDigest, + )).toBeNull(); + expect(cold.readRfc64PublicCatalogSynchronizationEvidenceV1( + successor.headObjectDigest, + )).toMatchObject({ + catalogHeadDigest: successor.headObjectDigest, + inventoryRowCount: 1, + activatedTripleCount: 2, + removedRowCount: 0, + appliedHeadStatus: 'applied', + }); + expect(cold.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + scheduled: 1, + applied: 1, + failed: 0, + }); + + const swmGraph = contextGraphLayerUri( + CONTEXT_GRAPH_ID, + MemoryLayer.SharedWorkingMemory, + AUTHOR, + 7, + ); + await expect((cold as any).store.query( + `SELECT ?s ?p ?o WHERE { GRAPH <${swmGraph}> { ?s ?p ?o } }`, + )).resolves.toMatchObject({ + type: 'bindings', + bindings: expect.arrayContaining([ + expect.objectContaining({ s: 'https://example.org/alice' }), + ]), + }); + }, 60_000); + it('materializes finalized VM through production two-agent wiring before applying the head', async () => { const kaNumber = 7n; const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); diff --git a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts index 857d557744..46cdfacf90 100644 --- a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts @@ -599,7 +599,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { await expect(service.discoverCurrentCatalogHead({ remotePeerId: 'unreachable-peer', scope: Object.freeze({ ...discoveryScope(), subGraphName: 'nested' as const }), - })).rejects.toThrow(/accepted public\/open root policy/); + })).rejects.toThrow(/accepted public root policy/); }); it('round-trips only exact canonical query fields', () => { diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index 187f59222a..e448645511 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -845,6 +845,32 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); }, 30_000); + it('withholds staging, semantic mutation, and head CAS when verified bundle retention fails', async () => { + const fixture = await setupLiveReceiver(); + await fixture.bootstrap(); + const putKaBundle = vi.fn(async () => { + throw new Error('simulated durable bundle-store failure'); + }); + const observed = fixture.createCasObservedReceiver( + undefined, + fixture.receiverStore, + { putKaBundle }, + ); + + await expect(fixture.synchronize( + fixture.announcement, + observed.receiver, + )).rejects.toMatchObject({ code: 'catalog-native-receiver-catalog' }); + expect(putKaBundle).toHaveBeenCalledTimes(1); + expect(observed.stageVerifiedObjects).not.toHaveBeenCalled(); + expect(observed.compareAndSwapAppliedCatalogHeadV1).not.toHaveBeenCalled(); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )?.currentCatalogHeadDigest).toBe(fixture.genesis.head.objectDigest); + await expect(fixture.receiverStore.countQuads()).resolves.toBe(0); + }, 30_000); + it('rejects a governed-scope genesis under the trusted null-governance policy before any mutation', async () => { const fixture = await setupLiveReceiver(); const observed = fixture.createCasObservedReceiver(); @@ -987,6 +1013,10 @@ describe('RFC-64 Gate 1 native successor to public SWM', () => { const restartedReceiver = fixture.createReceiver( reopened.inventory, reopened.controlObjects, + undefined, + undefined, + undefined, + reopened.kaBundles, ); await expect(fixture.bootstrap( @@ -1807,18 +1837,24 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { }, store: TripleStore = receiverStore, beforeAppliedHeadCommit?: Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1, + kaBundles: Pick = + receiverPersistence.kaBundles, ) => new Rfc64PublicCatalogNativeReceiverV1({ headTransport: { fetchCatalogHead: receiverHeadFetch }, contentTransport, controlObjects, inventory, + kaBundles, store, beforeAppliedHeadCommit, }); const createCasObservedReceiver = (contentTransport?: Pick< Rfc64PublicCatalogNativeTransportV1, 'fetchCatalogObject' | 'fetchKaBundle' - >, store: TripleStore = receiverStore) => { + >, store: TripleStore = receiverStore, kaBundles: Pick< + Rfc64PersistenceV1['kaBundles'], + 'putKaBundle' + > = receiverPersistence.kaBundles) => { const compareAndSwapAppliedCatalogHeadV1 = vi.fn( receiverPersistence.inventory.compareAndSwapAppliedCatalogHeadV1.bind( receiverPersistence.inventory, @@ -1842,7 +1878,10 @@ async function setupLiveReceiver(signingWallet = AUTHOR_WALLET) { receiverPersistence.inventory, ), compareAndSwapAppliedCatalogHeadV1, - }, { stageVerifiedObjects, getVerifiedObjectByDigest }, contentTransport, store), + }, { + stageVerifiedObjects, + getVerifiedObjectByDigest, + }, contentTransport, store, undefined, kaBundles), }); }; const receiver = createReceiver(receiverPersistence.inventory); From 214b92611da4e49b45caeac95f506aef9a724a93 Mon Sep 17 00:00:00 2001 From: Jurij89 <138491694+Jurij89@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:55:06 -0400 Subject: [PATCH 277/292] fix(publisher): terminal-fail definitive pre-acceptance sends; typed pre-send outcome (#1867, #1864) (#1918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(publisher): terminal-fail definitive pre-acceptance sends; typed pre-send outcome (#1867, #1864) #1867 — For a KA VM publish, the 'broadcast' status is recorded on the chain:txsigned write-ahead hook, strictly before eth_sendRawTransaction (the #1851 durability contract). A send that failed DEFINITIVELY before mempool acceptance (e.g. insufficient funds) threw after that durable record, so processKnowledgeAssetVmPublish's catch handed the job to chain recovery — which chased a never-accepted tx for the full recoveryLookupTimeoutMs (~15 min) before failing with a misleading recovery_state_inconsistent. Such rejects are now recorded as an immediate terminal broadcast-phase failure (insufficient_funds) via a CONSERVATIVE classifier (isDefinitivePreAcceptanceSendFailure) that whitelists only unambiguous pre-mempool rejects. Every ambiguous error (RPC timeouts, replacement-underpriced, nonce races, already-known, reverts) stays on the existing recovery early-return, preserving #1851's write-ahead durability guarantee — no double-submit. #1864 — Model the pre-send write-ahead boundary as an explicit typed PreSendOutcome ('not-reached' | 'recorded-durable' | 'rolled-back-pre-send') reported by recordDurableBroadcastBeforeSend and switched on by the catch, replacing the prior inference from a mutable executorReturned flag + a post-hoc getStatus re-read. Result-recording is split into its own try so its (unchanged) broadcast-phase failure handling no longer needs the flag. Tests: terminal insufficient_funds with no recovery chase/resend; the locked deliberate revert-with-"insufficient funds" edge; a 6-case safety-invariant matrix proving ambiguous/replacement/already-known/ plain-revert/timeout errors stay on recovery; and a pure classifier unit test. Full publisher unit suite green (520 tests). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(publisher): throw-safe pre-acceptance classifier; recorder-owned pre-send outcome (#1918 review) Addresses the #1918 bot review: - MUST-FIX (double-submit safety path): isDefinitivePreAcceptanceSendFailure no longer stringifies the error eagerly. A pathological thrown value (e.g. Object.create(null) → String() TypeError) previously could throw from the classifier, skipping the ambiguous-recovery branch and stranding the job off the recovery track. Message extraction is now throw-safe: an unstringifiable value falls back to an empty message → classified non-definitive → the durable 'broadcast' stays on the recovery track. - Model the PreSendOutcome in the broadcast recorder's closure (like the existing recordedTxHash) instead of threading a mutable out-parameter through recordKnowledgeAssetVmPublishBroadcastProgress / recordDurableBroadcastBeforeSend; those revert to pure helpers. The recorder exposes a read-only `outcome`; the catch reads it directly. - Move PreSendOutcome out of the shared persisted LiftJobState model (lift-job-states.ts) into a local transient type in the publisher impl. Tests: non-stringifiable thrown value after the durable broadcast stays on recovery (throw-safe); a whitelist<->mapper invariant test pinning that every whitelisted error maps to terminal insufficient_funds from 'broadcast' (kept as separate functions on purpose — the whitelist stays conservative and must not silently widen with the mapper). Publisher unit suite green (17 in the durability file). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(publisher): guard all thrown-value inspection in the pre-acceptance classifier (#1918 review) Round-5 review 🔴 — the throw-safety fix was incomplete: the `.code` read sat OUTSIDE the try, and property access can throw for a throwing accessor or a Proxy. On the recorded-durable path that would throw out of the catch before it returns the persisted 'broadcast' job, regressing the very double-submit recovery decision this is meant to protect. - isDefinitivePreAcceptanceSendFailure now wraps ALL inspection of the arbitrary thrown value (the `.code` read AND message extraction) in a single try; any failure to inspect → non-definitive → stays on recovery (we cannot prove a pre-acceptance reject, so treat it as ambiguous). - Remove the now-redundant safeErrorMessageLowerCase helper (folded in). - Un-export isDefinitivePreAcceptanceSendFailure from the package barrel (index.ts) — it is internal write-ahead recovery policy, not public API; the package-local test imports it directly from the module. Tests: durable-broadcast + a throwing `code` accessor stays 'broadcast' (never terminates, never resends); classifier unit test adds throwing- accessor and throwing-Proxy cases. Durability file: 18 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(publisher): exclude reverts from pre-acceptance whitelist; share throw-safe error extraction (#1918 review) Round-6 review: - 🔴 A revert is post-mempool (the tx was mined), so a `revert`/`reverted` message must NEVER be classified as a pre-acceptance reject — even when its caller-controlled revert string contains "insufficient funds". isDefinitivePreAcceptanceSendFailure now returns false for any revert message before the "insufficient funds" substring check, keeping ALL reverts uniformly on the recovery track (consistent with plain-revert handling) and confining the whitelist to node-level pre-send rejects. go-ethereum's balance pre-check ("insufficient funds for gas * price + value") contains no "revert", so no genuine pre-acceptance case is lost. This reverses the earlier "locked edge" — a revert is strictly narrower / more conservative, and no double-submit impact (recovery never resends). - 🟡 Share the throw-safe error EXTRACTION between the mapper and the classifier via a new `readPublishErrorFacts(error)` (guarded code + message reads). The classification DECISION stays separate: the pre-acceptance whitelist remains a deliberately narrow predicate and must not track the mapper's broader signals (that widening would re-open the #1851 double-submit hole). Their agreement stays pinned by the invariant test. Tests: the former locked-edge test is flipped — "execution reverted: insufficient funds" now STAYS on recovery ('broadcast'); a node-level "insufficient funds for gas * price + value" still terminal-fails; classifier unit test adds revert-with-insufficient-funds exclusion cases. Full publisher unit suite green (524). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/async-lift-publish-result.ts | 70 +++++- .../src/async-lift-publisher-impl.ts | 130 +++++++--- .../async-lift-broadcast-durability.test.ts | 230 ++++++++++++++++++ 3 files changed, 387 insertions(+), 43 deletions(-) diff --git a/packages/publisher/src/async-lift-publish-result.ts b/packages/publisher/src/async-lift-publish-result.ts index ee2f6819ef..596768db3e 100644 --- a/packages/publisher/src/async-lift-publish-result.ts +++ b/packages/publisher/src/async-lift-publish-result.ts @@ -120,11 +120,33 @@ export function mapPublishResultToLiftJobSuccess(params: { } } +/** Throw-safe facts read off an arbitrary thrown publish error. Both the failure-code mapper + * and the pre-acceptance classifier read the SAME low-level extraction here (shared + * mechanics), while each keeps its OWN classification policy. Inspecting a thrown value can + * fail — `String()` on a null-prototype object, or a throwing `.code` accessor / Proxy — so + * every read is guarded and falls back to a safe default (`undefined` code, `''` message). + * A failure to inspect must never throw out of a failure-recording or recovery-decision path. */ +function readPublishErrorFacts(error: unknown): { code: unknown; message: string; lowerMessage: string } { + let code: unknown; + try { + code = (error as { code?: unknown } | null | undefined)?.code; + } catch { + code = undefined; + } + let message = ''; + try { + const raw = error instanceof Error ? error.message : String(error); + if (typeof raw === 'string') message = raw; + } catch { + message = ''; + } + return { code, message, lowerMessage: message.toLowerCase() }; +} + export function mapPublishExceptionToLiftJobFailure( input: AsyncLiftPublishFailureInput, ): LiftJobFailureMetadata { - const message = input.error instanceof Error ? input.error.message : String(input.error); - const lower = message.toLowerCase(); + const { code: errorCode, message, lowerMessage: lower } = readPublishErrorFacts(input.error); // The funded-wallet-selection error (dkg-chain `InsufficientPublisherFundsError`, // code `NO_FUNDED_PUBLISHER_WALLET`) carries a friendly "no operational wallet @@ -137,7 +159,6 @@ export function mapPublishExceptionToLiftJobFailure( // same forever-retry trap #1013/#1121 fixed). `insufficient_funds` is only // valid from the 'broadcast' state, so only force it there — funded selection // is a broadcast-phase concern; any other state falls back to the classifier. - const errorCode = (input.error as { code?: unknown } | null | undefined)?.code; const isNoFundedWallet = errorCode === NO_FUNDED_PUBLISHER_WALLET_CODE || messageIndicatesNoFundedPublisherWallet(lower); const code = isNoFundedWallet && input.failedFromState === 'broadcast' @@ -160,6 +181,49 @@ export function mapPublishExceptionToLiftJobFailure( }); } +/** + * #1867 — a DEFINITIVE pre-acceptance send failure: an error the chain node returns while + * REJECTING the transaction before it is admitted to the mempool, so the tx provably never + * propagated and can never be mined. Only such errors may short-circuit the write-ahead + * `'broadcast'` recovery early-return into an immediate terminal failure. Every ambiguous + * error — RPC timeouts, `replacement transaction underpriced`, nonce races, `already known`, + * and reverts (post-mempool by definition) — MUST stay on the recovery track, or the #1851 + * write-ahead durability guarantee regresses and a double-submit becomes possible. + * + * CONSERVATIVE WHITELIST — only `insufficient funds` (plus the no-funded-wallet selection + * failure, which is thrown pre-signing so no tx exists at all). A node rejects a tx it cannot + * afford (gas * price + value) at `eth_sendRawTransaction` submission, before the tx enters + * the mempool; this reject is never emitted after admission. `nonce` and `tx reverted` are + * deliberately EXCLUDED: a nonce error can follow a competing tx already propagating, and a + * revert means the tx was mined. Widen only with a per-error pre-mempool proof. + * + * REVERTS ARE NEVER PRE-ACCEPTANCE: a `revert`/`reverted` message means the tx was mined + * (post-mempool), so it is excluded even when its (caller-controlled) revert string contains + * "insufficient funds". This keeps ALL reverts uniformly on the recovery track — consistent + * with the plain-revert handling elsewhere — and confines the whitelist to node-level + * pre-send rejects. go-ethereum's balance pre-check (`insufficient funds for gas * price + + * value`) contains no "revert", so the narrowing loses no genuine pre-acceptance case. + * + * DECISION vs MECHANICS: the throw-safe extraction of code+message is shared with + * `mapPublishExceptionToLiftJobFailure` (`readPublishErrorFacts`), but this predicate's + * classification policy is deliberately SEPARATE and narrower — it must never track the + * mapper's broader signals, or a future mapper code could silently widen this whitelist and + * re-open the #1851 double-submit hole. The invariant that every whitelisted error maps to a + * terminal `insufficient_funds` from 'broadcast' is pinned by test, not by shared code. + * + * THROW-SAFE via `readPublishErrorFacts`: this runs on the double-submit safety path — if it + * threw, the caller would never reach the ambiguous-recovery branch and the error would + * strand off recovery. An unstringifiable value / throwing `.code` accessor / Proxy yields + * empty facts → classified non-definitive → stays on recovery. + */ +export function isDefinitivePreAcceptanceSendFailure(error: unknown): boolean { + const { code, lowerMessage } = readPublishErrorFacts(error); + if (code === NO_FUNDED_PUBLISHER_WALLET_CODE) return true; + if (messageIndicatesNoFundedPublisherWallet(lowerMessage)) return true; + if (lowerMessage.includes('revert')) return false; + return lowerMessage.includes('insufficient funds'); +} + function classifyPublishFailureCode( lowerMessage: string, failedFromState: AsyncLiftPublishFailureInput['failedFromState'], diff --git a/packages/publisher/src/async-lift-publisher-impl.ts b/packages/publisher/src/async-lift-publisher-impl.ts index 9e8738bcc5..077c14a659 100644 --- a/packages/publisher/src/async-lift-publisher-impl.ts +++ b/packages/publisher/src/async-lift-publisher-impl.ts @@ -46,6 +46,7 @@ import { AsyncLiftJobConflictError } from './async-lift-publisher-types.js'; import { type TerminalJobClearOutcome } from './terminal-job-clear.js'; import { isSafeJobId } from './job-id.js'; import { + isDefinitivePreAcceptanceSendFailure, mapPublishExceptionToLiftJobFailure, mapPublishResultToLiftJobSuccess, type AsyncLiftPublishFailureInput, @@ -98,6 +99,20 @@ import { type PersistedFailedJob, } from './async-lift-publisher-utils.js'; +/** + * #1864 — outcome of the KA VM-publish pre-send write-ahead boundary + * (`recordDurableBroadcastBeforeSend`), tracked by the broadcast recorder's closure and + * read by the `processKnowledgeAssetVmPublish` catch to decide recovery vs terminal — + * replacing the prior inference from a mutable `executorReturned` flag + a post-hoc + * `getStatus` re-read. A transient control-flow value, deliberately kept out of the + * persisted `LiftJobState` model. + * - `'not-reached'` the write-ahead hook never fired (no tx was signed or sent). + * - `'recorded-durable'` `'broadcast'` was fsync-durably recorded; the tx is being/was sent. + * - `'rolled-back-pre-send'` the write-ahead was attempted but the fsync/transition failed + * and was rolled back to `'validated'`; the tx was never sent. + */ +type PreSendOutcome = 'not-reached' | 'recorded-durable' | 'rolled-back-pre-send'; + type AsyncLiftJobHandler = { readonly inspectPreparedPayload: (job: LiftJob) => Promise; readonly process: (claimed: LiftJob, walletId: string) => Promise; @@ -585,7 +600,6 @@ export class TripleStoreAsyncLiftPublisher const snapshotMetadata = createKnowledgeAssetVmPublishSnapshotMetadata(request); const preflightInput = { walletId, request, snapshot, snapshotMetadata }; - let executorReturned = false; try { const preflight = await this.knowledgeAssetVmPublishHandler.preflight?.(preflightInput); if (preflight?.action === 'noop') { @@ -625,19 +639,20 @@ export class TripleStoreAsyncLiftPublisher return await this.recordExecutionFailure(claimed.jobId, 'claimed', error); } + const publicByteSize = this.computePublicByteSize(prepared.publishOptions.quads); + const broadcastRecorder = this.createKnowledgeAssetVmPublishBroadcastRecorder({ + jobId: claimed.jobId, + walletId, + merkleRoot: request.sealMerkleRoot, + publicByteSize, + delegate: prepared.publishOptions.onPhase, + }); + let publishResult!: PublishResult; try { const preflight = await this.knowledgeAssetVmPublishHandler.preflight?.(preflightInput); if (preflight?.action === 'noop') { return await this.finalizeKnowledgeAssetVmPublishNoop(claimed.jobId, snapshot, snapshotMetadata); } - const publicByteSize = this.computePublicByteSize(prepared.publishOptions.quads); - const onPhase = this.createKnowledgeAssetVmPublishBroadcastProgressCallback({ - jobId: claimed.jobId, - walletId, - merkleRoot: request.sealMerkleRoot, - publicByteSize, - delegate: prepared.publishOptions.onPhase, - }); const executionInput = { walletId, request, @@ -647,36 +662,47 @@ export class TripleStoreAsyncLiftPublisher resolved: validated.resolved, publishOptions: { ...prepared.publishOptions, - onPhase, + onPhase: broadcastRecorder.onPhase, }, }; - const publishResult = await this.knowledgeAssetVmPublishHandler.execute(executionInput); - executorReturned = true; + publishResult = await this.knowledgeAssetVmPublishHandler.execute(executionInput); + } catch (error) { + // #1864 — switch on the typed pre-send boundary outcome (no `executorReturned` flag, + // no `getStatus` re-read). The tx send happens strictly AFTER the write-ahead durably + // records 'broadcast' (fsync inside recordDurableBroadcastBeforeSend, whose failure + // rolls the transition back), so a 'recorded-durable' outcome means the tx may be on + // the wire. + if (broadcastRecorder.outcome === 'recorded-durable' && !isDefinitivePreAcceptanceSendFailure(error)) { + // Ambiguous post-write-ahead failure — leave the job in 'broadcast' so recovery's + // interrupted-broadcast path reconciles it on chain, never resend. + return await this.getRequiredJob(claimed.jobId); + } + // #1867 — either the tx never left ('not-reached' / 'rolled-back-pre-send'), or a + // DEFINITIVE pre-acceptance reject (e.g. insufficient funds at eth_sendRawTransaction) + // on a durably-recorded broadcast: record an immediate terminal failure rather than a + // ~15-min recovery chase. A failed KA VM job is never chain-recovery-chased + // (canRetryFailedRecovery === false); its code comes from the publish mapper + // (insufficient_funds for the whitelisted rejects). + return await this.failKnowledgeAssetVmPublishExecution(claimed.jobId, error); + } + try { + // execute() returned — the tx landed and returned a result. A local recording failure + // here is a broadcast-phase failure (unchanged from the prior single-catch behavior). return await this.recordPublishResult(claimed.jobId, publishResult, { publicByteSize, }); } catch (error) { - const current = await this.getStatus(claimed.jobId); - if (!executorReturned && current?.status === 'broadcast') { - // The tx send happens strictly AFTER the write-ahead durably records - // 'broadcast' (fsync inside recordDurableBroadcastBeforeSend, whose - // failure rolls the transition back). So a durable 'broadcast' here means - // the tx may be on the wire — leave the job in 'broadcast' so recovery's - // interrupted-broadcast path reconciles it on chain, never resend. A - // pre-send failure (fsync failed → rolled back to 'validated', or an error - // before the write-ahead) does NOT reach here and is recorded as 'failed' - // below; a failed KA VM job is never chain-recovery-chased - // (canRetryFailedRecovery === false), and its code comes from the publish - // mapper (e.g. NO_FUNDED_PUBLISHER_WALLET → insufficient_funds). - return current; - } - const failedFromState: LiftJobState = this.isKnowledgeAssetPublishPreconditionFailure(error) - ? 'validated' - : 'broadcast'; - return await this.recordExecutionFailure(claimed.jobId, failedFromState, error); + return await this.failKnowledgeAssetVmPublishExecution(claimed.jobId, error); } } + private async failKnowledgeAssetVmPublishExecution(jobId: string, error: unknown): Promise { + const failedFromState: LiftJobState = this.isKnowledgeAssetPublishPreconditionFailure(error) + ? 'validated' + : 'broadcast'; + return await this.recordExecutionFailure(jobId, failedFromState, error); + } + private async recoverRawLiftInterrupted(job: LiftJob): Promise { if (job.status !== 'broadcast' && job.status !== 'included') { return false; @@ -1435,27 +1461,47 @@ export class TripleStoreAsyncLiftPublisher }); } - private createKnowledgeAssetVmPublishBroadcastProgressCallback(params: { + private createKnowledgeAssetVmPublishBroadcastRecorder(params: { jobId: string; walletId: string; merkleRoot: LiftJobHex; publicByteSize?: number; delegate?: PhaseCallback; - }): PhaseCallback { + }): { onPhase: PhaseCallback; readonly outcome: PreSendOutcome } { + // #1864 — the pre-send write-ahead outcome is tracked in this closure (like + // `recordedTxHash`) rather than threaded as a mutable out-parameter through the publish + // path. The processKnowledgeAssetVmPublish catch reads `.outcome` to decide recovery vs + // terminal. It stays 'not-reached' unless the write-ahead hook actually fires. + let outcome: PreSendOutcome = 'not-reached'; let recordedTxHash: LiftJobHex | undefined; - return async (phase, status) => { + const onPhase: PhaseCallback = async (phase, status) => { await (params.delegate?.(phase, status) as unknown as Promise | void); if (status !== 'start') return; const txHash = txHashFromSignedPhase(phase); if (!txHash || recordedTxHash) return; recordedTxHash = txHash; - await this.recordKnowledgeAssetVmPublishBroadcastProgress({ - jobId: params.jobId, - walletId: params.walletId, - txHash, - merkleRoot: params.merkleRoot, - publicByteSize: params.publicByteSize, - }); + try { + await this.recordKnowledgeAssetVmPublishBroadcastProgress({ + jobId: params.jobId, + walletId: params.walletId, + txHash, + merkleRoot: params.merkleRoot, + publicByteSize: params.publicByteSize, + }); + // The transition is fsync-durable (or was already durable): the tx is about to send. + outcome = 'recorded-durable'; + } catch (error) { + // recordDurableBroadcastBeforeSend rolled the transition back before re-throwing (or + // the write-ahead never durably mutated state): the tx was never sent. + outcome = 'rolled-back-pre-send'; + throw error; + } + }; + return { + onPhase, + get outcome() { + return outcome; + }, }; } @@ -1497,6 +1543,10 @@ export class TripleStoreAsyncLiftPublisher * never landed. The prior job is 'validated' (asserted by the sole caller), so * restoring it takes no fsync and cannot re-fail here. The caller then fails * the job from its pre-broadcast state, off the chain-recovery track. + * + * #1864 — success vs the rollback re-throw is what the broadcast recorder maps to its + * `PreSendOutcome` ('recorded-durable' vs 'rolled-back-pre-send'); this method itself + * stays a pure durability boundary and does not carry that state. */ private async recordDurableBroadcastBeforeSend( current: LiftJob, diff --git a/packages/publisher/test/async-lift-broadcast-durability.test.ts b/packages/publisher/test/async-lift-broadcast-durability.test.ts index b78d741cef..d9b74a8d0f 100644 --- a/packages/publisher/test/async-lift-broadcast-durability.test.ts +++ b/packages/publisher/test/async-lift-broadcast-durability.test.ts @@ -6,8 +6,12 @@ import { NO_FUNDED_PUBLISHER_WALLET_CODE } from '@origintrail-official/dkg-core' import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { TripleStoreAsyncLiftPublisher, + mapPublishExceptionToLiftJobFailure, type AsyncLiftPublisherConfig, } from '../src/index.js'; +// Internal recovery policy — imported directly from the module, deliberately not re-exported +// from the package barrel (kept off the public API surface). +import { isDefinitivePreAcceptanceSendFailure } from '../src/async-lift-publish-result.js'; import { DEFAULT_JOURNAL_GRAPH_URI, JOURNAL_SEQ, @@ -311,4 +315,230 @@ describe('async lift publisher broadcast durability', () => { expect(kinds.filter((k) => k === 'validated')).toHaveLength(1); expect(kinds.filter((k) => k === 'broadcast')).toHaveLength(1); }); + + // A VM-publish executor that fires the pre-send write-ahead (records a durable + // 'broadcast'), then throws `error` — the post-write-ahead failure #1867 classifies. + function firesBroadcastThenThrows( + error: unknown, + ): NonNullable { + return { + execute: async (input) => { + await input.publishOptions.onPhase?.(`chain:txsigned:tx-${TX_HASH}`, 'start'); + throw error; + }, + }; + } + + // #1867 — a send that fails DEFINITIVELY before mempool acceptance (insufficient funds + // at eth_sendRawTransaction) throws AFTER the durable 'broadcast' record. Instead of + // stranding the job on the ~15-min recovery chase (ending in recovery_state_inconsistent), + // it must be an immediate terminal broadcast-phase failure — insufficient_funds — with no + // recovery lookup and no resend. + it('records an immediate terminal insufficient_funds when the send is rejected before acceptance (#1867)', async () => { + const publisher = createPublisher(store, { + knowledgeAssetVmPublishHandler: firesBroadcastThenThrows( + new Error('insufficient funds for gas * price + value'), + ), + }); + + await stageShareSnapshot(store); + const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + const processed = await publisher.processNext('wallet-1'); + + // Terminal failure now — NOT left as 'broadcast' for recovery to chase. + expect(processed?.status).toBe('failed'); + expect(processed?.status).not.toBe('broadcast'); + expect(processed?.failure?.code).toBe('insufficient_funds'); + // Terminal, off the chain-recovery track (fail_job, non-retryable). + expect(processed?.failure?.resolution).toBe('fail_job'); + expect(processed?.failure?.resolution).not.toBe('retry_recovery'); + expect(processed?.failure?.retryable).toBe(false); + // The attempted broadcast tx hash is retained on the failed job for diagnostics. + expect(processed?.broadcast?.txHash).toBe(TX_HASH); + + // recover() must NOT chase or resubmit a never-accepted tx: no work, job stays failed. + expect(await publisher.recover()).toBe(0); + expect((await publisher.getStatus(jobId))?.status).toBe('failed'); + }); + + // #1867 SAFETY (throw-safe classifier) — a pathological, non-stringifiable thrown value + // (null-prototype object → String() TypeError) thrown AFTER the durable-broadcast record + // must NOT let the classifier throw: that would skip the ambiguous-recovery branch and + // strand the job off the recovery track. An unstringifiable error is classified + // non-definitive → the job stays 'broadcast' on the recovery track, never terminates, + // never resends. + it('keeps a non-stringifiable thrown value on the recovery track (throw-safe classifier)', async () => { + const publisher = createPublisher(store, { + knowledgeAssetVmPublishHandler: firesBroadcastThenThrows(Object.create(null) as unknown), + }); + + await stageShareSnapshot(store); + const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + const processed = await publisher.processNext('wallet-1'); + + expect(processed?.status).toBe('broadcast'); + expect(processed?.status).not.toBe('failed'); + expect(processed?.broadcast?.txHash).toBe(TX_HASH); + expect(await publisher.recover()).toBe(0); + expect((await publisher.getStatus(jobId))?.status).toBe('broadcast'); + }); + + // #1867 SAFETY (throw-safe classifier — #1918 round-5 🔴) — inspecting the thrown value must + // be guarded for MORE than String(): a throwing `code` accessor (or a Proxy) makes the very + // first property read throw. Thrown AFTER the durable-broadcast record, that would escape the + // catch before it can return the persisted broadcast job, stranding the job off recovery. The + // classifier must treat such a value as non-definitive → the job stays 'broadcast'. + it('keeps a value with a throwing "code" accessor on the recovery track (throw-safe classifier)', async () => { + const throwingCode = Object.defineProperty(new Error('rpc timeout'), 'code', { + get() { + throw new Error('code getter failed'); + }, + }); + const publisher = createPublisher(store, { + knowledgeAssetVmPublishHandler: firesBroadcastThenThrows(throwingCode), + }); + + await stageShareSnapshot(store); + const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + const processed = await publisher.processNext('wallet-1'); + + expect(processed?.status).toBe('broadcast'); + expect(processed?.status).not.toBe('failed'); + expect(processed?.broadcast?.txHash).toBe(TX_HASH); + expect(await publisher.recover()).toBe(0); + expect((await publisher.getStatus(jobId))?.status).toBe('broadcast'); + }); + + // #1867 (#1918 round-6 🔴) — a mined-then-reverted tx whose revert string happens to contain + // "insufficient funds" is NOT a pre-acceptance reject. A revert is post-mempool by definition + // (the tx was accepted and mined), so it must stay on the recovery track like every other + // revert (see the plain-revert case in the safety matrix below) — never taken by the + // pre-acceptance shortcut. This keeps the whitelist strictly to node-level pre-send rejects; + // go-ethereum's `insufficient funds for gas * price + value` contains no "revert", so the + // genuine pre-acceptance case (next test) is unaffected. + it('keeps a revert message that contains "insufficient funds" on the recovery track (revert is post-mempool)', async () => { + const publisher = createPublisher(store, { + knowledgeAssetVmPublishHandler: firesBroadcastThenThrows( + new Error('execution reverted: insufficient funds'), + ), + }); + + await stageShareSnapshot(store); + const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + const processed = await publisher.processNext('wallet-1'); + + expect(processed?.status).toBe('broadcast'); + expect(processed?.status).not.toBe('failed'); + expect(processed?.broadcast?.txHash).toBe(TX_HASH); + expect(await publisher.recover()).toBe(0); + expect((await publisher.getStatus(jobId))?.status).toBe('broadcast'); + }); + + // The genuine node-level pre-send reject (contains no "revert") still terminal-fails. + it('still terminal-fails a node-level "insufficient funds for gas" pre-send reject', async () => { + const publisher = createPublisher(store, { + knowledgeAssetVmPublishHandler: firesBroadcastThenThrows( + new Error('insufficient funds for gas * price + value'), + ), + }); + + await stageShareSnapshot(store); + await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + const processed = await publisher.processNext('wallet-1'); + + expect(processed?.status).toBe('failed'); + expect(processed?.failure?.code).toBe('insufficient_funds'); + }); + + // #1867 SAFETY INVARIANT (double-submit guard) — every AMBIGUOUS post-write-ahead error + // (one that could correspond to a tx already in the mempool or mined) MUST stay on the + // recovery early-return: left as 'broadcast', never terminated, never resent. If a future + // change broadens the whitelist to any of these, these cases fail loudly. Reverts here + // deliberately do NOT contain "insufficient funds" (that collision is the locked edge above). + it.each([ + ['a plain RPC timeout', 'ETIMEDOUT: request timed out'], + ['a nonce race', 'nonce too low'], + ['a replacement-underpriced reject', 'replacement transaction underpriced'], + ['an already-known tx', 'already known'], + ['a plain on-chain revert', 'execution reverted: KnowledgeCollection: not authorized'], + ['an opaque executor error', 'stop after the durable write-ahead'], + ])('keeps %s on the recovery track (stays broadcast, no resend)', async (_label, message) => { + const publisher = createPublisher(store, { + knowledgeAssetVmPublishHandler: firesBroadcastThenThrows(new Error(message)), + }); + + await stageShareSnapshot(store); + const jobId = await publisher.enqueueKnowledgeAssetVmPublish(kaVmPublishRequest()); + const processed = await publisher.processNext('wallet-1'); + + // Left on the recovery track: the durable 'broadcast' is preserved, NOT terminated. + expect(processed?.status).toBe('broadcast'); + expect(processed?.status).not.toBe('failed'); + expect(processed?.broadcast?.txHash).toBe(TX_HASH); + // Without a chain recovery resolver, recover() cannot resolve it and must not resend + // (recover() returns 0 recovered work here; the job stays 'broadcast'). + expect(await publisher.recover()).toBe(0); + expect((await publisher.getStatus(jobId))?.status).toBe('broadcast'); + }); + + // Pure classifier contract for the conservative whitelist — locks the exact boundary + // independently of the wiring (the invariant test above proves the wiring honors it). + it('isDefinitivePreAcceptanceSendFailure whitelists only unambiguous pre-mempool rejects', () => { + // Whitelisted — provably before mempool admission. + expect(isDefinitivePreAcceptanceSendFailure(new Error('insufficient funds for gas * price + value'))).toBe(true); + expect(isDefinitivePreAcceptanceSendFailure(new Error('INSUFFICIENT FUNDS'))).toBe(true); + expect(isDefinitivePreAcceptanceSendFailure( + Object.assign(new Error('re-wrapped'), { code: NO_FUNDED_PUBLISHER_WALLET_CODE }), + )).toBe(true); + expect(isDefinitivePreAcceptanceSendFailure( + new Error('No operational wallet has enough funds to publish'), + )).toBe(true); + + // Excluded — each can correspond to a tx already in the mempool or mined. + expect(isDefinitivePreAcceptanceSendFailure(new Error('nonce too low'))).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure(new Error('replacement transaction underpriced'))).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure(new Error('already known'))).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure(new Error('execution reverted: not authorized'))).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure(new Error('ETIMEDOUT: request timed out'))).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure(undefined)).toBe(false); + // A revert is post-mempool even when its string contains "insufficient funds": excluded. + expect(isDefinitivePreAcceptanceSendFailure(new Error('execution reverted: insufficient funds'))).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure(new Error('reverted: not enough; insufficient funds'))).toBe(false); + + // Throw-safe: an unstringifiable value, a throwing `code` accessor, and a throwing Proxy + // are each classified non-definitive rather than throwing out of the classifier. + expect(isDefinitivePreAcceptanceSendFailure(Object.create(null) as unknown)).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure(Symbol('opaque'))).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure( + Object.defineProperty(new Error('x'), 'code', { get() { throw new Error('boom'); } }), + )).toBe(false); + expect(isDefinitivePreAcceptanceSendFailure( + new Proxy({}, { get() { throw new Error('proxy trap'); } }), + )).toBe(false); + }); + + // Whitelist ↔ mapper invariant — the classifier and mapPublishExceptionToLiftJobFailure are + // kept as SEPARATE functions on purpose (the whitelist must stay conservative-by-construction + // and must NOT silently widen if the mapper later gains new broadcast-phase codes; unifying + // via the mapper would also reintroduce the throw path the classifier deliberately avoids). + // This test pins their agreement without coupling: every whitelisted error maps to the + // terminal insufficient_funds failure from the 'broadcast' state. + it('every whitelisted error maps to a terminal insufficient_funds from broadcast', () => { + const whitelisted: unknown[] = [ + new Error('insufficient funds for gas * price + value'), + Object.assign(new Error('re-wrapped'), { code: NO_FUNDED_PUBLISHER_WALLET_CODE }), + new Error('No operational wallet has enough funds to publish'), + ]; + for (const error of whitelisted) { + expect(isDefinitivePreAcceptanceSendFailure(error)).toBe(true); + const failure = mapPublishExceptionToLiftJobFailure({ + error, + failedFromState: 'broadcast', + errorPayloadRef: 'urn:dkg:test:error', + }); + expect(failure.code).toBe('insufficient_funds'); + expect(failure.mode).toBe('terminal'); + expect(failure.retryable).toBe(false); + } + }); }); From da06062639d61b327300b063ae7279322260c317 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Wed, 22 Jul 2026 23:58:48 +0200 Subject: [PATCH 278/292] fix(agent): close RFC-64 discovery trust boundaries --- .../catalog-transport-wire-v1-internal.ts | 137 ++++++++++++ ...ublic-catalog-current-head-discovery-v1.ts | 200 ++++++++---------- .../public-catalog-native-transport-v1.ts | 143 +++++-------- .../src/rfc64/public-catalog-service-v1.ts | 10 +- .../src/rfc64/public-catalog-transport-v1.ts | 150 +++++-------- .../src/rfc64/public-open-catalog-scope-v1.ts | 21 +- ...-catalog-current-head-discovery-v1.test.ts | 121 ++++++++++- .../rfc64-public-catalog-service-v1.test.ts | 11 +- 8 files changed, 461 insertions(+), 332 deletions(-) diff --git a/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts b/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts index 7da17954c9..23e777d1de 100644 --- a/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts +++ b/packages/agent/src/rfc64/catalog-transport-wire-v1-internal.ts @@ -51,6 +51,143 @@ export class Rfc64CatalogTransportWireUtilityErrorV1 extends Error { } } +export interface Rfc64CatalogTransportWireErrorMappingV1 { + readonly code: Code; + readonly message: string | ((cause: Rfc64CatalogTransportWireUtilityErrorV1) => string); +} + +/** + * Central adapter from package-private wire failures to one protocol's public + * error vocabulary. Protocol modules declare a compact reason table instead + * of duplicating catch ladders whenever the shared wire taxonomy evolves. + */ +export function rethrowRfc64CatalogTransportWireUtilityErrorV1( + cause: unknown, + fail: (code: Code, message: string, cause?: unknown) => never, + mappings: Partial + >>, + fallback?: Rfc64CatalogTransportWireErrorMappingV1, +): never { + if (!(cause instanceof Rfc64CatalogTransportWireUtilityErrorV1)) throw cause; + const mapping = mappings[cause.reason] ?? fallback; + if (mapping === undefined) throw cause; + fail( + mapping.code, + typeof mapping.message === 'function' ? mapping.message(cause) : mapping.message, + cause, + ); +} + +export interface Rfc64CatalogTransportWireAdapterMessagesV1 { + readonly encodePlainObject: string; + readonly encodeFieldShape: string; + readonly encodeOversized: (maxBytes: number) => string; + readonly parseOversized: string; + readonly parseStrictJson: string; + readonly parsePlainObject: string; + readonly parseExactKeys: string; + readonly parseNoncanonical: string; + readonly snapshot: string | ((cause: Rfc64CatalogTransportWireUtilityErrorV1) => string); + readonly evmAddress: (label: string) => string; + readonly peerIdType: string; + readonly peerIdCanonical: string; +} + +export interface Rfc64CatalogTransportWireAdapterV1 { + encodeFlatCanonicalJson(value: object, maxBytes: number): Uint8Array; + parseFlatCanonicalJson( + input: Uint8Array, + expectedKeys: readonly string[], + maxBytes: number, + ): Readonly>; + snapshotExactWireRecord( + value: unknown, + expectedKeys: readonly string[], + ): Readonly>; + assertCanonicalEvmAddress(value: unknown, label: string): asserts value is EvmAddressV1; + snapshotPeerId(value: unknown): string; +} + +/** Build one protocol-family adapter around the shared catalog wire codec. */ +export function createRfc64CatalogTransportWireAdapterV1(options: { + readonly fail: (code: Code, message: string, cause?: unknown) => never; + readonly wireCode: Code; + readonly inputCode: Code; + readonly messages: Rfc64CatalogTransportWireAdapterMessagesV1; +}): Rfc64CatalogTransportWireAdapterV1 { + const { fail, wireCode, inputCode, messages } = options; + return Object.freeze({ + encodeFlatCanonicalJson(value: object, maxBytes: number): Uint8Array { + try { + return encodeRfc64FlatCanonicalJsonV1(value, maxBytes); + } catch (cause) { + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, { + 'plain-object': { code: wireCode, message: messages.encodePlainObject }, + 'field-shape': { code: wireCode, message: messages.encodeFieldShape }, + oversized: { code: wireCode, message: messages.encodeOversized(maxBytes) }, + }); + } + }, + parseFlatCanonicalJson( + input: Uint8Array, + expectedKeys: readonly string[], + maxBytes: number, + ): Readonly> { + try { + return parseRfc64FlatCanonicalJsonV1(input, expectedKeys, maxBytes); + } catch (cause) { + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, { + oversized: { code: wireCode, message: messages.parseOversized }, + 'strict-json': { code: wireCode, message: messages.parseStrictJson }, + 'plain-object': { code: wireCode, message: messages.parsePlainObject }, + 'exact-keys': { code: wireCode, message: messages.parseExactKeys }, + noncanonical: { code: wireCode, message: messages.parseNoncanonical }, + }); + } + }, + snapshotExactWireRecord( + value: unknown, + expectedKeys: readonly string[], + ): Readonly> { + try { + return snapshotRfc64ExactWireRecordV1(value, expectedKeys); + } catch (cause) { + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, {}, { + code: wireCode, + message: messages.snapshot, + }); + } + }, + assertCanonicalEvmAddress( + value: unknown, + label: string, + ): asserts value is EvmAddressV1 { + try { + assertRfc64CanonicalEvmAddressV1(value, label); + } catch (cause) { + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, {}, { + code: wireCode, + message: messages.evmAddress(label), + }); + } + }, + snapshotPeerId(value: unknown): string { + try { + return snapshotRfc64PeerIdV1(value); + } catch (cause) { + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, { + 'peer-id-type': { code: inputCode, message: messages.peerIdType }, + }, { + code: inputCode, + message: messages.peerIdCanonical, + }); + } + }, + }); +} + export function encodeRfc64FlatCanonicalJsonV1( value: object, maxBytes: number, diff --git a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts index 9929f3023b..3a5974b313 100644 --- a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts @@ -25,6 +25,7 @@ import { assertSignedAuthorCatalogHeadEnvelopeV1, assertSubGraphNameV1, computeControlSignatureVariantDigestHex, + type AuthorCatalogScopeV1, type ContextGraphAccessPolicyV1, type ContextGraphIdV1, type DecimalU64V1, @@ -41,15 +42,12 @@ import { } from '@origintrail-official/dkg-chain'; import { - Rfc64CatalogTransportWireUtilityErrorV1, - assertRfc64CanonicalEvmAddressV1, assertRfc64ExactIssuerSignatureProofV1, - encodeRfc64FlatCanonicalJsonV1, + createRfc64CatalogTransportWireAdapterV1, encodeRfc64FoundStatusResponseV1, - parseRfc64FlatCanonicalJsonV1, parseRfc64StatusResponsePayloadV1, - snapshotRfc64ExactWireRecordV1, - snapshotRfc64PeerIdV1, + rethrowRfc64CatalogTransportWireUtilityErrorV1, + type Rfc64CatalogTransportWireAdapterV1, } from './catalog-transport-wire-v1-internal.js'; import type { Rfc64PublicCatalogHeadAnnouncementV1 } from './public-catalog-transport-v1.js'; @@ -81,6 +79,30 @@ const QUERY_KEYS = Object.freeze([ 'subGraphName', ] as const); +const DISCOVERY_WIRE: Rfc64CatalogTransportWireAdapterV1 = + createRfc64CatalogTransportWireAdapterV1({ + fail, + wireCode: 'catalog-discovery-wire', + inputCode: 'catalog-discovery-input', + messages: { + encodePlainObject: 'RFC-64 current-head query must be a plain object', + encodeFieldShape: 'RFC-64 current-head query accepts only string or null fields', + encodeOversized: (maxBytes) => `RFC-64 current-head query exceeds ${maxBytes} bytes`, + parseOversized: 'RFC-64 current-head query is empty or oversized', + parseStrictJson: 'RFC-64 current-head query is not strict UTF-8 JSON', + parsePlainObject: 'RFC-64 current-head query must be a plain JSON object', + parseExactKeys: 'RFC-64 current-head query has missing or unknown fields', + parseNoncanonical: 'RFC-64 current-head query bytes are not canonical JCS', + snapshot: (wireError) => wireError.message.replace( + 'RFC-64 wire', + 'RFC-64 current-head query', + ), + evmAddress: (label) => `${label} must be a canonical lowercase nonzero EVM address`, + peerIdType: 'remotePeerId must be a string', + peerIdCanonical: 'remotePeerId is empty, oversized, or noncanonical', + }, + }); + export interface Rfc64PublicCatalogCurrentHeadScopeV1 { readonly networkId: NetworkIdV1; readonly contextGraphId: ContextGraphIdV1; @@ -110,6 +132,8 @@ export interface Rfc64PublicCatalogCurrentHeadAuthorizationInputV1 export interface Rfc64PublicCatalogCurrentHeadAuthorizationV1 { readonly accessPolicy: ContextGraphAccessPolicyV1; readonly policyDigest: Digest32V1; + /** Exact semantic scope derived from independently accepted local policy. */ + readonly trustedCatalogScope: Readonly; } export interface Rfc64PublicCatalogCurrentHeadControlObjectReaderV1 { @@ -132,7 +156,7 @@ export interface Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1 { * capability contract. */ readonly readCurrentAppliedCatalogHeadDigest: ( - query: Rfc64PublicCatalogCurrentHeadQueryV1, + trustedCatalogScope: Readonly, ) => Promise; /** Must consult accepted current policy state, never echo the wire digest. */ readonly authorizeOpenCatalogOperation: ( @@ -251,12 +275,19 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { const remotePeerId = snapshotPeerId(remotePeerIdInput); const query = parseQuery(data); for (let attempt = 0; attempt < CURRENT_HEAD_SNAPSHOT_MAX_ATTEMPTS; attempt += 1) { - if (!await this.isOpenPolicy('current-head-discovery-inbound', remotePeerId, query)) { + const authorization = await this.openPolicyOrNull( + 'current-head-discovery-inbound', + remotePeerId, + query, + ); + if (authorization === null) { return Uint8Array.of(CURRENT_HEAD_DENIED); } throwIfAborted(signal); - const currentDigest = await this.readCurrentAppliedCatalogHeadDigest(query); + const currentDigest = await this.readCurrentAppliedCatalogHeadDigest( + authorization.trustedCatalogScope, + ); throwIfAborted(signal); const announcement = currentDigest === null ? null @@ -267,11 +298,17 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { // the pointer immediately before responding so discovery never knowingly // advertises a superseded or transient snapshot. One retry admits the // common single-writer CAS race while keeping work per request bounded. - const confirmedDigest = await this.readCurrentAppliedCatalogHeadDigest(query); + const confirmedDigest = await this.readCurrentAppliedCatalogHeadDigest( + authorization.trustedCatalogScope, + ); throwIfAborted(signal); if (confirmedDigest !== currentDigest) continue; - if (!await this.isOpenPolicy('current-head-discovery-inbound', remotePeerId, query)) { + if (await this.openPolicyOrNull( + 'current-head-discovery-inbound', + remotePeerId, + query, + ) === null) { return Uint8Array.of(CURRENT_HEAD_DENIED); } throwIfAborted(signal); @@ -287,10 +324,12 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { } private async readCurrentAppliedCatalogHeadDigest( - query: Rfc64PublicCatalogCurrentHeadQueryV1, + trustedCatalogScope: Readonly, ): Promise { try { - const currentDigest = await this.options.readCurrentAppliedCatalogHeadDigest(query); + const currentDigest = await this.options.readCurrentAppliedCatalogHeadDigest( + trustedCatalogScope, + ); if (currentDigest !== null) { assertCanonicalDigest(currentDigest, 'current catalog head digest'); } @@ -339,19 +378,18 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { return announcementFromHead(envelope, query.policyDigest); } - private async isOpenPolicy( + private async openPolicyOrNull( operation: Rfc64PublicCatalogCurrentHeadDiscoveryOperationV1, remotePeerId: string, query: Rfc64PublicCatalogCurrentHeadQueryV1, - ): Promise { + ): Promise { try { - await this.requireOpenPolicy(operation, remotePeerId, query); - return true; + return await this.requireOpenPolicy(operation, remotePeerId, query); } catch (cause) { if ( cause instanceof Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1 && cause.code === 'catalog-discovery-policy-denied' - ) return false; + ) return null; throw cause; } } @@ -360,7 +398,7 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { operation: Rfc64PublicCatalogCurrentHeadDiscoveryOperationV1, remotePeerId: string, query: Rfc64PublicCatalogCurrentHeadQueryV1, - ): Promise { + ): Promise { const input = Object.freeze({ operation, remotePeerId, @@ -389,6 +427,7 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { if (authorization.policyDigest !== query.policyDigest) { fail('catalog-discovery-policy-denied', 'current-head discovery policy generation is stale or mismatched'); } + return authorization; } private requireStarted(): void { @@ -481,25 +520,21 @@ function parseResponse(input: Uint8Array): Rfc64PublicCatalogHeadAnnouncementV1 RFC64_PUBLIC_CATALOG_CURRENT_HEAD_RESPONSE_MAX_BYTES_V1, ); } catch (cause) { - if ( - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'response-trailing' - ) { - fail( - 'catalog-discovery-wire', - input[0] === CURRENT_HEAD_NOT_FOUND + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, { + 'response-trailing': { + code: 'catalog-discovery-wire', + message: input[0] === CURRENT_HEAD_NOT_FOUND ? 'not-found current-head response has trailing bytes' : 'denied current-head response has trailing bytes', - cause, - ); - } - if ( - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'response-status' - ) { - fail('catalog-discovery-wire', 'current-head discovery response has an invalid status', cause); - } - fail('catalog-discovery-wire', 'current-head discovery response is empty or oversized', cause); + }, + 'response-status': { + code: 'catalog-discovery-wire', + message: 'current-head discovery response has an invalid status', + }, + }, { + code: 'catalog-discovery-wire', + message: 'current-head discovery response is empty or oversized', + }); } if (framed.status === 'not-found') return null; if (framed.status === 'denied') { @@ -580,38 +615,20 @@ function assertExactIssuerSignatureProof( try { assertRfc64ExactIssuerSignatureProofV1(envelope, proof); } catch (cause) { - fail( - 'catalog-discovery-signature', - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'issuer-proof-unminted' - ? 'issuer signature proof was not minted by the verifier' - : 'issuer signature proof is not bound to the exact head envelope', - cause, - ); + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, { + 'issuer-proof-unminted': { + code: 'catalog-discovery-signature', + message: 'issuer signature proof was not minted by the verifier', + }, + }, { + code: 'catalog-discovery-signature', + message: 'issuer signature proof is not bound to the exact head envelope', + }); } } function encodeFlatCanonicalJson(value: object, maxBytes: number): Uint8Array { - try { - return encodeRfc64FlatCanonicalJsonV1(value, maxBytes); - } catch (cause) { - if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { - if (cause.reason === 'plain-object') { - fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain object', cause); - } - if (cause.reason === 'field-shape') { - fail( - 'catalog-discovery-wire', - 'RFC-64 current-head query accepts only string or null fields', - cause, - ); - } - if (cause.reason === 'oversized') { - fail('catalog-discovery-wire', `RFC-64 current-head query exceeds ${maxBytes} bytes`, cause); - } - } - throw cause; - } + return DISCOVERY_WIRE.encodeFlatCanonicalJson(value, maxBytes); } function parseFlatCanonicalJson( @@ -619,69 +636,22 @@ function parseFlatCanonicalJson( expectedKeys: readonly string[], maxBytes: number, ): Record { - try { - return parseRfc64FlatCanonicalJsonV1(input, expectedKeys, maxBytes); - } catch (cause) { - if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { - if (cause.reason === 'oversized') { - fail('catalog-discovery-wire', 'RFC-64 current-head query is empty or oversized', cause); - } - if (cause.reason === 'strict-json') { - fail('catalog-discovery-wire', 'RFC-64 current-head query is not strict UTF-8 JSON', cause); - } - if (cause.reason === 'plain-object') { - fail('catalog-discovery-wire', 'RFC-64 current-head query must be a plain JSON object', cause); - } - if (cause.reason === 'exact-keys') { - fail('catalog-discovery-wire', 'RFC-64 current-head query has missing or unknown fields', cause); - } - if (cause.reason === 'noncanonical') { - fail('catalog-discovery-wire', 'RFC-64 current-head query bytes are not canonical JCS', cause); - } - } - throw cause; - } + return DISCOVERY_WIRE.parseFlatCanonicalJson(input, expectedKeys, maxBytes); } function snapshotExactWireRecord( value: Record, expectedKeys: readonly string[], ): Readonly> { - try { - return snapshotRfc64ExactWireRecordV1(value, expectedKeys); - } catch (cause) { - if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { - fail('catalog-discovery-wire', cause.message.replace('RFC-64 wire', 'RFC-64 current-head query'), cause); - } - throw cause; - } + return DISCOVERY_WIRE.snapshotExactWireRecord(value, expectedKeys); } function assertCanonicalEvmAddressV1(value: unknown, label: string): asserts value is EvmAddressV1 { - try { - assertRfc64CanonicalEvmAddressV1(value, label); - } catch (cause) { - fail( - 'catalog-discovery-wire', - `${label} must be a canonical lowercase nonzero EVM address`, - cause, - ); - } + DISCOVERY_WIRE.assertCanonicalEvmAddress(value, label); } function snapshotPeerId(value: unknown): string { - try { - return snapshotRfc64PeerIdV1(value); - } catch (cause) { - fail( - 'catalog-discovery-input', - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'peer-id-type' - ? 'remotePeerId must be a string' - : 'remotePeerId is empty, oversized, or noncanonical', - cause, - ); - } + return DISCOVERY_WIRE.snapshotPeerId(value); } function isPlainRecord(value: unknown): value is Record { diff --git a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts index e41d534a03..145db131a8 100644 --- a/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-transport-v1.ts @@ -52,15 +52,12 @@ import { } from './catalog-transport-authorization-v1.js'; import type { Rfc64AuthorizedCatalogWorkResultV1 } from './catalog-transport-authorization-v1.js'; import { - Rfc64CatalogTransportWireUtilityErrorV1, - assertRfc64CanonicalEvmAddressV1, assertRfc64ExactIssuerSignatureProofV1, - encodeRfc64FlatCanonicalJsonV1, + createRfc64CatalogTransportWireAdapterV1, encodeRfc64FoundStatusResponseV1, - parseRfc64FlatCanonicalJsonV1, parseRfc64StatusResponsePayloadV1, - snapshotRfc64ExactWireRecordV1, - snapshotRfc64PeerIdV1, + rethrowRfc64CatalogTransportWireUtilityErrorV1, + type Rfc64CatalogTransportWireAdapterV1, } from './catalog-transport-wire-v1-internal.js'; export const RFC64_PUBLIC_CATALOG_OBJECT_FETCH_PROTOCOL_V1 = @@ -122,6 +119,27 @@ export function assertRfc64PublicCatalogExactSetBundleBytesV1( const FETCH_NOT_FOUND = 0; const FETCH_DENIED = 2; + +const NATIVE_WIRE: Rfc64CatalogTransportWireAdapterV1 = + createRfc64CatalogTransportWireAdapterV1({ + fail, + wireCode: 'catalog-native-wire', + inputCode: 'catalog-native-input', + messages: { + encodePlainObject: 'catalog native request must be a plain object', + encodeFieldShape: 'catalog native requests accept only string or null fields', + encodeOversized: () => 'catalog native request exceeds its byte ceiling', + parseOversized: 'catalog native request is empty or oversized', + parseStrictJson: 'catalog native request is not strict UTF-8 JSON', + parsePlainObject: 'catalog native request must be an object', + parseExactKeys: 'catalog native request has missing or unknown fields', + parseNoncanonical: 'catalog native request bytes are not canonical JCS', + snapshot: 'catalog native request has missing or unknown fields', + evmAddress: (label) => `${label} must be a lowercase nonzero EVM address`, + peerIdType: 'remotePeerId must be a string', + peerIdCanonical: 'remotePeerId is empty, oversized, or noncanonical', + }, + }); const UTF8 = new TextEncoder(); const SCOPE_KEYS = Object.freeze([ @@ -618,58 +636,18 @@ function validateScope( } function encodeRequest(value: object): Uint8Array { - try { - return encodeRfc64FlatCanonicalJsonV1( - value, - RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1, - ); - } catch (cause) { - if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { - if (cause.reason === 'plain-object') { - fail('catalog-native-wire', 'catalog native request must be a plain object', cause); - } - if (cause.reason === 'field-shape') { - fail( - 'catalog-native-wire', - 'catalog native requests accept only string or null fields', - cause, - ); - } - if (cause.reason === 'oversized') { - fail('catalog-native-wire', 'catalog native request exceeds its byte ceiling', cause); - } - } - throw cause; - } + return NATIVE_WIRE.encodeFlatCanonicalJson( + value, + RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1, + ); } function parseRequest(input: Uint8Array, expectedKeys: readonly string[]): Record { - try { - return parseRfc64FlatCanonicalJsonV1( - input, - expectedKeys, - RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1, - ); - } catch (cause) { - if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { - if (cause.reason === 'oversized') { - fail('catalog-native-wire', 'catalog native request is empty or oversized', cause); - } - if (cause.reason === 'strict-json') { - fail('catalog-native-wire', 'catalog native request is not strict UTF-8 JSON', cause); - } - if (cause.reason === 'plain-object') { - fail('catalog-native-wire', 'catalog native request must be an object', cause); - } - if (cause.reason === 'exact-keys') { - fail('catalog-native-wire', 'catalog native request has missing or unknown fields', cause); - } - if (cause.reason === 'noncanonical') { - fail('catalog-native-wire', 'catalog native request bytes are not canonical JCS', cause); - } - } - throw cause; - } + return NATIVE_WIRE.parseFlatCanonicalJson( + input, + expectedKeys, + RFC64_PUBLIC_CATALOG_NATIVE_FETCH_REQUEST_MAX_BYTES_V1, + ); } function parseCatalogObjectResponse(input: Uint8Array): SignedControlEnvelopeV1 | null { @@ -702,25 +680,21 @@ function responsePayload(input: Uint8Array, maxBytes: number): Uint8Array | null try { framed = parseRfc64StatusResponsePayloadV1(input, maxBytes); } catch (cause) { - if ( - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'response-trailing' - ) { - fail( - 'catalog-native-wire', - input[0] === FETCH_NOT_FOUND + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, { + 'response-trailing': { + code: 'catalog-native-wire', + message: input[0] === FETCH_NOT_FOUND ? 'not-found response has trailing bytes' : 'denied response has trailing bytes', - cause, - ); - } - if ( - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'response-status' - ) { - fail('catalog-native-wire', 'catalog native response has an invalid status', cause); - } - fail('catalog-native-wire', 'catalog native response is empty or oversized', cause); + }, + 'response-status': { + code: 'catalog-native-wire', + message: 'catalog native response has an invalid status', + }, + }, { + code: 'catalog-native-wire', + message: 'catalog native response is empty or oversized', + }); } if (framed.status === 'not-found') return null; if (framed.status === 'denied') { @@ -770,37 +744,18 @@ function foundResponse(payload: Uint8Array): Uint8Array { } function assertCanonicalEvmAddress(value: unknown, label: string): asserts value is EvmAddressV1 { - try { - assertRfc64CanonicalEvmAddressV1(value, label); - } catch (cause) { - fail('catalog-native-wire', `${label} must be a lowercase nonzero EVM address`, cause); - } + NATIVE_WIRE.assertCanonicalEvmAddress(value, label); } function snapshotPeerId(value: unknown): string { - try { - return snapshotRfc64PeerIdV1(value); - } catch (cause) { - fail( - 'catalog-native-input', - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'peer-id-type' - ? 'remotePeerId must be a string' - : 'remotePeerId is empty, oversized, or noncanonical', - cause, - ); - } + return NATIVE_WIRE.snapshotPeerId(value); } function snapshotExactWireRecord( value: unknown, expectedKeys: readonly string[], ): Readonly> { - try { - return snapshotRfc64ExactWireRecordV1(value, expectedKeys); - } catch (cause) { - fail('catalog-native-wire', 'catalog native request has missing or unknown fields', cause); - } + return NATIVE_WIRE.snapshotExactWireRecord(value, expectedKeys); } function isPlainRecord(value: unknown): value is Record { diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 918adfd064..ac56ff1cf6 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -271,10 +271,8 @@ export class Rfc64PublicCatalogServiceV1 { ? undefined : new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(options.router, { controlObjects: this.#controlObjects, - readCurrentAppliedCatalogHeadDigest: (query) => - options.currentHeadDiscovery!.readCurrentAppliedCatalogHeadDigest( - this.#resolveTrustedCurrentHeadScope(query), - ), + readCurrentAppliedCatalogHeadDigest: + options.currentHeadDiscovery.readCurrentAppliedCatalogHeadDigest, authorizeOpenCatalogOperation: (input) => this.#authorizeCurrentHeadDiscovery(input), verifyIssuerSignature: this.#verifyIssuerSignature, @@ -669,8 +667,9 @@ export class Rfc64PublicCatalogServiceV1 { async #authorizeCurrentHeadDiscovery( input: Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, ): Promise { + let trustedCatalogScope: Readonly; try { - this.#resolveTrustedCurrentHeadScope(input); + trustedCatalogScope = this.#resolveTrustedCurrentHeadScope(input); } catch { return null; } @@ -679,6 +678,7 @@ export class Rfc64PublicCatalogServiceV1 { return Object.freeze({ accessPolicy: 0, policyDigest: record.policyDigest, + trustedCatalogScope, }); } diff --git a/packages/agent/src/rfc64/public-catalog-transport-v1.ts b/packages/agent/src/rfc64/public-catalog-transport-v1.ts index eedebb0577..5aef4adf4a 100644 --- a/packages/agent/src/rfc64/public-catalog-transport-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-transport-v1.ts @@ -36,15 +36,12 @@ import { } from './catalog-transport-authorization-v1.js'; import type { Rfc64AuthorizedCatalogWorkResultV1 } from './catalog-transport-authorization-v1.js'; import { - Rfc64CatalogTransportWireUtilityErrorV1, - assertRfc64CanonicalEvmAddressV1, assertRfc64ExactIssuerSignatureProofV1, - encodeRfc64FlatCanonicalJsonV1, + createRfc64CatalogTransportWireAdapterV1, encodeRfc64FoundStatusResponseV1, - parseRfc64FlatCanonicalJsonV1, parseRfc64StatusResponsePayloadV1, - snapshotRfc64ExactWireRecordV1, - snapshotRfc64PeerIdV1, + rethrowRfc64CatalogTransportWireUtilityErrorV1, + type Rfc64CatalogTransportWireAdapterV1, } from './catalog-transport-wire-v1-internal.js'; /** @@ -71,6 +68,27 @@ const ANNOUNCEMENT_DENIED = 0; const FETCH_NOT_FOUND = 0; const FETCH_DENIED = 2; +const CATALOG_WIRE: Rfc64CatalogTransportWireAdapterV1 = + createRfc64CatalogTransportWireAdapterV1({ + fail, + wireCode: 'catalog-transport-wire', + inputCode: 'catalog-transport-input', + messages: { + encodePlainObject: 'RFC-64 catalog message must be a plain object', + encodeFieldShape: 'RFC-64 catalog messages accept only string or null fields', + encodeOversized: (maxBytes) => `RFC-64 catalog message exceeds ${maxBytes} bytes`, + parseOversized: 'RFC-64 catalog message is empty or oversized', + parseStrictJson: 'RFC-64 catalog message is not strict UTF-8 JSON', + parsePlainObject: 'RFC-64 catalog message must be a plain JSON object', + parseExactKeys: 'RFC-64 catalog message has missing or unknown fields', + parseNoncanonical: 'RFC-64 catalog message bytes are not canonical JCS', + snapshot: 'RFC-64 catalog message has missing or unknown fields', + evmAddress: (label) => `${label} must be a canonical lowercase nonzero EVM address`, + peerIdType: 'remotePeerId must be a string', + peerIdCanonical: 'remotePeerId is empty, oversized, or noncanonical', + }, + }); + const ANNOUNCEMENT_KEYS = Object.freeze([ 'authorAddress', 'catalogEra', @@ -606,25 +624,21 @@ function parseFetchResponse(input: Uint8Array): SignedAuthorCatalogHeadEnvelopeV RFC64_PUBLIC_CATALOG_HEAD_FETCH_RESPONSE_MAX_BYTES_V1, ); } catch (cause) { - if ( - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'response-trailing' - ) { - fail( - 'catalog-transport-wire', - input[0] === FETCH_NOT_FOUND + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, { + 'response-trailing': { + code: 'catalog-transport-wire', + message: input[0] === FETCH_NOT_FOUND ? 'not-found author-catalog response has trailing bytes' : 'denied author-catalog response has trailing bytes', - cause, - ); - } - if ( - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'response-status' - ) { - fail('catalog-transport-wire', 'author-catalog head response has an invalid status', cause); - } - fail('catalog-transport-wire', 'author-catalog head response is empty or oversized', cause); + }, + 'response-status': { + code: 'catalog-transport-wire', + message: 'author-catalog head response has an invalid status', + }, + }, { + code: 'catalog-transport-wire', + message: 'author-catalog head response is empty or oversized', + }); } if (framed.status === 'not-found') return null; if (framed.status === 'denied') { @@ -693,14 +707,15 @@ function assertExactIssuerSignatureProof( try { assertRfc64ExactIssuerSignatureProofV1(envelope, proof); } catch (cause) { - fail( - 'catalog-transport-signature', - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'issuer-proof-unminted' - ? 'issuer signature proof was not minted by the verifier' - : 'issuer signature proof is not bound to the exact envelope', - cause, - ); + rethrowRfc64CatalogTransportWireUtilityErrorV1(cause, fail, { + 'issuer-proof-unminted': { + code: 'catalog-transport-signature', + message: 'issuer signature proof was not minted by the verifier', + }, + }, { + code: 'catalog-transport-signature', + message: 'issuer signature proof is not bound to the exact envelope', + }); } } @@ -708,26 +723,7 @@ function encodeFlatCanonicalJson( value: object, maxBytes: number, ): Uint8Array { - try { - return encodeRfc64FlatCanonicalJsonV1(value, maxBytes); - } catch (cause) { - if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { - if (cause.reason === 'plain-object') { - fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain object', cause); - } - if (cause.reason === 'field-shape') { - fail( - 'catalog-transport-wire', - 'RFC-64 catalog messages accept only string or null fields', - cause, - ); - } - if (cause.reason === 'oversized') { - fail('catalog-transport-wire', `RFC-64 catalog message exceeds ${maxBytes} bytes`, cause); - } - } - throw cause; - } + return CATALOG_WIRE.encodeFlatCanonicalJson(value, maxBytes); } function parseFlatCanonicalJson( @@ -735,66 +731,22 @@ function parseFlatCanonicalJson( expectedKeys: readonly string[], maxBytes: number, ): Record { - try { - return parseRfc64FlatCanonicalJsonV1(input, expectedKeys, maxBytes); - } catch (cause) { - if (cause instanceof Rfc64CatalogTransportWireUtilityErrorV1) { - if (cause.reason === 'oversized') { - fail('catalog-transport-wire', 'RFC-64 catalog message is empty or oversized', cause); - } - if (cause.reason === 'strict-json') { - fail('catalog-transport-wire', 'RFC-64 catalog message is not strict UTF-8 JSON', cause); - } - if (cause.reason === 'plain-object') { - fail('catalog-transport-wire', 'RFC-64 catalog message must be a plain JSON object', cause); - } - if (cause.reason === 'exact-keys') { - fail('catalog-transport-wire', 'RFC-64 catalog message has missing or unknown fields', cause); - } - if (cause.reason === 'noncanonical') { - fail('catalog-transport-wire', 'RFC-64 catalog message bytes are not canonical JCS', cause); - } - } - throw cause; - } + return CATALOG_WIRE.parseFlatCanonicalJson(input, expectedKeys, maxBytes); } function snapshotExactWireRecord( value: unknown, expectedKeys: readonly string[], ): Readonly> { - try { - return snapshotRfc64ExactWireRecordV1(value, expectedKeys); - } catch (cause) { - fail('catalog-transport-wire', 'RFC-64 catalog message has missing or unknown fields', cause); - } + return CATALOG_WIRE.snapshotExactWireRecord(value, expectedKeys); } function assertCanonicalEvmAddressV1(value: unknown, label: string): asserts value is EvmAddressV1 { - try { - assertRfc64CanonicalEvmAddressV1(value, label); - } catch (cause) { - fail( - 'catalog-transport-wire', - `${label} must be a canonical lowercase nonzero EVM address`, - cause, - ); - } + CATALOG_WIRE.assertCanonicalEvmAddress(value, label); } function snapshotPeerId(value: unknown): string { - try { - return snapshotRfc64PeerIdV1(value); - } catch (cause) { - fail( - 'catalog-transport-input', - cause instanceof Rfc64CatalogTransportWireUtilityErrorV1 - && cause.reason === 'peer-id-type' - ? 'remotePeerId must be a string' - : 'remotePeerId is empty, oversized, or noncanonical', - cause, - ); - } + return CATALOG_WIRE.snapshotPeerId(value); } function isPlainRecord(value: unknown): value is Record { diff --git a/packages/agent/src/rfc64/public-open-catalog-scope-v1.ts b/packages/agent/src/rfc64/public-open-catalog-scope-v1.ts index cdf236001b..3e7b138a5e 100644 --- a/packages/agent/src/rfc64/public-open-catalog-scope-v1.ts +++ b/packages/agent/src/rfc64/public-open-catalog-scope-v1.ts @@ -10,21 +10,22 @@ import { type AuthorCatalogScopeV1, + type ContextGraphIdV1, type ContextGraphPolicyV1, type CountV1, + type DecimalU64V1, + type EvmAddressV1, + type NetworkIdV1, + type SubGraphNameV1, } from '@origintrail-official/dkg-core'; -import type { - Rfc64PublicCatalogHeadAnnouncementV1, -} from './public-catalog-transport-v1.js'; - -/** Wire fields that identify one claimed public/open root author-catalog scope. */ +/** Neutral fields that identify one claimed public/open root author-catalog scope. */ export interface Rfc64PublicOpenCatalogScopeClaimV1 { - readonly networkId: Rfc64PublicCatalogHeadAnnouncementV1['networkId']; - readonly contextGraphId: Rfc64PublicCatalogHeadAnnouncementV1['contextGraphId']; - readonly subGraphName: Rfc64PublicCatalogHeadAnnouncementV1['subGraphName']; - readonly authorAddress: Rfc64PublicCatalogHeadAnnouncementV1['authorAddress']; - readonly catalogEra: Rfc64PublicCatalogHeadAnnouncementV1['catalogEra']; + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogEra: DecimalU64V1; } /** diff --git a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts index 34dcba7301..82c9e57553 100644 --- a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts @@ -29,6 +29,9 @@ import { import { Rfc64PublicCatalogServiceV1, } from '../src/rfc64/public-catalog-service-v1.js'; +import { + encodeRfc64PublicCatalogHeadAnnouncementV1, +} from '../src/rfc64/public-catalog-transport-v1.js'; import { openRfc64PersistenceV1, type Rfc64PersistenceV1, @@ -167,6 +170,14 @@ function discoveryScope() { }) as const; } +function directAuthorization(scope = catalogScope()) { + return Object.freeze({ + accessPolicy: 0 as const, + policyDigest: POLICY_DIGEST, + trustedCatalogScope: scope, + }); +} + describe('RFC-64 public catalog current-head discovery v1', () => { it('treats an unsorted expected-key declaration as an exact key set', () => { expect(snapshotRfc64ExactWireRecordV1( @@ -388,6 +399,62 @@ describe('RFC-64 public catalog current-head discovery v1', () => { expect(requester.stats().receiver.scheduled).toBe(0); }, 20_000); + it('reports denial when provider policy changes after initial authorization', async () => { + const [providerNode, requesterNode, providerPersistence, requesterPersistence] = + await Promise.all([ + startNode(), + startNode(), + openPersistence('revoked-provider'), + openPersistence('revoked-requester'), + ]); + await connect(requesterNode, providerNode); + let provider!: Rfc64PublicCatalogServiceV1; + let providerHeadReads = 0; + const readCurrentAppliedCatalogHeadDigest = vi.fn(async ( + trustedCatalogScope: Readonly, + ) => { + expect(trustedCatalogScope).toEqual(catalogScope()); + providerHeadReads += 1; + if (providerHeadReads === 1) { + const current = provider.acceptedPolicySnapshot(NETWORK_ID, CONTEXT_GRAPH_ID)!; + const successor = Object.freeze({ + ...current.policy, + version: '1' as const, + previousPolicyDigest: current.policyDigest, + }); + const advanced = provider.acceptPolicySnapshot({ + policy: successor, + policyDigest: `0x${'34'.repeat(32)}` as Digest32V1, + }); + expect(advanced.policyDigest).not.toBe(current.policyDigest); + } + return null; + }); + provider = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(providerNode), + controlObjects: providerPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest }, + transportTimeoutMs: 4_000, + }); + const requester = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(requesterNode), + controlObjects: requesterPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, + transportTimeoutMs: 4_000, + }); + services.push(provider, requester); + acceptPolicy(provider, '0' as TimestampMsV1); + acceptPolicy(requester, '0' as TimestampMsV1); + provider.start(); + requester.start(); + + await expect(requester.discoverCurrentCatalogHead({ + remotePeerId: providerNode.peerId, + scope: discoveryScope(), + })).rejects.toMatchObject({ code: 'catalog-discovery-policy-denied' }); + expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalledTimes(2); + }, 20_000); + it('fails closed when an applied-head pointer has no verified control object', async () => { const [providerNode, requesterNode, providerPersistence, requesterPersistence] = await Promise.all([ @@ -453,8 +520,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { readCurrentAppliedCatalogHeadDigest: vi.fn(async () => stored.head.objectDigest as Digest32V1), authorizeOpenCatalogOperation: vi.fn(async () => ({ - accessPolicy: 0 as const, - policyDigest: POLICY_DIGEST, + ...directAuthorization(), })), verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }); @@ -502,8 +568,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { readCurrentAppliedCatalogHeadDigest: vi.fn(async () => stored.head.objectDigest as Digest32V1), authorizeOpenCatalogOperation: vi.fn(async () => ({ - accessPolicy: 0 as const, - policyDigest: POLICY_DIGEST, + ...directAuthorization(), })), verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }); @@ -523,7 +588,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { it('rechecks outbound policy after the awaited remote response', async () => { const authorizeOpenCatalogOperation = vi.fn() - .mockResolvedValueOnce({ accessPolicy: 0 as const, policyDigest: POLICY_DIGEST }) + .mockResolvedValueOnce(directAuthorization()) .mockResolvedValueOnce(null); const send = vi.fn(async () => Uint8Array.of(0)); const router = { @@ -558,7 +623,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { options?: { signal?: AbortSignal }, ) => Promise) | undefined; const authorizeOpenCatalogOperation = vi.fn() - .mockResolvedValueOnce({ accessPolicy: 0 as const, policyDigest: POLICY_DIGEST }) + .mockResolvedValueOnce(directAuthorization()) .mockResolvedValueOnce(null); const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); const router = { @@ -610,6 +675,47 @@ describe('RFC-64 public catalog current-head discovery v1', () => { })).rejects.toThrow(/accepted public\/open root policy/); }); + it('rejects a canonical requester-side response outside the exact query scope', async () => { + const stored = await produceHeadWithProof(); + const mismatchedAnnouncement = Object.freeze({ + kind: 'rfc64-author-catalog-head-availability-v1' as const, + ...discoveryScope(), + subGraphName: 'nested' as const, + catalogVersion: '0' as const, + policyDigest: POLICY_DIGEST, + catalogHeadObjectDigest: stored.head.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + stored.head.objectDigest, + stored.head.signature, + ) as Digest32V1, + }); + const payload = encodeRfc64PublicCatalogHeadAnnouncementV1(mismatchedAnnouncement); + const response = new Uint8Array(payload.byteLength + 1); + response[0] = 1; + response.set(payload, 1); + const router = { + register() {}, + unregister() {}, + send: vi.fn(async () => response), + } as unknown as ProtocolRouter; + const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { + controlObjects: { getVerifiedObjectByDigest: vi.fn(async () => null) }, + readCurrentAppliedCatalogHeadDigest: vi.fn(async () => null), + authorizeOpenCatalogOperation: vi.fn(async () => directAuthorization()), + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + transport.start(); + const query = Object.freeze({ + kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, + ...discoveryScope(), + policyDigest: POLICY_DIGEST, + }) satisfies Rfc64PublicCatalogCurrentHeadQueryV1; + + await expect(transport.discoverCurrentCatalogHead('provider-peer', query)) + .rejects.toMatchObject({ code: 'catalog-discovery-object-mismatch' }); + transport.stop(); + }); + it('round-trips only exact canonical query fields', () => { const query = Object.freeze({ kind: RFC64_PUBLIC_CATALOG_CURRENT_HEAD_QUERY_KIND_V1, @@ -681,8 +787,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { } as unknown as ProtocolRouter; const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); const authorizeOpenCatalogOperation = vi.fn(async () => ({ - accessPolicy: 0 as const, - policyDigest: POLICY_DIGEST, + ...directAuthorization(), })); const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { controlObjects: { diff --git a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts index 7daab1300c..9bdc61aa79 100644 --- a/packages/agent/test/rfc64-public-catalog-service-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-service-v1.test.ts @@ -27,6 +27,9 @@ import { Rfc64PublicCatalogServiceV1, type Rfc64PublicCatalogReconcilerClientsV1, } from '../src/rfc64/public-catalog-service-v1.js'; +import { + RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1, +} from '../src/rfc64/public-catalog-current-head-discovery-v1.js'; import { RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_KIND_V1, RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1, @@ -99,10 +102,11 @@ class RecordingRouter { } } -function controlObjects(): Rfc64ControlObjectOperationsV1 { +function controlObjects() { return { namespaceDurability: 'posix-hardlink-no-replace-directory-fsync-v1', getVerifiedObject: vi.fn(async () => null), + getVerifiedObjectByDigest: vi.fn(async () => null), stageVerifiedObjects: vi.fn(async (input) => ({ durable: true, namespaceDurability: 'posix-hardlink-no-replace-directory-fsync-v1', @@ -809,6 +813,7 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { controlObjects: controlObjects(), accessPolicyAuthority: accessPolicyAuthority(), native: nativeOptions(() => inertReconciler()), + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, }); expect(() => service.start()).toThrow('registration failed'); @@ -822,6 +827,10 @@ describe('RFC-64 public catalog service v1 lifecycle ownership', () => { router, `unregister:${RFC64_PUBLIC_CATALOG_BUNDLE_FETCH_PROTOCOL_V1}`, )).toBe(1); + expect(countEvent( + router, + `unregister:${RFC64_PUBLIC_CATALOG_CURRENT_HEAD_DISCOVERY_PROTOCOL_V1}`, + )).toBe(1); await service.close(); }); From fed78e7fe871e14c3f997b47e2e706d3e26486e1 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 00:09:32 +0200 Subject: [PATCH 279/292] fix(agent): generalize RFC-64 discovery policy boundary --- ...ublic-catalog-current-head-discovery-v1.ts | 16 ++++++------ .../src/rfc64/public-catalog-service-v1.ts | 26 +++++++------------ ...-catalog-current-head-discovery-v1.test.ts | 24 ++++++++--------- 3 files changed, 29 insertions(+), 37 deletions(-) diff --git a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts index 3a5974b313..562ef8a0a7 100644 --- a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts @@ -1,10 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 /** - * RFC-64 public/open current-head discovery transport. + * RFC-64 public-root current-head discovery transport. * * Discovery is deliberately a narrow hint protocol. A requester names one - * independently accepted public/open catalog scope and a provider resolves it + * independently accepted public-root catalog scope and a provider resolves it * through a trusted semantic-current-head reader. Before advertising the * result, the provider re-reads and verifies the exact signed head object. The * requester must still exact-fetch and verify that object before treating the @@ -159,7 +159,7 @@ export interface Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1 { trustedCatalogScope: Readonly, ) => Promise; /** Must consult accepted current policy state, never echo the wire digest. */ - readonly authorizeOpenCatalogOperation: ( + readonly authorizeCatalogOperation: ( input: Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, ) => Promise; readonly verifyIssuerSignature: ( @@ -208,8 +208,8 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { if (typeof options.readCurrentAppliedCatalogHeadDigest !== 'function') { fail('catalog-discovery-input', 'readCurrentAppliedCatalogHeadDigest must be a function'); } - if (typeof options.authorizeOpenCatalogOperation !== 'function') { - fail('catalog-discovery-input', 'authorizeOpenCatalogOperation must be a function'); + if (typeof options.authorizeCatalogOperation !== 'function') { + fail('catalog-discovery-input', 'authorizeCatalogOperation must be a function'); } if (typeof options.verifyIssuerSignature !== 'function') { fail('catalog-discovery-input', 'verifyIssuerSignature must be a function'); @@ -412,12 +412,12 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { }) satisfies Rfc64PublicCatalogCurrentHeadAuthorizationInputV1; let authorization: Rfc64PublicCatalogCurrentHeadAuthorizationV1 | null; try { - authorization = await this.options.authorizeOpenCatalogOperation(input); + authorization = await this.options.authorizeCatalogOperation(input); } catch (cause) { - fail('catalog-discovery-policy-denied', 'open catalog discovery authorization failed', cause); + fail('catalog-discovery-policy-denied', 'public catalog discovery authorization failed', cause); } if (authorization === null || authorization.accessPolicy !== 0) { - fail('catalog-discovery-policy-denied', 'current-head discovery is not authorized by open policy'); + fail('catalog-discovery-policy-denied', 'current-head discovery is not authorized by public policy'); } try { assertCanonicalDigest(authorization.policyDigest, 'authorized policyDigest'); diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index 22e7119bca..f93916ae38 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -76,8 +76,6 @@ import { } from './public-catalog-current-head-discovery-v1.js'; import { Rfc64PublicCatalogNativeTransportV1, - type Rfc64PublicCatalogNativeAuthorizationInputV1, - type Rfc64PublicCatalogNativeAuthorizationV1, type Rfc64PublicCatalogNativeTransportOptionsV1, } from './public-catalog-native-transport-v1.js'; import { @@ -164,7 +162,7 @@ export interface Rfc64PublicCatalogServiceNativeOptionsV1 extends Pick< export interface Rfc64PublicCatalogServiceCurrentHeadDiscoveryOptionsV1 { /** * Resolve the durable semantically applied head for one locally trusted - * public/open root scope. Staged-only and candidate heads must not be returned. + * public-root scope. Staged-only and candidate heads must not be returned. */ readonly readCurrentAppliedCatalogHeadDigest: ( trustedScope: Readonly, @@ -269,7 +267,7 @@ export class Rfc64PublicCatalogServiceV1 { this.#transport = new Rfc64PublicCatalogTransportV1(options.router, { controlObjects: this.#controlObjects, - authorizeOpenCatalogOperation: this.#policies.authorize, + authorizeCatalogOperation: this.#policies.authorize, verifyIssuerSignature: this.#verifyIssuerSignature, // Non-blocking: schedule() enqueues synchronously so the transport's ACK // path (which awaits this callback) is never stalled on a fetch. @@ -284,7 +282,7 @@ export class Rfc64PublicCatalogServiceV1 { controlObjects: this.#controlObjects, readCurrentAppliedCatalogHeadDigest: options.currentHeadDiscovery.readCurrentAppliedCatalogHeadDigest, - authorizeOpenCatalogOperation: (input) => + authorizeCatalogOperation: (input) => this.#authorizeCurrentHeadDiscovery(input), verifyIssuerSignature: this.#verifyIssuerSignature, }); @@ -294,7 +292,7 @@ export class Rfc64PublicCatalogServiceV1 { : new Rfc64PublicCatalogNativeTransportV1(options.router, { readCatalogObjectByDigest: options.native.readCatalogObjectByDigest, readKaBundleByDigest: options.native.readKaBundleByDigest, - authorizeOpenCatalogOperation: (input) => this.#authorizeNativeOperation(input), + authorizeCatalogOperation: this.#policies.authorize, verifyIssuerSignature: this.#verifyIssuerSignature, }); const reconciler = options.native === undefined @@ -554,7 +552,7 @@ export class Rfc64PublicCatalogServiceV1 { } /** - * Pull and authenticate one provider's semantically current public/open head. + * Pull and authenticate one provider's semantically current public-root head. * The discovery response is treated as a hint: this method exact-fetches the * named signed head, re-verifies it, and binds it to local accepted policy * before returning. It intentionally does not stage, schedule, or activate. @@ -604,7 +602,7 @@ export class Rfc64PublicCatalogServiceV1 { assertAuthorCatalogHeadScopeBindingV1(head.envelope.payload, currentTrustedScope); } catch (cause) { throw new Error( - 'RFC-64 discovered head differs from the accepted public/open policy scope', + 'RFC-64 discovered head differs from the accepted public policy scope', { cause }, ); } @@ -612,7 +610,7 @@ export class Rfc64PublicCatalogServiceV1 { } /** - * Discover one provider's current public/open head, enqueue that exact + * Discover one provider's current public-root head, enqueue that exact * authenticated head through the ordinary receiver, and wait for all * scheduled reconciliation work to drain. A caller must still inspect the * durable applied-head record: receiver failures are reported through its @@ -726,12 +724,6 @@ export class Rfc64PublicCatalogServiceV1 { }); } - async #authorizeNativeOperation( - input: Rfc64PublicCatalogNativeAuthorizationInputV1, - ): Promise { - return this.#policies.authorize(input); - } - #resolveTrustedCatalogScope( announcement: Rfc64PublicCatalogHeadAnnouncementV1, ): Readonly { @@ -759,7 +751,7 @@ export class Rfc64PublicCatalogServiceV1 { const record = this.#policies.lookup(input.networkId, input.contextGraphId); if (record === null) { throw new Error( - 'RFC-64 current-head query is not bound to the accepted public/open root policy', + 'RFC-64 current-head query is not bound to the accepted public root policy', ); } try { @@ -807,7 +799,7 @@ export class Rfc64PublicCatalogServiceV1 { ); } catch (cause) { throw new Error( - 'RFC-64 fetched head differs from the accepted public/open policy scope', + 'RFC-64 fetched head differs from the accepted public policy scope', { cause }, ); } diff --git a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts index b6023711e2..ee663047d7 100644 --- a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts @@ -519,7 +519,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { }, readCurrentAppliedCatalogHeadDigest: vi.fn(async () => stored.head.objectDigest as Digest32V1), - authorizeOpenCatalogOperation: vi.fn(async () => ({ + authorizeCatalogOperation: vi.fn(async () => ({ ...directAuthorization(), })), verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, @@ -567,7 +567,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { }, readCurrentAppliedCatalogHeadDigest: vi.fn(async () => stored.head.objectDigest as Digest32V1), - authorizeOpenCatalogOperation: vi.fn(async () => ({ + authorizeCatalogOperation: vi.fn(async () => ({ ...directAuthorization(), })), verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, @@ -587,7 +587,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { }); it('rechecks outbound policy after the awaited remote response', async () => { - const authorizeOpenCatalogOperation = vi.fn() + const authorizeCatalogOperation = vi.fn() .mockResolvedValueOnce(directAuthorization()) .mockResolvedValueOnce(null); const send = vi.fn(async () => Uint8Array.of(0)); @@ -599,7 +599,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { controlObjects: { getVerifiedObjectByDigest: vi.fn(async () => null) }, readCurrentAppliedCatalogHeadDigest: vi.fn(async () => null), - authorizeOpenCatalogOperation, + authorizeCatalogOperation, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }); transport.start(); @@ -612,7 +612,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { await expect(transport.discoverCurrentCatalogHead('provider-peer', query)) .rejects.toMatchObject({ code: 'catalog-discovery-policy-denied' }); expect(send).toHaveBeenCalledTimes(1); - expect(authorizeOpenCatalogOperation).toHaveBeenCalledTimes(2); + expect(authorizeCatalogOperation).toHaveBeenCalledTimes(2); transport.stop(); }); @@ -622,7 +622,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { peerId: { toString(): string }, options?: { signal?: AbortSignal }, ) => Promise) | undefined; - const authorizeOpenCatalogOperation = vi.fn() + const authorizeCatalogOperation = vi.fn() .mockResolvedValueOnce(directAuthorization()) .mockResolvedValueOnce(null); const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); @@ -635,7 +635,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { controlObjects: { getVerifiedObjectByDigest: vi.fn(async () => null) }, readCurrentAppliedCatalogHeadDigest, - authorizeOpenCatalogOperation, + authorizeCatalogOperation, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }); transport.start(); @@ -650,7 +650,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { { toString: () => 'requester-peer' }, )).resolves.toEqual(Uint8Array.of(2)); expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalledTimes(2); - expect(authorizeOpenCatalogOperation).toHaveBeenCalledTimes(2); + expect(authorizeCatalogOperation).toHaveBeenCalledTimes(2); transport.stop(); }); @@ -701,7 +701,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { controlObjects: { getVerifiedObjectByDigest: vi.fn(async () => null) }, readCurrentAppliedCatalogHeadDigest: vi.fn(async () => null), - authorizeOpenCatalogOperation: vi.fn(async () => directAuthorization()), + authorizeCatalogOperation: vi.fn(async () => directAuthorization()), verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }); transport.start(); @@ -786,7 +786,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { unregister() {}, } as unknown as ProtocolRouter; const readCurrentAppliedCatalogHeadDigest = vi.fn(async () => null); - const authorizeOpenCatalogOperation = vi.fn(async () => ({ + const authorizeCatalogOperation = vi.fn(async () => ({ ...directAuthorization(), })); const transport = new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { @@ -794,7 +794,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { getVerifiedObjectByDigest: vi.fn(async () => null), }, readCurrentAppliedCatalogHeadDigest, - authorizeOpenCatalogOperation, + authorizeCatalogOperation, verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, }); transport.start(); @@ -812,7 +812,7 @@ describe('RFC-64 public catalog current-head discovery v1', () => { { toString: () => 'test-peer' }, { signal: controller.signal }, )).rejects.toBe(reason); - expect(authorizeOpenCatalogOperation).not.toHaveBeenCalled(); + expect(authorizeCatalogOperation).not.toHaveBeenCalled(); expect(readCurrentAppliedCatalogHeadDigest).not.toHaveBeenCalled(); transport.stop(); }); From ad230c238dee5bcccca906cfef5209dd28a17d4c Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 00:43:27 +0200 Subject: [PATCH 280/292] fix(agent): close RFC-64 cold-start review gaps --- packages/agent/scripts/test-package-root.mjs | 1 + .../agent/src/dkg-agent-rfc64-catalog-sync.ts | 89 ++++++++++++++ packages/agent/src/dkg-agent-rfc64-catalog.ts | 70 +---------- packages/agent/src/dkg-agent.ts | 5 +- ...ublic-catalog-current-head-discovery-v1.ts | 34 +++++- .../public-catalog-native-receiver-v1.ts | 90 +++++++++++++++ .../src/rfc64/public-catalog-service-v1.ts | 9 +- ...d-discovery-legacy-authorizer.typecheck.ts | 26 +++++ ...kg-agent-native-wiring.integration.test.ts | 73 ++++++++++++ ...-catalog-current-head-discovery-v1.test.ts | 109 ++++++++++++++++++ ...c-catalog-native-gate1.integration.test.ts | 48 ++++++++ 11 files changed, 473 insertions(+), 81 deletions(-) create mode 100644 packages/agent/src/dkg-agent-rfc64-catalog-sync.ts create mode 100644 packages/agent/test/rfc64-current-head-discovery-legacy-authorizer.typecheck.ts diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index d6ea163fb8..35d18d59c2 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -44,6 +44,7 @@ const requiredCatalogMethods = [ 'acceptRfc64CatalogAccessSnapshotV1', 'publishAuthorCatalogGenesisV1', 'publishAuthorCatalogExactSetSuccessorV1', + 'synchronizeRfc64PublicCatalogFromProviderV1', ]; for (const method of requiredCatalogMethods) { if ( diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-sync.ts b/packages/agent/src/dkg-agent-rfc64-catalog-sync.ts new file mode 100644 index 0000000000..50f9fa996b --- /dev/null +++ b/packages/agent/src/dkg-agent-rfc64-catalog-sync.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Focused DKGAgent facade for current-head discovery plus durable receiver + * synchronization. Keeping the orchestration here prevents the catalog + * lifecycle/authoring mixin from becoming the home for every RFC-64 workflow. + */ + +import { + computeAuthorCatalogScopeDigestV1, + deriveAuthorCatalogScopeFromHeadV1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; + +import { DKGAgentBase } from './dkg-agent-base.js'; +import type { DKGAgent } from './dkg-agent.js'; +import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; +import type { + Rfc64PublicCatalogCurrentHeadScopeV1, +} from './rfc64/public-catalog-current-head-discovery-v1.js'; + +/** Explicit provider + independently accepted public-root scope for a cold pull. */ +export interface SynchronizeRfc64PublicCatalogFromProviderParamsV1 { + readonly remotePeerId: string; + readonly scope: Rfc64PublicCatalogCurrentHeadScopeV1; + readonly signal?: AbortSignal; +} + +/** Exact durable postcondition of a successful provider synchronization. */ +export interface SynchronizeRfc64PublicCatalogFromProviderResultV1 + extends AppliedCatalogHeadSnapshotV1 { + readonly providerPeerId: string; + readonly signatureVariantDigest: Digest32V1; +} + +export class Rfc64CatalogSyncMethods extends DKGAgentBase { + /** + * Pull one provider's authenticated current public-root head and run it + * through the ordinary durable receiver. `null` means the provider has no + * applied head for the accepted scope. A non-null return proves the exact + * discovered head is now the receiver's durable applied-head post-read. + */ + async synchronizeRfc64PublicCatalogFromProviderV1( + this: DKGAgent, + params: SynchronizeRfc64PublicCatalogFromProviderParamsV1, + ): Promise { + const providerPeerId = params.remotePeerId; + const scope = params.scope; + const signal = params.signal; + const service = this.rfc64PublicCatalogServiceV1; + if (service === undefined) { + throw new Error('RFC-64 public catalog service is not started'); + } + const synchronized = await service.synchronizeCurrentCatalogHead({ + remotePeerId: providerPeerId, + scope, + ...(signal === undefined ? {} : { signal }), + }); + if (synchronized === null) return null; + const catalogScope = deriveAuthorCatalogScopeFromHeadV1( + synchronized.head.envelope.payload, + ); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(catalogScope); + const applied = this.rfc64PersistenceV1?.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + synchronized.announcement.authorAddress, + ) ?? null; + if ( + applied === null + || applied.currentCatalogHeadDigest + !== synchronized.announcement.catalogHeadObjectDigest + || applied.catalogVersion !== synchronized.announcement.catalogVersion + ) { + const failure = this.readRfc64PublicCatalogReconciliationFailureV1( + synchronized.announcement.catalogHeadObjectDigest, + ); + throw new Error( + failure === null + ? 'RFC-64 current public catalog head did not reach its durable applied postcondition' + : `RFC-64 current public catalog head reconciliation failed (${failure.errorCode ?? failure.errorName})`, + ); + } + return Object.freeze({ + ...applied, + providerPeerId, + signatureVariantDigest: synchronized.announcement.signatureVariantDigest, + }); + } +} diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 3bc98d65be..e3a8aefdd9 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -81,9 +81,6 @@ import { Rfc64PublicCatalogNativeReceiverV1, type Rfc64PublicCatalogNativeSynchronizationEvidenceV1, } from './rfc64/public-catalog-native-receiver-v1.js'; -import type { - Rfc64PublicCatalogCurrentHeadScopeV1, -} from './rfc64/public-catalog-current-head-discovery-v1.js'; import { createRfc64FinalizedVmAgentPrecommitV1 } from './rfc64/finalized-vm-agent-precommit-v1.js'; import { createRfc64BoundedPublicRootCatalogNativeReconcilerV1, @@ -170,19 +167,10 @@ export interface Rfc64AppliedCatalogHeadRefV1 { readonly authorAddress: EvmAddressV1; } -/** Explicit provider + independently accepted public-root scope for a cold pull. */ -export interface SynchronizeRfc64PublicCatalogFromProviderParamsV1 { - readonly remotePeerId: string; - readonly scope: Rfc64PublicCatalogCurrentHeadScopeV1; - readonly signal?: AbortSignal; -} - -/** Exact durable postcondition of a successful provider synchronization. */ -export interface SynchronizeRfc64PublicCatalogFromProviderResultV1 - extends AppliedCatalogHeadSnapshotV1 { - readonly providerPeerId: string; - readonly signatureVariantDigest: Digest32V1; -} +export type { + SynchronizeRfc64PublicCatalogFromProviderParamsV1, + SynchronizeRfc64PublicCatalogFromProviderResultV1, +} from './dkg-agent-rfc64-catalog-sync.js'; export { RFC64_PUBLIC_CATALOG_RECONCILIATION_FAILURE_MAX_ENTRIES_V1, @@ -432,56 +420,6 @@ export class Rfc64CatalogMethods extends DKGAgentBase { return this.requireRfc64PublicCatalogServiceV1().announceCatalogHead(input); } - /** - * Pull one provider's authenticated current public-root head and run it - * through the ordinary durable receiver. `null` means the provider has no - * applied head for the accepted scope. A non-null return proves the exact - * discovered head is now the receiver's durable applied-head post-read. - */ - async synchronizeRfc64PublicCatalogFromProviderV1( - this: DKGAgent, - params: SynchronizeRfc64PublicCatalogFromProviderParamsV1, - ): Promise { - const providerPeerId = params.remotePeerId; - const scope = params.scope; - const signal = params.signal; - const synchronized = await this.requireRfc64PublicCatalogServiceV1() - .synchronizeCurrentCatalogHead({ - remotePeerId: providerPeerId, - scope, - ...(signal === undefined ? {} : { signal }), - }); - if (synchronized === null) return null; - const catalogScope = deriveAuthorCatalogScopeFromHeadV1( - synchronized.head.envelope.payload, - ); - const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(catalogScope); - const applied = this.rfc64PersistenceV1?.inventory.readAppliedCatalogHeadV1( - catalogScopeDigest, - synchronized.announcement.authorAddress, - ) ?? null; - if ( - applied === null - || applied.currentCatalogHeadDigest - !== synchronized.announcement.catalogHeadObjectDigest - || applied.catalogVersion !== synchronized.announcement.catalogVersion - ) { - const failure = this.readRfc64PublicCatalogReconciliationFailureV1( - synchronized.announcement.catalogHeadObjectDigest, - ); - throw new Error( - failure === null - ? 'RFC-64 current public catalog head did not reach its durable applied postcondition' - : `RFC-64 current public catalog head reconciliation failed (${failure.errorCode ?? failure.errorName})`, - ); - } - return Object.freeze({ - ...applied, - providerPeerId, - signatureVariantDigest: synchronized.announcement.signatureVariantDigest, - }); - } - /** * Public/open author path for one exact root-lane successor. The predecessor * is reloaded from durable provider storage, the hardened producer performs diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index d906f890e9..5ad14a7aed 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -406,6 +406,7 @@ import { snapshotRfc64CatalogAccessPolicyAuthorityV1, snapshotRfc64CatalogDeploymentProfileV1, } from './dkg-agent-rfc64-catalog.js'; +import { Rfc64CatalogSyncMethods } from './dkg-agent-rfc64-catalog-sync.js'; import { ContextGraphRegistryMethods } from './dkg-agent-cg-registry.js'; import { JoinRequestMethods } from './dkg-agent-join.js'; import { SwmSubstrateMethods } from './dkg-agent-swm-substrate.js'; @@ -3057,5 +3058,5 @@ export class DKGAgent extends DKGAgentBase { } -export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods {} -applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods]); +export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods {} +applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods]); diff --git a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts index 562ef8a0a7..628642edd8 100644 --- a/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-current-head-discovery-v1.ts @@ -159,7 +159,11 @@ export interface Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1 { trustedCatalogScope: Readonly, ) => Promise; /** Must consult accepted current policy state, never echo the wire digest. */ - readonly authorizeCatalogOperation: ( + readonly authorizeCatalogOperation?: ( + input: Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, + ) => Promise; + /** @deprecated Use authorizeCatalogOperation. */ + readonly authorizeOpenCatalogOperation?: ( input: Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, ) => Promise; readonly verifyIssuerSignature: ( @@ -197,6 +201,9 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryErrorV1 extends Error { */ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { #started = false; + readonly #authorizeCatalogOperation: ( + input: Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, + ) => Promise; constructor( private readonly router: ProtocolRouter, @@ -208,9 +215,7 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { if (typeof options.readCurrentAppliedCatalogHeadDigest !== 'function') { fail('catalog-discovery-input', 'readCurrentAppliedCatalogHeadDigest must be a function'); } - if (typeof options.authorizeCatalogOperation !== 'function') { - fail('catalog-discovery-input', 'authorizeCatalogOperation must be a function'); - } + this.#authorizeCatalogOperation = normalizeCurrentHeadDiscoveryAuthorizerV1(options); if (typeof options.verifyIssuerSignature !== 'function') { fail('catalog-discovery-input', 'verifyIssuerSignature must be a function'); } @@ -412,7 +417,7 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { }) satisfies Rfc64PublicCatalogCurrentHeadAuthorizationInputV1; let authorization: Rfc64PublicCatalogCurrentHeadAuthorizationV1 | null; try { - authorization = await this.options.authorizeCatalogOperation(input); + authorization = await this.#authorizeCatalogOperation(input); } catch (cause) { fail('catalog-discovery-policy-denied', 'public catalog discovery authorization failed', cause); } @@ -437,6 +442,25 @@ export class Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1 { } } +function normalizeCurrentHeadDiscoveryAuthorizerV1( + options: Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1, +): ( + input: Rfc64PublicCatalogCurrentHeadAuthorizationInputV1, +) => Promise { + const current = options.authorizeCatalogOperation; + const legacyOpen = options.authorizeOpenCatalogOperation; + if ( + (typeof current !== 'function' && typeof legacyOpen !== 'function') + || (typeof current === 'function' && typeof legacyOpen === 'function') + ) { + fail( + 'catalog-discovery-input', + 'exactly one catalog access-policy authorizer must be configured', + ); + } + return typeof current === 'function' ? current : legacyOpen!; +} + export function encodeRfc64PublicCatalogCurrentHeadQueryV1( input: Rfc64PublicCatalogCurrentHeadQueryV1, ): Uint8Array { diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index e95e5d0f94..f2ce0528b5 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -18,6 +18,7 @@ import { AUTHOR_CATALOG_ISSUER_DELEGATION_OBJECT_TYPE_V1, AUTHOR_CATALOG_BUCKET_OBJECT_TYPE_V1, AUTHOR_CATALOG_DIRECTORY_NODE_OBJECT_TYPE_V1, + ASSERTION_SEAL_PREDICATES, DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1, ZERO_DIGEST32_V1, @@ -814,6 +815,17 @@ export class Rfc64PublicCatalogNativeReceiverV1 { head, trustedCatalogScope, ); + if (historyDisposition === 'cold-bootstrap') { + await assertColdBootstrapHasNoOmittedSemanticStateV1( + this.options.store, + trustedCatalogScope, + preparedRows.map((prepared) => transitionLocationFromTarget( + head, + prepared.row, + prepared.sealBinding, + )), + ); + } const targetKaIds = new Set(preparedRows.map(({ row }) => row.kaId)); const plannedRemovals = predecessorRows .filter((row) => !targetKaIds.has(row.kaId)) @@ -1567,6 +1579,84 @@ interface Rfc64SemanticTransitionPreimageV1 readonly sealQuads: readonly Readonly[]; } +/** + * Missing durable history is not proof that the semantic store is empty: a + * process may have died after atomic activation and before the applied-head + * CAS. Cold bootstrap is therefore allowed only when every materialization + * already present for this exact author/root scope belongs to the fetched + * target. Matching target rows are safe repair preimages; any omitted graph or + * author-seal subject makes the successor fail closed before mutation/CAS. + */ +async function assertColdBootstrapHasNoOmittedSemanticStateV1( + store: TripleStore, + scope: Readonly, + targets: readonly Readonly[], +): Promise { + const allowedGraphs = new Set(targets.map((target) => target.swmGraph)); + const allowedSealSubjects = new Set(targets.map((target) => target.sealSubject)); + const swmPrefix = `${contextGraphWorkspaceGraphUri(scope.contextGraphId)}` + + `/${scope.authorAddress}/`; + let existingGraphs: string[]; + try { + existingGraphs = store.listGraphsByPrefix === undefined + ? (await store.listGraphs({ source: 'rfc64-cold-bootstrap-namespace-proof' })) + .filter((graph) => graph.startsWith(swmPrefix)) + : await store.listGraphsByPrefix( + swmPrefix, + { source: 'rfc64-cold-bootstrap-namespace-proof' }, + ); + } catch (cause) { + fail( + 'catalog-native-receiver-history', + 'cold bootstrap could not prove the author SWM namespace is exact', + cause, + ); + } + if ( + existingGraphs.length > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 + || existingGraphs.some((graph) => !allowedGraphs.has(graph)) + ) { + fail( + 'catalog-native-receiver-history', + 'cold bootstrap found author SWM materialization omitted by the fetched exact head', + ); + } + + const metaGraph = targets[0]?.sealMetaGraph; + if (metaGraph === undefined) { + fail('catalog-native-receiver-history', 'cold successor has no semantic target scope'); + } + let sealSubjects; + try { + sealSubjects = await store.query( + `SELECT DISTINCT ?s WHERE { GRAPH <${metaGraph}> { ` + + `?s <${ASSERTION_SEAL_PREDICATES.AUTHOR_ADDRESS}> ` + + `${JSON.stringify(scope.authorAddress)} } } ` + + `LIMIT ${MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 + 1}`, + { + source: 'rfc64-cold-bootstrap-namespace-proof', + maxResponseBytes: 1024 * 1024, + }, + ); + } catch (cause) { + fail( + 'catalog-native-receiver-history', + 'cold bootstrap could not prove the author-seal namespace is exact', + cause, + ); + } + if ( + sealSubjects.type !== 'bindings' + || sealSubjects.bindings.length > MAX_AUTHOR_CATALOG_BUCKET_ROWS_V1 + || sealSubjects.bindings.some(({ s }) => typeof s !== 'string' || !allowedSealSubjects.has(s)) + ) { + fail( + 'catalog-native-receiver-history', + 'cold bootstrap found author-seal materialization omitted by the fetched exact head', + ); + } +} + function transitionLocationFromRemoval( removal: Readonly, ): Readonly { diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index f93916ae38..f598b6c9b4 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -85,7 +85,6 @@ import { type Rfc64BoundedPublicRootCatalogTrustedScopeResolverV1, } from './public-catalog-native-reconciler-v1.js'; import { - deriveRfc64PublicOpenCatalogScopeV1, deriveRfc64PublicRootCatalogScopeV1, } from './public-open-catalog-scope-v1.js'; import type { @@ -763,13 +762,7 @@ export class Rfc64PublicCatalogServiceV1 { })) { throw new Error('catalog author is not authorized by the accepted policy'); } - return record.policy.source.kind === 'owner-signed-unregistered' - && record.policy.governanceChainId === null - && record.policy.governanceContractAddress === null - && record.policy.ownershipTransitionDigest === null - && record.policy.source.ownerAddress === input.authorAddress - ? deriveRfc64PublicOpenCatalogScopeV1(input, record.policy) - : deriveRfc64PublicRootCatalogScopeV1(input, record.policy); + return deriveRfc64PublicRootCatalogScopeV1(input, record.policy); } catch (cause) { throw new Error( 'RFC-64 current-head query is not bound to the accepted public root policy', diff --git a/packages/agent/test/rfc64-current-head-discovery-legacy-authorizer.typecheck.ts b/packages/agent/test/rfc64-current-head-discovery-legacy-authorizer.typecheck.ts new file mode 100644 index 0000000000..9d0f330a15 --- /dev/null +++ b/packages/agent/test/rfc64-current-head-discovery-legacy-authorizer.typecheck.ts @@ -0,0 +1,26 @@ +import { + type Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1, +} from '@origintrail-official/dkg-agent'; + +declare const controlObjects: + Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1['controlObjects']; +declare const readCurrentAppliedCatalogHeadDigest: + Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1[ + 'readCurrentAppliedCatalogHeadDigest' + ]; +declare const authorizeOpenCatalogOperation: NonNullable< + Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1[ + 'authorizeOpenCatalogOperation' + ] +>; +declare const verifyIssuerSignature: + Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1['verifyIssuerSignature']; + +const legacyOptions: Rfc64PublicCatalogCurrentHeadDiscoveryTransportOptionsV1 = { + controlObjects, + readCurrentAppliedCatalogHeadDigest, + authorizeOpenCatalogOperation, + verifyIssuerSignature, +}; + +void legacyOptions; diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 5858ee9ac8..e1d7d8ba48 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -654,6 +654,79 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); + it('rejects the provider-sync API when scheduled semantic activation fails', async () => { + const [author, provider] = await Promise.all([ + startNativeAgent('failure-author'), + startNativeAgent('failure-provider'), + ]); + provider.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + await connectBothWays(author, provider); + const genesis = await author.publishOpenAuthorCatalogGenesisV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + author: AUTHOR_WALLET, + peers: [provider.peerId], + issuedAt: FIXED_HEAD_ISSUED_AT, + catalogIssuerDelegationEffectiveAt: DELEGATION_EFFECTIVE_AT, + catalogIssuerDelegationExpiresAt: MULTI_DELEGATION_EXPIRES_AT, + }); + await provider.whenRfc64PublicCatalogReceiverIdleV1(); + const successor = await author.publishOpenAuthorCatalogSuccessorV1({ + previousHead: { + objectDigest: genesis.headObjectDigest, + signatureVariantDigest: genesis.signatureVariantDigest, + }, + author: AUTHOR_WALLET, + catalogIssuerAuthorization: genesis.catalogIssuerAuthorization, + assertionCoordinate: 'failure-current-snapshot' as never, + projectionBytes: PROJECTION, + seal: await authorSeal(7n), + deployment: NATIVE_DEPLOYMENT, + issuedAt: SUCCESSOR_ISSUED_AT, + peers: [provider.peerId], + }); + await provider.whenRfc64PublicCatalogReceiverIdleV1(); + + const cold = await startNativeAgent('failure-late-receiver'); + cold.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + await connectBothWays(provider, cold); + const replaceGraphAndSubject = vi.spyOn( + (cold as any).store, + 'replaceGraphAndSubject', + ).mockRejectedValue(new Error('simulated receiver semantic-activation failure')); + + await expect(cold.synchronizeRfc64PublicCatalogFromProviderV1({ + remotePeerId: provider.peerId, + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: '0', + }, + })).rejects.toThrow(/reconciliation failed \(catalog-native-receiver-activation\)/u); + expect(replaceGraphAndSubject).toHaveBeenCalled(); + expect(cold.readRfc64PublicCatalogReconciliationFailureV1( + successor.headObjectDigest, + )).toEqual({ + catalogHeadDigest: successor.headObjectDigest, + errorName: 'Rfc64PublicCatalogNativeReceiverErrorV1', + errorCode: 'catalog-native-receiver-activation', + }); + expect(cold.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toBeNull(); + }, 60_000); + it('materializes finalized VM through production two-agent wiring before applying the head', async () => { const kaNumber = 7n; const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); diff --git a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts index ee663047d7..02f8372373 100644 --- a/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts +++ b/packages/agent/test/rfc64-public-catalog-current-head-discovery-v1.test.ts @@ -5,9 +5,11 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { DKGNode, + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, ProtocolRouter, computeControlSignatureVariantDigestHex, type AuthorCatalogScopeV1, + type ContextGraphPolicyV1, type Digest32V1, type EvmAddressV1, type TimestampMsV1, @@ -44,6 +46,8 @@ const AUTHOR_WALLET = new ethers.Wallet(`0x${'64'.repeat(32)}`); const AUTHOR = AUTHOR_WALLET.address.toLowerCase() as EvmAddressV1; const DELEGATION_DIGEST = `0x${'72'.repeat(32)}` as Digest32V1; const POLICY_DIGEST = `0x${'71'.repeat(32)}` as Digest32V1; +const GOVERNANCE_CONTRACT = + '0x5555555555555555555555555555555555555555' as EvmAddressV1; const temporaryDirectories: string[] = []; const nodes: DKGNode[] = []; @@ -107,6 +111,43 @@ function catalogScope( }) as AuthorCatalogScopeV1; } +function governedCatalogScope(): AuthorCatalogScopeV1 { + return Object.freeze({ + ...catalogScope(), + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: `0x${'57'.repeat(32)}`, + }) as AuthorCatalogScopeV1; +} + +function finalizedPublicPolicy(): ContextGraphPolicyV1 { + return Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: GOVERNANCE_CONTRACT, + ownershipTransitionDigest: `0x${'57'.repeat(32)}`, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'finalized-chain', + chainId: '20430', + contractAddress: GOVERNANCE_CONTRACT, + blockNumber: '123', + blockHash: `0x${'77'.repeat(32)}`, + }, + effectiveAt: '1773900000000', + issuedAt: '1773900000000', + }); +} + async function stageHead( persistence: Rfc64PersistenceV1, scope = catalogScope(), @@ -179,6 +220,27 @@ function directAuthorization(scope = catalogScope()) { } describe('RFC-64 public catalog current-head discovery v1', () => { + it('accepts the deprecated open-authorizer option but rejects ambiguity', () => { + const router = { + register() {}, + unregister() {}, + } as unknown as ProtocolRouter; + const legacy = vi.fn(async () => directAuthorization()); + expect(() => new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { + controlObjects: { getVerifiedObjectByDigest: vi.fn(async () => null) }, + readCurrentAppliedCatalogHeadDigest: vi.fn(async () => null), + authorizeOpenCatalogOperation: legacy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).not.toThrow(); + expect(() => new Rfc64PublicCatalogCurrentHeadDiscoveryTransportV1(router, { + controlObjects: { getVerifiedObjectByDigest: vi.fn(async () => null) }, + readCurrentAppliedCatalogHeadDigest: vi.fn(async () => null), + authorizeCatalogOperation: legacy, + authorizeOpenCatalogOperation: legacy, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + })).toThrow(/exactly one catalog access-policy authorizer/u); + }); + it('treats an unsorted expected-key declaration as an exact key set', () => { expect(snapshotRfc64ExactWireRecordV1( { alpha: '1', beta: '2' }, @@ -273,6 +335,53 @@ describe('RFC-64 public catalog current-head discovery v1', () => { })).resolves.toBeNull(); }, 30_000); + it('preserves the finalized public governance scope during current-head discovery', async () => { + const [providerNode, requesterNode, providerPersistence, requesterPersistence] = + await Promise.all([ + startNode(), + startNode(), + openPersistence('governed-provider'), + openPersistence('governed-requester'), + ]); + await connect(requesterNode, providerNode); + const expectedScope = governedCatalogScope(); + const head = await stageHead(providerPersistence, expectedScope); + const readCurrentAppliedCatalogHeadDigest = vi.fn(async ( + trustedScope: Readonly, + ) => { + expect(trustedScope).toEqual(expectedScope); + return head.objectDigest as Digest32V1; + }); + const provider = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(providerNode), + controlObjects: providerPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest }, + transportTimeoutMs: 4_000, + }); + const requester = new Rfc64PublicCatalogServiceV1({ + router: new ProtocolRouter(requesterNode), + controlObjects: requesterPersistence.controlObjects, + currentHeadDiscovery: { readCurrentAppliedCatalogHeadDigest: async () => null }, + transportTimeoutMs: 4_000, + }); + services.push(provider, requester); + for (const service of [provider, requester]) { + service.acceptPolicySnapshot({ + policy: finalizedPublicPolicy(), + policyDigest: POLICY_DIGEST, + }); + service.start(); + } + + await expect(requester.discoverCurrentCatalogHead({ + remotePeerId: providerNode.peerId, + scope: discoveryScope(), + })).resolves.toMatchObject({ + head: { envelope: { objectDigest: head.objectDigest } }, + }); + expect(readCurrentAppliedCatalogHeadDigest).toHaveBeenCalledTimes(2); + }, 30_000); + it('retries once when the applied ref advances while the old immutable head is read', async () => { const [providerNode, requesterNode, providerPersistence, requesterPersistence] = await Promise.all([ diff --git a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts index e448645511..b7e8e3c2b8 100644 --- a/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts +++ b/packages/agent/test/rfc64-public-catalog-native-gate1.integration.test.ts @@ -19,9 +19,12 @@ import { computeCanonicalGraphScopedAuthorSealDigestV1, computeControlObjectDigestHex, computeKaChunkTreeRootV1, + decodeOpaqueKaBundleV1, deriveCanonicalGraphScopedAuthorSealPlacementV1, deriveAuthorCatalogScopeFromHeadV1, encodeOpaqueKaBundleV1, + parseCanonicalGraphScopedAuthorSealV1, + projectCanonicalGraphScopedAuthorSealRowsV1, type AuthorCatalogRowV1, type AuthorCatalogScopeV1, type AuthorCatalogHeadV1, @@ -152,6 +155,51 @@ async function connect(from: DKGNode, to: DKGNode): Promise { } describe('RFC-64 Gate 1 native successor to public SWM', () => { + it('refuses cold bootstrap when an omitted author projection and seal lack durable history', async () => { + const fixture = await setupLiveReceiver(); + const decoded = decodeOpaqueKaBundleV1(fixture.secondRowBundle.bundleBytes); + const seal = parseCanonicalGraphScopedAuthorSealV1(decoded.sealBytes); + const placement = deriveCanonicalGraphScopedAuthorSealPlacementV1({ + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + assertionCoordinate: fixture.secondRowBundle.row.assertionCoordinate, + }); + const staleGraph = `did:dkg:context-graph:${CONTEXT_GRAPH_ID}` + + `/_shared_memory/${AUTHOR}/${SECOND_KA_NUMBER}`; + await fixture.receiverStore.insert([ + { + subject: 'https://example.org/stale', + predicate: 'https://schema.org/name', + object: '"omitted"', + graph: staleGraph, + }, + ...projectCanonicalGraphScopedAuthorSealRowsV1(seal, { + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + assertionCoordinate: fixture.secondRowBundle.row.assertionCoordinate, + }), + ]); + + await expect(fixture.synchronize()).rejects.toMatchObject({ + code: 'catalog-native-receiver-history', + message: expect.stringContaining('omitted by the fetched exact head'), + }); + expect(fixture.receiverPersistence.inventory.readAppliedCatalogHeadV1( + fixture.scopeDigest, + AUTHOR, + )).toBeNull(); + await expect(fixture.receiverStore.hasGraph(staleGraph)).resolves.toBe(true); + const sealRead = await fixture.receiverStore.query( + `SELECT ?p ?o WHERE { GRAPH <${placement.metaGraph}> { ` + + `<${placement.subject}> ?p ?o } } LIMIT 1`, + ); + expect(sealRead).toMatchObject({ type: 'bindings' }); + if (sealRead.type !== 'bindings') throw new Error('stale seal query was not bindings'); + expect(sealRead.bindings).toHaveLength(1); + }, 30_000); + it('bootstraps exact empty genesis then activates one successor without manual seeding', async () => { const fixture = await setupLiveReceiver(); const genesisEvidence = await fixture.bootstrap(); From ee3a40872ce3adc62aad1f976ac64b4d509535c3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 00:51:26 +0200 Subject: [PATCH 281/292] feat(agent): advance RFC-64 catalog after public publish --- packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/dkg-agent-base.ts | 2 + packages/agent/src/dkg-agent-publish.ts | 37 ++ .../dkg-agent-rfc64-catalog-auto-publish.ts | 397 ++++++++++++++++++ packages/agent/src/dkg-agent-rfc64-catalog.ts | 5 +- packages/agent/src/dkg-agent-types.ts | 14 + packages/agent/src/dkg-agent.ts | 10 +- .../src/rfc64/catalog-authority-config-v1.ts | 87 +++- .../agent/test/encrypt-inline-policy.test.ts | 43 +- .../test/publish-finalized-agent-lane.test.ts | 16 + ...kg-agent-native-wiring.integration.test.ts | 169 ++++++++ 11 files changed, 775 insertions(+), 6 deletions(-) create mode 100644 packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index d6ea163fb8..9af3ac25ad 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -44,6 +44,7 @@ const requiredCatalogMethods = [ 'acceptRfc64CatalogAccessSnapshotV1', 'publishAuthorCatalogGenesisV1', 'publishAuthorCatalogExactSetSuccessorV1', + 'recordConfirmedRfc64PublicCatalogAssetV1', ]; for (const method of requiredCatalogMethods) { if ( diff --git a/packages/agent/src/dkg-agent-base.ts b/packages/agent/src/dkg-agent-base.ts index fbad9fd3fa..9d28767011 100644 --- a/packages/agent/src/dkg-agent-base.ts +++ b/packages/agent/src/dkg-agent-base.ts @@ -1023,6 +1023,8 @@ export class DKGAgentBase { /** Bounded process-local terminal receiver failures, keyed by announced head. */ protected readonly rfc64PublicCatalogReconciliationFailuresV1 = new Rfc64PublicCatalogReconciliationFailureRegistryV1(); + /** Serialize local author-head construction/CAS independently per exact scope. */ + protected readonly rfc64AuthorCatalogMutationQueuesV1 = new Map>(); protected readonly subscribedContextGraphs = new Map(); protected contextGraphSubscriptionRehydrationStatus: ContextGraphSubscriptionRehydrationStatus | null = null; protected readonly contextGraphSubscriptionRehydrationAccountedIds = new Set(); diff --git a/packages/agent/src/dkg-agent-publish.ts b/packages/agent/src/dkg-agent-publish.ts index 312ca7e4f7..3e7825a7ca 100644 --- a/packages/agent/src/dkg-agent-publish.ts +++ b/packages/agent/src/dkg-agent-publish.ts @@ -98,6 +98,7 @@ import { ciphertextChunkStoreSubject, CIPHERTEXT_CHUNK_PREDICATE, type SubscriptionSource, + type AssertionCoordinateV1, SUBSCRIPTION_SOURCES, pickNetworkTunables, withSpan, @@ -5406,6 +5407,24 @@ export class PublishMethods extends DKGAgentBase { } } + if (result.status === 'confirmed') { + try { + await this.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: request.contextGraphId as never, + subGraphName: request.subGraphName as never, + assertionCoordinate: request.name as AssertionCoordinateV1, + publicQuads: snapshotQuads, + seal, + }); + } catch (err) { + this.log.warn( + ctx, + `Confirmed queued publish for <${assertionUri}> but RFC-64 catalog advancement failed: ` + + (err instanceof Error ? err.message : String(err)), + ); + } + } + return { ...result, assertionUri, seal }; } @@ -5960,6 +5979,24 @@ export class PublishMethods extends DKGAgentBase { } } + if (result.status === 'confirmed') { + try { + await this.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: contextGraphId as never, + subGraphName: opts?.subGraphName as never, + assertionCoordinate: name as AssertionCoordinateV1, + publicQuads: canonicalSwmQuads, + seal, + }); + } catch (err) { + this.log.warn( + opts?.operationCtx ?? createOperationContext('publishFromSWM'), + `Confirmed publish for <${assertionUri}> but RFC-64 catalog advancement failed: ` + + (err instanceof Error ? err.message : String(err)), + ); + } + } + return { ...result, assertionUri, seal }; } diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts b/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts new file mode 100644 index 0000000000..b2cc5c536f --- /dev/null +++ b/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Opt-in RFC-64 producer bridge from an ordinary confirmed public KA publish + * to this provider's durable exact author-catalog head. + */ + +import { + GRAPH_KA_CONTENT_SCOPE_VERSION, + assertCanonicalGraphScopedAuthorSealV1, + assertSignedAuthorCatalogHeadEnvelopeV1, + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, + canonicalizeCanonicalGraphScopedAuthorSealV1, + computeAuthorCatalogScopeDigestV1, + computeControlSignatureVariantDigestHex, + decodeOpaqueKaBundleV1, + parseCanonicalGraphScopedAuthorSealV1, + type AssertionCoordinateV1, + type AssertionSeal, + type AuthorCatalogScopeV1, + type CanonicalGraphScopedAuthorSealV1, + type CatalogSealDeploymentProfileV1, + type ContextGraphIdV1, + type CountV1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type SubGraphNameV1, + type TimestampMsV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; +import { serializeWorkspacePublicSnapshotQuads } from '@origintrail-official/dkg-publisher'; +import type { Quad } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; + +import { DKGAgentBase } from './dkg-agent-base.js'; +import type { DKGAgent } from './dkg-agent.js'; +import { + loadBoundedAuthorCatalogHistoryV1, + type BoundedAuthorCatalogHistoryV1, + type Rfc64CatalogAuthorSignerV1, + type Rfc64CatalogSuccessorAssetInputV1, + type Rfc64StagedAuthorCatalogHeadRefV1, +} from './dkg-agent-rfc64-catalog.js'; +import { snapshotRfc64CatalogDeploymentProfileV1 } from './rfc64/catalog-authority-config-v1.js'; +import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; +import { computeRfc64AppliedInventoryDigestV1 } from './rfc64/public-catalog-inventory-completeness-v1.js'; +import type { Rfc64PersistenceV1 } from './rfc64/persistence-v1.js'; + +/** Internal normal-publication handoff after VM confirmation is durable locally. */ +export interface RecordConfirmedRfc64PublicCatalogAssetParamsV1 { + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName?: SubGraphNameV1 | null; + readonly assertionCoordinate: AssertionCoordinateV1; + readonly publicQuads: readonly Quad[]; + readonly seal: AssertionSeal; +} + +export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { + /** + * Advance this provider's exact public-root author catalog after an ordinary + * graph-scoped KA publish has confirmed. The hook is dormant unless the + * preview config is present. Catalog objects and bundles are staged first, + * the provider's applied-head pointer advances second, and peer availability + * hints are sent last. + */ + async recordConfirmedRfc64PublicCatalogAssetV1( + this: DKGAgent, + params: RecordConfirmedRfc64PublicCatalogAssetParamsV1, + ): Promise { + const autoPublish = this.config.rfc64PublicCatalogAutoPublish; + if (autoPublish === undefined) return null; + if (params.subGraphName !== undefined && params.subGraphName !== null) return null; + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) { + throw new Error('RFC-64 auto-publish requires durable persistence'); + } + + const seal = canonicalRfc64SealFromAssertionSeal(params.seal); + if (params.publicQuads.length !== Number(seal.publicTripleCount)) { + throw new Error( + 'RFC-64 auto-publish public projection count differs from the confirmed author seal', + ); + } + const projectionBytes = canonicalPublicProjectionBytes(params.publicQuads); + const networkId = (this.config.rfc64CatalogDeploymentProfile?.networkId + ?? this.chain.chainId) as NetworkIdV1; + if (networkId === 'none') { + throw new Error('RFC-64 auto-publish requires a trusted deployment network'); + } + const service = this.rfc64PublicCatalogServiceV1; + if (service === undefined) { + throw new Error( + 'RFC-64 public catalog service is not available (agent not started or no dataDir)', + ); + } + const acceptedPolicy = service.acceptedPolicySnapshot(networkId, params.contextGraphId); + if (acceptedPolicy === null || acceptedPolicy.policy.accessPolicy !== 0) { + throw new Error( + 'RFC-64 auto-publish requires an independently accepted current public policy', + ); + } + const scope: AuthorCatalogScopeV1 = Object.freeze({ + networkId, + contextGraphId: params.contextGraphId, + governanceChainId: acceptedPolicy.policy.governanceChainId, + governanceContractAddress: acceptedPolicy.policy.governanceContractAddress, + ownershipTransitionDigest: acceptedPolicy.policy.ownershipTransitionDigest, + subGraphName: null, + authorAddress: seal.authorAddress, + era: acceptedPolicy.policy.era, + bucketCount: '1' as CountV1, + }); + service.acceptedPolicySnapshotForCatalogScope(scope); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const queueKey = `${catalogScopeDigest}\n${seal.authorAddress}`; + + return this.runSerializedRfc64AuthorCatalogMutationV1(queueKey, async () => { + const signer = this.createRfc64CatalogAuthorSignerV1(seal.authorAddress); + let current = persistence.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + seal.authorAddress, + ); + if (current === null) { + const genesis = await this.publishAuthorCatalogGenesisV1({ + scope, + author: signer, + peers: [], + issuedAt: Date.now().toString() as TimestampMsV1, + catalogIssuerDelegationEffectiveAt: + autoPublish.catalogIssuerDelegationEffectiveAt ?? ('0' as TimestampMsV1), + catalogIssuerDelegationExpiresAt: + autoPublish.catalogIssuerDelegationExpiresAt, + }); + const emptyInventoryDigest = computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest, + rows: [], + }); + current = persistence.inventory.compareAndSwapAppliedCatalogHeadV1({ + catalogScopeDigest, + authorAddress: seal.authorAddress, + currentCatalogHeadDigest: genesis.headObjectDigest, + appliedInventoryDigest: emptyInventoryDigest, + catalogVersion: genesis.announcement.catalogVersion, + inventoryRowCount: '0' as CountV1, + expectedCurrentCatalogHeadDigest: null, + }).snapshot; + } + + const storedHead = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: current.currentCatalogHeadDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedHead === null) { + throw new Error('RFC-64 applied author head is not durably staged'); + } + assertSignedAuthorCatalogHeadEnvelopeV1(storedHead.envelope); + const previousHead: Rfc64StagedAuthorCatalogHeadRefV1 = Object.freeze({ + objectDigest: storedHead.envelope.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + storedHead.envelope.objectDigest, + storedHead.envelope.signature, + ) as Digest32V1, + }); + const history = await loadBoundedAuthorCatalogHistoryV1(persistence, previousHead); + const assets = await loadRfc64CatalogSuccessorAssetsV1(persistence, history); + const nextAsset: Rfc64CatalogSuccessorAssetInputV1 = Object.freeze({ + assertionCoordinate: params.assertionCoordinate, + projectionBytes, + seal, + }); + const existingIndex = assets.findIndex( + (asset) => asset.seal.reservedKaId === seal.reservedKaId, + ); + if (existingIndex >= 0 && sameRfc64SuccessorAssetV1(assets[existingIndex]!, nextAsset)) { + return current; + } + if (existingIndex >= 0) assets[existingIndex] = nextAsset; + else assets.push(nextAsset); + + const storedDelegation = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: history.previousHead.payload.catalogIssuerDelegationDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedDelegation === null) { + throw new Error('RFC-64 applied author head delegation is not durably staged'); + } + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(storedDelegation.envelope); + const deployment = await this.resolveRfc64AutoPublishDeploymentProfileV1(networkId); + const successor = await this.publishAuthorCatalogExactSetSuccessorV1({ + previousHead, + author: signer, + catalogIssuerAuthorization: Object.freeze({ + catalogIssuerDelegation: storedDelegation.envelope, + parentAuthorAgentEvidence: null, + }), + assets, + deployment, + issuedAt: Date.now().toString() as TimestampMsV1, + peers: [], + }); + const appliedInventoryDigest = computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: successor.catalogScopeDigest, + rows: successor.assets, + }); + const applied = persistence.inventory.compareAndSwapAppliedCatalogHeadV1({ + catalogScopeDigest: successor.catalogScopeDigest, + authorAddress: seal.authorAddress, + currentCatalogHeadDigest: successor.headObjectDigest, + appliedInventoryDigest, + catalogVersion: successor.announcement.catalogVersion, + inventoryRowCount: successor.signedBucketRowCount, + expectedCurrentCatalogHeadDigest: current.currentCatalogHeadDigest, + }).snapshot; + await this.announceRfc64PublicCatalogHeadV1({ + announcement: successor.announcement, + peers: autoPublish.peers, + }); + return applied; + }); + } + + private createRfc64CatalogAuthorSignerV1( + this: DKGAgent, + authorAddress: EvmAddressV1, + ): Rfc64CatalogAuthorSignerV1 { + const custodialKey = this.getCustodialAgentPrivateKey(authorAddress); + if (custodialKey !== undefined) { + const wallet = new ethers.Wallet( + custodialKey.startsWith('0x') ? custodialKey : `0x${custodialKey}`, + ); + if (wallet.address.toLowerCase() !== authorAddress) { + throw new Error('RFC-64 custodial author key does not match the confirmed seal'); + } + return Object.freeze({ + address: authorAddress, + signMessage: (message: Uint8Array) => wallet.signMessage(message), + }); + } + const signMessageAs = this.chain.signMessageAs?.bind(this.chain); + const signMessage = this.chain.signMessage?.bind(this.chain); + return Object.freeze({ + address: authorAddress, + signMessage: async (message: Uint8Array) => { + const compact = signMessageAs !== undefined + ? await signMessageAs(authorAddress, message) + : signMessage !== undefined + ? await signMessage(message) + : (() => { throw new Error('RFC-64 configured chain has no message signer'); })(); + const signature = ethers.Signature.from({ + r: ethers.hexlify(compact.r), + yParityAndS: ethers.hexlify(compact.vs), + }).serialized; + if (ethers.verifyMessage(message, signature).toLowerCase() !== authorAddress) { + throw new Error('RFC-64 configured publisher cannot sign for the confirmed KA author'); + } + return signature; + }, + }); + } + + private async resolveRfc64AutoPublishDeploymentProfileV1( + this: DKGAgent, + networkId: NetworkIdV1, + ): Promise { + let deployment = this.config.rfc64CatalogDeploymentProfile; + const trustedNetworkId = deployment?.networkId ?? this.chain.chainId; + if (trustedNetworkId === 'none' || trustedNetworkId !== networkId) { + throw new Error('RFC-64 auto-publish network differs from the trusted deployment'); + } + if (deployment === undefined) { + const [chainId, kav10Address] = await Promise.all([ + this.chain.getEvmChainId(), + this.chain.getKnowledgeAssetsLifecycleAddress(), + ]); + deployment = snapshotRfc64CatalogDeploymentProfileV1({ + networkId, + assertedAtChainId: chainId.toString() as never, + assertedAtKav10Address: kav10Address as EvmAddressV1, + }); + if (deployment === undefined) { + throw new Error('RFC-64 chain deployment profile resolution failed'); + } + } + return deployment; + } + + private async runSerializedRfc64AuthorCatalogMutationV1( + this: DKGAgent, + key: string, + operation: () => Promise, + ): Promise { + const predecessor = this.rfc64AuthorCatalogMutationQueuesV1.get(key); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const tail = (predecessor ?? Promise.resolve()).catch(() => undefined).then(() => gate); + this.rfc64AuthorCatalogMutationQueuesV1.set(key, tail); + await predecessor?.catch(() => undefined); + try { + return await operation(); + } finally { + release(); + if (this.rfc64AuthorCatalogMutationQueuesV1.get(key) === tail) { + this.rfc64AuthorCatalogMutationQueuesV1.delete(key); + } + } + } +} + +function canonicalRfc64SealFromAssertionSeal( + seal: AssertionSeal, +): Readonly { + if ( + seal.contentScopeVersion !== GRAPH_KA_CONTENT_SCOPE_VERSION + || seal.kaUal === undefined + || seal.assertionVersion === undefined + || seal.publicTripleCount === undefined + || seal.privateTripleCount === undefined + || seal.reservedKaId === undefined + || seal.authorSchemeVersion !== 1 + ) { + throw new Error('RFC-64 auto-publish requires a complete graph-scoped v2 author seal'); + } + const canonical = { + assertionMerkleRoot: ethers.hexlify(seal.merkleRoot).toLowerCase(), + authorAddress: seal.authorAddress.toLowerCase(), + authorAttestationR: ethers.hexlify(seal.authorAttestationR).toLowerCase(), + authorAttestationVS: ethers.hexlify(seal.authorAttestationVS).toLowerCase(), + authorSchemeVersion: '1', + assertedAtChainId: seal.chainId.toString(), + assertedAtKav10Address: seal.kav10Address.toLowerCase(), + reservedKaId: seal.reservedKaId.toString(), + assertionFinalizedAt: seal.finalizedAtIso, + contentScopeVersion: '2', + kaUal: seal.kaUal, + assertionVersion: seal.assertionVersion, + publicTripleCount: seal.publicTripleCount.toString(), + privateTripleCount: seal.privateTripleCount.toString(), + privateMerkleRoot: seal.privateMerkleRoot === undefined + ? null + : ethers.hexlify(seal.privateMerkleRoot).toLowerCase(), + } as unknown as CanonicalGraphScopedAuthorSealV1; + assertCanonicalGraphScopedAuthorSealV1(canonical); + return Object.freeze(canonical); +} + +function canonicalPublicProjectionBytes(quads: readonly Quad[]): Uint8Array { + const sorted = quads + .map((quad) => ({ ...quad, graph: '' })) + .sort((left, right) => ( + left.subject.localeCompare(right.subject) + || left.predicate.localeCompare(right.predicate) + || left.object.localeCompare(right.object) + )); + return new TextEncoder().encode(serializeWorkspacePublicSnapshotQuads(sorted)); +} + +async function loadRfc64CatalogSuccessorAssetsV1( + persistence: Rfc64PersistenceV1, + history: BoundedAuthorCatalogHistoryV1, +): Promise { + const assets: Rfc64CatalogSuccessorAssetInputV1[] = []; + for (const row of history.previousBucket?.payload.rows ?? []) { + const bundleBytes = await persistence.kaBundles.readKaBundleByDigest(row.transfer.blobDigest); + if (bundleBytes === null) { + throw new Error(`RFC-64 applied catalog bundle ${row.transfer.blobDigest} is unavailable`); + } + const decoded = decodeOpaqueKaBundleV1(bundleBytes); + if ( + decoded.blobDigest !== row.transfer.blobDigest + || decoded.projectionDigest !== row.projectionDigest + ) { + throw new Error('RFC-64 applied catalog bundle differs from its signed predecessor row'); + } + assets.push(Object.freeze({ + assertionCoordinate: row.assertionCoordinate, + projectionBytes: new Uint8Array(decoded.projectionBytes), + seal: parseCanonicalGraphScopedAuthorSealV1(decoded.sealBytes), + })); + } + return assets; +} + +function sameRfc64SuccessorAssetV1( + left: Rfc64CatalogSuccessorAssetInputV1, + right: Rfc64CatalogSuccessorAssetInputV1, +): boolean { + return left.assertionCoordinate === right.assertionCoordinate + && canonicalizeCanonicalGraphScopedAuthorSealV1(left.seal) + === canonicalizeCanonicalGraphScopedAuthorSealV1(right.seal) + && equalBytes(left.projectionBytes, right.projectionBytes); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength + && left.every((byte, index) => byte === right[index]); +} diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 3bc98d65be..15d3968842 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -192,6 +192,7 @@ export { export { snapshotRfc64CatalogAccessPolicyAuthorityV1, snapshotRfc64CatalogDeploymentProfileV1, + snapshotRfc64PublicCatalogAutoPublishConfigV1, } from './rfc64/catalog-authority-config-v1.js'; export interface PublishOpenAuthorCatalogSuccessorParamsV1 { @@ -976,13 +977,13 @@ function countToSafeInteger(value: CountV1, label: string): number { return parsed; } -interface BoundedAuthorCatalogHistoryV1 { +export interface BoundedAuthorCatalogHistoryV1 { readonly previousHead: SignedAuthorCatalogHeadEnvelopeV1; readonly previousDirectoryPath: readonly SignedAuthorCatalogDirectoryNodeEnvelopeV1[]; readonly previousBucket: SignedAuthorCatalogBucketEnvelopeV1 | null; } -async function loadBoundedAuthorCatalogHistoryV1( +export async function loadBoundedAuthorCatalogHistoryV1( persistence: Rfc64PersistenceV1, ref: Rfc64StagedAuthorCatalogHeadRefV1, ): Promise { diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index f6facdf5b8..2d685ae67f 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -32,6 +32,7 @@ import type { ContextGraphJoinPolicyRecord as CoreContextGraphJoinPolicyRecord, CatalogSealDeploymentProfileV1, EvmAddressV1, + TimestampMsV1, } from '@origintrail-official/dkg-core'; import type { PhaseCallback, @@ -1059,6 +1060,17 @@ export interface Rfc64CatalogAccessPolicyAuthorityConfigV1 { ) => Promise; } +/** + * Opt-in RFC-64 author-catalog production for ordinary confirmed public KA + * publishes. Peer fan-out is an availability hint; the durable applied-head + * pointer remains the correctness source for pull discovery. + */ +export interface Rfc64PublicCatalogAutoPublishConfigV1 { + readonly peers: readonly string[]; + readonly catalogIssuerDelegationEffectiveAt?: TimestampMsV1; + readonly catalogIssuerDelegationExpiresAt: TimestampMsV1; +} + export interface DKGAgentConfig { name: string; /** Selected genesis document. Defaults to the compatibility Base testnet genesis. */ @@ -1078,6 +1090,8 @@ export interface DKGAgentConfig { * RFC-64 catalog policy. Omission preserves the legacy open-only lane. */ rfc64CatalogAccessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1; + /** Omission preserves the existing publication and synchronization behavior. */ + rfc64PublicCatalogAutoPublish?: Rfc64PublicCatalogAutoPublishConfigV1; /** * public-projection enable flag. When set, a private CG's confirmed VM * publishes emit/refresh a verifiable public projection (the floor: existence, diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index d906f890e9..c14b968f4e 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -406,6 +406,8 @@ import { snapshotRfc64CatalogAccessPolicyAuthorityV1, snapshotRfc64CatalogDeploymentProfileV1, } from './dkg-agent-rfc64-catalog.js'; +import { Rfc64CatalogAutoPublishMethods } from './dkg-agent-rfc64-catalog-auto-publish.js'; +import { snapshotRfc64PublicCatalogAutoPublishConfigV1 } from './rfc64/catalog-authority-config-v1.js'; import { ContextGraphRegistryMethods } from './dkg-agent-cg-registry.js'; import { JoinRequestMethods } from './dkg-agent-join.js'; import { SwmSubstrateMethods } from './dkg-agent-swm-substrate.js'; @@ -704,6 +706,9 @@ export class DKGAgent extends DKGAgentBase { const rfc64CatalogAccessPolicyAuthority = snapshotRfc64CatalogAccessPolicyAuthorityV1( config.rfc64CatalogAccessPolicyAuthority, ); + const rfc64PublicCatalogAutoPublish = snapshotRfc64PublicCatalogAutoPublishConfigV1( + config.rfc64PublicCatalogAutoPublish, + ); let wallet: DKGAgentWallet; if (config.dataDir) { try { @@ -810,6 +815,7 @@ export class DKGAgent extends DKGAgentBase { networkIdentity, rfc64CatalogAccessPolicyAuthority, rfc64CatalogDeploymentProfile, + rfc64PublicCatalogAutoPublish, }; const port = config.listenPort ?? 0; @@ -3057,5 +3063,5 @@ export class DKGAgent extends DKGAgentBase { } -export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods {} -applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods]); +export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogAutoPublishMethods {} +applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogAutoPublishMethods]); diff --git a/packages/agent/src/rfc64/catalog-authority-config-v1.ts b/packages/agent/src/rfc64/catalog-authority-config-v1.ts index 268e16668b..1085f5cb0d 100644 --- a/packages/agent/src/rfc64/catalog-authority-config-v1.ts +++ b/packages/agent/src/rfc64/catalog-authority-config-v1.ts @@ -5,10 +5,18 @@ import { assertNetworkIdV1, type CatalogSealDeploymentProfileV1, type EvmAddressV1, + type TimestampMsV1, } from '@origintrail-official/dkg-core'; import { ethers } from 'ethers'; -import type { Rfc64CatalogAccessPolicyAuthorityConfigV1 } from '../dkg-agent-types.js'; +import type { + Rfc64CatalogAccessPolicyAuthorityConfigV1, + Rfc64PublicCatalogAutoPublishConfigV1, +} from '../dkg-agent-types.js'; + +const MAX_RFC64_AUTO_PUBLISH_PEERS_V1 = 64; +const MAX_RFC64_PEER_ID_BYTES_V1 = 256; +const UTF8 = new TextEncoder(); /** Detach a locally configured deployment tuple from caller-owned state. */ export function snapshotRfc64CatalogDeploymentProfileV1( @@ -90,3 +98,80 @@ export function snapshotRfc64CatalogAccessPolicyAuthorityV1( resolveRemoteAgentAddress: input.resolveRemoteAgentAddress, }); } + +/** Detach and validate the preview authoring configuration at create time. */ +export function snapshotRfc64PublicCatalogAutoPublishConfigV1( + input: Rfc64PublicCatalogAutoPublishConfigV1 | undefined, +): Readonly | undefined { + if (input === undefined) return undefined; + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError('rfc64PublicCatalogAutoPublish must be a plain object'); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('rfc64PublicCatalogAutoPublish must be a plain object'); + } + const keys = Object.keys(input).sort(); + const allowed = new Set([ + 'catalogIssuerDelegationEffectiveAt', + 'catalogIssuerDelegationExpiresAt', + 'peers', + ]); + if ( + keys.some((key) => !allowed.has(key)) + || !keys.includes('peers') + || !keys.includes('catalogIssuerDelegationExpiresAt') + ) { + throw new TypeError('rfc64PublicCatalogAutoPublish has unknown or missing fields'); + } + if (!Array.isArray(input.peers) || input.peers.length > MAX_RFC64_AUTO_PUBLISH_PEERS_V1) { + throw new TypeError( + `rfc64PublicCatalogAutoPublish.peers must contain at most ${MAX_RFC64_AUTO_PUBLISH_PEERS_V1} peer IDs`, + ); + } + const peers: string[] = []; + const seen = new Set(); + for (const peerId of input.peers) { + if ( + typeof peerId !== 'string' + || peerId.length === 0 + || peerId.trim() !== peerId + || UTF8.encode(peerId).byteLength > MAX_RFC64_PEER_ID_BYTES_V1 + ) { + throw new TypeError('rfc64PublicCatalogAutoPublish.peers contains an invalid peer ID'); + } + if (seen.has(peerId)) { + throw new TypeError('rfc64PublicCatalogAutoPublish.peers must be unique'); + } + seen.add(peerId); + peers.push(peerId); + } + const effectiveAt = snapshotTimestamp( + input.catalogIssuerDelegationEffectiveAt ?? ('0' as TimestampMsV1), + 'catalogIssuerDelegationEffectiveAt', + ); + const expiresAt = snapshotTimestamp( + input.catalogIssuerDelegationExpiresAt, + 'catalogIssuerDelegationExpiresAt', + ); + if (BigInt(expiresAt) <= BigInt(effectiveAt)) { + throw new TypeError( + 'rfc64PublicCatalogAutoPublish delegation expiry must be after its effective time', + ); + } + return Object.freeze({ + peers: Object.freeze(peers), + catalogIssuerDelegationEffectiveAt: effectiveAt, + catalogIssuerDelegationExpiresAt: expiresAt, + }); +} + +function snapshotTimestamp(value: unknown, label: string): TimestampMsV1 { + if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/u.test(value)) { + throw new TypeError(`rfc64PublicCatalogAutoPublish.${label} must be a canonical timestamp`); + } + if (BigInt(value) > 18_446_744_073_709_551_615n) { + throw new TypeError(`rfc64PublicCatalogAutoPublish.${label} exceeds uint64`); + } + return value as TimestampMsV1; +} diff --git a/packages/agent/test/encrypt-inline-policy.test.ts b/packages/agent/test/encrypt-inline-policy.test.ts index 6f7db3afa6..b101cbb428 100644 --- a/packages/agent/test/encrypt-inline-policy.test.ts +++ b/packages/agent/test/encrypt-inline-policy.test.ts @@ -764,13 +764,14 @@ const QUEUED_TEST_LIFECYCLE = '0x2222222222222222222222222222222222222222'; function makeQueuedAgentHarness(options: { peerId: string; ual: string; + publishStatus?: 'tentative' | 'confirmed'; chain?: Record; onChainContextGraphId?: string | null; encryptInlinePayload?: unknown; encryptInlineChunked?: unknown; }) { const publisherPublish = recorder(async (_opts: any) => ({ - status: 'tentative' as const, + status: options.publishStatus ?? 'tentative', ual: options.ual, })); const agentLike: any = { @@ -853,6 +854,46 @@ async function makeQueuedPublishRequest(options: { } describe('DKGAgent.publishQueuedKnowledgeAssetVmPublish inline encryption routing', () => { + it('hands one confirmed queued public publish to the RFC-64 catalog bridge', async () => { + const { agentLike } = makeQueuedAgentHarness({ + peerId: 'did:dkg:agent:queued-catalog', + ual: 'did:dkg:local/queued-catalog', + publishStatus: 'confirmed', + }); + const catalogAdvance = recorder(async () => null); + agentLike.recordConfirmedRfc64PublicCatalogAssetV1 = catalogAdvance; + const snapshotQuads = [{ + subject: 'urn:test:queued-public', + predicate: 'http://schema.org/name', + object: '"Queued Public"', + graph: '', + }]; + const request = await makeQueuedPublishRequest({ + contextGraphId: 'public-cg', + name: 'queued-public-ka', + shareOperationId: 'share-op-catalog', + intentByte: 'ad', + quads: snapshotQuads, + }); + + await (DKGAgent.prototype as any).publishQueuedKnowledgeAssetVmPublish.call( + agentLike, + request, + { contextGraphId: request.contextGraphId, quads: snapshotQuads }, + ); + + expect(catalogAdvance.calls).toHaveLength(1); + expect(catalogAdvance.calls[0]?.[0]).toMatchObject({ + contextGraphId: 'public-cg', + assertionCoordinate: 'queued-public-ka', + publicQuads: snapshotQuads, + seal: { + authorAddress: QUEUED_TEST_AUTHOR, + contentScopeVersion: GRAPH_KA_CONTENT_SCOPE_VERSION, + }, + }); + }); + it('keeps the V2 snapshot exact while passing a detached catalog capability', async () => { const realInline = recorder(async (plaintext: Uint8Array) => new Uint8Array([...plaintext, 0xaa])); const realChunked = recorder(async () => ({ diff --git a/packages/agent/test/publish-finalized-agent-lane.test.ts b/packages/agent/test/publish-finalized-agent-lane.test.ts index e26d80e9ea..a497602485 100644 --- a/packages/agent/test/publish-finalized-agent-lane.test.ts +++ b/packages/agent/test/publish-finalized-agent-lane.test.ts @@ -140,6 +140,7 @@ describe('DKGAgent publishFromFinalizedAssertion agent lane', () => { }> = []; const publishCalls: Array<{ contextGraphId: string; selection: any; opts: any }> = []; const remainingClearCalls: any[][] = []; + const rfc64CatalogCalls: any[] = []; const agent = Object.create(DKGAgent.prototype) as any; agent.store = store; @@ -174,6 +175,10 @@ describe('DKGAgent publishFromFinalizedAssertion agent lane', () => { publicQuads: [], }; }; + agent.recordConfirmedRfc64PublicCatalogAssetV1 = async (input: any) => { + rfc64CatalogCalls.push(input); + return null; + }; // GH#1778 — a genuinely absent name still yields "is not finalized": // resolveAssertionAuthor finds no seal, returns undefined, and the caller @@ -221,6 +226,17 @@ describe('DKGAgent publishFromFinalizedAssertion agent lane', () => { expect(remainingClearCalls).toHaveLength(1); expect(remainingClearCalls[0].slice(0, 2)).toEqual([CG, undefined]); expect(result.status).toBe('confirmed'); + expect(rfc64CatalogCalls).toHaveLength(1); + expect(rfc64CatalogCalls[0]).toMatchObject({ + contextGraphId: CG, + assertionCoordinate: NAME, + publicQuads: [PUBLIC_QUAD], + }); + expect(rfc64CatalogCalls[0].seal).toMatchObject({ + authorAddress: AGENT_B, + reservedKaId: RESERVED_KA_ID, + kaUal: KA_UAL, + }); }); it('maps an empty exact KA graph to the mint no-data precondition', async () => { diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 5858ee9ac8..2af3430d28 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -12,6 +12,7 @@ import { contextGraphLayerUri, type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, + type AssertionSeal, type ContextGraphIdV1, type ContextGraphPolicyV1, type Digest32V1, @@ -28,11 +29,13 @@ import { DKGAgent } from '../src/dkg-agent.js'; import { snapshotRfc64CatalogAccessPolicyAuthorityV1, snapshotRfc64CatalogDeploymentProfileV1, + snapshotRfc64PublicCatalogAutoPublishConfigV1, } from '../src/dkg-agent-rfc64-catalog.js'; import type { ContextGraphSubscriptionRecord, ContextGraphSubscriptionStore, Rfc64CatalogAccessPolicyAuthorityConfigV1, + Rfc64PublicCatalogAutoPublishConfigV1, } from '../src/dkg-agent-types.js'; import { createLoopbackJsonRpcTestHarness, @@ -97,6 +100,7 @@ async function startNativeAgent( chainAdapter: FinalizedVmLoopbackMockChainAdapterV1; initialSubscription?: ContextGraphIdV1; }>, + autoPublish?: Rfc64PublicCatalogAutoPublishConfigV1, ): Promise { const dataDir = existingDataDir ?? await mkdtemp(join(tmpdir(), `dkg-rfc64-native-${name}-`)); @@ -116,6 +120,7 @@ async function startNativeAgent( agentProfileHeartbeatMs: 0, rfc64CatalogDeploymentProfile: deployment, rfc64CatalogAccessPolicyAuthority: accessPolicyAuthority, + rfc64PublicCatalogAutoPublish: autoPublish, ...(finalizedRuntime === undefined ? {} : { chainAdapter: finalizedRuntime.chainAdapter, chainConfig: { @@ -269,6 +274,147 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { })).toThrow(/non-zero EVM address/); }); + it('snapshots the opt-in normal-publication catalog producer configuration', () => { + const callerOwned = { + peers: ['12D3KooReceiver'], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }; + const snapshot = snapshotRfc64PublicCatalogAutoPublishConfigV1(callerOwned)!; + callerOwned.peers.push('hostile-late-mutation'); + expect(snapshot).toEqual({ + peers: ['12D3KooReceiver'], + catalogIssuerDelegationEffectiveAt: '0', + catalogIssuerDelegationExpiresAt: '1893456000000', + }); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.peers)).toBe(true); + expect(() => snapshotRfc64PublicCatalogAutoPublishConfigV1({ + peers: ['duplicate', 'duplicate'], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + })).toThrow(/must be unique/u); + }); + + it('turns one confirmed public KA into the provider current head and one cold receiver apply', async () => { + const receiver = await startNativeAgent('auto-publish-receiver'); + const author = await startNativeAgent( + 'auto-publish-author', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [receiver.peerId], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(AUTHOR_WALLET.privateKey); + for (const agent of [author, receiver]) { + agent.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + } + await connectBothWays(author, receiver); + + const seal = assertionSealFromCanonical(await authorSeal(11n)); + const first = await author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'ordinary-confirmed-publication' as never, + publicQuads: [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: 'urn:ignored-local-graph', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: 'urn:ignored-local-graph', + }, + ], + seal, + }); + expect(first).toMatchObject({ catalogVersion: '1', inventoryRowCount: '1' }); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + + const scopeDigest = catalogScopeDigest(); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR, + })).toEqual(first); + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ + currentCatalogHeadDigest: first?.currentCatalogHeadDigest, + catalogVersion: '1', + inventoryRowCount: '1', + }); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + applied: 1, + failed: 0, + }); + + const replay = await author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'ordinary-confirmed-publication' as never, + publicQuads: [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + ], + seal, + }); + expect(replay).toEqual(first); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver.applied).toBe(1); + + const second = await author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'second-ordinary-confirmed-publication' as never, + publicQuads: [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + ], + seal: assertionSealFromCanonical(await authorSeal(12n)), + }); + expect(second).toMatchObject({ catalogVersion: '2', inventoryRowCount: '2' }); + await receiver.whenRfc64PublicCatalogReceiverIdleV1(); + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ + currentCatalogHeadDigest: second?.currentCatalogHeadDigest, + catalogVersion: '2', + inventoryRowCount: '2', + }); + expect(receiver.rfc64PublicCatalogStatsV1()?.receiver).toMatchObject({ + applied: 2, + failed: 0, + }); + }, 60_000); + it('snapshots the explicit access authority and fails closed before private activation', async () => { const resolver = async () => AUTHOR; const callerOwned = { @@ -976,3 +1122,26 @@ async function authorSeal(kaNumber: bigint): Promise Date: Thu, 23 Jul 2026 01:16:39 +0200 Subject: [PATCH 282/292] fix(agent): harden RFC-64 public catalog bridge --- packages/agent/package.json | 1 + packages/agent/scripts/test-package-root.mjs | 1 + packages/agent/src/dkg-agent-publish.ts | 104 +++++--- .../dkg-agent-rfc64-catalog-auto-publish.ts | 229 +++-------------- .../src/dkg-agent-rfc64-catalog-upsert.ts | 234 ++++++++++++++++++ packages/agent/src/dkg-agent.ts | 5 +- .../src/rfc64/catalog-authority-config-v1.ts | 29 +-- packages/agent/src/rfc64/catalog-peers-v1.ts | 41 +++ .../src/rfc64/public-catalog-service-v1.ts | 44 +--- .../agent/test/encrypt-inline-policy.test.ts | 38 +++ .../test/publish-finalized-agent-lane.test.ts | 11 +- ...kg-agent-native-wiring.integration.test.ts | 108 +++++++- 12 files changed, 543 insertions(+), 302 deletions(-) create mode 100644 packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts create mode 100644 packages/agent/src/rfc64/catalog-peers-v1.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index f4ae5bf4b1..aeca5e75e9 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -24,6 +24,7 @@ "./dist/rfc64/control-object-store-v1.js": null, "./dist/rfc64/catalog-access-policy-v1.js": null, "./dist/rfc64/catalog-authority-config-v1.js": null, + "./dist/rfc64/catalog-peers-v1.js": null, "./dist/rfc64/catalog-transport-authorization-v1.js": null, "./dist/rfc64/catalog-transport-wire-v1-internal.js": null, "./dist/rfc64/durable-file-store-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index cadd671ee1..c9f6b5a9c5 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -99,6 +99,7 @@ const publicRfc64Modules = [ const blockedRfc64Modules = [ 'catalog-access-policy-v1.js', 'catalog-authority-config-v1.js', + 'catalog-peers-v1.js', 'catalog-transport-authorization-v1.js', 'catalog-transport-wire-v1-internal.js', 'control-object-store-v1-internal.js', diff --git a/packages/agent/src/dkg-agent-publish.ts b/packages/agent/src/dkg-agent-publish.ts index 3e7825a7ca..3e33446077 100644 --- a/packages/agent/src/dkg-agent-publish.ts +++ b/packages/agent/src/dkg-agent-publish.ts @@ -98,7 +98,9 @@ import { ciphertextChunkStoreSubject, CIPHERTEXT_CHUNK_PREDICATE, type SubscriptionSource, - type AssertionCoordinateV1, + assertAssertionCoordinateV1, + assertContextGraphIdV1, + assertSubGraphNameV1, SUBSCRIPTION_SOURCES, pickNetworkTunables, withSpan, @@ -5407,23 +5409,17 @@ export class PublishMethods extends DKGAgentBase { } } - if (result.status === 'confirmed') { - try { - await this.recordConfirmedRfc64PublicCatalogAssetV1({ - contextGraphId: request.contextGraphId as never, - subGraphName: request.subGraphName as never, - assertionCoordinate: request.name as AssertionCoordinateV1, - publicQuads: snapshotQuads, - seal, - }); - } catch (err) { - this.log.warn( - ctx, - `Confirmed queued publish for <${assertionUri}> but RFC-64 catalog advancement failed: ` - + (err instanceof Error ? err.message : String(err)), - ); - } - } + await this.afterConfirmedGraphScopedVmPublishV1({ + status: result.status, + contextGraphId: request.contextGraphId, + subGraphName: request.subGraphName, + assertionCoordinate: request.name, + publicQuads: snapshotQuads, + seal, + assertionUri, + ctx, + publicationLabel: 'queued publish', + }); return { ...result, assertionUri, seal }; } @@ -5979,27 +5975,65 @@ export class PublishMethods extends DKGAgentBase { } } - if (result.status === 'confirmed') { - try { - await this.recordConfirmedRfc64PublicCatalogAssetV1({ - contextGraphId: contextGraphId as never, - subGraphName: opts?.subGraphName as never, - assertionCoordinate: name as AssertionCoordinateV1, - publicQuads: canonicalSwmQuads, - seal, - }); - } catch (err) { - this.log.warn( - opts?.operationCtx ?? createOperationContext('publishFromSWM'), - `Confirmed publish for <${assertionUri}> but RFC-64 catalog advancement failed: ` - + (err instanceof Error ? err.message : String(err)), - ); - } - } + await this.afterConfirmedGraphScopedVmPublishV1({ + status: result.status, + contextGraphId, + subGraphName: opts?.subGraphName, + assertionCoordinate: name, + publicQuads: canonicalSwmQuads, + seal, + assertionUri, + ctx: opts?.operationCtx ?? createOperationContext('publishFromSWM'), + publicationLabel: 'publish', + }); return { ...result, assertionUri, seal }; } + private async afterConfirmedGraphScopedVmPublishV1( + this: DKGAgent, + input: Readonly<{ + status: PublishResult['status']; + contextGraphId: string; + subGraphName?: string; + assertionCoordinate: string; + publicQuads: readonly Quad[]; + seal: AssertionSeal; + assertionUri: string; + ctx: OperationContext; + publicationLabel: 'publish' | 'queued publish'; + }>, + ): Promise { + if (input.status !== 'confirmed') return; + try { + const contextGraphId = input.contextGraphId; + const assertionCoordinate = input.assertionCoordinate; + const subGraphName = input.subGraphName ?? null; + assertContextGraphIdV1(contextGraphId, 'confirmed publish contextGraphId'); + assertAssertionCoordinateV1( + assertionCoordinate, + 'confirmed publish assertionCoordinate', + ); + if (subGraphName !== null) { + assertSubGraphNameV1(subGraphName, 'confirmed publish subGraphName'); + } + await this.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId, + subGraphName, + assertionCoordinate, + publicQuads: input.publicQuads, + seal: input.seal, + }); + } catch (err) { + this.log.warn( + input.ctx, + `Confirmed ${input.publicationLabel} for <${input.assertionUri}> but ` + + `RFC-64 catalog advancement failed: ` + + (err instanceof Error ? err.message : String(err)), + ); + } + } + /** * OT-RFC-43 A2 — mint a `precomputedUpdateAttestation` over * `UpdateAuthorAttestation(kaId, newMerkleRoot=seal.merkleRoot, author)` for diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts b/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts index b2cc5c536f..cf0508284a 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts @@ -8,13 +8,6 @@ import { GRAPH_KA_CONTENT_SCOPE_VERSION, assertCanonicalGraphScopedAuthorSealV1, - assertSignedAuthorCatalogHeadEnvelopeV1, - assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, - canonicalizeCanonicalGraphScopedAuthorSealV1, - computeAuthorCatalogScopeDigestV1, - computeControlSignatureVariantDigestHex, - decodeOpaqueKaBundleV1, - parseCanonicalGraphScopedAuthorSealV1, type AssertionCoordinateV1, type AssertionSeal, type AuthorCatalogScopeV1, @@ -22,30 +15,23 @@ import { type CatalogSealDeploymentProfileV1, type ContextGraphIdV1, type CountV1, - type Digest32V1, type EvmAddressV1, type NetworkIdV1, type SubGraphNameV1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; -import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; import { serializeWorkspacePublicSnapshotQuads } from '@origintrail-official/dkg-publisher'; import type { Quad } from '@origintrail-official/dkg-storage'; import { ethers } from 'ethers'; import { DKGAgentBase } from './dkg-agent-base.js'; import type { DKGAgent } from './dkg-agent.js'; -import { - loadBoundedAuthorCatalogHistoryV1, - type BoundedAuthorCatalogHistoryV1, - type Rfc64CatalogAuthorSignerV1, - type Rfc64CatalogSuccessorAssetInputV1, - type Rfc64StagedAuthorCatalogHeadRefV1, +import type { + Rfc64CatalogAuthorSignerV1, + Rfc64CatalogSuccessorAssetInputV1, } from './dkg-agent-rfc64-catalog.js'; import { snapshotRfc64CatalogDeploymentProfileV1 } from './rfc64/catalog-authority-config-v1.js'; import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; -import { computeRfc64AppliedInventoryDigestV1 } from './rfc64/public-catalog-inventory-completeness-v1.js'; -import type { Rfc64PersistenceV1 } from './rfc64/persistence-v1.js'; /** Internal normal-publication handoff after VM confirmation is durable locally. */ export interface RecordConfirmedRfc64PublicCatalogAssetParamsV1 { @@ -71,12 +57,13 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { const autoPublish = this.config.rfc64PublicCatalogAutoPublish; if (autoPublish === undefined) return null; if (params.subGraphName !== undefined && params.subGraphName !== null) return null; - const persistence = this.rfc64PersistenceV1; - if (persistence === undefined) { - throw new Error('RFC-64 auto-publish requires durable persistence'); - } - const seal = canonicalRfc64SealFromAssertionSeal(params.seal); + // V1 deliberately catalogs public-only KA projections. Private-bearing + // publishes require the reserved cg-shared-v1 anchor/hash statements to be + // present in the author-sealed public projection; ordinary publication does + // not synthesize those statements yet, so attempting an upsert would always + // fail projection verification after chain confirmation. + if (BigInt(seal.privateTripleCount) > 0n) return null; if (params.publicQuads.length !== Number(seal.publicTripleCount)) { throw new Error( 'RFC-64 auto-publish public projection count differs from the confirmed author seal', @@ -112,111 +99,21 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { bucketCount: '1' as CountV1, }); service.acceptedPolicySnapshotForCatalogScope(scope); - const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(scope); - const queueKey = `${catalogScopeDigest}\n${seal.authorAddress}`; - - return this.runSerializedRfc64AuthorCatalogMutationV1(queueKey, async () => { - const signer = this.createRfc64CatalogAuthorSignerV1(seal.authorAddress); - let current = persistence.inventory.readAppliedCatalogHeadV1( - catalogScopeDigest, - seal.authorAddress, - ); - if (current === null) { - const genesis = await this.publishAuthorCatalogGenesisV1({ - scope, - author: signer, - peers: [], - issuedAt: Date.now().toString() as TimestampMsV1, - catalogIssuerDelegationEffectiveAt: - autoPublish.catalogIssuerDelegationEffectiveAt ?? ('0' as TimestampMsV1), - catalogIssuerDelegationExpiresAt: - autoPublish.catalogIssuerDelegationExpiresAt, - }); - const emptyInventoryDigest = computeRfc64AppliedInventoryDigestV1({ - catalogScopeDigest, - rows: [], - }); - current = persistence.inventory.compareAndSwapAppliedCatalogHeadV1({ - catalogScopeDigest, - authorAddress: seal.authorAddress, - currentCatalogHeadDigest: genesis.headObjectDigest, - appliedInventoryDigest: emptyInventoryDigest, - catalogVersion: genesis.announcement.catalogVersion, - inventoryRowCount: '0' as CountV1, - expectedCurrentCatalogHeadDigest: null, - }).snapshot; - } - - const storedHead = await persistence.controlObjects.getVerifiedObjectByDigest({ - objectDigest: current.currentCatalogHeadDigest, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - }); - if (storedHead === null) { - throw new Error('RFC-64 applied author head is not durably staged'); - } - assertSignedAuthorCatalogHeadEnvelopeV1(storedHead.envelope); - const previousHead: Rfc64StagedAuthorCatalogHeadRefV1 = Object.freeze({ - objectDigest: storedHead.envelope.objectDigest as Digest32V1, - signatureVariantDigest: computeControlSignatureVariantDigestHex( - storedHead.envelope.objectDigest, - storedHead.envelope.signature, - ) as Digest32V1, - }); - const history = await loadBoundedAuthorCatalogHistoryV1(persistence, previousHead); - const assets = await loadRfc64CatalogSuccessorAssetsV1(persistence, history); - const nextAsset: Rfc64CatalogSuccessorAssetInputV1 = Object.freeze({ - assertionCoordinate: params.assertionCoordinate, - projectionBytes, - seal, - }); - const existingIndex = assets.findIndex( - (asset) => asset.seal.reservedKaId === seal.reservedKaId, - ); - if (existingIndex >= 0 && sameRfc64SuccessorAssetV1(assets[existingIndex]!, nextAsset)) { - return current; - } - if (existingIndex >= 0) assets[existingIndex] = nextAsset; - else assets.push(nextAsset); - - const storedDelegation = await persistence.controlObjects.getVerifiedObjectByDigest({ - objectDigest: history.previousHead.payload.catalogIssuerDelegationDigest, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - }); - if (storedDelegation === null) { - throw new Error('RFC-64 applied author head delegation is not durably staged'); - } - assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(storedDelegation.envelope); - const deployment = await this.resolveRfc64AutoPublishDeploymentProfileV1(networkId); - const successor = await this.publishAuthorCatalogExactSetSuccessorV1({ - previousHead, - author: signer, - catalogIssuerAuthorization: Object.freeze({ - catalogIssuerDelegation: storedDelegation.envelope, - parentAuthorAgentEvidence: null, - }), - assets, - deployment, - issuedAt: Date.now().toString() as TimestampMsV1, - peers: [], - }); - const appliedInventoryDigest = computeRfc64AppliedInventoryDigestV1({ - catalogScopeDigest: successor.catalogScopeDigest, - rows: successor.assets, - }); - const applied = persistence.inventory.compareAndSwapAppliedCatalogHeadV1({ - catalogScopeDigest: successor.catalogScopeDigest, - authorAddress: seal.authorAddress, - currentCatalogHeadDigest: successor.headObjectDigest, - appliedInventoryDigest, - catalogVersion: successor.announcement.catalogVersion, - inventoryRowCount: successor.signedBucketRowCount, - expectedCurrentCatalogHeadDigest: current.currentCatalogHeadDigest, - }).snapshot; - await this.announceRfc64PublicCatalogHeadV1({ - announcement: successor.announcement, - peers: autoPublish.peers, - }); - return applied; + const asset: Rfc64CatalogSuccessorAssetInputV1 = Object.freeze({ + assertionCoordinate: params.assertionCoordinate, + projectionBytes, + seal, + }); + return this.upsertConfirmedRfc64PublicRootCatalogAssetV1({ + scope, + author: this.createRfc64CatalogAuthorSignerV1(seal.authorAddress), + asset, + deployment: await this.resolveRfc64AutoPublishDeploymentProfileV1(networkId), + peers: autoPublish.peers, + catalogIssuerDelegationEffectiveAt: + autoPublish.catalogIssuerDelegationEffectiveAt ?? ('0' as TimestampMsV1), + catalogIssuerDelegationExpiresAt: + autoPublish.catalogIssuerDelegationExpiresAt, }); } @@ -285,26 +182,6 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { return deployment; } - private async runSerializedRfc64AuthorCatalogMutationV1( - this: DKGAgent, - key: string, - operation: () => Promise, - ): Promise { - const predecessor = this.rfc64AuthorCatalogMutationQueuesV1.get(key); - let release!: () => void; - const gate = new Promise((resolve) => { release = resolve; }); - const tail = (predecessor ?? Promise.resolve()).catch(() => undefined).then(() => gate); - this.rfc64AuthorCatalogMutationQueuesV1.set(key, tail); - await predecessor?.catch(() => undefined); - try { - return await operation(); - } finally { - release(); - if (this.rfc64AuthorCatalogMutationQueuesV1.get(key) === tail) { - this.rfc64AuthorCatalogMutationQueuesV1.delete(key); - } - } - } } function canonicalRfc64SealFromAssertionSeal( @@ -345,53 +222,19 @@ function canonicalRfc64SealFromAssertionSeal( } function canonicalPublicProjectionBytes(quads: readonly Quad[]): Uint8Array { - const sorted = quads - .map((quad) => ({ ...quad, graph: '' })) - .sort((left, right) => ( - left.subject.localeCompare(right.subject) - || left.predicate.localeCompare(right.predicate) - || left.object.localeCompare(right.object) - )); - return new TextEncoder().encode(serializeWorkspacePublicSnapshotQuads(sorted)); + const encoder = new TextEncoder(); + const lines = quads.map((quad) => { + const line = serializeWorkspacePublicSnapshotQuads([{ ...quad, graph: '' }]); + return Object.freeze({ line, bytes: encoder.encode(line) }); + }); + lines.sort((left, right) => compareBytes(left.bytes, right.bytes)); + return encoder.encode(lines.map(({ line }) => line).join('')); } -async function loadRfc64CatalogSuccessorAssetsV1( - persistence: Rfc64PersistenceV1, - history: BoundedAuthorCatalogHistoryV1, -): Promise { - const assets: Rfc64CatalogSuccessorAssetInputV1[] = []; - for (const row of history.previousBucket?.payload.rows ?? []) { - const bundleBytes = await persistence.kaBundles.readKaBundleByDigest(row.transfer.blobDigest); - if (bundleBytes === null) { - throw new Error(`RFC-64 applied catalog bundle ${row.transfer.blobDigest} is unavailable`); - } - const decoded = decodeOpaqueKaBundleV1(bundleBytes); - if ( - decoded.blobDigest !== row.transfer.blobDigest - || decoded.projectionDigest !== row.projectionDigest - ) { - throw new Error('RFC-64 applied catalog bundle differs from its signed predecessor row'); - } - assets.push(Object.freeze({ - assertionCoordinate: row.assertionCoordinate, - projectionBytes: new Uint8Array(decoded.projectionBytes), - seal: parseCanonicalGraphScopedAuthorSealV1(decoded.sealBytes), - })); +function compareBytes(left: Uint8Array, right: Uint8Array): number { + const length = Math.min(left.byteLength, right.byteLength); + for (let index = 0; index < length; index += 1) { + if (left[index] !== right[index]) return left[index]! - right[index]!; } - return assets; -} - -function sameRfc64SuccessorAssetV1( - left: Rfc64CatalogSuccessorAssetInputV1, - right: Rfc64CatalogSuccessorAssetInputV1, -): boolean { - return left.assertionCoordinate === right.assertionCoordinate - && canonicalizeCanonicalGraphScopedAuthorSealV1(left.seal) - === canonicalizeCanonicalGraphScopedAuthorSealV1(right.seal) - && equalBytes(left.projectionBytes, right.projectionBytes); -} - -function equalBytes(left: Uint8Array, right: Uint8Array): boolean { - return left.byteLength === right.byteLength - && left.every((byte, index) => byte === right[index]); + return left.byteLength - right.byteLength; } diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts b/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts new file mode 100644 index 0000000000..ad7f1dee72 --- /dev/null +++ b/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** Catalog-owned, serialized exact-set mutation for one confirmed public asset. */ + +import { + assertSignedAuthorCatalogHeadEnvelopeV1, + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, + canonicalizeCanonicalGraphScopedAuthorSealV1, + computeAuthorCatalogScopeDigestV1, + computeControlSignatureVariantDigestHex, + decodeOpaqueKaBundleV1, + parseCanonicalGraphScopedAuthorSealV1, + type AuthorCatalogScopeV1, + type CatalogSealDeploymentProfileV1, + type CountV1, + type Digest32V1, + type TimestampMsV1, +} from '@origintrail-official/dkg-core'; +import { verifyControlEnvelopeIssuerSignatureV1 } from '@origintrail-official/dkg-chain'; + +import { DKGAgentBase } from './dkg-agent-base.js'; +import type { DKGAgent } from './dkg-agent.js'; +import { + loadBoundedAuthorCatalogHistoryV1, + type BoundedAuthorCatalogHistoryV1, + type Rfc64CatalogAuthorSignerV1, + type Rfc64CatalogSuccessorAssetInputV1, + type Rfc64StagedAuthorCatalogHeadRefV1, +} from './dkg-agent-rfc64-catalog.js'; +import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; +import { snapshotRfc64PublicCatalogAnnouncementPeersV1 } from './rfc64/catalog-peers-v1.js'; +import { computeRfc64AppliedInventoryDigestV1 } from './rfc64/public-catalog-inventory-completeness-v1.js'; +import type { Rfc64PersistenceV1 } from './rfc64/persistence-v1.js'; + +export interface UpsertConfirmedRfc64PublicRootCatalogAssetParamsV1 { + readonly scope: AuthorCatalogScopeV1; + readonly author: Rfc64CatalogAuthorSignerV1; + readonly asset: Rfc64CatalogSuccessorAssetInputV1; + readonly deployment: CatalogSealDeploymentProfileV1; + readonly peers: readonly string[]; + readonly catalogIssuerDelegationEffectiveAt: TimestampMsV1; + readonly catalogIssuerDelegationExpiresAt: TimestampMsV1; +} + +export class Rfc64CatalogUpsertMethods extends DKGAgentBase { + /** + * Own genesis creation, predecessor reconstruction, exact-set successor, + * applied-head CAS, and best-effort availability announcement as one + * serialized catalog mutation. + */ + async upsertConfirmedRfc64PublicRootCatalogAssetV1( + this: DKGAgent, + params: UpsertConfirmedRfc64PublicRootCatalogAssetParamsV1, + ): Promise { + if (params.scope.subGraphName !== null) { + throw new Error('RFC-64 confirmed public asset upsert requires the root catalog lane'); + } + if (params.scope.authorAddress !== params.asset.seal.authorAddress) { + throw new Error('RFC-64 confirmed public asset author differs from the catalog scope'); + } + const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(params.peers); + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) { + throw new Error('RFC-64 catalog upsert requires durable persistence'); + } + const service = this.rfc64PublicCatalogServiceV1; + if (service === undefined) { + throw new Error('RFC-64 catalog upsert requires the public catalog service'); + } + service.acceptedPolicySnapshotForCatalogScope(params.scope); + const catalogScopeDigest = computeAuthorCatalogScopeDigestV1(params.scope); + const queueKey = `${catalogScopeDigest}\n${params.scope.authorAddress}`; + + return this.runSerializedRfc64AuthorCatalogMutationV1(queueKey, async () => { + let current = persistence.inventory.readAppliedCatalogHeadV1( + catalogScopeDigest, + params.scope.authorAddress, + ); + if (current === null) { + const genesis = await this.publishAuthorCatalogGenesisV1({ + scope: params.scope, + author: params.author, + peers: [], + issuedAt: Date.now().toString() as TimestampMsV1, + catalogIssuerDelegationEffectiveAt: + params.catalogIssuerDelegationEffectiveAt, + catalogIssuerDelegationExpiresAt: + params.catalogIssuerDelegationExpiresAt, + }); + const emptyInventoryDigest = computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest, + rows: [], + }); + current = persistence.inventory.compareAndSwapAppliedCatalogHeadV1({ + catalogScopeDigest, + authorAddress: params.scope.authorAddress, + currentCatalogHeadDigest: genesis.headObjectDigest, + appliedInventoryDigest: emptyInventoryDigest, + catalogVersion: genesis.announcement.catalogVersion, + inventoryRowCount: '0' as CountV1, + expectedCurrentCatalogHeadDigest: null, + }).snapshot; + } + + const storedHead = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: current.currentCatalogHeadDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedHead === null) { + throw new Error('RFC-64 applied author head is not durably staged'); + } + assertSignedAuthorCatalogHeadEnvelopeV1(storedHead.envelope); + const previousHead: Rfc64StagedAuthorCatalogHeadRefV1 = Object.freeze({ + objectDigest: storedHead.envelope.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + storedHead.envelope.objectDigest, + storedHead.envelope.signature, + ) as Digest32V1, + }); + const history = await loadBoundedAuthorCatalogHistoryV1(persistence, previousHead); + const assets = await loadRfc64CatalogSuccessorAssetsV1(persistence, history); + const existingIndex = assets.findIndex( + (asset) => asset.seal.reservedKaId === params.asset.seal.reservedKaId, + ); + if ( + existingIndex >= 0 + && sameRfc64SuccessorAssetV1(assets[existingIndex]!, params.asset) + ) return current; + if (existingIndex >= 0) assets[existingIndex] = params.asset; + else assets.push(params.asset); + + const storedDelegation = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: history.previousHead.payload.catalogIssuerDelegationDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedDelegation === null) { + throw new Error('RFC-64 applied author head delegation is not durably staged'); + } + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(storedDelegation.envelope); + const successor = await this.publishAuthorCatalogExactSetSuccessorV1({ + previousHead, + author: params.author, + catalogIssuerAuthorization: Object.freeze({ + catalogIssuerDelegation: storedDelegation.envelope, + parentAuthorAgentEvidence: null, + }), + assets, + deployment: params.deployment, + issuedAt: Date.now().toString() as TimestampMsV1, + peers: [], + }); + const appliedInventoryDigest = computeRfc64AppliedInventoryDigestV1({ + catalogScopeDigest: successor.catalogScopeDigest, + rows: successor.assets, + }); + const applied = persistence.inventory.compareAndSwapAppliedCatalogHeadV1({ + catalogScopeDigest: successor.catalogScopeDigest, + authorAddress: params.scope.authorAddress, + currentCatalogHeadDigest: successor.headObjectDigest, + appliedInventoryDigest, + catalogVersion: successor.announcement.catalogVersion, + inventoryRowCount: successor.signedBucketRowCount, + expectedCurrentCatalogHeadDigest: current.currentCatalogHeadDigest, + }).snapshot; + await this.announceRfc64PublicCatalogHeadV1({ + announcement: successor.announcement, + peers, + }); + return applied; + }); + } + + private async runSerializedRfc64AuthorCatalogMutationV1( + this: DKGAgent, + key: string, + operation: () => Promise, + ): Promise { + const predecessor = this.rfc64AuthorCatalogMutationQueuesV1.get(key); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const tail = (predecessor ?? Promise.resolve()).catch(() => undefined).then(() => gate); + this.rfc64AuthorCatalogMutationQueuesV1.set(key, tail); + await predecessor?.catch(() => undefined); + try { + return await operation(); + } finally { + release(); + if (this.rfc64AuthorCatalogMutationQueuesV1.get(key) === tail) { + this.rfc64AuthorCatalogMutationQueuesV1.delete(key); + } + } + } +} + +async function loadRfc64CatalogSuccessorAssetsV1( + persistence: Rfc64PersistenceV1, + history: BoundedAuthorCatalogHistoryV1, +): Promise { + const assets: Rfc64CatalogSuccessorAssetInputV1[] = []; + for (const row of history.previousBucket?.payload.rows ?? []) { + const bundleBytes = await persistence.kaBundles.readKaBundleByDigest(row.transfer.blobDigest); + if (bundleBytes === null) { + throw new Error(`RFC-64 applied catalog bundle ${row.transfer.blobDigest} is unavailable`); + } + const decoded = decodeOpaqueKaBundleV1(bundleBytes); + if ( + decoded.blobDigest !== row.transfer.blobDigest + || decoded.projectionDigest !== row.projectionDigest + ) { + throw new Error('RFC-64 applied catalog bundle differs from its signed predecessor row'); + } + assets.push(Object.freeze({ + assertionCoordinate: row.assertionCoordinate, + projectionBytes: new Uint8Array(decoded.projectionBytes), + seal: parseCanonicalGraphScopedAuthorSealV1(decoded.sealBytes), + })); + } + return assets; +} + +function sameRfc64SuccessorAssetV1( + left: Rfc64CatalogSuccessorAssetInputV1, + right: Rfc64CatalogSuccessorAssetInputV1, +): boolean { + return left.assertionCoordinate === right.assertionCoordinate + && canonicalizeCanonicalGraphScopedAuthorSealV1(left.seal) + === canonicalizeCanonicalGraphScopedAuthorSealV1(right.seal) + && equalBytes(left.projectionBytes, right.projectionBytes); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength + && left.every((byte, index) => byte === right[index]); +} diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 9e321b0a1b..470738e3e4 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -407,6 +407,7 @@ import { snapshotRfc64CatalogDeploymentProfileV1, } from './dkg-agent-rfc64-catalog.js'; import { Rfc64CatalogAutoPublishMethods } from './dkg-agent-rfc64-catalog-auto-publish.js'; +import { Rfc64CatalogUpsertMethods } from './dkg-agent-rfc64-catalog-upsert.js'; import { snapshotRfc64PublicCatalogAutoPublishConfigV1 } from './rfc64/catalog-authority-config-v1.js'; import { Rfc64CatalogSyncMethods } from './dkg-agent-rfc64-catalog-sync.js'; import { ContextGraphRegistryMethods } from './dkg-agent-cg-registry.js'; @@ -3064,5 +3065,5 @@ export class DKGAgent extends DKGAgentBase { } -export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods, Rfc64CatalogAutoPublishMethods {} -applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods, Rfc64CatalogAutoPublishMethods]); +export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods, Rfc64CatalogUpsertMethods, Rfc64CatalogAutoPublishMethods {} +applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods, Rfc64CatalogUpsertMethods, Rfc64CatalogAutoPublishMethods]); diff --git a/packages/agent/src/rfc64/catalog-authority-config-v1.ts b/packages/agent/src/rfc64/catalog-authority-config-v1.ts index 1085f5cb0d..7debda876d 100644 --- a/packages/agent/src/rfc64/catalog-authority-config-v1.ts +++ b/packages/agent/src/rfc64/catalog-authority-config-v1.ts @@ -13,10 +13,8 @@ import type { Rfc64CatalogAccessPolicyAuthorityConfigV1, Rfc64PublicCatalogAutoPublishConfigV1, } from '../dkg-agent-types.js'; +import { snapshotRfc64PublicCatalogAnnouncementPeersV1 } from './catalog-peers-v1.js'; -const MAX_RFC64_AUTO_PUBLISH_PEERS_V1 = 64; -const MAX_RFC64_PEER_ID_BYTES_V1 = 256; -const UTF8 = new TextEncoder(); /** Detach a locally configured deployment tuple from caller-owned state. */ export function snapshotRfc64CatalogDeploymentProfileV1( @@ -124,28 +122,7 @@ export function snapshotRfc64PublicCatalogAutoPublishConfigV1( ) { throw new TypeError('rfc64PublicCatalogAutoPublish has unknown or missing fields'); } - if (!Array.isArray(input.peers) || input.peers.length > MAX_RFC64_AUTO_PUBLISH_PEERS_V1) { - throw new TypeError( - `rfc64PublicCatalogAutoPublish.peers must contain at most ${MAX_RFC64_AUTO_PUBLISH_PEERS_V1} peer IDs`, - ); - } - const peers: string[] = []; - const seen = new Set(); - for (const peerId of input.peers) { - if ( - typeof peerId !== 'string' - || peerId.length === 0 - || peerId.trim() !== peerId - || UTF8.encode(peerId).byteLength > MAX_RFC64_PEER_ID_BYTES_V1 - ) { - throw new TypeError('rfc64PublicCatalogAutoPublish.peers contains an invalid peer ID'); - } - if (seen.has(peerId)) { - throw new TypeError('rfc64PublicCatalogAutoPublish.peers must be unique'); - } - seen.add(peerId); - peers.push(peerId); - } + const peers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input.peers); const effectiveAt = snapshotTimestamp( input.catalogIssuerDelegationEffectiveAt ?? ('0' as TimestampMsV1), 'catalogIssuerDelegationEffectiveAt', @@ -160,7 +137,7 @@ export function snapshotRfc64PublicCatalogAutoPublishConfigV1( ); } return Object.freeze({ - peers: Object.freeze(peers), + peers, catalogIssuerDelegationEffectiveAt: effectiveAt, catalogIssuerDelegationExpiresAt: expiresAt, }); diff --git a/packages/agent/src/rfc64/catalog-peers-v1.ts b/packages/agent/src/rfc64/catalog-peers-v1.ts new file mode 100644 index 0000000000..e253d864a0 --- /dev/null +++ b/packages/agent/src/rfc64/catalog-peers-v1.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** Canonical bounded peer-list snapshot shared by catalog config and fan-out. */ + +export const RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1 = 64; +const RFC64_PUBLIC_CATALOG_PEER_ID_MAX_BYTES_V1 = 256; +const UTF8 = new TextEncoder(); + +export function snapshotRfc64PublicCatalogAnnouncementPeersV1( + input: readonly string[], +): readonly string[] { + if (!Array.isArray(input)) { + throw new TypeError('RFC-64 catalog announcement peers must be an array'); + } + if (input.length > RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1) { + throw new RangeError( + `RFC-64 catalog announcement accepts at most ` + + `${RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1} peers`, + ); + } + const seen = new Set(); + const peers: string[] = []; + for (let index = 0; index < input.length; index += 1) { + const peerId = input[index]; + const byteLength = typeof peerId === 'string' ? UTF8.encode(peerId).byteLength : 0; + if ( + typeof peerId !== 'string' + || byteLength === 0 + || byteLength > RFC64_PUBLIC_CATALOG_PEER_ID_MAX_BYTES_V1 + || peerId.trim() !== peerId + ) { + throw new TypeError(`RFC-64 catalog announcement peer ${index} is invalid`); + } + if (seen.has(peerId)) { + throw new TypeError(`RFC-64 catalog announcement peer ${index} is duplicated`); + } + seen.add(peerId); + peers.push(peerId); + } + return Object.freeze(peers); +} diff --git a/packages/agent/src/rfc64/public-catalog-service-v1.ts b/packages/agent/src/rfc64/public-catalog-service-v1.ts index f598b6c9b4..208780e92d 100644 --- a/packages/agent/src/rfc64/public-catalog-service-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-service-v1.ts @@ -98,13 +98,15 @@ import { type FetchedRfc64PublicCatalogHeadV1, type Rfc64PublicCatalogHeadAnnouncementV1, } from './public-catalog-transport-v1.js'; +import { snapshotRfc64PublicCatalogAnnouncementPeersV1 } from './catalog-peers-v1.js'; + +export { + RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1, + snapshotRfc64PublicCatalogAnnouncementPeersV1, +} from './catalog-peers-v1.js'; /** Default per-peer announce/fetch deadline (ms). */ const DEFAULT_TRANSPORT_TIMEOUT_MS = 10_000; -/** Hard fan-out bound for one explicit best-effort announcement call. */ -export const RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1 = 64; -const RFC64_PUBLIC_CATALOG_PEER_ID_MAX_BYTES_V1 = 256; -const UTF8 = new TextEncoder(); export interface Rfc64PublicCatalogServiceOptionsV1 { readonly router: ProtocolRouter; @@ -808,40 +810,6 @@ export class Rfc64PublicCatalogServiceV1 { } } -export function snapshotRfc64PublicCatalogAnnouncementPeersV1( - input: readonly string[], -): readonly string[] { - if (!Array.isArray(input)) { - throw new TypeError('RFC-64 catalog announcement peers must be an array'); - } - if (input.length > RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1) { - throw new RangeError( - `RFC-64 catalog announcement accepts at most ` - + `${RFC64_PUBLIC_CATALOG_ANNOUNCE_MAX_PEERS_V1} peers`, - ); - } - const seen = new Set(); - const peers: string[] = []; - for (let index = 0; index < input.length; index += 1) { - const peerId = input[index]; - const byteLength = typeof peerId === 'string' ? UTF8.encode(peerId).byteLength : 0; - if ( - typeof peerId !== 'string' - || byteLength === 0 - || byteLength > RFC64_PUBLIC_CATALOG_PEER_ID_MAX_BYTES_V1 - || peerId.trim() !== peerId - ) { - throw new TypeError(`RFC-64 catalog announcement peer ${index} is invalid`); - } - if (seen.has(peerId)) { - throw new TypeError(`RFC-64 catalog announcement peer ${index} is duplicated`); - } - seen.add(peerId); - peers.push(peerId); - } - return Object.freeze(peers); -} - function assertSupportedCatalogFanout( heldPolicy: AcceptedRfc64CatalogAccessSnapshotV1, peers: readonly string[], diff --git a/packages/agent/test/encrypt-inline-policy.test.ts b/packages/agent/test/encrypt-inline-policy.test.ts index b101cbb428..47f1c5b0b1 100644 --- a/packages/agent/test/encrypt-inline-policy.test.ts +++ b/packages/agent/test/encrypt-inline-policy.test.ts @@ -798,6 +798,8 @@ function makeQueuedAgentHarness(options: { _resolveEncryptInlineChunked: recorder(async () => options.encryptInlineChunked), _stampPointer: recorder(async () => undefined), }; + agentLike.afterConfirmedGraphScopedVmPublishV1 = + (DKGAgent.prototype as any).afterConfirmedGraphScopedVmPublishV1; if (options.onChainContextGraphId !== undefined) { agentLike.getContextGraphOnChainId = recorder( async () => options.onChainContextGraphId, @@ -894,6 +896,42 @@ describe('DKGAgent.publishQueuedKnowledgeAssetVmPublish inline encryption routin }); }); + it('keeps a confirmed queued publish successful when catalog advancement fails', async () => { + const { agentLike } = makeQueuedAgentHarness({ + peerId: 'did:dkg:agent:queued-catalog-failure', + ual: 'did:dkg:local/queued-catalog-failure', + publishStatus: 'confirmed', + }); + agentLike.recordConfirmedRfc64PublicCatalogAssetV1 = recorder(async () => { + throw new Error('simulated RFC-64 catalog failure'); + }); + const snapshotQuads = [{ + subject: 'urn:test:queued-public-failure', + predicate: 'http://schema.org/name', + object: '"Queued Public"', + graph: '', + }]; + const request = await makeQueuedPublishRequest({ + contextGraphId: 'public-cg', + name: 'queued-public-ka-failure', + shareOperationId: 'share-op-catalog-failure', + intentByte: 'ae', + quads: snapshotQuads, + }); + + const result = await (DKGAgent.prototype as any).publishQueuedKnowledgeAssetVmPublish.call( + agentLike, + request, + { contextGraphId: request.contextGraphId, quads: snapshotQuads }, + ); + + expect(result).toMatchObject({ status: 'confirmed' }); + expect(agentLike.log.warn.calls.some((call: unknown[]) => ( + String(call[1]).includes('RFC-64 catalog advancement failed') + && String(call[1]).includes('simulated RFC-64 catalog failure') + ))).toBe(true); + }); + it('keeps the V2 snapshot exact while passing a detached catalog capability', async () => { const realInline = recorder(async (plaintext: Uint8Array) => new Uint8Array([...plaintext, 0xaa])); const realChunked = recorder(async () => ({ diff --git a/packages/agent/test/publish-finalized-agent-lane.test.ts b/packages/agent/test/publish-finalized-agent-lane.test.ts index a497602485..22b088b20e 100644 --- a/packages/agent/test/publish-finalized-agent-lane.test.ts +++ b/packages/agent/test/publish-finalized-agent-lane.test.ts @@ -141,6 +141,7 @@ describe('DKGAgent publishFromFinalizedAssertion agent lane', () => { const publishCalls: Array<{ contextGraphId: string; selection: any; opts: any }> = []; const remainingClearCalls: any[][] = []; const rfc64CatalogCalls: any[] = []; + const warnings: string[] = []; const agent = Object.create(DKGAgent.prototype) as any; agent.store = store; @@ -150,7 +151,10 @@ describe('DKGAgent publishFromFinalizedAssertion agent lane', () => { value: '12D3KooWQz2bQbQueABKRSjV9koF8VYsXk5TdCsUmPf5zAEZg3q6', configurable: true, }); - agent.log = makeLog(); + agent.log = { + ...makeLog(), + warn: (_ctx: unknown, message: string) => { warnings.push(message); }, + }; agent.publisher = { hasSwmShareComplete: async ( contextGraphId: string, @@ -177,7 +181,7 @@ describe('DKGAgent publishFromFinalizedAssertion agent lane', () => { }; agent.recordConfirmedRfc64PublicCatalogAssetV1 = async (input: any) => { rfc64CatalogCalls.push(input); - return null; + throw new Error('simulated finalized RFC-64 catalog failure'); }; // GH#1778 — a genuinely absent name still yields "is not finalized": @@ -237,6 +241,9 @@ describe('DKGAgent publishFromFinalizedAssertion agent lane', () => { reservedKaId: RESERVED_KA_ID, kaUal: KA_UAL, }); + expect(warnings).toEqual(expect.arrayContaining([ + expect.stringContaining('simulated finalized RFC-64 catalog failure'), + ])); }); it('maps an empty exact KA graph to the mint no-data precondition', async () => { diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 7b897db84c..95130f7a83 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -21,7 +21,8 @@ import { type NetworkIdV1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; +import { computeFlatKCRootV10 } from '@origintrail-official/dkg-publisher'; import { ethers } from 'ethers'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -291,7 +292,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { expect(() => snapshotRfc64PublicCatalogAutoPublishConfigV1({ peers: ['duplicate', 'duplicate'], catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, - })).toThrow(/must be unique/u); + })).toThrow(/duplicated/u); }); it('turns one confirmed public KA into the provider current head and one cold receiver apply', async () => { @@ -415,6 +416,95 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); + it('serializes mixed-case projection terms in raw UTF-8 order', async () => { + const author = await startNativeAgent( + 'auto-publish-byte-order', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(AUTHOR_WALLET.privateKey); + author.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + const publicQuads = [ + { + subject: 'urn:a', + predicate: 'https://schema.org/name', + object: '"lowercase"', + graph: '', + }, + { + subject: 'urn:Z', + predicate: 'https://schema.org/name', + object: '"uppercase"', + graph: '', + }, + ]; + const applied = await author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'mixed-case-byte-order' as never, + publicQuads, + seal: assertionSealFromCanonical(await authorSeal(31n, publicQuads)), + }); + expect(applied).toMatchObject({ catalogVersion: '1', inventoryRowCount: '1' }); + }, 60_000); + + it('explicitly skips private-bearing ordinary publishes in the public-only V1 bridge', async () => { + const author = await startNativeAgent( + 'auto-publish-private-skip', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(AUTHOR_WALLET.privateKey); + author.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + const privateBearingSeal: AssertionSeal = { + ...assertionSealFromCanonical(await authorSeal(32n)), + privateTripleCount: 1, + privateMerkleRoot: ethers.getBytes(`0x${'99'.repeat(32)}`), + }; + await expect(author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'private-bearing-skip' as never, + publicQuads: [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + ], + seal: privateBearingSeal, + })).resolves.toBeNull(); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toBeNull(); + }, 60_000); + it('snapshots the explicit access authority and fails closed before private activation', async () => { const resolver = async () => AUTHOR; const callerOwned = { @@ -1160,13 +1250,19 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }, 60_000); }); -async function authorSeal(kaNumber: bigint): Promise { +async function authorSeal( + kaNumber: bigint, + publicQuads?: readonly Quad[], +): Promise { const kaId = ((BigInt(AUTHOR) << 96n) | kaNumber).toString(); const kaUal = `did:dkg:${NETWORK_ID}/${AUTHOR}/${kaNumber}`; + const assertionMerkleRoot = publicQuads === undefined + ? ASSERTION_ROOT + : ethers.hexlify(computeFlatKCRootV10([...publicQuads], [])) as Digest32V1; const typedData = buildAuthorAttestationTypedData({ chainId: BigInt(NATIVE_DEPLOYMENT.assertedAtChainId), kav10Address: NATIVE_DEPLOYMENT.assertedAtKav10Address, - merkleRoot: ethers.getBytes(ASSERTION_ROOT), + merkleRoot: ethers.getBytes(assertionMerkleRoot), authorAddress: AUTHOR, reservedKaId: BigInt(kaId), }); @@ -1176,7 +1272,7 @@ async function authorSeal(kaNumber: bigint): Promise Date: Thu, 23 Jul 2026 01:18:25 +0200 Subject: [PATCH 283/292] feat(agent): bootstrap pinned RFC-64 public catalogs --- packages/agent/scripts/test-package-root.mjs | 2 + packages/agent/src/dkg-agent-lifecycle.ts | 1 + .../src/dkg-agent-rfc64-catalog-bootstrap.ts | 300 ++++++++++++++++++ packages/agent/src/dkg-agent-rfc64-catalog.ts | 1 + packages/agent/src/dkg-agent-types.ts | 37 +++ packages/agent/src/dkg-agent.ts | 15 +- .../src/rfc64/catalog-authority-config-v1.ts | 211 ++++++++++++ ...kg-agent-native-wiring.integration.test.ts | 175 +++++++++- 8 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 packages/agent/src/dkg-agent-rfc64-catalog-bootstrap.ts diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index cadd671ee1..0a68a1edb6 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -46,6 +46,8 @@ const requiredCatalogMethods = [ 'publishAuthorCatalogExactSetSuccessorV1', 'recordConfirmedRfc64PublicCatalogAssetV1', 'synchronizeRfc64PublicCatalogFromProviderV1', + 'readRfc64PublicCatalogBootstrapStatusV1', + 'whenRfc64PublicCatalogBootstrapIdleV1', ]; for (const method of requiredCatalogMethods) { if ( diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index d4de362da6..649a87386e 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -1306,6 +1306,7 @@ export class LifecycleSyncMethods extends DKGAgentBase { // production router. Announce/fetch protocols are admission-gated like // every other node protocol. Dormant when no dataDir opened persistence. this.startRfc64PublicCatalogServiceV1(ctx); + this.startRfc64PublicCatalogBootstrapV1(ctx); const effectiveRole = this.config.nodeRole ?? 'edge'; const ackSignerCandidates = this.getACKSignerCandidateWallets(ctx); diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-bootstrap.ts b/packages/agent/src/dkg-agent-rfc64-catalog-bootstrap.ts new file mode 100644 index 0000000000..8677af11fd --- /dev/null +++ b/packages/agent/src/dkg-agent-rfc64-catalog-bootstrap.ts @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** Restart-safe, operator-pinned public catalog cold-start supervisor. */ + +import { + type ContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, + type NetworkIdV1, + type OperationContext, +} from '@origintrail-official/dkg-core'; + +import { DKGAgentBase } from './dkg-agent-base.js'; +import type { DKGAgent } from './dkg-agent.js'; +import type { + Rfc64PublicCatalogBootstrapConfigV1, + Rfc64PublicCatalogBootstrapScopeV1, +} from './dkg-agent-types.js'; + +const MAX_STATUS_ERROR_BYTES_V1 = 1024; +const MAX_CONCURRENT_TARGETS_V1 = 4; +const UTF8 = new TextEncoder(); + +export type Rfc64PublicCatalogBootstrapOutcomeV1 = + | 'pending' + | 'applied' + | 'not-found' + | 'failed'; + +export interface Rfc64PublicCatalogBootstrapTargetStatusV1 { + readonly scope: Readonly; + readonly providers: readonly string[]; + readonly outcome: Rfc64PublicCatalogBootstrapOutcomeV1; + readonly attempts: number; + readonly providerPeerId: string | null; + readonly appliedHeadDigest: Digest32V1 | null; + readonly catalogVersion: string | null; + readonly inventoryRowCount: string | null; + readonly lastError: string | null; + readonly updatedAtMs: number | null; +} + +export interface Rfc64PublicCatalogBootstrapStatusV1 { + readonly running: boolean; + readonly pass: number; + readonly retryIntervalMs: number; + readonly lastPassStartedAtMs: number | null; + readonly lastPassCompletedAtMs: number | null; + readonly targets: readonly Rfc64PublicCatalogBootstrapTargetStatusV1[]; +} + +interface MutableTargetStatusV1 { + readonly scope: Readonly; + readonly providers: readonly string[]; + outcome: Rfc64PublicCatalogBootstrapOutcomeV1; + attempts: number; + providerPeerId: string | null; + appliedHeadDigest: Digest32V1 | null; + catalogVersion: string | null; + inventoryRowCount: string | null; + lastError: string | null; + updatedAtMs: number | null; +} + +interface BootstrapStateV1 { + readonly config: Readonly; + readonly targets: MutableTargetStatusV1[]; + readonly ctx: OperationContext; + closed: boolean; + running: boolean; + pass: number; + lastPassStartedAtMs: number | null; + lastPassCompletedAtMs: number | null; + timer: ReturnType | null; + abortController: AbortController | null; + run: Promise | null; +} + +const STATES = new WeakMap(); + +export class Rfc64CatalogBootstrapMethods extends DKGAgentBase { + /** Accept pinned policies and start the first bounded provider pass. */ + startRfc64PublicCatalogBootstrapV1(this: DKGAgent, ctx: OperationContext): void { + const config = this.config.rfc64PublicCatalogBootstrap; + if (config === undefined || STATES.has(this)) return; + const service = this.rfc64PublicCatalogServiceV1; + if (service === undefined) { + throw new Error('RFC-64 bootstrap requires the public catalog service'); + } + for (const accepted of config.acceptedPublicPolicies) { + service.acceptPolicySnapshot({ + policy: accepted.policy, + policyDigest: accepted.policyDigest, + }); + } + const state: BootstrapStateV1 = { + config, + targets: config.targets.map((target) => ({ + scope: target.scope, + providers: target.providers, + outcome: 'pending', + attempts: 0, + providerPeerId: null, + appliedHeadDigest: null, + catalogVersion: null, + inventoryRowCount: null, + lastError: null, + updatedAtMs: null, + })), + ctx, + closed: false, + running: false, + pass: 0, + lastPassStartedAtMs: null, + lastPassCompletedAtMs: null, + timer: null, + abortController: null, + run: null, + }; + STATES.set(this, state); + this.launchRfc64PublicCatalogBootstrapPassV1(state); + } + + /** Immutable bounded observability for release harnesses and daemon adapters. */ + readRfc64PublicCatalogBootstrapStatusV1( + this: DKGAgent, + ): Readonly | null { + const state = STATES.get(this); + if (state === undefined) return null; + return Object.freeze({ + running: state.running, + pass: state.pass, + retryIntervalMs: state.config.retryIntervalMs ?? 0, + lastPassStartedAtMs: state.lastPassStartedAtMs, + lastPassCompletedAtMs: state.lastPassCompletedAtMs, + targets: Object.freeze(state.targets.map(snapshotTargetStatusV1)), + }); + } + + /** Wait for the currently running startup/refresh pass only. */ + async whenRfc64PublicCatalogBootstrapIdleV1(this: DKGAgent): Promise { + const state = STATES.get(this); + if (state === undefined) return; + while (state.run !== null) { + const current = state.run; + await current; + if (state.run === current) return; + } + } + + /** Stop future retries and abort/drain the current pass before service close. */ + async closeRfc64PublicCatalogBootstrapV1(this: DKGAgent): Promise { + const state = STATES.get(this); + if (state === undefined) return; + state.closed = true; + if (state.timer !== null) { + clearTimeout(state.timer); + state.timer = null; + } + state.abortController?.abort(new Error('RFC-64 public catalog bootstrap closing')); + await state.run?.catch(() => undefined); + STATES.delete(this); + } + + private launchRfc64PublicCatalogBootstrapPassV1( + this: DKGAgent, + state: BootstrapStateV1, + ): void { + if (state.closed || state.run !== null) return; + const run = this.runRfc64PublicCatalogBootstrapPassV1(state) + .catch((error) => { + this.log.warn( + state.ctx, + `RFC-64 public catalog bootstrap pass failed: ${errorMessageV1(error)}`, + ); + }) + .finally(() => { + if (state.run === run) state.run = null; + const retryIntervalMs = state.config.retryIntervalMs ?? 0; + if (!state.closed && retryIntervalMs > 0) { + state.timer = setTimeout(() => { + state.timer = null; + this.launchRfc64PublicCatalogBootstrapPassV1(state); + }, retryIntervalMs); + state.timer.unref?.(); + } + }); + state.run = run; + } + + private async runRfc64PublicCatalogBootstrapPassV1( + this: DKGAgent, + state: BootstrapStateV1, + ): Promise { + state.running = true; + state.pass += 1; + state.lastPassStartedAtMs = Date.now(); + const abortController = new AbortController(); + state.abortController = abortController; + let cursor = 0; + const workers = Array.from( + { length: Math.min(MAX_CONCURRENT_TARGETS_V1, state.targets.length) }, + async () => { + while (!state.closed) { + const index = cursor; + cursor += 1; + const target = state.targets[index]; + if (target === undefined) return; + await this.synchronizeRfc64PublicCatalogBootstrapTargetV1( + target, + abortController.signal, + ); + } + }, + ); + try { + await Promise.all(workers); + } finally { + if (state.abortController === abortController) state.abortController = null; + state.running = false; + state.lastPassCompletedAtMs = Date.now(); + } + } + + private async synchronizeRfc64PublicCatalogBootstrapTargetV1( + this: DKGAgent, + target: MutableTargetStatusV1, + signal: AbortSignal, + ): Promise { + let sawNotFound = false; + let lastError: string | null = null; + for (const providerPeerId of target.providers) { + if (signal.aborted) return; + target.attempts += 1; + try { + const applied = await this.synchronizeRfc64PublicCatalogFromProviderV1({ + remotePeerId: providerPeerId, + scope: target.scope, + signal, + }); + if (applied === null) { + sawNotFound = true; + continue; + } + target.outcome = 'applied'; + target.providerPeerId = providerPeerId; + target.appliedHeadDigest = applied.currentCatalogHeadDigest; + target.catalogVersion = applied.catalogVersion; + target.inventoryRowCount = applied.inventoryRowCount; + target.lastError = null; + target.updatedAtMs = Date.now(); + return; + } catch (error) { + if (signal.aborted) return; + lastError = boundedErrorV1(errorMessageV1(error)); + } + } + target.outcome = lastError === null && sawNotFound ? 'not-found' : 'failed'; + target.providerPeerId = null; + target.lastError = lastError; + target.updatedAtMs = Date.now(); + } +} + +function snapshotTargetStatusV1( + target: MutableTargetStatusV1, +): Readonly { + return Object.freeze({ + scope: Object.freeze({ + networkId: target.scope.networkId as NetworkIdV1, + contextGraphId: target.scope.contextGraphId as ContextGraphIdV1, + subGraphName: target.scope.subGraphName, + authorAddress: target.scope.authorAddress as EvmAddressV1, + catalogEra: target.scope.catalogEra, + }), + providers: Object.freeze([...target.providers]), + outcome: target.outcome, + attempts: target.attempts, + providerPeerId: target.providerPeerId, + appliedHeadDigest: target.appliedHeadDigest, + catalogVersion: target.catalogVersion, + inventoryRowCount: target.inventoryRowCount, + lastError: target.lastError, + updatedAtMs: target.updatedAtMs, + }); +} + +function errorMessageV1(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function boundedErrorV1(input: string): string { + if (UTF8.encode(input).byteLength <= MAX_STATUS_ERROR_BYTES_V1) return input; + let output = ''; + for (const character of input) { + if (UTF8.encode(`${output}${character}`).byteLength > MAX_STATUS_ERROR_BYTES_V1) break; + output += character; + } + return output; +} diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index 5f4b06d138..363fc3215b 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -181,6 +181,7 @@ export { snapshotRfc64CatalogAccessPolicyAuthorityV1, snapshotRfc64CatalogDeploymentProfileV1, snapshotRfc64PublicCatalogAutoPublishConfigV1, + snapshotRfc64PublicCatalogBootstrapConfigV1, } from './rfc64/catalog-authority-config-v1.js'; export interface PublishOpenAuthorCatalogSuccessorParamsV1 { diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index 2d685ae67f..8206af88be 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -31,7 +31,13 @@ import type { ContextGraphJoinPolicyMode as CoreContextGraphJoinPolicyMode, ContextGraphJoinPolicyRecord as CoreContextGraphJoinPolicyRecord, CatalogSealDeploymentProfileV1, + ContextGraphIdV1, + ContextGraphPolicyV1, + DecimalU64V1, + Digest32V1, EvmAddressV1, + NetworkIdV1, + SubGraphNameV1, TimestampMsV1, } from '@origintrail-official/dkg-core'; import type { @@ -1071,6 +1077,35 @@ export interface Rfc64PublicCatalogAutoPublishConfigV1 { readonly catalogIssuerDelegationExpiresAt: TimestampMsV1; } +export interface Rfc64PublicCatalogBootstrapScopeV1 { + readonly networkId: NetworkIdV1; + readonly contextGraphId: ContextGraphIdV1; + readonly subGraphName: SubGraphNameV1 | null; + readonly authorAddress: EvmAddressV1; + readonly catalogEra: DecimalU64V1; +} + +export interface Rfc64PublicCatalogBootstrapTargetV1 { + readonly scope: Rfc64PublicCatalogBootstrapScopeV1; + /** Ordered provider failover candidates for this exact author catalog. */ + readonly providers: readonly string[]; +} + +/** + * Explicit V1 cold-start manifest. Policies are operator-pinned outputs of an + * independent finality/administrative verifier; the catalog lane only consumes + * them. A zero retry interval performs one startup pass, which is useful for + * deterministic harnesses. Omission defaults to a 30-second refresh pass. + */ +export interface Rfc64PublicCatalogBootstrapConfigV1 { + readonly acceptedPublicPolicies: readonly Readonly<{ + readonly policy: ContextGraphPolicyV1; + readonly policyDigest: Digest32V1; + }>[]; + readonly targets: readonly Rfc64PublicCatalogBootstrapTargetV1[]; + readonly retryIntervalMs?: number; +} + export interface DKGAgentConfig { name: string; /** Selected genesis document. Defaults to the compatibility Base testnet genesis. */ @@ -1092,6 +1127,8 @@ export interface DKGAgentConfig { rfc64CatalogAccessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1; /** Omission preserves the existing publication and synchronization behavior. */ rfc64PublicCatalogAutoPublish?: Rfc64PublicCatalogAutoPublishConfigV1; + /** Omission preserves manual RFC-64 current-head discovery. */ + rfc64PublicCatalogBootstrap?: Rfc64PublicCatalogBootstrapConfigV1; /** * public-projection enable flag. When set, a private CG's confirmed VM * publishes emit/refresh a verifiable public projection (the floor: existence, diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 9e321b0a1b..72281cdb89 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -407,7 +407,11 @@ import { snapshotRfc64CatalogDeploymentProfileV1, } from './dkg-agent-rfc64-catalog.js'; import { Rfc64CatalogAutoPublishMethods } from './dkg-agent-rfc64-catalog-auto-publish.js'; -import { snapshotRfc64PublicCatalogAutoPublishConfigV1 } from './rfc64/catalog-authority-config-v1.js'; +import { Rfc64CatalogBootstrapMethods } from './dkg-agent-rfc64-catalog-bootstrap.js'; +import { + snapshotRfc64PublicCatalogAutoPublishConfigV1, + snapshotRfc64PublicCatalogBootstrapConfigV1, +} from './rfc64/catalog-authority-config-v1.js'; import { Rfc64CatalogSyncMethods } from './dkg-agent-rfc64-catalog-sync.js'; import { ContextGraphRegistryMethods } from './dkg-agent-cg-registry.js'; import { JoinRequestMethods } from './dkg-agent-join.js'; @@ -710,6 +714,9 @@ export class DKGAgent extends DKGAgentBase { const rfc64PublicCatalogAutoPublish = snapshotRfc64PublicCatalogAutoPublishConfigV1( config.rfc64PublicCatalogAutoPublish, ); + const rfc64PublicCatalogBootstrap = snapshotRfc64PublicCatalogBootstrapConfigV1( + config.rfc64PublicCatalogBootstrap, + ); let wallet: DKGAgentWallet; if (config.dataDir) { try { @@ -817,6 +824,7 @@ export class DKGAgent extends DKGAgentBase { rfc64CatalogAccessPolicyAuthority, rfc64CatalogDeploymentProfile, rfc64PublicCatalogAutoPublish, + rfc64PublicCatalogBootstrap, }; const port = config.listenPort ?? 0; @@ -1735,6 +1743,7 @@ export class DKGAgent extends DKGAgentBase { // router, node, and control-object store are all still live — before // node.stop() below and before closeRfc64PersistenceV1() releases the store. try { + await this.closeRfc64PublicCatalogBootstrapV1(); await this.closeRfc64PublicCatalogServiceV1(); } catch (err) { this.log.warn( @@ -3064,5 +3073,5 @@ export class DKGAgent extends DKGAgentBase { } -export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods, Rfc64CatalogAutoPublishMethods {} -applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods, Rfc64CatalogAutoPublishMethods]); +export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods, Rfc64CatalogAutoPublishMethods, Rfc64CatalogBootstrapMethods {} +applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, Rfc64CatalogMethods, Rfc64CatalogSyncMethods, Rfc64CatalogAutoPublishMethods, Rfc64CatalogBootstrapMethods]); diff --git a/packages/agent/src/rfc64/catalog-authority-config-v1.ts b/packages/agent/src/rfc64/catalog-authority-config-v1.ts index 1085f5cb0d..9d67c6814e 100644 --- a/packages/agent/src/rfc64/catalog-authority-config-v1.ts +++ b/packages/agent/src/rfc64/catalog-authority-config-v1.ts @@ -2,7 +2,15 @@ import { assertCanonicalChainId, + assertCanonicalDecimalU64, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertContextGraphIdV1, assertNetworkIdV1, + canonicalizeContextGraphPolicyPayloadV1, + parseCanonicalContextGraphPolicyPayloadV1, + type ContextGraphPolicyV1, + type Digest32V1, type CatalogSealDeploymentProfileV1, type EvmAddressV1, type TimestampMsV1, @@ -12,11 +20,19 @@ import { ethers } from 'ethers'; import type { Rfc64CatalogAccessPolicyAuthorityConfigV1, Rfc64PublicCatalogAutoPublishConfigV1, + Rfc64PublicCatalogBootstrapConfigV1, + Rfc64PublicCatalogBootstrapScopeV1, + Rfc64PublicCatalogBootstrapTargetV1, } from '../dkg-agent-types.js'; const MAX_RFC64_AUTO_PUBLISH_PEERS_V1 = 64; const MAX_RFC64_PEER_ID_BYTES_V1 = 256; const UTF8 = new TextEncoder(); +const MAX_RFC64_BOOTSTRAP_POLICIES_V1 = 64; +const MAX_RFC64_BOOTSTRAP_TARGETS_V1 = 256; +const MAX_RFC64_BOOTSTRAP_PROVIDERS_V1 = 8; +const DEFAULT_RFC64_BOOTSTRAP_RETRY_INTERVAL_MS_V1 = 30_000; +const MAX_RFC64_BOOTSTRAP_RETRY_INTERVAL_MS_V1 = 3_600_000; /** Detach a locally configured deployment tuple from caller-owned state. */ export function snapshotRfc64CatalogDeploymentProfileV1( @@ -166,6 +182,112 @@ export function snapshotRfc64PublicCatalogAutoPublishConfigV1( }); } +/** Detach and validate the explicit public cold-start manifest. */ +export function snapshotRfc64PublicCatalogBootstrapConfigV1( + input: Rfc64PublicCatalogBootstrapConfigV1 | undefined, +): Readonly | undefined { + if (input === undefined) return undefined; + assertPlainExactObject( + input, + 'rfc64PublicCatalogBootstrap', + ['acceptedPublicPolicies', 'retryIntervalMs', 'targets'], + ['acceptedPublicPolicies', 'targets'], + ); + if ( + !Array.isArray(input.acceptedPublicPolicies) + || input.acceptedPublicPolicies.length > MAX_RFC64_BOOTSTRAP_POLICIES_V1 + ) { + throw new TypeError( + `rfc64PublicCatalogBootstrap.acceptedPublicPolicies must contain at most ` + + `${MAX_RFC64_BOOTSTRAP_POLICIES_V1} policies`, + ); + } + const policies = input.acceptedPublicPolicies.map((entry, index) => { + assertPlainExactObject( + entry, + `rfc64PublicCatalogBootstrap.acceptedPublicPolicies[${index}]`, + ['policy', 'policyDigest'], + ['policy', 'policyDigest'], + ); + const candidate = entry as { + readonly policy: ContextGraphPolicyV1; + readonly policyDigest: Digest32V1; + }; + const policy = parseCanonicalContextGraphPolicyPayloadV1( + canonicalizeContextGraphPolicyPayloadV1(candidate.policy), + ); + if (policy.accessPolicy !== 0) { + throw new TypeError('rfc64PublicCatalogBootstrap accepts public policies only'); + } + assertCanonicalDigest(candidate.policyDigest, `acceptedPublicPolicies[${index}].policyDigest`); + return Object.freeze({ + policy: deepFreezePlain(policy), + policyDigest: candidate.policyDigest, + }); + }); + const policyKeys = new Set(); + for (const { policy } of policies) { + const key = `${policy.networkId}\n${policy.contextGraphId}`; + if (policyKeys.has(key)) { + throw new TypeError('rfc64PublicCatalogBootstrap policies must be unique by graph'); + } + policyKeys.add(key); + } + + if (!Array.isArray(input.targets) || input.targets.length > MAX_RFC64_BOOTSTRAP_TARGETS_V1) { + throw new TypeError( + `rfc64PublicCatalogBootstrap.targets must contain at most ` + + `${MAX_RFC64_BOOTSTRAP_TARGETS_V1} catalogs`, + ); + } + const targetKeys = new Set(); + const targets = input.targets.map((target, index) => { + assertPlainExactObject( + target, + `rfc64PublicCatalogBootstrap.targets[${index}]`, + ['providers', 'scope'], + ['providers', 'scope'], + ); + const candidate = target as unknown as Rfc64PublicCatalogBootstrapTargetV1; + const scope = snapshotBootstrapScope(candidate.scope, index); + const policy = policies.find((candidate) => ( + candidate.policy.networkId === scope.networkId + && candidate.policy.contextGraphId === scope.contextGraphId + )); + if (policy === undefined || policy.policy.era !== scope.catalogEra) { + throw new TypeError( + 'rfc64PublicCatalogBootstrap target has no matching accepted policy era', + ); + } + const providers = snapshotBootstrapProviders(candidate.providers, index); + const key = `${scope.networkId}\n${scope.contextGraphId}\n${scope.authorAddress}` + + `\n${scope.catalogEra}`; + if (targetKeys.has(key)) { + throw new TypeError('rfc64PublicCatalogBootstrap targets must be unique by author scope'); + } + targetKeys.add(key); + return Object.freeze({ scope, providers }); + }); + + const retryIntervalMs = input.retryIntervalMs + ?? DEFAULT_RFC64_BOOTSTRAP_RETRY_INTERVAL_MS_V1; + if ( + !Number.isSafeInteger(retryIntervalMs) + || retryIntervalMs < 0 + || retryIntervalMs > MAX_RFC64_BOOTSTRAP_RETRY_INTERVAL_MS_V1 + || (retryIntervalMs > 0 && retryIntervalMs < 1_000) + ) { + throw new TypeError( + 'rfc64PublicCatalogBootstrap.retryIntervalMs must be 0 or 1000..3600000', + ); + } + return Object.freeze({ + acceptedPublicPolicies: Object.freeze(policies), + targets: Object.freeze(targets), + retryIntervalMs, + }); +} + function snapshotTimestamp(value: unknown, label: string): TimestampMsV1 { if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/u.test(value)) { throw new TypeError(`rfc64PublicCatalogAutoPublish.${label} must be a canonical timestamp`); @@ -175,3 +297,92 @@ function snapshotTimestamp(value: unknown, label: string): TimestampMsV1 { } return value as TimestampMsV1; } + +function snapshotBootstrapScope( + input: Rfc64PublicCatalogBootstrapScopeV1, + index: number, +): Readonly { + assertPlainExactObject( + input, + `rfc64PublicCatalogBootstrap.targets[${index}].scope`, + ['authorAddress', 'catalogEra', 'contextGraphId', 'networkId', 'subGraphName'], + ['authorAddress', 'catalogEra', 'contextGraphId', 'networkId', 'subGraphName'], + ); + assertNetworkIdV1(input.networkId, 'bootstrap scope networkId'); + assertContextGraphIdV1(input.contextGraphId, 'bootstrap scope contextGraphId'); + if (input.subGraphName !== null) { + throw new TypeError('rfc64PublicCatalogBootstrap V1 targets public root catalogs only'); + } + assertCanonicalEvmAddress(input.authorAddress, 'bootstrap scope authorAddress'); + if (input.authorAddress === `0x${'0'.repeat(40)}`) { + throw new TypeError('rfc64PublicCatalogBootstrap authorAddress must be nonzero'); + } + assertCanonicalDecimalU64(input.catalogEra, 'bootstrap scope catalogEra'); + return Object.freeze({ + networkId: input.networkId, + contextGraphId: input.contextGraphId, + subGraphName: null, + authorAddress: input.authorAddress, + catalogEra: input.catalogEra, + }); +} + +function snapshotBootstrapProviders(input: readonly string[], targetIndex: number): readonly string[] { + if ( + !Array.isArray(input) + || input.length === 0 + || input.length > MAX_RFC64_BOOTSTRAP_PROVIDERS_V1 + ) { + throw new TypeError( + `rfc64PublicCatalogBootstrap.targets[${targetIndex}].providers must contain 1..` + + `${MAX_RFC64_BOOTSTRAP_PROVIDERS_V1} peers`, + ); + } + const seen = new Set(); + const providers = input.map((peerId) => { + if ( + typeof peerId !== 'string' + || peerId.length === 0 + || peerId.trim() !== peerId + || UTF8.encode(peerId).byteLength > MAX_RFC64_PEER_ID_BYTES_V1 + || seen.has(peerId) + ) { + throw new TypeError('rfc64PublicCatalogBootstrap contains an invalid provider peer ID'); + } + seen.add(peerId); + return peerId; + }); + return Object.freeze(providers); +} + +function assertPlainExactObject( + input: unknown, + label: string, + allowed: readonly string[], + required: readonly string[], +): asserts input is Record { + if (input === null || typeof input !== 'object' || Array.isArray(input)) { + throw new TypeError(`${label} must be a plain object`); + } + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${label} must be a plain object`); + } + const keys = Object.keys(input); + if ( + keys.some((key) => !allowed.includes(key)) + || required.some((key) => !keys.includes(key)) + ) { + throw new TypeError(`${label} has unknown or missing fields`); + } +} + +function deepFreezePlain(input: T): Readonly { + if (input !== null && typeof input === 'object') { + for (const value of Object.values(input as Record)) { + deepFreezePlain(value); + } + if (!Object.isFrozen(input)) Object.freeze(input); + } + return input; +} diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 7b897db84c..f4d0badc6e 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -30,12 +30,18 @@ import { snapshotRfc64CatalogAccessPolicyAuthorityV1, snapshotRfc64CatalogDeploymentProfileV1, snapshotRfc64PublicCatalogAutoPublishConfigV1, + snapshotRfc64PublicCatalogBootstrapConfigV1, } from '../src/dkg-agent-rfc64-catalog.js'; +import { + buildOpenOwnerContextGraphPolicyV1, + computeOpenContextGraphPolicyDigestV1, +} from '../src/rfc64/open-catalog-policy-v1.js'; import type { ContextGraphSubscriptionRecord, ContextGraphSubscriptionStore, Rfc64CatalogAccessPolicyAuthorityConfigV1, Rfc64PublicCatalogAutoPublishConfigV1, + Rfc64PublicCatalogBootstrapConfigV1, } from '../src/dkg-agent-types.js'; import { createLoopbackJsonRpcTestHarness, @@ -101,6 +107,8 @@ async function startNativeAgent( initialSubscription?: ContextGraphIdV1; }>, autoPublish?: Rfc64PublicCatalogAutoPublishConfigV1, + bootstrap?: Rfc64PublicCatalogBootstrapConfigV1, + persistentStorePath?: string, ): Promise { const dataDir = existingDataDir ?? await mkdtemp(join(tmpdir(), `dkg-rfc64-native-${name}-`)); @@ -112,7 +120,7 @@ async function startNativeAgent( listenPort: 0, bootstrapPeers: [], nodeRole: 'edge', - store: new OxigraphStore(), + store: new OxigraphStore(persistentStorePath), syncSharedMemoryOnConnect: false, syncReconcilerEnabled: false, syncOnConnectEnabled: false, @@ -121,6 +129,7 @@ async function startNativeAgent( rfc64CatalogDeploymentProfile: deployment, rfc64CatalogAccessPolicyAuthority: accessPolicyAuthority, rfc64PublicCatalogAutoPublish: autoPublish, + rfc64PublicCatalogBootstrap: bootstrap, ...(finalizedRuntime === undefined ? {} : { chainAdapter: finalizedRuntime.chainAdapter, chainConfig: { @@ -294,6 +303,46 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { })).toThrow(/must be unique/u); }); + it('snapshots a bounded public-root bootstrap manifest', () => { + const policy = buildOpenOwnerContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + const policyDigest = computeOpenContextGraphPolicyDigestV1(policy); + const providers = ['12D3KooPrimary']; + const callerOwned: Rfc64PublicCatalogBootstrapConfigV1 = { + acceptedPublicPolicies: [{ policy, policyDigest }], + targets: [{ + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: policy.era, + }, + providers, + }], + retryIntervalMs: 1_000, + }; + const snapshot = snapshotRfc64PublicCatalogBootstrapConfigV1(callerOwned)!; + providers.push('12D3KooLateMutation'); + + expect(snapshot.targets[0]?.providers).toEqual(['12D3KooPrimary']); + expect(snapshot.acceptedPublicPolicies[0]).toEqual({ policy, policyDigest }); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.targets)).toBe(true); + expect(Object.isFrozen(snapshot.targets[0]?.providers)).toBe(true); + expect(Object.isFrozen(snapshot.acceptedPublicPolicies[0]?.policy.source)).toBe(true); + expect(() => snapshotRfc64PublicCatalogBootstrapConfigV1({ + ...callerOwned, + targets: [{ + ...callerOwned.targets[0]!, + scope: { ...callerOwned.targets[0]!.scope, subGraphName: 'private' }, + }], + })).toThrow(/public root catalogs only/u); + }); + it('turns one confirmed public KA into the provider current head and one cold receiver apply', async () => { const receiver = await startNativeAgent('auto-publish-receiver'); const author = await startNativeAgent( @@ -415,6 +464,130 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); + it('automatically cold-joins a published public catalog and recovers it after restart', async () => { + const author = await startNativeAgent( + 'bootstrap-author', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(AUTHOR_WALLET.privateKey); + const accepted = author.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + const publicQuads = [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + ] as const; + await author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'bootstrap-publication-1' as never, + publicQuads, + seal: assertionSealFromCanonical(await authorSeal(21n)), + }); + const published = await author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'bootstrap-publication-2' as never, + publicQuads, + seal: assertionSealFromCanonical(await authorSeal(22n)), + }); + expect(published).toMatchObject({ catalogVersion: '2', inventoryRowCount: '2' }); + + const bootstrap: Rfc64PublicCatalogBootstrapConfigV1 = { + acceptedPublicPolicies: [accepted], + targets: [{ + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: accepted.policy.era, + }, + providers: [author.peerId], + }], + retryIntervalMs: 1_000, + }; + const receiverDataDir = await mkdtemp(join(tmpdir(), 'dkg-rfc64-native-bootstrap-')); + tempDirs.push(receiverDataDir); + const persistentStorePath = join(receiverDataDir, 'oxigraph'); + const receiver = await startNativeAgent( + 'bootstrap-receiver', + NATIVE_DEPLOYMENT, + receiverDataDir, + undefined, + undefined, + undefined, + bootstrap, + persistentStorePath, + ); + await connectBothWays(author, receiver); + await vi.waitFor(() => { + expect(receiver.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ + outcome: 'applied', + providerPeerId: author.peerId, + appliedHeadDigest: published?.currentCatalogHeadDigest, + catalogVersion: '2', + inventoryRowCount: '2', + }); + }, { timeout: 20_000, interval: 100 }); + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toMatchObject({ + currentCatalogHeadDigest: published?.currentCatalogHeadDigest, + catalogVersion: '2', + inventoryRowCount: '2', + }); + + await receiver.stop(); + agents.splice(agents.indexOf(receiver), 1); + const restarted = await startNativeAgent( + 'bootstrap-receiver', + NATIVE_DEPLOYMENT, + receiverDataDir, + undefined, + undefined, + undefined, + bootstrap, + persistentStorePath, + ); + await connectBothWays(author, restarted); + await vi.waitFor(() => { + expect(restarted.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ + outcome: 'applied', + providerPeerId: author.peerId, + appliedHeadDigest: published?.currentCatalogHeadDigest, + catalogVersion: '2', + inventoryRowCount: '2', + }); + }, { timeout: 20_000, interval: 100 }); + expect(restarted.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toMatchObject({ + currentCatalogHeadDigest: published?.currentCatalogHeadDigest, + catalogVersion: '2', + inventoryRowCount: '2', + }); + }, 60_000); + it('snapshots the explicit access authority and fails closed before private activation', async () => { const resolver = async () => AUTHOR; const callerOwned = { From 1d1b38df43ffd1e14bfbc5ab6645b1599a7636be Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 01:41:37 +0200 Subject: [PATCH 284/292] fix(agent): make first catalog upsert atomic --- .../src/dkg-agent-rfc64-catalog-upsert.ts | 97 +++++++------ ...kg-agent-native-wiring.integration.test.ts | 131 ++++++++++++++++++ 2 files changed, 182 insertions(+), 46 deletions(-) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts b/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts index ad7f1dee72..c67000a0e3 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts @@ -12,7 +12,6 @@ import { parseCanonicalGraphScopedAuthorSealV1, type AuthorCatalogScopeV1, type CatalogSealDeploymentProfileV1, - type CountV1, type Digest32V1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; @@ -30,6 +29,7 @@ import { import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; import { snapshotRfc64PublicCatalogAnnouncementPeersV1 } from './rfc64/catalog-peers-v1.js'; import { computeRfc64AppliedInventoryDigestV1 } from './rfc64/public-catalog-inventory-completeness-v1.js'; +import type { Rfc64PublicCatalogIssuerAuthorizationV1 } from './rfc64/public-catalog-successor-producer-v1.js'; import type { Rfc64PersistenceV1 } from './rfc64/persistence-v1.js'; export interface UpsertConfirmedRfc64PublicRootCatalogAssetParamsV1 { @@ -72,10 +72,14 @@ export class Rfc64CatalogUpsertMethods extends DKGAgentBase { const queueKey = `${catalogScopeDigest}\n${params.scope.authorAddress}`; return this.runSerializedRfc64AuthorCatalogMutationV1(queueKey, async () => { - let current = persistence.inventory.readAppliedCatalogHeadV1( + const current = persistence.inventory.readAppliedCatalogHeadV1( catalogScopeDigest, params.scope.authorAddress, ); + let previousHead: Rfc64StagedAuthorCatalogHeadRefV1; + let catalogIssuerAuthorization: Rfc64PublicCatalogIssuerAuthorizationV1; + let assets: Rfc64CatalogSuccessorAssetInputV1[]; + let expectedCurrentCatalogHeadDigest: Digest32V1 | null; if (current === null) { const genesis = await this.publishAuthorCatalogGenesisV1({ scope: params.scope, @@ -87,63 +91,64 @@ export class Rfc64CatalogUpsertMethods extends DKGAgentBase { catalogIssuerDelegationExpiresAt: params.catalogIssuerDelegationExpiresAt, }); - const emptyInventoryDigest = computeRfc64AppliedInventoryDigestV1({ - catalogScopeDigest, - rows: [], + previousHead = Object.freeze({ + objectDigest: genesis.headObjectDigest, + signatureVariantDigest: genesis.signatureVariantDigest, }); - current = persistence.inventory.compareAndSwapAppliedCatalogHeadV1({ - catalogScopeDigest, - authorAddress: params.scope.authorAddress, - currentCatalogHeadDigest: genesis.headObjectDigest, - appliedInventoryDigest: emptyInventoryDigest, - catalogVersion: genesis.announcement.catalogVersion, - inventoryRowCount: '0' as CountV1, - expectedCurrentCatalogHeadDigest: null, - }).snapshot; - } - - const storedHead = await persistence.controlObjects.getVerifiedObjectByDigest({ - objectDigest: current.currentCatalogHeadDigest, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - }); - if (storedHead === null) { - throw new Error('RFC-64 applied author head is not durably staged'); + catalogIssuerAuthorization = genesis.catalogIssuerAuthorization; + assets = []; + expectedCurrentCatalogHeadDigest = null; + } else { + const storedHead = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: current.currentCatalogHeadDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedHead === null) { + throw new Error('RFC-64 applied author head is not durably staged'); + } + assertSignedAuthorCatalogHeadEnvelopeV1(storedHead.envelope); + previousHead = Object.freeze({ + objectDigest: storedHead.envelope.objectDigest as Digest32V1, + signatureVariantDigest: computeControlSignatureVariantDigestHex( + storedHead.envelope.objectDigest, + storedHead.envelope.signature, + ) as Digest32V1, + }); + const history = await loadBoundedAuthorCatalogHistoryV1(persistence, previousHead); + assets = await loadRfc64CatalogSuccessorAssetsV1(persistence, history); + const storedDelegation = await persistence.controlObjects.getVerifiedObjectByDigest({ + objectDigest: history.previousHead.payload.catalogIssuerDelegationDigest, + verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, + }); + if (storedDelegation === null) { + throw new Error('RFC-64 applied author head delegation is not durably staged'); + } + assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(storedDelegation.envelope); + catalogIssuerAuthorization = Object.freeze({ + catalogIssuerDelegation: storedDelegation.envelope, + parentAuthorAgentEvidence: null, + }); + expectedCurrentCatalogHeadDigest = current.currentCatalogHeadDigest; } - assertSignedAuthorCatalogHeadEnvelopeV1(storedHead.envelope); - const previousHead: Rfc64StagedAuthorCatalogHeadRefV1 = Object.freeze({ - objectDigest: storedHead.envelope.objectDigest as Digest32V1, - signatureVariantDigest: computeControlSignatureVariantDigestHex( - storedHead.envelope.objectDigest, - storedHead.envelope.signature, - ) as Digest32V1, - }); - const history = await loadBoundedAuthorCatalogHistoryV1(persistence, previousHead); - const assets = await loadRfc64CatalogSuccessorAssetsV1(persistence, history); const existingIndex = assets.findIndex( (asset) => asset.seal.reservedKaId === params.asset.seal.reservedKaId, ); if ( existingIndex >= 0 && sameRfc64SuccessorAssetV1(assets[existingIndex]!, params.asset) - ) return current; + ) { + if (current === null) { + throw new Error('RFC-64 staged genesis unexpectedly contains an ordinary asset'); + } + return current; + } if (existingIndex >= 0) assets[existingIndex] = params.asset; else assets.push(params.asset); - const storedDelegation = await persistence.controlObjects.getVerifiedObjectByDigest({ - objectDigest: history.previousHead.payload.catalogIssuerDelegationDigest, - verifyIssuerSignature: verifyControlEnvelopeIssuerSignatureV1, - }); - if (storedDelegation === null) { - throw new Error('RFC-64 applied author head delegation is not durably staged'); - } - assertSignedAuthorCatalogIssuerDelegationEnvelopeV1(storedDelegation.envelope); const successor = await this.publishAuthorCatalogExactSetSuccessorV1({ previousHead, author: params.author, - catalogIssuerAuthorization: Object.freeze({ - catalogIssuerDelegation: storedDelegation.envelope, - parentAuthorAgentEvidence: null, - }), + catalogIssuerAuthorization, assets, deployment: params.deployment, issuedAt: Date.now().toString() as TimestampMsV1, @@ -160,7 +165,7 @@ export class Rfc64CatalogUpsertMethods extends DKGAgentBase { appliedInventoryDigest, catalogVersion: successor.announcement.catalogVersion, inventoryRowCount: successor.signedBucketRowCount, - expectedCurrentCatalogHeadDigest: current.currentCatalogHeadDigest, + expectedCurrentCatalogHeadDigest, }).snapshot; await this.announceRfc64PublicCatalogHeadV1({ announcement: successor.announcement, diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 95130f7a83..165daf261c 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -416,6 +416,137 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); + it('does not expose an empty applied head when first-asset successor staging fails', async () => { + const author = await startNativeAgent( + 'auto-publish-first-asset-retry', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(AUTHOR_WALLET.privateKey); + author.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + const publicQuads = [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + ]; + const params = { + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'first-asset-retry' as never, + publicQuads, + seal: assertionSealFromCanonical(await authorSeal(60n, publicQuads)), + }; + const publishSuccessor = vi.spyOn(author, 'publishAuthorCatalogExactSetSuccessorV1') + .mockRejectedValueOnce(new Error('simulated successor staging failure')); + await expect(author.recordConfirmedRfc64PublicCatalogAssetV1(params)) + .rejects.toThrow('simulated successor staging failure'); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toBeNull(); + + publishSuccessor.mockRestore(); + await expect(author.recordConfirmedRfc64PublicCatalogAssetV1(params)).resolves.toMatchObject({ + catalogVersion: '1', + inventoryRowCount: '1', + }); + }, 60_000); + + it('atomically serializes concurrent first-asset catalog upserts without losing a row', async () => { + const receiver = await startNativeAgent('auto-publish-concurrent-receiver'); + const author = await startNativeAgent( + 'auto-publish-concurrent-author', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [receiver.peerId], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(AUTHOR_WALLET.privateKey); + for (const agent of [author, receiver]) { + agent.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + } + await connectBothWays(author, receiver); + + const publicQuads = [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + ]; + const kaNumbers = [61n, 62n] as const; + const results = await Promise.all(kaNumbers.map(async (kaNumber, index) => ( + author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: `concurrent-confirmed-${index + 1}` as never, + publicQuads, + seal: assertionSealFromCanonical(await authorSeal(kaNumber, publicQuads)), + }) + ))); + expect(results).not.toContain(null); + const first = results.find((result) => result?.catalogVersion === '1'); + const final = results.find((result) => result?.catalogVersion === '2'); + expect(final).toMatchObject({ catalogVersion: '2', inventoryRowCount: '2' }); + if (first === undefined || first === null || final === undefined || final === null) { + throw new Error('concurrent catalog upserts did not produce both successors'); + } + + const scopeDigest = catalogScopeDigest(); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR, + })).toEqual(final); + await vi.waitFor(() => { + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR, + })?.currentCatalogHeadDigest).toBe(final.currentCatalogHeadDigest); + }, { timeout: 20_000, interval: 100 }); + const evidence = receiver.readRfc64PublicCatalogSynchronizationEvidenceV1( + final.currentCatalogHeadDigest, + ); + expect(evidence).toMatchObject({ + inventoryRowCount: 2, + appliedHeadStatus: 'applied', + }); + expect(new Set(evidence?.rows.map(({ kaId }) => kaId))).toEqual(new Set( + kaNumbers.map((kaNumber) => ((BigInt(AUTHOR) << 96n) | kaNumber).toString()), + )); + }, 60_000); + it('serializes mixed-case projection terms in raw UTF-8 order', async () => { const author = await startNativeAgent( 'auto-publish-byte-order', From a1f93be7fbee02a196af6a541729a1ad552d79bc Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 01:46:35 +0200 Subject: [PATCH 285/292] fix(agent): canonicalize RFC-64 publish bridge --- .../dkg-agent-rfc64-catalog-auto-publish.ts | 65 +------------ ...kg-agent-native-wiring.integration.test.ts | 91 +++++++++++++++++++ .../src/canonical-graph-scoped-author-seal.ts | 52 +++++++++++ packages/core/src/cg-shared-projection.ts | 32 +++++++ packages/core/src/index.ts | 1 + 5 files changed, 180 insertions(+), 61 deletions(-) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts b/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts index cf0508284a..761e775dd3 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts @@ -6,12 +6,11 @@ */ import { - GRAPH_KA_CONTENT_SCOPE_VERSION, - assertCanonicalGraphScopedAuthorSealV1, + canonicalGraphScopedAuthorSealFromAssertionSealV1, + encodeCanonicalCgSharedPublicRootProjectionV1, type AssertionCoordinateV1, type AssertionSeal, type AuthorCatalogScopeV1, - type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, type ContextGraphIdV1, type CountV1, @@ -20,7 +19,6 @@ import { type SubGraphNameV1, type TimestampMsV1, } from '@origintrail-official/dkg-core'; -import { serializeWorkspacePublicSnapshotQuads } from '@origintrail-official/dkg-publisher'; import type { Quad } from '@origintrail-official/dkg-storage'; import { ethers } from 'ethers'; @@ -57,7 +55,7 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { const autoPublish = this.config.rfc64PublicCatalogAutoPublish; if (autoPublish === undefined) return null; if (params.subGraphName !== undefined && params.subGraphName !== null) return null; - const seal = canonicalRfc64SealFromAssertionSeal(params.seal); + const seal = canonicalGraphScopedAuthorSealFromAssertionSealV1(params.seal); // V1 deliberately catalogs public-only KA projections. Private-bearing // publishes require the reserved cg-shared-v1 anchor/hash statements to be // present in the author-sealed public projection; ordinary publication does @@ -69,7 +67,7 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { 'RFC-64 auto-publish public projection count differs from the confirmed author seal', ); } - const projectionBytes = canonicalPublicProjectionBytes(params.publicQuads); + const projectionBytes = encodeCanonicalCgSharedPublicRootProjectionV1(params.publicQuads); const networkId = (this.config.rfc64CatalogDeploymentProfile?.networkId ?? this.chain.chainId) as NetworkIdV1; if (networkId === 'none') { @@ -183,58 +181,3 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { } } - -function canonicalRfc64SealFromAssertionSeal( - seal: AssertionSeal, -): Readonly { - if ( - seal.contentScopeVersion !== GRAPH_KA_CONTENT_SCOPE_VERSION - || seal.kaUal === undefined - || seal.assertionVersion === undefined - || seal.publicTripleCount === undefined - || seal.privateTripleCount === undefined - || seal.reservedKaId === undefined - || seal.authorSchemeVersion !== 1 - ) { - throw new Error('RFC-64 auto-publish requires a complete graph-scoped v2 author seal'); - } - const canonical = { - assertionMerkleRoot: ethers.hexlify(seal.merkleRoot).toLowerCase(), - authorAddress: seal.authorAddress.toLowerCase(), - authorAttestationR: ethers.hexlify(seal.authorAttestationR).toLowerCase(), - authorAttestationVS: ethers.hexlify(seal.authorAttestationVS).toLowerCase(), - authorSchemeVersion: '1', - assertedAtChainId: seal.chainId.toString(), - assertedAtKav10Address: seal.kav10Address.toLowerCase(), - reservedKaId: seal.reservedKaId.toString(), - assertionFinalizedAt: seal.finalizedAtIso, - contentScopeVersion: '2', - kaUal: seal.kaUal, - assertionVersion: seal.assertionVersion, - publicTripleCount: seal.publicTripleCount.toString(), - privateTripleCount: seal.privateTripleCount.toString(), - privateMerkleRoot: seal.privateMerkleRoot === undefined - ? null - : ethers.hexlify(seal.privateMerkleRoot).toLowerCase(), - } as unknown as CanonicalGraphScopedAuthorSealV1; - assertCanonicalGraphScopedAuthorSealV1(canonical); - return Object.freeze(canonical); -} - -function canonicalPublicProjectionBytes(quads: readonly Quad[]): Uint8Array { - const encoder = new TextEncoder(); - const lines = quads.map((quad) => { - const line = serializeWorkspacePublicSnapshotQuads([{ ...quad, graph: '' }]); - return Object.freeze({ line, bytes: encoder.encode(line) }); - }); - lines.sort((left, right) => compareBytes(left.bytes, right.bytes)); - return encoder.encode(lines.map(({ line }) => line).join('')); -} - -function compareBytes(left: Uint8Array, right: Uint8Array): number { - const length = Math.min(left.byteLength, right.byteLength); - for (let index = 0; index < length; index += 1) { - if (left[index] !== right[index]) return left[index]! - right[index]!; - } - return left.byteLength - right.byteLength; -} diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 165daf261c..a6d2a7bdcc 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -416,6 +416,97 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); + it('canonicalizes ordinary literal lexical forms before catalog projection verification', async () => { + const author = await startNativeAgent( + 'auto-publish-canonical-literal', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(AUTHOR_WALLET.privateKey); + author.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + const publicQuads = [{ + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"042"^^', + graph: 'urn:ignored-local-graph', + }]; + + await expect(author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'ordinary-noncanonical-literal' as never, + publicQuads, + seal: assertionSealFromCanonical(await authorSeal(13n, publicQuads)), + })).resolves.toMatchObject({ catalogVersion: '1', inventoryRowCount: '1' }); + }, 60_000); + + it('uses the chain signer for a confirmed author when no custodial key is available', async () => { + const author = await startNativeAgent( + 'auto-publish-chain-signer', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(undefined); + const chain = (author as unknown as { + chain: { + signMessageAs?: ( + address: string, + message: Uint8Array, + ) => Promise<{ r: Uint8Array; vs: Uint8Array }>; + }; + }).chain; + const signMessageAs = vi.fn(async (address: string, message: Uint8Array) => { + expect(address).toBe(AUTHOR); + const signature = ethers.Signature.from(await AUTHOR_WALLET.signMessage(message)); + return { + r: ethers.getBytes(signature.r), + vs: ethers.getBytes(signature.yParityAndS), + }; + }); + chain.signMessageAs = signMessageAs; + author.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + + await expect(author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'ordinary-chain-signed-publication' as never, + publicQuads: [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + ], + seal: assertionSealFromCanonical(await authorSeal(14n)), + })).resolves.toMatchObject({ catalogVersion: '1', inventoryRowCount: '1' }); + expect(signMessageAs).toHaveBeenCalled(); + }, 60_000); + it('does not expose an empty applied head when first-asset successor staging fails', async () => { const author = await startNativeAgent( 'auto-publish-first-asset-retry', diff --git a/packages/core/src/canonical-graph-scoped-author-seal.ts b/packages/core/src/canonical-graph-scoped-author-seal.ts index 8481bd3cdd..a472634a16 100644 --- a/packages/core/src/canonical-graph-scoped-author-seal.ts +++ b/packages/core/src/canonical-graph-scoped-author-seal.ts @@ -4,6 +4,7 @@ import { ASSERTION_SEAL_PREDICATES, buildAssertionSealQuads, parseGraphScopedAssertionSealCandidate, + type AssertionSeal, type GraphScopedAssertionSealCandidate, } from './assertion-seal.js'; import { @@ -121,6 +122,48 @@ export interface CanonicalGraphScopedAuthorSealV1 { readonly privateMerkleRoot: Digest32V1 | null; } +/** + * Convert the normal publication seal into the exact RFC-64 wire payload. + * Keeping this at the protocol boundary prevents catalog bridges from owning + * a second, cast-heavy encoding of the signed graph-scoped seal. + */ +export function canonicalGraphScopedAuthorSealFromAssertionSealV1( + seal: AssertionSeal, +): Readonly { + if ( + seal.contentScopeVersion !== GRAPH_KA_CONTENT_SCOPE_VERSION + || seal.kaUal === undefined + || seal.assertionVersion === undefined + || seal.publicTripleCount === undefined + || seal.privateTripleCount === undefined + || seal.reservedKaId === undefined + || seal.authorSchemeVersion !== 1 + ) { + fail('canonical-seal-schema', 'conversion requires a complete graph-scoped v2 author seal'); + } + const canonical: unknown = { + assertionMerkleRoot: bytesToLowerHex32(seal.merkleRoot, 'assertionMerkleRoot'), + authorAddress: seal.authorAddress.toLowerCase(), + authorAttestationR: bytesToLowerHex32(seal.authorAttestationR, 'authorAttestationR'), + authorAttestationVS: bytesToLowerHex32(seal.authorAttestationVS, 'authorAttestationVS'), + authorSchemeVersion: '1', + assertedAtChainId: seal.chainId.toString(), + assertedAtKav10Address: seal.kav10Address.toLowerCase(), + reservedKaId: seal.reservedKaId.toString(), + assertionFinalizedAt: seal.finalizedAtIso, + contentScopeVersion: '2', + kaUal: seal.kaUal, + assertionVersion: seal.assertionVersion, + publicTripleCount: seal.publicTripleCount.toString(), + privateTripleCount: seal.privateTripleCount.toString(), + privateMerkleRoot: seal.privateMerkleRoot === undefined + ? null + : bytesToLowerHex32(seal.privateMerkleRoot, 'privateMerkleRoot'), + }; + assertCanonicalGraphScopedAuthorSealV1(canonical); + return Object.freeze(canonical); +} + /** Authenticated catalog coordinate used to derive subject and physical metadata graph. */ export interface CanonicalGraphScopedAuthorSealCoordinateV1 extends CatalogLaneV1 { readonly contextGraphId: ContextGraphIdV1; @@ -965,6 +1008,15 @@ function hex32ToBytes(value: string): Uint8Array { return bytes; } +function bytesToLowerHex32(bytes: Uint8Array, label: string): string { + if (bytes.byteLength !== 32) { + fail('canonical-seal-scalar', `${label} must contain exactly 32 bytes`); + } + let result = '0x'; + for (const byte of bytes) result += byte.toString(16).padStart(2, '0'); + return result; +} + function bytesToDigest(bytes: Uint8Array): Digest32V1 { let result = '0x'; for (const byte of bytes) result += byte.toString(16).padStart(2, '0'); diff --git a/packages/core/src/cg-shared-projection.ts b/packages/core/src/cg-shared-projection.ts index dbdde3cd77..44412415ec 100644 --- a/packages/core/src/cg-shared-projection.ts +++ b/packages/core/src/cg-shared-projection.ts @@ -77,6 +77,38 @@ export interface CgSharedProjectionVerificationLimitsV1 { readonly maxLineBytes: number; } +export interface CgSharedPublicRootProjectionTripleV1 { + readonly subject: string; + readonly predicate: string; + readonly object: string; +} + +/** + * Encode public-root triples into the exact canonical bytes consumed by the + * cg-shared-v1 verifier: V10-canonical terms, one LF per line, and raw UTF-8 + * byte ordering. Graph placement is deliberately absent from this wire view. + */ +export function encodeCanonicalCgSharedPublicRootProjectionV1( + triples: readonly CgSharedPublicRootProjectionTripleV1[], +): Uint8Array { + const lines = triples.map(({ subject, predicate, object }) => { + const content = tripleContentV10(subject, predicate, object); + const line = new Uint8Array(content.byteLength + 1); + line.set(content); + line[line.byteLength - 1] = 0x0a; + return line; + }); + lines.sort(compareBytes); + const byteLength = lines.reduce((sum, line) => sum + line.byteLength, 0); + const projection = new Uint8Array(byteLength); + let offset = 0; + for (const line of lines) { + projection.set(line, offset); + offset += line.byteLength; + } + return projection; +} + /** * Process-local proof that one structurally verified transferred bundle carries * the exact canonical `cg-shared-v1` projection committed by its author seal. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c8ef6ed1ee..b8b0f2bbfd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -361,6 +361,7 @@ export { MAX_SEAL_TRIPLE_COUNT_V1, CanonicalGraphScopedAuthorSealError, assertCanonicalGraphScopedAuthorSealV1, + canonicalGraphScopedAuthorSealFromAssertionSealV1, canonicalizeCanonicalGraphScopedAuthorSealV1, canonicalizeCanonicalGraphScopedAuthorSealBytesV1, parseCanonicalGraphScopedAuthorSealV1, From 10ba00f6c8eddc8d5ff769d111de38174e77bd79 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 01:49:01 +0200 Subject: [PATCH 286/292] test(agent): prove RFC-64 public release convergence --- ...kg-agent-native-wiring.integration.test.ts | 255 +++++++++++++++++- 1 file changed, 252 insertions(+), 3 deletions(-) diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 4c4102dbf5..9b3f3926fd 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -219,7 +219,9 @@ function privateCatalogPolicy(): ContextGraphPolicyV1 { }; } -function finalizedPublicCatalogPolicy(): ContextGraphPolicyV1 { +function finalizedPublicCatalogPolicy( + publishPolicy: ContextGraphPolicyV1['publishPolicy'] = 1, +): ContextGraphPolicyV1 { return { networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, @@ -230,8 +232,8 @@ function finalizedPublicCatalogPolicy(): ContextGraphPolicyV1 { version: '0', previousPolicyDigest: null, accessPolicy: 0, - publishPolicy: 1, - publishAuthority: null, + publishPolicy, + publishAuthority: publishPolicy === 0 ? AUTHOR : null, publishAuthorityAccountId: '0', projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, administrativeDelegationDigest: null, @@ -1308,6 +1310,253 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { expect(finalizedCallTargets).not.toContain(KAV10); }, 60_000); + it('keeps warm and cold public-curated receivers at one exact finalized head across restart', async () => { + const kaNumbers = [41n] as const; + const assets = kaNumbers.map((kaNumber) => Object.freeze({ + assertionRoot: ASSERTION_ROOT, + assertionVersion: '1', + authorAddress: AUTHOR, + kaId: ((BigInt(AUTHOR) << 96n) | kaNumber).toString(), + publisherAddress: AUTHOR, + })); + const nameHash = ethers.keccak256(ethers.toUtf8Bytes(CONTEXT_GRAPH_ID)).toLowerCase(); + const fixture = Object.freeze({ + accessPolicy: 0, + active: true, + assertedAtChainId: NATIVE_DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10, + knowledgeAssetStorageAddress: KA_STORAGE, + assets: Object.freeze(assets), + blockHash: FINALIZED_BLOCK_HASH, + blockNumberQuantity: '0x7c', + contextGraphStorageAddress: CONTEXT_GRAPH_STORAGE, + nameHash: nameHash as Digest32V1, + networkId: NETWORK_ID, + onChainContextGraphId: ON_CHAIN_CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + publishPolicy: 0, + } satisfies FinalizedVmLoopbackFixtureConfigV1); + const finalizedRpc = createFinalizedVmLoopbackRpcV1(fixture); + const rpc = await rpcHarness.start((call, response) => { + try { + sendJsonRpcResult(response, call, finalizedRpc.respond(call.method, call.params)); + } catch (cause) { + sendJsonRpcError( + response, + call, + -32602, + cause instanceof Error ? cause.message : String(cause), + ); + } + }); + const policy = finalizedPublicCatalogPolicy(0); + const warmDataDir = await mkdtemp(join(tmpdir(), 'dkg-rfc64-release-warm-')); + const coldDataDir = await mkdtemp(join(tmpdir(), 'dkg-rfc64-release-cold-')); + tempDirs.push(warmDataDir, coldDataDir); + const warmStorePath = join(warmDataDir, 'oxigraph'); + const coldStorePath = join(coldDataDir, 'oxigraph'); + const startReleaseReceiver = async ( + name: string, + dataDir: string, + storePath: string, + bootstrapConfig?: Rfc64PublicCatalogBootstrapConfigV1, + ): Promise => { + const chainAdapter = new FinalizedVmLoopbackMockChainAdapterV1(fixture); + await chainAdapter.createOnChainContextGraph({ + accessPolicy: 0, + publishPolicy: 0, + nameHash, + }); + return startNativeAgent( + name, + NATIVE_DEPLOYMENT, + dataDir, + undefined, + { + rpcUrl: rpc.url, + chainAdapter, + initialSubscription: CONTEXT_GRAPH_ID, + }, + undefined, + bootstrapConfig, + storePath, + ); + }; + + // The warm receiver exists before the publisher and before any catalog head. + let warm = await startReleaseReceiver('release-proof-warm', warmDataDir, warmStorePath); + warm.acceptRfc64CatalogAccessSnapshotV1({ + policy, + policyDigest: FINALIZED_POLICY_DIGEST, + roster: null, + }); + const author = await startNativeAgent( + 'release-proof-author', + NATIVE_DEPLOYMENT, + undefined, + undefined, + { + rpcUrl: rpc.url, + chainAdapter: new FinalizedVmLoopbackMockChainAdapterV1(fixture), + }, + { + peers: [warm.peerId], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue( + AUTHOR_WALLET.privateKey, + ); + author.acceptRfc64CatalogAccessSnapshotV1({ + policy, + policyDigest: FINALIZED_POLICY_DIGEST, + roster: null, + }); + const bootstrap: Rfc64PublicCatalogBootstrapConfigV1 = { + acceptedPublicPolicies: [{ policy, policyDigest: FINALIZED_POLICY_DIGEST }], + targets: [{ + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: policy.era, + }, + providers: [author.peerId], + }], + retryIntervalMs: 1_000, + }; + await connectBothWays(author, warm); + await warm.awaitInitialChainPoll(); + + const scope = { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: '20430', + governanceContractAddress: CONTEXT_GRAPH_STORAGE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: '0', + bucketCount: '1', + } as const; + const publicQuads = [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + ]; + const published = await author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'release-proof-1' as never, + publicQuads, + seal: assertionSealFromCanonical(await authorSeal(kaNumbers[0], publicQuads)), + }); + if (published === null) throw new Error('release proof catalog bridge was not enabled'); + + const scopeDigest = computeAuthorCatalogScopeDigestV1(scope); + const expectExactReleaseState = async (receiver: DKGAgent): Promise => { + expect(receiver.readRfc64PublicCatalogReconciliationFailureV1( + published.currentCatalogHeadDigest, + )).toBeNull(); + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ + currentCatalogHeadDigest: published.currentCatalogHeadDigest, + catalogVersion: published.catalogVersion, + inventoryRowCount: '1', + }); + for (const kaNumber of kaNumbers) { + const vmGraph = contextGraphLayerUri( + CONTEXT_GRAPH_ID, + MemoryLayer.VerifiableMemory, + AUTHOR, + Number(kaNumber), + ); + await expect((receiver as any).store.query( + `SELECT ?s ?p ?o WHERE { GRAPH <${vmGraph}> { ?s ?p ?o } }`, + )).resolves.toMatchObject({ + type: 'bindings', + bindings: expect.arrayContaining([ + expect.objectContaining({ s: 'https://example.org/alice' }), + ]), + }); + } + }; + await vi.waitFor(() => { + expect(warm.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: scopeDigest, + authorAddress: AUTHOR, + })?.currentCatalogHeadDigest).toBe(published.currentCatalogHeadDigest); + }, { timeout: 20_000, interval: 100 }); + await expectExactReleaseState(warm); + + // The cold receiver starts only after the finalized head exists. + let cold = await startReleaseReceiver( + 'release-proof-cold', + coldDataDir, + coldStorePath, + bootstrap, + ); + await connectBothWays(author, cold); + await cold.awaitInitialChainPoll(); + await vi.waitFor(() => { + expect(cold.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ + outcome: 'applied', + providerPeerId: author.peerId, + appliedHeadDigest: published.currentCatalogHeadDigest, + inventoryRowCount: '1', + }); + }, { timeout: 20_000, interval: 100 }); + await expectExactReleaseState(cold); + + // Both receiver roles keep their exact durable SWM head and VM graphs after restart. + for (const receiver of [warm, cold]) { + await receiver.stop(); + agents.splice(agents.indexOf(receiver), 1); + } + warm = await startReleaseReceiver( + 'release-proof-warm', + warmDataDir, + warmStorePath, + bootstrap, + ); + cold = await startReleaseReceiver( + 'release-proof-cold', + coldDataDir, + coldStorePath, + bootstrap, + ); + await Promise.all([ + connectBothWays(author, warm), + connectBothWays(author, cold), + ]); + await Promise.all([warm.awaitInitialChainPoll(), cold.awaitInitialChainPoll()]); + await vi.waitFor(() => { + for (const receiver of [warm, cold]) { + expect(receiver.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ + outcome: 'applied', + providerPeerId: author.peerId, + appliedHeadDigest: published.currentCatalogHeadDigest, + inventoryRowCount: '1', + }); + } + }, { timeout: 20_000, interval: 100 }); + await Promise.all([ + expectExactReleaseState(warm), + expectExactReleaseState(cold), + ]); + }, 90_000); + it('exposes bounded typed failure evidence when wire scope differs from local deployment', async () => { const [author, receiver] = await Promise.all([ startNativeAgent('mismatch-author'), From d981fcb1be8ed5098ced89ac34fd9aa461e0de46 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 01:57:33 +0200 Subject: [PATCH 287/292] fix(agent): bind RFC-64 bootstrap manifests --- .../src/dkg-agent-rfc64-catalog-bootstrap.ts | 52 ++-- packages/agent/src/dkg-agent-types.ts | 16 +- .../src/rfc64/catalog-authority-config-v1.ts | 135 ++++------ ...kg-agent-native-wiring.integration.test.ts | 255 ++++++++++++++---- 4 files changed, 299 insertions(+), 159 deletions(-) diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-bootstrap.ts b/packages/agent/src/dkg-agent-rfc64-catalog-bootstrap.ts index 8677af11fd..fc65572e56 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog-bootstrap.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog-bootstrap.ts @@ -3,6 +3,7 @@ /** Restart-safe, operator-pinned public catalog cold-start supervisor. */ import { + computeContextGraphPolicyObjectDigestV1, type ContextGraphIdV1, type Digest32V1, type EvmAddressV1, @@ -16,6 +17,7 @@ import type { Rfc64PublicCatalogBootstrapConfigV1, Rfc64PublicCatalogBootstrapScopeV1, } from './dkg-agent-types.js'; +import { mapWithConcurrency } from './map-with-concurrency.js'; const MAX_STATUS_ERROR_BYTES_V1 = 1024; const MAX_CONCURRENT_TARGETS_V1 = 4; @@ -89,13 +91,25 @@ export class Rfc64CatalogBootstrapMethods extends DKGAgentBase { } for (const accepted of config.acceptedPublicPolicies) { service.acceptPolicySnapshot({ - policy: accepted.policy, - policyDigest: accepted.policyDigest, + policy: accepted.policyEnvelope.payload, + policyDigest: computeContextGraphPolicyObjectDigestV1(accepted.policyEnvelope), }); } + const targets = config.acceptedPublicPolicies.flatMap(({ policyEnvelope, targets }) => ( + targets.map((target) => ({ + scope: Object.freeze({ + networkId: policyEnvelope.payload.networkId, + contextGraphId: policyEnvelope.payload.contextGraphId, + subGraphName: null, + authorAddress: target.authorAddress, + catalogEra: policyEnvelope.payload.era, + }), + providers: target.providers, + })) + )); const state: BootstrapStateV1 = { config, - targets: config.targets.map((target) => ({ + targets: targets.map((target) => ({ scope: target.scope, providers: target.providers, outcome: 'pending', @@ -197,24 +211,18 @@ export class Rfc64CatalogBootstrapMethods extends DKGAgentBase { state.lastPassStartedAtMs = Date.now(); const abortController = new AbortController(); state.abortController = abortController; - let cursor = 0; - const workers = Array.from( - { length: Math.min(MAX_CONCURRENT_TARGETS_V1, state.targets.length) }, - async () => { - while (!state.closed) { - const index = cursor; - cursor += 1; - const target = state.targets[index]; - if (target === undefined) return; + try { + await mapWithConcurrency( + state.targets, + MAX_CONCURRENT_TARGETS_V1, + async (target) => { + if (state.closed) return; await this.synchronizeRfc64PublicCatalogBootstrapTargetV1( target, abortController.signal, ); - } - }, - ); - try { - await Promise.all(workers); + }, + ); } finally { if (state.abortController === abortController) state.abortController = null; state.running = false; @@ -227,6 +235,13 @@ export class Rfc64CatalogBootstrapMethods extends DKGAgentBase { target: MutableTargetStatusV1, signal: AbortSignal, ): Promise { + target.outcome = 'pending'; + target.attempts = 0; + target.providerPeerId = null; + target.appliedHeadDigest = null; + target.catalogVersion = null; + target.inventoryRowCount = null; + target.lastError = null; let sawNotFound = false; let lastError: string | null = null; for (const providerPeerId of target.providers) { @@ -257,6 +272,9 @@ export class Rfc64CatalogBootstrapMethods extends DKGAgentBase { } target.outcome = lastError === null && sawNotFound ? 'not-found' : 'failed'; target.providerPeerId = null; + target.appliedHeadDigest = null; + target.catalogVersion = null; + target.inventoryRowCount = null; target.lastError = lastError; target.updatedAtMs = Date.now(); } diff --git a/packages/agent/src/dkg-agent-types.ts b/packages/agent/src/dkg-agent-types.ts index 8206af88be..154bd7f3ed 100644 --- a/packages/agent/src/dkg-agent-types.ts +++ b/packages/agent/src/dkg-agent-types.ts @@ -39,6 +39,7 @@ import type { NetworkIdV1, SubGraphNameV1, TimestampMsV1, + UnsignedContextGraphPolicyEnvelopeV1, } from '@origintrail-official/dkg-core'; import type { PhaseCallback, @@ -1086,11 +1087,18 @@ export interface Rfc64PublicCatalogBootstrapScopeV1 { } export interface Rfc64PublicCatalogBootstrapTargetV1 { - readonly scope: Rfc64PublicCatalogBootstrapScopeV1; + readonly authorAddress: EvmAddressV1; /** Ordered provider failover candidates for this exact author catalog. */ readonly providers: readonly string[]; } +export interface Rfc64PublicCatalogBootstrapPolicyV1 { + /** Exact verified control object; its digest is recomputed during snapshotting. */ + readonly policyEnvelope: UnsignedContextGraphPolicyEnvelopeV1; + /** Author catalogs under the policy graph/era. */ + readonly targets: readonly Rfc64PublicCatalogBootstrapTargetV1[]; +} + /** * Explicit V1 cold-start manifest. Policies are operator-pinned outputs of an * independent finality/administrative verifier; the catalog lane only consumes @@ -1098,11 +1106,7 @@ export interface Rfc64PublicCatalogBootstrapTargetV1 { * deterministic harnesses. Omission defaults to a 30-second refresh pass. */ export interface Rfc64PublicCatalogBootstrapConfigV1 { - readonly acceptedPublicPolicies: readonly Readonly<{ - readonly policy: ContextGraphPolicyV1; - readonly policyDigest: Digest32V1; - }>[]; - readonly targets: readonly Rfc64PublicCatalogBootstrapTargetV1[]; + readonly acceptedPublicPolicies: readonly Rfc64PublicCatalogBootstrapPolicyV1[]; readonly retryIntervalMs?: number; } diff --git a/packages/agent/src/rfc64/catalog-authority-config-v1.ts b/packages/agent/src/rfc64/catalog-authority-config-v1.ts index 002f2fbf49..85f4fd0d7c 100644 --- a/packages/agent/src/rfc64/catalog-authority-config-v1.ts +++ b/packages/agent/src/rfc64/catalog-authority-config-v1.ts @@ -2,15 +2,10 @@ import { assertCanonicalChainId, - assertCanonicalDecimalU64, - assertCanonicalDigest, assertCanonicalEvmAddress, - assertContextGraphIdV1, assertNetworkIdV1, - canonicalizeContextGraphPolicyPayloadV1, - parseCanonicalContextGraphPolicyPayloadV1, - type ContextGraphPolicyV1, - type Digest32V1, + canonicalizeUnsignedContextGraphPolicyEnvelopeBytesV1, + parseCanonicalUnsignedContextGraphPolicyEnvelopeV1, type CatalogSealDeploymentProfileV1, type EvmAddressV1, type TimestampMsV1, @@ -21,7 +16,6 @@ import type { Rfc64CatalogAccessPolicyAuthorityConfigV1, Rfc64PublicCatalogAutoPublishConfigV1, Rfc64PublicCatalogBootstrapConfigV1, - Rfc64PublicCatalogBootstrapScopeV1, Rfc64PublicCatalogBootstrapTargetV1, } from '../dkg-agent-types.js'; import { snapshotRfc64PublicCatalogAnnouncementPeersV1 } from './catalog-peers-v1.js'; @@ -167,8 +161,8 @@ export function snapshotRfc64PublicCatalogBootstrapConfigV1( assertPlainExactObject( input, 'rfc64PublicCatalogBootstrap', - ['acceptedPublicPolicies', 'retryIntervalMs', 'targets'], - ['acceptedPublicPolicies', 'targets'], + ['acceptedPublicPolicies', 'retryIntervalMs'], + ['acceptedPublicPolicies'], ); if ( !Array.isArray(input.acceptedPublicPolicies) @@ -179,71 +173,70 @@ export function snapshotRfc64PublicCatalogBootstrapConfigV1( + `${MAX_RFC64_BOOTSTRAP_POLICIES_V1} policies`, ); } + const policyKeys = new Set(); + const targetKeys = new Set(); + let totalTargets = 0; const policies = input.acceptedPublicPolicies.map((entry, index) => { assertPlainExactObject( entry, `rfc64PublicCatalogBootstrap.acceptedPublicPolicies[${index}]`, - ['policy', 'policyDigest'], - ['policy', 'policyDigest'], + ['policyEnvelope', 'targets'], + ['policyEnvelope', 'targets'], ); - const candidate = entry as { - readonly policy: ContextGraphPolicyV1; - readonly policyDigest: Digest32V1; + const candidate = entry as unknown as { + readonly policyEnvelope: Rfc64PublicCatalogBootstrapConfigV1[ + 'acceptedPublicPolicies' + ][number]['policyEnvelope']; + readonly targets: readonly Rfc64PublicCatalogBootstrapTargetV1[]; }; - const policy = parseCanonicalContextGraphPolicyPayloadV1( - canonicalizeContextGraphPolicyPayloadV1(candidate.policy), + const policyEnvelope = parseCanonicalUnsignedContextGraphPolicyEnvelopeV1( + canonicalizeUnsignedContextGraphPolicyEnvelopeBytesV1(candidate.policyEnvelope), ); + const policy = policyEnvelope.payload; if (policy.accessPolicy !== 0) { throw new TypeError('rfc64PublicCatalogBootstrap accepts public policies only'); } - assertCanonicalDigest(candidate.policyDigest, `acceptedPublicPolicies[${index}].policyDigest`); - return Object.freeze({ - policy: deepFreezePlain(policy), - policyDigest: candidate.policyDigest, - }); - }); - const policyKeys = new Set(); - for (const { policy } of policies) { const key = `${policy.networkId}\n${policy.contextGraphId}`; if (policyKeys.has(key)) { throw new TypeError('rfc64PublicCatalogBootstrap policies must be unique by graph'); } policyKeys.add(key); - } - - if (!Array.isArray(input.targets) || input.targets.length > MAX_RFC64_BOOTSTRAP_TARGETS_V1) { - throw new TypeError( - `rfc64PublicCatalogBootstrap.targets must contain at most ` - + `${MAX_RFC64_BOOTSTRAP_TARGETS_V1} catalogs`, - ); - } - const targetKeys = new Set(); - const targets = input.targets.map((target, index) => { - assertPlainExactObject( - target, - `rfc64PublicCatalogBootstrap.targets[${index}]`, - ['providers', 'scope'], - ['providers', 'scope'], - ); - const candidate = target as unknown as Rfc64PublicCatalogBootstrapTargetV1; - const scope = snapshotBootstrapScope(candidate.scope, index); - const policy = policies.find((candidate) => ( - candidate.policy.networkId === scope.networkId - && candidate.policy.contextGraphId === scope.contextGraphId - )); - if (policy === undefined || policy.policy.era !== scope.catalogEra) { + if (!Array.isArray(candidate.targets)) { throw new TypeError( - 'rfc64PublicCatalogBootstrap target has no matching accepted policy era', + `rfc64PublicCatalogBootstrap.acceptedPublicPolicies[${index}].targets must be an array`, ); } - const providers = snapshotBootstrapProviders(candidate.providers, index); - const key = `${scope.networkId}\n${scope.contextGraphId}\n${scope.authorAddress}` - + `\n${scope.catalogEra}`; - if (targetKeys.has(key)) { - throw new TypeError('rfc64PublicCatalogBootstrap targets must be unique by author scope'); + totalTargets += candidate.targets.length; + if (totalTargets > MAX_RFC64_BOOTSTRAP_TARGETS_V1) { + throw new TypeError( + `rfc64PublicCatalogBootstrap targets must contain at most ` + + `${MAX_RFC64_BOOTSTRAP_TARGETS_V1} catalogs`, + ); } - targetKeys.add(key); - return Object.freeze({ scope, providers }); + const targets = candidate.targets.map((target, targetIndex) => { + assertPlainExactObject( + target, + `rfc64PublicCatalogBootstrap.acceptedPublicPolicies[${index}].targets[${targetIndex}]`, + ['authorAddress', 'providers'], + ['authorAddress', 'providers'], + ); + const targetCandidate = target as unknown as Rfc64PublicCatalogBootstrapTargetV1; + assertCanonicalEvmAddress(targetCandidate.authorAddress, 'bootstrap target authorAddress'); + if (targetCandidate.authorAddress === `0x${'0'.repeat(40)}`) { + throw new TypeError('rfc64PublicCatalogBootstrap authorAddress must be nonzero'); + } + const providers = snapshotBootstrapProviders(targetCandidate.providers, targetIndex); + const targetKey = `${key}\n${targetCandidate.authorAddress}\n${policy.era}`; + if (targetKeys.has(targetKey)) { + throw new TypeError('rfc64PublicCatalogBootstrap targets must be unique by author scope'); + } + targetKeys.add(targetKey); + return Object.freeze({ authorAddress: targetCandidate.authorAddress, providers }); + }); + return Object.freeze({ + policyEnvelope: deepFreezePlain(policyEnvelope), + targets: Object.freeze(targets), + }); }); const retryIntervalMs = input.retryIntervalMs @@ -260,7 +253,6 @@ export function snapshotRfc64PublicCatalogBootstrapConfigV1( } return Object.freeze({ acceptedPublicPolicies: Object.freeze(policies), - targets: Object.freeze(targets), retryIntervalMs, }); } @@ -275,35 +267,6 @@ function snapshotTimestamp(value: unknown, label: string): TimestampMsV1 { return value as TimestampMsV1; } -function snapshotBootstrapScope( - input: Rfc64PublicCatalogBootstrapScopeV1, - index: number, -): Readonly { - assertPlainExactObject( - input, - `rfc64PublicCatalogBootstrap.targets[${index}].scope`, - ['authorAddress', 'catalogEra', 'contextGraphId', 'networkId', 'subGraphName'], - ['authorAddress', 'catalogEra', 'contextGraphId', 'networkId', 'subGraphName'], - ); - assertNetworkIdV1(input.networkId, 'bootstrap scope networkId'); - assertContextGraphIdV1(input.contextGraphId, 'bootstrap scope contextGraphId'); - if (input.subGraphName !== null) { - throw new TypeError('rfc64PublicCatalogBootstrap V1 targets public root catalogs only'); - } - assertCanonicalEvmAddress(input.authorAddress, 'bootstrap scope authorAddress'); - if (input.authorAddress === `0x${'0'.repeat(40)}`) { - throw new TypeError('rfc64PublicCatalogBootstrap authorAddress must be nonzero'); - } - assertCanonicalDecimalU64(input.catalogEra, 'bootstrap scope catalogEra'); - return Object.freeze({ - networkId: input.networkId, - contextGraphId: input.contextGraphId, - subGraphName: null, - authorAddress: input.authorAddress, - catalogEra: input.catalogEra, - }); -} - function snapshotBootstrapProviders(input: readonly string[], targetIndex: number): readonly string[] { const providers = snapshotRfc64PublicCatalogAnnouncementPeersV1(input); if ( diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 600bf1ce99..a193633a2a 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -35,7 +35,7 @@ import { } from '../src/dkg-agent-rfc64-catalog.js'; import { buildOpenOwnerContextGraphPolicyV1, - computeOpenContextGraphPolicyDigestV1, + unsignedOpenContextGraphPolicyEnvelopeV1, } from '../src/rfc64/open-catalog-policy-v1.js'; import type { ContextGraphSubscriptionRecord, @@ -108,9 +108,45 @@ async function startNativeAgent( initialSubscription?: ContextGraphIdV1; }>, autoPublish?: Rfc64PublicCatalogAutoPublishConfigV1, - bootstrap?: Rfc64PublicCatalogBootstrapConfigV1, - persistentStorePath?: string, ): Promise { + return startNativeAgentWithOptions({ + name, + deployment, + existingDataDir, + accessPolicyAuthority, + finalizedRuntime, + autoPublish, + }); +} + +interface NativeAgentStartOptionsV1 { + readonly name: string; + readonly deployment?: CatalogSealDeploymentProfileV1; + readonly existingDataDir?: string; + readonly accessPolicyAuthority?: Rfc64CatalogAccessPolicyAuthorityConfigV1; + readonly finalizedRuntime?: Readonly<{ + rpcUrl: string; + chainAdapter: FinalizedVmLoopbackMockChainAdapterV1; + initialSubscription?: ContextGraphIdV1; + }>; + readonly autoPublish?: Rfc64PublicCatalogAutoPublishConfigV1; + readonly bootstrap?: Rfc64PublicCatalogBootstrapConfigV1; + readonly persistentStorePath?: string; +} + +async function startNativeAgentWithOptions( + options: NativeAgentStartOptionsV1, +): Promise { + const { + name, + deployment = NATIVE_DEPLOYMENT, + existingDataDir, + accessPolicyAuthority, + finalizedRuntime, + autoPublish, + bootstrap, + persistentStorePath, + } = options; const dataDir = existingDataDir ?? await mkdtemp(join(tmpdir(), `dkg-rfc64-native-${name}-`)); if (existingDataDir === undefined) tempDirs.push(dataDir); @@ -309,38 +345,32 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { contextGraphId: CONTEXT_GRAPH_ID, ownerAddress: AUTHOR, }); - const policyDigest = computeOpenContextGraphPolicyDigestV1(policy); + const policyEnvelope = unsignedOpenContextGraphPolicyEnvelopeV1(policy); const providers = ['12D3KooPrimary']; const callerOwned: Rfc64PublicCatalogBootstrapConfigV1 = { - acceptedPublicPolicies: [{ policy, policyDigest }], - targets: [{ - scope: { - networkId: NETWORK_ID, - contextGraphId: CONTEXT_GRAPH_ID, - subGraphName: null, - authorAddress: AUTHOR, - catalogEra: policy.era, - }, - providers, + acceptedPublicPolicies: [{ + policyEnvelope, + targets: [{ authorAddress: AUTHOR, providers }], }], retryIntervalMs: 1_000, }; const snapshot = snapshotRfc64PublicCatalogBootstrapConfigV1(callerOwned)!; providers.push('12D3KooLateMutation'); - expect(snapshot.targets[0]?.providers).toEqual(['12D3KooPrimary']); - expect(snapshot.acceptedPublicPolicies[0]).toEqual({ policy, policyDigest }); + expect(snapshot.acceptedPublicPolicies[0]?.targets[0]?.providers) + .toEqual(['12D3KooPrimary']); + expect(snapshot.acceptedPublicPolicies[0]?.policyEnvelope.payload).toEqual(policy); expect(Object.isFrozen(snapshot)).toBe(true); - expect(Object.isFrozen(snapshot.targets)).toBe(true); - expect(Object.isFrozen(snapshot.targets[0]?.providers)).toBe(true); - expect(Object.isFrozen(snapshot.acceptedPublicPolicies[0]?.policy.source)).toBe(true); + expect(Object.isFrozen(snapshot.acceptedPublicPolicies[0]?.targets)).toBe(true); + expect(Object.isFrozen(snapshot.acceptedPublicPolicies[0]?.targets[0]?.providers)).toBe(true); + expect(Object.isFrozen(snapshot.acceptedPublicPolicies[0]?.policyEnvelope.payload.source)) + .toBe(true); expect(() => snapshotRfc64PublicCatalogBootstrapConfigV1({ - ...callerOwned, - targets: [{ - ...callerOwned.targets[0]!, - scope: { ...callerOwned.targets[0]!.scope, subGraphName: 'private' }, + acceptedPublicPolicies: [{ + ...callerOwned.acceptedPublicPolicies[0]!, + policyDigest: `0x${'11'.repeat(32)}`, }], - })).toThrow(/public root catalogs only/u); + } as unknown as Rfc64PublicCatalogBootstrapConfigV1)).toThrow(/unknown or missing fields/u); }); it('turns one confirmed public KA into the provider current head and one cold receiver apply', async () => { @@ -733,32 +763,21 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { expect(published).toMatchObject({ catalogVersion: '2', inventoryRowCount: '2' }); const bootstrap: Rfc64PublicCatalogBootstrapConfigV1 = { - acceptedPublicPolicies: [accepted], - targets: [{ - scope: { - networkId: NETWORK_ID, - contextGraphId: CONTEXT_GRAPH_ID, - subGraphName: null, - authorAddress: AUTHOR, - catalogEra: accepted.policy.era, - }, - providers: [author.peerId], + acceptedPublicPolicies: [{ + policyEnvelope: unsignedOpenContextGraphPolicyEnvelopeV1(accepted.policy), + targets: [{ authorAddress: AUTHOR, providers: [author.peerId] }], }], retryIntervalMs: 1_000, }; const receiverDataDir = await mkdtemp(join(tmpdir(), 'dkg-rfc64-native-bootstrap-')); tempDirs.push(receiverDataDir); const persistentStorePath = join(receiverDataDir, 'oxigraph'); - const receiver = await startNativeAgent( - 'bootstrap-receiver', - NATIVE_DEPLOYMENT, - receiverDataDir, - undefined, - undefined, - undefined, + const receiver = await startNativeAgentWithOptions({ + name: 'bootstrap-receiver', + existingDataDir: receiverDataDir, bootstrap, persistentStorePath, - ); + }); await connectBothWays(author, receiver); await vi.waitFor(() => { expect(receiver.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ @@ -780,16 +799,12 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { await receiver.stop(); agents.splice(agents.indexOf(receiver), 1); - const restarted = await startNativeAgent( - 'bootstrap-receiver', - NATIVE_DEPLOYMENT, - receiverDataDir, - undefined, - undefined, - undefined, + const restarted = await startNativeAgentWithOptions({ + name: 'bootstrap-receiver', + existingDataDir: receiverDataDir, bootstrap, persistentStorePath, - ); + }); await connectBothWays(author, restarted); await vi.waitFor(() => { expect(restarted.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ @@ -810,6 +825,146 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }); }, 60_000); + it('retries an initial miss and fails over to the later provider', async () => { + const emptyProvider = await startNativeAgent('bootstrap-empty-provider'); + const author = await startNativeAgent( + 'bootstrap-retry-author', + NATIVE_DEPLOYMENT, + undefined, + undefined, + undefined, + { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + ); + vi.spyOn(author, 'getCustodialAgentPrivateKey').mockReturnValue(AUTHOR_WALLET.privateKey); + const accepted = author.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + emptyProvider.acceptOpenContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + const bootstrap: Rfc64PublicCatalogBootstrapConfigV1 = { + acceptedPublicPolicies: [{ + policyEnvelope: unsignedOpenContextGraphPolicyEnvelopeV1(accepted.policy), + targets: [{ + authorAddress: AUTHOR, + providers: [emptyProvider.peerId, author.peerId], + }], + }], + retryIntervalMs: 1_000, + }; + const receiver = await startNativeAgentWithOptions({ + name: 'bootstrap-retry-receiver', + bootstrap, + }); + await Promise.all([ + connectBothWays(emptyProvider, receiver), + connectBothWays(author, receiver), + ]); + await vi.waitFor(() => { + expect(receiver.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ + outcome: 'not-found', + attempts: 2, + providerPeerId: null, + appliedHeadDigest: null, + }); + }, { timeout: 20_000, interval: 100 }); + + const publicQuads = [ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + graph: '', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + graph: '', + }, + ]; + const published = await author.recordConfirmedRfc64PublicCatalogAssetV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: 'bootstrap-retry-publication' as never, + publicQuads, + seal: assertionSealFromCanonical(await authorSeal(23n, publicQuads)), + }); + await vi.waitFor(() => { + expect(receiver.readRfc64PublicCatalogBootstrapStatusV1()).toMatchObject({ + pass: expect.any(Number), + targets: [expect.objectContaining({ + outcome: 'applied', + attempts: 2, + providerPeerId: author.peerId, + appliedHeadDigest: published?.currentCatalogHeadDigest, + inventoryRowCount: '1', + })], + }); + }, { timeout: 20_000, interval: 100 }); + expect(receiver.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })?.currentCatalogHeadDigest).toBe(published?.currentCatalogHeadDigest); + }, 60_000); + + it('clears applied metadata when a later bootstrap refresh misses', async () => { + const policy = buildOpenOwnerContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + const appliedHeadDigest = `0x${'a1'.repeat(32)}` as Digest32V1; + const synchronize = vi.spyOn( + DKGAgent.prototype, + 'synchronizeRfc64PublicCatalogFromProviderV1', + ).mockResolvedValueOnce({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + currentCatalogHeadDigest: appliedHeadDigest, + appliedInventoryDigest: `0x${'b2'.repeat(32)}` as Digest32V1, + catalogVersion: '2' as never, + inventoryRowCount: '3' as never, + }).mockResolvedValue(null); + const bootstrap: Rfc64PublicCatalogBootstrapConfigV1 = { + acceptedPublicPolicies: [{ + policyEnvelope: unsignedOpenContextGraphPolicyEnvelopeV1(policy), + targets: [{ authorAddress: AUTHOR, providers: ['12D3KooStatusProvider'] }], + }], + retryIntervalMs: 1_000, + }; + const receiver = await startNativeAgentWithOptions({ + name: 'bootstrap-status-reset', + bootstrap, + }); + await vi.waitFor(() => { + expect(receiver.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ + outcome: 'applied', + appliedHeadDigest, + catalogVersion: '2', + inventoryRowCount: '3', + }); + }, { timeout: 10_000, interval: 50 }); + await vi.waitFor(() => { + expect(receiver.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ + outcome: 'not-found', + attempts: 1, + providerPeerId: null, + appliedHeadDigest: null, + catalogVersion: null, + inventoryRowCount: null, + }); + }, { timeout: 10_000, interval: 50 }); + expect(synchronize).toHaveBeenCalledTimes(2); + synchronize.mockRestore(); + }, 30_000); + it('serializes mixed-case projection terms in raw UTF-8 order', async () => { const author = await startNativeAgent( From 023e39d06084f049e3c182c68c32893d911ff11d Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 01:59:58 +0200 Subject: [PATCH 288/292] test(agent): bind release proof bootstrap policy --- ...kg-agent-native-wiring.integration.test.ts | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 9ad718cbab..ab900ab08b 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -4,11 +4,13 @@ import { join } from 'node:path'; import { multiaddr } from '@multiformats/multiaddr'; import { + CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, MemoryLayer, assertCanonicalGraphScopedAuthorSealV1, buildAuthorAttestationTypedData, computeAuthorCatalogScopeDigestV1, + computeContextGraphPolicyObjectDigestV1, contextGraphLayerUri, type CanonicalGraphScopedAuthorSealV1, type CatalogSealDeploymentProfileV1, @@ -20,6 +22,7 @@ import { type MemberRosterV1, type NetworkIdV1, type TimestampMsV1, + type UnsignedContextGraphPolicyEnvelopeV1, } from '@origintrail-official/dkg-core'; import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; import { computeFlatKCRootV10 } from '@origintrail-official/dkg-publisher'; @@ -1743,27 +1746,32 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { publishPolicy: 0, nameHash, }); - return startNativeAgent( + return startNativeAgentWithOptions({ name, - NATIVE_DEPLOYMENT, - dataDir, - undefined, - { + existingDataDir: dataDir, + finalizedRuntime: { rpcUrl: rpc.url, chainAdapter, initialSubscription: CONTEXT_GRAPH_ID, }, - undefined, - bootstrapConfig, - storePath, - ); + bootstrap: bootstrapConfig, + persistentStorePath: storePath, + }); }; // The warm receiver exists before the publisher and before any catalog head. let warm = await startReleaseReceiver('release-proof-warm', warmDataDir, warmStorePath); + const policyEnvelope = { + issuer: CONTEXT_GRAPH_STORAGE, + objectType: CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + payload: policy, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedContextGraphPolicyEnvelopeV1; + const policyDigest = computeContextGraphPolicyObjectDigestV1(policyEnvelope); warm.acceptRfc64CatalogAccessSnapshotV1({ policy, - policyDigest: FINALIZED_POLICY_DIGEST, + policyDigest, roster: null, }); const author = await startNativeAgent( @@ -1785,20 +1793,13 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { ); author.acceptRfc64CatalogAccessSnapshotV1({ policy, - policyDigest: FINALIZED_POLICY_DIGEST, + policyDigest, roster: null, }); const bootstrap: Rfc64PublicCatalogBootstrapConfigV1 = { - acceptedPublicPolicies: [{ policy, policyDigest: FINALIZED_POLICY_DIGEST }], - targets: [{ - scope: { - networkId: NETWORK_ID, - contextGraphId: CONTEXT_GRAPH_ID, - subGraphName: null, - authorAddress: AUTHOR, - catalogEra: policy.era, - }, - providers: [author.peerId], + acceptedPublicPolicies: [{ + policyEnvelope, + targets: [{ authorAddress: AUTHOR, providers: [author.peerId] }], }], retryIntervalMs: 1_000, }; From ab103825f92dfeaeb40f33d83aa9d2c99382fb74 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 02:03:01 +0200 Subject: [PATCH 289/292] test(agent): prove offline RFC-64 restart durability --- ...kg-agent-native-wiring.integration.test.ts | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index ab900ab08b..0803a33a40 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -257,9 +257,10 @@ function privateCatalogPolicy(): ContextGraphPolicyV1 { }; } -function finalizedPublicCatalogPolicy( - publishPolicy: ContextGraphPolicyV1['publishPolicy'] = 1, -): ContextGraphPolicyV1 { +function finalizedPublicCatalogPolicy(options: Readonly<{ + publishPolicy: ContextGraphPolicyV1['publishPolicy']; + publishAuthority: EvmAddressV1 | null; +}> = { publishPolicy: 1, publishAuthority: null }): ContextGraphPolicyV1 { return { networkId: NETWORK_ID, contextGraphId: CONTEXT_GRAPH_ID, @@ -270,8 +271,8 @@ function finalizedPublicCatalogPolicy( version: '0', previousPolicyDigest: null, accessPolicy: 0, - publishPolicy, - publishAuthority: publishPolicy === 0 ? AUTHOR : null, + publishPolicy: options.publishPolicy, + publishAuthority: options.publishAuthority, publishAuthorityAccountId: '0', projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, administrativeDelegationDigest: null, @@ -1728,7 +1729,10 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { ); } }); - const policy = finalizedPublicCatalogPolicy(0); + const policy = finalizedPublicCatalogPolicy({ + publishPolicy: 0, + publishAuthority: AUTHOR, + }); const warmDataDir = await mkdtemp(join(tmpdir(), 'dkg-rfc64-release-warm-')); const coldDataDir = await mkdtemp(join(tmpdir(), 'dkg-rfc64-release-cold-')); tempDirs.push(warmDataDir, coldDataDir); @@ -1896,7 +1900,7 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { }, { timeout: 20_000, interval: 100 }); await expectExactReleaseState(cold); - // Both receiver roles keep their exact durable SWM head and VM graphs after restart. + // Prove durability before any provider or bootstrap path can repopulate the warm receiver. for (const receiver of [warm, cold]) { await receiver.stop(); agents.splice(agents.indexOf(receiver), 1); @@ -1905,28 +1909,26 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { 'release-proof-warm', warmDataDir, warmStorePath, - bootstrap, ); + await warm.awaitInitialChainPoll(); + await expectExactReleaseState(warm); + + // Keep a separate restart assertion for the configured cold-bootstrap role. cold = await startReleaseReceiver( 'release-proof-cold', coldDataDir, coldStorePath, bootstrap, ); - await Promise.all([ - connectBothWays(author, warm), - connectBothWays(author, cold), - ]); - await Promise.all([warm.awaitInitialChainPoll(), cold.awaitInitialChainPoll()]); + await connectBothWays(author, cold); + await cold.awaitInitialChainPoll(); await vi.waitFor(() => { - for (const receiver of [warm, cold]) { - expect(receiver.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ - outcome: 'applied', - providerPeerId: author.peerId, - appliedHeadDigest: published.currentCatalogHeadDigest, - inventoryRowCount: '1', - }); - } + expect(cold.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]).toMatchObject({ + outcome: 'applied', + providerPeerId: author.peerId, + appliedHeadDigest: published.currentCatalogHeadDigest, + inventoryRowCount: '1', + }); }, { timeout: 20_000, interval: 100 }); await Promise.all([ expectExactReleaseState(warm), From bbb1a9bf2d3545f4b2d4ea06902663ff17fb9868 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 02:05:56 +0200 Subject: [PATCH 290/292] fix(agent): reject ephemeral RFC-64 bootstrap --- packages/agent/src/dkg-agent.ts | 6 ++++++ ...-dkg-agent-native-wiring.integration.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 6a726de434..b6f449075c 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -699,6 +699,12 @@ export class DKGAgent extends DKGAgentBase { | undefined; static async create(inputConfig: DKGAgentConfig): Promise { + // RFC-64 bootstrap owns durable catalog and control-object state. Reject + // an impossible ephemeral configuration before constructing a store or + // node so start() can never fail after leaving libp2p half-running. + if (inputConfig.rfc64PublicCatalogBootstrap !== undefined && !inputConfig.dataDir) { + throw new TypeError('rfc64PublicCatalogBootstrap requires dataDir'); + } validateSyncResponderSnapshotLimitsConfig(inputConfig.syncResponderSnapshotLimits); const config = normalizeStorageAckConfig({ ...inputConfig, diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index a193633a2a..e9cf1254dc 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -373,6 +373,23 @@ describe('RFC-64 DKGAgent production native catalog wiring', () => { } as unknown as Rfc64PublicCatalogBootstrapConfigV1)).toThrow(/unknown or missing fields/u); }); + it('rejects bootstrap without persistence before node startup', async () => { + const policy = buildOpenOwnerContextGraphPolicyV1({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + }); + await expect(DKGAgent.create({ + name: 'ephemeral-bootstrap-is-invalid', + rfc64PublicCatalogBootstrap: { + acceptedPublicPolicies: [{ + policyEnvelope: unsignedOpenContextGraphPolicyEnvelopeV1(policy), + targets: [{ authorAddress: AUTHOR, providers: ['12D3KooProvider'] }], + }], + }, + })).rejects.toThrow(/rfc64PublicCatalogBootstrap requires dataDir/u); + }); + it('turns one confirmed public KA into the provider current head and one cold receiver apply', async () => { const receiver = await startNativeAgent('auto-publish-receiver'); const author = await startNativeAgent( From 151654d155c87869700fbf504f8b33ad72d8c2ac Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 02:41:30 +0200 Subject: [PATCH 291/292] fix(agent): stabilize public RC validation --- packages/agent/src/dkg-agent-cg-resolve.ts | 2 +- packages/agent/test/e2e-memory-layers.test.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/dkg-agent-cg-resolve.ts b/packages/agent/src/dkg-agent-cg-resolve.ts index caf3dc4b6c..95b7cf2e86 100644 --- a/packages/agent/src/dkg-agent-cg-resolve.ts +++ b/packages/agent/src/dkg-agent-cg-resolve.ts @@ -1496,7 +1496,7 @@ export class ContextGraphResolveMethods extends DKGAgentBase { /** Canonical cleartext-or-hash Context Graph identity used by every index path. */ contextGraphWireId(this: DKGAgent, contextGraphId: string): string { - if (/^0x[0-9a-fA-F]{64}$/.test(contextGraphId)) return contextGraphId.toLowerCase(); + if (/^0x[0-9a-f]{64}$/i.test(contextGraphId)) return contextGraphId.toLowerCase(); return ethers.keccak256(ethers.toUtf8Bytes(contextGraphId)).toLowerCase(); } diff --git a/packages/agent/test/e2e-memory-layers.test.ts b/packages/agent/test/e2e-memory-layers.test.ts index 751840f06c..d9fd5148f8 100644 --- a/packages/agent/test/e2e-memory-layers.test.ts +++ b/packages/agent/test/e2e-memory-layers.test.ts @@ -1551,21 +1551,25 @@ describe('rootless graph-scoped KA lifecycle', () => { // is still present as a deeper backstop, but the marker gate wins here.) it('FIX 2: unregistered CG + finalized-but-UNSHARED asset rejects BEFORE registration (no gas burned)', async () => { const agent = await createAgent('NoQuadsBeforeRegisterBot'); + // Keep this chain assertion independent from the preceding test, which + // deliberately registers CG_ID before it completes. Reusing CG_ID made + // the poller race decide whether this test observed that earlier mint. + const unregisteredCgId = `${CG_ID}-unshared-precondition`; // DELIBERATELY unregistered, local-only CG. - await agent.createContextGraph({ id: CG_ID, name: 'No Quads Before Register E2E' }); + await agent.createContextGraph({ id: unregisteredCgId, name: 'No Quads Before Register E2E' }); const name = 'empty-swm-seal'; - await agent.assertion.create(CG_ID, name); - await agent.assertion.write(CG_ID, name, [ + await agent.assertion.create(unregisteredCgId, name); + await agent.assertion.write(unregisteredCgId, name, [ { subject: `${ENTITY_BASE}:nq`, predicate: 'http://schema.org/name', object: '"No Quads"' }, ]); // Finalize the WM draft (seals it) WITHOUT promoting — SWM stays empty and // NO full-share marker is set. - await agent.assertion.finalize(CG_ID, name); + await agent.assertion.finalize(unregisteredCgId, name); let thrown: any; try { - await agent.publishFromFinalizedAssertion(CG_ID, name); + await agent.publishFromFinalizedAssertion(unregisteredCgId, name); } catch (e) { thrown = e; } @@ -1576,7 +1580,7 @@ describe('rootless graph-scoped KA lifecycle', () => { expect(thrown.code).not.toBe('CG_NOT_REGISTERED'); // And the CG was NEVER registered as a side effect (no gas burned). - const onChainId = await agent.getContextGraphOnChainId(CG_ID); + const onChainId = await agent.getContextGraphOnChainId(unregisteredCgId); expect(onChainId == null).toBe(true); }, 30_000); From 65a3206671f6829ccb22fd11b71c2cbb626030ff Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Thu, 23 Jul 2026 02:53:00 +0200 Subject: [PATCH 292/292] fix(core): canonicalize trailing zeros in linear time --- packages/core/src/crypto/term-canon.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/crypto/term-canon.ts b/packages/core/src/crypto/term-canon.ts index de7569148f..532422ec75 100644 --- a/packages/core/src/crypto/term-canon.ts +++ b/packages/core/src/crypto/term-canon.ts @@ -204,11 +204,17 @@ function canonBoolean(lex: string): string { } // ── xsd:decimal ──────────────────────────────────────────────────────────────── +function trimTrailingAsciiZeros(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 48 /* 0 */) end -= 1; + return end === value.length ? value : value.slice(0, end); +} + function canonDecimal(lex: string): string { const m = /^([+-]?)(\d*)(?:\.(\d*))?$/.exec(lex); if (!m || (m[2] === '' && (m[3] === undefined || m[3] === ''))) throw new Error(`invalid xsd:decimal: ${lex}`); const intRaw = m[2].replace(/^0+/, ''); - const frac = (m[3] ?? '').replace(/0+$/, ''); + const frac = trimTrailingAsciiZeros(m[3] ?? ''); // oxigraph stores xsd:decimal as the SAME i128 / 10^18 fixed-point as duration // seconds: a value needing more than 18 fractional digits, or whose 10^18-scaled // magnitude overflows i128, fails to parse and is kept VERBATIM. @@ -563,7 +569,7 @@ function canonDuration(lex: string, dt: string): string { sWhole = sTok; } else { sWhole = sTok.slice(0, dot) || '0'; - const fracDigits = sTok.slice(dot + 1).replace(/0+$/, ''); + const fracDigits = trimTrailingAsciiZeros(sTok.slice(dot + 1)); if (fracDigits.length > 18) throw new Error('sub-1e-18 seconds'); fracScaled = fracDigits === '' ? 0n : BigInt(fracDigits.padEnd(18, '0')); }