From 86dd2ddd2a68e38538c0ca8fbd08d5eb3dba54ab Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 09:08:37 +0200 Subject: [PATCH 01/11] Add billing receipt privacy guard --- billing-receipt-privacy-guard/README.md | 23 ++ .../acceptance-notes.md | 21 ++ billing-receipt-privacy-guard/demo.js | 76 +++++ billing-receipt-privacy-guard/index.js | 278 ++++++++++++++++++ .../make-demo-video.py | 62 ++++ billing-receipt-privacy-guard/package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 41982 bytes .../reports/receipt-privacy-packet.json | 129 ++++++++ .../reports/receipt-privacy-report.md | 25 ++ .../reports/summary.svg | 11 + .../requirements-map.md | 25 ++ billing-receipt-privacy-guard/test.js | 84 ++++++ 12 files changed, 747 insertions(+) create mode 100644 billing-receipt-privacy-guard/README.md create mode 100644 billing-receipt-privacy-guard/acceptance-notes.md create mode 100644 billing-receipt-privacy-guard/demo.js create mode 100644 billing-receipt-privacy-guard/index.js create mode 100644 billing-receipt-privacy-guard/make-demo-video.py create mode 100644 billing-receipt-privacy-guard/package.json create mode 100644 billing-receipt-privacy-guard/reports/demo.mp4 create mode 100644 billing-receipt-privacy-guard/reports/receipt-privacy-packet.json create mode 100644 billing-receipt-privacy-guard/reports/receipt-privacy-report.md create mode 100644 billing-receipt-privacy-guard/reports/summary.svg create mode 100644 billing-receipt-privacy-guard/requirements-map.md create mode 100644 billing-receipt-privacy-guard/test.js diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md new file mode 100644 index 00000000..751bebd9 --- /dev/null +++ b/billing-receipt-privacy-guard/README.md @@ -0,0 +1,23 @@ +# Billing Receipt Privacy Guard + +This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. + +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, and unsafe provider metadata. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement line items and deterministic audit evidence. + +## Run + +```bash +npm test +npm run demo +npm run video +npm run check +``` + +## Outputs + +- `reports/receipt-privacy-packet.json` +- `reports/receipt-privacy-report.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +All data is synthetic. The module does not call payment processors, customer systems, private workspaces, institutional finance tools, or external APIs. diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md new file mode 100644 index 00000000..8f0c92e8 --- /dev/null +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -0,0 +1,21 @@ +# Acceptance Notes + +This #20 slice focuses specifically on privacy-safe billing artifacts before invoices, receipts, and provider metadata leave SCIBASE. + +It is not: + +- a broad revenue infrastructure module +- a subscription entitlement or renewal guard +- a tax, procurement, or invoice-acceptance workflow +- a payment-rail failover or webhook entitlement verifier +- an analytics licensing export gate +- a revenue dispute, reconciliation, or credit-breakage ledger + +Validation coverage: + +- safe receipts are deliverable with only allowed provider metadata +- private research project context is removed from customer-facing receipt line items +- restricted dataset details are replaced with usage-category-safe wording +- unsafe provider metadata keys are removed before delivery +- customer copies retain useful totals, currency, usage categories, quantities, and units +- audit digests are deterministic and private-context free diff --git a/billing-receipt-privacy-guard/demo.js b/billing-receipt-privacy-guard/demo.js new file mode 100644 index 00000000..ba23a862 --- /dev/null +++ b/billing-receipt-privacy-guard/demo.js @@ -0,0 +1,76 @@ +const fs = require('fs'); +const path = require('path'); +const { evaluateReceiptPrivacy, buildSampleBatch } = require('./index'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const result = evaluateReceiptPrivacy(buildSampleBatch()); + +const packetPath = path.join(reportsDir, 'receipt-privacy-packet.json'); +const reportPath = path.join(reportsDir, 'receipt-privacy-report.md'); +const svgPath = path.join(reportsDir, 'summary.svg'); + +fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); + +const receipts = result.receipts + .map( + (receipt) => + `- ${receipt.id}: ${receipt.decision}, findings: ${ + receipt.findings.length > 0 ? receipt.findings.join(', ') : 'none' + }` + ) + .join('\n'); + +const actions = result.remediationActions + .map((action) => `- ${action.id}: ${action.action} (${action.priority})`) + .join('\n'); + +const markdown = `# Billing Receipt Privacy Guard + +Batch: ${result.batchId} +Generated: ${result.generatedAt} + +## Summary + +- Deliverable receipts: ${result.summary.deliverableReceipts} +- Held receipts: ${result.summary.heldReceipts} +- Remediation actions: ${result.summary.remediationActions} +- Total cents reviewed: ${result.summary.totalCentsReviewed} +- Audit digest: ${result.auditDigest} + +## Receipt Decisions + +${receipts} + +## Remediation Actions + +${actions} + +## Safety + +All fixtures are synthetic. The guard does not call payment processors, customer systems, private workspaces, institutional finance tools, or external APIs. +`; + +fs.writeFileSync(reportPath, markdown); + +const svg = ` + + + Billing Receipt Privacy Guard + Deliverable receipts: ${result.summary.deliverableReceipts} + Held receipts: ${result.summary.heldReceipts} + Remediation actions: ${result.summary.remediationActions} + Checks: line-item text, provider metadata, restricted datasets, collaborator identifiers + Private research context is replaced before receipts leave SCIBASE. + ${result.auditDigest} + +`; + +fs.writeFileSync(svgPath, svg); + +console.log(`Wrote ${path.relative(__dirname, packetPath)}`); +console.log(`Wrote ${path.relative(__dirname, reportPath)}`); +console.log(`Wrote ${path.relative(__dirname, svgPath)}`); +console.log(`Deliverable receipts: ${result.summary.deliverableReceipts}`); +console.log(`Held receipts: ${result.summary.heldReceipts}`); diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js new file mode 100644 index 00000000..5310ba0f --- /dev/null +++ b/billing-receipt-privacy-guard/index.js @@ -0,0 +1,278 @@ +const crypto = require('crypto'); + +const SAFE_METADATA_KEYS = new Set(['accountRef', 'billingPeriod', 'invoiceRef', 'plan']); + +const PRIVATE_PATTERNS = [ + { + id: 'private-research-context', + pattern: /(alzheimer|single-cell|patient cohort|clinical trial|embargoed|irb)/i + }, + { + id: 'restricted-dataset-reference', + pattern: /(gse-private|dbgap|controlled-access|restricted dataset|participant)/i + }, + { + id: 'collaborator-identifier', + pattern: /(@|orcid|collaborator|researcher handle)/i + }, + { + id: 'grant-sensitive-context', + pattern: /(grant confidential|sponsor confidential|unannounced award)/i + } +]; + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); +} + +function digest(value) { + return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`; +} + +function findingsForText(text) { + return PRIVATE_PATTERNS.filter((item) => item.pattern.test(text)).map((item) => item.id); +} + +function categoryDescription(lineItem) { + if (lineItem.usageCategory === 'ai-compute') { + return 'AI compute usage for restricted research workspace'; + } + + if (lineItem.usageCategory === 'storage') { + return 'Restricted dataset storage and processing'; + } + + if (lineItem.usageCategory === 'analytics-license') { + return 'Analytics licensing service'; + } + + return 'Research platform service'; +} + +function sanitizeLineItem(lineItem) { + const findings = findingsForText(`${lineItem.description} ${lineItem.projectRef || ''}`); + const description = findings.length > 0 ? categoryDescription(lineItem) : lineItem.description; + + return { + id: lineItem.id, + usageCategory: lineItem.usageCategory, + quantity: lineItem.quantity, + unit: lineItem.unit, + amountCents: lineItem.amountCents, + description, + findings + }; +} + +function sanitizeMetadata(metadata) { + const safe = {}; + const removedKeys = []; + const findings = []; + + for (const [key, value] of Object.entries(metadata)) { + if (!SAFE_METADATA_KEYS.has(key)) { + removedKeys.push(key); + findings.push('unsafe-provider-metadata'); + continue; + } + + const textFindings = findingsForText(String(value)); + if (textFindings.length > 0) { + removedKeys.push(key); + findings.push(...textFindings); + continue; + } + + safe[key] = value; + } + + return { + safe, + removedKeys: Array.from(new Set(removedKeys)).sort(), + findings: Array.from(new Set(findings)).sort() + }; +} + +function evaluateReceipt(receipt) { + const redactedLineItems = receipt.lineItems.map(sanitizeLineItem); + const lineFindings = redactedLineItems.flatMap((lineItem) => lineItem.findings); + const metadata = sanitizeMetadata(receipt.providerMetadata); + const findings = Array.from(new Set([...lineFindings, ...metadata.findings])).sort(); + const decision = findings.length > 0 ? 'hold-for-finance-review' : 'deliver-receipt'; + + const customerCopy = { + receiptId: receipt.id, + customerId: receipt.customerId, + currency: receipt.currency, + totalCents: receipt.totalCents, + lineItems: redactedLineItems.map((lineItem) => ({ + id: lineItem.id, + description: lineItem.description, + usageCategory: lineItem.usageCategory, + quantity: lineItem.quantity, + unit: lineItem.unit, + amountCents: lineItem.amountCents + })) + }; + + return { + id: receipt.id, + invoiceId: receipt.invoiceId, + customerId: receipt.customerId, + decision, + findings, + removedMetadataKeys: metadata.removedKeys, + providerMetadata: metadata.safe, + redactedLineItems: redactedLineItems.map((lineItem) => ({ + id: lineItem.id, + description: lineItem.description, + usageCategory: lineItem.usageCategory, + findings: lineItem.findings + })), + customerCopy, + auditDigest: digest({ + id: receipt.id, + invoiceId: receipt.invoiceId, + decision, + findings, + providerMetadata: metadata.safe, + customerCopy + }) + }; +} + +function remediationAction(receipt) { + if (receipt.findings.includes('unsafe-provider-metadata')) { + return 'replace-private-billing-fields-before-delivery'; + } + + if (receipt.findings.includes('restricted-dataset-reference')) { + return 'replace-restricted-dataset-detail-with-usage-category'; + } + + return 'redact-private-research-context'; +} + +function evaluateReceiptPrivacy(batch) { + const receipts = batch.receipts.map(evaluateReceipt); + const remediationActions = receipts + .filter((receipt) => receipt.decision === 'hold-for-finance-review') + .map((receipt) => ({ + id: `remediate-${receipt.id}`, + receiptId: receipt.id, + action: remediationAction(receipt), + priority: + receipt.findings.includes('restricted-dataset-reference') || + receipt.findings.includes('private-research-context') || + receipt.findings.includes('unsafe-provider-metadata') + ? 'high' + : 'normal', + findings: receipt.findings + })); + + const summary = { + deliverableReceipts: receipts.filter((receipt) => receipt.decision === 'deliver-receipt').length, + heldReceipts: receipts.filter((receipt) => receipt.decision === 'hold-for-finance-review').length, + remediationActions: remediationActions.length, + totalCentsReviewed: receipts.reduce((total, receipt) => total + receipt.customerCopy.totalCents, 0) + }; + + return { + batchId: batch.batchId, + generatedAt: batch.generatedAt, + receipts, + remediationActions, + summary, + auditDigest: digest({ + batchId: batch.batchId, + generatedAt: batch.generatedAt, + receipts, + remediationActions, + summary + }) + }; +} + +function buildSampleBatch() { + return { + batchId: 'billing-privacy-review-20', + generatedAt: '2026-05-28T09:00:00Z', + receipts: [ + { + id: 'receipt-safe-lab-plan', + invoiceId: 'inv-safe-lab-plan', + customerId: 'customer-lab-001', + currency: 'USD', + totalCents: 29900, + providerMetadata: { + accountRef: 'acct-lab-001', + billingPeriod: '2026-05', + invoiceRef: 'inv-safe-lab-plan', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-lab-plan', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 1, + unit: 'month', + amountCents: 29900 + } + ] + }, + { + id: 'receipt-private-compute', + invoiceId: 'inv-private-compute', + customerId: 'customer-lab-002', + currency: 'USD', + totalCents: 122500, + providerMetadata: { + accountRef: 'acct-lab-002', + billingPeriod: '2026-05', + invoiceRef: 'inv-private-compute', + plan: 'institution-compute', + projectTitle: 'Alzheimer single-cell pilot', + collaboratorHandle: '@lab-private-reviewer' + }, + lineItems: [ + { + id: 'line-private-compute', + description: 'GPU inference for Alzheimer single-cell pilot', + usageCategory: 'ai-compute', + quantity: 250, + unit: 'compute-hour', + amountCents: 87500, + projectRef: 'irb-workspace-44' + }, + { + id: 'line-private-dataset', + description: 'Storage for GSE-private controlled-access dataset', + usageCategory: 'storage', + quantity: 700, + unit: 'gb-month', + amountCents: 35000, + projectRef: 'restricted dataset locker' + } + ] + } + ] + }; +} + +module.exports = { + evaluateReceiptPrivacy, + buildSampleBatch, + digest +}; diff --git a/billing-receipt-privacy-guard/make-demo-video.py b/billing-receipt-privacy-guard/make-demo-video.py new file mode 100644 index 00000000..6fe5f89c --- /dev/null +++ b/billing-receipt-privacy-guard/make-demo-video.py @@ -0,0 +1,62 @@ +import os +import subprocess + + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPORTS = os.path.join(HERE, "reports") +FRAME = os.path.join(REPORTS, "demo-frame.png") +OUTPUT = os.path.join(REPORTS, "demo.mp4") +os.makedirs(REPORTS, exist_ok=True) + + +def draw_frame_with_pillow(): + from PIL import Image, ImageDraw, ImageFont + + image = Image.new("RGB", (1280, 720), "#102027") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((54, 58, 1226, 662), radius=16, fill="#17313a", outline="#9bd67a", width=4) + + try: + title_font = ImageFont.truetype("arial.ttf", 48) + body_font = ImageFont.truetype("arial.ttf", 30) + note_font = ImageFont.truetype("arial.ttf", 26) + except OSError: + title_font = ImageFont.load_default() + body_font = ImageFont.load_default() + note_font = ImageFont.load_default() + + draw.text((96, 102), "Billing Receipt Privacy Guard", fill="white", font=title_font) + draw.text((96, 190), "Safe receipts keep only allowed provider metadata", fill="#dff5d5", font=body_font) + draw.text((96, 248), "Private project and dataset details are redacted", fill="#dff5d5", font=body_font) + draw.text((96, 306), "Unsafe receipts are held for finance review", fill="#dff5d5", font=body_font) + draw.text((96, 402), "Synthetic data only. No payment, customer, or workspace systems are called.", fill="#ffd37a", font=note_font) + + image.save(FRAME) + + +draw_frame_with_pillow() + +cmd = [ + "ffmpeg", + "-y", + "-loop", + "1", + "-i", + FRAME, + "-t", + "4", + "-r", + "30", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + OUTPUT, +] + +subprocess.run(cmd, check=True) +if os.path.exists(FRAME): + os.remove(FRAME) +print(f"Wrote {os.path.relpath(OUTPUT, HERE)}") diff --git a/billing-receipt-privacy-guard/package.json b/billing-receipt-privacy-guard/package.json new file mode 100644 index 00000000..0ecb8c92 --- /dev/null +++ b/billing-receipt-privacy-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "billing-receipt-privacy-guard", + "version": "1.0.0", + "description": "Dependency-free billing receipt privacy guard for SCIBASE revenue infrastructure.", + "main": "index.js", + "private": true, + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "video": "python make-demo-video.py", + "check": "npm test && npm run demo && npm run video" + } +} diff --git a/billing-receipt-privacy-guard/reports/demo.mp4 b/billing-receipt-privacy-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..6d4225e81973a0dedf31f604e0cf83a3f8c8fc02 GIT binary patch literal 41982 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

#{ugp+y8)j4vQJ;0<%;zcENr+!u`=QkJF0nBq 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 ABLDyZ literal 0 HcmV?d00001 diff --git a/billing-receipt-privacy-guard/reports/receipt-privacy-packet.json b/billing-receipt-privacy-guard/reports/receipt-privacy-packet.json new file mode 100644 index 00000000..4c501531 --- /dev/null +++ b/billing-receipt-privacy-guard/reports/receipt-privacy-packet.json @@ -0,0 +1,129 @@ +{ + "batchId": "billing-privacy-review-20", + "generatedAt": "2026-05-28T09:00:00Z", + "receipts": [ + { + "id": "receipt-safe-lab-plan", + "invoiceId": "inv-safe-lab-plan", + "customerId": "customer-lab-001", + "decision": "deliver-receipt", + "findings": [], + "removedMetadataKeys": [], + "providerMetadata": { + "accountRef": "acct-lab-001", + "billingPeriod": "2026-05", + "invoiceRef": "inv-safe-lab-plan", + "plan": "lab-pro" + }, + "redactedLineItems": [ + { + "id": "line-lab-plan", + "description": "Lab Pro monthly subscription", + "usageCategory": "subscription", + "findings": [] + } + ], + "customerCopy": { + "receiptId": "receipt-safe-lab-plan", + "customerId": "customer-lab-001", + "currency": "USD", + "totalCents": 29900, + "lineItems": [ + { + "id": "line-lab-plan", + "description": "Lab Pro monthly subscription", + "usageCategory": "subscription", + "quantity": 1, + "unit": "month", + "amountCents": 29900 + } + ] + }, + "auditDigest": "sha256:920e09e8abf433174de17eca8ca6b7e437f397637f333da492ac02d09c226c29" + }, + { + "id": "receipt-private-compute", + "invoiceId": "inv-private-compute", + "customerId": "customer-lab-002", + "decision": "hold-for-finance-review", + "findings": [ + "private-research-context", + "restricted-dataset-reference", + "unsafe-provider-metadata" + ], + "removedMetadataKeys": [ + "collaboratorHandle", + "projectTitle" + ], + "providerMetadata": { + "accountRef": "acct-lab-002", + "billingPeriod": "2026-05", + "invoiceRef": "inv-private-compute", + "plan": "institution-compute" + }, + "redactedLineItems": [ + { + "id": "line-private-compute", + "description": "AI compute usage for restricted research workspace", + "usageCategory": "ai-compute", + "findings": [ + "private-research-context" + ] + }, + { + "id": "line-private-dataset", + "description": "Restricted dataset storage and processing", + "usageCategory": "storage", + "findings": [ + "restricted-dataset-reference" + ] + } + ], + "customerCopy": { + "receiptId": "receipt-private-compute", + "customerId": "customer-lab-002", + "currency": "USD", + "totalCents": 122500, + "lineItems": [ + { + "id": "line-private-compute", + "description": "AI compute usage for restricted research workspace", + "usageCategory": "ai-compute", + "quantity": 250, + "unit": "compute-hour", + "amountCents": 87500 + }, + { + "id": "line-private-dataset", + "description": "Restricted dataset storage and processing", + "usageCategory": "storage", + "quantity": 700, + "unit": "gb-month", + "amountCents": 35000 + } + ] + }, + "auditDigest": "sha256:49cd2b51f8a44d0cb4a058712ed3c2e348b3502acc07149e7f68e503b17ef5f8" + } + ], + "remediationActions": [ + { + "id": "remediate-receipt-private-compute", + "receiptId": "receipt-private-compute", + "action": "replace-private-billing-fields-before-delivery", + "priority": "high", + "findings": [ + "private-research-context", + "restricted-dataset-reference", + "unsafe-provider-metadata" + ] + } + ], + "summary": { + "deliverableReceipts": 1, + "heldReceipts": 1, + "remediationActions": 1, + "totalCentsReviewed": 152400 + }, + "auditDigest": "sha256:20bea339360007ff72444466bdcc632050a108e5d9a63ded5838c5cd951d4d63" +} diff --git a/billing-receipt-privacy-guard/reports/receipt-privacy-report.md b/billing-receipt-privacy-guard/reports/receipt-privacy-report.md new file mode 100644 index 00000000..b4515eb8 --- /dev/null +++ b/billing-receipt-privacy-guard/reports/receipt-privacy-report.md @@ -0,0 +1,25 @@ +# Billing Receipt Privacy Guard + +Batch: billing-privacy-review-20 +Generated: 2026-05-28T09:00:00Z + +## Summary + +- Deliverable receipts: 1 +- Held receipts: 1 +- Remediation actions: 1 +- Total cents reviewed: 152400 +- Audit digest: sha256:20bea339360007ff72444466bdcc632050a108e5d9a63ded5838c5cd951d4d63 + +## Receipt Decisions + +- receipt-safe-lab-plan: deliver-receipt, findings: none +- receipt-private-compute: hold-for-finance-review, findings: private-research-context, restricted-dataset-reference, unsafe-provider-metadata + +## Remediation Actions + +- remediate-receipt-private-compute: replace-private-billing-fields-before-delivery (high) + +## Safety + +All fixtures are synthetic. The guard does not call payment processors, customer systems, private workspaces, institutional finance tools, or external APIs. diff --git a/billing-receipt-privacy-guard/reports/summary.svg b/billing-receipt-privacy-guard/reports/summary.svg new file mode 100644 index 00000000..4db63698 --- /dev/null +++ b/billing-receipt-privacy-guard/reports/summary.svg @@ -0,0 +1,11 @@ + + + + Billing Receipt Privacy Guard + Deliverable receipts: 1 + Held receipts: 1 + Remediation actions: 1 + Checks: line-item text, provider metadata, restricted datasets, collaborator identifiers + Private research context is replaced before receipts leave SCIBASE. + sha256:20bea339360007ff72444466bdcc632050a108e5d9a63ded5838c5cd951d4d63 + diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md new file mode 100644 index 00000000..ccfd76b1 --- /dev/null +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -0,0 +1,25 @@ +# Requirements Map + +## Tiered Subscription Billing + +- Keeps safe subscription receipts deliverable with a provider-metadata allowlist. +- Removes project titles, collaborator handles, and private research descriptors from receipt metadata. +- Preserves customer-useful totals, billing period, plan, and invoice references after redaction. + +## AI Compute Billing + +- Detects private compute line items that expose restricted research project context. +- Replaces specific project descriptions with usage-category-safe customer copy. +- Holds unsafe receipts before external delivery when compute or storage lines contain restricted details. + +## Licensing APIs And Analytics + +- Treats analytics licensing as a billable service category without exposing private corpus details. +- Produces deterministic audit packets for finance and compliance review. +- Keeps receipt evidence synthetic and independent of live customer or payment systems. + +## Safety And Scope + +- Synthetic data only. +- No credentials, payment processor calls, customer systems, private workspaces, institutional finance tools, or external APIs. +- This slice is distinct from pricing, tax, disputes, payment rails, webhook entitlement, invoice acceptance, procurement, subscription renewal, usage reconciliation, storage overage, and analytics licensing gates. diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js new file mode 100644 index 00000000..65fbcd42 --- /dev/null +++ b/billing-receipt-privacy-guard/test.js @@ -0,0 +1,84 @@ +const assert = require('assert'); +const { + evaluateReceiptPrivacy, + buildSampleBatch +} = require('./index'); + +function byId(items, id) { + return items.find((item) => item.id === id); +} + +function testSafeReceiptIsDeliverableWithOnlyAllowedMetadata() { + const result = evaluateReceiptPrivacy(buildSampleBatch()); + const receipt = byId(result.receipts, 'receipt-safe-lab-plan'); + + assert.equal(receipt.decision, 'deliver-receipt'); + assert.equal(receipt.findings.length, 0); + assert.deepEqual(receipt.providerMetadata, { + accountRef: 'acct-lab-001', + billingPeriod: '2026-05', + invoiceRef: 'inv-safe-lab-plan', + plan: 'lab-pro' + }); +} + +function testPrivateResearchContextIsRedactedBeforeReceiptDelivery() { + const result = evaluateReceiptPrivacy(buildSampleBatch()); + const receipt = byId(result.receipts, 'receipt-private-compute'); + + assert.equal(receipt.decision, 'hold-for-finance-review'); + assert.equal(receipt.findings.includes('private-research-context'), true); + assert.equal(receipt.findings.includes('restricted-dataset-reference'), true); + assert.equal(receipt.redactedLineItems[0].description, 'AI compute usage for restricted research workspace'); + assert.equal(receipt.redactedLineItems[1].description, 'Restricted dataset storage and processing'); +} + +function testProviderMetadataAllowlistBlocksOverSpecificFields() { + const result = evaluateReceiptPrivacy(buildSampleBatch()); + const receipt = byId(result.receipts, 'receipt-private-compute'); + + assert.equal(receipt.findings.includes('unsafe-provider-metadata'), true); + assert.equal(Object.prototype.hasOwnProperty.call(receipt.providerMetadata, 'projectTitle'), false); + assert.equal(Object.prototype.hasOwnProperty.call(receipt.providerMetadata, 'collaboratorHandle'), false); + + const action = byId(result.remediationActions, 'remediate-receipt-private-compute'); + assert.equal(action.action, 'replace-private-billing-fields-before-delivery'); + assert.equal(action.priority, 'high'); +} + +function testCustomerCopyRemainsUsefulAfterRedaction() { + const result = evaluateReceiptPrivacy(buildSampleBatch()); + const receipt = byId(result.receipts, 'receipt-private-compute'); + + assert.equal(receipt.customerCopy.totalCents, 122500); + assert.equal(receipt.customerCopy.currency, 'USD'); + assert.equal(receipt.customerCopy.lineItems.length, 2); + assert.equal(receipt.customerCopy.lineItems[0].usageCategory, 'ai-compute'); + assert.equal(receipt.customerCopy.lineItems[1].usageCategory, 'storage'); +} + +function testAuditDigestIsDeterministicAndPrivateFree() { + const first = evaluateReceiptPrivacy(buildSampleBatch()); + const second = evaluateReceiptPrivacy(buildSampleBatch()); + + assert.equal(first.auditDigest, second.auditDigest); + assert.ok(first.auditDigest.startsWith('sha256:')); + assert.equal(first.summary.deliverableReceipts, 1); + assert.equal(first.summary.heldReceipts, 1); + assert.equal(JSON.stringify(first).includes('Alzheimer'), false); + assert.equal(JSON.stringify(first).includes('GSE-private'), false); +} + +const tests = [ + testSafeReceiptIsDeliverableWithOnlyAllowedMetadata, + testPrivateResearchContextIsRedactedBeforeReceiptDelivery, + testProviderMetadataAllowlistBlocksOverSpecificFields, + testCustomerCopyRemainsUsefulAfterRedaction, + testAuditDigestIsDeterministicAndPrivateFree +]; + +for (const test of tests) { + test(); +} + +console.log(`${tests.length} billing receipt privacy guard tests passed`); From 094ae6edb71c56d12a94c1d2c3b1472f85e77c17 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Thu, 28 May 2026 17:00:03 +0200 Subject: [PATCH 02/11] Harden billing receipt metadata scanning --- billing-receipt-privacy-guard/README.md | 2 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/index.js | 10 ++++- .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 40 +++++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index 751bebd9..0251e94c 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, and unsafe provider metadata. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement line items and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, and unsafe provider metadata, including nested provider metadata values. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement line items and deterministic audit evidence. ## Run diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index 8f0c92e8..128dabeb 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -17,5 +17,6 @@ Validation coverage: - private research project context is removed from customer-facing receipt line items - restricted dataset details are replaced with usage-category-safe wording - unsafe provider metadata keys are removed before delivery +- allowlisted provider metadata keys are still scanned when values are structured or nested - customer copies retain useful totals, currency, usage categories, quantities, and units - audit digests are deterministic and private-context free diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index 5310ba0f..14853651 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -87,7 +87,7 @@ function sanitizeMetadata(metadata) { continue; } - const textFindings = findingsForText(String(value)); + const textFindings = findingsForText(metadataValueText(value)); if (textFindings.length > 0) { removedKeys.push(key); findings.push(...textFindings); @@ -104,6 +104,14 @@ function sanitizeMetadata(metadata) { }; } +function metadataValueText(value) { + if (value && typeof value === 'object') { + return stableStringify(value); + } + + return String(value); +} + function evaluateReceipt(receipt) { const redactedLineItems = receipt.lineItems.map(sanitizeLineItem); const lineFindings = redactedLineItems.flatMap((lineItem) => lineItem.findings); diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index ccfd76b1..4417b38e 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -4,6 +4,7 @@ - Keeps safe subscription receipts deliverable with a provider-metadata allowlist. - Removes project titles, collaborator handles, and private research descriptors from receipt metadata. +- Scans nested provider metadata values so allowlisted keys cannot hide private workspace context. - Preserves customer-useful totals, billing period, plan, and invoice references after redaction. ## AI Compute Billing diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index 65fbcd42..87d1b648 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -46,6 +46,45 @@ function testProviderMetadataAllowlistBlocksOverSpecificFields() { assert.equal(action.priority, 'high'); } +function testNestedAllowedMetadataStillScansPrivateContext() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-nested-metadata', + invoiceId: 'inv-nested-metadata', + customerId: 'customer-lab-003', + currency: 'USD', + totalCents: 45000, + providerMetadata: { + accountRef: { + workspace: 'IRB Alzheimer single-cell workspace', + billingId: 'acct-lab-003' + }, + billingPeriod: '2026-05', + invoiceRef: 'inv-nested-metadata', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-safe-subscription', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 1, + unit: 'month', + amountCents: 45000 + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = byId(result.receipts, 'receipt-nested-metadata'); + + assert.equal(receipt.decision, 'hold-for-finance-review'); + assert.equal(receipt.findings.includes('private-research-context'), true); + assert.equal(Object.prototype.hasOwnProperty.call(receipt.providerMetadata, 'accountRef'), false); +} + function testCustomerCopyRemainsUsefulAfterRedaction() { const result = evaluateReceiptPrivacy(buildSampleBatch()); const receipt = byId(result.receipts, 'receipt-private-compute'); @@ -73,6 +112,7 @@ const tests = [ testSafeReceiptIsDeliverableWithOnlyAllowedMetadata, testPrivateResearchContextIsRedactedBeforeReceiptDelivery, testProviderMetadataAllowlistBlocksOverSpecificFields, + testNestedAllowedMetadataStillScansPrivateContext, testCustomerCopyRemainsUsefulAfterRedaction, testAuditDigestIsDeterministicAndPrivateFree ]; From 25e1c089c6cb02ae48b579aaa73c3464ca45a541 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 17:39:28 +0200 Subject: [PATCH 03/11] Harden receipt line item privacy --- billing-receipt-privacy-guard/README.md | 2 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/index.js | 41 ++++++++++++++++--- .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 41 +++++++++++++++++++ 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index 0251e94c..9fe93299 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, and unsafe provider metadata, including nested provider metadata values. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement line items and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement line items and deterministic audit evidence. ## Run diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index 128dabeb..f055ac46 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -16,6 +16,7 @@ Validation coverage: - safe receipts are deliverable with only allowed provider metadata - private research project context is removed from customer-facing receipt line items - restricted dataset details are replaced with usage-category-safe wording +- customer-facing line-item identifiers and units are redacted when they contain restricted dataset context - unsafe provider metadata keys are removed before delivery - allowlisted provider metadata keys are still scanned when values are structured or nested - customer copies retain useful totals, currency, usage categories, quantities, and units diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index 14853651..e62ff5b7 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -44,6 +44,30 @@ function findingsForText(text) { return PRIVATE_PATTERNS.filter((item) => item.pattern.test(text)).map((item) => item.id); } +function privacyText(value) { + if (value === undefined || value === null) { + return ''; + } + + return metadataValueText(value); +} + +function hasPrivateContext(value) { + return findingsForText(privacyText(value)).length > 0; +} + +function lineItemPrivacyFindings(lineItem) { + return findingsForText( + stableStringify({ + id: lineItem.id, + description: lineItem.description, + projectRef: lineItem.projectRef, + usageCategory: lineItem.usageCategory, + unit: lineItem.unit + }) + ); +} + function categoryDescription(lineItem) { if (lineItem.usageCategory === 'ai-compute') { return 'AI compute usage for restricted research workspace'; @@ -60,15 +84,20 @@ function categoryDescription(lineItem) { return 'Research platform service'; } -function sanitizeLineItem(lineItem) { - const findings = findingsForText(`${lineItem.description} ${lineItem.projectRef || ''}`); +function sanitizeLineItem(lineItem, index) { + const findings = lineItemPrivacyFindings(lineItem); const description = findings.length > 0 ? categoryDescription(lineItem) : lineItem.description; + const id = hasPrivateContext(lineItem.id) ? `line-redacted-${index + 1}` : lineItem.id; + const usageCategory = hasPrivateContext(lineItem.usageCategory) + ? 'research-platform-service' + : lineItem.usageCategory; + const unit = hasPrivateContext(lineItem.unit) ? 'usage-unit' : lineItem.unit; return { - id: lineItem.id, - usageCategory: lineItem.usageCategory, + id, + usageCategory, quantity: lineItem.quantity, - unit: lineItem.unit, + unit, amountCents: lineItem.amountCents, description, findings @@ -113,7 +142,7 @@ function metadataValueText(value) { } function evaluateReceipt(receipt) { - const redactedLineItems = receipt.lineItems.map(sanitizeLineItem); + const redactedLineItems = receipt.lineItems.map((lineItem, index) => sanitizeLineItem(lineItem, index)); const lineFindings = redactedLineItems.flatMap((lineItem) => lineItem.findings); const metadata = sanitizeMetadata(receipt.providerMetadata); const findings = Array.from(new Set([...lineFindings, ...metadata.findings])).sort(); diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index 4417b38e..9aa2a0d4 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -5,6 +5,7 @@ - Keeps safe subscription receipts deliverable with a provider-metadata allowlist. - Removes project titles, collaborator handles, and private research descriptors from receipt metadata. - Scans nested provider metadata values so allowlisted keys cannot hide private workspace context. +- Redacts customer-facing line-item identifiers and units when they carry restricted dataset context. - Preserves customer-useful totals, billing period, plan, and invoice references after redaction. ## AI Compute Billing diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index 87d1b648..b793e721 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -85,6 +85,46 @@ function testNestedAllowedMetadataStillScansPrivateContext() { assert.equal(Object.prototype.hasOwnProperty.call(receipt.providerMetadata, 'accountRef'), false); } +function testCustomerFacingLineItemFieldsAreRedacted() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-line-field-leak', + invoiceId: 'inv-line-field-leak', + customerId: 'customer-lab-004', + currency: 'USD', + totalCents: 28000, + providerMetadata: { + accountRef: 'acct-lab-004', + billingPeriod: '2026-05', + invoiceRef: 'inv-line-field-leak', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-GSE-private-cohort-storage', + description: 'Storage usage', + usageCategory: 'storage', + quantity: 80, + unit: 'GSE-private gb-month', + amountCents: 28000 + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = byId(result.receipts, 'receipt-line-field-leak'); + const lineItem = receipt.customerCopy.lineItems[0]; + + assert.equal(receipt.decision, 'hold-for-finance-review'); + assert.equal(receipt.findings.includes('restricted-dataset-reference'), true); + assert.equal(lineItem.id, 'line-redacted-1'); + assert.equal(lineItem.unit, 'usage-unit'); + assert.equal(lineItem.description, 'Restricted dataset storage and processing'); + assert.equal(JSON.stringify(receipt).includes('GSE-private'), false); +} + function testCustomerCopyRemainsUsefulAfterRedaction() { const result = evaluateReceiptPrivacy(buildSampleBatch()); const receipt = byId(result.receipts, 'receipt-private-compute'); @@ -113,6 +153,7 @@ const tests = [ testPrivateResearchContextIsRedactedBeforeReceiptDelivery, testProviderMetadataAllowlistBlocksOverSpecificFields, testNestedAllowedMetadataStillScansPrivateContext, + testCustomerFacingLineItemFieldsAreRedacted, testCustomerCopyRemainsUsefulAfterRedaction, testAuditDigestIsDeterministicAndPrivateFree ]; From 3408b0637098a778b0054d4d429d1d8178fffc1f Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 18:53:12 +0200 Subject: [PATCH 04/11] Redact private receipt identifiers --- billing-receipt-privacy-guard/README.md | 2 +- .../acceptance-notes.md | 2 + billing-receipt-privacy-guard/index.js | 32 ++++-- .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 107 ++++++++++++++++++ 5 files changed, 135 insertions(+), 9 deletions(-) diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index 9fe93299..ce5c78bf 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement line items and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, replacement line items, and deterministic audit evidence. ## Run diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index f055ac46..e72fe66e 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -16,6 +16,8 @@ Validation coverage: - safe receipts are deliverable with only allowed provider metadata - private research project context is removed from customer-facing receipt line items - restricted dataset details are replaced with usage-category-safe wording +- receipt, invoice, and customer identifiers are redacted when they expose private context +- redacted receipt identifiers remain distinct for finance review correlation - customer-facing line-item identifiers and units are redacted when they contain restricted dataset context - unsafe provider metadata keys are removed before delivery - allowlisted provider metadata keys are still scanned when values are structured or nested diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index e62ff5b7..c115fac7 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -104,6 +104,18 @@ function sanitizeLineItem(lineItem, index) { }; } +function receiptIdentifierFindings(receipt) { + return findingsForText(stableStringify({ + id: receipt.id, + invoiceId: receipt.invoiceId, + customerId: receipt.customerId + })); +} + +function sanitizeIdentifier(value, fallback) { + return hasPrivateContext(value) ? fallback : value; +} + function sanitizeMetadata(metadata) { const safe = {}; const removedKeys = []; @@ -141,16 +153,20 @@ function metadataValueText(value) { return String(value); } -function evaluateReceipt(receipt) { +function evaluateReceipt(receipt, index) { const redactedLineItems = receipt.lineItems.map((lineItem, index) => sanitizeLineItem(lineItem, index)); const lineFindings = redactedLineItems.flatMap((lineItem) => lineItem.findings); + const identifierFindings = receiptIdentifierFindings(receipt); const metadata = sanitizeMetadata(receipt.providerMetadata); - const findings = Array.from(new Set([...lineFindings, ...metadata.findings])).sort(); + const findings = Array.from(new Set([...lineFindings, ...identifierFindings, ...metadata.findings])).sort(); const decision = findings.length > 0 ? 'hold-for-finance-review' : 'deliver-receipt'; + const safeReceiptId = sanitizeIdentifier(receipt.id, `receipt-redacted-${index + 1}`); + const safeInvoiceId = sanitizeIdentifier(receipt.invoiceId, `invoice-redacted-${index + 1}`); + const safeCustomerId = sanitizeIdentifier(receipt.customerId, `customer-redacted-${index + 1}`); const customerCopy = { - receiptId: receipt.id, - customerId: receipt.customerId, + receiptId: safeReceiptId, + customerId: safeCustomerId, currency: receipt.currency, totalCents: receipt.totalCents, lineItems: redactedLineItems.map((lineItem) => ({ @@ -164,9 +180,9 @@ function evaluateReceipt(receipt) { }; return { - id: receipt.id, - invoiceId: receipt.invoiceId, - customerId: receipt.customerId, + id: safeReceiptId, + invoiceId: safeInvoiceId, + customerId: safeCustomerId, decision, findings, removedMetadataKeys: metadata.removedKeys, @@ -202,7 +218,7 @@ function remediationAction(receipt) { } function evaluateReceiptPrivacy(batch) { - const receipts = batch.receipts.map(evaluateReceipt); + const receipts = batch.receipts.map((receipt, index) => evaluateReceipt(receipt, index)); const remediationActions = receipts .filter((receipt) => receipt.decision === 'hold-for-finance-review') .map((receipt) => ({ diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index 9aa2a0d4..bab27684 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -5,6 +5,7 @@ - Keeps safe subscription receipts deliverable with a provider-metadata allowlist. - Removes project titles, collaborator handles, and private research descriptors from receipt metadata. - Scans nested provider metadata values so allowlisted keys cannot hide private workspace context. +- Redacts receipt, invoice, and customer identifiers when they carry private project, dataset, or collaborator context. - Redacts customer-facing line-item identifiers and units when they carry restricted dataset context. - Preserves customer-useful totals, billing period, plan, and invoice references after redaction. diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index b793e721..af2a9837 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -125,6 +125,111 @@ function testCustomerFacingLineItemFieldsAreRedacted() { assert.equal(JSON.stringify(receipt).includes('GSE-private'), false); } +function testCustomerFacingReceiptIdentifiersAreRedacted() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-alzheimer-trial-001', + invoiceId: 'inv-GSE-private-collaborator', + customerId: 'customer-@private-reviewer', + currency: 'USD', + totalCents: 49000, + providerMetadata: { + accountRef: 'acct-lab-005', + billingPeriod: '2026-05', + invoiceRef: 'inv-public-safe', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-platform-subscription', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 1, + unit: 'month', + amountCents: 49000 + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = result.receipts[0]; + + assert.equal(receipt.decision, 'hold-for-finance-review'); + assert.equal(receipt.findings.includes('private-research-context'), true); + assert.equal(receipt.findings.includes('restricted-dataset-reference'), true); + assert.equal(receipt.findings.includes('collaborator-identifier'), true); + assert.equal(receipt.id, 'receipt-redacted-1'); + assert.equal(receipt.invoiceId, 'invoice-redacted-1'); + assert.equal(receipt.customerId, 'customer-redacted-1'); + assert.equal(receipt.customerCopy.receiptId, 'receipt-redacted-1'); + assert.equal(receipt.customerCopy.customerId, 'customer-redacted-1'); + assert.equal(JSON.stringify(result).includes('alzheimer'), false); + assert.equal(JSON.stringify(result).includes('GSE-private'), false); + assert.equal(JSON.stringify(result).includes('@private-reviewer'), false); +} + +function testRedactedReceiptIdentifiersRemainDistinct() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-alzheimer-trial-001', + invoiceId: 'inv-alzheimer-trial-001', + customerId: 'customer-lab-006', + currency: 'USD', + totalCents: 12000, + providerMetadata: { + accountRef: 'acct-lab-006', + billingPeriod: '2026-05', + invoiceRef: 'inv-public-safe-006', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-platform-subscription-a', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 1, + unit: 'month', + amountCents: 12000 + } + ] + }, + { + id: 'receipt-alzheimer-trial-002', + invoiceId: 'inv-alzheimer-trial-002', + customerId: 'customer-lab-007', + currency: 'USD', + totalCents: 13000, + providerMetadata: { + accountRef: 'acct-lab-007', + billingPeriod: '2026-05', + invoiceRef: 'inv-public-safe-007', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-platform-subscription-b', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 1, + unit: 'month', + amountCents: 13000 + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + + assert.deepEqual(result.receipts.map((receipt) => receipt.id), [ + 'receipt-redacted-1', + 'receipt-redacted-2' + ]); + assert.equal(new Set(result.receipts.map((receipt) => receipt.invoiceId)).size, 2); +} + function testCustomerCopyRemainsUsefulAfterRedaction() { const result = evaluateReceiptPrivacy(buildSampleBatch()); const receipt = byId(result.receipts, 'receipt-private-compute'); @@ -154,6 +259,8 @@ const tests = [ testProviderMetadataAllowlistBlocksOverSpecificFields, testNestedAllowedMetadataStillScansPrivateContext, testCustomerFacingLineItemFieldsAreRedacted, + testCustomerFacingReceiptIdentifiersAreRedacted, + testRedactedReceiptIdentifiersRemainDistinct, testCustomerCopyRemainsUsefulAfterRedaction, testAuditDigestIsDeterministicAndPrivateFree ]; From 768370e5cef6b2463f22bfaea30578841d6229de Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Fri, 29 May 2026 20:32:56 +0200 Subject: [PATCH 05/11] Handle missing provider metadata --- billing-receipt-privacy-guard/README.md | 2 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/index.js | 2 +- .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 32 +++++++++++++++++++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index ce5c78bf..7a516960 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, replacement line items, and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Missing provider metadata is treated as an empty provider packet rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, replacement line items, and deterministic audit evidence. ## Run diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index e72fe66e..74fefb44 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -19,6 +19,7 @@ Validation coverage: - receipt, invoice, and customer identifiers are redacted when they expose private context - redacted receipt identifiers remain distinct for finance review correlation - customer-facing line-item identifiers and units are redacted when they contain restricted dataset context +- missing provider metadata is treated as an empty provider packet instead of crashing receipt review - unsafe provider metadata keys are removed before delivery - allowlisted provider metadata keys are still scanned when values are structured or nested - customer copies retain useful totals, currency, usage categories, quantities, and units diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index c115fac7..c6e4b802 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -116,7 +116,7 @@ function sanitizeIdentifier(value, fallback) { return hasPrivateContext(value) ? fallback : value; } -function sanitizeMetadata(metadata) { +function sanitizeMetadata(metadata = {}) { const safe = {}; const removedKeys = []; const findings = []; diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index bab27684..91703f26 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -5,6 +5,7 @@ - Keeps safe subscription receipts deliverable with a provider-metadata allowlist. - Removes project titles, collaborator handles, and private research descriptors from receipt metadata. - Scans nested provider metadata values so allowlisted keys cannot hide private workspace context. +- Treats omitted provider metadata as an empty provider packet instead of crashing receipt review. - Redacts receipt, invoice, and customer identifiers when they carry private project, dataset, or collaborator context. - Redacts customer-facing line-item identifiers and units when they carry restricted dataset context. - Preserves customer-useful totals, billing period, plan, and invoice references after redaction. diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index af2a9837..fd6f7e28 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -230,6 +230,37 @@ function testRedactedReceiptIdentifiersRemainDistinct() { assert.equal(new Set(result.receipts.map((receipt) => receipt.invoiceId)).size, 2); } +function testMissingProviderMetadataIsTreatedAsEmptyMetadata() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-missing-provider-metadata', + invoiceId: 'inv-missing-provider-metadata', + customerId: 'customer-lab-008', + currency: 'USD', + totalCents: 31000, + lineItems: [ + { + id: 'line-platform-subscription-c', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 1, + unit: 'month', + amountCents: 31000 + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = result.receipts[0]; + + assert.equal(receipt.decision, 'deliver-receipt'); + assert.deepEqual(receipt.providerMetadata, {}); + assert.deepEqual(receipt.removedMetadataKeys, []); + assert.equal(receipt.findings.length, 0); +} + function testCustomerCopyRemainsUsefulAfterRedaction() { const result = evaluateReceiptPrivacy(buildSampleBatch()); const receipt = byId(result.receipts, 'receipt-private-compute'); @@ -261,6 +292,7 @@ const tests = [ testCustomerFacingLineItemFieldsAreRedacted, testCustomerFacingReceiptIdentifiersAreRedacted, testRedactedReceiptIdentifiersRemainDistinct, + testMissingProviderMetadataIsTreatedAsEmptyMetadata, testCustomerCopyRemainsUsefulAfterRedaction, testAuditDigestIsDeterministicAndPrivateFree ]; From 09859bd95dcaa574b1826c903c7aedff954916d0 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 00:01:47 +0200 Subject: [PATCH 06/11] Redact unsafe receipt currency labels --- billing-receipt-privacy-guard/README.md | 2 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/index.js | 16 +++++++- .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 38 +++++++++++++++++++ 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index 7a516960..7f9be717 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Missing provider metadata is treated as an empty provider packet rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, replacement line items, and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Missing provider metadata is treated as an empty provider packet rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, and deterministic audit evidence. ## Run diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index 74fefb44..a11eae25 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -18,6 +18,7 @@ Validation coverage: - restricted dataset details are replaced with usage-category-safe wording - receipt, invoice, and customer identifiers are redacted when they expose private context - redacted receipt identifiers remain distinct for finance review correlation +- customer-facing currency labels are replaced with `XXX` when they carry restricted dataset context - customer-facing line-item identifiers and units are redacted when they contain restricted dataset context - missing provider metadata is treated as an empty provider packet instead of crashing receipt review - unsafe provider metadata keys are removed before delivery diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index c6e4b802..c9b519eb 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -112,10 +112,20 @@ function receiptIdentifierFindings(receipt) { })); } +function receiptEnvelopeFindings(receipt) { + return findingsForText(stableStringify({ + currency: receipt.currency + })); +} + function sanitizeIdentifier(value, fallback) { return hasPrivateContext(value) ? fallback : value; } +function sanitizeCurrency(value) { + return hasPrivateContext(value) ? 'XXX' : value; +} + function sanitizeMetadata(metadata = {}) { const safe = {}; const removedKeys = []; @@ -157,17 +167,19 @@ function evaluateReceipt(receipt, index) { const redactedLineItems = receipt.lineItems.map((lineItem, index) => sanitizeLineItem(lineItem, index)); const lineFindings = redactedLineItems.flatMap((lineItem) => lineItem.findings); const identifierFindings = receiptIdentifierFindings(receipt); + const envelopeFindings = receiptEnvelopeFindings(receipt); const metadata = sanitizeMetadata(receipt.providerMetadata); - const findings = Array.from(new Set([...lineFindings, ...identifierFindings, ...metadata.findings])).sort(); + const findings = Array.from(new Set([...lineFindings, ...identifierFindings, ...envelopeFindings, ...metadata.findings])).sort(); const decision = findings.length > 0 ? 'hold-for-finance-review' : 'deliver-receipt'; const safeReceiptId = sanitizeIdentifier(receipt.id, `receipt-redacted-${index + 1}`); const safeInvoiceId = sanitizeIdentifier(receipt.invoiceId, `invoice-redacted-${index + 1}`); const safeCustomerId = sanitizeIdentifier(receipt.customerId, `customer-redacted-${index + 1}`); + const safeCurrency = sanitizeCurrency(receipt.currency); const customerCopy = { receiptId: safeReceiptId, customerId: safeCustomerId, - currency: receipt.currency, + currency: safeCurrency, totalCents: receipt.totalCents, lineItems: redactedLineItems.map((lineItem) => ({ id: lineItem.id, diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index 91703f26..425780bc 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -7,6 +7,7 @@ - Scans nested provider metadata values so allowlisted keys cannot hide private workspace context. - Treats omitted provider metadata as an empty provider packet instead of crashing receipt review. - Redacts receipt, invoice, and customer identifiers when they carry private project, dataset, or collaborator context. +- Redacts unsafe customer-facing currency labels when they carry restricted dataset context. - Redacts customer-facing line-item identifiers and units when they carry restricted dataset context. - Preserves customer-useful totals, billing period, plan, and invoice references after redaction. diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index fd6f7e28..62fc4eb4 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -261,6 +261,43 @@ function testMissingProviderMetadataIsTreatedAsEmptyMetadata() { assert.equal(receipt.findings.length, 0); } +function testCustomerFacingCurrencyLabelsAreRedacted() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-currency-leak', + invoiceId: 'inv-currency-leak', + customerId: 'customer-lab-009', + currency: 'USD GSE-private cohort', + totalCents: 27000, + providerMetadata: { + accountRef: 'acct-lab-009', + billingPeriod: '2026-05', + invoiceRef: 'inv-currency-leak', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-platform-subscription-d', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 1, + unit: 'month', + amountCents: 27000 + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = result.receipts[0]; + + assert.equal(receipt.decision, 'hold-for-finance-review'); + assert.equal(receipt.findings.includes('restricted-dataset-reference'), true); + assert.equal(receipt.customerCopy.currency, 'XXX'); + assert.equal(JSON.stringify(result).includes('GSE-private'), false); +} + function testCustomerCopyRemainsUsefulAfterRedaction() { const result = evaluateReceiptPrivacy(buildSampleBatch()); const receipt = byId(result.receipts, 'receipt-private-compute'); @@ -293,6 +330,7 @@ const tests = [ testCustomerFacingReceiptIdentifiersAreRedacted, testRedactedReceiptIdentifiersRemainDistinct, testMissingProviderMetadataIsTreatedAsEmptyMetadata, + testCustomerFacingCurrencyLabelsAreRedacted, testCustomerCopyRemainsUsefulAfterRedaction, testAuditDigestIsDeterministicAndPrivateFree ]; From bbb8df7efac26f636f4c7383246986206f1a441f Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 01:30:10 +0200 Subject: [PATCH 07/11] Redact unsafe receipt amount fields --- billing-receipt-privacy-guard/README.md | 2 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/index.js | 23 +++++++--- .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 42 +++++++++++++++++++ 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index 7f9be717..35bc1bd0 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Missing provider metadata is treated as an empty provider packet rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, unsafe monetary or quantity fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Missing provider metadata is treated as an empty provider packet rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, and deterministic audit evidence. ## Run diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index a11eae25..cc6c0d76 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -19,6 +19,7 @@ Validation coverage: - receipt, invoice, and customer identifiers are redacted when they expose private context - redacted receipt identifiers remain distinct for finance review correlation - customer-facing currency labels are replaced with `XXX` when they carry restricted dataset context +- customer-facing totals, quantities, and line-item amounts are replaced with `null` when they carry restricted dataset context - customer-facing line-item identifiers and units are redacted when they contain restricted dataset context - missing provider metadata is treated as an empty provider packet instead of crashing receipt review - unsafe provider metadata keys are removed before delivery diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index c9b519eb..89a1ca8c 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -62,8 +62,10 @@ function lineItemPrivacyFindings(lineItem) { id: lineItem.id, description: lineItem.description, projectRef: lineItem.projectRef, + quantity: lineItem.quantity, usageCategory: lineItem.usageCategory, - unit: lineItem.unit + unit: lineItem.unit, + amountCents: lineItem.amountCents }) ); } @@ -96,9 +98,9 @@ function sanitizeLineItem(lineItem, index) { return { id, usageCategory, - quantity: lineItem.quantity, + quantity: sanitizeCustomerNumber(lineItem.quantity), unit, - amountCents: lineItem.amountCents, + amountCents: sanitizeCustomerNumber(lineItem.amountCents), description, findings }; @@ -114,7 +116,8 @@ function receiptIdentifierFindings(receipt) { function receiptEnvelopeFindings(receipt) { return findingsForText(stableStringify({ - currency: receipt.currency + currency: receipt.currency, + totalCents: receipt.totalCents })); } @@ -126,6 +129,10 @@ function sanitizeCurrency(value) { return hasPrivateContext(value) ? 'XXX' : value; } +function sanitizeCustomerNumber(value) { + return hasPrivateContext(value) ? null : value; +} + function sanitizeMetadata(metadata = {}) { const safe = {}; const removedKeys = []; @@ -175,12 +182,13 @@ function evaluateReceipt(receipt, index) { const safeInvoiceId = sanitizeIdentifier(receipt.invoiceId, `invoice-redacted-${index + 1}`); const safeCustomerId = sanitizeIdentifier(receipt.customerId, `customer-redacted-${index + 1}`); const safeCurrency = sanitizeCurrency(receipt.currency); + const safeTotalCents = sanitizeCustomerNumber(receipt.totalCents); const customerCopy = { receiptId: safeReceiptId, customerId: safeCustomerId, currency: safeCurrency, - totalCents: receipt.totalCents, + totalCents: safeTotalCents, lineItems: redactedLineItems.map((lineItem) => ({ id: lineItem.id, description: lineItem.description, @@ -250,7 +258,10 @@ function evaluateReceiptPrivacy(batch) { deliverableReceipts: receipts.filter((receipt) => receipt.decision === 'deliver-receipt').length, heldReceipts: receipts.filter((receipt) => receipt.decision === 'hold-for-finance-review').length, remediationActions: remediationActions.length, - totalCentsReviewed: receipts.reduce((total, receipt) => total + receipt.customerCopy.totalCents, 0) + totalCentsReviewed: receipts.reduce((total, receipt) => { + const amount = receipt.customerCopy.totalCents; + return total + (Number.isFinite(amount) ? amount : 0); + }, 0) }; return { diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index 425780bc..cff1c94c 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -8,6 +8,7 @@ - Treats omitted provider metadata as an empty provider packet instead of crashing receipt review. - Redacts receipt, invoice, and customer identifiers when they carry private project, dataset, or collaborator context. - Redacts unsafe customer-facing currency labels when they carry restricted dataset context. +- Redacts unsafe customer-facing totals, quantities, and line-item amounts when they carry restricted dataset context. - Redacts customer-facing line-item identifiers and units when they carry restricted dataset context. - Preserves customer-useful totals, billing period, plan, and invoice references after redaction. diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index 62fc4eb4..8803d146 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -298,6 +298,47 @@ function testCustomerFacingCurrencyLabelsAreRedacted() { assert.equal(JSON.stringify(result).includes('GSE-private'), false); } +function testCustomerFacingMoneyAndQuantityFieldsAreRedacted() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-amount-leak', + invoiceId: 'inv-amount-leak', + customerId: 'customer-lab-010', + currency: 'USD', + totalCents: '27000 GSE-private cohort', + providerMetadata: { + accountRef: 'acct-lab-010', + billingPeriod: '2026-05', + invoiceRef: 'inv-amount-leak', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-platform-subscription-e', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: '1 participant from GSE-private cohort', + unit: 'month', + amountCents: '27000 GSE-private cohort' + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = result.receipts[0]; + const lineItem = receipt.customerCopy.lineItems[0]; + + assert.equal(receipt.decision, 'hold-for-finance-review'); + assert.equal(receipt.findings.includes('restricted-dataset-reference'), true); + assert.equal(receipt.customerCopy.totalCents, null); + assert.equal(lineItem.quantity, null); + assert.equal(lineItem.amountCents, null); + assert.equal(result.summary.totalCentsReviewed, 0); + assert.equal(JSON.stringify(result).includes('GSE-private'), false); +} + function testCustomerCopyRemainsUsefulAfterRedaction() { const result = evaluateReceiptPrivacy(buildSampleBatch()); const receipt = byId(result.receipts, 'receipt-private-compute'); @@ -331,6 +372,7 @@ const tests = [ testRedactedReceiptIdentifiersRemainDistinct, testMissingProviderMetadataIsTreatedAsEmptyMetadata, testCustomerFacingCurrencyLabelsAreRedacted, + testCustomerFacingMoneyAndQuantityFieldsAreRedacted, testCustomerCopyRemainsUsefulAfterRedaction, testAuditDigestIsDeterministicAndPrivateFree ]; From c04d71f72313f675556ce864a99698c6cec2389b Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 09:28:51 +0200 Subject: [PATCH 08/11] Redact unsafe receipt metadata keys --- billing-receipt-privacy-guard/README.md | 2 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/index.js | 14 +++++-- .../reports/receipt-privacy-packet.json | 9 +++-- .../reports/receipt-privacy-report.md | 4 +- .../reports/summary.svg | 2 +- .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 39 +++++++++++++++++++ 8 files changed, 62 insertions(+), 10 deletions(-) diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index 35bc1bd0..e90b758e 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, unsafe monetary or quantity fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values. Missing provider metadata is treated as an empty provider packet rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, unsafe monetary or quantity fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values and provider metadata key names. Missing provider metadata is treated as an empty provider packet rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, metadata-key redaction handles, and deterministic audit evidence. ## Run diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index cc6c0d76..4cacbab6 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -23,6 +23,7 @@ Validation coverage: - customer-facing line-item identifiers and units are redacted when they contain restricted dataset context - missing provider metadata is treated as an empty provider packet instead of crashing receipt review - unsafe provider metadata keys are removed before delivery +- unsafe provider metadata key names are redacted when the key itself carries restricted dataset context - allowlisted provider metadata keys are still scanned when values are structured or nested - customer copies retain useful totals, currency, usage categories, quantities, and units - audit digests are deterministic and private-context free diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index 89a1ca8c..7e6dafd2 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -137,17 +137,24 @@ function sanitizeMetadata(metadata = {}) { const safe = {}; const removedKeys = []; const findings = []; + let redactedKeyCount = 0; for (const [key, value] of Object.entries(metadata)) { + const keyFindings = findingsForText(key); + const removedKey = keyFindings.length > 0 + ? `metadata-key-redacted-${++redactedKeyCount}` + : key; + if (!SAFE_METADATA_KEYS.has(key)) { - removedKeys.push(key); + removedKeys.push(removedKey); findings.push('unsafe-provider-metadata'); + findings.push(...keyFindings); continue; } const textFindings = findingsForText(metadataValueText(value)); if (textFindings.length > 0) { - removedKeys.push(key); + removedKeys.push(removedKey); findings.push(...textFindings); continue; } @@ -320,7 +327,8 @@ function buildSampleBatch() { invoiceRef: 'inv-private-compute', plan: 'institution-compute', projectTitle: 'Alzheimer single-cell pilot', - collaboratorHandle: '@lab-private-reviewer' + collaboratorHandle: '@lab-private-reviewer', + 'GSE-private-cohort': 'restricted metadata field name' }, lineItems: [ { diff --git a/billing-receipt-privacy-guard/reports/receipt-privacy-packet.json b/billing-receipt-privacy-guard/reports/receipt-privacy-packet.json index 4c501531..397ccaa5 100644 --- a/billing-receipt-privacy-guard/reports/receipt-privacy-packet.json +++ b/billing-receipt-privacy-guard/reports/receipt-privacy-packet.json @@ -47,12 +47,14 @@ "customerId": "customer-lab-002", "decision": "hold-for-finance-review", "findings": [ + "collaborator-identifier", "private-research-context", "restricted-dataset-reference", "unsafe-provider-metadata" ], "removedMetadataKeys": [ - "collaboratorHandle", + "metadata-key-redacted-1", + "metadata-key-redacted-2", "projectTitle" ], "providerMetadata": { @@ -103,7 +105,7 @@ } ] }, - "auditDigest": "sha256:49cd2b51f8a44d0cb4a058712ed3c2e348b3502acc07149e7f68e503b17ef5f8" + "auditDigest": "sha256:7e05da13f9d6502443aee8363bb9290c27de9dc06484ff36c66300a8534fefdc" } ], "remediationActions": [ @@ -113,6 +115,7 @@ "action": "replace-private-billing-fields-before-delivery", "priority": "high", "findings": [ + "collaborator-identifier", "private-research-context", "restricted-dataset-reference", "unsafe-provider-metadata" @@ -125,5 +128,5 @@ "remediationActions": 1, "totalCentsReviewed": 152400 }, - "auditDigest": "sha256:20bea339360007ff72444466bdcc632050a108e5d9a63ded5838c5cd951d4d63" + "auditDigest": "sha256:08e21ce3fc6915ed223f64220fdcc534986805530e3a1169f3d73ebe8860930f" } diff --git a/billing-receipt-privacy-guard/reports/receipt-privacy-report.md b/billing-receipt-privacy-guard/reports/receipt-privacy-report.md index b4515eb8..d959bd23 100644 --- a/billing-receipt-privacy-guard/reports/receipt-privacy-report.md +++ b/billing-receipt-privacy-guard/reports/receipt-privacy-report.md @@ -9,12 +9,12 @@ Generated: 2026-05-28T09:00:00Z - Held receipts: 1 - Remediation actions: 1 - Total cents reviewed: 152400 -- Audit digest: sha256:20bea339360007ff72444466bdcc632050a108e5d9a63ded5838c5cd951d4d63 +- Audit digest: sha256:08e21ce3fc6915ed223f64220fdcc534986805530e3a1169f3d73ebe8860930f ## Receipt Decisions - receipt-safe-lab-plan: deliver-receipt, findings: none -- receipt-private-compute: hold-for-finance-review, findings: private-research-context, restricted-dataset-reference, unsafe-provider-metadata +- receipt-private-compute: hold-for-finance-review, findings: collaborator-identifier, private-research-context, restricted-dataset-reference, unsafe-provider-metadata ## Remediation Actions diff --git a/billing-receipt-privacy-guard/reports/summary.svg b/billing-receipt-privacy-guard/reports/summary.svg index 4db63698..38aa50c0 100644 --- a/billing-receipt-privacy-guard/reports/summary.svg +++ b/billing-receipt-privacy-guard/reports/summary.svg @@ -7,5 +7,5 @@ Remediation actions: 1 Checks: line-item text, provider metadata, restricted datasets, collaborator identifiers Private research context is replaced before receipts leave SCIBASE. - sha256:20bea339360007ff72444466bdcc632050a108e5d9a63ded5838c5cd951d4d63 + sha256:08e21ce3fc6915ed223f64220fdcc534986805530e3a1169f3d73ebe8860930f diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index cff1c94c..207dbe87 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -5,6 +5,7 @@ - Keeps safe subscription receipts deliverable with a provider-metadata allowlist. - Removes project titles, collaborator handles, and private research descriptors from receipt metadata. - Scans nested provider metadata values so allowlisted keys cannot hide private workspace context. +- Redacts unsafe provider metadata key names when the key itself carries restricted dataset or private research context. - Treats omitted provider metadata as an empty provider packet instead of crashing receipt review. - Redacts receipt, invoice, and customer identifiers when they carry private project, dataset, or collaborator context. - Redacts unsafe customer-facing currency labels when they carry restricted dataset context. diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index 8803d146..53f0c645 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -85,6 +85,44 @@ function testNestedAllowedMetadataStillScansPrivateContext() { assert.equal(Object.prototype.hasOwnProperty.call(receipt.providerMetadata, 'accountRef'), false); } +function testUnsafeProviderMetadataKeyNamesAreRedacted() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-metadata-key-leak', + invoiceId: 'inv-metadata-key-leak', + customerId: 'customer-lab-011', + currency: 'USD', + totalCents: 18000, + providerMetadata: { + accountRef: 'acct-lab-011', + billingPeriod: '2026-05', + invoiceRef: 'inv-metadata-key-leak', + plan: 'lab-pro', + 'GSE-private-cohort': 'metadata field name carries restricted context' + }, + lineItems: [ + { + id: 'line-platform-subscription-f', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 1, + unit: 'month', + amountCents: 18000 + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = result.receipts[0]; + + assert.equal(receipt.decision, 'hold-for-finance-review'); + assert.equal(receipt.findings.includes('restricted-dataset-reference'), true); + assert.equal(receipt.removedMetadataKeys.includes('metadata-key-redacted-1'), true); + assert.equal(JSON.stringify(result).includes('GSE-private'), false); +} + function testCustomerFacingLineItemFieldsAreRedacted() { const batch = buildSampleBatch(); batch.receipts = [ @@ -367,6 +405,7 @@ const tests = [ testPrivateResearchContextIsRedactedBeforeReceiptDelivery, testProviderMetadataAllowlistBlocksOverSpecificFields, testNestedAllowedMetadataStillScansPrivateContext, + testUnsafeProviderMetadataKeyNamesAreRedacted, testCustomerFacingLineItemFieldsAreRedacted, testCustomerFacingReceiptIdentifiersAreRedacted, testRedactedReceiptIdentifiersRemainDistinct, From 4a1844a556a9f3f3ede65ae47fbcb126977ea3ac Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 11:59:19 +0200 Subject: [PATCH 09/11] Handle sparse billing receipt payloads --- billing-receipt-privacy-guard/README.md | 2 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/demo.js | 11 ++++ billing-receipt-privacy-guard/index.js | 8 ++- .../make-demo-video.py | 3 +- .../reports/demo.mp4 | Bin 41982 -> 48322 bytes .../reports/empty-receipt-privacy-packet.json | 13 +++++ .../reports/receipt-privacy-report.md | 4 ++ .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 48 ++++++++++++++++++ 10 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 billing-receipt-privacy-guard/reports/empty-receipt-privacy-packet.json diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index e90b758e..02f5b831 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, unsafe monetary or quantity fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values and provider metadata key names. Missing provider metadata is treated as an empty provider packet rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, metadata-key redaction handles, and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, unsafe monetary or quantity fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values and provider metadata key names. Missing receipt lists, line-item lists, and provider metadata are treated as empty billing evidence rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, metadata-key redaction handles, and deterministic audit evidence. ## Run diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index 4cacbab6..9fb9e549 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -22,6 +22,7 @@ Validation coverage: - customer-facing totals, quantities, and line-item amounts are replaced with `null` when they carry restricted dataset context - customer-facing line-item identifiers and units are redacted when they contain restricted dataset context - missing provider metadata is treated as an empty provider packet instead of crashing receipt review +- missing receipt and line-item collections are treated as empty billing evidence instead of crashing receipt review - unsafe provider metadata keys are removed before delivery - unsafe provider metadata key names are redacted when the key itself carries restricted dataset context - allowlisted provider metadata keys are still scanned when values are structured or nested diff --git a/billing-receipt-privacy-guard/demo.js b/billing-receipt-privacy-guard/demo.js index ba23a862..e2967448 100644 --- a/billing-receipt-privacy-guard/demo.js +++ b/billing-receipt-privacy-guard/demo.js @@ -6,12 +6,18 @@ const reportsDir = path.join(__dirname, 'reports'); fs.mkdirSync(reportsDir, { recursive: true }); const result = evaluateReceiptPrivacy(buildSampleBatch()); +const emptyResult = evaluateReceiptPrivacy({ + batchId: 'billing-empty-review-20', + generatedAt: '2026-05-30T12:00:00Z' +}); const packetPath = path.join(reportsDir, 'receipt-privacy-packet.json'); +const emptyPacketPath = path.join(reportsDir, 'empty-receipt-privacy-packet.json'); const reportPath = path.join(reportsDir, 'receipt-privacy-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); +fs.writeFileSync(emptyPacketPath, `${JSON.stringify(emptyResult, null, 2)}\n`); const receipts = result.receipts .map( @@ -47,6 +53,10 @@ ${receipts} ${actions} +## Sparse Billing Batch Guard + +Empty or partially populated provider batches that omit receipt or line-item collections produce deterministic empty review evidence instead of runtime failures. The empty batch fixture reviewed ${emptyResult.receipts.length} receipts and generated ${emptyResult.remediationActions.length} remediation actions. + ## Safety All fixtures are synthetic. The guard does not call payment processors, customer systems, private workspaces, institutional finance tools, or external APIs. @@ -70,6 +80,7 @@ const svg = ` item.pattern.test(text)).map((item) => item.id); } +function evidenceList(value) { + return Array.isArray(value) ? value : []; +} + function privacyText(value) { if (value === undefined || value === null) { return ''; @@ -178,7 +182,7 @@ function metadataValueText(value) { } function evaluateReceipt(receipt, index) { - const redactedLineItems = receipt.lineItems.map((lineItem, index) => sanitizeLineItem(lineItem, index)); + const redactedLineItems = evidenceList(receipt.lineItems).map((lineItem, index) => sanitizeLineItem(lineItem, index)); const lineFindings = redactedLineItems.flatMap((lineItem) => lineItem.findings); const identifierFindings = receiptIdentifierFindings(receipt); const envelopeFindings = receiptEnvelopeFindings(receipt); @@ -245,7 +249,7 @@ function remediationAction(receipt) { } function evaluateReceiptPrivacy(batch) { - const receipts = batch.receipts.map((receipt, index) => evaluateReceipt(receipt, index)); + const receipts = evidenceList(batch.receipts).map((receipt, index) => evaluateReceipt(receipt, index)); const remediationActions = receipts .filter((receipt) => receipt.decision === 'hold-for-finance-review') .map((receipt) => ({ diff --git a/billing-receipt-privacy-guard/make-demo-video.py b/billing-receipt-privacy-guard/make-demo-video.py index 6fe5f89c..a761a224 100644 --- a/billing-receipt-privacy-guard/make-demo-video.py +++ b/billing-receipt-privacy-guard/make-demo-video.py @@ -29,7 +29,8 @@ def draw_frame_with_pillow(): draw.text((96, 190), "Safe receipts keep only allowed provider metadata", fill="#dff5d5", font=body_font) draw.text((96, 248), "Private project and dataset details are redacted", fill="#dff5d5", font=body_font) draw.text((96, 306), "Unsafe receipts are held for finance review", fill="#dff5d5", font=body_font) - draw.text((96, 402), "Synthetic data only. No payment, customer, or workspace systems are called.", fill="#ffd37a", font=note_font) + draw.text((96, 364), "Sparse provider batches produce deterministic empty evidence", fill="#dff5d5", font=body_font) + draw.text((96, 456), "Synthetic data only. No payment, customer, or workspace systems are called.", fill="#ffd37a", font=note_font) image.save(FRAME) diff --git a/billing-receipt-privacy-guard/reports/demo.mp4 b/billing-receipt-privacy-guard/reports/demo.mp4 index 6d4225e81973a0dedf31f604e0cf83a3f8c8fc02..b7062cbd5b666eb7bbf88d7d180cf8bce109b80b 100644 GIT binary patch delta 20232 zcmeFZWmF}>mM(a3Dcs%N9SV0X+}#Ryhe8hS?uEO%YXOBj6z=Zs4ny6(J*#`>O}~Eg ze!TwCYvo=mBO`Xi7hmkXb7!86^F9dr`w6th7!=ejr``mV1{@gM2>^ge004mDPqF$G zsZUw`6r)d(`;@Pr67(tjpThbn%%5WU&+^~%zt{ej1wZ9)ndMXd^Yy_0-46aY^*_h| z-<-k!KYfeOasM)E_^%ZHy&(80|4QM%Hu(Rlr~hpGw}XGJr~5n?{Ac;^68tUyW62V1 zpn;(Ei}mNwt)QUMkoBcsij{%+hz@2RyFRv-?r+|N@w^g@>=6Cm*I13q`b0-@?A&OX zHqTNG-O}=8#UJI4^h7ugVqH!;a{cr0c${T0dn zAE_XT^*XlJxA=L-MWg@~huPX8hG90>)e%@AT>3(cv}k;*Nw&VDj+5F!uArrLt?v34 zV)ca#tv2MhMBvT%Z`oGmLN-9+ZSJV41l_QkO##-AlBag4hGru^N=D0V!W(04ZK8Iu zUDio#1=6fI7>9kr>^LA(CTkBaLTJv1fK@{UmrF3L_N9jJ0#mM|>eD)?xCb-1DPsGp z5+YwH(|s7W?a1g`@+8d-lnmD5EzZ)r2y zM58^=?_|OB8qt5C7Ct2GV~31LRF=*%&|kj5Fl429Zy>Ne25ffKfP>(kWZ#=sr%wcE zSm%|Eb_0c!C=-Aqaeo9E&275V(e!R^M-uKUCnTv$I^jaG5KFo1z_5hX?ib~b*up~YBPBay)w-P)@Z2e~l;<>HaTQ)h7MU+Z^I$83>eW5kAk zeK8aG!iuwYcu>X={VNP~^Q6xPE_)DJsy2WGQwbv3P16s!CZ|%;#5{MOI6_aPlFuqj z0WMURSsfH&cTmTcbfHx_f3>jq9k{~uB;dmW#hj9+g zQNgXX9d8Pevf@z|bhYFBjA7vt=yNa$=;|A4L#dHDP^t8EwT7RFfhbWVekpBAZUDUH=?@X;SDrH&HUt}}Y;ZV|Vg=WW-lb6FAQM88d)y{00Q z7OHELPCP&qP{tf(hD?X^yfoL`LfUv$ED|>f)WlGYudA|M>DxA|R?td~H1$QFuL2Q` zf!XGqKuj_i#Aet*5Hi@Kso&kU(BBp>)?@D7-8=(M=4%6|q>KuSn+^vRhYh)?h;U%O zJpy*d-GCZkd_Ap`v()M$D8exu%DnR+eqg}xa~Job%wp;N6%^xz>we<7ysBs?CA8Pd zwH7B{8w2?mI$`sN@{j}+4YBJr3}r}Ma+R3Gt~6N?30_j8qWB+IC+UG#+-j=zEm{gi zKZAgw7b)M!Pu?GJa0*`(Yaq7^n3LtMlu9~d{)=k&yb@gKMy!f4kV>wZOn#uW794p%gmIq4og)?OFKdc7-B!b!^K#L7v^E@Iw0o2! z0_f10ig0eTUI$n+e(O}>Tv!d?-Ump08BGg%m>~U1c@jYu^G3 z3q>{8vKkRZzA)PUGGj^_3ank4N?of5g~|Yt5tMPzT|T0H@KTk1;o~4=J5P^F*xQRI zp%o1gYf=DN&z%p|MeZ4f6TTcHpwS3YHX5iD8u9wLa1r!Q8ZM~9AiiO)eh*pQ@8GhJ zY{h~ZwHbX8$wN5B(EIf*A92HCLLLoxrD3PInGTouNJFQg&~R;qw`ASkH(T>;Q7?4y z<-yjN1$~en1=ha)Ctdu3>q}-qQV^n2szB zKNjBP?VLIaQF3nqgis|qm-QX716bd=CR;aI#h}LBW)mla9&hSmE!r2efqct3 zVfC16aP(lZfxPn*I6c&c)V?RHH*d%z=WJSqm18}=+=bYvdKBR@C&9l1&p=`Dw*l8A=Tz0Xtv6 z)$A#E(lBBRRpJW(yR2<97nCaGq4fUQ6@PFr*IJ{5g;hKTNPKVX;3fYE2UnfU5(93e z>v}f|b+yyx0*aO&BPO)X3}LI}0(>Nxm*7_{O4CzLL>$&Qvh}c$?1kmOtypyT;<*56 zuep}RZuG6rRFOGmLx8QC^5iOkUq=KI&HRcbB%Xy!J$X$YpkBn8FJD1$MHUzosGt6)9^7+#d)4zq3S6_VX0{*rxc?4?fQmjXOGF z331ZW6OWjR5ISq3@6#3PE%WmTHfI}5aQnyAD;d^$cvlKin(qt0;G@-4-ETO&i*uBk+* z1l%9yw$KxsFw}wYcsMH5+H194Sf$##L2OJx7xQOHoAid**K4TVKvJiVo}XLadX&z- z!vPh{orijiQ{8&pygvwHLz0-f)b5O@HpawUzGf1%Z$Tlw}G5*+Q1xQIyM?#Fv_;|+ukTEf>q^K=4*)75M%$dR| z|28Wxro;W|bnPy5pBDoj;P`?P?IFTyan!c zgEV+@b1&p%O+(XgB4G#T3~fW#9k9D=!jx`ns7p2EP&&J{+xUn@tZ_M$zIKK_%aGi< z9Go&#Qql1%eV_KjEowCnwLM)j$*LbAjayyW^>EP))p)c%wYj^7m+4;eE{HpvYw7yS|7Dx}y zRPcH8kf@biBx_>zYqlesG&69zy`F54I9lL6a3kn^K%HH??z5?h z0|{te^3A3LWGfp;E^Cd*wlMHMDI#fK-dn|A4->*HFvJ@x4tOBB!5A?DFnw+(;)=tB zIrw6HNhxvXLbn!l^sr6;iZMvoQInmtE4F}=5h1LDYV7qRPSW}m%e}jmPB)47D~mIc zHOPDBqWSz{p9xW_1JOERmbJTTq^s{9MU(qOzmv-XfX}P3?@MjH)di3tC&~SZ8zDk> zLqg$)J^?S;f^%YU6D8X(nGe@}*^9Z!VEiR2jI;QJ@?QQ!2#}D8;+|_q8k$T*lz}DQ z@h%o6Tp<4yrX=X}IFzL|c=Il6~i$~2f|lQjLU|>d3F`W z2{l647PVy-*W*HpNLZli_z(0eELNU={q)x!Ip2iT{RHZm1;38<*KcfRYY<)$DElOM zn*_H4sSLMbmHm}9oQ)}P*Ck+x^wi;c@^=X(@TJsuS_kXoP^I6r_q3n`F+EKlEKiDmV+;k3VZ>OX3RJFRjSsF=ALn<^f+vaWnxTrE|ix5sdL8alQq!nZ{T z(vm&i>z_FVq5B@*yw14y$8uRARNRgNaf9%X!6cHOcu)lRefl~s0ZY{j(z9%IR_N-- zmpb0uxt+9nb*Bh|&`u5C5SOOfNy*8foyF%#4*7nl0dFBvDUdH;DDOVDu3M4w*ml<= z@ZcL3(hQ3OFJGPa4~1YiRKgwxi4|G)@nx<@3Y|;W@#lV;SR5#ZW>uo>vrM*tr(Zf> zlXVAjN${Qn=Y!tBw>5ly#|c?oE%belWaR7~0!fOf&@8LS@&!}Bz6JxlSsLWcQZM2$e<8FItJ!4I+5k{ak?$+{_fv*@P^(`7Vz)h*$v z;dqLV#m-8<0EAywlD^&hNPm;O(pSy~V2kC2 zfyljcX_Wva`|bnxIZJAe2I;0VulZQtUz%DMlBxbqJt=J0<#SdXi1x>H?>%EB_QRL8 zyYd0|zCF8c3lA0CDT;Qkn^CY2Yr1#4KLtS&n;?z%EhICEMqzx|cZRv}+5%?SXG>Tql)nx;eT0PgvsifuQjIW7f45Pws}T(?JS1klpCJ{#<2aejm-tl!*_Ts~be7cU+gJf88m%k0>wr)x-Ny9MD( zg79~M-|mY^(QsjWA&4rC?ZrhD0g7obPRYBPFGm zjcuMY6Im(UV*)Go^G$(}28ze1WduHrcQLJWxS6i^ugPTeKnH^MXe#}R65i$;^YgAq z^)fs#o3{&OSNhFRC0968N;EeijiE(>>qgp^kV!0c+gBm(g1TJ++}X_FW9>_y$}b#& z6l}9QM!6Zi2FO!;+q))H8;?6y6zNQEOr$rHMCdpQYx44RH*@G%04>v&l6@m&HFo{x z+e5tsJx+lTS)}~4bnvHem9;e*x^_b6f1F#i*e-gb6`oC@TBO%1!Vtj1KwIv_bsmUc81U2>&B&- zh@FI$k@_L4?HK~*_bD+esT5Htjl1!wVAE8RC&jUrQ?EKlxuZ1$uN;kJyQzk@%r1As zIOwfU^?Coawg%L*H&>7*YpraC4uhiqURE)u8=k`KV6vvYhSzt!ehbxjvk>gzq9n(k zCI)w%UsI;b08$o$f<{!jt`;nSzvpT?(SAP-7ZeD2tF}o93l+M6Mn}wfdhrh-5$5c3hia3;V^>oTW$X7u_B2MW_ z#O7{}n?p#lBctCJf1ci;eRTY@m-RNQrb|9ziA-(EiGY0`@gEzTv>8>?^XH zSpEmc+T(Sj&2(nZvx{EDp+_sPm&WautrDpS_$k&Mc~mde{@B;( z1Fg1)E9K!VTnj}e0`>{9-EdNfS~OvoAU@`uu5ElSjR+45tqieW4`~QQ3g{ zT$r6<6krZb_<+9tikoJ|I|sgdb-WRZif(KW`!D(wLhB*+yV^v{>sy5?A^?aG0R>a9 zGck(S552Wl5|as#)0M?JXc23DWVkt z-|@ItVoWW9a=r|pw*!bm-Irjkmwm6>So6H+L3%e!j@>pOe#krj|q;Y{j~EMAA9!<`FuL< z7f}CvuhA!r9F=yefwZcjs&Z?TQh0^+7f4VJRZ$((|L(o&>1DK zVK1WhYalO2XC;~^uXaC_qIB@!?5h2+Bh?%l|K4+boVwTy@)w3+(87hfqFIec=dHOV zGtqqu+|@lrrL*u&^DJzQ7L3nbjId5tIFNnu7#latUb001Xr!S5BRZ_R3ZvU?@18;P zMZs+~Jv!_ZD(VL>FQn0SdnQ%8H4_sz^^-crL{l!2A-O0+tvQr;sFI7^){Xj;Y`A*; z*)ZH%>oC|#m<^y=N;?I?!a|%-8uNV&Ftdv+dK?B%-34`m_yR@gRj~t)!B87NC9l}yBQq&?VVB-lWigT zT>31Mjnd@;%A)XU@d2g>at>^&A86dhg+Z|zMH?2%XH^er_VXE&72U%#`XQbw3%ux~ zw5Mgfq}0r=1dC7+Bkv#s+85Bu-+>RvCA>2cVatez*byAG?35Tna=uNZ5e2=qFSiSJ z-KQ6hNROngd*`|37wdu&621HMvKF!FYJvTdhSU z`l|G^i1ru@HUe`Zeh*gK(QR?kP{)QK8OIdzWO-+Z_<8fsVtb^_ZBv0`fi z6a$!Su88|2-4geQw^6@9P;AU2Ybi^1j$k4+H+RzrFM`u5`5ICFD6N7r$ZhYaD4H-n zji)=Pgb51F(o|KU7hs%e+zB{A2hmZQ5s9f1j{X=DBO`0cOxS+%&sB}7W6R1HS8JG2 z>)(2KljI%iE%=>5NSdRUPX|PQTdEI2;oQhPv~Uqvb7-OJIF>#eykD#-#ZS2H%YAys zm7{l|q4j40SL^tK5R#L52kKdvEPHW{IivCVD4#ltrK&b6NDU>!9E3B2{2d(G&kKgE zNGWrM?8=%doX@s?hJ5~67{hfya!WnkR({wyk6OK`Q>-KpK#N;DH!=cr#mUl zOVV@v*S<&+qIobHsNbW7Bp57w3a=MF&U>I1+s}Zs-n+(gY=nD8RV}0kDjm#5Mvu0! z)(?xGsGv$(<(Sjmw0oe1h#3*xak{V_bFHYLfr28ZEwz-q&VveFAgFJTgp+yOGM{a4 zCs-6E$szSc+4mWU&Tq1VjKulM-qmfCbBhr=PUZO*JNyB|@U>1>NQAJ-okkc!urXR! zQeVZ1oK#NbUO|SWj9k3LATT)D<-y!DcQ^YCU>X&(wU>${?970n_hCD?q;Jq)ky+o$ zmb)`|#ZdhokU1x7!xsTx;KT1&;33pPy^<~@f0p=0o-Im&!>`2bPeU;1${6{n4lCCk zt!T7J)D=-CgZ1daW+dH<+-@&j$@uDQR}lN{VK3gkt+~>_8V2r$s=HOuxRA1PY&-3Z zST#YKkeJ(d@woy|(0DVJDPPZ}q%v(@F6P*cj(M=CRPw$e4~AxZDUo{Wc+Qw~I|OW! zuS-wkp9b`Zw(w%W;9bsMUt%SGFVtJ9+5bxKN>CG5LcYG?8@eD&O+BMDnhj!)-i|Z8 z_tD<`K8FU5f+6`@d2vu#trJRn65MMm$)9J2Zbre5nx+k;JW(9B?-g$xDM$1oITtzY znSOl1Q}ZDcEP3A)M}a3m4bd{u0(>;~D;sg6m!2fY!b8yVJNOkNv@f3_KffducI)tS zwgIAo^|>&Hqt7jg36>Bjub&W)*v4~j&&Ojqe+E8WG?pu&Y}E>WZg zytiMs*XaZ*X|(JagLv}jny__1U6F4frA)P9r*})5wCU9gxiHx{LQ!RW7v7Xfi&1D@ zcAJd&`PQHsoytF(#PV0OrA(uti}3!7IxR zCleb%3j#hEG=%_xjZzPSkTE#{CyfiOdlr@-y(gPYzCLCQZCW7AeA&WfKc&t-v zF0hy9^!3K<+d#bImk8JIAIP~Twz%3sNEO>MN0PbjA2Bn?)H#js-Y@Cj`YYp1G*QXL z7h#&0b=b~*(|QgoeYZ6rhab>!QR!cXfsMs-U$}_2m|FXxreS?rsu(zM6fguBSYpMn z+Szi!eUO9_!=+c&r)&lg-^9et7g0|XJdbW4;`R z7BFjNTrqV+8jsRL%s#J`P-Z7A(ec-cN$*GOwMI3zUH zeW4po0)N5z;4icvl6I9h0f2ztcq(W?(&&XMcLYyqC*sUpmL)O~XyrpaOGkB~L zt}MI`^z-$HnsI5Hj{#Ki~`gI8J9tg!^2=x;%~L2z_r8?og*~9S(gg}G=z7Rc}D|wR3d4(=7|HxD4^XG9Oyf3 z1Z}>A^bglZlO@pFv>6K3-yeSjYIq5vwvo4oTSJ=KiN{FDa`I`FH$mpCrdvsshWx-q z2n^QC`%3NJ!_|6o#Cm(@VFk@m?c-Q$14zsG*+3FNs95GS$uq`og41UF^BN-6oybz_ z1z(-w@qI$A6%)>Q;A>p`l7UbqParCV_Y>7v{**Z*3Qk_ZxuC;0o&{dFmgmH_qGVy~ zYG2NC4yaLEZ5*Q*QVTIxZy?htd3b>bgY*ifFWSGD%jU-0{hza=Sa`goY9Q3JzQ+7Y zj}HVxhur+}4uRSXuieNFQfe9@{`-$)^S~l&2((Pz>3U7Q&viapL?^IM6w^g_K{*32 z0N+`_Xt|s@an1S*wLZ3_{ups-zdcJm=esjPD$l<$hW8A>Y>i5)HdcTC= z%DgXpSH$=7-?D1QLBR4=^rk5#v65E62=+yhe~`J=s#F?mLqLXZatNk?6qc^}lH3@1 zRnWmX*{iKPXjS?Pa#H~jfzdK}U?h$m8e&`Pqo%_Uvosotrj(T@avFJ!csVUDK*@n1 z{C&d(F}EQkoIDidCcs-r7QM+J=DKv)6ZSOx-N1Qm7EU!q)XA3Mwjaya8R5C#-mXZa z#D*O8z0R@srRxnM-EUnCKkt9@UqvI?o8#&COcrXb z+Jl$kxKye|7D=ZR+qzc^(Ak(=7h`mSESCjEYms8VrB{|QoWr>v;d{LbCn4PyQYtp} zYggYmT(|^%M9AMz&uA@D+Obx}dY-oN5?E%4HS%6ZUoRP0gPAtX!tLf8ami5{(Sn*8 zmuH_vmY16(!Ic7w>^#IToUSP|0m7`I#c_O3m&6&{6BZ@VLMu#PX9w~KhcJcO#HT&H z&^q@=_h3cs*TE`?BieySLuy;xIcbYMfnVtiANqjD&SR7@!$Z+43(}>z`m2xQsm_Fk z3K1q_)x;G|F2d~}u{Y)t#Pq4Uc?*ZZq0~%PS#6YnQWGHb4=FMglA@NRqp8AzkPY%f zjNcJkNk6Kdd?+KmkVja-CE&shD}SARXOR4UQRsRT#JhVjk_n9i-iToVy-lWvV#vCz z>%7^QLfJwno!&!NC=$VmX#YJjz-#fna@;MU*5OxD)e{cPR7xuG2Q{1SJo%Ejq?l8fyc!;zDIMqWd%d#O$(Y=y zZH=w(8hTm_nIPt;YGUvlFa1#m{jDCMbKxUO(Ho{D!AEgK(ZlT)JLrSp+i$!Yh5X!bQ0tcR)(M444gt)!( zoRW7$+iM-7AT4HCj4*9Ea`3?MyUc-p9g+?ta<&lUWXII8HaLV-1^o(j!Yy(+Se%sy z;G*udz0Mgw$A@DgC*!)yUOU2c3y%GxR7XYTvuoJs)!Zoa?OrwAd=J~DjuhS^wavpN$s>`y_9=^%_&ToDh4&h`fw z%e!z|c{m+`*B7-EB0qyUcvNA1RXni8Dz6zvccR!+SpV?Wtmvz@>&ZX#;t5Zfh7kO;EN5;WJYlmyfdQIh|`P_=htwFWQ z>Cx82&5QpidZ$1o$#T6@XH(izzM365X_(TfwM$(yhw7WL6x1S74|&<|EKx#-fwHF# zV8FA?D;m<)E$F?k!ihvVV0IaK4x<#gcG2}K2)CzuYyr$Oeyjf%1%&T#6PK&kJQ1(1 z=4W#4Ea3buI#ZWJ5Uxkrtt6FgA2uN5&qEjf6^l19a@_O6fxuwN7LA<^k(!xGQYmt- zM=UR?I=1rT7xxRWC5=0zGMNXT6ts3RpvIY%rw|C%gj}Sja6`%~5kDZ8->{X?L^vCj zlPem0cvtiJAujHWLO|VHZQlgXOHSgqP`s(puBe6OgA9XJ2Pd=HOh8!-=i zKcR2sGEngg2`I4BkW7H8y4lAUxq^C@E$<@SFlk0T_AfLPZ!eM2Db?vxA)VU$zh7D1`)GOr1YNvYQ=(}xCC}E7P2tU_NbC;x zzkBTL*+5{x{$)MOZ$fQ3SC16}_pP@(3~!FWJcd*LAi{dw2PElm+&4{Go%G2I$j zrp~=y67o6v!M0r*(%uoWb%WEfpkfA-H3f_o$Ey%M9j&Zg-0oKKeas5;9PulwFp)*J zuMq`s+n7Jn_x`4*e3w<2C~8m%PK6F_*TFfN0X1z%$>v z>@VW$L2rn&NeWptRSfc$Fq|XvSuM!zu$7%1*e}pV9*dlDYGCsYQ&<()bjpz&PxOqz zYZX`L&y8rBiEq6OK9VzuS8p(@vyoK@&s|$Q zf9|~}8nw&<)_EiESmf7nfjlc2Fk~(!ZShwWGrM;hZXM%Bks*do!;=KM#_ly%i4O>c z5hneHBY7yyXPAui+Rm>G3sh!exFL}l5rxVEr1UZCO^3?i!zTA}ecZ9es2@e5os=P6 z>y@V+)-`KCb2%xONe6xD+jhqiu?NVuRl-U6xPI3%vE5sA$~rNp0hM|x9NS?u$)5BU zgEe#hB;q45u^^0BmN(hQWsCW%pq%-3Q66XnhWx0=FthsBs6)yB5jWq$uAW?-!jkWJ z<=3`L?ujjr90lP|LKq18(=UL<$R#B=)1J}n9IX542x&KUg$Epf7P$~1S3saC*ld37=2pxN|1k6CT9`bOTHD-Q{u12Bavm+QG3dY>5d(y3ipd6m2KQd_Es{bh% z6wPU(-n!>UsmW_?49`@f}E1fc8l59%6X<_(Av}` zyWOBT1=h}%{purgkfj7At-LjqY`j-5BlU-kHa)jQ%AJM$H=knZj}OL}yr?0_@d}n? zBo(YbaB8pz*Fe2WQ1jg|G3qdSTzNlQ5Zq+>Dn{qXNcnB4k(g=uLQG!z=LGy^PTW1h zq^a|)cDKoli&Rl}&nX8|gZL=<-(98wobxI_+;jSD^Dr-|_r!a4&x7+7XxZpxP~28Q z{KsZs4u&^;K#9r6%@%{T9l)Ep6GEpi2hgZSl$QCbfWQ?!-ST>)lGM$mCMUADr3DcyUO{>xMQJq zTpg7uLC<&HQ99ZQF-;70dR0kMzP5X4y`jgU_rm^Zgrp-#KYuona zK})!Tda&T4AxrMy5F02lS9UJ>X=)buxmrVPCxDJ`ad<-=&GrnO?8HwgoH(R@wKcI1qi9)>EEW2GJt;xjPG)vV#}6vVz2eOJCJ==E{Oy zG#g2E5#*N`!CfN$c6P6h)s>(*u9BRQzTW)RlkA&F6C+xtqqs+K63ZMF4eh8#uQjE- z;R!73FZTHTV~nZbnvP=~-JYU>xOC!zTi<@xy6o%=S{lYQKdN=vOKH$D@j?a>4ns-Y z0mXQOOP*51ii%IP+^D59=he!)!;L#MMPr$U+5PSc%_H(rj9Bl%lCGEnr>tjb_Sebx z+J09|iZ>aHf*>zz0RwSm$q-K5_}i_ecYk1RJ&j)Brl;RQ9k>3~IJOnzh^WBDbI3Eb z|N3~|Kt`g3iIUM_h2ZxW{uz5W^#D+---FlCk&~5Z?8*)Taq#kRdOjqVRy`d#?{cu31}n5^Fe=N1sgL)yGq8O+ZB= zArX*wmplShFeEh5OB_$jXE_taj?HxTS2Q@rjU^s08Uwv@LFBi~rniF+`@H~ktrkG^ z2~qR8m#oB@Y6wygs=TZ=W5)NCaENhT9Ku+fp=SQ_eF)+N9YdP1e-Y%*dR+kL;!DJ2 z4Omk*ydElKW^us-c>=vaFZ;Vd=fU0U}1- zLEHSDxB2A0F+TT@apr4_py6dGzWDSW7zjUBZCvsiBr+OjBIWRbtG#mKXmUEAn*{cJ z>JwxVrmQ=+=o)i4;*nT2*i>BW@W<4IfBqPp5UVJtYFD8063o5WhbfL%ius=?=LjB;jnLw zB(Ajrb%S2W^*^Zj6l_wG`!`^v`RkuO`35QIpG>7Bv=U2p2NbW()}ue;LdO;*DXx)v7JQ524TYy1#UVQsgP$GEhPHA@+bvZ{AvgOGlQ2N2Mze~p zhzY5XOXAIHvqDW9&xp6qC{R?}BUTjo&(hzfr4D-ji&XoAY?YPYo3IhkfK9KBaSg?$6!*>td@h#1A zO+L&oZE-pdh$(T0H=3uuD*CGN(}q>Szl+N`C6z+ZX(sH#ghzk6Uv>`YsW_He=u!#O zeZ;QLvjpzSqwjVz*bj*4YbU(w+gxlJ*Kb$*recjK+1p<(xgj57KAaPc{uT>5OAQLR zQGSJ2Aj^YGMVr2b(rUC`h8sxe!+RW|8TK%mn$;fxCUoHbrlMWG&HT^`AX7;l2;fEN zB!Tt>QST+F?`Ho4-1C=G#!vQ^SzQ^gf#^F0KyIkTILvqdE&!#!&5IMtR%R}0t1`W& zSlDt}_M6ABY8%&~TKbL;m5XiRKbK^q8l}7&o=E3^G$E*p$~fL-lkQ8rnea8Rksu{f z*0eVW$RF&}D;q*}AY09DNwW-p%x>!oGK=u@{C8f?73}7zpx|chp-kdDq6p8q@-2$N zPf!lVa6ZCIa$)ISniaW|?)}1+v$LA%#cH^x@qkkiD)}_3c@%&XqLXam&;~N=-BTeI zvcT)YoxXegm6PA$Elje!I1liWTh& z19n7L{0`0_Ab@z5x1^Kno7H#)EFc8%`40TXL6BQ0?AR!?Ecy`-M~X5?FNEW!9%wRU z)4H8+ZaMjjavQecJ+H~pnzx-<=tiQg!)XnNfP*U4MlB5-G<$wbKYw98wf2m)axH4Y z@-4e&as;Oxya(>pSAh4Uh*16687$9{pp%J@17KYJ&8(`#G$>m1KO= zOSk#33STLkaaap~#FQGafAi_eCBu$kaYIq=h<9p!SW6Cehh`OEupe#LDZiY4S2uJ% zSVKv?32{felV{eEaBPEv=g*frUH_uV=nS{JJevZzkK@QvneNib0%?xP zouTDL6evpNXc4{NWddmpsA6AL9^mD_2V9Aix;a0r@9_0rx9~^B`NJfq24m-uba{1q z_tK+?<`;?iO4~johiMc0nmk37fF7+tN_iZMm^2ug=J86!4E_awlL4O=6Ui(n{D(q*twfe0ZQi znB`;jdB6v*?G*16#*8tA&qPyW(l1nJr3=)a$26|?v$jc)9`n?mf}|JY0`*SFH{3gp z(^7vVqw8MvU{Bib*M?BlRCqgHR>G|F2-fc~yp%_d&h!3WM}M&aeW4>AILkiLwDb*A z(d|DEyGj|y5;M@|d-S*tVi3N(F)m^hU+Y@>ZQgDfeK7`u;9qe-96XrIgFI9j@MV-y zDE$$%Fj%&#gGenZmTI>p3HTfRxL!|tT>2I2toWS7EqL!mtkJHcj?{m!2K;`Mtgb&| zmJ(y?*_cml$uL<$v0PtZfAX~~Owu~de~nLAoXkbAB~3mI8}2$;P51|T9*Ndok)5l( z$FOj#W`zZnL%-)TH`O-fCIAw+Zv=p>O>ze~etri7Z+{5@yFdqk0F4w60sf5*0M=n) z0QOlhppTh`Bz)ZF5vLKcMNy|1YP`q5P+>o0c}4b|`d5gptRnG;2-{fU+Cf;eDxvxX z#>Kh0|BNq^~|% zVC-nw`>?19#~P5N({|C{+TjT9S(dp#;5Cef0b&2qpAGY)H&aZNVH7%eF9y8)TN&mw zTiVAFtjqRKs|bg!XW!4w#Q;1Gz0kpZfBFY)dC28CTUk|I;90((7Os+QX?QKMI4nYVD3z-~!x4D>TZ}nMMJ2uY^zIv2s#t6*AV%46mks*6 z_;rvFBs8U0Iw+@iui7kJ^*UnkjiMJ1Ow?$-x$%^(N_uKISp98Yqk!Sm6^d&D%w;2E zK>8&T^|Jrus;Hk#yI%5oANHsRqqRqx`Z7)9mDeO2t9nppKk_aM0s+J{86kv4qgDKg zJV(Kh>fI%Gcr5gVwEEO{)~Q0pB*kw*c#jGb4}gX~BT5PGg>Q@?v@{)>#SWEyEs7+5 zK+jl4(z7xXU%=1LH3IY}7>9}hyPPf+^okaZbH=g`%|+Q*OUv&Ui~cO|g~3jJ_Q{*Q%?aE^-Za8~RIvB*Po7Ar-B}|q5r6<9?h}bw!f7ZE z6Fv6vGkI!`GypVx&>8-nbMRJfsvU|=;0(#kQk)9Z@s9lx0DuWg{1FQY{;4FEpbZR$;KqP7OG(wYmpd)#?sy?+83$u1pr{h9BEuUu-(6+7CL~Tj6;$_y%-h*o_nnN zP&-s@;fi(y2jA@sXBSHPkVU*$x(fnCdOrKtOYjJ%5DgY4T_o>64KomiMYv1n^$VCKJ$6>LB(DTop0dN|h z^?vaQ3JL8I@KkA@?-5C_d){RlsHePKxFJrIcy=2ADflXDa3i9BV4>LjY|otl7fA$s zJd|DpXU`$PtRxuxzEdj-?;-e)CXn8rO&CG4j-16l{$4=RGPNw5_(VutdM{{7{)R+5 zKSp4+c{yOD()|wwFaQGArwTnt)`{i6r~tU(C(Qu>B#n4Sj*V#pG}bX3y0yCbNiU** zDuC?+61EepLAVp-6Y+ulsV-`;Tse%k!IueyX+dFRp+|!MYzQIykDjyX{|N^0b&$;1htFVr*&FyLAOQZ~P5Ja7 z*$)2!qR52hPSQ^!{)b&^3VtduBp61c;rv?&(wi_V|JtWicS3oR%_kdI(fGvw5gXtB zj-5apgDU$!n}73ve*R$X39-pGAiN3h$)9WSpPBp9|JvL^vS0o~H2;PQFo0A~f^4h} zIJZz1!QaJD`k&GPlEwEQ!1?#a@%O(k7!#1<&~X3lQv5q>_+&#X^)DMaasN3svJC%& z;r|Dg0Fq_=zr=>X&wsEH{FjaYgsWr~{s(aWJsbbxDp|$y}ft9rvYG%84$>YS8oBNhXVO*006)k000p2Vzw`) z{9=VIhr`d0z|MgJH6e{l!@cmCEdyuTDQ z`Ll$7p7=iFP@ni`78B0K{~~4ZzgTaxo8z zE~V$dNA;WUez(a(Bk(Wjg5{Y3~L4mLRlXo@X zwrmrEcog>$-WfMQ+NPtjk6tbz##zU%?Gb^jex%OWEznaq-uL2}2r* z%VkV^lDqR#USn|0T`i?mH=`bRBBOO4qBwC8@9PxU zG_=X-k@jeF5kpU%nGK*9D49a|=5!pSJ|6BuLVPfEQ!!|FM{_vNzgm|sDN&-dk_(OP z3~LO^uoku#c>Z~6uzv8KpR!>pYw3EGXQJSA=SjJDb`6C5jluq0W&(0Jma7zy*9Iy> zJeF*yqbePZWH2-i>T^iEsyic20pG6`&2lzaiIpy$ZLI_2#O81NHHmv~r`Ss-Mn=JM@Kc&G*uDq2;Z;P4IT>><3L^%NY@BB|@L z@xg-j`}#dz1?oD^iliMy_`XD=KMh?MCR4}shx=(K7p$<~dUeV6JY+~S{MDM2g7{@) z%wD-#<`9R<2I)4|YW@O&8Zt4VLmRxpNf;SC&$QEpCZhQyZ3nNf_lP)>ST%GS;0CGf z#HI(t{9tjZr|0v?nk`ULa@dJlRPMI@3S10{5g`4jxv(Fe`K8{8s;;4(8RrcI=n^hb z!CZ$E_nja4l&pI43fxM(8VSNzY+8rZyKDl;4E(xNM606Q_B>Z*4R{RL9RX7iGuGBf z!ys5pp$e^vP9dS%#Z;i1QhIs8I#A6<6&nK-N{X|r_P&6_Im%zk*5U*>_|Z@G|glwOFkb{?iyk*K|{lojk<`)tk;dik;>FH(R@`F z271Xb_RCRcdCoRsywIJPH;->FL+oW1iF9EE94YprEt%Q+?#4K`MWihj!?caqj1>_< z3}`TH)XHvx`>na^B>>y+`O{*;z3l@+7EszxtgD7>w2uLv&Q8RpN$Qun44-+~+ZlL< zgu?8Js6Ok!3*NQ5Uw-?vjR4kUoB#n2?WywZo_j{@lPhbFnpCCIu=85dmZ>Eh#e_2S zEej(#x{8QPf?N{rcU{$_$|ImquDuaK5uAvTb6tu2`A)4Wr5`sOVQ!R|J&ZI6zerjd zp@$&I9IbsNid?^kEbLN zdF)>!C@hbCaKL_c{b=205bWVYK`H8bPE zKrL~~LnUCh6anwoN_32p8Ak;QOq+<5x`cG0-L-CL(KY z=#?6tg|6>gNng)%6EOp__`__1DM86d+exUD=Fb`R-G=WB1goY=&zVNiU|T`21dy6n z+)(OZ&1wi@3aAo4TC`hZC2n}oNq&&fdUg6v4;-)I>6V(862!sgY@R zW6REUJe-%JdW=!w5?H(XEz%7QtjcL{iy$e^`LyWsQ?`!i3n6kSj_!4Mke-zZ@U=Z^ zVA!$;%SP(xJ`t*FVRUi%suPM^&*!5>mJUoT9dcXySnQ9>ZuhT_eI>?{e~sbZJ=AS9 zy|GCMhkmFNrDT>1qy#zl09TU6W({}wQn?MAjz8)v|9rfnVL)Co`FGB|o^SYKfVWF0;R8-h8l=wTo}21VqKT>|wBC z{s=-%YUAkp-U4F4uKPhG#ubesbC>B1_47e*KTdTQ5EWn8~c#AAL$}Km#%{VJ5GdN3iJ$biq7{?f`-k9H=FQ!un&SA=&yw3M(^)UWYPO4aG&qe-i(sRFgb!6;n$1hzBQF|m8vpT(Q?-#W%3kS zLnku`t4AC5=L2@;kw4@HbARl5#&tg=IZBEVUtmdf_42*iLlkv@mY0jiWR{ANASutI zeSI@p{B7>(c~d43MVzL7911S znNk6=w4Tos6|RpHH^kOLmD6TfJ0t7Bc`d7+$#W9auT%F5xI(XT+!t09TW4(dbr+pn zf6K5wAsTMfTxu>1)r#baMqGSQo%;(vM}rz7X5lh}Vhz`!#I>klarm(lZ^_bwGHM*Q z{8|~8G;R4~R~?MI>K3^}hNh7psLH^p)!qbD6@nu(wa(`N#_uDh>AH_G#*`i(*|8R1eJH{5n^Wn~Js(bF#eot8K^&u-Q?yFh zrhm4bcn38tc^VF3C&~O=L^v3rmY(5A??-D?y3Xyfr`b0G+p(q&PV=1f}pq`m(Z@Tp~HRB;Xf^*|y^A9|)IHt(e5d~Zp(GvzMG*RsjC%V+2(HSJWHsWR(FMg8TqhN&<=r~}CI z`G&Kd)O9Pln35c#cX2o^-L&!3sA<4=m1e17A&7DtJl`3psn#I~@hXE;9{15LBK=!q zHjPR;92KJ)y3idxQhS&D%I`@}uK^R7`4#JcvNn1T zQ;UZ~s{KZB@oR)}K|74xVlg>epu?-Y#|43PTIS|~x&c(OZO;?bH3Cl3S_vj|qg}pI z)kCG`7?wJRH=qnhxq_|dkl>vf!Q^G=)$MxiwZ~ZV^{8fZgxt^YE0Te;PkFcd?Bn>? z5=j&%-oYyX>O!Y70Y8EOnS4{Iyu2pzjrBrh8M-gs_-ago#MXu<0Mh|%5cqoB^z~lS zA#E$5)OwZisr~pk9qAIpdtw4xO+Y|qP7WLlKo^Tm#CrOix_wcI%wJC|O1DUeHSQQP zvVnjVrG|coL2tPgW^E%8HIQM0j7KsCPY{+F?`c`tZ0`6bsec4X#Th=Xj~6tR&U8JK>_9dh&^y?nsaQmBG2NJWb$(aLO5WNW+wxS*J>U|6%?$0(q7*<=$xt*hWZrarj4|b&bIH^rV*Ged;f5hMk?xa12wu)>5{33SRr`P zCF`T}CU`|s+Ix_n#%kb9a)l?2Jk>&++1qb$=61jzAy9Fx*2eB;OdivwZ$+z@v65`B zc+zXy7Y6X0J|f0HK5x}r=Y&t9sRcLUA$Iv9S!rQt1;bRhbS!F3uXtCkLsV4@Cvfo%~T8||9D*8VXqpG4%I>{>Z+(RHC zoUE%WR5>_{940cq6>R4oY@2ObCWT8$5>RiWemDzL;!L#027R3CgVGB+b~%LzL@$DB zi!Yn4ws_+DVvgk9RGa+9#@4~Mg(a|LrwcuC8;#Vjj;0s1 zfzXQG>?>r#V0pn~i@ODAvp<>+DLs0`j5?hUR73rWZDZ}#Q^iT;f09x7-nIts118aO zhdTlD*dys}KL}?gwM6r|jbUrg^vNJ6BN=5d&tX2NKq5;Q=__>Y`C!h0Sl?W^m$(2a`?4T)n1Jmfkn-J7F;nEox|_nFMvw5<8HELD$LoOPScLcRz`H z8!i+FXpg_X<%1WanUIU)H(O?*v&KJ%@y*SD4wMCbT6hZ)=A_wcBw&yK_}0p_kg2FU zyWJKBEl_F;?4eMBQ7>e=&d&$Od}HxQHsujN_{k09Po#S7#ZiOxI`VKcLcJMKaYzL6iNtlIJ?5qp%B?pIJgs z;&NEa1~d@x)aP)YR_+dUN`h{Vh~RP@Rx8*(Y7UyV3_~XpdL&}_io$}#KD>iMxd9vWiMVPZ-(no;>KbvD6H2^ zO$K6l=1#(_1CGKTVD@O6UkY(23OdT9s`6(huQpt; zFc21U8Jl>!n7>Zq_@P&9V@sQh<}r+Jiw6f^DWPwrX7@*xRSy(&$9DcUxi%^H{MxjW z9WOu8;fRhV^TCnWeRVpJgy2vIi`DuyL?S3}7Z_rjP5`-q<7xpwrr&3YzzAFpe&-U! zgpOA?v45#38|L`)xZ8JcdfA|$1P+FeZ<*B;a#ZoDlhcdPoEo=%g+uBSvs`72n2u61 z_WQAm13^vnOxyr#H~P#62xVp*sT1pl#KFm>Igjt-*2WTnah&z{aB4{uQ66%U{brz# zm`yc&D-u!a0_kzV^Axq`sdEuRFubm`!q1_m+*dPdJCpWl_q5Oz?vfL=y+&kTDD;eC zYXz;=aj0TLGm9>giRLm?6QHR0X9`dZV`kWjC+&Hh@4%|AZ{K=h)O+EC&=m*ucZ(K~ z(ZC$7sKV>#rv`HGM!(lJ{yaU6rCS6-;U&J|{;q(fzQX)Lp?}^Tdwg!+dA?0f>__N91QQtxCEG*H(k zMhC0RSl_yWoRjvAH#Y!R?gAcFNeLOxWH=ucJ{nPf;4QtI(2; zEa=Y@I$ODNxx-v{pltCd=GLmIdB7i8RzYSF67aLuF~}CW5NV0vwv{~#GpXNxotlXj zCA%kC(!DiiATi@z$QbMA(62_3Dx5KE$@{~@j-U6XLJphw17_oghQ$uUNS$MjiTnEQ ztX3v8r7+fnOsUNP&b=lURV2$G94r+)>3PNMBXHJUj-ShD+)y(U6n_h*3&SOz7H-p-Y`^PjJ9pE90vT(79E`LRCmaSYyYo0P60k*eIrp zANva>cMnu7!EOpa&byzH2^1!tKK`?V`?4NC=q5gWA1N!Oxe`uH=C zG%=Ml8Ve&cl;v`5!gn)2Z%vngTi-9Ghu+M_1A;SHWYI(I2BG%f8I7DyScEcUZzjNO zGk61Z3`3nHan@?%TE5UAd{iq~=W~8*DU=Non}PcQ%_uK4)YWcU@6nV{{#^p7T`b4_ z*6IB+=qq8LyjoEP;Ft2NeT8d;AjWA!6WN_-EDQ597~3Ul_3xk+{0;KBJq<_8q;Ka- zNkq$f%A$-vU%MY~EK{3F_Lv^DsS*W(`Ex;U{?geDrcO;QaV5t?iQfsa+k}iN)7#?@shu zX=-?M23>fJXr$yor{$Gl1QHRI>E*t(?}pz%cibz>^!aj3XB?W&M8R7Hs6GV@D6_giNKU&h__xU#h`uXH(OB`Claq>(e@85J zP58B_@74BC`A0oonJH*ZoPiZO6rIk@z5?fH6vG=x|ZzDPeX#48Srsz(%5y`K&7Jc z$O>|l_N_q*U&oW~TeNh5r^!c42Ohl^)~Y9(ZRrzXd<7{hK9)sJq4|>{o3rmt(HatBSp*`vHVD z+UV6Pj8m@ef>^j8ukJkNYNl<+F6b5t9s+B>ZO-WPfriR*#B4DeZgVW~J_jnQAX4-p z3kOkSD=aq#RI_}lBnlWQ6|$v(bhQ8UNGxika@@AfY__DZWQ9rr`C;g3g+x}pOmGQ} zeKUSHX0qrL#u?t%rH>S<-5$n?m2Z&x@9MHlVp%R^QI+dRlnA}uk56D7ytMIhAW8wE zl?_$xr68fYrf*izH(jB4BYbxQ9pUkXpSxTesDd*tb=PBK3x!GW`43IL;rKWDrd338 z$dlETKnxyBJM?|ax5@!xY5Cnqu@)p?>gl|nVq6`dJJ zkWOjXmrYVyq~7%Zn#>F1ca2adf9Y3^JnaE}l@1E2@41B>BRplec_2`^;3L9wmxbJ! z3x6c>g!qEPw|SeGzM5cBAvPQv!u^GJ4hDNoz}QPJuvnWOyf(=k@DZ`#WR(D^*BuPI z#wuszPla3GTI!<#Q7Y@wRO}l>`h=nQCcrI#o~Az6IOE!(5hffLGUbrw9P*4s1ump` zuo-kmD`WrlF)+Zi$mys!pcJRn>3x^7{fEbJa$-tZ4cXYKO1hl6}uCBi(x&mpVsZUKKET_5c)~*{rdmteeV5 zAXwVZtDOgOXSmA&)M>o2z~G~pvQ*u~PiUa6GZtkxQU}g6o`85siuD%vi-&zvFPN{d z-pDQbEKqGR*D!D&@^Tg=C~ejnYJ-+*mFZWOZh(UsiaL*xt**J~TYwQ&X`QC8i>=VFO{L0+%Yc&aMW@?~+>m+9Y)c>cByym3$g1p5%{9qnLuHye7zpY;Rb)|}(Z$AnVk56+g+~@;oC_VD&4YIRA)KJ%MPh<9YYjYgX z29BPTt{|B7re6I`5hD;p;q{oon6g+@j9XWZ@lLaTMZara8Olyqr@*bt>JyYjLv^aI z)z28MH2Zh)(L>A`_0>{NApX@{SoXe5Pxx&I{k^LVMSCh&ufx;ZtXih@FY{-2*1udVp?SD4;mu?L7CQt;NeAPd^NG;%Qj(zjQR-sIX?xs&aprnA}tC#Cfu8bB7CQV|16rO(cqV!8b~3* zOaN^_dJtN>w*-Mg-0!R-UMZhRk7G4TDY1^I(^;WE9oiUMZcs zYU<~QW-J(j9KzRJW7Kb=%fIUPy4J$bw%3NsK zH~uWd`;ZI$M$$|lue(?>^k6zAZakApdUxBNR6Pgs08e5%2tY*z^K)L;V#bw|TMbj` z8(i>o0zlcrPM;*|=lkv?0l%Tb&pNp7oAJHYekBqxw02U=kxRVSDw{iwMkf6UHVrW)$Nz5>;kE;(>;yJIO57~l&O_7Xuo)mPv3yzbE zG-X2*Q8C#sCT!k%l02#zl)F;Mie1FaS0zMa#K5INkd|Gk1Y@8rzbDyEFTCwoi%G|j ze@NC%#s@tYPco^^e4in{zzY0~O~ga)HAwKML&@QZxzr=w1*UZf!`tQq;R^SSh|1lDkPB;^o2g zuj;Rc#;`$br+OS*ZXrKu24C$~96r^!-q3!Z<&_)jCbebfnmgJM445OtWyLa&xfZQg)KGfWohEOgmxlhkqVE2%su zZrBS@U$CW$Rwk0Ll&9fW2fwUAe}$?MmD%{_lbGA+=$1!fZmkT)oSbq@-~4VV_j)qtmv+3uC@Ei zrLF{SrK|SgvD_NXR7wy|VK>yRK6{X6hR+G8fuUIa+)KTPYSF14ZJ=Z2`SrJ!FBbv+ za0HHquRV!JFJaA>+>7jLT?}#uABU3NrPcv3!PU(CGkZC0=^gN+2fEIePtR}X6l%;Z zJgG`9)wykg0i_P{sY6WrhRhz4@)5bklQVBsH!(^#&*V=V5>t89X$i}lGY#SLT0%!b z4~I2EEU7b7%zh*JEVE`UsDxd@4x?CC_{|zviA4j?$X1jtUZ`tiFu!Sb9=cH`Su77y5eL~Ox2hjHa?5ov}Bq%_&! z+oHVQZ;qh?L-V1MFI~ftlv;+i1(3V~wL7$v|G+SUY2M5y!R2d=SM6lz`&ml0HMZ%s zyA+H!zss>Au$O$k9IDrK+Juq-g)$S>nCLYT{JY*>sB3O z>de~Y@kc14q%db=tCGGutlQm89&}O&ixoom?sfFkU_}kY(2d9@U1yPwH#+8@Kx?jj zw>&0W>gou2Lj<_rV;}I%7*^qFD(o;~8L-KD=c3(T3z{I8*YG59!YOD!fU*e3n?ZG< z5E&9aQGiq@jVFvL?k=A>pR?K_}KnoF*<7UWHw{eFB%(o*7g{L~RaF!yaP526p<|+lG zZ8k`cU^ruGbN14wh#_qjQLthTCvGjBf`TI-cfHst)QH=W3KQU4sxZ&f{ZpEgm=M;Q ziEAspLy;onUYDo)bp0OtU3WqW)L5gIg+=TY1nI@p(FRl{-!OXeG~3V-$jDFdV~?34 zO&q2oaMPBQWW5+QvMoV|fmkPHl@gf(&-b< zJHyo7I^Ka*G^FMl0Ujp~kUxSmwS5l|{yd)BBx zI*kyQ7SEj@?yWux-;o?^^CR{Q8)170XHX?F-cXlXM-l!>n<}zYgN8-zb%V490{pU7 z62U2FRvp6@O5U8^?vscAgjot!Jq;2W$}pP*sAP&DBZ5fGr&p z(>KG?J372Kehsg+YW=pe%TZj!X|sI4HhOaSF@EFd)jFI1db4K#?f{8d@wK8aQ~9>S z$K6b^-Zd8mo{vG!k7YD!hrU(?Pj*JhIYsFC2C`O{(PO{7ep3xPnu2;JqSgY*g( zG3c&8zowAp&c-P#5=1U|dO)+q!ciLG^BxqR8|Nr{_eSRl&LQaMj}W`=d+3;5V|(1T zuq$*P`4i%hi*jen?wASY(H~))yq8{?Y4}@bBB`2bvuQTHj!?1>nAdvIA^?D*o?kP- z;k*?9AJPYaC`Ks(BpZbT7`Vs*s7^NiFdj_ZKx+&%z)d(D2=KNL4ZyxFhZ;cR_VU-8 zt2>HFl{)wK$H>*8a|FbtZceZERXHcv&<Y+dEOXlZOPeeVq^%BHb1_2lRlhk_Wq>ikIt1<^Tw037m9)k zrZA=+?;jreL1U6Rc$LpLKsgx03;2ahae?qq0uPKB3;5`aa2Vcilk)Ft%%y779v)f< z=4=oiI!}+0Kaf724+iQVRaaEkZ+-U0GUEyV__j)zK~BeYUcwu#KNrs?gbD+_I@c)zE=r)=QsKb z0VFW&EDF26rU^%PV|m@c#N{9D*A8t2VnP%Ulzh7b6k#X0EmF z4er{N)VqmuG6kJI)X3%aXsq90F_9I1d!3hWvxwD7;q#)3T84*VbN)gD0Kl7)D>)v+ zE)UqLF+gZ9U=aCS`rr8j0DutDEHTfRX8-^U%oLPphkUsq74(X(UoJrcAeag+A%~K6 zIa62ZN7A_28u5}-#yJC_6h<XS}InYu_efA~Yu{GjT4fj{rD z@hoy-Ey;xs0jW+hW1YL!?e1@6xhk_6xsQT`4b=2Uaw zkPtu9O7KG~4=pA0U!kBp2x7_TLD*<#B^to|d7kc|65kkrhyH@84OYvh;q(XKkE(S4 zx(wSDXqvf?pm*>?`_HR?9UKJ8Vyf`*wDHMOkv4D(sPy6~1#ZkHeR(L8B-;;h0MM;BRevm6`z103IHwE?j2x%I@D ze+L!3S&zx_UvOUb0ib}$cKM&f{S&N6!!OvX24da+3j0gUmtY(Jd9V=KP5+oA04(2V z2gM+oy#%gpAov@eUs?FVbN3H<{@BdVf5wwg_-ixIg@3Ej@6CX7tNyM=S;GGe7NYtW z>}1p5!v2mOjNXii@!zEfZ&d%y5e9%!YJSO(75YmC!~ZJxpJd1?{D(69&W-=P0sl@4 z{yx(eMJ>Ug7Im;lCH61A;r~(gtndGj?@zM-qm=(s4*hS!|7Q{SrO5v#{I6>EBE$bC z{Qr*?-sJCGry{jvD0Dx!(!T6633|sl-_+L7wPyeJV_z#^p#^V40 diff --git a/billing-receipt-privacy-guard/reports/empty-receipt-privacy-packet.json b/billing-receipt-privacy-guard/reports/empty-receipt-privacy-packet.json new file mode 100644 index 00000000..9ae469ca --- /dev/null +++ b/billing-receipt-privacy-guard/reports/empty-receipt-privacy-packet.json @@ -0,0 +1,13 @@ +{ + "batchId": "billing-empty-review-20", + "generatedAt": "2026-05-30T12:00:00Z", + "receipts": [], + "remediationActions": [], + "summary": { + "deliverableReceipts": 0, + "heldReceipts": 0, + "remediationActions": 0, + "totalCentsReviewed": 0 + }, + "auditDigest": "sha256:600daa8274aae6ef49d5ea13eebb7841666aad4046ee736fe944e692d15f238a" +} diff --git a/billing-receipt-privacy-guard/reports/receipt-privacy-report.md b/billing-receipt-privacy-guard/reports/receipt-privacy-report.md index d959bd23..07c694c0 100644 --- a/billing-receipt-privacy-guard/reports/receipt-privacy-report.md +++ b/billing-receipt-privacy-guard/reports/receipt-privacy-report.md @@ -20,6 +20,10 @@ Generated: 2026-05-28T09:00:00Z - remediate-receipt-private-compute: replace-private-billing-fields-before-delivery (high) +## Sparse Billing Batch Guard + +Empty or partially populated provider batches that omit receipt or line-item collections produce deterministic empty review evidence instead of runtime failures. The empty batch fixture reviewed 0 receipts and generated 0 remediation actions. + ## Safety All fixtures are synthetic. The guard does not call payment processors, customer systems, private workspaces, institutional finance tools, or external APIs. diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index 207dbe87..818afb87 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -11,6 +11,7 @@ - Redacts unsafe customer-facing currency labels when they carry restricted dataset context. - Redacts unsafe customer-facing totals, quantities, and line-item amounts when they carry restricted dataset context. - Redacts customer-facing line-item identifiers and units when they carry restricted dataset context. +- Treats omitted receipt and line-item collections as empty billing evidence instead of crashing receipt review. - Preserves customer-useful totals, billing period, plan, and invoice references after redaction. ## AI Compute Billing diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index 53f0c645..b3516188 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -299,6 +299,52 @@ function testMissingProviderMetadataIsTreatedAsEmptyMetadata() { assert.equal(receipt.findings.length, 0); } +function testMissingReceiptListProducesEmptyReviewPacket() { + const batch = { + batchId: 'billing-empty-review-20', + generatedAt: '2026-05-30T12:00:00Z' + }; + + const result = evaluateReceiptPrivacy(batch); + + assert.deepEqual(result.receipts, []); + assert.deepEqual(result.remediationActions, []); + assert.equal(result.summary.deliverableReceipts, 0); + assert.equal(result.summary.heldReceipts, 0); + assert.equal(result.summary.remediationActions, 0); + assert.equal(result.summary.totalCentsReviewed, 0); + assert.ok(result.auditDigest.startsWith('sha256:')); +} + +function testMissingLineItemsAreTreatedAsEmptyReceiptLines() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-missing-line-items', + invoiceId: 'inv-missing-line-items', + customerId: 'customer-lab-012', + currency: 'USD', + totalCents: 9900, + providerMetadata: { + accountRef: 'acct-lab-012', + billingPeriod: '2026-05', + invoiceRef: 'inv-missing-line-items', + plan: 'lab-pro' + } + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = result.receipts[0]; + + assert.equal(receipt.decision, 'deliver-receipt'); + assert.deepEqual(receipt.customerCopy.lineItems, []); + assert.deepEqual(receipt.redactedLineItems, []); + assert.equal(result.summary.deliverableReceipts, 1); + assert.equal(result.summary.heldReceipts, 0); + assert.equal(result.summary.totalCentsReviewed, 9900); +} + function testCustomerFacingCurrencyLabelsAreRedacted() { const batch = buildSampleBatch(); batch.receipts = [ @@ -410,6 +456,8 @@ const tests = [ testCustomerFacingReceiptIdentifiersAreRedacted, testRedactedReceiptIdentifiersRemainDistinct, testMissingProviderMetadataIsTreatedAsEmptyMetadata, + testMissingReceiptListProducesEmptyReviewPacket, + testMissingLineItemsAreTreatedAsEmptyReceiptLines, testCustomerFacingCurrencyLabelsAreRedacted, testCustomerFacingMoneyAndQuantityFieldsAreRedacted, testCustomerCopyRemainsUsefulAfterRedaction, From 5d6a27255eeee9b9179cb14938ff8a7cd38a7653 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sat, 30 May 2026 14:33:40 +0200 Subject: [PATCH 10/11] Harden malformed billing receipt fields --- billing-receipt-privacy-guard/README.md | 3 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/demo.js | 36 ++++++++++ billing-receipt-privacy-guard/index.js | 66 +++++++++++------ .../malformed-receipt-privacy-packet.json | 70 +++++++++++++++++++ .../reports/receipt-privacy-report.md | 4 ++ .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 46 ++++++++++++ 8 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 billing-receipt-privacy-guard/reports/malformed-receipt-privacy-packet.json diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index 02f5b831..318d5b78 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, unsafe monetary or quantity fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values and provider metadata key names. Missing receipt lists, line-item lists, and provider metadata are treated as empty billing evidence rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, metadata-key redaction handles, and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, malformed monetary or quantity fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values and provider metadata key names. Missing receipt lists, line-item lists, and provider metadata are treated as empty billing evidence rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, metadata-key redaction handles, and deterministic audit evidence. ## Run @@ -16,6 +16,7 @@ npm run check ## Outputs - `reports/receipt-privacy-packet.json` +- `reports/malformed-receipt-privacy-packet.json` - `reports/receipt-privacy-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index 9fb9e549..738c0759 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -20,6 +20,7 @@ Validation coverage: - redacted receipt identifiers remain distinct for finance review correlation - customer-facing currency labels are replaced with `XXX` when they carry restricted dataset context - customer-facing totals, quantities, and line-item amounts are replaced with `null` when they carry restricted dataset context +- customer-facing totals, quantities, and line-item amounts are replaced with `null` when they are malformed or negative, even without private research text - customer-facing line-item identifiers and units are redacted when they contain restricted dataset context - missing provider metadata is treated as an empty provider packet instead of crashing receipt review - missing receipt and line-item collections are treated as empty billing evidence instead of crashing receipt review diff --git a/billing-receipt-privacy-guard/demo.js b/billing-receipt-privacy-guard/demo.js index e2967448..331f0bf5 100644 --- a/billing-receipt-privacy-guard/demo.js +++ b/billing-receipt-privacy-guard/demo.js @@ -10,14 +10,45 @@ const emptyResult = evaluateReceiptPrivacy({ batchId: 'billing-empty-review-20', generatedAt: '2026-05-30T12:00:00Z' }); +const malformedResult = evaluateReceiptPrivacy({ + batchId: 'billing-malformed-fields-review-20', + generatedAt: '2026-05-30T12:15:00Z', + receipts: [ + { + id: 'receipt-malformed-numeric-fields', + invoiceId: 'inv-malformed-numeric-fields', + customerId: 'customer-lab-013', + currency: 'USD', + totalCents: 'free-form total', + providerMetadata: { + accountRef: 'acct-lab-013', + billingPeriod: '2026-05', + invoiceRef: 'inv-malformed-numeric-fields', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-malformed-quantity', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 'one', + unit: 'month', + amountCents: -2500 + } + ] + } + ] +}); const packetPath = path.join(reportsDir, 'receipt-privacy-packet.json'); const emptyPacketPath = path.join(reportsDir, 'empty-receipt-privacy-packet.json'); +const malformedPacketPath = path.join(reportsDir, 'malformed-receipt-privacy-packet.json'); const reportPath = path.join(reportsDir, 'receipt-privacy-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(emptyPacketPath, `${JSON.stringify(emptyResult, null, 2)}\n`); +fs.writeFileSync(malformedPacketPath, `${JSON.stringify(malformedResult, null, 2)}\n`); const receipts = result.receipts .map( @@ -57,6 +88,10 @@ ${actions} Empty or partially populated provider batches that omit receipt or line-item collections produce deterministic empty review evidence instead of runtime failures. The empty batch fixture reviewed ${emptyResult.receipts.length} receipts and generated ${emptyResult.remediationActions.length} remediation actions. +## Malformed Billing Field Guard + +Receipts with non-numeric totals, quantities, or line-item amounts are held before delivery. The malformed fixture decision is ${malformedResult.receipts[0].decision}, and customer-facing numeric fields are redacted to ${malformedResult.receipts[0].customerCopy.totalCents}. + ## Safety All fixtures are synthetic. The guard does not call payment processors, customer systems, private workspaces, institutional finance tools, or external APIs. @@ -81,6 +116,7 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, emptyPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Deliverable receipts: ${result.summary.deliverableReceipts}`); diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index 454e4fbd..6f392ce8 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -61,17 +61,21 @@ function hasPrivateContext(value) { } function lineItemPrivacyFindings(lineItem) { - return findingsForText( - stableStringify({ - id: lineItem.id, - description: lineItem.description, - projectRef: lineItem.projectRef, - quantity: lineItem.quantity, - usageCategory: lineItem.usageCategory, - unit: lineItem.unit, - amountCents: lineItem.amountCents - }) - ); + return Array.from(new Set([ + ...findingsForText( + stableStringify({ + id: lineItem.id, + description: lineItem.description, + projectRef: lineItem.projectRef, + quantity: lineItem.quantity, + usageCategory: lineItem.usageCategory, + unit: lineItem.unit, + amountCents: lineItem.amountCents + }) + ), + ...numericFieldFindings(lineItem.quantity, 'quantity'), + ...numericFieldFindings(lineItem.amountCents, 'cents') + ])).sort(); } function categoryDescription(lineItem) { @@ -102,9 +106,9 @@ function sanitizeLineItem(lineItem, index) { return { id, usageCategory, - quantity: sanitizeCustomerNumber(lineItem.quantity), + quantity: sanitizeCustomerNumber(lineItem.quantity, 'quantity'), unit, - amountCents: sanitizeCustomerNumber(lineItem.amountCents), + amountCents: sanitizeCustomerNumber(lineItem.amountCents, 'cents'), description, findings }; @@ -119,10 +123,13 @@ function receiptIdentifierFindings(receipt) { } function receiptEnvelopeFindings(receipt) { - return findingsForText(stableStringify({ - currency: receipt.currency, - totalCents: receipt.totalCents - })); + return Array.from(new Set([ + ...findingsForText(stableStringify({ + currency: receipt.currency, + totalCents: receipt.totalCents + })), + ...numericFieldFindings(receipt.totalCents, 'cents') + ])).sort(); } function sanitizeIdentifier(value, fallback) { @@ -133,8 +140,20 @@ function sanitizeCurrency(value) { return hasPrivateContext(value) ? 'XXX' : value; } -function sanitizeCustomerNumber(value) { - return hasPrivateContext(value) ? null : value; +function sanitizeCustomerNumber(value, kind = 'number') { + return hasPrivateContext(value) || numericFieldFindings(value, kind).length > 0 ? null : value; +} + +function numericFieldFindings(value, kind) { + if (kind === 'cents') { + return Number.isInteger(value) && value >= 0 ? [] : ['invalid-billing-amount']; + } + + if (kind === 'quantity') { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? [] : ['invalid-billing-quantity']; + } + + return typeof value === 'number' && Number.isFinite(value) ? [] : ['invalid-billing-number']; } function sanitizeMetadata(metadata = {}) { @@ -193,7 +212,7 @@ function evaluateReceipt(receipt, index) { const safeInvoiceId = sanitizeIdentifier(receipt.invoiceId, `invoice-redacted-${index + 1}`); const safeCustomerId = sanitizeIdentifier(receipt.customerId, `customer-redacted-${index + 1}`); const safeCurrency = sanitizeCurrency(receipt.currency); - const safeTotalCents = sanitizeCustomerNumber(receipt.totalCents); + const safeTotalCents = sanitizeCustomerNumber(receipt.totalCents, 'cents'); const customerCopy = { receiptId: safeReceiptId, @@ -245,6 +264,13 @@ function remediationAction(receipt) { return 'replace-restricted-dataset-detail-with-usage-category'; } + if ( + receipt.findings.includes('invalid-billing-amount') || + receipt.findings.includes('invalid-billing-quantity') + ) { + return 'repair-malformed-billing-fields-before-delivery'; + } + return 'redact-private-research-context'; } diff --git a/billing-receipt-privacy-guard/reports/malformed-receipt-privacy-packet.json b/billing-receipt-privacy-guard/reports/malformed-receipt-privacy-packet.json new file mode 100644 index 00000000..65801911 --- /dev/null +++ b/billing-receipt-privacy-guard/reports/malformed-receipt-privacy-packet.json @@ -0,0 +1,70 @@ +{ + "batchId": "billing-malformed-fields-review-20", + "generatedAt": "2026-05-30T12:15:00Z", + "receipts": [ + { + "id": "receipt-malformed-numeric-fields", + "invoiceId": "inv-malformed-numeric-fields", + "customerId": "customer-lab-013", + "decision": "hold-for-finance-review", + "findings": [ + "invalid-billing-amount", + "invalid-billing-quantity" + ], + "removedMetadataKeys": [], + "providerMetadata": { + "accountRef": "acct-lab-013", + "billingPeriod": "2026-05", + "invoiceRef": "inv-malformed-numeric-fields", + "plan": "lab-pro" + }, + "redactedLineItems": [ + { + "id": "line-malformed-quantity", + "description": "Research platform service", + "usageCategory": "subscription", + "findings": [ + "invalid-billing-amount", + "invalid-billing-quantity" + ] + } + ], + "customerCopy": { + "receiptId": "receipt-malformed-numeric-fields", + "customerId": "customer-lab-013", + "currency": "USD", + "totalCents": null, + "lineItems": [ + { + "id": "line-malformed-quantity", + "description": "Research platform service", + "usageCategory": "subscription", + "quantity": null, + "unit": "month", + "amountCents": null + } + ] + }, + "auditDigest": "sha256:4a5ec7c1505990fe77bed26177f50c19d29b2cd8179bae3f19c95478af361100" + } + ], + "remediationActions": [ + { + "id": "remediate-receipt-malformed-numeric-fields", + "receiptId": "receipt-malformed-numeric-fields", + "action": "repair-malformed-billing-fields-before-delivery", + "priority": "normal", + "findings": [ + "invalid-billing-amount", + "invalid-billing-quantity" + ] + } + ], + "summary": { + "deliverableReceipts": 0, + "heldReceipts": 1, + "remediationActions": 1, + "totalCentsReviewed": 0 + }, + "auditDigest": "sha256:b2b35b8d83e1bf7fab9388e8086eb7f2cc5fc5ad226bb2b9e80203b5e2087066" +} diff --git a/billing-receipt-privacy-guard/reports/receipt-privacy-report.md b/billing-receipt-privacy-guard/reports/receipt-privacy-report.md index 07c694c0..50ef77c1 100644 --- a/billing-receipt-privacy-guard/reports/receipt-privacy-report.md +++ b/billing-receipt-privacy-guard/reports/receipt-privacy-report.md @@ -24,6 +24,10 @@ Generated: 2026-05-28T09:00:00Z Empty or partially populated provider batches that omit receipt or line-item collections produce deterministic empty review evidence instead of runtime failures. The empty batch fixture reviewed 0 receipts and generated 0 remediation actions. +## Malformed Billing Field Guard + +Receipts with non-numeric totals, quantities, or line-item amounts are held before delivery. The malformed fixture decision is hold-for-finance-review, and customer-facing numeric fields are redacted to null. + ## Safety All fixtures are synthetic. The guard does not call payment processors, customer systems, private workspaces, institutional finance tools, or external APIs. diff --git a/billing-receipt-privacy-guard/requirements-map.md b/billing-receipt-privacy-guard/requirements-map.md index 818afb87..f01a8496 100644 --- a/billing-receipt-privacy-guard/requirements-map.md +++ b/billing-receipt-privacy-guard/requirements-map.md @@ -10,6 +10,7 @@ - Redacts receipt, invoice, and customer identifiers when they carry private project, dataset, or collaborator context. - Redacts unsafe customer-facing currency labels when they carry restricted dataset context. - Redacts unsafe customer-facing totals, quantities, and line-item amounts when they carry restricted dataset context. +- Holds receipts and redacts customer-facing totals, quantities, and line-item amounts when numeric billing fields are malformed or negative. - Redacts customer-facing line-item identifiers and units when they carry restricted dataset context. - Treats omitted receipt and line-item collections as empty billing evidence instead of crashing receipt review. - Preserves customer-useful totals, billing period, plan, and invoice references after redaction. diff --git a/billing-receipt-privacy-guard/test.js b/billing-receipt-privacy-guard/test.js index b3516188..5f050912 100644 --- a/billing-receipt-privacy-guard/test.js +++ b/billing-receipt-privacy-guard/test.js @@ -423,6 +423,51 @@ function testCustomerFacingMoneyAndQuantityFieldsAreRedacted() { assert.equal(JSON.stringify(result).includes('GSE-private'), false); } +function testMalformedCustomerFacingMoneyAndQuantityFieldsAreHeld() { + const batch = buildSampleBatch(); + batch.receipts = [ + { + id: 'receipt-malformed-numeric-fields', + invoiceId: 'inv-malformed-numeric-fields', + customerId: 'customer-lab-013', + currency: 'USD', + totalCents: 'free-form total', + providerMetadata: { + accountRef: 'acct-lab-013', + billingPeriod: '2026-05', + invoiceRef: 'inv-malformed-numeric-fields', + plan: 'lab-pro' + }, + lineItems: [ + { + id: 'line-malformed-quantity', + description: 'Lab Pro monthly subscription', + usageCategory: 'subscription', + quantity: 'one', + unit: 'month', + amountCents: -2500 + } + ] + } + ]; + + const result = evaluateReceiptPrivacy(batch); + const receipt = result.receipts[0]; + const lineItem = receipt.customerCopy.lineItems[0]; + + assert.equal(receipt.decision, 'hold-for-finance-review'); + assert.equal(receipt.findings.includes('invalid-billing-amount'), true); + assert.equal(receipt.findings.includes('invalid-billing-quantity'), true); + assert.equal(receipt.customerCopy.totalCents, null); + assert.equal(lineItem.quantity, null); + assert.equal(lineItem.amountCents, null); + assert.equal(result.summary.totalCentsReviewed, 0); + + const action = result.remediationActions[0]; + assert.equal(action.action, 'repair-malformed-billing-fields-before-delivery'); + assert.equal(action.priority, 'normal'); +} + function testCustomerCopyRemainsUsefulAfterRedaction() { const result = evaluateReceiptPrivacy(buildSampleBatch()); const receipt = byId(result.receipts, 'receipt-private-compute'); @@ -460,6 +505,7 @@ const tests = [ testMissingLineItemsAreTreatedAsEmptyReceiptLines, testCustomerFacingCurrencyLabelsAreRedacted, testCustomerFacingMoneyAndQuantityFieldsAreRedacted, + testMalformedCustomerFacingMoneyAndQuantityFieldsAreHeld, testCustomerCopyRemainsUsefulAfterRedaction, testAuditDigestIsDeterministicAndPrivateFree ]; From 6caa2e90e1409ba26727bc1d279066ee68077a34 Mon Sep 17 00:00:00 2001 From: KoiosSG Date: Sun, 31 May 2026 10:43:19 +0200 Subject: [PATCH 11/11] Handle malformed billing line items --- billing-receipt-privacy-guard/README.md | 3 +- .../acceptance-notes.md | 1 + billing-receipt-privacy-guard/demo.js | 27 +++++++ billing-receipt-privacy-guard/index.js | 19 ++++- .../make-demo-video.py | 3 +- .../reports/demo.mp4 | Bin 48322 -> 56900 bytes .../malformed-line-item-privacy-packet.json | 67 ++++++++++++++++++ .../reports/receipt-privacy-report.md | 4 ++ .../requirements-map.md | 1 + billing-receipt-privacy-guard/test.js | 35 +++++++++ 10 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 billing-receipt-privacy-guard/reports/malformed-line-item-privacy-packet.json diff --git a/billing-receipt-privacy-guard/README.md b/billing-receipt-privacy-guard/README.md index 318d5b78..5aa2da20 100644 --- a/billing-receipt-privacy-guard/README.md +++ b/billing-receipt-privacy-guard/README.md @@ -2,7 +2,7 @@ This module adds a focused Revenue Infrastructure slice for SCIBASE issue #20. It validates customer-facing invoices, receipts, and payment-provider metadata before billing artifacts leave SCIBASE. -The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, malformed monetary or quantity fields, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values and provider metadata key names. Missing receipt lists, line-item lists, and provider metadata are treated as empty billing evidence rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, metadata-key redaction handles, and deterministic audit evidence. +The guard detects private research project context, restricted dataset references, collaborator identifiers, grant-sensitive phrases, unsafe receipt identifiers, unsafe customer-facing envelope fields, malformed monetary or quantity fields, malformed line-item entries, unsafe line-item fields, and unsafe provider metadata, including nested provider metadata values and provider metadata key names. Missing receipt lists, line-item lists, and provider metadata are treated as empty billing evidence rather than crashing receipt review. Safe receipts remain deliverable, while unsafe receipts are held for finance review with redacted replacement identifiers, safe currency labels, replacement line items, metadata-key redaction handles, and deterministic audit evidence. ## Run @@ -17,6 +17,7 @@ npm run check - `reports/receipt-privacy-packet.json` - `reports/malformed-receipt-privacy-packet.json` +- `reports/malformed-line-item-privacy-packet.json` - `reports/receipt-privacy-report.md` - `reports/summary.svg` - `reports/demo.mp4` diff --git a/billing-receipt-privacy-guard/acceptance-notes.md b/billing-receipt-privacy-guard/acceptance-notes.md index 738c0759..275863f2 100644 --- a/billing-receipt-privacy-guard/acceptance-notes.md +++ b/billing-receipt-privacy-guard/acceptance-notes.md @@ -21,6 +21,7 @@ Validation coverage: - customer-facing currency labels are replaced with `XXX` when they carry restricted dataset context - customer-facing totals, quantities, and line-item amounts are replaced with `null` when they carry restricted dataset context - customer-facing totals, quantities, and line-item amounts are replaced with `null` when they are malformed or negative, even without private research text +- malformed line-item entries are held with `malformed-line-item` findings instead of crashing receipt review - customer-facing line-item identifiers and units are redacted when they contain restricted dataset context - missing provider metadata is treated as an empty provider packet instead of crashing receipt review - missing receipt and line-item collections are treated as empty billing evidence instead of crashing receipt review diff --git a/billing-receipt-privacy-guard/demo.js b/billing-receipt-privacy-guard/demo.js index 331f0bf5..ac1e4347 100644 --- a/billing-receipt-privacy-guard/demo.js +++ b/billing-receipt-privacy-guard/demo.js @@ -39,16 +39,38 @@ const malformedResult = evaluateReceiptPrivacy({ } ] }); +const malformedLineItemResult = evaluateReceiptPrivacy({ + batchId: 'billing-malformed-line-item-review-20', + generatedAt: '2026-05-31T08:45:00Z', + receipts: [ + { + id: 'receipt-malformed-line-item', + invoiceId: 'inv-malformed-line-item', + customerId: 'customer-lab-014', + currency: 'USD', + totalCents: 19900, + providerMetadata: { + accountRef: 'acct-lab-014', + billingPeriod: '2026-05', + invoiceRef: 'inv-malformed-line-item', + plan: 'lab-pro' + }, + lineItems: [null] + } + ] +}); const packetPath = path.join(reportsDir, 'receipt-privacy-packet.json'); const emptyPacketPath = path.join(reportsDir, 'empty-receipt-privacy-packet.json'); const malformedPacketPath = path.join(reportsDir, 'malformed-receipt-privacy-packet.json'); +const malformedLineItemPacketPath = path.join(reportsDir, 'malformed-line-item-privacy-packet.json'); const reportPath = path.join(reportsDir, 'receipt-privacy-report.md'); const svgPath = path.join(reportsDir, 'summary.svg'); fs.writeFileSync(packetPath, `${JSON.stringify(result, null, 2)}\n`); fs.writeFileSync(emptyPacketPath, `${JSON.stringify(emptyResult, null, 2)}\n`); fs.writeFileSync(malformedPacketPath, `${JSON.stringify(malformedResult, null, 2)}\n`); +fs.writeFileSync(malformedLineItemPacketPath, `${JSON.stringify(malformedLineItemResult, null, 2)}\n`); const receipts = result.receipts .map( @@ -92,6 +114,10 @@ Empty or partially populated provider batches that omit receipt or line-item col Receipts with non-numeric totals, quantities, or line-item amounts are held before delivery. The malformed fixture decision is ${malformedResult.receipts[0].decision}, and customer-facing numeric fields are redacted to ${malformedResult.receipts[0].customerCopy.totalCents}. +## Malformed Line Item Guard + +Malformed line-item entries are held before delivery instead of crashing receipt review. The malformed line-item fixture decision is ${malformedLineItemResult.receipts[0].decision}, and the customer-facing line item id is ${malformedLineItemResult.receipts[0].customerCopy.lineItems[0].id}. + ## Safety All fixtures are synthetic. The guard does not call payment processors, customer systems, private workspaces, institutional finance tools, or external APIs. @@ -117,6 +143,7 @@ fs.writeFileSync(svgPath, svg); console.log(`Wrote ${path.relative(__dirname, packetPath)}`); console.log(`Wrote ${path.relative(__dirname, emptyPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, malformedPacketPath)}`); +console.log(`Wrote ${path.relative(__dirname, malformedLineItemPacketPath)}`); console.log(`Wrote ${path.relative(__dirname, reportPath)}`); console.log(`Wrote ${path.relative(__dirname, svgPath)}`); console.log(`Deliverable receipts: ${result.summary.deliverableReceipts}`); diff --git a/billing-receipt-privacy-guard/index.js b/billing-receipt-privacy-guard/index.js index 6f392ce8..1a344b1f 100644 --- a/billing-receipt-privacy-guard/index.js +++ b/billing-receipt-privacy-guard/index.js @@ -60,6 +60,10 @@ function hasPrivateContext(value) { return findingsForText(privacyText(value)).length > 0; } +function isRecord(value) { + return value && typeof value === 'object' && !Array.isArray(value); +} + function lineItemPrivacyFindings(lineItem) { return Array.from(new Set([ ...findingsForText( @@ -95,6 +99,18 @@ function categoryDescription(lineItem) { } function sanitizeLineItem(lineItem, index) { + if (!isRecord(lineItem)) { + return { + id: `line-malformed-${index + 1}`, + usageCategory: 'billing-line-repair', + quantity: null, + unit: 'usage-unit', + amountCents: null, + description: 'Malformed billing line item requires finance repair', + findings: ['malformed-line-item'] + }; + } + const findings = lineItemPrivacyFindings(lineItem); const description = findings.length > 0 ? categoryDescription(lineItem) : lineItem.description; const id = hasPrivateContext(lineItem.id) ? `line-redacted-${index + 1}` : lineItem.id; @@ -266,7 +282,8 @@ function remediationAction(receipt) { if ( receipt.findings.includes('invalid-billing-amount') || - receipt.findings.includes('invalid-billing-quantity') + receipt.findings.includes('invalid-billing-quantity') || + receipt.findings.includes('malformed-line-item') ) { return 'repair-malformed-billing-fields-before-delivery'; } diff --git a/billing-receipt-privacy-guard/make-demo-video.py b/billing-receipt-privacy-guard/make-demo-video.py index a761a224..6ef6e56e 100644 --- a/billing-receipt-privacy-guard/make-demo-video.py +++ b/billing-receipt-privacy-guard/make-demo-video.py @@ -30,7 +30,8 @@ def draw_frame_with_pillow(): draw.text((96, 248), "Private project and dataset details are redacted", fill="#dff5d5", font=body_font) draw.text((96, 306), "Unsafe receipts are held for finance review", fill="#dff5d5", font=body_font) draw.text((96, 364), "Sparse provider batches produce deterministic empty evidence", fill="#dff5d5", font=body_font) - draw.text((96, 456), "Synthetic data only. No payment, customer, or workspace systems are called.", fill="#ffd37a", font=note_font) + draw.text((96, 422), "Malformed line items become repair evidence, not runtime failures", fill="#dff5d5", font=body_font) + draw.text((96, 514), "Synthetic data only. No payment, customer, or workspace systems are called.", fill="#ffd37a", font=note_font) image.save(FRAME) diff --git a/billing-receipt-privacy-guard/reports/demo.mp4 b/billing-receipt-privacy-guard/reports/demo.mp4 index b7062cbd5b666eb7bbf88d7d180cf8bce109b80b..1f3d52756f5e9da78e29337f57acc9ba6c1b4337 100644 GIT binary patch delta 21681 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 Q>q4-=Vs+LsP!as!0IM`N%m4rY delta 13125 zcmeHtbyQqkmTwgf!QI^n5Zv7%xCVy=cZWbI+`X{i9)fG|;10pvT>=zN@JGJxo_Rg1 z`~o5snvOdlBouj(^|(e)fAT`Xax_>@V_P*Mt7M0sfo* zFZlmW2LEUNRxh}}XVm0xQ~3LV=!^Vq3jb#C&-BbMw!Z`XP5=7kUi7cy-zoS#{v%~z zI|L9^?R@PCLK75J3|#HkS0c8cB;+>$0M&67H9)*W&j>hkGYGeZHz0{cT3>Z?MF{<@ zlmEEpb&9kmc8xZSKYaZ@IA#jX`bd()s+?)nqa7``KOQP?@2))NwJLTI=!uwrDc0O7 zIOkP=)2Q@mc?k@O*OJcV+gB0}Ys@f$>C#2lx@0ZMlc;ZQaE74l79V#&Y?@d#x?@)F zWeb{|nu(|&?+VH9P~b17-k&?u>_*y?Pg%(2$mG32W^XxaJ0~I7KE^niNc(PdvQzIH zPJ_k&19UPzzF;ru`&EaSa%SGEMQ9)a@vW}yK;rG70 z9Q~yjfxPP7Fxs#Ed&g%jp#9cVO9YZ@@6}O;FBWj$aYcjYAlj-HRbFkEmey?K*Nw<$ z*RU__-#~f;y26?Vo*S#!N!dFaGsUK0uzr7Q z#W(4=9cRS?vj27#5e1R2+A(&Y>ST{kFQqcOh7~=KQ+Ydsd$*-cj&`g$)QH!JUAA7n zR7718MJplDd{4=V6Quc!&HuhII8LW%%}V{a;wH^;He;-;V{pnS)LUat5NDX_s6>#8 zfz6$C9zJsD3D~CxK~Vp2gHbFv6&b#Wwoeer&BR5AE2iZ4jViLByZZie&Y|N7;*9=_ zs%hsWw-mA}DkIaq%dBV>mp(d6f8yizjDpVVz!t%-Y{f50V-wWYY=R$G6{s5c#_o)w zN7f^8cW@Ky6)f%AEQYSSY}*hXdP748qxd`wlbW9?-N&yL@W#0iuK;2TCY-|f0E6N0gGgPZmN`Xz zW=kI-WM4Z)GkYho&&zkk)GLnSilw6r+mVzh{R-kDn1AI!%>)zg93=jc^6B_Xc-^n$ zy4893Su1pvA)F9M7VpGDx_91<`6pl!l6zjaV!HC;6Y*!{iQE+2e2~kP+`Ch%6OxCX zC`$5KXhslx*Ki>vF1wJ*1H{*L2g+*m4v^M;RewTⅆe-i+)3|k5|v?)jZPlY}FYZ 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 yI