From 325c73f595b237934c447b3b25253689492d7f71 Mon Sep 17 00:00:00 2001 From: Will Haynes Date: Thu, 19 Feb 2026 16:31:58 -0600 Subject: [PATCH 1/3] chore(codex): phase0 spine lockdown task spec --- codex/tasks/latest.json | 63 +++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/codex/tasks/latest.json b/codex/tasks/latest.json index 54ecd3d5..2286dc6e 100644 --- a/codex/tasks/latest.json +++ b/codex/tasks/latest.json @@ -1,49 +1,44 @@ { - "task_id": "meaning-contract-firewall-2026-02-19", - "title": "Meaning Contract firewall + narrative toggle + telemetry determinism", - "summary": "Harden the Meaning Output Contract at the reflection boundary: do not persist non-generic meaning without validated receipts; add an in-scope narrative toggle enforced deterministically; ensure Langfuse remains no-op/stubbed in tests and never produces outbound/flaky handles. Add/adjust tests to prevent regressions.", + "task_id": "phase0-spine-lockdown-2026-02-19", + "title": "Phase 0 Spine Lockdown: freeze contract vocab, kill ambiguous receipt offsets, harden emission + narrative policy", "base_branch": "develop", - "branch_name": "codex/meaning-contract-firewall-exec-2026-02-19", + "branch_name": "codex/phase0-spine-lockdown-exec-2026-02-19", + "summary": "Seal the Meaning Spine by freezing contract reason codes, enforcing unique-match offset inference (ambiguity=poison), ensuring ENTRY_ANALYZED emits contract+sanitized cards only (no raw reflection text), and locking narrative toggle behind a shared policy utility. Add/adjust tests to prevent regressions.", "repo_scope": [ "codex/tasks/latest.json", "server/src/workers/reflection.worker.js", "server/src/utils/truthValidator.js", - "server/tests/receipt.v1.test.js", - "server/tests/outboundPolicy.test.ts", - "server/tests/msw/handlers/infra.ts", - "server/utils/langfuse.js", - "server/utils/withLangfuseTrace.js" + "server/src/utils/**", + "server/src/workers/__tests__/**", + "server/tests/**", + "docs/testing-doctrine.md" ], - "agents_involved": ["codex-web"], - "risk_level": "medium", + "agents_involved": ["codex_web"], + "risk_level": "low", "tests_to_run": [ - "node -e \"JSON.parse(require('fs').readFileSync('codex/tasks/latest.json','utf8')); console.log('latest.json ok')\"", "node scripts/codex_preflight.mjs --ci", - "pnpm --filter server test tests/receipt.v1.test.js", - "pnpm --filter server test tests/outboundPolicy.test.ts" + "pnpm -C server test", + "pnpm -w test" ], "constraints": [ - "Codex Web environment: do NOT run git network commands (fetch/pull/push/clone). Use the UI 'Create PR' button only if there is a real diff.", - "No changes outside repo_scope. If a necessary fix is out of scope, STOP and report it with a minimal Repair Manifest (file list + reason).", - "Avoid formatting-only churn. Changes must be functional and proven by tests.", - "Determinism: tests must not access the real network. If a network attempt occurs, it must fail loudly and deterministically.", - "Do not add new runtime dependencies unless absolutely required; prefer small pure functions and unit tests.", - "If any file outside repo_scope becomes modified (e.g., pnpm-workspace.yaml), revert it immediately and continue. Do not include out-of-scope diffs in PR.", - "Narrative toggle MUST be implemented in-scope as an env flag (NARRATIVE_ENABLED). Do not search for an existing toggle elsewhere; define and enforce it here." + "CODEX_WEB: Do not run git push. Use the UI 'Create PR' button if a PR is needed.", + "ANTI-COP-OUT: No diff => no PR. If there is no actionable work, stop and report evidence.", + "SCOPE: Do not modify files outside repo_scope. If out-of-scope issues are found, produce a Repair Manifest instead of changing them.", + "ALIGNMENT: Print task_id/base_branch/branch_name/canary from latest.json before doing any work.", + "EVIDENCE_BUNDLE: Provide evidence in 4 phases: Alignment, Work-Exists Gate, Change Proof, Tests.", + "PR_BASE: Ensure PR base branch is develop (not another codex/* branch). Do not create draft PRs.", + "NO_PLACEHOLDERS: Do not create empty directories or placeholder files. Only create files with real content and tests.", + "NO_NETWORK: Tests must not touch real external network services." ], "acceptance_checks": [ - "Alignment Evidence: print task_id/base_branch/branch_name/canary/repo_scope/tests_to_run from codex/tasks/latest.json at the start.", - "Work-Exists Gate: identify exact code paths enforcing receipts + storage/emission boundaries; cite file+line targets before editing.", - "Meaning Contract: if bloom cards are empty after receipt validation, do NOT persist raw model reflectionText; persist a deterministic generic placeholder instead (no non-generic claims without receipts).", - "Narrative toggle: implement NARRATIVE_ENABLED env flag in server/src/workers/reflection.worker.js. When NARRATIVE_ENABLED='false', drop NARRATIVE/narrative cards and ensure they are not persisted/emitted; add a focused test proving this.", - "Telemetry determinism: Langfuse remains no-op in test/CI; outbound policy tests remain deterministic and green.", - "Change Proof: show git status -sb and git diff --stat after edits.", - "Tests: all commands in tests_to_run pass.", - "No diff => no PR. If no changes are needed, stop and explain why with evidence." + "Alignment Evidence: show codex/tasks/latest.json values for task_id, base_branch, branch_name, and canary.", + "Work-Exists Gate: prove target symbols exist (findReceiptOffsets / emitEntryAnalyzed / sanitizeBloomCardsWithContract / validateReceipt) via grep or file navigation; if not found, stop and report.", + "Freeze contract reason codes: add a shared constants module and replace raw string comparisons in the Meaning Spine paths touched by this task.", + "Unique Match Rule: any transcript-search offset inference must return null on ambiguous multi-occurrence matches (firstIndex !== lastIndex). Ambiguity must drop the receipt/card safely.", + "validateReceipt hardening: strict V1 path must not fall through to weaker matching if offsets fail; invalid shapes return an explicit failure reason.", + "Emission hardening: ENTRY_ANALYZED payload must contain sanitized cards and the Meaning Contract ledger; payload must not include raw reflection text anywhere.", + "Tests: add/adjust tests that fail if raw model output leaks into emission; add/adjust tests that verify ambiguous quote matches are dropped.", + "Proof: include git status -sb and git diff --stat after changes; run tests_to_run and report results." ], - "_meta": { - "canary": "meaning.contract.firewall.2026-02-19.canary.a1", - "created_at": "2026-02-19", - "source": "handoff+preflight-discovery" - } + "canary": "CANARY_PHASE0_SPINE_LOCKDOWN_2026_02_19" } From 19b227c3926c8305be319e4a3abd1faa8ee6b15f Mon Sep 17 00:00:00 2001 From: Will Haynes Date: Thu, 19 Feb 2026 16:36:09 -0600 Subject: [PATCH 2/3] chore(codex): tighten phase0 spine lockdown spec for codex web --- codex/tasks/latest.json | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/codex/tasks/latest.json b/codex/tasks/latest.json index 2286dc6e..3a88eb3a 100644 --- a/codex/tasks/latest.json +++ b/codex/tasks/latest.json @@ -3,7 +3,7 @@ "title": "Phase 0 Spine Lockdown: freeze contract vocab, kill ambiguous receipt offsets, harden emission + narrative policy", "base_branch": "develop", "branch_name": "codex/phase0-spine-lockdown-exec-2026-02-19", - "summary": "Seal the Meaning Spine by freezing contract reason codes, enforcing unique-match offset inference (ambiguity=poison), ensuring ENTRY_ANALYZED emits contract+sanitized cards only (no raw reflection text), and locking narrative toggle behind a shared policy utility. Add/adjust tests to prevent regressions.", + "summary": "Seal the Meaning Spine by freezing contract reason codes, enforcing unique-match offset inference (ambiguity=poison), hardening validateReceipt (strict V1 never falls through), ensuring ENTRY_ANALYZED emits contract+sanitized cards only (no raw reflection text), and locking narrative toggle behind a shared policy utility that callers cannot override. Add/adjust regression tests to prevent drift.", "repo_scope": [ "codex/tasks/latest.json", "server/src/workers/reflection.worker.js", @@ -17,12 +17,12 @@ "risk_level": "low", "tests_to_run": [ "node scripts/codex_preflight.mjs --ci", - "pnpm -C server test", - "pnpm -w test" + "pnpm -C server test" ], "constraints": [ - "CODEX_WEB: Do not run git push. Use the UI 'Create PR' button if a PR is needed.", - "ANTI-COP-OUT: No diff => no PR. If there is no actionable work, stop and report evidence.", + "CODEX_WEB: Do NOT run git network commands (no git fetch/pull/push/clone). Use the UI “Create PR” button if a PR is needed.", + "CODEX_WEB_HEAD: In Codex Web, the checked-out branch name may be 'work'. Do NOT treat HEAD name mismatch as stale. Locks+canary are the source of truth.", + "ANTI-COP-OUT: No diff => no PR. If no actionable work exists, stop and report evidence.", "SCOPE: Do not modify files outside repo_scope. If out-of-scope issues are found, produce a Repair Manifest instead of changing them.", "ALIGNMENT: Print task_id/base_branch/branch_name/canary from latest.json before doing any work.", "EVIDENCE_BUNDLE: Provide evidence in 4 phases: Alignment, Work-Exists Gate, Change Proof, Tests.", @@ -32,13 +32,14 @@ ], "acceptance_checks": [ "Alignment Evidence: show codex/tasks/latest.json values for task_id, base_branch, branch_name, and canary.", - "Work-Exists Gate: prove target symbols exist (findReceiptOffsets / emitEntryAnalyzed / sanitizeBloomCardsWithContract / validateReceipt) via grep or file navigation; if not found, stop and report.", - "Freeze contract reason codes: add a shared constants module and replace raw string comparisons in the Meaning Spine paths touched by this task.", - "Unique Match Rule: any transcript-search offset inference must return null on ambiguous multi-occurrence matches (firstIndex !== lastIndex). Ambiguity must drop the receipt/card safely.", - "validateReceipt hardening: strict V1 path must not fall through to weaker matching if offsets fail; invalid shapes return an explicit failure reason.", - "Emission hardening: ENTRY_ANALYZED payload must contain sanitized cards and the Meaning Contract ledger; payload must not include raw reflection text anywhere.", - "Tests: add/adjust tests that fail if raw model output leaks into emission; add/adjust tests that verify ambiguous quote matches are dropped.", - "Proof: include git status -sb and git diff --stat after changes; run tests_to_run and report results." + "Alignment Evidence: print `git rev-parse --abbrev-ref HEAD` and `git rev-parse HEAD` for evidence; do NOT stop on SHA mismatch.", + "Work-Exists Gate: prove target symbols exist via grep or file navigation; if not found, stop and report: findReceiptOffsets (or equivalent), emitEntryAnalyzed callsite/payload, sanitizeBloomCardsWithContract boundary, validateReceipt in server/src/utils/truthValidator.js (or its imported helpers).", + "Freeze contract reason codes: add a shared constants module and replace raw string comparisons/assignments in Meaning Spine paths touched by this task.", + "Unique Match Rule: any transcript-search offset inference must return null on ambiguous multi-occurrence matches (firstIndex !== lastIndex). Ambiguity must drop the receipt/card safely and be reflected in contract/dropped reasons.", + "validateReceipt hardening: strict V1 path must not fall through to weaker matching if offsets fail; invalid shapes return explicit failure reasons and do not throw.", + "Emission hardening: ENTRY_ANALYZED payload must contain sanitized cards AND the Meaning Contract ledger; payload must not include raw reflection text anywhere.", + "Tests: add/adjust regression tests that fail if raw model output leaks into emission serialization; add/adjust tests verifying ambiguous quote matches are dropped.", + "Proof: include git status -sb and git diff --stat after changes; run tests_to_run and report results. (Run `pnpm -w test` locally after PR if desired.)" ], "canary": "CANARY_PHASE0_SPINE_LOCKDOWN_2026_02_19" } From fee2dd4c813e71acfac1365a3944f78827fd025a Mon Sep 17 00:00:00 2001 From: William Leland Haynes <142263841+wileland@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:10:08 -0600 Subject: [PATCH 3/3] fix(server): lock meaning spine contracts and receipt validation --- server/src/utils/meaningSpineContracts.js | 19 +++++ server/src/utils/narrativePolicy.js | 10 +++ server/src/utils/truthValidator.js | 32 +++++--- .../__tests__/reflection.worker.test.ts | 28 +++++++ server/src/workers/reflection.worker.js | 79 ++++++++++--------- server/tests/receipt.v1.test.js | 62 ++++++++++----- 6 files changed, 165 insertions(+), 65 deletions(-) create mode 100644 server/src/utils/meaningSpineContracts.js create mode 100644 server/src/utils/narrativePolicy.js diff --git a/server/src/utils/meaningSpineContracts.js b/server/src/utils/meaningSpineContracts.js new file mode 100644 index 00000000..05e88147 --- /dev/null +++ b/server/src/utils/meaningSpineContracts.js @@ -0,0 +1,19 @@ +export const CONTRACT_REASONS = Object.freeze({ + OK: 'OK', + NO_RECEIPTS: 'NO_RECEIPTS', + NARRATIVE_FILTERED: 'NARRATIVE_FILTERED', + MALFORMED_INPUT: 'MALFORMED_INPUT', +}); + +export const RECEIPT_VALIDATION_REASONS = Object.freeze({ + EMPTY_TRANSCRIPT: 'EMPTY_TRANSCRIPT', + INVALID_RECEIPT_SHAPE: 'INVALID_RECEIPT_SHAPE', + MISSING_REQUIRED_FIELDS: 'MISSING_REQUIRED_FIELDS', + QUOTE_TOO_SHORT: 'QUOTE_TOO_SHORT', + TRANSCRIPT_HASH_MISMATCH: 'TRANSCRIPT_HASH_MISMATCH', + OFFSET_MISMATCH: 'OFFSET_MISMATCH', + OFFSET_AMBIGUOUS: 'OFFSET_AMBIGUOUS', + NOT_FOUND: 'NOT_FOUND', + QUOTE_HASH_MISMATCH: 'QUOTE_HASH_MISMATCH', + VALID: 'VALID', +}); diff --git a/server/src/utils/narrativePolicy.js b/server/src/utils/narrativePolicy.js new file mode 100644 index 00000000..ec57822a --- /dev/null +++ b/server/src/utils/narrativePolicy.js @@ -0,0 +1,10 @@ +const parseEnvFlag = (value, defaultEnabled = true) => { + if (value == null) return defaultEnabled; + const v = String(value).trim().toLowerCase(); + if (v === '') return defaultEnabled; + if (v === 'false' || v === '0' || v === 'no' || v === 'off') return false; + if (v === 'true' || v === '1' || v === 'yes' || v === 'on') return true; + return defaultEnabled; +}; + +export const isNarrativeEnabled = () => parseEnvFlag(process.env.NARRATIVE_ENABLED, true); diff --git a/server/src/utils/truthValidator.js b/server/src/utils/truthValidator.js index ef5932b3..d8377ddb 100644 --- a/server/src/utils/truthValidator.js +++ b/server/src/utils/truthValidator.js @@ -1,5 +1,7 @@ import { createHash } from 'node:crypto'; +import { RECEIPT_VALIDATION_REASONS } from './meaningSpineContracts.js'; + /** * Escapes special characters for use in a regular expression. * This prevents injection attacks and ensures characters like ? or . are treated literally. @@ -87,12 +89,22 @@ const hasV1RequiredFields = (receipt) => { export const validateReceipt = (transcript, receiptOrQuote) => { const normalizedTranscript = normalizeReceiptText(transcript); if (!normalizedTranscript) { - return { ok: false, reason: 'EMPTY_TRANSCRIPT' }; + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.EMPTY_TRANSCRIPT }; + } + + const isObjectReceipt = receiptOrQuote && typeof receiptOrQuote === 'object'; + const isV1Receipt = isObjectReceipt && receiptOrQuote.version === 'v1'; + + if (isObjectReceipt && !isV1Receipt) { + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.INVALID_RECEIPT_SHAPE }; + } + + if (isV1Receipt && receiptOrQuote.offsetInference === 'AMBIGUOUS_MATCH') { + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.OFFSET_AMBIGUOUS }; } - const isV1Receipt = receiptOrQuote && typeof receiptOrQuote === 'object' && receiptOrQuote.version === 'v1'; if (isV1Receipt && !hasV1RequiredFields(receiptOrQuote)) { - return { ok: false, reason: 'MISSING_REQUIRED_FIELDS' }; + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.MISSING_REQUIRED_FIELDS }; } const quote = isV1Receipt ? resolveReceiptQuote(receiptOrQuote) : receiptOrQuote; @@ -101,7 +113,7 @@ export const validateReceipt = (transcript, receiptOrQuote) => { // A quote must be at least 16 characters to be considered a valid receipt. // This strict limit prevents agents from anchoring on common phrases like "i feel" or "it was". if (normalizedQuote.length < MIN_QUOTE_LENGTH) { - return { ok: false, reason: 'QUOTE_TOO_SHORT' }; + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.QUOTE_TOO_SHORT }; } if (isV1Receipt) { @@ -109,7 +121,7 @@ export const validateReceipt = (transcript, receiptOrQuote) => { const actualTranscriptHash = sha256Hex(normalizedTranscript); if (actualTranscriptHash !== expectedTranscriptHash) { - return { ok: false, reason: 'TRANSCRIPT_HASH_MISMATCH' }; + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.TRANSCRIPT_HASH_MISMATCH }; } const offsets = resolveOffsets(receiptOrQuote); @@ -120,13 +132,13 @@ export const validateReceipt = (transcript, receiptOrQuote) => { offsets.end > String(transcript || '').length; if (hasInvalidOffsets) { - return { ok: false, reason: 'OFFSET_MISMATCH' }; + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.OFFSET_MISMATCH }; } const transcriptSlice = String(transcript || '').slice(offsets.start, offsets.end); const normalizedSlice = normalizeReceiptText(transcriptSlice); if (normalizedSlice !== normalizedQuote) { - return { ok: false, reason: 'OFFSET_MISMATCH' }; + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.OFFSET_MISMATCH }; } } @@ -136,7 +148,7 @@ export const validateReceipt = (transcript, receiptOrQuote) => { const pattern = new RegExp(`(?:^|\\s)${escapedQuote}(?:$|\\s)`, 'i'); if (!pattern.test(normalizedTranscript)) { - return { ok: false, reason: 'NOT_FOUND' }; + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.NOT_FOUND }; } if (isV1Receipt) { @@ -144,11 +156,11 @@ export const validateReceipt = (transcript, receiptOrQuote) => { const actualQuoteHash = sha256Hex(normalizedQuote); if (actualQuoteHash !== expectedQuoteHash) { - return { ok: false, reason: 'QUOTE_HASH_MISMATCH' }; + return { ok: false, reason: RECEIPT_VALIDATION_REASONS.QUOTE_HASH_MISMATCH }; } } - return { ok: true, reason: 'VALID' }; + return { ok: true, reason: RECEIPT_VALIDATION_REASONS.VALID }; }; export default validateReceipt; diff --git a/server/src/workers/__tests__/reflection.worker.test.ts b/server/src/workers/__tests__/reflection.worker.test.ts index b0bb125f..fbe6909a 100644 --- a/server/src/workers/__tests__/reflection.worker.test.ts +++ b/server/src/workers/__tests__/reflection.worker.test.ts @@ -316,6 +316,7 @@ describe('reflection.worker', () => { entryId: expect.any(String), userId: 'user-1', cardsCreatedCount: expect.any(Number), + contract: expect.objectContaining({ hasReceiptedMeaning: true }), meaning: expect.objectContaining({ structuredData: expect.objectContaining({ bloom_cards: expect.any(Array), @@ -336,6 +337,33 @@ describe('reflection.worker', () => { expect(result).toEqual(doneTask); }); + + + it('never emits raw reflection text in ENTRY_ANALYZED payload serialization', async () => { + const runningTask = { _id: taskId, entryId, status: 'running' }; + const doneTask = { _id: taskId, entryId, status: 'done' }; + + mocks.findByIdAndUpdateMock.mockResolvedValueOnce(runningTask).mockResolvedValueOnce(doneTask); + mocks.findByIdMock.mockReturnValue( + mockLeanResult({ _id: entryId, userId: 'user-1', transcript: 'hello world transcript' }), + ); + + const rawModelText = 'RAW_REFLECTION_SHOULD_NOT_BE_EMITTED_12345'; + mocks.reflectEntryWithContextMock.mockResolvedValueOnce({ + reply: `${rawModelText} +${JSON.stringify([{ type: 'reflection', headline: 'Safe headline', confidence: 0.9, receipts: [{ quote: 'hello world transcript' }] }])}`, + traceId: 'trace-reflect-raw', + }); + + await handleReflectionJob({ data: { entryId, taskId } } as any); + + const emittedPayload = mocks.emitEntryAnalyzedMock.mock.calls.at(-1)?.[0]; + expect(emittedPayload).toBeTruthy(); + expect(JSON.stringify(emittedPayload)).not.toContain(rawModelText); + expect(emittedPayload?.meaning?.text).toBeUndefined(); + expect(emittedPayload?.meaning?.summary).toBeUndefined(); + }); + it('blocks observer context when calibrated confidence is below the threshold', async () => { mocks.findByIdAndUpdateMock .mockResolvedValueOnce({ _id: taskId, entryId, status: 'running' }) diff --git a/server/src/workers/reflection.worker.js b/server/src/workers/reflection.worker.js index c27405a5..23fba598 100644 --- a/server/src/workers/reflection.worker.js +++ b/server/src/workers/reflection.worker.js @@ -14,6 +14,8 @@ import { isWorkerDisabled } from '../utils/boolean.js'; import { buildMCPContext, reflectEntryWithContext } from '../../utils/contextRouter.js'; import { observeTranscript } from '../../agents/observer.js'; import { normalizeReceiptText, validateReceipt } from '../utils/truthValidator.js'; +import { CONTRACT_REASONS, RECEIPT_VALIDATION_REASONS } from '../utils/meaningSpineContracts.js'; +import { isNarrativeEnabled } from '../utils/narrativePolicy.js'; import { calibrateObserverConfidence, OBSERVER_CONFIDENCE_THRESHOLD, @@ -31,14 +33,6 @@ export const REFLECTION_MODE = 'reflection'; // Deterministic, non-deceptive terminal placeholder. const NO_RECEIPTED_MEANING_PLACEHOLDER = 'No receipted meaning available.'; -// Machine-readable contract flags (lets UI avoid string-coupling). -export const CONTRACT_REASONS = Object.freeze({ - OK: 'OK', - NO_RECEIPTS: 'NO_RECEIPTS', - NARRATIVE_FILTERED: 'NARRATIVE_FILTERED', - MALFORMED_INPUT: 'MALFORMED_INPUT', -}); - // Single canonicalization routine (boundary-only, not a safety module). // Keep stable across OS newline differences + Unicode composition. function canonicalizeText(input) { @@ -51,16 +45,6 @@ function canonicalizeText(input) { .replace(/[^\S\n]+/g, ' '); // collapse spaces/tabs, preserve newlines } -// Local, isolated environment flag parser to prevent VS Code / Test import errors -function parseEnvFlag(value, defaultEnabled = true) { - if (value == null) return defaultEnabled; - const v = String(value).trim().toLowerCase(); - if (v === '') return defaultEnabled; - if (v === 'false' || v === '0' || v === 'no' || v === 'off') return false; - if (v === 'true' || v === '1' || v === 'yes' || v === 'on') return true; - return defaultEnabled; -} - function logSafetyEvent({ severity = 'INFO', type, userId, entryId, details = {} }) { const payload = { event: 'SAFETY_EVENT', @@ -137,26 +121,48 @@ const resolveReceiptOffsets = (receipt) => { return null; }; -const findReceiptOffsets = (transcript, quote) => { +const hasAmbiguousQuoteMatch = (transcriptText, quoteText) => { + if (!quoteText) return false; + const firstIndex = transcriptText.indexOf(quoteText); + if (firstIndex < 0) return false; + return firstIndex !== transcriptText.lastIndexOf(quoteText); +}; + +const findUniqueReceiptOffsets = (transcript, quote) => { const transcriptText = String(transcript || ''); const quoteText = String(quote || ''); if (!quoteText) return null; const directIndex = transcriptText.indexOf(quoteText); - if (directIndex >= 0) return { start: directIndex, end: directIndex + quoteText.length }; + if (directIndex >= 0) { + if (hasAmbiguousQuoteMatch(transcriptText, quoteText)) return null; + return { start: directIndex, end: directIndex + quoteText.length }; + } const lowerTranscript = transcriptText.toLowerCase(); const lowerQuote = quoteText.toLowerCase(); const foldedIndex = lowerTranscript.indexOf(lowerQuote); - if (foldedIndex >= 0) return { start: foldedIndex, end: foldedIndex + quoteText.length }; + if (foldedIndex >= 0) { + if (hasAmbiguousQuoteMatch(lowerTranscript, lowerQuote)) return null; + return { start: foldedIndex, end: foldedIndex + quoteText.length }; + } return null; }; const toCanonicalReceiptV1 = ({ receipt, transcript, entryId, transcriptVersion }) => { const quote = getReceiptAnchor(receipt); - const offsets = resolveReceiptOffsets(receipt) || findReceiptOffsets(transcript, quote); + const explicitOffsets = resolveReceiptOffsets(receipt); + const inferredOffsets = explicitOffsets ? null : findUniqueReceiptOffsets(transcript, quote); + const offsets = explicitOffsets || inferredOffsets; + const transcriptText = String(transcript || ''); + const quoteText = String(quote || ''); + const isAmbiguousOffsetInference = + !explicitOffsets && + !inferredOffsets && + (hasAmbiguousQuoteMatch(transcriptText, quoteText) || + hasAmbiguousQuoteMatch(transcriptText.toLowerCase(), quoteText.toLowerCase())); const normalizedTranscript = normalizeReceiptText(transcript); const normalizedQuote = normalizeReceiptText(quote); @@ -168,6 +174,7 @@ const toCanonicalReceiptV1 = ({ receipt, transcript, entryId, transcriptVersion spanEnd: offsets?.end, transcriptHash: sha256Hex(normalizedTranscript), quoteHash: sha256Hex(normalizedQuote), + ...(isAmbiguousOffsetInference ? { offsetInference: 'AMBIGUOUS_MATCH' } : {}), ...(receipt && typeof receipt === 'object' && receipt.entryId ? { entryId: receipt.entryId } : {}), ...(receipt && typeof receipt === 'object' && receipt.timestamp ? { timestamp: receipt.timestamp } : {}), ...(receipt && typeof receipt === 'object' && receipt.startTimestamp ? { startTimestamp: receipt.startTimestamp } : {}), @@ -229,7 +236,7 @@ const coerceCards = ({ reply, transcript }) => { return [ { type: 'reflection', - headline: (reply || '').slice(0, 180).trim() || 'Reflection', + headline: 'Reflection', confidence: 0.5, receipts: fallbackAnchor ? [{ quote: fallbackAnchor }] : [], user_state: 'pending', @@ -301,6 +308,7 @@ function buildMeaningContract({ sanitizedCount, dropped }) { narrative_disabled: 0, missing_receipts: 0, receipt_not_found: 0, + ambiguous_receipt_offsets: 0, empty_headline: 0, ...((dropped && typeof dropped === 'object') ? dropped : {}), }; @@ -308,6 +316,7 @@ function buildMeaningContract({ sanitizedCount, dropped }) { d.narrative_disabled = toCount(d.narrative_disabled); d.missing_receipts = toCount(d.missing_receipts); d.receipt_not_found = toCount(d.receipt_not_found); + d.ambiguous_receipt_offsets = toCount(d.ambiguous_receipt_offsets); d.empty_headline = toCount(d.empty_headline); const count = toCount(sanitizedCount); @@ -315,9 +324,9 @@ function buildMeaningContract({ sanitizedCount, dropped }) { let reason = CONTRACT_REASONS.OK; if (count === 0) { - if (d.narrative_disabled > 0 && d.missing_receipts === 0 && d.receipt_not_found === 0 && d.empty_headline === 0) { + if (d.narrative_disabled > 0 && d.missing_receipts === 0 && d.receipt_not_found === 0 && d.ambiguous_receipt_offsets === 0 && d.empty_headline === 0) { reason = CONTRACT_REASONS.NARRATIVE_FILTERED; - } else if (d.missing_receipts > 0 || d.receipt_not_found > 0 || d.empty_headline > 0) { + } else if (d.missing_receipts > 0 || d.receipt_not_found > 0 || d.ambiguous_receipt_offsets > 0 || d.empty_headline > 0) { reason = CONTRACT_REASONS.NO_RECEIPTS; } else { reason = CONTRACT_REASONS.MALFORMED_INPUT; @@ -332,13 +341,14 @@ const _sanitizeBloomCardsInternal = ({ transcript, entryId, transcriptVersion, - narrativeEnabled = true, }) => { const transcriptText = typeof transcript === 'string' ? transcript : String(transcript || ''); + const narrativeEnabled = isNarrativeEnabled(); let droppedByNarrative = 0; let droppedByMissingReceipts = 0; let droppedByReceiptNotFound = 0; + let droppedByAmbiguousOffsets = 0; let droppedByEmptyHeadline = 0; const sanitized = (Array.isArray(cards) ? cards : []).reduce((acc, rawCard) => { @@ -377,7 +387,7 @@ const _sanitizeBloomCardsInternal = ({ } const validatedReceipts = []; - let failureReason = 'unknown'; + let failureReason = RECEIPT_VALIDATION_REASONS.NOT_FOUND; for (const receipt of receipts) { const candidateReceipt = @@ -400,7 +410,8 @@ const _sanitizeBloomCardsInternal = ({ } if (!validatedReceipts.length) { - droppedByReceiptNotFound += 1; + if (failureReason === RECEIPT_VALIDATION_REASONS.OFFSET_AMBIGUOUS) droppedByAmbiguousOffsets += 1; + else droppedByReceiptNotFound += 1; console.warn('[ReflectionFirewall] dropped_card_receipt_not_found', { headline: card.headline || null, reason: failureReason, @@ -453,6 +464,7 @@ const _sanitizeBloomCardsInternal = ({ narrative_disabled: droppedByNarrative, missing_receipts: droppedByMissingReceipts, receipt_not_found: droppedByReceiptNotFound, + ambiguous_receipt_offsets: droppedByAmbiguousOffsets, empty_headline: droppedByEmptyHeadline, }, }; @@ -474,7 +486,7 @@ const buildReflectionDoc = ({ cards, safety, contract }) => { hasReceiptedMeaning: Array.isArray(cards) && cards.length > 0, reason: Array.isArray(cards) && cards.length > 0 ? CONTRACT_REASONS.OK : CONTRACT_REASONS.NO_RECEIPTS, - dropped: { narrative_disabled: 0, missing_receipts: 0, receipt_not_found: 0, empty_headline: 0 }, + dropped: { narrative_disabled: 0, missing_receipts: 0, receipt_not_found: 0, ambiguous_receipt_offsets: 0, empty_headline: 0 }, }; return { @@ -760,15 +772,11 @@ export async function handleReflectionJob(job) { userId: userIdStr, }); - // Use isolated local flag parser to clear import/VS Code issues - const narrativeEnabled = parseEnvFlag(process.env.NARRATIVE_ENABLED, true); - const { cards: bloomCards, contract } = sanitizeBloomCardsWithContract({ cards: candidateCards, transcript: canonicalTranscript, entryId: entryIdStr, transcriptVersion, - narrativeEnabled, }); const safetyTier1 = @@ -803,9 +811,8 @@ export async function handleReflectionJob(job) { bloomCards, cardsCreatedCount: bloomCards.length, analyzedAt: new Date().toISOString(), + contract: reflectionDoc.structuredData?.contract || contract, meaning: { - text: reflectionDoc.text, - summary: reflectionDoc.summary, structuredData: { bloom_cards: bloomCards, contract: reflectionDoc.structuredData?.contract || contract, @@ -924,4 +931,4 @@ export async function closeReflectionWorker() { await reflectionWorkerInstance.disconnect?.(); reflectionWorkerInstance = undefined; } -} \ No newline at end of file +} diff --git a/server/tests/receipt.v1.test.js b/server/tests/receipt.v1.test.js index 2d1b6883..34bc3193 100644 --- a/server/tests/receipt.v1.test.js +++ b/server/tests/receipt.v1.test.js @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { beforeAll, afterAll, describe, expect, it, vi } from 'vitest'; import { normalizeReceiptText, validateReceipt } from '../src/utils/truthValidator.js'; +import { CONTRACT_REASONS, RECEIPT_VALIDATION_REASONS } from '../src/utils/meaningSpineContracts.js'; let sanitizeBloomCards; let sanitizeBloomCardsWithContract; @@ -103,7 +104,7 @@ describe('receipt v1 validation', () => { expect(validateReceipt(transcript, receipt)).toEqual({ ok: false, - reason: 'MISSING_REQUIRED_FIELDS', + reason: RECEIPT_VALIDATION_REASONS.MISSING_REQUIRED_FIELDS, }); }); @@ -117,7 +118,7 @@ describe('receipt v1 validation', () => { expect(validateReceipt(transcript, receipt)).toEqual({ ok: false, - reason: 'OFFSET_MISMATCH', + reason: RECEIPT_VALIDATION_REASONS.OFFSET_MISMATCH, }); }); @@ -134,7 +135,7 @@ describe('receipt v1 validation', () => { expect(validateReceipt(transcript, receipt)).toEqual({ ok: false, - reason: 'QUOTE_HASH_MISMATCH', + reason: RECEIPT_VALIDATION_REASONS.QUOTE_HASH_MISMATCH, }); }); @@ -150,7 +151,7 @@ describe('receipt v1 validation', () => { expect(validateReceipt(driftedTranscript, receipt)).toEqual({ ok: false, - reason: 'TRANSCRIPT_HASH_MISMATCH', + reason: RECEIPT_VALIDATION_REASONS.TRANSCRIPT_HASH_MISMATCH, }); }); @@ -168,7 +169,7 @@ describe('receipt v1 validation', () => { expect(validateReceipt(transcript, receipt)).toEqual({ ok: true, - reason: 'VALID', + reason: RECEIPT_VALIDATION_REASONS.VALID, }); }); }); @@ -201,7 +202,7 @@ describe('receipt firewall behavior', () => { cards: [], contract: { hasReceiptedMeaning: false, - reason: 'NO_RECEIPTS', + reason: CONTRACT_REASONS.NO_RECEIPTS, dropped: { receipt_not_found: 1, }, @@ -210,8 +211,7 @@ describe('receipt firewall behavior', () => { }); it("drops narrative cards when NARRATIVE_ENABLED='false' (case/whitespace tolerant)", () => { - const previous = process.env.NARRATIVE_ENABLED; - process.env.NARRATIVE_ENABLED = 'false'; + vi.stubEnv('NARRATIVE_ENABLED', 'false'); const transcript = 'I paused my reaction and took three breaths to calm my body before speaking with care.'; @@ -232,22 +232,17 @@ describe('receipt firewall behavior', () => { }, ]; - const { cards: sanitized, contract } = sanitizeBloomCardsWithContract({ - cards, - transcript, - narrativeEnabled: process.env.NARRATIVE_ENABLED !== 'false', - }); + const { cards: sanitized, contract } = sanitizeBloomCardsWithContract({ cards, transcript }); expect(sanitized).toHaveLength(1); expect(sanitized[0].type).toBe('reflection'); expect(contract).toMatchObject({ hasReceiptedMeaning: true, - reason: 'OK', + reason: CONTRACT_REASONS.OK, dropped: { narrative_disabled: 1 }, }); - if (previous === undefined) delete process.env.NARRATIVE_ENABLED; - else process.env.NARRATIVE_ENABLED = previous; + vi.unstubAllEnvs(); }); it('keeps card and stores canonical v1 receipts when at least one receipt is valid', () => { @@ -266,7 +261,7 @@ describe('receipt firewall behavior', () => { const { cards: sanitized, contract } = sanitizeBloomCardsWithContract({ cards, transcript }); expect(sanitized).toHaveLength(1); - expect(contract).toMatchObject({ hasReceiptedMeaning: true, reason: 'OK' }); + expect(contract).toMatchObject({ hasReceiptedMeaning: true, reason: CONTRACT_REASONS.OK }); expect(sanitized[0].receipts).toHaveLength(1); expect(sanitized[0].receipts[0]).toMatchObject({ @@ -297,7 +292,7 @@ describe('receipt firewall behavior', () => { cards: [], contract: { hasReceiptedMeaning: false, - reason: 'NO_RECEIPTS', + reason: CONTRACT_REASONS.NO_RECEIPTS, dropped: { empty_headline: 1 }, }, }); @@ -319,12 +314,41 @@ describe('receipt firewall behavior', () => { cards: [], contract: { hasReceiptedMeaning: false, - reason: 'NO_RECEIPTS', + reason: CONTRACT_REASONS.NO_RECEIPTS, dropped: { missing_receipts: 3 }, }, }); }); + + + it('returns INVALID_RECEIPT_SHAPE for non-v1 receipt objects', () => { + expect(validateReceipt('hello transcript text for shape tests', { quote: 'hello transcript text for shape tests' })).toEqual({ + ok: false, + reason: RECEIPT_VALIDATION_REASONS.INVALID_RECEIPT_SHAPE, + }); + }); + + it('drops cards when quote offset inference is ambiguous', () => { + const transcript = + 'I paused and took three breaths before responding, then later I took three breaths before responding again.'; + const quote = 'took three breaths before responding'; + + const result = sanitizeBloomCardsWithContract({ + transcript, + cards: [{ headline: 'Ambiguous receipt', confidence: 0.9, receipts: [{ quote }] }], + }); + + expect(result).toMatchObject({ + cards: [], + contract: { + hasReceiptedMeaning: false, + reason: CONTRACT_REASONS.NO_RECEIPTS, + dropped: { ambiguous_receipt_offsets: 1 }, + }, + }); + }); + it('legacy sanitizer remains backward compatible (returns Card[] only)', () => { const transcript = 'I practiced stillness before responding.'; const cards = [