diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 04b0b14e..5cf47e90 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -179,3 +179,15 @@ Prevention: keep reported declarations editable, never derive them from active/c A real production scan could return `ready_for_confirmation` before any historical evidence existed. The application then built a `confirming` first turn, while the database correctly accepts only an `active` first turn, producing a delayed `action_conflict` after the fee reservation and calculation. Prevention: gate every technical packet by `MINIMUM_SCOREABLE_EVENTS`; until three effective, historical, scoreable events exist, persist no result ID and expose only an active `pending_validation` turn. Keep the production smoke in `smoke_only` until this path completes against the deployed SHA. + +## ERR-090 | Finance evidence passed the application contract but failed durable SQL validation | mitigated 2026-07-21 + +A real production first-turn scan completed, billing was released, and case creation returned the generic `action_conflict` response. The application contract included `finance`, and D2/D11 technical differences could select it, while the initial SQL validators and event table constraint still used an older evidence-domain list without `finance`. The SQL recap validator also omitted the optional `domain` field already emitted by the application. + +Prevention: keep evidence-request, life-event, private-candidate, public-recap, and event-row domain validation aligned in a forward migration, with migration regression coverage whenever the application evidence-domain enum evolves. + +## ERR-091 | A technically empty narrowed segment terminated evidence collection | mitigated 2026-07-21 + +After accumulated historical evidence produced a very narrow winning segment, that segment could contain fewer than two linked samples or discriminating divisional themes. Packet construction treated this valid “not enough distinction yet” state as a dependency failure, so a later answer returned 503 even though scoring and the astrology service were healthy. + +Prevention: classify insufficient candidate-range discrimination explicitly; when a newly narrowed segment cannot support the technical evidence contract, retain the prior candidate range, preserve scored evidence, clear the unconfirmed result, and continue conversational collection. diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 36eb9ce0..53c66cfa 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -502,72 +502,104 @@ export async function buildProductionConversationalRectificationPacket( const selectedRange = !input.preserveCandidateRange && eventScore?.winningSegment ? { startTime: eventScore.winningSegment.startTime, endTime: eventScore.winningSegment.endTime } : baseRange; - const questionnaires: RectificationQuestionnaire[] = []; - for (const scanRange of boundedScanRanges(selectedRange)) { - const scanPoint = scanCoordinates(scanRange); - const { questionnaire } = await rectificationPacketStage("scan", () => engine.scan({ - birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`, - uncertaintyMinutes: scanPoint.uncertaintyMinutes, - lat: latitude, - lon: longitude, - tz: place.timezoneOffset, - ayanamsa: "lahiri", - })); - questionnaires.push(questionnaire); - } - const questionnaire = await rectificationPacketStage( - "merge_scans", - () => mergeQuestionnaireScans(questionnaires, selectedRange), - ); - const candidateDifferences = await rectificationPacketStage("candidate_differences", () => engine.buildDifferencePacket({ - caseId: input.caseId, - asOfDate: input.asOfDate, - birthDate: input.declaredBirthInput.birthDate, - startTime: selectedRange.startTime, - endTime: selectedRange.endTime, - lat: latitude, - lon: longitude, - tz: place.timezoneOffset, - evidence: [], - events, - dismissedOpportunityIds: [], - questionFingerprints: [], - partitionFingerprints: [], - recentRanges: [], - candidateModel: null, - })); const calculationVersion = eventScore - ? `${candidateDifferences.packet.scoringVersion}+${eventScore.algorithmVersion}` - : candidateDifferences.packet.scoringVersion; - const metadata = layerMetadata(questionnaire, calculationVersion); - const timeLinkedScanSamples = await rectificationPacketStage( - "time_links", - () => sampleTimes(questionnaire), - ); - const representative = eventScore?.winningSegment?.representativeTime - ?? scanCoordinates(selectedRange).centerTime; - const { buildRectificationTechnicalPacket } = await import( + ? eventScore.algorithmVersion + : null; + const technicalPacketModule = await import( "../../../lib/conversational-rectification/technical-packet.ts" ); - return { - packet: await rectificationPacketStage("technical_packet", () => buildRectificationTechnicalPacket({ + const buildForRange = async ( + range: { readonly startTime: string; readonly endTime: string }, + packetEventScore: CandidateResult | null, + ) => { + const questionnaires: RectificationQuestionnaire[] = []; + for (const scanRange of boundedScanRanges(range)) { + const scanPoint = scanCoordinates(scanRange); + const { questionnaire } = await rectificationPacketStage("scan", () => engine.scan({ + birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`, + uncertaintyMinutes: scanPoint.uncertaintyMinutes, + lat: latitude, + lon: longitude, + tz: place.timezoneOffset, + ayanamsa: "lahiri", + })); + questionnaires.push(questionnaire); + } + const questionnaire = await rectificationPacketStage( + "merge_scans", + () => mergeQuestionnaireScans(questionnaires, range), + ); + const candidateDifferences = await rectificationPacketStage( + "candidate_differences", + () => engine.buildDifferencePacket({ + caseId: input.caseId, + asOfDate: input.asOfDate, + birthDate: input.declaredBirthInput.birthDate, + startTime: range.startTime, + endTime: range.endTime, + lat: latitude, + lon: longitude, + tz: place.timezoneOffset, + evidence: [], + events, + dismissedOpportunityIds: [], + questionFingerprints: [], + partitionFingerprints: [], + recentRanges: [], + candidateModel: null, + }), + ); + const version = calculationVersion + ? `${candidateDifferences.packet.scoringVersion}+${calculationVersion}` + : candidateDifferences.packet.scoringVersion; + const metadata = layerMetadata(questionnaire, version); + const timeLinkedScanSamples = await rectificationPacketStage( + "time_links", + () => sampleTimes(questionnaire), + ); + const representative = packetEventScore?.winningSegment?.representativeTime + ?? scanCoordinates(range).centerTime; + return technicalPacketModule.buildRectificationTechnicalPacket({ scan: questionnaire, candidateDifferences, - eventScore: input.preserveCandidateRange && eventScore - ? { ...eventScore, confidence: "low", canApply: false, winningSegment: null } - : eventScore, + eventScore: packetEventScore, consultation: { source: "server_consultation_workflow", - calculationVersion, + calculationVersion: version, availableLayers: metadata.availableLayers, layerReferences: metadata.layerReferences, timeLinkedScanSamples, - boundaryDistanceMinutes: boundaryDistance(selectedRange, representative), + boundaryDistanceMinutes: boundaryDistance(range, representative), futureWindows: [], }, - })), - resultId: eventScore?.resultId ?? null, + }); }; + + const packetEventScore = input.preserveCandidateRange && eventScore + ? { ...eventScore, confidence: "low" as const, canApply: false, winningSegment: null } + : eventScore; + try { + return { + packet: await buildForRange(selectedRange, packetEventScore), + resultId: eventScore?.resultId ?? null, + }; + } catch (error) { + const selectedWasNarrowed = selectedRange.startTime !== baseRange.startTime + || selectedRange.endTime !== baseRange.endTime; + if (!selectedWasNarrowed + || !(error instanceof technicalPacketModule.RectificationTechnicalPacketRangeError)) { + return rectificationPacketStage("technical_packet", () => Promise.reject(error)); + } + return { + packet: await rectificationPacketStage("technical_packet", () => buildForRange( + baseRange, + eventScore + ? { ...eventScore, confidence: "low", canApply: false, winningSegment: null } + : null, + )), + resultId: null, + }; + } } async function productionNarrativeGenerator(): Promise { diff --git a/frontend/src/lib/conversational-rectification/technical-packet.ts b/frontend/src/lib/conversational-rectification/technical-packet.ts index 46bd2a59..5d8c645a 100644 --- a/frontend/src/lib/conversational-rectification/technical-packet.ts +++ b/frontend/src/lib/conversational-rectification/technical-packet.ts @@ -92,6 +92,13 @@ type TimeLinkedVargaSample = { readonly sample: RectificationQuestionnaire["samples"][number]; }; +export class RectificationTechnicalPacketRangeError extends TypeError { + constructor(readonly reason: "insufficient_samples" | "insufficient_domains") { + super(`rectification candidate range has ${reason.replace("_", " ")}`); + this.name = "RectificationTechnicalPacketRangeError"; + } +} + const layerFields = [ ["D1", "ascendantSign"], ["D2", "d2Sign"], @@ -270,9 +277,7 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica - (timeToMinute(right.time) - timeToMinute(range.startTime) + 1_440) % 1_440 )); if (selectedSamples.length < 2) { - throw new TypeError( - "rectification packet requires two time-linked scan samples inside the selected candidate range", - ); + throw new RectificationTechnicalPacketRangeError("insufficient_samples"); } const layers = layerEvidence(selectedSamples.map((item) => item.sample), input.consultation); const d1 = layers.find((item) => item.layer === "D1"); @@ -283,9 +288,7 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica && available.has(item.layer)); const domains = suggestedDomains(sensitiveLayers, selectedSamples); if (domains.length < 2) { - throw new TypeError( - "rectification packet requires two time-linked discriminating domains inside the selected candidate range", - ); + throw new RectificationTechnicalPacketRangeError("insufficient_domains"); } const scoredHistoricalEvidence = (input.eventScore?.evidence ?? []).map((item) => ({ evidenceId: item.eventId, diff --git a/frontend/supabase/migrations/20260721150000_align_conversational_finance_domain.sql b/frontend/supabase/migrations/20260721150000_align_conversational_finance_domain.sql new file mode 100644 index 00000000..695373e4 --- /dev/null +++ b/frontend/supabase/migrations/20260721150000_align_conversational_finance_domain.sql @@ -0,0 +1,104 @@ +begin; + +-- The application contract has always treated finance as a first-class +-- rectification evidence domain. The initial durable SQL contract omitted it, +-- so a valid technical packet containing D2/D11 evidence was rejected only at +-- the create RPC boundary as conversational_action_conflict. +do $migration$ +declare + v_signature text; + v_definition text; + v_updated_definition text; + v_old_domains constant text := '''career'', ''education'', ''relocation'', ''relationship'', ''family'', ''other'''; + v_new_domains constant text := '''career'', ''education'', ''finance'', ''relocation'', ''relationship'', ''family'', ''other'''; +begin + foreach v_signature in array array[ + 'public.conversational_rectification_valid_evidence_request(jsonb)', + 'public.conversational_rectification_valid_life_event_evidence(jsonb)', + 'public.conversational_rectification_valid_private_candidate(jsonb)' + ] loop + select pg_catalog.pg_get_functiondef(v_signature::regprocedure) + into v_definition; + v_updated_definition := pg_catalog.replace( + v_definition, + v_old_domains, + v_new_domains + ); + if v_updated_definition is not distinct from v_definition then + raise exception 'finance domain migration could not update %', v_signature; + end if; + execute v_updated_definition; + end loop; +end; +$migration$; + +-- Public recap rows may carry their domain so a resumed conversation can keep +-- domain-aware follow-up ordering. The TypeScript contract already allowed it. +create or replace function public.conversational_rectification_valid_evidence_recap( + p_value jsonb +) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_typeof(p_value) = 'array' + and pg_catalog.octet_length(p_value::text) <= 24576 + and public.conversational_rectification_numbers_are_stable(p_value) + and pg_catalog.jsonb_array_length(p_value) <= 20 + and not exists ( + select 1 + from pg_catalog.jsonb_array_elements(p_value) item + where pg_catalog.jsonb_typeof(item) <> 'object' + or pg_catalog.octet_length(item::text) > 4096 + or not public.conversational_rectification_has_only_keys( + item, array['id', 'summary', 'dateLabel', 'domain', 'isCorrection']::text[] + ) + or not (item ?& array['id', 'summary', 'dateLabel']::text[]) + or pg_catalog.jsonb_typeof(item -> 'id') <> 'string' + or not public.conversational_rectification_valid_uuid_text(item ->> 'id') + or pg_catalog.jsonb_typeof(item -> 'summary') <> 'string' + or public.conversational_rectification_text_utf16_length( + item ->> 'summary' + ) not between 1 and 1000 + or public.conversational_rectification_text_is_nonblank( + item ->> 'summary' + ) is not true + or pg_catalog.jsonb_typeof(item -> 'dateLabel') <> 'string' + or public.conversational_rectification_text_utf16_length( + item ->> 'dateLabel' + ) not between 1 and 80 + or public.conversational_rectification_text_is_nonblank( + item ->> 'dateLabel' + ) is not true + or ( + item ? 'domain' + and ( + pg_catalog.jsonb_typeof(item -> 'domain') <> 'string' + or item ->> 'domain' not in ( + 'career', 'education', 'finance', 'relocation', + 'relationship', 'family', 'other' + ) + ) + ) + or ( + item ? 'isCorrection' + and pg_catalog.jsonb_typeof(item -> 'isCorrection') <> 'boolean' + ) + ); +$$; + +alter table public.birth_time_rectification_event_evidence + drop constraint if exists birth_time_rectification_event_evidence_domain_check; + +alter table public.birth_time_rectification_event_evidence + add constraint birth_time_rectification_event_evidence_domain_check + check ( + domain in ( + 'career', 'education', 'finance', 'relocation', + 'relationship', 'family', 'other' + ) + ); + +commit; diff --git a/frontend/tests/conversational-rectification-domain-migration.test.ts b/frontend/tests/conversational-rectification-domain-migration.test.ts new file mode 100644 index 00000000..6cca1fe6 --- /dev/null +++ b/frontend/tests/conversational-rectification-domain-migration.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync( + new URL( + "../supabase/migrations/20260721150000_align_conversational_finance_domain.sql", + import.meta.url, + ), + "utf8", +); + +test("durable rectification SQL accepts every application evidence domain", () => { + for (const validator of [ + "conversational_rectification_valid_evidence_request(jsonb)", + "conversational_rectification_valid_life_event_evidence(jsonb)", + "conversational_rectification_valid_private_candidate(jsonb)", + ]) { + assert.match(migration, new RegExp(validator.replace(/[()]/g, "\\$&"))); + } + assert.match(migration, /'education', 'finance', 'relocation'/); + assert.match(migration, /birth_time_rectification_event_evidence_domain_check/); +}); + +test("durable public recap accepts and validates its optional domain", () => { + assert.match( + migration, + /array\['id', 'summary', 'dateLabel', 'domain', 'isCorrection'\]/, + ); + assert.match(migration, /item \? 'domain'/); + assert.match(migration, /item ->> 'domain' not in/); +}); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index dae8beae..2d387678 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -594,6 +594,75 @@ test("production packet waits for three supported events and then scores the acc ); }); +test("production keeps the prior candidate range when a scored segment loses technical discrimination", async () => { + const scanCalls: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }> = []; + const evidence = [ + syntheticEvidence(11, "education"), + syntheticEvidence(12, "relocation"), + syntheticEvidence(13, "career"), + ]; + const overNarrowed: CandidateResult = { + resultId: "00000000-0000-4000-8000-000000000897", + confidence: "high", + canApply: true, + winningSegment: { + startTime: "05:20", + endTime: "05:20", + representativeTime: "05:20", + widthMinutes: 1, + }, + eventCount: 3, + domainCount: 3, + topScore: 10, + secondScore: 1, + marginPercent: 90, + reasons: ["synthetic over-narrowed segment"], + evidence: evidence.map((item) => ({ + eventId: item.id, + domain: item.domain as "career" | "education" | "relocation", + candidateTime: "05:20", + ruleIds: ["synthetic-rule"], + points: 1, + })), + algorithmVersion: "synthetic-event-score-v1", + }; + + const built = await buildProductionConversationalRectificationPacket( + packetEngine({ scanCalls, scoreResults: [overNarrowed] }), + { + userId, + caseId, + asOfDate: "2026-07-21", + declaredBirthInput: { + source: "approximate", + birthDate: "1990-01-01", + reportedTime: "05:20", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + birthTimeClue: null, + birthplace: packetBirthplace, + }, + privateCandidate: null, + evidence, + }, + ); + + assert.deepEqual(scanCalls, [{ + birthTime: "1990-01-01 05:20", + uncertaintyMinutes: 1, + }, { + birthTime: "1990-01-01 05:20", + uncertaintyMinutes: 30, + }]); + assert.deepEqual(built.packet.candidate.range, { startTime: "04:50", endTime: "05:50" }); + assert.equal(built.packet.candidate.status, "pending_validation"); + assert.equal(built.resultId, null); + assert.deepEqual( + built.packet.scoredHistoricalEvidence.map((item) => item.evidenceId), + evidence.map((item) => item.id), + ); +}); + test("legacy import scores inherited events without silently replacing the inherited candidate range", async () => { const scoreCalls: LifeEvent[][] = []; const inherited = { startTime: "05:10", endTime: "05:50" }; diff --git a/frontend/tests/conversational-technical-packet.test.ts b/frontend/tests/conversational-technical-packet.test.ts index c3863343..8bd34214 100644 --- a/frontend/tests/conversational-technical-packet.test.ts +++ b/frontend/tests/conversational-technical-packet.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { buildRectificationTechnicalPacket, projectRectificationTechnicalPacket, + RectificationTechnicalPacketRangeError, } from "../src/lib/conversational-rectification/technical-packet.ts"; import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; import type { CandidateDifferenceBuild } from "../src/lib/birth-time-dynamic-choice-internal.ts"; @@ -267,7 +268,8 @@ test("does not claim scan-wide 05:10-05:30 differences inside a 05:16-05:24 cand boundaryDistanceMinutes: 4, futureWindows: [], }, - }), /two time-linked scan samples inside the selected candidate range/); + }), (error) => error instanceof RectificationTechnicalPacketRangeError + && error.reason === "insufficient_samples"); }); test("does not describe sparse in-range samples as adjacent-minute switches", () => { @@ -296,7 +298,8 @@ test("does not describe sparse in-range samples as adjacent-minute switches", () boundaryDistanceMinutes: 4, futureWindows: [], }, - }), /two time-linked discriminating domains/); + }), (error) => error instanceof RectificationTechnicalPacketRangeError + && error.reason === "insufficient_domains"); }); test("uses typed server time links when normalized scan raw metadata omits sample times", () => {