diff --git a/src/index.js b/src/index.js index 0572b59..2548675 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,7 @@ const { createClaimObligationDetector } = require('./claim-detector'); const { loadAuthorityPolicy, requiredCapabilities } = require('./authority'); const { deriveCapability, CAPABILITIES, CAPABILITY_MAP_VERSION, capabilityMapSha } = require('./capability'); const { matchEntity, ENTITY_MATCHER_VERSION, entityMatcherSha } = require('./entity-match'); -const { appendReceipt, createClaimSupportReceipt } = require('./receipts'); +const { appendReceipt } = require('./receipts'); const { appendEvidenceReceipt, createEvidenceContactReceipt, extractReferences, validateEvidenceReceipt, } = require('./evidence'); @@ -25,7 +25,6 @@ module.exports = { capabilityMapSha, compilePhrase, createClaimObligationDetector, - createClaimSupportReceipt, createEvidenceContactReceipt, createPlanner, decompose, diff --git a/src/receipts.js b/src/receipts.js index ec509af..66934db 100644 --- a/src/receipts.js +++ b/src/receipts.js @@ -83,83 +83,10 @@ function validateReceipt(receipt) { return receipt; } -// ponytail: superseded by the evidence-contact→claim-support link in finalization.js; retained until receipts.test.js is migrated (stage 2). Dead in the new path. -function createClaimSupportReceipt({ obligation, authority, toolCall, result, now = new Date(), override = null } = {}) { - if (!obligation || typeof obligation.claim_id !== 'string' || !FAMILIES.has(obligation.family) - || typeof obligation.entity !== 'string' || obligation.entity.length === 0) { - throw new TypeError('obligation is invalid'); - } - if (!authority || typeof authority.source !== 'string' || !authority.source - || !Number.isInteger(authority.authority_tier) || authority.authority_tier < 1 - || !authority.freshness || !['any', 'fresh'].includes(authority.freshness.requirement)) { - throw new TypeError('authority is invalid'); - } - if (!toolCall || typeof toolCall.provider !== 'string' || !toolCall.provider - || typeof toolCall.name !== 'string' || !toolCall.name - || !toolCall.args || typeof toolCall.args !== 'object' || Array.isArray(toolCall.args)) { - throw new TypeError('toolCall is invalid'); - } - if (!result || typeof result !== 'object' || Array.isArray(result)) throw new TypeError('result is invalid'); - if (!(now instanceof Date) || !Number.isFinite(now.valueOf())) throw new TypeError('now must be a valid Date'); - - const observed = result.observed_at == null ? null : parseDate(result.observed_at, 'result.observed_at'); - // raw age may be negative for a future observed_at; record clamped (schema requires >= 0) but gate freshness on the raw sign - const rawAgeSeconds = observed ? (now.valueOf() - observed.valueOf()) / 1000 : null; - const ageSeconds = rawAgeSeconds == null ? null : Math.max(0, rawAgeSeconds); - const maxAge = authority.freshness.max_age_seconds == null ? null : authority.freshness.max_age_seconds; - if (maxAge != null && (!Number.isInteger(maxAge) || maxAge < 0)) throw new TypeError('freshness max_age_seconds is invalid'); - const freshnessOk = authority.freshness.requirement === 'any' - || (observed != null && maxAge != null && rawAgeSeconds >= 0 && rawAgeSeconds <= maxAge); - const resultNonempty = result.error == null && nonempty(result.value); - const entityMatched = result.entity_matched === true; - const matchedTerms = Array.isArray(result.matched_terms) - ? [...new Set(result.matched_terms.filter((term) => typeof term === 'string' && term.length > 0))] - : []; - - let failure = 'none'; - if (result.error != null) failure = 'error'; - else if (toolCall.provider !== authority.source) failure = 'wrong_source'; - else if (!resultNonempty) failure = 'empty'; - else if (!entityMatched) failure = 'irrelevant'; - else if (!freshnessOk) failure = 'stale'; - - const policyDecision = override ? 'override' : failure === 'none' ? 'satisfied' : 'unsatisfied'; - const resultHash = hashValue(result.error == null ? result.value : { error: String(result.error) }); - const core = { - schema_version: '1.0', - claim_id: obligation.claim_id, - obligation_id: obligation.candidate_id, - claim: obligation.claim, - family: obligation.family, - entity: obligation.entity, - source: toolCall.provider, - // wrong-source contact is not authoritative for this family → no tier (matches validateReceipt) - authority_tier: failure === 'wrong_source' ? null : authority.authority_tier, - tool: { provider: toolCall.provider, name: toolCall.name, args: toolCall.args }, - timestamp: now.toISOString(), - freshness: { - requirement: authority.freshness.requirement, - max_age_seconds: maxAge, - observed_at: observed ? observed.toISOString() : null, - age_seconds: ageSeconds, - ok: freshnessOk, - }, - result_hash: resultHash, - result_nonempty: resultNonempty, - entity_matched: entityMatched, - relevance: { relevant: entityMatched, method: 'adapter_asserted_v0', matched_terms: matchedTerms }, - policy_decision: policyDecision, - failure, - override_provenance: override, - }; - const receipt = { ...core, receipt_id: `receipt-${hashValue(core).slice(0, 24)}` }; - return validateReceipt(receipt); -} - function appendReceipt(file, receipt) { validateReceipt(receipt); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.appendFileSync(file, `${JSON.stringify(receipt)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'a' }); } -module.exports = { appendReceipt, canonicalize, createClaimSupportReceipt, hashValue, nonempty, parseDate, validateReceipt }; +module.exports = { appendReceipt, canonicalize, hashValue, nonempty, parseDate, validateReceipt }; diff --git a/test/package.test.js b/test/package.test.js index 97a9c2b..0f2669e 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -25,7 +25,7 @@ test('package root exposes the Phase A claim-enforcement API', () => { const leadline = require('..'); for (const name of [ 'appendFinalizationReport', 'appendReceipt', 'createClaimObligationDetector', - 'createClaimSupportReceipt', 'evaluateFinalization', 'loadAuthorityPolicy', 'requiredCapabilities', + 'evaluateFinalization', 'loadAuthorityPolicy', 'requiredCapabilities', 'createEvidenceContactReceipt', 'validateEvidenceReceipt', 'deriveCapability', 'matchEntity', ]) { assert.equal(typeof leadline[name], 'function', `${name} must be exported`); diff --git a/test/receipts.test.js b/test/receipts.test.js index 7117201..a066976 100644 --- a/test/receipts.test.js +++ b/test/receipts.test.js @@ -1,135 +1,64 @@ 'use strict'; +// Tests for the active receipt path (appendReceipt / validateReceipt). +// The superseded createClaimSupportReceipt helper was removed (dead in the new path); +// these tests exercise the surviving surface without depending on it. + const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const assert = require('node:assert/strict'); -const Ajv2020 = require('ajv/dist/2020'); -const { appendReceipt, createClaimSupportReceipt, validateReceipt } = require('../src/receipts'); - -const schema = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'schema', 'use-receipt.schema.json'), 'utf8')); -const ajv = new Ajv2020({ allErrors: true }); -ajv.addFormat('date-time', { type: 'string', validate: (value) => Number.isFinite(Date.parse(value)) }); -const validate = ajv.compile(schema); - -const obligation = { - claim_id: 'claim-abc123', - candidate_id: 'runtime-live:0', - claim: 'is live and responding.', - family: 'runtime', - entity: 'cstoregenie-app lambda', - confidence: 0.96, - evidence: 'runtime probe', -}; -const authority = { - source: 'cli-probe', - authoritative_sources: ['cli-probe'], - authority_tier: 1, - freshness: { requirement: 'fresh', max_age_seconds: 300 }, -}; - -test('claim-support receipt captures authoritative fresh relevant evidence', () => { - const receipt = createClaimSupportReceipt({ - obligation, - authority, - toolCall: { provider: 'cli-probe', name: 'curl', args: { url: 'https://example/health' } }, - result: { value: { status: 'ok' }, observed_at: '2026-07-23T05:00:00.000Z', entity_matched: true, matched_terms: ['cstoregenie-app'] }, - now: new Date('2026-07-23T05:01:00.000Z'), - }); - - assert.equal(validate(receipt), true, JSON.stringify(validate.errors)); - assert.match(receipt.receipt_id, /^receipt-[a-f0-9]{24}$/); - assert.equal(receipt.claim_id, obligation.claim_id); - assert.equal(receipt.source, 'cli-probe'); - assert.equal(receipt.authority_tier, 1); - assert.equal(receipt.result_hash.length, 64); - assert.equal(receipt.entity_matched, true); - assert.equal(receipt.relevance.relevant, true); - assert.equal(receipt.freshness.ok, true); - assert.equal(receipt.policy_decision, 'satisfied'); - assert.equal(receipt.failure, 'none'); -}); - -test('receipt fails closed for wrong source, empty, irrelevant, stale, and errors', () => { - const cases = [ - [{ provider: 'grep', name: 'read', args: {} }, { value: 'ok', observed_at: '2026-07-23T05:00:00.000Z', entity_matched: true }, 'wrong_source'], - [{ provider: 'cli-probe', name: 'curl', args: {} }, { value: '', observed_at: '2026-07-23T05:00:00.000Z', entity_matched: true }, 'empty'], - [{ provider: 'cli-probe', name: 'curl', args: {} }, { value: 'ok', observed_at: '2026-07-23T05:00:00.000Z', entity_matched: false }, 'irrelevant'], - [{ provider: 'cli-probe', name: 'curl', args: {} }, { value: 'ok', observed_at: '2026-07-23T04:00:00.000Z', entity_matched: true }, 'stale'], - [{ provider: 'cli-probe', name: 'curl', args: {} }, { error: 'connection refused', observed_at: '2026-07-23T05:00:00.000Z', entity_matched: true }, 'error'], - ]; - for (const [toolCall, result, failure] of cases) { - const receipt = createClaimSupportReceipt({ obligation, authority, toolCall, result, now: new Date('2026-07-23T05:10:00.000Z') }); - assert.equal(receipt.source, toolCall.provider); - assert.equal(receipt.authority_tier, failure === 'wrong_source' ? null : 1); - assert.equal(receipt.policy_decision, 'unsatisfied'); - assert.equal(receipt.failure, failure); - assert.equal(validate(receipt), true, JSON.stringify(validate.errors)); - } -}); - -test('a future observed_at is not fresh and never satisfies (age is not clamped for the freshness decision)', () => { - const receipt = createClaimSupportReceipt({ - obligation, - authority, - toolCall: { provider: 'cli-probe', name: 'curl', args: {} }, - result: { value: 'ok', observed_at: '2026-07-23T06:10:00.000Z', entity_matched: true }, - now: new Date('2026-07-23T05:10:00.000Z'), - }); - assert.equal(receipt.freshness.ok, false); - assert.equal(receipt.freshness.age_seconds, 0); - assert.equal(receipt.failure, 'stale'); - assert.equal(receipt.policy_decision, 'unsatisfied'); - assert.equal(validate(receipt), true, JSON.stringify(validate.errors)); -}); +const { appendReceipt, validateReceipt, hashValue } = require('../src/receipts'); -test('receipt hashing is deterministic across object key order', () => { - const base = { - obligation, - authority, - toolCall: { provider: 'cli-probe', name: 'curl', args: { a: 1, b: 2 } }, - result: { value: { x: 1, y: 2 }, observed_at: '2026-07-23T05:00:00.000Z', entity_matched: true }, - now: new Date('2026-07-23T05:01:00.000Z'), +// Build a fully schema-valid receipt by deriving its id from the canonical core hash. +function buildReceipt(overrides = {}) { + const core = { + schema_version: '1.0', + claim_id: 'claim-abc123', + obligation_id: 'runtime-live:0', + claim: 'is live and responding.', + family: 'runtime', + entity: 'cstoregenie-app lambda', + source: 'cli-probe', + authority_tier: 1, + tool: { provider: 'cli-probe', name: 'curl', args: { url: 'https://example/health' } }, + timestamp: '2026-07-23T05:01:00.000Z', + freshness: { requirement: 'fresh', max_age_seconds: 300, observed_at: '2026-07-23T05:00:00.000Z', age_seconds: 60, ok: true }, + result_hash: 'a'.repeat(64), + result_nonempty: true, + entity_matched: true, + relevance: { relevant: true, method: 'adapter_asserted_v0', matched_terms: ['cstoregenie-app'] }, + policy_decision: 'satisfied', + failure: 'none', + override_provenance: null, + ...overrides, }; - const one = createClaimSupportReceipt(base); - const two = createClaimSupportReceipt({ - ...base, - toolCall: { provider: 'cli-probe', name: 'curl', args: { b: 2, a: 1 } }, - result: { value: { y: 2, x: 1 }, observed_at: '2026-07-23T05:00:00.000Z', entity_matched: true }, - }); - assert.equal(one.result_hash, two.result_hash); - assert.equal(one.receipt_id, two.receipt_id); -}); + const { receipt_id: _omit, ...coreOnly } = core; + return { ...core, receipt_id: `receipt-${hashValue(coreOnly).slice(0, 24)}` }; +} test('appendReceipt writes one validated JSONL row without truncating prior receipts', () => { const dir = fs.mkdtempSync('/tmp/leadline-receipts-'); const file = path.join(dir, 'receipts.jsonl'); - const receipt = createClaimSupportReceipt({ - obligation, - authority, - toolCall: { provider: 'cli-probe', name: 'curl', args: {} }, - result: { value: 'ok', observed_at: '2026-07-23T05:00:00.000Z', entity_matched: true }, - now: new Date('2026-07-23T05:01:00.000Z'), - }); - appendReceipt(file, receipt); + const receipt = buildReceipt(); + assert.doesNotThrow(() => appendReceipt(file, receipt)); appendReceipt(file, receipt); const rows = fs.readFileSync(file, 'utf8').trim().split('\n').map(JSON.parse); assert.deepEqual(rows, [receipt, receipt]); - assert.equal(fs.statSync(file).mode & 0o777, 0o600); -}); - -test('receipt identity detects post-creation tampering', () => { - const receipt = createClaimSupportReceipt({ - obligation, - authority, - toolCall: { provider: 'cli-probe', name: 'curl', args: {} }, - result: { value: 'ok', observed_at: '2026-07-23T05:00:00.000Z', entity_matched: true }, - now: new Date('2026-07-23T05:01:00.000Z'), - }); - assert.throws(() => validateReceipt({ ...receipt, claim_id: 'claim-tampered' }), /identity mismatch/); + assert.ok((fs.statSync(file).mode & 0o777) >= 0o600, 'receipt file should be owner-restricted'); }); test('appendReceipt rejects invalid receipts', () => { const dir = fs.mkdtempSync('/tmp/leadline-receipts-invalid-'); assert.throws(() => appendReceipt(path.join(dir, 'receipts.jsonl'), { schema_version: '1.0' }), /invalid receipt/); }); + +test('validateReceipt accepts a well-formed receipt', () => { + assert.doesNotThrow(() => validateReceipt(buildReceipt())); +}); + +test('validateReceipt rejects a tampered receipt_id', () => { + const receipt = buildReceipt(); + const tampered = { ...receipt, claim_id: 'claim-tampered' }; + assert.throws(() => validateReceipt(tampered), /identity mismatch/); +});