diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54ffd948c..08f0144da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,10 @@ jobs: run: node --test interop/aae-psea-gate/verify.test.mjs interop/aae-psea-gate/reperform.test.mjs - name: Verify the public verifier-forgery corpus (node:test) run: node --test tests/verifier-forgery/forgery.test.mjs + - name: Verify executable AIPS-1 P3 public-comment lab + run: | + node --test standards/aips-1/p3-evidence-source-evaluation-v0/evaluate.selftest.mjs + node standards/aips-1/p3-evidence-source-evaluation-v0/generate-report.mjs --check - name: Verify every remaining package suite (discovered, not listed) # The step above still enumerates packages. That is the bug pattern that # dropped 8 of 11 verify suites until 2026-07-02 and left eight whole diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md index fe3991d88..f2551553d 100644 --- a/AI_CONTEXT.md +++ b/AI_CONTEXT.md @@ -2,7 +2,7 @@ > EMILIA is the authority control plane for autonomous work. The plain-language operating picture is a customer-owned authority toll booth at a configured protected boundary, where a human or institution defines a finite operating mandate and agents work unattended inside it. EMILIA Gate verifies the authority and evidence the owner requires for the exact action, reserves accepted authority before provider entry, admits or refuses the crossing, preserves executed or indeterminate outcomes, refuses blind replay, and returns an action-bound receipt packet. EMILIA Host is the private local deployment form of Gate for activated covered HTTP and MCP paths at a credential-owning provider boundary; the current surface is an HTTP local service alpha, HTTP and MCP SDK protection, and governed pilots. Native mandates and credentials from A2A, AP2, OAuth, and hardware-backed approval systems remain attributable to their issuers. EMILIA Protocol is the open Action Receipt Contract underneath Gate; EMILIA Approver captures device-bound exact-action human decisions when the mandate or local policy requires fresh human authority; EMILIA Assurance Plane defines scoped verification, re-performance, conformance-report, and deployment-evidence procedures without acting as an auditor or accredited certifier. The toll-booth metaphor describes the intended cross-stack contract, not current coverage or operation of a central global network. -Generated from repository evidence. Evidence snapshot: 2026-09-01T09:44:11.545Z. Input digest: sha256:58ef6ac4a77b5d5dbc19181b45e6f796b0a54a61aace98f77114fa91bcd7a2a8. +Generated from repository evidence. Evidence snapshot: 2026-09-02T00:56:07.055Z. Input digest: sha256:3e8f027d79f489d7ee1accb3695b6901b33f0f9a533299e8f457f17726c4a0c6. Do not edit this file directly. Source: [docs/ai/context-source.v1.json](docs/ai/context-source.v1.json). Freshness check: `npm run check:llm-context`. ## Read This First @@ -15,10 +15,10 @@ When this file conflicts with a quantitative claim elsewhere, use the machine-re ## Current Evidence Snapshot -- Automated tests: 10,609 cases across 658 files; all platform-applicable cases must pass; platform-specific cases may skip. +- Automated tests: 10,622 cases across 661 files; all platform-applicable cases must pass; platform-specific cases may skip. - Cross-language conformance: 21 suites, 332 current vectors, 3 same-team ports (JavaScript, Python, Go). This is consistency evidence, not implementation independence. - External Rust interoperability: pass on the time-pinned 164-vector set evaluated 2026-07-11; the current bundle has 332. The same pinned implementation passes 359 hostility cases. Strict clean-room construction acceptance: false. -- Security case: 35 executable claims, 259 evidence files, execution passed; bundle sha256:947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880. +- Security case: 35 executable claims, 259 evidence files, execution passed; bundle sha256:66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e. - Core formal inventory: 26 TLA+ invariants, 35 Alloy facts, 32 Alloy assertions. The selected-scenario models are reported separately below; formal scope and exclusions remain claim-specific. - Selected model/runtime scenario conformance: 78 content-addressed scenarios across 14 bounded models and 21 public claims; 51 negative controls pair a formal counterexample with a safe-runtime refusal. They do not mutate the runtime implementation. Boundary: selected model/runtime scenarios under explicit projection relations; not a mechanized implementation refinement proof. - Formal evidence taxonomy: 2 claims with verified formal obligations; 21 with bounded runtime-traced evidence; 1 with bounded formal evidence but no governed runtime bridge; 0 with partial symbolic coverage; 11 with executable or operational evidence only. diff --git a/lib/proof-stats.json b/lib/proof-stats.json index 70d10d28a..83ef22c43 100644 --- a/lib/proof-stats.json +++ b/lib/proof-stats.json @@ -1,8 +1,8 @@ { - "generatedAt": "2026-09-01T09:44:11.545Z", + "generatedAt": "2026-09-02T00:56:07.055Z", "tests": { - "total": 10609, - "files": 658, + "total": 10622, + "files": 661, "policy": "all platform-applicable cases must pass; platform-specific cases may skip" }, "tla": { @@ -105,7 +105,7 @@ "status": "passed", "claims": 35, "evidenceFiles": 259, - "evidenceBundleSha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "evidenceBundleSha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" }, "conformance": { "suites": 21, diff --git a/public/.well-known/emilia-context.json b/public/.well-known/emilia-context.json index b10ae1d82..51d33ec6a 100644 --- a/public/.well-known/emilia-context.json +++ b/public/.well-known/emilia-context.json @@ -1,9 +1,9 @@ { "@version": "EMILIA-REPO-CONTEXT-v1", - "evidence_snapshot_at": "2026-09-01T09:44:11.545Z", + "evidence_snapshot_at": "2026-09-02T00:56:07.055Z", "provenance": { "generator": "scripts/generate-llm-context.mts", - "input_digest_sha256": "58ef6ac4a77b5d5dbc19181b45e6f796b0a54a61aace98f77114fa91bcd7a2a8", + "input_digest_sha256": "3e8f027d79f489d7ee1accb3695b6901b33f0f9a533299e8f457f17726c4a0c6", "generated_from": [ { "path": "docs/ai/context-source.v1.json", @@ -17,7 +17,7 @@ }, { "path": "lib/proof-stats.json", - "sha256": "cab74e79ac95cff9d2747e038ba4d65fc7a9340dbab3e1dd24852592030c063e", + "sha256": "944ebd9a99a59e77ed8d7892affc0f91feaa93550b98868594250d1613747416", "bytes": 4299 }, { @@ -37,7 +37,7 @@ }, { "path": "security/security-case.json", - "sha256": "c068ea00b3d264f6d2d9cdc7883c7f9302c4008529c2d1190cfabafd5c1ee455", + "sha256": "da6a51f0253eaf494785b3f1eeef778da75f5e1793b50d80d145ae60242bbe57", "bytes": 386020 }, { @@ -307,8 +307,8 @@ ], "current_evidence": { "automated_tests": { - "total": 10609, - "files": 658, + "total": 10622, + "files": 661, "policy": "all platform-applicable cases must pass; platform-specific cases may skip" }, "cross_language_conformance": { @@ -419,7 +419,7 @@ "status": "passed", "claims": 35, "evidence_files": 259, - "evidence_bundle_sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "evidence_bundle_sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" }, "caid": { "core_vectors": 48, diff --git a/public/llms-full.txt b/public/llms-full.txt index a8d6b5967..e1a7522a3 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -2,7 +2,7 @@ > EMILIA is the authority control plane for autonomous work. The plain-language operating picture is a customer-owned authority toll booth at a configured protected boundary, where a human or institution defines a finite operating mandate and agents work unattended inside it. EMILIA Gate verifies the authority and evidence the owner requires for the exact action, reserves accepted authority before provider entry, admits or refuses the crossing, preserves executed or indeterminate outcomes, refuses blind replay, and returns an action-bound receipt packet. EMILIA Host is the private local deployment form of Gate for activated covered HTTP and MCP paths at a credential-owning provider boundary; the current surface is an HTTP local service alpha, HTTP and MCP SDK protection, and governed pilots. Native mandates and credentials from A2A, AP2, OAuth, and hardware-backed approval systems remain attributable to their issuers. EMILIA Protocol is the open Action Receipt Contract underneath Gate; EMILIA Approver captures device-bound exact-action human decisions when the mandate or local policy requires fresh human authority; EMILIA Assurance Plane defines scoped verification, re-performance, conformance-report, and deployment-evidence procedures without acting as an auditor or accredited certifier. The toll-booth metaphor describes the intended cross-stack contract, not current coverage or operation of a central global network. -Generated from repository evidence. Evidence snapshot: 2026-09-01T09:44:11.545Z. Input digest: sha256:58ef6ac4a77b5d5dbc19181b45e6f796b0a54a61aace98f77114fa91bcd7a2a8. +Generated from repository evidence. Evidence snapshot: 2026-09-02T00:56:07.055Z. Input digest: sha256:3e8f027d79f489d7ee1accb3695b6901b33f0f9a533299e8f457f17726c4a0c6. Do not edit this file directly. Source: [docs/ai/context-source.v1.json](https://github.com/emiliaprotocol/emilia-protocol/blob/main/docs/ai/context-source.v1.json). Freshness check: `npm run check:llm-context`. ## Read This First @@ -15,10 +15,10 @@ When this file conflicts with a quantitative claim elsewhere, use the machine-re ## Current Evidence Snapshot -- Automated tests: 10,609 cases across 658 files; all platform-applicable cases must pass; platform-specific cases may skip. +- Automated tests: 10,622 cases across 661 files; all platform-applicable cases must pass; platform-specific cases may skip. - Cross-language conformance: 21 suites, 332 current vectors, 3 same-team ports (JavaScript, Python, Go). This is consistency evidence, not implementation independence. - External Rust interoperability: pass on the time-pinned 164-vector set evaluated 2026-07-11; the current bundle has 332. The same pinned implementation passes 359 hostility cases. Strict clean-room construction acceptance: false. -- Security case: 35 executable claims, 259 evidence files, execution passed; bundle sha256:947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880. +- Security case: 35 executable claims, 259 evidence files, execution passed; bundle sha256:66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e. - Core formal inventory: 26 TLA+ invariants, 35 Alloy facts, 32 Alloy assertions. The selected-scenario models are reported separately below; formal scope and exclusions remain claim-specific. - Selected model/runtime scenario conformance: 78 content-addressed scenarios across 14 bounded models and 21 public claims; 51 negative controls pair a formal counterexample with a safe-runtime refusal. They do not mutate the runtime implementation. Boundary: selected model/runtime scenarios under explicit projection relations; not a mechanized implementation refinement proof. - Formal evidence taxonomy: 2 claims with verified formal obligations; 21 with bounded runtime-traced evidence; 1 with bounded formal evidence but no governed runtime bridge; 0 with partial symbolic coverage; 11 with executable or operational evidence only. diff --git a/public/llms.txt b/public/llms.txt index d025d00d6..c7721a9d9 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -2,13 +2,13 @@ > EMILIA is the authority control plane for autonomous work. The plain-language operating picture is a customer-owned authority toll booth at a configured protected boundary, where a human or institution defines a finite operating mandate and agents work unattended inside it. EMILIA Gate verifies the authority and evidence the owner requires for the exact action, reserves accepted authority before provider entry, admits or refuses the crossing, preserves executed or indeterminate outcomes, refuses blind replay, and returns an action-bound receipt packet. EMILIA Host is the private local deployment form of Gate for activated covered HTTP and MCP paths at a credential-owning provider boundary; the current surface is an HTTP local service alpha, HTTP and MCP SDK protection, and governed pilots. Native mandates and credentials from A2A, AP2, OAuth, and hardware-backed approval systems remain attributable to their issuers. EMILIA Protocol is the open Action Receipt Contract underneath Gate; EMILIA Approver captures device-bound exact-action human decisions when the mandate or local policy requires fresh human authority; EMILIA Assurance Plane defines scoped verification, re-performance, conformance-report, and deployment-evidence procedures without acting as an auditor or accredited certifier. The toll-booth metaphor describes the intended cross-stack contract, not current coverage or operation of a central global network. -This is a generated discovery index following the llms.txt proposal. Evidence snapshot: 2026-09-01T09:44:11.545Z. For substantive analysis, load the full or machine-readable context below before drawing conclusions from individual repository files. +This is a generated discovery index following the llms.txt proposal. Evidence snapshot: 2026-09-02T00:56:07.055Z. For substantive analysis, load the full or machine-readable context below before drawing conclusions from individual repository files. Prevention boundary: Gate prevents only on action paths under complete mediation. It does not constrain a path that bypasses the deployed enforcement point. ## Engineering Evidence -EMILIA is implemented security infrastructure, not architecture-only: 10,609 automated tests across 658 files; 35 executable security claims over 259 hashed evidence files; 20 verified obligations across 2 composed Tamarin models, with 8 deliberately weakened variants producing concrete attack traces; and 78 content-addressed selected model/runtime scenarios across 14 bounded models and 21 claims, including 51 paired formal-counterexample/runtime-refusal controls. +EMILIA is implemented security infrastructure, not architecture-only: 10,622 automated tests across 661 files; 35 executable security claims over 259 hashed evidence files; 20 verified obligations across 2 composed Tamarin models, with 8 deliberately weakened variants producing concrete attack traces; and 78 content-addressed selected model/runtime scenarios across 14 bounded models and 21 claims, including 51 paired formal-counterexample/runtime-refusal controls. Interoperability evidence: 21 conformance suites and 332 current vectors across three same-team ports; external Rust evidence covers a time-pinned 164-vector set plus 359 hostility cases. Strict clean-room construction acceptance remains false. diff --git a/scripts/generate-proof-stats.mjs b/scripts/generate-proof-stats.mjs index 3e598c8ce..2ef7eca8d 100644 --- a/scripts/generate-proof-stats.mjs +++ b/scripts/generate-proof-stats.mjs @@ -4,337 +4,606 @@ /* eslint-disable */ // Regenerates lib/proof-stats.json from ground truth or checks it in CI. import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs"; +import { constants, hostname, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; -const check = process.argv.includes("--check"); -const bootstrapDerivedEvidence = process.argv.includes("--bootstrap-derived-evidence"); -const securityCasePreverified = process.argv.includes("--security-case-preverified"); -if (check && bootstrapDerivedEvidence) { - throw new Error("bootstrap-derived-evidence cannot be used in check mode"); +const PROOF_STATS_LOCK_TIMEOUT_MS = 30 * 60 * 1000; +const PROOF_STATS_LOCK_POLL_MS = 250; +const PROOF_STATS_LOCK_VERSION = 1; +const sleepArray = new Int32Array(new SharedArrayBuffer(4)); +function processIdentity(pid) { + if (process.platform === "win32") + return null; + const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + maxBuffer: 64 * 1024, + timeout: 2_000, + }); + if (result.status !== 0 || result.error) + return null; + const started = result.stdout.trim().replace(/\s+/g, " "); + return started || null; } -if (securityCasePreverified) { - if (!check) { - throw new Error("security-case-preverified is check-mode only"); - } - if (process.env.GITHUB_ACTIONS !== "true") { - throw new Error("security-case-preverified requires GitHub Actions"); - } - const expectedSha = process.env.SECURITY_CASE_PREVERIFIED_SHA || ""; - const head = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }); - if (head.error) - throw head.error; - if (head.status !== 0 || !/^[0-9a-f]{40}$/.test(expectedSha) - || head.stdout.trim() !== expectedSha - || process.env.GITHUB_SHA !== expectedSha) { - throw new Error("security-case-preverified SHA does not match the checkout"); +function ownerIsLive(owner) { + if (owner.host !== hostname()) + return true; + try { + process.kill(owner.pid, 0); } + catch (error) { + const code = error.code; + if (code === "ESRCH") + return false; + return true; + } + const observedIdentity = processIdentity(owner.pid); + return !owner.processIdentity || !observedIdentity || + owner.processIdentity === observedIdentity; } -let j; -if (bootstrapDerivedEvidence) { - // Some tests deliberately compare generated proof/LLM surfaces with the - // security case. When a claim is added, those tests must remain red until a - // ground-truth-derived candidate exists. This one-shot bootstrap updates only - // the derived evidence fields while retaining the last measured test count; - // the normal unflagged run must follow and replaces that count from a complete - // passing Vitest report. CI never uses this mode. - const recorded = JSON.parse(readFileSync("lib/proof-stats.json", "utf8")); - if (!Number.isSafeInteger(recorded.tests?.total) || - !Number.isSafeInteger(recorded.tests?.files) || - recorded.tests.total < 1 || - recorded.tests.files < 1) { - throw new Error("recorded proof stats do not contain a reusable test measurement"); - } - j = { - numTotalTests: recorded.tests.total, - testResults: Array.from({ length: recorded.tests.files }, () => ({})), - }; -} -else { - const reportDir = mkdtempSync(join(tmpdir(), "ep-proof-stats-")); - const reportPath = join(reportDir, "vitest.json"); - const execution = spawnSync("npx", [ - "vitest", - "run", - "--silent", - // Proof-stat measurement runs the complete integration inventory, including - // tests that launch real git, archive, and protocol-check subprocesses. - // Bound worker fan-out and give each case an explicit integration budget so - // CPU starvation cannot turn Vitest's five-second unit default into a false - // governed-evidence failure. The run still fails closed on any timeout. - "--maxWorkers=4", - "--testTimeout=60000", - "--hookTimeout=60000", - "--reporter=json", - `--outputFile=${reportPath}`, - ], { - encoding: "utf8", - maxBuffer: 1e9, - }); - if (execution.error) - throw execution.error; - if (!existsSync(reportPath)) { - throw new Error(`Vitest did not write its JSON report:\n${execution.stderr || execution.stdout}`); - } - j = JSON.parse(readFileSync(reportPath, "utf8")); - rmSync(reportDir, { recursive: true, force: true }); - if (execution.status !== 0) { - console.error("PROOF STATS: FAIL — the measured test run did not pass"); - for (const result of j.testResults - .filter((item) => item.status === "failed") - .slice(0, 20)) { - console.error(result.name); - for (const assertion of result.assertionResults - .filter((item) => item.status === "failed") - .slice(0, 10)) { - console.error(` ${assertion.fullName}`); - for (const message of assertion.failureMessages.slice(0, 2)) - console.error(` ${message.split("\n")[0]}`); - } +function readLockOwner(participantPath) { + try { + const parsed = JSON.parse(readFileSync(join(participantPath, "owner.json"), "utf8")); + const token = participantPath.slice(participantPath.lastIndexOf("-") + 1); + if (parsed.version !== PROOF_STATS_LOCK_VERSION || + parsed.token !== token || + !Number.isSafeInteger(parsed.pid) || + parsed.pid < 1 || + parsed.host !== hostname() || + (parsed.processIdentity !== null && + typeof parsed.processIdentity !== "string") || + typeof parsed.createdAt !== "string" || + typeof parsed.choosing !== "boolean" || + (parsed.ticket !== null && + (!Number.isSafeInteger(parsed.ticket) || parsed.ticket < 1))) { + return null; } - process.exit(1); + return parsed; + } + catch (error) { + if (error.code === "ENOENT") + return null; + return null; } } -if (!securityCasePreverified) { - const liveSecurityCase = spawnSync(process.execPath, [ - "--import", - "./scripts/ts-loader/register.mjs", - "scripts/verify-security-case.mjs", - "--execute", - ], { +function writeLockOwner(participantPath, owner) { + const ownerPath = join(participantPath, "owner.json"); + const temporaryPath = join(participantPath, `.owner-${randomBytes(8).toString("hex")}.tmp`); + writeFileSync(temporaryPath, `${JSON.stringify(owner)}\n`, { encoding: "utf8", - maxBuffer: 1e9, + flag: "wx", + mode: 0o600, }); - if (liveSecurityCase.error) - throw liveSecurityCase.error; - if (liveSecurityCase.status !== 0) { - throw new Error(`The live machine-verifiable security case failed:\n${liveSecurityCase.stderr || liveSecurityCase.stdout}`); - } + renameSync(temporaryPath, ownerPath); } -const cfg = readFileSync("formal/ep_handshake.cfg", "utf8"); -const composedLifecycleCfg = readFileSync("formal/ep_composed_trust_lifecycle.cfg", "utf8"); -const als = readFileSync("formal/ep_relations.als", "utf8"); -const fedAls = readFileSync("formal/ep_federation.als", "utf8"); -const quorumAls = readFileSync("formal/ep_quorum.als", "utf8"); -const delegationAls = readFileSync("formal/ep_delegation.als", "utf8"); -const redTeam = readFileSync("docs/conformance/RED_TEAM_CASES.md", "utf8"); -const tamarinSummary = readFileSync("formal/tamarin/results/ep_reliance_composed.summary.txt", "utf8"); -const conformance = JSON.parse(readFileSync("conformance/conformance-manifest.json", "utf8")); -const external = JSON.parse(readFileSync("conformance/external/rust-cleanroom-jdieselny.v1.json", "utf8")); -const securityCase = JSON.parse(readFileSync("security/security-case.json", "utf8")); -const claimSource = JSON.parse(readFileSync("security/claims.v1.json", "utf8")); -const scenarioConformanceBytes = readFileSync("formal/results/formal-runtime-scenario-conformance.v2.json"); -const scenarioConformance = JSON.parse(scenarioConformanceBytes.toString("utf8")); -const tamarinVerifiedRows = [ - ...tamarinSummary.matchAll(/^\s{2}\S.*\((all-traces|exists-trace)\):\s+verified\b.*$/gm), -]; -const tamarinVerified = tamarinVerifiedRows.length; -const tamarinAllTraceObligations = tamarinVerifiedRows.filter((match) => match[1] === "all-traces").length; -const tamarinExistsTraceWitnesses = tamarinVerifiedRows.filter((match) => match[1] === "exists-trace").length; -const tamarinCounterexamples = (tamarinSummary.match(/^\s{2}\S.*:\s+falsified\s+-\s+found trace\b.*$/gm) || []).length; -const tamarinVersion = tamarinSummary.match(/^Tamarin:\s+(.+)$/m)?.[1]; -const tamarinModelHashes = [ - ...tamarinSummary.matchAll(/^Model SHA-256:\s+([a-f0-9]{64})$/gm), -].map((match) => match[1]); -const tamarinRunnerHash = tamarinSummary.match(/^Runner SHA-256:\s+([a-f0-9]{64})$/m)?.[1]; -const currentTamarinModelHashes = [ - "formal/tamarin/ep_reliance_composed.spthy", - "formal/tamarin/ep_six_claim_composed.spthy", -].map((file) => createHash("sha256").update(readFileSync(file)).digest("hex")); -const currentTamarinRunnerHash = createHash("sha256") - .update(readFileSync("formal/tamarin/run-composed.sh")) - .digest("hex"); -if (!tamarinVersion || - tamarinModelHashes.length !== 2 || - !isDeepStrictEqual(tamarinModelHashes, currentTamarinModelHashes) || - tamarinRunnerHash !== currentTamarinRunnerHash || - tamarinVerified === 0 || - tamarinAllTraceObligations === 0 || - tamarinExistsTraceWitnesses === 0 || - tamarinCounterexamples === 0) { - throw new Error("The composed Tamarin proof summary is incomplete or not bound to the current model and runner bytes"); +function lockParticipants(queuePath) { + return readdirSync(queuePath) + .filter((name) => /^participant-[a-f0-9]{32}$/.test(name)) + .sort() + .map((name) => join(queuePath, name)); } -if (securityCase.execution?.status !== "passed") { - throw new Error("The machine-verifiable security case is not passing"); -} -if (scenarioConformance["@version"] !== - "EP-SELECTED-SCENARIO-CONFORMANCE-EVIDENCE-v2" || - scenarioConformance.method !== "bounded_selected_scenario_conformance" || - !Array.isArray(scenarioConformance.scenarios) || - scenarioConformance.scenarios.length === 0 || - !scenarioConformance.scenarios.every((scenario) => scenario.matched === true) || - scenarioConformance.summary?.paired_negative_controls < 1 || - !Number.isSafeInteger(scenarioConformance.summary?.required_model_actions) || - scenarioConformance.summary.required_model_actions < 1 || - scenarioConformance.summary.covered_model_actions !== - scenarioConformance.summary.required_model_actions || - !Array.isArray(scenarioConformance.summary?.action_complete_models) || - scenarioConformance.summary.action_complete_models.length < 1) { - throw new Error("The formal runtime selected-scenario conformance evidence is missing or incomplete"); -} -if (!conformance.implementations?.every((item) => item.relationship === "one_team_port")) { - throw new Error("Reference verifier relationship is not uniformly one_team_port"); -} -if (external.conformance?.status !== "pass") { - throw new Error("The pinned external implementation does not report conformance pass"); -} -const FORMAL_EVIDENCE_CATEGORIES = Object.freeze([ - "verifiedFormalObligations", - "boundedRuntimeTraced", - "boundedFormalEvidence", - "partialSymbolicCoverage", - "executableOperationalEvidence", -]); -function classifyFormalEvidence(formal) { - if (formal.length > 0 && - formal.every((entry) => entry.status === "verified")) { - return "verifiedFormalObligations"; - } - if (formal.some((entry) => entry.status === "partial" && - entry.method?.startsWith("bounded_") && - entry.scenario_coverage === "selected" && - Array.isArray(entry.covered_actions) && - entry.covered_actions.length > 0 && - Array.isArray(entry.covered_obligations) && - entry.covered_obligations.length > 0 && - typeof entry.scenario_evidence === "string" && - entry.scenario_evidence.length > 0 && - typeof entry.scenario_runner === "string" && - entry.scenario_runner.length > 0 && - typeof entry.conformance_evidence === "string" && - entry.conformance_evidence.length > 0)) { - return "boundedRuntimeTraced"; - } - if (formal.some((entry) => entry.status === "partial" && - [ - "bounded_tla_model_checking", - "bounded_exhaustive_state_exploration", - ].includes(entry.method))) { - return "boundedFormalEvidence"; - } - if (formal.some((entry) => entry.status === "partial" || entry.status === "verified")) { - return "partialSymbolicCoverage"; - } - return "executableOperationalEvidence"; -} -const formalEvidenceCoverage = Object.fromEntries(FORMAL_EVIDENCE_CATEGORIES.map((category) => [ - category, - { count: 0, claimIds: [] }, -])); -for (const claim of claimSource.claims ?? []) { - const category = classifyFormalEvidence(claim.formal ?? []); - formalEvidenceCoverage[category].count += 1; - formalEvidenceCoverage[category].claimIds.push(claim.claim_id); -} -for (const category of FORMAL_EVIDENCE_CATEGORIES) { - formalEvidenceCoverage[category].claimIds.sort(); -} -const classifiedClaimCount = FORMAL_EVIDENCE_CATEGORIES.reduce((total, category) => total + formalEvidenceCoverage[category].count, 0); -if (classifiedClaimCount !== claimSource.claims?.length || - classifiedClaimCount !== securityCase.claim_count) { - throw new Error("The formal evidence taxonomy does not cover the complete security claim inventory"); +export function resolveProofStatsLockQueue(cwd = process.cwd()) { + const commonDirectory = spawnSync("git", ["rev-parse", "--git-common-dir"], { + cwd, + encoding: "utf8", + maxBuffer: 64 * 1024, + timeout: 5_000, + }); + if (commonDirectory.error || commonDirectory.status !== 0) { + throw new Error(`proof-stats run lock could not resolve the shared Git directory: ${commonDirectory.error?.message || commonDirectory.stderr || "unknown error"}`); + } + const rawCommonDirectory = commonDirectory.stdout.trim(); + if (!rawCommonDirectory) { + throw new Error("proof-stats run lock resolved an empty Git common directory"); + } + const absoluteCommonDirectory = realpathSync(resolve(cwd, rawCommonDirectory)); + const hostScope = createHash("sha256") + .update(hostname()) + .digest("hex") + .slice(0, 16); + return join(absoluteCommonDirectory, "emilia-run-locks", hostScope, "proof-stats-run-v1"); } -const recordedRuntimeTracedClaims = formalEvidenceCoverage.boundedRuntimeTraced.claimIds; -const executedRuntimeTracedClaims = [ - ...scenarioConformance.summary.claims, -].sort(); -if (!isDeepStrictEqual(recordedRuntimeTracedClaims, executedRuntimeTracedClaims)) { - throw new Error("Bounded runtime-traced claim metadata does not match the executed selected-scenario evidence"); +export function acquireProofStatsRunLock({ cwd = process.cwd(), timeoutMs = PROOF_STATS_LOCK_TIMEOUT_MS, pollMs, } = {}) { + const effectivePollMs = pollMs ?? Math.min(PROOF_STATS_LOCK_POLL_MS, timeoutMs); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { + throw new Error("proof-stats run lock timeout must be a positive integer"); + } + if (!Number.isSafeInteger(effectivePollMs) || + effectivePollMs < 1 || + effectivePollMs > timeoutMs) { + throw new Error("proof-stats run lock poll interval must be a positive integer no greater than its timeout"); + } + const queuePath = resolveProofStatsLockQueue(cwd); + mkdirSync(queuePath, { recursive: true, mode: 0o700 }); + if (!lstatSync(queuePath).isDirectory()) { + throw new Error("proof-stats run lock queue is not a directory"); + } + const versionPath = join(queuePath, ".version"); + const versionStagingPath = join(queuePath, `.version-${randomBytes(16).toString("hex")}.tmp`); + writeFileSync(versionStagingPath, `${PROOF_STATS_LOCK_VERSION}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + try { + linkSync(versionStagingPath, versionPath); + } + catch (error) { + if (error.code !== "EEXIST") + throw error; + } + finally { + rmSync(versionStagingPath, { force: true }); + } + try { + if (readFileSync(versionPath, "utf8") !== `${PROOF_STATS_LOCK_VERSION}\n`) { + throw new Error("proof-stats run lock queue has an unsupported version"); + } + } + catch (error) { + if (error.code === "ENOENT") { + throw new Error("proof-stats run lock queue lost its version marker"); + } + throw error; + } + const token = randomBytes(16).toString("hex"); + const participantPath = join(queuePath, `participant-${token}`); + const stagingPath = join(queuePath, `.staging-${token}`); + const owner = { + version: PROOF_STATS_LOCK_VERSION, + token, + pid: process.pid, + host: hostname(), + processIdentity: processIdentity(process.pid), + createdAt: new Date().toISOString(), + choosing: true, + ticket: null, + }; + mkdirSync(stagingPath, { mode: 0o700 }); + try { + writeLockOwner(stagingPath, owner); + renameSync(stagingPath, participantPath); + } + catch (error) { + rmSync(stagingPath, { recursive: true, force: true }); + throw error; + } + let released = false; + const releaseEntry = () => { + if (released) + return false; + released = true; + process.removeListener("exit", onExit); + for (const [signal, listener] of signalListeners) { + process.removeListener(signal, listener); + } + const recorded = readLockOwner(participantPath); + if (recorded?.token === token) { + rmSync(participantPath, { recursive: true, force: true }); + } + return true; + }; + const onExit = () => { + const recorded = readLockOwner(participantPath); + if (recorded?.token === token) { + rmSync(participantPath, { recursive: true, force: true }); + } + }; + const signalListeners = new Map(); + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) { + const listener = () => { + releaseEntry(); + process.exit(128 + constants.signals[signal]); + }; + signalListeners.set(signal, listener); + process.once(signal, listener); + } + process.once("exit", onExit); + const started = process.hrtime.bigint(); + const elapsedMs = () => Number((process.hrtime.bigint() - started) / 1000000n); + const describeOwner = (contender, participant) => contender + ? `live owner pid=${contender.pid} ticket=${contender.ticket ?? "choosing"} created_at=${contender.createdAt}` + : `unreadable owner entry ${participant.slice(participant.lastIndexOf("/") + 1)}`; + try { + let maxTicket = 0; + for (const participant of lockParticipants(queuePath)) { + if (participant === participantPath) + continue; + const contender = readLockOwner(participant); + if (contender && !ownerIsLive(contender)) { + rmSync(participant, { recursive: true, force: true }); + continue; + } + if (contender?.ticket) + maxTicket = Math.max(maxTicket, contender.ticket); + } + if (!Number.isSafeInteger(maxTicket + 1)) { + throw new Error("proof-stats run lock ticket space is exhausted"); + } + owner.choosing = false; + owner.ticket = maxTicket + 1; + writeLockOwner(participantPath, owner); + let blocker = "another owner"; + while (elapsedMs() <= timeoutMs) { + blocker = "another owner"; + let blocked = false; + for (const participant of lockParticipants(queuePath)) { + if (participant === participantPath) + continue; + const contender = readLockOwner(participant); + if (contender && !ownerIsLive(contender)) { + rmSync(participant, { recursive: true, force: true }); + continue; + } + if (!contender) { + blocked = true; + blocker = describeOwner(null, participant); + break; + } + if (contender.choosing || + contender.ticket === null || + contender.ticket < owner.ticket || + (contender.ticket === owner.ticket && contender.token < owner.token)) { + blocked = true; + blocker = describeOwner(contender, participant); + break; + } + } + if (!blocked) { + return { queuePath, release: releaseEntry }; + } + const remaining = timeoutMs - elapsedMs(); + if (remaining <= 0) + break; + Atomics.wait(sleepArray, 0, 0, Math.min(effectivePollMs, remaining)); + } + throw new Error(`proof-stats run lock timed out after ${timeoutMs}ms; ${blocker}`); + } + catch (error) { + releaseEntry(); + throw error; + } } -const stats = { - generatedAt: new Date().toISOString(), - tests: { - total: j.numTotalTests, - files: j.testResults.length, - policy: "all platform-applicable cases must pass; platform-specific cases may skip", - }, - tla: { - invariants: (cfg.match(/^INVARIANT/gm) || []).length, - composedLifecycleInvariants: (composedLifecycleCfg.match(/^INVARIANT/gm) || []).length, - checker: "TLC 2.19", - }, - formalScenarioConformance: { - method: scenarioConformance.method, - models: scenarioConformance.summary.models.length, - claims: scenarioConformance.summary.claims.length, - scenarios: scenarioConformance.summary.scenarios, - soundScenarios: scenarioConformance.summary.sound_scenarios, - pairedNegativeControls: scenarioConformance.summary.paired_negative_controls, - requiredModelActions: scenarioConformance.summary.required_model_actions, - coveredModelActions: scenarioConformance.summary.covered_model_actions, - actionCompleteModels: scenarioConformance.summary.action_complete_models.length, - formalMutationOperators: scenarioConformance.summary.formal_mutation_operators, - evidenceSha256: createHash("sha256") - .update(scenarioConformanceBytes) - .digest("hex"), - boundary: "selected model/runtime scenarios under explicit projection relations; not a mechanized implementation refinement proof", - }, - formalEvidenceCoverage, - alloy: { - // facts: the core relational model (ep_relations). assertions: total across - // ALL FOUR models that execute headless in CI (ep_relations + ep_federation - // + ep_quorum + ep_delegation, via formal/AlloyCheck.java in alloy.yml). The - // count was ep_relations+ep_federation only before ep_quorum/ep_delegation - // were CI-gated; docs state it as a floor, so widening it needs no doc edit. - facts: (als.match(/^fact/gm) || []).length, - assertions: (als.match(/^assert/gm) || []).length + - (fedAls.match(/^assert/gm) || []).length + - (quorumAls.match(/^assert/gm) || []).length + - (delegationAls.match(/^assert/gm) || []).length, - version: "6.2.0 (CI)", - }, - tamarin: { - model: "EP-RELIANCE-COMPOSED-v2 + EP-SIX-CLAIM-COMPOSED-v1", - models: 2, - verifiedObligations: tamarinVerified, - allTraceObligations: tamarinAllTraceObligations, - existsTraceWitnesses: tamarinExistsTraceWitnesses, - deliberatelyUnsafeCounterexamples: tamarinCounterexamples, - version: tamarinVersion, - modelSha256: tamarinModelHashes[0], - focusedModelSha256: tamarinModelHashes[1], - }, - securityCase: { - status: securityCase.execution.status, - claims: securityCase.claim_count, - evidenceFiles: securityCase.evidence_file_count, - evidenceBundleSha256: securityCase.evidence_bundle_sha256, - }, - conformance: { - suites: conformance.totals.suites, - vectors: conformance.totals.vectors, - referencePorts: conformance.totals.implementations, - relationship: "same_team_ports", - }, - externalImplementation: { - language: external.implementation.language, - vectors: external.conformance.vectors, - hostilityCases: external.hostility.structured_cases + external.hostility.raw_parser_cases, - strictCleanRoomAcceptance: external.construction_evidence.strict_clean_room_acceptance, - }, - redTeamCases: (redTeam.match(/^### /gm) || []).length, -}; -if (check) { - const current = JSON.parse(readFileSync("lib/proof-stats.json", "utf8")); - const measured = { ...stats }; - /** @type {Record} */ - const recorded = { ...current }; - delete measured.generatedAt; - delete recorded.generatedAt; - if (!isDeepStrictEqual(measured, recorded)) { - console.error("PROOF STATS: FAIL — lib/proof-stats.json does not match the executed suite"); - console.error(JSON.stringify({ recorded, measured }, null, 2)); - console.error("\nFix: run `npm run sync:proof-stats` and commit lib/proof-stats.json."); - console.error("(Docs state the count as a floor, so no doc edits are needed — only this one file.)"); - process.exitCode = 1; +function generateProofStats() { + const check = process.argv.includes("--check"); + const bootstrapDerivedEvidence = process.argv.includes("--bootstrap-derived-evidence"); + const securityCasePreverified = process.argv.includes("--security-case-preverified"); + if (check && bootstrapDerivedEvidence) { + throw new Error("bootstrap-derived-evidence cannot be used in check mode"); + } + if (securityCasePreverified) { + if (!check) { + throw new Error("security-case-preverified is check-mode only"); + } + if (process.env.GITHUB_ACTIONS !== "true") { + throw new Error("security-case-preverified requires GitHub Actions"); + } + const expectedSha = process.env.SECURITY_CASE_PREVERIFIED_SHA || ""; + const head = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }); + if (head.error) + throw head.error; + if (head.status !== 0 || !/^[0-9a-f]{40}$/.test(expectedSha) + || head.stdout.trim() !== expectedSha + || process.env.GITHUB_SHA !== expectedSha) { + throw new Error("security-case-preverified SHA does not match the checkout"); + } + } + let j; + if (bootstrapDerivedEvidence) { + // Some tests deliberately compare generated proof/LLM surfaces with the + // security case. When a claim is added, those tests must remain red until a + // ground-truth-derived candidate exists. This one-shot bootstrap updates only + // the derived evidence fields while retaining the last measured test count; + // the normal unflagged run must follow and replaces that count from a complete + // passing Vitest report. CI never uses this mode. + const recorded = JSON.parse(readFileSync("lib/proof-stats.json", "utf8")); + if (!Number.isSafeInteger(recorded.tests?.total) || + !Number.isSafeInteger(recorded.tests?.files) || + recorded.tests.total < 1 || + recorded.tests.files < 1) { + throw new Error("recorded proof stats do not contain a reusable test measurement"); + } + j = { + numTotalTests: recorded.tests.total, + testResults: Array.from({ length: recorded.tests.files }, () => ({})), + }; + } + else { + const reportDir = mkdtempSync(join(tmpdir(), "ep-proof-stats-")); + const reportPath = join(reportDir, "vitest.json"); + const execution = spawnSync("npx", [ + "vitest", + "run", + "--silent", + // Proof-stat measurement runs the complete integration inventory, including + // tests that launch real git, archive, and protocol-check subprocesses. + // Bound worker fan-out and give each case an explicit integration budget so + // CPU starvation cannot turn Vitest's five-second unit default into a false + // governed-evidence failure. The run still fails closed on any timeout. + "--maxWorkers=4", + "--testTimeout=60000", + "--hookTimeout=60000", + "--reporter=json", + `--outputFile=${reportPath}`, + ], { + encoding: "utf8", + maxBuffer: 1e9, + }); + if (execution.error) + throw execution.error; + if (!existsSync(reportPath)) { + throw new Error(`Vitest did not write its JSON report:\n${execution.stderr || execution.stdout}`); + } + j = JSON.parse(readFileSync(reportPath, "utf8")); + rmSync(reportDir, { recursive: true, force: true }); + if (execution.status !== 0) { + console.error("PROOF STATS: FAIL — the measured test run did not pass"); + for (const result of j.testResults + .filter((item) => item.status === "failed") + .slice(0, 20)) { + console.error(result.name); + for (const assertion of result.assertionResults + .filter((item) => item.status === "failed") + .slice(0, 10)) { + console.error(` ${assertion.fullName}`); + for (const message of assertion.failureMessages.slice(0, 2)) + console.error(` ${message.split("\n")[0]}`); + } + } + process.exit(1); + } + } + if (!securityCasePreverified) { + const liveSecurityCase = spawnSync(process.execPath, [ + "--import", + "./scripts/ts-loader/register.mjs", + "scripts/verify-security-case.mjs", + "--execute", + ], { + encoding: "utf8", + maxBuffer: 1e9, + }); + if (liveSecurityCase.error) + throw liveSecurityCase.error; + if (liveSecurityCase.status !== 0) { + throw new Error(`The live machine-verifiable security case failed:\n${liveSecurityCase.stderr || liveSecurityCase.stdout}`); + } + } + const cfg = readFileSync("formal/ep_handshake.cfg", "utf8"); + const composedLifecycleCfg = readFileSync("formal/ep_composed_trust_lifecycle.cfg", "utf8"); + const als = readFileSync("formal/ep_relations.als", "utf8"); + const fedAls = readFileSync("formal/ep_federation.als", "utf8"); + const quorumAls = readFileSync("formal/ep_quorum.als", "utf8"); + const delegationAls = readFileSync("formal/ep_delegation.als", "utf8"); + const redTeam = readFileSync("docs/conformance/RED_TEAM_CASES.md", "utf8"); + const tamarinSummary = readFileSync("formal/tamarin/results/ep_reliance_composed.summary.txt", "utf8"); + const conformance = JSON.parse(readFileSync("conformance/conformance-manifest.json", "utf8")); + const external = JSON.parse(readFileSync("conformance/external/rust-cleanroom-jdieselny.v1.json", "utf8")); + const securityCase = JSON.parse(readFileSync("security/security-case.json", "utf8")); + const claimSource = JSON.parse(readFileSync("security/claims.v1.json", "utf8")); + const scenarioConformanceBytes = readFileSync("formal/results/formal-runtime-scenario-conformance.v2.json"); + const scenarioConformance = JSON.parse(scenarioConformanceBytes.toString("utf8")); + const tamarinVerifiedRows = [ + ...tamarinSummary.matchAll(/^\s{2}\S.*\((all-traces|exists-trace)\):\s+verified\b.*$/gm), + ]; + const tamarinVerified = tamarinVerifiedRows.length; + const tamarinAllTraceObligations = tamarinVerifiedRows.filter((match) => match[1] === "all-traces").length; + const tamarinExistsTraceWitnesses = tamarinVerifiedRows.filter((match) => match[1] === "exists-trace").length; + const tamarinCounterexamples = (tamarinSummary.match(/^\s{2}\S.*:\s+falsified\s+-\s+found trace\b.*$/gm) || []).length; + const tamarinVersion = tamarinSummary.match(/^Tamarin:\s+(.+)$/m)?.[1]; + const tamarinModelHashes = [ + ...tamarinSummary.matchAll(/^Model SHA-256:\s+([a-f0-9]{64})$/gm), + ].map((match) => match[1]); + const tamarinRunnerHash = tamarinSummary.match(/^Runner SHA-256:\s+([a-f0-9]{64})$/m)?.[1]; + const currentTamarinModelHashes = [ + "formal/tamarin/ep_reliance_composed.spthy", + "formal/tamarin/ep_six_claim_composed.spthy", + ].map((file) => createHash("sha256").update(readFileSync(file)).digest("hex")); + const currentTamarinRunnerHash = createHash("sha256") + .update(readFileSync("formal/tamarin/run-composed.sh")) + .digest("hex"); + if (!tamarinVersion || + tamarinModelHashes.length !== 2 || + !isDeepStrictEqual(tamarinModelHashes, currentTamarinModelHashes) || + tamarinRunnerHash !== currentTamarinRunnerHash || + tamarinVerified === 0 || + tamarinAllTraceObligations === 0 || + tamarinExistsTraceWitnesses === 0 || + tamarinCounterexamples === 0) { + throw new Error("The composed Tamarin proof summary is incomplete or not bound to the current model and runner bytes"); + } + if (securityCase.execution?.status !== "passed") { + throw new Error("The machine-verifiable security case is not passing"); + } + if (scenarioConformance["@version"] !== + "EP-SELECTED-SCENARIO-CONFORMANCE-EVIDENCE-v2" || + scenarioConformance.method !== "bounded_selected_scenario_conformance" || + !Array.isArray(scenarioConformance.scenarios) || + scenarioConformance.scenarios.length === 0 || + !scenarioConformance.scenarios.every((scenario) => scenario.matched === true) || + scenarioConformance.summary?.paired_negative_controls < 1 || + !Number.isSafeInteger(scenarioConformance.summary?.required_model_actions) || + scenarioConformance.summary.required_model_actions < 1 || + scenarioConformance.summary.covered_model_actions !== + scenarioConformance.summary.required_model_actions || + !Array.isArray(scenarioConformance.summary?.action_complete_models) || + scenarioConformance.summary.action_complete_models.length < 1) { + throw new Error("The formal runtime selected-scenario conformance evidence is missing or incomplete"); + } + if (!conformance.implementations?.every((item) => item.relationship === "one_team_port")) { + throw new Error("Reference verifier relationship is not uniformly one_team_port"); + } + if (external.conformance?.status !== "pass") { + throw new Error("The pinned external implementation does not report conformance pass"); + } + const FORMAL_EVIDENCE_CATEGORIES = Object.freeze([ + "verifiedFormalObligations", + "boundedRuntimeTraced", + "boundedFormalEvidence", + "partialSymbolicCoverage", + "executableOperationalEvidence", + ]); + function classifyFormalEvidence(formal) { + if (formal.length > 0 && + formal.every((entry) => entry.status === "verified")) { + return "verifiedFormalObligations"; + } + if (formal.some((entry) => entry.status === "partial" && + entry.method?.startsWith("bounded_") && + entry.scenario_coverage === "selected" && + Array.isArray(entry.covered_actions) && + entry.covered_actions.length > 0 && + Array.isArray(entry.covered_obligations) && + entry.covered_obligations.length > 0 && + typeof entry.scenario_evidence === "string" && + entry.scenario_evidence.length > 0 && + typeof entry.scenario_runner === "string" && + entry.scenario_runner.length > 0 && + typeof entry.conformance_evidence === "string" && + entry.conformance_evidence.length > 0)) { + return "boundedRuntimeTraced"; + } + if (formal.some((entry) => entry.status === "partial" && + [ + "bounded_tla_model_checking", + "bounded_exhaustive_state_exploration", + ].includes(entry.method))) { + return "boundedFormalEvidence"; + } + if (formal.some((entry) => entry.status === "partial" || entry.status === "verified")) { + return "partialSymbolicCoverage"; + } + return "executableOperationalEvidence"; + } + const formalEvidenceCoverage = Object.fromEntries(FORMAL_EVIDENCE_CATEGORIES.map((category) => [ + category, + { count: 0, claimIds: [] }, + ])); + for (const claim of claimSource.claims ?? []) { + const category = classifyFormalEvidence(claim.formal ?? []); + formalEvidenceCoverage[category].count += 1; + formalEvidenceCoverage[category].claimIds.push(claim.claim_id); + } + for (const category of FORMAL_EVIDENCE_CATEGORIES) { + formalEvidenceCoverage[category].claimIds.sort(); + } + const classifiedClaimCount = FORMAL_EVIDENCE_CATEGORIES.reduce((total, category) => total + formalEvidenceCoverage[category].count, 0); + if (classifiedClaimCount !== claimSource.claims?.length || + classifiedClaimCount !== securityCase.claim_count) { + throw new Error("The formal evidence taxonomy does not cover the complete security claim inventory"); + } + const recordedRuntimeTracedClaims = formalEvidenceCoverage.boundedRuntimeTraced.claimIds; + const executedRuntimeTracedClaims = [ + ...scenarioConformance.summary.claims, + ].sort(); + if (!isDeepStrictEqual(recordedRuntimeTracedClaims, executedRuntimeTracedClaims)) { + throw new Error("Bounded runtime-traced claim metadata does not match the executed selected-scenario evidence"); + } + const stats = { + generatedAt: new Date().toISOString(), + tests: { + total: j.numTotalTests, + files: j.testResults.length, + policy: "all platform-applicable cases must pass; platform-specific cases may skip", + }, + tla: { + invariants: (cfg.match(/^INVARIANT/gm) || []).length, + composedLifecycleInvariants: (composedLifecycleCfg.match(/^INVARIANT/gm) || []).length, + checker: "TLC 2.19", + }, + formalScenarioConformance: { + method: scenarioConformance.method, + models: scenarioConformance.summary.models.length, + claims: scenarioConformance.summary.claims.length, + scenarios: scenarioConformance.summary.scenarios, + soundScenarios: scenarioConformance.summary.sound_scenarios, + pairedNegativeControls: scenarioConformance.summary.paired_negative_controls, + requiredModelActions: scenarioConformance.summary.required_model_actions, + coveredModelActions: scenarioConformance.summary.covered_model_actions, + actionCompleteModels: scenarioConformance.summary.action_complete_models.length, + formalMutationOperators: scenarioConformance.summary.formal_mutation_operators, + evidenceSha256: createHash("sha256") + .update(scenarioConformanceBytes) + .digest("hex"), + boundary: "selected model/runtime scenarios under explicit projection relations; not a mechanized implementation refinement proof", + }, + formalEvidenceCoverage, + alloy: { + // facts: the core relational model (ep_relations). assertions: total across + // ALL FOUR models that execute headless in CI (ep_relations + ep_federation + // + ep_quorum + ep_delegation, via formal/AlloyCheck.java in alloy.yml). The + // count was ep_relations+ep_federation only before ep_quorum/ep_delegation + // were CI-gated; docs state it as a floor, so widening it needs no doc edit. + facts: (als.match(/^fact/gm) || []).length, + assertions: (als.match(/^assert/gm) || []).length + + (fedAls.match(/^assert/gm) || []).length + + (quorumAls.match(/^assert/gm) || []).length + + (delegationAls.match(/^assert/gm) || []).length, + version: "6.2.0 (CI)", + }, + tamarin: { + model: "EP-RELIANCE-COMPOSED-v2 + EP-SIX-CLAIM-COMPOSED-v1", + models: 2, + verifiedObligations: tamarinVerified, + allTraceObligations: tamarinAllTraceObligations, + existsTraceWitnesses: tamarinExistsTraceWitnesses, + deliberatelyUnsafeCounterexamples: tamarinCounterexamples, + version: tamarinVersion, + modelSha256: tamarinModelHashes[0], + focusedModelSha256: tamarinModelHashes[1], + }, + securityCase: { + status: securityCase.execution.status, + claims: securityCase.claim_count, + evidenceFiles: securityCase.evidence_file_count, + evidenceBundleSha256: securityCase.evidence_bundle_sha256, + }, + conformance: { + suites: conformance.totals.suites, + vectors: conformance.totals.vectors, + referencePorts: conformance.totals.implementations, + relationship: "same_team_ports", + }, + externalImplementation: { + language: external.implementation.language, + vectors: external.conformance.vectors, + hostilityCases: external.hostility.structured_cases + external.hostility.raw_parser_cases, + strictCleanRoomAcceptance: external.construction_evidence.strict_clean_room_acceptance, + }, + redTeamCases: (redTeam.match(/^### /gm) || []).length, + }; + if (check) { + const current = JSON.parse(readFileSync("lib/proof-stats.json", "utf8")); + const measured = { ...stats }; + /** @type {Record} */ + const recorded = { ...current }; + delete measured.generatedAt; + delete recorded.generatedAt; + if (!isDeepStrictEqual(measured, recorded)) { + console.error("PROOF STATS: FAIL — lib/proof-stats.json does not match the executed suite"); + console.error(JSON.stringify({ recorded, measured }, null, 2)); + console.error("\nFix: run `npm run sync:proof-stats` and commit lib/proof-stats.json."); + console.error("(Docs state the count as a floor, so no doc edits are needed — only this one file.)"); + process.exitCode = 1; + } + else { + console.log(`PROOF STATS: PASS (${stats.tests.total} test cases, ${stats.tests.files} files; ${stats.tamarin.verifiedObligations} verified Tamarin lemmas; ${stats.securityCase.claims} executable security claims; ${stats.conformance.vectors} conformance vectors; ${stats.externalImplementation.hostilityCases} external hostility cases)`); + } } else { - console.log(`PROOF STATS: PASS (${stats.tests.total} test cases, ${stats.tests.files} files; ${stats.tamarin.verifiedObligations} verified Tamarin lemmas; ${stats.securityCase.claims} executable security claims; ${stats.conformance.vectors} conformance vectors; ${stats.externalImplementation.hostilityCases} external hostility cases)`); + writeFileSync("lib/proof-stats.json", `${JSON.stringify(stats, null, 2)}\n`); + console.log(stats); } } -else { - writeFileSync("lib/proof-stats.json", `${JSON.stringify(stats, null, 2)}\n`); - console.log(stats); +const scriptPath = fileURLToPath(import.meta.url); +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + const proofStatsRunLock = acquireProofStatsRunLock(); + try { + generateProofStats(); + } + finally { + proofStatsRunLock.release(); + } } diff --git a/scripts/generate-proof-stats.mts b/scripts/generate-proof-stats.mts index e3477db54..4d9141355 100644 --- a/scripts/generate-proof-stats.mts +++ b/scripts/generate-proof-stats.mts @@ -2,18 +2,342 @@ // SPDX-License-Identifier: Apache-2.0 // Regenerates lib/proof-stats.json from ground truth or checks it in CI. import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { existsSync, + linkSync, + lstatSync, + mkdirSync, mkdtempSync, + readdirSync, readFileSync, + realpathSync, + renameSync, rmSync, writeFileSync, } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { constants, hostname, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; +const PROOF_STATS_LOCK_TIMEOUT_MS = 30 * 60 * 1000; +const PROOF_STATS_LOCK_POLL_MS = 250; +const PROOF_STATS_LOCK_VERSION = 1; +const sleepArray = new Int32Array(new SharedArrayBuffer(4)); + +interface ProofStatsLockOwner { + version: number; + token: string; + pid: number; + host: string; + processIdentity: string | null; + createdAt: string; + choosing: boolean; + ticket: number | null; +} + +export interface ProofStatsRunLock { + queuePath: string; + release: () => boolean; +} + +interface ProofStatsRunLockOptions { + cwd?: string; + timeoutMs?: number; + pollMs?: number; +} + +function processIdentity(pid: number): string | null { + if (process.platform === "win32") return null; + const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + maxBuffer: 64 * 1024, + timeout: 2_000, + }); + if (result.status !== 0 || result.error) return null; + const started = result.stdout.trim().replace(/\s+/g, " "); + return started || null; +} + +function ownerIsLive(owner: ProofStatsLockOwner): boolean { + if (owner.host !== hostname()) return true; + try { + process.kill(owner.pid, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return false; + return true; + } + const observedIdentity = processIdentity(owner.pid); + return !owner.processIdentity || !observedIdentity || + owner.processIdentity === observedIdentity; +} + +function readLockOwner(participantPath: string): ProofStatsLockOwner | null { + try { + const parsed = JSON.parse( + readFileSync(join(participantPath, "owner.json"), "utf8"), + ) as Partial; + const token = participantPath.slice(participantPath.lastIndexOf("-") + 1); + if ( + parsed.version !== PROOF_STATS_LOCK_VERSION || + parsed.token !== token || + !Number.isSafeInteger(parsed.pid) || + (parsed.pid as number) < 1 || + parsed.host !== hostname() || + (parsed.processIdentity !== null && + typeof parsed.processIdentity !== "string") || + typeof parsed.createdAt !== "string" || + typeof parsed.choosing !== "boolean" || + (parsed.ticket !== null && + (!Number.isSafeInteger(parsed.ticket) || (parsed.ticket as number) < 1)) + ) { + return null; + } + return parsed as ProofStatsLockOwner; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + return null; + } +} + +function writeLockOwner(participantPath: string, owner: ProofStatsLockOwner): void { + const ownerPath = join(participantPath, "owner.json"); + const temporaryPath = join( + participantPath, + `.owner-${randomBytes(8).toString("hex")}.tmp`, + ); + writeFileSync(temporaryPath, `${JSON.stringify(owner)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + renameSync(temporaryPath, ownerPath); +} + +function lockParticipants(queuePath: string): string[] { + return readdirSync(queuePath) + .filter((name) => /^participant-[a-f0-9]{32}$/.test(name)) + .sort() + .map((name) => join(queuePath, name)); +} + +export function resolveProofStatsLockQueue(cwd: string = process.cwd()): string { + const commonDirectory = spawnSync( + "git", + ["rev-parse", "--git-common-dir"], + { + cwd, + encoding: "utf8", + maxBuffer: 64 * 1024, + timeout: 5_000, + }, + ); + if (commonDirectory.error || commonDirectory.status !== 0) { + throw new Error( + `proof-stats run lock could not resolve the shared Git directory: ${ + commonDirectory.error?.message || commonDirectory.stderr || "unknown error" + }`, + ); + } + const rawCommonDirectory = commonDirectory.stdout.trim(); + if (!rawCommonDirectory) { + throw new Error("proof-stats run lock resolved an empty Git common directory"); + } + const absoluteCommonDirectory = realpathSync(resolve(cwd, rawCommonDirectory)); + const hostScope = createHash("sha256") + .update(hostname()) + .digest("hex") + .slice(0, 16); + return join( + absoluteCommonDirectory, + "emilia-run-locks", + hostScope, + "proof-stats-run-v1", + ); +} + +export function acquireProofStatsRunLock({ + cwd = process.cwd(), + timeoutMs = PROOF_STATS_LOCK_TIMEOUT_MS, + pollMs, +}: ProofStatsRunLockOptions = {}): ProofStatsRunLock { + const effectivePollMs = pollMs ?? Math.min( + PROOF_STATS_LOCK_POLL_MS, + timeoutMs, + ); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { + throw new Error("proof-stats run lock timeout must be a positive integer"); + } + if ( + !Number.isSafeInteger(effectivePollMs) || + effectivePollMs < 1 || + effectivePollMs > timeoutMs + ) { + throw new Error( + "proof-stats run lock poll interval must be a positive integer no greater than its timeout", + ); + } + + const queuePath = resolveProofStatsLockQueue(cwd); + mkdirSync(queuePath, { recursive: true, mode: 0o700 }); + if (!lstatSync(queuePath).isDirectory()) { + throw new Error("proof-stats run lock queue is not a directory"); + } + const versionPath = join(queuePath, ".version"); + const versionStagingPath = join( + queuePath, + `.version-${randomBytes(16).toString("hex")}.tmp`, + ); + writeFileSync(versionStagingPath, `${PROOF_STATS_LOCK_VERSION}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + try { + linkSync(versionStagingPath, versionPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } finally { + rmSync(versionStagingPath, { force: true }); + } + try { + if (readFileSync(versionPath, "utf8") !== `${PROOF_STATS_LOCK_VERSION}\n`) { + throw new Error("proof-stats run lock queue has an unsupported version"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("proof-stats run lock queue lost its version marker"); + } + throw error; + } + + const token = randomBytes(16).toString("hex"); + const participantPath = join(queuePath, `participant-${token}`); + const stagingPath = join(queuePath, `.staging-${token}`); + const owner: ProofStatsLockOwner = { + version: PROOF_STATS_LOCK_VERSION, + token, + pid: process.pid, + host: hostname(), + processIdentity: processIdentity(process.pid), + createdAt: new Date().toISOString(), + choosing: true, + ticket: null, + }; + mkdirSync(stagingPath, { mode: 0o700 }); + try { + writeLockOwner(stagingPath, owner); + renameSync(stagingPath, participantPath); + } catch (error) { + rmSync(stagingPath, { recursive: true, force: true }); + throw error; + } + + let released = false; + const releaseEntry = (): boolean => { + if (released) return false; + released = true; + process.removeListener("exit", onExit); + for (const [signal, listener] of signalListeners) { + process.removeListener(signal, listener); + } + const recorded = readLockOwner(participantPath); + if (recorded?.token === token) { + rmSync(participantPath, { recursive: true, force: true }); + } + return true; + }; + const onExit = (): void => { + const recorded = readLockOwner(participantPath); + if (recorded?.token === token) { + rmSync(participantPath, { recursive: true, force: true }); + } + }; + const signalListeners = new Map void>(); + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as NodeJS.Signals[]) { + const listener = (): void => { + releaseEntry(); + process.exit(128 + constants.signals[signal]); + }; + signalListeners.set(signal, listener); + process.once(signal, listener); + } + process.once("exit", onExit); + + const started = process.hrtime.bigint(); + const elapsedMs = (): number => + Number((process.hrtime.bigint() - started) / 1_000_000n); + const describeOwner = ( + contender: ProofStatsLockOwner | null, + participant: string, + ): string => contender + ? `live owner pid=${contender.pid} ticket=${contender.ticket ?? "choosing"} created_at=${contender.createdAt}` + : `unreadable owner entry ${participant.slice(participant.lastIndexOf("/") + 1)}`; + + try { + let maxTicket = 0; + for (const participant of lockParticipants(queuePath)) { + if (participant === participantPath) continue; + const contender = readLockOwner(participant); + if (contender && !ownerIsLive(contender)) { + rmSync(participant, { recursive: true, force: true }); + continue; + } + if (contender?.ticket) maxTicket = Math.max(maxTicket, contender.ticket); + } + if (!Number.isSafeInteger(maxTicket + 1)) { + throw new Error("proof-stats run lock ticket space is exhausted"); + } + owner.choosing = false; + owner.ticket = maxTicket + 1; + writeLockOwner(participantPath, owner); + + let blocker = "another owner"; + while (elapsedMs() <= timeoutMs) { + blocker = "another owner"; + let blocked = false; + for (const participant of lockParticipants(queuePath)) { + if (participant === participantPath) continue; + const contender = readLockOwner(participant); + if (contender && !ownerIsLive(contender)) { + rmSync(participant, { recursive: true, force: true }); + continue; + } + if (!contender) { + blocked = true; + blocker = describeOwner(null, participant); + break; + } + if ( + contender.choosing || + contender.ticket === null || + contender.ticket < owner.ticket || + (contender.ticket === owner.ticket && contender.token < owner.token) + ) { + blocked = true; + blocker = describeOwner(contender, participant); + break; + } + } + if (!blocked) { + return { queuePath, release: releaseEntry }; + } + const remaining = timeoutMs - elapsedMs(); + if (remaining <= 0) break; + Atomics.wait(sleepArray, 0, 0, Math.min(effectivePollMs, remaining)); + } + throw new Error( + `proof-stats run lock timed out after ${timeoutMs}ms; ${blocker}`, + ); + } catch (error) { + releaseEntry(); + throw error; + } +} + +function generateProofStats(): void { const check: boolean = process.argv.includes("--check"); const bootstrapDerivedEvidence: boolean = process.argv.includes( "--bootstrap-derived-evidence", @@ -505,3 +829,14 @@ if (check) { writeFileSync("lib/proof-stats.json", `${JSON.stringify(stats, null, 2)}\n`); console.log(stats); } +} + +const scriptPath = fileURLToPath(import.meta.url); +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + const proofStatsRunLock = acquireProofStatsRunLock(); + try { + generateProofStats(); + } finally { + proofStatsRunLock.release(); + } +} diff --git a/scripts/verify-reproducible-package.mjs b/scripts/verify-reproducible-package.mjs index d46f8341c..113182478 100644 --- a/scripts/verify-reproducible-package.mjs +++ b/scripts/verify-reproducible-package.mjs @@ -173,6 +173,41 @@ export function validatePackedPackageIdentity(archive, expectedName, expectedVer packageJsonSha256: crypto.createHash('sha256').update(actualBytes).digest('hex'), }; } +const SPAWN_DIAGNOSTIC_LIMIT = 8_192; +function boundedSpawnText(value) { + const raw = Buffer.isBuffer(value) + ? value.toString('utf8') + : typeof value === 'string' + ? value + : value === undefined || value === null + ? '' + : String(value); + const redacted = raw + .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/giu, '$1[redacted]@') + .replace(/([?&](?:access_token|auth|credential|key|password|secret|signature|sig|token|x-amz-(?:credential|security-token|signature)|x-goog-(?:credential|signature))=)[^&\s]+/giu, '$1[redacted]') + .replace(/\bBearer\s+[a-z0-9._~+/-]+=*/giu, 'Bearer [redacted]') + .replace(/\b(?:npm|gh[oprsu])_[a-z0-9]{8,}\b/giu, '[redacted-token]') + .replace(/((?:authorization|_authToken)\s*[:=]\s*)[^\r\n]*/giu, '$1[redacted]') + .replace(/((?:GH_TOKEN|GITHUB_TOKEN|NODE_AUTH_TOKEN|NPM_TOKEN)\s*=\s*)\S+/giu, '$1[redacted]'); + if (!redacted) + return '(empty)'; + if (redacted.length <= SPAWN_DIAGNOSTIC_LIMIT) + return redacted; + return `[truncated to last ${SPAWN_DIAGNOSTIC_LIMIT} characters]\n${redacted.slice(-SPAWN_DIAGNOSTIC_LIMIT)}`; +} +export function formatSpawnFailure(label, result) { + const spawnError = result?.error + ? `${result.error.name || 'Error'}${result.error.code ? ` [${result.error.code}]` : ''}: ${result.error.message || String(result.error)}` + : '(none)'; + return [ + `${label} failed`, + `status: ${result?.status ?? 'null'}`, + `signal: ${result?.signal ?? 'null'}`, + `spawn error: ${boundedSpawnText(spawnError)}`, + `stdout: ${boundedSpawnText(result?.stdout)}`, + `stderr: ${boundedSpawnText(result?.stderr)}`, + ].join('\n'); +} /** * @param {string} [packagePath] * @param {{ outDir?: string | null, repositoryRoot?: string | null, reviewedCommit?: string | null }} [options] @@ -187,379 +222,383 @@ export function verifyReproduciblePackage(packagePath = 'packages/verify', { out throw new Error('package path must be a repository-relative directory'); } const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ep-repro-pack-')); - function run(command, args, label, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd || root, - encoding: options.encoding ?? 'utf8', - env: options.env, - maxBuffer: 128 * 1024 * 1024, - }); - if (result.status !== 0) { - throw new Error(`${label} failed:\n${result.stderr || result.stdout}`); - } - return result; - } - const objectFormat = String(run(git, ['rev-parse', '--show-object-format'], 'git object-format lookup').stdout).trim(); - const objectLength = objectFormat === 'sha1' ? 40 : objectFormat === 'sha256' ? 64 : 0; - if (!objectLength) - throw new Error(`unsupported git object format: ${objectFormat}`); - if (reviewedCommit !== null - && !new RegExp(`^[0-9a-f]{${objectLength}}$`, 'u').test(reviewedCommit)) { - throw new Error('reviewed commit must be an exact full-length Git object id, not a ref'); - } - const commit = String(run(git, ['rev-parse', '--verify', `${reviewedCommit || 'HEAD'}^{commit}`], 'reviewed commit resolution').stdout).trim(); - if (!new RegExp(`^[0-9a-f]{${objectLength}}$`, 'u').test(commit) - || (reviewedCommit !== null && commit !== reviewedCommit)) { - throw new Error('reviewed commit did not resolve to the exact requested Git object'); - } - for (const args of [ - ['diff', '--quiet', commit, '--'], - ['diff', '--cached', '--quiet', commit, '--'], - ]) { - const comparison = spawnSync(git, args, { cwd: root, encoding: 'utf8' }); - if (comparison.status !== 0) { - throw new Error('working checkout differs from the reviewed commit'); + try { + function run(command, args, label, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || root, + encoding: options.encoding ?? 'utf8', + env: options.env, + maxBuffer: 128 * 1024 * 1024, + }); + if (result.status !== 0) { + throw new Error(`${label} failed:\n${result.stderr || result.stdout}`); + } + return result; } - } - const treeOid = String(run(git, ['rev-parse', '--verify', `${commit}^{tree}`], 'reviewed tree resolution').stdout).trim(); - const packageTreeOid = packageRelative - ? String(run(git, ['rev-parse', '--verify', `${commit}:${packageRelative}`], 'reviewed package-tree resolution').stdout).trim() - : treeOid; - const sourceTree = run(git, ['ls-tree', '-rz', '--full-tree', commit], 'reviewed source-tree inventory', { encoding: 'buffer' }).stdout; - const trackedPaths = new Set(); - for (const rawRecord of sourceTree.toString('utf8').split('\0').filter(Boolean)) { - const match = rawRecord.match(/^(\d{6}) ([a-z]+) ([0-9a-f]+)\t(.+)$/u); - if (!match) - throw new Error(`malformed reviewed Git tree record: ${rawRecord}`); - const [, mode, type, , relative] = match; - const segments = relative.split('/'); - if (relative.startsWith('/') - || relative.includes('\\') - || segments.some((segment) => !segment || segment === '.' || segment === '..')) { - throw new Error(`reviewed Git tree contains an unsafe path: ${relative}`); + const objectFormat = String(run(git, ['rev-parse', '--show-object-format'], 'git object-format lookup').stdout).trim(); + const objectLength = objectFormat === 'sha1' ? 40 : objectFormat === 'sha256' ? 64 : 0; + if (!objectLength) + throw new Error(`unsupported git object format: ${objectFormat}`); + if (reviewedCommit !== null + && !new RegExp(`^[0-9a-f]{${objectLength}}$`, 'u').test(reviewedCommit)) { + throw new Error('reviewed commit must be an exact full-length Git object id, not a ref'); } - if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) { - throw new Error(`reviewed Git tree contains a symlink, submodule, or unsupported mode: ${relative} (${mode} ${type})`); + const commit = String(run(git, ['rev-parse', '--verify', `${reviewedCommit || 'HEAD'}^{commit}`], 'reviewed commit resolution').stdout).trim(); + if (!new RegExp(`^[0-9a-f]{${objectLength}}$`, 'u').test(commit) + || (reviewedCommit !== null && commit !== reviewedCommit)) { + throw new Error('reviewed commit did not resolve to the exact requested Git object'); } - trackedPaths.add(relative); - } - const packageJsonRelative = packageRelative ? `${packageRelative}/package.json` : 'package.json'; - if (!trackedPaths.has(packageJsonRelative)) { - throw new Error(`reviewed package.json is not tracked at ${packageJsonRelative}`); - } - for (const relative of [...trackedPaths].filter((entry) => path.posix.basename(entry) === '.gitattributes')) { - const attributes = run(git, ['show', `${commit}:${relative}`], `reviewed attributes lookup for ${relative}`, { encoding: 'buffer' }).stdout; - if (/\bexport-(?:ignore|subst)\b/u.test(attributes.toString('utf8'))) { - throw new Error(`reviewed Git attributes may not transform or omit source archive bytes: ${relative}`); - } - } - const snapshotRoot = path.join(scratch, 'reviewed-source'); - const sourceArchive = path.join(scratch, 'reviewed-source.tar'); - fs.mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 }); - run(git, ['archive', '--format=tar', `--output=${sourceArchive}`, commit], 'reviewed source archive'); - const sourceArchiveBytes = fs.readFileSync(sourceArchive); - run('tar', ['-xf', sourceArchive, '-C', snapshotRoot], 'reviewed source extraction'); - const materializedPaths = new Set(); - const inspectMaterialized = (directory) => { - for (const name of fs.readdirSync(directory).sort()) { - const absolute = path.join(directory, name); - const relative = path.relative(snapshotRoot, absolute).split(path.sep).join('/'); - const stat = fs.lstatSync(absolute); - if (stat.isSymbolicLink()) - throw new Error(`reviewed source extraction produced a symlink: ${relative}`); - if (stat.isDirectory()) - inspectMaterialized(absolute); - else if (stat.isFile()) - materializedPaths.add(relative); - else - throw new Error(`reviewed source extraction produced an unsupported entry: ${relative}`); - } - }; - inspectMaterialized(snapshotRoot); - if (trackedPaths.size !== materializedPaths.size - || [...trackedPaths].some((relative) => !materializedPaths.has(relative))) { - throw new Error('reviewed source extraction does not exactly match the Git tree'); - } - const packageJsonPath = path.join(snapshotRoot, packageJsonRelative); - const packageJsonBytes = fs.readFileSync(packageJsonPath); - const metadata = JSON.parse(packageJsonBytes.toString('utf8')); - const expectedFilename = npmArtifactFilename(metadata.name, metadata.version); - const packageJsonBlobOid = String(run(git, ['rev-parse', '--verify', `${commit}:${packageJsonRelative}`], 'reviewed package.json blob resolution').stdout).trim(); - const setTreeWritable = (directory, writable) => { - for (const name of fs.readdirSync(directory)) { - const absolute = path.join(directory, name); - const stat = fs.lstatSync(absolute); - if (stat.isSymbolicLink()) - throw new Error(`isolated source contains a symlink: ${absolute}`); - if (stat.isDirectory()) { - if (writable) - fs.chmodSync(absolute, 0o755); - setTreeWritable(absolute, writable); - if (!writable) - fs.chmodSync(absolute, 0o555); + for (const args of [ + ['diff', '--quiet', commit, '--'], + ['diff', '--cached', '--quiet', commit, '--'], + ]) { + const comparison = spawnSync(git, args, { cwd: root, encoding: 'utf8' }); + if (comparison.status !== 0) { + throw new Error('working checkout differs from the reviewed commit'); } - else if (stat.isFile()) { - const executable = (stat.mode & 0o111) !== 0; - fs.chmodSync(absolute, writable ? (executable ? 0o755 : 0o644) : (executable ? 0o555 : 0o444)); + } + const treeOid = String(run(git, ['rev-parse', '--verify', `${commit}^{tree}`], 'reviewed tree resolution').stdout).trim(); + const packageTreeOid = packageRelative + ? String(run(git, ['rev-parse', '--verify', `${commit}:${packageRelative}`], 'reviewed package-tree resolution').stdout).trim() + : treeOid; + const sourceTree = run(git, ['ls-tree', '-rz', '--full-tree', commit], 'reviewed source-tree inventory', { encoding: 'buffer' }).stdout; + const trackedPaths = new Set(); + for (const rawRecord of sourceTree.toString('utf8').split('\0').filter(Boolean)) { + const match = rawRecord.match(/^(\d{6}) ([a-z]+) ([0-9a-f]+)\t(.+)$/u); + if (!match) + throw new Error(`malformed reviewed Git tree record: ${rawRecord}`); + const [, mode, type, , relative] = match; + const segments = relative.split('/'); + if (relative.startsWith('/') + || relative.includes('\\') + || segments.some((segment) => !segment || segment === '.' || segment === '..')) { + throw new Error(`reviewed Git tree contains an unsafe path: ${relative}`); } - else { - throw new Error(`isolated source contains an unsupported entry: ${absolute}`); + if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) { + throw new Error(`reviewed Git tree contains a symlink, submodule, or unsupported mode: ${relative} (${mode} ${type})`); } + trackedPaths.add(relative); } - fs.chmodSync(directory, writable ? 0o755 : 0o555); - }; - function isolatedEnv(label, ignoreScripts, workingDirectory) { - const safeLabel = label.replace(/[^a-z0-9._-]+/giu, '-'); - const environmentRoot = path.join(scratch, `${safeLabel}-environment`); - const home = path.join(environmentRoot, 'home'); - const cache = path.join(environmentRoot, 'npm-cache'); - const temporary = path.join(environmentRoot, 'tmp'); - const prefix = path.join(environmentRoot, 'npm-prefix'); - const userConfig = path.join(environmentRoot, 'npmrc'); - for (const directory of [home, cache, temporary, prefix]) { - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(userConfig, '', { mode: 0o600 }); - const controlledPath = [ - path.dirname(process.execPath), - ...(process.platform === 'win32' - ? [ - path.join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32'), - process.env.SYSTEMROOT || 'C:\\Windows', - ] - : ['/usr/bin', '/bin']), - ].join(path.delimiter); - const environment = { - HOME: home, - USERPROFILE: home, - XDG_CACHE_HOME: cache, - PATH: controlledPath, - PWD: workingDirectory, - INIT_CWD: workingDirectory, - RUNNER_TEMP: temporary, - npm_config_cache: cache, - npm_config_prefix: prefix, - npm_config_userconfig: userConfig, - npm_config_ignore_scripts: ignoreScripts ? 'true' : 'false', - npm_config_audit: 'false', - npm_config_fund: 'false', - npm_config_update_notifier: 'false', - TMPDIR: temporary, - TMP: temporary, - TEMP: temporary, - }; - for (const key of ['CI', 'LANG', 'LC_ALL', 'SOURCE_DATE_EPOCH', 'TZ']) { - if (process.env[key] !== undefined) - environment[key] = process.env[key]; + const packageJsonRelative = packageRelative ? `${packageRelative}/package.json` : 'package.json'; + if (!trackedPaths.has(packageJsonRelative)) { + throw new Error(`reviewed package.json is not tracked at ${packageJsonRelative}`); } - if (process.platform === 'win32') { - for (const key of ['COMSPEC', 'PATHEXT', 'SYSTEMROOT', 'WINDIR']) { - if (process.env[key] !== undefined) - environment[key] = process.env[key]; + for (const relative of [...trackedPaths].filter((entry) => path.posix.basename(entry) === '.gitattributes')) { + const attributes = run(git, ['show', `${commit}:${relative}`], `reviewed attributes lookup for ${relative}`, { encoding: 'buffer' }).stdout; + if (/\bexport-(?:ignore|subst)\b/u.test(attributes.toString('utf8'))) { + throw new Error(`reviewed Git attributes may not transform or omit source archive bytes: ${relative}`); } } - return environment; - } - function runPack(args, label, workingDirectory) { - const run = spawnSync(npm, ['pack', ...args, '--json'], { - cwd: workingDirectory, - encoding: 'utf8', - env: isolatedEnv(`${label}-pack`, true, workingDirectory), - }); - if (run.status !== 0) { - throw new Error(`npm pack ${label} failed:\n${run.stderr || run.stdout}`); - } - let report; - try { - report = JSON.parse(run.stdout); - } - catch { - throw new Error(`npm pack ${label} did not return JSON: ${run.stdout}`); - } - const entries = Array.isArray(report) - ? report - : report && typeof report === 'object' - ? Object.values(report) - : []; - if (entries.length !== 1 || typeof entries[0]?.filename !== 'string') { - throw new Error(`npm pack ${label} returned an unexpected report`); - } - const [entry] = entries; - if (entry.name !== metadata.name - || entry.version !== metadata.version - || entry.filename !== expectedFilename) { - throw new Error(`npm pack ${label} package identity differs from approved ${metadata.name}@${metadata.version} (${expectedFilename})`); + const snapshotRoot = path.join(scratch, 'reviewed-source'); + const sourceArchive = path.join(scratch, 'reviewed-source.tar'); + fs.mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 }); + run(git, ['archive', '--format=tar', `--output=${sourceArchive}`, commit], 'reviewed source archive'); + const sourceArchiveBytes = fs.readFileSync(sourceArchive); + run('tar', ['-xf', sourceArchive, '-C', snapshotRoot], 'reviewed source extraction'); + const materializedPaths = new Set(); + const inspectMaterialized = (directory) => { + for (const name of fs.readdirSync(directory).sort()) { + const absolute = path.join(directory, name); + const relative = path.relative(snapshotRoot, absolute).split(path.sep).join('/'); + const stat = fs.lstatSync(absolute); + if (stat.isSymbolicLink()) + throw new Error(`reviewed source extraction produced a symlink: ${relative}`); + if (stat.isDirectory()) + inspectMaterialized(absolute); + else if (stat.isFile()) + materializedPaths.add(relative); + else + throw new Error(`reviewed source extraction produced an unsupported entry: ${relative}`); + } + }; + inspectMaterialized(snapshotRoot); + if (trackedPaths.size !== materializedPaths.size + || [...trackedPaths].some((relative) => !materializedPaths.has(relative))) { + throw new Error('reviewed source extraction does not exactly match the Git tree'); } - return entry; - } - function cloneDependencyTree(sourceRoot, targetRoot) { - const activeDirectories = new Set(); - const cloneEntry = (source, target) => { - const sourceLstat = fs.lstatSync(source); - const resolvedSource = sourceLstat.isSymbolicLink() ? fs.realpathSync(source) : source; - if (sourceLstat.isSymbolicLink()) { - const realSourceRoot = fs.realpathSync(sourceRoot); - const isInternalLink = resolvedSource === realSourceRoot - || resolvedSource.startsWith(`${realSourceRoot}${path.sep}`); - if (isInternalLink) { - const clonedTarget = path.join(targetRoot, path.relative(realSourceRoot, resolvedSource)); - const relativeTarget = path.relative(path.dirname(target), clonedTarget) || '.'; - fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 }); - fs.symlinkSync(relativeTarget, target, fs.statSync(resolvedSource).isDirectory() - ? (process.platform === 'win32' ? 'junction' : 'dir') - : 'file'); - return; + const packageJsonPath = path.join(snapshotRoot, packageJsonRelative); + const packageJsonBytes = fs.readFileSync(packageJsonPath); + const metadata = JSON.parse(packageJsonBytes.toString('utf8')); + const expectedFilename = npmArtifactFilename(metadata.name, metadata.version); + const packageJsonBlobOid = String(run(git, ['rev-parse', '--verify', `${commit}:${packageJsonRelative}`], 'reviewed package.json blob resolution').stdout).trim(); + const setTreeWritable = (directory, writable) => { + for (const name of fs.readdirSync(directory)) { + const absolute = path.join(directory, name); + const stat = fs.lstatSync(absolute); + if (stat.isSymbolicLink()) + throw new Error(`isolated source contains a symlink: ${absolute}`); + if (stat.isDirectory()) { + if (writable) + fs.chmodSync(absolute, 0o755); + setTreeWritable(absolute, writable); + if (!writable) + fs.chmodSync(absolute, 0o555); } - } - const sourceStat = sourceLstat.isSymbolicLink() ? fs.statSync(source) : sourceLstat; - if (sourceStat.isDirectory()) { - const realDirectory = fs.realpathSync(resolvedSource); - if (activeDirectories.has(realDirectory)) { - throw new Error(`dependency tree contains a symlink cycle: ${source}`); + else if (stat.isFile()) { + const executable = (stat.mode & 0o111) !== 0; + fs.chmodSync(absolute, writable ? (executable ? 0o755 : 0o644) : (executable ? 0o555 : 0o444)); } - activeDirectories.add(realDirectory); - fs.mkdirSync(target, { recursive: true, mode: sourceStat.mode & 0o777 }); - for (const entry of fs.readdirSync(resolvedSource)) { - cloneEntry(path.join(resolvedSource, entry), path.join(target, entry)); + else { + throw new Error(`isolated source contains an unsupported entry: ${absolute}`); } - fs.chmodSync(target, sourceStat.mode & 0o777); - activeDirectories.delete(realDirectory); - return; - } - if (!sourceStat.isFile()) { - throw new Error(`dependency tree entry is not a regular file or directory: ${source}`); } - fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 }); - fs.copyFileSync(resolvedSource, target, fs.constants.COPYFILE_FICLONE); - fs.chmodSync(target, sourceStat.mode & 0o777); + fs.chmodSync(directory, writable ? 0o755 : 0o555); }; - cloneEntry(sourceRoot, targetRoot); - } - const dependencyTemplate = path.join(scratch, 'dependency-template'); - fs.cpSync(snapshotRoot, dependencyTemplate, { recursive: true }); - setTreeWritable(snapshotRoot, false); - const templatePackageDir = path.join(dependencyTemplate, packageRelative); - for (const installDirectory of [ - fs.existsSync(path.join(dependencyTemplate, 'package-lock.json')) ? dependencyTemplate : null, - fs.existsSync(path.join(templatePackageDir, 'package-lock.json')) ? templatePackageDir : null, - ].filter((value) => value !== null)) { - const install = spawnSync(npm, ['ci', '--ignore-scripts', '--min-release-age=0'], { - cwd: installDirectory, - encoding: 'utf8', - env: isolatedEnv(`dependencies-${path.relative(dependencyTemplate, installDirectory) || 'root'}`, true, installDirectory), - }); - if (install.status !== 0) { - throw new Error(`locked dependency installation failed:\n${install.stderr || install.stdout}`); - } - } - function isolateBuildDependencies(buildRoot, buildPackageDir) { - const repositoryNodeModules = path.join(dependencyTemplate, 'node_modules'); - const packageNodeModules = path.join(templatePackageDir, 'node_modules'); - if (fs.existsSync(repositoryNodeModules)) { - cloneDependencyTree(repositoryNodeModules, path.join(buildRoot, 'node_modules')); - } - if (buildPackageDir !== buildRoot && fs.existsSync(packageNodeModules)) { - cloneDependencyTree(packageNodeModules, path.join(buildPackageDir, 'node_modules')); + function isolatedEnv(label, ignoreScripts, workingDirectory) { + const safeLabel = label.replace(/[^a-z0-9._-]+/giu, '-'); + const environmentRoot = path.join(scratch, `${safeLabel}-environment`); + const home = path.join(environmentRoot, 'home'); + const cache = path.join(environmentRoot, 'npm-cache'); + const temporary = path.join(environmentRoot, 'tmp'); + const prefix = path.join(environmentRoot, 'npm-prefix'); + const userConfig = path.join(environmentRoot, 'npmrc'); + for (const directory of [home, cache, temporary, prefix]) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(userConfig, '', { mode: 0o600 }); + const controlledPath = [ + path.dirname(process.execPath), + ...(process.platform === 'win32' + ? [ + path.join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32'), + process.env.SYSTEMROOT || 'C:\\Windows', + ] + : ['/usr/bin', '/bin']), + ].join(path.delimiter); + const environment = { + HOME: home, + USERPROFILE: home, + XDG_CACHE_HOME: cache, + PATH: controlledPath, + PWD: workingDirectory, + INIT_CWD: workingDirectory, + RUNNER_TEMP: temporary, + npm_config_cache: cache, + npm_config_prefix: prefix, + npm_config_userconfig: userConfig, + npm_config_ignore_scripts: ignoreScripts ? 'true' : 'false', + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_update_notifier: 'false', + TMPDIR: temporary, + TMP: temporary, + TEMP: temporary, + }; + for (const key of ['CI', 'LANG', 'LC_ALL', 'SOURCE_DATE_EPOCH', 'TZ']) { + if (process.env[key] !== undefined) + environment[key] = process.env[key]; + } + if (process.platform === 'win32') { + for (const key of ['COMSPEC', 'PATHEXT', 'SYSTEMROOT', 'WINDIR']) { + if (process.env[key] !== undefined) + environment[key] = process.env[key]; + } + } + return environment; } - } - function buildPackage(label) { - const buildRoot = path.join(scratch, `${label}-build`); - fs.cpSync(snapshotRoot, buildRoot, { recursive: true }); - setTreeWritable(buildRoot, true); - const buildPackageDir = path.join(buildRoot, packageRelative); - if (typeof metadata.scripts?.build === 'string') { - fs.rmSync(path.join(buildPackageDir, 'dist'), { recursive: true, force: true }); - isolateBuildDependencies(buildRoot, buildPackageDir); - const run = spawnSync(npm, ['run', '--ignore-scripts', 'build'], { - cwd: buildPackageDir, + function runPack(args, label, workingDirectory) { + const run = spawnSync(npm, ['pack', ...args, '--json'], { + cwd: workingDirectory, encoding: 'utf8', - env: isolatedEnv(`${label}-build`, false, buildPackageDir), + env: isolatedEnv(`${label}-pack`, true, workingDirectory), }); if (run.status !== 0) { - throw new Error(`package build ${label} failed:\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`); + throw new Error(`npm pack ${label} failed:\n${run.stderr || run.stdout}`); + } + let report; + try { + report = JSON.parse(run.stdout); + } + catch { + throw new Error(`npm pack ${label} did not return JSON: ${run.stdout}`); } - const builtPackageJson = fs.readFileSync(path.join(buildPackageDir, 'package.json')); - if (!builtPackageJson.equals(packageJsonBytes)) { - throw new Error(`package build ${label} mutated package.json`); + const entries = Array.isArray(report) + ? report + : report && typeof report === 'object' + ? Object.values(report) + : []; + if (entries.length !== 1 || typeof entries[0]?.filename !== 'string') { + throw new Error(`npm pack ${label} returned an unexpected report`); } + const [entry] = entries; + if (entry.name !== metadata.name + || entry.version !== metadata.version + || entry.filename !== expectedFilename) { + throw new Error(`npm pack ${label} package identity differs from approved ${metadata.name}@${metadata.version} (${expectedFilename})`); + } + return entry; } - const rejectPackageSymlinks = (directory) => { - for (const name of fs.readdirSync(directory)) { - if (name === 'node_modules') - continue; - const absolute = path.join(directory, name); - const stat = fs.lstatSync(absolute); - if (stat.isSymbolicLink()) { - throw new Error(`package build ${label} produced a forbidden symlink: ${path.relative(buildPackageDir, absolute)}`); + function cloneDependencyTree(sourceRoot, targetRoot) { + const activeDirectories = new Set(); + const cloneEntry = (source, target) => { + const sourceLstat = fs.lstatSync(source); + const resolvedSource = sourceLstat.isSymbolicLink() ? fs.realpathSync(source) : source; + if (sourceLstat.isSymbolicLink()) { + const realSourceRoot = fs.realpathSync(sourceRoot); + const isInternalLink = resolvedSource === realSourceRoot + || resolvedSource.startsWith(`${realSourceRoot}${path.sep}`); + if (isInternalLink) { + const clonedTarget = path.join(targetRoot, path.relative(realSourceRoot, resolvedSource)); + const relativeTarget = path.relative(path.dirname(target), clonedTarget) || '.'; + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 }); + fs.symlinkSync(relativeTarget, target, fs.statSync(resolvedSource).isDirectory() + ? (process.platform === 'win32' ? 'junction' : 'dir') + : 'file'); + return; + } } - if (stat.isDirectory()) - rejectPackageSymlinks(absolute); - else if (!stat.isFile()) - throw new Error(`package build ${label} produced an unsupported entry: ${absolute}`); + const sourceStat = sourceLstat.isSymbolicLink() ? fs.statSync(source) : sourceLstat; + if (sourceStat.isDirectory()) { + const realDirectory = fs.realpathSync(resolvedSource); + if (activeDirectories.has(realDirectory)) { + throw new Error(`dependency tree contains a symlink cycle: ${source}`); + } + activeDirectories.add(realDirectory); + fs.mkdirSync(target, { recursive: true, mode: sourceStat.mode & 0o777 }); + for (const entry of fs.readdirSync(resolvedSource)) { + cloneEntry(path.join(resolvedSource, entry), path.join(target, entry)); + } + fs.chmodSync(target, sourceStat.mode & 0o777); + activeDirectories.delete(realDirectory); + return; + } + if (!sourceStat.isFile()) { + throw new Error(`dependency tree entry is not a regular file or directory: ${source}`); + } + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 }); + fs.copyFileSync(resolvedSource, target, fs.constants.COPYFILE_FICLONE); + fs.chmodSync(target, sourceStat.mode & 0o777); + }; + cloneEntry(sourceRoot, targetRoot); + } + const dependencyTemplate = path.join(scratch, 'dependency-template'); + fs.cpSync(snapshotRoot, dependencyTemplate, { recursive: true }); + setTreeWritable(snapshotRoot, false); + const templatePackageDir = path.join(dependencyTemplate, packageRelative); + for (const installDirectory of [ + fs.existsSync(path.join(dependencyTemplate, 'package-lock.json')) ? dependencyTemplate : null, + fs.existsSync(path.join(templatePackageDir, 'package-lock.json')) ? templatePackageDir : null, + ].filter((value) => value !== null)) { + const install = spawnSync(npm, ['ci', '--ignore-scripts', '--min-release-age=0'], { + cwd: installDirectory, + encoding: 'utf8', + env: isolatedEnv(`dependencies-${path.relative(dependencyTemplate, installDirectory) || 'root'}`, true, installDirectory), + killSignal: 'SIGTERM', + maxBuffer: 128 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 600_000, + }); + if (install.status !== 0) { + throw new Error(formatSpawnFailure('locked dependency installation', install)); } - }; - rejectPackageSymlinks(buildPackageDir); - for (const relative of trackedPaths) { - if (packageRelative - && relative !== packageRelative - && !relative.startsWith(`${packageRelative}/`)) - continue; - const reviewedPath = path.join(snapshotRoot, relative); - const builtPath = path.join(buildRoot, relative); - const builtStat = fs.lstatSync(builtPath); - if (!builtStat.isFile() || builtStat.isSymbolicLink() - || !fs.readFileSync(reviewedPath).equals(fs.readFileSync(builtPath))) { - throw new Error(`package build ${label} mutated reviewed source bytes: ${relative}`); + } + function isolateBuildDependencies(buildRoot, buildPackageDir) { + const repositoryNodeModules = path.join(dependencyTemplate, 'node_modules'); + const packageNodeModules = path.join(templatePackageDir, 'node_modules'); + if (fs.existsSync(repositoryNodeModules)) { + cloneDependencyTree(repositoryNodeModules, path.join(buildRoot, 'node_modules')); + } + if (buildPackageDir !== buildRoot && fs.existsSync(packageNodeModules)) { + cloneDependencyTree(packageNodeModules, path.join(buildPackageDir, 'node_modules')); } } - return buildPackageDir; - } - function stageCanonicalPackage(builtPackageDir, label) { - const inventory = runPack([builtPackageDir, '--dry-run'], `${label} inventory`, builtPackageDir); - const stage = path.join(scratch, `${label}-canonical-input`); - const binValues = typeof metadata.bin === 'string' - ? [metadata.bin] - : metadata.bin && typeof metadata.bin === 'object' - ? Object.values(metadata.bin) - : []; - const executablePaths = new Set(binValues.map((value) => String(value).replace(/^\.\//, ''))); - fs.mkdirSync(stage, { recursive: true, mode: 0o755 }); - for (const entry of inventory.files || []) { - if (!entry || typeof entry.path !== 'string' || path.isAbsolute(entry.path)) { - throw new Error('npm pack inventory contains a malformed path'); + function buildPackage(label) { + const buildRoot = path.join(scratch, `${label}-build`); + fs.cpSync(snapshotRoot, buildRoot, { recursive: true }); + setTreeWritable(buildRoot, true); + const buildPackageDir = path.join(buildRoot, packageRelative); + if (typeof metadata.scripts?.build === 'string') { + fs.rmSync(path.join(buildPackageDir, 'dist'), { recursive: true, force: true }); + isolateBuildDependencies(buildRoot, buildPackageDir); + const run = spawnSync(npm, ['run', '--ignore-scripts', 'build'], { + cwd: buildPackageDir, + encoding: 'utf8', + env: isolatedEnv(`${label}-build`, false, buildPackageDir), + }); + if (run.status !== 0) { + throw new Error(`package build ${label} failed:\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`); + } + const builtPackageJson = fs.readFileSync(path.join(buildPackageDir, 'package.json')); + if (!builtPackageJson.equals(packageJsonBytes)) { + throw new Error(`package build ${label} mutated package.json`); + } } - const relative = entry.path.split('/').join(path.sep); - const source = path.resolve(builtPackageDir, relative); - if (!source.startsWith(`${builtPackageDir}${path.sep}`)) { - throw new Error(`npm pack inventory escapes package root: ${entry.path}`); + const rejectPackageSymlinks = (directory) => { + for (const name of fs.readdirSync(directory)) { + if (name === 'node_modules') + continue; + const absolute = path.join(directory, name); + const stat = fs.lstatSync(absolute); + if (stat.isSymbolicLink()) { + throw new Error(`package build ${label} produced a forbidden symlink: ${path.relative(buildPackageDir, absolute)}`); + } + if (stat.isDirectory()) + rejectPackageSymlinks(absolute); + else if (!stat.isFile()) + throw new Error(`package build ${label} produced an unsupported entry: ${absolute}`); + } + }; + rejectPackageSymlinks(buildPackageDir); + for (const relative of trackedPaths) { + if (packageRelative + && relative !== packageRelative + && !relative.startsWith(`${packageRelative}/`)) + continue; + const reviewedPath = path.join(snapshotRoot, relative); + const builtPath = path.join(buildRoot, relative); + const builtStat = fs.lstatSync(builtPath); + if (!builtStat.isFile() || builtStat.isSymbolicLink() + || !fs.readFileSync(reviewedPath).equals(fs.readFileSync(builtPath))) { + throw new Error(`package build ${label} mutated reviewed source bytes: ${relative}`); + } } - const sourceStat = fs.lstatSync(source); - if (!sourceStat.isFile()) { - throw new Error(`npm pack inventory requires a regular file: ${entry.path}`); + return buildPackageDir; + } + function stageCanonicalPackage(builtPackageDir, label) { + const inventory = runPack([builtPackageDir, '--dry-run'], `${label} inventory`, builtPackageDir); + const stage = path.join(scratch, `${label}-canonical-input`); + const binValues = typeof metadata.bin === 'string' + ? [metadata.bin] + : metadata.bin && typeof metadata.bin === 'object' + ? Object.values(metadata.bin) + : []; + const executablePaths = new Set(binValues.map((value) => String(value).replace(/^\.\//, ''))); + fs.mkdirSync(stage, { recursive: true, mode: 0o755 }); + for (const entry of inventory.files || []) { + if (!entry || typeof entry.path !== 'string' || path.isAbsolute(entry.path)) { + throw new Error('npm pack inventory contains a malformed path'); + } + const relative = entry.path.split('/').join(path.sep); + const source = path.resolve(builtPackageDir, relative); + if (!source.startsWith(`${builtPackageDir}${path.sep}`)) { + throw new Error(`npm pack inventory escapes package root: ${entry.path}`); + } + const sourceStat = fs.lstatSync(source); + if (!sourceStat.isFile()) { + throw new Error(`npm pack inventory requires a regular file: ${entry.path}`); + } + const target = path.join(stage, relative); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 }); + fs.copyFileSync(source, target); + fs.chmodSync(target, executablePaths.has(entry.path) ? 0o755 : 0o644); } - const target = path.join(stage, relative); - fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o755 }); - fs.copyFileSync(source, target); - fs.chmodSync(target, executablePaths.has(entry.path) ? 0o755 : 0o644); + return stage; + } + function pack(packageInput, label) { + const destination = path.join(scratch, label); + fs.mkdirSync(destination); + const report = runPack([packageInput, '--pack-destination', destination], label, packageInput); + const bytes = canonicalizeNpmTarball(fs.readFileSync(path.join(destination, report.filename))); + const identity = validatePackedPackageIdentity(bytes, metadata.name, metadata.version, packageJsonBytes); + const members = inspectPackedPackageMembers(bytes); + return { + bytes, + filename: report.filename, + members, + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + packageJsonSha256: identity.packageJsonSha256, + }; } - return stage; - } - function pack(packageInput, label) { - const destination = path.join(scratch, label); - fs.mkdirSync(destination); - const report = runPack([packageInput, '--pack-destination', destination], label, packageInput); - const bytes = canonicalizeNpmTarball(fs.readFileSync(path.join(destination, report.filename))); - const identity = validatePackedPackageIdentity(bytes, metadata.name, metadata.version, packageJsonBytes); - const members = inspectPackedPackageMembers(bytes); - return { - bytes, - filename: report.filename, - members, - sha256: crypto.createHash('sha256').update(bytes).digest('hex'), - packageJsonSha256: identity.packageJsonSha256, - }; - } - try { const first = pack(stageCanonicalPackage(buildPackage('first'), 'first'), 'first'); const second = pack(stageCanonicalPackage(buildPackage('second'), 'second'), 'second'); if (first.filename !== second.filename) diff --git a/scripts/verify-reproducible-package.mts b/scripts/verify-reproducible-package.mts index 09c14ba24..d58b8b03c 100644 --- a/scripts/verify-reproducible-package.mts +++ b/scripts/verify-reproducible-package.mts @@ -190,6 +190,49 @@ export function validatePackedPackageIdentity( }; } +const SPAWN_DIAGNOSTIC_LIMIT = 8_192; + +function boundedSpawnText(value: unknown): string { + const raw = Buffer.isBuffer(value) + ? value.toString('utf8') + : typeof value === 'string' + ? value + : value === undefined || value === null + ? '' + : String(value); + const redacted = raw + .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/giu, '$1[redacted]@') + .replace( + /([?&](?:access_token|auth|credential|key|password|secret|signature|sig|token|x-amz-(?:credential|security-token|signature)|x-goog-(?:credential|signature))=)[^&\s]+/giu, + '$1[redacted]', + ) + .replace(/\bBearer\s+[a-z0-9._~+/-]+=*/giu, 'Bearer [redacted]') + .replace(/\b(?:npm|gh[oprsu])_[a-z0-9]{8,}\b/giu, '[redacted-token]') + .replace(/((?:authorization|_authToken)\s*[:=]\s*)[^\r\n]*/giu, '$1[redacted]') + .replace(/((?:GH_TOKEN|GITHUB_TOKEN|NODE_AUTH_TOKEN|NPM_TOKEN)\s*=\s*)\S+/giu, '$1[redacted]'); + if (!redacted) return '(empty)'; + if (redacted.length <= SPAWN_DIAGNOSTIC_LIMIT) return redacted; + return `[truncated to last ${SPAWN_DIAGNOSTIC_LIMIT} characters]\n${ + redacted.slice(-SPAWN_DIAGNOSTIC_LIMIT) + }`; +} + +export function formatSpawnFailure(label: string, result: any): string { + const spawnError = result?.error + ? `${result.error.name || 'Error'}${ + result.error.code ? ` [${result.error.code}]` : '' + }: ${result.error.message || String(result.error)}` + : '(none)'; + return [ + `${label} failed`, + `status: ${result?.status ?? 'null'}`, + `signal: ${result?.signal ?? 'null'}`, + `spawn error: ${boundedSpawnText(spawnError)}`, + `stdout: ${boundedSpawnText(result?.stdout)}`, + `stderr: ${boundedSpawnText(result?.stderr)}`, + ].join('\n'); +} + /** * @param {string} [packagePath] * @param {{ outDir?: string | null, repositoryRoot?: string | null, reviewedCommit?: string | null }} [options] @@ -215,6 +258,7 @@ export function verifyReproduciblePackage( throw new Error('package path must be a repository-relative directory'); } const scratch: string = fs.mkdtempSync(path.join(os.tmpdir(), 'ep-repro-pack-')); + try { function run(command: string, args: string[], label: string, options: any = {}): any { const result = spawnSync(command, args, { @@ -514,9 +558,13 @@ export function verifyReproduciblePackage( cwd: installDirectory, encoding: 'utf8', env: isolatedEnv(`dependencies-${path.relative(dependencyTemplate, installDirectory) || 'root'}`, true, installDirectory), + killSignal: 'SIGTERM', + maxBuffer: 128 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 600_000, }); if (install.status !== 0) { - throw new Error(`locked dependency installation failed:\n${install.stderr || install.stdout}`); + throw new Error(formatSpawnFailure('locked dependency installation', install)); } } @@ -642,7 +690,6 @@ export function verifyReproduciblePackage( }; } - try { const first: any = pack(stageCanonicalPackage(buildPackage('first'), 'first'), 'first'); const second: any = pack(stageCanonicalPackage(buildPackage('second'), 'second'), 'second'); if (first.filename !== second.filename) throw new Error('pack filenames differ'); diff --git a/security/security-case.json b/security/security-case.json index 5970648d8..9522e5539 100644 --- a/security/security-case.json +++ b/security/security-case.json @@ -1,7 +1,7 @@ { "@version": "EP-SECURITY-CASE-RESOLVED-v2", "source": "security/claims.v1.json", - "evidence_bundle_sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880", + "evidence_bundle_sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e", "claim_count": 35, "evidence_file_count": 259, "execution": { @@ -1092,7 +1092,7 @@ "security-evidence": { "kind": "content-addressed-evidence-bundle", "filename": "security-case-evidence.v1", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880", + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e", "file_count": 259 }, "verify-sdk": { @@ -1256,7 +1256,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -1519,7 +1519,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -1628,7 +1628,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -1794,7 +1794,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -1918,7 +1918,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -2113,7 +2113,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -2474,7 +2474,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -2675,7 +2675,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -2858,7 +2858,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -3078,7 +3078,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -3312,7 +3312,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -3537,7 +3537,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -3646,7 +3646,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -3860,7 +3860,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -4002,7 +4002,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -4419,7 +4419,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -4580,7 +4580,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -4827,7 +4827,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -4961,7 +4961,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -5084,7 +5084,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -5220,7 +5220,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -5451,7 +5451,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -5666,7 +5666,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -5882,7 +5882,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -6186,7 +6186,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -6398,7 +6398,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -6564,7 +6564,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -6743,7 +6743,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -6918,7 +6918,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -7068,7 +7068,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -7209,7 +7209,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -7408,7 +7408,7 @@ "release_artifact_hashes": [ { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -7822,7 +7822,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -8203,7 +8203,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] }, @@ -8406,7 +8406,7 @@ }, { "artifact_id": "security-evidence", - "sha256": "947a0f8612dcc9a67520637a100d9ca3137193d5db78b882e3bbf98b82528880" + "sha256": "66c3b1d0a7bcde8d1aa627c75c0e1b735480973758f9ee196ece8f1de63bb58e" } ] } @@ -9302,7 +9302,7 @@ }, { "path": "scripts/verify-reproducible-package.mjs", - "sha256": "c87109f5e9fc6f98d7417bddcc1dfc98e59f2e983df8e7d7840d1165be0518e6" + "sha256": "ec8e74a9308f71139e07926d08f9036b24b629a79ac229ce0dd80bbde9b830e1" }, { "path": "scripts/verify-reproducible-wheel.mjs", @@ -9414,7 +9414,7 @@ }, { "path": "tests/release-reproducibility.test.ts", - "sha256": "00f8dbe169aa3571ed823f9c2fc346818a80bbc0b88cc19a93c15cd5139e9cd9" + "sha256": "02379603d1acc09afcef1fb24ed9bb4f79d41bdd029870c96fd7a66c397ef339" }, { "path": "tests/reliance-kernel.test.ts", diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/CLAIM-EVIDENCE.md b/standards/aips-1/p3-evidence-source-evaluation-v0/CLAIM-EVIDENCE.md new file mode 100644 index 000000000..d2ab1148b --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/CLAIM-EVIDENCE.md @@ -0,0 +1,39 @@ +# Claim/evidence ledger + +This ledger separates upstream facts, local test results, and proposal choices. +`Supported` means only that the cited artifact supports the bounded wording in +that row. It does not mean AIPS-1 has reviewed or accepted this package. + +| ID | Claim | Type | Evidence | Assessment and limit | +|---|---|---|---|---| +| C01 | AIPS-1 identifies v0.1 as a draft for public comment and gives 30 November 2026 as the closing date. | Current first-party fact | `SOURCES.md` A1, A2; `source-lock.json` | Supported as of the 2026-09-01 source read. Recheck before submission. | +| C02 | P3 requires Trigger predicates evaluable against declared Evidence Sources and excludes triggers depending solely on issuer opinion or undefined external conditions. | Primary specification fact | `SOURCES.md` A2, Sections 2.2 and 4 | Supported. The local profile does not widen this into a coverage or liability rule. | +| C03 | AIPS-1 v0.1 describes `triggers` at a high level and gives one worked oracle predicate, but the pinned tree has no Trigger schema or reference verifier implementation. | Primary-source and repository-tree fact | `SOURCES.md` A2, A3; pinned `schemas/README.md`; pinned `reference/README.md` | Supported at upstream commit `280a8ba0e9c2658ee6af10778e0f6a2fb669661d`. Later upstream work may change this. | +| C04 | The official-site and commit-pinned v0.1 PDFs are not byte-identical. | Artifact comparison | `source-lock.json`; `SOURCES.md`, Upstream variance | Supported by the two recorded SHA-256 digests. The P3 passages used here have the same substantive wording in both reviewed copies. | +| C05 | The upstream repository uses CC0 1.0 Universal; this EMILIA package is under the repository's Apache-2.0 license. | License fact | Pinned upstream `LICENSE`; repository root `LICENSE`; `SOURCES.md` | Supported. CC0's express exclusions remain in the upstream legal text. | +| C06 | The local evaluator has exactly three verdicts and keeps them separate from authorization, coverage, liability, claim acceptance, and payout. | Local implementation claim | `evaluate.mjs`; `evaluation-report.schema.json`; self-test “exports exactly the three closed lab trigger verdicts”; scope self-test | Supported for this repository-local version. These are not native AIPS-1 or EP-AEC verdicts. | +| C07 | Missing, unavailable, stale, unsupported, unpinned, ambiguous, conflicting, sole-issuer-opinion, or value-missing evidence produces `INDETERMINATE`, not a false trigger result. | Local behavioral claim | `evaluate.selftest.mjs`; `vectors/cases.json`; `report.json` | Supported for the exercised local cases. The fixtures do not prove external source truth. | +| C08 | `NOT_SATISFIED` is returned only for a determinate false local comparison. | Local behavioral claim | determinate-false self-test; vector pair `indeterminate-dominance` | Supported. If another predicate is unevaluable, the case is instead `INDETERMINATE`. | +| C09 | The only local combiner is `ALL`, and indeterminacy dominates a determinate false predicate. | Local profile choice | `evaluate.mjs`; `SPECIFICATION.md`, Section 8; `indeterminate-dominance` vectors | By construction. This is not attributed to AIPS-1. | +| C10 | Profile and observation pins cover locator, revision, JSON format, basis, canonical parsed-data digest, and freshness. Reports bind the canonical profile and evidence set and include bounded source snapshots. | Local implementation claim | `evaluate.mjs`; `evaluation-profile.schema.json`; `evidence-set.schema.json`; `evaluation-report.schema.json`; digest-binding, pin, and freshness self-tests | Supported structurally. The evaluator does not retrieve a locator, authenticate metadata, or hash raw source-response bytes. | +| C11 | The checked vector corpus has 20 cases in 10 control/hostile pairs, and all expected verdicts and reason codes match. | Generated local result | `vectors/cases.json`; `report.json`; generator self-tests | Supported by `report.json`: 10 controls, 10 hostile cases, zero mismatches. | +| C12 | The generated corpus contains nine `SATISFIED`, one `NOT_SATISFIED`, and ten `INDETERMINATE` results. | Generated local result | `report.json` | Supported for corpus digest `sha256:92a80bc1b1739e892f01900f1026e91042f0a086cbd78e9bba3e92000b7d31d1`. This is test distribution, not an external performance rate. | +| C13 | The checked-in corpus report is byte-stable and binds the source lock, report schema, evaluator, and generator bytes. | Local reproducibility claim | `generate-report.mjs`; `corpus-report.schema.json`; artifact-binding and report-determinism self-tests; `node generate-report.mjs --check` | Supported for the named working-tree bytes. The binding is a digest link, not a signature or third-party attestation. | +| C14 | The self-test file contains 44 passing tests in the current local run. | Time-bounded local test result | `evaluate.selftest.mjs`; `node --test evaluate.selftest.mjs` on 2026-09-01 | Supported for the tested working tree. This is same-team test evidence, not independent implementation evidence. | +| C15 | The evaluator is offline and does not authenticate sources, verify signatures, establish evidence truth, or apply an AIPS Policy Certificate. | Negative implementation claim | `evaluate.mjs`; `README.md`; `SPECIFICATION.md`, Sections 1 and 12 | Supported by code inspection and scope fields. Supplied fixture metadata remains an assumption. | +| C16 | This package is not a native AIPS verifier, coverage decision, liability rule, AEB adapter, CAID mapping, adoption claim, or endorsement claim. | Normative scope choice | `README.md`; `SPECIFICATION.md`; report `scope`; `COMMENT.md` | By construction. Any excerpt or submission must preserve this boundary. | +| C17 | `COMMENT.md` is staged text and has not been submitted. | Local workflow state | `COMMENT.md` status; no outbound receipt in this package | Supported only as repository state. Submission is a separate external action. | +| C18 | The evaluator supports the RFC 6901 root pointer and rejects duplicate object members, impossible calendar dates, non-canonical array indexes, unsafe numeric tokens, mutable programmatic accessors, and inputs over explicit resource limits without producing a false result. The corpus generator rejects inherited and prototype-bearing mutation paths. | Local defensive-behavior claim | `evaluate.mjs`; `generate-report.mjs`; root-pointer, strict-parser, timestamp, pointer, numeric-alias, inert-input, bounded-input, and mutation-path self-tests | Supported for the exercised local cases. This is not an external security assessment. | + +## Open evidence gaps + +- No upstream review, acceptance, adoption, or endorsement of the proposal. +- No native AIPS-1 predicate schema or reference-verifier result against this + package at the pinned upstream revision. +- No independent implementation or external reproduction of the local + evaluator. +- No live retrieval, signature verification, trust-anchor validation, or + evidence-truth check. +- No coverage, liability, claim-acceptance, settlement, or payout decision. +- The official-site PDF is mutable and differs from the pinned GitHub PDF; + refresh both sources immediately before external submission. diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/COMMENT.md b/standards/aips-1/p3-evidence-source-evaluation-v0/COMMENT.md new file mode 100644 index 000000000..16865eb60 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/COMMENT.md @@ -0,0 +1,54 @@ +# Comment on AIPS-1 v0.1 P3: Evidence Source evaluation + +Status: draft public comment, not submitted + +Target: AIPS-1 v0.1 + +Published deadline: 30 November 2026 + +AIPS-1 v0.1 requires Trigger predicates to be evaluable against declared +Evidence Sources and excludes triggers that depend solely on issuer discretion +or undefined external conditions. That boundary is useful. The draft does not +yet define enough evaluation semantics for two implementations to process the +same evidence and reliably reach the same result. + +The `triggers` field is described at a high level, and Appendix A gives one +oracle example. Neither defines a complete predicate grammar, typed comparison +rules, Evidence Source snapshot binding, retrieval and freshness behavior, +conflict handling, or portable reason codes. The specification also defers its +reference verifiers to v0.2. + +For v0.2, P3 should define a small, implementation-independent evaluation +profile with these properties: + +1. Each Evidence Source has a stable identifier, an addressable locator, a + declared data format, and a named trust or authentication policy. +2. Each observation records the source identifier, retrieval time, effective + time or validity interval, media type or schema, payload digest, and any + authentication result. A mutable URL alone is not a stable observation. +3. Trigger predicates use typed operands and a closed, versioned operator set. + Number, string, Boolean, timestamp, existence, and collection comparisons + need exact semantics. +4. Evaluation has three results: `SATISFIED`, `NOT_SATISFIED`, and + `INDETERMINATE`. `NOT_SATISFIED` is appropriate only when usable evidence + resolves the predicate false. Missing, unavailable, stale, conflicting, + unsupported, unauthenticated, or unpinned evidence yields `INDETERMINATE`. +5. A deterministic report identifies the profile version, evaluation time, + predicate, source snapshots, result, and closed reason codes. Re-evaluation + over the same inputs must produce the same report bytes or the same + canonical digest. +6. An issuer-operated source is not automatically unusable, but an issuer's + conclusion cannot substitute for the declared evidence or become the sole + discretionary basis for a P3 result. + +The result boundary should also be normative. `SATISFIED` would mean only that +the named Trigger predicate evaluated true under the declared profile and +source snapshots. It would not establish P2 coverage scope, P5 policy status, +legal enforceability, liability, claim acceptance, settlement, or payout. + +The accompanying package tests the source-pinning, closed-operator, three-state, +and deterministic-report parts of this proposal over paired offline fixtures. +Its reports are bound to their inputs. It does not retrieve or authenticate a +source, and it labels every result as local. It is not a native AIPS-1 verifier, +an AIPS-1 conformance suite, an AEB adapter, or evidence of adoption or +endorsement. diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/README.md b/standards/aips-1/p3-evidence-source-evaluation-v0/README.md new file mode 100644 index 000000000..c72d897c4 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/README.md @@ -0,0 +1,133 @@ +# AIPS-1 P3 Evidence Source Evaluation Lab v0.1 + +Status: experimental public-comment package, not submitted + +Prepared: 2026-09-01 + +Target: AIPS-1 v0.1, P3 Trigger Determinism + +Published comment deadline: 2026-11-30 + +This package tests one narrow question: can a closed local predicate be +evaluated against declared, pinned, current JSON evidence without turning +missing or ambiguous evidence into a false answer? + +It returns exactly three local trigger results: + +- `SATISFIED`: every predicate was evaluable and true. +- `NOT_SATISFIED`: every predicate was evaluable and at least one was false. +- `INDETERMINATE`: at least one predicate could not be evaluated safely. + +These are not AIPS-1 native verdicts. They do not decide authorization, +coverage, liability, claim acceptance, payout, or Policy Certificate status. +They also do not establish AIPS-1 conformance, review, adoption, endorsement, +or interoperability. + +## Why this is a comment lab + +AIPS-1 v0.1 requires predicates evaluable against declared Evidence Sources, +but it does not publish a normative Trigger predicate schema, predicate +language, predicate-outcome model, or reference verifier. Its schemas and +reference implementations are marked as planned or deferred to v0.2 in the +pinned upstream repository. + +This package therefore defines a small repository-local dialect. It uses an +`ALL` combiner, RFC 6901 JSON Pointers, four closed operators, exact source and +content pins, a relying-party evaluation time, and conservative +`INDETERMINATE` handling. `SPECIFICATION.md` defines that dialect. Nothing here +is called native AIPS verification. + +## What the evaluator checks + +For supplied offline fixtures, the evaluator checks: + +- closed profile and evidence-set structures; +- duplicate-member rejection before JSON is evaluated; +- exact source identifier, locator, revision, format, basis, and content-digest + pins; +- observation availability and freshness at the profile's evaluation time; +- agreement between duplicate observations and between declared sources; +- rejection of a predicate supported only by `ISSUER_OPINION` observations; +- root-value selection and canonical array-index handling for JSON Pointers; +- strict safe-integer comparison with no string-to-number coercion; and +- input, profile, evidence-set, and source-snapshot binding in a stable report. + +The evaluator does not fetch a URL, authenticate a source, verify a signature, +or prove that an `OBSERVED_FACT` label is true. Locator, revision, basis, and +timestamps are supplied fixture data. The `data_sha256` field binds parsed JSON +under this lab's canonical encoding; it is not a hash of the source's raw HTTP +response bytes. + +File input is limited to 1 MiB. The local profile also caps nesting, collection +size, strings, sources, predicates, observations, and freshness windows. Those +limits protect this reference tool; they are not proposed as AIPS-1 limits. +JSON numbers are limited to signed safe integers. Decimal quantities must use a +fixed-point integer or a string with separately defined semantics. + +## Package contents + +- `COMMENT.md`: submission-ready comment text. It remains unsubmitted. +- `SPECIFICATION.md`: the exact local input, evaluation, and report rules. +- `SOURCES.md`: human-readable primary-source register and interpretation + limits. +- `source-lock.json`: hashes and revision pins for the official site and the + upstream GitHub repository. +- `evaluation-profile.schema.json`: schema for the local profile. +- `evidence-set.schema.json`: schema for supplied offline observations. +- `evaluation-report.schema.json`: schema for local evaluation reports. +- `corpus-report.schema.json`: schema for the generated aggregate report and + its artifact bindings. +- `vector-corpus.schema.json`: schema for the paired vector corpus. +- `evaluate.mjs`: zero-dependency evaluator and CLI. +- `evaluate.selftest.mjs`: behavioral and hostile tests. +- `vectors/`: determinate controls and hostile cases. +- `generate-report.mjs`: deterministic aggregate report generator. +- `report.json`: generated report over the checked-in vectors. +- `CLAIM-EVIDENCE.md`: bounded claim ledger. + +## Run + +The evaluator and report generator have no runtime package dependency: + +```sh +node evaluate.mjs --json +node generate-report.mjs +node generate-report.mjs --check +``` + +The self-test also validates the JSON Schemas with the repository's pinned Ajv +development dependency. From the repository root after `npm ci --ignore-scripts`: + +```sh +node --test standards/aips-1/p3-evidence-source-evaluation-v0/evaluate.selftest.mjs +``` + +The evaluator writes one canonical JSON report to standard output. It exits +nonzero for file, parse, duplicate-member, resource-limit, or internal +evaluation failures. A structurally valid case may return `INDETERMINATE` +with exit zero; consumers must inspect the verdict and reason codes. + +`generate-report.mjs` prints the regenerated report to standard output. +`generate-report.mjs --check` compares those bytes with checked-in +`report.json` and fails if it is missing or stale. The report binds the raw +bytes of `source-lock.json`, `corpus-report.schema.json`, `evaluate.mjs`, and +`generate-report.mjs`. It also records the upstream commit and tree from the +source lock, so a reader can reproduce the exact local evidence and code +boundary without trusting a filename. + +The checked package has 44 passing self-tests and 20 vectors in 10 paired +control/hostile groups. The generated report records 10 `INDETERMINATE`, nine +`SATISFIED`, and one `NOT_SATISFIED` result, with no expectation mismatch. See +`CLAIM-EVIDENCE.md` for the exact boundary of those claims. + +## Source and license boundary + +The official-site PDF and the commit-pinned GitHub PDF both identify themselves +as AIPS-1 v0.1 but are not byte-identical. `source-lock.json` pins both and +`SOURCES.md` records the reviewed differences. The P3 passages used by this lab +have the same substantive wording in both copies. + +The upstream AIPS-1 repository uses CC0 1.0 Universal (`CC0-1.0`). This package +is published under the EMILIA Protocol repository's Apache-2.0 license. It is +not an AEB adapter and does not map AIPS evidence into CAID, AEB, AEC, Gate, or +an execution decision. diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/SOURCES.md b/standards/aips-1/p3-evidence-source-evaluation-v0/SOURCES.md new file mode 100644 index 000000000..7df45b861 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/SOURCES.md @@ -0,0 +1,114 @@ +# Source register + +Access date: 2026-09-01. `source-lock.json` records the retrieval time, +commit, byte lengths, hashes, Git blob identifiers, and observed upstream +variance. + +## A1. Official AIPS-1 site + +- URL: https://aips-1.org/ +- Role: current first-party status, document index, comment route, and comment + deadline. +- Read: AIPS-1 is presented as a v0.1 draft for public comment. The page states + that the comment period closes 30 November 2026. It describes P3 as trigger + conditions expressed as predicates evaluable against declared Evidence + Sources. +- Limit: this is a current web page, not an immutable revision. The exact bytes + read are recorded in `source-lock.json`. + +## A2. AIPS-1 Specification v0.1, official-site copy + +- URL: https://aips-1.org/AIPS-1_Specification_v0_1.pdf +- Version and date: working paper v0.1, 6 June 2026. +- Status: draft, open for public comment. +- Relevant parts: + - Section 2.2 defines a Trigger as a predicate condition that references one + or more Evidence Sources and resolves objectively. It defines an Evidence + Source as a defined, addressable source referenced by a Trigger. + - Section 4 says P3 requires predicates evaluable against declared Evidence + Sources. It excludes triggers depending solely on issuer opinion or on + undefined external conditions. + - Section 5 describes `triggers` only as an array of Trigger predicates with + referenced Evidence Sources. + - Section 7 says the JavaScript, smart-contract, and MCP reference verifiers + are deferred to v0.2. + - Section 13 asks whether Trigger predicates should use a defined predicate + language or remain issuer-defined within a structural envelope. It gives + 30 November 2026 as the v0.1 comment deadline. + - Appendix A contains one worked oracle predicate using `sourceRef`, `field`, + `operator`, and `value`. It is an example, not a published schema or a + complete predicate-language definition. +- Byte lock: SHA-256 + `01bf3d27ed21944f637ffbe1d968629f5dd491677852634e84436c562e3a3e0d`, + 190174 bytes. + +## A3. Upstream GitHub repository + +- Repository: https://github.com/Kadikoy1/aips-1 +- Branch resolved: `main`. +- Commit: [`280a8ba0e9c2658ee6af10778e0f6a2fb669661d`](https://github.com/Kadikoy1/aips-1/commit/280a8ba0e9c2658ee6af10778e0f6a2fb669661d). +- Commit time: 2026-06-06T17:16:07Z. +- Role: reproducible upstream snapshot for the specification, README, and + license. + +Pinned files: + +- [Specification PDF](https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/docs/AIPS-1_Specification_v0_1.pdf): + SHA-256 + `aeff5eda37b30bbd92b1d2008bd8c91a14479ccb8e8b97220f6d6e584bbee80c`, + 189878 bytes, Git blob `d1755be85f3e3a1e7071fe0c73f824b7e998df2e`. +- [README](https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/README.md): + SHA-256 + `a34fc7cb16ac7c5ead69e75f5b587f5341acac2991b6716ba1bad22d3afbc88d`, + 8338 bytes, Git blob `9444a21e88e2b76f471893e56f1ceb9c3f5c1ee5`. +- [Schemas status](https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/schemas/README.md): + SHA-256 + `cb9e8403919ea9a71536ae3595c602ba7a650e99f2a073a5aa7eea4385f9f63e`, + 1558 bytes, Git blob `196c81d05ac62744f32896fcffaa4dabba3af4d4`. + It lists the Trigger predicate and other JSON Schemas as planned for v0.2; + no schema file is present at the pinned tree. +- [Reference-verifier status](https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/reference/README.md): + SHA-256 + `01f361387e0509952969735ad6c486933cadcd8bb99605e5399a3f2b0ecd9cc5`, + 1652 bytes, Git blob `790e9a5761fe637af08f2fb7fbc7921208e32cc9`. + It marks the reference verifiers as deferred to v0.2; no verifier module is + present at the pinned tree. +- [LICENSE](https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/LICENSE): + SHA-256 + `1483739d4d4a5323e12d8cf6b69c21fd218fc0f3c60e581c719f7ca6d225de30`, + 6619 bytes, Git blob `90d1065546ed8420a50de14c8f4b5defbaa8f330`. + +## Upstream variance + +The official-site PDF and the commit-pinned repository PDF have the same title, +version, and date but different bytes. The current site copy adds AHS-1 and +healthcare material and contains editorial changes. The P3 passages, deferred +verifier statement, deadline, and predicate-language question used here have +the same substantive wording in both copies. + +This package therefore uses the site PDF for current public wording and the +GitHub commit for reproducibility. It does not use the shared filename as a +revision identifier. + +## License + +The pinned upstream repository includes the CC0 1.0 Universal legal text +(`CC0-1.0`). AIPS-1 also labels the specification CC0. CC0 does not waive or +license rights such as trademarks and patents that its legal text expressly +excludes. + +This EMILIA package is original commentary and test-lab material published +under the EMILIA Protocol repository's Apache-2.0 license. The upstream CC0 +status does not change the license of this repository. + +## Interpretation rules + +1. AIPS-1 v0.1 supplies a high-level P3 requirement and one worked example. It + does not supply a normative predicate grammar, Evidence Source schema, + predicate-outcome model, or reference verifier. +2. This package's schema, operators, reason codes, and three-result model are a + local proposal for public comment. They are not attributed to AIPS-1. +3. A P3 predicate result is not a coverage, liability, claim-acceptance, + settlement, or payout decision. +4. Neither publication here nor a passing local test establishes AIPS-1 + review, adoption, endorsement, conformance, or interoperability. diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/SPECIFICATION.md b/standards/aips-1/p3-evidence-source-evaluation-v0/SPECIFICATION.md new file mode 100644 index 000000000..37aed47c7 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/SPECIFICATION.md @@ -0,0 +1,356 @@ +# Local P3 Evidence Source Evaluation Profile + +Version: `aips1-p3-evidence-source-evaluation-v0.1` + +Status: experimental local profile for AIPS-1 v0.1 public comment + +The words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, and MAY are used in +their ordinary standards sense. + +## 1. Scope + +This profile evaluates repository-local Trigger predicates against supplied +offline JSON observations. It explores a possible concrete shape for AIPS-1 +v0.1 P3 Evidence Source evaluation. + +It is not an AIPS-1 predicate grammar, reference verifier, or conformance +suite. It does not parse a native Policy Certificate or evaluate the +Certificate's `evidenceRequired` field or status lifecycle. It is not an AEB +adapter and makes no authorization, coverage, liability, claim-acceptance, +settlement, or payout decision. + +The local dialect identifier is +`emilia-aips1-p3-local-json-pointer-v0.1`. A report carries this identifier, a +digest of the dialect declaration in `evaluate.mjs`, and the authority label +`repository_local_proposal_not_aips1_v0.1`. + +## 2. Evaluation case + +An evaluation case contains: + +| Field | Meaning | +|---|---| +| `case_id` | Stable local name for the case. | +| `profile` | The relying-party evaluation profile in Section 3. | +| `evidence_set` | Supplied offline observations in Section 4. | + +The CLI accepts one JSON file containing this object. The programmatic API uses +`unnamed-case` when `case_id` is absent or empty. + +## 3. Evaluation profile + +The profile is a closed object. Unknown fields are invalid. + +| Field | Rule | +|---|---| +| `profile_version` | MUST equal `aips1-p3-evidence-source-evaluation-v0.1`. | +| `profile_id` | MUST be a non-empty string. | +| `evaluation_time` | MUST be UTC with `Z`, using whole seconds or exactly three fractional digits. | +| `combiner` | MUST equal `ALL`. No other combiner is defined. | +| `sources` | MUST contain 1 to 32 source declarations. Repeated identifiers are ambiguous. | +| `predicates` | MUST contain 1 to 64 predicates. Repeated identifiers are ambiguous. | + +### 3.1 Source declaration + +Every source declaration has exactly these fields: + +| Field | Rule | +|---|---| +| `source_id` | Non-empty identifier, unique within the profile. | +| `source_type` | Non-empty descriptive type. It does not confer trust. | +| `locator` | Non-empty opaque locator pin. Matching is exact string equality. | +| `revision` | Non-empty opaque revision pin. Matching is exact string equality. | +| `format` | Data format. This version supports only `application/json`. | +| `basis` | Relying-party pin: `OBSERVED_FACT` or `ISSUER_OPINION`. | +| `data_sha256` | `sha256:` plus 64 lowercase hexadecimal characters. It is computed over the local canonical encoding of parsed `data`. | +| `max_age_seconds` | Integer freshness limit from 0 through 31,536,000 seconds. | + +`locator` and `revision` are not dereferenced or authenticated. `basis` is a +profile assertion, not proof of provenance or independence. `source_type` is +recorded but does not change evaluator behavior. + +### 3.2 Predicate + +Every predicate has exactly these fields: + +| Field | Rule | +|---|---| +| `predicate_id` | Non-empty identifier, unique within the profile. | +| `source_ids` | Non-empty array of unique source identifiers. | +| `path` | RFC 6901 JSON Pointer into each source's `data`; the empty pointer selects the complete value. Array tokens use canonical non-negative indexes: `/0` is valid, while `/01`, `/-`, and `/length` are not array entries. | +| `operator` | One operator from Section 6. | +| `expected` | A bounded local JSON value. Numbers MUST be signed IEEE-754 safe integers. | + +A source reference need not resolve during structural validation. An unresolved +reference is statically unevaluable and produces `SOURCE_UNPINNED`. + +## 4. Evidence set + +The evidence set is a closed object with two fields: + +| Field | Rule | +|---|---| +| `evidence_set_version` | MUST equal `aips1-p3-evidence-set-v0.1`. | +| `observations` | Array of zero to 128 source observations. | + +An observation permits exactly these fields: + +| Field | Rule | +|---|---| +| `source_id` | REQUIRED non-empty string. | +| `locator` | REQUIRED non-empty string. | +| `revision` | REQUIRED non-empty string. | +| `availability` | REQUIRED; `AVAILABLE` or `UNAVAILABLE`. | +| `observed_at` | REQUIRED for `AVAILABLE`; the same strict UTC form as `evaluation_time`. | +| `format` | REQUIRED for `AVAILABLE`; non-empty string. | +| `basis` | REQUIRED for `AVAILABLE`; `OBSERVED_FACT` or `ISSUER_OPINION`. | +| `data` | REQUIRED for `AVAILABLE`; a bounded local JSON value using only signed safe integers for numbers. | + +The last four fields MAY be omitted when `availability` is `UNAVAILABLE`. If +present, they MUST still satisfy their type and value rules. + +`basis` is supplied metadata and MUST match the profile's `basis` pin. The +evaluator does not verify that an `OBSERVED_FACT` was independently observed or +that an `ISSUER_OPINION` came from the issuer. + +## 5. Validation and source usability + +Unknown fields, missing required fields, wrong version identifiers, duplicate +predicate source references, malformed timestamps, invalid JSON Pointers, and +invalid value types fail structural validation. The file loader rejects +duplicate JSON object members before evaluation. Repeated source or predicate +identifiers remain representable in JSON but are statically ambiguous and +produce `SOURCE_AMBIGUOUS` or `PREDICATE_ID_AMBIGUOUS`. + +Programmatic input MUST be inert, plain JSON data. Accessor properties, +proxies, custom prototypes, symbols, sparse arrays, non-enumerable properties, +and extra array properties are rejected before values are evaluated. Accepted +programmatic input is snapshotted once so later reads cannot change a pin, +predicate value, digest, or report. + +An invalid profile returns `INDETERMINATE` with `PROFILE_INVALID`. An invalid +evidence set returns `INDETERMINATE` with `EVIDENCE_SET_INVALID`. Detailed +sorted entries appear in `validation_errors`. + +For each declared source used by a predicate, the evaluator applies these +checks in order: + +1. The source identifier MUST resolve to a declaration in the profile. +2. At least one observation with that identifier MUST exist. +3. Multiple observations for one identifier MUST be identical under the local + canonical encoding. Any difference, including metadata, is a conflict. +4. Observation `locator` and `revision` MUST exactly match their profile pins. +5. `availability` MUST be `AVAILABLE`. +6. Profile and observation format MUST match, and the format MUST be + `application/json`. +7. Observation `basis` MUST exactly match the profile's basis pin. +8. `observed_at` MUST be no later than `evaluation_time`, and its age MUST be + less than or equal to `max_age_seconds`. +9. The digest of parsed `data` under the local canonical encoding MUST equal + `data_sha256`. +10. `path` MUST resolve to an own property or array entry in `data`. Inherited + properties are ignored. + +A failed check makes the predicate `INDETERMINATE`. The evaluator does not +convert missing, unavailable, stale, unsupported, conflicting, or unpinned +evidence into `NOT_SATISFIED`. + +When a predicate declares multiple sources, the selected values MUST be equal +under the local canonical encoding. Disagreement returns `SOURCE_CONFLICT`. +Every source therefore supports one shared predicate fact; this version does +not evaluate a different operand from each source. + +If every usable source has `basis=ISSUER_OPINION`, the predicate returns +`SOURCE_ISSUER_OPINION_ONLY`. Issuer opinion MAY be included when at least one +declared usable source has `basis=OBSERVED_FACT` and every selected value +agrees. This is a local rule, not an AIPS-1 definition of independence. + +## 6. Operators + +The closed operator set is: + +| Operator | Meaning | +|---|---| +| `EQUALS` | Selected and expected JSON values are equal under the local canonical encoding. | +| `NOT_EQUALS` | Selected and expected JSON values are not equal under that encoding. | +| `NUMBER_GTE` | Both values are finite JSON numbers and selected is greater than or equal to expected. | +| `NUMBER_LTE` | Both values are finite JSON numbers and selected is less than or equal to expected. | + +There is no type coercion. A numeric string is not a number. A numeric operator +over a non-number or non-safe integer returns `VALUE_TYPE_UNSUPPORTED`. Decimal +quantities use fixed-point safe integers or strings with profile-defined +semantics. Any other operator returns `PREDICATE_UNSUPPORTED`. + +## 7. Canonical JSON and content pins + +The evaluator's local canonical encoding: + +- emits JSON primitives with the runtime's JSON encoding; +- accepts only signed safe integers as JSON numbers and rejects negative zero; +- preserves array order and rejects sparse arrays; +- sorts object member names lexicographically; and +- rejects non-finite numbers, non-JSON values, and cycles, while ignoring + inherited object members. + +This encoding exists to make local fixtures and reports byte-stable. It is not +claimed to be RFC 8785 JCS or an AIPS-1 canonicalization rule. + +`data_sha256` is the lowercase SHA-256 digest of that encoding with the +`sha256:` prefix. It binds the parsed JSON value chosen by the profile author. +It does not authenticate the source, bind HTTP headers, or preserve the raw +source bytes. + +## 8. Verdict composition + +Each predicate report carries `static_evaluable`, `runtime_evaluable`, a +verdict, and reason codes. + +- `static_evaluable=false` means the local operator, source reference, source + format, source identifier, or predicate identifier is unsupported or + ambiguous before observations are considered. +- `runtime_evaluable=false` means the supplied observations cannot determine + the predicate. +- `SATISFIED` means the predicate was evaluable and its comparison was true. +- `NOT_SATISFIED` means the predicate was evaluable and its comparison was + false. +- `INDETERMINATE` means the predicate could not be evaluated. + +Overall `ALL` composition is deliberately conservative: + +1. If any predicate is runtime-unevaluable, the case is `INDETERMINATE`. +2. Otherwise, if any predicate is `NOT_SATISFIED`, the case is + `NOT_SATISFIED`. +3. Otherwise, the case is `SATISFIED`. + +This makes indeterminacy dominate a determinate false predicate. That is a +local abstention policy, not an AIPS-1 Boolean-composition rule. + +## 9. Reason codes + +Top-level reason codes are sorted and deduplicated. Determinate predicate +results keep their result code on the predicate and do not copy it to the +top-level list. + +| Code | Meaning | +|---|---| +| `CASE_INVALID` | Programmatic input was not an object. | +| `INPUT_UNREADABLE` | CLI input file could not be read. | +| `INPUT_MALFORMED` | CLI input was not valid JSON. | +| `INPUT_DUPLICATE_MEMBER` | CLI input repeated an object member name. | +| `INPUT_NUMBER_UNSAFE` | CLI or programmatic input used a decimal, exponent, negative zero, or integer outside the signed safe range. | +| `INPUT_LIMIT_EXCEEDED` | Input exceeded a declared parser or evaluator resource limit. | +| `EVALUATION_FAILURE` | An unexpected local evaluation or report-serialization failure was safely converted to abstention. | +| `PROFILE_INVALID` | Profile failed structural validation. | +| `EVIDENCE_SET_INVALID` | Evidence set failed structural validation. | +| `SOURCE_AMBIGUOUS` | More than one source declaration used the same source identifier. | +| `SOURCE_MISSING` | No observation exists for a declared source. | +| `SOURCE_UNAVAILABLE` | The observation declares the source unavailable. | +| `SOURCE_STALE` | The observation is future-dated or older than the allowed age. | +| `SOURCE_UNSUPPORTED` | Source or observation format is unsupported or mismatched. | +| `SOURCE_UNPINNED` | Source reference is undeclared, locator, revision, or basis pins mismatch, or the content digest mismatches. | +| `SOURCE_CONFLICT` | Duplicate observations differ or selected values disagree across sources. | +| `SOURCE_ISSUER_OPINION_ONLY` | Every usable supporting observation is labeled issuer opinion. | +| `VALUE_MISSING` | The JSON Pointer did not resolve. | +| `VALUE_TYPE_UNSUPPORTED` | A typed operator received the wrong JSON type. | +| `PREDICATE_UNSUPPORTED` | The operator is outside the closed local set. | +| `PREDICATE_ID_AMBIGUOUS` | More than one predicate used the same predicate identifier. | +| `PREDICATE_SATISFIED` | Determinate predicate comparison was true. | +| `PREDICATE_NOT_SATISFIED` | Determinate predicate comparison was false. | + +## 10. Report + +A report contains: + +- `report_version`, fixed to `aips1-p3-evidence-source-report-v0.1`; +- `lab_profile`, fixed to the profile version; +- the local predicate-dialect identifier, digest, and authority label; +- `case_id`, `profile_id`, and the profile's `evaluation_time`; +- canonical digests for the parsed input, profile, and evidence set when those + values exist; +- a bounded snapshot of every declared source pin and matching observation; +- case-level static and runtime evaluability; +- the three-state verdict; +- sorted reason codes and validation errors; +- per-predicate results; and +- the fixed scope statement. + +`stableReportJson()` encodes the report with the local canonical JSON algorithm +and one trailing newline. For the same parsed input and evaluator version, the +bytes are deterministic. + +### 10.1 Aggregate corpus report + +`report.json` is the aggregate result over `vectors/cases.json` and validates +against `corpus-report.schema.json`. Its `artifact_bindings` object records +raw-byte SHA-256 digests for `source-lock.json`, the corpus-report schema, +`evaluate.mjs`, and `generate-report.mjs`. It also copies the source lock's +resolved upstream commit and tree. `binding_digest` covers that binding object +except for the digest field itself. + +The generator traverses mutation paths through own properties only. Array +segments must be existing non-negative integer indexes. The segments +`__proto__`, `constructor`, and `prototype` are rejected at every position, +including the final assignment target. + +These bindings make the checked report reproducible against named bytes. They +do not sign the report, establish who produced it, or turn the local result +into independent AIPS-1 evidence. + +The CLI rejects duplicate JSON members and invalid UTF-8. For valid JSON, +`input_digest` binds the canonical parsed object. When parsing cannot produce +an object, the report uses a digest of the bounded input bytes when available; +unreadable and over-size inputs leave that field null. The profile and evidence +digests remain null until those objects exist. + +The report does not include or validate a Policy Certificate, source payload +signature, trust anchor, retrieval receipt, network response, coverage term, +claim record, or payment instruction. + +## 11. Resource limits + +This implementation caps input at 1,048,576 bytes, nesting at 32 levels, +total JSON nodes at 10,000, and collection members at 256. It counts string +limits in Unicode code points, matching JSON Schema: 8,192 for JSON strings, +256 for identifiers, and 2,048 for locators and pointers. Programmatic input +also retains a 1,048,576 UTF-16-code-unit aggregate storage bound. Sources are +capped at 32, predicates at 64, observations at 128, and freshness at one year. +A limit breach returns `INDETERMINATE` with `INPUT_LIMIT_EXCEEDED`. + +Reports retain at most 64 validation diagnostics of at most 512 Unicode code points. +Truncated details are replaced by deterministic hashes, and overflow is +represented by a count and digest rather than silently discarded. + +These are local denial-of-service controls, not proposed AIPS-1 requirements. + +## 12. Mapping from the v0.1 worked example + +Appendix A of AIPS-1 v0.1 contains one illustrative oracle predicate. This +profile maps its example concepts as follows: + +| Appendix A concept | Local field | +|---|---| +| `type` | `sources[].source_type` | +| `sourceRef` | `predicates[].source_ids`, resolved to `sources[].locator` | +| `field` | `predicates[].path` as a JSON Pointer | +| `operator: eq` | `operator: EQUALS` | +| `value` | `predicates[].expected` | + +The mapping is explanatory only. It does not make the local names native, +interpret Appendix A as a schema, or cover its `evidenceRequired` field. + +## 13. Limits + +This profile does not: + +- retrieve, preserve, or authenticate external evidence; +- verify source identity, authority, independence, signatures, or truth; +- establish that `source_type`, `basis`, `revision`, or timestamps are honest; +- decide whether evidence is legally admissible or sufficient for a claim; +- evaluate P1, P2, P4, P5, Policy Certificate status, or an underlying Policy; +- assign coverage, liability, fault, negligence, claim acceptance, settlement, + or payout; +- authorize an action or an insurance decision; +- implement CAID, AEB, AEC, Gate, or an execution adapter; +- establish AIPS-1 conformance, adoption, endorsement, or interoperability; or +- replace review by the insurer, policyholder, relying party, regulator, court, + or other authorized decision-maker. diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/corpus-report.schema.json b/standards/aips-1/p3-evidence-source-evaluation-v0/corpus-report.schema.json new file mode 100644 index 000000000..e46b4a76a --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/corpus-report.schema.json @@ -0,0 +1,169 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:emilia:aips1-p3-evidence-source-corpus-report:v0.1", + "$comment": "Aggregate report over the repository-local paired corpus. Not an AIPS-1 conformance report.", + "type": "object", + "additionalProperties": false, + "required": [ + "corpus_report_version", + "corpus_version", + "corpus_digest", + "artifact_bindings", + "lab_profile", + "predicate_dialect", + "all_expectations_met", + "summary", + "cases", + "scope" + ], + "properties": { + "corpus_report_version": { "const": "aips1-p3-evidence-source-corpus-report-v0.1" }, + "corpus_version": { "const": "aips1-p3-evidence-source-vectors-v0.1" }, + "corpus_digest": { "$ref": "#/$defs/digest" }, + "artifact_bindings": { "$ref": "#/$defs/artifact_bindings" }, + "lab_profile": { "const": "aips1-p3-evidence-source-evaluation-v0.1" }, + "predicate_dialect": { "$ref": "#/$defs/predicate_dialect" }, + "all_expectations_met": { "type": "boolean" }, + "summary": { "$ref": "#/$defs/summary" }, + "cases": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { "$ref": "#/$defs/case_result" } + }, + "scope": { "$ref": "#/$defs/scope" } + }, + "$defs": { + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "digest_or_null": { + "anyOf": [ + { "$ref": "#/$defs/digest" }, + { "type": "null" } + ] + }, + "verdict": { "enum": ["SATISFIED", "NOT_SATISFIED", "INDETERMINATE"] }, + "file_binding": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "type": "string", "minLength": 1, "maxLength": 256 }, + "sha256": { "$ref": "#/$defs/digest" } + } + }, + "source_lock_binding": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "lock_version", "resolved_commit", "resolved_tree"], + "properties": { + "path": { "const": "source-lock.json" }, + "sha256": { "$ref": "#/$defs/digest" }, + "lock_version": { "const": "EMILIA-AIPS1-P3-SOURCE-LOCK-v0" }, + "resolved_commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "resolved_tree": { "type": "string", "pattern": "^[0-9a-f]{40}$" } + } + }, + "artifact_bindings": { + "type": "object", + "additionalProperties": false, + "required": ["binding_version", "source_lock", "corpus_report_schema", "evaluator", "generator", "binding_digest"], + "properties": { + "binding_version": { "const": "aips1-p3-corpus-artifact-bindings-v0.1" }, + "source_lock": { "$ref": "#/$defs/source_lock_binding" }, + "corpus_report_schema": { + "allOf": [ + { "$ref": "#/$defs/file_binding" }, + { "type": "object", "properties": { "path": { "const": "corpus-report.schema.json" } } } + ] + }, + "evaluator": { + "allOf": [ + { "$ref": "#/$defs/file_binding" }, + { "type": "object", "properties": { "path": { "const": "evaluate.mjs" } } } + ] + }, + "generator": { + "allOf": [ + { "$ref": "#/$defs/file_binding" }, + { "type": "object", "properties": { "path": { "const": "generate-report.mjs" } } } + ] + }, + "binding_digest": { "$ref": "#/$defs/digest" } + } + }, + "predicate_dialect": { + "type": "object", + "additionalProperties": false, + "required": ["dialect_id", "digest", "authority"], + "properties": { + "dialect_id": { "const": "emilia-aips1-p3-local-json-pointer-v0.1" }, + "digest": { "$ref": "#/$defs/digest" }, + "authority": { "const": "repository_local_proposal_not_aips1_v0.1" } + } + }, + "reason_codes": { + "type": "array", + "maxItems": 256, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "case_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "case_id", "pair_id", "kind", "expected_verdict", "actual_verdict", + "expected_reason_codes", "actual_reason_codes", "actual_input_digest", + "actual_profile_digest", "actual_evidence_set_digest", "static_evaluable", + "runtime_evaluable", "expectation_met" + ], + "properties": { + "case_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "pair_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "kind": { "enum": ["CONTROL", "HOSTILE"] }, + "expected_verdict": { "$ref": "#/$defs/verdict" }, + "actual_verdict": { "$ref": "#/$defs/verdict" }, + "expected_reason_codes": { "$ref": "#/$defs/reason_codes" }, + "actual_reason_codes": { "$ref": "#/$defs/reason_codes" }, + "actual_input_digest": { "$ref": "#/$defs/digest_or_null" }, + "actual_profile_digest": { "$ref": "#/$defs/digest_or_null" }, + "actual_evidence_set_digest": { "$ref": "#/$defs/digest_or_null" }, + "static_evaluable": { "type": "boolean" }, + "runtime_evaluable": { "type": "boolean" }, + "expectation_met": { "type": "boolean" } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["total", "controls", "hostile", "mismatches", "verdict_counts"], + "properties": { + "total": { "type": "integer", "minimum": 0, "maximum": 256 }, + "controls": { "type": "integer", "minimum": 0, "maximum": 256 }, + "hostile": { "type": "integer", "minimum": 0, "maximum": 256 }, + "mismatches": { "type": "integer", "minimum": 0, "maximum": 256 }, + "verdict_counts": { + "type": "object", + "additionalProperties": false, + "required": ["SATISFIED", "NOT_SATISFIED", "INDETERMINATE"], + "properties": { + "SATISFIED": { "type": "integer", "minimum": 0, "maximum": 256 }, + "NOT_SATISFIED": { "type": "integer", "minimum": 0, "maximum": 256 }, + "INDETERMINATE": { "type": "integer", "minimum": 0, "maximum": 256 } + } + } + } + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": ["evaluates", "evaluation_mode", "does_not_evaluate", "assumptions", "does_not_determine"], + "properties": { + "evaluates": { "const": "local_trigger_predicate_satisfaction" }, + "evaluation_mode": { "const": "offline_fixture_evaluation" }, + "does_not_evaluate": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, + "assumptions": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 8192 } }, + "does_not_determine": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 256 } } + } + } + } +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/evaluate.mjs b/standards/aips-1/p3-evidence-source-evaluation-v0/evaluate.mjs new file mode 100644 index 000000000..07bdc3419 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/evaluate.mjs @@ -0,0 +1,1198 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: Apache-2.0 +// +// Local, experimental evaluator for AIPS-1 P3 public-comment analysis. +// AIPS-1 v0.1 publishes neither a normative predicate grammar nor a reference +// verifier. This file therefore implements a closed, repository-local dialect +// and never reports AIPS conformance, authorization, coverage, liability, +// claim acceptance, payout, or a certificate state transition. + +import { createHash } from "node:crypto"; +import { closeSync, openSync, readSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { types as utilTypes } from "node:util"; + +export const PROFILE_VERSION = "aips1-p3-evidence-source-evaluation-v0.1"; +export const EVIDENCE_SET_VERSION = "aips1-p3-evidence-set-v0.1"; +export const REPORT_VERSION = "aips1-p3-evidence-source-report-v0.1"; +export const VERDICTS = ["SATISFIED", "NOT_SATISFIED", "INDETERMINATE"]; +export const SUPPORTED_OPERATORS = ["EQUALS", "NOT_EQUALS", "NUMBER_GTE", "NUMBER_LTE"]; +export const SUPPORTED_SOURCE_FORMATS = ["application/json"]; + +export const LIMITS = Object.freeze({ + max_input_bytes: 1_048_576, + max_depth: 32, + max_total_nodes: 10_000, + max_collection_items: 256, + max_string_length: 8_192, + max_total_string_length: 1_048_576, + max_identifier_length: 256, + max_locator_length: 2_048, + max_sources: 32, + max_predicates: 64, + max_observations: 128, + max_age_seconds: 31_536_000, + max_validation_errors: 64, + max_validation_error_length: 512, +}); + +export const LOCAL_DIALECT = Object.freeze({ + dialect_id: "emilia-aips1-p3-local-json-pointer-v0.1", + authority: "repository_local_proposal_not_aips1_v0.1", + appendix_a_example_mapping: { + type: "sources[].source_type", + sourceRef: "predicates[].source_ids resolved through sources[].locator", + field: "predicates[].path as an RFC 6901 JSON Pointer", + operator_eq: "EQUALS", + value: "predicates[].expected", + }, + local_extensions: [ + "ALL composition", + "multiple declared sources per predicate", + "NOT_EQUALS", + "NUMBER_GTE", + "NUMBER_LTE", + "three-state runtime verdict", + "safe-integer-only JSON numbers", + ], +}); + +export const SCOPE = Object.freeze({ + evaluates: "local_trigger_predicate_satisfaction", + evaluation_mode: "offline_fixture_evaluation", + does_not_evaluate: ["AIPS certificate evidenceRequired", "AIPS certificate state transitions"], + assumptions: [ + "the evaluation profile is trusted out of band", + "fixture locator, revision, observed_at, and availability metadata are supplied inputs", + "data_sha256 pins canonical parsed JSON, not source raw bytes", + "JSON numbers are restricted to signed IEEE-754 safe integers; decimals use fixed-point integers or strings", + ], + does_not_determine: [ + "authorization", + "coverage", + "liability", + "claim_acceptance", + "payout", + ], +}); + +const PROFILE_FIELDS = [ + "profile_version", + "profile_id", + "evaluation_time", + "combiner", + "sources", + "predicates", +]; +const SOURCE_FIELDS = [ + "source_id", + "source_type", + "locator", + "revision", + "format", + "basis", + "data_sha256", + "max_age_seconds", +]; +const PREDICATE_FIELDS = [ + "predicate_id", + "source_ids", + "path", + "operator", + "expected", +]; +const EVIDENCE_SET_FIELDS = ["evidence_set_version", "observations"]; +const OBSERVATION_FIELDS = [ + "source_id", + "locator", + "revision", + "observed_at", + "availability", + "format", + "basis", + "data", +]; + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.length > 0; +} + +function unicodeCodePointLength(value, maximum = Number.POSITIVE_INFINITY) { + let length = 0; + for (const _codePoint of value) { + length += 1; + if (length > maximum) return length; + } + return length; +} + +function isBoundedString(value, maximum) { + return isNonEmptyString(value) && unicodeCodePointLength(value, maximum) <= maximum; +} + +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function unknownFields(object, allowed, prefix) { + if (!isRecord(object)) return []; + return Object.keys(object) + .filter((key) => !allowed.includes(key)) + .sort() + .map((key) => `${prefix}unknown-field:${key}`); +} + +const STRICT_TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z$/; + +function strictTimestampMs(value) { + if (typeof value !== "string") return null; + const match = STRICT_TIMESTAMP_PATTERN.exec(value); + if (!match || match[1] === "0000") return null; + const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return null; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (day < 1 || day > days[month - 1]) return null; + const milliseconds = Date.parse(value); + return Number.isFinite(milliseconds) ? milliseconds : null; +} + +function isRfc3339(value) { + return strictTimestampMs(value) !== null; +} + +function inspectJson(value) { + const errors = []; + let nodes = 0; + let totalStringLength = 0; + const ancestors = new Set(); + + function countString(valueToCount) { + const codePointLength = unicodeCodePointLength(valueToCount, LIMITS.max_string_length); + // Retain the original aggregate UTF-16 storage bound as a separate + // resource limit. JSON Schema maxLength checks use code points. + totalStringLength += valueToCount.length; + if (codePointLength > LIMITS.max_string_length) { + errors.push("input:max-string-length-exceeded"); + } + if (totalStringLength > LIMITS.max_total_string_length) { + errors.push("input:max-total-string-length-exceeded"); + } + } + + function visit(current, depth) { + nodes += 1; + if (nodes > LIMITS.max_total_nodes) { + errors.push("input:max-total-nodes-exceeded"); + return; + } + if (depth > LIMITS.max_depth) { + errors.push("input:max-depth-exceeded"); + return; + } + if (current === null || typeof current === "boolean") return; + if (typeof current === "string") { + countString(current); + return; + } + if (typeof current === "number") { + if (!Number.isSafeInteger(current) || Object.is(current, -0)) { + errors.push("input:unsafe-number"); + } + return; + } + if (typeof current !== "object") { + errors.push("input:non-json-value"); + return; + } + if (utilTypes.isProxy(current)) { + errors.push("input:proxy-object"); + return; + } + if (ancestors.has(current)) { + errors.push("input:cyclic-value"); + return; + } + ancestors.add(current); + if (Array.isArray(current)) { + if (Object.getPrototypeOf(current) !== Array.prototype) { + errors.push("input:non-plain-object"); + ancestors.delete(current); + return; + } + const descriptors = Object.getOwnPropertyDescriptors(current); + const length = descriptors.length?.value; + if (!Number.isSafeInteger(length) || length < 0) { + errors.push("input:invalid-array-length"); + ancestors.delete(current); + return; + } + if (length > LIMITS.max_collection_items) { + errors.push("input:max-collection-items-exceeded"); + } + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== "string") { + errors.push("input:symbol-property"); + continue; + } + if (key !== "length" && !/^(?:0|[1-9]\d*)$/.test(key)) { + errors.push("input:array-extra-property"); + } + } + const maximum = Math.min(length, LIMITS.max_collection_items + 1); + for (let index = 0; index < maximum; index += 1) { + const descriptor = descriptors[index]; + if (!descriptor) { + errors.push("input:sparse-array"); + continue; + } + if (!("value" in descriptor)) { + errors.push("input:accessor-property"); + continue; + } + if (!descriptor.enumerable) { + errors.push("input:non-enumerable-property"); + continue; + } + visit(descriptor.value, depth + 1); + } + } else { + const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null) { + errors.push("input:non-plain-object"); + ancestors.delete(current); + return; + } + const descriptors = Object.getOwnPropertyDescriptors(current); + const keys = Reflect.ownKeys(descriptors); + if (keys.length > LIMITS.max_collection_items) { + errors.push("input:max-collection-items-exceeded"); + } + for (const key of keys.slice(0, LIMITS.max_collection_items + 1)) { + if (typeof key !== "string") { + errors.push("input:symbol-property"); + continue; + } + countString(key); + const descriptor = descriptors[key]; + if (!("value" in descriptor)) { + errors.push("input:accessor-property"); + continue; + } + if (!descriptor.enumerable) { + errors.push("input:non-enumerable-property"); + continue; + } + visit(descriptor.value, depth + 1); + } + } + ancestors.delete(current); + } + + visit(value, 0); + return [...new Set(errors)].sort(); +} + +function isJsonValue(value) { + return inspectJson(value).length === 0; +} + +function snapshotJson(value, snapshots = new Map()) { + if (value === null || typeof value !== "object") return value; + if (snapshots.has(value)) return snapshots.get(value); + if (Array.isArray(value)) { + const descriptors = Object.getOwnPropertyDescriptors(value); + const snapshot = []; + snapshots.set(value, snapshot); + for (let index = 0; index < descriptors.length.value; index += 1) { + snapshot.push(snapshotJson(descriptors[index].value, snapshots)); + } + return snapshot; + } + const snapshot = Object.create(null); + snapshots.set(value, snapshot); + const descriptors = Object.getOwnPropertyDescriptors(value); + for (const key of Object.keys(descriptors)) { + Object.defineProperty(snapshot, key, { + value: snapshotJson(descriptors[key].value, snapshots), + enumerable: true, + configurable: true, + writable: true, + }); + } + return snapshot; +} + +function canonicalJsonUnchecked(value, seen = new Set()) { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return JSON.stringify(value); + } + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || Object.is(value, -0)) { + throw new TypeError("number is outside the safe-integer JSON subset"); + } + return JSON.stringify(value); + } + if (typeof value !== "object" || seen.has(value)) { + throw new TypeError("value is not acyclic JSON"); + } + seen.add(value); + let encoded; + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + if (!hasOwn(value, index)) throw new TypeError("sparse arrays are not JSON values"); + } + encoded = `[${value.map((item) => canonicalJsonUnchecked(item, seen)).join(",")}]`; + } else { + const members = Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJsonUnchecked(value[key], seen)}`); + encoded = `{${members.join(",")}}`; + } + seen.delete(value); + return encoded; +} + +export function canonicalJson(value) { + const errors = inspectJson(value); + if (errors.length > 0) throw new TypeError(errors[0]); + return canonicalJsonUnchecked(value); +} + +export function digestJson(value) { + return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +} + +function digestBytes(value) { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +class StrictJsonError extends SyntaxError { + constructor(code) { + super(code); + this.name = "StrictJsonError"; + this.code = code; + } +} + +function strictJsonError(code) { + throw new StrictJsonError(code); +} + +export function parseJsonStrict(source) { + if (typeof source !== "string") strictJsonError("MALFORMED_JSON"); + if (Buffer.byteLength(source, "utf8") > LIMITS.max_input_bytes) { + strictJsonError("INPUT_LIMIT_EXCEEDED"); + } + let index = 0; + let nodes = 0; + + function whitespace() { + while (index < source.length && /[\t\n\r ]/.test(source[index])) index += 1; + } + + function countNode(depth) { + nodes += 1; + if (nodes > LIMITS.max_total_nodes || depth > LIMITS.max_depth) { + strictJsonError("INPUT_LIMIT_EXCEEDED"); + } + } + + function stringValue() { + if (source[index] !== '"') strictJsonError("MALFORMED_JSON"); + const start = index; + index += 1; + let escaped = false; + while (index < source.length) { + const code = source.charCodeAt(index); + if (escaped) { + if (source[index] === "u") { + if (!/^[0-9a-fA-F]{4}$/.test(source.slice(index + 1, index + 5))) { + strictJsonError("MALFORMED_JSON"); + } + index += 5; + } else { + if (!['"', "\\", "/", "b", "f", "n", "r", "t"].includes(source[index])) { + strictJsonError("MALFORMED_JSON"); + } + index += 1; + } + escaped = false; + continue; + } + if (source[index] === "\\") { + escaped = true; + index += 1; + continue; + } + if (source[index] === '"') { + index += 1; + let decoded; + try { + decoded = JSON.parse(source.slice(start, index)); + } catch { + strictJsonError("MALFORMED_JSON"); + } + if (unicodeCodePointLength(decoded, LIMITS.max_string_length) > LIMITS.max_string_length) { + strictJsonError("INPUT_LIMIT_EXCEEDED"); + } + return decoded; + } + if (code < 0x20) strictJsonError("MALFORMED_JSON"); + index += 1; + } + strictJsonError("MALFORMED_JSON"); + } + + function arrayValue(depth) { + index += 1; + const result = []; + whitespace(); + if (source[index] === "]") { + index += 1; + return result; + } + while (index < source.length) { + if (result.length >= LIMITS.max_collection_items) strictJsonError("INPUT_LIMIT_EXCEEDED"); + result.push(value(depth + 1)); + whitespace(); + if (source[index] === "]") { + index += 1; + return result; + } + if (source[index] !== ",") strictJsonError("MALFORMED_JSON"); + index += 1; + whitespace(); + } + strictJsonError("MALFORMED_JSON"); + } + + function objectValue(depth) { + index += 1; + const result = Object.create(null); + const members = new Set(); + whitespace(); + if (source[index] === "}") { + index += 1; + return result; + } + while (index < source.length) { + if (members.size >= LIMITS.max_collection_items) strictJsonError("INPUT_LIMIT_EXCEEDED"); + const key = stringValue(); + if (members.has(key)) strictJsonError("DUPLICATE_MEMBER"); + members.add(key); + whitespace(); + if (source[index] !== ":") strictJsonError("MALFORMED_JSON"); + index += 1; + whitespace(); + Object.defineProperty(result, key, { + value: value(depth + 1), + enumerable: true, + configurable: true, + writable: true, + }); + whitespace(); + if (source[index] === "}") { + index += 1; + return result; + } + if (source[index] !== ",") strictJsonError("MALFORMED_JSON"); + index += 1; + whitespace(); + } + strictJsonError("MALFORMED_JSON"); + } + + function value(depth) { + whitespace(); + countNode(depth); + const character = source[index]; + if (character === '"') return stringValue(); + if (character === "[") return arrayValue(depth); + if (character === "{") return objectValue(depth); + for (const [literal, decoded] of [["true", true], ["false", false], ["null", null]]) { + if (source.startsWith(literal, index)) { + index += literal.length; + return decoded; + } + } + const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(source.slice(index)); + if (!number) strictJsonError("MALFORMED_JSON"); + index += number[0].length; + const token = number[0]; + const decoded = Number(token); + if (!Number.isSafeInteger(decoded) || Object.is(decoded, -0) || token !== String(decoded)) { + strictJsonError("UNSAFE_NUMBER"); + } + return decoded; + } + + const result = value(0); + whitespace(); + if (index !== source.length) strictJsonError("MALFORMED_JSON"); + return result; +} + +function readTextBounded(filePath) { + const descriptor = openSync(filePath, "r"); + try { + const buffer = Buffer.allocUnsafe(LIMITS.max_input_bytes + 1); + let total = 0; + while (total < buffer.length) { + const count = readSync(descriptor, buffer, total, buffer.length - total, null); + if (count === 0) break; + total += count; + } + if (total > LIMITS.max_input_bytes) strictJsonError("INPUT_LIMIT_EXCEEDED"); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, total)); + } catch { + strictJsonError("MALFORMED_JSON"); + } + } finally { + closeSync(descriptor); + } +} + +export function loadJsonFileStrict(filePath) { + return parseJsonStrict(readTextBounded(filePath)); +} + +export const LOCAL_DIALECT_SHA256 = digestJson(LOCAL_DIALECT); + +function validPointer(pointer) { + if (pointer === "") return true; + if (typeof pointer !== "string" || !pointer.startsWith("/")) return false; + return !/(?:~(?![01]))/.test(pointer); +} + +function pointerValue(document, pointer) { + if (pointer === "") return { found: true, value: document }; + if (!validPointer(pointer)) return { found: false }; + let current = document; + for (const token of pointer.slice(1).split("/")) { + const key = token.replace(/~1/g, "/").replace(/~0/g, "~"); + if (Array.isArray(current)) { + if (!/^(?:0|[1-9]\d*)$/.test(key)) return { found: false }; + const arrayIndex = Number(key); + if (!Number.isSafeInteger(arrayIndex) || arrayIndex >= current.length || !hasOwn(current, arrayIndex)) { + return { found: false }; + } + current = current[arrayIndex]; + continue; + } + if (current === null || typeof current !== "object" || !hasOwn(current, key)) { + return { found: false }; + } + current = current[key]; + } + return { found: true, value: current }; +} + +function validateProfile(profile) { + const errors = []; + if (!isRecord(profile)) return ["profile:not-an-object"]; + errors.push(...unknownFields(profile, PROFILE_FIELDS, "profile:")); + if (profile.profile_version !== PROFILE_VERSION) errors.push("profile:wrong-version"); + if (!isBoundedString(profile.profile_id, LIMITS.max_identifier_length)) { + errors.push("profile:invalid-profile-id"); + } + if (!isRfc3339(profile.evaluation_time)) errors.push("profile:invalid-evaluation-time"); + if (profile.combiner !== "ALL") errors.push("profile:unsupported-combiner"); + if (!Array.isArray(profile.sources) || profile.sources.length === 0) { + errors.push("profile:sources-must-be-non-empty-array"); + } else if (profile.sources.length > LIMITS.max_sources) { + errors.push("profile:too-many-sources"); + } + if (!Array.isArray(profile.predicates) || profile.predicates.length === 0) { + errors.push("profile:predicates-must-be-non-empty-array"); + } else if (profile.predicates.length > LIMITS.max_predicates) { + errors.push("profile:too-many-predicates"); + } + + if (Array.isArray(profile.sources)) { + profile.sources.forEach((source, index) => { + const prefix = `profile:sources[${index}]:`; + if (!isRecord(source)) { + errors.push(`${prefix}not-an-object`); + return; + } + errors.push(...unknownFields(source, SOURCE_FIELDS, prefix)); + for (const field of SOURCE_FIELDS) { + if (!hasOwn(source, field)) errors.push(`${prefix}missing:${field}`); + } + for (const field of ["source_id", "source_type", "revision", "format"]) { + if (!isBoundedString(source[field], LIMITS.max_identifier_length)) { + errors.push(`${prefix}invalid:${field}`); + } + } + if (!isBoundedString(source.locator, LIMITS.max_locator_length)) { + errors.push(`${prefix}invalid:locator`); + } + if (!["OBSERVED_FACT", "ISSUER_OPINION"].includes(source.basis)) { + errors.push(`${prefix}invalid:basis`); + } + if (!/^sha256:[0-9a-f]{64}$/.test(source.data_sha256 ?? "")) { + errors.push(`${prefix}invalid:data_sha256`); + } + if ( + !Number.isInteger(source.max_age_seconds) || + source.max_age_seconds < 0 || + source.max_age_seconds > LIMITS.max_age_seconds + ) { + errors.push(`${prefix}invalid:max_age_seconds`); + } + }); + } + + if (Array.isArray(profile.predicates)) { + profile.predicates.forEach((predicate, index) => { + const prefix = `profile:predicates[${index}]:`; + if (!isRecord(predicate)) { + errors.push(`${prefix}not-an-object`); + return; + } + errors.push(...unknownFields(predicate, PREDICATE_FIELDS, prefix)); + for (const field of PREDICATE_FIELDS) { + if (!hasOwn(predicate, field)) errors.push(`${prefix}missing:${field}`); + } + if (!isBoundedString(predicate.predicate_id, LIMITS.max_identifier_length)) { + errors.push(`${prefix}invalid:predicate_id`); + } + if (!validPointer(predicate.path)) errors.push(`${prefix}invalid:path`); + if ( + typeof predicate.path !== "string" || + unicodeCodePointLength(predicate.path, LIMITS.max_locator_length) > LIMITS.max_locator_length + ) { + errors.push(`${prefix}invalid:path`); + } + if (!isBoundedString(predicate.operator, LIMITS.max_identifier_length)) { + errors.push(`${prefix}invalid:operator`); + } + if (!hasOwn(predicate, "expected") || !isJsonValue(predicate.expected)) { + errors.push(`${prefix}invalid:expected`); + } + if (!Array.isArray(predicate.source_ids) || predicate.source_ids.length === 0) { + errors.push(`${prefix}invalid:source_ids`); + } else { + if (predicate.source_ids.length > LIMITS.max_sources) { + errors.push(`${prefix}too-many:source_ids`); + } + const localIds = new Set(); + predicate.source_ids.forEach((sourceId) => { + if (!isBoundedString(sourceId, LIMITS.max_identifier_length)) { + errors.push(`${prefix}invalid:source_id`); + } + if (localIds.has(sourceId)) errors.push(`${prefix}duplicate-source-ref`); + localIds.add(sourceId); + }); + } + }); + } + return [...new Set(errors)].sort(); +} + +function validateEvidenceSet(evidenceSet) { + const errors = []; + if (!isRecord(evidenceSet)) return ["evidence-set:not-an-object"]; + errors.push(...unknownFields(evidenceSet, EVIDENCE_SET_FIELDS, "evidence-set:")); + if (evidenceSet.evidence_set_version !== EVIDENCE_SET_VERSION) { + errors.push("evidence-set:wrong-version"); + } + if (!Array.isArray(evidenceSet.observations)) { + errors.push("evidence-set:observations-must-be-array"); + return [...new Set(errors)].sort(); + } + if (evidenceSet.observations.length > LIMITS.max_observations) { + errors.push("evidence-set:too-many-observations"); + } + evidenceSet.observations.forEach((observation, index) => { + const prefix = `evidence-set:observations[${index}]:`; + if (!isRecord(observation)) { + errors.push(`${prefix}not-an-object`); + return; + } + errors.push(...unknownFields(observation, OBSERVATION_FIELDS, prefix)); + for (const field of ["source_id", "locator", "revision", "availability"]) { + if (!hasOwn(observation, field)) errors.push(`${prefix}missing:${field}`); + } + for (const field of ["source_id", "revision"]) { + if (!isBoundedString(observation[field], LIMITS.max_identifier_length)) { + errors.push(`${prefix}invalid:${field}`); + } + } + if (!isBoundedString(observation.locator, LIMITS.max_locator_length)) { + errors.push(`${prefix}invalid:locator`); + } + if (!["AVAILABLE", "UNAVAILABLE"].includes(observation.availability)) { + errors.push(`${prefix}invalid:availability`); + } + if (observation.availability === "AVAILABLE") { + for (const field of ["observed_at", "format", "basis", "data"]) { + if (!hasOwn(observation, field)) errors.push(`${prefix}missing:${field}`); + } + } + if (hasOwn(observation, "observed_at") && !isRfc3339(observation.observed_at)) { + errors.push(`${prefix}invalid:observed_at`); + } + if ( + hasOwn(observation, "format") && + !isBoundedString(observation.format, LIMITS.max_identifier_length) + ) { + errors.push(`${prefix}invalid:format`); + } + if (hasOwn(observation, "basis") && !["OBSERVED_FACT", "ISSUER_OPINION"].includes(observation.basis)) { + errors.push(`${prefix}invalid:basis`); + } + if (hasOwn(observation, "data") && !isJsonValue(observation.data)) { + errors.push(`${prefix}invalid:data`); + } + }); + return [...new Set(errors)].sort(); +} + +function deepEqualJson(left, right) { + return canonicalJson(left) === canonicalJson(right); +} + +function indexBy(items, key) { + const index = new Map(); + for (const item of items) { + const value = item[key]; + const group = index.get(value) ?? []; + group.push(item); + index.set(value, group); + } + return index; +} + +function staticPredicateIssue(predicate, indexes) { + if (!SUPPORTED_OPERATORS.includes(predicate.operator)) return "PREDICATE_UNSUPPORTED"; + if ((indexes.predicates.get(predicate.predicate_id) ?? []).length > 1) { + return "PREDICATE_ID_AMBIGUOUS"; + } + for (const sourceId of predicate.source_ids) { + const pins = indexes.sources.get(sourceId) ?? []; + if (pins.length === 0) return "SOURCE_UNPINNED"; + if (pins.length > 1) return "SOURCE_AMBIGUOUS"; + const [pin] = pins; + if (!SUPPORTED_SOURCE_FORMATS.includes(pin.format)) return "SOURCE_UNSUPPORTED"; + } + return null; +} + +function compare(operator, actual, expected) { + if (operator === "EQUALS" || operator === "NOT_EQUALS") { + const equal = deepEqualJson(actual, expected); + return { determinate: true, satisfied: operator === "EQUALS" ? equal : !equal }; + } + if (["NUMBER_GTE", "NUMBER_LTE"].includes(operator)) { + if ( + typeof actual !== "number" || + !Number.isFinite(actual) || + typeof expected !== "number" || + !Number.isFinite(expected) + ) { + return { determinate: false, reason: "VALUE_TYPE_UNSUPPORTED" }; + } + return { + determinate: true, + satisfied: operator === "NUMBER_GTE" ? actual >= expected : actual <= expected, + }; + } + return { determinate: false, reason: "PREDICATE_UNSUPPORTED" }; +} + +function observationConflict(observations) { + if (observations.length < 2) return false; + const first = canonicalJson(observations[0]); + return observations.slice(1).some((observation) => canonicalJson(observation) !== first); +} + +function predicateResult(predicate, profile, indexes) { + const staticIssue = staticPredicateIssue(predicate, indexes); + if (staticIssue) { + return { + predicate_id: predicate.predicate_id, + source_ids: [...predicate.source_ids], + static_evaluable: false, + runtime_evaluable: false, + verdict: "INDETERMINATE", + reason_codes: [staticIssue], + }; + } + + const reasons = []; + const values = []; + const bases = []; + for (const sourceId of predicate.source_ids) { + const [pin] = indexes.sources.get(sourceId); + const observations = indexes.observations.get(sourceId) ?? []; + if (observations.length === 0) { + reasons.push("SOURCE_MISSING"); + continue; + } + if (observationConflict(observations)) { + reasons.push("SOURCE_CONFLICT"); + continue; + } + const observation = observations[0]; + if (observation.locator !== pin.locator || observation.revision !== pin.revision) { + reasons.push("SOURCE_UNPINNED"); + continue; + } + if (observation.availability !== "AVAILABLE") { + reasons.push("SOURCE_UNAVAILABLE"); + continue; + } + if (observation.format !== pin.format || !SUPPORTED_SOURCE_FORMATS.includes(observation.format)) { + reasons.push("SOURCE_UNSUPPORTED"); + continue; + } + if (observation.basis !== pin.basis) { + reasons.push("SOURCE_UNPINNED"); + continue; + } + const observedAt = strictTimestampMs(observation.observed_at); + const evaluatedAt = strictTimestampMs(profile.evaluation_time); + if (observedAt > evaluatedAt || evaluatedAt - observedAt > pin.max_age_seconds * 1000) { + reasons.push("SOURCE_STALE"); + continue; + } + if (digestJson(observation.data) !== pin.data_sha256) { + reasons.push("SOURCE_UNPINNED"); + continue; + } + const selected = pointerValue(observation.data, predicate.path); + if (!selected.found) { + reasons.push("VALUE_MISSING"); + continue; + } + values.push(selected.value); + bases.push(observation.basis); + } + + if (reasons.length > 0 || values.length !== predicate.source_ids.length) { + return { + predicate_id: predicate.predicate_id, + source_ids: [...predicate.source_ids], + static_evaluable: true, + runtime_evaluable: false, + verdict: "INDETERMINATE", + reason_codes: [...new Set(reasons)].sort(), + }; + } + if (values.slice(1).some((value) => !deepEqualJson(value, values[0]))) { + return { + predicate_id: predicate.predicate_id, + source_ids: [...predicate.source_ids], + static_evaluable: true, + runtime_evaluable: false, + verdict: "INDETERMINATE", + reason_codes: ["SOURCE_CONFLICT"], + }; + } + if (bases.every((basis) => basis === "ISSUER_OPINION")) { + return { + predicate_id: predicate.predicate_id, + source_ids: [...predicate.source_ids], + static_evaluable: true, + runtime_evaluable: false, + verdict: "INDETERMINATE", + reason_codes: ["SOURCE_ISSUER_OPINION_ONLY"], + }; + } + + const comparison = compare(predicate.operator, values[0], predicate.expected); + if (!comparison.determinate) { + return { + predicate_id: predicate.predicate_id, + source_ids: [...predicate.source_ids], + static_evaluable: comparison.reason !== "PREDICATE_UNSUPPORTED", + runtime_evaluable: false, + verdict: "INDETERMINATE", + reason_codes: [comparison.reason], + }; + } + return { + predicate_id: predicate.predicate_id, + source_ids: [...predicate.source_ids], + static_evaluable: true, + runtime_evaluable: true, + verdict: comparison.satisfied ? "SATISFIED" : "NOT_SATISFIED", + reason_codes: [comparison.satisfied ? "PREDICATE_SATISFIED" : "PREDICATE_NOT_SATISFIED"], + }; +} + +function safeDigest(value) { + try { + return value === undefined ? null : digestJson(value); + } catch { + return null; + } +} + +function safeRead(object, key) { + try { + return object?.[key]; + } catch { + return undefined; + } +} + +function sourceSnapshots(profile, evidenceSet) { + const observations = indexBy(evidenceSet.observations, "source_id"); + const pins = indexBy(profile.sources, "source_id"); + const visited = new Set(); + return profile.sources.flatMap((source) => { + if (visited.has(source.source_id)) return []; + visited.add(source.source_id); + return [{ + source_id: source.source_id, + pin_count: pins.get(source.source_id).length, + locator: source.locator, + revision: source.revision, + format: source.format, + basis: source.basis, + data_sha256: source.data_sha256, + max_age_seconds: source.max_age_seconds, + observations: (observations.get(source.source_id) ?? []).map((observation) => ({ + availability: observation.availability, + locator: observation.locator, + revision: observation.revision, + observed_at: hasOwn(observation, "observed_at") ? observation.observed_at : null, + format: hasOwn(observation, "format") ? observation.format : null, + basis: hasOwn(observation, "basis") ? observation.basis : null, + data_sha256: hasOwn(observation, "data") ? safeDigest(observation.data) : null, + })), + }]; + }); +} + +function digestStringList(values) { + const hash = createHash("sha256"); + for (const value of values) { + hash.update(String(Buffer.byteLength(value, "utf8"))); + hash.update(":"); + hash.update(value); + } + return hash.digest("hex"); +} + +function normalizeValidationErrors(validationErrors) { + const unique = [...new Set(validationErrors)].sort(); + const normalized = unique.map((error) => { + if (unicodeCodePointLength(error, LIMITS.max_validation_error_length) <= LIMITS.max_validation_error_length) { + return error; + } + return `validation-error-hashed:sha256:${digestStringList([error])}`; + }); + if (normalized.length <= LIMITS.max_validation_errors) return normalized; + const retained = normalized.slice(0, LIMITS.max_validation_errors - 1); + retained.push( + `validation-errors-truncated:count=${unique.length}:sha256:${digestStringList(unique)}`, + ); + return retained; +} + +function invalidReport(input, reasonCodes, validationErrors, options = {}) { + const candidateCaseId = safeRead(input, "case_id"); + const profile = safeRead(input, "profile"); + const evidenceSet = safeRead(input, "evidence_set"); + const profileId = safeRead(profile, "profile_id"); + const evaluationTime = safeRead(profile, "evaluation_time"); + return { + report_version: REPORT_VERSION, + lab_profile: PROFILE_VERSION, + predicate_dialect: { + dialect_id: LOCAL_DIALECT.dialect_id, + digest: LOCAL_DIALECT_SHA256, + authority: LOCAL_DIALECT.authority, + }, + case_id: isBoundedString(candidateCaseId, LIMITS.max_identifier_length) + ? candidateCaseId + : "invalid-input", + profile_id: isBoundedString(profileId, LIMITS.max_identifier_length) ? profileId : null, + evaluation_time: isRfc3339(evaluationTime) ? evaluationTime : null, + input_digest: hasOwn(options, "input_digest") ? options.input_digest : safeDigest(input), + profile_digest: safeDigest(profile), + evidence_set_digest: safeDigest(evidenceSet), + source_snapshots: [], + static_evaluable: false, + runtime_evaluable: false, + verdict: "INDETERMINATE", + reason_codes: [...new Set(reasonCodes)].sort(), + validation_errors: normalizeValidationErrors(validationErrors), + predicate_results: [], + scope: SCOPE, + }; +} + +function evaluateCaseInternal(input) { + if (!isRecord(input)) return invalidReport(input, ["CASE_INVALID"], ["case:not-an-object"]); + const profileErrors = validateProfile(input.profile); + const evidenceErrors = validateEvidenceSet(input.evidence_set); + const reasons = []; + if (profileErrors.length > 0) reasons.push("PROFILE_INVALID"); + if (evidenceErrors.length > 0) reasons.push("EVIDENCE_SET_INVALID"); + if (reasons.length > 0) { + return invalidReport(input, reasons, [...profileErrors, ...evidenceErrors]); + } + + const indexes = { + sources: indexBy(input.profile.sources, "source_id"), + predicates: indexBy(input.profile.predicates, "predicate_id"), + observations: indexBy(input.evidence_set.observations, "source_id"), + }; + const predicateResults = input.profile.predicates.map((predicate) => + predicateResult(predicate, input.profile, indexes), + ); + const staticEvaluable = predicateResults.every((result) => result.static_evaluable); + const runtimeEvaluable = predicateResults.every((result) => result.runtime_evaluable); + const verdict = !runtimeEvaluable + ? "INDETERMINATE" + : predicateResults.some((result) => result.verdict === "NOT_SATISFIED") + ? "NOT_SATISFIED" + : "SATISFIED"; + const reasonCodes = [...new Set(predicateResults.flatMap((result) => result.reason_codes))] + .filter((reason) => !["PREDICATE_SATISFIED", "PREDICATE_NOT_SATISFIED"].includes(reason)) + .sort(); + + return { + report_version: REPORT_VERSION, + lab_profile: PROFILE_VERSION, + predicate_dialect: { + dialect_id: LOCAL_DIALECT.dialect_id, + digest: LOCAL_DIALECT_SHA256, + authority: LOCAL_DIALECT.authority, + }, + case_id: isBoundedString(input.case_id, LIMITS.max_identifier_length) + ? input.case_id + : "unnamed-case", + profile_id: input.profile.profile_id, + evaluation_time: input.profile.evaluation_time, + input_digest: digestJson(input), + profile_digest: digestJson(input.profile), + evidence_set_digest: digestJson(input.evidence_set), + source_snapshots: sourceSnapshots(input.profile, input.evidence_set), + static_evaluable: staticEvaluable, + runtime_evaluable: runtimeEvaluable, + verdict, + reason_codes: reasonCodes, + validation_errors: [], + predicate_results: predicateResults, + scope: SCOPE, + }; +} + +export function evaluateCase(input) { + try { + const inspectionErrors = inspectJson(input); + const bounds = inspectionErrors.filter((error) => error.startsWith("input:max-")); + if (bounds.length > 0) { + return invalidReport({}, ["INPUT_LIMIT_EXCEEDED"], bounds, { input_digest: null }); + } + if (inspectionErrors.includes("input:unsafe-number")) { + return invalidReport({}, ["INPUT_NUMBER_UNSAFE"], ["input:unsafe-number"], { + input_digest: null, + }); + } + const nonInert = inspectionErrors; + if (nonInert.length > 0) { + return invalidReport({}, ["CASE_INVALID"], nonInert, { input_digest: null }); + } + return evaluateCaseInternal(snapshotJson(input)); + } catch { + return invalidReport({}, ["EVALUATION_FAILURE"], ["evaluation:internal-failure"], { + input_digest: null, + }); + } +} + +export function stableReportJson(report) { + return `${canonicalJson(report)}\n`; +} + +export function evaluateFile(filePath) { + let source; + try { + source = readTextBounded(filePath); + } catch (error) { + if (error?.code === "INPUT_LIMIT_EXCEEDED") { + return invalidReport({}, ["INPUT_LIMIT_EXCEEDED"], ["input:max-bytes-exceeded"], { + input_digest: null, + }); + } + if (error?.code === "MALFORMED_JSON") { + return invalidReport({}, ["INPUT_MALFORMED"], ["input:malformed-json"], { + input_digest: null, + }); + } + return invalidReport({}, ["INPUT_UNREADABLE"], ["input:unreadable"], { + input_digest: null, + }); + } + let input; + try { + input = parseJsonStrict(source); + } catch (error) { + const inputDigest = digestBytes(Buffer.from(source, "utf8")); + if (error?.code === "DUPLICATE_MEMBER") { + return invalidReport({}, ["INPUT_DUPLICATE_MEMBER"], ["input:duplicate-member"], { + input_digest: inputDigest, + }); + } + if (error?.code === "UNSAFE_NUMBER") { + return invalidReport({}, ["INPUT_NUMBER_UNSAFE"], ["input:unsafe-number"], { + input_digest: inputDigest, + }); + } + if (error?.code === "INPUT_LIMIT_EXCEEDED") { + return invalidReport({}, ["INPUT_LIMIT_EXCEEDED"], ["input:resource-limit-exceeded"], { + input_digest: inputDigest, + }); + } + return invalidReport({}, ["INPUT_MALFORMED"], ["input:malformed-json"], { + input_digest: inputDigest, + }); + } + return evaluateCase(input); +} + +function main(argv) { + const args = argv.slice(2); + const files = args.filter((arg) => arg !== "--json"); + const unknownFlags = files.filter((arg) => arg.startsWith("--")); + if (unknownFlags.length > 0 || files.length !== 1) { + process.stderr.write("usage: node evaluate.mjs [--json]\n"); + return 2; + } + let report = evaluateFile(files[0]); + try { + process.stdout.write(stableReportJson(report)); + } catch { + report = invalidReport({}, ["EVALUATION_FAILURE"], ["evaluation:report-serialization-failed"], { + input_digest: null, + }); + process.stdout.write(stableReportJson(report)); + } + return report.reason_codes.some( + (reason) => reason.startsWith("INPUT_") || reason === "EVALUATION_FAILURE", + ) + ? 1 + : 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main(process.argv)); +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/evaluate.selftest.mjs b/standards/aips-1/p3-evidence-source-evaluation-v0/evaluate.selftest.mjs new file mode 100644 index 000000000..61835d8c5 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/evaluate.selftest.mjs @@ -0,0 +1,900 @@ +// SPDX-License-Identifier: Apache-2.0 +// Behavioral and schema-parity tests for the local AIPS-1 P3 comment lab. +// Run after repository dependencies are installed: node --test evaluate.selftest.mjs + +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; + +import { + LIMITS, + VERDICTS, + digestJson, + evaluateCase, + evaluateFile, + parseJsonStrict, +} from "./evaluate.mjs"; +import { + buildArtifactBindings, + buildCorpusReport, + loadCorpus, + materializeVector, + stableCorpusReportJson, +} from "./generate-report.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); + +function fileSha256(path) { + return `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`; +} + +function withTempDirectory(run) { + const directory = mkdtempSync(join(tmpdir(), "aips1-p3-selftest-")); + try { + return run(directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +const baseProfile = { + profile_version: "aips1-p3-evidence-source-evaluation-v0.1", + profile_id: "example.trigger.v1", + evaluation_time: "2026-09-01T12:00:00Z", + combiner: "ALL", + sources: [ + { + source_id: "filing", + source_type: "regulator_filing", + locator: "https://example.test/filings/42", + revision: "sha256:source-revision-42", + format: "application/json", + basis: "OBSERVED_FACT", + data_sha256: digestJson({ status: "effective" }), + max_age_seconds: 86400, + }, + ], + predicates: [ + { + predicate_id: "filing-is-effective", + source_ids: ["filing"], + path: "/status", + operator: "EQUALS", + expected: "effective", + }, + ], +}; + +function evidenceWith(status) { + return { + evidence_set_version: "aips1-p3-evidence-set-v0.1", + observations: [ + { + source_id: "filing", + locator: "https://example.test/filings/42", + revision: "sha256:source-revision-42", + observed_at: "2026-09-01T11:00:00Z", + availability: "AVAILABLE", + format: "application/json", + basis: "OBSERVED_FACT", + data: { status }, + }, + ], + }; +} + +function evaluateMutation(caseId, mutate, { repinData = true } = {}) { + const profile = structuredClone(baseProfile); + const evidenceSet = evidenceWith("effective"); + mutate({ profile, evidenceSet }); + if (repinData && evidenceSet.observations[0]?.data !== undefined) { + profile.sources[0].data_sha256 = digestJson(evidenceSet.observations[0].data); + } + return evaluateCase({ case_id: caseId, profile, evidence_set: evidenceSet }); +} + +test("exports exactly the three closed lab trigger verdicts", () => { + assert.deepEqual(VERDICTS, ["SATISFIED", "NOT_SATISFIED", "INDETERMINATE"]); +}); + +test("returns SATISFIED only when a declared, pinned, current source makes the predicate true", () => { + const report = evaluateCase({ case_id: "control-satisfied", profile: baseProfile, evidence_set: evidenceWith("effective") }); + assert.equal(report.verdict, "SATISFIED"); + assert.equal(report.predicate_results[0].verdict, "SATISFIED"); +}); + +test("every report binds the canonical profile, evidence set, input, and source snapshot", () => { + const input = { + case_id: "digest-binding-control", + profile: baseProfile, + evidence_set: evidenceWith("effective"), + }; + const report = evaluateCase(input); + assert.equal(report.profile_digest, digestJson(input.profile)); + assert.equal(report.evidence_set_digest, digestJson(input.evidence_set)); + assert.equal(report.input_digest, digestJson(input)); + assert.deepEqual(report.source_snapshots, [ + { + source_id: "filing", + pin_count: 1, + locator: "https://example.test/filings/42", + revision: "sha256:source-revision-42", + format: "application/json", + basis: "OBSERVED_FACT", + data_sha256: digestJson({ status: "effective" }), + max_age_seconds: 86400, + observations: [ + { + availability: "AVAILABLE", + locator: "https://example.test/filings/42", + revision: "sha256:source-revision-42", + observed_at: "2026-09-01T11:00:00Z", + format: "application/json", + basis: "OBSERVED_FACT", + data_sha256: digestJson({ status: "effective" }), + }, + ], + }, + ]); + + const changedEvidence = evidenceWith("effective"); + changedEvidence.observations[0].observed_at = "2026-09-01T11:00:01Z"; + const changed = evaluateCase({ ...input, evidence_set: changedEvidence }); + assert.equal(changed.verdict, report.verdict); + assert.notEqual(changed.evidence_set_digest, report.evidence_set_digest); + assert.notEqual(changed.input_digest, report.input_digest); + assert.notEqual(JSON.stringify(changed), JSON.stringify(report)); +}); + +test("returns NOT_SATISFIED for a determinate false predicate", () => { + const profile = structuredClone(baseProfile); + profile.sources[0].data_sha256 = digestJson({ status: "withdrawn" }); + const report = evaluateCase({ case_id: "control-not-satisfied", profile, evidence_set: evidenceWith("withdrawn") }); + assert.equal(report.verdict, "NOT_SATISFIED"); + assert.equal(report.predicate_results[0].verdict, "NOT_SATISFIED"); +}); + +test("returns INDETERMINATE, never false, when required evidence is missing", () => { + const report = evaluateCase({ + case_id: "hostile-missing-source", + profile: baseProfile, + evidence_set: { evidence_set_version: "aips1-p3-evidence-set-v0.1", observations: [] }, + }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["SOURCE_MISSING"]); +}); + +for (const hostile of [ + { + name: "unavailable", + reason: "SOURCE_UNAVAILABLE", + mutate: ({ evidenceSet }) => { evidenceSet.observations[0].availability = "UNAVAILABLE"; }, + }, + { + name: "stale", + reason: "SOURCE_STALE", + mutate: ({ evidenceSet }) => { evidenceSet.observations[0].observed_at = "2026-08-30T11:59:59Z"; }, + }, + { + name: "unsupported", + reason: "SOURCE_UNSUPPORTED", + mutate: ({ profile, evidenceSet }) => { + profile.sources[0].format = "application/cbor"; + evidenceSet.observations[0].format = "application/cbor"; + }, + }, + { + name: "wrong locator pin", + reason: "SOURCE_UNPINNED", + mutate: ({ evidenceSet }) => { evidenceSet.observations[0].locator = "https://attacker.test/filing"; }, + }, + { + name: "wrong revision pin", + reason: "SOURCE_UNPINNED", + mutate: ({ evidenceSet }) => { evidenceSet.observations[0].revision = "sha256:substituted"; }, + }, + { + name: "value missing from an otherwise available source", + reason: "VALUE_MISSING", + mutate: ({ evidenceSet }) => { evidenceSet.observations[0].data = {}; }, + }, +]) { + test(`${hostile.name} evidence is INDETERMINATE, not NOT_SATISFIED`, () => { + const report = evaluateMutation(`hostile-${hostile.name}`, hostile.mutate); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, [hostile.reason]); + }); +} + +test("a trigger depending solely on issuer opinion is INDETERMINATE", () => { + const report = evaluateMutation("hostile-issuer-opinion", ({ profile, evidenceSet }) => { + profile.sources[0].basis = "ISSUER_OPINION"; + evidenceSet.observations[0].basis = "ISSUER_OPINION"; + }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["SOURCE_ISSUER_OPINION_ONLY"]); +}); + +test("issuer opinion plus a declared observed-fact source is not sole-opinion support", () => { + const report = evaluateMutation("control-opinion-plus-observed-fact", ({ profile, evidenceSet }) => { + profile.sources.push({ + source_id: "court-order", + source_type: "court_order", + locator: "https://example.test/orders/7", + revision: "sha256:order-7", + format: "application/json", + basis: "OBSERVED_FACT", + data_sha256: digestJson({ status: "effective" }), + max_age_seconds: 86400, + }); + profile.predicates[0].source_ids.push("court-order"); + profile.sources[0].basis = "ISSUER_OPINION"; + evidenceSet.observations[0].basis = "ISSUER_OPINION"; + evidenceSet.observations.push({ + source_id: "court-order", + locator: "https://example.test/orders/7", + revision: "sha256:order-7", + observed_at: "2026-09-01T11:30:00Z", + availability: "AVAILABLE", + format: "application/json", + basis: "OBSERVED_FACT", + data: { status: "effective" }, + }); + }); + assert.equal(report.verdict, "SATISFIED"); +}); + +test("RFC 6901 array selection accepts canonical indexes and rejects array properties", () => { + const control = evaluateMutation("control-array-index", ({ profile, evidenceSet }) => { + profile.predicates[0].path = "/0"; + evidenceSet.observations[0].data = ["effective"]; + }); + assert.equal(control.verdict, "SATISFIED"); + + for (const path of ["/length", "/01", "/-"]) { + const hostile = evaluateMutation(`hostile-array-pointer-${path}`, ({ profile, evidenceSet }) => { + profile.predicates[0].path = path; + profile.predicates[0].expected = path === "/length" ? 1 : "effective"; + evidenceSet.observations[0].data = ["effective"]; + }); + assert.equal(hostile.verdict, "INDETERMINATE", path); + assert.deepEqual(hostile.reason_codes, ["VALUE_MISSING"], path); + } +}); + +test("the empty RFC 6901 pointer selects the complete source value", () => { + const report = evaluateMutation("control-root-pointer", ({ profile, evidenceSet }) => { + profile.predicates[0].path = ""; + profile.predicates[0].expected = { status: "effective" }; + evidenceSet.observations[0].data = { status: "effective" }; + }); + assert.equal(report.verdict, "SATISFIED"); + assert.equal(report.predicate_results[0].runtime_evaluable, true); +}); + +test("string bounds count Unicode code points exactly as the JSON Schemas do", () => { + const emoji = "😀"; + const sourceId = emoji.repeat(LIMITS.max_identifier_length); + const revision = emoji.repeat(LIMITS.max_identifier_length); + const locator = emoji.repeat(LIMITS.max_locator_length); + const propertyName = emoji.repeat(LIMITS.max_locator_length - 1); + const payload = emoji.repeat(LIMITS.max_string_length); + const profile = structuredClone(baseProfile); + const evidenceSet = evidenceWith("effective"); + + profile.profile_id = emoji.repeat(200); + profile.sources[0].source_id = sourceId; + profile.sources[0].source_type = emoji.repeat(LIMITS.max_identifier_length); + profile.sources[0].locator = locator; + profile.sources[0].revision = revision; + profile.predicates[0].predicate_id = emoji.repeat(LIMITS.max_identifier_length); + profile.predicates[0].source_ids = [sourceId]; + profile.predicates[0].path = `/${propertyName}`; + profile.predicates[0].expected = payload; + evidenceSet.observations[0].source_id = sourceId; + evidenceSet.observations[0].locator = locator; + evidenceSet.observations[0].revision = revision; + evidenceSet.observations[0].data = { [propertyName]: payload }; + profile.sources[0].data_sha256 = digestJson(evidenceSet.observations[0].data); + + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + const validateProfile = ajv.compile( + JSON.parse(readFileSync(join(here, "evaluation-profile.schema.json"), "utf8")), + ); + const validateEvidence = ajv.compile( + JSON.parse(readFileSync(join(here, "evidence-set.schema.json"), "utf8")), + ); + const validateReport = ajv.compile( + JSON.parse(readFileSync(join(here, "evaluation-report.schema.json"), "utf8")), + ); + assert.equal(validateProfile(profile), true, JSON.stringify(validateProfile.errors)); + assert.equal(validateEvidence(evidenceSet), true, JSON.stringify(validateEvidence.errors)); + + const input = { case_id: "unicode-code-point-control", profile, evidence_set: evidenceSet }; + const programmaticReport = evaluateCase(input); + assert.equal(programmaticReport.verdict, "SATISFIED"); + assert.equal(validateReport(programmaticReport), true, JSON.stringify(validateReport.errors)); + + const parsedReport = evaluateCase(parseJsonStrict(JSON.stringify(input))); + assert.equal(parsedReport.verdict, "SATISFIED"); + assert.equal(validateReport(parsedReport), true, JSON.stringify(validateReport.errors)); + + const overBoundProfile = structuredClone(baseProfile); + overBoundProfile.profile_id = emoji.repeat(LIMITS.max_identifier_length + 1); + assert.equal(validateProfile(overBoundProfile), false); + const overBoundReport = evaluateCase({ + case_id: "unicode-code-point-over-bound", + profile: overBoundProfile, + evidence_set: evidenceWith("effective"), + }); + assert.equal(overBoundReport.verdict, "INDETERMINATE"); + assert.deepEqual(overBoundReport.reason_codes, ["PROFILE_INVALID"]); + assert.ok(overBoundReport.validation_errors.includes("profile:invalid-profile-id")); +}); + +test("timestamps reject impossible calendar dates and precision beyond milliseconds", () => { + for (const timestamp of [ + "2026-02-30T12:00:00Z", + "2026-09-01T12:00:00.0001Z", + "2026-09-01T12:00:00+00:00", + ]) { + const report = evaluateMutation(`hostile-timestamp-${timestamp}`, ({ profile }) => { + profile.evaluation_time = timestamp; + }); + assert.equal(report.verdict, "INDETERMINATE", timestamp); + assert.deepEqual(report.reason_codes, ["PROFILE_INVALID"], timestamp); + assert.ok(report.validation_errors.includes("profile:invalid-evaluation-time"), timestamp); + } + + const millisecondControl = evaluateMutation("control-millisecond-time", ({ profile, evidenceSet }) => { + profile.evaluation_time = "2026-09-01T12:00:00.125Z"; + evidenceSet.observations[0].observed_at = "2026-09-01T12:00:00.124Z"; + }); + assert.equal(millisecondControl.verdict, "SATISFIED"); +}); + +test("conflicting declared sources yield INDETERMINATE", () => { + const report = evaluateMutation("hostile-conflict", ({ profile, evidenceSet }) => { + profile.sources.push({ + source_id: "court-order", + source_type: "court_order", + locator: "https://example.test/orders/7", + revision: "sha256:order-7", + format: "application/json", + basis: "OBSERVED_FACT", + data_sha256: digestJson({ status: "stayed" }), + max_age_seconds: 86400, + }); + profile.predicates[0].source_ids.push("court-order"); + evidenceSet.observations.push({ + source_id: "court-order", + locator: "https://example.test/orders/7", + revision: "sha256:order-7", + observed_at: "2026-09-01T11:30:00Z", + availability: "AVAILABLE", + format: "application/json", + basis: "OBSERVED_FACT", + data: { status: "stayed" }, + }); + }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["SOURCE_CONFLICT"]); +}); + +test("duplicate observations that disagree for one source yield INDETERMINATE", () => { + const report = evaluateMutation("hostile-duplicate-conflict", ({ evidenceSet }) => { + const conflict = structuredClone(evidenceSet.observations[0]); + conflict.data.status = "withdrawn"; + evidenceSet.observations.push(conflict); + }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["SOURCE_CONFLICT"]); +}); + +test("forged data that does not match the relying-party content pin is INDETERMINATE", () => { + const report = evaluateMutation("hostile-forged-data", ({ evidenceSet }) => { + evidenceSet.observations[0].data.status = "forged-effective"; + }, { repinData: false }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["SOURCE_UNPINNED"]); +}); + +test("a basis label that does not match the relying-party source pin is INDETERMINATE", () => { + const report = evaluateMutation("hostile-forged-basis", ({ evidenceSet }) => { + evidenceSet.observations[0].basis = "ISSUER_OPINION"; + }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["SOURCE_UNPINNED"]); +}); + +test("an undefined local predicate operator yields INDETERMINATE", () => { + const report = evaluateMutation("hostile-undefined-operator", ({ profile }) => { + profile.predicates[0].operator = "ISSUER_DECIDES"; + }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["PREDICATE_UNSUPPORTED"]); +}); + +test("duplicate semantic identifiers remain schema-valid but evaluate as ambiguous", () => { + const duplicateSourceProfile = structuredClone(baseProfile); + duplicateSourceProfile.sources.push(structuredClone(duplicateSourceProfile.sources[0])); + const sourceReport = evaluateCase({ + case_id: "hostile-duplicate-source-id", + profile: duplicateSourceProfile, + evidence_set: evidenceWith("effective"), + }); + assert.equal(sourceReport.verdict, "INDETERMINATE"); + assert.deepEqual(sourceReport.reason_codes, ["SOURCE_AMBIGUOUS"]); + + const duplicatePredicateProfile = structuredClone(baseProfile); + duplicatePredicateProfile.predicates.push(structuredClone(duplicatePredicateProfile.predicates[0])); + const predicateReport = evaluateCase({ + case_id: "hostile-duplicate-predicate-id", + profile: duplicatePredicateProfile, + evidence_set: evidenceWith("effective"), + }); + assert.equal(predicateReport.verdict, "INDETERMINATE"); + assert.deepEqual(predicateReport.reason_codes, ["PREDICATE_ID_AMBIGUOUS"]); +}); + +test("supported numeric predicates are determinate and reject type ambiguity", () => { + const satisfied = evaluateMutation("control-number-gte", ({ profile, evidenceSet }) => { + profile.predicates[0].operator = "NUMBER_GTE"; + profile.predicates[0].expected = 10; + evidenceSet.observations[0].data.status = 12; + }); + assert.equal(satisfied.verdict, "SATISFIED"); + + const notSatisfied = evaluateMutation("control-number-gte-false", ({ profile, evidenceSet }) => { + profile.predicates[0].operator = "NUMBER_GTE"; + profile.predicates[0].expected = 10; + evidenceSet.observations[0].data.status = 9; + }); + assert.equal(notSatisfied.verdict, "NOT_SATISFIED"); + + const ambiguous = evaluateMutation("hostile-number-type", ({ profile, evidenceSet }) => { + profile.predicates[0].operator = "NUMBER_GTE"; + profile.predicates[0].expected = 10; + evidenceSet.observations[0].data.status = "12"; + }); + assert.equal(ambiguous.verdict, "INDETERMINATE"); + assert.deepEqual(ambiguous.reason_codes, ["VALUE_TYPE_UNSUPPORTED"]); +}); + +test("unsafe or non-integer programmatic numbers are rejected before evaluation", () => { + for (const value of [ + 9_007_199_254_740_992, + 1.5, + -0, + ]) { + const evidenceSet = evidenceWith("effective"); + evidenceSet.observations[0].data.status = value; + const report = evaluateCase({ + case_id: "hostile-programmatic-number", + profile: baseProfile, + evidence_set: evidenceSet, + }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["INPUT_NUMBER_UNSAFE"]); + assert.deepEqual(report.validation_errors, ["input:unsafe-number"]); + } +}); + +test("numeric JSON tokens that alias after Number conversion are rejected", () => { + for (const token of [ + "9007199254740992", + "9007199254740993", + "0.10000000000000001", + ]) { + assert.throws( + () => parseJsonStrict(`{"value":${token}}`), + (error) => error?.code === "UNSAFE_NUMBER", + token, + ); + } + + assert.equal( + parseJsonStrict('{"value":9007199254740991}').value, + 9_007_199_254_740_991, + ); +}); + +test("programmatic objects with inherited properties are rejected as non-plain", () => { + const data = Object.create({ status: "effective" }); + const evidenceSet = evidenceWith("effective"); + evidenceSet.observations[0].data = data; + const report = evaluateCase({ case_id: "hostile-inherited-field", profile: baseProfile, evidence_set: evidenceSet }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["CASE_INVALID"]); + assert.deepEqual(report.validation_errors, ["input:non-plain-object"]); +}); + +test("programmatic sparse arrays are rejected instead of canonicalized as non-JSON", () => { + const profile = structuredClone(baseProfile); + profile.predicates[0].expected = new Array(2); + const report = evaluateCase({ case_id: "hostile-sparse-array", profile, evidence_set: evidenceWith("effective") }); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["CASE_INVALID"]); + assert.deepEqual(report.validation_errors, ["input:sparse-array"]); +}); + +test("an indeterminate predicate dominates a determinate false in ALL composition", () => { + const report = evaluateMutation("hostile-false-plus-missing", ({ profile }) => { + profile.predicates[0].expected = "withdrawn"; + profile.predicates.push({ + predicate_id: "missing-source-predicate", + source_ids: ["absent-source"], + path: "/status", + operator: "EQUALS", + expected: "effective", + }); + }); + assert.equal(report.predicate_results[0].verdict, "NOT_SATISFIED"); + assert.equal(report.predicate_results[1].verdict, "INDETERMINATE"); + assert.equal(report.verdict, "INDETERMINATE"); +}); + +test("invalid profile and evidence-set structures abstain with named reasons", () => { + const invalidProfile = evaluateCase({ + case_id: "hostile-invalid-profile", + profile: { profile_version: "wrong" }, + evidence_set: evidenceWith("effective"), + }); + assert.equal(invalidProfile.verdict, "INDETERMINATE"); + assert.deepEqual(invalidProfile.reason_codes, ["PROFILE_INVALID"]); + + const invalidEvidence = evaluateCase({ + case_id: "hostile-invalid-evidence", + profile: baseProfile, + evidence_set: { evidence_set_version: "wrong", observations: [] }, + }); + assert.equal(invalidEvidence.verdict, "INDETERMINATE"); + assert.deepEqual(invalidEvidence.reason_codes, ["EVIDENCE_SET_INVALID"]); +}); + +test("the report keeps trigger evaluation separate from legal and payment decisions", () => { + const report = evaluateCase({ case_id: "scope-control", profile: baseProfile, evidence_set: evidenceWith("effective") }); + assert.equal(report.scope.evaluates, "local_trigger_predicate_satisfaction"); + assert.equal(report.scope.evaluation_mode, "offline_fixture_evaluation"); + assert.equal(report.static_evaluable, true); + assert.equal(report.runtime_evaluable, true); + assert.equal(report.profile_id, "example.trigger.v1"); + assert.equal(report.evaluation_time, "2026-09-01T12:00:00Z"); + assert.deepEqual(report.scope.does_not_determine, [ + "authorization", + "coverage", + "liability", + "claim_acceptance", + "payout", + ]); + assert.ok(!("authorized" in report)); + assert.ok(!("eligible" in report)); +}); + +test("the static vector corpus has a control paired with every hostile case", () => { + const corpus = loadCorpus(join(here, "vectors", "cases.json")); + const pairs = new Map(); + for (const vector of corpus.vectors) { + const members = pairs.get(vector.pair_id) ?? []; + members.push(vector.kind); + pairs.set(vector.pair_id, members); + } + assert.ok(pairs.size >= 10); + for (const members of pairs.values()) { + assert.deepEqual([...members].sort(), ["CONTROL", "HOSTILE"]); + } +}); + +test("every static vector reproduces its expected verdict and reason codes", () => { + const corpus = loadCorpus(join(here, "vectors", "cases.json")); + const report = buildCorpusReport(corpus); + assert.equal(report.all_expectations_met, true); + assert.ok(report.summary.total >= 20); + assert.equal(report.summary.mismatches, 0); + assert.deepEqual( + [...new Set(report.cases.map((entry) => entry.actual_verdict))].sort(), + ["INDETERMINATE", "NOT_SATISFIED", "SATISFIED"], + ); + const hostileReasons = new Set( + report.cases.filter((entry) => entry.kind === "HOSTILE").flatMap((entry) => entry.actual_reason_codes), + ); + for (const required of [ + "SOURCE_MISSING", + "SOURCE_UNAVAILABLE", + "SOURCE_STALE", + "SOURCE_UNSUPPORTED", + "SOURCE_CONFLICT", + "SOURCE_UNPINNED", + ]) { + assert.ok(hostileReasons.has(required), `missing hostile reason ${required}`); + } +}); + +test("corpus mutation paths cannot traverse or assign prototype-bearing segments", () => { + const corpus = loadCorpus(join(here, "vectors", "cases.json")); + const marker = "__aips1_p3_polluted__"; + const paths = [ + ["__proto__", marker], + ["constructor", "prototype", marker], + ["profile", "__proto__"], + ["profile", "constructor"], + ]; + try { + for (const path of paths) { + assert.throws( + () => materializeVector(corpus, { + case_id: "hostile-prototype-path", + mutations: [{ op: "SET", path, value: true }], + }), + /mutation path rejected/, + path.join("/"), + ); + assert.equal(Object.prototype[marker], undefined); + } + } finally { + delete Object.prototype[marker]; + } +}); + +test("the generated corpus report is byte-stable and matches report.json", () => { + const corpus = loadCorpus(join(here, "vectors", "cases.json")); + const first = stableCorpusReportJson(buildCorpusReport(corpus)); + const second = stableCorpusReportJson(buildCorpusReport(corpus)); + assert.equal(first, second); + assert.equal(first, readFileSync(join(here, "report.json"), "utf8")); +}); + +test("the corpus report binds its source lock, schema, evaluator, and generator bytes", () => { + const corpus = loadCorpus(join(here, "vectors", "cases.json")); + const report = buildCorpusReport(corpus); + assert.deepEqual(report.artifact_bindings, buildArtifactBindings()); + for (const binding of [ + report.artifact_bindings.source_lock, + report.artifact_bindings.corpus_report_schema, + report.artifact_bindings.evaluator, + report.artifact_bindings.generator, + ]) { + assert.equal(binding.sha256, fileSha256(join(here, binding.path)), binding.path); + } + const { binding_digest: claimedDigest, ...bindingMaterial } = report.artifact_bindings; + assert.equal(claimedDigest, digestJson(bindingMaterial)); + assert.equal( + report.artifact_bindings.source_lock.resolved_commit, + "280a8ba0e9c2658ee6af10778e0f6a2fb669661d", + ); + assert.equal( + report.artifact_bindings.source_lock.resolved_tree, + "0131893fd6a7c0341521d73591d14976b1af43ca", + ); +}); + +test("JSON schemas validate the executable's accepted envelopes and emitted reports", () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + const schemas = {}; + for (const name of [ + "evaluation-profile.schema.json", + "evidence-set.schema.json", + "evaluation-report.schema.json", + "corpus-report.schema.json", + "vector-corpus.schema.json", + ]) { + const schema = JSON.parse(readFileSync(join(here, name), "utf8")); + assert.equal(schema.$schema, "https://json-schema.org/draft/2020-12/schema"); + schemas[name] = schema; + } + const reportSchema = schemas["evaluation-report.schema.json"]; + assert.deepEqual(reportSchema.$defs.verdict.enum, VERDICTS); + + const validateProfile = ajv.compile(schemas["evaluation-profile.schema.json"]); + const validateEvidence = ajv.compile(schemas["evidence-set.schema.json"]); + const validateReport = ajv.compile(reportSchema); + const validateCorpus = ajv.compile(schemas["vector-corpus.schema.json"]); + const validateCorpusReport = ajv.compile(schemas["corpus-report.schema.json"]); + assert.equal(validateProfile(baseProfile), true, JSON.stringify(validateProfile.errors)); + assert.equal(validateEvidence(evidenceWith("effective")), true, JSON.stringify(validateEvidence.errors)); + const report = evaluateCase({ case_id: "schema-report-control", profile: baseProfile, evidence_set: evidenceWith("effective") }); + assert.equal(validateReport(report), true, JSON.stringify(validateReport.errors)); + const invalidReport = evaluateCase({ + case_id: "schema-report-invalid-control", + profile: { profile_version: "wrong" }, + evidence_set: evidenceWith("effective"), + }); + assert.equal(validateReport(invalidReport), true, JSON.stringify(validateReport.errors)); + + const noisyProfile = structuredClone(baseProfile); + for (let index = 0; index < 248; index += 1) { + noisyProfile.sources[0][`unknown-${index.toString().padStart(3, "0")}`] = true; + } + const boundedDiagnosticReport = evaluateCase({ + case_id: "schema-report-bounded-diagnostics", + profile: noisyProfile, + evidence_set: evidenceWith("effective"), + }); + assert.equal(boundedDiagnosticReport.verdict, "INDETERMINATE"); + assert.deepEqual(boundedDiagnosticReport.reason_codes, ["PROFILE_INVALID"]); + assert.ok(boundedDiagnosticReport.validation_errors.length <= LIMITS.max_validation_errors); + assert.match(boundedDiagnosticReport.validation_errors.at(-1), /^validation-errors-truncated:count=\d+:sha256:[0-9a-f]{64}$/); + assert.equal( + validateReport(boundedDiagnosticReport), + true, + JSON.stringify(validateReport.errors), + ); + const corpus = loadCorpus(join(here, "vectors", "cases.json")); + assert.equal(validateCorpus(corpus), true, JSON.stringify(validateCorpus.errors)); + const corpusReport = buildCorpusReport(corpus); + assert.equal(validateCorpusReport(corpusReport), true, JSON.stringify(validateCorpusReport.errors)); + + const unsupported = structuredClone(baseProfile); + unsupported.predicates[0].operator = "FUTURE_PROFILE_OPERATOR"; + assert.equal(validateProfile(unsupported), true, JSON.stringify(validateProfile.errors)); + const unsupportedReport = evaluateCase({ case_id: "schema-unsupported-operator", profile: unsupported, evidence_set: evidenceWith("effective") }); + assert.deepEqual(unsupportedReport.reason_codes, ["PREDICATE_UNSUPPORTED"]); + + const duplicateSourceProfile = structuredClone(baseProfile); + duplicateSourceProfile.sources.push(structuredClone(duplicateSourceProfile.sources[0])); + assert.equal(validateProfile(duplicateSourceProfile), true, JSON.stringify(validateProfile.errors)); + assert.deepEqual( + evaluateCase({ case_id: "schema-duplicate-source-id", profile: duplicateSourceProfile, evidence_set: evidenceWith("effective") }).reason_codes, + ["SOURCE_AMBIGUOUS"], + ); + + const impossibleDate = structuredClone(baseProfile); + impossibleDate.evaluation_time = "2026-02-30T12:00:00Z"; + assert.equal(validateProfile(impossibleDate), false); + assert.deepEqual( + evaluateCase({ case_id: "schema-impossible-date", profile: impossibleDate, evidence_set: evidenceWith("effective") }).reason_codes, + ["PROFILE_INVALID"], + ); + + for (const unsafeValue of [9_007_199_254_740_992, 0.1]) { + const unsafeEvidence = evidenceWith("effective"); + unsafeEvidence.observations[0].data.status = unsafeValue; + assert.equal(validateEvidence(unsafeEvidence), false, String(unsafeValue)); + } +}); + +test("strict JSON rejects duplicate members at every evaluator envelope level", () => { + for (const [name, source] of [ + ["case", '{"case_id":"first","case_id":"second","profile":{},"evidence_set":{}}'], + ["profile", '{"case_id":"x","profile":{"profile_version":"a","profile_version":"b"},"evidence_set":{}}'], + ["source", '{"profile":{"sources":[{"source_id":"a","source_id":"b"}]}}'], + ["predicate", '{"profile":{"predicates":[{"path":"/a","path":"/b"}]}}'], + ["observation", '{"evidence_set":{"observations":[{"availability":"AVAILABLE","availability":"UNAVAILABLE"}]}}'], + ]) { + assert.throws( + () => parseJsonStrict(source), + (error) => error?.code === "DUPLICATE_MEMBER", + name, + ); + } + + withTempDirectory((directory) => { + const path = join(directory, "duplicate.json"); + writeFileSync(path, '{"case_id":"first","case_id":"second","profile":{},"evidence_set":{}}'); + const report = evaluateFile(path); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["INPUT_DUPLICATE_MEMBER"]); + assert.equal(report.case_id, "invalid-input"); + }); +}); + +test("strict corpus loading rejects duplicate JSON members", () => { + withTempDirectory((directory) => { + const path = join(directory, "duplicate-corpus.json"); + writeFileSync(path, '{"corpus_version":"first","corpus_version":"second","base_case":{},"vectors":[]}'); + assert.throws( + () => loadCorpus(path), + (error) => error?.code === "DUPLICATE_MEMBER", + ); + }); +}); + +test("bounded inputs abstain deterministically instead of exhausting the evaluator", () => { + const oversized = structuredClone(baseProfile); + oversized.profile_id = "x".repeat(LIMITS.max_string_length + 1); + const oversizedReport = evaluateCase({ case_id: "oversized", profile: oversized, evidence_set: evidenceWith("effective") }); + assert.equal(oversizedReport.verdict, "INDETERMINATE"); + assert.deepEqual(oversizedReport.reason_codes, ["INPUT_LIMIT_EXCEEDED"]); + + let tooDeep = "leaf"; + for (let depth = 0; depth <= LIMITS.max_depth; depth += 1) tooDeep = { nested: tooDeep }; + const deepReport = evaluateCase(tooDeep); + assert.equal(deepReport.verdict, "INDETERMINATE"); + assert.deepEqual(deepReport.reason_codes, ["INPUT_LIMIT_EXCEEDED"]); + + withTempDirectory((directory) => { + const path = join(directory, "oversized.json"); + writeFileSync(path, `{"padding":"${"x".repeat(LIMITS.max_input_bytes)}"}`); + const report = evaluateFile(path); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["INPUT_LIMIT_EXCEEDED"]); + }); +}); + +test("accessor-backed inputs are rejected without invoking an accessor", () => { + const hostile = {}; + let reads = 0; + Object.defineProperty(hostile, "profile", { + enumerable: true, + get() { + reads += 1; + return reads % 2 === 0 ? structuredClone(baseProfile) : { profile_version: "substituted" }; + }, + }); + const first = evaluateCase(hostile); + const second = evaluateCase(hostile); + assert.deepEqual(first, second); + assert.equal(reads, 0); + assert.equal(first.verdict, "INDETERMINATE"); + assert.deepEqual(first.reason_codes, ["CASE_INVALID"]); + assert.deepEqual(first.validation_errors, ["input:accessor-property"]); +}); + +test("proxy and non-plain programmatic inputs are rejected without reading values", () => { + let reads = 0; + const proxy = new Proxy({}, { + get() { + reads += 1; + return structuredClone(baseProfile); + }, + }); + const proxyReport = evaluateCase(proxy); + assert.equal(reads, 0); + assert.deepEqual(proxyReport.reason_codes, ["CASE_INVALID"]); + assert.deepEqual(proxyReport.validation_errors, ["input:proxy-object"]); + + const classInstance = new (class CaseEnvelope {})(); + const classReport = evaluateCase(classInstance); + assert.deepEqual(classReport.reason_codes, ["CASE_INVALID"]); + assert.deepEqual(classReport.validation_errors, ["input:non-plain-object"]); +}); + +test("malformed JSON produces a deterministic INDETERMINATE report without a crash", () => { + const command = [join(here, "evaluate.mjs"), join(here, "vectors", "malformed.json"), "--json"]; + const first = spawnSync(process.execPath, command, { encoding: "utf8" }); + const second = spawnSync(process.execPath, command, { encoding: "utf8" }); + assert.equal(first.status, 1); + assert.equal(second.status, 1); + assert.equal(first.stderr, ""); + assert.equal(first.stdout, second.stdout); + const report = JSON.parse(first.stdout); + assert.equal(report.verdict, "INDETERMINATE"); + assert.deepEqual(report.reason_codes, ["INPUT_MALFORMED"]); +}); + +test("malformed and unreadable reports are path-independent across working directories", () => { + const script = join(here, "evaluate.mjs"); + const first = spawnSync(process.execPath, [script, "vectors/malformed.json", "--json"], { + cwd: here, + encoding: "utf8", + }); + const second = spawnSync(process.execPath, [script, "malformed.json", "--json"], { + cwd: join(here, "vectors"), + encoding: "utf8", + }); + assert.equal(first.status, 1); + assert.equal(second.status, 1); + assert.equal(first.stdout, second.stdout); + assert.ok(!first.stdout.includes("malformed.json")); + + const unreadableFirst = spawnSync(process.execPath, [script, "missing-a.json", "--json"], { + cwd: here, + encoding: "utf8", + }); + const unreadableSecond = spawnSync(process.execPath, [script, "missing-b.json", "--json"], { + cwd: join(here, "vectors"), + encoding: "utf8", + }); + assert.equal(unreadableFirst.stdout, unreadableSecond.stdout); + assert.ok(!unreadableFirst.stdout.includes("missing-")); +}); diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/evaluation-profile.schema.json b/standards/aips-1/p3-evidence-source-evaluation-v0/evaluation-profile.schema.json new file mode 100644 index 000000000..94013f805 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/evaluation-profile.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:emilia:aips1-p3-evidence-source-evaluation-profile:v0.1", + "$comment": "Repository-local public-comment lab dialect. Not an AIPS-1 v0.1 schema or conformance claim.", + "type": "object", + "additionalProperties": false, + "maxProperties": 6, + "required": ["profile_version", "profile_id", "evaluation_time", "combiner", "sources", "predicates"], + "properties": { + "profile_version": { "const": "aips1-p3-evidence-source-evaluation-v0.1" }, + "profile_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "evaluation_time": { + "type": "string", + "format": "date-time", + "pattern": "^(?!0000)[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]{3})?Z$" + }, + "combiner": { "const": "ALL" }, + "sources": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/source" } }, + "predicates": { "type": "array", "minItems": 1, "maxItems": 64, "items": { "$ref": "#/$defs/predicate" } } + }, + "$defs": { + "source": { + "type": "object", + "additionalProperties": false, + "maxProperties": 8, + "required": ["source_id", "source_type", "locator", "revision", "format", "basis", "data_sha256", "max_age_seconds"], + "properties": { + "source_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "source_type": { "type": "string", "minLength": 1, "maxLength": 256 }, + "locator": { "type": "string", "minLength": 1, "maxLength": 2048 }, + "revision": { "type": "string", "minLength": 1, "maxLength": 256 }, + "format": { "type": "string", "minLength": 1, "maxLength": 256 }, + "basis": { "enum": ["OBSERVED_FACT", "ISSUER_OPINION"] }, + "data_sha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "max_age_seconds": { "type": "integer", "minimum": 0, "maximum": 31536000 } + } + }, + "predicate": { + "type": "object", + "additionalProperties": false, + "maxProperties": 5, + "required": ["predicate_id", "source_ids", "path", "operator", "expected"], + "properties": { + "predicate_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "source_ids": { "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, + "path": { "type": "string", "maxLength": 2048, "pattern": "^(?:$|/(?:[^~]|~[01])*)$" }, + "operator": { "type": "string", "minLength": 1, "maxLength": 256 }, + "expected": { "$ref": "#/$defs/json_value" } + } + }, + "json_value": { + "anyOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "string", "maxLength": 8192 }, + { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, + { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/json_value" } }, + { + "type": "object", + "maxProperties": 256, + "propertyNames": { "maxLength": 8192 }, + "additionalProperties": { "$ref": "#/$defs/json_value" } + } + ] + } + } +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/evaluation-report.schema.json b/standards/aips-1/p3-evidence-source-evaluation-v0/evaluation-report.schema.json new file mode 100644 index 000000000..7ad2ddc60 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/evaluation-report.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:emilia:aips1-p3-evidence-source-report:v0.1", + "$comment": "The verdict vocabulary is local to this lab and is not defined by AIPS-1 v0.1 or EP-AEC.", + "type": "object", + "additionalProperties": false, + "maxProperties": 17, + "required": [ + "report_version", "lab_profile", "predicate_dialect", "case_id", "profile_id", + "evaluation_time", "input_digest", "profile_digest", "evidence_set_digest", + "source_snapshots", "static_evaluable", "runtime_evaluable", "verdict", + "reason_codes", "validation_errors", "predicate_results", "scope" + ], + "properties": { + "report_version": { "const": "aips1-p3-evidence-source-report-v0.1" }, + "lab_profile": { "const": "aips1-p3-evidence-source-evaluation-v0.1" }, + "predicate_dialect": { "$ref": "#/$defs/predicate_dialect" }, + "case_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "profile_id": { "anyOf": [{ "type": "string", "minLength": 1, "maxLength": 256 }, { "type": "null" }] }, + "evaluation_time": { "$ref": "#/$defs/timestamp_or_null" }, + "input_digest": { "$ref": "#/$defs/digest_or_null" }, + "profile_digest": { "$ref": "#/$defs/digest_or_null" }, + "evidence_set_digest": { "$ref": "#/$defs/digest_or_null" }, + "source_snapshots": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/source_snapshot" } }, + "static_evaluable": { "type": "boolean" }, + "runtime_evaluable": { "type": "boolean" }, + "verdict": { "$ref": "#/$defs/verdict" }, + "reason_codes": { "type": "array", "maxItems": 256, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, + "validation_errors": { "type": "array", "maxItems": 64, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 512 } }, + "predicate_results": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/predicate_result" } }, + "scope": { "$ref": "#/$defs/scope" } + }, + "$defs": { + "verdict": { "enum": ["SATISFIED", "NOT_SATISFIED", "INDETERMINATE"] }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "digest_or_null": { "anyOf": [{ "$ref": "#/$defs/digest" }, { "type": "null" }] }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?!0000)[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]{3})?Z$" + }, + "timestamp_or_null": { "anyOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }] }, + "predicate_dialect": { + "type": "object", + "additionalProperties": false, + "required": ["dialect_id", "digest", "authority"], + "properties": { + "dialect_id": { "const": "emilia-aips1-p3-local-json-pointer-v0.1" }, + "digest": { "$ref": "#/$defs/digest" }, + "authority": { "const": "repository_local_proposal_not_aips1_v0.1" } + } + }, + "source_observation": { + "type": "object", + "additionalProperties": false, + "required": ["availability", "locator", "revision", "observed_at", "format", "basis", "data_sha256"], + "properties": { + "availability": { "enum": ["AVAILABLE", "UNAVAILABLE"] }, + "locator": { "type": "string", "minLength": 1, "maxLength": 2048 }, + "revision": { "type": "string", "minLength": 1, "maxLength": 256 }, + "observed_at": { "$ref": "#/$defs/timestamp_or_null" }, + "format": { "anyOf": [{ "type": "string", "minLength": 1, "maxLength": 256 }, { "type": "null" }] }, + "basis": { "anyOf": [{ "enum": ["OBSERVED_FACT", "ISSUER_OPINION"] }, { "type": "null" }] }, + "data_sha256": { "$ref": "#/$defs/digest_or_null" } + } + }, + "source_snapshot": { + "type": "object", + "additionalProperties": false, + "required": ["source_id", "pin_count", "locator", "revision", "format", "basis", "data_sha256", "max_age_seconds", "observations"], + "properties": { + "source_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "pin_count": { "type": "integer", "minimum": 1, "maximum": 32 }, + "locator": { "type": "string", "minLength": 1, "maxLength": 2048 }, + "revision": { "type": "string", "minLength": 1, "maxLength": 256 }, + "format": { "type": "string", "minLength": 1, "maxLength": 256 }, + "basis": { "enum": ["OBSERVED_FACT", "ISSUER_OPINION"] }, + "data_sha256": { "$ref": "#/$defs/digest" }, + "max_age_seconds": { "type": "integer", "minimum": 0, "maximum": 31536000 }, + "observations": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/source_observation" } } + } + }, + "predicate_result": { + "type": "object", + "additionalProperties": false, + "required": ["predicate_id", "source_ids", "static_evaluable", "runtime_evaluable", "verdict", "reason_codes"], + "properties": { + "predicate_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "source_ids": { "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, + "static_evaluable": { "type": "boolean" }, + "runtime_evaluable": { "type": "boolean" }, + "verdict": { "$ref": "#/$defs/verdict" }, + "reason_codes": { "type": "array", "maxItems": 256, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } } + } + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": ["evaluates", "evaluation_mode", "does_not_evaluate", "assumptions", "does_not_determine"], + "properties": { + "evaluates": { "const": "local_trigger_predicate_satisfaction" }, + "evaluation_mode": { "const": "offline_fixture_evaluation" }, + "does_not_evaluate": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, + "assumptions": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 8192 } }, + "does_not_determine": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 256 } } + } + } + } +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/evidence-set.schema.json b/standards/aips-1/p3-evidence-source-evaluation-v0/evidence-set.schema.json new file mode 100644 index 000000000..f08c0dbf0 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/evidence-set.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:emilia:aips1-p3-evidence-set:v0.1", + "$comment": "Offline fixture envelope for a repository-local public-comment lab, not an AIPS-native evidence format.", + "type": "object", + "additionalProperties": false, + "maxProperties": 2, + "required": ["evidence_set_version", "observations"], + "properties": { + "evidence_set_version": { "const": "aips1-p3-evidence-set-v0.1" }, + "observations": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/observation" } } + }, + "$defs": { + "observation": { + "type": "object", + "additionalProperties": false, + "maxProperties": 8, + "required": ["source_id", "locator", "revision", "availability"], + "properties": { + "source_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "locator": { "type": "string", "minLength": 1, "maxLength": 2048 }, + "revision": { "type": "string", "minLength": 1, "maxLength": 256 }, + "observed_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?!0000)[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]{3})?Z$" + }, + "availability": { "enum": ["AVAILABLE", "UNAVAILABLE"] }, + "format": { "type": "string", "minLength": 1, "maxLength": 256 }, + "basis": { "enum": ["OBSERVED_FACT", "ISSUER_OPINION"] }, + "data": { "$ref": "#/$defs/json_value" } + }, + "allOf": [ + { + "if": { "properties": { "availability": { "const": "AVAILABLE" } }, "required": ["availability"] }, + "then": { + "properties": { + "observed_at": true, + "format": true, + "basis": true, + "data": { "$ref": "#/$defs/json_value" } + }, + "required": ["observed_at", "format", "basis", "data"] + } + } + ] + }, + "json_value": { + "anyOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "string", "maxLength": 8192 }, + { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, + { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/json_value" } }, + { + "type": "object", + "maxProperties": 256, + "propertyNames": { "maxLength": 8192 }, + "additionalProperties": { "$ref": "#/$defs/json_value" } + } + ] + } + } +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/generate-report.mjs b/standards/aips-1/p3-evidence-source-evaluation-v0/generate-report.mjs new file mode 100644 index 000000000..ddae9aa88 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/generate-report.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: Apache-2.0 +// Deterministic report generator for the paired local evaluation vectors. + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + LOCAL_DIALECT, + LOCAL_DIALECT_SHA256, + PROFILE_VERSION, + SCOPE, + digestJson, + evaluateCase, + loadJsonFileStrict, + stableReportJson, +} from "./evaluate.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); + +export function loadCorpus(filePath = join(here, "vectors", "cases.json")) { + return loadJsonFileStrict(filePath); +} + +function fileBinding(baseDirectory, path) { + return { + path, + sha256: `sha256:${createHash("sha256") + .update(readFileSync(join(baseDirectory, path))) + .digest("hex")}`, + }; +} + +export function buildArtifactBindings(baseDirectory = here) { + const sourceLock = loadJsonFileStrict(join(baseDirectory, "source-lock.json")); + const material = { + binding_version: "aips1-p3-corpus-artifact-bindings-v0.1", + source_lock: { + ...fileBinding(baseDirectory, "source-lock.json"), + lock_version: sourceLock["@version"], + resolved_commit: sourceLock.upstream_repository?.resolved_commit, + resolved_tree: sourceLock.upstream_repository?.resolved_tree, + }, + corpus_report_schema: fileBinding(baseDirectory, "corpus-report.schema.json"), + evaluator: fileBinding(baseDirectory, "evaluate.mjs"), + generator: fileBinding(baseDirectory, "generate-report.mjs"), + }; + return { + ...material, + binding_digest: digestJson(material), + }; +} + +const DANGEROUS_MUTATION_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); + +function rejectMutationPath() { + throw new Error("mutation path rejected"); +} + +function ownPathValue(container, segment) { + if (typeof segment === "string" && DANGEROUS_MUTATION_SEGMENTS.has(segment)) { + rejectMutationPath(); + } + if (Array.isArray(container)) { + if (!Number.isInteger(segment) || segment < 0 || !Object.hasOwn(container, segment)) { + rejectMutationPath(); + } + } else if ( + container === null || + typeof container !== "object" || + typeof segment !== "string" || + !Object.hasOwn(container, segment) + ) { + rejectMutationPath(); + } + return container[segment]; +} + +function resolveParent(root, path) { + if (!Array.isArray(path) || path.length === 0) rejectMutationPath(); + let parent = root; + for (const segment of path.slice(0, -1)) { + parent = ownPathValue(parent, segment); + } + const key = path.at(-1); + ownPathValue(parent, key); + return { parent, key }; +} + +export function materializeVector(corpus, vector) { + const input = structuredClone(corpus.base_case); + input.case_id = vector.case_id; + for (const mutation of vector.mutations) { + const { parent, key } = resolveParent(input, mutation.path); + if (mutation.op === "SET") { + parent[key] = structuredClone(mutation.value); + } else if (mutation.op === "APPEND") { + if (!Array.isArray(parent[key])) throw new Error("APPEND target must be an array"); + parent[key].push(structuredClone(mutation.value)); + } else { + throw new Error(`unsupported mutation op: ${mutation.op}`); + } + } + return input; +} + +export function buildCorpusReport(corpus) { + const cases = corpus.vectors.map((vector) => { + const actual = evaluateCase(materializeVector(corpus, vector)); + const verdictMatches = actual.verdict === vector.expected_verdict; + const reasonsMatch = JSON.stringify(actual.reason_codes) === JSON.stringify(vector.expected_reason_codes); + return { + case_id: vector.case_id, + pair_id: vector.pair_id, + kind: vector.kind, + expected_verdict: vector.expected_verdict, + actual_verdict: actual.verdict, + expected_reason_codes: vector.expected_reason_codes, + actual_reason_codes: actual.reason_codes, + actual_input_digest: actual.input_digest, + actual_profile_digest: actual.profile_digest, + actual_evidence_set_digest: actual.evidence_set_digest, + static_evaluable: actual.static_evaluable, + runtime_evaluable: actual.runtime_evaluable, + expectation_met: verdictMatches && reasonsMatch, + }; + }); + const counts = Object.fromEntries( + ["SATISFIED", "NOT_SATISFIED", "INDETERMINATE"].map((verdict) => [ + verdict, + cases.filter((entry) => entry.actual_verdict === verdict).length, + ]), + ); + const mismatches = cases.filter((entry) => !entry.expectation_met).length; + return { + corpus_report_version: "aips1-p3-evidence-source-corpus-report-v0.1", + corpus_version: corpus.corpus_version, + corpus_digest: digestJson(corpus), + artifact_bindings: buildArtifactBindings(), + lab_profile: PROFILE_VERSION, + predicate_dialect: { + dialect_id: LOCAL_DIALECT.dialect_id, + digest: LOCAL_DIALECT_SHA256, + authority: LOCAL_DIALECT.authority, + }, + all_expectations_met: mismatches === 0, + summary: { + total: cases.length, + controls: cases.filter((entry) => entry.kind === "CONTROL").length, + hostile: cases.filter((entry) => entry.kind === "HOSTILE").length, + mismatches, + verdict_counts: counts, + }, + cases, + scope: SCOPE, + }; +} + +export function stableCorpusReportJson(report) { + return stableReportJson(report); +} + +function main(argv) { + const args = argv.slice(2); + if (args.some((arg) => arg !== "--check")) { + process.stderr.write("usage: node generate-report.mjs [--check]\n"); + return 2; + } + let output; + try { + output = stableCorpusReportJson(buildCorpusReport(loadCorpus())); + } catch (error) { + const classification = ["DUPLICATE_MEMBER", "INPUT_LIMIT_EXCEEDED", "MALFORMED_JSON"].includes(error?.code) + ? error.code.toLowerCase().replaceAll("_", "-") + : "invalid-corpus"; + process.stderr.write(`vector corpus rejected: ${classification}\n`); + return 1; + } + if (args.includes("--check")) { + let committed; + try { + committed = readFileSync(join(here, "report.json"), "utf8"); + } catch { + process.stderr.write("report.json is missing\n"); + return 1; + } + if (output !== committed) { + process.stderr.write("report.json is stale\n"); + return 1; + } + } else { + process.stdout.write(output); + } + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main(process.argv)); +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/report.json b/standards/aips-1/p3-evidence-source-evaluation-v0/report.json new file mode 100644 index 000000000..964906833 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/report.json @@ -0,0 +1 @@ +{"all_expectations_met":true,"artifact_bindings":{"binding_digest":"sha256:339099b3d88c105387caeb46673b85cb47d1808fd540708540dad0b531fb035e","binding_version":"aips1-p3-corpus-artifact-bindings-v0.1","corpus_report_schema":{"path":"corpus-report.schema.json","sha256":"sha256:cdb7d4eab212488dcdbe5467089ee0ca3540a23b8929d9a5d3ae1703ca401679"},"evaluator":{"path":"evaluate.mjs","sha256":"sha256:a435318a4e145b0d9ec21643abcdc8570f017fad6a1eed6a31baf32233dad47e"},"generator":{"path":"generate-report.mjs","sha256":"sha256:0e6a351a02b9b1e6d74c961eeee5266192a45c4dca33ebcd3ac0cf309fd4cdfa"},"source_lock":{"lock_version":"EMILIA-AIPS1-P3-SOURCE-LOCK-v0","path":"source-lock.json","resolved_commit":"280a8ba0e9c2658ee6af10778e0f6a2fb669661d","resolved_tree":"0131893fd6a7c0341521d73591d14976b1af43ca","sha256":"sha256:03cc2cb3a9bb4f7921b6711101361d88caba21b8a174fb704fe6ac90222d0675"}},"cases":[{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:60e555daab4faa78302209a54c8f752297582747f098edd1da4debd40c9c4f50","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-source-present","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"source-presence","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:0cf919634ec242f300f3f4ed3dd5e569b1abf1f444d2e31cb5fa1d7f0ed10ed9","actual_input_digest":"sha256:689a395c3d5bcac2d22246dc1afaac6c87cb99e3824557be90f9a7992068beed","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":["SOURCE_MISSING"],"actual_verdict":"INDETERMINATE","case_id":"hostile-source-missing","expectation_met":true,"expected_reason_codes":["SOURCE_MISSING"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"source-presence","runtime_evaluable":false,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:ea3b4d15cf19e0e17bdaec0976f4a67cc8a3afaf5d874c18ca8446c613cf4254","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-source-available","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"availability","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:4780b038552bdb6529cdc9ff4d121ebd907077dfb554359cb115e399c0e4c455","actual_input_digest":"sha256:8d99c001c0f4263eaaffa0b0e84fff3cc355b43303b79f378111b0fd85ac2089","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":["SOURCE_UNAVAILABLE"],"actual_verdict":"INDETERMINATE","case_id":"hostile-source-unavailable","expectation_met":true,"expected_reason_codes":["SOURCE_UNAVAILABLE"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"availability","runtime_evaluable":false,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:39adf410c77d7c9895340e4dab231b3af3e9e9c6bfde6d2aa630df480ea9007a","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-source-current","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"freshness","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:da1708373d1459178acd195a2b75f899c33e32826230085ce2bb1c6a50941a29","actual_input_digest":"sha256:cf60a2ba0809eaea3f55069b8ed4ceb0b11153e9e4f78bdc727e29ea3de816bc","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":["SOURCE_STALE"],"actual_verdict":"INDETERMINATE","case_id":"hostile-source-stale","expectation_met":true,"expected_reason_codes":["SOURCE_STALE"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"freshness","runtime_evaluable":false,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:f7eb00beebaace0a0da5dc631cf97433ba6ff0168f88ba250f418b4617b49840","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-source-format-supported","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"format-support","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:2782131e10156b3986b4c46b1988d9d29717fdf35170f8316fee291ec8e17db5","actual_input_digest":"sha256:395cb30c50b4e4d681357e4331cb58492208a4230da956626765c9f145ce31f8","actual_profile_digest":"sha256:579a396190ca24d2d292f820cbdfd94740d46515128426b64fe26aeec41abf66","actual_reason_codes":["SOURCE_UNSUPPORTED"],"actual_verdict":"INDETERMINATE","case_id":"hostile-source-format-unsupported","expectation_met":true,"expected_reason_codes":["SOURCE_UNSUPPORTED"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"format-support","runtime_evaluable":false,"static_evaluable":false},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:93ff1f8fffc476554cfe4a25c4f8720e06fc2c6078dffbd72ca3613205255c66","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-source-pinned","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"source-pin","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:e320739be0241451866870f742e252e9966953beff531841ec4a5a6ca998d023","actual_input_digest":"sha256:a233764e8bc74397cf17158f7389be9038eb2828c7de9de212d1d71802a3fc07","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":["SOURCE_UNPINNED"],"actual_verdict":"INDETERMINATE","case_id":"hostile-source-unpinned","expectation_met":true,"expected_reason_codes":["SOURCE_UNPINNED"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"source-pin","runtime_evaluable":false,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:a8d3ee96d00eb2e9ad8c79fa24a0cf1039d2c0f527008da4e6c7ae9703c73ee0","actual_input_digest":"sha256:dc4c8629c5bb844d02144b8189aeadf9c778c6a1938a7eae4ebc72068de0c5a1","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-duplicate-agrees","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"source-conflict","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:ba1e4cbbdbb7ea1d11c9088fe894fd189070be5b9339fca34d4eac4e41474be1","actual_input_digest":"sha256:f97d073e388b53b3ba276ed124b5411151986c82c9617d1dacf67593508e8cee","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":["SOURCE_CONFLICT"],"actual_verdict":"INDETERMINATE","case_id":"hostile-source-conflict","expectation_met":true,"expected_reason_codes":["SOURCE_CONFLICT"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"source-conflict","runtime_evaluable":false,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:9bbed1d5c878a31d48bc118abcfbc60a182ac723503c8ea04cbd4069308aa484","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-observed-fact","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"issuer-opinion","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:44249091605518ba3ce402443c701c4e44eeef0cde3f3459a7eba873cbcae374","actual_input_digest":"sha256:d80c41bffa7b0584d33abfb94c5f8fe5bcefd671ee0f8ba2cec63cc4119b720b","actual_profile_digest":"sha256:f0c84f00f82e3f8bf8d414b71b4ef47d0543b66b12400b5563a5d1e028b8440f","actual_reason_codes":["SOURCE_ISSUER_OPINION_ONLY"],"actual_verdict":"INDETERMINATE","case_id":"hostile-issuer-opinion-only","expectation_met":true,"expected_reason_codes":["SOURCE_ISSUER_OPINION_ONLY"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"issuer-opinion","runtime_evaluable":false,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:1b39f27c6ac942223867950e27d9ba5e1a554bb57829279c1c335ba1c78ac6bd","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-operator-defined","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"operator-support","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:e270bb7076c1ed8f617d807022f581dc767895007488134676bf1a42b5fea018","actual_profile_digest":"sha256:e78b7b627c568f2bf48010db63e054e712d02dcffe9c92dc595acbf91b35d5b1","actual_reason_codes":["PREDICATE_UNSUPPORTED"],"actual_verdict":"INDETERMINATE","case_id":"hostile-operator-undefined","expectation_met":true,"expected_reason_codes":["PREDICATE_UNSUPPORTED"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"operator-support","runtime_evaluable":false,"static_evaluable":false},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:253b82ad8e77b7f5a36c6424e8a1f82f476cb81d5e8edd2cf1ea1c84669f1fab","actual_profile_digest":"sha256:689cbab901ffc4c670c64c708bf32b3c25cffef146de95d243bc202a9b16192e","actual_reason_codes":[],"actual_verdict":"SATISFIED","case_id":"control-value-present","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"SATISFIED","kind":"CONTROL","pair_id":"value-presence","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:ec7489d371c3342783df1ac7d8e37480dcee0089d3c2fcf419db4cedeac5be62","actual_input_digest":"sha256:4082ed4230971e1b952a69cf53198dd1d819b0ed51e7648e27067926d13c5243","actual_profile_digest":"sha256:d82a1658f86d815d3fda295f23568a3cf5f6e13369f4a4d0fc28c15eafe3d9f3","actual_reason_codes":["VALUE_MISSING"],"actual_verdict":"INDETERMINATE","case_id":"hostile-value-missing","expectation_met":true,"expected_reason_codes":["VALUE_MISSING"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"value-presence","runtime_evaluable":false,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:b3d63af229ff87e415cd0a6a8a4213708dc19c638257967903e715b191de01a9","actual_profile_digest":"sha256:c61dd74b76abdc14bbe56b5ce8b753481bcf25762ac263f3eab73a530803fa8c","actual_reason_codes":[],"actual_verdict":"NOT_SATISFIED","case_id":"control-determinate-false","expectation_met":true,"expected_reason_codes":[],"expected_verdict":"NOT_SATISFIED","kind":"CONTROL","pair_id":"indeterminate-dominance","runtime_evaluable":true,"static_evaluable":true},{"actual_evidence_set_digest":"sha256:29a2fced6ea55dcd8931dd540c17b057a53594e7642c45cbdc19406520c139d3","actual_input_digest":"sha256:dbd13ed7db82c90aba9c9a4bb8d9fa85b4ed3da7bec648e017dd47fbfd41d3db","actual_profile_digest":"sha256:c3514d5f2176642a6a6f2d9af2fceafbee407c15e2b442db054344199134f11c","actual_reason_codes":["SOURCE_UNPINNED"],"actual_verdict":"INDETERMINATE","case_id":"hostile-false-plus-unpinned","expectation_met":true,"expected_reason_codes":["SOURCE_UNPINNED"],"expected_verdict":"INDETERMINATE","kind":"HOSTILE","pair_id":"indeterminate-dominance","runtime_evaluable":false,"static_evaluable":false}],"corpus_digest":"sha256:92a80bc1b1739e892f01900f1026e91042f0a086cbd78e9bba3e92000b7d31d1","corpus_report_version":"aips1-p3-evidence-source-corpus-report-v0.1","corpus_version":"aips1-p3-evidence-source-vectors-v0.1","lab_profile":"aips1-p3-evidence-source-evaluation-v0.1","predicate_dialect":{"authority":"repository_local_proposal_not_aips1_v0.1","dialect_id":"emilia-aips1-p3-local-json-pointer-v0.1","digest":"sha256:cb79909eefc9c5c59fa9298132adb2641809c7b955d06b4d9120275f4fee3846"},"scope":{"assumptions":["the evaluation profile is trusted out of band","fixture locator, revision, observed_at, and availability metadata are supplied inputs","data_sha256 pins canonical parsed JSON, not source raw bytes","JSON numbers are restricted to signed IEEE-754 safe integers; decimals use fixed-point integers or strings"],"does_not_determine":["authorization","coverage","liability","claim_acceptance","payout"],"does_not_evaluate":["AIPS certificate evidenceRequired","AIPS certificate state transitions"],"evaluates":"local_trigger_predicate_satisfaction","evaluation_mode":"offline_fixture_evaluation"},"summary":{"controls":10,"hostile":10,"mismatches":0,"total":20,"verdict_counts":{"INDETERMINATE":10,"NOT_SATISFIED":1,"SATISFIED":9}}} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/source-lock.json b/standards/aips-1/p3-evidence-source-evaluation-v0/source-lock.json new file mode 100644 index 000000000..d5fd66bbf --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/source-lock.json @@ -0,0 +1,120 @@ +{ + "@version": "EMILIA-AIPS1-P3-SOURCE-LOCK-v0", + "retrieved_at": "2026-09-01T21:05:14Z", + "subject": { + "name": "AIPS-1 Agent Insurance Policy Standard", + "version": "0.1", + "status": "draft for public comment", + "publisher": "Kadikoy Limited, Bermuda", + "publication_date": "2026-06-06", + "comment_deadline": "2026-11-30" + }, + "upstream_repository": { + "url": "https://github.com/Kadikoy1/aips-1", + "default_branch": "main", + "resolved_commit": "280a8ba0e9c2658ee6af10778e0f6a2fb669661d", + "resolved_tree": "0131893fd6a7c0341521d73591d14976b1af43ca", + "commit_url": "https://github.com/Kadikoy1/aips-1/commit/280a8ba0e9c2658ee6af10778e0f6a2fb669661d", + "commit_timestamp": "2026-06-06T17:16:07Z" + }, + "source_count": 7, + "sources": [ + { + "id": "aips1-official-site", + "role": "current first-party status, public-comment route, and document index", + "url": "https://aips-1.org/", + "revision": "web-snapshot:2026-09-01T21:05:14Z", + "content_type": "text/html; charset=UTF-8", + "etag": "\"4548466ceb38ce0a9778c027fb9b93a6-ssl\"", + "bytes": 45144, + "sha256": "57a59aa216e0f4d797694e7d6b8ec0bca07067deded6d4545b5908f3db9a2fb2" + }, + { + "id": "aips1-site-specification-pdf", + "role": "current specification linked from the official site", + "url": "https://aips-1.org/AIPS-1_Specification_v0_1.pdf", + "revision": "web-snapshot:2026-09-01T21:05:14Z", + "content_type": "application/pdf", + "etag": "\"7a50f2fdb661a5f50792567a15ef3dba-ssl\"", + "bytes": 190174, + "sha256": "01bf3d27ed21944f637ffbe1d968629f5dd491677852634e84436c562e3a3e0d", + "locators": [ + "section 2.2, Trigger and Evidence Source definitions", + "section 4, P3 Trigger Determinism", + "section 5, triggers field", + "section 7, deferred reference implementations", + "section 13, request for comment", + "Appendix A, worked trigger example" + ] + }, + { + "id": "aips1-repository-specification-pdf", + "role": "commit-pinned repository copy of the specification", + "url": "https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/docs/AIPS-1_Specification_v0_1.pdf", + "html_url": "https://github.com/Kadikoy1/aips-1/blob/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/docs/AIPS-1_Specification_v0_1.pdf", + "revision": "git:280a8ba0e9c2658ee6af10778e0f6a2fb669661d", + "git_blob_sha1": "d1755be85f3e3a1e7071fe0c73f824b7e998df2e", + "content_type": "application/octet-stream", + "bytes": 189878, + "sha256": "aeff5eda37b30bbd92b1d2008bd8c91a14479ccb8e8b97220f6d6e584bbee80c" + }, + { + "id": "aips1-repository-readme", + "role": "commit-pinned repository status, roadmap, comment channels, and scope statement", + "url": "https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/README.md", + "html_url": "https://github.com/Kadikoy1/aips-1/blob/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/README.md", + "revision": "git:280a8ba0e9c2658ee6af10778e0f6a2fb669661d", + "git_blob_sha1": "9444a21e88e2b76f471893e56f1ceb9c3f5c1ee5", + "content_type": "text/plain; charset=utf-8", + "bytes": 8338, + "sha256": "a34fc7cb16ac7c5ead69e75f5b587f5341acac2991b6716ba1bad22d3afbc88d" + }, + { + "id": "aips1-repository-schemas-status", + "role": "commit-pinned status of planned upstream schemas", + "url": "https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/schemas/README.md", + "html_url": "https://github.com/Kadikoy1/aips-1/blob/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/schemas/README.md", + "revision": "git:280a8ba0e9c2658ee6af10778e0f6a2fb669661d", + "git_blob_sha1": "196c81d05ac62744f32896fcffaa4dabba3af4d4", + "content_type": "text/plain; charset=utf-8", + "bytes": 1558, + "sha256": "cb9e8403919ea9a71536ae3595c602ba7a650e99f2a073a5aa7eea4385f9f63e" + }, + { + "id": "aips1-repository-reference-status", + "role": "commit-pinned status of planned upstream reference verifiers", + "url": "https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/reference/README.md", + "html_url": "https://github.com/Kadikoy1/aips-1/blob/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/reference/README.md", + "revision": "git:280a8ba0e9c2658ee6af10778e0f6a2fb669661d", + "git_blob_sha1": "790e9a5761fe637af08f2fb7fbc7921208e32cc9", + "content_type": "text/plain; charset=utf-8", + "bytes": 1652, + "sha256": "01f361387e0509952969735ad6c486933cadcd8bb99605e5399a3f2b0ecd9cc5" + }, + { + "id": "aips1-repository-license", + "role": "commit-pinned upstream license text", + "url": "https://raw.githubusercontent.com/Kadikoy1/aips-1/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/LICENSE", + "html_url": "https://github.com/Kadikoy1/aips-1/blob/280a8ba0e9c2658ee6af10778e0f6a2fb669661d/LICENSE", + "revision": "git:280a8ba0e9c2658ee6af10778e0f6a2fb669661d", + "git_blob_sha1": "90d1065546ed8420a50de14c8f4b5defbaa8f330", + "content_type": "text/plain; charset=utf-8", + "bytes": 6619, + "sha256": "1483739d4d4a5323e12d8cf6b69c21fd218fc0f3c60e581c719f7ca6d225de30", + "spdx_id": "CC0-1.0" + } + ], + "observed_variance": { + "site_and_repository_pdf_byte_identical": false, + "site_pdf_sha256": "01bf3d27ed21944f637ffbe1d968629f5dd491677852634e84436c562e3a3e0d", + "repository_pdf_sha256": "aeff5eda37b30bbd92b1d2008bd8c91a14479ccb8e8b97220f6d6e584bbee80c", + "review_result": "The current site PDF adds AHS-1 and healthcare material and contains editorial changes. The P3 definitions, P3 requirement, triggers-field description, deferred-verifier statement, public-comment deadline, and predicate-language question used by this package have the same substantive wording in both reviewed copies.", + "use_rule": "Use the site PDF for current public wording and the commit-pinned repository PDF for reproducibility. Do not treat the shared v0.1 filename as proof that the bytes are identical." + }, + "license": { + "upstream_material": "CC0-1.0", + "upstream_evidence": "aips1-repository-license", + "this_package": "Apache-2.0", + "this_package_evidence": "repository root LICENSE" + } +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/vector-corpus.schema.json b/standards/aips-1/p3-evidence-source-evaluation-v0/vector-corpus.schema.json new file mode 100644 index 000000000..97be4fa18 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/vector-corpus.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:emilia:aips1-p3-evidence-source-vectors:v0.1", + "$comment": "Paired controls and hostile mutations for this repository-local lab.", + "type": "object", + "additionalProperties": false, + "maxProperties": 3, + "required": ["corpus_version", "base_case", "vectors"], + "properties": { + "corpus_version": { "const": "aips1-p3-evidence-source-vectors-v0.1" }, + "base_case": { "$ref": "#/$defs/json_object" }, + "vectors": { + "type": "array", + "minItems": 20, + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "maxProperties": 6, + "required": ["case_id", "pair_id", "kind", "expected_verdict", "expected_reason_codes", "mutations"], + "properties": { + "case_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "pair_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "kind": { "enum": ["CONTROL", "HOSTILE"] }, + "expected_verdict": { "enum": ["SATISFIED", "NOT_SATISFIED", "INDETERMINATE"] }, + "expected_reason_codes": { "type": "array", "maxItems": 256, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 256 } }, + "mutations": { + "type": "array", + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "maxProperties": 3, + "required": ["op", "path", "value"], + "properties": { + "op": { "enum": ["SET", "APPEND"] }, + "path": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "anyOf": [{ "type": "string", "minLength": 1, "maxLength": 256 }, { "type": "integer", "minimum": 0, "maximum": 255 }] } }, + "value": { "$ref": "#/$defs/json_value" } + } + } + } + } + } + } + }, + "$defs": { + "json_object": { + "type": "object", + "maxProperties": 256, + "propertyNames": { "maxLength": 8192 }, + "additionalProperties": { "$ref": "#/$defs/json_value" } + }, + "json_value": { + "anyOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "string", "maxLength": 8192 }, + { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, + { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/json_value" } }, + { "$ref": "#/$defs/json_object" } + ] + } + } +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/vectors/cases.json b/standards/aips-1/p3-evidence-source-evaluation-v0/vectors/cases.json new file mode 100644 index 000000000..ae5119678 --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/vectors/cases.json @@ -0,0 +1,79 @@ +{ + "corpus_version": "aips1-p3-evidence-source-vectors-v0.1", + "base_case": { + "case_id": "base", + "profile": { + "profile_version": "aips1-p3-evidence-source-evaluation-v0.1", + "profile_id": "example.trigger.v1", + "evaluation_time": "2026-09-01T12:00:00Z", + "combiner": "ALL", + "sources": [ + { + "source_id": "filing", + "source_type": "regulator_filing", + "locator": "https://example.test/filings/42", + "revision": "sha256:source-revision-42", + "format": "application/json", + "basis": "OBSERVED_FACT", + "data_sha256": "sha256:ddef1aa708fafb20a1a41a29584e8cc9b3a7e7bd7126c70685913bcaa61515dd", + "max_age_seconds": 86400 + } + ], + "predicates": [ + { + "predicate_id": "filing-is-effective", + "source_ids": ["filing"], + "path": "/status", + "operator": "EQUALS", + "expected": "effective" + } + ] + }, + "evidence_set": { + "evidence_set_version": "aips1-p3-evidence-set-v0.1", + "observations": [ + { + "source_id": "filing", + "locator": "https://example.test/filings/42", + "revision": "sha256:source-revision-42", + "observed_at": "2026-09-01T11:00:00Z", + "availability": "AVAILABLE", + "format": "application/json", + "basis": "OBSERVED_FACT", + "data": { "status": "effective" } + } + ] + } + }, + "vectors": [ + { "case_id": "control-source-present", "pair_id": "source-presence", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [] }, + { "case_id": "hostile-source-missing", "pair_id": "source-presence", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["SOURCE_MISSING"], "mutations": [{ "op": "SET", "path": ["evidence_set", "observations"], "value": [] }] }, + + { "case_id": "control-source-available", "pair_id": "availability", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [] }, + { "case_id": "hostile-source-unavailable", "pair_id": "availability", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["SOURCE_UNAVAILABLE"], "mutations": [{ "op": "SET", "path": ["evidence_set", "observations", 0, "availability"], "value": "UNAVAILABLE" }] }, + + { "case_id": "control-source-current", "pair_id": "freshness", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [] }, + { "case_id": "hostile-source-stale", "pair_id": "freshness", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["SOURCE_STALE"], "mutations": [{ "op": "SET", "path": ["evidence_set", "observations", 0, "observed_at"], "value": "2026-08-31T11:59:59Z" }] }, + + { "case_id": "control-source-format-supported", "pair_id": "format-support", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [] }, + { "case_id": "hostile-source-format-unsupported", "pair_id": "format-support", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["SOURCE_UNSUPPORTED"], "mutations": [{ "op": "SET", "path": ["profile", "sources", 0, "format"], "value": "application/cbor" }, { "op": "SET", "path": ["evidence_set", "observations", 0, "format"], "value": "application/cbor" }] }, + + { "case_id": "control-source-pinned", "pair_id": "source-pin", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [] }, + { "case_id": "hostile-source-unpinned", "pair_id": "source-pin", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["SOURCE_UNPINNED"], "mutations": [{ "op": "SET", "path": ["evidence_set", "observations", 0, "locator"], "value": "https://attacker.test/filing" }] }, + + { "case_id": "control-duplicate-agrees", "pair_id": "source-conflict", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [{ "op": "APPEND", "path": ["evidence_set", "observations"], "value": { "source_id": "filing", "locator": "https://example.test/filings/42", "revision": "sha256:source-revision-42", "observed_at": "2026-09-01T11:00:00Z", "availability": "AVAILABLE", "format": "application/json", "basis": "OBSERVED_FACT", "data": { "status": "effective" } } }] }, + { "case_id": "hostile-source-conflict", "pair_id": "source-conflict", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["SOURCE_CONFLICT"], "mutations": [{ "op": "APPEND", "path": ["evidence_set", "observations"], "value": { "source_id": "filing", "locator": "https://example.test/filings/42", "revision": "sha256:source-revision-42", "observed_at": "2026-09-01T11:00:00Z", "availability": "AVAILABLE", "format": "application/json", "basis": "OBSERVED_FACT", "data": { "status": "stayed" } } }] }, + + { "case_id": "control-observed-fact", "pair_id": "issuer-opinion", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [] }, + { "case_id": "hostile-issuer-opinion-only", "pair_id": "issuer-opinion", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["SOURCE_ISSUER_OPINION_ONLY"], "mutations": [{ "op": "SET", "path": ["profile", "sources", 0, "basis"], "value": "ISSUER_OPINION" }, { "op": "SET", "path": ["evidence_set", "observations", 0, "basis"], "value": "ISSUER_OPINION" }] }, + + { "case_id": "control-operator-defined", "pair_id": "operator-support", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [] }, + { "case_id": "hostile-operator-undefined", "pair_id": "operator-support", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["PREDICATE_UNSUPPORTED"], "mutations": [{ "op": "SET", "path": ["profile", "predicates", 0, "operator"], "value": "ISSUER_DECIDES" }] }, + + { "case_id": "control-value-present", "pair_id": "value-presence", "kind": "CONTROL", "expected_verdict": "SATISFIED", "expected_reason_codes": [], "mutations": [] }, + { "case_id": "hostile-value-missing", "pair_id": "value-presence", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["VALUE_MISSING"], "mutations": [{ "op": "SET", "path": ["profile", "sources", 0, "data_sha256"], "value": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" }, { "op": "SET", "path": ["evidence_set", "observations", 0, "data"], "value": {} }] }, + + { "case_id": "control-determinate-false", "pair_id": "indeterminate-dominance", "kind": "CONTROL", "expected_verdict": "NOT_SATISFIED", "expected_reason_codes": [], "mutations": [{ "op": "SET", "path": ["profile", "predicates", 0, "expected"], "value": "withdrawn" }] }, + { "case_id": "hostile-false-plus-unpinned", "pair_id": "indeterminate-dominance", "kind": "HOSTILE", "expected_verdict": "INDETERMINATE", "expected_reason_codes": ["SOURCE_UNPINNED"], "mutations": [{ "op": "SET", "path": ["profile", "predicates", 0, "expected"], "value": "withdrawn" }, { "op": "APPEND", "path": ["profile", "predicates"], "value": { "predicate_id": "undeclared-source", "source_ids": ["absent-source"], "path": "/status", "operator": "EQUALS", "expected": "effective" } }] } + ] +} diff --git a/standards/aips-1/p3-evidence-source-evaluation-v0/vectors/malformed.json b/standards/aips-1/p3-evidence-source-evaluation-v0/vectors/malformed.json new file mode 100644 index 000000000..3ac9417cd --- /dev/null +++ b/standards/aips-1/p3-evidence-source-evaluation-v0/vectors/malformed.json @@ -0,0 +1 @@ +{"case_id":"malformed", diff --git a/standards/aiuc/incident-fields-v0/incident-fields.schema.json b/standards/aiuc/incident-fields-v0/incident-fields.schema.json index 85f5c66ff..9758d1942 100644 --- a/standards/aiuc/incident-fields-v0/incident-fields.schema.json +++ b/standards/aiuc/incident-fields-v0/incident-fields.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:incident-action-authorization-field-group:v0.1", + "$id": "urn:emilia:aiuc:incident-action-authorization-field-group:v0.1", "title": "Incident Action Authorization Field Group v0.1", "description": "Unsubmitted, implementation-neutral proposal for coding one AI-agent action in an incident.", "type": "object", diff --git a/tests/aiuc-incident-fields-schema.test.ts b/tests/aiuc-incident-fields-schema.test.ts new file mode 100644 index 000000000..123b31749 --- /dev/null +++ b/tests/aiuc-incident-fields-schema.test.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import { describe, expect, it } from 'vitest'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PACKAGE_ROOT = path.join(ROOT, 'standards/aiuc/incident-fields-v0'); +const schema = JSON.parse(readFileSync( + path.join(PACKAGE_ROOT, 'incident-fields.schema.json'), + 'utf8', +)); +const example = JSON.parse(readFileSync( + path.join(PACKAGE_ROOT, 'example-aiid-1152.json'), + 'utf8', +)); + +describe('AIUC incident-field JSON Schema', () => { + it('compiles under JSON Schema 2020-12 and accepts the published example', () => { + expect(schema.$id).toBe( + 'urn:emilia:aiuc:incident-action-authorization-field-group:v0.1', + ); + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + const validate = ajv.compile(schema); + + expect(validate(example), JSON.stringify(validate.errors, null, 2)).toBe(true); + }); +}); diff --git a/tests/proof-stats-run-lock.test.ts b/tests/proof-stats-run-lock.test.ts new file mode 100644 index 000000000..dee504df3 --- /dev/null +++ b/tests/proof-stats-run-lock.test.ts @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +import { spawn } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + acquireProofStatsRunLock, + resolveProofStatsLockQueue, +} from '../scripts/generate-proof-stats.mjs'; + +const runtimeUrl = pathToFileURL( + path.resolve('scripts/generate-proof-stats.mjs'), +).href; +const fixtures: string[] = []; +const children = new Set>(); + +function makeWorktreeFixture(): { root: string; peer: string } { + const parent = mkdtempSync(path.join(os.tmpdir(), 'ep-proof-lock-test-')); + const root = path.join(parent, 'repository'); + const peer = path.join(parent, 'peer'); + fixtures.push(parent); + mkdirSync(root); + writeFileSync(path.join(root, 'README.md'), 'fixture\n'); + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync('git', ['config', 'user.name', 'Proof Lock Test'], { cwd: root }); + execFileSync('git', ['config', 'user.email', 'proof-lock@example.test'], { + cwd: root, + }); + execFileSync('git', ['add', 'README.md'], { cwd: root }); + execFileSync('git', ['commit', '-q', '-m', 'fixture'], { cwd: root }); + execFileSync('git', ['worktree', 'add', '-q', '-b', 'peer', peer], { + cwd: root, + }); + return { root, peer }; +} + +function probeSource( + cwd: string, + { + holdMs, + timeoutMs, + extraSignalListener = false, + }: { holdMs: number; timeoutMs: number; extraSignalListener?: boolean }, +): string { + return [ + `import { acquireProofStatsRunLock } from ${JSON.stringify(runtimeUrl)};`, + ...(extraSignalListener + ? ['process.on("SIGTERM", () => process.stdout.write("OTHER SIGNAL LISTENER\\n"));'] + : []), + `const lock = acquireProofStatsRunLock({ cwd: ${JSON.stringify(cwd)}, timeoutMs: ${timeoutMs}, pollMs: 10 });`, + 'process.stdout.write(`ACQUIRED ${Date.now()}\\n`);', + `setTimeout(() => { lock.release(); process.stdout.write(\`RELEASED \${Date.now()}\\n\`); }, ${holdMs});`, + '', + ].join('\n'); +} + +function startProbe( + cwd: string, + options: { + holdMs: number; + timeoutMs: number; + extraSignalListener?: boolean; + }, +): ReturnType { + const child = spawn( + process.execPath, + ['--input-type=module', '-e', probeSource(cwd, options)], + { cwd, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + children.add(child); + child.once('exit', () => children.delete(child)); + return child; +} + +function collect(child: ReturnType): Promise<{ + status: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; +}> { + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { stdout += chunk; }); + child.stderr?.on('data', (chunk) => { stderr += chunk; }); + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (status, signal) => resolve({ + status, + signal, + stdout, + stderr, + })); + }); +} + +function waitForLine( + child: ReturnType, + prefix: string, + timeoutMs = 2_000, +): Promise { + return new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout(() => reject(new Error( + `timed out waiting for ${prefix}; output=${JSON.stringify(output)}`, + )), timeoutMs); + child.stdout?.on('data', (chunk) => { + output += chunk; + const line = output.split('\n').find((entry) => entry.startsWith(prefix)); + if (line) { + clearTimeout(timer); + resolve(line); + } + }); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('exit', (status, signal) => { + if (!output.includes(prefix)) { + clearTimeout(timer); + reject(new Error( + `process exited before ${prefix}: status=${status} signal=${signal}`, + )); + } + }); + }); +} + +afterEach(() => { + for (const child of children) child.kill('SIGKILL'); + children.clear(); + for (const fixture of fixtures.splice(0)) { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +describe('proof-stats run serialization', () => { + it('resolves linked worktrees to one repository and host scoped queue', () => { + const { root, peer } = makeWorktreeFixture(); + expect(resolveProofStatsLockQueue(root)).toBe(resolveProofStatsLockQueue(peer)); + expect(resolveProofStatsLockQueue(root)).toContain('proof-stats-run-v1'); + }); + + it('serializes complete runs across linked worktrees', async () => { + const { root, peer } = makeWorktreeFixture(); + const holder = startProbe(root, { holdMs: 350, timeoutMs: 2_000 }); + const holderDone = collect(holder); + await waitForLine(holder, 'ACQUIRED '); + + const waiter = startProbe(peer, { holdMs: 0, timeoutMs: 2_000 }); + const [holderResult, waiterResult] = await Promise.all([ + holderDone, + collect(waiter), + ]); + + expect(holderResult.status).toBe(0); + expect(waiterResult.status).toBe(0); + const releasedAt = Number(holderResult.stdout.match(/RELEASED (\d+)/u)?.[1]); + const acquiredAt = Number(waiterResult.stdout.match(/ACQUIRED (\d+)/u)?.[1]); + expect(acquiredAt).toBeGreaterThanOrEqual(releasedAt); + }); + + it('serializes simultaneous first-use initialization', async () => { + const { root, peer } = makeWorktreeFixture(); + const first = startProbe(root, { holdMs: 100, timeoutMs: 2_000 }); + const second = startProbe(peer, { holdMs: 100, timeoutMs: 2_000 }); + const [firstResult, secondResult] = await Promise.all([ + collect(first), + collect(second), + ]); + + expect(firstResult.status).toBe(0); + expect(secondResult.status).toBe(0); + const intervals = [firstResult, secondResult].map((result) => ({ + acquired: Number(result.stdout.match(/ACQUIRED (\d+)/u)?.[1]), + released: Number(result.stdout.match(/RELEASED (\d+)/u)?.[1]), + })); + expect( + intervals[0].released <= intervals[1].acquired || + intervals[1].released <= intervals[0].acquired, + ).toBe(true); + expect(readdirSync(resolveProofStatsLockQueue(root))).toEqual(['.version']); + }); + + it('times out clearly without stealing a live owner', async () => { + const { root, peer } = makeWorktreeFixture(); + const holder = startProbe(root, { holdMs: 1_000, timeoutMs: 2_000 }); + await waitForLine(holder, 'ACQUIRED '); + + const waiter = await collect(startProbe(peer, { + holdMs: 0, + timeoutMs: 100, + })); + expect(waiter.status).toBe(1); + expect(waiter.stderr).toMatch(/proof-stats run lock timed out after 100ms/u); + expect(waiter.stderr).toMatch(/live owner/u); + expect(holder.exitCode).toBeNull(); + const holderDone = collect(holder); + holder.kill('SIGTERM'); + const holderResult = await holderDone; + expect(holderResult.status).toBe(143); + expect(holderResult.signal).toBeNull(); + expect(readdirSync(resolveProofStatsLockQueue(root))).toEqual(['.version']); + }); + + it('terminates after cleanup when another signal listener is present', async () => { + const { root } = makeWorktreeFixture(); + const holder = startProbe(root, { + holdMs: 60_000, + timeoutMs: 2_000, + extraSignalListener: true, + }); + await waitForLine(holder, 'ACQUIRED '); + + const holderDone = collect(holder); + holder.kill('SIGTERM'); + const result = await holderDone; + expect(result.status).toBe(143); + expect(result.signal).toBeNull(); + expect(result.stdout).toContain('OTHER SIGNAL LISTENER'); + expect(result.stdout).not.toContain('RELEASED'); + expect(readdirSync(resolveProofStatsLockQueue(root))).toEqual(['.version']); + }); + + it('recovers a dead owner entry but never needs a shared-path eviction', async () => { + const { root, peer } = makeWorktreeFixture(); + const abandoned = startProbe(root, { holdMs: 60_000, timeoutMs: 2_000 }); + await waitForLine(abandoned, 'ACQUIRED '); + abandoned.kill('SIGKILL'); + await collect(abandoned); + + const recovery = await collect(startProbe(peer, { + holdMs: 0, + timeoutMs: 2_000, + })); + expect(recovery.status).toBe(0); + expect(recovery.stdout).toMatch(/^ACQUIRED \d+\nRELEASED \d+\n$/u); + + const queue = resolveProofStatsLockQueue(root); + expect(readFileSync(path.join(queue, '.version'), 'utf8')).toBe('1\n'); + }); + + it('releases its own entry on normal exit', () => { + const { root } = makeWorktreeFixture(); + const lock = acquireProofStatsRunLock({ cwd: root, timeoutMs: 100 }); + expect(lock.queuePath).toBe(resolveProofStatsLockQueue(root)); + lock.release(); + expect(lock.release()).toBe(false); + }); +}); diff --git a/tests/release-reproducibility.test.ts b/tests/release-reproducibility.test.ts index e8c6c29c4..8192cdd0f 100644 --- a/tests/release-reproducibility.test.ts +++ b/tests/release-reproducibility.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, statSync, symlinkSync, @@ -18,6 +19,7 @@ import { gzipSync } from 'node:zlib'; import { assertArtifactBytesMatch, canonicalizeNpmTarball, + formatSpawnFailure, validatePackedPackageIdentity, verifyReproduciblePackage, } from '../scripts/verify-reproducible-package.mjs'; @@ -42,6 +44,98 @@ describe('release byte reproducibility', () => { }); }; + it('reports bounded nonblank diagnostics for a failed locked install', () => { + const diagnostic = formatSpawnFailure('locked dependency installation', { + status: null, + signal: 'SIGTERM', + error: Object.assign(new Error('spawn npm ENOENT'), { code: 'ENOENT' }), + stdout: `begin-${'x'.repeat(20_000)}-npm_abcdefghijklmnopqrstuvwxyz-stdout-tail`, + stderr: [ + 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.secret.signature', + 'https://registry.example.test/package?token=query-secret&X-Amz-Signature=aws-secret&keep=yes', + 'NPM_TOKEN=environment-secret', + ].join('\n'), + }); + + expect(diagnostic).toContain('locked dependency installation failed'); + expect(diagnostic).toContain('status: null'); + expect(diagnostic).toContain('signal: SIGTERM'); + expect(diagnostic).toContain('spawn error: Error [ENOENT]: spawn npm ENOENT'); + expect(diagnostic).toContain('stdout: [truncated to last 8192 characters]'); + expect(diagnostic).toContain('-stdout-tail'); + expect(diagnostic).toContain('[redacted-token]'); + expect(diagnostic).not.toContain('npm_abcdefghijklmnopqrstuvwxyz'); + expect(diagnostic).toContain('Authorization: [redacted]'); + expect(diagnostic).toContain('?token=[redacted]&X-Amz-Signature=[redacted]&keep=yes'); + expect(diagnostic).toContain('NPM_TOKEN=[redacted]'); + expect(diagnostic).not.toContain('eyJhbGciOiJIUzI1NiJ9'); + expect(diagnostic).not.toContain('query-secret'); + expect(diagnostic).not.toContain('aws-secret'); + expect(diagnostic).not.toContain('environment-secret'); + expect(diagnostic.length).toBeLessThan(18_000); + + const source = readFileSync('scripts/verify-reproducible-package.mts', 'utf8'); + expect(source).toContain('maxBuffer: 128 * 1024 * 1024'); + expect(source).toContain("stdio: ['ignore', 'pipe', 'pipe']"); + expect(source).toContain('timeout: 600_000'); + }); + + it('removes scratch state when verification fails before package builds', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ep-pack-early-failure-')); + const scratchParent = mkdtempSync(path.join(os.tmpdir(), 'ep-pack-cleanup-')); + const priorTmpdir = process.env.TMPDIR; + writeFileSync(path.join(root, 'package.json'), JSON.stringify({ + name: 'early-failure-fixture', + version: '1.0.0', + files: ['index.js'], + })); + writeFileSync(path.join(root, 'index.js'), 'export const value = "reviewed";\n'); + const reviewedCommit = commitFixture(root); + writeFileSync(path.join(root, 'index.js'), 'export const value = "modified";\n'); + process.env.TMPDIR = scratchParent; + try { + expect(() => verifyReproduciblePackage(root, { + repositoryRoot: root, + reviewedCommit, + })).toThrow(/working checkout differs from the reviewed commit/u); + expect(readdirSync(scratchParent)).toEqual([]); + } finally { + if (priorTmpdir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = priorTmpdir; + rmSync(root, { recursive: true, force: true }); + rmSync(scratchParent, { recursive: true, force: true }); + } + }); + + it('removes scratch state and reports child details after a locked install fails', () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ep-pack-install-failure-')); + const scratchParent = mkdtempSync(path.join(os.tmpdir(), 'ep-pack-install-cleanup-')); + const priorTmpdir = process.env.TMPDIR; + writeFileSync(path.join(root, 'package.json'), JSON.stringify({ + name: 'install-failure-fixture', + version: '1.0.0', + files: ['index.js'], + })); + writeFileSync(path.join(root, 'package-lock.json'), '{not-json\n'); + writeFileSync(path.join(root, 'index.js'), 'export const value = "reviewed";\n'); + const reviewedCommit = commitFixture(root); + process.env.TMPDIR = scratchParent; + try { + expect(() => verifyReproduciblePackage(root, { + repositoryRoot: root, + reviewedCommit, + })).toThrow( + /locked dependency installation failed\nstatus: 1\nsignal: null\nspawn error: \(none\)\nstdout:/u, + ); + expect(readdirSync(scratchParent)).toEqual([]); + } finally { + if (priorTmpdir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = priorTmpdir; + rmSync(root, { recursive: true, force: true }); + rmSync(scratchParent, { recursive: true, force: true }); + } + }); + it('packs @emilia-protocol/verify twice to byte-identical tarballs', () => { const result = verifyReproduciblePackage('packages/verify'); expect(result.name).toBe('@emilia-protocol/verify'); @@ -54,7 +148,7 @@ describe('release byte reproducibility', () => { // The locked clean install honors the repository's 600-second npm fetch // timeout. Keep this test's outer deadline at least as large so a slow cold // registry download cannot kill the byte-comparison oracle first. - }, 600_000); + }, 660_000); it('normalizes source file modes across independent package checkouts', () => { const root = mkdtempSync(path.join(os.tmpdir(), 'ep-pack-modes-'));