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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -25,7 +25,6 @@ module.exports = {
capabilityMapSha,
compilePhrase,
createClaimObligationDetector,
createClaimSupportReceipt,
createEvidenceContactReceipt,
createPlanner,
decompose,
Expand Down
75 changes: 1 addition & 74 deletions src/receipts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
2 changes: 1 addition & 1 deletion test/package.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
157 changes: 43 additions & 114 deletions test/receipts.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading