Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
run: git diff --exit-code packages/ci/dist
- name: Verify dependency boundaries (adapters + toolkit confinement)
run: bun run check:deps
- name: Verify frozen oracle hashes match (feature 010 Barrier B, R5 mechanism 2)
run: bun run check:freeze-hashes
- name: Lint ADR corpus
run: bun run adr lint

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"lint": "bun run --filter='*' lint",
"schema:emit": "bun run --filter='@adrkit/core' schema:emit",
"check:deps": "bun run scripts/check-deps.ts",
"check:freeze-hashes": "bun run scripts/check-freeze-hashes.ts",
"audit:gate": "bun run scripts/audit-gate.ts",
"adr": "bun packages/cli/src/index.ts",
"release:pack": "bun scripts/release-pack.ts",
Expand Down
67 changes: 67 additions & 0 deletions scripts/audit-oracle-freeze.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, test } from 'bun:test';
import { join } from 'node:path';
import {
auditFromEvidence,
auditOracleFreeze,
canonicalHash,
REASON_NO_ADEQUACY,
REASON_ORDER_NOT_COMPARE_CODE_UNITS,
} from './audit-oracle-freeze.ts';

const EVIDENCE = join(process.cwd(), 'specs/010-catalog-backstage/evidence');
const NEG = join(EVIDENCE, 'negative-cases');

describe('T019 audit procedure — observed passing on the live freeze', () => {
test('the real audit passes against the live evidence tree', async () => {
const result = await auditFromEvidence(EVIDENCE);
expect(result).toEqual({ ok: true, findings: [] });
});
});

describe('T020 — observed failing: derivedPathPatterns in input order', () => {
test('the real audit FAILS with the ordering reason against the retained input-order variant', async () => {
const result = await auditFromEvidence(join(NEG, 'oracle-input-order'));
expect(result.ok).toBe(false);
// The variant recomputes its own hash, so ordering is the SOLE finding — proving the
// audit catches the spike-009 defect independent of integrity.
expect(result.findings.map((f) => f.check)).toEqual(['ordering']);
expect(result.findings[0]!.reason).toBe(REASON_ORDER_NOT_COMPARE_CODE_UNITS);
});

test('and PASSES again once the correct compareCodeUnits-ordered artifact is used (restore)', async () => {
const result = await auditFromEvidence(EVIDENCE);
expect(result).toEqual({ ok: true, findings: [] });
});
});

describe('T021 — observed failing: integrity confirmed but adequacy never reached', () => {
test('the real audit records FAIL against SC-010, not a silent accept', async () => {
const result = await auditFromEvidence(join(NEG, 'audit-integrity-only'));
expect(result.ok).toBe(false);
// Integrity is intact (hashes match), so the ONLY finding is the missing adequacy
// determination — the exact failure mode the adequacy requirement exists to prevent.
expect(result.findings.map((f) => f.check)).toEqual(['adequacy']);
expect(result.findings[0]!.reason).toBe(REASON_NO_ADEQUACY);
});

test('a bare integrity-only audit input (no adequacy field) is rejected in isolation too', () => {
// Minimal reproduction independent of files: correct hashes, no adequacy finding.
const frozen: Record<string, unknown> = { derivedPathPatterns: [] };
frozen.contentHash = canonicalHash(frozen);
const accept: Record<string, unknown> = {};
accept.contentHash = canonicalHash(accept);
const result = auditOracleFreeze({
frozenExpectationSet: frozen,
acceptCorpusFreeze: accept,
// adequacy deliberately omitted
});
expect(result.ok).toBe(false);
expect(result.findings.map((f) => f.check)).toEqual(['adequacy']);
expect(result.findings[0]!.reason).toBe(REASON_NO_ADEQUACY);
});

test('and PASSES again once an explicit adequacy finding is recorded (restore)', async () => {
const result = await auditFromEvidence(EVIDENCE);
expect(result).toEqual({ ok: true, findings: [] });
});
});
147 changes: 147 additions & 0 deletions scripts/audit-oracle-freeze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';

/**
* The T019 oracle-freeze audit procedure, encoded as an executable so that its
* FAIL behaviour can be observed (ADR-0016), not merely asserted. This is the same
* procedure the independent auditor ran by hand in T019; making it a program lets
* T020 and T021 attack it with deliberately bad input and record the exact reason
* strings it emits.
*
* It performs three checks:
* 1. content-hash integrity — recompute each artifact's canonical hash and compare
* to the recorded contentHash (evidence/README.md §3 canonical form);
* 2. derivedPathPatterns ordering — confirm compareCodeUnits order, not input order;
* 3. adequacy — require an explicit adequacy finding (ADR-0020 clause 5(a));
* hash integrity alone is NOT sufficient and must be recorded as a FAIL
* against SC-010, never silently accepted.
*/

/** compareCodeUnits: UTF-16 code-unit ordering. JS relational operators ARE this. */
export function compareCodeUnits(a: string, b: string): -1 | 0 | 1 {
return a < b ? -1 : a > b ? 1 : 0;
}

/** README §3 canonical form: object with contentHash removed, keys ascending by
* compareCodeUnits, array order preserved, no insignificant whitespace, JSON string
* escaping, UTF-8, no trailing newline. */
export function canonicalize(value: unknown): string {
if (value === null || typeof value === 'number' || typeof value === 'boolean') {
return JSON.stringify(value);
}
if (typeof value === 'string') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort(compareCodeUnits);
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(',')}}`;
}

export function canonicalHash(artifact: Record<string, unknown>): string {
const { contentHash: _omit, ...rest } = artifact;
return createHash('sha256').update(Buffer.from(canonicalize(rest), 'utf8')).digest('hex');
}

export interface AuditFinding {
check: 'content-hash' | 'ordering' | 'adequacy';
reason: string;
}

export interface AdequacyFinding {
/** Must be exactly 'adequate' or 'inadequate'; anything else (including absence)
* means the audit never reached an adequacy determination. */
finding?: 'adequate' | 'inadequate';
reasoning?: string;
}

export interface AuditInput {
frozenExpectationSet: Record<string, unknown>;
acceptCorpusFreeze: Record<string, unknown>;
/** The adequacy finding the auditor recorded. Its ABSENCE is the T021 failure mode. */
adequacy?: AdequacyFinding;
}

export interface AuditResult {
ok: boolean;
findings: AuditFinding[];
}

// Exact reason strings — frozen so the observed-failing tests can match them verbatim.
export const REASON_HASH_DRIFT = 'content hash does not match recomputed canonical hash';
export const REASON_ORDER_NOT_COMPARE_CODE_UNITS =
'derivedPathPatterns is not in compareCodeUnits order (input order or any other order is inadmissible)';
export const REASON_NO_ADEQUACY =
'audit confirmed integrity but recorded no adequacy finding — SC-010 requires an explicit adequacy determination, an integrity confirmation alone does not satisfy clause 5(a)';

export function auditOracleFreeze(input: AuditInput): AuditResult {
const findings: AuditFinding[] = [];

// 1. content-hash integrity for both artifacts.
for (const artifact of [input.frozenExpectationSet, input.acceptCorpusFreeze]) {
const recorded = artifact.contentHash;
const recomputed = canonicalHash(artifact);
if (recorded !== recomputed) {
findings.push({ check: 'content-hash', reason: REASON_HASH_DRIFT });
}
}

// 2. derivedPathPatterns ordering — must be exactly compareCodeUnits-sorted.
const dpp = input.frozenExpectationSet.derivedPathPatterns;
if (Array.isArray(dpp)) {
const sorted = [...(dpp as string[])].sort(compareCodeUnits);
if (JSON.stringify(dpp) !== JSON.stringify(sorted)) {
findings.push({ check: 'ordering', reason: REASON_ORDER_NOT_COMPARE_CODE_UNITS });
}
} else {
findings.push({ check: 'ordering', reason: REASON_ORDER_NOT_COMPARE_CODE_UNITS });
}

// 3. adequacy — an explicit finding is mandatory (ADR-0020 clause 5(a) / SC-010).
if (input.adequacy?.finding !== 'adequate' && input.adequacy?.finding !== 'inadequate') {
findings.push({ check: 'adequacy', reason: REASON_NO_ADEQUACY });
}
Comment on lines +100 to +103

return { ok: findings.length === 0, findings };
}

async function readJson(path: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>;
}

/** Load the live evidence artifacts and the auditor's recorded adequacy finding. */
export async function auditFromEvidence(evidenceDir: string): Promise<AuditResult> {
const frozenExpectationSet = await readJson(
join(evidenceDir, 'frozen-expectations', 'frozen-expectation-set.json'),
);
const acceptCorpusFreeze = await readJson(
join(evidenceDir, 'accept-corpus-freeze', 'accept-corpus-freeze.json'),
);
const adequacyAudit = await readJson(
join(evidenceDir, 'accept-corpus-freeze', 'adequacy-audit.json'),
);
const rawFinding = (adequacyAudit.requirement3_adequacyFinding as Record<string, unknown> | undefined)
?.finding;
// The recorded finding is a prose sentence beginning with the verdict word.
const adequacy: AdequacyFinding =
typeof rawFinding === 'string' && /^ADEQUATE\b/i.test(rawFinding) && !/^INADEQUATE/i.test(rawFinding)
? { finding: 'adequate' }
: typeof rawFinding === 'string' && /^INADEQUATE\b/i.test(rawFinding)
? { finding: 'inadequate' }
: {};
return auditOracleFreeze({ frozenExpectationSet, acceptCorpusFreeze, adequacy });
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const evidenceDir =
process.argv[2] ?? join(process.cwd(), 'specs/010-catalog-backstage/evidence');
const result = await auditFromEvidence(evidenceDir);
if (result.ok) {
console.log('audit-oracle-freeze: PASS');
} else {
for (const finding of result.findings) {
console.error(`FAIL [${finding.check}]: ${finding.reason}`);
}
process.exitCode = 1;
}
}
74 changes: 74 additions & 0 deletions scripts/check-freeze-hashes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { cp, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { cleanupTestDir, resetTestDir } from '../packages/core/test/helpers.ts';
import { checkFreezeHashes, REASON_DRIFT } from './check-freeze-hashes.ts';

const DIR_NAME = 'check-freeze-hashes';
const LIVE_EVIDENCE = join(process.cwd(), 'specs/010-catalog-backstage/evidence');

afterEach(async () => {
await cleanupTestDir(DIR_NAME);
});

describe('T022 — freeze-hash drift check (R5 mechanism 2)', () => {
test('passes against the live frozen trees, checking exactly the two hashed artifacts', async () => {
const result = await checkFreezeHashes(LIVE_EVIDENCE);
expect(result.ok).toBe(true);
expect(result.findings).toEqual([]);
expect(result.checked).toEqual([
'frozen-expectations/frozen-expectation-set.json',
'accept-corpus-freeze/accept-corpus-freeze.json',
]);
});

test('ignores sibling audit records that carry no contentHash', async () => {
// The live tree contains audit-record.json and adequacy-audit.json; neither is
// in `checked`, proving the drift check does not try to hash sibling records.
const result = await checkFreezeHashes(LIVE_EVIDENCE);
expect(result.checked).not.toContain('frozen-expectations/audit-record.json');
expect(result.checked).not.toContain('accept-corpus-freeze/adequacy-audit.json');
});
});

describe('T023 — observed failing: a single mutated byte in a frozen artifact', () => {
test('mutating one byte makes the drift check FAIL with the exact reason; restoring makes it PASS', async () => {
const root = await resetTestDir(DIR_NAME);
const evidence = join(root, 'evidence');
// Copy only the two freeze dirs into an isolated evidence tree.
await cp(join(LIVE_EVIDENCE, 'frozen-expectations'), join(evidence, 'frozen-expectations'), {
recursive: true,
});
await cp(join(LIVE_EVIDENCE, 'accept-corpus-freeze'), join(evidence, 'accept-corpus-freeze'), {
recursive: true,
});

// Baseline: the untouched copy passes.
expect((await checkFreezeHashes(evidence)).ok).toBe(true);

// Mutate exactly one byte of one frozen artifact WITHOUT touching its recorded
// contentHash — flip a single character inside a value so the JSON stays parseable.
const target = join(evidence, 'frozen-expectations', 'frozen-expectation-set.json');
const original = await readFile(target, 'utf8');
const idx = original.indexOf('workspaces/alpha/src/**');
expect(idx).toBeGreaterThan(-1);
// Change 'alpha' -> 'blpha' (one byte: 'a' -> 'b') at the located path value.
const mutated = original.slice(0, idx) + 'b' + original.slice(idx + 1);
expect(mutated).not.toBe(original);
expect(mutated.length).toBe(original.length); // exactly one byte changed
await writeFile(target, mutated, 'utf8');

const failed = await checkFreezeHashes(evidence);
expect(failed.ok).toBe(false);
const drift = failed.findings.find(
(f) => f.file === 'frozen-expectations/frozen-expectation-set.json',
);
expect(drift).toBeDefined();
expect(drift!.reason).toBe(REASON_DRIFT);
expect(drift!.recomputed).not.toBe(drift!.recorded);

// Restore the byte; the check passes again.
await writeFile(target, original, 'utf8');
expect((await checkFreezeHashes(evidence)).ok).toBe(true);
});
});
Loading