diff --git a/.superpowers/sdd/task-1-report.md b/.superpowers/sdd/task-1-report.md new file mode 100644 index 00000000..f4f99f45 --- /dev/null +++ b/.superpowers/sdd/task-1-report.md @@ -0,0 +1,75 @@ +# Task 1 — Dynamic Choice Contracts and Stop Policy + +## Implementation + +- Added browser-safe dynamic choice and time-range Zod schemas. Public question parsing is strict and rejects hidden partition fields. +- Added internal-only dynamic choice contracts, persisted/private question schemas, candidate-difference packet schemas, and an explicit public projection helper. +- Added pure deterministic stop policy with the specified precedence and a material-change calculation for candidate range, representative time, and two-point margin changes. +- Added separate `DynamicNextAction` and `DynamicJourneyProgress` schemas, preserving the legacy guided-v1 `NextAction` and `JourneyProgress` parser path. +- Kept the internal contract module dependency-free as resolved by the user. A source-contract test scans components, hooks, client transports, and response schemas to prohibit imports of the private module. +- Dynamic IDs are opaque nonempty server-issued strings, rather than being overconstrained to UUIDs. + +## Files changed + +- `frontend/src/lib/birth-time-dynamic-choice.ts` +- `frontend/src/lib/birth-time-dynamic-choice-internal.ts` +- `frontend/src/lib/birth-time-dynamic-stop-policy.ts` +- `frontend/src/lib/birth-time-journey-turn-protocol.ts` +- `frontend/src/lib/birth-time-journey-turn.ts` +- `frontend/tests/birth-time-dynamic-choice.test.ts` +- `frontend/tests/birth-time-dynamic-stop-policy.test.ts` + +## RED + +1. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-choice.test.ts` + - Failed as expected before the public contract existed: `ERR_MODULE_NOT_FOUND` for `birth-time-dynamic-choice.ts`. +2. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-stop-policy.test.ts` + - Failed as expected before the policy existed: `ERR_MODULE_NOT_FOUND` for `birth-time-dynamic-stop-policy.ts`. +3. After the boundary resolution, the dynamic choice test failed as expected while the obsolete `server-only` marker remained: `ERR_MODULE_NOT_FOUND: Cannot find package 'server-only'`. +4. The opaque-ID regression initially failed because the first implementation required UUIDs. + +## GREEN + +1. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts tests/birth-time-journey-turn.test.ts` + - `14` passed, `0` failed. +2. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-*.test.ts` + - `194` passed, `0` failed, duration `1449ms`. +3. `git diff --check` + - Passed with no whitespace errors. + +## Self-review + +- Public choices are strict, require 2–4 primary options plus exactly one unknown and one unmatched option, reject duplicate IDs, cap labels at 80 characters, and reject private fields. +- Persisted primary choices require nonempty partitions and finite score maps. Unknown/unmatched choices require both private fields to be `null`. +- The public projection parses through the public schema, so partition IDs and candidate scores cannot cross the browser boundary. +- Stop ordering is high confidence, effective-answer safety cap, plateau, no information gain, repeated partition, then continue. Non-effective answers retain the prior plateau count. +- Legacy schemas and turn behavior remain unchanged; v2 schemas use distinct dynamic names and are re-exported from the turn module. +- All created/modified source files are within the 250 pure-LOC threshold (largest: `birth-time-journey-turn.ts`, 229 lines; new internal contract, 208 lines). + +## Concerns + +- Full `tsc --noEmit --incremental false` remains blocked by an unrelated existing error in `frontend/tests/profile-persistence.test.ts:7`: the project targets ES2017 while that test uses an ES2018 regular-expression flag. None of the Task 1 files produced a TypeScript error. +- The supplied no-excuse checker could not run because it is outside the frontend dependency tree and cannot resolve its own `typescript` package. The focused runtime suite, full birth-time suite, diff check, and manual forbidden-pattern scan completed successfully. + +## Review fixes + +- `DynamicStopInput.result` is now nullable, so a dynamic flow can finish before its first score. It also carries the explicit `forcedReason` union: `user_finished`, `generation_unavailable`, or `null`. +- Forced terminal reasons now win over every score-derived condition. A null result preserves the current plateau count instead of attempting score comparison. +- Added and re-exported `dynamicJourneyTurnStateSchema` / `DynamicJourneyTurnState`. The schema is strict and explicitly requires `journeyProtocol: "dynamic-choice-v2"`, a nonnegative turn version, a dynamic action, dynamic progress, and the existing permissions shape. The legacy `journeyTurnStateSchema` is unchanged. +- Added regressions for both forced terminal reasons, their high-confidence precedence, the dynamic discriminator, and rejection of a valid legacy action under the v2 schema. + +### Review RED + +`/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts` + +- Failed before implementation because `dynamicJourneyTurnStateSchema` was not exported. +- Existing stop policy threw on `result: null` and returned `high_confidence` instead of the forced `user_finished` reason. + +### Review GREEN + +1. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts tests/birth-time-journey-turn.test.ts` + - `16` passed, `0` failed. +2. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-*.test.ts` + - `196` passed, `0` failed, duration `1472ms`. +3. `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node ./node_modules/typescript/bin/tsc --noEmit --incremental false` + - Still reports only the existing `tests/profile-persistence.test.ts:7` ES2018-regexp/ES2017-target incompatibility; no Task 1 diagnostic was emitted. diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 00000000..ed0c7c07 --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,95 @@ +# Task 2 — Deterministic Candidate Opportunities and Choice Scoring + +## Implementation + +- Added the versioned `birth-time-choice-scoring-v2` engine for reusable minute candidates, bounded life-stage windows, candidate-backed partitions, normalized information gain, and deterministic choice adjudication. +- Reused the existing local chart, D4/D9/D10/D24/D30, Vimshottari, and Narayana calculation path. Each candidate chart is computed once for the complete synthetic window set, then its activation rows are reused across dimensions. +- Persisted candidate models are strictly rebound to birth date, `as_of_date`, range, canonical candidate minutes, supported dimensions, bounded window dates, finite non-boolean activation values, and mandatory-layer shape before reuse. +- Fingerprint inputs contain only the scoring version, dimension code, ISO window boundaries, and sorted candidate memberships. User-facing prose never enters a hash basis. +- Added deterministic high/medium/low gates. Only high confidence can set `can_apply=true`; low and medium remain non-applicable. Public evidence is always empty and compatibility counts mirror effective answers/dimensions. +- Unknown, unmatched, free-text, client `option_id`, duplicate questions, empty server identifiers, unsupported dimensions, out-of-range candidate keys, negative/non-finite scores, and more than 10 effective evidence rows are rejected before scoring. +- Added strict legacy-safe POST routing for `/api/dynamic_rectification_opportunities` and `/api/dynamic_rectification_score`; existing active-rectification endpoints and their behavior were not changed. + +## Files changed + +- `scripts/dynamic_rectification.py` +- `scripts/dynamic_rectification_opportunities.py` +- `scripts/jyotish_api_server.py` +- `tests/test_dynamic_rectification.py` +- `tests/test_dynamic_rectification_scoring.py` +- `tests/test_active_rectification_api.py` +- `.superpowers/sdd/task-2-report.md` + +## RED + +1. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k packet` + - Collection failed as expected with `ImportError: cannot import name 'dynamic_rectification' from 'scripts'`. +2. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k 'primary_choice or unknown or high_confidence'` + - Three tests failed as expected because `score_choice_evidence` and `adjudicate_choice_rows` did not exist. +3. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_active_rectification_api.py -k dynamic` + - Four tests failed as expected because both dynamic API handler methods did not exist. +4. Candidate-model hardening regressions failed before their fixes: out-of-bounds windows and boolean activations were accepted, unmatched text was silently ignored, and semantically identical score maps were rejected when JSON key order differed. +5. The candidate reuse regression showed a missing D10 layer incorrectly blocking every dimension instead of career only. +6. The persisted-clock edge regression produced an invalid window ending before it began on the exact twelfth birthday; the under-age regression also showed unnecessary chart computation before age 12. +7. Final strict-boundary self-review reproduced acceptance of a negative candidate score and an empty persisted partition ID; both are now rejected before score accumulation. + +## GREEN + +1. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py tests/test_active_rectification_api.py tests/test_active_rectification_questions.py tests/test_active_rectification_events.py` + - `38` passed, `0` failed. +2. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m ruff check scripts/dynamic_rectification.py tests/test_dynamic_rectification.py tests/test_active_rectification_api.py` + - Passed with no diagnostics. +3. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m compileall -q scripts/dynamic_rectification.py scripts/jyotish_api_server.py` + - Passed. +4. `git diff --check` + - Passed with no whitespace errors. +5. Real local-engine smoke using persisted `as_of_date=2026-07-18`, range `05:30—05:31` + - Built version `birth-time-choice-scoring-v2`, `2` candidate minutes, and `20` bounded dimension/window activation rows. It correctly returned no opportunity when those two real candidates had no scoreable partition gain. + +## Pre-work gate + +- Ran `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`. +- The gate remained red only on the unrelated fragment-governance assertion: `candidate_count` expected `0`, observed `2`. Remote visibility was also reported as blocked, so no cloud-sync claim is made. + +## Self-review + +- Candidate generation owns one versioned deterministic rectification boundary and delegates chart/Dasha mathematics to the existing engine rather than duplicating it. +- Untrusted HTTP payloads are allowlisted at the API boundary; persisted candidate models and service-resolved evidence are parsed again at the deterministic module boundary before expensive computation or scoring. +- Candidate-model reuse is deterministic across days because every window derives from persisted `as_of_date`; the process clock is never read. +- Effective evidence is the only scored input. Answered-count/UI semantics remain outside the scorer, so unknown and unmatched choices cannot become score evidence. +- Hash bases were manually inspected and contain no descriptors, labels, prompts, notes, or other prose. +- Legacy endpoints remain byte-for-byte unchanged except for adjacent registration of the two new routes; the full legacy focused suites stayed green. +- No dependencies, logging, mutable module state, broad exception handlers, or model-controlled confidence fields were introduced. + +## Concerns + +- Full-file Ruff on `scripts/jyotish_api_server.py` still reports inherited baseline debt (import ordering, legacy f-strings, an existing undefined `swe`, and other unrelated diagnostics). Ruff is clean for the new module and both modified test files; compileall and all focused suites pass. + +## Review fixes + +- Both dynamic routes now fail closed unless `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` is configured and the request carries its exact bearer value. Comparison uses `secrets.compare_digest`; missing and wrong credentials are rejected before payload validation or scoring. +- Removed both routes from `API_COMMAND_MAP`, `TECHNIQUE_EXAMPLE_ENDPOINTS`, technique-example dispatch, and generated technique summaries. They remain direct authenticated POST routes only. +- Latitude, longitude, and timezone are required. The normalized location triple is persisted inside the candidate model and exact-matched during reuse. +- Adjudication preserves submitted candidate order, so an overnight `23:59` to `00:00` leader is one contiguous segment. +- Question IDs are trimmed opaque nonempty strings. Duplicate detection uses the normalized value and remains enforced. +- Split candidate-model/opportunity work into `dynamic_rectification_opportunities.py` and scoring regressions into `test_dynamic_rectification_scoring.py` without changing public entrypoints. +- Pure LOC after the split: public engine `215`, opportunity module `231`, opportunity tests `168`, scoring/auth tests `213`, active API tests `230`. + +### Review RED + +1. Location/timezone reuse tests initially passed for the wrong reason because the old model rejected the new location field entirely; the original same-location reuse regression also failed until location became part of the canonical model contract. +2. Overnight and opaque-ID regressions failed with UUID validation; the independent reviewer reproduction also showed wall-clock sorting split `23:59` and `00:00` into tied leaders. +3. Missing/wrong bearer regressions reached request validation/scoring instead of raising `Forbidden`; a forged unauthenticated four-row request could therefore reach the scorer. +4. Missing `lat`, `lon`, or `tz` silently normalized to zero. +5. Browser-runnable registration assertions failed because both private endpoints appeared in technique examples, command mapping, dispatch, and summaries. + +### Review GREEN + +1. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py tests/test_dynamic_rectification_scoring.py tests/test_active_rectification_api.py tests/test_active_rectification_questions.py tests/test_active_rectification_events.py` + - `45` passed, `0` failed. +2. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m ruff check scripts/dynamic_rectification.py scripts/dynamic_rectification_opportunities.py tests/test_dynamic_rectification.py tests/test_dynamic_rectification_scoring.py tests/test_active_rectification_api.py` + - Passed with no diagnostics after correcting one import-order finding. +3. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m compileall -q scripts/dynamic_rectification.py scripts/dynamic_rectification_opportunities.py scripts/jyotish_api_server.py` + - Passed. +4. `git diff --check` + - Passed after removing one trailing blank line in the API regression file. diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md new file mode 100644 index 00000000..d4a72614 --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -0,0 +1,84 @@ +# Task 3 — TypeScript Engine Adapter and Trust Boundary + +## Final design + +- `BirthTimeJourneyEngine` requires both dynamic operations: `buildDifferencePacket` and `scoreChoices`. Existing scan/score consumers depend on the explicit `LegacyBirthTimeJourneyEngine` pick instead of weakening the primary interface. +- The server-only engine factory owns `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN`. Both dynamic endpoints use an authenticated POST and a 45-second abort signal; all three legacy endpoints remain unauthenticated. +- Request serializers expose only server-resolved choice evidence. Client option IDs, confidence, applicability, and model-controlled safety gates are never sent as scoring authority. +- Dynamic v2 responses have a dedicated strict adapter. Root objects, ranges, opportunities, partitions, winning segments, score-map keys, counts, versions, modes, and duplicate identifiers are validated before mapping. +- Public difference packets omit private candidate score vectors. Private scoring partitions retain the exact server vector used by the later deterministic scoring call. +- Legacy response parsing remains compatibility-oriented: unknown server metadata is accepted and stripped. Only the shared result representation supports up to ten effective items; the legacy dated-event request remains capped at six. +- The HTTP wire accepts injected fetch and timeout-signal factories for executable contract tests. Production still defaults to `AbortSignal.timeout`. + +## Files changed + +Production: + +- `frontend/src/lib/birth-time-evidence.ts` +- `frontend/src/lib/birth-time-journey-service.ts` +- `frontend/src/lib/birth-time-journey-engine.ts` +- `frontend/src/lib/birth-time-journey-engine-model.ts` +- `frontend/src/lib/birth-time-journey-adapters.ts` +- `frontend/src/lib/birth-time-journey-dynamic-adapters.ts` +- `frontend/src/lib/birth-time-journey-assessment.ts` + +Tests and support: + +- `frontend/tests/birth-time-journey-engine.test.ts` +- `frontend/tests/birth-time-journey-adapters.test.ts` +- `frontend/tests/birth-time-journey-dynamic-adapters.test.ts` +- `frontend/tests/birth-time-journey-memory-store.ts` +- `frontend/tests/birth-time-journey-test-support.ts` +- `frontend/tests/birth-time-journey-service.test.ts` +- `frontend/tests/birth-time-agent-flow-test-support.ts` + +Documentation: + +- `docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md` +- `.superpowers/sdd/task-3-report.md` + +## RED evidence + +1. The initial focused run failed to load because the dynamic response parsers did not exist. +2. Adapter regressions then exposed acceptance of malformed score keys, keys outside the submitted range, duplicate opportunity/partition identifiers, nested extra fields, and legacy evidence metadata incompatibility. +3. Interface and wire review probes exposed optional primary dynamic methods, source-regex authentication assertions, and a missing executable proof for exact URLs, bodies, authorization, and timeout behavior. +4. The final wire cleanup test injected a timeout factory and failed with `[]` instead of `[45000, 45000]`, proving that the seam was initially ignored. + +## Final verification + +1. Focused adapter, wire, and evidence suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts tests/birth-time-journey-dynamic-adapters.test.ts tests/birth-time-evidence.test.ts` + - 29 passed, 0 failed. +2. Complete birth-time frontend suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time*.test.ts` + - 208 passed, 0 failed. +3. Full frontend suite: + - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts` + - 283 passed, 0 failed. +4. ESLint across every changed production/test TypeScript module: + - Passed with no diagnostics. +5. TypeScript diagnostic: + - No Task 3 diagnostics. The only result is the known baseline `tests/profile-persistence.test.ts:7 TS1501`, caused by an ES2018 regex flag under the project's ES2017 target. +6. Pure-LOC audit: + - Every changed TypeScript file is at or below 250 pure LOC. The largest is `frontend/src/lib/birth-time-journey-service.ts` at 239; the split test-support modules are 171 and 111. +7. `git diff --check`: + - Passed with no whitespace errors. + +## Pre-work gate + +- `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` remained red only on the unrelated known fragment-governance baseline: `candidate_count` expected `0`, observed `1`. +- Remote visibility was blocked, so no cloud-sync claim is made. + +## Self-review + +- Dynamic secrets and candidate score vectors remain behind the server boundary. +- Wire tests use independent literal request bodies rather than production serializers and directly assert two `45_000` timeout calls and the exact injected signals. +- Missing-token tests prove both dynamic operations fail before fetch. Executable legacy tests prove no Authorization header reaches any legacy endpoint. +- Dynamic parsing is fail-closed; legacy parsing preserves its prior accept-and-strip behavior. +- Cross-midnight ranges enumerate minutes modulo 24 hours and bind score keys to the exact submitted interval. +- The extracted memory store has no dependency on the fixture module, so its re-export does not create a runtime cycle. +- No dependency, logging field, client response field, or persistence write was added. + +## Known unrelated baseline + +- A clean TypeScript run is still blocked by `tests/profile-persistence.test.ts:7 TS1501`; Task 3 introduces no additional diagnostic. diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 00000000..022a4fc3 --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -0,0 +1,120 @@ +# Task 4 Report — Selection-Only Dynamic Choice Generation + +## Outcome + +Task 4 now implements the approved hybrid boundary: + +- the deterministic engine creates opportunities, candidate partitions, selectable answer + semantics, localized prompts, and localized labels; +- the Agent may only select one exact server opportunity ID or return advisory + `no_useful_question`; +- the server validates and renders all public copy, attaches private score vectors, creates + public UUIDs, and decides retry, fallback, and termination behavior; and +- the raw unmatched-answer note remains available to later workflow layers but is completely + omitted from the Agent prompt. + +The Agent cannot author a question, option, label, partition ID, birth-time claim, confidence +claim, or control instruction. Those fields are unrepresentable in its strict output schema. +This supersedes the keyword-filter/substring-grounding design reviewed in +`.omo/evidence/task-4-rereview.md` and the earlier interim `CLEAR` narrative. +The final acceptance correction has been implemented and independently re-audited by the +executor, but the main acceptance reviewer remains authoritative for completion status. + +## RED evidence + +Artifact: `.omo/evidence/task-4-finite-red.log` + +Tests were changed before production code: + +- TypeScript: 14 tests, 9 expected failures. The failures demonstrated that the note still + crossed the prompt, selection-only output was rejected, old free-copy output remained + possible, selected server copy was not rendered, and duplicate server labels were accepted. +- Python: 7 tests, 1 expected failure. Two distinct same-year windows both rendered as the + indistinguishable label `2012—2012 年`. + +Final-fix RED artifact: `.omo/evidence/task-4-final-red.log`. + +- TypeScript: 17 tests, 2 expected failures. Exact and NFKC/whitespace-equivalent primary + labels matching either reserved choice were accepted by both binder and service instead of + failing before ID allocation. +- Python: 4/4 passed, including the new same-month/day-precision regression, confirming that + the production behavior existed but previously lacked durable coverage. + +Standards-axis RED artifact: `.omo/evidence/task-4-axis-red.log`. + +- TypeScript: 19 tests, 2 expected failures. Server prompts of 121 and 240 characters were + accepted by binder and service instead of failing before ID allocation and commit. +- Python: 4/4 passed after replacing localized precision glyph assertions with numeric-boundary + structure and normalized uniqueness checks. + +## Implementation + +- `birth-time-dynamic-question-copy.ts` now contains only server-copy structural validation, + NFKC/whitespace label normalization, the note-free opportunity-selection projection, and + deterministic server-copy fingerprinting. The former note blacklist and substring + grounding logic were removed. Shared constants cap server questions at 120 characters and + labels at 80 across the API adapter, public schema, internal model, persisted schema, and + binding guard. +- `birth-time-dynamic-question-validator.ts` accepts only strict selection objects. Binding + resolves the selected server opportunity, validates the prompt and normalized uniqueness + across every primary and reserved visible label, validates every matching private + partition, and only then allocates IDs. Malformed + server copy, private bindings, UUIDs, and persisted records raise + `BirthTimeDynamicBindingError` and cannot be retried into a false low result. +- Fallback sorts opportunities by information gain descending and then opportunity ID, + independent of packet order. Repeated fingerprints alone are skipped as recoverable. +- `dynamic_rectification_copy.py` owns localized contexts and the least detailed + year/month/day range representation needed to distinguish visible windows. Cross-year + ranges stay concise; same-year or same-month collisions gain month or day precision. + Its precision discriminator is the exhaustive `Literal["year", "month", "day"]` domain; + unknown precision cannot silently fall through. `dynamic_rectification_opportunities.py` + remains below the 250-pure-LOC boundary. +- The Mastra contract describes selection only and forbids prompt/options/labels/partition + fields in Agent output. + +The real Python-shaped fixture retains structural CJK/no-ASCII copy, normalized label +uniqueness, partition count, opportunity ID, fingerprint, and partition-ID seam checks without +pinning exact natural-language prose. It is parsed through the Task 3 adapter and exercised +through the Task 4 service. Task 5 persistence was not changed. +The service-level adversarial-note regression independently parses every captured Agent prompt +and requires the exact `task`/`opportunities` projection and exact safe opportunity keys. It +does not call the production serializer or search for literal note prose. + +## Verification + +| Gate | Result | Artifact | +| --- | --- | --- | +| Standards-axis RED | expected 2 TS failures; Python 4/4 | `.omo/evidence/task-4-axis-red.log` | +| Focused dynamic/guide TypeScript | 40/40 pass | `.omo/evidence/task-4-axis-focused-ts.log` | +| Public dynamic-choice schema TypeScript | 7/7 pass | `.omo/evidence/task-4-axis-public-schema-ts.log` | +| Dynamic adapter boundary TypeScript | 8/8 pass | `.omo/evidence/task-4-axis-adapter-ts.log` | +| Focused Task 2 Python | 26/26 pass | `.omo/evidence/task-4-axis-focused-python.log` | +| Legacy Python rectification | 22/22 pass | `.omo/evidence/task-4-axis-legacy-python.log` | +| All birth-time TypeScript | 229/229 pass | `.omo/evidence/task-4-axis-birth-time.log` | +| Full frontend | 304/304 pass | `.omo/evidence/task-4-axis-frontend-full.log` | +| Cumulative changed TypeScript ESLint | pass, zero diagnostics | `.omo/evidence/task-4-axis-eslint.log` | +| Cumulative changed Python Ruff | pass | `.omo/evidence/task-4-axis-ruff.log` | +| Diff check and all changed TS/Python LOC | pass; every audited file <=250 | `.omo/evidence/task-4-axis-quality.log` | +| Full TypeScript check | only known unrelated `profile-persistence.test.ts:7` TS1501 | `.omo/evidence/task-4-axis-tsc.log` | +| Structural prompt focused TypeScript | 40/40 pass | `.omo/evidence/task-4-structural-focused-ts.log` | +| Structural prompt ESLint | pass, zero diagnostics | `.omo/evidence/task-4-structural-eslint.log` | +| Structural prompt diff/LOC audit | pass; cumulative files <=250 | `.omo/evidence/task-4-structural-quality.log` | +| Structural prompt TypeScript check | only known unrelated TS1501 | `.omo/evidence/task-4-structural-tsc.log` | +| Fresh structural-prompt review | CLEAR / APPROVE; no blockers | `.omo/evidence/task-4-structural-prompt-code-review.md` | + +The TypeScript command remains non-zero solely because the pre-existing profile-persistence +test uses a regular-expression flag newer than the configured target. No Task 4 file reports +a type error. +The earlier `.omo/evidence/task-4-selection-boundary-code-review.md` `CLEAR` is explicitly +superseded by `.omo/evidence/task-4-final-review.md`; it is not cited as current acceptance. +The earlier `.omo/evidence/task-4-final-fix-code-review.md` `CLEAR` is explicitly superseded by +the standards-axis review and is not cited as current acceptance. The new tests contain no +localized month/day or domain-word assertions; precision is verified through distinct normalized +labels and the number of numeric range-boundary tokens. +The earlier `.omo/evidence/task-4-axis-fix-code-review.md` `CLEAR` is explicitly superseded by +the main acceptance test finding; it is retained only as historical evidence. The shared +120/80 boundary remains verified through the public schema, API adapter, internal and persisted +schemas, and binding guard. +The fresh reviewer independently verified the adversarial structural projection assertion, +40/40 focused tests, zero-diagnostic ESLint, the 250-pure-LOC boundary, and both required +programming/remove-slops perspectives with no remaining blocker. diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 00000000..64667de6 --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -0,0 +1,106 @@ +# Task 5 Report: Durable v2 Persistence and Legacy Isolation + +## Outcome + +Implemented durable `dynamic-choice-v2` persistence with a public/private data split. Public case rows contain only the public dynamic turn projection; the exact candidate model, persisted question binding, answers, server evidence, control state, and bounded Agent context are kept in a service-role-only companion row. Versioned turn and scoring-job writes are transactionally coordinated by service-role-only RPCs and never update `active_birth_time`. + +New assessments initialize the public case, private state, and profile pointer in one database RPC transaction. Owner-scoped resume parsing requires complete v2 public/private rows and returns a strict legacy/v2 union; only absent protocol data from old legacy rows retains compatibility defaults. Active legacy cases can be upgraded without losing evidence or audit data, terminal legacy cases remain unchanged, and every legacy guided mutation entry point rejects v2 cases before writing. + +The acceptance follow-up preserves an existing chart time when rectification starts, rejects split public/JSON turn versions, and routes v2 resume before any legacy scoring normalization. Legacy store inputs now accept only the strict legacy arm. Direct legacy updates include an atomic protocol predicate, while ordered service-role RPC wrappers lock the case and require `legacy-guided-v1` before invoking the former scoring or candidate transaction. Dynamic action receipts are canonicalized to lowercase in both production and the shared memory fake. + +The scoring-persistence follow-up adds typed completion and failure commands that call the exact service-role RPCs, validate the returned version, reload the owner-scoped stored case, and preserve idempotent replay and stale-write behavior. It deliberately does not add stop policy, transition routing, or Task 6 orchestration. + +## Files + +- `frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql` +- `frontend/supabase/migrations/20260718091000_dynamic_choice_birth_time_transitions.sql` +- `frontend/supabase/migrations/20260718092000_legacy_scoring_protocol_guards.sql` +- `frontend/supabase/migrations/20260718093000_legacy_candidate_protocol_guards.sql` +- `frontend/src/lib/birth-time-evidence-service.ts` +- `frontend/src/lib/birth-time-guided-candidate.ts` +- `frontend/src/lib/birth-time-guided-draft-revision.ts` +- `frontend/src/lib/birth-time-journey-actions.ts` +- `frontend/src/lib/birth-time-journey-case-loader.ts` +- `frontend/src/lib/birth-time-journey-dynamic-case.ts` +- `frontend/src/lib/birth-time-journey-dynamic-persistence.ts` +- `frontend/src/lib/birth-time-journey-dynamic-state.ts` +- `frontend/src/lib/birth-time-journey-errors.ts` +- `frontend/src/lib/birth-time-journey-response.ts` +- `frontend/src/lib/birth-time-journey-service.ts` +- `frontend/src/lib/birth-time-journey-store-errors.ts` +- `frontend/src/lib/birth-time-journey-stored-protocol.ts` +- `frontend/src/lib/birth-time-journey-store.ts` +- `frontend/src/lib/birth-time-journey-turn-persistence.ts` +- `frontend/src/lib/birth-time-scoring-service.ts` +- `frontend/tests/birth-time-dynamic-persistence-fixture.ts` +- `frontend/tests/birth-time-dynamic-persistence.test.ts` +- `frontend/tests/birth-time-dynamic-resume.test.ts` +- `frontend/tests/birth-time-dynamic-scoring-memory-store.test.ts` +- `frontend/tests/birth-time-dynamic-scoring-persistence.test.ts` +- `frontend/tests/birth-time-journey-memory-store.ts` +- `frontend/tests/birth-time-journey-legacy-isolation.test.ts` +- `tests/test_birth_time_dynamic_persistence_contract.py` +- `tests/test_birth_time_journey_contract.py` + +## TDD Evidence + +- Migration RED: `.omo/evidence/task-5-python-red.log` +- Persistence RED: `.omo/evidence/task-5-ts-red.log` +- SQL null-state regression RED: `.omo/evidence/task-5-null-state-red.log` +- Unknown-time initialization RED: `.omo/evidence/task-5-unknown-range-red.log` +- Atomic creation RED: `.omo/evidence/task-5-atomic-create-red.log`, `.omo/evidence/task-5-atomic-create-ts-red.log` +- Legacy isolation RED: `.omo/evidence/task-5-guided-isolation-red.log`, `.omo/evidence/task-5-all-legacy-isolation-red.log` +- Memory replay RED: `.omo/evidence/task-5-memory-replay-red.log` +- Profile preservation RED: `.omo/evidence/task-5-profile-preservation-ts-red.log`, `.omo/evidence/task-5-profile-preservation-python-red.log` +- Migration GREEN: `.omo/evidence/task-5-python-green.log` +- Persistence GREEN: `.omo/evidence/task-5-ts-green.log` +- SQL null-state regression GREEN: `.omo/evidence/task-5-null-state-green.log` +- Unknown-time initialization GREEN: `.omo/evidence/task-5-unknown-range-green.log` +- Atomic creation GREEN: `.omo/evidence/task-5-atomic-create-green.log` +- Legacy isolation and replay GREEN: `.omo/evidence/task-5-memory-and-isolation-green.log` +- Profile preservation GREEN: `.omo/evidence/task-5-profile-preservation-ts-green.log`, `.omo/evidence/task-5-profile-preservation-python-green.log` + +The scoring-persistence follow-up began RED with all 3 executable tests failing because the completion and failure methods did not exist. After adding only the typed persistence wrappers and memory-fake support, the same 3 tests passed GREEN. + +Fresh review then reproduced a shared-fake replay mismatch: completion of one job could be returned for a different failure command at the same expected version. The fake now records the endpoint kind, canonical job identity, fingerprint, algorithm, failure code or candidate result, and returns a replay only for an equivalent operation. Two executable regressions prove exact completion/failure replay and reject changed job, endpoint, fingerprint, algorithm, failure code, or result. + +The additional regressions prove that a missing current scoring action cannot pass a SQL `NOT IN` guard through three-valued `NULL` logic, that the supported unknown-time assessment initializes a valid full-day dynamic range, that SQL/public JSON turn versions cannot disagree, that v2 resume performs zero legacy writes, that an upgrade race cannot cross a legacy protocol predicate, and that uppercase UUID retries replay the stored advanced dynamic state. Executable scoring tests additionally prove exact completion/failure RPC names and payloads, returned-version parsing, owner reload, replay, stale and unknown-error propagation, and the public/private payload split without exposing an active or birth time. + +## Verification + +- Focused persistence, resume, scoring-job, and upgrade-race TypeScript: 33/33 passed. +- Relevant Python persistence, engine, and scoring contracts: 43/43 passed. +- Full birth-time frontend suite: 264/264 passed. +- Full frontend suite: 339/339 passed. +- Changed-file ESLint: passed. +- Changed-file Ruff: passed. +- `git diff --check`: passed. +- All changed TypeScript, test, and ordered migration modules are at most 250 pure LOC; maximum is 250. +- Full TypeScript check reports only the known unrelated baseline at `frontend/tests/profile-persistence.test.ts:7` (`TS1501`, ES2018 regex under the existing target). + +Evidence: + +- `.omo/evidence/task-5-python-relevant.log` +- `.omo/evidence/task-5-birth-time-suite.log` +- `.omo/evidence/task-5-frontend-full.log` +- `.omo/evidence/task-5-eslint.log` +- `.omo/evidence/task-5-ruff.log` +- `.omo/evidence/task-5-diff-check.log` +- `.omo/evidence/task-5-loc-final.log` +- `.omo/evidence/task-5-tsc.log` + +## Review + +Final cross-review after the identity-faithful memory replay fix: **CLEAR / APPROVE**, with no remaining blocker. The earlier untracked review artifact records the reproduced mismatch that prompted this final fix and is superseded by the passing cross-review. + +Live PostgreSQL execution was not available: Docker CLI is installed, but the daemon socket does not exist. `.omo/evidence/task-5-live-postgres-unavailable.log` records the exact failure. SQL checks are therefore described only as static migration contracts; TypeScript fakes execute the RPC boundary and failure/replay semantics without claiming database execution. + +Task 6 remains responsible for routing public `assess`/`resume` responses and dynamic actions through the v2 transition service. Task 5 establishes and verifies the durable store boundary those transitions use. + +## Commit + +Commit message: `feat: persist dynamic rectification turns` + +Follow-up commit message: `fix: isolate dynamic rectification persistence` + +Scoring-persistence follow-up commit message: `fix: expose dynamic scoring persistence` diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md new file mode 100644 index 00000000..f019fd7d --- /dev/null +++ b/.superpowers/sdd/task-6-report.md @@ -0,0 +1,130 @@ +# Task 6 Report: Dynamic Journey Orchestration + +## Outcome + +Implemented the `dynamic-choice-v2` journey as a persisted, Agent-driven state machine rather +than a fixed questionnaire loop. A primary click resolves only its server-owned private +partition, records one canonical evidence item, and creates one idempotent scoring job in the +same versioned transition. Unknown and unmatched choices remain non-evidence actions; unmatched +free context is bounded, stored separately for the Agent, and never enters scoring. + +Question generation uses the full difference packet and persists the chosen question/private +binding before exposing it. Repeated generation fingerprints fail closed to a terminal low +result. Scoring claims the durable job, validates identity/fingerprint/algorithm, scores once, +and saves the result and deterministic stop decision in the same turn. Continuation depends on +available information and plateau/confidence state, not a fixed round count. High confidence +still requires explicit candidate confirmation and never applies a minute during orchestration. + +Pause stores the exact current dynamic action and resume reconstructs that persisted action. +Terminal low, medium, confirmation, and ready turns are one-way: answer, generation, retry, +pause, and finish mutations cannot restart them. New assessments reload and return their +persisted v2 generation turn instead of projecting the former legacy baseline question. + +The focused review follow-up closes three additional safety seams. Scoring now independently +recomputes the deterministic confidence class from persisted effective evidence/domain counts, +margin thresholds, and segment width; medium and high cannot be accepted below their gates. +Winning segments must be a chronological subset of the persisted range, with exact inclusive +width and midpoint representative time, including across midnight. Unmatched-context, pause, +and finish retries now carry a private typed receipt and replay only the identical action, +version, and payload; cross-action or changed-payload receipt reuse is stale. + +The second review follow-up closes the database success-path seam. Every action-bearing v2 +mutation now persists a canonical strict receipt: answers bind question and option, question +commits bind submitted/result fingerprints or a terminal result, and special actions/resume +bind their exact payload. A new ordered migration replaces the already-deployed generic turn +save: it locks case then private state and permits processed-action success only for version +`expected + 1` plus exact JSONB receipt equality. Dynamic scoring-job creation applies the same +locked private receipt check. TypeScript revalidates version and receipt after both successful +and failed RPC responses, so a normal duplicate-success response cannot bypass comparison. + +## Persistence Amendment + +Added service-role-only `create_birth_time_dynamic_scoring_job` and +`claim_birth_time_dynamic_scoring_job` RPCs. Creation owner-locks the v2 case and atomically +validates/persists the public turn, private state, canonical receipt, and pending job. Claiming +checks ownership, job identity, evidence fingerprint, algorithm version, current action, and +lease; completed replay requires a coherent persisted result/action. Production store methods +use these RPCs directly and do not call legacy scoring wrappers or expose the private row. +Claim now locks job then case, matching completion/failure order and removing the prior lock +cycle. The executable memory store also models the 60-second processing lease and reclaim. +Its completed replay additionally binds the current job action and checks persisted candidate +result/action coherence. + +## Main Files + +- `frontend/src/lib/birth-time-dynamic-actions.ts` +- `frontend/src/lib/birth-time-dynamic-special-actions.ts` +- `frontend/src/lib/birth-time-dynamic-action-replay.ts` +- `frontend/src/lib/birth-time-dynamic-action-receipt.ts` +- `frontend/src/lib/birth-time-dynamic-result-validator.ts` +- `frontend/src/lib/birth-time-dynamic-transitions.ts` +- `frontend/src/lib/birth-time-dynamic-scoring-service.ts` +- `frontend/src/lib/birth-time-dynamic-scoring-job-store.ts` +- `frontend/src/lib/birth-time-dynamic-engine-input.ts` +- `frontend/src/lib/birth-time-dynamic-service-methods.ts` +- `frontend/src/lib/birth-time-journey-service.ts` +- `frontend/src/lib/birth-time-journey-store.ts` +- `frontend/src/lib/birth-time-scoring-job.ts` +- `frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql` +- `frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql` +- `frontend/tests/birth-time-dynamic-actions.test.ts` +- `frontend/tests/birth-time-dynamic-scoring.test.ts` +- `frontend/tests/birth-time-dynamic-scoring-store.test.ts` +- `frontend/tests/birth-time-dynamic-terminal.test.ts` +- `tests/test_birth_time_dynamic_scoring_job_contract.py` + +## TDD Evidence + +- Initial action RED: dynamic answer/generation methods and modules were absent. +- Scoring RED: the first implementation exposed legacy mutation behavior and later failed + completed-job replay because it inspected a now-terminal action before claiming the job. +- Persistence RED: all SQL contract cases failed before the ordered v2 job migration existed. +- Assessment regression RED: a newly persisted v2 case returned a response with no + `journeyProtocol`; GREEN now reloads and returns the stored dynamic generation turn. +- Review RED: `medium + null segment + 1/1` was accepted, claim locked case before job, and + unmatched/pause/finish lost-response retries failed before receipt replay. Dedicated tests + reproduced each failure before the focused fix. +- Second review RED: database duplicate-success returned on action ID alone, answer/commit did + not write typed receipts, and TypeScript trusted a successful RPC version without comparing + the reloaded receipt. SQL and RPC fakes now reproduce exact duplicate success, concurrent + cross-action success, changed answer/commit payloads, lease reclaim, and corrupted completed + result/action replay. +- Final review RED: a generic transport error after a committed dynamic turn reloaded state but + skipped receipt classification unless the error text itself said stale. The adapter now treats + an exact committed receipt as lost-response replay, a processed conflicting receipt as stale, + and only an uncommitted generic error as a store failure. Scoring-job creation already used + these semantics; a generic-error regression test locks that parity. + +Final verification: + +- Focused Task 6 TypeScript: **47/47 passed**. +- Route/telemetry regression subset after v2 assessment routing: **28/28 passed**. +- Full frontend TypeScript tests: **362/362 passed**. +- Dynamic action-receipt and scoring-job SQL contracts: **7/7 passed**. +- ESLint: **0 errors**, with the two pre-existing `page.tsx` hook warnings. +- `git diff --check`: passed. +- Every changed/new Task 6 TypeScript, test, and migration module is at most 250 pure LOC. +- TypeScript check reports only the known unrelated baseline at + `frontend/tests/profile-persistence.test.ts:7` (`TS1501`, ES2018 regex under the existing + target). + +Live PostgreSQL execution was unavailable from the inherited Task 5 environment because the +Docker daemon socket was absent. The database claim is therefore limited to static SQL +token/order contracts plus executable TypeScript RPC fakes; no live-database pass is claimed. +The SQL tests do not simulate PostgreSQL locking; the lock-order assertion is structural, while +lease/reclaim behavior is executed by the typed memory store. + +## Handoff + +Task 7 should expose the new v2 commands through authenticated request/response schemas and +browser coordination. In particular, v2 polling must call `pollDynamicScoringJob`; the legacy +`pollScoringJob` remains legacy-only. The route change in this task only permits a fresh v2 +assessment response through the existing telemetry wrapper. + +Commit message: `feat: orchestrate dynamic rectification turns` + +Focused review fix commit message: `fix: harden dynamic rectification orchestration` + +Exact receipt fix commit message: `fix: close dynamic receipt replay gaps` + +Lost-response fix commit message: `fix: recover committed dynamic transport errors` diff --git a/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md new file mode 100644 index 00000000..62fb9d00 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md @@ -0,0 +1,1388 @@ +# Dynamic-Choice Birth-Time Rectification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the fixed five-domain, fixed-round, text-draft birth-time flow with a model-generated one-question-at-a-time choice flow whose candidate scoring, stopping decisions, persistence, and application permissions remain deterministic and server-owned. + +**Architecture:** The Python Jyotish engine computes minute candidates, date-window opportunities, candidate partitions, information gain, and versioned scores. A constrained Mastra Agent may select one server-issued opportunity and write neutral Simplified Chinese question/option labels, while a TypeScript validator binds those labels to server-issued partition IDs. `BirthTimeJourney` persists the complete internal question, accepts only `questionId + optionId` from the client, drives scoring and stop policy, and makes terminal states irreversible within the same case. + +**Tech Stack:** Python 3.11+, TypeScript 5, Zod 3, Next.js 16.2 Route Handlers, React 19, Mastra 1.50, Supabase/PostgreSQL, Node test runner, pytest, Playwright visual QA. + +## Global Constraints + +- Preserve the dirty worktree. Never reset, restore, overwrite, or stage unrelated user changes. +- New assessments use protocol `dynamic-choice-v2`; existing fixed-question fields remain read-only legacy audit data. +- The UI never displays a fixed total question count or an adaptive round number. +- The deterministic engine may use a finite registry of scoreable experience dimensions, but v2 has no “ask every domain” checklist: opportunity gain may skip a dimension, revisit a different partition in one dimension, or stop before any nominal coverage target. +- Each generated question has 2–4 primary choices plus server-added `不确定 / 不记得` and `都不符合` choices. +- A primary choice submits immediately; it never creates a date draft, precision selector, or second confirmation screen. +- `都不符合` may collect at most 240 characters of optional context. That text is never scored directly. +- The model cannot create candidate minutes, partitions, weights, scores, confidence, progress, permissions, or application commands. +- The browser submits only `caseId`, `actionId`, `turnVersion`, `questionId`, and `optionId`; it never receives or submits a `partitionId`. +- Stop on high confidence, no useful opportunity, two consecutive effective plateaus, repeated question/partition fingerprints, explicit user finish, unrecoverable generation fallback, or 10 effective answers. +- `present_low_result`, `present_medium_result`, and `ready` are terminal for their existing `caseId`; resume cannot generate another question. +- Low and medium confidence can save a candidate range only. Only an explicitly confirmed high-confidence candidate may update `active_birth_time`. +- `reported_birth_time` is immutable. +- Keep all scoring thresholds in a versioned deterministic module; prompts and client parameters cannot override them. +- Read `frontend/node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md` and `05-server-and-client-components.md` before changing Route Handlers or server/client component boundaries. +- Add no runtime dependency. +- Every task uses red → green TDD and ends with a focused commit containing only that task's files. + +## File Responsibility Map + +New focused files: + +- `scripts/dynamic_rectification.py`: candidate-window opportunity generation and deterministic choice scoring. +- `frontend/src/lib/birth-time-dynamic-choice.ts`: browser-safe public question, option, and range schemas. +- `frontend/src/lib/birth-time-dynamic-choice-internal.ts`: server-only opportunities, private partition mappings, answers, evidence, and control state. +- `frontend/src/lib/birth-time-dynamic-stop-policy.ts`: pure stop/continue decision and plateau calculation. +- `frontend/src/lib/birth-time-dynamic-question-validator.ts`: bind model labels to server opportunities and add special options. +- `frontend/src/lib/birth-time-dynamic-transitions.ts`: pure v2 Journey transitions. +- `frontend/src/lib/birth-time-dynamic-actions.ts`: authenticated/idempotent v2 mutations. +- `frontend/src/lib/birth-time-dynamic-scoring-service.ts`: claim, execute, and complete v2 score jobs. +- `frontend/src/components/birth-time-choice-question.tsx`: click-first question and optional unmatched note UI. + +Existing files retain these roles: + +- `scripts/active_rectification_questions.py` and `active_rectification_scoring.py`: legacy fixed-question audit behavior only. +- `frontend/src/lib/birth-time-guide-agent.ts`: constrained question-generation request/output contract. +- `frontend/src/lib/birth-time-guide-service.ts`: generate and persist a v2 question; it does not score. +- `frontend/src/lib/birth-time-journey-service.ts`: protocol routing and public journey response orchestration. +- `frontend/src/lib/birth-time-journey-turn-protocol.ts`: public `NextAction` and progress protocol. +- `frontend/src/lib/birth-time-journey-turn-persistence.ts`: load public case state plus service-role-only v2 private state and save both atomically through RPCs. +- `frontend/src/hooks/use-birth-time-guided-journey.ts`: browser coordination only; no scoring or stop decisions. + +--- + +### Task 1: Dynamic Choice Contracts and Stop Policy + +**Files:** +- Create: `frontend/src/lib/birth-time-dynamic-choice.ts` +- Create: `frontend/src/lib/birth-time-dynamic-choice-internal.ts` +- Create: `frontend/src/lib/birth-time-dynamic-stop-policy.ts` +- Modify: `frontend/src/lib/birth-time-journey-turn-protocol.ts` +- Modify: `frontend/src/lib/birth-time-journey-turn.ts` +- Test: `frontend/tests/birth-time-dynamic-choice.test.ts` +- Test: `frontend/tests/birth-time-dynamic-stop-policy.test.ts` + +**Interfaces:** +- Produces `CandidateDifferencePacket`, `QuestionOpportunity`, `PersistedDynamicChoiceQuestion`, `PublicDynamicChoiceQuestion`, `StoredChoiceAnswer`, and `DynamicControlState`. +- Produces `decideDynamicStop(input: DynamicStopInput): DynamicStopDecision`. +- Replaces fixed `ask_baseline_evidence` / `ask_adaptive_evidence` in v2 with `generate_dynamic_question`, `ask_dynamic_choice`, and `clarify_unmatched_answer`. + +- [ ] **Step 1: Write failing schema tests** + +```ts +test("public questions never expose partition ids", () => { + const parsed = publicDynamicChoiceQuestionSchema.parse({ + questionId: "11111111-1111-4111-8111-111111111111", + prompt: "哪一个时间段更接近这次工作变化?", + options: [ + { optionId: "22222222-2222-4222-8222-222222222222", label: "2018—2020 年", kind: "primary" }, + { optionId: "33333333-3333-4333-8333-333333333333", label: "2021—2023 年", kind: "primary" }, + { optionId: "44444444-4444-4444-8444-444444444444", label: "不确定 / 不记得", kind: "unknown" }, + { optionId: "55555555-5555-4555-8555-555555555555", label: "都不符合", kind: "unmatched" }, + ], + }); + assert.equal("partitionId" in parsed.options[0], false); + assert.equal(publicDynamicChoiceQuestionSchema.safeParse({ + ...parsed, + options: [{ ...parsed.options[0], partitionId: "private" }, ...parsed.options.slice(1)], + }).success, false); +}); + +test("internal primary choices require a server partition", () => { + assert.equal(persistedDynamicChoiceQuestionSchema.safeParse(internalQuestion).success, true); + assert.equal(persistedDynamicChoiceQuestionSchema.safeParse({ + ...internalQuestion, + options: internalQuestion.options.map((option) => option.kind === "primary" + ? { optionId: option.optionId, label: option.label, kind: option.kind, partitionId: null } + : option), + }).success, false); +}); +``` + +- [ ] **Step 2: Run the contracts test and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-choice.test.ts` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `birth-time-dynamic-choice.ts`. + +- [ ] **Step 3: Add strict public/internal schemas** + +Add the browser-safe shapes to `birth-time-dynamic-choice.ts` and the partition-bearing shapes to `birth-time-dynamic-choice-internal.ts`. Do not add a `server-only` package dependency: this repository does not currently install that marker and the plan forbids new runtime dependencies. Enforce the boundary with strict public projection plus a source-contract test proving no component, hook, client transport, or public response schema imports `birth-time-dynamic-choice-internal.ts`. + +```ts +export type PublicChoiceKind = "primary" | "unknown" | "unmatched"; + +export type TimeRange = { readonly startTime: string; readonly endTime: string }; + +export type PublicDynamicChoiceQuestion = { + readonly questionId: string; + readonly prompt: string; + readonly options: readonly { + readonly optionId: string; + readonly label: string; + readonly kind: PublicChoiceKind; + }[]; +}; +``` + +Use these exact server-only shapes: + +```ts +import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { PublicChoiceKind, PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts"; + +export type EvidencePartition = { + readonly partitionId: string; + readonly descriptor: string; + readonly fallbackLabel: string; +}; + +export type ScoredEvidencePartition = EvidencePartition & { + readonly candidateScores: Readonly>; +}; + +export type QuestionOpportunity = { + readonly opportunityId: string; + readonly dimensionCode: string; + readonly neutralContext: string; + readonly estimatedInformationGain: number; + readonly candidatePartitionFingerprint: string; + readonly fallbackPrompt: string; + readonly partitions: readonly EvidencePartition[]; +}; + +export type CandidateDifferencePacket = { + readonly caseId: string; + readonly scoringVersion: "birth-time-choice-scoring-v2"; + readonly currentRange: TimeRange; + readonly opportunities: readonly QuestionOpportunity[]; + readonly askedQuestionFingerprints: readonly string[]; + readonly candidatePartitionFingerprints: readonly string[]; + readonly recentRangeHistory: readonly TimeRange[]; +}; + +export type CandidateDifferenceBuild = { + readonly packet: CandidateDifferencePacket; + readonly candidateModel: Readonly>; + readonly scoringPartitions: Readonly>; +}; + +export type PersistedDynamicChoiceQuestion = PublicDynamicChoiceQuestion & { + readonly opportunityId: string; + readonly dimensionCode: string; + readonly estimatedInformationGain: number; + readonly scoringVersion: string; + readonly source: "agent" | "fallback"; + readonly questionFingerprint: string; + readonly candidatePartitionFingerprint: string; + readonly options: readonly { + readonly optionId: string; + readonly label: string; + readonly kind: PublicChoiceKind; + readonly partitionId: string | null; + readonly candidateScores: Readonly> | null; + }[]; +}; + +export type StoredChoiceAnswer = { + readonly questionId: string; + readonly optionId: string; + readonly kind: PublicChoiceKind; + readonly opportunityId: string; + readonly answeredAt: string; +}; + +export type ServerChoiceEvidence = { + readonly questionId: string; + readonly opportunityId: string; + readonly partitionId: string; + readonly dimensionCode: string; + readonly candidateScores: Readonly>; + readonly informationGain: number; +}; + +export type DynamicChoiceScoringResult = { + readonly candidate: CandidateResult; + readonly evidenceMode: "dynamic_choice"; + readonly effectiveAnswerCount: number; + readonly dimensionCount: number; +}; + +export type PausedDynamicAction = + | { readonly kind: "generate_dynamic_question" } + | { readonly kind: "ask_dynamic_choice"; readonly questionId: string } + | { readonly kind: "clarify_unmatched_answer"; readonly questionId: string } + | { readonly kind: "retry_question_generation" } + | { readonly kind: "score_pending"; readonly jobId: string } + | { readonly kind: "retry_scoring"; readonly jobId: string }; + +export type DynamicControlState = { + readonly asOfDate: string; + readonly answeredCount: number; + readonly effectiveAnswerCount: number; + readonly plateauCount: number; + readonly questionFingerprints: readonly string[]; + readonly partitionFingerprints: readonly string[]; + readonly dismissedOpportunityIds: readonly string[]; + readonly recentRanges: readonly TimeRange[]; + readonly pausedAction: PausedDynamicAction | null; +}; +``` + +Use `.strict().readonly()` Zod objects. Enforce exactly 2–4 `primary`, exactly one `unknown`, exactly one `unmatched`, unique `optionId`, and nonempty labels up to 80 characters. Primary choices require a nonempty `partitionId` and finite `candidateScores`; both special choices require `partitionId === null` and `candidateScores === null`. + +- [ ] **Step 4: Write failing stop-policy tests** + +```ts +test("two effective unchanged scores stop without starting another question", () => { + const decision = decideDynamicStop({ + result: mediumCandidate, + effectiveAnswer: true, + previousResult: mediumCandidate, + priorPlateauCount: 1, + usefulOpportunityCount: 3, + repeatedOnly: false, + effectiveAnswerCount: 6, + }); + assert.deepEqual(decision, { kind: "finish", reason: "plateau", plateauCount: 2 }); +}); + +test("unknown answers do not advance plateau or the effective safety count", () => { + const decision = decideDynamicStop({ + result: lowCandidate, + effectiveAnswer: false, + previousResult: lowCandidate, + priorPlateauCount: 1, + usefulOpportunityCount: 2, + repeatedOnly: false, + effectiveAnswerCount: 4, + }); + assert.deepEqual(decision, { kind: "continue", plateauCount: 1 }); +}); + +test("terminal conditions are deterministic", () => { + assert.equal(decisionFor({ result: null, forcedReason: "user_finished" }).reason, "user_finished"); + assert.equal(decisionFor({ result: null, forcedReason: "generation_unavailable" }).reason, "generation_unavailable"); + assert.equal(decisionFor({ confidence: "high" }).reason, "high_confidence"); + assert.equal(decisionFor({ usefulOpportunityCount: 0 }).reason, "no_information_gain"); + assert.equal(decisionFor({ repeatedOnly: true }).reason, "repeated_partition"); + assert.equal(decisionFor({ effectiveAnswerCount: 10 }).reason, "safety_cap"); +}); +``` + +- [ ] **Step 5: Run the stop-policy test and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-stop-policy.test.ts` + +Expected: FAIL because `decideDynamicStop` does not exist. + +- [ ] **Step 6: Implement deterministic stop ordering** + +`DynamicStopInput.result` is nullable so generation can stop safely before a first score. Add `forcedReason: "user_finished" | "generation_unavailable" | null`; these explicit terminal events are checked before score-derived conditions. Use this decision order: + +```ts +export function decideDynamicStop(input: DynamicStopInput): DynamicStopDecision { + const plateauCount = input.effectiveAnswer && input.result + ? materiallyChanged(input.previousResult, input.result) ? 0 : input.priorPlateauCount + 1 + : input.priorPlateauCount; + if (input.forcedReason) return { kind: "finish", reason: input.forcedReason, plateauCount }; + if (input.result?.confidence === "high") return { kind: "finish", reason: "high_confidence", plateauCount }; + if (input.effectiveAnswerCount >= 10) return { kind: "finish", reason: "safety_cap", plateauCount }; + if (plateauCount >= 2) return { kind: "finish", reason: "plateau", plateauCount }; + if (input.usefulOpportunityCount === 0) return { kind: "finish", reason: "no_information_gain", plateauCount }; + if (input.repeatedOnly) return { kind: "finish", reason: "repeated_partition", plateauCount }; + return { kind: "continue", plateauCount }; +} +``` + +`materiallyChanged()` returns true when the winning range start/end changes, the winning representative changes, or the margin changes by at least 2 percentage points. + +- [ ] **Step 7: Replace the public v2 progress/action shapes** + +Add these variants without deleting the legacy parser path yet: + +```ts +type DynamicNextAction = + | { readonly kind: "generate_dynamic_question" } + | { readonly kind: "ask_dynamic_choice"; readonly question: PublicDynamicChoiceQuestion } + | { readonly kind: "clarify_unmatched_answer"; readonly questionId: string } + | { readonly kind: "retry_question_generation" } + | { readonly kind: "score_pending"; readonly jobId: string } + | { readonly kind: "retry_scoring"; readonly jobId: string } + | { readonly kind: "present_low_result"; readonly resultId: string | null } + | { readonly kind: "present_medium_result"; readonly resultId: string } + | { readonly kind: "request_candidate_confirmation"; readonly resultId: string } + | { readonly kind: "ready"; readonly activeTime: string } + | { readonly kind: "paused" }; + +type DynamicJourneyProgress = { + readonly phase: "question" | "clarification" | "scoring" | "result" | "ready" | "paused"; + readonly answeredCount: number; + readonly effectiveAnswerCount: number; + readonly currentRange: TimeRange; + readonly previousRange: TimeRange | null; + readonly plateauCount: number; +}; +``` + +Do not expose the hidden safety count or a maximum question count in either schema. + +Add `dynamicJourneyTurnStateSchema` with `journeyProtocol: z.literal("dynamic-choice-v2")`, nonnegative `turnVersion`, `dynamicNextActionSchema`, `dynamicJourneyProgressSchema`, and the existing derived permissions schema. Keep `journeyTurnStateSchema` unchanged as the legacy-guided-v1 compatibility contract. Terminal resume behavior is implemented by Task 6 transitions, but every v2 public response must parse through this explicit dynamic turn-state discriminator. + +- [ ] **Step 8: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts tests/birth-time-journey-turn.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-dynamic-choice.ts frontend/src/lib/birth-time-dynamic-choice-internal.ts frontend/src/lib/birth-time-dynamic-stop-policy.ts frontend/src/lib/birth-time-journey-turn-protocol.ts frontend/src/lib/birth-time-journey-turn.ts frontend/tests/birth-time-dynamic-choice.test.ts frontend/tests/birth-time-dynamic-stop-policy.test.ts +git commit -m "feat: define dynamic birth time choice protocol" +``` + +--- + +### Task 2: Deterministic Candidate Opportunities and Choice Scoring + +**Files:** +- Create: `scripts/dynamic_rectification.py` +- Create: `scripts/dynamic_rectification_opportunities.py` +- Modify: `scripts/jyotish_api_server.py:1280-1325,1735-1755,6766-6890,7645-7660,7770-7790` +- Test: `tests/test_dynamic_rectification.py` +- Test: `tests/test_dynamic_rectification_scoring.py` +- Modify: `tests/test_active_rectification_api.py` + +**Interfaces:** +- Produces `build_difference_packet(request) -> dict` and `score_choice_evidence(request) -> dict`. +- Adds `POST /api/dynamic_rectification_opportunities` and `POST /api/dynamic_rectification_score`. +- Keeps `/api/active_rectification_questions`, `/api/active_rectification_score`, and `/api/active_rectification_events` unchanged for legacy cases. + +- [ ] **Step 1: Write failing opportunity tests** + +```python +def test_packet_contains_only_candidate_backed_high_gain_opportunities(monkeypatch): + monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", fake_rows) + packet = dynamic_rectification.build_difference_packet(base_request()) + assert packet["scoring_version"] == "birth-time-choice-scoring-v2" + assert packet["current_range"] == {"start_time": "05:30", "end_time": "06:00"} + assert len(packet["opportunities"]) >= 1 + for opportunity in packet["opportunities"]: + assert opportunity["estimated_information_gain"] >= 0.15 + assert 2 <= len(opportunity["partitions"]) <= 4 + assert len({item["partition_id"] for item in opportunity["partitions"]}) == len(opportunity["partitions"]) + +def test_packet_excludes_used_opportunity_and_partition_fingerprints(monkeypatch): + monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", fake_rows) + first = dynamic_rectification.build_difference_packet(base_request()) + used = first["opportunities"][0] + request = base_request() + request["dismissed_opportunity_ids"] = [used["opportunity_id"]] + request["partition_fingerprints"] = [used["candidate_partition_fingerprint"]] + second = dynamic_rectification.build_difference_packet(request) + assert all(item["opportunity_id"] != used["opportunity_id"] for item in second["opportunities"]) + assert all(item["candidate_partition_fingerprint"] != used["candidate_partition_fingerprint"] for item in second["opportunities"]) + +def test_packet_reuses_the_persisted_candidate_model(monkeypatch): + calls = [] + monkeypatch.setattr(dynamic_rectification, "_compute_candidate_model", lambda request: calls.append(request) or fake_model()) + first = dynamic_rectification.build_difference_packet(base_request()) + second = dynamic_rectification.build_difference_packet({ + **base_request(), "candidate_model": first["candidate_model"], + }) + assert len(calls) == 1 + assert second["candidate_model"] == first["candidate_model"] +``` + +- [ ] **Step 2: Run opportunity tests and verify RED** + +Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k packet` + +Expected: FAIL with `ImportError: cannot import name 'dynamic_rectification'`. + +- [ ] **Step 3: Generate candidate-backed date-window opportunities** + +Use minute candidates from the submitted range, the existing local chart engine, D4/D9/D10/D24/D30, Vimshottari, and Narayana Dasha. For each supported experience dimension, evaluate bounded calendar windows from age 12 through the persisted `as_of_date`. Compute each candidate chart once, then reuse it across every dimension/window. Return a compact versioned `candidate_model` containing only candidate activation numbers needed for later opportunity ranking; a subsequent request must validate and reuse that model instead of recalculating charts. A candidate joins the partition for the window with its strongest domain activation; discard opportunities with fewer than two populated partitions or normalized entropy below `0.15`. + +Treat an overnight range as one chronological sequence: `23:59` and `00:00` are adjacent candidates. Bind every reusable candidate model to the exact birth date, persisted `as_of_date`, start/end range, latitude, longitude, and timezone; location fields are required and never default to zero. Keep the public entrypoints/scoring in `dynamic_rectification.py` and extract candidate-model/opportunity helpers to `dynamic_rectification_opportunities.py` so production and test files stay within the repository's 250 pure-LOC limit. + +The exact opportunity contract is: + +```python +class EvidencePartition(TypedDict): + partition_id: str + descriptor: str + fallback_label: str + candidate_scores: dict[str, float] + +class QuestionOpportunity(TypedDict): + opportunity_id: str + dimension_code: str + neutral_context: str + estimated_information_gain: float + candidate_partition_fingerprint: str + fallback_prompt: str + partitions: list[EvidencePartition] +``` + +`candidate_scores` keys are `HH:MM` candidates inside the current range. IDs and fingerprints are SHA-256 hashes of canonical JSON containing scoring version, dimension, window boundaries, and sorted candidate memberships. Never use prose in a fingerprint. + +- [ ] **Step 4: Write failing deterministic scoring tests** + +```python +def test_primary_choice_changes_rankings_and_returns_a_real_range(): + result = dynamic_rectification.score_choice_evidence({ + **score_request(), + "choice_evidence": [{ + "question_id": str(uuid4()), + "opportunity_id": "career-window", + "partition_id": "career-2020-2022", + "dimension_code": "career", + "candidate_scores": {"05:30": 0.0, "05:31": 1.0, "05:32": 1.0, "05:33": 0.0}, + "information_gain": 0.5, + }], + }) + assert result["effective_answer_count"] == 1 + assert result["winning_segment"] == { + "start_time": "05:31", "end_time": "05:32", "representative_time": "05:31", "width_minutes": 2, + } + assert result["can_apply"] is False + +def test_unknown_and_unmatched_are_never_choice_evidence(): + with pytest.raises(ValueError, match="partition evidence"): + dynamic_rectification.score_choice_evidence({ + **score_request(), + "choice_evidence": [{"kind": "unknown"}], + }) + +def test_high_confidence_requires_versioned_hard_gates(): + result = dynamic_rectification.adjudicate_choice_rows( + decisive_rows(), effective_answer_count=4, dimension_count=3, missing_layers=[] + ) + assert result["confidence"] == "high" + assert result["can_apply"] is True + assert result["winning_segment"]["width_minutes"] <= 5 + assert result["margin_percent"] >= 20 +``` + +- [ ] **Step 5: Run scoring tests and verify RED** + +Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k 'primary_choice or unknown or high_confidence'` + +Expected: FAIL because choice scoring functions are absent. + +- [ ] **Step 6: Add versioned scoring gates** + +Set `ALGORITHM_VERSION = "birth-time-choice-scoring-v2"`. Sum only server-resolved primary evidence. Keep `answered_count` separate from `effective_answer_count`; the Python scorer receives only effective evidence. Return existing candidate-result compatibility fields, with `event_count = effective_answer_count`, `domain_count = dimension_count`, and an empty public `evidence` array because private choice evidence remains in the service-only table. Also return: + +```python +{ + "evidence_mode": "dynamic_choice", + "effective_answer_count": effective_answer_count, + "dimension_count": dimension_count, + "algorithm_version": ALGORITHM_VERSION, +} +``` + +High confidence requires one winning segment, at least 4 effective answers across 3 dimensions, width at most 5 minutes, margin at least 20%, and no missing mandatory layers. Medium requires one segment, at least 3 effective answers across 2 dimensions, width at most 15 minutes, and margin at least 10%. Every other result is low and `can_apply` is false. + +- [ ] **Step 7: Add strict API validation and endpoints** + +For opportunities accept only birth date, a persisted ISO `as_of_date`, start/end time, required location, an optional server-owned `candidate_model`, existing choice evidence summary, dismissed opportunity IDs, and fingerprint arrays. For scoring accept only birth/location/range and server-resolved `choice_evidence`. Reject candidate models whose version, bound location/range, candidate times, or numeric activation shape do not match the request; also reject candidate times outside the submitted range, duplicate question IDs, more than 10 evidence rows, non-finite scores, unsupported dimensions, and any client-style `option_id` field. Accept opaque trimmed nonempty server-issued question IDs rather than UUID-only IDs. Window generation uses `as_of_date`, never the Python process clock, so an existing case remains reproducible across days. + +Both dynamic Python endpoints are server-to-server only. Require a constant-time checked bearer token from `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN`, fail closed when it is absent, and remove the dynamic endpoints from any browser-runnable technique-example dispatch. The authenticated TypeScript adapter in Task 3 is the only application caller; a browser must not be able to submit `candidate_model`, `partition_id`, or `candidate_scores` directly. + +- [ ] **Step 8: Run Python suites and commit** + +Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py tests/test_active_rectification_api.py tests/test_active_rectification_questions.py tests/test_active_rectification_events.py` + +Expected: all selected tests pass. + +```bash +git add scripts/dynamic_rectification.py scripts/jyotish_api_server.py tests/test_dynamic_rectification.py tests/test_active_rectification_api.py +git commit -m "feat: score dynamic birth time choices" +``` + +--- + +### Task 3: TypeScript Engine Adapter and Trust Boundary + +**Files:** +- Modify: `frontend/src/lib/birth-time-journey-service.ts:1-120` +- Modify: `frontend/src/lib/birth-time-journey-engine.ts` +- Modify: `frontend/src/lib/birth-time-journey-adapters.ts` +- Create: `frontend/src/lib/birth-time-journey-dynamic-adapters.ts` +- Modify: `frontend/src/lib/birth-time-journey-engine-model.ts` +- Modify: `frontend/src/lib/birth-time-evidence.ts:86-150` +- Test: `frontend/tests/birth-time-journey-engine.test.ts` +- Test: `frontend/tests/birth-time-journey-adapters.test.ts` +- Test: `frontend/tests/birth-time-journey-dynamic-adapters.test.ts` +- Test support: `frontend/tests/birth-time-journey-memory-store.ts` + +**Interfaces:** +- Adds `buildDifferencePacket(input: DifferencePacketInput): Promise`. +- Adds `scoreChoices(input: DynamicChoiceScoreInput): Promise`. +- Preserves `scan`, `score`, and `scoreEvents` for legacy protocol cases. + +- [ ] **Step 1: Write failing adapter tests** + +```ts +test("difference packets keep candidate scores on the server-only internal shape", () => { + const build = parseCandidateDifferenceBuild(apiPacket); + assert.equal(build.scoringPartitions["career-window"][0].candidateScores["05:31"], 1); + assert.equal(build.packet.opportunities[0].estimatedInformationGain, 0.5); + assert.deepEqual(build.candidateModel, apiPacket.candidate_model); +}); + +test("choice score parser rejects model-controlled confidence fields", () => { + assert.throws(() => parseDynamicChoiceScoring({ + ...apiScore, + confidence: "high", + effective_answer_count: 1, + can_apply: true, + })); +}); + +test("choice scores adapt into the existing guarded candidate shape", () => { + const parsed = parseDynamicChoiceScoring(apiScore); + assert.equal(parsed.candidate.eventCount, parsed.effectiveAnswerCount); + assert.equal(parsed.candidate.domainCount, parsed.dimensionCount); + assert.deepEqual(parsed.candidate.evidence, []); + assert.equal(parsed.candidate.algorithmVersion, "birth-time-choice-scoring-v2"); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `cd frontend && node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts` + +Expected: FAIL because both parsers and engine methods are missing. + +- [ ] **Step 3: Add exact engine inputs** + +```ts +export type DifferencePacketInput = { + readonly caseId: string; + readonly asOfDate: string; + readonly birthDate: string; + readonly startTime: string; + readonly endTime: string; + readonly lat: number; + readonly lon: number; + readonly tz: number; + readonly evidence: readonly ServerChoiceEvidence[]; + readonly dismissedOpportunityIds: readonly string[]; + readonly questionFingerprints: readonly string[]; + readonly partitionFingerprints: readonly string[]; + readonly recentRanges: readonly TimeRange[]; + readonly candidateModel: Readonly> | null; +}; + +export type DynamicChoiceScoreInput = Pick; +``` + +Extend `BirthTimeJourneyEngine` with the two methods. Do not add partition data to any client response schema. + +Keep the primary `BirthTimeJourneyEngine` contract fully capable: both dynamic methods are required. Use an explicit legacy-only `Pick`/interface for old services and test doubles that intentionally need only `scan`, `score`, and `scoreEvents`; do not weaken the primary methods to optional. + +Raise the compatibility `candidateResultSchema.eventCount` maximum from 6 to 10 and change its high-gate message from “events” to “effective evidence items.” The dated-event request schema remains capped at 6, so legacy API behavior does not broaden; the shared candidate result can now represent the v2 safety cap. + +- [ ] **Step 4: Post to the new Python endpoints** + +`buildDifferencePacket()` posts snake-case payloads to `/api/dynamic_rectification_opportunities` and separates the response into `{ packet, candidateModel, scoringPartitions }`. Only `packet` may enter the Agent prompt; `candidateModel` and `scoringPartitions` stay server-only. `bindDynamicQuestion()` copies the selected partition's score vector into the private persisted question, and the model cannot supply or alter that vector. `scoreChoices()` posts to `/api/dynamic_rectification_score`. Both use the existing 45-second abort timeout and strict adapter parsing. + +For both dynamic calls, require `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` in the server environment and send it as a bearer token. Never expose that token through a client module or response. Legacy engine calls remain unchanged and unauthenticated. + +`parseDynamicChoiceScoring()` must require `event_count === effective_answer_count`, `domain_count === dimension_count`, `evidence_mode === "dynamic_choice"`, an empty public evidence array, and the v2 algorithm version before constructing `DynamicChoiceScoringResult`. This prevents a malformed engine payload from satisfying the high-confidence gate with inconsistent counts. + +Place all v2 response schemas and mappings in `birth-time-journey-dynamic-adapters.ts`; keep legacy parsing behavior byte-compatible in `birth-time-journey-adapters.ts`. Every nested dynamic object, including `winning_segment`, is strict. Keep each production and test module within 250 pure LOC, add duplicate opportunity/partition attack tests, and assert mapped fields against independent input fixtures rather than against each other. + +Test authentication through an executable fake fetch/wire seam for both dynamic endpoints: exact URL, bearer header, request body, timeout signal, and missing-token fail-before-fetch. Also prove legacy calls omit the dynamic Authorization header. The HTTP helper accepts one typed request/options object rather than four primitive parameters. + +Wire assertions use independent literal request expectations, not the production serializer as the expected value. Inject the timeout-signal factory in tests and assert it receives the literal `45_000`; do not infer the timeout from a sibling exported constant. If a pre-existing test-support module exceeds the limit, extract the memory journey store into `birth-time-journey-memory-store.ts` instead of compressing formatting to pass the LOC check. + +- [ ] **Step 5: Verify endpoint payload ownership** + +Add a source-level test asserting that `candidate_scores` appears only in server modules and never in `birth-time-journey-client.ts`, `birth-time-journey-request.ts`, or a component/hook. + +- [ ] **Step 6: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-journey-service.ts frontend/src/lib/birth-time-journey-engine.ts frontend/src/lib/birth-time-journey-adapters.ts frontend/src/lib/birth-time-journey-engine-model.ts frontend/src/lib/birth-time-evidence.ts frontend/tests/birth-time-journey-engine.test.ts frontend/tests/birth-time-journey-adapters.test.ts +git commit -m "feat: connect dynamic rectification engine" +``` + +--- + +### Task 4: Constrained Agent Question Generation and Fallback + +**Files:** +- Create: `frontend/src/lib/birth-time-dynamic-question-validator.ts` +- Modify: `frontend/src/lib/birth-time-guide-agent.ts` +- Modify: `frontend/src/lib/birth-time-guide-service.ts` +- Modify: `frontend/src/mastra/index.ts:179-220` +- Test: `frontend/tests/birth-time-guide-agent.test.ts` +- Test: `frontend/tests/birth-time-guide-route.test.ts` + +**Interfaces:** +- Produces `generateDynamicQuestionPrompt(packet, note)` and `parseDynamicQuestionOutput(value, packet)`. +- Produces `bindDynamicQuestion(output, build, ids): PersistedDynamicChoiceQuestion`; `build.packet` supplies model-safe IDs/copy and `build.scoringPartitions` supplies the private score vector. +- Model output is either `{ kind: "question", opportunityId, prompt, options }` or `{ kind: "no_useful_question" }`. + +- [ ] **Step 1: Replace variant tests with failing dynamic-output tests** + +```ts +test("agent output may only reference one server opportunity and its partitions", () => { + const parsed = parseDynamicQuestionOutput({ + kind: "question", + opportunityId: "career-window", + prompt: "哪一个时间段更接近一次明显的工作变化?", + options: [ + { partitionId: "window-a", label: "2018—2020 年" }, + { partitionId: "window-b", label: "2021—2023 年" }, + ], + }, packet); + assert.equal(parsed.kind, "question"); + assert.throws(() => parseDynamicQuestionOutput({ + ...parsed, + options: [{ partitionId: "invented", label: "某个时间" }], + }, packet), BirthTimeGuideOutputError); +}); + +test("server adds special options and keeps partitions private", () => { + const internal = bindDynamicQuestion(validOutput, differenceBuild, deterministicIds); + const publicQuestion = toPublicDynamicChoiceQuestion(internal); + assert.deepEqual(publicQuestion.options.slice(-2).map((item) => item.label), ["不确定 / 不记得", "都不符合"]); + assert.equal(publicQuestion.options.some((item) => "partitionId" in item), false); +}); +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `cd frontend && node --test tests/birth-time-guide-agent.test.ts` + +Expected: FAIL because dynamic generation functions do not exist. + +- [ ] **Step 3: Define the model prompt boundary** + +Send only opportunity ID, dimension code, neutral context, partition ID, descriptor, fallback label, prior public question summaries, and the optional unmatched note. Do not send candidate times, candidate scores, partition memberships, confidence thresholds, or support directions. + +The Mastra instruction must require valid JSON only, one question, 2–4 options, neutral Simplified Chinese, no birth-minute claim, no methodology exposure, and exact server IDs. It must state that `no_useful_question` is advisory and the server makes the stop decision. + +- [ ] **Step 4: Bind, fingerprint, and validate server-side** + +`bindDynamicQuestion()` must: + +1. verify the opportunity exists; +2. verify each partition belongs to it and appears once; +3. require 2–4 primary labels; +4. reject prompts over 120 characters and labels over 80; +5. reject time-of-birth strings matching `HH:MM`, confidence language, candidate-support language, and control claims; +6. create UUIDs server-side for question/options; +7. add the two special options with null partitions; +8. hash normalized public semantics for `questionFingerprint`; +9. reject existing question or partition fingerprints. + +- [ ] **Step 5: Add one retry and deterministic fallback tests** + +```ts +test("invalid model output retries once then persists the top opportunity fallback", async () => { + const calls: string[] = []; + const result = await serviceWithGenerator(async () => { + calls.push("generate"); + return { text: "{}" }; + }).generateQuestion("owner-1", generationCommand); + assert.equal(calls.length, 2); + assert.equal(result.nextAction.kind, "ask_dynamic_choice"); + assert.equal(result.nextAction.question.prompt, packet.opportunities[0].fallbackPrompt); + assert.equal(result.nextAction.question.options.length, packet.opportunities[0].partitions.length + 2); +}); + +test("no opportunity ends safely instead of regenerating the first question", async () => { + const result = await serviceWithPacket({ ...packet, opportunities: [] }) + .generateQuestion("owner-1", generationCommand); + assert.equal(result.nextAction.kind, "present_low_result"); +}); + +test("model no_useful_question cannot stop while the engine has an opportunity", async () => { + const result = await serviceWithGenerator(async () => ({ + text: JSON.stringify({ kind: "no_useful_question" }), + })).generateQuestion("owner-1", generationCommand); + assert.equal(result.nextAction.kind, "ask_dynamic_choice"); + assert.equal(result.nextAction.question.prompt, packet.opportunities[0].fallbackPrompt); +}); +``` + +- [ ] **Step 6: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-guide-agent.test.ts tests/birth-time-guide-route.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-dynamic-question-validator.ts frontend/src/lib/birth-time-guide-agent.ts frontend/src/lib/birth-time-guide-service.ts frontend/src/mastra/index.ts frontend/tests/birth-time-guide-agent.test.ts frontend/tests/birth-time-guide-route.test.ts +git commit -m "feat: generate constrained dynamic choice questions" +``` + +#### Task 4 review amendment (mandatory before Task 5) + +Independent review of `437d50f..1ffc09e` blocked Task 4. Complete and independently re-review +these corrections before persistence work begins: + +- Localize engine-owned `neutral_context`, `fallback_prompt`, and fallback labels with a + deterministic Simplified-Chinese dimension map. Add a real Task 2 Python-shaped + adapter-to-service regression proving two invalid Agent responses still persist the + highest-gain fallback. +- Treat `unmatchedNote` as untrusted evidence: discard or redact birth-time, scoring, + confidence, support, control, and instruction-like content before the model boundary; label + the remaining text as untrusted quoted data. Require generated public copy to be grounded in + the selected opportunity's localized context so valid IDs cannot authorize unrelated copy. +- Separate recoverable Agent-output/repetition failures from server binding, private scoring, + UUID, and persisted-schema failures. Validate bindings before allocating IDs, catch only + recoverable variants, and never translate a server fault into `present_low_result`. +- Enforce byte-exact model IDs and close the reviewed confidence/support/control wording gaps. + Keep `bindDynamicQuestion(output, build, ids)` as the agent-facing API and use a separate + fallback binder for server-owned source selection. +- Split the dynamic tests and shared fixtures so every changed TypeScript test module is at or + below 250 pure lines. Add distinct-input semantic-normalization coverage and replace + sanitized-only integration fixtures with the real engine shape. +- Correct `.superpowers/sdd/task-4-report.md` and reference durable RED/GREEN/gate artifacts. + +This amendment expands Task 4 ownership to +`scripts/dynamic_rectification_opportunities.py`, its focused Python test, and focused +dynamic-question test/fixture modules. Prior public-question summaries are deferred to Task 6, +where persisted question history becomes available; Task 4 continues to enforce exact server +fingerprints without fabricating summaries from hashes. + +#### Task 4 second review amendment (finite rendering contract) + +The corrected range `437d50f..797cb65` is still blocked because free-form notes and model-authored +labels remain bypassable. The final Task 4 boundary is therefore: + +- Raw `unmatchedNote` never crosses the Agent boundary. Task 4 omits it rather than attempting + semantic instruction detection with keyword filters. +- Agent output is selection-only: `{ kind: "question", opportunityId }` or + `{ kind: "no_useful_question" }`. The server renders the selected engine opportunity's + prompt and primary labels; model-authored prompt/label copy is not accepted. +- The model dynamically chooses the next information opportunity, while the deterministic + engine owns partitions/answer semantics and the server owns a finite public rendering + grammar. This is the approved hybrid design, not a fixed-round questionnaire. +- `bindDynamicQuestion(selection, build, ids)` validates unique normalized server labels and + binds them to private partitions. Duplicate/malformed server copy is a binding fault that + propagates; it cannot be retried as model output or converted to low confidence. +- Python range labels select year/month/day precision as needed so distinct same-year windows + remain visibly distinct. Fallback explicitly chooses maximum information gain with stable ID + tie-breaking rather than trusting packet order. +- Required regressions cover the tea/water note bypass, inability for the model to author or + duplicate labels, unsorted multi-opportunity fallback, valid Agent selection with correct + private bindings, same-year unique labels, and the real Python public-copy seam. Superseded + `CLEAR` evidence and the Task 4 report must be corrected with fresh artifact paths. + +--- + +### Task 5: Durable v2 Persistence and Legacy Isolation + +**Files:** +- Create: `frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql` +- Modify: `frontend/src/lib/birth-time-journey-turn-persistence.ts` +- Modify: `frontend/src/lib/birth-time-journey-store.ts` +- Modify: `frontend/src/lib/birth-time-journey-service.ts` +- Modify: `tests/test_birth_time_journey_contract.py` +- Test: `frontend/tests/birth-time-dynamic-persistence.test.ts` + +**Interfaces:** +- Persists `journey_protocol` on the existing public case row. +- Persists the candidate model, internal current question, choice answers, server choice evidence, dynamic control state, and optional Agent context in `birth_time_rectification_dynamic_state`, which authenticated clients cannot select. +- Adds `saveDynamicTurn(value, expectedVersion, actionId)` and `upgradeLegacyActiveCase(value)`. +- Existing terminal cases remain terminal and are never upgraded into a question state. + +- [ ] **Step 1: Write failing migration contract tests** + +```python +def test_dynamic_choice_migration_keeps_private_mapping_and_agent_context_server_side(): + sql = DYNAMIC_CHOICE_MIGRATION.read_text() + assert "journey_protocol text not null default 'legacy-guided-v1'" in sql + assert "create table if not exists public.birth_time_rectification_dynamic_state" in sql + assert "candidate_model jsonb" in sql + assert "current_choice_question jsonb" in sql + assert "choice_answers jsonb not null default '[]'::jsonb" in sql + assert "choice_evidence jsonb not null default '[]'::jsonb" in sql + assert "dynamic_control jsonb" in sql + assert "agent_context jsonb not null default '[]'::jsonb" in sql + assert "revoke all on table public.birth_time_rectification_dynamic_state from anon, authenticated" in sql + assert "grant all on table public.birth_time_rectification_dynamic_state to service_role" in sql + assert "save_birth_time_dynamic_turn" in sql + assert "complete_birth_time_dynamic_scoring_job" in sql + assert "fail_birth_time_dynamic_scoring_job" in sql +``` + +- [ ] **Step 2: Run and verify RED** + +Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py -k dynamic_choice` + +Expected: FAIL because the migration is absent. + +- [ ] **Step 3: Add a private dynamic-state table and transactional RPC** + +Add only `journey_protocol` to `birth_time_rectification_cases`, allowing `legacy-guided-v1` or `dynamic-choice-v2`. Create `birth_time_rectification_dynamic_state` with `case_id` primary/foreign key, `user_id`, `candidate_model`, the other five private JSON fields, and timestamps. Add JSON type checks, cap the audit-only `choice_answers` array at 50 rows, cap effective `choice_evidence` at 10 rows, and cap Agent context at 10 notes of at most 240 characters. Enable RLS, revoke every privilege from `anon` and `authenticated`, and grant all only to `service_role`. + +Create `save_birth_time_dynamic_turn(p_user_id, p_case_id, p_expected_version, p_action_id, p_public_turn_state, p_snapshot, p_candidate_result, p_private_state)`. The function must be `security definer`, set `search_path = ''`, require the matching owner and `dynamic-choice-v2`, perform the optimistic version/action-receipt update, and upsert the private row in the same database transaction. Return the new version; return the existing version for a replayed action; raise `stale_birth_time_dynamic_turn` otherwise. Revoke function execution from `public`, `anon`, and `authenticated`; grant it only to `service_role`. + +Create matching service-role-only `complete_birth_time_dynamic_scoring_job(...)` and `fail_birth_time_dynamic_scoring_job(...)` RPCs. Each verifies the owner, case, job ID, expected turn version, evidence fingerprint, algorithm version, and current job state before atomically updating the job, public turn/result, and private dynamic state. A replay returns the already completed/failed turn; a mismatch raises a stale-job exception. + +- [ ] **Step 4: Write failing store tests** + +```ts +test("v2 load restores the exact internal question after refresh", async () => { + const loaded = await loadStoredRectificationCase(fakeSupabase(v2CaseRow, v2PrivateRow), "owner", caseId); + assert.deepEqual(loaded?.currentChoiceQuestion, persistedQuestion); + assert.deepEqual(loaded?.candidateModel, persistedCandidateModel); + assert.deepEqual(loaded?.dynamicControl.questionFingerprints, [persistedQuestion.questionFingerprint]); +}); + +test("save uses optimistic version and action receipt once", async () => { + const first = await store.saveDynamicTurn(updated, 7, actionId); + const replay = await store.saveDynamicTurn(updated, 7, actionId); + assert.equal(first.turnVersion, 8); + assert.equal(replay.turnVersion, 8); + assert.equal(replay.processedActionIds.filter((value) => value === actionId).length, 1); +}); +``` + +- [ ] **Step 5: Extend stored case parsing and persistence** + +Discriminate by `journey_protocol`. `saveAssessment()` explicitly creates a `dynamic-choice-v2` case, initializes `asOfDate`, and inserts its empty private state before returning the case ID. For v2 resume, load the owner-scoped public case row and the service-role-only private row, then parse private JSON with Task 1 schemas; a missing private row is a store error, not an excuse to regenerate from scratch. `saveDynamicTurn()` calls the transactional RPC and never writes `active_birth_time`. Only `toPublicDynamicChoiceQuestion(currentChoiceQuestion)` is stored in public `turn_state` and projected into `nextAction`; candidate scores, partition IDs, and Agent notes never enter the case row. + +- [ ] **Step 6: Define legacy upgrade rules** + +`upgradeLegacyActiveCase()` is allowed only when the old case is nonterminal. It preserves `answers`, `life_events`, questionnaire, candidate result, reported range, and audit timestamps; sets protocol v2; initializes dynamic counters from confirmed legacy evidence; excludes legacy question fingerprints; and sets `generate_dynamic_question`. Old `present_low_result`, `present_medium_result`, confirmation, and ready states return unchanged. + +- [ ] **Step 7: Run persistence tests and commit** + +Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py && cd frontend && node --test tests/birth-time-dynamic-persistence.test.ts tests/birth-time-journey-turn-persistence.test.mjs` + +Expected: all selected tests pass. + +```bash +git add frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql frontend/src/lib/birth-time-journey-turn-persistence.ts frontend/src/lib/birth-time-journey-store.ts frontend/src/lib/birth-time-journey-service.ts tests/test_birth_time_journey_contract.py frontend/tests/birth-time-dynamic-persistence.test.ts +git commit -m "feat: persist dynamic rectification turns" +``` + +#### Task 5 review amendment + +Review expands Task 5 ownership to the following correctness and maintainability fixes before +Task 6: + +- Split the migration into ordered schema/turn and scoring-job RPC migrations, each at or below + 250 pure lines. Deduplicate private-state persistence through one service-role-only SQL helper. +- Create public v2 case, required private state, and exact profile link atomically through a + service-role `create_birth_time_dynamic_case` RPC; never use separate inserts. +- Apply protocol isolation to every legacy guided mutation and scoring-poll path, not only + question/evidence actions. +- Add ordered protocol-guard migrations for existing legacy scoring/candidate RPCs: public + signatures stay stable, internal bodies are not executable by API roles, and service-role + wrappers owner-lock and verify `legacy-guided-v1` atomically. Direct legacy PostgREST writes + include the same protocol predicate. +- Parse external rows into a strict `journeyProtocol`-discriminated stored-case union; normalize + absent old protocol values to legacy at the loader boundary. +- Make memory replay return the stored advanced state. Map supported unknown time ranges to the + full day while continuing to reject malformed mixed-null ranges. +- Split contract tests below 250 lines and replace deletion-only/source-mirroring claims with + executable store behavior. If local Postgres execution is unavailable, preserve evidence of + the environment limitation and make no live-database claim. +- Expose typed persistence wrappers for dynamic scoring completion/failure RPCs and cover exact + payloads, replay/version results, and stale/error propagation with executable fakes. Task 6 + continues to own stop-policy and scoring orchestration. + +--- + +### Task 6: Journey Actions, Scoring Jobs, and Anti-Loop Transitions + +#### Task 6 persistence amendment + +Task 5 intentionally exposed only typed dynamic scoring completion/failure wrappers. Its +legacy scoring protocol guards make the existing public create/claim RPCs unavailable to +`dynamic-choice-v2`, so Task 6 must also close the v2 job lifecycle rather than bypassing the +private-state boundary or leaving browser polling unable to complete. + +- Add one ordered migration at or below 250 pure lines for + `create_birth_time_dynamic_scoring_job(...)` and + `claim_birth_time_dynamic_scoring_job(...)`. +- Add a later ordered replacement for `save_birth_time_dynamic_turn(...)` so a processed + action succeeds only when the locked private `lastActionReceipt` exactly matches the proposed + canonical receipt and expected next version. Apply the same exact-receipt rule to dynamic + scoring-job creation; TypeScript success and error reloads must independently verify it. +- Creation owner-locks a v2 case, validates expected version/action/question/job/fingerprint/ + algorithm, atomically persists the advanced public turn, private dynamic state, canonical + action receipt, and one pending job, and replays only the identical completed action. +- Claim owner-locks the v2 case, validates job identity, fingerprint, algorithm, current + `score_pending`/`retry_scoring` action, and the processing lease. Completed replay is allowed + only when the stored candidate result and dynamic terminal/continuation action agree. +- Add typed production store methods and executable fake/store tests. Do not call the + legacy-guarded public wrappers or write the service-only private table from orchestration. +- If live PostgreSQL is unavailable, record that limitation explicitly and retain executable + TypeScript RPC-fake evidence plus static SQL contract/syntax checks without claiming a live + database pass. + +**Files:** +- Create: `frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql` +- Create: `frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql` +- Create: `frontend/src/lib/birth-time-dynamic-transitions.ts` +- Create: `frontend/src/lib/birth-time-dynamic-actions.ts` +- Create: `frontend/src/lib/birth-time-dynamic-scoring-service.ts` +- Modify: `frontend/src/lib/birth-time-journey-service.ts` +- Modify: `frontend/src/lib/birth-time-scoring-job.ts` +- Modify: `frontend/src/lib/birth-time-scoring-job-store.ts` +- Test: `frontend/tests/birth-time-dynamic-actions.test.ts` +- Test: `frontend/tests/birth-time-dynamic-scoring.test.ts` +- Test: `frontend/tests/birth-time-dynamic-terminal.test.ts` +- Test: `tests/test_birth_time_dynamic_scoring_job_contract.py` + +**Interfaces:** +- Produces `answerDynamicChoice`, `submitUnmatchedContext`, `generateDynamicQuestion`, `pauseDynamic`, `resumeDynamic`, and `finishDynamic` service actions. +- Primary choices resolve a stored partition and create one idempotent `birth-time-choice-scoring-v2` job. +- Unknown and unmatched answers never create `ServerChoiceEvidence`. + +- [ ] **Step 1: Write failing primary-answer tests** + +```ts +test("a primary click resolves its private partition and enters score_pending", async () => { + const result = await flow.answerDynamicChoice("owner", { + caseId, actionId, turnVersion: 4, questionId, optionId: primaryOptionId, + }); + assert.equal(result.nextAction.kind, "score_pending"); + assert.equal(flow.saved.choiceAnswers.length, 1); + assert.equal(flow.saved.choiceEvidence[0].partitionId, "window-a"); + assert.equal(flow.saved.dynamicControl.effectiveAnswerCount, 1); +}); + +test("a forged or stale option cannot affect evidence", async () => { + await assert.rejects(() => flow.answerDynamicChoice("owner", { + caseId, actionId, turnVersion: 3, questionId, optionId: forgedOptionId, + }), StaleJourneyTurnError); + assert.deepEqual(flow.saved.choiceEvidence, []); +}); +``` + +- [ ] **Step 2: Run action tests and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-actions.test.ts` + +Expected: FAIL because dynamic actions do not exist. + +- [ ] **Step 3: Implement special-choice transitions** + +- Primary: persist answer and private evidence, increment both counts, clear current question, create score job. +- Unknown: persist a non-effective answer, increment only `answeredCount`, dismiss the opportunity/fingerprints, clear current question, enter `generate_dynamic_question`. +- Unmatched: persist a non-effective answer, increment only `answeredCount`, retain the question, enter `clarify_unmatched_answer`. +- Unmatched context: validate at most 240 characters, persist separate Agent context, dismiss the old opportunity/fingerprints, clear the question, enter `generate_dynamic_question` without scoring. +- Finish: preserve current result/range and enter a terminal low or medium result. + +- [ ] **Step 4: Write failing score-completion tests** + +```ts +test("score completion continues only when the stop policy allows it", async () => { + const result = await scoring.complete(lowChangedScore, packetWithUsefulOpportunity); + assert.equal(result.nextAction.kind, "generate_dynamic_question"); +}); + +test("the second plateau is terminal and resume stays terminal", async () => { + const terminal = await scoring.complete(mediumUnchangedScore, packetWithUsefulOpportunity); + assert.equal(terminal.nextAction.kind, "present_medium_result"); + const resumed = await flow.resumeDynamic("owner", caseId); + assert.deepEqual(resumed.nextAction, terminal.nextAction); +}); + +test("high confidence still requires explicit confirmation", async () => { + const result = await scoring.complete(highScore, packetWithUsefulOpportunity); + assert.equal(result.nextAction.kind, "request_candidate_confirmation"); + assert.equal(result.snapshot.activeTime, null); + assert.equal(result.permissions.canConfirmCandidate, true); +}); +``` + +- [ ] **Step 5: Run scoring tests and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-scoring.test.ts tests/birth-time-dynamic-terminal.test.ts` + +Expected: FAIL because v2 completion and terminal guards are absent. + +- [ ] **Step 6: Add scoring claim/completion flow** + +Fingerprint canonical server choice evidence, not public labels or Agent notes. Claim jobs by case, evidence fingerprint, and algorithm version. Validate returned effective count, dimension count, algorithm version, candidate range, and confidence gates before persisting. Apply `decideDynamicStop()` in the same saved turn as the candidate result; never expose an intermediate low result that `resume()` could reinterpret as a new cycle. + +- [ ] **Step 7: Make terminal transitions one-way** + +Every answer, generation, reframe, retry, and scoring action must reject a terminal `nextAction`. `resumeDynamic()` returns the stored terminal state byte-for-byte. `pauseDynamic()` stores the exact non-paused action in `dynamicControl.pausedAction`; resume restores only that action and clears the saved pause action. + +- [ ] **Step 8: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-dynamic-actions.test.ts tests/birth-time-dynamic-scoring.test.ts tests/birth-time-dynamic-terminal.test.ts tests/birth-time-scoring-job.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-dynamic-transitions.ts frontend/src/lib/birth-time-dynamic-actions.ts frontend/src/lib/birth-time-dynamic-scoring-service.ts frontend/src/lib/birth-time-journey-service.ts frontend/src/lib/birth-time-scoring-job.ts frontend/src/lib/birth-time-scoring-job-store.ts frontend/tests/birth-time-dynamic-actions.test.ts frontend/tests/birth-time-dynamic-scoring.test.ts frontend/tests/birth-time-dynamic-terminal.test.ts +git commit -m "feat: orchestrate dynamic rectification turns" +``` + +--- + +### Task 7: Authenticated API, Client Commands, and Automatic Browser Coordination + +**Files:** +- Modify: `frontend/src/lib/birth-time-journey-request.ts` +- Modify: `frontend/src/lib/birth-time-journey-response-schema.ts` +- Modify: `frontend/src/lib/birth-time-journey-client.ts` +- Modify: `frontend/src/app/api/birth-time-journey/route.ts` +- Modify: `frontend/src/app/api/birth-time-guide/route.ts` +- Modify: `frontend/src/hooks/use-birth-time-guided-journey.ts` +- Test: `frontend/tests/birth-time-dynamic-api.test.ts` +- Modify: `frontend/tests/birth-time-guide-client.test.ts` +- Modify: `frontend/tests/birth-time-guided-polling.test.ts` + +**Interfaces:** +- Journey command: `{ type: "answer_dynamic_choice", caseId, actionId, turnVersion, questionId, optionId }`. +- Guide commands: `generate_dynamic_question` and `reframe_unmatched`. +- Controller exposes `selectOption(optionId)`, `submitUnmatchedContext(note)`, `finish()`, `pause()`, and existing candidate actions. + +- [ ] **Step 1: Write failing request-boundary tests** + +```ts +test("choice commands accept only public ids", () => { + const valid = { type: "answer_dynamic_choice", caseId, actionId, turnVersion: 4, questionId, optionId }; + assert.equal(birthTimeJourneyRequestSchema.safeParse(valid).success, true); + for (const field of ["partitionId", "candidateScores", "confidence", "time"] as const) { + assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, [field]: "forged" }).success, false); + } +}); + +test("unmatched context is optional, trimmed, and bounded", () => { + assert.equal(birthTimeGuideRequestSchema.safeParse({ + type: "reframe_unmatched", caseId, actionId, turnVersion: 5, questionId, note: " 更像是 2017 年 ", + }).success, true); + assert.equal(birthTimeGuideRequestSchema.safeParse({ + type: "reframe_unmatched", caseId, actionId, turnVersion: 5, questionId, note: "字".repeat(241), + }).success, false); +}); +``` + +- [ ] **Step 2: Run API tests and verify RED** + +Run: `cd frontend && node --test tests/birth-time-dynamic-api.test.ts tests/birth-time-guide-client.test.ts` + +Expected: FAIL because v2 commands are absent. + +- [ ] **Step 3: Add strict route dispatch** + +Authenticate before body parsing. Route each v2 command to only its scoped service method. Map stale/terminal/forged actions to 409, missing cases to 404, invalid model output to the deterministic fallback path, and engine/store outages to 503 while preserving the current question. Record metrics after persisted transitions only. + +- [ ] **Step 4: Add automatic generation and scoring coordination** + +In the hook: + +- on `generate_dynamic_question`, call the guide route once per `caseId:turnVersion` identity; +- on `score_pending`, poll the existing idempotent job identity; +- on network failure, keep the same action and show retry; do not optimistically create another question; +- on `ask_dynamic_choice`, render the persisted public question directly, without a second render-question request; +- on primary click, disable all options until the mutation resolves; +- on terminal result, stop all generation and polling effects. + +- [ ] **Step 5: Add race/replay tests** + +```ts +test("duplicate option clicks publish one advanced turn", async () => { + const requests = coordinateDuplicateClicks(); + await Promise.all([requests.select(primaryOptionId), requests.select(primaryOptionId)]); + assert.equal(requests.sent.length, 1); + assert.equal(requests.published.at(-1)?.nextAction.kind, "score_pending"); +}); + +test("a stale generated question cannot replace a newer turn", async () => { + const result = await resolveGenerationAfterTurnAdvanced(); + assert.equal(result.current.turnVersion, 8); + assert.notEqual(result.current.nextAction.kind, "ask_dynamic_choice"); +}); +``` + +- [ ] **Step 6: Run focused tests and commit** + +Run: `cd frontend && node --test tests/birth-time-dynamic-api.test.ts tests/birth-time-guide-client.test.ts tests/birth-time-guided-polling.test.ts tests/birth-time-guided-review-fixes.test.ts` + +Expected: all selected tests pass. + +```bash +git add frontend/src/lib/birth-time-journey-request.ts frontend/src/lib/birth-time-journey-response-schema.ts frontend/src/lib/birth-time-journey-client.ts frontend/src/app/api/birth-time-journey/route.ts frontend/src/app/api/birth-time-guide/route.ts frontend/src/hooks/use-birth-time-guided-journey.ts frontend/tests/birth-time-dynamic-api.test.ts frontend/tests/birth-time-guide-client.test.ts frontend/tests/birth-time-guided-polling.test.ts +git commit -m "feat: expose dynamic rectification actions" +``` + +--- + +### Task 8: Click-First Question UI and Simplified Progress + +**Files:** +- Create: `frontend/src/components/birth-time-choice-question.tsx` +- Modify: `frontend/src/components/birth-time-rectification.tsx` +- Modify: `frontend/src/components/birth-time-candidate-result.tsx` +- Modify: `frontend/src/app/globals.css:360-415,650-670` +- Modify: `frontend/src/hooks/use-birth-time-guided-journey.ts` +- Test: `frontend/tests/birth-time-choice-question.test.ts` +- Modify: `frontend/tests/birth-time-guide-flow.test.ts` +- Modify: `frontend/tests/birth-time-rectification-contract.test.ts` + +**Interfaces:** +- Consumes only `PublicDynamicChoiceQuestion`, `DynamicJourneyProgress`, and controller callbacks. +- Removes v2 imports/usages of `BirthTimeGuideTurn` and `BirthTimeEvidenceDraftCard` from the active rectification path. + +- [ ] **Step 1: Write failing UI contract tests** + +```ts +test("the v2 question surface is click-first", () => { + assert.match(choiceSource, /question\.options\.map/); + assert.match(choiceSource, /onSelect\(option\.optionId\)/); + assert.doesNotMatch(choiceSource, /整理为经历草稿|记得的精度|发生时间|第.*\/.*轮/); + assert.doesNotMatch(choiceSource, /