fix: preserve rectification evidence flow
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<RectificationNarrativeGenerator> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user