From 933bdaa0f7dc36870064823c55b46b5d0e825ea1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:53:37 -0700 Subject: [PATCH 01/37] test(billing): define tenant evidence export contract --- ...pe-reconciliation-evidence-export.test.mjs | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 tests/unit/stripe-reconciliation-evidence-export.test.mjs diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs new file mode 100644 index 00000000..0624d1d0 --- /dev/null +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -0,0 +1,244 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; + +import { + StripeReconciliationEvidenceExportError, + createSqliteStripeReconciliationEvidenceExportRepository, +} from '../../server/stripe_reconciliation_evidence_export.mjs'; + +const db = new DatabaseSync(':memory:'); +db.exec(` + PRAGMA foreign_keys = ON; + + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE billing_stripe_customers ( + customer_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES orgs(id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_subscriptions ( + subscription_id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES billing_stripe_customers(customer_id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + provider_created_at_sec INTEGER NOT NULL, + event_type TEXT NOT NULL, + object_id TEXT NOT NULL, + object_type TEXT NOT NULL, + api_version TEXT, + request_id TEXT, + payload_sha256 TEXT NOT NULL, + first_received_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_triggers ( + event_id TEXT PRIMARY KEY REFERENCES billing_stripe_webhook_events(event_id), + subscription_id TEXT NOT NULL, + queued_at_ms INTEGER NOT NULL, + processing_state TEXT NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_jobs ( + event_id TEXT PRIMARY KEY REFERENCES billing_stripe_reconciliation_triggers(event_id), + processing_state TEXT NOT NULL, + attempt_count INTEGER NOT NULL, + next_attempt_at_ms INTEGER NOT NULL, + lease_token_sha256 TEXT, + lease_expires_at_ms INTEGER, + completed_at_ms INTEGER, + last_error_code TEXT, + claim_decision_id INTEGER + ); + CREATE TABLE billing_stripe_reconciliation_attempts ( + attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES billing_stripe_reconciliation_jobs(event_id), + attempt_number INTEGER NOT NULL, + lease_started_at_ms INTEGER NOT NULL, + lease_expires_at_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + UNIQUE(event_id, attempt_number) + ); + CREATE TABLE billing_stripe_reconciliation_recoveries ( + recovery_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + actor_user_id INTEGER NOT NULL, + evidence_reference TEXT NOT NULL, + requested_at_ms INTEGER NOT NULL, + completed_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + claim_decision_id INTEGER, + UNIQUE(event_id, evidence_reference) + ); +`); + +db.exec(` + INSERT INTO orgs(id,name) VALUES(1,'Tenant One'),(2,'Tenant Two'); + INSERT INTO billing_stripe_customers(customer_id,organization_id,first_observed_at_ms) + VALUES('cus_one',1,100),('cus_two',2,100); + INSERT INTO billing_stripe_subscriptions(subscription_id,customer_id,first_observed_at_ms) + VALUES('sub_one','cus_one',100),('sub_two','cus_two',100); + + INSERT INTO billing_stripe_webhook_events( + event_id,provider_created_at_sec,event_type,object_id,object_type, + api_version,request_id,payload_sha256,first_received_at_ms + ) VALUES + ('evt_one_old',1787000000,'customer.subscription.updated','sub_one','subscription', + '2025-03-31.basil','req_one_old','aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',1000), + ('evt_one_new',1787000100,'invoice.paid','in_one','invoice', + '2025-03-31.basil',NULL,'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',2000), + ('evt_two',1787000200,'customer.subscription.updated','sub_two','subscription', + '2025-03-31.basil','req_two','cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',3000); + + INSERT INTO billing_stripe_reconciliation_triggers(event_id,subscription_id,queued_at_ms,processing_state) + VALUES('evt_one_old','sub_one',1100,'pending'), + ('evt_one_new','sub_one',2100,'pending'), + ('evt_two','sub_two',3100,'pending'); + + INSERT INTO billing_stripe_reconciliation_jobs( + event_id,processing_state,attempt_count,next_attempt_at_ms,lease_token_sha256, + lease_expires_at_ms,completed_at_ms,last_error_code,claim_decision_id + ) VALUES + ('evt_one_old','dead_letter',2,1300,NULL,NULL,1400,'stripe_reconciliation_failed',NULL), + ('evt_one_new','processing',1,2200,'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + 999999999999,NULL,NULL,NULL), + ('evt_two','succeeded',1,3200,NULL,NULL,3300,NULL,77); + + INSERT INTO billing_stripe_reconciliation_attempts( + event_id,attempt_number,lease_started_at_ms,lease_expires_at_ms,finished_at_ms,outcome,error_code + ) VALUES + ('evt_one_old',1,1110,1210,1200,'retry','stripe_provider_timeout'), + ('evt_one_old',2,1210,1310,1400,'dead_letter','stripe_reconciliation_failed'), + ('evt_one_new',1,2110,999999999999,NULL,NULL,NULL), + ('evt_two',1,3110,3210,3300,'succeeded',NULL); + + INSERT INTO billing_stripe_reconciliation_recoveries( + event_id,attempt_number,actor_user_id,evidence_reference,requested_at_ms, + completed_at_ms,outcome,error_code,claim_decision_id + ) VALUES( + 'evt_one_old',2,10,'INC-PRIVATE-CUSTOMER-TICKET',1450,1460, + 'dead_letter','stripe_reconciliation_failed',NULL + ); +`); + +const repository = createSqliteStripeReconciliationEvidenceExportRepository(db); +const report = repository.exportTenantEvidence({ organizationId: 1, limit: 10 }); + +assert.equal(report.schemaVersion, 'scopeweave.stripe-reconciliation-evidence/v1'); +assert.equal(report.organizationId, 1); +assert.equal(report.events.length, 2, 'only the requested tenant evidence is exported'); +assert.deepEqual(report.events.map((event) => event.eventId), ['evt_one_new', 'evt_one_old']); +assert.equal(JSON.stringify(report).includes('evt_two'), false, 'foreign tenant identities never enter the export'); +assert.equal(JSON.stringify(report).includes('cus_two'), false, 'foreign customer identities never enter the export'); +assert.equal( + JSON.stringify(report).includes('dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'), + false, + 'active lease hashes never enter customer-facing evidence exports', +); +assert.equal( + JSON.stringify(report).includes('INC-PRIVATE-CUSTOMER-TICKET'), + false, + 'free-form operator evidence text is not copied into the export', +); + +const newest = report.events[0]; +assert.deepEqual(newest, { + eventId: 'evt_one_new', + subscriptionId: 'sub_one', + eventType: 'invoice.paid', + providerCreatedAtSec: 1787000100, + payloadSha256: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + firstReceivedAtMs: 2000, + queuedAtMs: 2100, + processingState: 'processing', + attemptCount: 1, + nextAttemptAtMs: 2200, + completedAtMs: null, + lastErrorCode: null, + claimDecisionId: null, + attempts: [{ + attemptNumber: 1, + leaseStartedAtMs: 2110, + leaseExpiresAtMs: 999999999999, + finishedAtMs: null, + outcome: null, + errorCode: null, + }], + recoveries: [], +}); + +const older = report.events[1]; +assert.equal(older.recoveries.length, 1); +assert.deepEqual(older.recoveries[0], { + recoveryId: 1, + attemptNumber: 2, + actorUserId: 10, + evidenceReferenceSha256: createHash('sha256') + .update('INC-PRIVATE-CUSTOMER-TICKET', 'utf8') + .digest('hex'), + requestedAtMs: 1450, + completedAtMs: 1460, + outcome: 'dead_letter', + errorCode: 'stripe_reconciliation_failed', + claimDecisionId: null, +}); + +assert.deepEqual( + repository.exportTenantEvidence({ organizationId: 1, limit: 1 }).events.map((event) => event.eventId), + ['evt_one_new'], + 'event count is bounded before nested history is materialized', +); +assert.deepEqual( + repository.exportTenantEvidence({ organizationId: 999, limit: 10 }), + { + schemaVersion: 'scopeweave.stripe-reconciliation-evidence/v1', + organizationId: 999, + events: [], + }, + 'an unknown tenant does not disclose whether another tenant has billing evidence', +); + +for (const input of [ + { organizationId: 0, limit: 10 }, + { organizationId: 1, limit: 0 }, + { organizationId: 1, limit: 101 }, + { organizationId: 1, limit: 1.5 }, +]) { + assert.throws( + () => repository.exportTenantEvidence(input), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.code === 'stripe_reconciliation_evidence_export_invalid', + ); +} + +// A single selected event with more nested rows than the hard response budget must +// fail closed instead of allocating an arbitrarily large JSON evidence document. +db.exec(` + DELETE FROM billing_stripe_reconciliation_attempts WHERE event_id = 'evt_one_new'; + WITH RECURSIVE sequence(value) AS ( + VALUES(1) + UNION ALL + SELECT value + 1 FROM sequence WHERE value < 1001 + ) + INSERT INTO billing_stripe_reconciliation_attempts( + event_id,attempt_number,lease_started_at_ms,lease_expires_at_ms,finished_at_ms,outcome,error_code + ) + SELECT 'evt_one_new', value, 5000 + value, 6000 + value, 6000 + value, 'retry', 'stripe_provider_timeout' + FROM sequence; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.code === 'stripe_reconciliation_evidence_export_too_large' + && error.status === 413, + 'oversized nested evidence fails closed with a stable bounded error', +); + +db.close(); \ No newline at end of file From 2f2f2b90fc53a06cef0e3773186084e7fd0401a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:54:18 -0700 Subject: [PATCH 02/37] test(billing): register reconciliation evidence export RED --- tests/unit/server-runtime-force-close.test.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/server-runtime-force-close.test.mjs b/tests/unit/server-runtime-force-close.test.mjs index d907c47d..e9417eea 100644 --- a/tests/unit/server-runtime-force-close.test.mjs +++ b/tests/unit/server-runtime-force-close.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { test } from 'node:test'; +await import('./stripe-reconciliation-evidence-export.test.mjs'); + import { bindScopeWeaveRuntime } from '../../server/server_runtime.mjs'; test('forced-close failures are sanitized when the graceful shutdown window expires', () => { From e2538bdb2866dc893c84507867bdc5f97cb539c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:56:25 -0700 Subject: [PATCH 03/37] feat(billing): add bounded tenant reconciliation evidence export --- .../stripe_reconciliation_evidence_export.mjs | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 server/stripe_reconciliation_evidence_export.mjs diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs new file mode 100644 index 00000000..32e32cc7 --- /dev/null +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -0,0 +1,310 @@ +import { createHash } from 'node:crypto'; + +const DEFAULT_EVENT_LIMIT = 50; +const MAX_EVENT_LIMIT = 100; +const MAX_NESTED_EVIDENCE_ROWS = 1_000; +const MAX_PROVIDER_ID_LENGTH = 255; +const MAX_EVENT_TYPE_LENGTH = 255; +const MAX_ERROR_CODE_LENGTH = 96; +const MAX_EVIDENCE_REFERENCE_LENGTH = 256; +const PROVIDER_IDENTIFIER_PATTERN = /^[A-Za-z0-9_:-]+$/u; +const ERROR_CODE_PATTERN = /^[a-z0-9_:-]+$/u; +const PAYLOAD_SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const PROCESSING_STATES = new Set(['pending', 'processing', 'succeeded', 'dead_letter']); +const ATTEMPT_OUTCOMES = new Set(['succeeded', 'retry', 'dead_letter']); +const RECOVERY_OUTCOMES = new Set(['succeeded', 'dead_letter']); +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u; +const SCHEMA_VERSION = 'scopeweave.stripe-reconciliation-evidence/v1'; + +/** Stable fail-closed error for tenant reconciliation evidence exports. */ +export class StripeReconciliationEvidenceExportError extends Error { + /** + * Create one sanitized evidence-export failure. + * @param {string} code stable machine-readable failure code + * @param {number} [status=400] HTTP-compatible status for an API adapter + */ + constructor(code, status = 400) { + super(code); + this.name = 'StripeReconciliationEvidenceExportError'; + this.code = code; + this.status = status; + } +} + +function exportError(code = 'stripe_reconciliation_evidence_export_invalid', status = 400) { + return new StripeReconciliationEvidenceExportError(code, status); +} + +function positiveInteger(value) { + if (!Number.isSafeInteger(value) || value <= 0) throw exportError(); + return value; +} + +function nonNegativeInteger(value) { + const normalized = Number(value); + if (!Number.isSafeInteger(normalized) || normalized < 0) throw exportError(undefined, 500); + return normalized; +} + +function nullableNonNegativeInteger(value) { + return value == null ? null : nonNegativeInteger(value); +} + +function nullablePositiveInteger(value) { + if (value == null) return null; + const normalized = Number(value); + if (!Number.isSafeInteger(normalized) || normalized <= 0) throw exportError(undefined, 500); + return normalized; +} + +function boundedIdentifier(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_PROVIDER_ID_LENGTH + || !PROVIDER_IDENTIFIER_PATTERN.test(value) + ) { + throw exportError(undefined, 500); + } + return value; +} + +function eventTypeValue(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_EVENT_TYPE_LENGTH + || CONTROL_CHARACTER_PATTERN.test(value) + ) { + throw exportError(undefined, 500); + } + return value; +} + +function nullableErrorCode(value) { + if (value == null) return null; + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_ERROR_CODE_LENGTH + || !ERROR_CODE_PATTERN.test(value) + ) { + throw exportError(undefined, 500); + } + return value; +} + +function payloadSha256Value(value) { + if (typeof value !== 'string' || !PAYLOAD_SHA256_PATTERN.test(value)) { + throw exportError(undefined, 500); + } + return value; +} + +function processingStateValue(value) { + if (!PROCESSING_STATES.has(value)) throw exportError(undefined, 500); + return value; +} + +function nullableOutcome(value, allowed) { + if (value == null) return null; + if (!allowed.has(value)) throw exportError(undefined, 500); + return value; +} + +function eventLimitValue(value) { + if (value === undefined) return DEFAULT_EVENT_LIMIT; + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_EVENT_LIMIT) { + throw exportError(); + } + return value; +} + +function evidenceReferenceDigest(value) { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_EVIDENCE_REFERENCE_LENGTH + || CONTROL_CHARACTER_PATTERN.test(value) + ) { + throw exportError(undefined, 500); + } + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function frozenAttempt(row) { + const outcome = nullableOutcome(row.outcome, ATTEMPT_OUTCOMES); + const finishedAtMs = nullableNonNegativeInteger(row.finished_at_ms); + const errorCode = nullableErrorCode(row.error_code); + if (outcome == null && (finishedAtMs != null || errorCode != null)) throw exportError(undefined, 500); + if (outcome === 'succeeded' && errorCode != null) throw exportError(undefined, 500); + if ((outcome === 'retry' || outcome === 'dead_letter') && errorCode == null) { + throw exportError(undefined, 500); + } + + return Object.freeze({ + attemptNumber: positiveInteger(Number(row.attempt_number)), + leaseStartedAtMs: nonNegativeInteger(row.lease_started_at_ms), + leaseExpiresAtMs: nonNegativeInteger(row.lease_expires_at_ms), + finishedAtMs, + outcome, + errorCode, + }); +} + +function frozenRecovery(row) { + const outcome = nullableOutcome(row.outcome, RECOVERY_OUTCOMES); + const completedAtMs = nullableNonNegativeInteger(row.completed_at_ms); + const errorCode = nullableErrorCode(row.error_code); + const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); + if (outcome == null && (completedAtMs != null || errorCode != null || claimDecisionId != null)) { + throw exportError(undefined, 500); + } + if (outcome === 'succeeded' && (completedAtMs == null || errorCode != null || claimDecisionId == null)) { + throw exportError(undefined, 500); + } + if (outcome === 'dead_letter' && (completedAtMs == null || errorCode == null || claimDecisionId != null)) { + throw exportError(undefined, 500); + } + + return Object.freeze({ + recoveryId: positiveInteger(Number(row.recovery_id)), + attemptNumber: positiveInteger(Number(row.attempt_number)), + actorUserId: positiveInteger(Number(row.actor_user_id)), + evidenceReferenceSha256: evidenceReferenceDigest(row.evidence_reference), + requestedAtMs: nonNegativeInteger(row.requested_at_ms), + completedAtMs, + outcome, + errorCode, + claimDecisionId, + }); +} + +function frozenEvent(row, attempts, recoveries) { + const completedAtMs = nullableNonNegativeInteger(row.completed_at_ms); + const lastErrorCode = nullableErrorCode(row.last_error_code); + const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); + const attemptCount = nonNegativeInteger(row.attempt_count); + if (attempts.length > attemptCount) throw exportError(undefined, 500); + + return Object.freeze({ + eventId: boundedIdentifier(row.event_id), + subscriptionId: boundedIdentifier(row.subscription_id), + eventType: eventTypeValue(row.event_type), + providerCreatedAtSec: nonNegativeInteger(row.provider_created_at_sec), + payloadSha256: payloadSha256Value(row.payload_sha256), + firstReceivedAtMs: nonNegativeInteger(row.first_received_at_ms), + queuedAtMs: nonNegativeInteger(row.queued_at_ms), + processingState: processingStateValue(row.processing_state), + attemptCount, + nextAttemptAtMs: nonNegativeInteger(row.next_attempt_at_ms), + completedAtMs, + lastErrorCode, + claimDecisionId, + attempts: Object.freeze(attempts), + recoveries: Object.freeze(recoveries), + }); +} + +/** + * Create the read-only tenant evidence export repository. + * + * Tenant authority is derived exclusively through the normalized persisted + * Subscription -> Customer -> organization chain. The export never returns raw + * webhook payloads, provider credentials, active lease-token hashes, or free-form + * recovery evidence text. Operator evidence remains correlatable through SHA-256. + * Event selection is capped at 100 and the combined nested attempt/recovery history + * is capped at 1,000 rows before those histories are materialized. + * + * @param {import('node:sqlite').DatabaseSync} database bootstrapped SQLite database + * @returns {{exportTenantEvidence(input:{organizationId:number,limit?:number}):Readonly}} + * bounded read-only evidence port + */ +export function createSqliteStripeReconciliationEvidenceExportRepository(database) { + if (!database || typeof database.prepare !== 'function') { + throw new TypeError('database must provide SQLite prepare operations'); + } + + const selectEvents = database.prepare(` + SELECT + j.event_id, + t.subscription_id, + e.event_type, + e.provider_created_at_sec, + e.payload_sha256, + e.first_received_at_ms, + t.queued_at_ms, + j.processing_state, + j.attempt_count, + j.next_attempt_at_ms, + j.completed_at_ms, + j.last_error_code, + j.claim_decision_id + FROM billing_stripe_reconciliation_jobs AS j + JOIN billing_stripe_reconciliation_triggers AS t ON t.event_id = j.event_id + JOIN billing_stripe_webhook_events AS e ON e.event_id = j.event_id + JOIN billing_stripe_subscriptions AS s ON s.subscription_id = t.subscription_id + JOIN billing_stripe_customers AS c ON c.customer_id = s.customer_id + WHERE c.organization_id = ? + ORDER BY t.queued_at_ms DESC, j.event_id DESC + LIMIT ? + `); + const countNestedEvidence = database.prepare(` + SELECT + (SELECT COUNT(*) FROM billing_stripe_reconciliation_attempts WHERE event_id = ?) + + (SELECT COUNT(*) FROM billing_stripe_reconciliation_recoveries WHERE event_id = ?) + AS evidence_row_count + `); + const selectAttempts = database.prepare(` + SELECT attempt_number, lease_started_at_ms, lease_expires_at_ms, + finished_at_ms, outcome, error_code + FROM billing_stripe_reconciliation_attempts + WHERE event_id = ? + ORDER BY attempt_number ASC + `); + const selectRecoveries = database.prepare(` + SELECT recovery_id, attempt_number, actor_user_id, evidence_reference, + requested_at_ms, completed_at_ms, outcome, error_code, claim_decision_id + FROM billing_stripe_reconciliation_recoveries + WHERE event_id = ? + ORDER BY recovery_id ASC + `); + + return Object.freeze({ + /** + * Export one tenant's bounded reconciliation evidence without mutation. + * @param {{organizationId:number,limit?:number}} input tenant and event ceiling + * @returns {Readonly<{schemaVersion:string,organizationId:number,events:ReadonlyArray}>} + * immutable evidence document + */ + exportTenantEvidence({ organizationId, limit } = {}) { + const tenantId = positiveInteger(organizationId); + const eventLimit = eventLimitValue(limit); + const eventRows = selectEvents.all(tenantId, eventLimit); + + let nestedRows = 0; + for (const row of eventRows) { + const eventId = boundedIdentifier(row.event_id); + const count = countNestedEvidence.get(eventId, eventId)?.evidence_row_count; + const normalizedCount = nonNegativeInteger(count); + nestedRows += normalizedCount; + if (!Number.isSafeInteger(nestedRows) || nestedRows > MAX_NESTED_EVIDENCE_ROWS) { + throw exportError('stripe_reconciliation_evidence_export_too_large', 413); + } + } + + const events = eventRows.map((row) => { + const eventId = boundedIdentifier(row.event_id); + const attempts = selectAttempts.all(eventId).map(frozenAttempt); + const recoveries = selectRecoveries.all(eventId).map(frozenRecovery); + return frozenEvent(row, attempts, recoveries); + }); + + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + organizationId: tenantId, + events: Object.freeze(events), + }); + }, + }); +} From 7ac20d4a2a9879e228b9a22db8766256ac351a8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:57:56 -0700 Subject: [PATCH 04/37] test(billing): define authenticated evidence export route --- ...pe-reconciliation-evidence-export.test.mjs | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 tests/api/stripe-reconciliation-evidence-export.test.mjs diff --git a/tests/api/stripe-reconciliation-evidence-export.test.mjs b/tests/api/stripe-reconciliation-evidence-export.test.mjs new file mode 100644 index 00000000..3d26e884 --- /dev/null +++ b/tests/api/stripe-reconciliation-evidence-export.test.mjs @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.test'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_evidence_export'; +process.env.STRIPE_PRICE_ID = 'price_evidence_export'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_evidence_export'; +delete process.env.ORCHESTRATOR_URL; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); +const jsonBody = (value) => JSON.stringify(value); + +async function signup(email, name) { + const response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email, password: 'password123', name }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + const payload = await response.json(); + const me = await request('/api/me', { + headers: { authorization: `Bearer ${payload.token}` }, + }); + const identity = await me.json(); + return { + token: payload.token, + userId: identity.user.id, + organizationId: identity.orgs[0].id, + }; +} + +function seedEvidence({ + organizationId, + actorUserId, + eventId, + subscriptionId, + customerId, + payloadSha256, + queuedAtMs, + evidenceReference, +}) { + db.prepare(` + INSERT INTO billing_stripe_customers(customer_id, organization_id, first_observed_at_ms) + VALUES(?,?,?) + `).run(customerId, organizationId, queuedAtMs - 100); + db.prepare(` + INSERT INTO billing_stripe_subscriptions(subscription_id, customer_id, first_observed_at_ms) + VALUES(?,?,?) + `).run(subscriptionId, customerId, queuedAtMs - 100); + db.prepare(` + INSERT INTO billing_stripe_webhook_events( + event_id, provider_created_at_sec, event_type, object_id, object_type, + api_version, request_id, payload_sha256, first_received_at_ms + ) VALUES(?,?,?,?,?,?,?,?,?) + `).run( + eventId, + 1_787_000_000, + 'customer.subscription.updated', + subscriptionId, + 'subscription', + '2025-03-31.basil', + null, + payloadSha256, + queuedAtMs - 10, + ); + db.prepare(` + INSERT INTO billing_stripe_reconciliation_triggers( + event_id, subscription_id, queued_at_ms, processing_state + ) VALUES(?,?,?,'pending') + `).run(eventId, subscriptionId, queuedAtMs); + db.prepare(` + INSERT INTO billing_stripe_reconciliation_jobs( + event_id, processing_state, attempt_count, next_attempt_at_ms, + lease_token_sha256, lease_expires_at_ms, completed_at_ms, + last_error_code, claim_decision_id + ) VALUES(?,'dead_letter',1,?,NULL,NULL,?,'stripe_reconciliation_failed',NULL) + `).run(eventId, queuedAtMs + 10, queuedAtMs + 20); + db.prepare(` + INSERT INTO billing_stripe_reconciliation_attempts( + event_id, attempt_number, lease_started_at_ms, lease_expires_at_ms, + finished_at_ms, outcome, error_code + ) VALUES(?,1,?,?,?,'dead_letter','stripe_reconciliation_failed') + `).run(eventId, queuedAtMs + 1, queuedAtMs + 11, queuedAtMs + 20); + db.prepare(` + INSERT INTO billing_stripe_reconciliation_recoveries( + event_id, attempt_number, actor_user_id, evidence_reference, + requested_at_ms, completed_at_ms, outcome, error_code, claim_decision_id + ) VALUES(?,1,?,?,?,?,'dead_letter','stripe_reconciliation_failed',NULL) + `).run(eventId, actorUserId, evidenceReference, queuedAtMs + 30, queuedAtMs + 40); +} + +const owner = await signup('export-owner@scopeweave.test', 'Export Owner'); +const member = await signup('export-member@scopeweave.test', 'Export Member'); +db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)') + .run(owner.organizationId, member.userId, 'member'); + +seedEvidence({ + organizationId: owner.organizationId, + actorUserId: owner.userId, + eventId: 'evt_export_owner', + subscriptionId: 'sub_export_owner', + customerId: 'cus_export_owner', + payloadSha256: 'a'.repeat(64), + queuedAtMs: 2_000, + evidenceReference: 'INC-PRIVATE-EXPORT-OWNER', +}); +seedEvidence({ + organizationId: member.organizationId, + actorUserId: member.userId, + eventId: 'evt_export_foreign', + subscriptionId: 'sub_export_foreign', + customerId: 'cus_export_foreign', + payloadSha256: 'b'.repeat(64), + queuedAtMs: 3_000, + evidenceReference: 'INC-PRIVATE-EXPORT-FOREIGN', +}); + +const ownerPath = `/api/orgs/${owner.organizationId}/billing/reconciliation/evidence`; +let response = await request(ownerPath); +assert.equal(response.status, 401, 'evidence export requires authentication'); + +response = await request(ownerPath, { + headers: { authorization: `Bearer ${member.token}` }, +}); +assert.equal(response.status, 403, 'ordinary members cannot export workspace billing evidence'); + +response = await request(`${ownerPath}?limit=0`, { + headers: { authorization: `Bearer ${owner.token}` }, +}); +assert.equal(response.status, 400, 'invalid export bounds fail closed'); +assert.deepEqual(await response.json(), { error: 'stripe_reconciliation_evidence_export_invalid' }); + +response = await request(ownerPath, { + headers: { authorization: `Bearer ${owner.token}` }, +}); +assert.equal(response.status, 200, 'workspace owner can export its reconciliation evidence'); +assert.equal(response.headers.get('cache-control'), 'no-store'); +assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); +assert.equal( + response.headers.get('content-disposition'), + 'attachment; filename="scopeweave-stripe-reconciliation-evidence.json"', +); +const ownerReport = await response.json(); +assert.equal(ownerReport.schemaVersion, 'scopeweave.stripe-reconciliation-evidence/v1'); +assert.equal(ownerReport.organizationId, owner.organizationId); +assert.deepEqual(ownerReport.events.map((event) => event.eventId), ['evt_export_owner']); +assert.equal(JSON.stringify(ownerReport).includes('evt_export_foreign'), false); +assert.equal(JSON.stringify(ownerReport).includes('INC-PRIVATE-EXPORT-OWNER'), false); +assert.equal( + ownerReport.events[0].recoveries[0].evidenceReferenceSha256, + createHash('sha256').update('INC-PRIVATE-EXPORT-OWNER', 'utf8').digest('hex'), +); + +response = await request( + `/api/orgs/${member.organizationId}/billing/reconciliation/evidence`, + { headers: { authorization: `Bearer ${member.token}` } }, +); +assert.equal(response.status, 200, 'a second tenant owner can export only its own evidence'); +const memberReport = await response.json(); +assert.deepEqual(memberReport.events.map((event) => event.eventId), ['evt_export_foreign']); +assert.equal(JSON.stringify(memberReport).includes('evt_export_owner'), false); + +db.close(); \ No newline at end of file From 775db53e3fe2c58e718c06702c5469612accb2c6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 03:58:13 -0700 Subject: [PATCH 05/37] test(billing): register evidence export API RED --- tests/unit/server-runtime-force-close.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/server-runtime-force-close.test.mjs b/tests/unit/server-runtime-force-close.test.mjs index e9417eea..1c21628a 100644 --- a/tests/unit/server-runtime-force-close.test.mjs +++ b/tests/unit/server-runtime-force-close.test.mjs @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events'; import { test } from 'node:test'; await import('./stripe-reconciliation-evidence-export.test.mjs'); +await import('../api/stripe-reconciliation-evidence-export.test.mjs'); import { bindScopeWeaveRuntime } from '../../server/server_runtime.mjs'; From 9cabff5c6e894f63e7b4d17052710c52aa57de4f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:00:36 -0700 Subject: [PATCH 06/37] feat(billing): expose tenant reconciliation evidence export --- .../stripe_reconciliation_recovery_routes.mjs | 63 +++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/server/stripe_reconciliation_recovery_routes.mjs b/server/stripe_reconciliation_recovery_routes.mjs index d5dd38cc..295bdfa6 100644 --- a/server/stripe_reconciliation_recovery_routes.mjs +++ b/server/stripe_reconciliation_recovery_routes.mjs @@ -7,9 +7,23 @@ import { recoverStripeBillingDeadLetter, stripeReconciliationRecoveries, } from './db.mjs'; +import { + StripeReconciliationEvidenceExportError, + createSqliteStripeReconciliationEvidenceExportRepository, +} from './stripe_reconciliation_evidence_export.mjs'; import { StripeReconciliationRecoveryError } from './stripe_reconciliation_recovery.mjs'; const MAX_RECOVERY_REQUEST_BYTES = 4 * 1024; +const EVIDENCE_EXPORT_HEADERS = Object.freeze({ + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', +}); +const EVIDENCE_EXPORT_DOWNLOAD_HEADERS = Object.freeze({ + ...EVIDENCE_EXPORT_HEADERS, + 'Content-Disposition': 'attachment; filename="scopeweave-stripe-reconciliation-evidence.json"', +}); +const stripeReconciliationEvidenceExports = + createSqliteStripeReconciliationEvidenceExportRepository(db); function organizationRole(userId, organizationId) { return db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?') @@ -56,6 +70,17 @@ function recoveryFailure(c, error) { ); } +function evidenceExportFailure(c, error) { + if (error instanceof StripeReconciliationEvidenceExportError) { + return c.json({ error: error.code }, error.status, EVIDENCE_EXPORT_HEADERS); + } + return c.json( + { error: 'stripe_reconciliation_evidence_export_unavailable' }, + 500, + EVIDENCE_EXPORT_HEADERS, + ); +} + function auditRecovery(organizationId, actorUserId, result) { try { db.prepare(` @@ -82,17 +107,43 @@ function auditRecovery(organizationId, actorUserId, result) { } /** - * Tenant-scoped operator API for Stripe reconciliation dead-letter recovery. + * Tenant-scoped operator API for Stripe reconciliation evidence and dead-letter recovery. * * The route graph deliberately exposes no lease token, provider secret, raw webhook - * payload, or caller-selected Subscription identity. Owners/admins can inspect their - * bounded backlog and retry one exact verified Event using a durable evidence reference. - * Recovery JSON is capped at 4 KiB by Hono's body-limit middleware, which checks both - * declared Content-Length and streamed bytes before the JSON parser can buffer an - * unbounded privileged request. + * payload, or caller-selected Subscription identity. Owners/admins can export their + * bounded reconciliation evidence, inspect their bounded backlog, and retry one exact + * verified Event using a durable evidence reference. Recovery JSON is capped at 4 KiB + * by Hono's body-limit middleware, which checks declared Content-Length and streamed + * bytes before the JSON parser can buffer an unbounded privileged request. */ export const stripeReconciliationRecoveryRoutes = new Hono(); +stripeReconciliationRecoveryRoutes.get( + '/api/orgs/:id/billing/reconciliation/evidence', + requireRecoveryAuth, + (c) => { + const actorUserId = c.get('recoveryUserId'); + const organizationId = Number(c.req.param('id')); + const role = Number.isSafeInteger(organizationId) && organizationId > 0 + ? organizationRole(actorUserId, organizationId) + : null; + if (!role) return c.json({ error: 'not found' }, 404, EVIDENCE_EXPORT_HEADERS); + if (!canManage(role)) return c.json({ error: 'forbidden' }, 403, EVIDENCE_EXPORT_HEADERS); + + const rawLimit = c.req.query('limit'); + const limit = rawLimit === undefined ? undefined : Number(rawLimit); + try { + const report = stripeReconciliationEvidenceExports.exportTenantEvidence({ + organizationId, + limit, + }); + return c.json(report, 200, EVIDENCE_EXPORT_DOWNLOAD_HEADERS); + } catch (error) { + return evidenceExportFailure(c, error); + } + }, +); + stripeReconciliationRecoveryRoutes.get( '/api/orgs/:id/billing/reconciliation/dead-letters', requireRecoveryAuth, From 0af7b0c19b9543324f91856e4d562a80925bcd70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:01:31 -0700 Subject: [PATCH 07/37] test(billing): canonically register reconciliation evidence export --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 25593cae..863f5748 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/stripe-reconciliation-dead-letter-recovery.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap-package-contract.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.mjs --include=server/stripe_webhook_reconciliation_queue.mjs --include=server/stripe_checkout_identity_bootstrap.mjs --include=server/stripe_reconciliation_worker.mjs --include=server/stripe_reconciliation_recovery.mjs --include=server/stripe_reconciliation_recovery_routes.mjs --include=server/stripe_reconciliation_scheduler.mjs --include=server/server_runtime.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/api/stripe-reconciliation-evidence-export.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap-package-contract.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.mjs --include=server/stripe_webhook_reconciliation_queue.mjs --include=server/stripe_checkout_identity_bootstrap.mjs --include=server/stripe_reconciliation_worker.mjs --include=server/stripe_reconciliation_recovery.mjs --include=server/stripe_reconciliation_evidence_export.mjs --include=server/stripe_reconciliation_recovery_routes.mjs --include=server/stripe_reconciliation_scheduler.mjs --include=server/server_runtime.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From e076009d2aaad7ef79346ce0a502d946a219a0d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:01:56 -0700 Subject: [PATCH 08/37] test(billing): remove temporary evidence export registrations --- tests/unit/server-runtime-force-close.test.mjs | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/server-runtime-force-close.test.mjs b/tests/unit/server-runtime-force-close.test.mjs index 1c21628a..d907c47d 100644 --- a/tests/unit/server-runtime-force-close.test.mjs +++ b/tests/unit/server-runtime-force-close.test.mjs @@ -2,9 +2,6 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { test } from 'node:test'; -await import('./stripe-reconciliation-evidence-export.test.mjs'); -await import('../api/stripe-reconciliation-evidence-export.test.mjs'); - import { bindScopeWeaveRuntime } from '../../server/server_runtime.mjs'; test('forced-close failures are sanitized when the graceful shutdown window expires', () => { From b42a7311b0f04f439a9bcd3e93f4b98a785f8410 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:04:28 -0700 Subject: [PATCH 09/37] docs(billing): trace reconciliation evidence export controls --- .../stripe-reconciliation-evidence-export.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/doctoring/stripe-reconciliation-evidence-export.md diff --git a/docs/doctoring/stripe-reconciliation-evidence-export.md b/docs/doctoring/stripe-reconciliation-evidence-export.md new file mode 100644 index 00000000..dc4a86ec --- /dev/null +++ b/docs/doctoring/stripe-reconciliation-evidence-export.md @@ -0,0 +1,43 @@ +# Stripe reconciliation evidence export + +Status: **active PR only**. This document describes the bounded evidence-export slice on PR #582. It is not protected-`develop` shipped truth until the prerequisite #488 stack is integrated and the then-current required gates pass on the exact integrated head. + +## Buyer and operator decision + +Use `GET /api/orgs/:organizationId/billing/reconciliation/evidence` when a workspace owner or administrator needs a portable reconciliation record for incident review, diligence, or customer/auditor evidence. The response is an attachment-oriented JSON document and is deliberately read-only. If an Event is still `pending` or `processing`, inspect the corresponding reconciliation queue before taking a recovery action. If it is `dead_letter`, use the separately authorized recovery endpoint rather than editing evidence state. + +## Authority and privacy boundary + +The export derives tenant authority only through the persisted `billing_stripe_subscriptions -> billing_stripe_customers -> organization_id` relationship. The URL tenant identifier never substitutes provider or persisted tenant authority. Authentication plus owner/admin membership is required before export. + +The exported document contains bounded reconciliation facts needed to understand what ScopeWeave observed and attempted: Stripe Event and Subscription identifiers, Event type and provider creation time, the stored payload SHA-256, local receipt/queue/completion timing, processing state, attempt history, recovery history, stable error codes, and entitlement claim-decision linkage when present. + +The export does **not** include raw webhook payloads, Stripe credentials, active worker lease-token hashes, or plaintext operator recovery references. A recovery reference is exported only as a SHA-256 correlation value so an authorized operator who already possesses the reference can compare it without copying the free-form reference into a portable artifact. The response uses `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, and an attachment disposition. + +These choices implement purpose-bound access and data minimization rather than indiscriminate masking. NIST Privacy Framework Control-P calls for managing data at sufficient granularity to manage privacy risk, and CT.DM-P8 specifically connects audit/log records with data minimization. The export therefore keeps decision-relevant provenance while excluding unrelated secret or free-form payload material. + +## Boundedness and failure behavior + +- `limit` defaults to 50 Events and cannot exceed 100. +- Event selection is tenant-scoped and bounded before nested history is loaded. +- The combined selected attempt/recovery history is capped at 1,000 rows before materialization; larger exports fail closed with HTTP-compatible `413` and `stripe_reconciliation_evidence_export_too_large`. +- Persisted identifiers, hashes, times, states, outcomes, and stable error codes are revalidated during export. Corrupt or contradictory evidence fails closed instead of being serialized as authoritative audit evidence. +- An unknown tenant identifier produces no cross-tenant evidence. The HTTP route separately hides workspace membership with the existing not-found boundary. + +## Why immutable Event evidence is retained + +Stripe documents that webhook deliveries can be duplicated, retried, and delivered out of order. It recommends tracking processed Event IDs to prevent duplicate processing, and its undelivered-Event guidance notes that automatic retries can continue while operators manually process events. ScopeWeave therefore exports durable Event identity, ordering/provenance timestamps, processing state, and attempt/recovery evidence rather than treating receipt order as entitlement authority. Authoritative reconciliation remains responsible for re-reading current provider state. + +## Acceptance evidence + +The implementation was developed test-first on the existing #488 stack. RED evidence first proved the repository module was absent and then proved the authenticated HTTP route was absent. The production implementation and route subsequently passed the repository's normal unit/API suite, browser cloud E2E, dependency review, and OSV scan on the PR-associated synthetic merge revision. Those successful runs are useful behavioral evidence but are **not** exact-head merge authorization because the current Server Tests workflow checks GitHub's `pull//merge` revision. Exact-head control remains owned by the repository-wide CI repair path (#523), and absent CodeQL/SAST/security/coverage/review gates remain non-passing until regenerated under live governance. + +The new production module is explicitly included by `test:coverage`, and its unit regression plus authenticated API regression are registered in the canonical test scripts. No release or certification claim is made by this slice. + +## References + +National Institute of Standards and Technology. (2020). *NIST privacy framework: A tool for improving privacy through enterprise risk management, version 1.0*. https://www.nist.gov/privacy-framework + +Stripe. (n.d.). *Process undelivered webhook events*. https://docs.stripe.com/webhooks/process-undelivered-events + +Stripe. (n.d.). *Receive Stripe events in your webhook endpoint*. https://docs.stripe.com/webhooks From 2056072b2efaf8dd7fcb8109e08f3ad630f5a7dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:53:05 -0700 Subject: [PATCH 10/37] test(billing): reject contradictory reconciliation evidence --- ...pe-reconciliation-evidence-export.test.mjs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs index 0624d1d0..7bbfc5bd 100644 --- a/tests/unit/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -218,6 +218,29 @@ for (const input of [ ); } +// Export is an audit boundary over persisted state, so it must not serialize a +// contradictory terminal job as authoritative evidence even if a damaged restore or +// manually altered database bypassed the worker table's normal CHECK constraints. +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'succeeded', completed_at_ms = NULL, + last_error_code = NULL, claim_decision_id = 42 + WHERE event_id = 'evt_one_new'; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.code === 'stripe_reconciliation_evidence_export_invalid' + && error.status === 500, + 'contradictory persisted job state fails closed instead of becoming audit evidence', +); +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'processing', completed_at_ms = NULL, + last_error_code = NULL, claim_decision_id = NULL + WHERE event_id = 'evt_one_new'; +`); + // A single selected event with more nested rows than the hard response budget must // fail closed instead of allocating an arbitrarily large JSON evidence document. db.exec(` From abfe12a466ed02c2c1d15b1621922cf4c48594f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:55:31 -0700 Subject: [PATCH 11/37] fix(billing): reject contradictory reconciliation job evidence --- server/stripe_reconciliation_evidence_export.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index 32e32cc7..e04c2f3b 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -181,12 +181,23 @@ function frozenRecovery(row) { } function frozenEvent(row, attempts, recoveries) { + const processingState = processingStateValue(row.processing_state); const completedAtMs = nullableNonNegativeInteger(row.completed_at_ms); const lastErrorCode = nullableErrorCode(row.last_error_code); const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); const attemptCount = nonNegativeInteger(row.attempt_count); if (attempts.length > attemptCount) throw exportError(undefined, 500); + const lifecycleInvalid = + (processingState === 'pending' && (completedAtMs != null || claimDecisionId != null)) + || (processingState === 'processing' + && (completedAtMs != null || lastErrorCode != null || claimDecisionId != null)) + || (processingState === 'succeeded' + && (completedAtMs == null || lastErrorCode != null || claimDecisionId == null)) + || (processingState === 'dead_letter' + && (completedAtMs == null || lastErrorCode == null || claimDecisionId != null)); + if (lifecycleInvalid) throw exportError(undefined, 500); + return Object.freeze({ eventId: boundedIdentifier(row.event_id), subscriptionId: boundedIdentifier(row.subscription_id), @@ -195,7 +206,7 @@ function frozenEvent(row, attempts, recoveries) { payloadSha256: payloadSha256Value(row.payload_sha256), firstReceivedAtMs: nonNegativeInteger(row.first_received_at_ms), queuedAtMs: nonNegativeInteger(row.queued_at_ms), - processingState: processingStateValue(row.processing_state), + processingState, attemptCount, nextAttemptAtMs: nonNegativeInteger(row.next_attempt_at_ms), completedAtMs, From 2b6c608670143b7e0ec8e019737f549bf043f186 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:57:58 -0700 Subject: [PATCH 12/37] test(billing): reject impossible reconciliation attempt timelines --- ...pe-reconciliation-evidence-export.test.mjs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs index 7bbfc5bd..4f524c10 100644 --- a/tests/unit/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -241,6 +241,40 @@ db.exec(` WHERE event_id = 'evt_one_new'; `); +// The production worker constrains lease expiry and completion to occur no earlier +// than lease start. A damaged restore that bypasses those CHECKs must not become a +// plausible-looking audit timeline. +db.exec(` + UPDATE billing_stripe_reconciliation_attempts + SET lease_expires_at_ms = 2100 + WHERE event_id = 'evt_one_new' AND attempt_number = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'attempt lease expiry before lease start fails closed', +); +db.exec(` + UPDATE billing_stripe_reconciliation_attempts + SET lease_expires_at_ms = 999999999999 + WHERE event_id = 'evt_one_new' AND attempt_number = 1; + UPDATE billing_stripe_reconciliation_attempts + SET finished_at_ms = 1100 + WHERE event_id = 'evt_one_old' AND attempt_number = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'attempt completion before lease start fails closed', +); +db.exec(` + UPDATE billing_stripe_reconciliation_attempts + SET finished_at_ms = 1200 + WHERE event_id = 'evt_one_old' AND attempt_number = 1; +`); + // A single selected event with more nested rows than the hard response budget must // fail closed instead of allocating an arbitrarily large JSON evidence document. db.exec(` From 7bb7f95e877dcba387b583aac3da150f82ed129b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:59:41 -0700 Subject: [PATCH 13/37] fix(billing): validate reconciliation attempt chronology --- server/stripe_reconciliation_evidence_export.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index e04c2f3b..e6957529 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -134,8 +134,13 @@ function evidenceReferenceDigest(value) { function frozenAttempt(row) { const outcome = nullableOutcome(row.outcome, ATTEMPT_OUTCOMES); + const leaseStartedAtMs = nonNegativeInteger(row.lease_started_at_ms); + const leaseExpiresAtMs = nonNegativeInteger(row.lease_expires_at_ms); const finishedAtMs = nullableNonNegativeInteger(row.finished_at_ms); const errorCode = nullableErrorCode(row.error_code); + if (leaseExpiresAtMs < leaseStartedAtMs || (finishedAtMs != null && finishedAtMs < leaseStartedAtMs)) { + throw exportError(undefined, 500); + } if (outcome == null && (finishedAtMs != null || errorCode != null)) throw exportError(undefined, 500); if (outcome === 'succeeded' && errorCode != null) throw exportError(undefined, 500); if ((outcome === 'retry' || outcome === 'dead_letter') && errorCode == null) { @@ -144,8 +149,8 @@ function frozenAttempt(row) { return Object.freeze({ attemptNumber: positiveInteger(Number(row.attempt_number)), - leaseStartedAtMs: nonNegativeInteger(row.lease_started_at_ms), - leaseExpiresAtMs: nonNegativeInteger(row.lease_expires_at_ms), + leaseStartedAtMs, + leaseExpiresAtMs, finishedAtMs, outcome, errorCode, From 744d412f7dd34ed652a9db087af31545f3ba2b96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:02:55 -0700 Subject: [PATCH 14/37] test(billing): reject impossible recovery chronology --- ...pe-reconciliation-evidence-export.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs index 4f524c10..383d08cf 100644 --- a/tests/unit/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -275,6 +275,25 @@ db.exec(` WHERE event_id = 'evt_one_old' AND attempt_number = 1; `); +// Recovery completion chronology is persisted evidence too; a damaged restore that +// predates completion before the operator request must fail closed. +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET completed_at_ms = 1400 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'recovery completion before request fails closed', +); +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET completed_at_ms = 1460 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); + // A single selected event with more nested rows than the hard response budget must // fail closed instead of allocating an arbitrarily large JSON evidence document. db.exec(` From 1abe09f125af2704d792c6fb5997fe3f2d1941b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:04:34 -0700 Subject: [PATCH 15/37] fix(billing): validate reconciliation recovery chronology --- server/stripe_reconciliation_evidence_export.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index e6957529..d8549bdf 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -159,9 +159,11 @@ function frozenAttempt(row) { function frozenRecovery(row) { const outcome = nullableOutcome(row.outcome, RECOVERY_OUTCOMES); + const requestedAtMs = nonNegativeInteger(row.requested_at_ms); const completedAtMs = nullableNonNegativeInteger(row.completed_at_ms); const errorCode = nullableErrorCode(row.error_code); const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); + if (completedAtMs != null && completedAtMs < requestedAtMs) throw exportError(undefined, 500); if (outcome == null && (completedAtMs != null || errorCode != null || claimDecisionId != null)) { throw exportError(undefined, 500); } @@ -177,7 +179,7 @@ function frozenRecovery(row) { attemptNumber: positiveInteger(Number(row.attempt_number)), actorUserId: positiveInteger(Number(row.actor_user_id)), evidenceReferenceSha256: evidenceReferenceDigest(row.evidence_reference), - requestedAtMs: nonNegativeInteger(row.requested_at_ms), + requestedAtMs, completedAtMs, outcome, errorCode, From 209996ac2aaeb4e3a7bc81ad23126230455c6317 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:06:45 -0700 Subject: [PATCH 16/37] test(billing): reject orphaned recovery evidence --- ...pe-reconciliation-evidence-export.test.mjs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs index 383d08cf..88b6146a 100644 --- a/tests/unit/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -294,6 +294,26 @@ db.exec(` WHERE event_id = 'evt_one_old' AND recovery_id = 1; `); +// Production recovery rows are composite-FK-bound to the exact worker attempt. A +// damaged restore must not be able to make one recovery appear to authorize an +// attempt that does not exist in the exported event history. +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET attempt_number = 99 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'recovery referencing a missing attempt fails closed', +); +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET attempt_number = 2 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); + // A single selected event with more nested rows than the hard response budget must // fail closed instead of allocating an arbitrarily large JSON evidence document. db.exec(` From eaaf71a695ba5d59f358360c6b1a6e78a2b2cd89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 06:56:24 -0700 Subject: [PATCH 17/37] fix(billing): reject orphaned recovery evidence --- server/stripe_reconciliation_evidence_export.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index d8549bdf..f740e855 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -194,6 +194,10 @@ function frozenEvent(row, attempts, recoveries) { const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); const attemptCount = nonNegativeInteger(row.attempt_count); if (attempts.length > attemptCount) throw exportError(undefined, 500); + const attemptNumbers = new Set(attempts.map((attempt) => attempt.attemptNumber)); + if (recoveries.some((recovery) => !attemptNumbers.has(recovery.attemptNumber))) { + throw exportError(undefined, 500); + } const lifecycleInvalid = (processingState === 'pending' && (completedAtMs != null || claimDecisionId != null)) From e7062c5dcbe09f8aacb3d9dd9c440cca27e00983 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:27:36 -0700 Subject: [PATCH 18/37] test(billing): reject incomplete reconciliation history --- ...pe-reconciliation-evidence-export.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs index 88b6146a..0fa1c6fa 100644 --- a/tests/unit/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -314,6 +314,25 @@ db.exec(` WHERE event_id = 'evt_one_old' AND recovery_id = 1; `); +// attempt_count is the worker's durable count of append-only attempts. If a damaged +// restore loses an attempt row, the export must not present a plausible but incomplete +// audit history merely because the surviving attempt numbers are individually valid. +db.exec(` + DELETE FROM billing_stripe_reconciliation_attempts + WHERE event_id = 'evt_one_old' AND attempt_number = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'missing append-only attempt history fails closed instead of producing incomplete audit evidence', +); +db.exec(` + INSERT INTO billing_stripe_reconciliation_attempts( + event_id,attempt_number,lease_started_at_ms,lease_expires_at_ms,finished_at_ms,outcome,error_code + ) VALUES('evt_one_old',1,1110,1210,1200,'retry','stripe_provider_timeout'); +`); + // A single selected event with more nested rows than the hard response budget must // fail closed instead of allocating an arbitrarily large JSON evidence document. db.exec(` From 2fdc99c56506496a4ddd81b874ad8cce61feccde Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:30:16 -0700 Subject: [PATCH 19/37] fix(billing): fail closed on incomplete attempt evidence --- server/stripe_reconciliation_evidence_export.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index f740e855..07ee1692 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -193,7 +193,12 @@ function frozenEvent(row, attempts, recoveries) { const lastErrorCode = nullableErrorCode(row.last_error_code); const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); const attemptCount = nonNegativeInteger(row.attempt_count); - if (attempts.length > attemptCount) throw exportError(undefined, 500); + if ( + attempts.length !== attemptCount + || attempts.some((attempt, index) => attempt.attemptNumber !== index + 1) + ) { + throw exportError(undefined, 500); + } const attemptNumbers = new Set(attempts.map((attempt) => attempt.attemptNumber)); if (recoveries.some((recovery) => !attemptNumbers.has(recovery.attemptNumber))) { throw exportError(undefined, 500); From ab2be7ccef365c563339daa2ada0c5980a199750 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:36:19 -0700 Subject: [PATCH 20/37] test(billing): reject job-attempt lifecycle contradictions --- ...pe-reconciliation-evidence-export.test.mjs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs index 0fa1c6fa..82880434 100644 --- a/tests/unit/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -333,6 +333,31 @@ db.exec(` ) VALUES('evt_one_old',1,1110,1210,1200,'retry','stripe_provider_timeout'); `); +// Job and attempt rows are written in one worker savepoint and describe one lifecycle. +// A damaged restore with a terminal job but an unfinished latest attempt must not be +// presented as internally coherent audit evidence merely because each row is valid alone. +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'succeeded', completed_at_ms = 2300, + last_error_code = NULL, claim_decision_id = 42, + lease_token_sha256 = NULL, lease_expires_at_ms = NULL + WHERE event_id = 'evt_one_new'; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'terminal job with unfinished latest attempt fails closed as contradictory audit evidence', +); +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'processing', completed_at_ms = NULL, + last_error_code = NULL, claim_decision_id = NULL, + lease_token_sha256 = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + lease_expires_at_ms = 999999999999 + WHERE event_id = 'evt_one_new'; +`); + // A single selected event with more nested rows than the hard response budget must // fail closed instead of allocating an arbitrarily large JSON evidence document. db.exec(` From ff5cec141d61166fe900e82be366ec312b4fe228 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 07:39:16 -0700 Subject: [PATCH 21/37] fix(billing): validate reconciliation lifecycle evidence --- server/stripe_reconciliation_evidence_export.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index 07ee1692..4916735c 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -214,6 +214,17 @@ function frozenEvent(row, attempts, recoveries) { && (completedAtMs == null || lastErrorCode == null || claimDecisionId != null)); if (lifecycleInvalid) throw exportError(undefined, 500); + const latestAttempt = attempts.at(-1) ?? null; + const latestAttemptLifecycleInvalid = + (processingState === 'pending' && latestAttempt != null && latestAttempt.outcome !== 'retry') + || (processingState === 'processing' + && (latestAttempt == null || latestAttempt.outcome !== null)) + || (processingState === 'succeeded' + && (latestAttempt == null || latestAttempt.outcome !== 'succeeded')) + || (processingState === 'dead_letter' + && (latestAttempt == null || latestAttempt.outcome !== 'dead_letter')); + if (latestAttemptLifecycleInvalid) throw exportError(undefined, 500); + return Object.freeze({ eventId: boundedIdentifier(row.event_id), subscriptionId: boundedIdentifier(row.subscription_id), From fd6c747de211483d6391a57839095bcbca1517f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:04:49 -0700 Subject: [PATCH 22/37] test(billing): reject contradictory recovery evidence --- ...pe-reconciliation-evidence-export.test.mjs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/unit/stripe-reconciliation-evidence-export.test.mjs b/tests/unit/stripe-reconciliation-evidence-export.test.mjs index 82880434..9b40161e 100644 --- a/tests/unit/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export.test.mjs @@ -314,6 +314,27 @@ db.exec(` WHERE event_id = 'evt_one_old' AND recovery_id = 1; `); +// Recovery outcome is written atomically with the exact worker attempt. A damaged +// restore must not serialize success when its linked worker attempt is durable +// dead-letter evidence. +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET outcome = 'succeeded', error_code = NULL, claim_decision_id = 42 + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); +assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 10 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + 'recovery outcome must agree with its linked worker attempt', +); +db.exec(` + UPDATE billing_stripe_reconciliation_recoveries + SET outcome = 'dead_letter', error_code = 'stripe_reconciliation_failed', + claim_decision_id = NULL + WHERE event_id = 'evt_one_old' AND recovery_id = 1; +`); + // attempt_count is the worker's durable count of append-only attempts. If a damaged // restore loses an attempt row, the export must not present a plausible but incomplete // audit history merely because the surviving attempt numbers are individually valid. @@ -381,4 +402,4 @@ assert.throws( 'oversized nested evidence fails closed with a stable bounded error', ); -db.close(); \ No newline at end of file +db.close(); From 6488f6b01e63f6ed40e247b094a53a61aaf43fc4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:07:05 -0700 Subject: [PATCH 23/37] fix(billing): cross-check recovery attempt evidence --- server/stripe_reconciliation_evidence_export.mjs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index 4916735c..ef1ea409 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -199,9 +199,16 @@ function frozenEvent(row, attempts, recoveries) { ) { throw exportError(undefined, 500); } - const attemptNumbers = new Set(attempts.map((attempt) => attempt.attemptNumber)); - if (recoveries.some((recovery) => !attemptNumbers.has(recovery.attemptNumber))) { - throw exportError(undefined, 500); + const attemptsByNumber = new Map(attempts.map((attempt) => [attempt.attemptNumber, attempt])); + for (const recovery of recoveries) { + const attempt = attemptsByNumber.get(recovery.attemptNumber); + if ( + attempt == null + || recovery.outcome !== attempt.outcome + || recovery.errorCode !== attempt.errorCode + ) { + throw exportError(undefined, 500); + } } const lifecycleInvalid = From ce6258af1a98f552112f276aac0061aa23de807a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:48:12 -0700 Subject: [PATCH 24/37] test(billing): reject contradictory export lease evidence --- ...evidence-export-lease-consistency.test.mjs | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs diff --git a/tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs b/tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs new file mode 100644 index 00000000..69ccc2ca --- /dev/null +++ b/tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + StripeReconciliationEvidenceExportError, + createSqliteStripeReconciliationEvidenceExportRepository, +} from '../../server/stripe_reconciliation_evidence_export.mjs'; + +const db = new DatabaseSync(':memory:'); +db.exec(` + CREATE TABLE billing_stripe_customers ( + customer_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_subscriptions ( + subscription_id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL + ); + CREATE TABLE billing_stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + provider_created_at_sec INTEGER NOT NULL, + payload_sha256 TEXT NOT NULL, + first_received_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_triggers ( + event_id TEXT PRIMARY KEY, + subscription_id TEXT NOT NULL, + queued_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_jobs ( + event_id TEXT PRIMARY KEY, + processing_state TEXT NOT NULL, + attempt_count INTEGER NOT NULL, + next_attempt_at_ms INTEGER NOT NULL, + lease_token_sha256 TEXT, + lease_expires_at_ms INTEGER, + completed_at_ms INTEGER, + last_error_code TEXT, + claim_decision_id INTEGER + ); + CREATE TABLE billing_stripe_reconciliation_attempts ( + event_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + lease_started_at_ms INTEGER NOT NULL, + lease_expires_at_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + error_code TEXT + ); + CREATE TABLE billing_stripe_reconciliation_recoveries ( + recovery_id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + actor_user_id INTEGER NOT NULL, + evidence_reference TEXT NOT NULL, + requested_at_ms INTEGER NOT NULL, + completed_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + claim_decision_id INTEGER + ); + + INSERT INTO billing_stripe_customers(customer_id,organization_id) + VALUES('cus_one',1); + INSERT INTO billing_stripe_subscriptions(subscription_id,customer_id) + VALUES('sub_one','cus_one'); + INSERT INTO billing_stripe_webhook_events( + event_id,event_type,provider_created_at_sec,payload_sha256,first_received_at_ms + ) VALUES( + 'evt_one','invoice.paid',1787000100, + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',1000 + ); + INSERT INTO billing_stripe_reconciliation_triggers(event_id,subscription_id,queued_at_ms) + VALUES('evt_one','sub_one',1100); + INSERT INTO billing_stripe_reconciliation_jobs( + event_id,processing_state,attempt_count,next_attempt_at_ms,lease_token_sha256, + lease_expires_at_ms,completed_at_ms,last_error_code,claim_decision_id + ) VALUES( + 'evt_one','processing',1,1200, + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 5000,NULL,NULL,NULL + ); + INSERT INTO billing_stripe_reconciliation_attempts( + event_id,attempt_number,lease_started_at_ms,lease_expires_at_ms,finished_at_ms,outcome,error_code + ) VALUES('evt_one',1,4000,5000,NULL,NULL,NULL); +`); + +const repository = createSqliteStripeReconciliationEvidenceExportRepository(db); +const valid = repository.exportTenantEvidence({ organizationId: 1, limit: 1 }); +assert.equal(valid.events.length, 1); +assert.equal( + JSON.stringify(valid).includes('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'), + false, + 'active lease-token hashes remain validation-only and never enter exported evidence', +); + +function assertFailsClosed(message) { + assert.throws( + () => repository.exportTenantEvidence({ organizationId: 1, limit: 1 }), + (error) => error instanceof StripeReconciliationEvidenceExportError + && error.status === 500, + message, + ); +} + +// A processing worker row is authoritative only while it retains the opaque lease +// digest required by the worker schema. A damaged restore must not become plausible +// audit evidence merely because the attempt row is otherwise coherent. +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET lease_token_sha256 = NULL + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('processing evidence without its active lease digest fails closed'); + +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET lease_token_sha256 = 'not-a-sha256' + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('processing evidence with a malformed lease digest fails closed'); + +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET lease_token_sha256 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + lease_expires_at_ms = NULL + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('processing evidence without durable lease expiry fails closed'); + +db.exec(` + UPDATE billing_stripe_reconciliation_jobs + SET lease_expires_at_ms = 5001 + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('job lease expiry must agree with the exact unfinished attempt'); + +// Terminal worker state clears active lease material atomically. If damaged state +// retains a lease after success, the evidence exporter must reject that contradiction. +db.exec(` + UPDATE billing_stripe_reconciliation_attempts + SET finished_at_ms = 6000, outcome = 'succeeded' + WHERE event_id = 'evt_one' AND attempt_number = 1; + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'succeeded', lease_expires_at_ms = 5000, + completed_at_ms = 6000, claim_decision_id = 77 + WHERE event_id = 'evt_one'; +`); +assertFailsClosed('terminal evidence retaining active lease material fails closed'); + +db.close(); From 68d80a4372cfed3fb2a50601b3376c83c9883a37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:49:30 -0700 Subject: [PATCH 25/37] test(billing): register lease consistency regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 863f5748..6d62f489 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/api/stripe-reconciliation-evidence-export.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap-package-contract.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap-package-contract.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.mjs --include=server/stripe_webhook_reconciliation_queue.mjs --include=server/stripe_checkout_identity_bootstrap.mjs --include=server/stripe_reconciliation_worker.mjs --include=server/stripe_reconciliation_recovery.mjs --include=server/stripe_reconciliation_evidence_export.mjs --include=server/stripe_reconciliation_recovery_routes.mjs --include=server/stripe_reconciliation_scheduler.mjs --include=server/server_runtime.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From 2066ff76b14e3d531adbb1ba79c3e53e36eda8f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 08:51:18 -0700 Subject: [PATCH 26/37] fix(billing): validate reconciliation export lease state --- .../stripe_reconciliation_evidence_export.mjs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index ef1ea409..88fcfd91 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -101,6 +101,13 @@ function payloadSha256Value(value) { return value; } +function leaseTokenSha256Value(value) { + if (typeof value !== 'string' || !PAYLOAD_SHA256_PATTERN.test(value)) { + throw exportError(undefined, 500); + } + return value; +} + function processingStateValue(value) { if (!PROCESSING_STATES.has(value)) throw exportError(undefined, 500); return value; @@ -193,6 +200,10 @@ function frozenEvent(row, attempts, recoveries) { const lastErrorCode = nullableErrorCode(row.last_error_code); const claimDecisionId = nullablePositiveInteger(row.claim_decision_id); const attemptCount = nonNegativeInteger(row.attempt_count); + const leaseTokenSha256 = row.lease_token_sha256 == null + ? null + : leaseTokenSha256Value(row.lease_token_sha256); + const leaseExpiresAtMs = nullableNonNegativeInteger(row.lease_expires_at_ms); if ( attempts.length !== attemptCount || attempts.some((attempt, index) => attempt.attemptNumber !== index + 1) @@ -232,6 +243,16 @@ function frozenEvent(row, attempts, recoveries) { && (latestAttempt == null || latestAttempt.outcome !== 'dead_letter')); if (latestAttemptLifecycleInvalid) throw exportError(undefined, 500); + const leaseLifecycleInvalid = processingState === 'processing' + ? ( + leaseTokenSha256 == null + || leaseExpiresAtMs == null + || latestAttempt == null + || leaseExpiresAtMs !== latestAttempt.leaseExpiresAtMs + ) + : leaseTokenSha256 != null || leaseExpiresAtMs != null; + if (leaseLifecycleInvalid) throw exportError(undefined, 500); + return Object.freeze({ eventId: boundedIdentifier(row.event_id), subscriptionId: boundedIdentifier(row.subscription_id), @@ -282,6 +303,8 @@ export function createSqliteStripeReconciliationEvidenceExportRepository(databas j.processing_state, j.attempt_count, j.next_attempt_at_ms, + j.lease_token_sha256, + j.lease_expires_at_ms, j.completed_at_ms, j.last_error_code, j.claim_decision_id From 4d342cbbdaec076e4378cc416149e2e96ef098ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:07:59 -0700 Subject: [PATCH 27/37] test(billing): reproduce reconciliation export snapshot race --- ...iliation-evidence-export-snapshot.test.mjs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs diff --git a/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs b/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs new file mode 100644 index 00000000..cffbb4f3 --- /dev/null +++ b/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { + createSqliteStripeReconciliationEvidenceExportRepository, +} from '../../server/stripe_reconciliation_evidence_export.mjs'; + +const tempDirectory = mkdtempSync(join(tmpdir(), 'scopeweave-reconciliation-evidence-')); +const databasePath = join(tempDirectory, 'snapshot.sqlite'); +let bootstrap; +let reader; +let writer; + +try { + bootstrap = new DatabaseSync(databasePath); + bootstrap.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + CREATE TABLE billing_stripe_customers ( + customer_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES orgs(id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_subscriptions ( + subscription_id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL REFERENCES billing_stripe_customers(customer_id), + first_observed_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_webhook_events ( + event_id TEXT PRIMARY KEY, + provider_created_at_sec INTEGER NOT NULL, + event_type TEXT NOT NULL, + object_id TEXT NOT NULL, + object_type TEXT NOT NULL, + api_version TEXT, + request_id TEXT, + payload_sha256 TEXT NOT NULL, + first_received_at_ms INTEGER NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_triggers ( + event_id TEXT PRIMARY KEY REFERENCES billing_stripe_webhook_events(event_id), + subscription_id TEXT NOT NULL, + queued_at_ms INTEGER NOT NULL, + processing_state TEXT NOT NULL + ); + CREATE TABLE billing_stripe_reconciliation_jobs ( + event_id TEXT PRIMARY KEY REFERENCES billing_stripe_reconciliation_triggers(event_id), + processing_state TEXT NOT NULL, + attempt_count INTEGER NOT NULL, + next_attempt_at_ms INTEGER NOT NULL, + lease_token_sha256 TEXT, + lease_expires_at_ms INTEGER, + completed_at_ms INTEGER, + last_error_code TEXT, + claim_decision_id INTEGER + ); + CREATE TABLE billing_stripe_reconciliation_attempts ( + attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL REFERENCES billing_stripe_reconciliation_jobs(event_id), + attempt_number INTEGER NOT NULL, + lease_started_at_ms INTEGER NOT NULL, + lease_expires_at_ms INTEGER NOT NULL, + finished_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + UNIQUE(event_id, attempt_number) + ); + CREATE TABLE billing_stripe_reconciliation_recoveries ( + recovery_id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + actor_user_id INTEGER NOT NULL, + evidence_reference TEXT NOT NULL, + requested_at_ms INTEGER NOT NULL, + completed_at_ms INTEGER, + outcome TEXT, + error_code TEXT, + claim_decision_id INTEGER, + UNIQUE(event_id, evidence_reference) + ); + + INSERT INTO orgs(id, name) VALUES(1, 'Tenant Snapshot'); + INSERT INTO billing_stripe_customers(customer_id, organization_id, first_observed_at_ms) + VALUES('cus_snapshot', 1, 100); + INSERT INTO billing_stripe_subscriptions(subscription_id, customer_id, first_observed_at_ms) + VALUES('sub_snapshot', 'cus_snapshot', 100); + INSERT INTO billing_stripe_webhook_events( + event_id, provider_created_at_sec, event_type, object_id, object_type, + api_version, request_id, payload_sha256, first_received_at_ms + ) VALUES( + 'evt_snapshot', 1787000000, 'invoice.paid', 'in_snapshot', 'invoice', + '2025-03-31.basil', 'req_snapshot', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 1000 + ); + INSERT INTO billing_stripe_reconciliation_triggers( + event_id, subscription_id, queued_at_ms, processing_state + ) VALUES('evt_snapshot', 'sub_snapshot', 1100, 'pending'); + INSERT INTO billing_stripe_reconciliation_jobs( + event_id, processing_state, attempt_count, next_attempt_at_ms, + lease_token_sha256, lease_expires_at_ms, completed_at_ms, + last_error_code, claim_decision_id + ) VALUES('evt_snapshot', 'pending', 0, 1200, NULL, NULL, NULL, NULL, NULL); + `); + bootstrap.close(); + bootstrap = null; + + reader = new DatabaseSync(databasePath); + writer = new DatabaseSync(databasePath); + reader.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;'); + writer.exec('PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 1000;'); + + let writerCommitted = false; + const databaseWithConcurrentWriter = { + exec(sql) { + return reader.exec(sql); + }, + prepare(sql) { + const statement = reader.prepare(sql); + if (!sql.includes('FROM billing_stripe_reconciliation_attempts')) return statement; + return { + all(...parameters) { + if (!writerCommitted) { + writer.exec(` + BEGIN IMMEDIATE; + UPDATE billing_stripe_reconciliation_jobs + SET processing_state = 'processing', + attempt_count = 1, + lease_token_sha256 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + lease_expires_at_ms = 5000 + WHERE event_id = 'evt_snapshot'; + INSERT INTO billing_stripe_reconciliation_attempts( + event_id, attempt_number, lease_started_at_ms, lease_expires_at_ms, + finished_at_ms, outcome, error_code + ) VALUES('evt_snapshot', 1, 4000, 5000, NULL, NULL, NULL); + COMMIT; + `); + writerCommitted = true; + } + return statement.all(...parameters); + }, + }; + }, + }; + + const repository = createSqliteStripeReconciliationEvidenceExportRepository( + databaseWithConcurrentWriter, + ); + const firstReport = repository.exportTenantEvidence({ organizationId: 1, limit: 10 }); + + assert.equal(writerCommitted, true, 'the concurrent reconciliation write committed during export'); + assert.deepEqual( + firstReport.events.map((event) => ({ + eventId: event.eventId, + processingState: event.processingState, + attemptCount: event.attemptCount, + attempts: event.attempts.length, + })), + [{ eventId: 'evt_snapshot', processingState: 'pending', attemptCount: 0, attempts: 0 }], + 'one evidence document must come from one SQLite read snapshot even while a writer commits', + ); + + const secondReport = repository.exportTenantEvidence({ organizationId: 1, limit: 10 }); + assert.deepEqual( + secondReport.events.map((event) => ({ + eventId: event.eventId, + processingState: event.processingState, + attemptCount: event.attemptCount, + attempts: event.attempts.length, + })), + [{ eventId: 'evt_snapshot', processingState: 'processing', attemptCount: 1, attempts: 1 }], + 'a later export observes the committed reconciliation state after the snapshot is released', + ); +} finally { + try { writer?.close(); } catch {} + try { reader?.close(); } catch {} + try { bootstrap?.close(); } catch {} + rmSync(tempDirectory, { recursive: true, force: true }); +} From 12b7d7885e7dd9a42bd4f8ef02807ba1a1aef788 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:09:18 -0700 Subject: [PATCH 28/37] test(billing): register reconciliation snapshot regression --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6d62f489..202ebf5a 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs && node tests/api/billing-effective-plan-status.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/api/stripe-reconciliation-evidence-export.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap-package-contract.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap-package-contract.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && node tests/unit/toast-accessibility.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/application_routes.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/billing_status_response.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/stripe_webhook.mjs --include=server/stripe_webhook_event_ledger.mjs --include=server/stripe_subscription_provider.mjs --include=server/stripe_subscription_observation_ledger.mjs --include=server/stripe_subscription_current_projection.mjs --include=server/stripe_entitlement_policy.mjs --include=server/stripe_invoice_provider.mjs --include=server/stripe_invoice_observation_ledger.mjs --include=server/stripe_invoice_current_projection.mjs --include=server/stripe_entitlement_claim_ledger.mjs --include=server/stripe_billing_reconciliation.mjs --include=server/stripe_webhook_reconciliation_queue.mjs --include=server/stripe_checkout_identity_bootstrap.mjs --include=server/stripe_reconciliation_worker.mjs --include=server/stripe_reconciliation_recovery.mjs --include=server/stripe_reconciliation_evidence_export.mjs --include=server/stripe_reconciliation_recovery_routes.mjs --include=server/stripe_reconciliation_scheduler.mjs --include=server/server_runtime.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && npm run test:api", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-effective-plan.test.mjs && node tests/unit/billing-effective-plan-bootstrap.test.mjs && node tests/unit/billing-status-package-contract.test.mjs && node tests/unit/billing-status-response.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout-reconciliation.test.mjs && node tests/unit/billing-checkout-reconciliation-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/stripe-webhook-boundary.test.mjs && node tests/unit/stripe-webhook-event-ledger.test.mjs && node tests/unit/stripe-webhook-recorder-integration.test.mjs && node tests/unit/stripe-subscription-provider.test.mjs && node tests/unit/stripe-subscription-metadata-propagation.test.mjs && node tests/unit/stripe-subscription-observation-ledger.test.mjs && node tests/unit/stripe-subscription-current-projection.test.mjs && node tests/unit/stripe-entitlement-policy.test.mjs && node tests/unit/stripe-entitlement-policy-edge.test.mjs && node tests/unit/stripe-entitlement-policy-duplicate-claims.test.mjs && node tests/unit/stripe-invoice-provider.test.mjs && node tests/unit/stripe-invoice-provider-edge.test.mjs && node tests/unit/stripe-invoice-observation-ledger.test.mjs && node tests/unit/stripe-invoice-current-projection-package-contract.test.mjs && node tests/unit/stripe-invoice-current-projection.test.mjs && node tests/unit/stripe-entitlement-claim-package-contract.test.mjs && node tests/unit/stripe-entitlement-claim-ledger.test.mjs && node tests/unit/stripe-billing-authoritative-reconciliation.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue.test.mjs && node tests/unit/stripe-checkout-identity-bootstrap.test.mjs && node tests/unit/stripe-webhook-reconciliation-queue-integration.test.mjs && node tests/unit/stripe-reconciliation-worker.test.mjs && node tests/unit/stripe-reconciliation-worker-time-budget.test.mjs && node tests/unit/stripe-reconciliation-scheduler.test.mjs && node tests/unit/server-runtime-force-close.test.mjs && node tests/unit/stripe-reconciliation-dead-letter-recovery.test.mjs && node tests/unit/stripe-reconciliation-evidence-export.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-lease-consistency.test.mjs && node tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs && node tests/fuzz/stripeReconciliationRecovery.fuzz.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", From fcb6124cc667c2c5b49c3a2ae6a78102ad3c8ecf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:10:53 -0700 Subject: [PATCH 29/37] test(billing): isolate reconciliation snapshot race hook --- .../stripe-reconciliation-evidence-export-snapshot.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs b/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs index cffbb4f3..da1c5cca 100644 --- a/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs @@ -124,7 +124,9 @@ try { }, prepare(sql) { const statement = reader.prepare(sql); - if (!sql.includes('FROM billing_stripe_reconciliation_attempts')) return statement; + const isAttemptMaterialization = sql.includes('FROM billing_stripe_reconciliation_attempts') + && sql.includes('ORDER BY attempt_number ASC'); + if (!isAttemptMaterialization) return statement; return { all(...parameters) { if (!writerCommitted) { From b20b26bfeb2dbe12b51190503661693615e3f69c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 12:14:05 -0700 Subject: [PATCH 30/37] fix(billing): export reconciliation evidence from one snapshot --- .../stripe_reconciliation_evidence_export.mjs | 88 ++++++++++++++----- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index 88fcfd91..16107fd8 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -15,6 +15,7 @@ const ATTEMPT_OUTCOMES = new Set(['succeeded', 'retry', 'dead_letter']); const RECOVERY_OUTCOMES = new Set(['succeeded', 'dead_letter']); const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u; const SCHEMA_VERSION = 'scopeweave.stripe-reconciliation-evidence/v1'; +const READ_SNAPSHOT_SAVEPOINT = 'scopeweave_stripe_reconciliation_evidence_export'; /** Stable fail-closed error for tenant reconciliation evidence exports. */ export class StripeReconciliationEvidenceExportError extends Error { @@ -272,6 +273,40 @@ function frozenEvent(row, attempts, recoveries) { }); } +/** + * Run a synchronous evidence read inside one composable SQLite read snapshot. + * + * A SAVEPOINT is used instead of a bare BEGIN so this repository can be called + * inside an outer transaction. SQLite fixes the read view on the first SELECT; + * every count, history read, and validation therefore describes one database + * moment even when a WAL-mode reconciliation writer commits concurrently. + * + * @param {import('node:sqlite').DatabaseSync} database SQLite database + * @param {()=>any} operation synchronous evidence read operation + * @returns {any} operation result after the snapshot is released + */ +function withReadSnapshot(database, operation) { + let snapshotStarted = false; + try { + database.exec(`SAVEPOINT ${READ_SNAPSHOT_SAVEPOINT}`); + snapshotStarted = true; + const result = operation(); + database.exec(`RELEASE SAVEPOINT ${READ_SNAPSHOT_SAVEPOINT}`); + return result; + } catch (error) { + if (!snapshotStarted) { + throw exportError('stripe_reconciliation_evidence_export_snapshot_failed', 500); + } + try { + database.exec(`ROLLBACK TO SAVEPOINT ${READ_SNAPSHOT_SAVEPOINT}`); + database.exec(`RELEASE SAVEPOINT ${READ_SNAPSHOT_SAVEPOINT}`); + } catch { + throw exportError('stripe_reconciliation_evidence_export_snapshot_failed', 500); + } + throw error; + } +} + /** * Create the read-only tenant evidence export repository. * @@ -280,15 +315,17 @@ function frozenEvent(row, attempts, recoveries) { * webhook payloads, provider credentials, active lease-token hashes, or free-form * recovery evidence text. Operator evidence remains correlatable through SHA-256. * Event selection is capped at 100 and the combined nested attempt/recovery history - * is capped at 1,000 rows before those histories are materialized. + * is capped at 1,000 rows before those histories are materialized. Every exported + * document is read from one SQLite snapshot so the row cap and lifecycle evidence + * cannot be invalidated by a concurrent reconciliation commit. * * @param {import('node:sqlite').DatabaseSync} database bootstrapped SQLite database * @returns {{exportTenantEvidence(input:{organizationId:number,limit?:number}):Readonly}} * bounded read-only evidence port */ export function createSqliteStripeReconciliationEvidenceExportRepository(database) { - if (!database || typeof database.prepare !== 'function') { - throw new TypeError('database must provide SQLite prepare operations'); + if (!database || typeof database.prepare !== 'function' || typeof database.exec !== 'function') { + throw new TypeError('database must provide SQLite prepare and exec operations'); } const selectEvents = database.prepare(` @@ -348,30 +385,33 @@ export function createSqliteStripeReconciliationEvidenceExportRepository(databas exportTenantEvidence({ organizationId, limit } = {}) { const tenantId = positiveInteger(organizationId); const eventLimit = eventLimitValue(limit); - const eventRows = selectEvents.all(tenantId, eventLimit); - - let nestedRows = 0; - for (const row of eventRows) { - const eventId = boundedIdentifier(row.event_id); - const count = countNestedEvidence.get(eventId, eventId)?.evidence_row_count; - const normalizedCount = nonNegativeInteger(count); - nestedRows += normalizedCount; - if (!Number.isSafeInteger(nestedRows) || nestedRows > MAX_NESTED_EVIDENCE_ROWS) { - throw exportError('stripe_reconciliation_evidence_export_too_large', 413); + + return withReadSnapshot(database, () => { + const eventRows = selectEvents.all(tenantId, eventLimit); + + let nestedRows = 0; + for (const row of eventRows) { + const eventId = boundedIdentifier(row.event_id); + const count = countNestedEvidence.get(eventId, eventId)?.evidence_row_count; + const normalizedCount = nonNegativeInteger(count); + nestedRows += normalizedCount; + if (!Number.isSafeInteger(nestedRows) || nestedRows > MAX_NESTED_EVIDENCE_ROWS) { + throw exportError('stripe_reconciliation_evidence_export_too_large', 413); + } } - } - const events = eventRows.map((row) => { - const eventId = boundedIdentifier(row.event_id); - const attempts = selectAttempts.all(eventId).map(frozenAttempt); - const recoveries = selectRecoveries.all(eventId).map(frozenRecovery); - return frozenEvent(row, attempts, recoveries); - }); + const events = eventRows.map((row) => { + const eventId = boundedIdentifier(row.event_id); + const attempts = selectAttempts.all(eventId).map(frozenAttempt); + const recoveries = selectRecoveries.all(eventId).map(frozenRecovery); + return frozenEvent(row, attempts, recoveries); + }); - return Object.freeze({ - schemaVersion: SCHEMA_VERSION, - organizationId: tenantId, - events: Object.freeze(events), + return Object.freeze({ + schemaVersion: SCHEMA_VERSION, + organizationId: tenantId, + events: Object.freeze(events), + }); }); }, }); From 61b8a26c3a06891e4beadc5b97d4fdc785628781 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 15:45:06 -0700 Subject: [PATCH 31/37] test(billing): preserve queued evidence before worker seeding --- ...iliation-evidence-export-snapshot.test.mjs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs b/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs index da1c5cca..2459406e 100644 --- a/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs +++ b/tests/unit/stripe-reconciliation-evidence-export-snapshot.test.mjs @@ -180,9 +180,51 @@ try { [{ eventId: 'evt_snapshot', processingState: 'processing', attemptCount: 1, attempts: 1 }], 'a later export observes the committed reconciliation state after the snapshot is released', ); + + writer.exec(` + INSERT INTO billing_stripe_webhook_events( + event_id, provider_created_at_sec, event_type, object_id, object_type, + api_version, request_id, payload_sha256, first_received_at_ms + ) VALUES( + 'evt_queued_unseeded', 1787000100, 'customer.subscription.updated', + 'sub_snapshot', 'subscription', '2025-03-31.basil', 'req_queued_unseeded', + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', 6000 + ); + INSERT INTO billing_stripe_reconciliation_triggers( + event_id, subscription_id, queued_at_ms, processing_state + ) VALUES('evt_queued_unseeded', 'sub_snapshot', 6100, 'pending'); + `); + + const queuedBeforeWorkerSeed = repository.exportTenantEvidence({ organizationId: 1, limit: 10 }); + assert.deepEqual( + queuedBeforeWorkerSeed.events.map((event) => ({ + eventId: event.eventId, + processingState: event.processingState, + attemptCount: event.attemptCount, + nextAttemptAtMs: event.nextAttemptAtMs, + attempts: event.attempts.length, + })), + [ + { + eventId: 'evt_queued_unseeded', + processingState: 'pending', + attemptCount: 0, + nextAttemptAtMs: 6100, + attempts: 0, + }, + { + eventId: 'evt_snapshot', + processingState: 'processing', + attemptCount: 1, + nextAttemptAtMs: 1200, + attempts: 1, + }, + ], + 'durable queued evidence must remain visible before the polling worker seeds its job row', + ); } finally { try { writer?.close(); } catch {} try { reader?.close(); } catch {} try { bootstrap?.close(); } catch {} rmSync(tempDirectory, { recursive: true, force: true }); -} +} \ No newline at end of file From 99de86bb09c4bbe013d62e66490adc9013b650f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 15:47:39 -0700 Subject: [PATCH 32/37] fix(billing): export queued reconciliation evidence before worker seeding --- .../stripe_reconciliation_evidence_export.mjs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/server/stripe_reconciliation_evidence_export.mjs b/server/stripe_reconciliation_evidence_export.mjs index 16107fd8..bca912ce 100644 --- a/server/stripe_reconciliation_evidence_export.mjs +++ b/server/stripe_reconciliation_evidence_export.mjs @@ -315,9 +315,10 @@ function withReadSnapshot(database, operation) { * webhook payloads, provider credentials, active lease-token hashes, or free-form * recovery evidence text. Operator evidence remains correlatable through SHA-256. * Event selection is capped at 100 and the combined nested attempt/recovery history - * is capped at 1,000 rows before those histories are materialized. Every exported - * document is read from one SQLite snapshot so the row cap and lifecycle evidence - * cannot be invalidated by a concurrent reconciliation commit. + * is capped at 1,000 rows before those histories are materialized. Durable triggers + * remain visible immediately even before the polling worker lazily seeds a job row. + * Every exported document is read from one SQLite snapshot so the row cap and + * lifecycle evidence cannot be invalidated by a concurrent reconciliation commit. * * @param {import('node:sqlite').DatabaseSync} database bootstrapped SQLite database * @returns {{exportTenantEvidence(input:{organizationId:number,limit?:number}):Readonly}} @@ -330,28 +331,28 @@ export function createSqliteStripeReconciliationEvidenceExportRepository(databas const selectEvents = database.prepare(` SELECT - j.event_id, + t.event_id, t.subscription_id, e.event_type, e.provider_created_at_sec, e.payload_sha256, e.first_received_at_ms, t.queued_at_ms, - j.processing_state, - j.attempt_count, - j.next_attempt_at_ms, + COALESCE(j.processing_state, 'pending') AS processing_state, + COALESCE(j.attempt_count, 0) AS attempt_count, + COALESCE(j.next_attempt_at_ms, t.queued_at_ms) AS next_attempt_at_ms, j.lease_token_sha256, j.lease_expires_at_ms, j.completed_at_ms, j.last_error_code, j.claim_decision_id - FROM billing_stripe_reconciliation_jobs AS j - JOIN billing_stripe_reconciliation_triggers AS t ON t.event_id = j.event_id - JOIN billing_stripe_webhook_events AS e ON e.event_id = j.event_id + FROM billing_stripe_reconciliation_triggers AS t + JOIN billing_stripe_webhook_events AS e ON e.event_id = t.event_id JOIN billing_stripe_subscriptions AS s ON s.subscription_id = t.subscription_id JOIN billing_stripe_customers AS c ON c.customer_id = s.customer_id + LEFT JOIN billing_stripe_reconciliation_jobs AS j ON j.event_id = t.event_id WHERE c.organization_id = ? - ORDER BY t.queued_at_ms DESC, j.event_id DESC + ORDER BY t.queued_at_ms DESC, t.event_id DESC LIMIT ? `); const countNestedEvidence = database.prepare(` @@ -415,4 +416,4 @@ export function createSqliteStripeReconciliationEvidenceExportRepository(databas }); }, }); -} +} \ No newline at end of file From 4ebaea08d44022f2d39d9ddae58b7660feb584e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 15:50:01 -0700 Subject: [PATCH 33/37] test(billing): require auditable evidence exports --- ...pe-reconciliation-evidence-export.test.mjs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/api/stripe-reconciliation-evidence-export.test.mjs b/tests/api/stripe-reconciliation-evidence-export.test.mjs index 3d26e884..deba94ac 100644 --- a/tests/api/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/api/stripe-reconciliation-evidence-export.test.mjs @@ -138,6 +138,13 @@ response = await request(`${ownerPath}?limit=0`, { assert.equal(response.status, 400, 'invalid export bounds fail closed'); assert.deepEqual(await response.json(), { error: 'stripe_reconciliation_evidence_export_invalid' }); +assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'billing.reconciliation.evidence_export'") + .get().count, + 0, + 'denied and invalid requests do not create successful-export audit records', +); + response = await request(ownerPath, { headers: { authorization: `Bearer ${owner.token}` }, }); @@ -159,6 +166,25 @@ assert.equal( createHash('sha256').update('INC-PRIVATE-EXPORT-OWNER', 'utf8').digest('hex'), ); +const ownerAudit = db.prepare(` + SELECT org_id, user_id, target_type, target_id, meta + FROM audit_log + WHERE action = 'billing.reconciliation.evidence_export' + AND org_id = ? AND user_id = ? +`).get(owner.organizationId, owner.userId); +assert.ok(ownerAudit, 'each successful evidence export is durably access-logged'); +assert.equal(ownerAudit.target_type, 'organization'); +assert.equal(ownerAudit.target_id, String(owner.organizationId)); +assert.deepEqual(JSON.parse(ownerAudit.meta), { + schemaVersion: 'scopeweave.stripe-reconciliation-evidence/v1', + eventCount: 1, +}); +assert.equal( + JSON.stringify(ownerAudit).includes('INC-PRIVATE-EXPORT-OWNER'), + false, + 'export audit metadata never copies private recovery evidence text', +); + response = await request( `/api/orgs/${member.organizationId}/billing/reconciliation/evidence`, { headers: { authorization: `Bearer ${member.token}` } }, @@ -167,5 +193,20 @@ assert.equal(response.status, 200, 'a second tenant owner can export only its ow const memberReport = await response.json(); assert.deepEqual(memberReport.events.map((event) => event.eventId), ['evt_export_foreign']); assert.equal(JSON.stringify(memberReport).includes('evt_export_owner'), false); +assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'billing.reconciliation.evidence_export'") + .get().count, + 2, + 'successful exports are independently logged for each tenant actor', +); + +db.exec('DROP TABLE audit_log'); +response = await request(ownerPath, { + headers: { authorization: `Bearer ${owner.token}` }, +}); +assert.equal(response.status, 500, 'evidence disclosure fails closed when its audit sink is unavailable'); +assert.deepEqual(await response.json(), { + error: 'stripe_reconciliation_evidence_export_audit_failed', +}); -db.close(); \ No newline at end of file +db.close(); From 43bec3606d1f5a3624ca09b7cd762f824a83e93e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 15:50:51 -0700 Subject: [PATCH 34/37] fix(billing): audit evidence exports before disclosure --- .../stripe_reconciliation_recovery_routes.mjs | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/server/stripe_reconciliation_recovery_routes.mjs b/server/stripe_reconciliation_recovery_routes.mjs index 295bdfa6..88558957 100644 --- a/server/stripe_reconciliation_recovery_routes.mjs +++ b/server/stripe_reconciliation_recovery_routes.mjs @@ -81,6 +81,30 @@ function evidenceExportFailure(c, error) { ); } +function auditEvidenceExport(organizationId, actorUserId, report) { + try { + db.prepare(` + INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) + VALUES(?,?,?,?,?,?) + `).run( + organizationId, + actorUserId, + 'billing.reconciliation.evidence_export', + 'organization', + String(organizationId), + JSON.stringify({ + schemaVersion: report.schemaVersion, + eventCount: report.events.length, + }), + ); + } catch { + throw new StripeReconciliationEvidenceExportError( + 'stripe_reconciliation_evidence_export_audit_failed', + 500, + ); + } +} + function auditRecovery(organizationId, actorUserId, result) { try { db.prepare(` @@ -112,9 +136,11 @@ function auditRecovery(organizationId, actorUserId, result) { * The route graph deliberately exposes no lease token, provider secret, raw webhook * payload, or caller-selected Subscription identity. Owners/admins can export their * bounded reconciliation evidence, inspect their bounded backlog, and retry one exact - * verified Event using a durable evidence reference. Recovery JSON is capped at 4 KiB - * by Hono's body-limit middleware, which checks declared Content-Length and streamed - * bytes before the JSON parser can buffer an unbounded privileged request. + * verified Event using a durable evidence reference. Successful evidence disclosure + * is fail-closed on its durable access-audit write, without copying private recovery + * text into the audit metadata. Recovery JSON is capped at 4 KiB by Hono's body-limit + * middleware, which checks declared Content-Length and streamed bytes before the JSON + * parser can buffer an unbounded privileged request. */ export const stripeReconciliationRecoveryRoutes = new Hono(); @@ -137,6 +163,7 @@ stripeReconciliationRecoveryRoutes.get( organizationId, limit, }); + auditEvidenceExport(organizationId, actorUserId, report); return c.json(report, 200, EVIDENCE_EXPORT_DOWNLOAD_HEADERS); } catch (error) { return evidenceExportFailure(c, error); From 49d9010108fd63b757395bc10af4bd6367630d81 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:23:08 -0700 Subject: [PATCH 35/37] test(billing): bind evidence export audit to response bytes --- tests/api/stripe-reconciliation-evidence-export.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/api/stripe-reconciliation-evidence-export.test.mjs b/tests/api/stripe-reconciliation-evidence-export.test.mjs index deba94ac..9d072178 100644 --- a/tests/api/stripe-reconciliation-evidence-export.test.mjs +++ b/tests/api/stripe-reconciliation-evidence-export.test.mjs @@ -155,7 +155,11 @@ assert.equal( response.headers.get('content-disposition'), 'attachment; filename="scopeweave-stripe-reconciliation-evidence.json"', ); -const ownerReport = await response.json(); +const ownerReportBody = await response.text(); +const ownerReport = JSON.parse(ownerReportBody); +const ownerReportSha256 = createHash('sha256') + .update(ownerReportBody, 'utf8') + .digest('hex'); assert.equal(ownerReport.schemaVersion, 'scopeweave.stripe-reconciliation-evidence/v1'); assert.equal(ownerReport.organizationId, owner.organizationId); assert.deepEqual(ownerReport.events.map((event) => event.eventId), ['evt_export_owner']); @@ -178,6 +182,7 @@ assert.equal(ownerAudit.target_id, String(owner.organizationId)); assert.deepEqual(JSON.parse(ownerAudit.meta), { schemaVersion: 'scopeweave.stripe-reconciliation-evidence/v1', eventCount: 1, + evidenceDocumentSha256: ownerReportSha256, }); assert.equal( JSON.stringify(ownerAudit).includes('INC-PRIVATE-EXPORT-OWNER'), From ab8692718dd1c40c1fd2c4c4551eff66a2fa8390 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:24:34 -0700 Subject: [PATCH 36/37] fix(billing): bind evidence audits to exported bytes --- .../stripe_reconciliation_recovery_routes.mjs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/server/stripe_reconciliation_recovery_routes.mjs b/server/stripe_reconciliation_recovery_routes.mjs index 88558957..58dfc294 100644 --- a/server/stripe_reconciliation_recovery_routes.mjs +++ b/server/stripe_reconciliation_recovery_routes.mjs @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import { Hono } from 'hono'; import { bodyLimit } from 'hono/body-limit'; @@ -21,6 +23,7 @@ const EVIDENCE_EXPORT_HEADERS = Object.freeze({ const EVIDENCE_EXPORT_DOWNLOAD_HEADERS = Object.freeze({ ...EVIDENCE_EXPORT_HEADERS, 'Content-Disposition': 'attachment; filename="scopeweave-stripe-reconciliation-evidence.json"', + 'Content-Type': 'application/json; charset=UTF-8', }); const stripeReconciliationEvidenceExports = createSqliteStripeReconciliationEvidenceExportRepository(db); @@ -81,8 +84,11 @@ function evidenceExportFailure(c, error) { ); } -function auditEvidenceExport(organizationId, actorUserId, report) { +function auditEvidenceExport(organizationId, actorUserId, report, evidenceDocument) { try { + const evidenceDocumentSha256 = createHash('sha256') + .update(evidenceDocument, 'utf8') + .digest('hex'); db.prepare(` INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?) @@ -95,6 +101,7 @@ function auditEvidenceExport(organizationId, actorUserId, report) { JSON.stringify({ schemaVersion: report.schemaVersion, eventCount: report.events.length, + evidenceDocumentSha256, }), ); } catch { @@ -137,10 +144,11 @@ function auditRecovery(organizationId, actorUserId, result) { * payload, or caller-selected Subscription identity. Owners/admins can export their * bounded reconciliation evidence, inspect their bounded backlog, and retry one exact * verified Event using a durable evidence reference. Successful evidence disclosure - * is fail-closed on its durable access-audit write, without copying private recovery - * text into the audit metadata. Recovery JSON is capped at 4 KiB by Hono's body-limit - * middleware, which checks declared Content-Length and streamed bytes before the JSON - * parser can buffer an unbounded privileged request. + * is fail-closed on its durable access-audit write, which binds the audit record to the + * exact exported JSON bytes by SHA-256 without copying private recovery text. Recovery + * JSON is capped at 4 KiB by Hono's body-limit middleware, which checks declared + * Content-Length and streamed bytes before the JSON parser can buffer an unbounded + * privileged request. */ export const stripeReconciliationRecoveryRoutes = new Hono(); @@ -163,8 +171,9 @@ stripeReconciliationRecoveryRoutes.get( organizationId, limit, }); - auditEvidenceExport(organizationId, actorUserId, report); - return c.json(report, 200, EVIDENCE_EXPORT_DOWNLOAD_HEADERS); + const evidenceDocument = JSON.stringify(report); + auditEvidenceExport(organizationId, actorUserId, report, evidenceDocument); + return c.body(evidenceDocument, 200, EVIDENCE_EXPORT_DOWNLOAD_HEADERS); } catch (error) { return evidenceExportFailure(c, error); } From 08d89b004c66e0f0bc57c40d2fcd77965a865213 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 11:26:41 -0700 Subject: [PATCH 37/37] docs(billing): trace exported evidence digest --- .../stripe-reconciliation-evidence-export.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/stripe-reconciliation-evidence-export.md b/docs/doctoring/stripe-reconciliation-evidence-export.md index dc4a86ec..000b5e27 100644 --- a/docs/doctoring/stripe-reconciliation-evidence-export.md +++ b/docs/doctoring/stripe-reconciliation-evidence-export.md @@ -12,7 +12,9 @@ The export derives tenant authority only through the persisted `billing_stripe_s The exported document contains bounded reconciliation facts needed to understand what ScopeWeave observed and attempted: Stripe Event and Subscription identifiers, Event type and provider creation time, the stored payload SHA-256, local receipt/queue/completion timing, processing state, attempt history, recovery history, stable error codes, and entitlement claim-decision linkage when present. -The export does **not** include raw webhook payloads, Stripe credentials, active worker lease-token hashes, or plaintext operator recovery references. A recovery reference is exported only as a SHA-256 correlation value so an authorized operator who already possesses the reference can compare it without copying the free-form reference into a portable artifact. The response uses `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, and an attachment disposition. +The export does **not** include raw webhook payloads, Stripe credentials, active worker lease-token hashes, or plaintext operator recovery references. A recovery reference is exported only as a SHA-256 correlation value so an authorized operator who already possesses the reference can compare it without copying the free-form reference into a portable artifact. The response uses `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, a JSON content type, and an attachment disposition. + +Every successful disclosure is durably access-logged before response bytes are released. ScopeWeave serializes the report once, records the document's SHA-256 as `evidenceDocumentSha256` together with its schema version and Event count, and then returns those exact serialized bytes. This binds the audit event to the specific portable artifact without copying its private contents into the audit stream. If the audit write fails, disclosure fails closed instead of returning an unaudited evidence document. These choices implement purpose-bound access and data minimization rather than indiscriminate masking. NIST Privacy Framework Control-P calls for managing data at sufficient granularity to manage privacy risk, and CT.DM-P8 specifically connects audit/log records with data minimization. The export therefore keeps decision-relevant provenance while excluding unrelated secret or free-form payload material. @@ -22,7 +24,9 @@ These choices implement purpose-bound access and data minimization rather than i - Event selection is tenant-scoped and bounded before nested history is loaded. - The combined selected attempt/recovery history is capped at 1,000 rows before materialization; larger exports fail closed with HTTP-compatible `413` and `stripe_reconciliation_evidence_export_too_large`. - Persisted identifiers, hashes, times, states, outcomes, and stable error codes are revalidated during export. Corrupt or contradictory evidence fails closed instead of being serialized as authoritative audit evidence. +- The selected Event rows and their nested attempt/recovery histories are read under one SQLite snapshot so concurrent worker progress cannot create a mixed-time artifact. - An unknown tenant identifier produces no cross-tenant evidence. The HTTP route separately hides workspace membership with the existing not-found boundary. +- A successful response is emitted only after its audit event has been persisted; an unavailable audit sink returns `stripe_reconciliation_evidence_export_audit_failed` and no evidence document. ## Why immutable Event evidence is retained @@ -30,9 +34,11 @@ Stripe documents that webhook deliveries can be duplicated, retried, and deliver ## Acceptance evidence -The implementation was developed test-first on the existing #488 stack. RED evidence first proved the repository module was absent and then proved the authenticated HTTP route was absent. The production implementation and route subsequently passed the repository's normal unit/API suite, browser cloud E2E, dependency review, and OSV scan on the PR-associated synthetic merge revision. Those successful runs are useful behavioral evidence but are **not** exact-head merge authorization because the current Server Tests workflow checks GitHub's `pull//merge` revision. Exact-head control remains owned by the repository-wide CI repair path (#523), and absent CodeQL/SAST/security/coverage/review gates remain non-passing until regenerated under live governance. +The implementation was developed test-first on the existing #488 stack. RED evidence first proved the repository module was absent and then proved the authenticated HTTP route was absent. Later realistic regressions cover recovery/attempt linkage, append-only history completeness, contradictory job/attempt lifecycle state, lease consistency, one-snapshot concurrent reads, nested-evidence bounds, audit-before-disclosure, and exact exported-document digest binding. + +The repository's normal unit/API suite, browser cloud E2E, dependency review, and OSV scan have produced useful behavioral evidence on PR-associated revisions, but synthetic pull-request merge checkouts are **not** exact-head merge authorization. Exact-head Server Tests control remains owned by the repository-wide CI repair path (#523), and centrally reusable SAST/Security exact-head attestation remains on its separate organization-owned repair path. Current-head evidence must be regenerated after every contributor-head change; queued, cancelled, predecessor, synthetic-only, absent, or model-only results are non-passing. -The new production module is explicitly included by `test:coverage`, and its unit regression plus authenticated API regression are registered in the canonical test scripts. No release or certification claim is made by this slice. +The production export module and route are explicitly included by the canonical coverage contract, and the unit/API regressions are registered in the canonical test scripts. No release or certification claim is made by this slice. ## References